Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/common/editRebase.spec.ts
13405 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 { expect, suite, test } from 'vitest';
6
import { decomposeStringEdit } from '../../../../platform/inlineEdits/common/dataTypes/editUtils';
7
import { TestLogService } from '../../../../platform/testing/common/testLogService';
8
import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit';
9
import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';
10
import { maxAgreementOffset, maxImperfectAgreementLength, tryRebase, tryRebaseStringEdits } from '../../common/editRebase';
11
12
13
suite('NextEditCache', () => {
14
test('tryRebase keeps index and full edit', async () => {
15
const originalDocument = `
16
class Point3D {
17
constructor(x, y) {
18
this.x = x;
19
this.y = y;
20
}
21
}
22
`;
23
const suggestedEdit = StringEdit.create([
24
StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),
25
StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),
26
]);
27
const userEdit = StringEdit.create([
28
StringReplacement.replace(new OffsetRange(34, 34), ', z'),
29
StringReplacement.replace(new OffsetRange(65, 65), '\n this.'),
30
]);
31
const final = suggestedEdit.apply(originalDocument);
32
expect(final).toStrictEqual(`
33
class Point3D {
34
constructor(x, y, z) {
35
this.x = x;
36
this.y = y;
37
this.z = z;
38
}
39
}
40
`);
41
const currentDocument = userEdit.apply(originalDocument);
42
expect(currentDocument).toStrictEqual(`
43
class Point3D {
44
constructor(x, y, z) {
45
this.x = x;
46
this.y = y;
47
this.
48
}
49
}
50
`);
51
52
const logger = new TestLogService();
53
{
54
const res = tryRebase(originalDocument, undefined, decomposeStringEdit(suggestedEdit).edits, [], userEdit, currentDocument, [], 'strict', logger);
55
expect(res).toBeTypeOf('object');
56
const result = res as Exclude<typeof res, string | undefined>;
57
expect(result[0].rebasedEditIndex).toBe(1);
58
expect(result[0].rebasedEdit.toString()).toMatchInlineSnapshot(`"[68, 76) -> "\\n\\t\\tthis.z = z;""`);
59
}
60
{
61
const res = tryRebase(originalDocument, undefined, decomposeStringEdit(suggestedEdit).edits, [], userEdit, currentDocument, [], 'lenient', logger);
62
expect(res).toBeTypeOf('object');
63
const result = res as Exclude<typeof res, string | undefined>;
64
expect(result[0].rebasedEditIndex).toBe(1);
65
expect(result[0].rebasedEdit.toString()).toMatchInlineSnapshot(`"[68, 76) -> "\\n\\t\\tthis.z = z;""`);
66
}
67
});
68
69
test('tryRebase matches up edits', async () => {
70
// Ambiguity with shifted edits.
71
const originalDocument = `
72
function getEnvVar(name): string | undefined {
73
const value = process.env[name] || undefined;
74
if (!value) {
75
console.warn(\`Environment variable \${name} is not set\`);
76
}
77
return value;
78
}
79
80
function main() {
81
const foo = getEnvVar("FOO");
82
if (!foo) {
83
return;
84
}
85
}
86
`;
87
const suggestedEdit = StringEdit.create([
88
StringReplacement.replace(new OffsetRange(265, 266), ` // Do something with foo
89
}`),
90
]);
91
const userEdit = StringEdit.create([
92
StringReplacement.replace(new OffsetRange(264, 264), `
93
94
95
96
// Do something with foo`),
97
]);
98
const final = suggestedEdit.apply(originalDocument);
99
expect(final).toStrictEqual(`
100
function getEnvVar(name): string | undefined {
101
const value = process.env[name] || undefined;
102
if (!value) {
103
console.warn(\`Environment variable \${name} is not set\`);
104
}
105
return value;
106
}
107
108
function main() {
109
const foo = getEnvVar("FOO");
110
if (!foo) {
111
return;
112
}
113
// Do something with foo
114
}
115
`);
116
const currentDocument = userEdit.apply(originalDocument);
117
expect(currentDocument).toStrictEqual(`
118
function getEnvVar(name): string | undefined {
119
const value = process.env[name] || undefined;
120
if (!value) {
121
console.warn(\`Environment variable \${name} is not set\`);
122
}
123
return value;
124
}
125
126
function main() {
127
const foo = getEnvVar("FOO");
128
if (!foo) {
129
return;
130
}
131
132
133
134
// Do something with foo
135
}
136
`);
137
138
const logger = new TestLogService();
139
expect(tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'strict', logger)).toStrictEqual('rebaseFailed');
140
expect(tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'lenient', logger)).toStrictEqual('rebaseFailed');
141
});
142
143
test('tryRebase correct offsets', async () => {
144
const originalDocument = `
145
#include <vector>
146
namespace
147
{
148
size_t func()
149
{
150
std::vector<int> result42;
151
if (result.empty())
152
return result.size();
153
result.clear();
154
return result.size();
155
}
156
}
157
158
159
int main()
160
{
161
return 0;
162
}
163
`;
164
const suggestedEdit = StringEdit.create([
165
StringReplacement.replace(new OffsetRange(78, 178), ` if (result42.empty())
166
return result42.size();
167
result42.clear();
168
return result42.size();
169
`),
170
]);
171
const userEdit = StringEdit.create([
172
StringReplacement.replace(new OffsetRange(86, 92), `r`),
173
]);
174
const final = suggestedEdit.apply(originalDocument);
175
expect(final).toStrictEqual(`
176
#include <vector>
177
namespace
178
{
179
size_t func()
180
{
181
std::vector<int> result42;
182
if (result42.empty())
183
return result42.size();
184
result42.clear();
185
return result42.size();
186
}
187
}
188
189
190
int main()
191
{
192
return 0;
193
}
194
`);
195
const currentDocument = userEdit.apply(originalDocument);
196
expect(currentDocument).toStrictEqual(`
197
#include <vector>
198
namespace
199
{
200
size_t func()
201
{
202
std::vector<int> result42;
203
if (r.empty())
204
return result.size();
205
result.clear();
206
return result.size();
207
}
208
}
209
210
211
int main()
212
{
213
return 0;
214
}
215
`);
216
217
const logger = new TestLogService();
218
{
219
const res = tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'strict', logger);
220
expect(res).toBeTypeOf('object');
221
const result = res as Exclude<typeof res, string | undefined>;
222
expect(result[0].rebasedEditIndex).toBe(0);
223
expect(StringEdit.single(result[0].rebasedEdit).apply(currentDocument)).toStrictEqual(final);
224
expect(result[0].rebasedEdit.removeCommonSuffixAndPrefix(currentDocument).toString()).toMatchInlineSnapshot(`"[87, 164) -> "esult42.empty())\\n return result42.size();\\n result42.clear();\\n return result42""`);
225
}
226
{
227
const res = tryRebase(originalDocument, undefined, suggestedEdit.replacements, [], userEdit, currentDocument, [], 'lenient', logger);
228
expect(res).toBeTypeOf('object');
229
const result = res as Exclude<typeof res, string | undefined>;
230
expect(result[0].rebasedEditIndex).toBe(0);
231
expect(StringEdit.single(result[0].rebasedEdit).apply(currentDocument)).toStrictEqual(final);
232
expect(result[0].rebasedEdit.removeCommonSuffixAndPrefix(currentDocument).toString()).toMatchInlineSnapshot(`"[87, 164) -> "esult42.empty())\\n return result42.size();\\n result42.clear();\\n return result42""`);
233
}
234
});
235
236
test('tryRebase fails when user types characters absent from the suggestion', () => {
237
// Document state when suggestion was cached:
238
// "function fib\n"
239
// ^ cursor at offset 12
240
//
241
// Suggestion (two edits):
242
// edit 0: replace [0,12) "function fib" → "function fib(n: number): number {"
243
// edit 1: insert at 34 → " if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n"
244
//
245
// User then types "()" at offset 12, producing:
246
// "function fib()\n"
247
//
248
// Rebase fails because the diff of edit 0 inserts "(n: number): number {" at offset 12,
249
// but the user typed "()" — and "()" is not a substring of "(n: number): number {",
250
// so agreementIndexOf returns -1 and the rebase cannot reconcile the two.
251
const originalDocument = 'function fib\n';
252
const originalEdits = [
253
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
254
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
255
];
256
const userEditSince = StringEdit.create([
257
StringReplacement.replace(new OffsetRange(12, 12), '()'),
258
]);
259
const currentDocumentContent = 'function fib()\n';
260
const editWindow = new OffsetRange(0, 13);
261
const currentSelection = [new OffsetRange(13, 13)];
262
263
const logger = new TestLogService();
264
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger)).toBe('rebaseFailed');
265
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger)).toBe('rebaseFailed');
266
});
267
268
test('absorbSubsequenceTyping: parentheses typed by user are absorbed', () => {
269
// The "()" the user typed is a subsequence of the suggestion's "(n: number): number {",
270
// so the rebased edit replaces it with the suggestion's text.
271
const originalDocument = 'function fib\n';
272
const originalEdits = [
273
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
274
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
275
];
276
const userEditSince = StringEdit.create([
277
StringReplacement.replace(new OffsetRange(12, 12), '()'),
278
]);
279
const currentDocumentContent = 'function fib()\n';
280
const editWindow = new OffsetRange(0, 13);
281
const currentSelection = [new OffsetRange(13, 13)];
282
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
283
const logger = new TestLogService();
284
285
const final = 'function fib(n: number): number {\n if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n';
286
287
{
288
const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs);
289
expect(res).toBeTypeOf('object');
290
const result = res as Exclude<typeof res, string>;
291
expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);
292
}
293
{
294
const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs);
295
expect(res).toBeTypeOf('object');
296
const result = res as Exclude<typeof res, string>;
297
expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);
298
}
299
});
300
301
test('absorbSubsequenceTyping: user types partial params "(n: )" NOT absorbed (not an auto-close pair)', () => {
302
// User types "(n: )" in "function fib" → "function fib(n: )\n"
303
// "(n: )" is a subsequence of the suggestion but is NOT an auto-close pair,
304
// so absorption does not apply.
305
const originalDocument = 'function fib\n';
306
const originalEdits = [
307
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
308
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
309
];
310
const userEditSince = StringEdit.create([
311
StringReplacement.replace(new OffsetRange(12, 12), '(n: )'),
312
]);
313
const currentDocumentContent = 'function fib(n: )\n';
314
const editWindow = new OffsetRange(0, 13);
315
const currentSelection = [new OffsetRange(16, 16)];
316
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
317
const logger = new TestLogService();
318
319
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
320
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
321
});
322
323
test('absorbSubsequenceTyping: semicolon NOT absorbed when it cannot align with suggestion', () => {
324
// User types ";" but suggestion wants to insert ": string = \"hello\""
325
// ";" is not a subsequence of ": string = \"hello\"", so absorption fails
326
const originalDocument = 'const x\n';
327
const originalEdits = [
328
StringReplacement.replace(new OffsetRange(0, 7), 'const x: string = "hello"'),
329
];
330
const userEditSince = StringEdit.create([
331
StringReplacement.replace(new OffsetRange(7, 7), ';'),
332
]);
333
const currentDocumentContent = 'const x;\n';
334
const editWindow = new OffsetRange(0, 8);
335
const currentSelection = [new OffsetRange(8, 8)];
336
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
337
const logger = new TestLogService();
338
339
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
340
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
341
});
342
343
test('absorbSubsequenceTyping: semicolon NOT absorbed (not an auto-close pair)', () => {
344
// User types ";" and suggestion inserts ": string = \"hello\";"
345
// ";" is present in the suggestion but is NOT an auto-close pair,
346
// so absorption does not apply.
347
const originalDocument = 'const x\n';
348
const originalEdits = [
349
StringReplacement.replace(new OffsetRange(0, 7), 'const x: string = "hello";'),
350
];
351
const userEditSince = StringEdit.create([
352
StringReplacement.replace(new OffsetRange(7, 7), ';'),
353
]);
354
const currentDocumentContent = 'const x;\n';
355
const editWindow = new OffsetRange(0, 8);
356
const currentSelection = [new OffsetRange(8, 8)];
357
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
358
const logger = new TestLogService();
359
360
// Strict rejects the exact match (offset 25 > maxAgreementOffset) and absorption
361
// doesn't apply because ";" is not an auto-close pair.
362
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
363
});
364
365
test('absorbSubsequenceTyping: text NOT a subsequence of suggestion is NOT absorbed', () => {
366
// User types "abc" — not a subsequence of "(n: number): number {", so not absorbed
367
const originalDocument = 'function fib\n';
368
const originalEdits = [
369
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
370
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
371
];
372
const userEditSince = StringEdit.create([
373
StringReplacement.replace(new OffsetRange(12, 12), 'abc'),
374
]);
375
const currentDocumentContent = 'function fibabc\n';
376
const editWindow = new OffsetRange(0, 13);
377
const currentSelection = [new OffsetRange(15, 15)];
378
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
379
const logger = new TestLogService();
380
381
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
382
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
383
});
384
385
test('absorbSubsequenceTyping: text NOT a subsequence of suggestion is NOT absorbed (2)', () => {
386
// User types "(a" — "a" is not found in "(n: number): number {", so not absorbed
387
const originalDocument = 'function fib\n';
388
const originalEdits = [
389
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
390
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
391
];
392
const userEditSince = StringEdit.create([
393
StringReplacement.replace(new OffsetRange(12, 12), '(a'),
394
]);
395
const currentDocumentContent = 'function fib(a\n';
396
const editWindow = new OffsetRange(0, 13);
397
const currentSelection = [new OffsetRange(14, 14)];
398
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
399
const logger = new TestLogService();
400
401
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs)).toBe('rebaseFailed');
402
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
403
});
404
405
test('absorbSubsequenceTyping: config disabled means punctuation is NOT absorbed', () => {
406
// Same fib scenario with "()" but config is explicitly false
407
const originalDocument = 'function fib\n';
408
const originalEdits = [
409
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
410
StringReplacement.replace(new OffsetRange(34, 34), ' if (n <= 1) return n;\n return fib(n - 1) + fib(n - 2);\n}\n'),
411
];
412
const userEditSince = StringEdit.create([
413
StringReplacement.replace(new OffsetRange(12, 12), '()'),
414
]);
415
const currentDocumentContent = 'function fib()\n';
416
const editWindow = new OffsetRange(0, 13);
417
const currentSelection = [new OffsetRange(13, 13)];
418
const logger = new TestLogService();
419
420
// Explicitly disabled
421
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, { absorbSubsequenceTyping: false, maxImperfectAgreementLength })).toBe('rebaseFailed');
422
// Default (no config)
423
expect(tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger)).toBe('rebaseFailed');
424
});
425
426
test('absorbSubsequenceTyping: normal agreement still works when user types text present in suggestion', () => {
427
// User types "(n" which IS a prefix found in the suggestion "(n: number): number {"
428
// Normal agreement should handle this regardless of the config
429
const originalDocument = 'function fib\n';
430
const suggestedEdit = StringEdit.create([
431
StringReplacement.replace(new OffsetRange(0, 12), 'function fib(n: number): number {'),
432
]);
433
const userEdit = StringEdit.create([
434
StringReplacement.replace(new OffsetRange(12, 12), '(n'),
435
]);
436
const currentDocument = userEdit.apply(originalDocument);
437
expect(currentDocument).toBe('function fib(n\n');
438
439
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
440
const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);
441
expect(res).toBeDefined();
442
expect(res!.apply(currentDocument)).toBe(suggestedEdit.apply(originalDocument));
443
});
444
445
test('absorbSubsequenceTyping via tryRebaseStringEdits: single curly brace NOT absorbed (not an auto-close pair)', () => {
446
const text = 'if (true)\n';
447
const suggestion = StringEdit.create([
448
StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");\n}'),
449
]);
450
const userEdit = StringEdit.create([
451
StringReplacement.replace(new OffsetRange(9, 9), '{'),
452
]);
453
const current = userEdit.apply(text);
454
expect(current).toBe('if (true){\n');
455
456
// Without config: fails
457
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
458
459
// With config: still fails because a single "{" is not an auto-close pair
460
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();
461
});
462
463
test('absorbSubsequenceTyping: "{}" NOT absorbed when suggestion only has opening brace', () => {
464
// User types "{}" but suggestion only inserts " {" (no closing brace in suggestion text)
465
// "}" is not found after "{" in " {", so subsequence check fails
466
const text = 'if (true)\n';
467
const suggestion = StringEdit.create([
468
StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");'),
469
]);
470
const userEdit = StringEdit.create([
471
StringReplacement.replace(new OffsetRange(9, 9), '{}'),
472
]);
473
const current = userEdit.apply(text);
474
expect(current).toBe('if (true){}\n');
475
476
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength })).toBeUndefined();
477
});
478
479
test('absorbSubsequenceTyping: "{}" absorbed when suggestion has both braces', () => {
480
const text = 'if (true)\n';
481
const suggestion = StringEdit.create([
482
StringReplacement.replace(new OffsetRange(0, 9), 'if (true) {\n console.log("yes");\n}'),
483
]);
484
const userEdit = StringEdit.create([
485
StringReplacement.replace(new OffsetRange(9, 9), '{}'),
486
]);
487
const current = userEdit.apply(text);
488
expect(current).toBe('if (true){}\n');
489
490
const final = suggestion.apply(text);
491
expect(final).toBe('if (true) {\n console.log("yes");\n}\n');
492
493
const result = tryRebaseStringEdits(text, suggestion, userEdit, 'strict', { absorbSubsequenceTyping: true, maxImperfectAgreementLength });
494
expect(result).toBeDefined();
495
expect(result!.apply(current)).toBe(final);
496
});
497
498
test('absorbSubsequenceTyping: "{}" typed after function signature, suggestion fills body', () => {
499
// User types "{}" after "function fib(n: number) " → "function fib(n: number) {}\n"
500
// Suggestion wants to replace with a full function body including { ... }
501
// "{}" is a subsequence of "{\n if ...\n}" so absorption succeeds
502
const originalDocument = 'function fib(n: number) \n';
503
const originalEdits = [
504
StringReplacement.replace(new OffsetRange(0, 24), 'function fib(n: number) {\n if (n <= 1) return 1;\n return n * factorial(n - 1);\n}'),
505
];
506
const userEditSince = StringEdit.create([
507
StringReplacement.replace(new OffsetRange(24, 24), '{}'),
508
]);
509
const currentDocumentContent = 'function fib(n: number) {}\n';
510
const editWindow = new OffsetRange(0, 25);
511
const currentSelection = [new OffsetRange(26, 26)];
512
const nesConfigs = { absorbSubsequenceTyping: true, maxImperfectAgreementLength };
513
const logger = new TestLogService();
514
515
const final = 'function fib(n: number) {\n if (n <= 1) return 1;\n return n * factorial(n - 1);\n}\n';
516
517
{
518
const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'strict', logger, nesConfigs);
519
expect(res).toBeTypeOf('object');
520
const result = res as Exclude<typeof res, string>;
521
expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);
522
}
523
{
524
const res = tryRebase(originalDocument, editWindow, originalEdits, [], userEditSince, currentDocumentContent, currentSelection, 'lenient', logger, nesConfigs);
525
expect(res).toBeTypeOf('object');
526
const result = res as Exclude<typeof res, string>;
527
expect(StringEdit.create(result.map(r => r.rebasedEdit)).apply(currentDocumentContent)).toBe(final);
528
}
529
});
530
});
531
532
suite('NextEditCache.tryRebaseStringEdits', () => {
533
test('insert', () => {
534
const text = 'class Point3 {';
535
const edit = StringEdit.create([
536
StringReplacement.replace(new OffsetRange(0, 14), 'class Point3D {'),
537
]);
538
const base = StringEdit.create([
539
StringReplacement.replace(new OffsetRange(12, 12), 'D'),
540
]);
541
expect(edit.apply(text)).toStrictEqual('class Point3D {');
542
expect(base.apply(text)).toStrictEqual('class Point3D {');
543
544
expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
545
expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
546
});
547
test('replace', () => {
548
const text = 'class Point3d {';
549
const edit = StringEdit.create([
550
StringReplacement.replace(new OffsetRange(0, 15), 'class Point3D {'),
551
]);
552
const base = StringEdit.create([
553
StringReplacement.replace(new OffsetRange(12, 13), 'D'),
554
]);
555
expect(edit.apply(text)).toStrictEqual('class Point3D {');
556
expect(base.apply(text)).toStrictEqual('class Point3D {');
557
558
expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
559
expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
560
});
561
test('delete', () => {
562
const text = 'class Point34D {';
563
const edit = StringEdit.create([
564
StringReplacement.replace(new OffsetRange(0, 16), 'class Point3D {'),
565
]);
566
const base = StringEdit.create([
567
StringReplacement.replace(new OffsetRange(12, 13), ''),
568
]);
569
expect(edit.apply(text)).toStrictEqual('class Point3D {');
570
expect(base.apply(text)).toStrictEqual('class Point3D {');
571
572
expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
573
expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toMatchInlineSnapshot(`"[0, 15) -> "class Point3D {""`);
574
});
575
test('insert', () => {
576
const text = 'class Point3 {';
577
const edit = StringEdit.create([
578
StringReplacement.replace(new OffsetRange(0, 14), 'class Point3D {'),
579
]);
580
const base = StringEdit.create([
581
StringReplacement.replace(new OffsetRange(12, 12), 'd'),
582
]);
583
expect(edit.apply(text)).toStrictEqual('class Point3D {');
584
expect(base.apply(text)).toStrictEqual('class Point3d {');
585
586
expect(tryRebaseStringEdits(text, edit, base, 'strict')?.replacements.toString()).toBeUndefined();
587
expect(tryRebaseStringEdits(text, edit, base, 'lenient')?.replacements.toString()).toBeUndefined();
588
});
589
590
test('insert 2 edits', () => {
591
const text = `
592
class Point3D {
593
constructor(x, y) {
594
this.x = x;
595
this.y = y;
596
}
597
}
598
`;
599
const edit = StringEdit.create([
600
StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),
601
StringReplacement.replace(new OffsetRange(66, 66), ' this.z = z;\n'),
602
]);
603
const base = StringEdit.create([
604
StringReplacement.replace(new OffsetRange(34, 34), ', z'),
605
]);
606
const final = edit.apply(text);
607
expect(final).toStrictEqual(`
608
class Point3D {
609
constructor(x, y, z) {
610
this.x = x;
611
this.y = y;
612
this.z = z;
613
}
614
}
615
`);
616
const current = base.apply(text);
617
expect(current).toStrictEqual(`
618
class Point3D {
619
constructor(x, y, z) {
620
this.x = x;
621
this.y = y;
622
}
623
}
624
`);
625
626
const strict = tryRebaseStringEdits(text, edit, base, 'strict')?.removeCommonSuffixAndPrefix(current);
627
expect(strict?.apply(current)).toStrictEqual(final);
628
expect(strict?.replacements.toString()).toMatchInlineSnapshot(`"[69, 69) -> "\\t\\tthis.z = z;\\n""`);
629
const lenient = tryRebaseStringEdits(text, edit, base, 'lenient')?.removeCommonSuffixAndPrefix(current);
630
expect(lenient?.apply(current)).toStrictEqual(final);
631
expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`"[69, 69) -> "\\t\\tthis.z = z;\\n""`);
632
});
633
test('insert 2 and 2 edits', () => {
634
const text = `
635
class Point3D {
636
constructor(x, y) {
637
this.x = x;
638
this.y = y;
639
}
640
}
641
`;
642
const edit = StringEdit.create([
643
StringReplacement.replace(new OffsetRange(17, 37), ' constructor(x, y, z) {'),
644
StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),
645
]);
646
const base = StringEdit.create([
647
StringReplacement.replace(new OffsetRange(34, 34), ', z'),
648
StringReplacement.replace(new OffsetRange(65, 65), '\n this.z = z;'),
649
]);
650
const final = edit.apply(text);
651
expect(final).toStrictEqual(`
652
class Point3D {
653
constructor(x, y, z) {
654
this.x = x;
655
this.y = y;
656
this.z = z;
657
}
658
}
659
`);
660
const current = base.apply(text);
661
expect(current).toStrictEqual(`
662
class Point3D {
663
constructor(x, y, z) {
664
this.x = x;
665
this.y = y;
666
this.z = z;
667
}
668
}
669
`);
670
671
const strict = tryRebaseStringEdits(text, edit, base, 'strict')?.removeCommonSuffixAndPrefix(current);
672
expect(strict?.apply(current)).toStrictEqual(final);
673
expect(strict?.replacements.toString()).toMatchInlineSnapshot(`""`);
674
const lenient = tryRebaseStringEdits(text, edit, base, 'lenient')?.removeCommonSuffixAndPrefix(current);
675
expect(lenient?.apply(current)).toStrictEqual(final);
676
expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);
677
});
678
test('insert 2 and 1 edits, 1 fully contained', () => {
679
const text = `abcdefghi`;
680
const suggestion = StringEdit.create([
681
StringReplacement.replace(new OffsetRange(4, 5), '234'),
682
StringReplacement.replace(new OffsetRange(7, 8), 'ABC'),
683
]);
684
const userEdit = StringEdit.create([
685
StringReplacement.replace(new OffsetRange(1, 6), '123456'),
686
]);
687
const intermediate = suggestion.apply(text);
688
expect(intermediate).toStrictEqual(`abcd234fgABCi`);
689
const current = userEdit.apply(text);
690
expect(current).toStrictEqual(`a123456ghi`);
691
692
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
693
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
694
});
695
696
test('2 user edits contained in 1', () => {
697
const text = `abcdef`;
698
const suggestion = StringEdit.create([
699
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
700
]);
701
const applied = suggestion.apply(text);
702
expect(applied).toStrictEqual(`ab1c2def`);
703
704
const userEdit = StringEdit.create([
705
StringReplacement.replace(new OffsetRange(2, 2), '1'),
706
StringReplacement.replace(new OffsetRange(3, 3), '2'),
707
StringReplacement.replace(new OffsetRange(5, 5), '3'),
708
]);
709
const current = userEdit.apply(text);
710
expect(current).toStrictEqual(`ab1c2de3f`);
711
712
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
713
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')?.removeCommonSuffixAndPrefix(current);
714
expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');
715
expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);
716
});
717
718
test('2 user edits contained in 1, conflicting 1', () => {
719
const text = `abcde`;
720
const suggestion = StringEdit.create([
721
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
722
]);
723
const applied = suggestion.apply(text);
724
expect(applied).toStrictEqual(`ab1c2de`);
725
726
const userEdit = StringEdit.create([
727
StringReplacement.replace(new OffsetRange(2, 2), '1'),
728
StringReplacement.replace(new OffsetRange(3, 3), '3'),
729
]);
730
const current = userEdit.apply(text);
731
expect(current).toStrictEqual(`ab1c3de`);
732
733
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
734
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
735
});
736
737
test('2 user edits contained in 1, conflicting 2', () => {
738
const text = `abcde`;
739
const suggestion = StringEdit.create([
740
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
741
]);
742
const applied = suggestion.apply(text);
743
expect(applied).toStrictEqual(`ab1c2de`);
744
745
const userEdit = StringEdit.create([
746
StringReplacement.replace(new OffsetRange(2, 2), '2'),
747
StringReplacement.replace(new OffsetRange(3, 3), '1'),
748
]);
749
const current = userEdit.apply(text);
750
expect(current).toStrictEqual(`ab2c1de`);
751
752
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
753
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
754
});
755
756
test('2 edits contained in 1 user edit', () => {
757
const text = `abcdef`;
758
const userEdit = StringEdit.create([
759
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
760
]);
761
const current = userEdit.apply(text);
762
expect(current).toStrictEqual(`ab1c2def`);
763
764
const suggestion = StringEdit.create([
765
StringReplacement.replace(new OffsetRange(2, 2), '1'),
766
StringReplacement.replace(new OffsetRange(3, 3), '2'),
767
StringReplacement.replace(new OffsetRange(5, 5), '3'),
768
]);
769
const applied = suggestion.apply(text);
770
expect(applied).toStrictEqual(`ab1c2de3f`);
771
772
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
773
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
774
});
775
776
test('2 edits contained in 1 user edit, conflicting 1', () => {
777
const text = `abcde`;
778
const userEdit = StringEdit.create([
779
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
780
]);
781
const current = userEdit.apply(text);
782
expect(current).toStrictEqual(`ab1c2de`);
783
784
const suggestion = StringEdit.create([
785
StringReplacement.replace(new OffsetRange(2, 2), '1'),
786
StringReplacement.replace(new OffsetRange(3, 3), '3'),
787
]);
788
const applied = suggestion.apply(text);
789
expect(applied).toStrictEqual(`ab1c3de`);
790
791
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
792
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
793
});
794
795
test('2 edits contained in 1 user edit, conflicting 2', () => {
796
const text = `abcde`;
797
const userEdit = StringEdit.create([
798
StringReplacement.replace(new OffsetRange(1, 4), 'b1c2d'),
799
]);
800
const current = userEdit.apply(text);
801
expect(current).toStrictEqual(`ab1c2de`);
802
803
const suggestion = StringEdit.create([
804
StringReplacement.replace(new OffsetRange(2, 2), '2'),
805
StringReplacement.replace(new OffsetRange(3, 3), '1'),
806
]);
807
const applied = suggestion.apply(text);
808
expect(applied).toStrictEqual(`ab2c1de`);
809
810
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
811
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
812
});
813
814
test('1 additional user edit', () => {
815
const text = `abcdef`;
816
const userEdit = StringEdit.create([
817
StringReplacement.replace(new OffsetRange(2, 2), '1'),
818
StringReplacement.replace(new OffsetRange(3, 3), '2'),
819
StringReplacement.replace(new OffsetRange(5, 5), '3'),
820
]);
821
const current = userEdit.apply(text);
822
expect(current).toStrictEqual(`ab1c2de3f`);
823
824
const suggestion = StringEdit.create([
825
StringReplacement.replace(new OffsetRange(2, 2), '1'),
826
StringReplacement.replace(new OffsetRange(5, 5), '3'),
827
]);
828
const applied = suggestion.apply(text);
829
expect(applied).toStrictEqual(`ab1cde3f`);
830
831
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
832
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')?.removeCommonSuffixAndPrefix(current);
833
expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');
834
expect(lenient?.replacements.toString()).toMatchInlineSnapshot(`""`);
835
});
836
837
test('1 additional suggestion edit', () => {
838
const text = `abcdef`;
839
const userEdit = StringEdit.create([
840
StringReplacement.replace(new OffsetRange(2, 2), '1'),
841
StringReplacement.replace(new OffsetRange(5, 5), '3'),
842
]);
843
const current = userEdit.apply(text);
844
expect(current).toStrictEqual(`ab1cde3f`);
845
846
const suggestion = StringEdit.create([
847
StringReplacement.replace(new OffsetRange(2, 2), '1'),
848
StringReplacement.replace(new OffsetRange(3, 3), '2'),
849
StringReplacement.replace(new OffsetRange(5, 5), '3'),
850
]);
851
const applied = suggestion.apply(text);
852
expect(applied).toStrictEqual(`ab1c2de3f`);
853
854
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
855
expect(strict?.apply(current)).toStrictEqual('ab1c2de3f');
856
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 4) -> "2""`);
857
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
858
expect(lenient?.apply(current)).toStrictEqual('ab1c2de3f');
859
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 4) -> "2""`);
860
});
861
862
test('shifted edits 1', () => {
863
const text = `abcde`;
864
const userEdit = StringEdit.create([
865
StringReplacement.replace(new OffsetRange(2, 2), 'c1'),
866
]);
867
const current = userEdit.apply(text);
868
expect(current).toStrictEqual(`abc1cde`);
869
870
const suggestion = StringEdit.create([
871
StringReplacement.replace(new OffsetRange(1, 1), '0'),
872
StringReplacement.replace(new OffsetRange(3, 3), '1c'),
873
StringReplacement.replace(new OffsetRange(4, 4), '2'),
874
]);
875
const applied = suggestion.apply(text);
876
expect(applied).toStrictEqual(`a0bc1cd2e`);
877
878
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
879
expect(strict?.apply(current)).toStrictEqual('a0bc1cd2e');
880
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0",[6, 6) -> "2""`);
881
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
882
expect(lenient?.apply(current)).toStrictEqual('a0bc1cd2e');
883
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0",[6, 6) -> "2""`);
884
});
885
886
test('shifted edits 2', () => {
887
const text = `abcde`;
888
const userEdit = StringEdit.create([
889
StringReplacement.replace(new OffsetRange(3, 3), '1c'),
890
]);
891
const current = userEdit.apply(text);
892
expect(current).toStrictEqual(`abc1cde`);
893
894
const suggestion = StringEdit.create([
895
StringReplacement.replace(new OffsetRange(1, 1), '0'),
896
StringReplacement.replace(new OffsetRange(2, 2), 'c1'),
897
]);
898
const applied = suggestion.apply(text);
899
expect(applied).toStrictEqual(`a0bc1cde`);
900
901
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
902
expect(strict?.apply(current)).toStrictEqual('a0bc1cde');
903
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0""`);
904
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
905
expect(lenient?.apply(current)).toStrictEqual('a0bc1cde');
906
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[1, 1) -> "0""`);
907
});
908
909
test('user deletes 1', () => {
910
const text = `abcde`;
911
const userEdit = StringEdit.create([
912
StringReplacement.replace(new OffsetRange(2, 3), ''),
913
]);
914
const current = userEdit.apply(text);
915
expect(current).toStrictEqual(`abde`);
916
917
const suggestion = StringEdit.create([
918
StringReplacement.replace(new OffsetRange(3, 3), '1c'),
919
]);
920
const applied = suggestion.apply(text);
921
expect(applied).toStrictEqual(`abc1cde`);
922
923
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
924
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
925
});
926
927
test('user deletes 2', () => {
928
const text = `abcde`;
929
const userEdit = StringEdit.create([
930
StringReplacement.replace(new OffsetRange(2, 3), ''),
931
]);
932
const current = userEdit.apply(text);
933
expect(current).toStrictEqual(`abde`);
934
935
const suggestion = StringEdit.create([
936
StringReplacement.replace(new OffsetRange(2, 2), 'c1'),
937
]);
938
const applied = suggestion.apply(text);
939
expect(applied).toStrictEqual(`abc1cde`);
940
941
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
942
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
943
});
944
945
test('overlap: suggestion replaces in disagreement', () => {
946
const text = `this.myPet = g`;
947
const userEdit = StringEdit.create([
948
StringReplacement.replace(new OffsetRange(14, 14), 'et'),
949
]);
950
const current = userEdit.apply(text);
951
expect(current).toStrictEqual(`this.myPet = get`);
952
953
const suggestion = StringEdit.create([
954
StringReplacement.replace(new OffsetRange(13, 14), 'new Pet("Buddy", 3);'),
955
]);
956
const applied = suggestion.apply(text);
957
expect(applied).toStrictEqual(`this.myPet = new Pet("Buddy", 3);`);
958
959
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'strict')).toBeUndefined();
960
expect(tryRebaseStringEdits(text, suggestion, userEdit, 'lenient')).toBeUndefined();
961
});
962
963
test('overlap: suggestion replaces in agreement', () => {
964
const text = `this.myPet = g`;
965
const userEdit = StringEdit.create([
966
StringReplacement.replace(new OffsetRange(14, 14), 'et'),
967
]);
968
const current = userEdit.apply(text);
969
expect(current).toStrictEqual(`this.myPet = get`);
970
971
const suggestion = StringEdit.create([
972
StringReplacement.replace(new OffsetRange(13, 14), 'getPet();'),
973
]);
974
const applied = suggestion.apply(text);
975
expect(applied).toStrictEqual(`this.myPet = getPet();`);
976
977
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
978
expect(strict?.apply(current)).toStrictEqual('this.myPet = getPet();');
979
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[16, 16) -> "Pet();""`);
980
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
981
expect(lenient?.apply(current)).toStrictEqual('this.myPet = getPet();');
982
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[16, 16) -> "Pet();""`);
983
});
984
985
test('overlap: both replace in agreement 1', () => {
986
const text = `abcdefg`;
987
const userEdit = StringEdit.create([
988
StringReplacement.replace(new OffsetRange(2, 5), 'CD'),
989
]);
990
const current = userEdit.apply(text);
991
expect(current).toStrictEqual(`abCDfg`);
992
993
const suggestion = StringEdit.create([
994
StringReplacement.replace(new OffsetRange(1, 6), 'bCDEF'),
995
]);
996
const applied = suggestion.apply(text);
997
expect(applied).toStrictEqual(`abCDEFg`);
998
999
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
1000
expect(strict?.apply(current)).toStrictEqual('abCDEFg');
1001
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 5) -> "EF""`);
1002
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
1003
expect(lenient?.apply(current)).toStrictEqual('abCDEFg');
1004
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[4, 5) -> "EF""`);
1005
});
1006
1007
test('overlap: both replace in agreement 2', () => {
1008
const text = `abcdefg`;
1009
const userEdit = StringEdit.create([
1010
StringReplacement.replace(new OffsetRange(1, 5), 'bC'),
1011
]);
1012
const current = userEdit.apply(text);
1013
expect(current).toStrictEqual(`abCfg`);
1014
1015
const suggestion = StringEdit.create([
1016
StringReplacement.replace(new OffsetRange(2, 5), 'CDE'),
1017
]);
1018
const applied = suggestion.apply(text);
1019
expect(applied).toStrictEqual(`abCDEfg`);
1020
1021
const strict = tryRebaseStringEdits(text, suggestion, userEdit, 'strict');
1022
expect(strict?.apply(current)).toStrictEqual('abCDEfg');
1023
expect(strict?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[3, 3) -> "DE""`);
1024
const lenient = tryRebaseStringEdits(text, suggestion, userEdit, 'lenient');
1025
expect(lenient?.apply(current)).toStrictEqual('abCDEfg');
1026
expect(lenient?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[3, 3) -> "DE""`);
1027
});
1028
1029
test('overlap: both insert in agreement with large offset', () => {
1030
const text = `abcdefg`;
1031
const userEdit = StringEdit.create([
1032
StringReplacement.replace(new OffsetRange(7, 7), 'h'),
1033
]);
1034
const current = userEdit.apply(text);
1035
expect(current).toStrictEqual(`abcdefgh`);
1036
1037
const suggestion1 = StringEdit.create([
1038
StringReplacement.replace(new OffsetRange(7, 7), 'x'.repeat(maxAgreementOffset) + 'h'),
1039
]);
1040
const applied1 = suggestion1.apply(text);
1041
expect(applied1).toStrictEqual(`abcdefg${'x'.repeat(maxAgreementOffset)}h`);
1042
1043
const strict1 = tryRebaseStringEdits(text, suggestion1, userEdit, 'strict');
1044
expect(strict1?.apply(current)).toStrictEqual(applied1);
1045
expect(strict1?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset)}""`);
1046
const lenient1 = tryRebaseStringEdits(text, suggestion1, userEdit, 'lenient');
1047
expect(lenient1?.apply(current)).toStrictEqual(applied1);
1048
expect(lenient1?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset)}""`);
1049
1050
const suggestion2 = StringEdit.create([
1051
StringReplacement.replace(new OffsetRange(7, 7), 'x'.repeat(maxAgreementOffset + 1) + 'h'),
1052
]);
1053
const applied2 = suggestion2.apply(text);
1054
expect(applied2).toStrictEqual(`abcdefg${'x'.repeat(maxAgreementOffset + 1)}h`);
1055
1056
expect(tryRebaseStringEdits(text, suggestion2, userEdit, 'strict')).toBeUndefined();
1057
const lenient2 = tryRebaseStringEdits(text, suggestion2, userEdit, 'lenient');
1058
expect(lenient2?.apply(current)).toStrictEqual(applied2);
1059
expect(lenient2?.removeCommonSuffixAndPrefix(current).replacements.toString()).toMatchInlineSnapshot(`"[7, 7) -> "${'x'.repeat(maxAgreementOffset + 1)}""`);
1060
});
1061
1062
test('overlap: both insert in agreement with an offset with longish user edit', () => {
1063
const text = `abcdefg`;
1064
const userEdit1 = StringEdit.create([
1065
StringReplacement.replace(new OffsetRange(7, 7), 'h'.repeat(maxImperfectAgreementLength)),
1066
]);
1067
const current1 = userEdit1.apply(text);
1068
expect(current1).toStrictEqual(`abcdefg${'h'.repeat(maxImperfectAgreementLength)}`);
1069
1070
const suggestion = StringEdit.create([
1071
StringReplacement.replace(new OffsetRange(7, 7), `x${'h'.repeat(maxImperfectAgreementLength + 2)}x`),
1072
]);
1073
const applied = suggestion.apply(text);
1074
expect(applied).toStrictEqual(`abcdefgx${'h'.repeat(maxImperfectAgreementLength + 2)}x`);
1075
1076
const strict1 = tryRebaseStringEdits(text, suggestion, userEdit1, 'strict');
1077
expect(strict1?.apply(current1)).toStrictEqual(applied);
1078
expect(strict1?.removeCommonSuffixAndPrefix(current1).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);
1079
const lenient1 = tryRebaseStringEdits(text, suggestion, userEdit1, 'lenient');
1080
expect(lenient1?.apply(current1)).toStrictEqual(applied);
1081
expect(lenient1?.removeCommonSuffixAndPrefix(current1).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);
1082
1083
const userEdit2 = StringEdit.create([
1084
StringReplacement.replace(new OffsetRange(7, 7), 'h'.repeat(maxImperfectAgreementLength + 1)),
1085
]);
1086
const current2 = userEdit2.apply(text);
1087
expect(current2).toStrictEqual(`abcdefg${'h'.repeat(maxImperfectAgreementLength + 1)}`);
1088
1089
expect(tryRebaseStringEdits(text, suggestion, userEdit2, 'strict')).toBeUndefined();
1090
const lenient2 = tryRebaseStringEdits(text, suggestion, userEdit2, 'lenient');
1091
expect(lenient2?.apply(current2)).toStrictEqual(applied);
1092
expect(lenient2?.removeCommonSuffixAndPrefix(current2).replacements.toString()).toMatchInlineSnapshot(`"[7, ${7 + maxImperfectAgreementLength + 1}) -> "x${'h'.repeat(maxImperfectAgreementLength + 2)}x""`);
1093
});
1094
1095
test('reverse agreement: user typed more than model predicted at same position', () => {
1096
// Model predicts two edits: insert "{" and insert body.
1097
// User typed "{\n\t" which covers the first edit and the start of the second.
1098
// Rebase should succeed, offering the unconsumed portion of the second edit.
1099
const originalDocument = 'class Fibonacci \n';
1100
const originalEdits = [
1101
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),
1102
StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),
1103
];
1104
const userEditSince = StringEdit.create([
1105
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
1106
]);
1107
const currentDocumentContent = 'class Fibonacci {\n\t\n';
1108
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1109
1110
const logger = new TestLogService();
1111
// Without flag: rebase fails
1112
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');
1113
// With flag: rebase succeeds
1114
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
1115
expect(res).toBeTypeOf('object');
1116
const result = res as Exclude<typeof res, string>;
1117
expect(result.length).toBe(1);
1118
expect(result[0].rebasedEditIndex).toBe(1);
1119
// The unconsumed portion of the body edit should be offered
1120
expect(result[0].rebasedEdit.newText).toContain('private memo');
1121
});
1122
1123
test('reverse agreement: user typed exactly the first model edit', () => {
1124
// User typed exactly "{" which is the model's first edit.
1125
// The second edit (body) should be offered in full.
1126
// Note: this case is actually handled by the existing forward agreement path
1127
// (user text length == model text length), so it works regardless of the flag.
1128
const originalDocument = 'class Foo \n';
1129
const originalEdits = [
1130
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
1131
StringReplacement.replace(OffsetRange.emptyAt(12), '\n\tbar(): void {}\n}'),
1132
];
1133
const userEditSince = StringEdit.create([
1134
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
1135
]);
1136
const currentDocumentContent = 'class Foo {\n';
1137
1138
const logger = new TestLogService();
1139
// Works without reverse agreement flag (handled by forward agreement)
1140
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger);
1141
expect(res).toBeTypeOf('object');
1142
const result = res as Exclude<typeof res, string>;
1143
expect(result.length).toBe(1);
1144
expect(result[0].rebasedEditIndex).toBe(1);
1145
expect(result[0].rebasedEdit.newText).toContain('bar(): void {}');
1146
});
1147
1148
test('reverse agreement: user typed completely different text — should conflict', () => {
1149
// Model: "class Foo " → "class Foo {"
1150
// User: "class Foo " → "class Foo XYZ"
1151
// "XYZ" is NOT found in "{", so this should fail.
1152
const originalDocument = 'class Foo \n';
1153
const originalEdits = [
1154
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo {'),
1155
];
1156
const userEditSince = StringEdit.create([
1157
StringReplacement.replace(new OffsetRange(0, 10), 'class Foo XYZ'),
1158
]);
1159
const currentDocumentContent = 'class Foo XYZ\n';
1160
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1161
1162
const logger = new TestLogService();
1163
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
1164
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
1165
});
1166
1167
test('reverse agreement: user typed text that accidentally contains model text as substring', () => {
1168
// Model: replace [0,5) "hello" → "hello{" (diff: insert "{" at 5), then insert body at 6.
1169
// User: replace [0,5) "hello" → "helloXX{YY" (diff: insert "XX{YY" at 5).
1170
// The model's first diff ("{") IS found in user's "XX{YY" at offset 2, so it's consumed.
1171
// But the model's second edit ("\n\tworld\n}") can't be found in the remaining
1172
// user text "YY" — partial consumption also fails ("YY" doesn't start with "\n\tworld\n}").
1173
// So the rebase correctly fails for the second edit.
1174
const originalDocument = 'hello\n';
1175
const originalEdits = [
1176
StringReplacement.replace(new OffsetRange(0, 5), 'hello{'),
1177
StringReplacement.replace(OffsetRange.emptyAt(6), '\n\tworld\n}'),
1178
];
1179
const userEditSince = StringEdit.create([
1180
StringReplacement.replace(new OffsetRange(0, 5), 'helloXX{YY'),
1181
]);
1182
const currentDocumentContent = 'helloXX{YY\n';
1183
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1184
1185
const logger = new TestLogService();
1186
// Fails because user's remaining text "YY" doesn't match model's second edit
1187
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
1188
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'lenient', logger, nesConfigs)).toBe('rebaseFailed');
1189
});
1190
1191
test('reverse agreement: user typed text with model text at large offset — strict rejects', () => {
1192
// Model: "a" → "a{"
1193
// User: "a" → "a" + "X".repeat(15) + "{"
1194
// The "{" is at offset 15 into the user text, which exceeds maxAgreementOffset (10).
1195
// Strict should reject; lenient should also fail since there's no lenient fallback
1196
// in the reverse branch.
1197
const pad = 'X'.repeat(maxAgreementOffset + 1);
1198
const originalDocument = 'a\n';
1199
const originalEdits = [
1200
StringReplacement.replace(new OffsetRange(0, 1), 'a{'),
1201
];
1202
const userEditSince = StringEdit.create([
1203
StringReplacement.replace(new OffsetRange(0, 1), 'a' + pad + '{'),
1204
]);
1205
const currentDocumentContent = 'a' + pad + '{\n';
1206
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1207
1208
const logger = new TestLogService();
1209
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
1210
});
1211
1212
test('reverse agreement: user typed long text at small offset — strict rejects imperfect agreement', () => {
1213
// Model: "a" → "a{"
1214
// User: "a" → "aX" + "{".repeat(maxImperfectAgreementLength + 1)
1215
// The model text "{" is found at offset 1 (> 0) and the effective text length
1216
// is 1 (≤ maxImperfectAgreementLength), so this should pass strict.
1217
// But if effectiveText were longer...
1218
const longText = 'Z'.repeat(maxImperfectAgreementLength + 1);
1219
const originalDocument = 'a\n';
1220
const originalEdits = [
1221
StringReplacement.replace(new OffsetRange(0, 1), 'a' + longText),
1222
];
1223
const userEditSince = StringEdit.create([
1224
StringReplacement.replace(new OffsetRange(0, 1), 'aX' + longText),
1225
]);
1226
const currentDocumentContent = 'aX' + longText + '\n';
1227
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1228
1229
const logger = new TestLogService();
1230
// offset = 1 > 0, effectiveText.length = longText.length > maxImperfectAgreementLength
1231
// → strict rejected
1232
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
1233
});
1234
1235
test('reverse agreement: all model edits fully consumed by user — no rebased edit emitted', () => {
1236
// Model predicts single edit: insert "{\n\t"
1237
// User typed "{\n\tfoo\n}" which fully contains "{\n\t"
1238
// All model edits consumed → nothing to offer
1239
const originalDocument = 'fn \n';
1240
const originalEdits = [
1241
StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\t'),
1242
];
1243
const userEditSince = StringEdit.create([
1244
StringReplacement.replace(new OffsetRange(0, 3), 'fn {\n\tfoo\n}'),
1245
]);
1246
const currentDocumentContent = 'fn {\n\tfoo\n}\n';
1247
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1248
1249
const logger = new TestLogService();
1250
// Without flag: rebase fails
1251
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger)).toBe('rebaseFailed');
1252
// With flag: succeeds with no edits to offer
1253
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
1254
expect(res).toBeTypeOf('object');
1255
const result = res as Exclude<typeof res, string>;
1256
// The single model edit was fully consumed — nothing left to suggest
1257
expect(result.length).toBe(0);
1258
});
1259
1260
test('reverse agreement: consistency check — rebased edit applied to current doc produces expected result', () => {
1261
// This is the key correctness check: applying the rebased edit to the current
1262
// document should produce the same result as applying the original edits to
1263
// the original document.
1264
const originalDocument = 'class Fibonacci \n';
1265
const originalEdits = [
1266
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {'),
1267
StringReplacement.replace(OffsetRange.emptyAt(17), '\n\tprivate memo: Map<number, number>;\n}'),
1268
];
1269
const userEditSince = StringEdit.create([
1270
StringReplacement.replace(new OffsetRange(0, 16), 'class Fibonacci {\n\t'),
1271
]);
1272
const currentDocumentContent = 'class Fibonacci {\n\t\n';
1273
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1274
1275
// Expected final: apply both model edits in sequence to original
1276
const expectedFinal = new StringEdit([originalEdits[0]]).apply(originalDocument);
1277
const expectedFinal2 = new StringEdit([originalEdits[1]]).apply(expectedFinal);
1278
1279
const logger = new TestLogService();
1280
const res = tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs);
1281
expect(res).toBeTypeOf('object');
1282
const result = res as Exclude<typeof res, string>;
1283
expect(result.length).toBe(1);
1284
1285
// Apply rebased edit to current document
1286
const actualFinal = StringEdit.single(result[0].rebasedEdit).apply(currentDocumentContent);
1287
expect(actualFinal).toBe(expectedFinal2);
1288
});
1289
1290
test('reverse agreement: pure inserts at same position — user insert is superset of model insert', () => {
1291
// Both edits are pure inserts at position 5.
1292
// Model inserts "X", user inserts "XY".
1293
// After removeCommonSuffixAndPrefix on user edit:
1294
// user edit: insert at 5 → "XY", model edit: insert at 5 → "X"
1295
// These have equal replaceRange (both emptyAt(5)).
1296
// The reverse branch should fire: "X" found in "XY" at offset 0 → consumed.
1297
// Nothing left to suggest from this model edit.
1298
const originalDocument = 'hello world\n';
1299
const suggestedEdit = StringEdit.create([
1300
StringReplacement.replace(OffsetRange.emptyAt(5), 'X'),
1301
]);
1302
const userEdit = StringEdit.create([
1303
StringReplacement.replace(OffsetRange.emptyAt(5), 'XY'),
1304
]);
1305
const current = userEdit.apply(originalDocument);
1306
expect(current).toBe('helloXY world\n');
1307
1308
// Without flag: rebase fails
1309
expect(tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict')).toBeUndefined();
1310
// With flag: model edit fully consumed → empty result
1311
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1312
const res = tryRebaseStringEdits(originalDocument, suggestedEdit, userEdit, 'strict', nesConfigs);
1313
expect(res).toBeDefined();
1314
expect(res!.replacements.length).toBe(0);
1315
});
1316
1317
test('reverse agreement: does NOT fire when ranges differ', () => {
1318
// Model replaces [0,3), user replaces [0,5) — different ranges.
1319
// The reverse branch requires equal ranges, so this should NOT trigger it.
1320
// Instead, this falls through to the conflict branch.
1321
const originalDocument = 'abcde\n';
1322
const originalEdits = [
1323
StringReplacement.replace(new OffsetRange(0, 3), 'XYZ'),
1324
];
1325
const userEditSince = StringEdit.create([
1326
StringReplacement.replace(new OffsetRange(0, 5), 'XYZWV'),
1327
]);
1328
const currentDocumentContent = 'XYZWV\n';
1329
const nesConfigs = { reverseAgreement: true, maxImperfectAgreementLength };
1330
1331
const logger = new TestLogService();
1332
// The ranges don't match after removeCommonSuffixAndPrefix, so this conflicts
1333
expect(tryRebase(originalDocument, undefined, originalEdits, [], userEditSince, currentDocumentContent, [], 'strict', logger, nesConfigs)).toBe('rebaseFailed');
1334
});
1335
});
1336
1337