/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { isNonEmptyArray } from 'vs/base/common/arrays'; import { TimeoutTimer } from 'vs/base/common/async'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { IDisposable, dispose, DisposableStore, isDisposable } from 'vs/base/common/lifecycle'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { CursorChangeReason, ICursorSelectionChangedEvent } from 'vs/editor/common/controller/cursorEvents'; import { Position, IPosition } from 'vs/editor/common/core/position'; import { Selection } from 'vs/editor/common/core/selection'; import { ITextModel, IWordAtPosition } from 'vs/editor/common/model'; import { CompletionItemProvider, StandardTokenType, CompletionContext, CompletionProviderRegistry, CompletionTriggerKind, CompletionItemKind } from 'vs/editor/common/modes'; import { CompletionModel } from './completionModel'; import { CompletionItem, getSuggestionComparator, provideSuggestionItems, getSnippetSuggestSupport, SnippetSortOrder, CompletionOptions } from './suggest'; import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; import { WordDistance } from 'vs/editor/contrib/suggest/wordDistance'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; export interface ICancelEvent { readonly retrigger: boolean; } export interface ITriggerEvent { readonly auto: boolean; readonly shy: boolean; readonly position: IPosition; } export interface ISuggestEvent { readonly completionModel: CompletionModel; readonly isFrozen: boolean; readonly auto: boolean; readonly shy: boolean; } export interface SuggestTriggerContext { readonly auto: boolean; readonly shy: boolean; readonly triggerCharacter?: string; } export class LineContext { static shouldAutoTrigger(editor: ICodeEditor): boolean { if (!editor.hasModel()) { return false; } const model = editor.getModel(); const pos = editor.getPosition(); model.tokenizeIfCheap(pos.lineNumber); const word = model.getWordAtPosition(pos); if (!word) { return false; } if (word.endColumn !== pos.column) { return false; } if (!isNaN(Number(word.word))) { return false; } return true; } readonly lineNumber: number; readonly column: number; readonly leadingLineContent: string; readonly leadingWord: IWordAtPosition; readonly auto: boolean; readonly shy: boolean; constructor(model: ITextModel, position: Position, auto: boolean, shy: boolean) { this.leadingLineContent = model.getLineContent(position.lineNumber).substr(0, position.column - 1); this.leadingWord = model.getWordUntilPosition(position); this.lineNumber = position.lineNumber; this.column = position.column; this.auto = auto; this.shy = shy; } } export const enum State { Idle = 0, Manual = 1, Auto = 2 } export class SuggestModel implements IDisposable { private readonly _toDispose = new DisposableStore(); private _quickSuggestDelay: number = 10; private _triggerCharacterListener?: IDisposable; private readonly _triggerQuickSuggest = new TimeoutTimer(); private _state: State = State.Idle; private _requestToken?: CancellationTokenSource; private _context?: LineContext; private _currentSelection: Selection; private _completionModel: CompletionModel | undefined; private readonly _completionDisposables = new DisposableStore(); private readonly _onDidCancel = new Emitter(); private readonly _onDidTrigger = new Emitter(); private readonly _onDidSuggest = new Emitter(); readonly onDidCancel: Event = this._onDidCancel.event; readonly onDidTrigger: Event = this._onDidTrigger.event; readonly onDidSuggest: Event = this._onDidSuggest.event; constructor( private readonly _editor: ICodeEditor, private readonly _editorWorker: IEditorWorkerService ) { this._currentSelection = this._editor.getSelection() || new Selection(1, 1, 1, 1); // wire up various listeners this._toDispose.add(this._editor.onDidChangeModel(() => { this._updateTriggerCharacters(); this.cancel(); })); this._toDispose.add(this._editor.onDidChangeModelLanguage(() => { this._updateTriggerCharacters(); this.cancel(); })); this._toDispose.add(this._editor.onDidChangeConfiguration(() => { this._updateTriggerCharacters(); this._updateQuickSuggest(); })); this._toDispose.add(CompletionProviderRegistry.onDidChange(() => { this._updateTriggerCharacters(); this._updateActiveSuggestSession(); })); this._toDispose.add(this._editor.onDidChangeCursorSelection(e => { this._onCursorChange(e); })); let editorIsComposing = false; this._toDispose.add(this._editor.onCompositionStart(() => { editorIsComposing = true; })); this._toDispose.add(this._editor.onCompositionEnd(() => { // refilter when composition ends editorIsComposing = false; this._refilterCompletionItems(); })); this._toDispose.add(this._editor.onDidChangeModelContent(() => { // only filter completions when the editor isn't // composing a character, e.g. ¨ + u makes ü but just // ¨ cannot be used for filtering if (!editorIsComposing) { this._refilterCompletionItems(); } })); this._updateTriggerCharacters(); this._updateQuickSuggest(); } dispose(): void { dispose(this._triggerCharacterListener); dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]); this._toDispose.dispose(); this._completionDisposables.dispose(); this.cancel(); } // --- handle configuration & precondition changes private _updateQuickSuggest(): void { this._quickSuggestDelay = this._editor.getOption(EditorOption.quickSuggestionsDelay); if (isNaN(this._quickSuggestDelay) || (!this._quickSuggestDelay && this._quickSuggestDelay !== 0) || this._quickSuggestDelay < 0) { this._quickSuggestDelay = 10; } } private _updateTriggerCharacters(): void { dispose(this._triggerCharacterListener); if (this._editor.getOption(EditorOption.readOnly) || !this._editor.hasModel() || !this._editor.getOption(EditorOption.suggestOnTriggerCharacters)) { return; } const supportsByTriggerCharacter: { [ch: string]: Set } = Object.create(null); for (const support of CompletionProviderRegistry.all(this._editor.getModel())) { for (const ch of support.triggerCharacters || []) { let set = supportsByTriggerCharacter[ch]; if (!set) { set = supportsByTriggerCharacter[ch] = new Set(); set.add(getSnippetSuggestSupport()); } set.add(support); } } this._triggerCharacterListener = this._editor.onDidType(text => { const lastChar = text.charAt(text.length - 1); const supports = supportsByTriggerCharacter[lastChar]; if (supports) { // keep existing items that where not computed by the // supports/providers that want to trigger now const items: CompletionItem[] | undefined = this._completionModel ? this._completionModel.adopt(supports) : undefined; this.trigger({ auto: true, shy: false, triggerCharacter: lastChar }, Boolean(this._completionModel), supports, items); } }); } // --- trigger/retrigger/cancel suggest get state(): State { return this._state; } cancel(retrigger: boolean = false): void { if (this._state !== State.Idle) { this._triggerQuickSuggest.cancel(); if (this._requestToken) { this._requestToken.cancel(); this._requestToken = undefined; } this._state = State.Idle; this._completionModel = undefined; this._context = undefined; this._onDidCancel.fire({ retrigger }); } } clear() { this._completionDisposables.clear(); } private _updateActiveSuggestSession(): void { if (this._state !== State.Idle) { if (!this._editor.hasModel() || !CompletionProviderRegistry.has(this._editor.getModel())) { this.cancel(); } else { this.trigger({ auto: this._state === State.Auto, shy: false }, true); } } } private _onCursorChange(e: ICursorSelectionChangedEvent): void { if (!this._editor.hasModel()) { return; } const model = this._editor.getModel(); const prevSelection = this._currentSelection; this._currentSelection = this._editor.getSelection(); if (!e.selection.isEmpty() || e.reason !== CursorChangeReason.NotSet || (e.source !== 'keyboard' && e.source !== 'deleteLeft') ) { // Early exit if nothing needs to be done! // Leave some form of early exit check here if you wish to continue being a cursor position change listener ;) if (this._state !== State.Idle) { this.cancel(); } return; } if (!CompletionProviderRegistry.has(model)) { return; } if (this._state === State.Idle) { if (this._editor.getOption(EditorOption.quickSuggestions) === false) { // not enabled return; } if (!prevSelection.containsRange(this._currentSelection) && !prevSelection.getEndPosition().isBeforeOrEqual(this._currentSelection.getPosition())) { // cursor didn't move RIGHT return; } if (this._editor.getOption(EditorOption.suggest).snippetsPreventQuickSuggestions && SnippetController2.get(this._editor).isInSnippet()) { // no quick suggestion when in snippet mode return; } this.cancel(); this._triggerQuickSuggest.cancelAndSet(() => { if (this._state !== State.Idle) { return; } if (!LineContext.shouldAutoTrigger(this._editor)) { return; } if (!this._editor.hasModel()) { return; } const model = this._editor.getModel(); const pos = this._editor.getPosition(); // validate enabled now const quickSuggestions = this._editor.getOption(EditorOption.quickSuggestions); if (quickSuggestions === false) { return; } else if (quickSuggestions === true) { // all good } else { // Check the type of the token that triggered this model.tokenizeIfCheap(pos.lineNumber); const lineTokens = model.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); const inValidScope = quickSuggestions.other && tokenType === StandardTokenType.Other || quickSuggestions.comments && tokenType === StandardTokenType.Comment || quickSuggestions.strings && tokenType === StandardTokenType.String; if (!inValidScope) { return; } } // we made it till here -> trigger now this.trigger({ auto: true, shy: false }); }, this._quickSuggestDelay); } } private _refilterCompletionItems(): void { // Re-filter suggestions. This MUST run async because filtering/scoring // uses the model content AND the cursor position. The latter is NOT // updated when the document has changed (the event which drives this method) // and therefore a little pause (next mirco task) is needed. See: // https://stackoverflow.com/questions/25915634/difference-between-microtask-and-macrotask-within-an-event-loop-context#25933985 Promise.resolve().then(() => { if (this._state === State.Idle) { return; } if (!this._editor.hasModel()) { return; } const model = this._editor.getModel(); const position = this._editor.getPosition(); const ctx = new LineContext(model, position, this._state === State.Auto, false); this._onNewContext(ctx); }); } trigger(context: SuggestTriggerContext, retrigger: boolean = false, onlyFrom?: Set, existingItems?: CompletionItem[]): void { if (!this._editor.hasModel()) { return; } const model = this._editor.getModel(); const auto = context.auto; const ctx = new LineContext(model, this._editor.getPosition(), auto, context.shy); // Cancel previous requests, change state & update UI this.cancel(retrigger); this._state = auto ? State.Auto : State.Manual; this._onDidTrigger.fire({ auto, shy: context.shy, position: this._editor.getPosition() }); // Capture context when request was sent this._context = ctx; // Build context for request let suggestCtx: CompletionContext; if (context.triggerCharacter) { suggestCtx = { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter: context.triggerCharacter }; } else if (onlyFrom && onlyFrom.size > 0) { suggestCtx = { triggerKind: CompletionTriggerKind.TriggerForIncompleteCompletions }; } else { suggestCtx = { triggerKind: CompletionTriggerKind.Invoke }; } this._requestToken = new CancellationTokenSource(); // kind filter and snippet sort rules const snippetSuggestions = this._editor.getOption(EditorOption.snippetSuggestions); let snippetSortOrder = SnippetSortOrder.Inline; switch (snippetSuggestions) { case 'top': snippetSortOrder = SnippetSortOrder.Top; break; // ↓ that's the default anyways... // case 'inline': // snippetSortOrder = SnippetSortOrder.Inline; // break; case 'bottom': snippetSortOrder = SnippetSortOrder.Bottom; break; } let itemKindFilter = SuggestModel._createItemKindFilter(this._editor); let wordDistance = WordDistance.create(this._editorWorker, this._editor); let items = provideSuggestionItems( model, this._editor.getPosition(), new CompletionOptions(snippetSortOrder, itemKindFilter, onlyFrom), suggestCtx, this._requestToken.token ); Promise.all([items, wordDistance]).then(([items, wordDistance]) => { dispose(this._requestToken); if (this._state === State.Idle) { return; } if (!this._editor.hasModel()) { return; } const model = this._editor.getModel(); if (isNonEmptyArray(existingItems)) { const cmpFn = getSuggestionComparator(snippetSortOrder); items = items.concat(existingItems).sort(cmpFn); } const ctx = new LineContext(model, this._editor.getPosition(), auto, context.shy); this._completionModel = new CompletionModel(items, this._context!.column, { leadingLineContent: ctx.leadingLineContent, characterCountDelta: ctx.column - this._context!.column }, wordDistance, this._editor.getOption(EditorOption.suggest), this._editor.getOption(EditorOption.snippetSuggestions) ); // store containers so that they can be disposed later for (const item of items) { if (isDisposable(item.container)) { this._completionDisposables.add(item.container); } } this._onNewContext(ctx); }).catch(onUnexpectedError); } private static _createItemKindFilter(editor: ICodeEditor): Set { // kind filter and snippet sort rules const result = new Set(); // snippet setting const snippetSuggestions = editor.getOption(EditorOption.snippetSuggestions); if (snippetSuggestions === 'none') { result.add(CompletionItemKind.Snippet); } // type setting const suggestOptions = editor.getOption(EditorOption.suggest); if (!suggestOptions.showMethods) { result.add(CompletionItemKind.Method); } if (!suggestOptions.showFunctions) { result.add(CompletionItemKind.Function); } if (!suggestOptions.showConstructors) { result.add(CompletionItemKind.Constructor); } if (!suggestOptions.showFields) { result.add(CompletionItemKind.Field); } if (!suggestOptions.showVariables) { result.add(CompletionItemKind.Variable); } if (!suggestOptions.showClasses) { result.add(CompletionItemKind.Class); } if (!suggestOptions.showStructs) { result.add(CompletionItemKind.Struct); } if (!suggestOptions.showInterfaces) { result.add(CompletionItemKind.Interface); } if (!suggestOptions.showModules) { result.add(CompletionItemKind.Module); } if (!suggestOptions.showProperties) { result.add(CompletionItemKind.Property); } if (!suggestOptions.showEvents) { result.add(CompletionItemKind.Event); } if (!suggestOptions.showOperators) { result.add(CompletionItemKind.Operator); } if (!suggestOptions.showUnits) { result.add(CompletionItemKind.Unit); } if (!suggestOptions.showValues) { result.add(CompletionItemKind.Value); } if (!suggestOptions.showConstants) { result.add(CompletionItemKind.Constant); } if (!suggestOptions.showEnums) { result.add(CompletionItemKind.Enum); } if (!suggestOptions.showEnumMembers) { result.add(CompletionItemKind.EnumMember); } if (!suggestOptions.showKeywords) { result.add(CompletionItemKind.Keyword); } if (!suggestOptions.showWords) { result.add(CompletionItemKind.Text); } if (!suggestOptions.showColors) { result.add(CompletionItemKind.Color); } if (!suggestOptions.showFiles) { result.add(CompletionItemKind.File); } if (!suggestOptions.showReferences) { result.add(CompletionItemKind.Reference); } if (!suggestOptions.showColors) { result.add(CompletionItemKind.Customcolor); } if (!suggestOptions.showFolders) { result.add(CompletionItemKind.Folder); } if (!suggestOptions.showTypeParameters) { result.add(CompletionItemKind.TypeParameter); } if (!suggestOptions.showSnippets) { result.add(CompletionItemKind.Snippet); } return result; } private _onNewContext(ctx: LineContext): void { if (!this._context) { // happens when 24x7 IntelliSense is enabled and still in its delay return; } if (ctx.lineNumber !== this._context.lineNumber) { // e.g. happens when pressing Enter while IntelliSense is computed this.cancel(); return; } if (ctx.leadingWord.startColumn < this._context.leadingWord.startColumn) { // happens when the current word gets outdented this.cancel(); return; } if (ctx.column < this._context.column) { // typed -> moved cursor LEFT -> retrigger if still on a word if (ctx.leadingWord.word) { this.trigger({ auto: this._context.auto, shy: false }, true); } else { this.cancel(); } return; } if (!this._completionModel) { // happens when IntelliSense is not yet computed return; } if (ctx.column > this._context.column && this._completionModel.incomplete.size > 0 && ctx.leadingWord.word.length !== 0) { // typed -> moved cursor RIGHT & incomple model & still on a word -> retrigger const { incomplete } = this._completionModel; const adopted = this._completionModel.adopt(incomplete); this.trigger({ auto: this._state === State.Auto, shy: false }, true, incomplete, adopted); } else { // typed -> moved cursor RIGHT -> update UI let oldLineContext = this._completionModel.lineContext; let isFrozen = false; this._completionModel.lineContext = { leadingLineContent: ctx.leadingLineContent, characterCountDelta: ctx.column - this._context.column }; if (this._completionModel.items.length === 0) { if (LineContext.shouldAutoTrigger(this._editor) && this._context.leadingWord.endColumn < ctx.leadingWord.startColumn) { // retrigger when heading into a new word this.trigger({ auto: this._context.auto, shy: false }, true); return; } if (!this._context.auto) { // freeze when IntelliSense was manually requested this._completionModel.lineContext = oldLineContext; isFrozen = this._completionModel.items.length > 0; if (isFrozen && ctx.leadingWord.word.length === 0) { // there were results before but now there aren't // and also we are not on a word anymore -> cancel this.cancel(); return; } } else { // nothing left this.cancel(); return; } } this._onDidSuggest.fire({ completionModel: this._completionModel, auto: this._context.auto, shy: this._context.shy, isFrozen, }); } } }