Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 44 additions & 57 deletions extensions/vscode/lib/rangeFormatting.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type * as vscode from 'vscode';
import diff = require('fast-diff');

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

const map = createOffsetMap(oldText, edit.newText);
const newStart = map[overlapStart];
const newEnd = map[overlapEnd];
return {
start: editStart + overlapStart,
end: editStart + overlapEnd,
newText: edit.newText.slice(newStart, newEnd),
};
}
let oldTextIndex = 0;
let newTextIndex = 0;
let newStart!: number;
let newEnd!: number;

function createOffsetMap(oldText: string, newText: string) {
const length = oldText.length;
const map = new Array<number>(length + 1);
let oldIndex = 0;
let newIndex = 0;
map[0] = 0;

for (const [op, text] of diff(oldText, newText)) {
if (op === diff.EQUAL) {
for (let i = 0; i < text.length; i++) {
oldIndex++;
newIndex++;
map[oldIndex] = newIndex;
}
while (true) {
if (oldTextIndex === overlapStart) {
newStart = newTextIndex;
break;
}
const oldCharCode = oldText.charCodeAt(oldTextIndex);
const newCharCode = edit.newText.charCodeAt(newTextIndex);
if (oldCharCode === newCharCode || (!isWhitespaceChar(oldCharCode) && !isWhitespaceChar(newCharCode))) {
oldTextIndex++;
newTextIndex++;
continue;
}
else if (op === diff.DELETE) {
for (let i = 0; i < text.length; i++) {
oldIndex++;
map[oldIndex] = Number.NaN;
}
if (isWhitespaceChar(oldCharCode)) {
oldTextIndex++;
}
else {
newIndex += text.length;
if (isWhitespaceChar(newCharCode)) {
newTextIndex++;
}
}

map[length] = newIndex;

let lastDefinedIndex = 0;
for (let i = 1; i <= length; i++) {
if (map[i] === undefined || Number.isNaN(map[i])) {
oldTextIndex = oldText.length - 1;
newTextIndex = edit.newText.length - 1;
while (true) {
if (oldTextIndex + 1 === overlapEnd) {
newEnd = newTextIndex + 1;
break;
}
const oldCharCode = oldText.charCodeAt(oldTextIndex);
const newCharCode = edit.newText.charCodeAt(newTextIndex);
if (oldCharCode === newCharCode || (!isWhitespaceChar(oldCharCode) && !isWhitespaceChar(newCharCode))) {
oldTextIndex--;
newTextIndex--;
continue;
}
interpolate(map, lastDefinedIndex, i);
lastDefinedIndex = i;
}
if (lastDefinedIndex < length) {
interpolate(map, lastDefinedIndex, length);
if (isWhitespaceChar(oldCharCode)) {
oldTextIndex--;
}
if (isWhitespaceChar(newCharCode)) {
newTextIndex--;
}
}

return map;
return {
start: editStart + overlapStart,
end: editStart + overlapEnd,
newText: edit.newText.slice(newStart, newEnd),
};
}

function interpolate(map: number[], startIndex: number, endIndex: number) {
const startValue = map[startIndex] ?? 0;
const endValue = map[endIndex] ?? startValue;
const gap = endIndex - startIndex;
if (gap <= 1) {
return;
}
const delta = (endValue - startValue) / gap;
for (let i = 1; i < gap; i++) {
const index = startIndex + i;
if (map[index] !== undefined && !Number.isNaN(map[index])) {
continue;
}
map[index] = Math.floor(startValue + delta * i);
}
function isWhitespaceChar(charCode: number) {
return charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13;
}
1 change: 0 additions & 1 deletion extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,6 @@
"@vue/language-core": "3.1.8",
"@vue/language-server": "3.1.8",
"@vue/typescript-plugin": "3.1.8",
"fast-diff": "^1.3.0",
"laplacenoma": "^0.0.3",
"reactive-vscode": "^0.2.9",
"rolldown": "1.0.0-beta.8",
Expand Down
102 changes: 101 additions & 1 deletion extensions/vscode/tests/rangeFormatting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('provideDocumentRangeFormattingEdits', () => {
createTextEdit(
selection.start.character - 1,
selection.end.character,
` <div>
`\n <div>
<div>2</div>
</div>`,
),
Expand Down Expand Up @@ -75,6 +75,106 @@ describe('provideDocumentRangeFormattingEdits', () => {
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01X23456789"`);
});

test('handles deletion where newText is shorter than oldText in selection', () => {
const document = createDocument('ab ');
const selection = createRange(1, 3);
const edits = [createTextEdit(0, 4, 'ab')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"ab "`);
});

test('handles newText completely exhausted before reaching overlapEnd', () => {
const document = createDocument('abcdef');
const selection = createRange(1, 5); // select "bcde"
const edits = [createTextEdit(0, 6, 'ab')]; // replace all with just "ab"
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"af"`);
});

test('handles insertion where newText is longer than oldText', () => {
const document = createDocument('abc');
const selection = createRange(1, 2); // select "b"
const edits = [createTextEdit(0, 3, 'aXYZc')]; // insert XYZ in the middle
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"aXYZc"`);
});

test('handles whitespace-only differences', () => {
const document = createDocument('a b c');
const selection = createRange(1, 6); // select " b "
const edits = [createTextEdit(0, 7, 'a b c')]; // normalize spaces
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"a b c"`);
});

test('handles edit range completely before selection', () => {
const document = createDocument('0123456789');
const selection = createRange(5, 8);
const edits = [createTextEdit(0, 3, 'XYZ')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
});

test('handles edit range completely after selection', () => {
const document = createDocument('0123456789');
const selection = createRange(2, 5);
const edits = [createTextEdit(7, 10, 'XYZ')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
});

test('handles empty selection', () => {
const document = createDocument('0123456789');
const selection = createRange(5, 5); // empty selection at position 5
const edits = [createTextEdit(3, 7, 'ABCD')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
});

test('handles empty edit (pure insertion)', () => {
const document = createDocument('0123456789');
const selection = createRange(3, 7);
const edits = [createTextEdit(5, 5, 'XXX')]; // insert at position 5
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01234XXX56789"`);
});

test('handles multiple edits within selection', () => {
const document = createDocument('0123456789');
const selection = createRange(2, 8);
const edits = [
createTextEdit(3, 4, 'A'),
createTextEdit(6, 7, 'B'),
];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"012A45B789"`);
});

test('handles edit with mixed whitespace and non-whitespace changes', () => {
const document = createDocument('a\n\tb\n\tc');
const selection = createRange(1, 5); // select "\n\tb\n"
const edits = [createTextEdit(0, 7, 'a b c')]; // normalize all whitespace
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"a b c"`);
});

test('handles non-ASCII characters', () => {
const document = createDocument('你好世界');
const selection = createRange(1, 3); // select "好世"
const edits = [createTextEdit(0, 4, '你好朋友')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"你好朋界"`);
});

test('handles overlapStart equals overlapEnd', () => {
// When edit and selection don't actually overlap in content
const document = createDocument('0123456789');
const selection = createRange(5, 5);
const edits = [createTextEdit(3, 7, 'WXYZ')];
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
});
});

// self implementation of vscode test utils
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.