Path: blob/main/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts
4798 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 { assertNever } from '../../../../../base/common/assert.js';6import { AsyncIterableProducer } from '../../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';8import { BugIndicatingError, onUnexpectedExternalError } from '../../../../../base/common/errors.js';9import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';10import { prefixedUuid } from '../../../../../base/common/uuid.js';11import { ICommandService } from '../../../../../platform/commands/common/commands.js';12import { ISingleEditOperation } from '../../../../common/core/editOperation.js';13import { StringReplacement } from '../../../../common/core/edits/stringEdit.js';14import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';15import { Position } from '../../../../common/core/position.js';16import { Range } from '../../../../common/core/range.js';17import { TextReplacement } from '../../../../common/core/edits/textEdit.js';18import { InlineCompletionEndOfLifeReason, InlineCompletionEndOfLifeReasonKind, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider, PartialAcceptInfo, InlineCompletionsDisposeReason, LifetimeSummary, ProviderId, IInlineCompletionHint } from '../../../../common/languages.js';19import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js';20import { ITextModel } from '../../../../common/model.js';21import { fixBracketsInLine } from '../../../../common/model/bracketPairsTextModelPart/fixBrackets.js';22import { SnippetParser, Text } from '../../../snippet/browser/snippetParser.js';23import { ErrorResult, getReadonlyEmptyArray } from '../utils.js';24import { groupByMap } from '../../../../../base/common/collections.js';25import { DirectedGraph } from './graph.js';26import { CachedFunction } from '../../../../../base/common/cache.js';27import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';28import { isDefined } from '../../../../../base/common/types.js';29import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js';30import { EditDeltaInfo } from '../../../../common/textModelEditSource.js';31import { URI } from '../../../../../base/common/uri.js';32import { InlineSuggestionEditKind } from './editKind.js';33import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js';3435export type InlineCompletionContextWithoutUuid = Omit<InlineCompletionContext, 'requestUuid'>;3637export function provideInlineCompletions(38providers: InlineCompletionsProvider[],39position: Position,40model: ITextModel,41context: InlineCompletionContextWithoutUuid,42requestInfo: InlineSuggestRequestInfo,43languageConfigurationService?: ILanguageConfigurationService,44): IInlineCompletionProviderResult {45const requestUuid = prefixedUuid('icr');4647const cancellationTokenSource = new CancellationTokenSource();48let cancelReason: InlineCompletionsDisposeReason | undefined = undefined;4950const contextWithUuid: InlineCompletionContext = { ...context, requestUuid: requestUuid };5152const defaultReplaceRange = getDefaultRange(position, model);5354const providersByGroupId = groupByMap(providers, p => p.groupId);55const yieldsToGraph = DirectedGraph.from(providers, p => {56return p.yieldsToGroupIds?.flatMap(groupId => providersByGroupId.get(groupId) ?? []) ?? [];57});58const { foundCycles } = yieldsToGraph.removeCycles();59if (foundCycles.length > 0) {60onUnexpectedExternalError(new Error(`Inline completions: cyclic yield-to dependency detected.`61+ ` Path: ${foundCycles.map(s => s.toString ? s.toString() : ('' + s)).join(' -> ')}`));62}6364let runningCount = 0;6566const queryProvider = new CachedFunction(async (provider: InlineCompletionsProvider<InlineCompletions>): Promise<InlineSuggestionList | undefined> => {67try {68runningCount++;69if (cancellationTokenSource.token.isCancellationRequested) {70return undefined;71}7273const yieldsTo = yieldsToGraph.getOutgoing(provider);74for (const p of yieldsTo) {75// We know there is no cycle, so no recursion here76const result = await queryProvider.get(p);77if (result) {78for (const item of result.inlineSuggestions.items) {79if (item.isInlineEdit || typeof item.insertText !== 'string' && item.insertText !== undefined) {80return undefined;81}82if (item.insertText !== undefined) {83const t = new TextReplacement(Range.lift(item.range) ?? defaultReplaceRange, item.insertText);84if (inlineCompletionIsVisible(t, undefined, model, position)) {85return undefined;86}87}8889// else: inline completion is not visible, so lets not block90}91}92}9394let result: InlineCompletions | null | undefined;95const providerStartTime = Date.now();96try {97result = await provider.provideInlineCompletions(model, position, contextWithUuid, cancellationTokenSource.token);98} catch (e) {99onUnexpectedExternalError(e);100return undefined;101}102const providerEndTime = Date.now();103104if (!result) {105return undefined;106}107108const data: InlineSuggestData[] = [];109const list = new InlineSuggestionList(result, data, provider);110list.addRef();111runWhenCancelled(cancellationTokenSource.token, () => {112return list.removeRef(cancelReason);113});114if (cancellationTokenSource.token.isCancellationRequested) {115return undefined; // The list is disposed now, so we cannot return the items!116}117118for (const item of result.items) {119const r = toInlineSuggestData(item, list, defaultReplaceRange, model, languageConfigurationService, contextWithUuid, requestInfo, { startTime: providerStartTime, endTime: providerEndTime });120if (ErrorResult.is(r)) {121r.logError();122continue;123}124data.push(r);125}126127return list;128} finally {129runningCount--;130}131});132133const inlineCompletionLists = AsyncIterableProducer.fromPromisesResolveOrder(providers.map(p => queryProvider.get(p))).filter(isDefined);134135return {136contextWithUuid,137get didAllProvidersReturn() { return runningCount === 0; },138lists: inlineCompletionLists,139cancelAndDispose: reason => {140if (cancelReason !== undefined) {141return;142}143cancelReason = reason;144cancellationTokenSource.dispose(true);145}146};147}148149/** If the token is eventually cancelled, this will not leak either. */150export function runWhenCancelled(token: CancellationToken, callback: () => void): IDisposable {151if (token.isCancellationRequested) {152callback();153return Disposable.None;154} else {155const listener = token.onCancellationRequested(() => {156listener.dispose();157callback();158});159return { dispose: () => listener.dispose() };160}161}162163export interface IInlineCompletionProviderResult {164get didAllProvidersReturn(): boolean;165166contextWithUuid: InlineCompletionContext;167168cancelAndDispose(reason: InlineCompletionsDisposeReason): void;169170lists: AsyncIterableProducer<InlineSuggestionList>;171}172173function toInlineSuggestData(174inlineCompletion: InlineCompletion,175source: InlineSuggestionList,176defaultReplaceRange: Range,177textModel: ITextModel,178languageConfigurationService: ILanguageConfigurationService | undefined,179context: InlineCompletionContext,180requestInfo: InlineSuggestRequestInfo,181providerRequestInfo: InlineSuggestProviderRequestInfo,182): InlineSuggestData | ErrorResult {183184let action: IInlineSuggestDataAction | undefined;185const uri = inlineCompletion.uri ? URI.revive(inlineCompletion.uri) : undefined;186187if (inlineCompletion.jumpToPosition !== undefined) {188action = {189kind: 'jumpTo',190position: Position.lift(inlineCompletion.jumpToPosition),191uri,192};193} else if (inlineCompletion.insertText !== undefined) {194let insertText: string;195let snippetInfo: SnippetInfo | undefined;196let range = inlineCompletion.range ? Range.lift(inlineCompletion.range) : defaultReplaceRange;197198if (typeof inlineCompletion.insertText === 'string') {199insertText = inlineCompletion.insertText;200201if (languageConfigurationService && inlineCompletion.completeBracketPairs) {202insertText = closeBrackets(203insertText,204range.getStartPosition(),205textModel,206languageConfigurationService207);208209// Modify range depending on if brackets are added or removed210const diff = insertText.length - inlineCompletion.insertText.length;211if (diff !== 0) {212range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff);213}214}215216snippetInfo = undefined;217} else if ('snippet' in inlineCompletion.insertText) {218const preBracketCompletionLength = inlineCompletion.insertText.snippet.length;219220if (languageConfigurationService && inlineCompletion.completeBracketPairs) {221inlineCompletion.insertText.snippet = closeBrackets(222inlineCompletion.insertText.snippet,223range.getStartPosition(),224textModel,225languageConfigurationService226);227228// Modify range depending on if brackets are added or removed229const diff = inlineCompletion.insertText.snippet.length - preBracketCompletionLength;230if (diff !== 0) {231range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff);232}233}234235const snippet = new SnippetParser().parse(inlineCompletion.insertText.snippet);236237if (snippet.children.length === 1 && snippet.children[0] instanceof Text) {238insertText = snippet.children[0].value;239snippetInfo = undefined;240} else {241insertText = snippet.toString();242snippetInfo = {243snippet: inlineCompletion.insertText.snippet,244range: range245};246}247} else {248assertNever(inlineCompletion.insertText);249}250action = {251kind: 'edit',252range,253insertText,254snippetInfo,255uri,256alternativeAction: undefined,257};258} else {259action = undefined;260if (!inlineCompletion.hint) {261return ErrorResult.message('Inline completion has no insertText, jumpToPosition nor hint.');262}263}264265return new InlineSuggestData(266action,267inlineCompletion.hint,268inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(),269inlineCompletion,270source,271context,272inlineCompletion.isInlineEdit ?? false,273inlineCompletion.supportsRename ?? false,274requestInfo,275providerRequestInfo,276inlineCompletion.correlationId,277);278}279280export type InlineSuggestSku = { type: string; plan: string };281282export type InlineSuggestRequestInfo = {283startTime: number;284editorType: InlineCompletionEditorType;285languageId: string;286reason: string;287typingInterval: number;288typingIntervalCharacterCount: number;289availableProviders: ProviderId[];290sku: InlineSuggestSku | undefined;291};292293export type InlineSuggestProviderRequestInfo = {294startTime: number;295endTime: number;296};297298export type PartialAcceptance = {299characters: number;300count: number;301ratio: number;302};303304export type RenameInfo = {305createdRename: boolean;306duration: number;307timedOut?: boolean;308droppedOtherEdits?: number;309droppedRenameEdits?: number;310};311312export type InlineSuggestViewData = {313editorType: InlineCompletionEditorType;314renderData?: InlineCompletionViewData;315viewKind?: InlineCompletionViewKind;316};317318export type IInlineSuggestDataAction = IInlineSuggestDataActionEdit | IInlineSuggestDataActionJumpTo;319320export interface IInlineSuggestDataActionEdit {321kind: 'edit';322range: Range;323insertText: string;324snippetInfo: SnippetInfo | undefined;325uri: URI | undefined;326alternativeAction: InlineSuggestAlternativeAction | undefined;327}328329export interface IInlineSuggestDataActionJumpTo {330kind: 'jumpTo';331position: Position;332uri: URI | undefined;333}334335export class InlineSuggestData {336private _didShow = false;337private _timeUntilShown: number | undefined = undefined;338private _timeUntilActuallyShown: number | undefined = undefined;339private _showStartTime: number | undefined = undefined;340private _shownDuration: number = 0;341private _showUncollapsedStartTime: number | undefined = undefined;342private _showUncollapsedDuration: number = 0;343private _notShownReason: string | undefined = undefined;344345private _viewData: InlineSuggestViewData;346private _didReportEndOfLife = false;347private _lastSetEndOfLifeReason: InlineCompletionEndOfLifeReason | undefined = undefined;348private _isPreceeded = false;349private _partiallyAcceptedCount = 0;350private _partiallyAcceptedSinceOriginal: PartialAcceptance = { characters: 0, ratio: 0, count: 0 };351private _renameInfo: RenameInfo | undefined = undefined;352private _editKind: InlineSuggestionEditKind | undefined = undefined;353354get action(): IInlineSuggestDataAction | undefined {355return this._action;356}357358constructor(359private _action: IInlineSuggestDataAction | undefined,360public readonly hint: IInlineCompletionHint | undefined,361public readonly additionalTextEdits: readonly ISingleEditOperation[],362public readonly sourceInlineCompletion: InlineCompletion,363public readonly source: InlineSuggestionList,364public readonly context: InlineCompletionContext,365public readonly isInlineEdit: boolean,366public readonly supportsRename: boolean,367private readonly _requestInfo: InlineSuggestRequestInfo,368private readonly _providerRequestInfo: InlineSuggestProviderRequestInfo,369private readonly _correlationId: string | undefined,370) {371this._viewData = { editorType: _requestInfo.editorType };372}373374public get showInlineEditMenu() { return this.sourceInlineCompletion.showInlineEditMenu ?? false; }375376public get partialAccepts(): PartialAcceptance { return this._partiallyAcceptedSinceOriginal; }377378379public async reportInlineEditShown(commandService: ICommandService, updatedInsertText: string, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, editKind: InlineSuggestionEditKind | undefined, timeWhenShown: number): Promise<void> {380this.updateShownDuration(viewKind);381382if (this._didShow) {383return;384}385this.addPerformanceMarker('shown');386this._didShow = true;387this._editKind = editKind;388this._viewData.viewKind = viewKind;389this._viewData.renderData = viewData;390this._timeUntilShown = timeWhenShown - this._requestInfo.startTime;391this._timeUntilActuallyShown = Date.now() - this._requestInfo.startTime;392393const editDeltaInfo = new EditDeltaInfo(viewData.lineCountModified, viewData.lineCountOriginal, viewData.characterCountModified, viewData.characterCountOriginal);394this.source.provider.handleItemDidShow?.(this.source.inlineSuggestions, this.sourceInlineCompletion, updatedInsertText, editDeltaInfo);395396if (this.sourceInlineCompletion.shownCommand) {397await commandService.executeCommand(this.sourceInlineCompletion.shownCommand.id, ...(this.sourceInlineCompletion.shownCommand.arguments || []));398}399}400401public reportPartialAccept(acceptedCharacters: number, info: PartialAcceptInfo, partialAcceptance: PartialAcceptance) {402this._partiallyAcceptedCount++;403this._partiallyAcceptedSinceOriginal.characters += partialAcceptance.characters;404this._partiallyAcceptedSinceOriginal.ratio = Math.min(this._partiallyAcceptedSinceOriginal.ratio + (1 - this._partiallyAcceptedSinceOriginal.ratio) * partialAcceptance.ratio, 1);405this._partiallyAcceptedSinceOriginal.count += partialAcceptance.count;406407this.source.provider.handlePartialAccept?.(408this.source.inlineSuggestions,409this.sourceInlineCompletion,410acceptedCharacters,411info412);413}414415/**416* Sends the end of life event to the provider.417* If no reason is provided, the last set reason is used.418* If no reason was set, the default reason is used.419*/420public reportEndOfLife(reason?: InlineCompletionEndOfLifeReason): void {421if (this._didReportEndOfLife) {422return;423}424this._didReportEndOfLife = true;425this.reportInlineEditHidden();426427if (!reason) {428reason = this._lastSetEndOfLifeReason ?? { kind: InlineCompletionEndOfLifeReasonKind.Ignored, userTypingDisagreed: false, supersededBy: undefined };429}430431if (reason.kind === InlineCompletionEndOfLifeReasonKind.Rejected && this.source.provider.handleRejection) {432this.source.provider.handleRejection(this.source.inlineSuggestions, this.sourceInlineCompletion);433}434435if (this.source.provider.handleEndOfLifetime) {436const summary: LifetimeSummary = {437requestUuid: this.context.requestUuid,438correlationId: this._correlationId,439selectedSuggestionInfo: !!this.context.selectedSuggestionInfo,440partiallyAccepted: this._partiallyAcceptedCount,441partiallyAcceptedCountSinceOriginal: this._partiallyAcceptedSinceOriginal.count,442partiallyAcceptedRatioSinceOriginal: this._partiallyAcceptedSinceOriginal.ratio,443partiallyAcceptedCharactersSinceOriginal: this._partiallyAcceptedSinceOriginal.characters,444shown: this._didShow,445shownDuration: this._shownDuration,446shownDurationUncollapsed: this._showUncollapsedDuration,447editKind: this._editKind?.toString(),448preceeded: this._isPreceeded,449timeUntilShown: this._timeUntilShown,450timeUntilActuallyShown: this._timeUntilActuallyShown,451timeUntilProviderRequest: this._providerRequestInfo.startTime - this._requestInfo.startTime,452timeUntilProviderResponse: this._providerRequestInfo.endTime - this._requestInfo.startTime,453editorType: this._viewData.editorType,454languageId: this._requestInfo.languageId,455requestReason: this._requestInfo.reason,456viewKind: this._viewData.viewKind,457notShownReason: this._notShownReason,458performanceMarkers: this.performance.toString(),459renameCreated: this._renameInfo?.createdRename,460renameDuration: this._renameInfo?.duration,461renameTimedOut: this._renameInfo?.timedOut,462renameDroppedOtherEdits: this._renameInfo?.droppedOtherEdits,463renameDroppedRenameEdits: this._renameInfo?.droppedRenameEdits,464typingInterval: this._requestInfo.typingInterval,465typingIntervalCharacterCount: this._requestInfo.typingIntervalCharacterCount,466skuPlan: this._requestInfo.sku?.plan,467skuType: this._requestInfo.sku?.type,468availableProviders: this._requestInfo.availableProviders.map(p => p.toString()).join(','),469...this._viewData.renderData?.getData(),470};471this.source.provider.handleEndOfLifetime(this.source.inlineSuggestions, this.sourceInlineCompletion, reason, summary);472}473}474475public setIsPreceeded(partialAccepts: PartialAcceptance): void {476this._isPreceeded = true;477478if (this._partiallyAcceptedSinceOriginal.characters !== 0 || this._partiallyAcceptedSinceOriginal.ratio !== 0 || this._partiallyAcceptedSinceOriginal.count !== 0) {479console.warn('Expected partiallyAcceptedCountSinceOriginal to be { characters: 0, rate: 0, partialAcceptances: 0 } before setIsPreceeded.');480}481this._partiallyAcceptedSinceOriginal = partialAccepts;482}483484public setNotShownReason(reason: string): void {485this._notShownReason ??= reason;486}487488/**489* Sets the end of life reason, but does not send the event to the provider yet.490*/491public setEndOfLifeReason(reason: InlineCompletionEndOfLifeReason): void {492this.reportInlineEditHidden();493this._lastSetEndOfLifeReason = reason;494}495496private updateShownDuration(viewKind: InlineCompletionViewKind) {497const timeNow = Date.now();498if (!this._showStartTime) {499this._showStartTime = timeNow;500}501502const isCollapsed = viewKind === InlineCompletionViewKind.Collapsed;503if (!isCollapsed && this._showUncollapsedStartTime === undefined) {504this._showUncollapsedStartTime = timeNow;505}506507if (isCollapsed && this._showUncollapsedStartTime !== undefined) {508this._showUncollapsedDuration += timeNow - this._showUncollapsedStartTime;509}510}511512private reportInlineEditHidden() {513if (this._showStartTime === undefined) {514return;515}516const timeNow = Date.now();517this._shownDuration += timeNow - this._showStartTime;518this._showStartTime = undefined;519520if (this._showUncollapsedStartTime === undefined) {521return;522}523this._showUncollapsedDuration += timeNow - this._showUncollapsedStartTime;524this._showUncollapsedStartTime = undefined;525}526527public setRenameProcessingInfo(info: RenameInfo): void {528if (this._renameInfo) {529throw new BugIndicatingError('Rename info has already been set.');530}531this._renameInfo = info;532}533534public withAction(action: IInlineSuggestDataAction): InlineSuggestData {535this._action = action;536return this;537}538539private performance = new InlineSuggestionsPerformance();540public addPerformanceMarker(marker: string): void {541this.performance.mark(marker);542}543}544545class InlineSuggestionsPerformance {546private markers: { name: string; timeStamp: number }[] = [];547constructor() {548this.markers.push({ name: 'start', timeStamp: Date.now() });549}550551mark(marker: string): void {552this.markers.push({ name: marker, timeStamp: Date.now() });553}554555toString(): string {556const deltas = [];557for (let i = 1; i < this.markers.length; i++) {558const delta = this.markers[i].timeStamp - this.markers[i - 1].timeStamp;559deltas.push({ [this.markers[i].name]: delta });560}561return JSON.stringify(deltas);562}563}564565export interface SnippetInfo {566snippet: string;567/* Could be different than the main range */568range: Range;569}570571export enum InlineCompletionEditorType {572TextEditor = 'textEditor',573DiffEditor = 'diffEditor',574Notebook = 'notebook',575}576577/**578* A ref counted pointer to the computed `InlineCompletions` and the `InlineCompletionsProvider` that579* computed them.580*/581export class InlineSuggestionList {582private refCount = 0;583constructor(584public readonly inlineSuggestions: InlineCompletions,585public readonly inlineSuggestionsData: readonly InlineSuggestData[],586public readonly provider: InlineCompletionsProvider,587) { }588589addRef(): void {590this.refCount++;591}592593removeRef(reason: InlineCompletionsDisposeReason = { kind: 'other' }): void {594this.refCount--;595if (this.refCount === 0) {596for (const item of this.inlineSuggestionsData) {597// Fallback if it has not been called before598item.reportEndOfLife();599}600this.provider.disposeInlineCompletions(this.inlineSuggestions, reason);601}602}603}604605function getDefaultRange(position: Position, model: ITextModel): Range {606const word = model.getWordAtPosition(position);607const maxColumn = model.getLineMaxColumn(position.lineNumber);608// By default, always replace up until the end of the current line.609// This default might be subject to change!610return word611? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn)612: Range.fromPositions(position, position.with(undefined, maxColumn));613}614615function closeBrackets(text: string, position: Position, model: ITextModel, languageConfigurationService: ILanguageConfigurationService): string {616const currentLine = model.getLineContent(position.lineNumber);617const edit = StringReplacement.replace(new OffsetRange(position.column - 1, currentLine.length), text);618619const proposedLineTokens = model.tokenization.tokenizeLinesAt(position.lineNumber, [edit.replace(currentLine)]);620const textTokens = proposedLineTokens?.[0].sliceZeroCopy(edit.getRangeAfterReplace());621if (!textTokens) {622return text;623}624625const fixedText = fixBracketsInLine(textTokens, languageConfigurationService);626return fixedText;627}628629630