diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index 8e82270904..41400fd771 100644 --- a/extensions/vscode/index.ts +++ b/extensions/vscode/index.ts @@ -18,6 +18,7 @@ import * as vscode from 'vscode'; import { config } from './lib/config'; import * as focusMode from './lib/focusMode'; import * as interpolationDecorators from './lib/interpolationDecorators'; +import { restrictFormattingEditsToRange } from './lib/rangeFormatting'; import * as reactivityVisualization from './lib/reactivityVisualization'; import * as welcome from './lib/welcome'; @@ -176,6 +177,25 @@ function launch(serverPath: string, tsdk: string) { } return await (middleware.resolveCodeAction?.(item, token, next) ?? next(item, token)); }, + async provideDocumentRangeFormattingEdits(document, range, options, token, next) { + const edits = await next(document, range, options, token); + if (edits) { + return restrictFormattingEditsToRange( + document, + range, + edits, + (start, end, newText) => + new vscode.TextEdit( + new vscode.Range( + document.positionAt(start), + document.positionAt(end), + ), + newText, + ), + ); + } + return edits; + }, }, documentSelector: config.server.includeLanguages, markdown: { diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts new file mode 100644 index 0000000000..d5e87ed143 --- /dev/null +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -0,0 +1,132 @@ +import type * as vscode from 'vscode'; +import diff = require('fast-diff'); + +/** for test unit */ +export type FormatableTextDocument = Pick; + +/** for test unit */ +export type TextEditReplace = (start: number, end: number, newText: string) => vscode.TextEdit; + +export function restrictFormattingEditsToRange( + document: FormatableTextDocument, + range: vscode.Range, + edits: vscode.TextEdit[], + replace: TextEditReplace, +) { + const selectionStart = document.offsetAt(range.start); + const selectionEnd = document.offsetAt(range.end); + const result: vscode.TextEdit[] = []; + + for (const edit of edits) { + const editStart = document.offsetAt(edit.range.start); + const editEnd = document.offsetAt(edit.range.end); + + if (editStart >= selectionStart && editEnd <= selectionEnd) { + result.push(edit); + continue; + } + + if (editEnd < selectionStart || editStart > selectionEnd) { + continue; + } + + const trimmedEdit = getTrimmedNewText(document, selectionStart, selectionEnd, edit, editStart, editEnd); + if (trimmedEdit) { + result.push(replace(trimmedEdit.start, trimmedEdit.end, trimmedEdit.newText)); + } + } + + return result; +} + +function getTrimmedNewText( + document: FormatableTextDocument, + selectionStart: number, + selectionEnd: number, + edit: vscode.TextEdit, + editStart: number, + editEnd: number, +) { + if (editStart === editEnd) { + return { + start: editStart, + end: editEnd, + newText: edit.newText, + }; + } + const oldText = document.getText(edit.range); + const overlapStart = Math.max(editStart, selectionStart) - editStart; + const overlapEnd = Math.min(editEnd, selectionEnd) - editStart; + if (overlapStart === overlapEnd) { + 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), + }; +} + +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; + } + } + else if (op === diff.DELETE) { + for (let i = 0; i < text.length; i++) { + oldIndex++; + map[oldIndex] = Number.NaN; + } + } + else { + newIndex += text.length; + } + } + + map[length] = newIndex; + + let lastDefinedIndex = 0; + for (let i = 1; i <= length; i++) { + if (map[i] === undefined || Number.isNaN(map[i])) { + continue; + } + interpolate(map, lastDefinedIndex, i); + lastDefinedIndex = i; + } + if (lastDefinedIndex < length) { + interpolate(map, lastDefinedIndex, length); + } + + return map; +} + +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); + } +} diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index e187ca407f..eaea13b507 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -496,5 +496,8 @@ "semver": "^7.5.4", "vscode-ext-gen": "^1.0.2", "vscode-tmlanguage-snapshot": "^1.0.1" + }, + "dependencies": { + "fast-diff": "^1.3.0" } } diff --git a/extensions/vscode/tests/rangeFormatting.spec.ts b/extensions/vscode/tests/rangeFormatting.spec.ts new file mode 100644 index 0000000000..3e9591b81e --- /dev/null +++ b/extensions/vscode/tests/rangeFormatting.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import type * as vscode from 'vscode'; +import { type FormatableTextDocument, restrictFormattingEditsToRange } from '../lib/rangeFormatting'; + +describe('provideDocumentRangeFormattingEdits', () => { + test('only replace selected range', () => { + const document = createDocument('012345'); + const selection = createRange(1, 5); + const edits = [createTextEdit(0, 5, '_BCDE')]; + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0BCDE5"`); + }); + + test('keeps indent when edits start on previous line', () => { + const content = ` +`; + const document = createDocument(content); + const selectionText = `
+
2
+
`; + const selectionStart = content.indexOf(selectionText); + const selection = createRange(selectionStart, selectionStart + selectionText.length); + const edits = [ + createTextEdit( + selection.start.character - 1, + selection.end.character, + `
+
2
+
`, + ), + ]; + + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(` + " + " + `); + }); + + test('drops edits if the selection text unchanged after restrict', () => { + const document = createDocument('0123456789'); + const selection = createRange(2, 5); + const edits = [createTextEdit(0, 10, '0123456789')]; + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`); + }); + + test('returns next edits unchanged when they fully match the selection', () => { + const document = createDocument('0123456789'); + const selection = createRange(2, 7); + const edits = [createTextEdit(3, 5, 'aa')]; + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"012aa56789"`); + }); + + test('keeps boundary inserts when other edits are out of range', () => { + const document = createDocument('0123456789'); + const selection = createRange(2, 5); + const edits = [ + createTextEdit(5, 6, 'Z'), + createTextEdit(2, 2, 'X'), + ]; + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01X23456789"`); + }); +}); + +// self implementation of vscode test utils + +function applyEdits( + document: FormatableTextDocument, + edits: vscode.TextEdit[], +) { + let content = document.getText(); + const sortedEdits = edits.slice().sort((a, b) => { + const aStart = document.offsetAt(a.range.start); + const bStart = document.offsetAt(b.range.start); + return bStart - aStart; + }); + for (const edit of sortedEdits) { + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + content = content.slice(0, start) + edit.newText + content.slice(end); + } + return content; +} + +function createDocument(content: string): FormatableTextDocument { + return { + offsetAt: ({ character }) => character, + positionAt: (offset: number) => ({ line: 0, character: offset }) as unknown as vscode.Position, + getText: range => range ? content.slice(range.start.character, range.end.character) : content, + }; +} + +function createRange(start: number, end: number): vscode.Range { + return { + start: { line: 0, character: start }, + end: { line: 0, character: end }, + } as unknown as vscode.Range; +} + +function createTextEdit(start: number, end: number, newText: string) { + return { + range: createRange(start, end), + newText, + } as unknown as vscode.TextEdit; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02ed9c711d..4a4d831441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,10 @@ importers: version: 3.1.3(@types/node@22.15.2) extensions/vscode: + dependencies: + fast-diff: + specifier: ^1.3.0 + version: 1.3.0 devDependencies: '@types/node': specifier: ^22.10.4 @@ -2079,6 +2083,9 @@ 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'} @@ -5805,6 +5812,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5