diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 230e6eb7bdd..6ec11f03919 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -254,6 +254,15 @@ export function splitLines(str: string): string[] { return str.split(/\r\n|\r|\n/); } +export function splitLinesIncludeSeparators(str: string): string[] { + const linesWithSeparators: string[] = []; + const splitLinesAndSeparators = str.split(/(\r\n|\r|\n)/); + for (let i = 0; i < Math.ceil(splitLinesAndSeparators.length / 2); i++) { + linesWithSeparators.push(splitLinesAndSeparators[2 * i] + (splitLinesAndSeparators[2 * i + 1] ?? '')); + } + return linesWithSeparators; +} + /** * Returns first index of the string that is not whitespace. * If string is empty or contains only whitespaces, returns -1 diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index 2faf2a0f8a9..e385a0d3c65 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -7,7 +7,7 @@ import { mapFindFirst } from 'vs/base/common/arraysFind'; import { BugIndicatingError, onUnexpectedExternalError } from 'vs/base/common/errors'; import { Disposable } from 'vs/base/common/lifecycle'; import { IObservable, IReader, ITransaction, autorun, derived, derivedHandleChanges, derivedOpts, recomputeInitiallyAndOnChange, observableSignal, observableValue, subtransaction, transaction } from 'vs/base/common/observable'; -import { commonPrefixLength } from 'vs/base/common/strings'; +import { commonPrefixLength, splitLinesIncludeSeparators } from 'vs/base/common/strings'; import { isDefined } from 'vs/base/common/types'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditOperation } from 'vs/editor/common/core/editOperation'; @@ -22,7 +22,7 @@ import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostT import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource'; import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; import { SuggestItemInfo } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; -import { Permutation, addPositions, getNewRanges, lengthOfText } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { Permutation, addPositions, getNewRanges, lengthOfText, subtractPositions } from 'vs/editor/contrib/inlineCompletions/browser/utils'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -223,7 +223,7 @@ export class InlineCompletionsModel extends Disposable { const mode = this._suggestPreviewMode.read(reader); const positions = this._positions.read(reader); - const edits = [fullEdit, ...this._getSecondaryEdits(this.textModel, positions, fullEdit)]; + const edits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)]; const ghostTexts = edits .map((edit, idx) => edit.computeGhostText(model, mode, positions[idx], fullEditPreviewLength)) .filter(isDefined); @@ -237,7 +237,7 @@ export class InlineCompletionsModel extends Disposable { const replacement = inlineCompletion.toSingleTextEdit(reader); const mode = this._inlineSuggestMode.read(reader); const positions = this._positions.read(reader); - const edits = [replacement, ...this._getSecondaryEdits(this.textModel, positions, replacement)]; + const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)]; const ghostTexts = edits .map((edit, idx) => edit.computeGhostText(model, mode, positions[idx], 0)) .filter(isDefined); @@ -432,7 +432,7 @@ export class InlineCompletionsModel extends Disposable { const replaceRange = Range.fromPositions(cursorPosition, ghostTextPos); const newText = editor.getModel()!.getValueInRange(replaceRange) + partialGhostTextVal; const primaryEdit = new SingleTextEdit(replaceRange, newText); - const edits = [primaryEdit, ...this._getSecondaryEdits(this.textModel, positions, primaryEdit)]; + const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)]; const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replaceMove(edit.range, edit.text))); editor.setSelections(selections, 'inlineCompletionPartialAccept'); @@ -455,23 +455,6 @@ export class InlineCompletionsModel extends Disposable { } } - private _getSecondaryEdits(textModel: ITextModel, positions: readonly Position[], primaryEdit: SingleTextEdit): SingleTextEdit[] { - const primaryPosition = positions[0]; - const secondaryPositions = positions.slice(1); - const replacedTextAfterPrimaryCursor = textModel - .getLineContent(primaryPosition.lineNumber) - .substring(primaryPosition.column - 1, primaryEdit.range.endColumn - 1); - const secondaryEditText = primaryEdit.text.substring(primaryPosition.column - primaryEdit.range.startColumn); - return secondaryPositions.map(pos => { - const textAfterSecondaryCursor = this.textModel - .getLineContent(pos.lineNumber) - .substring(pos.column - 1); - const l = commonPrefixLength(replacedTextAfterPrimaryCursor, textAfterSecondaryCursor); - const range = Range.fromPositions(pos, pos.delta(0, l)); - return new SingleTextEdit(range, secondaryEditText); - }); - } - public handleSuggestAccepted(item: SuggestItemInfo) { const itemEdit = item.toSingleTextEdit().removeCommonPrefix(this.textModel); const augmentedCompletion = this._computeAugmentation(itemEdit, undefined); @@ -486,6 +469,34 @@ export class InlineCompletionsModel extends Disposable { } } +export function getSecondaryEdits(textModel: ITextModel, positions: readonly Position[], primaryEdit: SingleTextEdit): SingleTextEdit[] { + const primaryPosition = positions[0]; + const secondaryPositions = positions.slice(1); + const primaryEditEndPosition = primaryEdit.range.getEndPosition(); + const replacedTextAfterPrimaryCursor = textModel.getValueInRange( + Range.fromPositions(primaryPosition, primaryEditEndPosition) + ); + const positionWithinTextEdit = subtractPositions(primaryPosition, primaryEdit.range.getStartPosition()); + const secondaryEditText = getTextFromPosition(primaryEdit.text, positionWithinTextEdit); + return secondaryPositions.map(pos => { + const textAfterSecondaryCursor = textModel.getValueInRange( + Range.fromPositions(pos, primaryEditEndPosition) + ); + const l = commonPrefixLength(replacedTextAfterPrimaryCursor, textAfterSecondaryCursor); + const range = Range.fromPositions(pos, pos.delta(0, l)); + return new SingleTextEdit(range, secondaryEditText); + }); +} + +function getTextFromPosition(text: string, pos: Position): string { + let subtext = ''; + const lines = splitLinesIncludeSeparators(text); + for (let i = pos.lineNumber - 1; i < lines.length; i++) { + subtext += lines[i].substring(i === pos.lineNumber - 1 ? pos.column - 1 : 0); + } + return subtext; +} + function getEndPositionsAfterApplying(edits: readonly SingleTextEdit[]): Position[] { const sortPerm = Permutation.createSortPermutation(edits, (edit1, edit2) => Range.compareRangesUsingStarts(edit1.range, edit2.range)); const sortedNewRanges = getNewRanges(sortPerm.apply(edits)); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts index 805de9a781a..4a9e78238b6 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts @@ -96,6 +96,10 @@ export function addPositions(pos1: Position, pos2: Position): Position { return new Position(pos1.lineNumber + pos2.lineNumber - 1, pos2.lineNumber === 1 ? pos1.column + pos2.column - 1 : pos2.column); } +export function subtractPositions(pos1: Position, pos2: Position): Position { + return new Position(pos1.lineNumber - pos2.lineNumber + 1, pos1.lineNumber - pos2.lineNumber === 0 ? pos1.column - pos2.column + 1 : pos1.column); +} + export function lengthOfText(text: string): Position { let line = 1; let column = 1; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts new file mode 100644 index 00000000000..67c56bb3945 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsModel.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; +import { Position } from 'vs/editor/common/core/position'; +import { getSecondaryEdits } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { createTextModel } from 'vs/editor/test/common/testTextModel'; +import { Range } from 'vs/editor/common/core/range'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; + +suite('inlineCompletionModel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getSecondaryEdits - basic', async function () { + + const textModel = createTextModel([ + 'function fib(', + 'function fib(' + ].join('\n')); + const positions = [ + new Position(1, 14), + new Position(2, 14) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 1, 14), 'function fib() {'); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(2, 14, 2, 14), + ') {' + )]); + textModel.dispose(); + }); + + test('getSecondaryEdits - cursor not on same line as primary edit 1', async function () { + + const textModel = createTextModel([ + 'function fib(', + '', + 'function fib(', + '' + ].join('\n')); + const positions = [ + new Position(2, 1), + new Position(4, 1) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 2, 1), [ + 'function fib() {', + ' return 0;', + '}' + ].join('\n')); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(4, 1, 4, 1), [ + ' return 0;', + '}' + ].join('\n') + )]); + textModel.dispose(); + }); + + test('getSecondaryEdits - cursor not on same line as primary edit 2', async function () { + + const textModel = createTextModel([ + 'class A {', + '', + 'class B {', + '', + 'function f() {}' + ].join('\n')); + const positions = [ + new Position(2, 1), + new Position(4, 1) + ]; + const primaryEdit = new SingleTextEdit(new Range(1, 1, 2, 1), [ + 'class A {', + ' public x: number = 0;', + ' public y: number = 0;', + '}' + ].join('\n')); + const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit); + assert.deepStrictEqual(secondaryEdits, [new SingleTextEdit( + new Range(4, 1, 4, 1), [ + ' public x: number = 0;', + ' public y: number = 0;', + '}' + ].join('\n') + )]); + textModel.dispose(); + }); +});