diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index abec864b13..ddfc0f97a0 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'; @@ -170,6 +171,11 @@ 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 (middleware.provideDocumentRangeFormattingEdits?.(document, range, options, token, next) + ?? next(document, range, options, token)); + return restrictFormattingEditsToRange(document, range, edits, vscode.TextEdit.replace); + }, }, 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..0498ec0504 --- /dev/null +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -0,0 +1,142 @@ +import type * as vscode from 'vscode'; +import diff = require('fast-diff'); + +/** for test unit */ +export type FormatableTextDocument = Pick; + +/** for test unit */ +export type TextEditReplace = (range: vscode.Range, newText: string) => vscode.TextEdit; + +export function restrictFormattingEditsToRange( + document: FormatableTextDocument, + range: vscode.Range, + edits: vscode.TextEdit[] | null | undefined, + replace: TextEditReplace, +) { + if (!edits?.length) { + return edits; + } + + if (edits.every(edit => range.contains(edit.range))) { + return edits; + } + + const selectionStart = document.offsetAt(range.start); + const selectionEnd = document.offsetAt(range.end); + let selectionText = document.getText(range); + + const sortedEdits = [...edits].sort((a, b) => document.offsetAt(b.range.start) - document.offsetAt(a.range.start)); + + for (const edit of sortedEdits) { + const editStart = document.offsetAt(edit.range.start); + const editEnd = document.offsetAt(edit.range.end); + + if (editEnd <= selectionStart || editStart >= selectionEnd) { + continue; + } + + const relativeStart = Math.max(editStart, selectionStart) - selectionStart; + const relativeEnd = Math.min(editEnd, selectionEnd) - selectionStart; + const trimmedText = getTrimmedNewText(document, selectionStart, selectionEnd, edit, editStart, editEnd); + + selectionText = selectionText.slice(0, relativeStart) + trimmedText + selectionText.slice(relativeEnd); + } + + if (selectionText === document.getText(range)) { + return []; + } + + return [replace(range, selectionText)]; +} + +function getTrimmedNewText( + document: FormatableTextDocument, + selectionStart: number, + selectionEnd: number, + edit: vscode.TextEdit, + editStart: number, + editEnd: number, +) { + if (editStart === editEnd) { + if (editStart < selectionStart || editStart > selectionEnd) { + return ''; + } + return edit.newText; + } + + const oldText = document.getText(edit.range); + if (!oldText) { + return ''; + } + + 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 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 baa64170d7..6dabf2c2cd 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -474,5 +474,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..78a405adeb --- /dev/null +++ b/extensions/vscode/tests/rangeFormatting.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest'; +import type * as vscode from 'vscode'; +import { + type FormatableTextDocument, + restrictFormattingEditsToRange, + type TextEditReplace, +} from '../lib/rangeFormatting'; + +const textEditReplace: TextEditReplace = (range, newText) => ({ range, newText }); + +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, textEditReplace); + expect(result).toEqual([textEditReplace(selection, 'BCDE')]); + }); + + 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, textEditReplace); + + expect(result).toEqual([textEditReplace( + selection, + `
+
2
+
`, + )]); + }); + + 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, textEditReplace); + expect(result).toEqual([]); + }); + + 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, textEditReplace); + expect(result).toBe(edits); + }); +}); + +// self implementation of vscode test utils + +function createDocument(content: string): FormatableTextDocument { + return { + offsetAt: ({ character }) => character, + getText: range => range ? content.slice(range.start.character, range.end.character) : content, + }; +} + +function createRange(start: number, end: number): vscode.Range { + const position = (character: number) => ({ line: 0, character }); + return { + start: position(start), + end: position(end), + contains(value: vscode.Range | vscode.Position) { + if ('start' in value && 'end' in value) { + return start <= value.start.character && end >= value.end.character; + } + return start <= value.character && end >= value.character; + }, + isEqual(other: vscode.Range) { + return other.start.character === start && other.end.character === end; + }, + } as unknown as vscode.Range; +} + +function createTextEdit(start: number, end: number, newText: string) { + return textEditReplace(createRange(start, end), newText); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89434909cf..dc94c4d63b 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