diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 9c5f2238c6a..5d7a36dfe25 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -453,7 +453,7 @@ export interface CompletionItem { * *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: IRange; + range: IRange | { insert: IRange, replace: IRange }; /** * An optional set of characters that when pressed while this completion is active will accept it first and * then type that character. *Note* that all commit characters should have `length=1` and that superfluous diff --git a/src/vs/editor/contrib/suggest/completionModel.ts b/src/vs/editor/contrib/suggest/completionModel.ts index f1bbdb462f7..c1349fa7935 100644 --- a/src/vs/editor/contrib/suggest/completionModel.ts +++ b/src/vs/editor/contrib/suggest/completionModel.ts @@ -160,7 +160,7 @@ export class CompletionModel { // 'word' is that remainder of the current line that we // filter and score against. In theory each suggestion uses a // different word, but in practice not - that's why we cache - const overwriteBefore = item.position.column - item.completion.range.startColumn; + const overwriteBefore = item.position.column - item.editStart.column; const wordLen = overwriteBefore + characterCountDelta - (item.position.column - this._column); if (word.length !== wordLen) { word = wordLen === 0 ? '' : leadingLineContent.slice(-wordLen); diff --git a/src/vs/editor/contrib/suggest/suggest.ts b/src/vs/editor/contrib/suggest/suggest.ts index 09f1b9a4d06..d28423e1602 100644 --- a/src/vs/editor/contrib/suggest/suggest.ts +++ b/src/vs/editor/contrib/suggest/suggest.ts @@ -31,6 +31,11 @@ export class CompletionItem { readonly resolve: (token: CancellationToken) => Promise; + // + readonly editStart: IPosition; + readonly editInsertEnd: IPosition; + readonly editReplaceEnd: IPosition; + // perf readonly labelLow: string; readonly sortTextLow?: string; @@ -54,6 +59,17 @@ export class CompletionItem { this.sortTextLow = completion.sortText && completion.sortText.toLowerCase(); this.filterTextLow = completion.filterText && completion.filterText.toLowerCase(); + // normalize ranges + if (Range.isIRange(completion.range)) { + this.editStart = new Position(completion.range.startLineNumber, completion.range.startColumn); + this.editInsertEnd = new Position(completion.range.endLineNumber, completion.range.endColumn); + this.editReplaceEnd = new Position(completion.range.endLineNumber, completion.range.endColumn); + } else { + this.editStart = new Position(completion.range.insert.startLineNumber, completion.range.insert.startColumn); + this.editInsertEnd = new Position(completion.range.insert.endLineNumber, completion.range.insert.endColumn); + this.editReplaceEnd = new Position(completion.range.replace.endLineNumber, completion.range.replace.endColumn); + } + // create the suggestion resolver const { resolveCompletionItem } = provider; if (typeof resolveCompletionItem !== 'function') { @@ -122,8 +138,12 @@ export function provideSuggestionItems( token: CancellationToken = CancellationToken.None ): Promise { - const wordUntil = model.getWordUntilPosition(position); - const defaultRange = new Range(position.lineNumber, wordUntil.startColumn, position.lineNumber, wordUntil.endColumn); + const word = model.getWordAtPosition(position); + const defaultReplaceRange = word ? new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) : Range.fromPositions(position); + const defaultInsertRange = defaultReplaceRange.setEndPosition(position.lineNumber, position.column); + + // const wordUntil = model.getWordUntilPosition(position); + // const defaultRange = new Range(position.lineNumber, wordUntil.startColumn, position.lineNumber, wordUntil.endColumn); position = position.clone(); @@ -159,7 +179,7 @@ export function provideSuggestionItems( // fill in default range when missing if (!suggestion.range) { - suggestion.range = defaultRange; + suggestion.range = { insert: defaultInsertRange, replace: defaultReplaceRange }; } // fill in default sortText when missing if (!suggestion.sortText) { diff --git a/src/vs/editor/contrib/suggest/suggestController.ts b/src/vs/editor/contrib/suggest/suggestController.ts index c173302a30d..007b89d7203 100644 --- a/src/vs/editor/contrib/suggest/suggestController.ts +++ b/src/vs/editor/contrib/suggest/suggestController.ts @@ -138,7 +138,7 @@ export class SuggestController implements IEditorContribution { this._toDispose.add(widget.onDidFocus(({ item }) => { const position = this._editor.getPosition()!; - const startColumn = item.completion.range.startColumn; + const startColumn = item.editStart.column; const endColumn = position.column; let value = true; if ( @@ -241,7 +241,8 @@ export class SuggestController implements IEditorContribution { const model = this._editor.getModel(); const modelVersionNow = model.getAlternativeVersionId(); - const { completion: suggestion, position } = event.item; + const { item } = event; + const { completion: suggestion, position } = item; const columnDelta = this._editor.getPosition().column - position.column; // pushing undo stops *before* additional text edits and @@ -255,33 +256,20 @@ export class SuggestController implements IEditorContribution { } // keep item in memory - this._memoryService.memorize(model, this._editor.getPosition(), event.item); + this._memoryService.memorize(model, this._editor.getPosition(), item); let { insertText } = suggestion; if (!(suggestion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet)) { insertText = SnippetParser.escape(insertText); } - let overwriteBefore = position.column - suggestion.range.startColumn; - let overwriteAfter = suggestion.range.endColumn - position.column; - let suffixDelta = this._lineSuffix.value ? this._lineSuffix.value.delta(this._editor.getPosition()) : 0; - let word = model.getWordAtPosition(this._editor.getPosition()); - const overwriteConfig = flags & InsertFlags.AlternativeOverwriteConfig ? !this._editor.getOption(EditorOption.suggest).overwriteOnAccept : this._editor.getOption(EditorOption.suggest).overwriteOnAccept; - if (!overwriteConfig) { - if (overwriteAfter > 0 && word && suggestion.range.endColumn === word.endColumn) { - // don't overwrite anything right of the cursor, overrule extension even when the - // completion only replaces a word... - overwriteAfter = 0; - } - } else { - if (overwriteAfter === 0 && word) { - // compute fallback overwrite length - overwriteAfter = word.endColumn - this._editor.getPosition().column; - } - } + + const overwriteBefore = position.column - item.editStart.column; + const overwriteAfter = (overwriteConfig ? item.editReplaceEnd.column : item.editInsertEnd.column) - position.column; + const suffixDelta = this._lineSuffix.value ? this._lineSuffix.value.delta(this._editor.getPosition()) : 0; SnippetController2.get(this._editor).insert(insertText, { overwriteBefore: overwriteBefore + columnDelta, @@ -367,7 +355,7 @@ export class SuggestController implements IEditorContribution { return true; } const position = this._editor.getPosition()!; - const startColumn = item.completion.range.startColumn; + const startColumn = item.editStart.column; const endColumn = position.column; if (endColumn - startColumn !== item.completion.insertText.length) { // unequal lengths -> makes edit diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 18e0a9abe18..2fa34ea2ea6 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4765,7 +4765,10 @@ declare namespace monaco.languages { * *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: IRange; + range: IRange | { + insert: IRange; + replace: IRange; + }; /** * An optional set of characters that when pressed while this completion is active will accept it first and * then type that character. *Note* that all commit characters should have `length=1` and that superfluous diff --git a/src/vs/vscode.d.ts b/src/vs/vscode.d.ts index 2e430b02494..e80e129efc0 100644 --- a/src/vs/vscode.d.ts +++ b/src/vs/vscode.d.ts @@ -3393,15 +3393,17 @@ declare module 'vscode' { insertText?: string | SnippetString; /** - * A range of text that should be replaced by this completion item. + * A range or a insert and replace range selecting the 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. + * 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:* The range must be a [single line](#Range.isSingleLine) and it must + * *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; + range?: Range | { insert: Range; replace: Range; }; /** * An optional set of characters that when pressed while this completion is active will accept it first and diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 05bc4303f3e..8b57f6097fc 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -326,7 +326,8 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha // --- suggest - private static _inflateSuggestDto(defaultRange: IRange, data: ISuggestDataDto): modes.CompletionItem { + private static _inflateSuggestDto(defaultRange: IRange | { insert: IRange, replace: IRange }, data: ISuggestDataDto): modes.CompletionItem { + return { label: data[ISuggestDataDtoField.label], kind: data[ISuggestDataDtoField.kind], @@ -337,8 +338,8 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha filterText: data[ISuggestDataDtoField.filterText], preselect: data[ISuggestDataDtoField.preselect], insertText: typeof data.h === 'undefined' ? data[ISuggestDataDtoField.label] : data.h, - insertTextRules: data[ISuggestDataDtoField.insertTextRules], range: data[ISuggestDataDtoField.range] || defaultRange, + insertTextRules: data[ISuggestDataDtoField.insertTextRules], commitCharacters: data[ISuggestDataDtoField.commitCharacters], additionalTextEdits: data[ISuggestDataDtoField.additionalTextEdits], command: data[ISuggestDataDtoField.command], @@ -370,6 +371,7 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha if (!result) { return suggestion; } + let newSuggestion = MainThreadLanguageFeatures._inflateSuggestDto(suggestion.range, result); return mixin(suggestion, newSuggestion, true); }); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 59675a4794b..26671937bea 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -970,7 +970,7 @@ export interface ISuggestDataDto { [ISuggestDataDtoField.preselect]?: boolean; [ISuggestDataDtoField.insertText]?: string; [ISuggestDataDtoField.insertTextRules]?: modes.CompletionItemInsertTextRule; - [ISuggestDataDtoField.range]?: IRange; + [ISuggestDataDtoField.range]?: IRange | { insert: IRange, replace: IRange }; [ISuggestDataDtoField.commitCharacters]?: string[]; [ISuggestDataDtoField.additionalTextEdits]?: ISingleEditOperation[]; [ISuggestDataDtoField.command]?: modes.Command; @@ -981,7 +981,7 @@ export interface ISuggestDataDto { export interface ISuggestResultDto { x?: number; - a: IRange; + a: { insert: IRange, replace: IRange }; b: ISuggestDataDto[]; c?: boolean; } diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 872fd2be5e8..b32418267fb 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -658,13 +658,13 @@ class SuggestAdapter { this._disposables.set(pid, disposables); // the default text edit range - const wordRangeBeforePos = (doc.getWordRangeAtPosition(pos) as Range || new Range(pos, pos)) - .with({ end: pos }); + const replaceRange = doc.getWordRangeAtPosition(pos) || new Range(pos, pos); + const insertRange = replaceRange.with({ end: pos }); const result: extHostProtocol.ISuggestResultDto = { x: pid, b: [], - a: typeConvert.Range.from(wordRangeBeforePos), + a: { replace: typeConvert.Range.from(replaceRange), insert: typeConvert.Range.from(insertRange) }, c: list.isIncomplete || undefined }; @@ -751,21 +751,44 @@ class SuggestAdapter { } // 'overwrite[Before|After]'-logic - let range: vscode.Range | undefined; + let range: vscode.Range | { insert: vscode.Range, replace: vscode.Range } | undefined; if (item.textEdit) { range = item.textEdit.range; } else if (item.range) { range = item.range; } - result[extHostProtocol.ISuggestDataDtoField.range] = typeConvert.Range.from(range); - if (range && (!range.isSingleLine || range.start.line !== position.line)) { - console.warn('INVALID text edit -> must be single line and on the same line'); - return undefined; + if (range) { + if (Range.isRange(range)) { + if (!SuggestAdapter._isValidRangeForCompletion(range, position)) { + console.trace('INVALID range -> must be single line and on the same line'); + return undefined; + } + result[extHostProtocol.ISuggestDataDtoField.range] = typeConvert.Range.from(range); + + } else { + if ( + !SuggestAdapter._isValidRangeForCompletion(range.insert, position) + || !SuggestAdapter._isValidRangeForCompletion(range.replace, position) + || !range.insert.start.isEqual(range.replace.start) + || !range.replace.contains(range.insert) + ) { + console.trace('INVALID range -> must be single line, on the same line, insert range must be a prefix of replace range'); + return undefined; + } + result[extHostProtocol.ISuggestDataDtoField.range] = { + insert: typeConvert.Range.from(range.insert), + replace: typeConvert.Range.from(range.replace) + }; + } } return result; } + + private static _isValidRangeForCompletion(range: vscode.Range, position: vscode.Position): boolean { + return range.isSingleLine || range.start.line === position.line; + } } class SignatureHelpAdapter { diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index c3f84a27286..b9eeee2ac6b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -15,7 +15,7 @@ import { URI, UriComponents } from 'vs/base/common/uri'; import { ProgressLocation as MainProgressLocation } from 'vs/platform/progress/common/progress'; import { SaveReason } from 'vs/workbench/services/textfile/common/textfiles'; import { IPosition } from 'vs/editor/common/core/position'; -import { IRange } from 'vs/editor/common/core/range'; +import * as editorRange from 'vs/editor/common/core/range'; import { ISelection } from 'vs/editor/common/core/selection'; import * as htmlContent from 'vs/base/common/htmlContent'; import * as languageSelector from 'vs/editor/common/modes/languageSelector'; @@ -68,9 +68,9 @@ export namespace Selection { export namespace Range { export function from(range: undefined): undefined; - export function from(range: RangeLike): IRange; - export function from(range: RangeLike | undefined): IRange | undefined; - export function from(range: RangeLike | undefined): IRange | undefined { + export function from(range: RangeLike): editorRange.IRange; + export function from(range: RangeLike | undefined): editorRange.IRange | undefined; + export function from(range: RangeLike | undefined): editorRange.IRange | undefined { if (!range) { return undefined; } @@ -84,9 +84,9 @@ export namespace Range { } export function to(range: undefined): types.Range; - export function to(range: IRange): types.Range; - export function to(range: IRange | undefined): types.Range | undefined; - export function to(range: IRange | undefined): types.Range | undefined { + export function to(range: editorRange.IRange): types.Range; + export function to(range: editorRange.IRange | undefined): types.Range | undefined; + export function to(range: editorRange.IRange | undefined): types.Range | undefined { if (!range) { return undefined; } @@ -821,14 +821,14 @@ export namespace CompletionItem { result.filterText = suggestion.filterText; result.preselect = suggestion.preselect; result.commitCharacters = suggestion.commitCharacters; - result.range = Range.to(suggestion.range); + result.range = editorRange.Range.isIRange(suggestion.range) ? Range.to(suggestion.range) : { insert: Range.to(suggestion.range.insert), replace: Range.to(suggestion.range.replace) }; result.keepWhitespace = typeof suggestion.insertTextRules === 'undefined' ? false : Boolean(suggestion.insertTextRules & modes.CompletionItemInsertTextRule.KeepWhitespace); // 'inserText'-logic if (typeof suggestion.insertTextRules !== 'undefined' && suggestion.insertTextRules & modes.CompletionItemInsertTextRule.InsertAsSnippet) { result.insertText = new types.SnippetString(suggestion.insertText); } else { result.insertText = suggestion.insertText; - result.textEdit = new types.TextEdit(result.range, result.insertText); + result.textEdit = result.range instanceof types.Range ? new types.TextEdit(result.range, result.insertText) : undefined; } // TODO additionalEdits, command diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index a5644289cf7..c765f8d930e 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1343,7 +1343,7 @@ export class CompletionItem implements vscode.CompletionItem { preselect?: boolean; insertText?: string | SnippetString; keepWhitespace?: boolean; - range?: Range; + range?: Range | { insert: Range; replace: Range; }; commitCharacters?: string[]; textEdit?: TextEdit; additionalTextEdits?: TextEdit[]; diff --git a/src/vs/workbench/contrib/snippets/test/browser/snippetsService.test.ts b/src/vs/workbench/contrib/snippets/test/browser/snippetsService.test.ts index 298f9a71b6c..f120a12e2d9 100644 --- a/src/vs/workbench/contrib/snippets/test/browser/snippetsService.test.ts +++ b/src/vs/workbench/contrib/snippets/test/browser/snippetsService.test.ts @@ -84,7 +84,7 @@ suite('SnippetsService', function () { assert.equal(result.incomplete, undefined); assert.equal(result.suggestions.length, 1); assert.equal(result.suggestions[0].label, 'bar'); - assert.equal(result.suggestions[0].range.startColumn, 1); + assert.equal((result.suggestions[0].range as any).startColumn, 1); assert.equal(result.suggestions[0].insertText, 'barCodeSnippet'); }); }); @@ -117,10 +117,10 @@ suite('SnippetsService', function () { assert.equal(result.suggestions.length, 2); assert.equal(result.suggestions[0].label, 'bar'); assert.equal(result.suggestions[0].insertText, 's1'); - assert.equal(result.suggestions[0].range.startColumn, 1); + assert.equal((result.suggestions[0].range as any).startColumn, 1); assert.equal(result.suggestions[1].label, 'bar-bar'); assert.equal(result.suggestions[1].insertText, 's2'); - assert.equal(result.suggestions[1].range.startColumn, 1); + assert.equal((result.suggestions[1].range as any).startColumn, 1); }); await provider.provideCompletionItems(model, new Position(1, 5), context)!.then(result => { @@ -128,7 +128,7 @@ suite('SnippetsService', function () { assert.equal(result.suggestions.length, 1); assert.equal(result.suggestions[0].label, 'bar-bar'); assert.equal(result.suggestions[0].insertText, 's2'); - assert.equal(result.suggestions[0].range.startColumn, 1); + assert.equal((result.suggestions[0].range as any).startColumn, 1); }); await provider.provideCompletionItems(model, new Position(1, 6), context)!.then(result => { @@ -136,10 +136,10 @@ suite('SnippetsService', function () { assert.equal(result.suggestions.length, 2); assert.equal(result.suggestions[0].label, 'bar'); assert.equal(result.suggestions[0].insertText, 's1'); - assert.equal(result.suggestions[0].range.startColumn, 5); + assert.equal((result.suggestions[0].range as any).startColumn, 5); assert.equal(result.suggestions[1].label, 'bar-bar'); assert.equal(result.suggestions[1].insertText, 's2'); - assert.equal(result.suggestions[1].range.startColumn, 1); + assert.equal((result.suggestions[1].range as any).startColumn, 1); }); }); @@ -165,14 +165,14 @@ suite('SnippetsService', function () { return provider.provideCompletionItems(model, new Position(1, 4), context)!; }).then(result => { assert.equal(result.suggestions.length, 1); - assert.equal(result.suggestions[0].range.startColumn, 2); + assert.equal((result.suggestions[0].range as any).startColumn, 2); model.dispose(); model = TextModel.createFromString('a { assert.equal(result.suggestions.length, 1); - assert.equal(result.suggestions[0].range.startColumn, 2); + assert.equal((result.suggestions[0].range as any).startColumn, 2); model.dispose(); }); }); @@ -400,13 +400,13 @@ suite('SnippetsService', function () { assert.equal(result.suggestions.length, 1); let [first] = result.suggestions; - assert.equal(first.range.startColumn, 2); + assert.equal((first.range as any).startColumn, 2); model = TextModel.createFromString('1', undefined, modeService.getLanguageIdentifier('fooLang')); result = await provider.provideCompletionItems(model, new Position(1, 2), context)!; assert.equal(result.suggestions.length, 1); [first] = result.suggestions; - assert.equal(first.range.startColumn, 1); + assert.equal((first.range as any).startColumn, 1); }); }); diff --git a/src/vs/workbench/test/electron-browser/api/extHostApiCommands.test.ts b/src/vs/workbench/test/electron-browser/api/extHostApiCommands.test.ts index 9664bc68bd0..d8e1e01cbb3 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostApiCommands.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostApiCommands.test.ts @@ -412,11 +412,8 @@ suite('ExtHostLanguageFeatureCommands', function () { assert.equal(values.length, 4); let [first, second, third, fourth] = values; assert.equal(first.label, 'item1'); - assert.equal(first.textEdit!.newText, 'item1'); - assert.equal(first.textEdit!.range.start.line, 0); - assert.equal(first.textEdit!.range.start.character, 0); - assert.equal(first.textEdit!.range.end.line, 0); - assert.equal(first.textEdit!.range.end.character, 4); + assert.equal(first.textEdit, undefined);// no text edit, default ranges + assert.ok(!types.Range.isRange(first.range)); assert.equal(second.label, 'item2'); assert.equal(second.textEdit!.newText, 'foo'); @@ -434,10 +431,13 @@ suite('ExtHostLanguageFeatureCommands', function () { assert.equal(fourth.label, 'item4'); assert.equal(fourth.textEdit, undefined); - assert.equal(fourth.range!.start.line, 0); - assert.equal(fourth.range!.start.character, 1); - assert.equal(fourth.range!.end.line, 0); - assert.equal(fourth.range!.end.character, 4); + + const range: any = fourth.range!; + assert.ok(types.Range.isRange(range)); + assert.equal(range.start.line, 0); + assert.equal(range.start.character, 1); + assert.equal(range.end.line, 0); + assert.equal(range.end.character, 4); assert.ok(fourth.insertText instanceof types.SnippetString); assert.equal((fourth.insertText).value, 'foo$0bar'); });