-
-
Notifications
You must be signed in to change notification settings - Fork 233
perf: enable incremental TypeScript reparsing via getChangeRange #2961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jasonlyu123
merged 8 commits into
sveltejs:master
from
MathiasWP:perf/incremental-ts-reparsing
Mar 14, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
8aac0d6
perf: enable incremental TypeScript reparsing via getChangeRange
MathiasWP 5870ea0
add changeset
MathiasWP 4c891a0
fix: prettier formatting in benchmark file
MathiasWP 08ba58a
format changeset
MathiasWP d5273f6
test: add realistic full-file tests for computeChangeRange
MathiasWP 19fae09
trim tests and remove benchmark per review feedback
MathiasWP 3b57d4e
fix: address PR review feedback for incremental reparsing
MathiasWP 8e6ecbb
fix: prettier formatting in test file
MathiasWP File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
packages/language-server/test/plugins/typescript/DocumentSnapshot.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', () => { | ||
MathiasWP marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.