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 e9cab4a5b53cc..4d9a77f2eb791 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts @@ -135,7 +135,7 @@ export interface Completion { preselect?: boolean; insertText: string; insertTextRules?: CompletionItemInsertTextRule; - range: Range | { + range?: Range | { insert: Range; replace: Range; }; @@ -201,6 +201,10 @@ export interface CompletionDto extends Completion { export interface CompletionResultDto extends IdObject { id: number; + defaultRange: { + insert: Range, + replace: Range + } completions: CompletionDto[]; incomplete?: boolean; } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 830d1362b64de..fdad6bdbf351d 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1166,7 +1166,7 @@ export interface PluginInfo { export interface LanguagesExt { $provideCompletionItems(handle: number, resource: UriComponents, position: Position, context: CompletionContext, token: CancellationToken): Promise; - $resolveCompletionItem(handle: number, resource: UriComponents, position: Position, completion: Completion, token: CancellationToken): Promise; + $resolveCompletionItem(handle: number, parentId: number, id: number, token: CancellationToken): Promise; $releaseCompletionItems(handle: number, id: number): void; $provideImplementation(handle: number, resource: UriComponents, position: Position, token: CancellationToken): Promise; $provideTypeDefinition(handle: number, resource: UriComponents, position: Position, token: CancellationToken): Promise; diff --git a/packages/plugin-ext/src/main/browser/languages-main.ts b/packages/plugin-ext/src/main/browser/languages-main.ts index 58b34bc7ac60d..168fa8d9464a0 100644 --- a/packages/plugin-ext/src/main/browser/languages-main.ts +++ b/packages/plugin-ext/src/main/browser/languages-main.ts @@ -37,7 +37,7 @@ import { import { injectable, inject } from 'inversify'; import { SerializedDocumentFilter, MarkerData, Range, RelatedInformation, - MarkerSeverity, DocumentLink, WorkspaceSymbolParams, CodeAction + MarkerSeverity, DocumentLink, WorkspaceSymbolParams, CodeAction, CompletionDto } from '../../common/plugin-api-rpc-model'; import { RPCProtocol } from '../../common/rpc-protocol'; import { fromLanguageSelector } from '../../plugin/type-converters'; @@ -57,6 +57,7 @@ import { toDefinition, toUriComponents, fromDefinition, fromPosition, toCaller } import { Position, DocumentUri } from 'vscode-languageserver-types'; import { ObjectIdentifier } from '../../common/object-identifier'; import { WorkspaceSymbolProvider } from '@theia/languages/lib/browser/language-client-services'; +import { mixin } from '../../common/types'; @injectable() export class LanguagesMainImpl implements LanguagesMain, Disposable { @@ -142,7 +143,9 @@ export class LanguagesMainImpl implements LanguagesMain, Disposable { return undefined; } return { - suggestions: result.completions, + suggestions: result.completions.map(c => Object.assign(c, { + range: c.range || result.defaultRange + })), incomplete: result.incomplete, dispose: () => this.proxy.$releaseCompletionItems(handle, result.id) }; @@ -151,7 +154,13 @@ export class LanguagesMainImpl implements LanguagesMain, Disposable { protected resolveCompletionItem(handle: number, model: monaco.editor.ITextModel, position: monaco.Position, item: monaco.languages.CompletionItem, token: monaco.CancellationToken): monaco.languages.ProviderResult { - return this.proxy.$resolveCompletionItem(handle, model.uri, position, item, token); + const { parentId, id } = item as CompletionDto; + return this.proxy.$resolveCompletionItem(handle, parentId, id, token).then(resolved => { + if (resolved) { + mixin(item, resolved, true); + } + return item; + }); } $registerDefinitionProvider(handle: number, pluginInfo: PluginInfo, selector: SerializedDocumentFilter[]): void { diff --git a/packages/plugin-ext/src/plugin/languages.ts b/packages/plugin-ext/src/plugin/languages.ts index aaa58fe4a9d25..4e9dca520437f 100644 --- a/packages/plugin-ext/src/plugin/languages.ts +++ b/packages/plugin-ext/src/plugin/languages.ts @@ -235,8 +235,8 @@ export class LanguagesExtImpl implements LanguagesExt { return this.withAdapter(handle, CompletionAdapter, adapter => adapter.provideCompletionItems(URI.revive(resource), position, context, token), undefined); } - $resolveCompletionItem(handle: number, resource: UriComponents, position: Position, completion: Completion, token: theia.CancellationToken): Promise { - return this.withAdapter(handle, CompletionAdapter, adapter => adapter.resolveCompletionItem(URI.revive(resource), position, completion, token), undefined); + $resolveCompletionItem(handle: number, parentId: number, id: number, token: theia.CancellationToken): Promise { + return this.withAdapter(handle, CompletionAdapter, adapter => adapter.resolveCompletionItem(parentId, id, token), undefined); } $releaseCompletionItems(handle: number, id: number): void { diff --git a/packages/plugin-ext/src/plugin/languages/completion.ts b/packages/plugin-ext/src/plugin/languages/completion.ts index 1bde89a98f4e3..212539c1e8067 100644 --- a/packages/plugin-ext/src/plugin/languages/completion.ts +++ b/packages/plugin-ext/src/plugin/languages/completion.ts @@ -19,7 +19,6 @@ import * as theia from '@theia/plugin'; import { CompletionList, Range, SnippetString } from '../types-impl'; import { DocumentsExtImpl } from '../documents'; import * as Converter from '../type-converters'; -import { mixin } from '../../common/types'; import { Position } from '../../common/plugin-api-rpc'; import { CompletionContext, CompletionResultDto, Completion, CompletionDto, CompletionItemInsertTextRule } from '../../common/plugin-api-rpc-model'; import { CommandRegistryImpl } from '../command-registry'; @@ -45,6 +44,13 @@ export class CompletionAdapter { const doc = document.document; const pos = Converter.toPosition(position); + + // The default insert/replace ranges. It's important to compute them + // before asynchronously asking the provider for its results. See + // https://github.com/microsoft/vscode/issues/83400#issuecomment-546851421 + const replacing = doc.getWordRangeAtPosition(pos) || new Range(pos, pos); + const inserting = replacing.with({ end: pos }); + return Promise.resolve(this.delegate.provideCompletionItems(doc, pos, token, context)).then(value => { const id = this.cacheId++; @@ -54,6 +60,10 @@ export class CompletionAdapter { const result: CompletionResultDto = { id, completions: [], + defaultRange: { + insert: Converter.fromRange(inserting), + replace: Converter.fromRange(replacing) + } }; let list: CompletionList; @@ -66,11 +76,8 @@ export class CompletionAdapter { result.incomplete = list.isIncomplete; } - const wordRangeBeforePos = (doc.getWordRangeAtPosition(pos) as Range || new Range(pos, pos)) - .with({ end: pos }); - for (let i = 0; i < list.items.length; i++) { - const suggestion = this.convertCompletionItem(list.items[i], pos, wordRangeBeforePos, i, id); + const suggestion = this.convertCompletionItem(list.items[i], i, id, inserting, replacing); if (suggestion) { result.completions.push(suggestion); } @@ -81,33 +88,19 @@ export class CompletionAdapter { }); } - resolveCompletionItem(resource: URI, position: Position, completion: Completion, token: theia.CancellationToken): Promise { + async resolveCompletionItem(parentId: number, id: number, token: theia.CancellationToken): Promise { if (typeof this.delegate.resolveCompletionItem !== 'function') { - return Promise.resolve(completion); + return undefined; } - - const { parentId, id } = (completion); - const item = this.cache.has(parentId) && this.cache.get(parentId)![id]; + const item = this.cache.get(parentId)?.[id]; if (!item) { - return Promise.resolve(completion); + return undefined; } - - return Promise.resolve(this.delegate.resolveCompletionItem(item, token)).then(resolvedItem => { - - if (!resolvedItem) { - return completion; - } - - const doc = this.documents.getDocumentData(resource)!.document; - const pos = Converter.toPosition(position); - const wordRangeBeforePos = (doc.getWordRangeAtPosition(pos) as Range || new Range(pos, pos)).with({ end: pos }); - const newCompletion = this.convertCompletionItem(resolvedItem, pos, wordRangeBeforePos, id, parentId); - if (newCompletion) { - mixin(completion, newCompletion, true); - } - - return completion; - }); + const resolvedItem = await this.delegate.resolveCompletionItem(item, token); + if (!resolvedItem) { + return undefined; + } + return this.convertCompletionItem(resolvedItem, id, parentId); } async releaseCompletionItems(id: number): Promise { @@ -119,7 +112,8 @@ export class CompletionAdapter { } } - private convertCompletionItem(item: theia.CompletionItem, position: theia.Position, defaultRange: theia.Range, id: number, parentId: number): CompletionDto | undefined { + private convertCompletionItem(item: theia.CompletionItem, id: number, parentId: number, + defaultInserting?: theia.Range, defaultReplacing?: theia.Range): CompletionDto | undefined { if (typeof item.label !== 'string' || item.label.length === 0) { console.warn('Invalid Completion Item -> must have at least a label'); return undefined; @@ -130,12 +124,6 @@ export class CompletionAdapter { throw Error('DisposableCollection is missing...'); } - const range = item.textEdit ? item.textEdit.range : item.range || defaultRange; - if (range && (!range.isSingleLine || range.start.line !== position.line)) { - console.warn('Invalid Completion Item -> must be single line and on the same line'); - return undefined; - } - let insertText = item.label; let insertTextRules = item.keepWhitespace ? CompletionItemInsertTextRule.KeepWhitespace : 0; if (item.textEdit) { @@ -147,6 +135,17 @@ export class CompletionAdapter { insertTextRules |= CompletionItemInsertTextRule.InsertAsSnippet; } + let range: Completion['range'] | undefined; + const itemRange = item.textEdit?.range || item.range; + if (Range.isRange(itemRange)) { + range = Converter.fromRange(itemRange); + } else if (itemRange && (!defaultInserting?.isEqual(itemRange.inserting) || !defaultReplacing?.isEqual(itemRange.replacing))) { + range = { + insert: Converter.fromRange(itemRange.inserting), + replace: Converter.fromRange(itemRange.replacing) + }; + } + return { id, parentId, @@ -159,7 +158,7 @@ export class CompletionAdapter { preselect: item.preselect, insertText, insertTextRules, - range: Converter.fromRange(range), + range, additionalTextEdits: item.additionalTextEdits && item.additionalTextEdits.map(Converter.fromTextEdit), command: this.commands.converter.toSafeCommand(item.command, toDispose), commitCharacters: item.commitCharacters diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 59e6538916310..e754bea4874e1 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -390,7 +390,8 @@ export class Range { return new Range(start, end); } - static isRange(thing: {}): thing is theia.Range { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static isRange(thing: any): thing is theia.Range { if (thing instanceof Range) { return true; } @@ -2013,17 +2014,17 @@ export enum FoldingRangeKind { export class SelectionRange { - range: Range; - parent?: SelectionRange; + range: Range; + parent?: SelectionRange; - constructor(range: Range, parent?: SelectionRange) { - this.range = range; - this.parent = parent; + constructor(range: Range, parent?: SelectionRange) { + this.range = range; + this.parent = parent; - if (parent && !parent.range.contains(this.range)) { - throw new Error('Invalid argument: parent must contain this range'); - } - } + if (parent && !parent.range.contains(this.range)) { + throw new Error('Invalid argument: parent must contain this range'); + } + } } /** diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index ad53344802561..184bf3f63e39c 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5982,15 +5982,17 @@ declare module '@theia/plugin' { insertText?: string | SnippetString; /** - * A range of text that should be replaced by this completion item. - * - * Defaults to a range from the start of the [current word](#TextDocument.getWordRangeAtPosition) to the - * current position. - * - * *Note:* The range must be a [single line](#Range.isSingleLine) and it must - * [contain](#Range.contains) the position at which completion has been [requested](#CompletionItemProvider.provideCompletionItems). - */ - range?: Range; + * A range or a insert and replace range selecting the text that should be replaced by this completion item. + * + * When omitted, the range of the [current word](#TextDocument.getWordRangeAtPosition) is used as replace-range + * and as insert-range the start of the [current word](#TextDocument.getWordRangeAtPosition) to the + * current position is used. + * + * *Note 1:* A range must be a [single line](#Range.isSingleLine) and it must + * [contain](#Range.contains) the position at which completion has been [requested](#CompletionItemProvider.provideCompletionItems). + * *Note 2:* A insert range must be a prefix of a replace range, that means it must be contained and starting at the same position. + */ + range?: Range | { inserting: Range; replacing: Range; }; /** * An optional set of characters that when pressed while this completion is active will accept it first and