Skip to content

Commit 963958a

Browse files
authored
Fixes invariant violation in tryRebase (microsoft#291840)
1 parent 4d48941 commit 963958a

File tree

3 files changed

+107
-23
lines changed

3 files changed

+107
-23
lines changed

src/vs/editor/common/core/edits/stringEdit.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -97,48 +97,36 @@ export abstract class BaseStringEdit<T extends BaseStringReplacement<T> = BaseSt
9797
let baseIdx = 0;
9898
let ourIdx = 0;
9999
let offset = 0;
100-
let lastEndEx = -1; // Track end of last added edit to ensure sorted/disjoint invariant
101100

102101
while (ourIdx < this.replacements.length || baseIdx < base.replacements.length) {
103102
// take the edit that starts first
104-
const baseEdit = base.replacements[baseIdx];
105-
const ourEdit = this.replacements[ourIdx];
103+
const baseEdit = base.replacements.at(baseIdx);
104+
const ourEdit = this.replacements.at(ourIdx);
106105

107106
if (!ourEdit) {
108107
// We processed all our edits
109108
break;
110109
} else if (!baseEdit) {
111110
// no more edits from base
112111
const transformedRange = ourEdit.replaceRange.delta(offset);
113-
// Check if the transformed edit would violate the sorted/disjoint invariant
114-
if (transformedRange.start < lastEndEx) {
115-
if (noOverlap) {
116-
return undefined;
117-
}
118-
ourIdx++; // Skip this edit as it conflicts with a previously added edit
119-
continue;
120-
}
121112
newEdits.push(new StringReplacement(transformedRange, ourEdit.newText));
122-
lastEndEx = transformedRange.endExclusive;
123113
ourIdx++;
124-
} else if (ourEdit.replaceRange.intersects(baseEdit.replaceRange) || areConcurrentInserts(ourEdit.replaceRange, baseEdit.replaceRange)) {
114+
} else if (
115+
ourEdit.replaceRange.intersects(baseEdit.replaceRange) ||
116+
areConcurrentInserts(ourEdit.replaceRange, baseEdit.replaceRange) ||
117+
isInsertStrictlyInsideRange(ourEdit.replaceRange, baseEdit.replaceRange) ||
118+
isInsertStrictlyInsideRange(baseEdit.replaceRange, ourEdit.replaceRange)
119+
) {
125120
ourIdx++; // Don't take our edit, as it is conflicting -> skip
126121
if (noOverlap) {
127122
return undefined;
128123
}
129-
} else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) {
130-
// Our edit starts first
124+
} else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start ||
125+
(ourEdit.replaceRange.isEmpty && ourEdit.replaceRange.start === baseEdit.replaceRange.start)) {
126+
// Our edit starts first, or is an insert at the start of base's range
131127
const transformedRange = ourEdit.replaceRange.delta(offset);
132128
// Check if the transformed edit would violate the sorted/disjoint invariant
133-
if (transformedRange.start < lastEndEx) {
134-
if (noOverlap) {
135-
return undefined;
136-
}
137-
ourIdx++; // Skip this edit as it conflicts with a previously added edit
138-
continue;
139-
}
140129
newEdits.push(new StringReplacement(transformedRange, ourEdit.newText));
141-
lastEndEx = transformedRange.endExclusive;
142130
ourIdx++;
143131
} else {
144132
baseIdx++;
@@ -299,6 +287,25 @@ export abstract class BaseStringReplacement<T extends BaseStringReplacement<T> =
299287
* All these replacements are applied at once.
300288
*/
301289
export class StringEdit extends BaseStringEdit<StringReplacement, StringEdit> {
290+
/**
291+
* Parses an edit from its string representation.
292+
* E.g. [[2, 12) -> "fgh", [14, 20) -> "qrst", [22, 22) -> "de\n"]
293+
*/
294+
public static parse(toStringValue: string): StringEdit {
295+
const replacements: StringReplacement[] = [];
296+
const regex = /\[(\d+),\s*(\d+)\)\s*->\s*"([^"]*)"/g;
297+
let match;
298+
299+
while ((match = regex.exec(toStringValue)) !== null) {
300+
const start = parseInt(match[1], 10);
301+
const endEx = parseInt(match[2], 10);
302+
const text = match[3].replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
303+
replacements.push(new StringReplacement(new OffsetRange(start, endEx), text));
304+
}
305+
306+
return new StringEdit(replacements);
307+
}
308+
302309
public static readonly empty = new StringEdit([]);
303310

304311
public static create(replacements: readonly StringReplacement[]): StringEdit {
@@ -596,3 +603,11 @@ export class AnnotatedStringReplacement<T extends IEditData<T>> extends BaseStri
596603
function areConcurrentInserts(r1: OffsetRange, r2: OffsetRange): boolean {
597604
return r1.isEmpty && r2.isEmpty && r1.start === r2.start;
598605
}
606+
607+
/**
608+
* Returns true if `insert` is an empty range (insert) strictly inside `range`.
609+
* For example, insert at position 5 is inside [3, 7) but not inside [5, 7) or [3, 5).
610+
*/
611+
function isInsertStrictlyInsideRange(insert: OffsetRange, range: OffsetRange): boolean {
612+
return insert.isEmpty && range.start < insert.start && insert.start < range.endExclusive;
613+
}

src/vs/editor/common/core/ranges/offsetRange.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ export class OffsetRange implements IOffsetRange {
130130
return Math.max(0, end - start);
131131
}
132132

133+
/**
134+
* `a.intersects(b)` iff there exists a number n so that `a.contains(n)` and `b.contains(n)`.
135+
* Warning: If one range is empty, this method returns always false.
136+
*/
133137
public intersects(other: OffsetRange): boolean {
134138
const start = Math.max(this.start, other.start);
135139
const end = Math.min(this.endExclusive, other.endExclusive);

src/vs/editor/test/common/core/stringEdit.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,4 +276,69 @@ suite('Edit', () => {
276276
str.substring(textStart, textStart + textLen)
277277
);
278278
}
279+
280+
suite('tryRebase invariants', () => {
281+
for (let i = 0; i < 1000; i++) {
282+
test('case' + i, () => {
283+
runTest(i);
284+
});
285+
}
286+
287+
function runTest(seed: number) {
288+
const rng = Random.create(seed);
289+
const s0 = 'abcde\nfghij\nklmno\npqrst\n';
290+
291+
const e1 = getRandomEdit(s0, rng.nextIntRange(1, 4), rng);
292+
const e2 = getRandomEdit(s0, rng.nextIntRange(1, 4), rng);
293+
294+
const e1RebasedOnE2 = e1.tryRebase(e2);
295+
const e2RebasedOnE1 = e2.tryRebase(e1);
296+
297+
// Invariant 1: e1.rebase(e2) != undefined <=> e2.rebase(e1) != undefined
298+
assert.strictEqual(
299+
e1RebasedOnE2 !== undefined,
300+
e2RebasedOnE1 !== undefined,
301+
`Symmetry violated: e1.rebase(e2)=${e1RebasedOnE2 !== undefined}, e2.rebase(e1)=${e2RebasedOnE1 !== undefined}`
302+
);
303+
304+
// Invariant 2: e1.rebase(e2) != undefined => e1.compose(e2.rebase(e1)) = e2.compose(e1.rebase(e2))
305+
if (e1RebasedOnE2 !== undefined && e2RebasedOnE1 !== undefined) {
306+
const path1 = e1.compose(e2RebasedOnE1);
307+
const path2 = e2.compose(e1RebasedOnE2);
308+
309+
// Both paths should produce the same result when applied to s0
310+
const result1 = path1.apply(s0);
311+
const result2 = path2.apply(s0);
312+
assert.strictEqual(result1, result2, `Diamond property violated`);
313+
}
314+
315+
// Invariant 3: empty.rebase(e) = empty
316+
const emptyRebasedOnE1 = StringEdit.empty.tryRebase(e1);
317+
assert.ok(emptyRebasedOnE1 !== undefined);
318+
assert.ok(emptyRebasedOnE1.isEmpty);
319+
320+
// Invariant 4: e.rebase(empty) = e
321+
const e1RebasedOnEmpty = e1.tryRebase(StringEdit.empty);
322+
assert.ok(e1RebasedOnEmpty !== undefined);
323+
assert.ok(e1.equals(e1RebasedOnEmpty), `e.rebase(empty) should equal e`);
324+
325+
// Invariant 5 (TP2): T(T(e3, e1), T(e2, e1)) = T(T(e3, e2), T(e1, e2))
326+
// For 3+ concurrent operations, transformation order shouldn't matter
327+
const e3 = getRandomEdit(s0, rng.nextIntRange(1, 4), rng);
328+
329+
const e2OnE1 = e2.tryRebase(e1);
330+
const e1OnE2 = e1.tryRebase(e2);
331+
const e3OnE1 = e3.tryRebase(e1);
332+
const e3OnE2 = e3.tryRebase(e2);
333+
334+
if (e2OnE1 && e1OnE2 && e3OnE1 && e3OnE2) {
335+
const path1 = e3OnE1.tryRebase(e2OnE1); // T(T(e3, e1), T(e2, e1))
336+
const path2 = e3OnE2.tryRebase(e1OnE2); // T(T(e3, e2), T(e1, e2))
337+
338+
if (path1 && path2) {
339+
assert.ok(path1.equals(path2), `TP2 violated: transformation order matters`);
340+
}
341+
}
342+
}
343+
});
279344
});

0 commit comments

Comments
 (0)