Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paredit Multicursor - Rewrap #2474

Merged
merged 5 commits into from
Mar 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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