From a00916f2cc12bf8b00600291d658223d990633d6 Mon Sep 17 00:00:00 2001 From: jramosg Date: Wed, 21 Jan 2026 09:39:22 +0100 Subject: [PATCH 1/2] Add support for pair/triple selecting in condp Enhance the growSelection function to support: - Test/result pairs in condp forms (e.g., condp = x 1 "one" 2 "two") - Test :>> function triples in condp (e.g., condp some [1 2] #{0 6} :>> inc) - Default values (odd element at the end is not paired) This allows users to expand selections correctly when working with condp conditional expressions, improving the editing experience in Calva. Known limitation: There is a bug (also affecting case forms) where selecting the first element of a pair and growing the selection causes it to jump backward to the test expression instead of forward to include the pair. This needs further investigation of the rangesForSexpsInList() behavior. The main use case (selecting the second element first) works correctly. Fixes #2995 --- CHANGELOG.md | 2 + src/cursor-doc/paredit.ts | 252 ++++++++++++++++-- .../unit/cursor-doc/paredit-test.ts | 77 ++++++ test-data/test-files/paredit_sandbox.clj | 53 +++- 4 files changed, 356 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a091cefb..201ff4e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 81f088f77..a4a205085 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -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 @@ -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()) { @@ -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 @@ -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); } } } diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index 11af01423..2a4d38147 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -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', () => { diff --git a/test-data/test-files/paredit_sandbox.clj b/test-data/test-files/paredit_sandbox.clj index 93e820ed1..599a02fa9 100644 --- a/test-data/test-files/paredit_sandbox.clj +++ b/test-data/test-files/paredit_sandbox.clj @@ -8,8 +8,6 @@ ;; Expressions starting on the current line past the cursor are killed ;; - - (a| b (c d) e) @@ -68,8 +66,6 @@ string. " ;; | (23 34 ;; ) - - ;; Example 11 -- Deleting should delete whole expr to closing ] | 24 [1] @@ -84,11 +80,10 @@ string. " ;; Example 14 -- newline in string, deletes to end of string ["abc| def\n ghi" "this stays"] - ;; Example 15 -- Heisenbug should delete up to and including g] -#_|[a b (c d - e - f) g] +#_| [a b (c d + e + f) g] :a ;; Kill right @@ -96,12 +91,10 @@ string. " "This | needs to find the end of the string." - (map inc (map inc| (range 3))) -(map inc (map inc| - )) +(map inc (map inc|)) ; https://github.com/BetterThanTomorrow/calva/issues/2327 ; Should delete `#` @@ -109,6 +102,44 @@ string. " ; Should not delete `#` (#|()) +;; Pair selecting + +(cond + (= 1 1) (println "one is one") + (= 2 2) (println "two is two")) + +(cond->> [] + true (cons 1) + false (cons 2) + true (cons 3)) + +(cond-> {} + true (assoc :a 1) + false (assoc :b 2) + true (assoc :c 3)) + +(case 1 + 1 "one" + 2 "two" + 3 "three" + "other") + +(condp = 2 + 1 "one" + 2 "two" + 3 "three" + "other") + +(condp some [1 2 3 4] + #{0 6 7} :>> inc + #{5 9} :>> dec) + +;; Mixed pairs and triples +(condp some [1 2 3 4] + #{0 6 7} :>> inc + #{1 2} "found 1 or 2" + #{5 9} :>> dec + "default") (comment (a b (c From ca9b7b959f0331fe803f57976dc5b2a42f6aaacb Mon Sep 17 00:00:00 2001 From: jramosg Date: Wed, 21 Jan 2026 18:07:19 +0100 Subject: [PATCH 2/2] rollback paredit_sandbox format --- test-data/test-files/paredit_sandbox.clj | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test-data/test-files/paredit_sandbox.clj b/test-data/test-files/paredit_sandbox.clj index 599a02fa9..56619dfc4 100644 --- a/test-data/test-files/paredit_sandbox.clj +++ b/test-data/test-files/paredit_sandbox.clj @@ -8,6 +8,8 @@ ;; Expressions starting on the current line past the cursor are killed ;; + + (a| b (c d) e) @@ -66,6 +68,8 @@ string. " ;; | (23 34 ;; ) + + ;; Example 11 -- Deleting should delete whole expr to closing ] | 24 [1] @@ -80,10 +84,11 @@ string. " ;; Example 14 -- newline in string, deletes to end of string ["abc| def\n ghi" "this stays"] + ;; Example 15 -- Heisenbug should delete up to and including g] -#_| [a b (c d - e - f) g] +#_|[a b (c d + e + f) g] :a ;; Kill right @@ -91,10 +96,12 @@ string. " "This | needs to find the end of the string." + (map inc (map inc| (range 3))) -(map inc (map inc|)) +(map inc (map inc| + )) ; https://github.com/BetterThanTomorrow/calva/issues/2327 ; Should delete `#`