-
-
Notifications
You must be signed in to change notification settings - Fork 94
Expand file tree
/
Copy pathGenerateSnippet.ts
More file actions
252 lines (222 loc) · 8.08 KB
/
Copy pathGenerateSnippet.ts
File metadata and controls
252 lines (222 loc) · 8.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import type {
EditableTextEditor,
IDE,
TextEditor,
} from "@cursorless/lib-common";
import { FlashStyle, Range, matchAll, Selection } from "@cursorless/lib-common";
import type {
Snippet,
SnippetFile,
SnippetHeader,
SnippetVariable,
} from "@cursorless/talon-tools";
import {
parseSnippetFile,
serializeSnippetFile,
} from "@cursorless/talon-tools";
import type { Snippets } from "../../core/Snippets";
import type { Target } from "../../typings/target.types";
import { ensureSingleTarget, flashTargets } from "../../util/targetUtils";
import type { ActionReturnValue } from "../actions.types";
import { constructSnippetBody } from "./constructSnippetBody";
import { editText } from "./editText";
import type { Offsets } from "./Offsets";
/**
* This action can be used to automatically create a snippet from a target. Any
* cursor selections inside the target will become placeholders in the final
* snippet. This action creates a new file, and inserts a snippet that the user
* can fill out to construct their desired snippet.
*
* Note that there are two snippets involved in this implementation:
*
* - The snippet that the user is trying to create. We refer to this snippet as
* the user snippet.
* - The snippet that we insert that the user can use to build their snippet. We
* refer to this as the meta snippet.
*
* We proceed as follows:
*
* 1. Ask user for snippet name if not provided as arg
* 2. Find all cursor selections inside target - these will become the user
* snippet variables
* 3. Extract text of target
* 4. Replace cursor selections in text with snippet variables
* 4. Construct the user snippet body as a list of strings
* 5. Construct a javascript object that will be serialized to become the meta
* snippet
* 6. Serialize the javascript object
* 7. Escape dollar signs and replace placeholder text with snippet placeholders.
* This modified json output is the meta snippet.
* 8. Open a new document in the snippets dir to hold the new snippet.
* 9. Insert the meta snippet so that the user can construct their snippet.
*/
export class GenerateSnippet {
constructor(
private ide: IDE,
private snippets: Snippets,
) {
this.run = this.run.bind(this);
}
async run(
targets: Target[],
directory: string,
snippetName?: string,
): Promise<ActionReturnValue> {
if (directory == null) {
throw new Error(
"Directory argument is required for GenerateSnippet action. Please update Cursorless Talon",
);
}
const target = ensureSingleTarget(targets);
const editor = target.editor;
// NB: We don't await the pending edit decoration so that if the user
// immediately starts saying the name of the snippet (eg command chain
// "snip make funk camel my function"), we're more likely to
// win the race and have the input box ready for them
void flashTargets(this.ide, targets, FlashStyle.referenced);
let resolvedSnippetName = snippetName;
if (resolvedSnippetName == null) {
resolvedSnippetName = await this.ide.showInputBox({
prompt: "Name of snippet",
placeHolder: "helloWorld",
});
// User cancelled; do nothing
if (!resolvedSnippetName) {
return {};
}
}
const baseOffset = editor.document.offsetAt(target.contentRange.start);
/**
* The variables that will appear in the user snippet.
*/
const selections = getsSnippetSelections(editor, target.contentRange);
const variables = selections.map(
(selection, index): Variable => ({
offsets: {
start: editor.document.offsetAt(selection.start) - baseOffset,
end: editor.document.offsetAt(selection.end) - baseOffset,
},
name: index === selections.length - 1 ? "0" : `${index + 1}`,
}),
);
/**
* Text before the start of the snippet in the snippet start line. We need
* to pass this to {@link constructSnippetBody} so that it knows the
* baseline indentation of the snippet
*/
const linePrefix = editor.document.getText(
new Range(
target.contentRange.start.with(undefined, 0),
target.contentRange.start,
),
);
const originalText = editor.document.getText(target.contentRange);
const snippetBodyText = editText(originalText, [
...matchAll(originalText, /\$|\\/gu, (match) => ({
offsets: {
start: match.index!,
end: match.index! + match[0].length,
},
text: `\\${match[0]}`,
})),
...variables.map(({ offsets, name }) => ({
offsets,
text: `$${name}`,
})),
]);
const snippetLines = constructSnippetBody(snippetBodyText, linePrefix);
let editableEditor: EditableTextEditor;
let snippetFile: SnippetFile = { snippets: [] };
if (this.ide.runMode === "test") {
// If we're testing, we just overwrite the current document
editableEditor = this.ide.getEditableTextEditor(editor);
} else {
// Otherwise, we create and open a new document for the snippet
editableEditor = this.ide.getEditableTextEditor(
await this.snippets.openNewSnippetFile(resolvedSnippetName, directory),
);
snippetFile = parseSnippetFile(editableEditor.document.getText());
}
await editableEditor.setSelections([
Selection.fromRange(editableEditor.document.range),
]);
/** The next placeholder index to use for the meta snippet */
let currentPlaceholderIndex = 1;
const { header } = snippetFile;
const phrases =
snippetFile.header?.phrases != null
? undefined
: [`${PLACEHOLDER}${currentPlaceholderIndex++}`];
const createVariable = (variable: Variable): SnippetVariable => {
const hasPhrase = header?.variables?.some(
(v) => v.name === variable.name && v.wrapperPhrases != null,
);
return {
name: variable.name,
wrapperPhrases: hasPhrase
? undefined
: [`${PLACEHOLDER}${currentPlaceholderIndex++}`],
};
};
const snippet: Snippet = {
name:
header?.name === resolvedSnippetName ? undefined : resolvedSnippetName,
phrases,
languages: getSnippetLanguages(editor, header),
body: snippetLines,
variables: variables.map(createVariable),
};
snippetFile.snippets.push(snippet);
/**
* This is the text of the meta-snippet in Textmate format that we will
* insert into the new document where the user will fill out their snippet
* definition
*/
const metaSnippetText = serializeSnippetFile(snippetFile)
// Escape dollar signs in the snippet text so that they don't get used as
// placeholders in the meta snippet
.replaceAll("$", String.raw`\$`)
// Replace constant with dollar sign for meta snippet placeholders
.replaceAll(PLACEHOLDER, "$");
// Insert the meta-snippet
await editableEditor.insertSnippet(metaSnippetText);
return {
thatSelections: targets.map(({ editor, contentSelection }) => ({
editor,
selection: contentSelection,
})),
};
}
}
function getSnippetLanguages(
editor: TextEditor,
header: SnippetHeader | undefined,
): string[] | undefined {
if (header?.languages?.includes(editor.document.languageId)) {
return undefined;
}
return [editor.document.languageId];
}
function getsSnippetSelections(editor: TextEditor, range: Range): Selection[] {
const selections = editor.selections.filter((selection) =>
range.contains(selection),
);
selections.sort((a, b) => a.start.compareTo(b.start));
return selections;
}
// Used to temporarily escape the $1, $2 snippet holes (the "meta snippet" holes
// that become live snippets when the user edits) so we can use traditional
// backslash escaping for the holes in the underlying snippet itself (the "user
// snippet" holes that will be saved as part of their new template).
const PLACEHOLDER = "PLACEHOLDER_VFA77zcbLD6wXNmfMAay";
interface Variable {
/**
* The start an end offsets of the variable relative to the text of the
* snippet that contains it
*/
offsets: Offsets;
/**
* The name for the variable
*/
name: string;
}