diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b17d436..dfe0f314b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Changes to Calva. ## [Unreleased] +- Fix: [Make sexp cursor movement (and selection) in comments consistently act like VSCode's default. The changes apply to SelectForwardSexp, SelectBackwardSexp, + ForwardSexp, and BackwardSexp](https://github.com/BetterThanTomorrow/calva/issues/2878) + ## [2.0.523] - 2025-07-14 - Fix: [[doc] Unneeded send-off function call in sample code](https://github.com/BetterThanTomorrow/calva/issues/2888) diff --git a/docs/site/customizing.md b/docs/site/customizing.md index 1b53dff5e..6bf8139c1 100644 --- a/docs/site/customizing.md +++ b/docs/site/customizing.md @@ -106,6 +106,10 @@ The following contexts are available with Calva: * `calva:cursorInComment`: `true` when the cursor is in, or adjacent to a line comment * `calva:cursorBeforeComment`: `true` when the cursor is adjacent before a line comment * `calva:cursorAfterComment`: `true` when the cursor is adjacent after a line comment +* `calva:cursorSeesCommentPrev`: `true` when the the previous character, excluding whitespace, is a comment character. + It is true even if the cursor is inside a comment, but not if it is before the comment character. +* `calva:cursorSeesCommentNext`: `true` when the next character, excluding whitespace, is part of a comment. + It is true even if inside a comment, but not if at the newline that ends a comment. * `calva:cursorAtStartOfLine`: `true` when the cursor is at the start of a line including any leading whitespace * `calva:cursorAtEndOfLine`: `true` when the cursor is at the end of a line including any trailing whitespace diff --git a/docs/site/when-clauses.md b/docs/site/when-clauses.md index 870b30925..65f250478 100644 --- a/docs/site/when-clauses.md +++ b/docs/site/when-clauses.md @@ -17,6 +17,10 @@ description: Calva comes with batteries included and preconfigured, and if you d * `calva:cursorInComment`: `true` when the cursor is in, or adjacent to a line comment * `calva:cursorBeforeComment`: `true` when the cursor is adjacent before a line comment * `calva:cursorAfterComment`: `true` when the cursor is adjacent after a line comment +* `calva:cursorSeesCommentPrev`: `true` when the the previous character, excluding whitespace, is a comment character. + It is true even if the cursor is inside a comment, but not if it is before the comment character. +* `calva:cursorSeesCommentNext`: `true` when the next character, excluding whitespace, is part of a comment. + It is true even if inside a comment, but not if at the newline that ends a comment. * `calva:cursorAtStartOfLine`: `true` when the cursor is at the start of a line including any leading whitespace * `calva:cursorAtEndOfLine`: `true` when the cursor is at the end of a line including any trailing whitespace * `calva:projectRoot`: A string with the absolute path to the repl project root, _without trailing slash_ diff --git a/package-lock.json b/package-lock.json index 19ac43335..6a1afe503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "calva", - "version": "2.0.523", + "version": "2.0.524", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "calva", - "version": "2.0.523", + "version": "2.0.524", "license": "MIT", "dependencies": { "@vscode/debugadapter": "^1.64.0", diff --git a/package.json b/package.json index d2df6aa5e..0d366476c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Calva: Clojure & ClojureScript Interactive Programming", "description": "Integrated REPL, formatter, Paredit, and more. Powered by cider-nrepl and clojure-lsp.", "icon": "assets/calva.png", - "version": "2.0.523", + "version": "2.0.524", "publisher": "betterthantomorrow", "author": { "name": "Better Than Tomorrow", @@ -2385,28 +2385,28 @@ "mac": "ctrl+left", "win": "alt+left", "linux": "alt+left", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && !config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment || calva:cursorBeforeComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && !config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentPrev" }, { "command": "paredit.backwardSexp", "mac": "alt+left", "win": "ctrl+left", "linux": "ctrl+left", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment || calva:cursorBeforeComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentPrev" }, { "command": "paredit.forwardSexp", "mac": "ctrl+right", "win": "alt+right", "linux": "alt+right", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && !config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment || calva:cursorAfterComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentNext" }, { "command": "paredit.forwardSexp", "mac": "alt+right", "win": "ctrl+right", "linux": "ctrl+right", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment || calva:cursorAfterComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentNext" }, { "command": "paredit.forwardDownSexp", @@ -2443,7 +2443,7 @@ "mac": "shift+alt+right", "win": "shift+ctrl+right", "linux": "shift+ctrl+right", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment && !calva:cursorAfterComment && !calva:cursorBeforeComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentNext" }, { "command": "paredit.selectRight", @@ -2457,7 +2457,7 @@ "mac": "shift+alt+left", "win": "shift+ctrl+left", "linux": "shift+ctrl+left", - "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment && !calva:cursorAfterComment && !calva:cursorBeforeComment" + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorSeesCommentPrev" }, { "command": "paredit.selectForwardDownSexp", diff --git a/src/cursor-doc/cursor-context.ts b/src/cursor-doc/cursor-context.ts index 22e5bbd83..e85574635 100644 --- a/src/cursor-doc/cursor-context.ts +++ b/src/cursor-doc/cursor-context.ts @@ -7,6 +7,8 @@ export const allCursorContexts = [ 'calva:cursorAtEndOfLine', 'calva:cursorBeforeComment', 'calva:cursorAfterComment', + 'calva:cursorSeesCommentNext', + 'calva:cursorSeesCommentPrev', ] as const; export type CursorContext = typeof allCursorContexts[number]; @@ -56,6 +58,54 @@ export function isAtLineEndInclWS(doc: EditableDocument, offset = doc.selections return false; } +/** + * when true, a comment is visible upstream from the cursor. Because this is + * used to govern SelectBackwardSexp, eol (actually beginning of line) is considered a + * comment so selection reverts back to VSCode's selection + */ +function hasPrevComment(doc: EditableDocument, offset: number) { + const findCursorLineOffset = (doc: EditableDocument, cursorOffset: number): number => { + const documentText = doc.model.getText(0, cursorOffset); + const startOfLineOffset = documentText.lastIndexOf('\n') + 1; + return cursorOffset - startOfLineOffset; + }; + + const backCursor = doc.getTokenCursor(offset); + + while (!backCursor.atStart()) { + const tokenType = backCursor.getPrevToken().type; + if (tokenType === 'comment' || tokenType === 'prompt') { + return true; + } else if (tokenType === 'eol' || tokenType === 'ws') { + backCursor.previous(); + if (['comment', 'prompt', 'eol'].includes(backCursor.getPrevToken().type)) { + return true; + } else { + // non-comment token, check forward beyond any whitespace for a comment + // that starts before the cursor position + backCursor.forwardWhitespace(false); + const cursorLineOffset = findCursorLineOffset(doc, offset); + const currToken = backCursor.getToken(); + return currToken.type === 'comment' && currToken.offset < cursorLineOffset; + } + } else { + return backCursor.getToken().type === 'comment'; + } + } + return false; +} + +/** + * when true, a comment is visible downstream from the cursor. Because this is + * used to govern SelectForwardSexp, eol is considered a comment so selection reverts + * back to VSCode's selection + */ +function hasNextComment(doc: EditableDocument, offset: number) { + const nextCursor = doc.getTokenCursor(offset); + nextCursor.forwardWhitespace(false); + return nextCursor.getToken().type === 'comment' || nextCursor.getToken().type === 'eol'; +} + export function determineContexts( doc: EditableDocument, offset = doc.selections[0].active @@ -90,5 +140,13 @@ export function determineContexts( } } + if (hasNextComment(doc, offset)) { + contexts.push('calva:cursorSeesCommentNext'); + } + + if (hasPrevComment(doc, offset)) { + contexts.push('calva:cursorSeesCommentPrev'); + } + return contexts; } diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 81f088f77..b9aa4f497 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -106,7 +106,7 @@ export function selectForwardSexp(doc: EditableDocument, selections = doc.select const ranges = selections.map((selection) => selection.active >= selection.anchor ? forwardSexpRange(doc, selection.end) - : forwardSexpRange(doc, selection.active, true) + : forwardSexpRange(doc, selection.active, false) ); selectRangeForward(doc, ranges, selections); } diff --git a/src/extension-test/unit/cursor-doc/cursor-context-test.ts b/src/extension-test/unit/cursor-doc/cursor-context-test.ts index bbb659198..21c012f4b 100644 --- a/src/extension-test/unit/cursor-doc/cursor-context-test.ts +++ b/src/extension-test/unit/cursor-doc/cursor-context-test.ts @@ -77,6 +77,102 @@ describe('Cursor Contexts', () => { expect(contexts.includes('calva:cursorInComment')).toBe(false); }); }); + describe('cursorSeesCommentPrev', () => { + it('sees a comment upstream', () => { + const contexts = context.determineContexts(docFromTextNotation(';; my comment |')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('sees an sexp upstream', () => { + const contexts = context.determineContexts(docFromTextNotation('(+ 1 1)|')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(false); + }); + it('sees an sexp upstream and a comment downstream, cursor on the comment offset', () => { + const contexts = context.determineContexts(docFromTextNotation('(+ 1 1) |; my comment')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(false); + }); + it('sees a comment followed by an sexp upstream', () => { + const contexts = context.determineContexts(docFromTextNotation('(+ 1 1) ;| my comment \n')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('inside an sexp, no comment seen', () => { + const contexts = context.determineContexts(docFromTextNotation('(+ 1 1|) ; my comment \n')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(false); + }); + it('beginning of document is treated as comment', () => { + const contexts = context.determineContexts( + docFromTextNotation('\n | (+ 1 1) ; my comment \n') + ); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('sees a comment prev when cursor is after a comment', () => { + const contexts = context.determineContexts( + docFromTextNotation('(do-something) ; a comment|') + ); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('sees a comment upstream when cursor is on an empty line', () => { + const contexts = context.determineContexts(docFromTextNotation(';; a comment\n|\n(def a 1)')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('does not see a comment upstream when cursor is on an empty line with code after', () => { + const contexts = context.determineContexts(docFromTextNotation('(def a 1)\n|\n(def b 2)')); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(false); + }); + it('should see a comment upstream when there are multiple breaks', () => { + const contexts = context.determineContexts( + docFromTextNotation('{}\n ;; More comments go |here\n\n#{}\n') + ); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('should see a comment upstream when the previous token is a close', () => { + const contexts = context.determineContexts( + docFromTextNotation('{}\n {};; More comments go |here\n\n#{}\n') + ); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + it('should see a comment upstream when the previous token is an sexp', () => { + const contexts = context.determineContexts( + docFromTextNotation('{}\n {};|; More comments go here\n\n#{}\n') + ); + expect(contexts.includes('calva:cursorSeesCommentPrev')).toBe(true); + }); + }); + describe('cursorSeesCommentNext', () => { + it('sees a comment downstream', () => { + const contexts = context.determineContexts(docFromTextNotation('| ;; my comment')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(true); + }); + it('sees an sexp downstream', () => { + const contexts = context.determineContexts(docFromTextNotation('| (+ 1 1)')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(false); + }); + it('sees an sexp followed by a comment downstream', () => { + const contexts = context.determineContexts(docFromTextNotation('| (+ 1 1) ; my comment')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(false); + }); + it('sees a comment followed by an sexp downstream', () => { + const contexts = context.determineContexts(docFromTextNotation('| ; my comment \n (+ 1 1) ')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(true); + }); + it('sees a comment downstream with sexp upstream', () => { + const contexts = context.determineContexts(docFromTextNotation('(+ 1 1) | ; my comment \n')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(true); + }); + it('end of document is treated as comment', () => { + const contexts = context.determineContexts( + docFromTextNotation(' (+ 1 1) ; my comment \n|\n') + ); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(true); + }); + it('sees a comment downstream when cursor is on an empty line', () => { + const contexts = context.determineContexts(docFromTextNotation('(def a 1)\n|\n;; a comment')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(true); + }); + it('does not see a comment downstream when cursor is on an empty line with code after', () => { + const contexts = context.determineContexts(docFromTextNotation('(def a 1)\n|\n(def b 2)')); + expect(contexts.includes('calva:cursorSeesCommentNext')).toBe(false); + }); + }); describe('cursorBeforeComment', () => { it('is false in comment', () => { const contexts = context.determineContexts( diff --git a/src/extension-test/unit/paredit/commands-test.ts b/src/extension-test/unit/paredit/commands-test.ts index 3133d4d25..8f484520e 100644 --- a/src/extension-test/unit/paredit/commands-test.ts +++ b/src/extension-test/unit/paredit/commands-test.ts @@ -530,7 +530,7 @@ describe('paredit commands', () => { ); const aSelections = a.selections; const b = docFromTextNotation( - '(defn|1 |1[a b]•(let [^js |2aa #p (+ a)|2•b b]•{:a aa•:b b}))•(:|a|)' + '(defn|1 [a b]•(let [^js |2aa #p (+ a)|2•b b]•{:a aa•:b b}))•(:|a|)' ); handlers.selectForwardSexp(a, true); expect(a.selectionsStack).toEqual([aSelections, b.selections]);