Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/common/editRebase.ts
13399 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
6
import { SingleEdits } from '../../../platform/inlineEdits/common/dataTypes/edit';
7
import { ILogger } from '../../../platform/log/common/logService';
8
import { ErrorUtils } from '../../../util/common/errors';
9
import { AnnotatedStringEdit, AnnotatedStringReplacement, IEditData, StringEdit, StringReplacement, VoidEditData } from '../../../util/vs/editor/common/core/edits/stringEdit';
10
import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';
11
import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';
12
import { DefaultLinesDiffComputer } from '../../../util/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer';
13
import { ILinesDiffComputerOptions } from '../../../util/vs/editor/common/diff/linesDiffComputer';
14
15
const TROUBLESHOOT_EDIT_CONSISTENCY = false;
16
17
export const maxAgreementOffset = 10; // If the user's typing is more than this into the suggestion we consider it a miss.
18
export const maxImperfectAgreementLength = 5; // If the user's typing is longer than this and the suggestion is not a perfect match we consider it a miss.
19
20
export interface NesRebaseConfigs {
21
/**
22
* When enabled, if the user's typed text is an editor auto-close pair
23
* (e.g. `()`, `[]`, `{}`, `<>`, `""`, `''`, ` `` `) and both characters
24
* appear in order in the suggestion's insert text, the rebase absorbs
25
* the typed pair instead of failing.
26
*/
27
readonly absorbSubsequenceTyping?: boolean;
28
/**
29
* When enabled, allows rebase to succeed when the user typed more text
30
* than the model predicted at the same position (reverse agreement).
31
* Model edits consumed by the user's typing are absorbed, and any
32
* unconsumed portion of subsequent model edits is offered as the
33
* rebased suggestion.
34
*/
35
readonly reverseAgreement?: boolean;
36
/**
37
* Maximum length (in characters) of an imperfect agreement that is still
38
* accepted during a strict rebase. When the base new-text is longer than
39
* this value and it does not start at the exact predicted offset, the
40
* rebase is considered a miss. Helper functions such as {@link tryRebase}
41
* use {@link maxImperfectAgreementLength} when `nesConfigs` is omitted,
42
* but explicit {@link NesRebaseConfigs} objects must provide this value.
43
*/
44
readonly maxImperfectAgreementLength: number;
45
}
46
47
export class EditDataWithIndex implements IEditData<EditDataWithIndex> {
48
constructor(
49
public readonly index: number
50
) { }
51
52
join(data: EditDataWithIndex): EditDataWithIndex | undefined {
53
if (this.index !== data.index) {
54
return undefined;
55
}
56
return this;
57
}
58
}
59
60
export function tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSince: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] | 'outsideEditWindow' | 'rebaseFailed' | 'error' | 'inconsistentEdits' {
61
const start = Date.now();
62
try {
63
return _tryRebase(originalDocument, editWindow, originalEdits, detailedEdits, userEditSince, currentDocumentContent, currentSelection, resolution, logger, nesConfigs);
64
} catch (err) {
65
logger.trace(`Rebase error: ${ErrorUtils.toString(err)}`);
66
return 'error';
67
} finally {
68
logger.trace(`Rebase duration: ${Date.now() - start}ms`);
69
}
70
}
71
72
function _tryRebase(originalDocument: string, editWindow: OffsetRange | undefined, originalEdits: readonly StringReplacement[], detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][], userEditSinceOrig: StringEdit, currentDocumentContent: string, currentSelection: readonly OffsetRange[], resolution: 'strict' | 'lenient', logger: ILogger, nesConfigs: NesRebaseConfigs) {
73
if (!checkEditConsistency(originalDocument, userEditSinceOrig, currentDocumentContent, logger, true)) {
74
return 'inconsistentEdits';
75
}
76
const userEditSince = userEditSinceOrig.removeCommonSuffixAndPrefix(originalDocument);
77
const cursorRange = currentSelection[0];
78
if (editWindow && cursorRange) {
79
const updatedEditWindow = userEditSince.applyToOffsetRangeOrUndefined(editWindow);
80
if (!updatedEditWindow?.containsRange(cursorRange)) {
81
return 'outsideEditWindow';
82
}
83
}
84
if (detailedEdits.length < originalEdits.length) {
85
let intermediateDocument = originalDocument;
86
for (let index = 0; index < detailedEdits.length; index++) {
87
const edit = originalEdits[index];
88
intermediateDocument = StringEdit.single(edit).apply(intermediateDocument);
89
}
90
for (let index = detailedEdits.length; index < originalEdits.length; index++) {
91
const edit = originalEdits[index];
92
const editData = new EditDataWithIndex(index);
93
detailedEdits[index] = computeDiff(edit.replaceRange.substring(intermediateDocument), edit.newText, edit.replaceRange.start, editData, {
94
ignoreTrimWhitespace: false,
95
computeMoves: false,
96
extendToSubwords: true,
97
maxComputationTimeMs: 500,
98
}) || [new AnnotatedStringReplacement(edit.replaceRange, edit.newText, editData)];
99
intermediateDocument = StringEdit.single(edit).apply(intermediateDocument);
100
}
101
}
102
const diffedEdit = AnnotatedStringEdit.compose(detailedEdits.map(edits => AnnotatedStringEdit.create(edits)));
103
const rebasedEdit = tryRebaseEdits(originalDocument, diffedEdit, userEditSince, resolution, nesConfigs);
104
if (!rebasedEdit) {
105
return 'rebaseFailed';
106
}
107
const grouped = rebasedEdit.replacements.reduce((acc, item) => {
108
(acc[item.data.index] ||= []).push(item);
109
return acc;
110
}, [] as (AnnotatedStringReplacement<EditDataWithIndex>[] | undefined)[]);
111
const resultEdits: { rebasedEdit: StringReplacement; rebasedEditIndex: number }[] = [];
112
for (let index = 0; index < grouped.length; index++) {
113
const group = grouped[index];
114
if (!group) {
115
continue;
116
}
117
const range = OffsetRange.fromTo(group[0].replaceRange.start, group[group.length - 1].replaceRange.endExclusive);
118
const newText = group.map((edit, i, a) => {
119
if (i > 0) {
120
return currentDocumentContent.substring(a[i - 1].replaceRange.endExclusive, edit.replaceRange.start) + edit.newText;
121
} else {
122
return edit.newText;
123
}
124
}).join('');
125
const resultEdit = StringReplacement.replace(range, newText);
126
if (!resultEdit.removeCommonSuffixAndPrefix(currentDocumentContent).isEmpty) {
127
resultEdits.push({ rebasedEdit: resultEdit, rebasedEditIndex: index });
128
}
129
}
130
if (resolution === 'strict' && resultEdits.length > 0 && new SingleEdits(originalEdits).apply(originalDocument) !== StringEdit.create(resultEdits.map(r => r.rebasedEdit)).apply(currentDocumentContent)) {
131
logger.trace('Result consistency check failed');
132
return 'inconsistentEdits';
133
}
134
return resultEdits;
135
}
136
137
export function checkEditConsistency(original: string, edit: StringEdit, current: string, logger: ILogger, enabled = TROUBLESHOOT_EDIT_CONSISTENCY) {
138
if (!enabled) {
139
return true;
140
}
141
const consistent = edit.apply(original) === current;
142
if (!consistent) {
143
logger.trace('Edit consistency check failed');
144
}
145
return consistent;
146
}
147
148
export function tryRebaseStringEdits<T extends IEditData<T>>(content: string, ours: StringEdit, base: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs = { maxImperfectAgreementLength }): StringEdit | undefined {
149
return tryRebaseEdits(content, ours.mapData(r => new VoidEditData()), base, resolution, nesConfigs)?.toStringEdit();
150
}
151
152
function tryRebaseEdits<T extends IEditData<T>>(content: string, ours: AnnotatedStringEdit<T>, baseOrig: StringEdit, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs): AnnotatedStringEdit<T> | undefined {
153
const base = baseOrig.removeCommonSuffixAndPrefix(content);
154
155
const newEdits: AnnotatedStringReplacement<T>[] = [];
156
157
let baseIdx = 0;
158
let ourIdx = 0;
159
let offset = 0;
160
161
while (ourIdx < ours.replacements.length || baseIdx < base.replacements.length) {
162
// take the edit that starts first
163
const baseEdit = base.replacements[baseIdx];
164
const ourEdit = ours.replacements[ourIdx];
165
166
if (!ourEdit) {
167
if (resolution === 'strict') {
168
// baseEdit does not match but interleaves
169
return undefined;
170
}
171
// We processed all our edits
172
break;
173
} else if (!baseEdit) {
174
// no more edits from base
175
newEdits.push(ourEdit.delta(offset));
176
ourIdx++;
177
} else {
178
let ourE = ourEdit;
179
if (!ourE.replaceRange.containsRange(baseEdit.replaceRange)) {
180
// Try to shift our edit to include the base edit.
181
if (ourE.replaceRange.start > baseEdit.replaceRange.start) {
182
// Expand our edit to the left to include the base edit.
183
const added = content.substring(baseEdit.replaceRange.start, ourE.replaceRange.start);
184
const updated = added + ourE.newText;
185
// Remove the same text from the right.
186
if (updated.endsWith(added)) {
187
ourE = new AnnotatedStringReplacement(
188
OffsetRange.fromTo(baseEdit.replaceRange.start, ourE.replaceRange.endExclusive - added.length),
189
updated.substring(0, updated.length - added.length),
190
ourE.data,
191
);
192
}
193
}
194
// Skipping the case where there is another edit for now because we might have to merge with it first.
195
else if (ourIdx === ours.replacements.length - 1 && ourE.replaceRange.endExclusive < baseEdit.replaceRange.endExclusive) {
196
// Expand our edit to the right to include the base edit.
197
const added = content.substring(ourE.replaceRange.endExclusive, baseEdit.replaceRange.endExclusive);
198
const updated = ourE.newText + added;
199
// Remove the same text from the left.
200
if (updated.startsWith(added)) {
201
ourE = new AnnotatedStringReplacement(
202
OffsetRange.fromTo(ourE.replaceRange.start + added.length, baseEdit.replaceRange.endExclusive),
203
updated.substring(added.length),
204
ourE.data,
205
);
206
}
207
}
208
}
209
if (ourE.replaceRange.intersectsOrTouches(baseEdit.replaceRange)) {
210
if (ourE.replaceRange.containsRange(baseEdit.replaceRange) && ourE.newText.length >= baseEdit.newText.length) {
211
let delta = 0;
212
let ourNewTextOffset = 0;
213
let baseE = baseEdit;
214
let previousBaseE: StringReplacement | undefined;
215
while (baseE && ourE.replaceRange.containsRange(baseE.replaceRange)) {
216
ourNewTextOffset = agreementIndexOf(content, ourE, baseE, previousBaseE, ourNewTextOffset, resolution, nesConfigs);
217
if (ourNewTextOffset === -1) {
218
// Conflicting
219
return undefined;
220
}
221
delta += baseE.newText.length - baseE.replaceRange.length;
222
previousBaseE = baseE;
223
baseE = base.replacements[++baseIdx];
224
}
225
newEdits.push(new AnnotatedStringReplacement(
226
new OffsetRange(ourE.replaceRange.start + offset, ourE.replaceRange.endExclusive + offset + delta),
227
ourE.newText,
228
ourE.data,
229
));
230
ourIdx++;
231
offset += delta;
232
} else if (nesConfigs.reverseAgreement && ourEdit.replaceRange.equals(baseEdit.replaceRange)) {
233
// Reverse agreement: user's edit (base) covers model's edit (ours)
234
// at the same range. The user typed more than the model predicted.
235
// Use ourEdit (pre-shift) to avoid false matches from shift alignment.
236
// Iterate over consecutive our-edits consumed by this base edit.
237
let baseNewTextOffset = 0;
238
let previousOurE: AnnotatedStringReplacement<T> | undefined;
239
240
while (ourIdx < ours.replacements.length && baseEdit.replaceRange.containsRange(ours.replacements[ourIdx].replaceRange)) {
241
const curOurE = ours.replacements[ourIdx];
242
243
// Account for gap content between previous our-edit end and current our-edit start
244
const gapStart = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;
245
const gapText = gapStart < curOurE.replaceRange.start ? content.substring(gapStart, curOurE.replaceRange.start) : '';
246
const effectiveText = gapText + curOurE.newText;
247
248
// Try full consumption: model text found entirely within user text
249
const j = baseEdit.newText.indexOf(effectiveText, baseNewTextOffset);
250
const strictRejected = j !== -1 && resolution === 'strict' && (
251
j - baseNewTextOffset > maxAgreementOffset ||
252
(j - baseNewTextOffset > 0 && effectiveText.length > nesConfigs.maxImperfectAgreementLength)
253
);
254
255
if (j !== -1 && !strictRejected) {
256
// Full consumption — model edit absorbed by user typing
257
baseNewTextOffset = j + effectiveText.length;
258
previousOurE = curOurE;
259
ourIdx++;
260
continue;
261
}
262
263
// Try partial consumption: remaining user text is a prefix of model text
264
const remainingBase = baseEdit.newText.substring(baseNewTextOffset);
265
if (remainingBase.length > 0 && effectiveText.startsWith(remainingBase)) {
266
const consumedFromNewText = Math.max(0, remainingBase.length - gapText.length);
267
const unconsumedNewText = curOurE.newText.substring(consumedFromNewText);
268
if (unconsumedNewText.length > 0) {
269
newEdits.push(new AnnotatedStringReplacement(
270
OffsetRange.emptyAt(baseEdit.replaceRange.start + offset + baseEdit.newText.length),
271
unconsumedNewText,
272
curOurE.data,
273
));
274
}
275
baseNewTextOffset = baseEdit.newText.length;
276
previousOurE = curOurE;
277
ourIdx++;
278
break;
279
}
280
281
// Conflicting
282
return undefined;
283
}
284
285
// Verify trailing gap in strict mode: any original content between the
286
// last consumed our-edit and the end of the base range must be preserved.
287
// Remaining user text beyond the gap is the user's own typing and is fine.
288
if (baseNewTextOffset < baseEdit.newText.length && resolution === 'strict') {
289
const lastOurEnd = previousOurE ? previousOurE.replaceRange.endExclusive : baseEdit.replaceRange.start;
290
const trailingGap = content.substring(lastOurEnd, baseEdit.replaceRange.endExclusive);
291
if (trailingGap.length > 0) {
292
const remainingBase = baseEdit.newText.substring(baseNewTextOffset);
293
if (!remainingBase.startsWith(trailingGap)) {
294
return undefined;
295
}
296
}
297
}
298
299
baseIdx++;
300
offset += baseEdit.newText.length - baseEdit.replaceRange.length;
301
} else {
302
// Conflicting
303
return undefined;
304
}
305
} else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) {
306
// Our edit starts first
307
newEdits.push(new AnnotatedStringReplacement(
308
ourEdit.replaceRange.delta(offset),
309
ourEdit.newText,
310
ourEdit.data,
311
));
312
ourIdx++;
313
} else {
314
if (resolution === 'strict') {
315
// baseEdit does not match but interleaves
316
return undefined;
317
}
318
baseIdx++;
319
offset += baseEdit.newText.length - baseEdit.replaceRange.length;
320
}
321
}
322
}
323
324
return AnnotatedStringEdit.create(newEdits);
325
}
326
327
/** Returns true if every character of `typed` appears in `suggestion` in order (subsequence match). */
328
function isSubsequenceOf(typed: string, suggestion: string): boolean {
329
let si = 0;
330
for (let ti = 0; ti < typed.length; ti++) {
331
const idx = suggestion.indexOf(typed[ti], si);
332
if (idx === -1) {
333
return false;
334
}
335
si = idx + 1;
336
}
337
return true;
338
}
339
340
const autoClosePairs = new Set(['()', '[]', '{}', '<>', '""', `''`, '``']);
341
342
/** Returns true if `text` is an editor auto-close pair such as `()`, `[]`, `{}`, etc. */
343
function isAutoClosePair(text: string): boolean {
344
return autoClosePairs.has(text);
345
}
346
347
function agreementIndexOf<T extends IEditData<T>>(content: string, ourE: AnnotatedStringReplacement<T>, baseE: StringReplacement, previousBaseE: StringReplacement | undefined, ourNewTextOffset: number, resolution: 'strict' | 'lenient', nesConfigs: NesRebaseConfigs) {
348
const originalBaseNewText = baseE.newText;
349
const minStart = previousBaseE ? previousBaseE.replaceRange.endExclusive : ourE.replaceRange.start;
350
if (minStart < baseE.replaceRange.start) {
351
baseE = new StringReplacement(
352
OffsetRange.fromTo(minStart, baseE.replaceRange.endExclusive),
353
content.substring(minStart, baseE.replaceRange.start) + baseE.newText
354
);
355
}
356
const j = ourE.newText.indexOf(baseE.newText, ourNewTextOffset);
357
const strictRejected = j !== -1 && resolution === 'strict' && (
358
j > maxAgreementOffset ||
359
(j > 0 && baseE.newText.length > nesConfigs.maxImperfectAgreementLength)
360
);
361
if (j !== -1 && !strictRejected) {
362
return j + baseE.newText.length;
363
}
364
// User typed text not found in suggestion (or rejected by strict limits) — absorb if it aligns as a subsequence.
365
// Guard: restrict to known editor auto-close pairs via isAutoClosePair(...) (effectively 2-character pairs)
366
// to avoid expensive matching on long pastes and to stay consistent with the existing strict safeguards.
367
if (nesConfigs.absorbSubsequenceTyping && isAutoClosePair(originalBaseNewText) && isSubsequenceOf(originalBaseNewText, ourE.newText.substring(ourNewTextOffset))) {
368
return ourNewTextOffset;
369
}
370
return -1;
371
}
372
373
function computeDiff(original: string, modified: string, offset: number, editData: EditDataWithIndex, options: ILinesDiffComputerOptions): AnnotatedStringReplacement<EditDataWithIndex>[] | undefined {
374
const originalLines = original.split(/\r\n|\r|\n/);
375
const modifiedLines = modified.split(/\r\n|\r|\n/);
376
const diffComputer = new DefaultLinesDiffComputer();
377
const result = diffComputer.computeDiff(originalLines, modifiedLines, options);
378
if (result.hitTimeout) {
379
return undefined;
380
}
381
382
const originalText = new StringText(original);
383
const modifiedText = new StringText(modified);
384
return result.changes.map(change => (change.innerChanges || []).map(innerChange => {
385
const range = originalText.getTransformer().getOffsetRange(innerChange.originalRange);
386
const newText = modifiedText.getValueOfRange(innerChange.modifiedRange);
387
return new AnnotatedStringReplacement(range.delta(offset), newText, editData);
388
})).flat();
389
}
390
391