Skip to content
Merged
20 changes: 20 additions & 0 deletions extensions/vscode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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: {
Expand Down
132 changes: 132 additions & 0 deletions extensions/vscode/lib/rangeFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type * as vscode from 'vscode';
import diff = require('fast-diff');

/** for test unit */
export type FormatableTextDocument = Pick<vscode.TextDocument, 'getText' | 'offsetAt' | 'positionAt'>;

/** 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<number>(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);
}
}
3 changes: 3 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
120 changes: 120 additions & 0 deletions extensions/vscode/tests/rangeFormatting.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<template>
<div
>1</div>
<div>
<div>2</div>
</div>
</template>
`;
const document = createDocument(content);
const selectionText = ` <div>
<div>2</div>
</div>`;
const selectionStart = content.indexOf(selectionText);
const selection = createRange(selectionStart, selectionStart + selectionText.length);
const edits = [
createTextEdit(
selection.start.character - 1,
selection.end.character,
` <div>
<div>2</div>
</div>`,
),
];

const result = restrictFormattingEditsToRange(document, selection, edits, createTextEdit);
expect(applyEdits(document, result)).toMatchInlineSnapshot(`
"<template>
<div
>1</div>
<div>
<div>2</div>
</div>
</template>
"
`);
});

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;
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.