Skip to content

Commit

Permalink
Merge pull request #2474 from BetterThanTomorrow/wip/rayat/paredit/mu…
Browse files Browse the repository at this point in the history
…lticursor/rewrap

Paredit Multicursor - Rewrap
  • Loading branch information
PEZ committed Mar 31, 2024
2 parents 9a6e1d2 + b1c3e1c commit 8d83117
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 31 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](https://github.com/BetterThanTomorrow/calva/issues/2448). Enable `calva.paredit.multicursor` in your settings to try it out. Closes [#2473](https://github.com/BetterThanTomorrow/calva/issues/2473)

## [2.0.432] - 2024-03-26

- Fix: [Extraneous newlines printed to terminal for some output](https://github.com/BetterThanTomorrow/calva/issues/2468)
Expand Down
3 changes: 2 additions & 1 deletion docs/site/paredit.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,5 @@ Happy Editing! ❤️
There is an ongoing effort to support simultaneous multicursor editing with Paredit. This is an experimental feature and is not enabled by default. To enable it, set `calva.paredit.multicursor` to `true`. This feature is still in development and may not work as expected in all cases. Currently, this supports the following categories:

- Movement
- Selection
- Selection (except for `Select Current Form` - coming soon!)
- Rewrap
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@
},
"calva.paredit.multicursor": {
"type": "boolean",
"markdownDescription": "Experimental: Support for multiple cursors in paredit commands.\nCurrently supported commands:\n- Cursor movement\n- Cursor selection",
"markdownDescription": "Experimental: Support for multiple cursors in paredit commands.\nCurrently supported commands:\n- Cursor movement\n- Cursor selection\n- Rewrap",
"default": false,
"scope": "window"
}
Expand Down
113 changes: 92 additions & 21 deletions src/cursor-doc/paredit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,32 +670,103 @@ 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);
if (cursor.backwardList()) {
cursor.backwardUpList();
const oldOpenStart = cursor.offsetStart;
const oldOpenLength = cursor.getToken().raw.length;
const oldOpenEnd = oldOpenStart + oldOpenLength;
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)] }
);
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;
const oldOpenLength = cursor.getToken().raw.length;
const oldOpenEnd = oldOpenStart + oldOpenLength;
if (cursor.forwardSexp()) {
const oldCloseStart = cursor.offsetStart - close.length;
const oldCloseEnd = cursor.offsetStart;
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
179 changes: 176 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,179 @@ 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('Multi-cursor: Handles rewrapping nested forms [] -> {}', async () => {
const a = docFromTextNotation('[:d :e [a|1 [b c|]]]');
const b = docFromTextNotation('[:d :e {a|1 {b c|}}]');
await handlers.rewrapCurly(a, true);
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
});
it('Multi-cursor: Handles rewrapping nested forms [] -> {} 2', async () => {
const a = docFromTextNotation('[|1:d :e [a|2 [b c|]]]');
const b = docFromTextNotation('{|1:d :e {a|2 {b c|}}}');
await handlers.rewrapCurly(a, true);
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
});
it('Multi-cursor: Handles rewrapping nested forms mixed -> {}', async () => {
const a = docFromTextNotation('[:d :e (a|1 {b c|})]');
const b = docFromTextNotation('[:d :e {a|1 {b c|}}]');
await handlers.rewrapCurly(a, true);
expect(textNotationFromDoc(a)).toEqual(textNotationFromDoc(b));
expect(_.omit(a, defaultDocOmit)).toEqual(_.omit(b, defaultDocOmit));
});
it('Multi-cursor: Handles rewrapping nested forms mixed -> {} 2', async () => {
const a = docFromTextNotation('[|1:d :e (a|2 {b c|})]');
const b = docFromTextNotation('{|1:d :e {a|2 {b c|}}}');
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.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]]);
}
Loading

0 comments on commit 8d83117

Please sign in to comment.