123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- /*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
- import { groupBy } from '../../../base/common/arrays.js';
- import { dispose } from '../../../base/common/lifecycle.js';
- import { getLeadingWhitespace } from '../../../base/common/strings.js';
- import './snippetSession.css';
- import { EditOperation } from '../../common/core/editOperation.js';
- import { Range } from '../../common/core/range.js';
- import { Selection } from '../../common/core/selection.js';
- import { ModelDecorationOptions } from '../../common/model/textModel.js';
- import { ILabelService } from '../../../platform/label/common/label.js';
- import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js';
- import { Choice, Placeholder, SnippetParser, Text } from './snippetParser.js';
- import { ClipboardBasedVariableResolver, CommentBasedVariableResolver, CompositeSnippetVariableResolver, ModelBasedVariableResolver, RandomBasedVariableResolver, SelectionBasedVariableResolver, TimeBasedVariableResolver, WorkspaceBasedVariableResolver } from './snippetVariables.js';
- export class OneSnippet {
- constructor(_editor, _snippet, _offset, _snippetLineLeadingWhitespace) {
- this._editor = _editor;
- this._snippet = _snippet;
- this._offset = _offset;
- this._snippetLineLeadingWhitespace = _snippetLineLeadingWhitespace;
- this._nestingLevel = 1;
- this._placeholderGroups = groupBy(_snippet.placeholders, Placeholder.compareByIndex);
- this._placeholderGroupsIdx = -1;
- }
- dispose() {
- if (this._placeholderDecorations) {
- this._editor.deltaDecorations([...this._placeholderDecorations.values()], []);
- }
- this._placeholderGroups.length = 0;
- }
- _initDecorations() {
- if (this._placeholderDecorations) {
- // already initialized
- return;
- }
- this._placeholderDecorations = new Map();
- const model = this._editor.getModel();
- this._editor.changeDecorations(accessor => {
- // create a decoration for each placeholder
- for (const placeholder of this._snippet.placeholders) {
- const placeholderOffset = this._snippet.offset(placeholder);
- const placeholderLen = this._snippet.fullLen(placeholder);
- const range = Range.fromPositions(model.getPositionAt(this._offset + placeholderOffset), model.getPositionAt(this._offset + placeholderOffset + placeholderLen));
- const options = placeholder.isFinalTabstop ? OneSnippet._decor.inactiveFinal : OneSnippet._decor.inactive;
- const handle = accessor.addDecoration(range, options);
- this._placeholderDecorations.set(placeholder, handle);
- }
- });
- }
- move(fwd) {
- if (!this._editor.hasModel()) {
- return [];
- }
- this._initDecorations();
- // Transform placeholder text if necessary
- if (this._placeholderGroupsIdx >= 0) {
- let operations = [];
- for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
- // Check if the placeholder has a transformation
- if (placeholder.transform) {
- const id = this._placeholderDecorations.get(placeholder);
- const range = this._editor.getModel().getDecorationRange(id);
- const currentValue = this._editor.getModel().getValueInRange(range);
- const transformedValueLines = placeholder.transform.resolve(currentValue).split(/\r\n|\r|\n/);
- // fix indentation for transformed lines
- for (let i = 1; i < transformedValueLines.length; i++) {
- transformedValueLines[i] = this._editor.getModel().normalizeIndentation(this._snippetLineLeadingWhitespace + transformedValueLines[i]);
- }
- operations.push(EditOperation.replace(range, transformedValueLines.join(this._editor.getModel().getEOL())));
- }
- }
- if (operations.length > 0) {
- this._editor.executeEdits('snippet.placeholderTransform', operations);
- }
- }
- let couldSkipThisPlaceholder = false;
- if (fwd === true && this._placeholderGroupsIdx < this._placeholderGroups.length - 1) {
- this._placeholderGroupsIdx += 1;
- couldSkipThisPlaceholder = true;
- }
- else if (fwd === false && this._placeholderGroupsIdx > 0) {
- this._placeholderGroupsIdx -= 1;
- couldSkipThisPlaceholder = true;
- }
- else {
- // the selection of the current placeholder might
- // not acurate any more -> simply restore it
- }
- const newSelections = this._editor.getModel().changeDecorations(accessor => {
- const activePlaceholders = new Set();
- // change stickiness to always grow when typing at its edges
- // because these decorations represent the currently active
- // tabstop.
- // Special case #1: reaching the final tabstop
- // Special case #2: placeholders enclosing active placeholders
- const selections = [];
- for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
- const id = this._placeholderDecorations.get(placeholder);
- const range = this._editor.getModel().getDecorationRange(id);
- selections.push(new Selection(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn));
- // consider to skip this placeholder index when the decoration
- // range is empty but when the placeholder wasn't. that's a strong
- // hint that the placeholder has been deleted. (all placeholder must match this)
- couldSkipThisPlaceholder = couldSkipThisPlaceholder && this._hasPlaceholderBeenCollapsed(placeholder);
- accessor.changeDecorationOptions(id, placeholder.isFinalTabstop ? OneSnippet._decor.activeFinal : OneSnippet._decor.active);
- activePlaceholders.add(placeholder);
- for (const enclosingPlaceholder of this._snippet.enclosingPlaceholders(placeholder)) {
- const id = this._placeholderDecorations.get(enclosingPlaceholder);
- accessor.changeDecorationOptions(id, enclosingPlaceholder.isFinalTabstop ? OneSnippet._decor.activeFinal : OneSnippet._decor.active);
- activePlaceholders.add(enclosingPlaceholder);
- }
- }
- // change stickness to never grow when typing at its edges
- // so that in-active tabstops never grow
- for (const [placeholder, id] of this._placeholderDecorations) {
- if (!activePlaceholders.has(placeholder)) {
- accessor.changeDecorationOptions(id, placeholder.isFinalTabstop ? OneSnippet._decor.inactiveFinal : OneSnippet._decor.inactive);
- }
- }
- return selections;
- });
- return !couldSkipThisPlaceholder ? newSelections !== null && newSelections !== void 0 ? newSelections : [] : this.move(fwd);
- }
- _hasPlaceholderBeenCollapsed(placeholder) {
- // A placeholder is empty when it wasn't empty when authored but
- // when its tracking decoration is empty. This also applies to all
- // potential parent placeholders
- let marker = placeholder;
- while (marker) {
- if (marker instanceof Placeholder) {
- const id = this._placeholderDecorations.get(marker);
- const range = this._editor.getModel().getDecorationRange(id);
- if (range.isEmpty() && marker.toString().length > 0) {
- return true;
- }
- }
- marker = marker.parent;
- }
- return false;
- }
- get isAtFirstPlaceholder() {
- return this._placeholderGroupsIdx <= 0 || this._placeholderGroups.length === 0;
- }
- get isAtLastPlaceholder() {
- return this._placeholderGroupsIdx === this._placeholderGroups.length - 1;
- }
- get hasPlaceholder() {
- return this._snippet.placeholders.length > 0;
- }
- computePossibleSelections() {
- const result = new Map();
- for (const placeholdersWithEqualIndex of this._placeholderGroups) {
- let ranges;
- for (const placeholder of placeholdersWithEqualIndex) {
- if (placeholder.isFinalTabstop) {
- // ignore those
- break;
- }
- if (!ranges) {
- ranges = [];
- result.set(placeholder.index, ranges);
- }
- const id = this._placeholderDecorations.get(placeholder);
- const range = this._editor.getModel().getDecorationRange(id);
- if (!range) {
- // one of the placeholder lost its decoration and
- // therefore we bail out and pretend the placeholder
- // (with its mirrors) doesn't exist anymore.
- result.delete(placeholder.index);
- break;
- }
- ranges.push(range);
- }
- }
- return result;
- }
- get choice() {
- return this._placeholderGroups[this._placeholderGroupsIdx][0].choice;
- }
- merge(others) {
- const model = this._editor.getModel();
- this._nestingLevel *= 10;
- this._editor.changeDecorations(accessor => {
- // For each active placeholder take one snippet and merge it
- // in that the placeholder (can be many for `$1foo$1foo`). Because
- // everything is sorted by editor selection we can simply remove
- // elements from the beginning of the array
- for (const placeholder of this._placeholderGroups[this._placeholderGroupsIdx]) {
- const nested = others.shift();
- console.assert(!nested._placeholderDecorations);
- // Massage placeholder-indicies of the nested snippet to be
- // sorted right after the insertion point. This ensures we move
- // through the placeholders in the correct order
- const indexLastPlaceholder = nested._snippet.placeholderInfo.last.index;
- for (const nestedPlaceholder of nested._snippet.placeholderInfo.all) {
- if (nestedPlaceholder.isFinalTabstop) {
- nestedPlaceholder.index = placeholder.index + ((indexLastPlaceholder + 1) / this._nestingLevel);
- }
- else {
- nestedPlaceholder.index = placeholder.index + (nestedPlaceholder.index / this._nestingLevel);
- }
- }
- this._snippet.replace(placeholder, nested._snippet.children);
- // Remove the placeholder at which position are inserting
- // the snippet and also remove its decoration.
- const id = this._placeholderDecorations.get(placeholder);
- accessor.removeDecoration(id);
- this._placeholderDecorations.delete(placeholder);
- // For each *new* placeholder we create decoration to monitor
- // how and if it grows/shrinks.
- for (const placeholder of nested._snippet.placeholders) {
- const placeholderOffset = nested._snippet.offset(placeholder);
- const placeholderLen = nested._snippet.fullLen(placeholder);
- const range = Range.fromPositions(model.getPositionAt(nested._offset + placeholderOffset), model.getPositionAt(nested._offset + placeholderOffset + placeholderLen));
- const handle = accessor.addDecoration(range, OneSnippet._decor.inactive);
- this._placeholderDecorations.set(placeholder, handle);
- }
- }
- // Last, re-create the placeholder groups by sorting placeholders by their index.
- this._placeholderGroups = groupBy(this._snippet.placeholders, Placeholder.compareByIndex);
- });
- }
- }
- OneSnippet._decor = {
- active: ModelDecorationOptions.register({ description: 'snippet-placeholder-1', stickiness: 0 /* AlwaysGrowsWhenTypingAtEdges */, className: 'snippet-placeholder' }),
- inactive: ModelDecorationOptions.register({ description: 'snippet-placeholder-2', stickiness: 1 /* NeverGrowsWhenTypingAtEdges */, className: 'snippet-placeholder' }),
- activeFinal: ModelDecorationOptions.register({ description: 'snippet-placeholder-3', stickiness: 1 /* NeverGrowsWhenTypingAtEdges */, className: 'finish-snippet-placeholder' }),
- inactiveFinal: ModelDecorationOptions.register({ description: 'snippet-placeholder-4', stickiness: 1 /* NeverGrowsWhenTypingAtEdges */, className: 'finish-snippet-placeholder' }),
- };
- const _defaultOptions = {
- overwriteBefore: 0,
- overwriteAfter: 0,
- adjustWhitespace: true,
- clipboardText: undefined,
- overtypingCapturer: undefined
- };
- export class SnippetSession {
- constructor(editor, template, options = _defaultOptions) {
- this._templateMerges = [];
- this._snippets = [];
- this._editor = editor;
- this._template = template;
- this._options = options;
- }
- static adjustWhitespace(model, position, snippet, adjustIndentation, adjustNewlines) {
- const line = model.getLineContent(position.lineNumber);
- const lineLeadingWhitespace = getLeadingWhitespace(line, 0, position.column - 1);
- // the snippet as inserted
- let snippetTextString;
- snippet.walk(marker => {
- // all text elements that are not inside choice
- if (!(marker instanceof Text) || marker.parent instanceof Choice) {
- return true;
- }
- const lines = marker.value.split(/\r\n|\r|\n/);
- if (adjustIndentation) {
- // adjust indentation of snippet test
- // -the snippet-start doesn't get extra-indented (lineLeadingWhitespace), only normalized
- // -all N+1 lines get extra-indented and normalized
- // -the text start get extra-indented and normalized when following a linebreak
- const offset = snippet.offset(marker);
- if (offset === 0) {
- // snippet start
- lines[0] = model.normalizeIndentation(lines[0]);
- }
- else {
- // check if text start is after a linebreak
- snippetTextString = snippetTextString !== null && snippetTextString !== void 0 ? snippetTextString : snippet.toString();
- let prevChar = snippetTextString.charCodeAt(offset - 1);
- if (prevChar === 10 /* LineFeed */ || prevChar === 13 /* CarriageReturn */) {
- lines[0] = model.normalizeIndentation(lineLeadingWhitespace + lines[0]);
- }
- }
- for (let i = 1; i < lines.length; i++) {
- lines[i] = model.normalizeIndentation(lineLeadingWhitespace + lines[i]);
- }
- }
- const newValue = lines.join(model.getEOL());
- if (newValue !== marker.value) {
- marker.parent.replace(marker, [new Text(newValue)]);
- snippetTextString = undefined;
- }
- return true;
- });
- return lineLeadingWhitespace;
- }
- static adjustSelection(model, selection, overwriteBefore, overwriteAfter) {
- if (overwriteBefore !== 0 || overwriteAfter !== 0) {
- // overwrite[Before|After] is compute using the position, not the whole
- // selection. therefore we adjust the selection around that position
- const { positionLineNumber, positionColumn } = selection;
- const positionColumnBefore = positionColumn - overwriteBefore;
- const positionColumnAfter = positionColumn + overwriteAfter;
- const range = model.validateRange({
- startLineNumber: positionLineNumber,
- startColumn: positionColumnBefore,
- endLineNumber: positionLineNumber,
- endColumn: positionColumnAfter
- });
- selection = Selection.createWithDirection(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn, selection.getDirection());
- }
- return selection;
- }
- static createEditsAndSnippets(editor, template, overwriteBefore, overwriteAfter, enforceFinalTabstop, adjustWhitespace, clipboardText, overtypingCapturer) {
- const edits = [];
- const snippets = [];
- if (!editor.hasModel()) {
- return { edits, snippets };
- }
- const model = editor.getModel();
- const workspaceService = editor.invokeWithinContext(accessor => accessor.get(IWorkspaceContextService));
- const modelBasedVariableResolver = editor.invokeWithinContext(accessor => new ModelBasedVariableResolver(accessor.get(ILabelService), model));
- const readClipboardText = () => clipboardText;
- let delta = 0;
- // know what text the overwrite[Before|After] extensions
- // of the primary curser have selected because only when
- // secondary selections extend to the same text we can grow them
- let firstBeforeText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), overwriteBefore, 0));
- let firstAfterText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), 0, overwriteAfter));
- // remember the first non-whitespace column to decide if
- // `keepWhitespace` should be overruled for secondary selections
- let firstLineFirstNonWhitespace = model.getLineFirstNonWhitespaceColumn(editor.getSelection().positionLineNumber);
- // sort selections by their start position but remeber
- // the original index. that allows you to create correct
- // offset-based selection logic without changing the
- // primary selection
- const indexedSelections = editor.getSelections()
- .map((selection, idx) => ({ selection, idx }))
- .sort((a, b) => Range.compareRangesUsingStarts(a.selection, b.selection));
- for (const { selection, idx } of indexedSelections) {
- // extend selection with the `overwriteBefore` and `overwriteAfter` and then
- // compare if this matches the extensions of the primary selection
- let extensionBefore = SnippetSession.adjustSelection(model, selection, overwriteBefore, 0);
- let extensionAfter = SnippetSession.adjustSelection(model, selection, 0, overwriteAfter);
- if (firstBeforeText !== model.getValueInRange(extensionBefore)) {
- extensionBefore = selection;
- }
- if (firstAfterText !== model.getValueInRange(extensionAfter)) {
- extensionAfter = selection;
- }
- // merge the before and after selection into one
- const snippetSelection = selection
- .setStartPosition(extensionBefore.startLineNumber, extensionBefore.startColumn)
- .setEndPosition(extensionAfter.endLineNumber, extensionAfter.endColumn);
- const snippet = new SnippetParser().parse(template, true, enforceFinalTabstop);
- // adjust the template string to match the indentation and
- // whitespace rules of this insert location (can be different for each cursor)
- // happens when being asked for (default) or when this is a secondary
- // cursor and the leading whitespace is different
- const start = snippetSelection.getStartPosition();
- const snippetLineLeadingWhitespace = SnippetSession.adjustWhitespace(model, start, snippet, adjustWhitespace || (idx > 0 && firstLineFirstNonWhitespace !== model.getLineFirstNonWhitespaceColumn(selection.positionLineNumber)), true);
- snippet.resolveVariables(new CompositeSnippetVariableResolver([
- modelBasedVariableResolver,
- new ClipboardBasedVariableResolver(readClipboardText, idx, indexedSelections.length, editor.getOption(70 /* multiCursorPaste */) === 'spread'),
- new SelectionBasedVariableResolver(model, selection, idx, overtypingCapturer),
- new CommentBasedVariableResolver(model, selection),
- new TimeBasedVariableResolver,
- new WorkspaceBasedVariableResolver(workspaceService),
- new RandomBasedVariableResolver,
- ]));
- const offset = model.getOffsetAt(start) + delta;
- delta += snippet.toString().length - model.getValueLengthInRange(snippetSelection);
- // store snippets with the index of their originating selection.
- // that ensures the primiary cursor stays primary despite not being
- // the one with lowest start position
- edits[idx] = EditOperation.replace(snippetSelection, snippet.toString());
- edits[idx].identifier = { major: idx, minor: 0 }; // mark the edit so only our undo edits will be used to generate end cursors
- snippets[idx] = new OneSnippet(editor, snippet, offset, snippetLineLeadingWhitespace);
- }
- return { edits, snippets };
- }
- dispose() {
- dispose(this._snippets);
- }
- _logInfo() {
- return `template="${this._template}", merged_templates="${this._templateMerges.join(' -> ')}"`;
- }
- insert() {
- if (!this._editor.hasModel()) {
- return;
- }
- // make insert edit and start with first selections
- const { edits, snippets } = SnippetSession.createEditsAndSnippets(this._editor, this._template, this._options.overwriteBefore, this._options.overwriteAfter, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer);
- this._snippets = snippets;
- this._editor.executeEdits('snippet', edits, undoEdits => {
- if (this._snippets[0].hasPlaceholder) {
- return this._move(true);
- }
- else {
- return undoEdits
- .filter(edit => !!edit.identifier) // only use our undo edits
- .map(edit => Selection.fromPositions(edit.range.getEndPosition()));
- }
- });
- this._editor.revealRange(this._editor.getSelections()[0]);
- }
- merge(template, options = _defaultOptions) {
- if (!this._editor.hasModel()) {
- return;
- }
- this._templateMerges.push([this._snippets[0]._nestingLevel, this._snippets[0]._placeholderGroupsIdx, template]);
- const { edits, snippets } = SnippetSession.createEditsAndSnippets(this._editor, template, options.overwriteBefore, options.overwriteAfter, true, options.adjustWhitespace, options.clipboardText, options.overtypingCapturer);
- this._editor.executeEdits('snippet', edits, undoEdits => {
- for (const snippet of this._snippets) {
- snippet.merge(snippets);
- }
- console.assert(snippets.length === 0);
- if (this._snippets[0].hasPlaceholder) {
- return this._move(undefined);
- }
- else {
- return (undoEdits
- .filter(edit => !!edit.identifier) // only use our undo edits
- .map(edit => Selection.fromPositions(edit.range.getEndPosition())));
- }
- });
- }
- next() {
- const newSelections = this._move(true);
- this._editor.setSelections(newSelections);
- this._editor.revealPositionInCenterIfOutsideViewport(newSelections[0].getPosition());
- }
- prev() {
- const newSelections = this._move(false);
- this._editor.setSelections(newSelections);
- this._editor.revealPositionInCenterIfOutsideViewport(newSelections[0].getPosition());
- }
- _move(fwd) {
- const selections = [];
- for (const snippet of this._snippets) {
- const oneSelection = snippet.move(fwd);
- selections.push(...oneSelection);
- }
- return selections;
- }
- get isAtFirstPlaceholder() {
- return this._snippets[0].isAtFirstPlaceholder;
- }
- get isAtLastPlaceholder() {
- return this._snippets[0].isAtLastPlaceholder;
- }
- get hasPlaceholder() {
- return this._snippets[0].hasPlaceholder;
- }
- get choice() {
- return this._snippets[0].choice;
- }
- isSelectionWithinPlaceholders() {
- if (!this.hasPlaceholder) {
- return false;
- }
- const selections = this._editor.getSelections();
- if (selections.length < this._snippets.length) {
- // this means we started snippet mode with N
- // selections and have M (N > M) selections.
- // So one snippet is without selection -> cancel
- return false;
- }
- let allPossibleSelections = new Map();
- for (const snippet of this._snippets) {
- const possibleSelections = snippet.computePossibleSelections();
- // for the first snippet find the placeholder (and its ranges)
- // that contain at least one selection. for all remaining snippets
- // the same placeholder (and their ranges) must be used.
- if (allPossibleSelections.size === 0) {
- for (const [index, ranges] of possibleSelections) {
- ranges.sort(Range.compareRangesUsingStarts);
- for (const selection of selections) {
- if (ranges[0].containsRange(selection)) {
- allPossibleSelections.set(index, []);
- break;
- }
- }
- }
- }
- if (allPossibleSelections.size === 0) {
- // return false if we couldn't associate a selection to
- // this (the first) snippet
- return false;
- }
- // add selections from 'this' snippet so that we know all
- // selections for this placeholder
- allPossibleSelections.forEach((array, index) => {
- array.push(...possibleSelections.get(index));
- });
- }
- // sort selections (and later placeholder-ranges). then walk both
- // arrays and make sure the placeholder-ranges contain the corresponding
- // selection
- selections.sort(Range.compareRangesUsingStarts);
- for (let [index, ranges] of allPossibleSelections) {
- if (ranges.length !== selections.length) {
- allPossibleSelections.delete(index);
- continue;
- }
- ranges.sort(Range.compareRangesUsingStarts);
- for (let i = 0; i < ranges.length; i++) {
- if (!ranges[i].containsRange(selections[i])) {
- allPossibleSelections.delete(index);
- continue;
- }
- }
- }
- // from all possible selections we have deleted those
- // that don't match with the current selection. if we don't
- // have any left, we don't have a selection anymore
- return allPossibleSelections.size > 0;
- }
- }
|