Path: blob/main/src/vs/editor/contrib/suggest/browser/suggest.ts
4797 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { CancellationError, isCancellationError, onUnexpectedExternalError } from '../../../../base/common/errors.js';7import { FuzzyScore } from '../../../../base/common/filters.js';8import { DisposableStore, IDisposable, isDisposable } from '../../../../base/common/lifecycle.js';9import { StopWatch } from '../../../../base/common/stopwatch.js';10import { assertType } from '../../../../base/common/types.js';11import { URI } from '../../../../base/common/uri.js';12import { ICodeEditor } from '../../../browser/editorBrowser.js';13import { IPosition, Position } from '../../../common/core/position.js';14import { Range } from '../../../common/core/range.js';15import { IEditorContribution } from '../../../common/editorCommon.js';16import { ITextModel } from '../../../common/model.js';17import * as languages from '../../../common/languages.js';18import { ITextModelService } from '../../../common/services/resolverService.js';19import { SnippetParser } from '../../snippet/browser/snippetParser.js';20import { localize } from '../../../../nls.js';21import { MenuId } from '../../../../platform/actions/common/actions.js';22import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';23import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';24import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';25import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';26import { historyNavigationVisible } from '../../../../platform/history/browser/contextScopedHistoryWidget.js';27import { InternalQuickSuggestionsOptions, QuickSuggestionsValue } from '../../../common/config/editorOptions.js';28import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';29import { StandardTokenType } from '../../../common/encodedTokenAttributes.js';3031export const Context = {32Visible: historyNavigationVisible,33HasFocusedSuggestion: new RawContextKey<boolean>('suggestWidgetHasFocusedSuggestion', false, localize('suggestWidgetHasSelection', "Whether any suggestion is focused")),34DetailsVisible: new RawContextKey<boolean>('suggestWidgetDetailsVisible', false, localize('suggestWidgetDetailsVisible', "Whether suggestion details are visible")),35DetailsFocused: new RawContextKey<boolean>('suggestWidgetDetailsFocused', false, localize('suggestWidgetDetailsFocused', "Whether the details pane of the suggest widget has focus")),36MultipleSuggestions: new RawContextKey<boolean>('suggestWidgetMultipleSuggestions', false, localize('suggestWidgetMultipleSuggestions', "Whether there are multiple suggestions to pick from")),37MakesTextEdit: new RawContextKey<boolean>('suggestionMakesTextEdit', true, localize('suggestionMakesTextEdit', "Whether inserting the current suggestion yields in a change or has everything already been typed")),38AcceptSuggestionsOnEnter: new RawContextKey<boolean>('acceptSuggestionOnEnter', true, localize('acceptSuggestionOnEnter', "Whether suggestions are inserted when pressing Enter")),39HasInsertAndReplaceRange: new RawContextKey<boolean>('suggestionHasInsertAndReplaceRange', false, localize('suggestionHasInsertAndReplaceRange', "Whether the current suggestion has insert and replace behaviour")),40InsertMode: new RawContextKey<'insert' | 'replace'>('suggestionInsertMode', undefined, { type: 'string', description: localize('suggestionInsertMode', "Whether the default behaviour is to insert or replace") }),41CanResolve: new RawContextKey<boolean>('suggestionCanResolve', false, localize('suggestionCanResolve', "Whether the current suggestion supports to resolve further details")),42};4344export const suggestWidgetStatusbarMenu = new MenuId('suggestWidgetStatusBar');4546export class CompletionItem {4748_brand!: 'ISuggestionItem';4950//51readonly editStart: IPosition;52readonly editInsertEnd: IPosition;53readonly editReplaceEnd: IPosition;5455//56readonly textLabel: string;5758// perf59readonly labelLow: string;60readonly sortTextLow?: string;61readonly filterTextLow?: string;6263// validation64readonly isInvalid: boolean = false;6566// sorting, filtering67score: FuzzyScore = FuzzyScore.Default;68distance: number = 0;69idx?: number;70word?: string;7172// instrumentation73readonly extensionId?: ExtensionIdentifier;7475// resolving76private _resolveDuration?: number;77private _resolveCache?: Promise<void>;7879constructor(80readonly position: IPosition,81readonly completion: languages.CompletionItem,82readonly container: languages.CompletionList,83readonly provider: languages.CompletionItemProvider,84) {85this.textLabel = typeof completion.label === 'string'86? completion.label87: completion.label?.label;8889// ensure lower-variants (perf)90this.labelLow = this.textLabel.toLowerCase();9192// validate label93this.isInvalid = !this.textLabel;9495this.sortTextLow = completion.sortText && completion.sortText.toLowerCase();96this.filterTextLow = completion.filterText && completion.filterText.toLowerCase();9798this.extensionId = completion.extensionId;99100// normalize ranges101if (Range.isIRange(completion.range)) {102this.editStart = new Position(completion.range.startLineNumber, completion.range.startColumn);103this.editInsertEnd = new Position(completion.range.endLineNumber, completion.range.endColumn);104this.editReplaceEnd = new Position(completion.range.endLineNumber, completion.range.endColumn);105106// validate range107this.isInvalid = this.isInvalid108|| Range.spansMultipleLines(completion.range) || completion.range.startLineNumber !== position.lineNumber;109110} else {111this.editStart = new Position(completion.range.insert.startLineNumber, completion.range.insert.startColumn);112this.editInsertEnd = new Position(completion.range.insert.endLineNumber, completion.range.insert.endColumn);113this.editReplaceEnd = new Position(completion.range.replace.endLineNumber, completion.range.replace.endColumn);114115// validate ranges116this.isInvalid = this.isInvalid117|| Range.spansMultipleLines(completion.range.insert) || Range.spansMultipleLines(completion.range.replace)118|| completion.range.insert.startLineNumber !== position.lineNumber || completion.range.replace.startLineNumber !== position.lineNumber119|| completion.range.insert.startColumn !== completion.range.replace.startColumn;120}121122// create the suggestion resolver123if (typeof provider.resolveCompletionItem !== 'function') {124this._resolveCache = Promise.resolve();125this._resolveDuration = 0;126}127}128129// ---- resolving130131get isResolved(): boolean {132return this._resolveDuration !== undefined;133}134135get resolveDuration(): number {136return this._resolveDuration !== undefined ? this._resolveDuration : -1;137}138139async resolve(token: CancellationToken) {140if (!this._resolveCache) {141const sub = token.onCancellationRequested(() => {142this._resolveCache = undefined;143this._resolveDuration = undefined;144});145const sw = new StopWatch(true);146this._resolveCache = Promise.resolve(this.provider.resolveCompletionItem!(this.completion, token)).then(value => {147Object.assign(this.completion, value);148this._resolveDuration = sw.elapsed();149}, err => {150if (isCancellationError(err)) {151// the IPC queue will reject the request with the152// cancellation error -> reset cached153this._resolveCache = undefined;154this._resolveDuration = undefined;155}156}).finally(() => {157sub.dispose();158});159}160return this._resolveCache;161}162}163164export const enum SnippetSortOrder {165Top, Inline, Bottom166}167168export class CompletionOptions {169170static readonly default = new CompletionOptions();171172constructor(173readonly snippetSortOrder = SnippetSortOrder.Bottom,174readonly kindFilter = new Set<languages.CompletionItemKind>(),175readonly providerFilter = new Set<languages.CompletionItemProvider>(),176readonly providerItemsToReuse: ReadonlyMap<languages.CompletionItemProvider, CompletionItem[]> = new Map<languages.CompletionItemProvider, CompletionItem[]>(),177readonly showDeprecated = true178) { }179}180181let _snippetSuggestSupport: languages.CompletionItemProvider | undefined;182183export function getSnippetSuggestSupport(): languages.CompletionItemProvider | undefined {184return _snippetSuggestSupport;185}186187export function setSnippetSuggestSupport(support: languages.CompletionItemProvider | undefined): languages.CompletionItemProvider | undefined {188const old = _snippetSuggestSupport;189_snippetSuggestSupport = support;190return old;191}192193export interface CompletionDurationEntry {194readonly providerName: string;195readonly elapsedProvider: number;196readonly elapsedOverall: number;197}198199export interface CompletionDurations {200readonly entries: readonly CompletionDurationEntry[];201readonly elapsed: number;202}203204export class CompletionItemModel {205constructor(206readonly items: CompletionItem[],207readonly needsClipboard: boolean,208readonly durations: CompletionDurations,209readonly disposable: IDisposable,210) { }211}212213export async function provideSuggestionItems(214registry: LanguageFeatureRegistry<languages.CompletionItemProvider>,215model: ITextModel,216position: Position,217options: CompletionOptions = CompletionOptions.default,218context: languages.CompletionContext = { triggerKind: languages.CompletionTriggerKind.Invoke },219token: CancellationToken = CancellationToken.None220): Promise<CompletionItemModel> {221222const sw = new StopWatch();223position = position.clone();224225const word = model.getWordAtPosition(position);226const defaultReplaceRange = word ? new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) : Range.fromPositions(position);227const defaultRange = { replace: defaultReplaceRange, insert: defaultReplaceRange.setEndPosition(position.lineNumber, position.column) };228229const result: CompletionItem[] = [];230const disposables = new DisposableStore();231const durations: CompletionDurationEntry[] = [];232let needsClipboard = false;233234const onCompletionList = (provider: languages.CompletionItemProvider, container: languages.CompletionList | null | undefined, sw: StopWatch): boolean => {235let didAddResult = false;236if (!container) {237return didAddResult;238}239for (const suggestion of container.suggestions) {240if (!options.kindFilter.has(suggestion.kind)) {241// skip if not showing deprecated suggestions242if (!options.showDeprecated && suggestion?.tags?.includes(languages.CompletionItemTag.Deprecated)) {243continue;244}245// fill in default range when missing246if (!suggestion.range) {247suggestion.range = defaultRange;248}249// fill in default sortText when missing250if (!suggestion.sortText) {251suggestion.sortText = typeof suggestion.label === 'string' ? suggestion.label : suggestion.label.label;252}253if (!needsClipboard && suggestion.insertTextRules && suggestion.insertTextRules & languages.CompletionItemInsertTextRule.InsertAsSnippet) {254needsClipboard = SnippetParser.guessNeedsClipboard(suggestion.insertText);255}256result.push(new CompletionItem(position, suggestion, container, provider));257didAddResult = true;258}259}260if (isDisposable(container)) {261disposables.add(container);262}263durations.push({264providerName: provider._debugDisplayName ?? 'unknown_provider', elapsedProvider: container.duration ?? -1, elapsedOverall: sw.elapsed()265});266return didAddResult;267};268269// ask for snippets in parallel to asking "real" providers. Only do something if configured to270// do so - no snippet filter, no special-providers-only request271const snippetCompletions = (async () => {272if (!_snippetSuggestSupport || options.kindFilter.has(languages.CompletionItemKind.Snippet)) {273return;274}275// we have items from a previous session that we can reuse276const reuseItems = options.providerItemsToReuse.get(_snippetSuggestSupport);277if (reuseItems) {278reuseItems.forEach(item => result.push(item));279return;280}281if (options.providerFilter.size > 0 && !options.providerFilter.has(_snippetSuggestSupport)) {282return;283}284const sw = new StopWatch();285const list = await _snippetSuggestSupport.provideCompletionItems(model, position, context, token);286onCompletionList(_snippetSuggestSupport, list, sw);287})();288289// add suggestions from contributed providers - providers are ordered in groups of290// equal score and once a group produces a result the process stops291// get provider groups, always add snippet suggestion provider292for (const providerGroup of registry.orderedGroups(model)) {293294// for each support in the group ask for suggestions295let didAddResult = false;296await Promise.all(providerGroup.map(async provider => {297// we have items from a previous session that we can reuse298if (options.providerItemsToReuse.has(provider)) {299const items = options.providerItemsToReuse.get(provider)!;300items.forEach(item => result.push(item));301didAddResult = didAddResult || items.length > 0;302return;303}304// check if this provider is filtered out305if (options.providerFilter.size > 0 && !options.providerFilter.has(provider)) {306return;307}308try {309const sw = new StopWatch();310const list = await provider.provideCompletionItems(model, position, context, token);311didAddResult = onCompletionList(provider, list, sw) || didAddResult;312} catch (err) {313onUnexpectedExternalError(err);314}315}));316317if (didAddResult || token.isCancellationRequested) {318break;319}320}321322await snippetCompletions;323324if (token.isCancellationRequested) {325disposables.dispose();326return Promise.reject(new CancellationError());327}328329return new CompletionItemModel(330result.sort(getSuggestionComparator(options.snippetSortOrder)),331needsClipboard,332{ entries: durations, elapsed: sw.elapsed() },333disposables,334);335}336337338function defaultComparator(a: CompletionItem, b: CompletionItem): number {339// check with 'sortText'340if (a.sortTextLow && b.sortTextLow) {341if (a.sortTextLow < b.sortTextLow) {342return -1;343} else if (a.sortTextLow > b.sortTextLow) {344return 1;345}346}347// check with 'label'348if (a.textLabel < b.textLabel) {349return -1;350} else if (a.textLabel > b.textLabel) {351return 1;352}353// check with 'type'354return a.completion.kind - b.completion.kind;355}356357function snippetUpComparator(a: CompletionItem, b: CompletionItem): number {358if (a.completion.kind !== b.completion.kind) {359if (a.completion.kind === languages.CompletionItemKind.Snippet) {360return -1;361} else if (b.completion.kind === languages.CompletionItemKind.Snippet) {362return 1;363}364}365return defaultComparator(a, b);366}367368function snippetDownComparator(a: CompletionItem, b: CompletionItem): number {369if (a.completion.kind !== b.completion.kind) {370if (a.completion.kind === languages.CompletionItemKind.Snippet) {371return 1;372} else if (b.completion.kind === languages.CompletionItemKind.Snippet) {373return -1;374}375}376return defaultComparator(a, b);377}378379interface Comparator<T> { (a: T, b: T): number }380const _snippetComparators = new Map<SnippetSortOrder, Comparator<CompletionItem>>();381_snippetComparators.set(SnippetSortOrder.Top, snippetUpComparator);382_snippetComparators.set(SnippetSortOrder.Bottom, snippetDownComparator);383_snippetComparators.set(SnippetSortOrder.Inline, defaultComparator);384385export function getSuggestionComparator(snippetConfig: SnippetSortOrder): (a: CompletionItem, b: CompletionItem) => number {386return _snippetComparators.get(snippetConfig)!;387}388389CommandsRegistry.registerCommand('_executeCompletionItemProvider', async (accessor, ...args: [URI, IPosition, string?, number?]) => {390const [uri, position, triggerCharacter, maxItemsToResolve] = args;391assertType(URI.isUri(uri));392assertType(Position.isIPosition(position));393assertType(typeof triggerCharacter === 'string' || !triggerCharacter);394assertType(typeof maxItemsToResolve === 'number' || !maxItemsToResolve);395396const { completionProvider } = accessor.get(ILanguageFeaturesService);397const ref = await accessor.get(ITextModelService).createModelReference(uri);398try {399400const result: languages.CompletionList = {401incomplete: false,402suggestions: []403};404405const resolving: Promise<unknown>[] = [];406const actualPosition = ref.object.textEditorModel.validatePosition(position);407const completions = await provideSuggestionItems(completionProvider, ref.object.textEditorModel, actualPosition, undefined, { triggerCharacter: triggerCharacter ?? undefined, triggerKind: triggerCharacter ? languages.CompletionTriggerKind.TriggerCharacter : languages.CompletionTriggerKind.Invoke });408for (const item of completions.items) {409if (resolving.length < (maxItemsToResolve ?? 0)) {410resolving.push(item.resolve(CancellationToken.None));411}412result.incomplete = result.incomplete || item.container.incomplete;413result.suggestions.push(item.completion);414}415416try {417await Promise.all(resolving);418return result;419} finally {420setTimeout(() => completions.disposable.dispose(), 100);421}422423} finally {424ref.dispose();425}426427});428429interface SuggestController extends IEditorContribution {430triggerSuggest(onlyFrom?: Set<languages.CompletionItemProvider>, auto?: boolean, noFilter?: boolean): void;431}432433export function showSimpleSuggestions(editor: ICodeEditor, provider: languages.CompletionItemProvider) {434editor.getContribution<SuggestController>('editor.contrib.suggestController')?.triggerSuggest(435new Set<languages.CompletionItemProvider>().add(provider), undefined, true436);437}438439export interface ISuggestItemPreselector {440/**441* The preselector with highest priority is asked first.442*/443readonly priority: number;444445/**446* Is called to preselect a suggest item.447* When -1 is returned, item preselectors with lower priority are asked.448*/449select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number | -1;450}451452453export abstract class QuickSuggestionsOptions {454455static isAllOff(config: InternalQuickSuggestionsOptions): boolean {456return config.other === 'off' && config.comments === 'off' && config.strings === 'off';457}458459static isAllOn(config: InternalQuickSuggestionsOptions): boolean {460return config.other === 'on' && config.comments === 'on' && config.strings === 'on';461}462463static valueFor(config: InternalQuickSuggestionsOptions, tokenType: StandardTokenType): QuickSuggestionsValue {464switch (tokenType) {465case StandardTokenType.Comment: return config.comments;466case StandardTokenType.String: return config.strings;467default: return config.other;468}469}470}471472473