Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts
13399 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 { Position, Range, TextDocument } from 'vscode';67export interface InlineSuggestionEdit {8readonly range: Range;9readonly newText: string;10}1112/**13* Determines whether an edit can be displayed as an inline suggestion (ghost text).14* If so, returns the (possibly adjusted) range and text that touches the cursor position,15* which is required for VS Code to render ghost text.16*/17export function toInlineSuggestion(cursorPos: Position, doc: TextDocument, range: Range, newText: string, advanced: boolean = true): InlineSuggestionEdit | undefined {18// Special case: a multi-line insertion that starts on the line *after* the cursor19// can be re-expressed as a pure insertion at the cursor.20const nextLineInsertion = tryAdjustNextLineInsertion(cursorPos, doc, range, newText);21if (nextLineInsertion) {22return nextLineInsertion;23}2425// If the range spans multiple lines, try to collapse it to a single line by26// trimming a shared prefix up to a newline boundary.27if (advanced && range.start.line !== range.end.line) {28({ range, newText } = stripCommonLinePrefix(doc, range, newText));29}3031// Ghost text requires the edit to be on the cursor's line.32if (range.start.line !== range.end.line || range.start.line !== cursorPos.line) {33return undefined;34}3536return validateSameLineGhostText(cursorPos, doc, range, newText);37}3839/**40* If the cursor is at the end of a line and the edit is an empty-range insertion41* at column 0 of the next line, rewrite it as a pure insertion at the cursor42* position. This is allowed when either:43* - `newText` ends with a newline (any existing content on the target line is44* pushed onto the following line), or45* - `newText` contains a newline and the target line is fully consumed by the46* insertion (no leftover content after the insertion).47*/48function tryAdjustNextLineInsertion(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {49if (!range.isEmpty) {50return undefined;51}52if (cursorPos.line + 1 !== range.start.line || range.start.character !== 0) {53return undefined;54}55if (doc.lineAt(cursorPos.line).text.length !== cursorPos.character) {56return undefined; // cursor is not at the end of the line57}5859const targetLineFullyConsumed = doc.lineAt(range.end.line).text.length === range.end.character;60const noLeftoverAfterInsertion = newText.endsWith('\n') || (newText.includes('\n') && targetLineFullyConsumed);61if (!noLeftoverAfterInsertion) {62return undefined;63}6465// Use an empty range at the cursor so the suggestion is a pure insertion.66// The original line terminator between the cursor and `range.start` is preserved67// in the document, so:68// - prepend that terminator to `newText` (it lives in the doc, not in the edit), and69// - drop a single trailing line ending from `newText` to avoid an extra blank line.70// CRLF-safe so we don't leak a dangling '\r' into the suggestion.71const lineBreak = doc.getText(new Range(cursorPos, range.start));72const trimmedNewText = newText.replace(/\r?\n$/, '');73return { range: new Range(cursorPos, cursorPos), newText: lineBreak + trimmedNewText };74}7576/**77* Strip the longest shared prefix that ends on a newline boundary from both sides78* of a multi-line edit. This often shrinks the range so it fits on a single line,79* which is required for ghost text rendering.80*/81function stripCommonLinePrefix(doc: TextDocument, range: Range, newText: string): { range: Range; newText: string } {82const replacedText = doc.getText(range);83const maxLen = Math.min(replacedText.length, newText.length);84let commonLen = 0;85while (commonLen < maxLen && replacedText[commonLen] === newText[commonLen]) {86commonLen++;87}88if (commonLen === 0) {89return { range, newText };90}91const lastNewline = replacedText.lastIndexOf('\n', commonLen - 1);92if (lastNewline < 0) {93return { range, newText };94}95const strippedLen = lastNewline + 1;96const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen);97return { range: new Range(newStart, range.end), newText: newText.substring(strippedLen) };98}99100/**101* Validate that a single-line edit can be rendered as ghost text at the cursor:102* - the cursor is at or after `range.start`103* - everything before the cursor in the replaced text matches `newText`104* - the replaced text is a subword of `newText` (i.e. only insertions are needed)105*/106function validateSameLineGhostText(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {107const replacedText = doc.getText(range);108const cursorOffsetInReplacedText = cursorPos.character - range.start.character;109if (cursorOffsetInReplacedText < 0) {110return undefined;111}112if (replacedText.substring(0, cursorOffsetInReplacedText) !== newText.substring(0, cursorOffsetInReplacedText)) {113return undefined;114}115if (!isSubword(replacedText, newText)) {116return undefined;117}118return { range, newText };119}120121/**122* a is subword of b if a can be obtained by removing characters from b123*/124export function isSubword(a: string, b: string): boolean {125for (let aIdx = 0, bIdx = 0; aIdx < a.length; bIdx++) {126if (bIdx >= b.length) {127return false;128}129if (a[aIdx] === b[bIdx]) {130aIdx++;131}132}133return true;134}135136137138