Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
51241a9
Add helper to extend ranges backward over preceding line comments
jramosg Apr 18, 2026
041c195
Drag sexp backward together with preceding line comments
jramosg Apr 18, 2026
fbdebd5
Drag sexp forward together with preceding line comments
jramosg Apr 18, 2026
a5d8e26
Test: drag backward carries preceding line comment
jramosg Apr 18, 2026
232dad7
Test: drag forward carries preceding line comment
jramosg Apr 18, 2026
6e35fd3
Test: blank line breaks backward comment association
jramosg Apr 18, 2026
f82fc4f
Test: blank line breaks forward comment association
jramosg Apr 18, 2026
9aad55f
Test: drag backward carries multiple preceding comment lines
jramosg Apr 18, 2026
55bae2e
Test: drag forward alone when form has no preceding comment
jramosg Apr 18, 2026
dbbce39
Test: drag backward when only the preceding form has a comment
jramosg Apr 18, 2026
23f5641
Support indented forms when extending over preceding comments
jramosg Apr 18, 2026
5dd1c37
Test: drag indented sexp backward inside a container
jramosg Apr 18, 2026
a4779fc
Test: drag indented sexp forward inside a container
jramosg Apr 18, 2026
633f4fe
Add changelog entry for dragging sexps with adjacent comments
jramosg Apr 18, 2026
916a01d
Add helper to detect form attached to a comment line
jramosg Apr 18, 2026
e7be838
Use comment-attached form in dragSexprBackward
jramosg Apr 18, 2026
912577b
Use comment-attached form in dragSexprForward
jramosg Apr 18, 2026
940faa1
Test: drag comment-form pair backward when cursor is on the comment
jramosg Apr 18, 2026
5207041
Test: drag comment-form pair forward when cursor is on the comment
jramosg Apr 18, 2026
0995dfb
Unify comment-attachment helpers and extend forward over trailing com…
jramosg Apr 23, 2026
7aefb1e
Test: drag sexp with its trailing same-line comment
jramosg Apr 23, 2026
e7b0193
Test: drag sexp with a comment on the line below
jramosg Apr 23, 2026
aef0cb2
fmt
jramosg Apr 23, 2026
5b714a9
lint
jramosg Apr 23, 2026
cda36c4
Trigger Build
jramosg Apr 23, 2026
3fa66b2
Merge branch 'dev' into fix/drag-sexp-comments-3073
jramosg Apr 23, 2026
2c38eb6
node one-liner deletes everything under ./out/ except cljs-lib, then …
jramosg Apr 23, 2026
0fec76e
Merge branch 'fix/drag-sexp-comments-3073' of https://github.com/jram…
jramosg Apr 23, 2026
b6bb142
Fix drag sexp keybindings to allow comments
jramosg Apr 26, 2026
83d72c5
Add tests for dragging sexprs with comments
jramosg Apr 26, 2026
42f50c8
rollback clean script change
jramosg Apr 28, 2026
ed2cbd4
Merge remote-tracking branch 'upstream/dev' into fix/drag-sexp-commen…
jramosg Apr 28, 2026
642e5f0
Merge remote-tracking branch 'upstream/dev' into fix/drag-sexp-commen…
jramosg Apr 28, 2026
937b20f
Rollback watch-docs chnge
jramosg Apr 28, 2026
489fe61
Merge branch 'dev' into fix/drag-sexp-comments-3073
jramosg Apr 28, 2026
83868f6
Refactor drag sexpr tests to use textNotation
jramosg Apr 29, 2026
6c79d5c
fmt
jramosg Apr 29, 2026
d376672
Merge branch 'fix/drag-sexp-comments-3073' of https://github.com/jram…
jramosg Apr 29, 2026
c5ae562
Merge branch 'dev' into fix/drag-sexp-comments-3073
jramosg May 4, 2026
8f0f142
Update changelog
jramosg May 4, 2026
0419285
Remove unused import from paredit-test.ts
jramosg May 4, 2026
31cef04
Add support for trailing result comments in paredit
jramosg May 4, 2026
fe4fd41
Add tests for dragging sexp backward with result comments
jramosg May 4, 2026
4c8ae16
Add support for result comment blocks in dragSexprForward
jramosg May 4, 2026
8fabd25
Add tests for dragging sexp forward with result comments
jramosg May 4, 2026
151b606
Normalize ranges for leading comments in drag functions
jramosg May 5, 2026
7206911
Add tests for dragging commented forms in paredit without altering in…
jramosg May 5, 2026
e6b65b7
Handle result comments in form attachment logic
jramosg May 5, 2026
cb6e713
Add tests for dragging forms with tight result comments
jramosg May 5, 2026
8521b61
group tests in describe blocks
jramosg May 5, 2026
52cd85a
add drag comments info in docs [skip ci]
jramosg May 5, 2026
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]

- [Dragging sexps with adjacent comments](https://github.com/BetterThanTomorrow/calva/issues/3073)

## [2.0.582] - 2026-05-03

- [Make Orphaned/dicsonnected WebSocket servers closable](https://github.com/BetterThanTomorrow/calva/issues/3195)
Expand Down
8 changes: 8 additions & 0 deletions docs/site/paredit.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ And like so (wait for it):

![](images/paredit/drag-pairs-in-maps.gif)

Drag forward/backward also treats certain line comments as attached to forms:

- Contiguous comment lines immediately above a form move with that form.
- Trailing same-line comments move with the form on that line.
- Tight result comments such as `;=>` and `;;=>` are attached to the form immediately above them, and move with that form when it is dragged.
- When the cursor is on an attached comment line, dragging acts on the
associated form-comment pair rather than on the comment line by itself.

## Toggle Comment Behavior

The **Toggle Comment** command (`ctrl+/` / `cmd+/`) behavior when there is no text selected and the cursor is not in a line comment is controlled by the `calva.paredit.toggleCommentBehavior` setting:
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2994,22 +2994,22 @@
{
"command": "paredit.dragSexprBackward",
"key": "ctrl+shift+alt+b",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && !calva:cursorInComment"
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/"
},
{
"command": "paredit.dragSexprForward",
"key": "ctrl+shift+alt+f",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && !calva:cursorInComment"
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/"
},
{
"command": "paredit.dragSexprBackward",
"key": "alt+up",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment"
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults"
},
{
"command": "paredit.dragSexprForward",
"key": "alt+down",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults && !calva:cursorInComment"
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/ && config.calva.paredit.hijackVSCodeDefaults"
},
{
"command": "paredit.dragSexprBackwardUp",
Expand Down
262 changes: 237 additions & 25 deletions src/cursor-doc/paredit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2337,6 +2337,179 @@ export function currentSexpsRange(
return currentSingleRange;
}

type LineInfo = {
start: number;
end: number;
content: string;
trimmed: string;
};

function lineInfoAt(text: string, offset: number): LineInfo {
const start = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
const nl = text.indexOf('\n', offset);
const end = nl === -1 ? text.length : nl;
const content = text.substring(start, end);
return { start, end, content, trimmed: content.trimStart() };
}

const prevLine = (text: string, lineStart: number) =>
lineStart > 0 ? lineInfoAt(text, lineStart - 1) : null;

const nextLine = (text: string, lineEnd: number) =>
lineEnd < text.length ? lineInfoAt(text, lineEnd + 1) : null;

const isCommentLine = (line: LineInfo) => line.trimmed.startsWith(';');
const isBlankLine = (line: LineInfo) => line.trimmed === '';
const isResultCommentStartLine = (line: LineInfo) => /^;{1,2}=>/.test(line.trimmed);

function isResultCommentBlock(lines: LineInfo[]): boolean {
if (!lines.length || !isResultCommentStartLine(lines[0])) {
return false;
}

const semicolonPrefix = lines[0].trimmed.startsWith(';;=>') ? ';;' : ';';
return lines.slice(1).every((line) => line.trimmed.startsWith(`${semicolonPrefix} `));
}

function trailingResultCommentBlockEnd(text: string, fromLineEnd: number): number | null {
const first = nextLine(text, fromLineEnd);
if (!first || !isResultCommentStartLine(first)) {
return null;
}

const semicolonPrefix = first.trimmed.startsWith(';;=>') ? ';;' : ';';
let end = first.end;
let line = nextLine(text, first.end);
while (line && isCommentLine(line) && line.trimmed.startsWith(`${semicolonPrefix} `)) {
end = line.end;
line = nextLine(text, line.end);
}
return end;
}

function extendRangeToLineIndent(text: string, [start, end]: [number, number]): [number, number] {
const startLine = lineInfoAt(text, start);
return text.substring(startLine.start, start).trim() === ''
? [startLine.start, end]
: [start, end];
}

function normalizeSwapRangesForLeadingComments(
text: string,
leftRange: [number, number],
rightRange: [number, number]
): [[number, number], [number, number]] {
const leftStartsWithComment = isCommentLine(lineInfoAt(text, leftRange[0]));
const rightStartsWithComment = isCommentLine(lineInfoAt(text, rightRange[0]));

return [
rightStartsWithComment ? extendRangeToLineIndent(text, leftRange) : leftRange,
leftStartsWithComment ? extendRangeToLineIndent(text, rightRange) : rightRange,
];
}

/**
* Extends a form's range to include its attached line comments:
* - Backward: contiguous comment lines immediately above the form (only when
* the form's line has nothing but whitespace before the form).
* - Forward: a trailing `;…` on the form's last line, plus comment lines
* below that are NOT leading a following form (terminated by a blank line
* or EOF).
*/
function extendRangeOverAttachedComments(
doc: EditableDocument,
[start, end]: [number, number]
): [number, number] {
const text = doc.model.getText(0, Number.MAX_SAFE_INTEGER);

let lo = start;
const startLine = lineInfoAt(text, start);
if (text.substring(startLine.start, start).trim() === '') {
const leadingCommentLines: LineInfo[] = [];
for (let line = prevLine(text, startLine.start); line && isCommentLine(line); ) {
leadingCommentLines.unshift(line);
line = prevLine(text, line.start);
}
if (leadingCommentLines.length && !isResultCommentBlock(leadingCommentLines)) {
lo = leadingCommentLines[0].start;
}
}

let hi = end;
const endLine = lineInfoAt(text, end);
if (/^\s*;/.test(text.substring(end, endLine.end))) {
hi = endLine.end;
}

const resultCommentsEnd = trailingResultCommentBlockEnd(text, endLine.end);
if (resultCommentsEnd !== null) {
hi = resultCommentsEnd;
}

const trailingEnds: number[] = [];
let below = nextLine(text, endLine.end);
while (below && isCommentLine(below)) {
trailingEnds.push(below.end);
below = nextLine(text, below.end);
}
if (trailingEnds.length && (!below || isBlankLine(below))) {
hi = trailingEnds[trailingEnds.length - 1];
Comment on lines +2454 to +2456
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extendRangeOverAttachedComments only attaches comment lines below the form if they’re followed by a blank line or EOF. Calva’s evaluation result comments are produced as \n;;=> … (see resultAsComment) and can be immediately followed by the next form without an extra blank line, in which case the ;;=> lines would not be moved with the form. Consider treating ;;=> (and/or ;=>) result comment lines as attached-to-previous even when another form follows.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something we can consider following up with, @jramosg. Sometimes you want a tight RCF with no blank lines, and the ;=> marker is what tells us where the comment belongs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, now is handled in trailingResultCommentBlockEnd

}

return [lo, hi];
}

/**
* If `offset` is on a line-comment line, returns the range of the form that
* comment is attached to: the form immediately below when contiguous (no
* blank line between), else the form immediately above when the comment is a
* trailing annotation. Returns `null` when the cursor isn't on a comment, or
* when no form is attached.
*/
function formAttachedToCommentAt(doc: EditableDocument, offset: number): [number, number] | null {
const text = doc.model.getText(0, Number.MAX_SAFE_INTEGER);
const here = lineInfoAt(text, offset);
if (!isCommentLine(here)) {
return null;
}

if (isResultCommentStartLine(here)) {
let above = prevLine(text, here.start);
while (above && isCommentLine(above)) {
above = prevLine(text, above.start);
}
if (above && !isBlankLine(above)) {
const formEnd = above.start + above.content.trimEnd().length;
const cursor = doc.getTokenCursor(formEnd);
cursor.backwardSexp();
return cursor.rangeForCurrentForm(cursor.offsetStart);
}
return null;
}

let below = nextLine(text, here.end);
while (below && isCommentLine(below)) {
below = nextLine(text, below.end);
}
if (below && !isBlankLine(below)) {
const formStart = below.start + (below.content.length - below.trimmed.length);
return doc.getTokenCursor(formStart).rangeForCurrentForm(formStart);
}

let above = prevLine(text, here.start);
while (above && isCommentLine(above)) {
above = prevLine(text, above.start);
}
if (above && !isBlankLine(above)) {
const formEnd = above.start + above.content.trimEnd().length;
const cursor = doc.getTokenCursor(formEnd);
cursor.backwardSexp();
return cursor.rangeForCurrentForm(cursor.offsetStart);
}

return null;
}

export async function dragSexprBackward(
doc: EditableDocument,
left = doc.selections[0].anchor,
Expand All @@ -2345,21 +2518,36 @@ export async function dragSexprBackward(
) {
const cursor = doc.getTokenCursor(right);
const usePairs = isInPairsList(cursor, config);
const currentRange = currentSexpsRange(doc, cursor, right, usePairs, config);
const newPosOffset = right - currentRange[0];
const backCursor = doc.getTokenCursor(currentRange[0]);
const text = doc.model.getText(0, Number.MAX_SAFE_INTEGER);
const baseRange =
formAttachedToCommentAt(doc, right) ?? currentSexpsRange(doc, cursor, right, usePairs, config);
Comment on lines +2522 to +2523
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the cursor is on a line comment, baseRange falls back to currentSexpsRange if formAttachedToCommentAt returns null. Because the token cursor’s whitespace skipping treats comments as whitespace by default, this can end up dragging a neighboring form even when the comment is intentionally not attached (e.g. separated by a blank line). To preserve the “blank line breaks association” semantics, consider returning early (no-op) when offset is on a comment line but formAttachedToCommentAt returns null.

Suggested change
const baseRange =
formAttachedToCommentAt(doc, right) ?? currentSexpsRange(doc, cursor, right, usePairs, config);
const attachedCommentRange = formAttachedToCommentAt(doc, right);
const text = doc.model.getText(0, Number.MAX_SAFE_INTEGER);
const onCommentLine = isCommentLine(lineInfoAt(text, right));
if (onCommentLine && !attachedCommentRange) {
return;
}
const baseRange =
attachedCommentRange ?? currentSexpsRange(doc, cursor, right, usePairs, config);

Copilot uses AI. Check for mistakes.
const currentRange = extendRangeOverAttachedComments(doc, baseRange);
const backCursor = doc.getTokenCursor(baseRange[0]);
backCursor.backwardSexp();
const backRange = currentSexpsRange(doc, backCursor, backCursor.offsetStart, usePairs, config);
if (backRange[0] !== currentRange[0]) {
// there is a sexp to the left
const leftText = doc.model.getText(backRange[0], backRange[1]);
const currentText = doc.model.getText(currentRange[0], currentRange[1]);
const backBase = currentSexpsRange(doc, backCursor, backCursor.offsetStart, usePairs, config);
if (backBase[0] !== baseRange[0]) {
const backRange = extendRangeOverAttachedComments(doc, backBase);
const [normalizedCurrentRange, normalizedBackRange] = normalizeSwapRangesForLeadingComments(
text,
currentRange,
backRange
);
const leftText = doc.model.getText(normalizedBackRange[0], normalizedBackRange[1]);
const currentText = doc.model.getText(normalizedCurrentRange[0], normalizedCurrentRange[1]);
return doc.model.edit(
[
new ModelEdit('changeRange', [currentRange[0], currentRange[1], leftText]),
new ModelEdit('changeRange', [backRange[0], backRange[1], currentText]),
new ModelEdit('changeRange', [
normalizedCurrentRange[0],
normalizedCurrentRange[1],
leftText,
]),
new ModelEdit('changeRange', [normalizedBackRange[0], normalizedBackRange[1], currentText]),
],
{ selections: [new ModelEditSelection(backRange[0] + newPosOffset)] }
{
selections: [
new ModelEditSelection(normalizedBackRange[0] + right - normalizedCurrentRange[0]),
],
}
);
}
}
Expand All @@ -2372,31 +2560,55 @@ export async function dragSexprForward(
) {
const cursor = doc.getTokenCursor(right);
const usePairs = isInPairsList(cursor, config);
const currentRange = currentSexpsRange(doc, cursor, right, usePairs, config);
const newPosOffset = currentRange[1] - right;
const forwardCursor = doc.getTokenCursor(currentRange[1]);
forwardCursor.forwardSexp();
const forwardRange = currentSexpsRange(
const text = doc.model.getText(0, Number.MAX_SAFE_INTEGER);
const baseRange =
formAttachedToCommentAt(doc, right) ?? currentSexpsRange(doc, cursor, right, usePairs, config);
const currentRange = extendRangeOverAttachedComments(doc, baseRange);
const forwardCursor = doc.getTokenCursor(baseRange[1]);
forwardCursor.forwardWhitespace();
let forwardBase = currentSexpsRange(
doc,
forwardCursor,
forwardCursor.offsetStart,
usePairs,
config
);
if (forwardRange[0] !== currentRange[0]) {
// there is a sexp to the right
const rightText = doc.model.getText(forwardRange[0], forwardRange[1]);
const currentText = doc.model.getText(currentRange[0], currentRange[1]);
if (forwardBase[0] === baseRange[0]) {
forwardCursor.forwardSexp();
forwardCursor.forwardWhitespace();
forwardBase = currentSexpsRange(
doc,
forwardCursor,
forwardCursor.offsetStart,
usePairs,
config
);
}
if (forwardBase[0] !== baseRange[0]) {
const forwardRange = extendRangeOverAttachedComments(doc, forwardBase);
const [normalizedCurrentRange, normalizedForwardRange] = normalizeSwapRangesForLeadingComments(
text,
currentRange,
forwardRange
);
const leftText = doc.model.getText(normalizedCurrentRange[0], normalizedCurrentRange[1]);
const rightText = doc.model.getText(normalizedForwardRange[0], normalizedForwardRange[1]);
return doc.model.edit(
[
new ModelEdit('changeRange', [forwardRange[0], forwardRange[1], currentText]),
new ModelEdit('changeRange', [currentRange[0], currentRange[1], rightText]),
new ModelEdit('changeRange', [
normalizedForwardRange[0],
normalizedForwardRange[1],
leftText,
]),
new ModelEdit('changeRange', [
normalizedCurrentRange[0],
normalizedCurrentRange[1],
rightText,
]),
],
{
selections: [
new ModelEditSelection(
currentRange[1] + (forwardRange[1] - currentRange[1]) - newPosOffset
),
new ModelEditSelection(normalizedForwardRange[1] + right - normalizedCurrentRange[1]),
],
}
);
Expand Down
Loading