From 129a62ed1f35e3bf6adda20b66f23498a499def6 Mon Sep 17 00:00:00 2001 From: Rayat M Rahman <22646419+riotrah@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:08:55 -0700 Subject: [PATCH] Implement multi-cursor rewrap. Addresses #2448. --- CHANGELOG.md | 1 + src/cursor-doc/paredit.ts | 95 +++++++++-- .../unit/paredit/commands-test.ts | 150 +++++++++++++++++- src/paredit/commands.ts | 22 +++ src/paredit/extension.ts | 15 +- 5 files changed, 263 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 732060667..52fc4645d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Changes to Calva. ## [Unreleased] +- Implement experimental support for multicursor rewrap commands. Enable `calva.paredit.multicursor` in your settings to try it out. Addressing [#2448](https://github.com/BetterThanTomorrow/calva/issues/2448) - [Add Paredit Kill Left equivalent to Kill Right](https://github.com/BetterThanTomorrow/calva/issues/2426) - Fix: Certain `paredit.killRight` edges cases on Windows. diff --git a/src/cursor-doc/paredit.ts b/src/cursor-doc/paredit.ts index 1fca21cea..9689c85c7 100644 --- a/src/cursor-doc/paredit.ts +++ b/src/cursor-doc/paredit.ts @@ -670,14 +670,36 @@ export async function wrapSexpr( } } -export async function rewrapSexpr( +/** + * 'Rewraps' the lists containing each cursor/selection, as provided by `selections`, with + * the provided `open` and `close` strings. + * + * Single cursor is just the simpler special case when `selections.length` is 1 + * High level overview: + * - For each cursor, find the offsets/ranges for its containing list's open/close tokens. + * - Make 2 ModelEdits for each token's replacement + 1 Selection; record the offset change. + * - Dedupe each edit (as multi cursors could be in the same list). + * - Then, reposition the edits and selections by the preceding edits' offset changes. + * - Finally, apply the edits and update the selections. + * + * @param doc + * @param open + * @param close + * @param selections + * @returns + */ +export function rewrapSexpr( doc: EditableDocument, open: string, close: string, - start: number = doc.selections[0].anchor, - end: number = doc.selections[0].active -): Promise> { - const cursor = doc.getTokenCursor(end); + selections = [doc.selections[0]] +) { + const edits: { type: 'open' | 'close'; change: number; edit: ModelEdit<'changeRange'> }[] = [], + newSelections = _.clone(selections).map((s) => ({ selection: s, change: 0 })); + + selections.forEach((sel, index) => { + const { active } = sel; + const cursor = doc.getTokenCursor(active); if (cursor.backwardList()) { cursor.backwardUpList(); const oldOpenStart = cursor.offsetStart; @@ -686,16 +708,65 @@ export async function rewrapSexpr( if (cursor.forwardSexp()) { const oldCloseStart = cursor.offsetStart - close.length; const oldCloseEnd = cursor.offsetStart; - const d = open.length - oldOpenLength; - return doc.model.edit( - [ - new ModelEdit('changeRange', [oldCloseStart, oldCloseEnd, close]), - new ModelEdit('changeRange', [oldOpenStart, oldOpenEnd, open]), - ], - { selections: [new ModelEditSelection(end + d)] } + const openChange = open.length - oldOpenLength; + edits.push( + { + edit: new ModelEdit('changeRange', [oldCloseStart, oldCloseEnd, close]), + change: 0, + type: 'close', + }, + { + edit: new ModelEdit('changeRange', [oldOpenStart, oldOpenEnd, open]), + change: openChange, + type: 'open', + } ); + newSelections[index] = { + selection: new ModelEditSelection(active), + change: openChange, + }; } } + }); + + // Due to the nature of dealing with list boundaries, multiple cursors could be targeting + // the same lists, which will result in attempting to delete the same ranges twice. So we dedupe. + const uniqEdits = _.uniqWith(edits, _.isEqual); + + // for both edits and new selections, get the offset by which to move each based on prior edits + function getOffset(cursorOffset: number) { + return _(uniqEdits) + .filter((x) => { + const [xStart] = x.edit.args; + return xStart < cursorOffset; + }) + .map(({ change }) => change) + .sum(); + } + + const editsToApply = _(uniqEdits) + // First, importantly, sort by list open char offset + .sortBy((e) => e.edit.args[0]) + // now, let's iterate thru each cursor and adjust their positions if earlier chars are delete/added + .map((e) => { + const [oldStart, oldEnd, text] = e.edit.args; + const offset = getOffset(oldStart); + const newStart = oldStart + offset; + const newEnd = oldEnd + offset; + return { ...e.edit, args: [newStart, newEnd, text] as const }; + }) + .value(); + const selectionsToApply = newSelections.map(({ selection }) => { + const { active } = selection; + const newSel = selection.clone(); + const offset = getOffset(active); + newSel.reposition(offset); + return newSel; + }); + + return doc.model.edit(editsToApply, { + selections: selectionsToApply, + }); } export async function splitSexp(doc: EditableDocument, start: number = doc.selections[0].active) { diff --git a/src/extension-test/unit/paredit/commands-test.ts b/src/extension-test/unit/paredit/commands-test.ts index c14402819..e26ca1dff 100644 --- a/src/extension-test/unit/paredit/commands-test.ts +++ b/src/extension-test/unit/paredit/commands-test.ts @@ -1,7 +1,7 @@ import * as expect from 'expect'; import * as model from '../../../cursor-doc/model'; import * as handlers from '../../../paredit/commands'; -import { docFromTextNotation } from '../common/text-notation'; +import { docFromTextNotation, textNotationFromDoc } from '../common/text-notation'; import _ = require('lodash'); model.initScanner(20000); @@ -1009,7 +1009,6 @@ describe('paredit commands', () => { it('Single-cursor: Deals with empty lines', async () => { const a = docFromTextNotation('\n|'); const b = docFromTextNotation('|'); - // const expected = { range: textAndSelection(b)[1], editOptions: { skipFormat: false } }; await handlers.killLeft(a, false); expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); }); @@ -1017,7 +1016,6 @@ describe('paredit commands', () => { it('Single-cursor: Deals with empty lines (Windows)', async () => { const a = docFromTextNotation('\r\n|'); const b = docFromTextNotation('|'); - // const expected = { range: textAndSelection(b)[1], editOptions: { skipFormat: false } }; await handlers.killLeft(a, false); expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); }); @@ -1087,4 +1085,150 @@ describe('paredit commands', () => { }); }); }); + + describe('editing', () => { + describe('wrapping', () => { + describe('rewrap', () => { + it('Single-cursor: Rewraps () -> []', async () => { + const a = docFromTextNotation('a (b c|) d'); + const b = docFromTextNotation('a [b c|] d'); + await handlers.rewrapSquare(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps () -> []', async () => { + const a = docFromTextNotation('(a|2 (b c|) |1d)|3'); + const b = docFromTextNotation('[a|2 [b c|] |1d]|3'); + await handlers.rewrapSquare(a, true); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Rewraps [] -> ()', async () => { + const a = docFromTextNotation('[a [b c|] d]'); + const b = docFromTextNotation('[a (b c|) d]'); + await handlers.rewrapParens(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps [] -> ()', async () => { + const a = docFromTextNotation('[a|2 [b c|] |1d]|3'); + const b = docFromTextNotation('(a|2 (b c|) |1d)|3'); + await handlers.rewrapParens(a, true); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Rewraps [] -> {}', async () => { + const a = docFromTextNotation('[a [b c|] d]'); + const b = docFromTextNotation('[a {b c|} d]'); + await handlers.rewrapCurly(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps [] -> {}', async () => { + const a = docFromTextNotation('[a|2 [b c|] |1d]|3'); + const b = docFromTextNotation('{a|2 {b c|} |1d}|3'); + await handlers.rewrapCurly(a, true); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Rewraps #{} -> {}', async () => { + const a = docFromTextNotation('#{a #{b c|} d}'); + const b = docFromTextNotation('#{a {b c|} d}'); + await handlers.rewrapCurly(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps #{} -> {}', async () => { + const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3'); + const b = docFromTextNotation('{a|2 {b c|} |1d}|3'); + await handlers.rewrapCurly(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Rewraps #{} -> ""', async () => { + const a = docFromTextNotation('#{a #{b c|} d}'); + const b = docFromTextNotation('#{a "b c|" d}'); + await handlers.rewrapQuote(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps #{} -> ""', async () => { + const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3'); + const b = docFromTextNotation('"a|2 "b c|" |1d"|3'); + await handlers.rewrapQuote(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps #{} -> "" 2', async () => { + const a = docFromTextNotation('#{a|2 #{b c|} |1d}|3\n#{a|6 #{b c|4} |5d}|7'); + const b = docFromTextNotation('"a|2 "b c|" |1d"|3\n"a|6 "b c|4" |5d"|7'); + await handlers.rewrapQuote(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps #{} -> [] 3', async () => { + const a = docFromTextNotation('#{a|2 #{b c|} |1d\n#{a|6 #{b c|4} |5d}}|3'); + const b = docFromTextNotation('[a|2 [b c|] |1d\n[a|6 [b c|4] |5d]]|3'); + await handlers.rewrapSquare(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + it('Single-cursor: Rewraps [] -> #{}', async () => { + const a = docFromTextNotation('[[b c|] d]'); + const b = docFromTextNotation('[#{b c|} d]'); + await handlers.rewrapSet(a, false); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps [] -> #{}', async () => { + const a = docFromTextNotation('[[b|2 c|] |1d]|3'); + const b = docFromTextNotation('#{#{b|2 c|} |1d}|3'); + await handlers.rewrapSet(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps [] -> #{} 2', async () => { + const a = docFromTextNotation('[[b|2 c|] |1d]|3\n[a|6 [b c|4] |5d]|7'); + const b = docFromTextNotation('#{#{b|2 c|} |1d}|3\n#{a|6 #{b c|4} |5d}|7'); + await handlers.rewrapSet(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps [] -> #{} 3', async () => { + const a = docFromTextNotation('[[b|2 c|] |1d\n[a|6 [b c|4] |5d]]|3'); + const b = docFromTextNotation('#{#{b|2 c|} |1d\n#{a|6 #{b c|4} |5d}}|3'); + await handlers.rewrapSet(a, true); + expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b)); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + // TODO: This tests current behavior. What should happen? + it('Single-cursor: Rewraps ^{} -> #{}', async () => { + const a = docFromTextNotation('^{^{b c|} d}'); + const b = docFromTextNotation('^{#{b c|} d}'); + await handlers.rewrapSet(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps ^{} -> #{}', async () => { + const a = docFromTextNotation('^{^{b|2 c|} |1d}|3'); + const b = docFromTextNotation('#{#{b|2 c|} |1d}|3'); + await handlers.rewrapSet(a, true); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + + // TODO: This tests current behavior. What should happen? + it('Single-cursor: Rewraps ~{} -> #{}', async () => { + const a = docFromTextNotation('~{~{b c|} d}'); + const b = docFromTextNotation('~{#{b c|} d}'); + await handlers.rewrapSet(a, false); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + it('Multi-cursor: Rewraps ~{} -> #{}', async () => { + const a = docFromTextNotation('~{~{b|2 c|} |1d}|3'); + const b = docFromTextNotation('#{#{b|2 c|} |1d}|3'); + await handlers.rewrapSet(a, true); + expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit)); + }); + }); + }); + }); }); diff --git a/src/paredit/commands.ts b/src/paredit/commands.ts index 38c07cea9..9e29d28f8 100644 --- a/src/paredit/commands.ts +++ b/src/paredit/commands.ts @@ -122,3 +122,25 @@ export async function killLeft( result.editOptions ); } + +// REWRAP + +export function rewrapQuote(doc: EditableDocument, isMulti: boolean) { + return paredit.rewrapSexpr(doc, '"', '"', isMulti ? doc.selections : [doc.selections[0]]); +} + +export function rewrapSet(doc: EditableDocument, isMulti: boolean) { + return paredit.rewrapSexpr(doc, '#{', '}', isMulti ? doc.selections : [doc.selections[0]]); +} + +export function rewrapCurly(doc: EditableDocument, isMulti: boolean) { + return paredit.rewrapSexpr(doc, '{', '}', isMulti ? doc.selections : [doc.selections[0]]); +} + +export function rewrapSquare(doc: EditableDocument, isMulti: boolean) { + return paredit.rewrapSexpr(doc, '[', ']', isMulti ? doc.selections : [doc.selections[0]]); +} + +export function rewrapParens(doc: EditableDocument, isMulti: boolean) { + return paredit.rewrapSexpr(doc, '(', ')', isMulti ? doc.selections : [doc.selections[0]]); +} diff --git a/src/paredit/extension.ts b/src/paredit/extension.ts index 1708ebf77..a30c421e0 100644 --- a/src/paredit/extension.ts +++ b/src/paredit/extension.ts @@ -392,31 +392,36 @@ const pareditCommands: PareditCommand[] = [ { command: 'paredit.rewrapParens', handler: (doc: EditableDocument) => { - return paredit.rewrapSexpr(doc, '(', ')'); + const isMulti = multiCursorEnabled(); + return handlers.rewrapParens(doc, isMulti); }, }, { command: 'paredit.rewrapSquare', handler: (doc: EditableDocument) => { - return paredit.rewrapSexpr(doc, '[', ']'); + const isMulti = multiCursorEnabled(); + return handlers.rewrapSquare(doc, isMulti); }, }, { command: 'paredit.rewrapCurly', handler: (doc: EditableDocument) => { - return paredit.rewrapSexpr(doc, '{', '}'); + const isMulti = multiCursorEnabled(); + return handlers.rewrapCurly(doc, isMulti); }, }, { command: 'paredit.rewrapSet', handler: (doc: EditableDocument) => { - return paredit.rewrapSexpr(doc, '#{', '}'); + const isMulti = multiCursorEnabled(); + return handlers.rewrapSet(doc, isMulti); }, }, { command: 'paredit.rewrapQuote', handler: (doc: EditableDocument) => { - return paredit.rewrapSexpr(doc, '"', '"'); + const isMulti = multiCursorEnabled(); + return handlers.rewrapQuote(doc, isMulti); }, }, {