Skip to content

Commit d9a5528

Browse files
committed
Merge branch dev into published
2 parents 1e19863 + d041418 commit d9a5528

15 files changed

Lines changed: 418 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Changes to Calva.
44

55
## [Unreleased]
66

7+
## [2.0.588] - 2026-05-15
8+
9+
- [Make API clojure editing independent of a `TextEditor` instance](https://github.com/BetterThanTomorrow/calva/issues/3220)
10+
- Bump deps.clj to v1.12.5.1638
11+
712
## [2.0.587] - 2026-05-10
813

914
- Fix: [The structural editor fails in some API usage scenarios](https://github.com/BetterThanTomorrow/calva/issues/3218)

deps-clj-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v1.12.4.1618
1+
v1.12.5.1638

deps.clj.jar

2 Bytes
Binary file not shown.

docs/site/api.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,16 @@ The `ranges` module contains functions for retreiving [vscode.Range](https://cod
351351
All functions in this module have the following TypeScript signature:
352352

353353
```typescript
354-
(editor = vscode.window.activeTextEditor, position = editor?.selection?.active) => [vscode.Range, string];
354+
(editorOrDocument?: vscode.TextEditor | vscode.TextDocument, position?: vscode.Position) => [vscode.Range, string];
355355
```
356356

357-
I.e. they expect a [vscode.TextEditor](https://code.visualstudio.com/api/references/vscode-api#TextEditor) – defaulting to the currently active editor – and a [vscode.Position](https://code.visualstudio.com/api/references/vscode-api#Position) – defaulting to the current active position in the editor (or the first active position if multiple selections/positions exist, and will return a tuple with the range, and the text for the piece of interest requested.
357+
They can be called in three ways:
358+
359+
* **No arguments**: uses the active text editor’s document and cursor position.
360+
* **A `TextEditor`**: uses its document and primary cursor position (or the given `position` if provided).
361+
* **A `TextDocument` + `Position`**: uses them directly — no visible editor required. This is useful for programmatic/API usage where you have a document reference but no open editor tab.
362+
363+
All variants return a tuple with the range and the text for the piece of interest requested.
358364

359365
!!! Note "Custom REPL Commands"
360366
The `ranges` function have corresponding [REPL Snippets/Commands](custom-commands.md) substitution variables. It is the same implementation functions used in both cases.
@@ -402,6 +408,17 @@ _Corresponding [REPL Snippet](custom-commands.md) variable: `$top-level-defined-
402408
...)
403409
```
404410

411+
=== "Joyride (with TextDocument + Position)"
412+
413+
```clojure
414+
;; Query a form without the file being open in an editor
415+
(p/let [uri (vscode/Uri.file "/path/to/file.clj")
416+
doc (vscode/workspace.openTextDocument uri)
417+
pos (vscode/Position. 5 0)
418+
[range text] (calva/ranges.currentTopLevelForm doc pos)]
419+
(println "Form at line 5:" text))
420+
```
421+
405422
=== "ClojureScript"
406423

407424
```clojure
@@ -421,12 +438,14 @@ The `editor` module has facilites (well, a facility, so far) for editing Clojure
421438

422439
### `editor.replace()`
423440

424-
With `editor.replace()` you can replace a range in a Clojure editor with new text. The arguments are:
441+
With `editor.replace()` you can replace a range in a Clojure document with new text. The arguments are:
425442

426-
* `editor`, a `vscode.TextEditor`
443+
* `editorOrDocument`, a `vscode.TextEditor` or a `vscode.TextDocument`
427444
* `range`, a `vscode.Range`
428445
* `newText`, a string
429446

447+
When a `TextEditor` is provided, the edit uses `TextEditor.edit()` with undo grouping and formatting. When a `TextDocument` is provided, the edit uses `WorkspaceEdit` — no visible editor is required, making it suitable for programmatic edits from other extensions.
448+
430449
=== "Joyride"
431450

432451
```clojure
@@ -437,6 +456,19 @@ With `editor.replace()` you can replace a range in a Clojure editor with new tex
437456
(println "Error replacing text:" e))))
438457
```
439458

459+
=== "Joyride (editor-free)"
460+
461+
```clojure
462+
;; Edit a document without opening it in an editor
463+
(-> (p/let [uri (vscode/Uri.file "/path/to/file.clj")
464+
doc (vscode/workspace.openTextDocument uri)
465+
range (vscode/Range. (vscode/Position. 2 0) (vscode/Position. 2 9))
466+
_ (calva/editor.replace doc range "(def a 42)")]
467+
(println "Text replaced without opening an editor!"))
468+
(p/catch (fn [e]
469+
(println "Error replacing text:" e))))
470+
```
471+
440472
=== "JavaScript"
441473

442474
```javascript

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Calva: Clojure & ClojureScript Interactive Programming",
44
"description": "Integrated REPL, formatter, Paredit, and more. Powered by cider-nrepl and clojure-lsp.",
55
"icon": "assets/calva.png",
6-
"version": "2.0.587",
6+
"version": "2.0.588",
77
"publisher": "betterthantomorrow",
88
"author": {
99
"name": "Better Than Tomorrow",

src/api/ranges.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import * as vscode from 'vscode';
22
import * as getText from '../util/get-text';
3+
import { resolveDocAndPos } from '../util/resolve-doc-and-pos';
34

5+
/**
6+
* Wraps a `(document, position) => [Range, string]` function so it can be called with:
7+
* - No arguments: uses the active text editor's document and cursor position.
8+
* - A `TextEditor`: uses its document and primary cursor position.
9+
* - A `TextDocument` + `Position`: uses them directly, no visible editor required.
10+
*
11+
* Returns `[undefined, undefined]` when no document/position can be resolved.
12+
*/
413
const wrapSelectionAndTextFunction = (
514
f: (document: vscode.TextDocument, position: vscode.Position) => [vscode.Range, string]
615
) => {
7-
return (editor = vscode.window.activeTextEditor, position = editor?.selections?.[0]?.active) => {
8-
if (editor && position && editor.document && editor.document.languageId === 'clojure') {
9-
return f(editor.document, position);
10-
} else {
16+
return (editorOrDoc?: vscode.TextEditor | vscode.TextDocument, position?: vscode.Position) => {
17+
const resolved = resolveDocAndPos(editorOrDoc, position, vscode.window.activeTextEditor);
18+
if (!resolved) {
1119
return [undefined, undefined];
1220
}
21+
return f(resolved.doc as vscode.TextDocument, resolved.pos as vscode.Position);
1322
};
1423
};
1524

src/doc-mirror/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,48 @@ export class DocumentModel implements EditableModel {
241241
);
242242
}
243243

244+
private applyViaWorkspaceEdit(modelEdits: ModelEdit<ModelEditFunction>[]): Thenable<boolean> {
245+
const doc = this.document.document;
246+
const wsEdit = new vscode.WorkspaceEdit();
247+
for (const modelEdit of modelEdits) {
248+
switch (modelEdit.editFn) {
249+
case 'insertString': {
250+
const [offset, text] = modelEdit.args as documentModel.ModelEditArgs<'insertString'>;
251+
wsEdit.insert(doc.uri, doc.positionAt(offset), text);
252+
break;
253+
}
254+
case 'changeRange': {
255+
const [start, end, text] = modelEdit.args as documentModel.ModelEditArgs<'changeRange'>;
256+
const range = new vscode.Range(doc.positionAt(start), doc.positionAt(end));
257+
wsEdit.replace(doc.uri, range, text);
258+
break;
259+
}
260+
case 'deleteRange': {
261+
const [offset, count] = modelEdit.args as documentModel.ModelEditArgs<'deleteRange'>;
262+
const range = new vscode.Range(doc.positionAt(offset), doc.positionAt(offset + count));
263+
wsEdit.delete(doc.uri, range);
264+
break;
265+
}
266+
default:
267+
break;
268+
}
269+
}
270+
this.staleDocumentVersion = this.documentVersion;
271+
return vscode.workspace.applyEdit(wsEdit);
272+
}
273+
244274
edit(modelEdits: ModelEdit<ModelEditFunction>[], options: ModelEditOptions): Thenable<boolean> {
245275
// undoStopBefore===false joins this edit with the prior one in a single undoable unit.
246276
const undoStopBefore = !(options.undoStopBefore === false);
247277
// Nothing to do?
248278
if (!modelEdits || modelEdits.length == 0) {
249279
return Promise.resolve(true);
250280
}
281+
// Editor-free path: when no formatting, no selections, and no explicit editor,
282+
// use WorkspaceEdit which only needs a document URI (no visible editor required).
283+
if (options.skipFormat && !options.selections && !options.editor) {
284+
return this.applyViaWorkspaceEdit(modelEdits);
285+
}
251286
// Reformatting will retouch the spots affected by edits.
252287
// The edits are stated in terms of the document-as-it-is, before any of the edits.
253288
// Reformat's offsets must be in post-edit terms (i.e., "a later as-is", before reformatting).

src/edit.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as printer from './printer';
77
import * as paredit from './cursor-doc/paredit';
88
import * as format from './calva-fmt/src/format';
99
import * as commentPrefix from './comment-prefix';
10+
import { isTextEditor } from './util/editor-utils';
1011

1112
type CandidatesMap = Map<number, number[]>;
1213

@@ -612,13 +613,26 @@ export async function toggleLineCommentCommand(behaviorArg?: ToggleCommentBehavi
612613
);
613614
}
614615

616+
/**
617+
* Replaces text in a Clojure document within the given range.
618+
*
619+
* @param editorOrDocument When a `TextEditor` is provided, uses `TextEditor.edit()`
620+
* with undo grouping, formatting, and selection restoration (the interactive editing path).
621+
* When a `TextDocument` is provided, uses `WorkspaceEdit` via `vscode.workspace.applyEdit()`,
622+
* which requires no visible editor and causes no UI side effects — suitable for
623+
* programmatic/API edits. Formatting is automatically skipped since it requires a visible editor.
624+
* @param range The document range to replace.
625+
* @param newText The replacement text.
626+
* @param options Edit options forwarded to `DocumentModel.edit()`.
627+
*/
615628
export function replace(
616-
editor: vscode.TextEditor,
629+
editorOrDocument: vscode.TextEditor | vscode.TextDocument,
617630
range: vscode.Range,
618631
newText: string,
619632
options = {}
620633
) {
621-
const document = editor.document;
634+
const hasEditor = isTextEditor(editorOrDocument);
635+
const document = hasEditor ? editorOrDocument.document : editorOrDocument;
622636
const mirrorDoc: model.EditableDocument = docMirror.getDocument(document);
623637
return mirrorDoc.model.edit(
624638
[
@@ -633,7 +647,9 @@ export function replace(
633647
undoStopBefore: true,
634648
},
635649
...options,
636-
editor,
650+
// Without a TextEditor, formatting and selection restoration can't work,
651+
// so force skipFormat to route through WorkspaceEdit.
652+
...(hasEditor ? { editor: editorOrDocument } : { skipFormat: true }),
637653
}
638654
);
639655
}

src/extension-test/integration/suite/edit-replace-test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as testUtil from './util';
44
import * as vscode from 'vscode';
55
import * as edit from '../../../edit';
66
import * as docMirror from '../../../doc-mirror';
7+
import * as ranges from '../../../api/ranges';
78

89
const suiteName = 'Edit Replace Suite';
910

@@ -104,4 +105,142 @@ suite(suiteName, function () {
104105
'Active editor content should be unchanged'
105106
);
106107
});
108+
109+
suite('Editor-less API', function () {
110+
async function openDocWithMirror(): Promise<vscode.TextDocument> {
111+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(targetFilePath));
112+
await testUtil.waitForCondition(
113+
() => {
114+
try {
115+
docMirror.getDocument(doc);
116+
return true;
117+
} catch {
118+
return false;
119+
}
120+
},
121+
4000,
122+
50,
123+
'Timed out waiting for mirror document (no editor)'
124+
);
125+
return doc;
126+
}
127+
128+
test('edit.replace applies edit via TextDocument', async function () {
129+
const targetDoc = await openDocWithMirror();
130+
131+
const range = new vscode.Range(
132+
new vscode.Position(2, 0),
133+
new vscode.Position(2, '(def a 1)'.length)
134+
);
135+
const result = await edit.replace(targetDoc, range, '(def a 42)', {
136+
skipFormat: true,
137+
});
138+
139+
assert.strictEqual(result, true, 'edit.replace should return true');
140+
const content = targetDoc.getText();
141+
assert.ok(content.includes('(def a 42)'), `Should contain replacement. Got: ${content}`);
142+
assert.ok(!content.includes('(def a 1)'), `Should not contain original. Got: ${content}`);
143+
});
144+
145+
test('ranges.currentForm resolves with TextDocument + Position', async function () {
146+
const targetDoc = await openDocWithMirror();
147+
148+
// Position on the opening paren — currentForm returns the whole list
149+
const pos = new vscode.Position(2, 0);
150+
const [formRange, formText] = ranges.currentForm(targetDoc, pos);
151+
assert.ok(formRange, 'should return a range');
152+
assert.strictEqual(formText, '(def a 1)', `should return the form. Got: ${formText}`);
153+
});
154+
155+
test('ranges.currentTopLevelForm resolves with TextDocument + Position', async function () {
156+
const targetDoc = await openDocWithMirror();
157+
158+
// Position inside a symbol — currentTopLevelForm still returns the enclosing def
159+
const pos = new vscode.Position(2, 5);
160+
const [formRange, formText] = ranges.currentTopLevelForm(targetDoc, pos);
161+
assert.ok(formRange, 'should return a range');
162+
assert.strictEqual(
163+
formText,
164+
'(def a 1)',
165+
`should return the top-level form. Got: ${formText}`
166+
);
167+
});
168+
169+
test('ranges.currentEnclosingForm resolves with TextDocument + Position', async function () {
170+
const targetDoc = await openDocWithMirror();
171+
172+
// Position on symbol 'a' — enclosing form is (def a 1)
173+
const pos = new vscode.Position(2, 5);
174+
const [formRange, formText] = ranges.currentEnclosingForm(targetDoc, pos);
175+
assert.ok(formRange, 'should return a range');
176+
assert.strictEqual(
177+
formText,
178+
'(def a 1)',
179+
`should return the enclosing form. Got: ${formText}`
180+
);
181+
});
182+
183+
test('edit then ranges: doc mirror reflects the edit', async function () {
184+
const targetDoc = await openDocWithMirror();
185+
186+
// Replace (def a 1) with (def a 42)
187+
const range = new vscode.Range(
188+
new vscode.Position(2, 0),
189+
new vscode.Position(2, '(def a 1)'.length)
190+
);
191+
await edit.replace(targetDoc, range, '(def a 42)', { skipFormat: true });
192+
193+
// Range query on the edited content should see the new form
194+
const pos = new vscode.Position(2, 0);
195+
const [, formText] = ranges.currentForm(targetDoc, pos);
196+
assert.strictEqual(
197+
formText,
198+
'(def a 42)',
199+
`After edit, form should be updated. Got: ${formText}`
200+
);
201+
});
202+
203+
test('edit.replace edits the target document, not the active editor', async function () {
204+
const targetDoc = await openDocWithMirror();
205+
206+
// Open a different file as the active editor
207+
const otherDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(otherFilePath));
208+
await vscode.window.showTextDocument(otherDoc, {
209+
viewColumn: vscode.ViewColumn.One,
210+
preview: false,
211+
});
212+
await testUtil.waitForCondition(
213+
() => vscode.window.activeTextEditor?.document.uri.fsPath === otherFilePath,
214+
4000,
215+
50,
216+
'Timed out waiting for other file to become active'
217+
);
218+
219+
const otherContentBefore = vscode.window.activeTextEditor.document.getText();
220+
221+
// Edit the target via TextDocument (no skipFormat — the exact scenario that was buggy)
222+
const range = new vscode.Range(
223+
new vscode.Position(2, 0),
224+
new vscode.Position(2, '(def a 1)'.length)
225+
);
226+
const result = await edit.replace(targetDoc, range, '(def a 42)');
227+
228+
assert.strictEqual(result, true, 'edit.replace should return true');
229+
230+
// Target document should have the edit
231+
const targetContent = targetDoc.getText();
232+
assert.ok(
233+
targetContent.includes('(def a 42)'),
234+
`Target should contain replacement. Got: ${targetContent}`
235+
);
236+
237+
// Active editor's document should be untouched
238+
const otherContentAfter = vscode.window.activeTextEditor.document.getText();
239+
assert.strictEqual(
240+
otherContentAfter,
241+
otherContentBefore,
242+
'Active editor content should be unchanged when editing via TextDocument'
243+
);
244+
});
245+
});
107246
});

0 commit comments

Comments
 (0)