Skip to content

Commit 704b30a

Browse files
perf(vscode): replace fast-diff with custom character-by-character alignment algorithm (#5849)
1 parent d1e7568 commit 704b30a

File tree

4 files changed

+145
-67
lines changed

4 files changed

+145
-67
lines changed
Lines changed: 44 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type * as vscode from 'vscode';
2-
import diff = require('fast-diff');
32

43
/** for test unit */
54
export type FormatableTextDocument = Pick<vscode.TextDocument, 'getText' | 'offsetAt' | 'positionAt'>;
@@ -61,72 +60,60 @@ function getTrimmedNewText(
6160
return;
6261
}
6362

64-
const map = createOffsetMap(oldText, edit.newText);
65-
const newStart = map[overlapStart];
66-
const newEnd = map[overlapEnd];
67-
return {
68-
start: editStart + overlapStart,
69-
end: editStart + overlapEnd,
70-
newText: edit.newText.slice(newStart, newEnd),
71-
};
72-
}
63+
let oldTextIndex = 0;
64+
let newTextIndex = 0;
65+
let newStart!: number;
66+
let newEnd!: number;
7367

74-
function createOffsetMap(oldText: string, newText: string) {
75-
const length = oldText.length;
76-
const map = new Array<number>(length + 1);
77-
let oldIndex = 0;
78-
let newIndex = 0;
79-
map[0] = 0;
80-
81-
for (const [op, text] of diff(oldText, newText)) {
82-
if (op === diff.EQUAL) {
83-
for (let i = 0; i < text.length; i++) {
84-
oldIndex++;
85-
newIndex++;
86-
map[oldIndex] = newIndex;
87-
}
68+
while (true) {
69+
if (oldTextIndex === overlapStart) {
70+
newStart = newTextIndex;
71+
break;
72+
}
73+
const oldCharCode = oldText.charCodeAt(oldTextIndex);
74+
const newCharCode = edit.newText.charCodeAt(newTextIndex);
75+
if (oldCharCode === newCharCode || (!isWhitespaceChar(oldCharCode) && !isWhitespaceChar(newCharCode))) {
76+
oldTextIndex++;
77+
newTextIndex++;
78+
continue;
8879
}
89-
else if (op === diff.DELETE) {
90-
for (let i = 0; i < text.length; i++) {
91-
oldIndex++;
92-
map[oldIndex] = Number.NaN;
93-
}
80+
if (isWhitespaceChar(oldCharCode)) {
81+
oldTextIndex++;
9482
}
95-
else {
96-
newIndex += text.length;
83+
if (isWhitespaceChar(newCharCode)) {
84+
newTextIndex++;
9785
}
9886
}
9987

100-
map[length] = newIndex;
101-
102-
let lastDefinedIndex = 0;
103-
for (let i = 1; i <= length; i++) {
104-
if (map[i] === undefined || Number.isNaN(map[i])) {
88+
oldTextIndex = oldText.length - 1;
89+
newTextIndex = edit.newText.length - 1;
90+
while (true) {
91+
if (oldTextIndex + 1 === overlapEnd) {
92+
newEnd = newTextIndex + 1;
93+
break;
94+
}
95+
const oldCharCode = oldText.charCodeAt(oldTextIndex);
96+
const newCharCode = edit.newText.charCodeAt(newTextIndex);
97+
if (oldCharCode === newCharCode || (!isWhitespaceChar(oldCharCode) && !isWhitespaceChar(newCharCode))) {
98+
oldTextIndex--;
99+
newTextIndex--;
105100
continue;
106101
}
107-
interpolate(map, lastDefinedIndex, i);
108-
lastDefinedIndex = i;
109-
}
110-
if (lastDefinedIndex < length) {
111-
interpolate(map, lastDefinedIndex, length);
102+
if (isWhitespaceChar(oldCharCode)) {
103+
oldTextIndex--;
104+
}
105+
if (isWhitespaceChar(newCharCode)) {
106+
newTextIndex--;
107+
}
112108
}
113109

114-
return map;
110+
return {
111+
start: editStart + overlapStart,
112+
end: editStart + overlapEnd,
113+
newText: edit.newText.slice(newStart, newEnd),
114+
};
115115
}
116116

117-
function interpolate(map: number[], startIndex: number, endIndex: number) {
118-
const startValue = map[startIndex] ?? 0;
119-
const endValue = map[endIndex] ?? startValue;
120-
const gap = endIndex - startIndex;
121-
if (gap <= 1) {
122-
return;
123-
}
124-
const delta = (endValue - startValue) / gap;
125-
for (let i = 1; i < gap; i++) {
126-
const index = startIndex + i;
127-
if (map[index] !== undefined && !Number.isNaN(map[index])) {
128-
continue;
129-
}
130-
map[index] = Math.floor(startValue + delta * i);
131-
}
117+
function isWhitespaceChar(charCode: number) {
118+
return charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13;
132119
}

extensions/vscode/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,6 @@
490490
"@vue/language-core": "3.1.8",
491491
"@vue/language-server": "3.1.8",
492492
"@vue/typescript-plugin": "3.1.8",
493-
"fast-diff": "^1.3.0",
494493
"laplacenoma": "^0.0.3",
495494
"reactive-vscode": "^0.2.9",
496495
"rolldown": "1.0.0-beta.8",

extensions/vscode/tests/rangeFormatting.spec.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('provideDocumentRangeFormattingEdits', () => {
3030
createTextEdit(
3131
selection.start.character - 1,
3232
selection.end.character,
33-
` <div>
33+
`\n <div>
3434
<div>2</div>
3535
</div>`,
3636
),
@@ -75,6 +75,106 @@ describe('provideDocumentRangeFormattingEdits', () => {
7575
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
7676
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01X23456789"`);
7777
});
78+
79+
test('handles deletion where newText is shorter than oldText in selection', () => {
80+
const document = createDocument('ab ');
81+
const selection = createRange(1, 3);
82+
const edits = [createTextEdit(0, 4, 'ab')];
83+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
84+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"ab "`);
85+
});
86+
87+
test('handles newText completely exhausted before reaching overlapEnd', () => {
88+
const document = createDocument('abcdef');
89+
const selection = createRange(1, 5); // select "bcde"
90+
const edits = [createTextEdit(0, 6, 'ab')]; // replace all with just "ab"
91+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
92+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"af"`);
93+
});
94+
95+
test('handles insertion where newText is longer than oldText', () => {
96+
const document = createDocument('abc');
97+
const selection = createRange(1, 2); // select "b"
98+
const edits = [createTextEdit(0, 3, 'aXYZc')]; // insert XYZ in the middle
99+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
100+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"aXYZc"`);
101+
});
102+
103+
test('handles whitespace-only differences', () => {
104+
const document = createDocument('a b c');
105+
const selection = createRange(1, 6); // select " b "
106+
const edits = [createTextEdit(0, 7, 'a b c')]; // normalize spaces
107+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
108+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"a b c"`);
109+
});
110+
111+
test('handles edit range completely before selection', () => {
112+
const document = createDocument('0123456789');
113+
const selection = createRange(5, 8);
114+
const edits = [createTextEdit(0, 3, 'XYZ')];
115+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
116+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
117+
});
118+
119+
test('handles edit range completely after selection', () => {
120+
const document = createDocument('0123456789');
121+
const selection = createRange(2, 5);
122+
const edits = [createTextEdit(7, 10, 'XYZ')];
123+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
124+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
125+
});
126+
127+
test('handles empty selection', () => {
128+
const document = createDocument('0123456789');
129+
const selection = createRange(5, 5); // empty selection at position 5
130+
const edits = [createTextEdit(3, 7, 'ABCD')];
131+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
132+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
133+
});
134+
135+
test('handles empty edit (pure insertion)', () => {
136+
const document = createDocument('0123456789');
137+
const selection = createRange(3, 7);
138+
const edits = [createTextEdit(5, 5, 'XXX')]; // insert at position 5
139+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
140+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01234XXX56789"`);
141+
});
142+
143+
test('handles multiple edits within selection', () => {
144+
const document = createDocument('0123456789');
145+
const selection = createRange(2, 8);
146+
const edits = [
147+
createTextEdit(3, 4, 'A'),
148+
createTextEdit(6, 7, 'B'),
149+
];
150+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
151+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"012A45B789"`);
152+
});
153+
154+
test('handles edit with mixed whitespace and non-whitespace changes', () => {
155+
const document = createDocument('a\n\tb\n\tc');
156+
const selection = createRange(1, 5); // select "\n\tb\n"
157+
const edits = [createTextEdit(0, 7, 'a b c')]; // normalize all whitespace
158+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
159+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"a b c"`);
160+
});
161+
162+
test('handles non-ASCII characters', () => {
163+
const document = createDocument('你好世界');
164+
const selection = createRange(1, 3); // select "好世"
165+
const edits = [createTextEdit(0, 4, '你好朋友')];
166+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
167+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"你好朋界"`);
168+
});
169+
170+
test('handles overlapStart equals overlapEnd', () => {
171+
// When edit and selection don't actually overlap in content
172+
const document = createDocument('0123456789');
173+
const selection = createRange(5, 5);
174+
const edits = [createTextEdit(3, 7, 'WXYZ')];
175+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
176+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
177+
});
78178
});
79179

80180
// self implementation of vscode test utils

pnpm-lock.yaml

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)