From 3d9f5b0e706fa00bfdb50efefee71f45073d0aed Mon Sep 17 00:00:00 2001 From: conao3 Date: Sun, 13 Jul 2025 21:41:04 +0900 Subject: [PATCH 1/4] add squeeze paredit action --- CHANGELOG.md | 1 + package.json | 11 +++ src/cursor-doc/paredit.ts | 29 ++++++++ .../unit/cursor-doc/paredit-test.ts | 70 +++++++++++++++++++ .../unit/paredit/commands-test.ts | 49 +++++++++++++ src/paredit/commands.ts | 9 +++ src/paredit/extension.ts | 7 ++ 7 files changed, 176 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c771b5c..451dbc64d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Changes to Calva. - Fix: [[doc] Unneeded send-off function call in sample code](https://github.com/BetterThanTomorrow/calva/issues/2888) - Fix: [Ctrl+Home shortcut has no title](https://github.com/BetterThanTomorrow/calva/issues/2890) +- [Add paredit squeeze command to remove parentheses from current form](https://github.com/BetterThanTomorrow/calva/issues/2892) ## [2.0.522] - 2025-07-12 diff --git a/package.json b/package.json index c99fc4777..1ad0d2490 100644 --- a/package.json +++ b/package.json @@ -1922,6 +1922,12 @@ "title": "Rewrap \"\"", "enablement": "editorLangId == clojure" }, + { + "category": "Calva Paredit", + "command": "paredit.squeeze", + "title": "Squeeze - Cut sibling S-exps and delete paren", + "enablement": "editorLangId == clojure" + }, { "command": "calva-fmt.formatCurrentForm", "title": "Format Current Form", @@ -2690,6 +2696,11 @@ "key": "ctrl+alt+r ctrl+alt+q", "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/" }, + { + "command": "paredit.squeeze", + "key": "ctrl+alt+r ctrl+alt+r", + "when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/" + }, { "command": "paredit.deleteForward", "key": "delete", diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 81f088f77..6b4f5ecef 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -754,6 +754,35 @@ export function rewrapSexpr( return doc.model.edit(editsToApply, {}); } +export async function squeezeSexpr( + doc: EditableDocument, + onRange: (doc: EditableDocument, range: [number, number]) => Promise, + start = doc.selections[0].active +) { + const startC = doc.getTokenCursor(start); + + startC.backwardList(); + if(startC.backwardUpList()) { + const outerStart = startC.offsetStart; + + const endC = doc.getTokenCursor(startC.offsetStart); + endC.forwardSexp(); + const outerEnd = endC.offsetStart; + endC.previous(); + const innerEnd = endC.offsetStart; + + startC.downList(); + const innerStart = startC.offsetStart; + + await onRange(doc, [innerStart, innerEnd]); + + return doc.model.edit( + [new ModelEdit('changeRange', [outerStart, outerEnd, ''])], + {} + ); + } +} + export async function splitSexp(doc: EditableDocument, start: number = doc.selections[0].active) { const cursor = doc.getTokenCursor(start); if (!cursor.withinString() && !(cursor.isWhiteSpace() || cursor.previousIsWhiteSpace())) { diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index 11af01423..10c25ed59 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -1676,6 +1676,76 @@ describe('paredit', () => { }); }); + describe('Squeeze', () => { + let clipboardContent = ''; + const mockCopyToClipboard = async (doc, range) => { + clipboardContent = doc.model.getText(range[0], range[1]); + }; + + it('Squeezes content and deletes entire sexp with ()', async () => { + const a = docFromTextNotation('foo (bar|) baz'); + const b = docFromTextNotation('foo | baz'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar'); + }); + it('Squeezes content and deletes entire sexp with []', async () => { + const a = docFromTextNotation('foo [bar|] baz'); + const b = docFromTextNotation('foo | baz'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar'); + }); + it('Squeezes content and deletes entire sexp with {}', async () => { + const a = docFromTextNotation('foo {bar|} baz'); + const b = docFromTextNotation('foo | baz'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar'); + }); + it('Squeezes content and deletes entire sexp with #{}', async () => { + const a = docFromTextNotation('foo #{bar|} baz'); + const b = docFromTextNotation('foo | baz'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar'); + }); + it('Squeezes content and deletes entire sexp with ""', async () => { + const a = docFromTextNotation('foo "bar|" baz'); + const b = docFromTextNotation('foo | baz'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar'); + }); + it('Squeezes multi-element content correctly', async () => { + const a = docFromTextNotation('foo (bar baz |qux) quux'); + const b = docFromTextNotation('foo | quux'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('bar baz qux'); + }); + it('Squeezes nested expression content correctly', async () => { + const a = docFromTextNotation('(a (b c|) d)'); + const b = docFromTextNotation('(a | d)'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('b c'); + }); + it('Squeezes with metadata', async () => { + const a = docFromTextNotation('^:foo (b c|) d'); + const b = docFromTextNotation('^:foo | d'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + expect(clipboardContent).toEqual('b c'); + }); + it('Does nothing when cursor is not in a list', async () => { + const a = docFromTextNotation('a b| c d'); + const b = docFromTextNotation('a b| c d'); + await paredit.squeezeSexpr(a, mockCopyToClipboard); + expect(textAndSelection(a)).toEqual(textAndSelection(b)); + }); + }); + describe('Slurping', () => { describe('Slurping forwards', () => { it('slurps form after list', async () => { diff --git a/src/extension-test/unit/paredit/commands-test.ts b/src/extension-test/unit/paredit/commands-test.ts index 3133d4d25..072dbd59a 100644 --- a/src/extension-test/unit/paredit/commands-test.ts +++ b/src/extension-test/unit/paredit/commands-test.ts @@ -1351,6 +1351,55 @@ describe('paredit commands', () => { expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); }); }); + + describe('squeeze', () => { + it('Single-cursor: Squeezes content and deletes sexp with ()', async () => { + const a = docFromTextNotation('foo (bar|) baz'); + const b = docFromTextNotation('foo | baz'); + await handlers.squeeze(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Squeezes content and deletes sexp with []', async () => { + const a = docFromTextNotation('foo [bar|] baz'); + const b = docFromTextNotation('foo | baz'); + await handlers.squeeze(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Squeezes content and deletes sexp with {}', async () => { + const a = docFromTextNotation('foo {bar|} baz'); + const b = docFromTextNotation('foo | baz'); + await handlers.squeeze(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Squeezes content and deletes sexp with #{}', async () => { + const a = docFromTextNotation('foo #{bar|} baz'); + const b = docFromTextNotation('foo | baz'); + await handlers.squeeze(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Squeezes content and deletes sexp with ""', async () => { + const a = docFromTextNotation('foo "bar|" baz'); + const b = docFromTextNotation('foo | baz'); + await handlers.squeeze(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Does nothing when cursor is not in a list', async () => { + const a = docFromTextNotation('a b| c d'); + const b = docFromTextNotation('a b| c d'); + await handlers.squeeze(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + }); }); }); }); diff --git a/src/paredit/commands.ts b/src/paredit/commands.ts index 6ccf7208c..059c355ef 100644 --- a/src/paredit/commands.ts +++ b/src/paredit/commands.ts @@ -147,3 +147,12 @@ export function rewrapSquare(doc: EditableDocument, isMulti: boolean) { export function rewrapParens(doc: EditableDocument, isMulti: boolean) { return paredit.rewrapSexpr(doc, '(', ')', isMulti ? doc.selections : [doc.selections[0]]); } + +export async function squeeze( + doc: EditableDocument, + isMulti: boolean, + onRange: (doc: EditableDocument, range: [number, number]) => Promise = async () => {} +) { + // TODO: support multi-cursor + return paredit.squeezeSexpr(doc, onRange, doc.selections[0].active); +} diff --git a/src/paredit/extension.ts b/src/paredit/extension.ts index 737256a46..9acfd8574 100644 --- a/src/paredit/extension.ts +++ b/src/paredit/extension.ts @@ -449,6 +449,13 @@ const pareditCommands = [ return handlers.rewrapQuote(doc, isMulti); }, }, + { + command: 'paredit.squeeze', + handler: (doc: EditableDocument, opts?: { multicursor: boolean }) => { + const isMulti = multiCursorEnabled(opts?.multicursor); + return handlers.squeeze(doc, isMulti, copyRangeToClipboard); + }, + }, { command: 'paredit.deleteForward', handlerNow: (doc: EditableDocument, builder: vscode.TextEditorEdit) => { From 0a28bb69c4cf286295f7e95d3f54902e6ccb638d Mon Sep 17 00:00:00 2001 From: conao3 Date: Mon, 14 Jul 2025 01:01:38 +0900 Subject: [PATCH 2/4] fmt --- src/cursor-doc/paredit.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 6b4f5ecef..6f59c440a 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -762,7 +762,7 @@ export async function squeezeSexpr( const startC = doc.getTokenCursor(start); startC.backwardList(); - if(startC.backwardUpList()) { + if (startC.backwardUpList()) { const outerStart = startC.offsetStart; const endC = doc.getTokenCursor(startC.offsetStart); @@ -776,10 +776,7 @@ export async function squeezeSexpr( await onRange(doc, [innerStart, innerEnd]); - return doc.model.edit( - [new ModelEdit('changeRange', [outerStart, outerEnd, ''])], - {} - ); + return doc.model.edit([new ModelEdit('changeRange', [outerStart, outerEnd, ''])], {}); } } From 38e565676fe707b899862a543190f8540eb9ac03 Mon Sep 17 00:00:00 2001 From: conao3 Date: Mon, 14 Jul 2025 01:03:33 +0900 Subject: [PATCH 3/4] rename variable; mockCopyToClipboard -> onRange --- .../unit/cursor-doc/paredit-test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index 10c25ed59..18bed612c 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -1678,70 +1678,70 @@ describe('paredit', () => { describe('Squeeze', () => { let clipboardContent = ''; - const mockCopyToClipboard = async (doc, range) => { + const onRange = async (doc, range) => { clipboardContent = doc.model.getText(range[0], range[1]); }; it('Squeezes content and deletes entire sexp with ()', async () => { const a = docFromTextNotation('foo (bar|) baz'); const b = docFromTextNotation('foo | baz'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar'); }); it('Squeezes content and deletes entire sexp with []', async () => { const a = docFromTextNotation('foo [bar|] baz'); const b = docFromTextNotation('foo | baz'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar'); }); it('Squeezes content and deletes entire sexp with {}', async () => { const a = docFromTextNotation('foo {bar|} baz'); const b = docFromTextNotation('foo | baz'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar'); }); it('Squeezes content and deletes entire sexp with #{}', async () => { const a = docFromTextNotation('foo #{bar|} baz'); const b = docFromTextNotation('foo | baz'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar'); }); it('Squeezes content and deletes entire sexp with ""', async () => { const a = docFromTextNotation('foo "bar|" baz'); const b = docFromTextNotation('foo | baz'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar'); }); it('Squeezes multi-element content correctly', async () => { const a = docFromTextNotation('foo (bar baz |qux) quux'); const b = docFromTextNotation('foo | quux'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('bar baz qux'); }); it('Squeezes nested expression content correctly', async () => { const a = docFromTextNotation('(a (b c|) d)'); const b = docFromTextNotation('(a | d)'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('b c'); }); it('Squeezes with metadata', async () => { const a = docFromTextNotation('^:foo (b c|) d'); const b = docFromTextNotation('^:foo | d'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); expect(clipboardContent).toEqual('b c'); }); it('Does nothing when cursor is not in a list', async () => { const a = docFromTextNotation('a b| c d'); const b = docFromTextNotation('a b| c d'); - await paredit.squeezeSexpr(a, mockCopyToClipboard); + await paredit.squeezeSexpr(a, onRange); expect(textAndSelection(a)).toEqual(textAndSelection(b)); }); }); From 967da1823e5ca3e20ffae428971f34176936de2d Mon Sep 17 00:00:00 2001 From: conao3 Date: Mon, 14 Jul 2025 01:12:29 +0900 Subject: [PATCH 4/4] eslint --- src/extension-test/unit/cursor-doc/paredit-test.ts | 3 ++- src/paredit/commands.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/extension-test/unit/cursor-doc/paredit-test.ts b/src/extension-test/unit/cursor-doc/paredit-test.ts index 18bed612c..39190ea6c 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -1678,8 +1678,9 @@ describe('paredit', () => { describe('Squeeze', () => { let clipboardContent = ''; - const onRange = async (doc, range) => { + const onRange = (doc, range) => { clipboardContent = doc.model.getText(range[0], range[1]); + return Promise.resolve(); }; it('Squeezes content and deletes entire sexp with ()', async () => { diff --git a/src/paredit/commands.ts b/src/paredit/commands.ts index 059c355ef..3bfba1160 100644 --- a/src/paredit/commands.ts +++ b/src/paredit/commands.ts @@ -151,7 +151,9 @@ export function rewrapParens(doc: EditableDocument, isMulti: boolean) { export async function squeeze( doc: EditableDocument, isMulti: boolean, - onRange: (doc: EditableDocument, range: [number, number]) => Promise = async () => {} + onRange: (doc: EditableDocument, range: [number, number]) => Promise = async () => { + // Default empty implementation + } ) { // TODO: support multi-cursor return paredit.squeezeSexpr(doc, onRange, doc.selections[0].active);