diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index d5e87ed143..eb0e9a20fa 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -1,5 +1,4 @@ import type * as vscode from 'vscode'; -import diff = require('fast-diff'); /** for test unit */ export type FormatableTextDocument = Pick; @@ -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(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; } diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index ff9ef6929e..f0da2d5e50 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -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", diff --git a/extensions/vscode/tests/rangeFormatting.spec.ts b/extensions/vscode/tests/rangeFormatting.spec.ts index 3e9591b81e..f45921bc68 100644 --- a/extensions/vscode/tests/rangeFormatting.spec.ts +++ b/extensions/vscode/tests/rangeFormatting.spec.ts @@ -30,7 +30,7 @@ describe('provideDocumentRangeFormattingEdits', () => { createTextEdit( selection.start.character - 1, selection.end.character, - `
+ `\n
2
`, ), @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41ecb1b182..1a831d8f59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,9 +68,6 @@ importers: '@vue/typescript-plugin': specifier: 3.1.8 version: link:../../packages/typescript-plugin - fast-diff: - specifier: ^1.3.0 - version: 1.3.0 laplacenoma: specifier: ^0.0.3 version: 0.0.3 @@ -2082,9 +2079,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5811,8 +5805,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5