Skip to content

Commit

Permalink
joh/theoretical quokka (#154157)
Browse files Browse the repository at this point in the history
* add `SnippetController#apply(ISnippetEdit[])`

This replaces the initial ugly trick with a more sound implementation of arbitrary snippet edits. A snippet edit can cover disconnected regions, each will be applied as separate text edit but everything will become a single `OneSnippet` instance

* add integration test for SnippetString-text edit inside workspace edit
  • Loading branch information
jrieken authored Jul 5, 2022
1 parent 0843ea2 commit 71c221c
Show file tree
Hide file tree
Showing 8 changed files with 367 additions and 85 deletions.
1 change: 1 addition & 0 deletions extensions/vscode-api-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"scmActionButton",
"scmSelectedProvider",
"scmValidation",
"snippetWorkspaceEdit",
"taskPresentationGroup",
"terminalDataWriteEvent",
"terminalDimensions",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1147,4 +1147,27 @@ suite('vscode API - workspace', () => {
assert.strictEqual(document.isDirty, false);
}
});

test('SnippetString in WorkspaceEdit', async function (): Promise<any> {
const file = await createRandomFile('hello\nworld');

const document = await vscode.workspace.openTextDocument(file);
const edt = await vscode.window.showTextDocument(document);

assert.ok(edt === vscode.window.activeTextEditor);

const we = new vscode.WorkspaceEdit();
we.set(document.uri, [{ range: new vscode.Range(0, 0, 0, 0), newText: '', newText2: new vscode.SnippetString('${1:foo}${2:bar}') }]);
const success = await vscode.workspace.applyEdit(we);


if (edt !== vscode.window.activeTextEditor) {
return this.skip();
}

assert.ok(success);
assert.strictEqual(document.getText(), 'foobarhello\nworld');
assert.deepStrictEqual(edt.selections, [new vscode.Selection(0, 0, 0, 3)]);

});
});
79 changes: 30 additions & 49 deletions src/vs/editor/contrib/snippet/browser/snippetController2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,26 @@

import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { assertType } from 'vs/base/common/types';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorCommand, registerEditorCommand, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { ISelection } from 'vs/editor/common/core/selection';
import { ISelection, Selection } from 'vs/editor/common/core/selection';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
import { CompletionItem, CompletionItemKind, CompletionItemProvider } from 'vs/editor/common/languages';
import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry';
import { ITextModel } from 'vs/editor/common/model';
import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures';
import { Choice, SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser';
import { Choice } from 'vs/editor/contrib/snippet/browser/snippetParser';
import { showSimpleSuggestions } from 'vs/editor/contrib/suggest/browser/suggest';
import { OvertypingCapturer } from 'vs/editor/contrib/suggest/browser/suggestOvertypingCapturer';
import { localize } from 'vs/nls';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { ILogService } from 'vs/platform/log/common/log';
import { SnippetSession } from './snippetSession';
import { ISnippetEdit, SnippetSession } from './snippetSession';

export interface ISnippetInsertOptions {
overwriteBefore: number;
Expand Down Expand Up @@ -88,6 +89,19 @@ export class SnippetController2 implements IEditorContribution {
this._snippetListener.dispose();
}

apply(edits: ISnippetEdit[], opts?: Partial<ISnippetInsertOptions>) {
try {
this._doInsert(edits, typeof opts === 'undefined' ? _defaultOptions : { ..._defaultOptions, ...opts });

} catch (e) {
this.cancel();
this._logService.error(e);
this._logService.error('snippet_error');
this._logService.error('insert_edits=', edits);
this._logService.error('existing_template=', this._session ? this._session._logInfo() : '<no_session>');
}
}

insert(
template: string,
opts?: Partial<ISnippetInsertOptions>
Expand All @@ -108,7 +122,7 @@ export class SnippetController2 implements IEditorContribution {
}

private _doInsert(
template: string,
template: string | ISnippetEdit[],
opts: ISnippetInsertOptions
): void {
if (!this._editor.hasModel()) {
Expand All @@ -123,11 +137,17 @@ export class SnippetController2 implements IEditorContribution {
this._editor.getModel().pushStackElement();
}

// don't merge
if (this._session && typeof template !== 'string') {
this.cancel();
}

if (!this._session) {
this._modelVersionId = this._editor.getModel().getAlternativeVersionId();
this._session = new SnippetSession(this._editor, template, opts, this._languageConfigurationService);
this._session.insert();
} else {
assertType(typeof template === 'string');
this._session.merge(template, opts);
}

Expand Down Expand Up @@ -342,50 +362,11 @@ export function performSnippetEdit(editor: ICodeEditor, snippet: string, selecti
return false;
}
editor.focus();
editor.setSelections(selections ?? []);
controller.insert(snippet);
return controller.isInSnippet();
}


export type ISnippetEdit = {
range: Range;
snippet: string;
};

// ---

export function performSnippetEdits(editor: ICodeEditor, edits: ISnippetEdit[]) {

if (!editor.hasModel()) {
return false;
}
if (edits.length === 0) {
return false;
}

const model = editor.getModel();
let newText = '';
let last: ISnippetEdit | undefined;
edits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));

for (const item of edits) {
if (last) {
const between = Range.fromPositions(last.range.getEndPosition(), item.range.getStartPosition());
const text = model.getValueInRange(between);
newText += SnippetParser.escape(text);
}
newText += item.snippet;
last = item;
}

const controller = SnippetController2.get(editor);
if (!controller) {
return false;
}
model.pushStackElement();
const range = Range.plusRange(edits[0].range, edits[edits.length - 1].range);
editor.setSelection(range);
controller.insert(newText, { undoStopBefore: false });
controller.apply(selections.map(selection => {
return {
range: Selection.liftSelection(selection),
template: snippet
};
}));
return controller.isInSnippet();
}
32 changes: 20 additions & 12 deletions src/vs/editor/contrib/snippet/browser/snippetParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,11 +613,17 @@ export class SnippetParser {
}

parse(value: string, insertFinalTabstop?: boolean, enforceFinalTabstop?: boolean): TextmateSnippet {
const snippet = new TextmateSnippet();
this.parseFragment(value, snippet);
this.ensureFinalTabstop(snippet, enforceFinalTabstop ?? false, insertFinalTabstop ?? false);
return snippet;
}

parseFragment(value: string, snippet: TextmateSnippet): readonly Marker[] {

const offset = snippet.children.length;
this._scanner.text(value);
this._token = this._scanner.next();

const snippet = new TextmateSnippet();
while (this._parse(snippet)) {
// nothing
}
Expand All @@ -626,10 +632,8 @@ export class SnippetParser {
// that has a value defines the value for all placeholders with that index
const placeholderDefaultValues = new Map<number, Marker[] | undefined>();
const incompletePlaceholders: Placeholder[] = [];
let placeholderCount = 0;
snippet.walk(marker => {
if (marker instanceof Placeholder) {
placeholderCount += 1;
if (marker.isFinalTabstop) {
placeholderDefaultValues.set(0, undefined);
} else if (!placeholderDefaultValues.has(marker.index) && marker.children.length > 0) {
Expand All @@ -640,6 +644,7 @@ export class SnippetParser {
}
return true;
});

for (const placeholder of incompletePlaceholders) {
const defaultValues = placeholderDefaultValues.get(placeholder.index);
if (defaultValues) {
Expand All @@ -652,17 +657,20 @@ export class SnippetParser {
}
}

if (!enforceFinalTabstop) {
enforceFinalTabstop = placeholderCount > 0 && insertFinalTabstop;
}
return snippet.children.slice(offset);
}

if (!placeholderDefaultValues.has(0) && enforceFinalTabstop) {
// the snippet uses placeholders but has no
// final tabstop defined -> insert at the end
snippet.appendChild(new Placeholder(0));
ensureFinalTabstop(snippet: TextmateSnippet, enforceFinalTabstop: boolean, insertFinalTabstop: boolean) {

if (enforceFinalTabstop || insertFinalTabstop && snippet.placeholders.length > 0) {
const finalTabstop = snippet.placeholders.find(p => p.index === 0);
if (!finalTabstop) {
// the snippet uses placeholders but has no
// final tabstop defined -> insert at the end
snippet.appendChild(new Placeholder(0));
}
}

return snippet;
}

private _accept(type?: TokenType): boolean;
Expand Down
95 changes: 80 additions & 15 deletions src/vs/editor/contrib/snippet/browser/snippetSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ const _defaultOptions: ISnippetSessionInsertOptions = {
overtypingCapturer: undefined
};

export interface ISnippetEdit {
range: Range;
template: string;
}

export class SnippetSession {

static adjustWhitespace(model: ITextModel, position: IPosition, snippet: TextmateSnippet, adjustIndentation: boolean, adjustNewlines: boolean): string {
Expand Down Expand Up @@ -434,7 +439,7 @@ export class SnippetSession {
return selection;
}

static createEditsAndSnippets(editor: IActiveCodeEditor, template: string, overwriteBefore: number, overwriteAfter: number, enforceFinalTabstop: boolean, adjustWhitespace: boolean, clipboardText: string | undefined, overtypingCapturer: OvertypingCapturer | undefined, languageConfigurationService: ILanguageConfigurationService): { edits: IIdentifiedSingleEditOperation[]; snippets: OneSnippet[] } {
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[] } {
const edits: IIdentifiedSingleEditOperation[] = [];
const snippets: OneSnippet[] = [];

Expand Down Expand Up @@ -518,22 +523,79 @@ export class SnippetSession {
return { edits, snippets };
}

private readonly _editor: IActiveCodeEditor;
private readonly _template: string;
private readonly _templateMerges: [number, number, string][] = [];
private readonly _options: ISnippetSessionInsertOptions;
static createEditsAndSnippetsFromEdits(editor: IActiveCodeEditor, snippetEdits: ISnippetEdit[], enforceFinalTabstop: boolean, adjustWhitespace: boolean, clipboardText: string | undefined, overtypingCapturer: OvertypingCapturer | undefined, languageConfigurationService: ILanguageConfigurationService): { edits: IIdentifiedSingleEditOperation[]; snippets: OneSnippet[] } {

if (!editor.hasModel() || snippetEdits.length === 0) {
return { edits: [], snippets: [] };
}

const edits: IIdentifiedSingleEditOperation[] = [];
const model = editor.getModel();

const parser = new SnippetParser();
const snippet = new TextmateSnippet();

//
snippetEdits = snippetEdits.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
let offset = 0;
for (let i = 0; i < snippetEdits.length; i++) {

const { range, template } = snippetEdits[i];

// gaps between snippet edits are appended as text nodes. this
// ensures placeholder-offsets are later correct
if (i > 0) {
const lastRange = snippetEdits[i - 1].range;
const textRange = Range.fromPositions(lastRange.getEndPosition(), range.getStartPosition());
const textNode = new Text(model.getValueInRange(textRange));
snippet.appendChild(textNode);
offset += textNode.value.length;
}

parser.parseFragment(template, snippet);

const snippetText = snippet.toString();
const snippetFragmentText = snippetText.slice(offset);
offset = snippetText.length;

// make edit
const edit: IIdentifiedSingleEditOperation = EditOperation.replace(range, snippetFragmentText);
edit.identifier = { major: i, minor: 0 }; // mark the edit so only our undo edits will be used to generate end cursors
edit._isTracked = true;
edits.push(edit);
}

//
parser.ensureFinalTabstop(snippet, enforceFinalTabstop, true);

// snippet variables resolver
const resolver = new CompositeSnippetVariableResolver([
editor.invokeWithinContext(accessor => new ModelBasedVariableResolver(accessor.get(ILabelService), model)),
new ClipboardBasedVariableResolver(() => clipboardText, 0, editor.getSelections().length, editor.getOption(EditorOption.multiCursorPaste) === 'spread'),
new SelectionBasedVariableResolver(model, editor.getSelection(), 0, overtypingCapturer),
new CommentBasedVariableResolver(model, editor.getSelection(), languageConfigurationService),
new TimeBasedVariableResolver,
new WorkspaceBasedVariableResolver(editor.invokeWithinContext(accessor => accessor.get(IWorkspaceContextService))),
new RandomBasedVariableResolver,
]);
snippet.resolveVariables(resolver);


return {
edits,
snippets: [new OneSnippet(editor, snippet, '')]
};
}

private readonly _templateMerges: [number, number, string | ISnippetEdit[]][] = [];
private _snippets: OneSnippet[] = [];

constructor(
editor: IActiveCodeEditor,
template: string,
options: ISnippetSessionInsertOptions = _defaultOptions,
private readonly _editor: IActiveCodeEditor,
private readonly _template: string | ISnippetEdit[],
private readonly _options: ISnippetSessionInsertOptions = _defaultOptions,
@ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService
) {
this._editor = editor;
this._template = template;
this._options = options;
}
) { }

dispose(): void {
dispose(this._snippets);
Expand All @@ -549,7 +611,10 @@ export class SnippetSession {
}

// 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._languageConfigurationService);
const { edits, snippets } = typeof this._template === 'string'
? SnippetSession.createEditsAndSnippetsFromSelections(this._editor, this._template, this._options.overwriteBefore, this._options.overwriteAfter, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer, this._languageConfigurationService)
: SnippetSession.createEditsAndSnippetsFromEdits(this._editor, this._template, false, this._options.adjustWhitespace, this._options.clipboardText, this._options.overtypingCapturer, this._languageConfigurationService);

this._snippets = snippets;

this._editor.executeEdits('snippet', edits, _undoEdits => {
Expand All @@ -576,7 +641,7 @@ export class SnippetSession {
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._languageConfigurationService);
const { edits, snippets } = SnippetSession.createEditsAndSnippetsFromSelections(this._editor, template, options.overwriteBefore, options.overwriteAfter, true, options.adjustWhitespace, options.clipboardText, options.overtypingCapturer, this._languageConfigurationService);

this._editor.executeEdits('snippet', edits, _undoEdits => {
// Sometimes, the text buffer will remove automatic whitespace when doing any edits,
Expand Down
Loading

0 comments on commit 71c221c

Please sign in to comment.