Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts
13405 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 { assert, suite, test } from 'vitest';6import { Position, Range, Uri } from 'vscode';7import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';8import { toInlineSuggestion } from '../../vscode-node/isInlineSuggestion';910suite('toInlineSuggestion', () => {1112function createMockDocument(lines: string[], languageId: string = 'typescript') {13return createTextDocumentData(Uri.from({ scheme: 'test', path: '/test/file.ts' }), lines.join('\n'), languageId).document;14}1516function getBaseCompletionScenario() {17const document = createMockDocument(['This is line 1,', 'This is line,', 'This is line 3,']);18const replaceRange = new Range(1, 0, 1, 13);19const completionInsertionPoint = new Position(1, 12);20const replaceText = 'This is line 2,';21return { document, completionInsertionPoint, replaceRange, replaceText };22}2324test('line before completion', () => {25const { document, completionInsertionPoint, replaceRange, replaceText } = getBaseCompletionScenario();2627const cursorPosition = new Position(completionInsertionPoint.line - 1, completionInsertionPoint.character);2829assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));30});3132test('same line before completion', () => {33const { document, completionInsertionPoint, replaceRange, replaceText } = getBaseCompletionScenario();3435const cursorPosition = new Position(completionInsertionPoint.line, completionInsertionPoint.character - 1);3637const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);38assert.isDefined(result);39assert.deepStrictEqual(result!.range, replaceRange);40assert.strictEqual(result!.newText, replaceText);41});4243test('same line at completion', () => {44const { document, completionInsertionPoint, replaceRange, replaceText } = getBaseCompletionScenario();4546const cursorPosition = new Position(completionInsertionPoint.line, completionInsertionPoint.character);4748const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);49assert.isDefined(result);50assert.deepStrictEqual(result!.range, replaceRange);51assert.strictEqual(result!.newText, replaceText);52});5354test('same line after completion', () => {55const { document, completionInsertionPoint, replaceRange, replaceText } = getBaseCompletionScenario();5657const cursorPosition = new Position(completionInsertionPoint.line, completionInsertionPoint.character + 1);5859assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));60});6162test('line after completion', () => {63const { document, completionInsertionPoint, replaceRange, replaceText } = getBaseCompletionScenario();6465const cursorPosition = new Position(completionInsertionPoint.line + 1, completionInsertionPoint.character);6667assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));68});6970test('multi-line replace range', () => {71const document = createMockDocument(['This is line 1,', 'This is line,', 'This is line,']);72const replaceRange = new Range(1, 0, 2, 13);73const replaceText = 'This is line 2,\nThis is line 3,';7475const cursorPosition = replaceRange.start;7677assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));78});7980test('multi-line insertion on same line', () => {81const document = createMockDocument(['This is line 1,', 'This is line,', 'This is line 5,']);82const replaceRange = new Range(1, 12, 1, 13);83const replaceText = ' 2,\nThis is line 3,\nThis is line 4,';8485const cursorPosition = replaceRange.start;8687const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);88assert.isDefined(result);89assert.deepStrictEqual(result!.range, replaceRange);90assert.strictEqual(result!.newText, replaceText);91});9293test('multi-line insertion on next line extends range to cursor', () => {94const document = createMockDocument(['This is line 1,', 'This is line 2,', 'This is line 5,']);95const cursorPosition = new Position(1, 15); // end of "This is line 2,"96const replaceRange = new Range(2, 0, 2, 0);97const replaceText = 'This is line 3,\nThis is line 4,\n';9899const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);100assert.isDefined(result);101// Range is an empty range at the cursor for a pure insertion102assert.deepStrictEqual(result!.range, new Range(1, 15, 1, 15));103// Text is prepended with the newline between cursor and original range,104// and the trailing newline is dropped so we don't introduce a blank line.105assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, ''));106});107108test('should not use ghost text when inserting on next line when none empty', () => {109const document = createMockDocument(['This is line 1,', 'This is line 2,', 'line 3,']);110const cursorPosition = new Position(1, 15);111const replaceRange = new Range(2, 0, 2, 0);112const replaceText = 'This is ';113114assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));115});116117// Even though this would be a nice way to render the suggestion, ghost text view on the core side118// is not able to render such suggestions119test('should not use ghost text when inserting on existing line below', () => {120const document = createMockDocument(['This is line 1,', 'This is line 2,', '', 'This is line 4,']);121const cursorPosition = new Position(1, 15);122const replaceRange = new Range(2, 0, 2, 0);123const replaceText = 'This is line 3,';124125assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));126});127128// Tests probing the behavior change: multi-line next-line insertions129// where newText does not end with '\n'130131test('multi-line insertion on next empty line without trailing newline', () => {132const document = createMockDocument(['function foo(', '', 'other']);133const cursorPosition = new Position(0, 13); // end of "function foo("134const replaceRange = new Range(1, 0, 1, 0); // empty line135const replaceText = ' a: string,\n b: number\n)';136137const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);138assert.isDefined(result);139assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));140assert.strictEqual(result!.newText, '\n' + replaceText);141});142143test('multi-line insertion on next non-empty line with trailing newline', () => {144const document = createMockDocument(['function foo(', ')', 'other']);145const cursorPosition = new Position(0, 13); // end of "function foo("146const replaceRange = new Range(1, 0, 1, 0); // non-empty line ")"147const replaceText = ' a: string,\n b: number\n';148149const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);150assert.isDefined(result);151assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));152// Trailing '\n' is dropped to avoid a spurious blank line.153assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, ''));154});155156test('multi-line insertion without trailing newline rejected when target line has content', () => {157const document = createMockDocument(['function foo(', ')', 'other']);158const cursorPosition = new Position(0, 13);159const replaceRange = new Range(1, 0, 1, 0);160const replaceText = ' a: string,\n b: number';161162// newText doesn't end with \n, and target line ")" is non-empty → undefined163assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));164});165166test('single-line insertion on next empty line is not an inline suggestion', () => {167const document = createMockDocument(['function foo(', '', 'other']);168const cursorPosition = new Position(0, 13);169const replaceRange = new Range(1, 0, 1, 0);170const replaceText = ' a: string';171172// Single-line text has no \n — neither endsWith nor includes matches173assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));174});175176test('render ghost text for next line suggestion with massaged range', () => {177178const document = createMockDocument([`import * as vscode from 'vscode';179import { NodeTypesIndex } from './nodeTypesIndex';180import { Result } from './util/common/result';181182export class NodeTypesOutlineProvider implements vscode.DocumentSymbolProvider {183184/**185* @remark This works only for valid tree-sitter \`node-types.json\` files.186*/187provideDocumentSymbols(188document: vscode.TextDocument,189token: vscode.CancellationToken190): vscode.ProviderResult<vscode.SymbolInformation[] | vscode.DocumentSymbol[]> {191192const nodeTypesIndex = new NodeTypesIndex(document);193194const astNodes = nodeTypesIndex.nodes;195196if (Result.isErr(astNodes)) {197throw astNodes.err;198}199200const symbols: vscode.DocumentSymbol[] = astNodes.val.map(astNode => {201const range = new vscode.Range(202document.positionAt(astNode.offset),203document.positionAt(astNode.offset + astNode.length)204);205206const revealRange = new vscode.Range(207document.positionAt(astNode.type.offset),208document.positionAt(astNode.type.offset + astNode.type.length)209);210211return new vscode.DocumentSymbol(212astNode.type.value,213astNode.named.value ? 'Named' : 'Anonymous',214vscode.SymbolKind.Object,215range,216revealRange,217);218});219220return symbols;221}222}223function createDocumentSymbol(224`]);225const cursorPosition = new Position(45, 30);226const replaceRange = new Range(46, 0, 46, 0);227const replaceText = ` astNode: { type: { value: string; offset: number; length: number }; named: { value: boolean }; offset: number; length: number },228document: vscode.TextDocument229): vscode.DocumentSymbol {230const range = new vscode.Range(231document.positionAt(astNode.offset),232document.positionAt(astNode.offset + astNode.length)233);234235const revealRange = new vscode.Range(236document.positionAt(astNode.type.offset),237document.positionAt(astNode.type.offset + astNode.type.length)238);239240return new vscode.DocumentSymbol(241astNode.type.value,242astNode.named.value ? 'Named' : 'Anonymous',243vscode.SymbolKind.Object,244range,245revealRange,246);247}`;248249const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);250assert.isDefined(result);251// Range is an empty range at cursor position252assert.deepStrictEqual(result!.range, new Range(45, 30, 45, 30));253// Text is prepended with newline254assert.strictEqual(result!.newText, '\n' + replaceText);255});256257// --- Branch 1 regression: next-line insertion edge cases ---258259test('next-line: cursor mid-line rejects even with valid next-line edit', () => {260const document = createMockDocument(['function foo(bar', '', 'other']);261const cursorPosition = new Position(0, 8); // middle of "function foo(bar"262const replaceRange = new Range(1, 0, 1, 0);263const replaceText = ' param1,\n param2\n';264265// Cursor not at end of line → rejected266assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));267});268269test('next-line: non-empty range on next line falls through and is rejected', () => {270const document = createMockDocument(['function foo(', 'old content', 'other']);271const cursorPosition = new Position(0, 13);272const replaceRange = new Range(1, 0, 1, 11); // non-empty range replacing "old content"273const replaceText = 'new content\n';274275// range.isEmpty is false → branch 1 skipped, branch 2 rejects (different line)276assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));277});278279test('next-line: non-empty replace range covering only whitespace on next line', () => {280const document = createMockDocument([281' for item in items:',282' ',283'other_code',284], 'python');285const cursorPosition = new Position(1, 4);286const replaceRange = new Range(0, 22, 1, 8);287const replaceText = '\n process(item)\n return result';288289const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);290assert.isDefined(result);291});292293test('next-line: range 2 lines ahead is rejected', () => {294const document = createMockDocument(['line 0', 'line 1', '', 'line 3']);295const cursorPosition = new Position(0, 6);296const replaceRange = new Range(2, 0, 2, 0);297const replaceText = 'inserted\n';298299// cursorPos.line + 1 !== range.start.line → rejected300assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));301});302303test('next-line: empty range at non-zero column on next line is rejected', () => {304const document = createMockDocument(['function foo(', ' ', 'other']);305const cursorPosition = new Position(0, 13);306const replaceRange = new Range(1, 4, 1, 4); // empty range at col 4307const replaceText = 'a: string,\n b: number\n';308309// range.start.character !== 0 → rejected310assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));311});312313test('next-line: inserting just a newline character', () => {314const document = createMockDocument(['line 0', '', 'line 2']);315const cursorPosition = new Position(0, 6);316const replaceRange = new Range(1, 0, 1, 0);317const replaceText = '\n';318319const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);320assert.isDefined(result);321assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6));322// Trailing '\n' is dropped — only the prepended newline remains.323assert.strictEqual(result!.newText, '\n');324});325326test('next-line: cursor at end of an empty line', () => {327const document = createMockDocument(['', '', 'other']);328const cursorPosition = new Position(0, 0); // end of empty line 0329const replaceRange = new Range(1, 0, 1, 0);330const replaceText = 'new line\n';331332const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);333assert.isDefined(result);334assert.deepStrictEqual(result!.range, new Range(0, 0, 0, 0));335// Trailing '\n' is dropped to avoid a spurious blank line.336assert.strictEqual(result!.newText, '\nnew line');337});338339test('next-line: range on line before cursor is rejected', () => {340const document = createMockDocument(['line 0', 'line 1', 'line 2']);341const cursorPosition = new Position(2, 6);342const replaceRange = new Range(1, 0, 1, 0);343const replaceText = 'inserted\n';344345// cursorPos.line + 1 !== range.start.line (1 !== 3)346assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));347});348349// --- Branch 2 regression: same-line edit edge cases ---350351test('same-line: cursor before range start rejects', () => {352const document = createMockDocument(['abcdef']);353const cursorPosition = new Position(0, 1);354const replaceRange = new Range(0, 3, 0, 6); // replaces "def"355const replaceText = 'defgh';356357// cursorOffsetInReplacedText < 0358assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));359});360361test('same-line: text before cursor differs rejects', () => {362const document = createMockDocument(['abcdef']);363const cursorPosition = new Position(0, 4);364const replaceRange = new Range(0, 0, 0, 6);365const replaceText = 'XXXX_modified';366367// "abcd" !== "XXXX" → text before cursor mismatch368assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));369});370371test('same-line: replaced text is not subword of new text rejects', () => {372const document = createMockDocument(['abcxyz']);373const cursorPosition = new Position(0, 0);374const replaceRange = new Range(0, 0, 0, 6);375const replaceText = 'abc'; // "abcxyz" is not a subword of "abc"376377assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));378});379380test('same-line: deletion (empty newText) rejects', () => {381const document = createMockDocument(['abcdef']);382const cursorPosition = new Position(0, 0);383const replaceRange = new Range(0, 0, 0, 3);384const replaceText = '';385386// "abc" is not a subword of "" → rejected387assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));388});389390test('same-line: empty range and empty text at cursor (no-op) succeeds', () => {391const document = createMockDocument(['abcdef']);392const cursorPosition = new Position(0, 3);393const replaceRange = new Range(0, 3, 0, 3);394const replaceText = '';395396// Empty replaced text is trivially a subword of empty new text397const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);398assert.isDefined(result);399assert.deepStrictEqual(result!.range, new Range(0, 3, 0, 3));400assert.strictEqual(result!.newText, '');401});402403test('same-line: pure insertion (empty range) at cursor', () => {404const document = createMockDocument(['ab']);405const cursorPosition = new Position(0, 1);406const replaceRange = new Range(0, 1, 0, 1);407const replaceText = 'XY';408409const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);410assert.isDefined(result);411assert.deepStrictEqual(result!.range, new Range(0, 1, 0, 1));412assert.strictEqual(result!.newText, 'XY');413});414415test('same-line: cursor at col 0 with range at col 0', () => {416const document = createMockDocument(['hello']);417const cursorPosition = new Position(0, 0);418const replaceRange = new Range(0, 0, 0, 5);419const replaceText = 'hello world';420421const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);422assert.isDefined(result);423assert.deepStrictEqual(result!.range, replaceRange);424assert.strictEqual(result!.newText, 'hello world');425});426427test('same-line: subword insertion mid-word', () => {428const document = createMockDocument(['clog']);429const cursorPosition = new Position(0, 1);430const replaceRange = new Range(0, 0, 0, 4);431const replaceText = 'console.log';432433// "clog" IS a subword of "console.log" (c...o...l...og)434// But text before cursor: replaced[0..1]="c", new[0..1]="c" → match435const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);436assert.isDefined(result);437assert.strictEqual(result!.newText, 'console.log');438});439440// --- Prefix-stripping: multi-line range reduction ---441442test('prefix-strip: multi-line range reduced to single-line edit on cursor line', () => {443// Range spans lines 0-1, replaced text = "abc\ndef", newText = "abc\ndefghi"444// Common prefix up to newline = "abc\n", strip it → range becomes (1,0)-(1,3), newText = "defghi"445const document = createMockDocument(['abc', 'def', 'other']);446const cursorPosition = new Position(1, 0);447const replaceRange = new Range(0, 0, 1, 3);448const replaceText = 'abc\ndefghi';449450const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);451assert.isDefined(result);452assert.deepStrictEqual(result!.range, new Range(1, 0, 1, 3));453assert.strictEqual(result!.newText, 'defghi');454});455456test('prefix-strip: no newline in common prefix, multi-line range still rejected', () => {457// Range spans lines 0-1, replaced = "ab\ncd", newText = "abXY"458// Common prefix = "ab" but no newline → no stripping → multi-line range rejected459const document = createMockDocument(['ab', 'cd', 'other']);460const cursorPosition = new Position(0, 0);461const replaceRange = new Range(0, 0, 1, 2);462const replaceText = 'abXY';463464assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));465});466467test('prefix-strip: strips multiple newlines to last boundary', () => {468// Range spans 3 lines: "line0\nline1\nxy", newText = "line0\nline1\nxyz"469// Common prefix includes two newlines, stripping to last → range becomes (2,0)-(2,2)470const document = createMockDocument(['line0', 'line1', 'xy', 'other']);471const cursorPosition = new Position(2, 0);472const replaceRange = new Range(0, 0, 2, 2);473const replaceText = 'line0\nline1\nxyz';474475const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);476assert.isDefined(result);477assert.deepStrictEqual(result!.range, new Range(2, 0, 2, 2));478assert.strictEqual(result!.newText, 'xyz');479});480481test('prefix-strip: after stripping still multi-line, rejected', () => {482// Range spans 3 lines: "a\nb\nc", newText = "a\nB\nC"483// Common prefix up to newline = "a\n", strip → range becomes (1,0)-(2,1) which is still multi-line484const document = createMockDocument(['a', 'b', 'c', 'other']);485const cursorPosition = new Position(1, 0);486const replaceRange = new Range(0, 0, 2, 1);487const replaceText = 'a\nB\nC';488489assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));490});491492test('prefix-strip: reduced to single line but cursor on different line, rejected', () => {493// Strip reduces range to line 1, but cursor is on line 0494const document = createMockDocument(['abc', 'def', 'other']);495const cursorPosition = new Position(0, 2);496const replaceRange = new Range(0, 0, 1, 3);497const replaceText = 'abc\ndefghi';498499assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));500});501502test('prefix-strip: reduced to single line, subword check fails', () => {503// After stripping "abc\n", range = (1,0)-(1,3) with replaced "def", newText = "xy"504// "def" is not a subword of "xy"505const document = createMockDocument(['abc', 'def', 'other']);506const cursorPosition = new Position(1, 0);507const replaceRange = new Range(0, 0, 1, 3);508const replaceText = 'abc\nxy';509510assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));511});512513test('prefix-strip: diverges before first newline, no stripping', () => {514// replaced = "ax\nyz", newText = "ab\nyz" → common prefix "a" has no newline → no strip515const document = createMockDocument(['ax', 'yz']);516const cursorPosition = new Position(0, 0);517const replaceRange = new Range(0, 0, 1, 2);518const replaceText = 'ab\nyz';519520assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));521});522523test('prefix-strip: range starts mid-line, strips prefix through newline', () => {524// Document: "hello world", " ns", "other"525// Range (0,6)-(1,4) → replaced text = "world\n ns", newText = "world\n new_stuff"526// Common prefix = "world\n " → last newline at index 5, strip "world\n"527// Reduced range: (1,0)-(1,4), newText = " new_stuff"528// isSubword(" ns", " new_stuff") → true529const document = createMockDocument(['hello world', ' ns', 'other']);530const cursorPosition = new Position(1, 0);531const replaceRange = new Range(0, 6, 1, 4);532const replaceText = 'world\n new_stuff';533534const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);535assert.isDefined(result);536assert.deepStrictEqual(result!.range, new Range(1, 0, 1, 4));537assert.strictEqual(result!.newText, ' new_stuff');538});539540test('prefix-strip: empty newText after stripping prefix', () => {541// replaced = "abc\n", newText = "abc\n" → after stripping "abc\n", replaced="" and newText=""542// This is a no-op on the second line, succeeds as empty edit543const document = createMockDocument(['abc', '', 'other']);544const cursorPosition = new Position(1, 0);545const replaceRange = new Range(0, 0, 1, 0);546const replaceText = 'abc\n';547548const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);549assert.isDefined(result);550assert.deepStrictEqual(result!.range, new Range(1, 0, 1, 0));551assert.strictEqual(result!.newText, '');552});553554test('insertion on next line in fieldLabels object', () => {555const doc = `import React, { useState } from "react";556557interface FormData {558firstName: string;559lastName: string;560password: string;561email: string;562age: string;563city: string;564}565566const initialFormData: FormData = {567firstName: "",568lastName: "",569password: "",570email: "",571age: "",572city: "",573};574575const fieldLabels: Record<keyof FormData, string> = {576firstName: "First Name",577lastName: "Last Name",578email: "Email Address",579age: "Age",580city: "City",581};582`;583const document = createTextDocumentData(Uri.from({ scheme: 'test', path: '/test/file.tsx' }), doc, 'typescriptreact').document;584const cursorPosition = new Position(22, 26); // end of ` lastName: "Last Name",`585const replaceRange = new Range(23, 0, 23, 0);586const replaceText = ' password: "Password",\n';587588const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText, true);589assert.isDefined(result);590assert.deepStrictEqual(result!.range, new Range(22, 26, 22, 26));591// Trailing '\n' is dropped because the original line terminator after592// the cursor is preserved.593assert.strictEqual(result!.newText, '\n password: "Password",');594});595596suite('CRLF', () => {597598function createCRLFDocument(lines: string[], languageId: string = 'typescript') {599return createTextDocumentData(600Uri.from({ scheme: 'test', path: '/test/file.ts' }),601lines.join('\r\n'),602languageId,603'\r\n',604).document;605}606607test('next-line insertion: trailing CRLF is dropped (no dangling \\r)', () => {608const document = createCRLFDocument(['function foo(', '', 'other']);609const cursorPosition = new Position(0, 13); // end of "function foo("610const replaceRange = new Range(1, 0, 1, 0); // empty line611const replaceText = ' a: string,\r\n b: number\r\n';612613const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);614assert.isDefined(result);615assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));616// The trailing CRLF must be stripped entirely; no dangling '\r'617// should leak into the suggestion text.618assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number');619});620621test('next-line insertion: trailing CRLF on non-empty target line', () => {622const document = createCRLFDocument(['function foo(', ')', 'other']);623const cursorPosition = new Position(0, 13);624const replaceRange = new Range(1, 0, 1, 0);625const replaceText = ' a: string,\r\n b: number\r\n';626627const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);628assert.isDefined(result);629assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));630assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number');631});632633test('next-line insertion: CRLF-only newText is fully stripped', () => {634const document = createCRLFDocument(['line 0', '', 'line 2']);635const cursorPosition = new Position(0, 6);636const replaceRange = new Range(1, 0, 1, 0);637const replaceText = '\r\n';638639const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);640assert.isDefined(result);641assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6));642// Only the prepended CRLF between cursor and original range remains.643assert.strictEqual(result!.newText, '\r\n');644});645});646647suite('multi-line range, no common prefix', () => {648649// Regression: when commonLen === 0 and the replaced text starts with '\n',650// `lastIndexOf('\n', -1)` would (incorrectly) clamp to 0 and report a651// match, causing the leading newline to be stripped — which can collapse652// the multi-line range into a same-line "suggestion" that the function653// then accepts. With the original substring-based check, no strip occurs654// and the result is `undefined`.655test('does not strip leading newline when nothing is in common', () => {656const document = createMockDocument(['abc', 'x', 'rest']);657// replacedText = '\nx', newText[0]='Y' differs from '\n', commonLen=0.658const replaceRange = new Range(0, 3, 1, 1);659const cursorPosition = new Position(1, 1);660const replaceText = 'Yx';661662// The range cannot legitimately be collapsed to a single line, so663// the function must not synthesize a ghost-text suggestion.664assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));665});666});667});668669670