Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/inlineEdits/common/responseProcessor.ts
13400 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
import { illegalArgument } from '../../../util/vs/base/common/errors';
6
import { LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit';
7
import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange';
8
9
10
export namespace ResponseProcessor {
11
12
/**
13
* Controls when to emit fast cursor line changes.
14
* - `off`: Never emit fast cursor line changes
15
* - `additiveOnly`: Only emit when the edit on the cursor line is additive (only adds text)
16
*/
17
export const enum EmitFastCursorLineChange {
18
Off = 'off',
19
AdditiveOnly = 'additiveOnly',
20
}
21
22
export type DiffParams = {
23
/**
24
* Controls when to emit a fast cursor line change event.
25
*/
26
readonly emitFastCursorLineChange: EmitFastCursorLineChange;
27
readonly nSignificantLinesToConverge: number;
28
readonly nLinesToConverge: number;
29
};
30
31
export const DEFAULT_DIFF_PARAMS: DiffParams = {
32
emitFastCursorLineChange: EmitFastCursorLineChange.Off,
33
nSignificantLinesToConverge: 2,
34
nLinesToConverge: 3,
35
};
36
37
/**
38
* Maps the `emitFastCursorLineChange` setting value to the new type,
39
* preserving backward compatibility with the old boolean type.
40
*/
41
export function mapEmitFastCursorLineChange(value: boolean | EmitFastCursorLineChange): EmitFastCursorLineChange {
42
if (value === true) {
43
return EmitFastCursorLineChange.AdditiveOnly;
44
}
45
if (value === false) {
46
return EmitFastCursorLineChange.Off;
47
}
48
return value;
49
}
50
51
type DivergenceState =
52
| { k: 'aligned' }
53
| {
54
k: 'diverged';
55
startLineIdx: number;
56
newLines: string[];
57
convergenceCandidates?: number[];
58
};
59
60
/**
61
*
62
* @param originalLines
63
* @param modifiedLines
64
* @param cursorOriginalLinesOffset offset of cursor within original lines
65
*/
66
export async function* diff(originalLines: string[], modifiedLines: AsyncIterable<string>, cursorOriginalLinesOffset: number, params: DiffParams): AsyncIterable<LineReplacement> {
67
68
const lineToIdxs = new ArrayMap<string, number>();
69
for (const [i, line] of originalLines.entries()) {
70
lineToIdxs.add(line, i);
71
}
72
73
let editWindowIdx = 0;
74
let updatedEditWindowIdx = -1;
75
76
let state: DivergenceState = { k: 'aligned' };
77
78
for await (const line of modifiedLines) {
79
++updatedEditWindowIdx;
80
81
// handle modifiedLines.length > originalLines.length
82
if (editWindowIdx >= originalLines.length) {
83
switch (state.k) {
84
case 'aligned': {
85
state = { k: 'diverged', startLineIdx: editWindowIdx, newLines: [line] };
86
break;
87
}
88
case 'diverged': {
89
state.newLines.push(line);
90
}
91
}
92
continue;
93
}
94
95
if (state.k === 'aligned') {
96
if (originalLines[editWindowIdx] === line) { // if line is the same as in originalLines, skip over it
97
++editWindowIdx;
98
continue;
99
}
100
state = { k: 'diverged', startLineIdx: editWindowIdx, newLines: [] };
101
}
102
103
state.newLines.push(line);
104
105
const convergenceResult = checkForConvergence(
106
originalLines,
107
cursorOriginalLinesOffset,
108
lineToIdxs,
109
state,
110
editWindowIdx,
111
params,
112
);
113
114
if (convergenceResult) {
115
yield convergenceResult.singleLineEdit;
116
editWindowIdx = convergenceResult.convergenceEndIdx;
117
state = { k: 'aligned' };
118
}
119
}
120
121
switch (state.k) {
122
case 'diverged': {
123
const lineRange = new LineRange(state.startLineIdx + 1, originalLines.length + 1);
124
yield new LineReplacement(lineRange, state.newLines);
125
break;
126
}
127
128
case 'aligned': {
129
if (editWindowIdx < originalLines.length) {
130
const lineRange = new LineRange(editWindowIdx + 1, originalLines.length + 1);
131
yield new LineReplacement(lineRange, []);
132
}
133
break;
134
}
135
}
136
}
137
138
function isSignificant(s: string) {
139
return !!s.match(/[a-zA-Z1-9]+/);
140
}
141
142
/**
143
* Checks if a line edit is additive (only adds text without removing any).
144
* An edit is additive if the original line is a subsequence of the new line,
145
* meaning all characters from the original appear in the new line in the same order.
146
*
147
* Examples:
148
* - "function fib() {" → "function fib(n: number) {" ✓ (additive)
149
* - "hello world" → "hello" ✗ (not additive, removes " world")
150
* - "abc" → "aXbYcZ" ✓ (additive)
151
*/
152
export function isAdditiveEdit(originalLine: string, newLine: string): boolean {
153
return isSubsequence(originalLine, newLine);
154
}
155
156
/**
157
* Returns true if `subsequence` is a subsequence of `str`.
158
* A subsequence means all characters appear in `str` in the same relative order,
159
* but not necessarily consecutively.
160
*/
161
function isSubsequence(subsequence: string, str: string): boolean {
162
if (subsequence.length === 0) {
163
return true;
164
}
165
if (subsequence.length > str.length) {
166
return false;
167
}
168
169
let subIdx = 0;
170
for (let i = 0; i < str.length && subIdx < subsequence.length; i++) {
171
if (str[i] === subsequence[subIdx]) {
172
subIdx++;
173
}
174
}
175
176
return subIdx === subsequence.length;
177
}
178
179
function checkForConvergence(
180
originalLines: string[],
181
cursorOriginalLinesOffset: number,
182
lineToIndexes: ArrayMap<string, number>,
183
state: DivergenceState & { k: 'diverged' },
184
editWindowIdx: number,
185
params: DiffParams,
186
): undefined | {
187
singleLineEdit: LineReplacement;
188
convergenceEndIdx: number;
189
} {
190
if (state.newLines.length === 0) {
191
throw illegalArgument('Cannot check for convergence without new lines');
192
}
193
194
let newLinesIdx = state.newLines.length - 1;
195
let candidates = lineToIndexes.get(state.newLines[newLinesIdx]).map((idx): [number, number] => [idx, idx]);
196
197
if (candidates.length === 0) {
198
if (params.emitFastCursorLineChange === EmitFastCursorLineChange.Off ||
199
editWindowIdx !== cursorOriginalLinesOffset || state.newLines.length > 1
200
) {
201
return;
202
}
203
204
// Check if emit is allowed based on the setting
205
const originalLine = originalLines[editWindowIdx];
206
const newLine = state.newLines[0];
207
208
// When the cursor is on an empty line and the model outputs content that matches
209
// (or is a prefix of) the next original line, it's likely deleting the empty line
210
// rather than replacing it. Skip fast-emit to avoid line duplication.
211
if (originalLine.trim() === '' && editWindowIdx + 1 < originalLines.length) {
212
const nextLine = originalLines[editWindowIdx + 1];
213
if (newLine === nextLine || nextLine.startsWith(newLine)) {
214
return;
215
}
216
}
217
218
if (!isAdditiveEdit(originalLine, newLine)) {
219
return;
220
}
221
222
// we detected that line with the cursor has changed, so we immediately emit an edit for it
223
const zeroBasedLineRange = [editWindowIdx, editWindowIdx + 1];
224
const lineRange = new LineRange(zeroBasedLineRange[0] + 1, zeroBasedLineRange[1] + 1);
225
return {
226
singleLineEdit: new LineReplacement(lineRange, state.newLines),
227
convergenceEndIdx: editWindowIdx + 1,
228
};
229
}
230
231
// we don't have enough lines even for significant-lines convergence which's less than non-significant
232
if (state.newLines.length < params.nSignificantLinesToConverge) {
233
return;
234
}
235
236
let nNonSigMatches = 1;
237
let nSigMatches = isSignificant(state.newLines[newLinesIdx]) ? 1 : 0;
238
--newLinesIdx;
239
240
let result: 'found_matches' | 'found_significant_matches' | undefined;
241
let match: [number, number] = candidates[0];
242
243
// if several lines are being just replaced and we found a convergence right after, we want to treat this as a significant match
244
// original | modified
245
// a | a
246
// b | b'
247
// c | c'
248
// d | d <-- match here should allow convergence
249
// e | e
250
if (nNonSigMatches > 0 && (match[0] - state.startLineIdx) === state.newLines.length - 1 /* to discount for converging line */) {
251
result = 'found_significant_matches';
252
}
253
254
for (; newLinesIdx >= 0; --newLinesIdx) {
255
candidates = candidates.map(([convEndIdx, convIdx]): [number, number] => [convEndIdx, convIdx - 1]);
256
candidates = candidates.filter(([_, currentIdx]) => currentIdx >= 0 && editWindowIdx <= currentIdx);
257
candidates = candidates.filter(([_, currentIdx]) => originalLines[currentIdx] === state.newLines[newLinesIdx]);
258
259
// count in matches for current batch
260
if (candidates.length === 0) {
261
break;
262
} else {
263
++nNonSigMatches;
264
if (isSignificant(state.newLines[newLinesIdx])) {
265
++nSigMatches;
266
}
267
}
268
if (nSigMatches === params.nSignificantLinesToConverge) {
269
result = 'found_significant_matches';
270
match = candidates[0];
271
}
272
if (nNonSigMatches === params.nLinesToConverge) {
273
result = 'found_matches';
274
match = candidates[0];
275
break;
276
}
277
}
278
279
if (!result) {
280
return;
281
}
282
283
const originalLinesConvIdx = match[1];
284
const originalLinesConvEndIdx = match[0];
285
const nLinesToConverge = originalLinesConvEndIdx - originalLinesConvIdx + 1;
286
287
const nLinesRemoved = originalLinesConvIdx - state.startLineIdx;
288
const linesInserted = state.newLines.slice(0, state.newLines.length - nLinesToConverge);
289
const nLinesInserted = linesInserted.length;
290
if (nLinesRemoved - nLinesInserted > 1 && nLinesInserted > 0) {
291
return;
292
}
293
294
const zeroBasedLineRange: [startLineOffset: number, endLineOffset: number] = [state.startLineIdx, originalLinesConvIdx];
295
const lineRange = new LineRange(zeroBasedLineRange[0] + 1, zeroBasedLineRange[1] + 1);
296
const singleLineEdit = new LineReplacement(lineRange, linesInserted);
297
return {
298
singleLineEdit,
299
convergenceEndIdx: originalLinesConvEndIdx + 1,
300
};
301
}
302
}
303
304
export class ArrayMap<K, V> {
305
private map = new Map<K, V[]>();
306
307
/**
308
* Appends a value to the array of values for the given key.
309
*/
310
add(key: K, value: V): void {
311
const values = this.map.get(key);
312
if (values) {
313
values.push(value);
314
} else {
315
this.map.set(key, [value]);
316
}
317
}
318
319
/**
320
* Gets the array of values for the given key.
321
* Returns an empty array if the key does not exist.
322
*/
323
get(key: K): V[] {
324
return this.map.get(key) || [];
325
}
326
}
327
328