Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 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 @@ -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: {
Expand Down
142 changes: 142 additions & 0 deletions extensions/vscode/lib/rangeFormatting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type * as vscode from 'vscode';
import diff = require('fast-diff');

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

/** 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<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 @@ -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"
}
}
100 changes: 100 additions & 0 deletions extensions/vscode/tests/rangeFormatting.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<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, textEditReplace);

expect(result).toEqual([textEditReplace(
selection,
` <div>
<div>2</div>
</div>`,
)]);
});

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);
}
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.

Loading