From faf70944d8d8c2087619f7d048bd731fc19d0540 Mon Sep 17 00:00:00 2001 From: vince-fugnitto Date: Fri, 18 Nov 2022 14:09:08 -0500 Subject: [PATCH] vscode: add support for inline completions The commit adds support for the `inline completions` VS Code API. Signed-off-by: vince-fugnitto --- .../src/common/plugin-api-rpc-model.ts | 95 +++++++++++ .../plugin-ext/src/common/plugin-api-rpc.ts | 16 +- .../plugin-ext/src/common/reference-map.ts | 38 +++++ .../src/main/browser/languages-main.ts | 19 ++- packages/plugin-ext/src/plugin/languages.ts | 30 +++- .../src/plugin/languages/inline-completion.ts | 126 +++++++++++++++ .../plugin-ext/src/plugin/plugin-context.ts | 13 +- packages/plugin-ext/src/plugin/types-impl.ts | 31 ++++ packages/plugin/src/theia.d.ts | 150 ++++++++++++++++++ 9 files changed, 512 insertions(+), 6 deletions(-) create mode 100644 packages/plugin-ext/src/common/reference-map.ts create mode 100644 packages/plugin-ext/src/plugin/languages/inline-completion.ts diff --git a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts index 658c34d950551..5c0cb666c41de 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts @@ -764,3 +764,98 @@ export interface InlayHintsProvider { resolveInlayHint?(hint: InlayHint, token: monaco.CancellationToken): InlayHint[] | undefined | Thenable; } +/** + * How an {@link InlineCompletionsProvider inline completion provider} was triggered. + */ +export enum InlineCompletionTriggerKind { + /** + * Completion was triggered automatically while editing. + * It is sufficient to return a single completion item in this case. + */ + Automatic = 0, + + /** + * Completion was triggered explicitly by a user gesture. + * Return multiple completion items to enable cycling through them. + */ + Explicit = 1, +} + +export interface InlineCompletionContext { + /** + * How the completion was triggered. + */ + readonly triggerKind: InlineCompletionTriggerKind; + + readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined; +} + +export interface SelectedSuggestionInfo { + range: Range; + text: string; + isSnippetText: boolean; + completionKind: CompletionItemKind; +} + +export interface InlineCompletion { + /** + * The text to insert. + * If the text contains a line break, the range must end at the end of a line. + * If existing text should be replaced, the existing text must be a prefix of the text to insert. + * + * The text can also be a snippet. In that case, a preview with default parameters is shown. + * When accepting the suggestion, the full snippet is inserted. + */ + readonly insertText: string | { snippet: string }; + + /** + * A text that is used to decide if this inline completion should be shown. + * An inline completion is shown if the text to replace is a subword of the filter text. + */ + readonly filterText?: string; + + /** + * An optional array of additional text edits that are applied when + * selecting this completion. Edits must not overlap with the main edit + * nor with themselves. + */ + readonly additionalTextEdits?: SingleEditOperation[]; + + /** + * The range to replace. + * Must begin and end on the same line. + */ + readonly range?: Range; + + readonly command?: Command; + + /** + * If set to `true`, unopened closing brackets are removed and unclosed opening brackets are closed. + * Defaults to `false`. + */ + readonly completeBracketPairs?: boolean; +} + +export interface InlineCompletions { + readonly items: readonly TItem[]; +} + +export interface InlineCompletionsProvider { + provideInlineCompletions( + model: monaco.editor.ITextModel, + position: monaco.Position, + context: InlineCompletionContext, + token: monaco.CancellationToken + ): T[] | undefined | Thenable; + + /** + * Will be called when an item is shown. + */ + handleItemDidShow?(completions: T, item: T['items'][number]): void; + + /** + * Will be called when a completions list is no longer in use and can be garbage-collected. + */ + freeInlineCompletions(completions: T): void; +} + diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 83df9be8d0eb5..3aa8df9ad1edd 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -81,7 +81,10 @@ import { InlayHint, CachedSession, CachedSessionItem, - TypeHierarchyItem + TypeHierarchyItem, + InlineCompletion, + InlineCompletions, + InlineCompletionContext } from './plugin-api-rpc-model'; import { ExtPluginApi } from './plugin-ext-api-contribution'; import { KeysToAnyValues, KeysToKeysToAnyValue } from './types'; @@ -1576,6 +1579,8 @@ export interface LanguagesExt { $provideSuperTypes(handle: number, sessionId: string, itemId: string, token: theia.CancellationToken): Promise $provideSubTypes(handle: number, sessionId: string, itemId: string, token: theia.CancellationToken): Promise; $releaseTypeHierarchy(handle: number, session?: string): Promise; + $provideInlineCompletions(handle: number, resource: UriComponents, position: Position, context: InlineCompletionContext, token: CancellationToken): Promise; + $freeInlineCompletionsList(handle: number, pid: number): void; } export const LanguagesMainFactory = Symbol('LanguagesMainFactory'); @@ -1633,6 +1638,7 @@ export interface LanguagesMain { $registerTypeHierarchyProvider(handle: number, selector: SerializedDocumentFilter[]): void; $setLanguageStatus(handle: number, status: LanguageStatus): void; $removeLanguageStatus(handle: number): void; + $registerInlineCompletionsSupport(handle: number, selector: SerializedDocumentFilter[]): void; } export interface WebviewInitData { @@ -2040,3 +2046,11 @@ export interface SecretsMain { export type InlayHintDto = CachedSessionItem; export type InlayHintsDto = CachedSession<{ hints: InlayHint[] }>; + +export interface IdentifiableInlineCompletions extends InlineCompletions { + pid: number; +} + +export interface IdentifiableInlineCompletion extends InlineCompletion { + idx: number; +} diff --git a/packages/plugin-ext/src/common/reference-map.ts b/packages/plugin-ext/src/common/reference-map.ts new file mode 100644 index 0000000000000..6438b9574553f --- /dev/null +++ b/packages/plugin-ext/src/common/reference-map.ts @@ -0,0 +1,38 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +// copied from hhttps://github.com/microsoft/vscode/blob/6261075646f055b99068d3688932416f2346dd3b/src/vs/workbench/api/common/extHostLanguageFeatures.ts#L1291-L1310. + +export class ReferenceMap { + private readonly _references = new Map(); + private _idPool = 1; + + createReferenceId(value: T): number { + const id = this._idPool++; + this._references.set(id, value); + return id; + } + + disposeReferenceId(referenceId: number): T | undefined { + const value = this._references.get(referenceId); + this._references.delete(referenceId); + return value; + } + + get(referenceId: number): T | undefined { + return this._references.get(referenceId); + } +} diff --git a/packages/plugin-ext/src/main/browser/languages-main.ts b/packages/plugin-ext/src/main/browser/languages-main.ts index f2ab71f4e5385..ca078cf868683 100644 --- a/packages/plugin-ext/src/main/browser/languages-main.ts +++ b/packages/plugin-ext/src/main/browser/languages-main.ts @@ -34,7 +34,8 @@ import { WorkspaceTextEditDto, PluginInfo, LanguageStatus as LanguageStatusDTO, - InlayHintDto + InlayHintDto, + IdentifiableInlineCompletions } from '../../common/plugin-api-rpc'; import { injectable, inject } from '@theia/core/shared/inversify'; import { @@ -868,6 +869,22 @@ export class LanguagesMainImpl implements LanguagesMain, Disposable { } } + $registerInlineCompletionsSupport(handle: number, selector: SerializedDocumentFilter[]): void { + const languageSelector = this.toLanguageSelector(selector); + const provider: monaco.languages.InlineCompletionsProvider = { + provideInlineCompletions: async ( + model: monaco.editor.ITextModel, + position: monaco.Position, + context: monaco.languages.InlineCompletionContext, + token: CancellationToken + ): Promise => this.proxy.$provideInlineCompletions(handle, model.uri, position, context, token), + freeInlineCompletions: (completions: IdentifiableInlineCompletions): void => { + this.proxy.$freeInlineCompletionsList(handle, completions.pid); + } + }; + this.register(handle, (monaco.languages.registerInlineCompletionsProvider as RegistrationFunction)(languageSelector, provider)); + } + $registerQuickFixProvider( handle: number, pluginInfo: PluginInfo, diff --git a/packages/plugin-ext/src/plugin/languages.ts b/packages/plugin-ext/src/plugin/languages.ts index 945531d8703a9..e4cae467118cb 100644 --- a/packages/plugin-ext/src/plugin/languages.ts +++ b/packages/plugin-ext/src/plugin/languages.ts @@ -27,6 +27,7 @@ import { Plugin, InlayHintsDto, InlayHintDto, + IdentifiableInlineCompletions, } from '../common/plugin-api-rpc'; import { RPCProtocol } from '../common/rpc-protocol'; import * as theia from '@theia/plugin'; @@ -67,7 +68,8 @@ import { EvaluatableExpression, InlineValue, InlineValueContext, - TypeHierarchyItem + TypeHierarchyItem, + InlineCompletionContext } from '../common/plugin-api-rpc-model'; import { CompletionAdapter } from './languages/completion'; import { Diagnostics } from './languages/diagnostics'; @@ -106,6 +108,7 @@ import { Severity } from '@theia/core/lib/common/severity'; import { LinkedEditingRangeAdapter } from './languages/linked-editing-range'; import { serializeEnterRules, serializeIndentation, serializeRegExp } from './languages-utils'; import { InlayHintsAdapter } from './languages/inlay-hints'; +import { InlineCompletionAdapter, InlineCompletionAdapterBase } from './languages/inline-completion'; type Adapter = CompletionAdapter | SignatureHelpAdapter | @@ -135,7 +138,8 @@ type Adapter = CompletionAdapter | DocumentRangeSemanticTokensAdapter | DocumentSemanticTokensAdapter | LinkedEditingRangeAdapter | - TypeHierarchyAdapter; + TypeHierarchyAdapter | + InlineCompletionAdapter; export class LanguagesExtImpl implements LanguagesExt { @@ -275,6 +279,28 @@ export class LanguagesExtImpl implements LanguagesExt { } // ### Completion end + // ### Inline completion provider begin + registerInlineCompletionsProvider(selector: theia.DocumentSelector, provider: theia.InlineCompletionItemProvider): theia.Disposable { + const callId = this.addNewAdapter(new InlineCompletionAdapter(this.documents, provider, this.commands)); + this.proxy.$registerInlineCompletionsSupport(callId, this.transformDocumentSelector(selector)); + return this.createDisposable(callId); + } + + $provideInlineCompletions( + handle: number, + resource: UriComponents, + position: Position, + context: InlineCompletionContext, + token: theia.CancellationToken + ): Promise { + return this.withAdapter(handle, InlineCompletionAdapterBase, adapter => adapter.provideInlineCompletions(URI.revive(resource), position, context, token), undefined); + } + + $freeInlineCompletionsList(handle: number, pid: number): void { + this.withAdapter(handle, InlineCompletionAdapterBase, async adapter => { adapter.disposeCompletions(pid); }, undefined); + } + // ### Inline completion provider end + // ### Definition provider begin $provideDefinition(handle: number, resource: UriComponents, position: Position, token: theia.CancellationToken): Promise { return this.withAdapter(handle, DefinitionAdapter, adapter => adapter.provideDefinition(URI.revive(resource), position, token), undefined); diff --git a/packages/plugin-ext/src/plugin/languages/inline-completion.ts b/packages/plugin-ext/src/plugin/languages/inline-completion.ts new file mode 100644 index 0000000000000..67b5a64c0b86b --- /dev/null +++ b/packages/plugin-ext/src/plugin/languages/inline-completion.ts @@ -0,0 +1,126 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 +// ***************************************************************************** + +// copied from https://github.com/microsoft/vscode/blob/6261075646f055b99068d3688932416f2346dd3b/src/vs/workbench/api/common/extHostLanguageFeatures.ts#L1069-L1185. + +import * as theia from '@theia/plugin'; +import * as Converter from '../type-converters'; +import { DocumentsExtImpl } from '../documents'; +import { URI } from '@theia/core/shared/vscode-uri'; +import { CommandRegistryImpl } from '../command-registry'; +import { ReferenceMap } from '../../common/reference-map'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { InlineCompletionTriggerKind as TriggerKind } from '../../plugin/types-impl'; +import { Command, InlineCompletionContext, InlineCompletionTriggerKind } from '../../common/plugin-api-rpc-model'; +import { IdentifiableInlineCompletion, IdentifiableInlineCompletions, Position } from '../../common/plugin-api-rpc'; + +export class InlineCompletionAdapterBase { + + async provideInlineCompletions( + _resource: URI, + _position: Position, + _context: InlineCompletionContext, + _token: theia.CancellationToken + ): Promise { + return undefined; + } + + disposeCompletions(pid: number): void { return; }; +} + +export class InlineCompletionAdapter extends InlineCompletionAdapterBase { + + private readonly references = new ReferenceMap<{ + dispose(): void; + items: readonly theia.InlineCompletionItem[]; + }>(); + + constructor( + private readonly documents: DocumentsExtImpl, + private readonly provider: theia.InlineCompletionItemProvider, + private readonly commands: CommandRegistryImpl, + ) { + super(); + } + + private readonly languageTriggerKindToVSCodeTriggerKind: Record = { + [InlineCompletionTriggerKind.Automatic]: TriggerKind.Automatic, + [InlineCompletionTriggerKind.Explicit]: TriggerKind.Invoke, + }; + + override async provideInlineCompletions( + resource: URI, + position: Position, + context: InlineCompletionContext, + token: theia.CancellationToken + ): Promise { + const doc = this.documents.getDocument(resource); + const pos = Converter.toPosition(position); + + const result = await this.provider.provideInlineCompletionItems(doc, pos, { + selectedCompletionInfo: + context.selectedSuggestionInfo + ? { + range: Converter.toRange(context.selectedSuggestionInfo.range), + text: context.selectedSuggestionInfo.text + } + : undefined, + triggerKind: this.languageTriggerKindToVSCodeTriggerKind[context.triggerKind] + }, token); + + if (!result || token.isCancellationRequested) { + return undefined; + } + + const normalizedResult = Array.isArray(result) ? result : result.items; + + let disposableCollection: DisposableCollection | undefined = undefined; + const pid = this.references.createReferenceId({ + dispose(): void { + disposableCollection?.dispose(); + }, + items: normalizedResult + }); + + return { + pid, + items: normalizedResult.map((item, idx) => { + let command: Command | undefined = undefined; + if (item.command) { + if (!disposableCollection) { + disposableCollection = new DisposableCollection(); + } + command = this.commands.converter.toSafeCommand(item.command, disposableCollection); + } + + const insertText = item.insertText; + return ({ + insertText: typeof insertText === 'string' ? insertText : { snippet: insertText.value }, + filterText: item.filterText, + range: item.range ? Converter.fromRange(item.range) : undefined, + command, + idx: idx + }); + }) + }; + } + + override disposeCompletions(pid: number): void { + const data = this.references.disposeReferenceId(pid); + data?.dispose(); + } + +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 36f99bf58ecf2..0fee9f59bec5f 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -159,7 +159,10 @@ import { TestTag, TestRunRequest, TestMessage, - ExtensionKind + ExtensionKind, + InlineCompletionItem, + InlineCompletionList, + InlineCompletionTriggerKind } from './types-impl'; import { AuthenticationExtImpl } from './authentication-ext'; import { SymbolKind } from '../common/plugin-api-rpc-model'; @@ -715,6 +718,9 @@ export function createAPIFactory( registerCompletionItemProvider(selector: theia.DocumentSelector, provider: theia.CompletionItemProvider, ...triggerCharacters: string[]): theia.Disposable { return languagesExt.registerCompletionItemProvider(selector, provider, triggerCharacters, pluginToPluginInfo(plugin)); }, + registerInlineCompletionItemProvider(selector: theia.DocumentSelector, provider: theia.InlineCompletionItemProvider): theia.Disposable { + return languagesExt.registerInlineCompletionsProvider(selector, provider); + }, registerDefinitionProvider(selector: theia.DocumentSelector, provider: theia.DefinitionProvider): theia.Disposable { return languagesExt.registerDefinitionProvider(selector, provider, pluginToPluginInfo(plugin)); }, @@ -1124,7 +1130,10 @@ export function createAPIFactory( TestTag, TestRunRequest, TestMessage, - ExtensionKind + ExtensionKind, + InlineCompletionItem, + InlineCompletionList, + InlineCompletionTriggerKind }; }; } diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 1ff5649ca8b57..7b8b539ecdf7d 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -992,6 +992,37 @@ export class CompletionList { } } +export enum InlineCompletionTriggerKind { + Invoke = 0, + Automatic = 1, +} + +@es5ClassCompat +export class InlineCompletionItem implements theia.InlineCompletionItem { + + filterText?: string; + insertText: string; + range?: Range; + command?: theia.Command; + + constructor(insertText: string, range?: Range, command?: theia.Command) { + this.insertText = insertText; + this.range = range; + this.command = command; + } +} + +@es5ClassCompat +export class InlineCompletionList implements theia.InlineCompletionList { + + items: theia.InlineCompletionItem[]; + commands: theia.Command[] | undefined = undefined; + + constructor(items: theia.InlineCompletionItem[]) { + this.items = items; + } +} + export enum DiagnosticSeverity { Error = 0, Warning = 1, diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 2a64228cfc63c..2784e264a21b3 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -8292,6 +8292,143 @@ export module '@theia/plugin' { resolveCompletionItem?(item: T, token: CancellationToken): ProviderResult; } + /** + * The inline completion item provider interface defines the contract between extensions and + * the inline completion feature. + * + * Providers are asked for completions either explicitly by a user gesture or implicitly when typing. + */ + export interface InlineCompletionItemProvider { + + /** + * Provides inline completion items for the given position and document. + * If inline completions are enabled, this method will be called whenever the user stopped typing. + * It will also be called when the user explicitly triggers inline completions or explicitly asks for the next or previous inline completion. + * In that case, all available inline completions should be returned. + * `context.triggerKind` can be used to distinguish between these scenarios. + * + * @param document The document inline completions are requested for. + * @param position The position inline completions are requested for. + * @param context A context object with additional information. + * @param token A cancellation token. + * @return An array of completion items or a thenable that resolves to an array of completion items. + */ + provideInlineCompletionItems(document: TextDocument, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult; + } + + /** + * Represents a collection of {@link InlineCompletionItem inline completion items} to be presented + * in the editor. + */ + export class InlineCompletionList { + /** + * The inline completion items. + */ + items: InlineCompletionItem[]; + + /** + * Creates a new list of inline completion items. + */ + constructor(items: InlineCompletionItem[]); + } + + /** + * Provides information about the context in which an inline completion was requested. + */ + export interface InlineCompletionContext { + /** + * Describes how the inline completion was triggered. + */ + readonly triggerKind: InlineCompletionTriggerKind; + + /** + * Provides information about the currently selected item in the autocomplete widget if it is visible. + * + * If set, provided inline completions must extend the text of the selected item + * and use the same range, otherwise they are not shown as preview. + * As an example, if the document text is `console.` and the selected item is `.log` replacing the `.` in the document, + * the inline completion must also replace `.` and start with `.log`, for example `.log()`. + * + * Inline completion providers are requested again whenever the selected item changes. + */ + readonly selectedCompletionInfo: SelectedCompletionInfo | undefined; + } + + /** + * Describes the currently selected completion item. + */ + export interface SelectedCompletionInfo { + /** + * The range that will be replaced if this completion item is accepted. + */ + readonly range: Range; + + /** + * The text the range will be replaced with if this completion is accepted. + */ + readonly text: string; + } + + /** + * Describes how an {@link InlineCompletionItemProvider inline completion provider} was triggered. + */ + export enum InlineCompletionTriggerKind { + /** + * Completion was triggered explicitly by a user gesture. + * Return multiple completion items to enable cycling through them. + */ + Invoke = 0, + + /** + * Completion was triggered automatically while editing. + * It is sufficient to return a single completion item in this case. + */ + Automatic = 1, + } + + /** + * An inline completion item represents a text snippet that is proposed inline to complete text that is being typed. + * + * @see {@link InlineCompletionItemProvider.provideInlineCompletionItems} + */ + export class InlineCompletionItem { + /** + * The text to replace the range with. Must be set. + * Is used both for the preview and the accept operation. + */ + insertText: string | SnippetString; + + /** + * A text that is used to decide if this inline completion should be shown. When `falsy` + * the {@link InlineCompletionItem.insertText} is used. + * + * An inline completion is shown if the text to replace is a prefix of the filter text. + */ + filterText?: string; + + /** + * The range to replace. + * Must begin and end on the same line. + * + * Prefer replacements over insertions to provide a better experience when the user deletes typed text. + */ + range?: Range; + + /** + * An optional {@link Command} that is executed *after* inserting this completion. + */ + command?: Command; + + /** + * Creates a new inline completion item. + * + * @param insertText The text to replace the range with. + * @param range The range to replace. If not set, the word at the requested position will be used. + * @param command An optional {@link Command} that is executed *after* inserting this completion. + */ + constructor(insertText: string | SnippetString, range?: Range, command?: Command); + } + /** * Represents a location inside a resource, such as a line * inside a text file. @@ -9659,6 +9796,19 @@ export module '@theia/plugin' { */ export function registerCompletionItemProvider(selector: DocumentSelector, provider: CompletionItemProvider, ...triggerCharacters: string[]): Disposable; + /** + * Registers an inline completion provider. + * + * Multiple providers can be registered for a language. In that case providers are asked in + * parallel and the results are merged. A failing provider (rejected promise or exception) will + * not cause a failure of the whole operation. + * + * @param selector A selector that defines the documents this provider is applicable to. + * @param provider An inline completion provider. + * @return A {@link Disposable} that unregisters this provider when being disposed. + */ + export function registerInlineCompletionItemProvider(selector: DocumentSelector, provider: InlineCompletionItemProvider): Disposable; + /** * Register a definition provider. *