Skip to content

Commit

Permalink
Merge branch 'gh-1024-paredit-kill-right' of https://github.com/xfthh…
Browse files Browse the repository at this point in the history
…xk/calva into xfthhxk-gh-1024-paredit-kill-right
  • Loading branch information
PEZ committed Oct 20, 2021
2 parents 1faa0e4 + fe0e49a commit 9a7ed8c
Show file tree
Hide file tree
Showing 9 changed files with 410 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Changes to Calva.
## [2.0.218] - 2021-10-18
- [npm audit fixes](https://github.com/BetterThanTomorrow/calva/issues/1346)
- [Select top level form fails for top level derefs in comment forms](https://github.com/BetterThanTomorrow/calva/issues/1345)
- [Add Paredit kill right functionality](https://github.com/BetterThanTomorrow/calva/issues/1024)

## [2.0.217] - 2021-10-17
- [Support setting the cider-nrepl print-fn to whatever](https://github.com/BetterThanTomorrow/calva/issues/1340)
Expand Down
1 change: 1 addition & 0 deletions docs/site/paredit.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Default keybinding | Action | Description
`ctrl+backspace` | **Kill List Backward** | Deletes everything from the cursor to the opening of the current enclosing form.<br> ![](images/paredit/kill-open-list.gif)
`ctrl+alt+shift+delete` | **Splice Killing Forward** | Delete forward to end of the list, then Splice. <br> ![](images/paredit/splice-killing-forward.gif)
`ctrl+alt+shift+backspace` | **Splice Killing Backwards** | Delete backward to the start of the list, then Splice. <br> ![](images/paredit/splice-killing-backward.gif)
`ctrl+k` | **Kill Right** | Delete forward to the end of the current form or the first newline.<br> ![](images/paredit/kill-right.gif)
`ctrl+alt+shift+p` | **Wrap Around ()** | Wraps the current form, or selection, with parens. <br> ![](images/paredit/wrap-around-parens.gif)
`ctrl+alt+shift+s` | **Wrap Around []** | Wraps the current form, or selection, with square brackets. <br> ![](images/paredit/wrap-around-brackets.gif)
`ctrl+alt+shift+c` | **Wrap Around {}** | Wraps the current form, or selection, with curlies. <br> ![](images/paredit/wrap-around-curlies.gif)
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,12 @@
"title": "Convolute Sexp ¯\\_(ツ)_/¯",
"enablement": "editorLangId == clojure"
},
{
"category": "Calva Paredit",
"command": "paredit.killRight",
"title": "Kill/Delete Right",
"enablement": "editorLangId == clojure"
},
{
"category": "Calva Paredit",
"command": "paredit.killSexpForward",
Expand Down Expand Up @@ -1905,6 +1911,11 @@
"key": "ctrl+shift+c",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/"
},
{
"command": "paredit.killRight",
"key": "ctrl+k",
"when": "calva:keybindingsEnabled && editorLangId == clojure && editorTextFocus && paredit:keyMap =~ /original|strict/"
},
{
"command": "paredit.killSexpForward",
"key": "ctrl+shift+delete",
Expand Down
2 changes: 1 addition & 1 deletion src/cursor-doc/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export type ModelEditOptions = {
};

export interface EditableModel {
readonly lineEndingLength: number,
readonly lineEndingLength: number;

/**
* Performs a model edit batch.
Expand Down
80 changes: 79 additions & 1 deletion src/cursor-doc/paredit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { includes } from "lodash";
import { validPair } from "./clojure-lexer";
import { ModelEdit, EditableDocument, ModelEditOptions, ModelEditSelection } from "./model";
import { ModelEdit, EditableDocument, ModelEditSelection } from "./model";
import { LispTokenCursor } from "./token-cursor";

// NB: doc.model.edit returns a Thenable, so that the vscode Editor can compose commands.
Expand Down Expand Up @@ -139,6 +139,84 @@ export function backwardListRange(doc: EditableDocument, start: number = doc.sel
return [cursor.offsetStart, start];
}


/**
* Aims to find the end of the current form (list|vector|map|set|string etc)
* When there is a newline before the end of the current form either:
* - Return the end of the nearest form to the right of the cursor location if one exists
* - Returns the newline's offset if no form exists
*
* This function's output range is needed to implement features similar to paredit's
* killRight or smartparens' sp-kill-hybrid-sexp.
*
* @param doc
* @param offset
* @param goPastWhitespace
* @returns [number, number]
*/
export function forwardHybridSexpRange(doc: EditableDocument, offset = Math.max(doc.selection.anchor, doc.selection.active), goPastWhitespace = false): [number, number] {
let cursor = doc.getTokenCursor(offset);
if (cursor.getToken().type === 'open') {
return forwardSexpRange(doc);
} else if (cursor.getToken().type === 'close') {
return [offset, offset];
}

const currentLineText = doc.model.getLineText(cursor.line);
const lineStart = doc.model.getOffsetForLine(cursor.line);
const currentLineNewlineOffset = lineStart + currentLineText.length;
const remainderLineText = doc.model.getText(offset, currentLineNewlineOffset + 1);

cursor.forwardList(); // move to the end of the current form
const currentFormEndToken = cursor.getToken();
// when we've advanced the cursor but start is behind us then go to the end
// happens when in a clojure comment i.e: ;; ----
let cursorOffsetEnd = cursor.offsetStart <= offset ? cursor.offsetEnd : cursor.offsetStart;
const text = doc.model.getText(offset, cursorOffsetEnd);
let hasNewline = text.indexOf("\n") > -1;
let end = cursorOffsetEnd;

// Want the min of closing token or newline
// After moving forward, the cursor is not yet at the end of the current line,
// and it is not a close token. So we include the newline
// because what forms are here extend beyond the end of the current line
if (currentLineNewlineOffset > cursor.offsetEnd && currentFormEndToken.type != 'close') {
hasNewline = true;
end = currentLineNewlineOffset;
}

if (remainderLineText === '' || remainderLineText === '\n') {
end = currentLineNewlineOffset + doc.model.lineEndingLength;
} else if (hasNewline) {
// Try to find the first open token to the right of the document's cursor location if any
let nearestOpenTokenOffset = -1;

// Start at the newline.
// Work backwards to find the smallest open token offset
// greater than the document's cursor location if any
cursor = doc.getTokenCursor(currentLineNewlineOffset);
while(cursor.offsetStart > offset) {
while(cursor.backwardSexp()) {}
if (cursor.offsetStart > offset) {
nearestOpenTokenOffset = cursor.offsetStart;
cursor = doc.getTokenCursor(cursor.offsetStart - 1);
}
}

if (nearestOpenTokenOffset > 0) {
cursor = doc.getTokenCursor(nearestOpenTokenOffset);
cursor.forwardList();
end = cursor.offsetEnd; // include the closing token
} else {
// no open tokens found so the end is the newline
end = currentLineNewlineOffset;
}
}
return [offset, end];
}



export function rangeToForwardUpList(doc: EditableDocument, offset: number = Math.max(doc.selection.anchor, doc.selection.active), goPastWhitespace = false): [number, number] {
const cursor = doc.getTokenCursor(offset);
cursor.forwardList();
Expand Down
2 changes: 0 additions & 2 deletions src/doc-mirror/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ export class DocumentModel implements EditableModel {
this.lineInputModel = new LineInputModel(this.lineEndingLength);
}



edit(modelEdits: ModelEdit[], options: ModelEditOptions): Thenable<boolean> {
const editor = vscode.window.activeTextEditor,
undoStopBefore = !!options.undoStopBefore;
Expand Down
216 changes: 215 additions & 1 deletion src/extension-test/unit/cursor-doc/paredit-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,220 @@ describe('paredit', () => {
});
})

describe('forwardHybridSexpRange', () => {
it('Finds end of string', () => {
const a = docFromTextNotation('"This |needs to find the end of the string."');
const b = docFromTextNotation('"This |needs to find the end of the string.|"');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds newline in multi line string', () => {
const a = docFromTextNotation('"This |needs to find the end\n of the string."');
const b = docFromTextNotation('"This |needs to find the end|\n of the string."');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds newline in multi line string (Windows)', () => {
const a = docFromTextNotation('"This |needs to find the end\r\n of the string."');
const b = docFromTextNotation('"This |needs to find the end|\r\n of the string."');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of comment', () => {
const a = docFromTextNotation('(a |;; foo\n e)');
const b = docFromTextNotation('(a |;; foo|\n e)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of comment (Windows)', () => {
const a = docFromTextNotation('(a |;; foo\r\n e)');
const b = docFromTextNotation('(a |;; foo|\r\n e)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Maintains balanced delimiters 1', () => {
const a = docFromTextNotation('(a| b (c\n d) e)');
const b = docFromTextNotation('(a| b (c\n d)| e)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Maintains balanced delimiters 1 (Windows)', () => {
const a = docFromTextNotation('(a| b (c\r\n d) e)');
const b = docFromTextNotation('(a| b (c\r\n d)| e)');
const [start,end] = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
// off by 1 because \r\n is treated as 1 char?
expect(actual).toEqual([start, end - 1]);
});

it('Maintains balanced delimiters 2', () => {
const a = docFromTextNotation('(aa| (c (e\nf)) g)');
const b = docFromTextNotation('(aa| (c (e\nf))|g)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Maintains balanced delimiters 2 (Windows)', () => {
const a = docFromTextNotation('(aa| (c (e\r\nf)) g)');
const b = docFromTextNotation('(aa| (c (e\r\nf))|g)');
const [start,end] = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
// off by 1 because \r\n is treated as 1 char?
expect(actual).toEqual([start, end - 1]);
});

it('Maintains balanced delimiters 3', () => {
const a = docFromTextNotation('(aa| ( c (e\nf)) g)');
const b = docFromTextNotation('(aa| ( c (e\nf))|g)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Advances past newline when invoked on newline', () => {
const a = docFromTextNotation('(a|\n e) g)');
const b = docFromTextNotation('(a|\n| e)');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of vectors', () => {
const a = docFromTextNotation('[a [b |c d e f] g h]');
const b = docFromTextNotation('[a [b |c d e f|] g h]');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of lists', () => {
const a = docFromTextNotation('(foo |bar)\n');
const b = docFromTextNotation('(foo |bar|)\n');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})


it('Finds end of maps', () => {
const a = docFromTextNotation('{:a 1 |:b 2 :c 3}');
const b = docFromTextNotation('{:a 1 |:b 2 :c 3|}');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of line in multiline maps', () => {
const a = docFromTextNotation('{:a 1 |:b 2\n:c 3}');
const b = docFromTextNotation('{:a 1 |:b 2|:c 3}');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of expr in multiline maps', () => {
const a = docFromTextNotation('{:a 1 |:b (+\n 0\n 2\n) :c 3}');
const b = docFromTextNotation('{:a 1 |:b (+\n 0\n 2\n)| :c 3}');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of line in bindings', () => {
const a = docFromTextNotation('(let [|a (+ 1 2)\n b (+ 2 3)] (+ a b))');
const b = docFromTextNotation('(let [|a (+ 1 2)|\n b (+ 2 3)] (+ a b))');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds end of expr in multiline bindings', () => {
const a = docFromTextNotation('(let [|a (+\n 1 \n 2)\n b (+ 2 3)] (+ a b))');
const b = docFromTextNotation('(let [|a (+\n 1 \n 2)|\n b (+ 2 3)] (+ a b))');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
});

it('Finds range in line of tokens', () => {
const a = docFromTextNotation(' | 2 "hello" :hello/world\nbye');
const b = docFromTextNotation(' | 2 "hello" :hello/world|\nbye');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Finds range in token with form over multiple lines', () => {
const a = docFromTextNotation(' | 2 [\n 1 \n]');
const b = docFromTextNotation(' | 2 [\n 1 \n]|');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Deals with comments start of line', () => {
const a = docFromTextNotation('|;; hi\n');
const b = docFromTextNotation('|;; hi|\n');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Deals with comments middle of line', () => {
const a = docFromTextNotation(';; |hi\n');
const b = docFromTextNotation(';; |hi|\n');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Deals with empty lines', () => {
const a = docFromTextNotation('|\n');
const b = docFromTextNotation('|\n|');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Deals with comments with empty line', () => {
const a = docFromTextNotation(';; |\n');
const b = docFromTextNotation(';; |\n|');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Does not advance when on closing token type ', () => {
const a = docFromTextNotation('(a e|)\n');
const b = docFromTextNotation('(a e||)\n');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})

it('Handles Heisenbug', () => {
// a bug that showed up in @PEZ's testing occassionaly
const a = docFromTextNotation('#_|[a b (c d\n e\n f) g]\n:a');
const b = docFromTextNotation('#_|[a b (c d\n e\n f) g]|\n:a');
const expected = textAndSelection(b)[1];
const actual = paredit.forwardHybridSexpRange(a);
expect(actual).toEqual(expected);
})
})

describe('moveToRangeRight', () => {
it('Places cursor at the right end of the selection', () => {
const a = docFromTextNotation('(def |>|foo|>| [vec])');
Expand Down Expand Up @@ -844,6 +1058,6 @@ describe('paredit', () => {
paredit.addRichComment(a);
expect(textAndSelection(a)).toEqual(textAndSelection(b));
});
})
})
});
});
10 changes: 10 additions & 0 deletions src/paredit/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ const pareditCommands: PareditCommand[] = [
command: 'paredit.convolute',
handler: paredit.convolute
},
{
command: 'paredit.killRight',
handler: (doc: EditableDocument) => {
const range = paredit.forwardHybridSexpRange(doc);
if (shouldKillAlsoCutToClipboard()) {
copyRangeToClipboard(doc, range);
}
paredit.killRange(doc, range);
}
},
{
command: 'paredit.killSexpForward',
handler: (doc: EditableDocument) => {
Expand Down
Loading

0 comments on commit 9a7ed8c

Please sign in to comment.