From c356c97c10343c4a609859224980b682e291f5c5 Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 17 Nov 2025 21:12:55 +0800 Subject: [PATCH 1/7] feat(vscode): supports format with selected range --- extensions/vscode/index.ts | 6 + extensions/vscode/lib/rangeFormatting.ts | 135 +++++++++++++++++++++++ extensions/vscode/package.json | 3 + pnpm-lock.yaml | 9 ++ 4 files changed, 153 insertions(+) create mode 100644 extensions/vscode/lib/rangeFormatting.ts diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index abec864b13..8aeddeca2c 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); + }, }, 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..c936cc768a --- /dev/null +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -0,0 +1,135 @@ +import * as vscode from 'vscode'; +import diff = require('fast-diff'); + +export function restrictFormattingEditsToRange( + document: vscode.TextDocument, + range: vscode.Range, + edits: vscode.TextEdit[] | null | undefined, +) { + 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 [vscode.TextEdit.replace(range, selectionText)]; +} + +function getTrimmedNewText( + document: vscode.TextDocument, + 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/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 From ecaa4ae689a695fe6e6445bf5b4032ded353cf19 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 18 Nov 2025 04:02:49 +0800 Subject: [PATCH 2/7] test: add test unit --- extensions/vscode/index.ts | 2 +- extensions/vscode/lib/rangeFormatting.ts | 15 ++- .../vscode/tests/rangeFormatting.spec.ts | 100 ++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 extensions/vscode/tests/rangeFormatting.spec.ts diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index 8aeddeca2c..ddfc0f97a0 100644 --- a/extensions/vscode/index.ts +++ b/extensions/vscode/index.ts @@ -174,7 +174,7 @@ function launch(serverPath: string, tsdk: string) { 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); + return restrictFormattingEditsToRange(document, range, edits, vscode.TextEdit.replace); }, }, documentSelector: config.server.includeLanguages, diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index c936cc768a..0498ec0504 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -1,10 +1,17 @@ -import * as vscode from 'vscode'; +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: vscode.TextDocument, + document: FormatableTextDocument, range: vscode.Range, edits: vscode.TextEdit[] | null | undefined, + replace: TextEditReplace, ) { if (!edits?.length) { return edits; @@ -39,11 +46,11 @@ export function restrictFormattingEditsToRange( return []; } - return [vscode.TextEdit.replace(range, selectionText)]; + return [replace(range, selectionText)]; } function getTrimmedNewText( - document: vscode.TextDocument, + document: FormatableTextDocument, selectionStart: number, selectionEnd: number, edit: vscode.TextEdit, 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); +} From 26294d00e94907039f5cd6ab5eda5465fa79a147 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 9 Dec 2025 05:45:14 +0800 Subject: [PATCH 3/7] Update extensions/vscode/lib/rangeFormatting.ts Co-authored-by: Johnson Chu --- extensions/vscode/lib/rangeFormatting.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index 0498ec0504..6880d6dd76 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -58,12 +58,8 @@ function getTrimmedNewText( editEnd: number, ) { if (editStart === editEnd) { - if (editStart < selectionStart || editStart > selectionEnd) { - return ''; - } return edit.newText; } - const oldText = document.getText(edit.range); if (!oldText) { return ''; From 5c0d2783233c75c99180801d7cf9c9c863892dd9 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 9 Dec 2025 05:45:21 +0800 Subject: [PATCH 4/7] Update extensions/vscode/lib/rangeFormatting.ts Co-authored-by: Johnson Chu --- extensions/vscode/lib/rangeFormatting.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index 6880d6dd76..a46413feb3 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -61,10 +61,6 @@ function getTrimmedNewText( 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) { From 303bb008a3525d04d90128eb1181565155b4d661 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 9 Dec 2025 06:06:53 +0800 Subject: [PATCH 5/7] Update index.ts --- extensions/vscode/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index 06ecdb02a0..5378f2a24b 100644 --- a/extensions/vscode/index.ts +++ b/extensions/vscode/index.ts @@ -178,8 +178,7 @@ 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)); + const edits = await next(document, range, options, token); return restrictFormattingEditsToRange(document, range, edits, vscode.TextEdit.replace); }, }, From c1b5c9c8b8979e145e2ddf062d2d288114b07a21 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 9 Dec 2025 06:13:33 +0800 Subject: [PATCH 6/7] fix: keeps boundary inserts when other edits are out of range --- extensions/vscode/lib/rangeFormatting.ts | 2 +- extensions/vscode/tests/rangeFormatting.spec.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index a46413feb3..58e3fc2800 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -31,7 +31,7 @@ export function restrictFormattingEditsToRange( const editStart = document.offsetAt(edit.range.start); const editEnd = document.offsetAt(edit.range.end); - if (editEnd <= selectionStart || editStart >= selectionEnd) { + if (editEnd < selectionStart || editStart > selectionEnd) { continue; } diff --git a/extensions/vscode/tests/rangeFormatting.spec.ts b/extensions/vscode/tests/rangeFormatting.spec.ts index 78a405adeb..c7839dc571 100644 --- a/extensions/vscode/tests/rangeFormatting.spec.ts +++ b/extensions/vscode/tests/rangeFormatting.spec.ts @@ -67,6 +67,17 @@ describe('provideDocumentRangeFormattingEdits', () => { const result = restrictFormattingEditsToRange(document, selection, edits, textEditReplace); expect(result).toBe(edits); }); + + 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, textEditReplace); + expect(result).toEqual([textEditReplace(selection, 'X234')]); + }); }); // self implementation of vscode test utils From aed27ebdcf5055a8918282e05e4ab06ae9f366fb Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 9 Dec 2025 07:17:16 +0800 Subject: [PATCH 7/7] perf: do not combine edits --- extensions/vscode/index.ts | 17 +++- extensions/vscode/lib/rangeFormatting.ts | 54 ++++++------- .../vscode/tests/rangeFormatting.spec.ts | 81 ++++++++++--------- 3 files changed, 87 insertions(+), 65 deletions(-) diff --git a/extensions/vscode/index.ts b/extensions/vscode/index.ts index 5378f2a24b..41400fd771 100644 --- a/extensions/vscode/index.ts +++ b/extensions/vscode/index.ts @@ -179,7 +179,22 @@ function launch(serverPath: string, tsdk: string) { }, async provideDocumentRangeFormattingEdits(document, range, options, token, next) { const edits = await next(document, range, options, token); - return restrictFormattingEditsToRange(document, range, edits, vscode.TextEdit.replace); + 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, diff --git a/extensions/vscode/lib/rangeFormatting.ts b/extensions/vscode/lib/rangeFormatting.ts index 58e3fc2800..d5e87ed143 100644 --- a/extensions/vscode/lib/rangeFormatting.ts +++ b/extensions/vscode/lib/rangeFormatting.ts @@ -2,51 +2,41 @@ import type * as vscode from 'vscode'; import diff = require('fast-diff'); /** for test unit */ -export type FormatableTextDocument = Pick; +export type FormatableTextDocument = Pick; /** for test unit */ -export type TextEditReplace = (range: vscode.Range, newText: string) => vscode.TextEdit; +export type TextEditReplace = (start: number, end: number, newText: string) => vscode.TextEdit; export function restrictFormattingEditsToRange( document: FormatableTextDocument, range: vscode.Range, - edits: vscode.TextEdit[] | null | undefined, + edits: vscode.TextEdit[], 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 result: vscode.TextEdit[] = []; - const sortedEdits = [...edits].sort((a, b) => document.offsetAt(b.range.start) - document.offsetAt(a.range.start)); - - for (const edit of sortedEdits) { + for (const edit of edits) { const editStart = document.offsetAt(edit.range.start); const editEnd = document.offsetAt(edit.range.end); - if (editEnd < selectionStart || editStart > selectionEnd) { + if (editStart >= selectionStart && editEnd <= selectionEnd) { + result.push(edit); 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 (editEnd < selectionStart || editStart > selectionEnd) { + continue; + } - if (selectionText === document.getText(range)) { - return []; + const trimmedEdit = getTrimmedNewText(document, selectionStart, selectionEnd, edit, editStart, editEnd); + if (trimmedEdit) { + result.push(replace(trimmedEdit.start, trimmedEdit.end, trimmedEdit.newText)); + } } - return [replace(range, selectionText)]; + return result; } function getTrimmedNewText( @@ -58,19 +48,27 @@ function getTrimmedNewText( editEnd: number, ) { if (editStart === editEnd) { - return edit.newText; + 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 ''; + return; } const map = createOffsetMap(oldText, edit.newText); const newStart = map[overlapStart]; const newEnd = map[overlapEnd]; - return edit.newText.slice(newStart, newEnd); + return { + start: editStart + overlapStart, + end: editStart + overlapEnd, + newText: edit.newText.slice(newStart, newEnd), + }; } function createOffsetMap(oldText: string, newText: string) { diff --git a/extensions/vscode/tests/rangeFormatting.spec.ts b/extensions/vscode/tests/rangeFormatting.spec.ts index c7839dc571..3e9591b81e 100644 --- a/extensions/vscode/tests/rangeFormatting.spec.ts +++ b/extensions/vscode/tests/rangeFormatting.spec.ts @@ -1,20 +1,14 @@ 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 }); +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, textEditReplace); - expect(result).toEqual([textEditReplace(selection, 'BCDE')]); + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0BCDE5"`); }); test('keeps indent when edits start on previous line', () => { @@ -42,30 +36,33 @@ describe('provideDocumentRangeFormattingEdits', () => { ), ]; - const result = restrictFormattingEditsToRange(document, selection, edits, textEditReplace); - - expect(result).toEqual([textEditReplace( - selection, - `
-
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, textEditReplace); - expect(result).toEqual([]); + 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, textEditReplace); - expect(result).toBe(edits); + const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit); + expect(applyEdits(document, result)).toMatchInlineSnapshot(`"012aa56789"`); }); test('keeps boundary inserts when other edits are out of range', () => { @@ -75,37 +72,49 @@ describe('provideDocumentRangeFormattingEdits', () => { createTextEdit(5, 6, 'Z'), createTextEdit(2, 2, 'X'), ]; - const result = restrictFormattingEditsToRange(document, selection, edits, textEditReplace); - expect(result).toEqual([textEditReplace(selection, 'X234')]); + 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 { - 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; - }, + start: { line: 0, character: start }, + end: { line: 0, character: end }, } as unknown as vscode.Range; } function createTextEdit(start: number, end: number, newText: string) { - return textEditReplace(createRange(start, end), newText); + return { + range: createRange(start, end), + newText, + } as unknown as vscode.TextEdit; }