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..6f59c440a 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -754,6 +754,32 @@ 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..39190ea6c 100644 --- a/src/extension-test/unit/cursor-doc/paredit-test.ts +++ b/src/extension-test/unit/cursor-doc/paredit-test.ts @@ -1676,6 +1676,77 @@ describe('paredit', () => { }); }); + describe('Squeeze', () => { + let clipboardContent = ''; + const onRange = (doc, range) => { + clipboardContent = doc.model.getText(range[0], range[1]); + return Promise.resolve(); + }; + + it('Squeezes content and deletes entire sexp with ()', async () => { + const a = docFromTextNotation('foo (bar|) baz'); + const b = docFromTextNotation('foo | baz'); + 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, 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, 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, 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, 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, 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, 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, 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, onRange); + 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..3bfba1160 100644 --- a/src/paredit/commands.ts +++ b/src/paredit/commands.ts @@ -147,3 +147,14 @@ 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 () => { + // Default empty implementation + } +) { + // 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) => {