From f39aabecdd20ed318eeea120ebf17dea2d145ecf Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 25 Nov 2025 22:53:05 +0100 Subject: [PATCH] add editKind which describes the shape of the edit --- src/vs/editor/common/languages.ts | 1 + .../browser/model/editKind.ts | 265 ++++++++++ .../browser/model/inlineCompletionsSource.ts | 1 + .../browser/model/inlineSuggestionItem.ts | 14 +- .../browser/model/provideInlineCompletions.ts | 6 +- .../inlineCompletions/browser/telemetry.ts | 3 + .../test/browser/editKind.test.ts | 456 ++++++++++++++++++ .../api/browser/mainThreadLanguageFeatures.ts | 1 + 8 files changed, 744 insertions(+), 3 deletions(-) create mode 100644 src/vs/editor/contrib/inlineCompletions/browser/model/editKind.ts create mode 100644 src/vs/editor/contrib/inlineCompletions/test/browser/editKind.test.ts diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index bb02e95317f..294996bdbcf 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -1071,6 +1071,7 @@ export type LifetimeSummary = { renameCreated: boolean; renameDuration?: number; renameTimedOut: boolean; + editKind: string | undefined; }; export interface CodeAction { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/editKind.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/editKind.ts new file mode 100644 index 00000000000..c766705d534 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/editKind.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Position } from '../../../../common/core/position.js'; +import { StringEdit, StringReplacement } from '../../../../common/core/edits/stringEdit.js'; +import { ITextModel } from '../../../../common/model.js'; + +const syntacticalChars = new Set([';', ',', '=', '+', '-', '*', '/', '{', '}', '(', ')', '[', ']', '<', '>', ':', '.', '!', '?', '&', '|', '^', '%', '@', '#', '~', '`', '\\', '\'', '"', '$']); + +function isSyntacticalChar(char: string): boolean { + return syntacticalChars.has(char); +} + +function isIdentifierChar(char: string): boolean { + return /[a-zA-Z0-9_]/.test(char); +} + +function isWhitespaceChar(char: string): boolean { + return char === ' ' || char === '\t'; +} + +type SingleCharacterKind = 'syntactical' | 'identifier' | 'whitespace'; + +interface SingleLineTextShape { + readonly kind: 'singleLine'; + readonly isSingleCharacter: boolean; + readonly singleCharacterKind: SingleCharacterKind | undefined; + readonly isWord: boolean; + readonly isMultipleWords: boolean; + readonly isMultipleWhitespace: boolean; + readonly hasDuplicatedWhitespace: boolean; +} + +interface MultiLineTextShape { + readonly kind: 'multiLine'; + readonly lineCount: number; +} + +type TextShape = SingleLineTextShape | MultiLineTextShape; + +function analyzeTextShape(text: string): TextShape { + const lines = text.split(/\r\n|\r|\n/); + if (lines.length > 1) { + return { + kind: 'multiLine', + lineCount: lines.length, + }; + } + + const isSingleChar = text.length === 1; + let singleCharKind: SingleCharacterKind | undefined; + if (isSingleChar) { + if (isSyntacticalChar(text)) { + singleCharKind = 'syntactical'; + } else if (isIdentifierChar(text)) { + singleCharKind = 'identifier'; + } else if (isWhitespaceChar(text)) { + singleCharKind = 'whitespace'; + } + } + + // Analyze whitespace patterns + const whitespaceMatches = text.match(/[ \t]+/g) || []; + const isMultipleWhitespace = whitespaceMatches.some(ws => ws.length > 1); + const hasDuplicatedWhitespace = whitespaceMatches.some(ws => + (ws.includes(' ') || ws.includes('\t\t')) + ); + + // Analyze word patterns + const words = text.split(/\s+/).filter(w => w.length > 0); + const isWord = words.length === 1 && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(words[0]); + const isMultipleWords = words.length > 1; + + return { + kind: 'singleLine', + isSingleCharacter: isSingleChar, + singleCharacterKind: singleCharKind, + isWord, + isMultipleWords, + isMultipleWhitespace, + hasDuplicatedWhitespace, + }; +} + +type InsertLocationShape = 'endOfLine' | 'emptyLine' | 'startOfLine' | 'middleOfLine'; + +interface InsertLocationRelativeToCursor { + readonly atCursor: boolean; + readonly beforeCursorOnSameLine: boolean; + readonly afterCursorOnSameLine: boolean; + readonly linesAbove: number | undefined; + readonly linesBelow: number | undefined; +} + +export interface InsertProperties { + readonly textShape: TextShape; + readonly locationShape: InsertLocationShape; + readonly relativeToCursor: InsertLocationRelativeToCursor | undefined; +} + +export interface DeleteProperties { + readonly textShape: TextShape; + readonly isAtEndOfLine: boolean; + readonly deletesEntireLineContent: boolean; +} + +export interface ReplaceProperties { + readonly isWordToWordReplacement: boolean; + readonly isAdditive: boolean; + readonly isSubtractive: boolean; + readonly isSingleLineToSingleLine: boolean; + readonly isSingleLineToMultiLine: boolean; + readonly isMultiLineToSingleLine: boolean; +} + +type EditOperation = 'insert' | 'delete' | 'replace'; + +interface IInlineSuggestionEditKindEdit { + readonly operation: EditOperation; + readonly properties: InsertProperties | DeleteProperties | ReplaceProperties; +} +export class InlineSuggestionEditKind { + constructor(readonly edits: IInlineSuggestionEditKindEdit[]) { } + toString(): string { + return JSON.stringify({ edits: this.edits }); + } +} + +export function computeEditKind(edit: StringEdit, textModel: ITextModel, cursorPosition?: Position): InlineSuggestionEditKind | undefined { + if (edit.replacements.length === 0) { + // Empty edit - treat as insert with empty text + return undefined; + } + + return new InlineSuggestionEditKind(edit.replacements.map(rep => computeSingleEditKind(rep, textModel, cursorPosition))); +} + +function computeSingleEditKind(replacement: StringReplacement, textModel: ITextModel, cursorPosition?: Position): IInlineSuggestionEditKindEdit { + const replaceRange = replacement.replaceRange; + const newText = replacement.newText; + + const kind = replaceRange.isEmpty ? 'insert' : (newText.length === 0 ? 'delete' : 'replace'); + switch (kind) { + case 'insert': + return { + operation: 'insert', + properties: computeInsertProperties(replaceRange.start, newText, textModel, cursorPosition), + }; + case 'delete': + return { + operation: 'delete', + properties: computeDeleteProperties(replaceRange.start, replaceRange.endExclusive, textModel), + }; + case 'replace': { + const oldText = textModel.getValue().substring(replaceRange.start, replaceRange.endExclusive); + return { + operation: 'replace', + properties: computeReplaceProperties(oldText, newText), + }; + } + } + +} + +function computeInsertProperties(offset: number, newText: string, textModel: ITextModel, cursorPosition?: Position): InsertProperties { + const textShape = analyzeTextShape(newText); + const insertPosition = textModel.getPositionAt(offset); + const lineContent = textModel.getLineContent(insertPosition.lineNumber); + const lineLength = lineContent.length; + + // Determine location shape + let locationShape: InsertLocationShape; + const isLineEmpty = lineContent.trim().length === 0; + const isAtEndOfLine = insertPosition.column > lineLength; + const isAtStartOfLine = insertPosition.column === 1; + + if (isLineEmpty) { + locationShape = 'emptyLine'; + } else if (isAtEndOfLine) { + locationShape = 'endOfLine'; + } else if (isAtStartOfLine) { + locationShape = 'startOfLine'; + } else { + locationShape = 'middleOfLine'; + } + + // Compute relative to cursor if cursor position is provided + let relativeToCursor: InsertLocationRelativeToCursor | undefined; + if (cursorPosition) { + const cursorLine = cursorPosition.lineNumber; + const insertLine = insertPosition.lineNumber; + const cursorColumn = cursorPosition.column; + const insertColumn = insertPosition.column; + + const atCursor = cursorLine === insertLine && cursorColumn === insertColumn; + const beforeCursorOnSameLine = cursorLine === insertLine && insertColumn < cursorColumn; + const afterCursorOnSameLine = cursorLine === insertLine && insertColumn > cursorColumn; + const linesAbove = insertLine < cursorLine ? cursorLine - insertLine : undefined; + const linesBelow = insertLine > cursorLine ? insertLine - cursorLine : undefined; + + relativeToCursor = { + atCursor, + beforeCursorOnSameLine, + afterCursorOnSameLine, + linesAbove, + linesBelow, + }; + } + + return { + textShape, + locationShape, + relativeToCursor, + }; +} + +function computeDeleteProperties(startOffset: number, endOffset: number, textModel: ITextModel): DeleteProperties { + const deletedText = textModel.getValue().substring(startOffset, endOffset); + const textShape = analyzeTextShape(deletedText); + + const startPosition = textModel.getPositionAt(startOffset); + const endPosition = textModel.getPositionAt(endOffset); + + // Check if delete is at end of line + const lineContent = textModel.getLineContent(endPosition.lineNumber); + const isAtEndOfLine = endPosition.column > lineContent.length; + + // Check if entire line content is deleted + const deletesEntireLineContent = + startPosition.lineNumber === endPosition.lineNumber && + startPosition.column === 1 && + endPosition.column > lineContent.length; + + return { + textShape, + isAtEndOfLine, + deletesEntireLineContent, + }; +} + +function computeReplaceProperties(oldText: string, newText: string): ReplaceProperties { + const oldShape = analyzeTextShape(oldText); + const newShape = analyzeTextShape(newText); + + const oldIsWord = oldShape.kind === 'singleLine' && oldShape.isWord; + const newIsWord = newShape.kind === 'singleLine' && newShape.isWord; + const isWordToWordReplacement = oldIsWord && newIsWord; + + const isAdditive = newText.length > oldText.length; + const isSubtractive = newText.length < oldText.length; + + const isSingleLineToSingleLine = oldShape.kind === 'singleLine' && newShape.kind === 'singleLine'; + const isSingleLineToMultiLine = oldShape.kind === 'singleLine' && newShape.kind === 'multiLine'; + const isMultiLineToSingleLine = oldShape.kind === 'multiLine' && newShape.kind === 'singleLine'; + + return { + isWordToWordReplacement, + isAdditive, + isSubtractive, + isSingleLineToSingleLine, + isSingleLineToMultiLine, + isMultiLineToSingleLine, + }; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts index 0e80cc8ca4e..c51fde60923 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts @@ -452,6 +452,7 @@ export class InlineCompletionsSource extends Disposable { renameCreated: false, renameDuration: undefined, renameTimedOut: false, + editKind: undefined, }; const dataChannel = this._instantiationService.createInstance(DataChannelForwardingTelemetryService); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts index bc6409a89fe..ca181e6322e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts @@ -24,6 +24,7 @@ import { Command, InlineCompletion, InlineCompletionHintStyle, InlineCompletionE import { EndOfLinePreference, ITextModel } from '../../../../common/model.js'; import { TextModelText } from '../../../../common/model/textModelText.js'; import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js'; +import { InlineSuggestionEditKind, computeEditKind } from './editKind.js'; import { InlineSuggestData, InlineSuggestionList, PartialAcceptance, RenameInfo, SnippetInfo } from './provideInlineCompletions.js'; import { singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js'; @@ -94,6 +95,7 @@ abstract class InlineSuggestionItemBase { public abstract withIdentity(identity: InlineSuggestionIdentity): InlineSuggestionItem; public abstract canBeReused(model: ITextModel, position: Position): boolean; + public abstract computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined; public addRef(): void { this.identity.addRef(); @@ -105,8 +107,8 @@ abstract class InlineSuggestionItemBase { this.source.removeRef(); } - public reportInlineEditShown(commandService: ICommandService, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData) { - this._data.reportInlineEditShown(commandService, this.insertText, viewKind, viewData); + public reportInlineEditShown(commandService: ICommandService, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, model: ITextModel) { + this._data.reportInlineEditShown(commandService, this.insertText, viewKind, viewData, this.computeEditKind(model)); } public reportPartialAccept(acceptedCharacters: number, info: PartialAcceptInfo, partialAcceptance: PartialAcceptance) { @@ -309,6 +311,10 @@ export class InlineCompletionItem extends InlineSuggestionItemBase { const singleTextEdit = this.getSingleTextEdit(); return inlineCompletionIsVisible(singleTextEdit, this._originalRange, model, cursorPosition); } + + override computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined { + return computeEditKind(new StringEdit([this._edit]), model); + } } export function inlineCompletionIsVisible(singleTextEdit: TextReplacement, originalRange: Range | undefined, model: ITextModel, cursorPosition: Position): boolean { @@ -470,6 +476,10 @@ export class InlineEditItem extends InlineSuggestionItemBase { inlineEditModelVersion, ); } + + override computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined { + return computeEditKind(this._edit, model); + } } function getStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts index 694d1a1d5ac..9e14a79bca8 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/provideInlineCompletions.ts @@ -30,6 +30,7 @@ import { isDefined } from '../../../../../base/common/types.js'; import { inlineCompletionIsVisible } from './inlineSuggestionItem.js'; import { EditDeltaInfo } from '../../../../common/textModelEditSource.js'; import { URI } from '../../../../../base/common/uri.js'; +import { InlineSuggestionEditKind } from './editKind.js'; export type InlineCompletionContextWithoutUuid = Omit; @@ -303,6 +304,7 @@ export class InlineSuggestData { private _partiallyAcceptedCount = 0; private _partiallyAcceptedSinceOriginal: PartialAcceptance = { characters: 0, ratio: 0, count: 0 }; private _renameInfo: RenameInfo | undefined = undefined; + private _editKind: InlineSuggestionEditKind | undefined = undefined; constructor( public readonly range: Range, @@ -334,13 +336,14 @@ export class InlineSuggestData { return new TextReplacement(this.range, this.insertText); } - public async reportInlineEditShown(commandService: ICommandService, updatedInsertText: string, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData): Promise { + public async reportInlineEditShown(commandService: ICommandService, updatedInsertText: string, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, editKind: InlineSuggestionEditKind | undefined): Promise { this.updateShownDuration(viewKind); if (this._didShow) { return; } this._didShow = true; + this._editKind = editKind; this._viewData.viewKind = viewKind; this._viewData.renderData = viewData; this._timeUntilShown = Date.now() - this._requestInfo.startTime; @@ -399,6 +402,7 @@ export class InlineSuggestData { shown: this._didShow, shownDuration: this._shownDuration, shownDurationUncollapsed: this._showUncollapsedDuration, + editKind: this._editKind?.toString(), preceeded: this._isPreceeded, timeUntilShown: this._timeUntilShown, timeUntilProviderRequest: this._providerRequestInfo.startTime - this._requestInfo.startTime, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/telemetry.ts b/src/vs/editor/contrib/inlineCompletions/browser/telemetry.ts index 4ef2df054bd..6eee3b172f2 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/telemetry.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/telemetry.ts @@ -54,6 +54,8 @@ export type InlineCompletionEndOfLifeEvent = { sameShapeReplacements: boolean | undefined; // empty noSuggestionReason: string | undefined; + // shape + editKind: string | undefined; }; type InlineCompletionsEndOfLifeClassification = { @@ -98,4 +100,5 @@ type InlineCompletionsEndOfLifeClassification = { sameShapeReplacements: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether all inner replacements are the same shape' }; noSuggestionReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason why no inline completion was provided' }; notShownReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason why the inline completion was not shown' }; + editKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of edit made by the inline completion' }; }; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/editKind.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/editKind.test.ts new file mode 100644 index 00000000000..0b4fa897032 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/editKind.test.ts @@ -0,0 +1,456 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Position } from '../../../../common/core/position.js'; +import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js'; +import { StringEdit } from '../../../../common/core/edits/stringEdit.js'; +import { createTextModel } from '../../../../test/common/testTextModel.js'; +import { computeEditKind, InsertProperties, DeleteProperties, ReplaceProperties } from '../../browser/model/editKind.js'; + +suite('computeEditKind', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('Insert operations', () => { + test('single character insert - syntactical', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, ';'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.textShape.kind, 'singleLine'); + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isSingleCharacter, true); + assert.strictEqual(props.textShape.singleCharacterKind, 'syntactical'); + } + model.dispose(); + }); + + test('single character insert - identifier', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, 'a'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isSingleCharacter, true); + assert.strictEqual(props.textShape.singleCharacterKind, 'identifier'); + } + model.dispose(); + }); + + test('single character insert - whitespace', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, ' '); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isSingleCharacter, true); + assert.strictEqual(props.textShape.singleCharacterKind, 'whitespace'); + } + model.dispose(); + }); + + test('word insert', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, 'foo'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isWord, true); + assert.strictEqual(props.textShape.isMultipleWords, false); + } + model.dispose(); + }); + + test('multiple words insert', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, 'foo bar baz'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isMultipleWords, true); + } + model.dispose(); + }); + + test('multi-line insert', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, 'line1\nline2\nline3'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.textShape.kind, 'multiLine'); + if (props.textShape.kind === 'multiLine') { + assert.strictEqual(props.textShape.lineCount, 3); + } + model.dispose(); + }); + + test('insert at end of line', () => { + const model = createTextModel('hello'); + const edit = StringEdit.insert(5, ' world'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.locationShape, 'endOfLine'); + model.dispose(); + }); + + test('insert on empty line', () => { + const model = createTextModel('hello\n\nworld'); + const edit = StringEdit.insert(6, 'text'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.locationShape, 'emptyLine'); + model.dispose(); + }); + + test('insert at start of line', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(0, 'prefix'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.locationShape, 'startOfLine'); + model.dispose(); + }); + + test('insert in middle of line', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, '_'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.strictEqual(props.locationShape, 'middleOfLine'); + model.dispose(); + }); + + test('insert relative to cursor - at cursor', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(5, 'text'); + const cursor = new Position(1, 6); // column is 1-based + const result = computeEditKind(edit, model, cursor); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.ok(props.relativeToCursor); + assert.strictEqual(props.relativeToCursor.atCursor, true); + model.dispose(); + }); + + test('insert relative to cursor - before cursor on same line', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(2, 'text'); + const cursor = new Position(1, 8); + const result = computeEditKind(edit, model, cursor); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.ok(props.relativeToCursor); + assert.strictEqual(props.relativeToCursor.beforeCursorOnSameLine, true); + model.dispose(); + }); + + test('insert relative to cursor - after cursor on same line', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.insert(8, 'text'); + const cursor = new Position(1, 4); + const result = computeEditKind(edit, model, cursor); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.ok(props.relativeToCursor); + assert.strictEqual(props.relativeToCursor.afterCursorOnSameLine, true); + model.dispose(); + }); + + test('insert relative to cursor - lines above', () => { + const model = createTextModel('line1\nline2\nline3'); + const edit = StringEdit.insert(0, 'text'); + const cursor = new Position(3, 1); + const result = computeEditKind(edit, model, cursor); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.ok(props.relativeToCursor); + assert.strictEqual(props.relativeToCursor.linesAbove, 2); + model.dispose(); + }); + + test('insert relative to cursor - lines below', () => { + const model = createTextModel('line1\nline2\nline3'); + const edit = StringEdit.insert(12, 'text'); // after 'line2\n' + const cursor = new Position(1, 1); + const result = computeEditKind(edit, model, cursor); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + assert.ok(props.relativeToCursor); + assert.strictEqual(props.relativeToCursor.linesBelow, 2); + model.dispose(); + }); + + test('duplicated whitespace insert', () => { + const model = createTextModel('hello'); + const edit = StringEdit.insert(5, ' '); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'insert'); + const props = result.edits[0].properties as InsertProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.hasDuplicatedWhitespace, true); + } + model.dispose(); + }); + }); + + suite('Delete operations', () => { + test('single character delete - identifier', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.delete(new OffsetRange(4, 5)); // delete 'o' + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'delete'); + const props = result.edits[0].properties as DeleteProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isSingleCharacter, true); + assert.strictEqual(props.textShape.singleCharacterKind, 'identifier'); + } + model.dispose(); + }); + + test('single character delete - syntactical', () => { + const model = createTextModel('hello;world'); + const edit = StringEdit.delete(new OffsetRange(5, 6)); // delete ';' + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'delete'); + const props = result.edits[0].properties as DeleteProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isSingleCharacter, true); + assert.strictEqual(props.textShape.singleCharacterKind, 'syntactical'); + } + model.dispose(); + }); + + test('word delete', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.delete(new OffsetRange(0, 5)); // delete 'hello' + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'delete'); + const props = result.edits[0].properties as DeleteProperties; + if (props.textShape.kind === 'singleLine') { + assert.strictEqual(props.textShape.isWord, true); + } + model.dispose(); + }); + + test('multi-line delete', () => { + const model = createTextModel('line1\nline2\nline3'); + const edit = StringEdit.delete(new OffsetRange(0, 12)); // delete 'line1\nline2\n' + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'delete'); + const props = result.edits[0].properties as DeleteProperties; + assert.strictEqual(props.textShape.kind, 'multiLine'); + model.dispose(); + }); + + test('delete entire line content', () => { + const model = createTextModel('hello'); + const edit = StringEdit.delete(new OffsetRange(0, 5)); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'delete'); + const props = result.edits[0].properties as DeleteProperties; + assert.strictEqual(props.deletesEntireLineContent, true); + model.dispose(); + }); + }); + + suite('Replace operations', () => { + test('word to word replacement', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.replace(new OffsetRange(0, 5), 'goodbye'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isWordToWordReplacement, true); + model.dispose(); + }); + + test('additive replacement', () => { + const model = createTextModel('hi world'); + const edit = StringEdit.replace(new OffsetRange(0, 2), 'hello'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isAdditive, true); + assert.strictEqual(props.isSubtractive, false); + model.dispose(); + }); + + test('subtractive replacement', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.replace(new OffsetRange(0, 5), 'hi'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isSubtractive, true); + assert.strictEqual(props.isAdditive, false); + model.dispose(); + }); + + test('single line to multi-line replacement', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.replace(new OffsetRange(0, 5), 'line1\nline2'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isSingleLineToMultiLine, true); + model.dispose(); + }); + + test('multi-line to single line replacement', () => { + const model = createTextModel('line1\nline2\nline3'); + const edit = StringEdit.replace(new OffsetRange(0, 12), 'hello'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isMultiLineToSingleLine, true); + model.dispose(); + }); + + test('single line to single line replacement', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.replace(new OffsetRange(0, 5), 'goodbye'); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 1); + assert.strictEqual(result.edits[0].operation, 'replace'); + const props = result.edits[0].properties as ReplaceProperties; + assert.strictEqual(props.isSingleLineToSingleLine, true); + model.dispose(); + }); + }); + + suite('Empty edit', () => { + test('empty edit returns undefined', () => { + const model = createTextModel('hello world'); + const edit = StringEdit.empty; + const result = computeEditKind(edit, model); + + assert.strictEqual(result, undefined); + model.dispose(); + }); + }); + + suite('Multiple replacements', () => { + test('multiple inserts', () => { + const model = createTextModel('hello world'); + const edit = new StringEdit([ + StringEdit.insert(0, 'A').replacements[0], + StringEdit.insert(5, 'B').replacements[0], + ]); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 2); + assert.strictEqual(result.edits[0].operation, 'insert'); + assert.strictEqual(result.edits[1].operation, 'insert'); + model.dispose(); + }); + + test('mixed operations', () => { + const model = createTextModel('hello world'); + const edit = new StringEdit([ + StringEdit.insert(0, 'prefix').replacements[0], + StringEdit.delete(new OffsetRange(5, 6)).replacements[0], + ]); + const result = computeEditKind(edit, model); + + assert.ok(result); + assert.strictEqual(result.edits.length, 2); + assert.strictEqual(result.edits[0].operation, 'insert'); + assert.strictEqual(result.edits[1].operation, 'delete'); + model.dispose(); + }); + }); +}); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 4aa79c80b2e..55b7028fb9d 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1449,6 +1449,7 @@ class ExtensionBackedInlineCompletionsProvider extends Disposable implements lan renameCreated: lifetimeSummary.renameCreated, renameDuration: lifetimeSummary.renameDuration, renameTimedOut: lifetimeSummary.renameTimedOut, + editKind: lifetimeSummary.editKind, ...forwardToChannelIf(isCopilotLikeExtension(this.providerId.extensionId!)), };