Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Changes to Calva.

## [Unreleased]

- Enhancement: [Grow selection now considers test/result pairs and test :>> function triples in `condp` forms](https://github.com/BetterThanTomorrow/calva/issues/2995)

## [2.0.545] - 2026-01-17

- Fix: [Jack-in sometimes fails from quoting issues](https://github.com/BetterThanTomorrow/calva/issues/2549)
Expand Down
252 changes: 235 additions & 17 deletions src/cursor-doc/paredit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,20 +1253,26 @@ export function growSelection(doc: EditableDocument, selections = doc.selections
// if there's not, do nothing, we will not be expanding this cursor
return [start, end];
} else {
// check if we need to handle pairs (binding forms, conditional forms, maps, etc.)
if (isInPairsList(startC, bindingForms)) {
// Use the selection start to determine the pair
const pairRange = currentSexpsRange(doc, startC, start, true);
// Only expand to pair if current selection is smaller than the pair
// (i.e., we have a single form selected, not already a pair or larger)
const currentSelectionLength = end - start;
const pairLength = pairRange[1] - pairRange[0];
if (currentSelectionLength < pairLength) {
return pairRange;
}
// else, current selection is >= pair size, next section should handle whole list
}

// check if there's a list containing the current form
if (startC.getPrevToken().type == 'open' && endC.getToken().type == 'close') {
startC.backwardList();
startC.backwardUpList();
endC.forwardList();
return [startC.offsetStart, endC.offsetEnd];
// check if we need to handle binding pairs
} else if (isInPairsList(startC, bindingForms)) {
const pairRange = currentSexpsRange(doc, startC, start, true);
// if pair not already selected, expand to pair
if (!_.isEqual(pairRange, [start, end])) {
return pairRange;
}
// else, if pair already selected, next section should handle whole list
}

// expand to whole list contents, if appropriate
Expand Down Expand Up @@ -1491,6 +1497,27 @@ export const bindingForms = [
'with-redefs',
];

const conditionalForms = ['condp'];

/**
* Returns the offset (number of initial forms that are not part of pairs)
* for conditional forms.
* - condp: pairs start after function name, predicate, and initial form (offset 3)
*/
function getConditionalFormPairOffset(cursor: LispTokenCursor): number {
const probeCursor = cursor.clone();
if (probeCursor.backwardList()) {
const opening = probeCursor.getPrevToken().raw;
if (opening.endsWith('(')) {
const fn = probeCursor.getFunctionName();
if (fn === 'condp') {
return 3;
}
}
}
return 0;
}

export function isInPairsList(cursor: LispTokenCursor, pairForms: string[]): boolean {
const probeCursor = cursor.clone();
if (probeCursor.backwardList()) {
Expand All @@ -1509,11 +1536,184 @@ export function isInPairsList(cursor: LispTokenCursor, pairForms: string[]): boo
return true;
}
}
if (opening.endsWith('(')) {
// Check if this is a conditional form like (cond test expr test expr ...)
const fn = probeCursor.getFunctionName();
if (fn && conditionalForms.includes(fn)) {
return true;
}
}
return false;
}
return false;
}

/**
* Checks if the current index is part of a condp triple (test :>> function).
* Returns the range of the triple if found, otherwise null.
*/
function getCondpTripleRange(
doc: EditableDocument,
ranges: [number, number][],
currentIndex: number,
pairOffset: number
): [number, number] | null {
const adjustedIndex = currentIndex - pairOffset;
if (adjustedIndex < 0) {
return null;
}

// Helper to get text for a range index
const getText = (idx: number) =>
idx >= 0 && idx < ranges.length ? doc.model.getText(ranges[idx][0], ranges[idx][1]) : '';

// If current is :>>, return test + :>> + fn
if (
getText(currentIndex) === ':>>' &&
currentIndex > pairOffset &&
currentIndex < ranges.length - 1
) {
return [ranges[currentIndex - 1][0], ranges[currentIndex + 1][1]];
}

// If previous is :>>, return test + :>> + fn
if (getText(currentIndex - 1) === ':>>' && currentIndex > pairOffset + 1) {
return [ranges[currentIndex - 2][0], ranges[currentIndex][1]];
}

// If next is :>>, return test + :>> + fn
if (getText(currentIndex + 1) === ':>>' && currentIndex < ranges.length - 2) {
return [ranges[currentIndex][0], ranges[currentIndex + 2][1]];
}

return null;
}

/**
* Builds condp element groups (pairs and :>> triples) and returns the range
* containing the current selection, or currentSingleRange as a fallback.
*/
function getCondpElementGroupRange(
doc: EditableDocument,
ranges: [number, number][],
indexOfCurrentSingle: number,
pairOffset: number,
currentSingleRange: [number, number]
): [number, number] {
const adjustedIndex = indexOfCurrentSingle - pairOffset;
if (adjustedIndex < 0) {
return currentSingleRange;
}

// Check for :>> triple first
const tripleRange = getCondpTripleRange(doc, ranges, indexOfCurrentSingle, pairOffset);
if (tripleRange) {
return tripleRange;
}

// Check for default (last element, odd count)
const pairableElementsCount = ranges.length - pairOffset;
const isOddElementCount = pairableElementsCount % 2 === 1;
const isLastElement = adjustedIndex === pairableElementsCount - 1;
if (isOddElementCount && isLastElement) {
return currentSingleRange;
}

// Handle regular pairs and :>> triples by grouping elements
const elementGroups: { start: number; end: number; groupStart: number }[] = [];
let i = pairOffset;

while (i < ranges.length) {
// Check if next element is :>>
if (i + 1 < ranges.length) {
const nextText = doc.model.getText(ranges[i + 1][0], ranges[i + 1][1]);
if (nextText === ':>>') {
if (i + 2 < ranges.length) {
elementGroups.push({
start: ranges[i][0],
end: ranges[i + 2][1],
groupStart: i,
});
i += 3;
continue;
}
}
}

// Regular pair (or default)
if (i + 1 < ranges.length) {
const remainingElements = ranges.length - i;
if (remainingElements === 1) {
// default
elementGroups.push({ start: ranges[i][0], end: ranges[i][1], groupStart: i });
i++;
} else {
// pair
elementGroups.push({
start: ranges[i][0],
end: ranges[i + 1][1],
groupStart: i,
});
i += 2;
}
} else {
// last element (default)
elementGroups.push({ start: ranges[i][0], end: ranges[i][1], groupStart: i });
i++;
}
}

// Find which group contains the current selection
for (const group of elementGroups) {
if (currentSingleRange[0] >= group.start && currentSingleRange[1] <= group.end) {
return [group.start, group.end];
}
}

return currentSingleRange;
}

/**
* Build paired element groups for non-conditional lists and return the group
* range that contains the provided currentSingleRange.
*
* Pairs are formed by consecutive elements starting at `pairOffset`. If the
* list has an odd trailing element that cannot be paired, it is treated as a
* single-element group (the "default" case).
*/
function getPairElementGroupRange(
ranges: [number, number][],
pairOffset: number,
currentSingleRange: [number, number]
): [number, number] {
const elementGroups: { start: number; end: number }[] = [];
const rangesLength = ranges.length;
let i = pairOffset;

while (i < rangesLength) {
if (
i + 1 < rangesLength &&
!(i === rangesLength - 1 && (rangesLength - pairOffset) % 2 === 1)
) {
// pair
elementGroups.push({ start: ranges[i][0], end: ranges[i + 1][1] });
i += 2;
} else {
// default (single element at the end)
elementGroups.push({ start: ranges[i][0], end: ranges[i][1] });
i++;
}
}

for (const group of elementGroups) {
if (currentSingleRange[0] >= group.start && currentSingleRange[1] <= group.end) {
return [group.start, group.end];
}
}

return currentSingleRange;
}

/**
* Returns the range of the current form
* or the current form pair, if usePairs is true
Expand All @@ -1526,19 +1726,37 @@ export function currentSexpsRange(
): [number, number] {
const currentSingleRange = cursor.rangeForCurrentForm(offset);
if (usePairs) {
const ranges = cursor.rangesForSexpsInList();
// Create a fresh cursor at the offset position to ensure correct list context
const listCursor = doc.getTokenCursor(offset);
const ranges = listCursor.rangesForSexpsInList();
if (ranges.length > 1) {
const indexOfCurrentSingle = ranges.findIndex(
(r) => r[0] === currentSingleRange[0] && r[1] === currentSingleRange[1]
);
if (indexOfCurrentSingle % 2 == 0) {
const pairCursor = doc.getTokenCursor(currentSingleRange[1]);
pairCursor.forwardSexp();
return [currentSingleRange[0], pairCursor.offsetStart];
} else {
const pairCursor = doc.getTokenCursor(currentSingleRange[0]);
pairCursor.backwardSexp();
return [pairCursor.offsetStart, currentSingleRange[1]];

// Get the offset for conditional forms (e.g., cond has 1 initial non-pair form)
const pairOffset = getConditionalFormPairOffset(listCursor);

// Adjust the index to account for non-pair forms at the start
const adjustedIndex = indexOfCurrentSingle - pairOffset;

// Only treat as pairs if we're past the offset
if (adjustedIndex >= 0) {
// Check if we're in a condp form
const probeCursor = listCursor.clone();
if (probeCursor.backwardList()) {
const fn = probeCursor.getFunctionName();
if (fn === 'condp') {
return getCondpElementGroupRange(
doc,
ranges,
indexOfCurrentSingle,
pairOffset,
currentSingleRange
);
}
}
return getPairElementGroupRange(ranges, pairOffset, currentSingleRange);
}
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/extension-test/unit/cursor-doc/paredit-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,83 @@ describe('paredit', () => {
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});

it('grows selection to test/result pairs in condp (result selected first)', () => {
const a = docFromTextNotation('(condp = x 1 |"one"| 2 "two" "default")');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp = x |01 "one"|0 2 "two" "default")');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to test/result pairs in condp (test selected first)', () => {
const a = docFromTextNotation('(condp = x |"x"| "one" "y" "two" "default")');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp = x |"x" "one"| "y" "two" "default")');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to test/result pairs in condp with set test', () => {
const a = docFromTextNotation('(condp = x #{1 2} |"one or two"| 3 "three" "default")');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp = x |0#{1 2} "one or two"|0 3 "three" "default")');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to triple in condp with :>> (test selected)', () => {
const a = docFromTextNotation('(condp some [1 2 3 4] |#{0 6 7}| :>> inc #{5 9} :>> dec)');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp some [1 2 3 4] |0#{0 6 7} :>> inc|0 #{5 9} :>> dec)');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to triple in condp with :>> (:>> selected)', () => {
const a = docFromTextNotation('(condp some [1 2 3 4] #{0 6 7} |:>>| inc #{5 9} :>> dec)');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp some [1 2 3 4] |0#{0 6 7} :>> inc|0 #{5 9} :>> dec)');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to triple in condp with :>> (function selected)', () => {
const a = docFromTextNotation('(condp some [1 2 3 4] #{0 6 7} :>> |inc| #{5 9} :>> dec)');
const aSelection = a.selections[0];
const b = docFromTextNotation('(condp some [1 2 3 4] |0#{0 6 7} :>> inc|0 #{5 9} :>> dec)');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection to triple in condp with :>> and fn form', () => {
const a = docFromTextNotation(
'(condp some [1 2 3 4] #{1 2 3} :>> |#(+ % 3)| #{5 9} :>> dec)'
);
const aSelection = a.selections[0];
const b = docFromTextNotation(
'(condp some [1 2 3 4] |0#{1 2 3} :>> #(+ % 3)|0 #{5 9} :>> dec)'
);
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('does not treat default as pair when growing selection in condp', () => {
const a = docFromTextNotation('(condp = x 1 "one" 2 "two" |"default"|)');
const aSelection = a.selections[0];
const b = docFromTextNotation('(|condp = x 1 "one" 2 "two" "default"|)');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
it('grows selection from condp keyword to list contents', () => {
const a = docFromTextNotation('(|condp| = x 1 "one" 2 "two" "default")');
const aSelection = a.selections[0];
const b = docFromTextNotation('(|condp = x 1 "one" 2 "two" "default"|)');
const bSelection = b.selections[0];
paredit.growSelection(a);
expect(a.selectionsStack).toEqual([[aSelection], [bSelection]]);
});
});

describe('dragSexpr', () => {
Expand Down
Loading