Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fast-pipes-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-language-server': patch
---

perf: enable incremental TypeScript reparsing via getChangeRange
75 changes: 58 additions & 17 deletions packages/language-server/src/plugins/typescript/DocumentSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ import { URI } from 'vscode-uri';
import { surroundWithIgnoreComments } from './features/utils';
import { configLoader } from '../../lib/documents/configLoader';

/**
* Computes the change range between two snapshots by scanning for the first and last
* differing characters. This allows TypeScript to do incremental re-parsing instead of
* a full reparse when a snapshot changes.
*/
export function computeChangeRange(oldText: string, newText: string): ts.TextChangeRange {
const minLen = Math.min(oldText.length, newText.length);

// Scan from the front to find the first differing character
let prefixLen = 0;
while (prefixLen < minLen && oldText.charCodeAt(prefixLen) === newText.charCodeAt(prefixLen)) {
prefixLen++;
}

// Scan from the back to find the last differing character
let oldSuffixStart = oldText.length;
let newSuffixStart = newText.length;
while (
oldSuffixStart > prefixLen &&
newSuffixStart > prefixLen &&
oldText.charCodeAt(oldSuffixStart - 1) === newText.charCodeAt(newSuffixStart - 1)
) {
oldSuffixStart--;
newSuffixStart--;
}

return {
span: { start: prefixLen, length: oldSuffixStart - prefixLen },
newLength: newSuffixStart - prefixLen
};
}

/**
* An error which occurred while trying to parse/preprocess the svelte file contents.
*/
Expand Down Expand Up @@ -318,8 +350,16 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
return this.text;
}

getChangeRange() {
return undefined;
getChangeRange(oldSnapshot: ts.IScriptSnapshot) {
if (oldSnapshot instanceof SvelteDocumentSnapshot) {
if (
oldSnapshot.scriptKind !== this.scriptKind ||
!!oldSnapshot.parserError !== !!this.parserError
) {
return undefined;
}
}
return computeChangeRange(oldSnapshot.getText(0, oldSnapshot.getLength()), this.text);
}

positionAt(offset: number) {
Expand Down Expand Up @@ -456,16 +496,15 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn
private clientHooksPath = 'src/hooks.client';
private universalHooksPath = 'src/hooks';

private openedByClient = false;

isOpenedInClient(): boolean {
return this.openedByClient;
}

constructor(
public version: number,
public readonly filePath: string,
private text: string
private text: string,
private openedByClient = false
) {
super(pathToUrl(filePath));
this.adjustText();
Expand All @@ -483,8 +522,8 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn
return this.text;
}

getChangeRange() {
return undefined;
getChangeRange(oldSnapshot: ts.IScriptSnapshot) {
return computeChangeRange(oldSnapshot.getText(0, oldSnapshot.getLength()), this.text);
}

positionAt(offset: number) {
Expand Down Expand Up @@ -535,27 +574,29 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn
return this.originalPositionAt(pos - total);
}

update(changes: TextDocumentContentChangeEvent[]): void {
update(changes: TextDocumentContentChangeEvent[]): JSOrTSDocumentSnapshot {
let updatedOriginalText = this.originalText;

for (const change of changes) {
let start = 0;
let end = 0;
if ('range' in change) {
start = this.originalOffsetAt(change.range.start);
end = this.originalOffsetAt(change.range.end);
} else {
end = this.originalText.length;
end = updatedOriginalText.length;
}

this.originalText =
this.originalText.slice(0, start) + change.text + this.originalText.slice(end);
updatedOriginalText =
updatedOriginalText.slice(0, start) + change.text + updatedOriginalText.slice(end);
}

this.adjustText();
this.version++;
this.lineOffsets = undefined;
this.internalLineOffsets = undefined;
// only client can have incremental updates
this.openedByClient = true;
return new JSOrTSDocumentSnapshot(
this.version + 1,
this.filePath,
updatedOriginalText,
true // openedByClient
);
}

protected getLineOffsets() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ export class GlobalSnapshotsManager {
if (!(previousSnapshot instanceof JSOrTSDocumentSnapshot)) {
return;
}
previousSnapshot.update(changes);
this.emitter.emit('change', fileName, previousSnapshot);
return previousSnapshot;
const newSnapshot = previousSnapshot.update(changes);
this.set(fileName, newSnapshot);
return newSnapshot;
} else {
const newSnapshot = DocumentSnapshot.fromNonSvelteFilePath(fileName, this.tsSystem);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import * as assert from 'assert';
import ts from 'typescript';
import {
computeChangeRange,
JSOrTSDocumentSnapshot,
SvelteDocumentSnapshot
} from '../../../src/plugins/typescript/DocumentSnapshot';
import { Document } from '../../../src/lib/documents';
import { pathToUrl } from '../../../src/utils';

/**
* Helper: verifies the reconstruction invariant for computeChangeRange.
* Given old and new text, the change range should allow reconstructing
* the new text by splicing the changed portion into the old text.
*/
function assertValidChangeRange(oldText: string, newText: string, label?: string) {
const range = computeChangeRange(oldText, newText);
const before = oldText.substring(0, range.span.start);
const after = oldText.substring(range.span.start + range.span.length);
const inserted = newText.substring(range.span.start, range.span.start + range.newLength);
const reconstructed = before + inserted + after;
assert.strictEqual(
reconstructed,
newText,
`${label ? label + ': ' : ''}Reconstruction failed for change ` +
`span(${range.span.start}, ${range.span.length}), newLength=${range.newLength}`
);
return range;
}

describe('computeChangeRange', () => {
it('returns empty change range for identical texts', () => {
const result = computeChangeRange('hello world', 'hello world');
assert.deepStrictEqual(result, {
span: { start: 11, length: 0 },
newLength: 0
});
});

it('detects insertion in the middle', () => {
const range = assertValidChangeRange('hello world', 'hello beautiful world');
assert.deepStrictEqual(range, {
span: { start: 6, length: 0 },
newLength: 10
});
});

it('detects deletion in the middle', () => {
const range = assertValidChangeRange('hello beautiful world', 'hello world');
assert.deepStrictEqual(range, {
span: { start: 6, length: 10 },
newLength: 0
});
});

it('detects replacement', () => {
const range = assertValidChangeRange('hello world', 'hello earth');
assert.deepStrictEqual(range, {
span: { start: 6, length: 5 },
newLength: 5
});
});

it('handles multiline text with change on one line', () => {
assertValidChangeRange('line1\nline2\nline3\nline4', 'line1\nmodified\nline3\nline4');
});

it('holds reconstruction invariant for diverse edit patterns', () => {
const cases: [string, string][] = [
['', 'x'],
['x', ''],
['abcdef', 'abcxyzdef'],
['abcxyzdef', 'abcdef'],
['prefix_middle_suffix', 'prefix_changed_suffix'],
['aaa', 'aaaa']
];

for (const [oldText, newText] of cases) {
assertValidChangeRange(oldText, newText, `'${oldText}' -> '${newText}'`);
}
});
});

describe('JSOrTSDocumentSnapshot.getChangeRange', () => {
function createSnapshot(text: string, version = 0, filePath = '/test/file.ts') {
return new JSOrTSDocumentSnapshot(version, filePath, text);
}

it('computes change range between two different snapshots', () => {
const old = createSnapshot('const x = 1;');
const current = createSnapshot('const x = 42;');
const range = current.getChangeRange(old);
assert.ok(range);
assert.deepStrictEqual(range.span.start, 10);
assert.deepStrictEqual(range.span.length, 1);
assert.deepStrictEqual(range.newLength, 2);
});

it('returns empty change for identical snapshots', () => {
const old = createSnapshot('const x = 1;');
const current = createSnapshot('const x = 1;');
const range = current.getChangeRange(old);
assert.ok(range);
assert.strictEqual(range.span.length, 0);
assert.strictEqual(range.newLength, 0);
});

it('update returns a new snapshot with correct change range', () => {
const original = createSnapshot('const x = 1;');
const updated = original.update([
{
range: {
start: { line: 0, character: 10 },
end: { line: 0, character: 11 }
},
text: '42'
}
]);

assert.notStrictEqual(original, updated, 'update should return a new snapshot');
assert.strictEqual(updated.getFullText(), 'const x = 42;');
assert.strictEqual(updated.version, original.version + 1);

const range = updated.getChangeRange(original);
assert.ok(range);

const oldText = original.getFullText();
const newText = updated.getFullText();
const before = oldText.substring(0, range.span.start);
const after = oldText.substring(range.span.start + range.span.length);
const inserted = newText.substring(range.span.start, range.span.start + range.newLength);
assert.strictEqual(before + inserted + after, newText);
});
});

describe('SvelteDocumentSnapshot.getChangeRange', () => {
function createSvelteSnapshot(
generatedText: string,
options?: { scriptKind?: ts.ScriptKind; parserError?: boolean }
) {
const uri = pathToUrl('/test/Component.svelte');
const doc = new Document(uri, generatedText);
return new SvelteDocumentSnapshot(
doc,
options?.parserError
? {
message: 'error',
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
code: -1
}
: null,
options?.scriptKind ?? ts.ScriptKind.TS,
'4.0.0',
generatedText,
0,
{ has: () => false }
);
}

it('computes change range between two svelte snapshots', () => {
const old = createSvelteSnapshot('let x = 1;');
const current = createSvelteSnapshot('let x = 42;');

const range = current.getChangeRange(old);
assert.ok(range);

const oldText = old.getFullText();
const newText = current.getFullText();
const before = oldText.substring(0, range.span.start);
const after = oldText.substring(range.span.start + range.span.length);
const inserted = newText.substring(range.span.start, range.span.start + range.newLength);
assert.strictEqual(before + inserted + after, newText);
});

it('returns undefined when scriptKind changes', () => {
const old = createSvelteSnapshot('let x = 1;', { scriptKind: ts.ScriptKind.JS });
const current = createSvelteSnapshot('let x = 1;', { scriptKind: ts.ScriptKind.TS });

const range = current.getChangeRange(old);
assert.strictEqual(range, undefined);
});

it('returns undefined when parserError state changes', () => {
const old = createSvelteSnapshot('let x = 1;', { parserError: false });
const current = createSvelteSnapshot('let x = 1;', { parserError: true });

const range = current.getChangeRange(old);
assert.strictEqual(range, undefined);
});
});
Loading