Skip to content

Commit

Permalink
Implement multi-cursor rewrap. Addresses #2448.
Browse files Browse the repository at this point in the history
  • Loading branch information
riotrah committed Mar 30, 2024
1 parent 00276d6 commit b932e6c
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 20 deletions.
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]

- 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)

## [2.0.432] - 2024-03-26

- Fix: [Extraneous newlines printed to terminal for some output](https://github.com/BetterThanTomorrow/calva/issues/2468)
Expand Down
95 changes: 83 additions & 12 deletions src/cursor-doc/paredit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Thenable<boolean>> {
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;
Expand All @@ -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) {
Expand Down
150 changes: 147 additions & 3 deletions src/extension-test/unit/paredit/commands-test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -1009,15 +1009,13 @@ 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));
});

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));
});
Expand Down Expand Up @@ -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));
});
});
});
});
});
22 changes: 22 additions & 0 deletions src/paredit/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]]);
}
15 changes: 10 additions & 5 deletions src/paredit/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
},
{
Expand Down

0 comments on commit b932e6c

Please sign in to comment.