Path: blob/main/src/vs/editor/contrib/inlineCompletions/browser/model/computeGhostText.ts
4798 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IDiffChange, LcsDiff } from '../../../../../base/common/diff/diff.js';6import { getLeadingWhitespace } from '../../../../../base/common/strings.js';7import { Position } from '../../../../common/core/position.js';8import { Range } from '../../../../common/core/range.js';9import { TextReplacement } from '../../../../common/core/edits/textEdit.js';10import { ITextModel } from '../../../../common/model.js';11import { GhostText, GhostTextPart } from './ghostText.js';12import { singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js';1314/**15* @param previewSuffixLength Sets where to split `inlineCompletion.text`.16* If the text is `hello` and the suffix length is 2, the non-preview part is `hel` and the preview-part is `lo`.17*/18export function computeGhostText(19edit: TextReplacement,20model: ITextModel,21mode: 'prefix' | 'subword' | 'subwordSmart',22cursorPosition?: Position,23previewSuffixLength = 024): GhostText | undefined {25let e = singleTextRemoveCommonPrefix(edit, model);2627if (e.range.endLineNumber !== e.range.startLineNumber) {28// This edit might span multiple lines, but the first lines must be a common prefix.29return undefined;30}3132const sourceLine = model.getLineContent(e.range.startLineNumber);33const sourceIndentationLength = getLeadingWhitespace(sourceLine).length;3435const suggestionTouchesIndentation = e.range.startColumn - 1 <= sourceIndentationLength;36if (suggestionTouchesIndentation) {37// source: ··········[······abc]38// ^^^^^^^^^ inlineCompletion.range39// ^^^^^^^^^^ ^^^^^^ sourceIndentationLength40// ^^^^^^ replacedIndentation.length41// ^^^ rangeThatDoesNotReplaceIndentation42// inlineCompletion.text: '··foo'43// ^^ suggestionAddedIndentationLength44const suggestionAddedIndentationLength = getLeadingWhitespace(e.text).length;4546const replacedIndentation = sourceLine.substring(e.range.startColumn - 1, sourceIndentationLength);4748const [startPosition, endPosition] = [e.range.getStartPosition(), e.range.getEndPosition()];49const newStartPosition = startPosition.column + replacedIndentation.length <= endPosition.column50? startPosition.delta(0, replacedIndentation.length)51: endPosition;52const rangeThatDoesNotReplaceIndentation = Range.fromPositions(newStartPosition, endPosition);5354const suggestionWithoutIndentationChange = e.text.startsWith(replacedIndentation)55// Adds more indentation without changing existing indentation: We can add ghost text for this56? e.text.substring(replacedIndentation.length)57// Changes or removes existing indentation. Only add ghost text for the non-indentation part.58: e.text.substring(suggestionAddedIndentationLength);5960e = new TextReplacement(rangeThatDoesNotReplaceIndentation, suggestionWithoutIndentationChange);61}6263// This is a single line string64const valueToBeReplaced = model.getValueInRange(e.range);6566const changes = cachingDiff(valueToBeReplaced, e.text);6768if (!changes) {69// No ghost text in case the diff would be too slow to compute70return undefined;71}7273const lineNumber = e.range.startLineNumber;7475const parts = new Array<GhostTextPart>();7677if (mode === 'prefix') {78const filteredChanges = changes.filter(c => c.originalLength === 0);79if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) {80// Prefixes only have a single change.81return undefined;82}83}8485const previewStartInCompletionText = e.text.length - previewSuffixLength;8687for (const c of changes) {88const insertColumn = e.range.startColumn + c.originalStart + c.originalLength;8990if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === e.range.startLineNumber && insertColumn < cursorPosition.column) {91// No ghost text before cursor92return undefined;93}9495if (c.originalLength > 0) {96return undefined;97}9899if (c.modifiedLength === 0) {100continue;101}102103const modifiedEnd = c.modifiedStart + c.modifiedLength;104const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInCompletionText));105const nonPreviewText = e.text.substring(c.modifiedStart, nonPreviewTextEnd);106const italicText = e.text.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd));107108if (nonPreviewText.length > 0) {109parts.push(new GhostTextPart(insertColumn, nonPreviewText, false));110}111if (italicText.length > 0) {112parts.push(new GhostTextPart(insertColumn, italicText, true));113}114}115116return new GhostText(lineNumber, parts);117}118119let lastRequest: { originalValue: string; newValue: string; changes: readonly IDiffChange[] | undefined } | undefined = undefined;120function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] | undefined {121if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) {122return lastRequest?.changes;123} else {124let changes = smartDiff(originalValue, newValue, true);125if (changes) {126const deletedChars = deletedCharacters(changes);127if (deletedChars > 0) {128// For performance reasons, don't compute diff if there is nothing to improve129const newChanges = smartDiff(originalValue, newValue, false);130if (newChanges && deletedCharacters(newChanges) < deletedChars) {131// Disabling smartness seems to be better here132changes = newChanges;133}134}135}136lastRequest = {137originalValue,138newValue,139changes140};141return changes;142}143}144145function deletedCharacters(changes: readonly IDiffChange[]): number {146let sum = 0;147for (const c of changes) {148sum += c.originalLength;149}150return sum;151}152153/**154* When matching `if ()` with `if (f() = 1) { g(); }`,155* align it like this: `if ( )`156* Not like this: `if ( )`157* Also not like this: `if ( )`.158*159* The parenthesis are preprocessed to ensure that they match correctly.160*/161export function smartDiff(originalValue: string, newValue: string, smartBracketMatching: boolean): (readonly IDiffChange[]) | undefined {162if (originalValue.length > 5000 || newValue.length > 5000) {163// We don't want to work on strings that are too big164return undefined;165}166167function getMaxCharCode(val: string): number {168let maxCharCode = 0;169for (let i = 0, len = val.length; i < len; i++) {170const charCode = val.charCodeAt(i);171if (charCode > maxCharCode) {172maxCharCode = charCode;173}174}175return maxCharCode;176}177178const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue));179function getUniqueCharCode(id: number): number {180if (id < 0) {181throw new Error('unexpected');182}183return maxCharCode + id + 1;184}185186function getElements(source: string): Int32Array {187let level = 0;188let group = 0;189const characters = new Int32Array(source.length);190for (let i = 0, len = source.length; i < len; i++) {191// TODO support more brackets192if (smartBracketMatching && source[i] === '(') {193const id = group * 100 + level;194characters[i] = getUniqueCharCode(2 * id);195level++;196} else if (smartBracketMatching && source[i] === ')') {197level = Math.max(level - 1, 0);198const id = group * 100 + level;199characters[i] = getUniqueCharCode(2 * id + 1);200if (level === 0) {201group++;202}203} else {204characters[i] = source.charCodeAt(i);205}206}207return characters;208}209210const elements1 = getElements(originalValue);211const elements2 = getElements(newValue);212213return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes;214}215216217