diff --git a/src/vs/editor/common/core/edits/stringEdit.ts b/src/vs/editor/common/core/edits/stringEdit.ts index 9f6c9800f41..83d40b5e8c2 100644 --- a/src/vs/editor/common/core/edits/stringEdit.ts +++ b/src/vs/editor/common/core/edits/stringEdit.ts @@ -97,6 +97,7 @@ export abstract class BaseStringEdit = BaseSt let baseIdx = 0; let ourIdx = 0; let offset = 0; + let lastEndEx = -1; // Track end of last added edit to ensure sorted/disjoint invariant while (ourIdx < this.replacements.length || baseIdx < base.replacements.length) { // take the edit that starts first @@ -108,10 +109,17 @@ export abstract class BaseStringEdit = BaseSt break; } else if (!baseEdit) { // no more edits from base - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else if (ourEdit.replaceRange.intersects(baseEdit.replaceRange) || areConcurrentInserts(ourEdit.replaceRange, baseEdit.replaceRange)) { ourIdx++; // Don't take our edit, as it is conflicting -> skip @@ -120,10 +128,17 @@ export abstract class BaseStringEdit = BaseSt } } else if (ourEdit.replaceRange.start < baseEdit.replaceRange.start) { // Our edit starts first - newEdits.push(new StringReplacement( - ourEdit.replaceRange.delta(offset), - ourEdit.newText - )); + const transformedRange = ourEdit.replaceRange.delta(offset); + // Check if the transformed edit would violate the sorted/disjoint invariant + if (transformedRange.start < lastEndEx) { + if (noOverlap) { + return undefined; + } + ourIdx++; // Skip this edit as it conflicts with a previously added edit + continue; + } + newEdits.push(new StringReplacement(transformedRange, ourEdit.newText)); + lastEndEx = transformedRange.endExclusive; ourIdx++; } else { baseIdx++; diff --git a/src/vs/editor/test/common/core/stringEdit.test.ts b/src/vs/editor/test/common/core/stringEdit.test.ts index c54e132580e..9189dd62be3 100644 --- a/src/vs/editor/test/common/core/stringEdit.test.ts +++ b/src/vs/editor/test/common/core/stringEdit.test.ts @@ -154,6 +154,62 @@ suite('Edit', () => { // This should return undefined because both are inserts at the same position assert.strictEqual(rebasedEdit, undefined); }); + + test('tryRebase should return undefined when rebasing would produce non-disjoint edits (negative offset case)', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [120, 120) -> "B" + // baseEdit: [110, 125) -> "" (delete 15 chars, offset = -15) + // After transformation, ourEdit2 at [105, 105) < ourEdit1 end (110) + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.strictEqual(result, undefined); + }); + + test('tryRebase should succeed when edits remain disjoint after rebasing', () => { + // ourEdit1: [100, 110) -> "A" + // ourEdit2: [200, 210) -> "B" + // baseEdit: [50, 60) -> "" (delete 10 chars, offset = -10) + // After: ourEdit1 at [90, 100), ourEdit2 at [190, 200) - still disjoint + + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(new OffsetRange(200, 210), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(50, 60), ''), + ]); + + const result = ourEdit.tryRebase(baseEdit); + assert.ok(result); + assert.strictEqual(result?.replacements[0].replaceRange.start, 90); + assert.strictEqual(result?.replacements[1].replaceRange.start, 190); + }); + + test('rebaseSkipConflicting should skip edits that would produce non-disjoint results', () => { + const ourEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(100, 110), 'A'), + new StringReplacement(OffsetRange.emptyAt(120), 'B'), + ]); + + const baseEdit = StringEdit.create([ + new StringReplacement(new OffsetRange(110, 125), ''), + ]); + + // Should not throw, and should skip the conflicting edit + const result = ourEdit.rebaseSkipConflicting(baseEdit); + assert.strictEqual(result.replacements.length, 1); + assert.strictEqual(result.replacements[0].replaceRange.start, 100); + }); }); suite('ArrayEdit', () => {