Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -644,4 +644,25 @@ const fieldLabels: Record<keyof FormData, string> = {
assert.strictEqual(result!.newText, '\r\n');
});
});

suite('multi-line range, no common prefix', () => {

// Regression: when commonLen === 0 and the replaced text starts with '\n',
// `lastIndexOf('\n', -1)` would (incorrectly) clamp to 0 and report a
// match, causing the leading newline to be stripped — which can collapse
// the multi-line range into a same-line "suggestion" that the function
// then accepts. With the original substring-based check, no strip occurs
// and the result is `undefined`.
test('does not strip leading newline when nothing is in common', () => {
const document = createMockDocument(['abc', 'x', 'rest']);
// replacedText = '\nx', newText[0]='Y' differs from '\n', commonLen=0.
const replaceRange = new Range(0, 3, 1, 1);
const cursorPosition = new Position(1, 1);
const replaceText = 'Yx';

// The range cannot legitimately be collapsed to a single line, so
// the function must not synthesize a ghost-text suggestion.
assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/

import { Position, Range, TextDocument } from 'vscode';
import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';

export interface InlineSuggestionEdit {
readonly range: Range;
Expand All @@ -17,73 +16,112 @@ export interface InlineSuggestionEdit {
* which is required for VS Code to render ghost text.
*/
export function toInlineSuggestion(cursorPos: Position, doc: TextDocument, range: Range, newText: string, advanced: boolean = true): InlineSuggestionEdit | undefined {
// If multi line insertion starts on the next line
// All new lines have to be newly created lines
if (range.isEmpty && cursorPos.line + 1 === range.start.line && range.start.character === 0
&& doc.lineAt(cursorPos.line).text.length === cursorPos.character // cursor is at the end of the line
&& (newText.endsWith('\n') || (newText.includes('\n') && doc.lineAt(range.end.line).text.length === range.end.character)) // no remaining content after insertion
) {
// Use an empty range at the cursor so the suggestion is a pure insertion
const adjustedRange = new Range(cursorPos, cursorPos);
const textBetweenCursorAndRange = doc.getText(new Range(cursorPos, range.start));
// The original range is on the next line, so the line terminator that
// already separates the cursor's line from range.start is preserved.
// Drop a single trailing line ending from newText (if present) to avoid
// inserting an extra blank line after the suggestion. Handle CRLF as
// well as LF so we don't leave a dangling '\r'.
const adjustedNewText = newText.replace(/\r?\n$/, '');
return { range: adjustedRange, newText: textBetweenCursorAndRange + adjustedNewText };
// Special case: a multi-line insertion that starts on the line *after* the cursor
// can be re-expressed as a pure insertion at the cursor.
const nextLineInsertion = tryAdjustNextLineInsertion(cursorPos, doc, range, newText);
if (nextLineInsertion) {
return nextLineInsertion;
}

if (advanced) {
// If the range spans multiple lines, try to reduce it by stripping a common
// prefix (up to a newline boundary) from the replaced text and newText.
if (range.start.line !== range.end.line) {
const fullReplacedText = doc.getText(range);
let commonLen = 0;
const maxLen = Math.min(fullReplacedText.length, newText.length);
while (commonLen < maxLen && fullReplacedText[commonLen] === newText[commonLen]) {
commonLen++;
}
const lastNewline = fullReplacedText.substring(0, commonLen).lastIndexOf('\n');
if (lastNewline >= 0) {
const strippedLen = lastNewline + 1;
newText = newText.substring(strippedLen);
const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen);
range = new Range(newStart, range.end);
}
}
// If the range spans multiple lines, try to collapse it to a single line by
// trimming a shared prefix up to a newline boundary.
if (advanced && range.start.line !== range.end.line) {
({ range, newText } = stripCommonLinePrefix(doc, range, newText));
}

// Ghost text requires the edit to be on the cursor's line.
if (range.start.line !== range.end.line || range.start.line !== cursorPos.line) {
return undefined;
}

const cursorOffset = doc.offsetAt(cursorPos);
const offsetRange = new OffsetRange(doc.offsetAt(range.start), doc.offsetAt(range.end));

const replacedText = offsetRange.substring(doc.getText());
return validateSameLineGhostText(cursorPos, doc, range, newText);
}

const cursorOffsetInReplacedText = cursorOffset - offsetRange.start;
if (cursorOffsetInReplacedText < 0) {
/**
* If the cursor is at the end of a line and the edit is an empty-range insertion
* at column 0 of the next line, rewrite it as a pure insertion at the cursor
* position. This is allowed when either:
* - `newText` ends with a newline (any existing content on the target line is
* pushed onto the following line), or
* - `newText` contains a newline and the target line is fully consumed by the
* insertion (no leftover content after the insertion).
*/
function tryAdjustNextLineInsertion(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {
if (!range.isEmpty) {
return undefined;
}
if (cursorPos.line + 1 !== range.start.line || range.start.character !== 0) {
return undefined;
}
if (doc.lineAt(cursorPos.line).text.length !== cursorPos.character) {
return undefined; // cursor is not at the end of the line
}

const textBeforeCursorIsEqual = replacedText.substring(0, cursorOffsetInReplacedText) === newText.substring(0, cursorOffsetInReplacedText);
if (!textBeforeCursorIsEqual) {
const targetLineFullyConsumed = doc.lineAt(range.end.line).text.length === range.end.character;
const noLeftoverAfterInsertion = newText.endsWith('\n') || (newText.includes('\n') && targetLineFullyConsumed);
if (!noLeftoverAfterInsertion) {
return undefined;
}

// Use an empty range at the cursor so the suggestion is a pure insertion.
// The original line terminator between the cursor and `range.start` is preserved
// in the document, so:
// - prepend that terminator to `newText` (it lives in the doc, not in the edit), and
// - drop a single trailing line ending from `newText` to avoid an extra blank line.
// CRLF-safe so we don't leak a dangling '\r' into the suggestion.
const lineBreak = doc.getText(new Range(cursorPos, range.start));
const trimmedNewText = newText.replace(/\r?\n$/, '');
return { range: new Range(cursorPos, cursorPos), newText: lineBreak + trimmedNewText };
}

/**
* Strip the longest shared prefix that ends on a newline boundary from both sides
* of a multi-line edit. This often shrinks the range so it fits on a single line,
* which is required for ghost text rendering.
*/
function stripCommonLinePrefix(doc: TextDocument, range: Range, newText: string): { range: Range; newText: string } {
const replacedText = doc.getText(range);
const maxLen = Math.min(replacedText.length, newText.length);
let commonLen = 0;
while (commonLen < maxLen && replacedText[commonLen] === newText[commonLen]) {
commonLen++;
}
if (commonLen === 0) {
return { range, newText };
}
const lastNewline = replacedText.lastIndexOf('\n', commonLen - 1);
if (lastNewline < 0) {
return { range, newText };
}
const strippedLen = lastNewline + 1;
const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen);
return { range: new Range(newStart, range.end), newText: newText.substring(strippedLen) };
}

/**
* Validate that a single-line edit can be rendered as ghost text at the cursor:
* - the cursor is at or after `range.start`
* - everything before the cursor in the replaced text matches `newText`
* - the replaced text is a subword of `newText` (i.e. only insertions are needed)
*/
function validateSameLineGhostText(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {
const replacedText = doc.getText(range);
const cursorOffsetInReplacedText = cursorPos.character - range.start.character;
if (cursorOffsetInReplacedText < 0) {
return undefined;
}
if (replacedText.substring(0, cursorOffsetInReplacedText) !== newText.substring(0, cursorOffsetInReplacedText)) {
return undefined;
}
if (!isSubword(replacedText, newText)) {
return undefined;
}

return { range, newText };
}

/**
* a is subword of b if a can be obtained by removing characters from b
*/

export function isSubword(a: string, b: string): boolean {
for (let aIdx = 0, bIdx = 0; aIdx < a.length; bIdx++) {
if (bIdx >= b.length) {
Expand Down
Loading