44 *--------------------------------------------------------------------------------------------*/
55
66import { Position , Range , TextDocument } from 'vscode' ;
7- import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange' ;
87
98export interface InlineSuggestionEdit {
109 readonly range : Range ;
@@ -17,73 +16,108 @@ export interface InlineSuggestionEdit {
1716 * which is required for VS Code to render ghost text.
1817 */
1918export function toInlineSuggestion ( cursorPos : Position , doc : TextDocument , range : Range , newText : string , advanced : boolean = true ) : InlineSuggestionEdit | undefined {
20- // If multi line insertion starts on the next line
21- // All new lines have to be newly created lines
22- if ( range . isEmpty && cursorPos . line + 1 === range . start . line && range . start . character === 0
23- && doc . lineAt ( cursorPos . line ) . text . length === cursorPos . character // cursor is at the end of the line
24- && ( newText . endsWith ( '\n' ) || ( newText . includes ( '\n' ) && doc . lineAt ( range . end . line ) . text . length === range . end . character ) ) // no remaining content after insertion
25- ) {
26- // Use an empty range at the cursor so the suggestion is a pure insertion
27- const adjustedRange = new Range ( cursorPos , cursorPos ) ;
28- const textBetweenCursorAndRange = doc . getText ( new Range ( cursorPos , range . start ) ) ;
29- // The original range is on the next line, so the line terminator that
30- // already separates the cursor's line from range.start is preserved.
31- // Drop a single trailing line ending from newText (if present) to avoid
32- // inserting an extra blank line after the suggestion. Handle CRLF as
33- // well as LF so we don't leave a dangling '\r'.
34- const adjustedNewText = newText . replace ( / \r ? \n $ / , '' ) ;
35- return { range : adjustedRange , newText : textBetweenCursorAndRange + adjustedNewText } ;
19+ // Special case: a multi-line insertion that starts on the line *after* the cursor
20+ // can be re-expressed as a pure insertion at the cursor.
21+ const nextLineInsertion = tryAdjustNextLineInsertion ( cursorPos , doc , range , newText ) ;
22+ if ( nextLineInsertion ) {
23+ return nextLineInsertion ;
3624 }
3725
38- if ( advanced ) {
39- // If the range spans multiple lines, try to reduce it by stripping a common
40- // prefix (up to a newline boundary) from the replaced text and newText.
41- if ( range . start . line !== range . end . line ) {
42- const fullReplacedText = doc . getText ( range ) ;
43- let commonLen = 0 ;
44- const maxLen = Math . min ( fullReplacedText . length , newText . length ) ;
45- while ( commonLen < maxLen && fullReplacedText [ commonLen ] === newText [ commonLen ] ) {
46- commonLen ++ ;
47- }
48- const lastNewline = fullReplacedText . substring ( 0 , commonLen ) . lastIndexOf ( '\n' ) ;
49- if ( lastNewline >= 0 ) {
50- const strippedLen = lastNewline + 1 ;
51- newText = newText . substring ( strippedLen ) ;
52- const newStart = doc . positionAt ( doc . offsetAt ( range . start ) + strippedLen ) ;
53- range = new Range ( newStart , range . end ) ;
54- }
55- }
26+ // If the range spans multiple lines, try to collapse it to a single line by
27+ // trimming a shared prefix up to a newline boundary.
28+ if ( advanced && range . start . line !== range . end . line ) {
29+ ( { range, newText } = stripCommonLinePrefix ( doc , range , newText ) ) ;
5630 }
5731
32+ // Ghost text requires the edit to be on the cursor's line.
5833 if ( range . start . line !== range . end . line || range . start . line !== cursorPos . line ) {
5934 return undefined ;
6035 }
6136
62- const cursorOffset = doc . offsetAt ( cursorPos ) ;
63- const offsetRange = new OffsetRange ( doc . offsetAt ( range . start ) , doc . offsetAt ( range . end ) ) ;
64-
65- const replacedText = offsetRange . substring ( doc . getText ( ) ) ;
37+ return validateSameLineGhostText ( cursorPos , doc , range , newText ) ;
38+ }
6639
67- const cursorOffsetInReplacedText = cursorOffset - offsetRange . start ;
68- if ( cursorOffsetInReplacedText < 0 ) {
40+ /**
41+ * If the cursor is at the end of a line and the edit is an empty-range insertion
42+ * at column 0 of the next line (with no leftover content after the insertion),
43+ * rewrite it as a pure insertion at the cursor position.
44+ */
45+ function tryAdjustNextLineInsertion ( cursorPos : Position , doc : TextDocument , range : Range , newText : string ) : InlineSuggestionEdit | undefined {
46+ if ( ! range . isEmpty ) {
47+ return undefined ;
48+ }
49+ if ( cursorPos . line + 1 !== range . start . line || range . start . character !== 0 ) {
6950 return undefined ;
7051 }
52+ if ( doc . lineAt ( cursorPos . line ) . text . length !== cursorPos . character ) {
53+ return undefined ; // cursor is not at the end of the line
54+ }
7155
72- const textBeforeCursorIsEqual = replacedText . substring ( 0 , cursorOffsetInReplacedText ) === newText . substring ( 0 , cursorOffsetInReplacedText ) ;
73- if ( ! textBeforeCursorIsEqual ) {
56+ const targetLineFullyConsumed = doc . lineAt ( range . end . line ) . text . length === range . end . character ;
57+ const noLeftoverAfterInsertion = newText . endsWith ( '\n' ) || ( newText . includes ( '\n' ) && targetLineFullyConsumed ) ;
58+ if ( ! noLeftoverAfterInsertion ) {
7459 return undefined ;
7560 }
7661
62+ // Use an empty range at the cursor so the suggestion is a pure insertion.
63+ // The original line terminator between the cursor and `range.start` is preserved
64+ // in the document, so:
65+ // - prepend that terminator to `newText` (it lives in the doc, not in the edit), and
66+ // - drop a single trailing line ending from `newText` to avoid an extra blank line.
67+ // CRLF-safe so we don't leak a dangling '\r' into the suggestion.
68+ const lineBreak = doc . getText ( new Range ( cursorPos , range . start ) ) ;
69+ const trimmedNewText = newText . replace ( / \r ? \n $ / , '' ) ;
70+ return { range : new Range ( cursorPos , cursorPos ) , newText : lineBreak + trimmedNewText } ;
71+ }
72+
73+ /**
74+ * Strip the longest shared prefix that ends on a newline boundary from both sides
75+ * of a multi-line edit. This often shrinks the range so it fits on a single line,
76+ * which is required for ghost text rendering.
77+ */
78+ function stripCommonLinePrefix ( doc : TextDocument , range : Range , newText : string ) : { range : Range ; newText : string } {
79+ const replacedText = doc . getText ( range ) ;
80+ const maxLen = Math . min ( replacedText . length , newText . length ) ;
81+ let commonLen = 0 ;
82+ while ( commonLen < maxLen && replacedText [ commonLen ] === newText [ commonLen ] ) {
83+ commonLen ++ ;
84+ }
85+ if ( commonLen === 0 ) {
86+ return { range, newText } ;
87+ }
88+ const lastNewline = replacedText . lastIndexOf ( '\n' , commonLen - 1 ) ;
89+ if ( lastNewline < 0 ) {
90+ return { range, newText } ;
91+ }
92+ const strippedLen = lastNewline + 1 ;
93+ const newStart = doc . positionAt ( doc . offsetAt ( range . start ) + strippedLen ) ;
94+ return { range : new Range ( newStart , range . end ) , newText : newText . substring ( strippedLen ) } ;
95+ }
96+
97+ /**
98+ * Validate that a single-line edit can be rendered as ghost text at the cursor:
99+ * - the cursor is at or after `range.start`
100+ * - everything before the cursor in the replaced text matches `newText`
101+ * - the replaced text is a subword of `newText` (i.e. only insertions are needed)
102+ */
103+ function validateSameLineGhostText ( cursorPos : Position , doc : TextDocument , range : Range , newText : string ) : InlineSuggestionEdit | undefined {
104+ const replacedText = doc . getText ( range ) ;
105+ const cursorOffsetInReplacedText = cursorPos . character - range . start . character ;
106+ if ( cursorOffsetInReplacedText < 0 ) {
107+ return undefined ;
108+ }
109+ if ( replacedText . substring ( 0 , cursorOffsetInReplacedText ) !== newText . substring ( 0 , cursorOffsetInReplacedText ) ) {
110+ return undefined ;
111+ }
77112 if ( ! isSubword ( replacedText , newText ) ) {
78113 return undefined ;
79114 }
80-
81115 return { range, newText } ;
82116}
117+
83118/**
84119 * a is subword of b if a can be obtained by removing characters from b
85120*/
86-
87121export function isSubword ( a : string , b : string ) : boolean {
88122 for ( let aIdx = 0 , bIdx = 0 ; aIdx < a . length ; bIdx ++ ) {
89123 if ( bIdx >= b . length ) {
0 commit comments