Skip to content

Commit f1a6b52

Browse files
authored
feat(vscode): supports format with selected range (#5761)
1 parent 3a84ff6 commit f1a6b52

File tree

5 files changed

+284
-0
lines changed

5 files changed

+284
-0
lines changed

extensions/vscode/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as vscode from 'vscode';
1818
import { config } from './lib/config';
1919
import * as focusMode from './lib/focusMode';
2020
import * as interpolationDecorators from './lib/interpolationDecorators';
21+
import { restrictFormattingEditsToRange } from './lib/rangeFormatting';
2122
import * as reactivityVisualization from './lib/reactivityVisualization';
2223
import * as welcome from './lib/welcome';
2324

@@ -176,6 +177,25 @@ function launch(serverPath: string, tsdk: string) {
176177
}
177178
return await (middleware.resolveCodeAction?.(item, token, next) ?? next(item, token));
178179
},
180+
async provideDocumentRangeFormattingEdits(document, range, options, token, next) {
181+
const edits = await next(document, range, options, token);
182+
if (edits) {
183+
return restrictFormattingEditsToRange(
184+
document,
185+
range,
186+
edits,
187+
(start, end, newText) =>
188+
new vscode.TextEdit(
189+
new vscode.Range(
190+
document.positionAt(start),
191+
document.positionAt(end),
192+
),
193+
newText,
194+
),
195+
);
196+
}
197+
return edits;
198+
},
179199
},
180200
documentSelector: config.server.includeLanguages,
181201
markdown: {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type * as vscode from 'vscode';
2+
import diff = require('fast-diff');
3+
4+
/** for test unit */
5+
export type FormatableTextDocument = Pick<vscode.TextDocument, 'getText' | 'offsetAt' | 'positionAt'>;
6+
7+
/** for test unit */
8+
export type TextEditReplace = (start: number, end: number, newText: string) => vscode.TextEdit;
9+
10+
export function restrictFormattingEditsToRange(
11+
document: FormatableTextDocument,
12+
range: vscode.Range,
13+
edits: vscode.TextEdit[],
14+
replace: TextEditReplace,
15+
) {
16+
const selectionStart = document.offsetAt(range.start);
17+
const selectionEnd = document.offsetAt(range.end);
18+
const result: vscode.TextEdit[] = [];
19+
20+
for (const edit of edits) {
21+
const editStart = document.offsetAt(edit.range.start);
22+
const editEnd = document.offsetAt(edit.range.end);
23+
24+
if (editStart >= selectionStart && editEnd <= selectionEnd) {
25+
result.push(edit);
26+
continue;
27+
}
28+
29+
if (editEnd < selectionStart || editStart > selectionEnd) {
30+
continue;
31+
}
32+
33+
const trimmedEdit = getTrimmedNewText(document, selectionStart, selectionEnd, edit, editStart, editEnd);
34+
if (trimmedEdit) {
35+
result.push(replace(trimmedEdit.start, trimmedEdit.end, trimmedEdit.newText));
36+
}
37+
}
38+
39+
return result;
40+
}
41+
42+
function getTrimmedNewText(
43+
document: FormatableTextDocument,
44+
selectionStart: number,
45+
selectionEnd: number,
46+
edit: vscode.TextEdit,
47+
editStart: number,
48+
editEnd: number,
49+
) {
50+
if (editStart === editEnd) {
51+
return {
52+
start: editStart,
53+
end: editEnd,
54+
newText: edit.newText,
55+
};
56+
}
57+
const oldText = document.getText(edit.range);
58+
const overlapStart = Math.max(editStart, selectionStart) - editStart;
59+
const overlapEnd = Math.min(editEnd, selectionEnd) - editStart;
60+
if (overlapStart === overlapEnd) {
61+
return;
62+
}
63+
64+
const map = createOffsetMap(oldText, edit.newText);
65+
const newStart = map[overlapStart];
66+
const newEnd = map[overlapEnd];
67+
return {
68+
start: editStart + overlapStart,
69+
end: editStart + overlapEnd,
70+
newText: edit.newText.slice(newStart, newEnd),
71+
};
72+
}
73+
74+
function createOffsetMap(oldText: string, newText: string) {
75+
const length = oldText.length;
76+
const map = new Array<number>(length + 1);
77+
let oldIndex = 0;
78+
let newIndex = 0;
79+
map[0] = 0;
80+
81+
for (const [op, text] of diff(oldText, newText)) {
82+
if (op === diff.EQUAL) {
83+
for (let i = 0; i < text.length; i++) {
84+
oldIndex++;
85+
newIndex++;
86+
map[oldIndex] = newIndex;
87+
}
88+
}
89+
else if (op === diff.DELETE) {
90+
for (let i = 0; i < text.length; i++) {
91+
oldIndex++;
92+
map[oldIndex] = Number.NaN;
93+
}
94+
}
95+
else {
96+
newIndex += text.length;
97+
}
98+
}
99+
100+
map[length] = newIndex;
101+
102+
let lastDefinedIndex = 0;
103+
for (let i = 1; i <= length; i++) {
104+
if (map[i] === undefined || Number.isNaN(map[i])) {
105+
continue;
106+
}
107+
interpolate(map, lastDefinedIndex, i);
108+
lastDefinedIndex = i;
109+
}
110+
if (lastDefinedIndex < length) {
111+
interpolate(map, lastDefinedIndex, length);
112+
}
113+
114+
return map;
115+
}
116+
117+
function interpolate(map: number[], startIndex: number, endIndex: number) {
118+
const startValue = map[startIndex] ?? 0;
119+
const endValue = map[endIndex] ?? startValue;
120+
const gap = endIndex - startIndex;
121+
if (gap <= 1) {
122+
return;
123+
}
124+
const delta = (endValue - startValue) / gap;
125+
for (let i = 1; i < gap; i++) {
126+
const index = startIndex + i;
127+
if (map[index] !== undefined && !Number.isNaN(map[index])) {
128+
continue;
129+
}
130+
map[index] = Math.floor(startValue + delta * i);
131+
}
132+
}

extensions/vscode/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,5 +496,8 @@
496496
"semver": "^7.5.4",
497497
"vscode-ext-gen": "^1.0.2",
498498
"vscode-tmlanguage-snapshot": "^1.0.1"
499+
},
500+
"dependencies": {
501+
"fast-diff": "^1.3.0"
499502
}
500503
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expect, test } from 'vitest';
2+
import type * as vscode from 'vscode';
3+
import { type FormatableTextDocument, restrictFormattingEditsToRange } from '../lib/rangeFormatting';
4+
5+
describe('provideDocumentRangeFormattingEdits', () => {
6+
test('only replace selected range', () => {
7+
const document = createDocument('012345');
8+
const selection = createRange(1, 5);
9+
const edits = [createTextEdit(0, 5, '_BCDE')];
10+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
11+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0BCDE5"`);
12+
});
13+
14+
test('keeps indent when edits start on previous line', () => {
15+
const content = `<template>
16+
<div
17+
>1</div>
18+
<div>
19+
<div>2</div>
20+
</div>
21+
</template>
22+
`;
23+
const document = createDocument(content);
24+
const selectionText = ` <div>
25+
<div>2</div>
26+
</div>`;
27+
const selectionStart = content.indexOf(selectionText);
28+
const selection = createRange(selectionStart, selectionStart + selectionText.length);
29+
const edits = [
30+
createTextEdit(
31+
selection.start.character - 1,
32+
selection.end.character,
33+
` <div>
34+
<div>2</div>
35+
</div>`,
36+
),
37+
];
38+
39+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
40+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`
41+
"<template>
42+
<div
43+
>1</div>
44+
<div>
45+
<div>2</div>
46+
</div>
47+
</template>
48+
"
49+
`);
50+
});
51+
52+
test('drops edits if the selection text unchanged after restrict', () => {
53+
const document = createDocument('0123456789');
54+
const selection = createRange(2, 5);
55+
const edits = [createTextEdit(0, 10, '0123456789')];
56+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
57+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"0123456789"`);
58+
});
59+
60+
test('returns next edits unchanged when they fully match the selection', () => {
61+
const document = createDocument('0123456789');
62+
const selection = createRange(2, 7);
63+
const edits = [createTextEdit(3, 5, 'aa')];
64+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
65+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"012aa56789"`);
66+
});
67+
68+
test('keeps boundary inserts when other edits are out of range', () => {
69+
const document = createDocument('0123456789');
70+
const selection = createRange(2, 5);
71+
const edits = [
72+
createTextEdit(5, 6, 'Z'),
73+
createTextEdit(2, 2, 'X'),
74+
];
75+
const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
76+
expect(applyEdits(document, result)).toMatchInlineSnapshot(`"01X23456789"`);
77+
});
78+
});
79+
80+
// self implementation of vscode test utils
81+
82+
function applyEdits(
83+
document: FormatableTextDocument,
84+
edits: vscode.TextEdit[],
85+
) {
86+
let content = document.getText();
87+
const sortedEdits = edits.slice().sort((a, b) => {
88+
const aStart = document.offsetAt(a.range.start);
89+
const bStart = document.offsetAt(b.range.start);
90+
return bStart - aStart;
91+
});
92+
for (const edit of sortedEdits) {
93+
const start = document.offsetAt(edit.range.start);
94+
const end = document.offsetAt(edit.range.end);
95+
content = content.slice(0, start) + edit.newText + content.slice(end);
96+
}
97+
return content;
98+
}
99+
100+
function createDocument(content: string): FormatableTextDocument {
101+
return {
102+
offsetAt: ({ character }) => character,
103+
positionAt: (offset: number) => ({ line: 0, character: offset }) as unknown as vscode.Position,
104+
getText: range => range ? content.slice(range.start.character, range.end.character) : content,
105+
};
106+
}
107+
108+
function createRange(start: number, end: number): vscode.Range {
109+
return {
110+
start: { line: 0, character: start },
111+
end: { line: 0, character: end },
112+
} as unknown as vscode.Range;
113+
}
114+
115+
function createTextEdit(start: number, end: number, newText: string) {
116+
return {
117+
range: createRange(start, end),
118+
newText,
119+
} as unknown as vscode.TextEdit;
120+
}

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)