Skip to content

Commit 113f590

Browse files
committed
Stop modifying document
1 parent 0d50abc commit 113f590

File tree

5 files changed

+240
-105
lines changed

5 files changed

+240
-105
lines changed

src/actions/GenerateSnippet/GenerateSnippet.ts

Lines changed: 99 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
1-
import { range, zip } from "lodash";
21
import { ensureSingleTarget } from "../../util/targetUtils";
32

43
import { open } from "fs/promises";
54
import { join } from "path";
6-
import { commands, window, workspace } from "vscode";
7-
import { performEditsAndUpdateSelections } from "../../core/updateSelections/updateSelections";
5+
import { commands, Range, window, workspace } from "vscode";
6+
import { Offsets } from "../../processTargets/modifiers/surroundingPair/types";
87
import { Target } from "../../typings/target.types";
98
import { Graph } from "../../typings/Types";
10-
import { performDocumentEdits } from "../../util/performDocumentEdits";
119
import { Action, ActionReturnValue } from "../actions.types";
12-
import Substituter from "./Substituter";
1310
import { constructSnippetBody } from "./constructSnippetBody";
11+
import { editText } from "./editText";
12+
import Substituter from "./Substituter";
1413

1514
interface Variable {
15+
/**
16+
* The start an end offsets of the variable relative to the text of the
17+
* snippet that contains it
18+
*/
19+
offsets: Offsets;
20+
1621
/**
1722
* The default name for the given variable that will appear as the placeholder
1823
* text in the meta snippet
@@ -42,12 +47,13 @@ interface Variable {
4247
*
4348
* 1. Ask user for snippet name if not provided as arg
4449
* 2. Find all cursor selections inside target
45-
* 3. Replace cursor selections in document with random ids that won't be
50+
* 3. Extract text of target
51+
* 4. Replace cursor selections in text with random ids that won't be
4652
* affected by json serialization. After serialization we'll replace these
47-
* id's by snippet placeholders. Note that this modifies the document, so we
48-
* need to reset later.
49-
* 4. Construct a snippet body as a list of strings
50-
* 5. Construct a snippet as a javascript object
53+
* id's by snippet placeholders.
54+
* 4. Construct the user snippet body as a list of strings
55+
* 5. Construct a javascript object that will be json-ified to become the meta
56+
* snippet
5157
* 6. Serialize the javascript object to json
5258
* 7. Perform replacements on the random id's appearing in this json to get the
5359
* text we desire. This modified json output is the meta snippet.
@@ -88,72 +94,100 @@ export default class GenerateSnippet implements Action {
8894
/** The next placeholder index to use for the meta snippet */
8995
let nextPlaceholderIndex = 1;
9096

91-
/**
92-
* The original selections and the editor that will become variables in the
93-
* user snippet
94-
*/
95-
const originalVariableSelections = editor.selections.filter((selection) =>
96-
target.contentRange.contains(selection)
97-
);
98-
99-
/**
100-
* The original text of the selections where the variables were in the
101-
* document to use to restore the document contents when we're done.
102-
*/
103-
const originalVariableTexts = originalVariableSelections.map((selection) =>
104-
editor.document.getText(selection)
105-
);
97+
const baseOffset = editor.document.offsetAt(target.contentRange.start);
10698

10799
/**
108100
* The variables that will appear in the user snippet. Note that
109101
* `placeholderIndex` here is the placeholder index in the meta snippet not
110102
* the user snippet.
111103
*/
112-
const variables: Variable[] = range(originalVariableSelections.length).map(
113-
(index) => ({
104+
const variables: Variable[] = editor.selections
105+
.filter((selection) => target.contentRange.contains(selection))
106+
.map((selection, index) => ({
107+
offsets: {
108+
start: editor.document.offsetAt(selection.start) - baseOffset,
109+
end: editor.document.offsetAt(selection.end) - baseOffset,
110+
},
114111
defaultName: `variable${index + 1}`,
115112
placeholderIndex: nextPlaceholderIndex++,
116-
})
117-
);
113+
}));
118114

119115
/**
120116
* Constructs random ids that can be put into the text that won't be
121117
* modified by json serialization.
122118
*/
123119
const substituter = new Substituter();
124120

125-
const [variableSelections, [targetSelection]] =
126-
await performEditsAndUpdateSelections(
127-
this.graph.rangeUpdater,
128-
editor,
129-
originalVariableSelections.map((selection, index) => ({
130-
editor,
131-
range: selection,
132-
text: substituter.addSubstitution(
133-
[
134-
"\\$${",
135-
variables[index].placeholderIndex,
136-
":",
137-
variables[index].defaultName,
138-
"}",
139-
].join("")
140-
),
141-
})),
142-
[originalVariableSelections, [target.contentSelection]]
143-
);
144-
145-
const snippetLines = constructSnippetBody(editor, targetSelection);
121+
const linePrefix = editor.document.getText(
122+
new Range(
123+
target.contentRange.start.with(undefined, 0),
124+
target.contentRange.start
125+
)
126+
);
146127

147-
await performDocumentEdits(
148-
this.graph.rangeUpdater,
149-
editor,
150-
zip(variableSelections, originalVariableTexts).map(([range, text]) => ({
151-
editor,
152-
range: range!,
153-
text: text!,
128+
/** The text of the snippet, with placeholders inserted for variables */
129+
const snippetBodyText = editText(
130+
editor.document.getText(target.contentRange),
131+
variables.map(({ offsets, defaultName, placeholderIndex }) => ({
132+
offsets,
133+
text: substituter.addSubstitution(
134+
[
135+
// This `\$` will end up being a `$` in the final document. It
136+
// indicates the start of a variable in the user snippet. We need
137+
// the `\` so that the meta-snippet doesn't see it as one of its
138+
// placeholders.
139+
// Note that the reason we use the substituter here is primarily so
140+
// that the `\` here doesn't get escaped upon conversion to json.
141+
"\\$",
142+
143+
// The remaining text here is a placeholder in the meta-snippet
144+
// that the user can use to name their snippet variable that will
145+
// be in the user snippet.
146+
"${",
147+
placeholderIndex,
148+
":",
149+
defaultName,
150+
"}",
151+
].join("")
152+
),
154153
}))
155154
);
156155

156+
const snippetLines = constructSnippetBody(snippetBodyText, linePrefix);
157+
158+
/**
159+
* Constructs a key-value entry for use in the variable description section
160+
* of the user snippet definition. It contains tabstops for use in the
161+
* meta-snippet.
162+
* @param variable The variable
163+
* @returns A [key, value] pair for use in the meta-snippet
164+
*/
165+
const constructVariableDescriptionEntry = ({
166+
placeholderIndex,
167+
}: Variable): [string, string] => {
168+
// The key will have the same placeholder index as the other location
169+
// where this variable appears.
170+
const key = "$" + placeholderIndex;
171+
172+
// The value will end up being an empty object with a tabstop in the
173+
// middle so that the user can add information about the variable, such
174+
// as wrapperScopeType. Ie the output will look like `{|}` (with the `|`
175+
// representing a tabstop in the meta-snippet)
176+
//
177+
// NB: We use the subsituter here, with `isQuoted=true` because in order
178+
// to make this work for the meta-snippet, we want to end up with
179+
// something like `{$3}`, which is not valid json. So we instead arrange
180+
// to end up with json like `"hgidfsivhs"`, and then replace the whole
181+
// string (including quotes) with `{$3}` after json-ification
182+
const value = substituter.addSubstitution(
183+
"{$" + nextPlaceholderIndex++ + "}",
184+
true
185+
);
186+
187+
return [key, value];
188+
};
189+
190+
/** An object that will be json-ified to become the meta-snippet */
157191
const snippet = {
158192
[snippetName]: {
159193
definitions: [
@@ -166,23 +200,22 @@ export default class GenerateSnippet implements Action {
166200
],
167201
description: `$${nextPlaceholderIndex++}`,
168202
variables:
169-
originalVariableSelections.length === 0
203+
variables.length === 0
170204
? undefined
171205
: Object.fromEntries(
172-
range(originalVariableSelections.length).map((index) => [
173-
`$${variables[index].placeholderIndex}`,
174-
substituter.addSubstitution(
175-
`{$${nextPlaceholderIndex++}}`,
176-
true
177-
),
178-
])
206+
variables.map(constructVariableDescriptionEntry)
179207
),
180208
},
181209
};
210+
211+
/**
212+
* This is the text of the meta-snippet in Textmate format that we will
213+
* insert into the new document where the user will fill out their snippet
214+
* definition
215+
*/
182216
const snippetText = substituter.makeSubstitutions(
183217
JSON.stringify(snippet, null, 2)
184218
);
185-
console.debug(snippetText);
186219

187220
const userSnippetsDir = workspace
188221
.getConfiguration("cursorless.experimental")
Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,56 @@
1-
import { range, repeat } from "lodash";
2-
import { Range, TextEditor } from "vscode";
1+
import { repeat } from "lodash";
2+
3+
interface Line {
4+
/**
5+
* The text of the line
6+
*/
7+
text: string;
8+
9+
/**
10+
* The index at which the snippet starts on this line. Will be 0 for all but
11+
* the first line. For the first line it will be set so that the first line
12+
* can be the actual document line containing the start of the snippet, for
13+
* use with indentation, but we set startIndex so that we only use the
14+
* content from where the snippet starts.
15+
*/
16+
startIndex: number;
17+
}
318

419
/**
520
* Converts a range of text in an editor into a snippet body representation as
621
* expected by textmate.
722
*
8-
* Note that if you want tabstops, you must first directly modify the editor to
9-
* contain tabstops and then reset it afterwards. Also note that in order to
10-
* avoid extra escaping of eg slashes, you need to do so using something like
11-
* {@link Substituter}.
12-
*
13-
* NB: We operate on a range here instead of just getting the text for a few reasons:
14-
*
15-
* - We want to be able to see the entire first line so that we know the indentation of the first lines
16-
* - We let vscode normalize line endings
17-
* - We let vscode figure out where line content starts
23+
* Note that if you want tabstops, you must first modify {@link text} to
24+
* contain the tabstops. contain tabstops and then reset it afterwards. Also
25+
* note that in order to avoid extra escaping of eg slashes, you need to do so
26+
* using something like {@link Substituter}.
1827
*
19-
* None of these are insurmountable obstacles if we switched to just operating
20-
* on text, so we should probably do that in the future to avoid needing to
21-
* manipulate the editor before running this function.
22-
*
23-
* @param editor The editor containing {@param snippetRange}
24-
* @param snippetRange The range of text in the editor to convert to a snippet
28+
* @param text The text to use for the snippet body
29+
* @param linePrefix The text on the line that the snippet starts on leading to
30+
* the start of the snippet. This is used for determining indentation
2531
* @returns The body of a snippet represented as a list of lines as expected for
2632
* textmate snippets
2733
*/
2834
export function constructSnippetBody(
29-
editor: TextEditor,
30-
snippetRange: Range
35+
text: string,
36+
linePrefix: string
3137
): string[] {
32-
const snippetLines: string[] = [];
38+
const outputLines: string[] = [];
3339
let currentTabCount = 0;
3440
let currentIndentationString: string | null = null;
3541

36-
const { start, end } = snippetRange;
37-
const startLine = start.line;
38-
const endLine = end.line;
42+
const [firstLine, ...remainingLines] = text.split(/\r?\n/);
43+
const lines: Line[] = [
44+
{
45+
text: linePrefix + firstLine,
46+
startIndex: linePrefix.length,
47+
},
48+
...remainingLines.map((line) => ({ text: line, startIndex: 0 })),
49+
];
3950

40-
range(startLine, endLine + 1).forEach((lineNumber) => {
41-
const line = editor.document.lineAt(lineNumber);
42-
const { text, firstNonWhitespaceCharacterIndex } = line;
43-
const newIndentationString = text.substring(
44-
0,
45-
firstNonWhitespaceCharacterIndex
46-
);
51+
lines.forEach(({ text, startIndex }) => {
52+
const newIndentationString = text.match(/^\s*/)?.[0] ?? "";
53+
const firstNonWhitespaceCharacterIndex = newIndentationString.length;
4754

4855
if (currentIndentationString != null) {
4956
if (newIndentationString.length > currentIndentationString.length) {
@@ -59,16 +66,13 @@ export function constructSnippetBody(
5966

6067
const lineContentStart = Math.max(
6168
firstNonWhitespaceCharacterIndex,
62-
lineNumber === startLine ? start.character : 0
63-
);
64-
const lineContentEnd = Math.min(
65-
text.length,
66-
lineNumber === endLine ? end.character : Infinity
69+
startIndex
6770
);
6871
const snippetIndentationString = repeat("\t", currentTabCount);
69-
const lineContent = text.substring(lineContentStart, lineContentEnd);
70-
snippetLines.push(snippetIndentationString + lineContent);
72+
const lineContent = text.slice(lineContentStart);
73+
74+
outputLines.push(snippetIndentationString + lineContent);
7175
});
7276

73-
return snippetLines;
77+
return outputLines;
7478
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { sortBy } from "lodash";
2+
import { Offsets } from "../../processTargets/modifiers/surroundingPair/types";
3+
4+
/**
5+
* For each edit in {@link edits} replaces the given {@link Edit.offsets} in
6+
* {@link text} with {@link Edit.text}.
7+
*
8+
* @param text The text to edit
9+
* @param edits The edits to perform
10+
* @returns The edited string
11+
*/
12+
export function editText(text: string, edits: Edit[]): string {
13+
const sortedEdits = sortBy(edits, (edit) => edit.offsets.start);
14+
let output = "";
15+
let currentOffset = 0;
16+
17+
for (const edit of sortedEdits) {
18+
output += text.slice(currentOffset, edit.offsets.start) + edit.text;
19+
currentOffset = edit.offsets.end;
20+
}
21+
22+
output += text.slice(currentOffset);
23+
24+
return output;
25+
}
26+
27+
interface Edit {
28+
offsets: Offsets;
29+
text: string;
30+
}

0 commit comments

Comments
 (0)