Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/snippet/browser/snippetSession.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { groupBy } from '../../../../base/common/arrays.js';
7
import { CharCode } from '../../../../base/common/charCode.js';
8
import { dispose } from '../../../../base/common/lifecycle.js';
9
import { getLeadingWhitespace } from '../../../../base/common/strings.js';
10
import './snippetSession.css';
11
import { IActiveCodeEditor } from '../../../browser/editorBrowser.js';
12
import { EditorOption } from '../../../common/config/editorOptions.js';
13
import { EditOperation, ISingleEditOperation } from '../../../common/core/editOperation.js';
14
import { IPosition } from '../../../common/core/position.js';
15
import { Range } from '../../../common/core/range.js';
16
import { Selection } from '../../../common/core/selection.js';
17
import { TextChange } from '../../../common/core/textChange.js';
18
import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js';
19
import { IIdentifiedSingleEditOperation, ITextModel, TrackedRangeStickiness } from '../../../common/model.js';
20
import { ModelDecorationOptions } from '../../../common/model/textModel.js';
21
import { OvertypingCapturer } from '../../suggest/browser/suggestOvertypingCapturer.js';
22
import { ILabelService } from '../../../../platform/label/common/label.js';
23
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
24
import { Choice, Marker, Placeholder, SnippetParser, Text, TextmateSnippet } from './snippetParser.js';
25
import { ClipboardBasedVariableResolver, CommentBasedVariableResolver, CompositeSnippetVariableResolver, ModelBasedVariableResolver, RandomBasedVariableResolver, SelectionBasedVariableResolver, TimeBasedVariableResolver, WorkspaceBasedVariableResolver } from './snippetVariables.js';
26
import { EditSources, TextModelEditSource } from '../../../common/textModelEditSource.js';
27
28
export class OneSnippet {
29
30
private _placeholderDecorations?: Map<Placeholder, string>;
31
private _placeholderGroups: Placeholder[][];
32
private _offset: number = -1;
33
_placeholderGroupsIdx: number;
34
_nestingLevel: number = 1;
35
36
private static readonly _decor = {
37
active: ModelDecorationOptions.register({ description: 'snippet-placeholder-1', stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges, className: 'snippet-placeholder' }),
38
inactive: ModelDecorationOptions.register({ description: 'snippet-placeholder-2', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'snippet-placeholder' }),
39
activeFinal: ModelDecorationOptions.register({ description: 'snippet-placeholder-3', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'finish-snippet-placeholder' }),
40
inactiveFinal: ModelDecorationOptions.register({ description: 'snippet-placeholder-4', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'finish-snippet-placeholder' }),
41
};
42
43
constructor(
44
private readonly _editor: IActiveCodeEditor,
45
private readonly _snippet: TextmateSnippet,
46
private readonly _snippetLineLeadingWhitespace: string
47
) {
48
this._placeholderGroups = groupBy(_snippet.placeholders, Placeholder.compareByIndex);
49
this._placeholderGroupsIdx = -1;
50
}
51
52
initialize(textChange: TextChange): void {
53
this._offset = textChange.newPosition;
54
}
55
56
dispose(): void {
57
if (this._placeholderDecorations) {
58
this._editor.removeDecorations([...this._placeholderDecorations.values()]);
59
}
60
this._placeholderGroups.length = 0;
61
}
62
63
private _initDecorations(): void {
64
65
if (this._offset === -1) {
66
throw new Error(`Snippet not initialized!`);
67
}
68
69
if (this._placeholderDecorations) {
70
// already initialized
71
return;
72
}
73
74
this._placeholderDecorations = new Map<Placeholder, string>();
75
const model = this._editor.getModel();
76
77
this._editor.changeDecorations(accessor => {
78
// create a decoration for each placeholder
79
for (const placeholder of this._snippet.placeholders) {
80
const placeholderOffset = this._snippet.offset(placeholder);
81
const placeholderLen = this._snippet.fullLen(placeholder);
82
const range = Range.fromPositions(
83
model.getPositionAt(this._offset + placeholderOffset),
84
model.getPositionAt(this._offset + placeholderOffset + placeholderLen)
85
);
86
const options = placeholder.isFinalTabstop ? OneSnippet._decor.inactiveFinal : OneSnippet._decor.inactive;
87
const handle = accessor.addDecoration(range, options);
88
this._placeholderDecorations!.set(placeholder, handle);
89
}
90
});
91
}
92
93
move(fwd: boolean | undefined): Selection[] {
94
if (!this._editor.hasModel()) {
95
return [];
96
}
97
98
this._initDecorations();
99
100
// Transform placeholder text if necessary
101
if (this._placeholderGroupsIdx >= 0) {
102
const operations: ISingleEditOperation[] = [];
103
104
for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
105
// Check if the placeholder has a transformation
106
if (placeholder.transform) {
107
const id = this._placeholderDecorations!.get(placeholder)!;
108
const range = this._editor.getModel().getDecorationRange(id)!;
109
const currentValue = this._editor.getModel().getValueInRange(range);
110
const transformedValueLines = placeholder.transform.resolve(currentValue).split(/\r\n|\r|\n/);
111
// fix indentation for transformed lines
112
for (let i = 1; i < transformedValueLines.length; i++) {
113
transformedValueLines[i] = this._editor.getModel().normalizeIndentation(this._snippetLineLeadingWhitespace + transformedValueLines[i]);
114
}
115
operations.push(EditOperation.replace(range, transformedValueLines.join(this._editor.getModel().getEOL())));
116
}
117
}
118
if (operations.length > 0) {
119
this._editor.executeEdits('snippet.placeholderTransform', operations);
120
}
121
}
122
123
let couldSkipThisPlaceholder = false;
124
if (fwd === true && this._placeholderGroupsIdx < this._placeholderGroups.length - 1) {
125
this._placeholderGroupsIdx += 1;
126
couldSkipThisPlaceholder = true;
127
128
} else if (fwd === false && this._placeholderGroupsIdx > 0) {
129
this._placeholderGroupsIdx -= 1;
130
couldSkipThisPlaceholder = true;
131
132
} else {
133
// the selection of the current placeholder might
134
// not acurate any more -> simply restore it
135
}
136
137
const newSelections = this._editor.getModel().changeDecorations(accessor => {
138
139
const activePlaceholders = new Set<Placeholder>();
140
141
// change stickiness to always grow when typing at its edges
142
// because these decorations represent the currently active
143
// tabstop.
144
// Special case #1: reaching the final tabstop
145
// Special case #2: placeholders enclosing active placeholders
146
const selections: Selection[] = [];
147
for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
148
const id = this._placeholderDecorations!.get(placeholder)!;
149
const range = this._editor.getModel().getDecorationRange(id)!;
150
selections.push(new Selection(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn));
151
152
// consider to skip this placeholder index when the decoration
153
// range is empty but when the placeholder wasn't. that's a strong
154
// hint that the placeholder has been deleted. (all placeholder must match this)
155
couldSkipThisPlaceholder = couldSkipThisPlaceholder && this._hasPlaceholderBeenCollapsed(placeholder);
156
157
accessor.changeDecorationOptions(id, placeholder.isFinalTabstop ? OneSnippet._decor.activeFinal : OneSnippet._decor.active);
158
activePlaceholders.add(placeholder);
159
160
for (const enclosingPlaceholder of this._snippet.enclosingPlaceholders(placeholder)) {
161
const id = this._placeholderDecorations!.get(enclosingPlaceholder)!;
162
accessor.changeDecorationOptions(id, enclosingPlaceholder.isFinalTabstop ? OneSnippet._decor.activeFinal : OneSnippet._decor.active);
163
activePlaceholders.add(enclosingPlaceholder);
164
}
165
}
166
167
// change stickness to never grow when typing at its edges
168
// so that in-active tabstops never grow
169
for (const [placeholder, id] of this._placeholderDecorations!) {
170
if (!activePlaceholders.has(placeholder)) {
171
accessor.changeDecorationOptions(id, placeholder.isFinalTabstop ? OneSnippet._decor.inactiveFinal : OneSnippet._decor.inactive);
172
}
173
}
174
175
return selections;
176
});
177
178
return !couldSkipThisPlaceholder ? newSelections ?? [] : this.move(fwd);
179
}
180
181
private _hasPlaceholderBeenCollapsed(placeholder: Placeholder): boolean {
182
// A placeholder is empty when it wasn't empty when authored but
183
// when its tracking decoration is empty. This also applies to all
184
// potential parent placeholders
185
let marker: Marker | undefined = placeholder;
186
while (marker) {
187
if (marker instanceof Placeholder) {
188
const id = this._placeholderDecorations!.get(marker)!;
189
const range = this._editor.getModel().getDecorationRange(id)!;
190
if (range.isEmpty() && marker.toString().length > 0) {
191
return true;
192
}
193
}
194
marker = marker.parent;
195
}
196
return false;
197
}
198
199
get isAtFirstPlaceholder() {
200
return this._placeholderGroupsIdx <= 0 || this._placeholderGroups.length === 0;
201
}
202
203
get isAtLastPlaceholder() {
204
return this._placeholderGroupsIdx === this._placeholderGroups.length - 1;
205
}
206
207
get hasPlaceholder() {
208
return this._snippet.placeholders.length > 0;
209
}
210
211
/**
212
* A snippet is trivial when it has no placeholder or only a final placeholder at
213
* its very end
214
*/
215
get isTrivialSnippet(): boolean {
216
if (this._snippet.placeholders.length === 0) {
217
return true;
218
}
219
if (this._snippet.placeholders.length === 1) {
220
const [placeholder] = this._snippet.placeholders;
221
if (placeholder.isFinalTabstop) {
222
if (this._snippet.rightMostDescendant === placeholder) {
223
return true;
224
}
225
}
226
}
227
return false;
228
}
229
230
computePossibleSelections() {
231
const result = new Map<number, Range[]>();
232
for (const placeholdersWithEqualIndex of this._placeholderGroups) {
233
let ranges: Range[] | undefined;
234
235
for (const placeholder of placeholdersWithEqualIndex) {
236
if (placeholder.isFinalTabstop) {
237
// ignore those
238
break;
239
}
240
241
if (!ranges) {
242
ranges = [];
243
result.set(placeholder.index, ranges);
244
}
245
246
const id = this._placeholderDecorations!.get(placeholder)!;
247
const range = this._editor.getModel().getDecorationRange(id);
248
if (!range) {
249
// one of the placeholder lost its decoration and
250
// therefore we bail out and pretend the placeholder
251
// (with its mirrors) doesn't exist anymore.
252
result.delete(placeholder.index);
253
break;
254
}
255
256
ranges.push(range);
257
}
258
}
259
return result;
260
}
261
262
get activeChoice(): { choice: Choice; range: Range } | undefined {
263
if (!this._placeholderDecorations) {
264
return undefined;
265
}
266
const placeholder = this._placeholderGroups[this._placeholderGroupsIdx][0];
267
if (!placeholder?.choice) {
268
return undefined;
269
}
270
const id = this._placeholderDecorations.get(placeholder);
271
if (!id) {
272
return undefined;
273
}
274
const range = this._editor.getModel().getDecorationRange(id);
275
if (!range) {
276
return undefined;
277
}
278
return { range, choice: placeholder.choice };
279
}
280
281
get hasChoice(): boolean {
282
let result = false;
283
this._snippet.walk(marker => {
284
result = marker instanceof Choice;
285
return !result;
286
});
287
return result;
288
}
289
290
merge(others: OneSnippet[]): void {
291
292
const model = this._editor.getModel();
293
this._nestingLevel *= 10;
294
295
this._editor.changeDecorations(accessor => {
296
297
// For each active placeholder take one snippet and merge it
298
// in that the placeholder (can be many for `$1foo$1foo`). Because
299
// everything is sorted by editor selection we can simply remove
300
// elements from the beginning of the array
301
for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
302
const nested = others.shift()!;
303
console.assert(nested._offset !== -1);
304
console.assert(!nested._placeholderDecorations);
305
306
// Massage placeholder-indicies of the nested snippet to be
307
// sorted right after the insertion point. This ensures we move
308
// through the placeholders in the correct order
309
const indexLastPlaceholder = nested._snippet.placeholderInfo.last!.index;
310
311
for (const nestedPlaceholder of nested._snippet.placeholderInfo.all) {
312
if (nestedPlaceholder.isFinalTabstop) {
313
nestedPlaceholder.index = placeholder.index + ((indexLastPlaceholder + 1) / this._nestingLevel);
314
} else {
315
nestedPlaceholder.index = placeholder.index + (nestedPlaceholder.index / this._nestingLevel);
316
}
317
}
318
this._snippet.replace(placeholder, nested._snippet.children);
319
320
// Remove the placeholder at which position are inserting
321
// the snippet and also remove its decoration.
322
const id = this._placeholderDecorations!.get(placeholder)!;
323
accessor.removeDecoration(id);
324
this._placeholderDecorations!.delete(placeholder);
325
326
// For each *new* placeholder we create decoration to monitor
327
// how and if it grows/shrinks.
328
for (const placeholder of nested._snippet.placeholders) {
329
const placeholderOffset = nested._snippet.offset(placeholder);
330
const placeholderLen = nested._snippet.fullLen(placeholder);
331
const range = Range.fromPositions(
332
model.getPositionAt(nested._offset + placeholderOffset),
333
model.getPositionAt(nested._offset + placeholderOffset + placeholderLen)
334
);
335
const handle = accessor.addDecoration(range, OneSnippet._decor.inactive);
336
this._placeholderDecorations!.set(placeholder, handle);
337
}
338
}
339
340
// Last, re-create the placeholder groups by sorting placeholders by their index.
341
this._placeholderGroups = groupBy(this._snippet.placeholders, Placeholder.compareByIndex);
342
});
343
}
344
345
getEnclosingRange(): Range | undefined {
346
let result: Range | undefined;
347
const model = this._editor.getModel();
348
for (const decorationId of this._placeholderDecorations!.values()) {
349
const placeholderRange = model.getDecorationRange(decorationId) ?? undefined;
350
if (!result) {
351
result = placeholderRange;
352
} else {
353
result = result.plusRange(placeholderRange!);
354
}
355
}
356
return result;
357
}
358
}
359
360
export interface ISnippetSessionInsertOptions {
361
overwriteBefore: number;
362
overwriteAfter: number;
363
adjustWhitespace: boolean;
364
clipboardText: string | undefined;
365
overtypingCapturer: OvertypingCapturer | undefined;
366
}
367
368
const _defaultOptions: ISnippetSessionInsertOptions = {
369
overwriteBefore: 0,
370
overwriteAfter: 0,
371
adjustWhitespace: true,
372
clipboardText: undefined,
373
overtypingCapturer: undefined
374
};
375
376
export interface ISnippetEdit {
377
range: Range;
378
template: string;
379
keepWhitespace?: boolean;
380
}
381
382
export class SnippetSession {
383
384
static adjustWhitespace(model: ITextModel, position: IPosition, adjustIndentation: boolean, snippet: TextmateSnippet, filter?: Set<Marker>): string {
385
const line = model.getLineContent(position.lineNumber);
386
const lineLeadingWhitespace = getLeadingWhitespace(line, 0, position.column - 1);
387
388
// the snippet as inserted
389
let snippetTextString: string | undefined;
390
391
snippet.walk(marker => {
392
// all text elements that are not inside choice
393
if (!(marker instanceof Text) || marker.parent instanceof Choice) {
394
return true;
395
}
396
397
// check with filter (iff provided)
398
if (filter && !filter.has(marker)) {
399
return true;
400
}
401
402
const lines = marker.value.split(/\r\n|\r|\n/);
403
404
if (adjustIndentation) {
405
// adjust indentation of snippet test
406
// -the snippet-start doesn't get extra-indented (lineLeadingWhitespace), only normalized
407
// -all N+1 lines get extra-indented and normalized
408
// -the text start get extra-indented and normalized when following a linebreak
409
const offset = snippet.offset(marker);
410
if (offset === 0) {
411
// snippet start
412
lines[0] = model.normalizeIndentation(lines[0]);
413
414
} else {
415
// check if text start is after a linebreak
416
snippetTextString = snippetTextString ?? snippet.toString();
417
const prevChar = snippetTextString.charCodeAt(offset - 1);
418
if (prevChar === CharCode.LineFeed || prevChar === CharCode.CarriageReturn) {
419
lines[0] = model.normalizeIndentation(lineLeadingWhitespace + lines[0]);
420
}
421
}
422
for (let i = 1; i < lines.length; i++) {
423
lines[i] = model.normalizeIndentation(lineLeadingWhitespace + lines[i]);
424
}
425
}
426
427
const newValue = lines.join(model.getEOL());
428
if (newValue !== marker.value) {
429
marker.parent.replace(marker, [new Text(newValue)]);
430
snippetTextString = undefined;
431
}
432
return true;
433
});
434
435
return lineLeadingWhitespace;
436
}
437
438
static adjustSelection(model: ITextModel, selection: Selection, overwriteBefore: number, overwriteAfter: number): Selection {
439
if (overwriteBefore !== 0 || overwriteAfter !== 0) {
440
// overwrite[Before|After] is compute using the position, not the whole
441
// selection. therefore we adjust the selection around that position
442
const { positionLineNumber, positionColumn } = selection;
443
const positionColumnBefore = positionColumn - overwriteBefore;
444
const positionColumnAfter = positionColumn + overwriteAfter;
445
446
const range = model.validateRange({
447
startLineNumber: positionLineNumber,
448
startColumn: positionColumnBefore,
449
endLineNumber: positionLineNumber,
450
endColumn: positionColumnAfter
451
});
452
453
selection = Selection.createWithDirection(
454
range.startLineNumber, range.startColumn,
455
range.endLineNumber, range.endColumn,
456
selection.getDirection()
457
);
458
}
459
return selection;
460
}
461
462
static createEditsAndSnippetsFromSelections(editor: IActiveCodeEditor, template: string, overwriteBefore: number, overwriteAfter: number, enforceFinalTabstop: boolean, adjustWhitespace: boolean, clipboardText: string | undefined, overtypingCapturer: OvertypingCapturer | undefined, languageConfigurationService: ILanguageConfigurationService): { edits: IIdentifiedSingleEditOperation[]; snippets: OneSnippet[] } {
463
const edits: IIdentifiedSingleEditOperation[] = [];
464
const snippets: OneSnippet[] = [];
465
466
if (!editor.hasModel()) {
467
return { edits, snippets };
468
}
469
const model = editor.getModel();
470
471
const workspaceService = editor.invokeWithinContext(accessor => accessor.get(IWorkspaceContextService));
472
const modelBasedVariableResolver = editor.invokeWithinContext(accessor => new ModelBasedVariableResolver(accessor.get(ILabelService), model));
473
const readClipboardText = () => clipboardText;
474
475
// know what text the overwrite[Before|After] extensions
476
// of the primary cursor have selected because only when
477
// secondary selections extend to the same text we can grow them
478
const firstBeforeText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), overwriteBefore, 0));
479
const firstAfterText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), 0, overwriteAfter));
480
481
// remember the first non-whitespace column to decide if
482
// `keepWhitespace` should be overruled for secondary selections
483
const firstLineFirstNonWhitespace = model.getLineFirstNonWhitespaceColumn(editor.getSelection().positionLineNumber);
484
485
// sort selections by their start position but remeber
486
// the original index. that allows you to create correct
487
// offset-based selection logic without changing the
488
// primary selection
489
const indexedSelections = editor.getSelections()
490
.map((selection, idx) => ({ selection, idx }))
491
.sort((a, b) => Range.compareRangesUsingStarts(a.selection, b.selection));
492
493
for (const { selection, idx } of indexedSelections) {
494
495
// extend selection with the `overwriteBefore` and `overwriteAfter` and then
496
// compare if this matches the extensions of the primary selection
497
let extensionBefore = SnippetSession.adjustSelection(model, selection, overwriteBefore, 0);
498
let extensionAfter = SnippetSession.adjustSelection(model, selection, 0, overwriteAfter);
499
if (firstBeforeText !== model.getValueInRange(extensionBefore)) {
500
extensionBefore = selection;
501
}
502
if (firstAfterText !== model.getValueInRange(extensionAfter)) {
503
extensionAfter = selection;
504
}
505
506
// merge the before and after selection into one
507
const snippetSelection = selection
508
.setStartPosition(extensionBefore.startLineNumber, extensionBefore.startColumn)
509
.setEndPosition(extensionAfter.endLineNumber, extensionAfter.endColumn);
510
511
const snippet = new SnippetParser().parse(template, true, enforceFinalTabstop);
512
513
// adjust the template string to match the indentation and
514
// whitespace rules of this insert location (can be different for each cursor)
515
// happens when being asked for (default) or when this is a secondary
516
// cursor and the leading whitespace is different
517
const start = snippetSelection.getStartPosition();
518
const snippetLineLeadingWhitespace = SnippetSession.adjustWhitespace(
519
model, start,
520
adjustWhitespace || (idx > 0 && firstLineFirstNonWhitespace !== model.getLineFirstNonWhitespaceColumn(selection.positionLineNumber)),
521
snippet,
522
);
523
524
snippet.resolveVariables(new CompositeSnippetVariableResolver([
525
modelBasedVariableResolver,
526
new ClipboardBasedVariableResolver(readClipboardText, idx, indexedSelections.length, editor.getOption(EditorOption.multiCursorPaste) === 'spread'),
527
new SelectionBasedVariableResolver(model, selection, idx, overtypingCapturer),
528
new CommentBasedVariableResolver(model, selection, languageConfigurationService),
529
new TimeBasedVariableResolver,
530
new WorkspaceBasedVariableResolver(workspaceService),
531
new RandomBasedVariableResolver,
532
]));
533
534
// store snippets with the index of their originating selection.
535
// that ensures the primary cursor stays primary despite not being
536
// the one with lowest start position
537
edits[idx] = EditOperation.replace(snippetSelection, snippet.toString());
538
edits[idx].identifier = { major: idx, minor: 0 }; // mark the edit so only our undo edits will be used to generate end cursors
539
edits[idx]._isTracked = true;
540
snippets[idx] = new OneSnippet(editor, snippet, snippetLineLeadingWhitespace);
541
}
542
543
return { edits, snippets };
544
}
545
546
static createEditsAndSnippetsFromEdits(editor: IActiveCodeEditor, snippetEdits: ISnippetEdit[], enforceFinalTabstop: boolean, adjustWhitespace: boolean, clipboardText: string | undefined, overtypingCapturer: OvertypingCapturer | undefined, languageConfigurationService: ILanguageConfigurationService): { edits: IIdentifiedSingleEditOperation[]; snippets: OneSnippet[] } {
547
548
if (!editor.hasModel() || snippetEdits.length === 0) {
549
return { edits: [], snippets: [] };
550
}
551
552
const edits: IIdentifiedSingleEditOperation[] = [];
553
const model = editor.getModel();
554
555
const parser = new SnippetParser();
556
const snippet = new TextmateSnippet();
557
558
// snippet variables resolver
559
const resolver = new CompositeSnippetVariableResolver([
560
editor.invokeWithinContext(accessor => new ModelBasedVariableResolver(accessor.get(ILabelService), model)),
561
new ClipboardBasedVariableResolver(() => clipboardText, 0, editor.getSelections().length, editor.getOption(EditorOption.multiCursorPaste) === 'spread'),
562
new SelectionBasedVariableResolver(model, editor.getSelection(), 0, overtypingCapturer),
563
new CommentBasedVariableResolver(model, editor.getSelection(), languageConfigurationService),
564
new TimeBasedVariableResolver,
565
new WorkspaceBasedVariableResolver(editor.invokeWithinContext(accessor => accessor.get(IWorkspaceContextService))),
566
new RandomBasedVariableResolver,
567
]);
568
569
//
570
snippetEdits = snippetEdits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
571
let offset = 0;
572
for (let i = 0; i < snippetEdits.length; i++) {
573
574
const { range, template, keepWhitespace } = snippetEdits[i];
575
576
// gaps between snippet edits are appended as text nodes. this
577
// ensures placeholder-offsets are later correct
578
if (i > 0) {
579
const lastRange = snippetEdits[i - 1].range;
580
const textRange = Range.fromPositions(lastRange.getEndPosition(), range.getStartPosition());
581
const textNode = new Text(model.getValueInRange(textRange));
582
snippet.appendChild(textNode);
583
offset += textNode.value.length;
584
}
585
586
const newNodes = parser.parseFragment(template, snippet);
587
SnippetSession.adjustWhitespace(model, range.getStartPosition(), keepWhitespace !== undefined ? !keepWhitespace : adjustWhitespace, snippet, new Set(newNodes));
588
snippet.resolveVariables(resolver);
589
590
const snippetText = snippet.toString();
591
const snippetFragmentText = snippetText.slice(offset);
592
offset = snippetText.length;
593
594
// make edit
595
const edit: IIdentifiedSingleEditOperation = EditOperation.replace(range, snippetFragmentText);
596
edit.identifier = { major: i, minor: 0 }; // mark the edit so only our undo edits will be used to generate end cursors
597
edit._isTracked = true;
598
edits.push(edit);
599
}
600
601
//
602
parser.ensureFinalTabstop(snippet, enforceFinalTabstop, true);
603
604
return {
605
edits,
606
snippets: [new OneSnippet(editor, snippet, '')]
607
};
608
}
609
610
private readonly _templateMerges: [number, number, string | ISnippetEdit[]][] = [];
611
private _snippets: OneSnippet[] = [];
612
613
constructor(
614
private readonly _editor: IActiveCodeEditor,
615
private readonly _template: string | ISnippetEdit[],
616
private readonly _options: ISnippetSessionInsertOptions = _defaultOptions,
617
@ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService
618
) { }
619
620
dispose(): void {
621
dispose(this._snippets);
622
}
623
624
_logInfo(): string {
625
return `template="${this._template}", merged_templates="${this._templateMerges.join(' -> ')}"`;
626
}
627
628
insert(editReason?: TextModelEditSource): void {
629
if (!this._editor.hasModel()) {
630
return;
631
}
632
633
// make insert edit and start with first selections
634
const { edits, snippets } = typeof this._template === 'string'
635
? SnippetSession.createEditsAndSnippetsFromSelections(this._editor, this._template, this._options.overwriteBefore, this._options.overwriteAfter, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer, this._languageConfigurationService)
636
: SnippetSession.createEditsAndSnippetsFromEdits(this._editor, this._template, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer, this._languageConfigurationService);
637
638
this._snippets = snippets;
639
640
this._editor.executeEdits(editReason ?? EditSources.snippet(), edits, _undoEdits => {
641
// Sometimes, the text buffer will remove automatic whitespace when doing any edits,
642
// so we need to look only at the undo edits relevant for us.
643
// Our edits have an identifier set so that's how we can distinguish them
644
const undoEdits = _undoEdits.filter(edit => !!edit.identifier);
645
for (let idx = 0; idx < snippets.length; idx++) {
646
snippets[idx].initialize(undoEdits[idx].textChange);
647
}
648
649
if (this._snippets[0].hasPlaceholder) {
650
return this._move(true);
651
} else {
652
return undoEdits
653
.map(edit => Selection.fromPositions(edit.range.getEndPosition()));
654
}
655
});
656
this._editor.revealRange(this._editor.getSelections()[0]);
657
}
658
659
merge(template: string, options: ISnippetSessionInsertOptions = _defaultOptions): void {
660
if (!this._editor.hasModel()) {
661
return;
662
}
663
this._templateMerges.push([this._snippets[0]._nestingLevel, this._snippets[0]._placeholderGroupsIdx, template]);
664
const { edits, snippets } = SnippetSession.createEditsAndSnippetsFromSelections(this._editor, template, options.overwriteBefore, options.overwriteAfter, true, options.adjustWhitespace, options.clipboardText, options.overtypingCapturer, this._languageConfigurationService);
665
666
this._editor.executeEdits('snippet', edits, _undoEdits => {
667
// Sometimes, the text buffer will remove automatic whitespace when doing any edits,
668
// so we need to look only at the undo edits relevant for us.
669
// Our edits have an identifier set so that's how we can distinguish them
670
const undoEdits = _undoEdits.filter(edit => !!edit.identifier);
671
for (let idx = 0; idx < snippets.length; idx++) {
672
snippets[idx].initialize(undoEdits[idx].textChange);
673
}
674
675
// Trivial snippets have no placeholder or are just the final placeholder. That means they
676
// are just text insertions and we don't need to merge the nested snippet into the existing
677
// snippet
678
const isTrivialSnippet = snippets[0].isTrivialSnippet;
679
if (!isTrivialSnippet) {
680
for (const snippet of this._snippets) {
681
snippet.merge(snippets);
682
}
683
console.assert(snippets.length === 0);
684
}
685
686
if (this._snippets[0].hasPlaceholder && !isTrivialSnippet) {
687
return this._move(undefined);
688
} else {
689
return undoEdits.map(edit => Selection.fromPositions(edit.range.getEndPosition()));
690
}
691
});
692
}
693
694
next(): void {
695
const newSelections = this._move(true);
696
this._editor.setSelections(newSelections);
697
this._editor.revealPositionInCenterIfOutsideViewport(newSelections[0].getPosition());
698
}
699
700
prev(): void {
701
const newSelections = this._move(false);
702
this._editor.setSelections(newSelections);
703
this._editor.revealPositionInCenterIfOutsideViewport(newSelections[0].getPosition());
704
}
705
706
private _move(fwd: boolean | undefined): Selection[] {
707
const selections: Selection[] = [];
708
for (const snippet of this._snippets) {
709
const oneSelection = snippet.move(fwd);
710
selections.push(...oneSelection);
711
}
712
return selections;
713
}
714
715
get isAtFirstPlaceholder() {
716
return this._snippets[0].isAtFirstPlaceholder;
717
}
718
719
get isAtLastPlaceholder() {
720
return this._snippets[0].isAtLastPlaceholder;
721
}
722
723
get hasPlaceholder() {
724
return this._snippets[0].hasPlaceholder;
725
}
726
727
get hasChoice(): boolean {
728
return this._snippets[0].hasChoice;
729
}
730
731
get activeChoice(): { choice: Choice; range: Range } | undefined {
732
return this._snippets[0].activeChoice;
733
}
734
735
isSelectionWithinPlaceholders(): boolean {
736
737
if (!this.hasPlaceholder) {
738
return false;
739
}
740
741
const selections = this._editor.getSelections();
742
if (selections.length < this._snippets.length) {
743
// this means we started snippet mode with N
744
// selections and have M (N > M) selections.
745
// So one snippet is without selection -> cancel
746
return false;
747
}
748
749
const allPossibleSelections = new Map<number, Range[]>();
750
for (const snippet of this._snippets) {
751
752
const possibleSelections = snippet.computePossibleSelections();
753
754
// for the first snippet find the placeholder (and its ranges)
755
// that contain at least one selection. for all remaining snippets
756
// the same placeholder (and their ranges) must be used.
757
if (allPossibleSelections.size === 0) {
758
for (const [index, ranges] of possibleSelections) {
759
ranges.sort(Range.compareRangesUsingStarts);
760
for (const selection of selections) {
761
if (ranges[0].containsRange(selection)) {
762
allPossibleSelections.set(index, []);
763
break;
764
}
765
}
766
}
767
}
768
769
if (allPossibleSelections.size === 0) {
770
// return false if we couldn't associate a selection to
771
// this (the first) snippet
772
return false;
773
}
774
775
// add selections from 'this' snippet so that we know all
776
// selections for this placeholder
777
allPossibleSelections.forEach((array, index) => {
778
array.push(...possibleSelections.get(index)!);
779
});
780
}
781
782
// sort selections (and later placeholder-ranges). then walk both
783
// arrays and make sure the placeholder-ranges contain the corresponding
784
// selection
785
selections.sort(Range.compareRangesUsingStarts);
786
787
for (const [index, ranges] of allPossibleSelections) {
788
if (ranges.length !== selections.length) {
789
allPossibleSelections.delete(index);
790
continue;
791
}
792
793
ranges.sort(Range.compareRangesUsingStarts);
794
795
for (let i = 0; i < ranges.length; i++) {
796
if (!ranges[i].containsRange(selections[i])) {
797
allPossibleSelections.delete(index);
798
continue;
799
}
800
}
801
}
802
803
// from all possible selections we have deleted those
804
// that don't match with the current selection. if we don't
805
// have any left, we don't have a selection anymore
806
return allPossibleSelections.size > 0;
807
}
808
809
public getEnclosingRange(): Range | undefined {
810
let result: Range | undefined;
811
for (const snippet of this._snippets) {
812
const snippetRange = snippet.getEnclosingRange();
813
if (!result) {
814
result = snippetRange;
815
} else {
816
result = result.plusRange(snippetRange!);
817
}
818
}
819
return result;
820
}
821
}
822
823