diff --git a/CHANGELOG.md b/CHANGELOG.md
index 349c9ff..4b7b4f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Any row that names a symbol can start a **flow**: press `Flow ›`, then type a second symbol or press `→ here` on another row, and you get the path between them — so "how does `POST /v1/payroll/cycles/{cycleID}/run` reach the database" is two clicks. Typing into the search box now finds entry points too, under their own heading below the symbol matches, so a URL comes back with its handler attached instead of on its own.
+- **Syntax colouring in `codegraph ui` now comes from CodeGraph's own reading of your code.** The viewer used to run a second syntax highlighter over source CodeGraph had already parsed, with its own separate set of grammars. It doesn't any more: the colouring is taken straight from the parse that built your graph, so a file is coloured by exactly the grammar that decided what its symbols are. Three things you will notice — the name a definition declares now stands out on the line that declares it, wherever it appears; calls written inside a string (`${user.name()}`, `#{...}`, `$"{...}"`) are read as code and are now clickable links like every other call site; and built-in type words such as `string`, `int` and `void` look the same in every language instead of one way in Go and another in TypeScript. A big file paints far faster, most visibly in TypeScript, which was by a wide margin the slowest before.
+
+ Two formats change for the worse and it is worth saying so: Liquid, Razor, YAML, Twig, XML and `.properties` files are shown without colouring now, and in `.svelte`, `.vue` and `.astro` files the `\n', 'svelte');
+ expect(regions).toHaveLength(1);
+ expect(regions?.[0]?.language).toBe('typescript');
});
- withGrammars('loads a grammar chain dependencies-first, so embedded blocks highlight', () => {
- const found = loadManifest();
- const vue = (found as NonNullable).manifest.languages['vue'] ?? [];
- // The single-file component's own grammar is last; everything it embeds
- // has to be registered before Shiki resolves `embeddedLangs`.
- expect(vue[vue.length - 1]).toBe('vue');
- expect(vue).toContain('typescript');
- expect(vue.indexOf('typescript')).toBeLessThan(vue.length - 1);
+ it('has no grammar for the formats that only have file-level extraction', () => {
+ for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) {
+ expect(grammarFor(language)).toBeNull();
+ }
});
});
describe('classification', () => {
beforeAll(() => clearHighlightCache());
- withGrammars('reads TypeScript with the four classes the theme paints', async () => {
+ it('reads TypeScript with the classes the theme paints', async () => {
const result = await highlightLines(['const answer = 42; // note'], {
language: 'typescript',
});
- expect(result.engine).toBe('shiki');
+ expect(result.engine).toBe('tree-sitter');
expect(result.grammar).toBe('typescript');
expect(result.classes).toEqual([...TOKEN_CLASSES]);
const rendered = shape(result, 0);
@@ -123,7 +119,7 @@ describe('classification', () => {
expect(rendered).toContain('comment:// note');
});
- withGrammars('reads a # comment as a comment in Python and as code in TypeScript', async () => {
+ it('reads a # comment as a comment in Python and as code in TypeScript', async () => {
const python = await highlightLines(['x = 1 # note'], { language: 'python' });
expect(shape(python, 0).at(-1)).toBe('comment:# note');
@@ -131,7 +127,7 @@ describe('classification', () => {
expect(shape(ts, 0).at(-1)).not.toBe('comment:# note');
});
- withGrammars('carries a block comment across lines within one slice', async () => {
+ it('carries a block comment across lines within one slice', async () => {
const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], {
language: 'typescript',
});
@@ -140,21 +136,73 @@ describe('classification', () => {
expect(shape(result, 2)).toContain('keyword:const');
});
- withGrammars('reads Go, which has its own idea of what a keyword is', async () => {
+ it('reads Go, which has its own idea of what a keyword is', async () => {
const result = await highlightLines(['func Greet(name string) string {'], { language: 'go' });
expect(shape(result, 0)).toContain('keyword:func');
- expect(shape(result, 0)).toContain('ident:Greet');
+ expect(shape(result, 0)).toContain('def:Greet');
});
- withGrammars('reads ArkTS with the TypeScript grammar', async () => {
+ it('reads ArkTS with its own grammar, not TypeScript’s', async () => {
const result = await highlightLines(['@Entry struct Index { build() {} }'], {
language: 'arkts',
});
- expect(result.engine).toBe('shiki');
- expect(result.grammar).toBe('typescript');
+ expect(result.engine).toBe('tree-sitter');
+ expect(result.grammar).toBe('arkts');
});
- withGrammars('emits one entry per source line, always', async () => {
+ it('does not read a type annotation’s `string` as a string literal', async () => {
+ // An anonymous tree-sitter node's type IS its text, so `string` in a
+ // signature arrives as a node literally typed `string`. Reading that as a
+ // string literal greys out half of every signature in TypeScript and PHP.
+ for (const [language, line] of [
+ ['typescript', 'function put(key: string): void {}'],
+ ['php', ' {
+ // The grammars disagree: `string` is a `type_identifier` in Go and an
+ // anonymous token inside a `predefined_type` in TypeScript. Left alone that
+ // is one word painting two ways on the same screen.
+ for (const [language, line] of [
+ ['typescript', 'let a: string;'],
+ ['go', 'var a string'],
+ ['csharp', 'string a;'],
+ ['rust', 'let a: u32 = 1;'],
+ ] as const) {
+ const rendered = shape(await highlightLines([line], { language }), 0);
+ expect(rendered.some((t) => t.startsWith('type:'))).toBe(true);
+ expect(rendered.some((t) => t === 'keyword:string' || t === 'keyword:u32')).toBe(false);
+ }
+ });
+
+ it('keeps a template literal’s interpolated call as code, so it can link', async () => {
+ const line = 'const s = `n=${store.size()} done`;';
+ const result = await highlightLines([line], { language: 'typescript' });
+ expect(shape(result, 0)).toContain('ident:size');
+ expect(claimedText(result, 0, lineRef({ ident: 'size' }))).toBe('size');
+ });
+
+ it('marks a definition’s own name, from the extractor’s tables', async () => {
+ const cases: [string, string, string][] = [
+ ['typescript', 'export class Store {}', 'Store'],
+ ['python', 'def put(self):', 'put'],
+ ['rust', 'pub fn put(&self) {}', 'put'],
+ ['ruby', 'class Store', 'Store'],
+ ['csharp', 'public class Store {}', 'Store'],
+ ['swift', 'final class Store {}', 'Store'],
+ ];
+ for (const [language, line, name] of cases) {
+ const result = await highlightLines([line], { language });
+ expect(shape(result, 0)).toContain(`def:${name}`);
+ }
+ });
+
+ it('emits one entry per source line, always', async () => {
const lines = ['a();', '', 'b();', ''];
const result = await highlightLines(lines, { language: 'typescript' });
// The code block indexes rows positionally: one short answer and every
@@ -162,6 +210,34 @@ describe('classification', () => {
expect(result.lines).toHaveLength(lines.length);
expect(result.lines[1]).toEqual([]);
});
+
+ it('reproduces every line of a real file exactly', async () => {
+ // The code block renders these tokens and nothing else, so a dropped or
+ // duplicated character is a corrupted file on screen — silently.
+ const file = path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts');
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
+ const result = await highlightLines(lines, { language: 'typescript' });
+ expect(result.engine).toBe('tree-sitter');
+ result.lines.forEach((row, i) => {
+ expect(row.map(([, text]) => text).join('')).toBe(lines[i]);
+ });
+ });
+
+ it('classifies a component’s script and leaves its markup plain', async () => {
+ const lines = [
+ '',
+ '',
+ '',
+ ];
+ const result = await highlightLines(lines, { language: 'svelte' });
+ expect(result.engine).toBe('tree-sitter');
+ expect(shape(result, 1)).toContain('keyword:let');
+ // The markup still splits into identifiers, so a call site in it links.
+ expect(claimedText(result, 4, lineRef({ ident: 'bump' }))).toBe('bump');
+ expect(result.lines.map((row) => row.map(([, t]) => t).join(''))).toEqual(lines);
+ });
});
describe('the plain fallback', () => {
@@ -182,7 +258,7 @@ describe('the plain fallback', () => {
expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock');
});
- it('refuses to tokenise a minified line rather than wedging on it', async () => {
+ it('refuses to classify a minified line rather than wedging on it', async () => {
const enormous = 'a'.repeat(MAX_HIGHLIGHT_CHARS + 1);
const result = await highlightLines([enormous], { language: 'javascript' });
expect(result.engine).toBe('plain');
@@ -191,41 +267,17 @@ describe('the plain fallback', () => {
expect(result.lines[0]?.map(([, text]) => text).join('')).toHaveLength(enormous.length);
});
- it('answers plain when a shipped grammar file is missing or unreadable', async () => {
- // The install is half there: a manifest that names a grammar whose file
- // never made it. The viewer must still get its source.
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-textmate-'));
- fs.writeFileSync(
- path.join(dir, 'manifest.json'),
- JSON.stringify({ shikiVersion: 'test', languages: { typescript: ['typescript'] } })
- );
-
- const previous = process.env.CODEGRAPH_TEXTMATE_PATH;
- process.env.CODEGRAPH_TEXTMATE_PATH = dir;
- // A fresh module registry: the highlighter and its grammar bookkeeping are
- // created once per process, and this case is about that first attempt.
- vi.resetModules();
- try {
- const mod = await import('../src/ui-server/highlight');
- const result: HighlightResult = await mod.highlightLines(['const x = 1;'], {
- language: 'typescript',
- });
- expect(result.engine).toBe('plain');
- expect(result.reason).toBeTruthy();
- expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('const x = 1;');
- } finally {
- if (previous === undefined) delete process.env.CODEGRAPH_TEXTMATE_PATH;
- else process.env.CODEGRAPH_TEXTMATE_PATH = previous;
- fs.rmSync(dir, { recursive: true, force: true });
- vi.resetModules();
- }
+ it('answers plain for a component whose script block is empty', async () => {
+ const result = await highlightLines(['
');
});
});
describe('graph links land on the right token', () => {
beforeAll(() => clearHighlightCache());
- withGrammars('marks the callee, not the receiver the recorded column points at', async () => {
+ it('marks the callee, not the receiver the recorded column points at', async () => {
// The recorded column is the start of the calling EXPRESSION — `this` —
// and the underline has to end up on `withLock`.
const line = ' return this.indexMutex.withLock(async () => {';
@@ -235,7 +287,7 @@ describe('graph links land on the right token', () => {
);
});
- withGrammars('lands on a real call site in the engine’s own src/index.ts', async () => {
+ it('lands on a real call site in the engine’s own src/index.ts', async () => {
const file = path.join(__dirname, '..', 'src', 'index.ts');
const source = fs.readFileSync(file, 'utf-8').split('\n');
// A line the engine actually contains, found rather than hard-coded, so a
@@ -252,7 +304,7 @@ describe('graph links land on the right token', () => {
);
});
- withGrammars('lands on a Go method call', async () => {
+ it('lands on a Go method call', async () => {
const line = '\tresult := s.repo.FindByID(ctx, id)';
const result = await highlightLines([line], { language: 'go' });
expect(claimedText(result, 0, lineRef({ ident: 'FindByID', col: line.indexOf('s.repo') }))).toBe(
@@ -260,7 +312,7 @@ describe('graph links land on the right token', () => {
);
});
- withGrammars('lands on a Python method call, not on the receiver of the same name', async () => {
+ it('lands on a Python method call, not on the receiver of the same name', async () => {
const line = ' return self.store.join(self.store.path)';
const result = await highlightLines([line], { language: 'python' });
expect(claimedText(result, 0, lineRef({ ident: 'join', col: line.indexOf('self') }))).toBe(
@@ -268,7 +320,7 @@ describe('graph links land on the right token', () => {
);
});
- withGrammars('leaves a word inside a comment or a string alone', async () => {
+ it('leaves a word inside a comment or a string alone', async () => {
const result = await highlightLines(
[' // call render here', ' const s = "render";'],
{ language: 'typescript' }
@@ -277,7 +329,7 @@ describe('graph links land on the right token', () => {
expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined();
});
- withGrammars('keeps every identifier separately claimable', async () => {
+ it('keeps every identifier separately claimable', async () => {
const result = await highlightLines(['render(); render();'], { language: 'typescript' });
const tokens = tokensOf(result, 0);
const claimed = assignRefs(tokens, [
@@ -287,7 +339,13 @@ describe('graph links land on the right token', () => {
expect(claimed.size).toBe(2);
});
- withGrammars('reproduces the line exactly — the code block renders these tokens', async () => {
+ it('keeps a type name claimable — it is a distinct class, not an excluded one', async () => {
+ const result = await highlightLines(['let store: Store = make();'], { language: 'typescript' });
+ expect(shape(result, 0)).toContain('type:Store');
+ expect(claimedText(result, 0, lineRef({ ident: 'Store' }))).toBe('Store');
+ });
+
+ it('reproduces the line exactly — the code block renders these tokens', async () => {
const line = ' const s = `a ${b.c()} d`; // 1 + 2';
const result = await highlightLines([line], { language: 'typescript' });
expect(
@@ -299,7 +357,27 @@ describe('graph links land on the right token', () => {
});
describe('cost', () => {
- withGrammars('answers a cached slice without re-tokenising it', async () => {
+ it('classifies three thousand lines of TypeScript well inside the budget', async () => {
+ clearHighlightCache();
+ const lines = fs
+ .readFileSync(path.join(__dirname, '..', 'src', 'extraction', 'tree-sitter.ts'), 'utf-8')
+ .split('\n')
+ .slice(0, 3000);
+ // Warm the grammar load, which is a one-off per language per process.
+ await highlightLines(lines.slice(0, 5), { language: 'typescript' });
+ clearHighlightCache();
+
+ const started = Date.now();
+ const result = await highlightLines(lines, { language: 'typescript' });
+ const elapsed = Date.now() - started;
+
+ expect(result.engine).toBe('tree-sitter');
+ // The whole point of CG-57's swap: the TextMate grammar took ~700 ms here.
+ // Generous against a loaded CI box; the dev Mac measures 24–41 ms.
+ expect(elapsed).toBeLessThan(400);
+ });
+
+ it('answers a cached slice without re-classifying it', async () => {
clearHighlightCache();
const lines = fs
.readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8')
@@ -313,7 +391,7 @@ describe('cost', () => {
const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
const warmMs = Date.now() - warm;
- expect(second.engine).toBe('shiki');
+ expect(second.engine).toBe('tree-sitter');
// The cache is what makes a re-render free: every resize, theme flip and
// step back through the trail re-asks for the same slice.
expect(warmMs).toBeLessThan(Math.max(20, coldMs / 4));
@@ -333,7 +411,7 @@ describe('cost', () => {
expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES);
});
- withGrammars('keys the cache on the content, so an edited file re-highlights', async () => {
+ it('keys the cache on the content, so an edited file re-classifies', async () => {
clearHighlightCache();
const first = await highlightLines(['const a = 1;'], {
language: 'typescript',
@@ -347,3 +425,34 @@ describe('cost', () => {
expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;');
});
});
+
+describe('the classifier itself', () => {
+ it('covers the source with ordered, non-overlapping spans', async () => {
+ const source = fs
+ .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'flow.ts'), 'utf-8')
+ .slice(0, 40_000);
+ await initGrammars();
+ await loadGrammarsForLanguages(['typescript']);
+ const parser = getParser('typescript');
+ expect(parser).not.toBeNull();
+ const tree = (parser as NonNullable).parse(source);
+ const spans = classifyTree((tree as NonNullable).rootNode, source, 'typescript');
+
+ expect(spans.length).toBeGreaterThan(1000);
+ let previous = 0;
+ for (const span of spans) {
+ expect(span.start).toBeGreaterThanOrEqual(previous);
+ expect(span.end).toBeGreaterThan(span.start);
+ previous = span.end;
+ }
+ expect(previous).toBeLessThanOrEqual(source.length);
+ // Everything the walk did not claim is whitespace the caller fills in.
+ const uncovered: string[] = [];
+ let at = 0;
+ for (const span of spans) {
+ if (span.start > at) uncovered.push(source.slice(at, span.start));
+ at = span.end;
+ }
+ expect(uncovered.every((gap) => gap.trim() === '')).toBe(true);
+ });
+});
diff --git a/__tests__/ui-server-api.test.ts b/__tests__/ui-server-api.test.ts
index 182742c..0d73906 100644
--- a/__tests__/ui-server-api.test.ts
+++ b/__tests__/ui-server-api.test.ts
@@ -671,6 +671,8 @@ describe('GET /api/source', () => {
'string',
'keyword',
'number',
+ 'type',
+ 'def',
]);
expect(body.highlight.lines).toHaveLength(body.lines.length);
// Every line's tokens reproduce that line exactly — the code block renders
diff --git a/__tests__/ui-symbol-model.test.ts b/__tests__/ui-symbol-model.test.ts
index d96e7c8..b97d111 100644
--- a/__tests__/ui-symbol-model.test.ts
+++ b/__tests__/ui-symbol-model.test.ts
@@ -484,7 +484,7 @@ describe('showsBody', () => {
describe('client-side token decoding', () => {
// The classification itself is the server's job (`src/ui-server/highlight/`,
- // real TextMate grammars); what is worth pinning here is the decoding — the
+ // the engine's own tree-sitter parse); what is worth pinning here is the decoding — the
// columns the call-site overlay matches against, and the plain fallback that
// has to keep links working when no grammar covers a file.
const CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'];
@@ -552,7 +552,7 @@ describe('client-side token decoding', () => {
it('keys a slice by real file line, not by offset into the slice', () => {
const byLine = tokensByLine(['a();', 'b();'], 120, {
- engine: 'shiki',
+ engine: 'tree-sitter',
grammar: 'typescript',
classes: CLASSES,
lines: [
diff --git a/docs/design/cg57-highlighting-parity.md b/docs/design/cg57-highlighting-parity.md
new file mode 100644
index 0000000..a8f4174
--- /dev/null
+++ b/docs/design/cg57-highlighting-parity.md
@@ -0,0 +1,95 @@
+# Highlighting parity: Shiki → the engine's own tree-sitter parse (CG-57)
+
+The viewer's code block used to be classified by a second highlighter — Shiki with 56 pruned
+TextMate grammars shipped in `dist/textmate/` — over source the engine had already parsed with a
+real grammar. CG-57 takes the classification off that tree instead. This file records what the swap
+changed, measured rather than asserted, so nobody has to re-derive it from a diff.
+
+Screenshots, one per language, before on the left and after on the right, same stylesheet:
+[`cg57-highlighting-parity/`](./cg57-highlighting-parity/) — `typescript.png`, `go.png`,
+`python.png`, `rust.png`, `swift.png`, `csharp.png`, `ruby.png`, `php.png`.
+
+## What it costs
+
+3 000 lines, cold, dev Mac (M-series), parse + classify + wire:
+
+| | TypeScript | Go | Python | Rust | Swift | C# | Ruby | PHP |
+|---|---|---|---|---|---|---|---|---|
+| Shiki + TextMate | ~700 ms | 43–57 ms | 35–47 ms | — | — | — | — | — |
+| Engine tree-sitter | 24–41 ms | ~30 ms | 25–29 ms | 18–19 ms | 25–27 ms | 20–25 ms | 14–16 ms | 20–22 ms |
+
+The task's budget was **< 100 ms per 3 000-line file warm**; every language clears it *cold*.
+TypeScript is the number that mattered: its TextMate grammar was 5–7× every other one and the cost
+was regex *execution*, not compilation, so nothing about the old module could have fixed it. The
+slice cache still exists — a re-render (resize, theme flip, stepping back through the trail) should
+cost nothing at all, and the whole-file view pages the same file repeatedly.
+
+## What it changes on screen
+
+Per-character comparison over ~40 lines of realistic source per language, counting only
+non-whitespace characters, and treating `ident` / `other` / `type` as one bucket because all three
+paint at plain ink:
+
+| language | painted identically | what moved |
+|---|---|---|
+| TypeScript | 91.3% | 33 `def`, 40 interpolation chars now code, 2 punctuation |
+| Go | 91.5% | 37 built-in type words, 13 `def` |
+| Python | 93.2% | 26 `def`, 15 keyword (`is not`, `__future__`) |
+| Rust | 96.3% | 21 `def`, 3 keyword |
+| Swift | 93.3% | 18 `def`, 14 keyword (`throws`/`rethrows`) |
+| C# | 88.3% | 30 built-in type words, 23 `def`, 31 interpolation chars now code |
+| Ruby | 83.8% | 23 `def`, 35 interpolation chars now code, 14 symbol literals, 3 keyword |
+| PHP | 85.9% | 33 built-in type words, 29 `def`, 15 phpdoc tag chars, 12 keyword |
+
+Every remaining difference is one of five deliberate categories:
+
+1. **`ident` → `def`.** The definition's own name now carries weight 600, everywhere rather than
+ only on the line the Symbol view opened at. It comes from the extractors' own definition tables
+ (`functionTypes`, `classTypes`, `methodTypes`, …) plus each language's `nameField`, so it cannot
+ drift from what indexing considers a definition.
+2. **`string` → code, inside an interpolation.** A template literal's `${…}`, an f-string's `{…}`,
+ Ruby's `#{…}` and C#'s `$"{…}"` are classified as code. This is the one difference that is not
+ cosmetic: the call-site overlay deliberately refuses to claim a token classed `string`, so
+ **calls inside interpolated strings now link and did not before.**
+3. **`keyword` → `type`, on built-in type words.** `string`, `int`, `u32`, `void`. The grammars
+ disagree with each other about what a built-in type is — tree-sitter-go calls `string` a
+ `type_identifier`, tree-sitter-typescript wraps it in a `predefined_type` whose child is an
+ anonymous token spelled `string` — and TextMate scoped them inconsistently too (plain in
+ TypeScript, `storage.type` in Go). They now all paint at plain ink, like a user-defined type
+ name, in every language.
+4. **Keyword-set corrections.** Python's `is not`, Rust's and Swift's modifiers, and Ruby's `new`
+ (which is a method, not a keyword — TextMate's `keyword.operator.new` matched it anyway).
+5. **`keyword` → `comment`, on phpdoc tags.** `@var` and friends recede with the comment they are
+ in, which is what the near-monochrome ramp asks for.
+
+## What is no longer highlighted
+
+Nine formats have extraction but no tree-sitter grammar. Three of them — `.svelte`, `.vue`,
+`.astro` — are classified through their `'.length;
+ regions.push({
+ start,
+ end: start + body.length,
+ language: TS_LANG_ATTR.test(match[1] ?? '') ? 'typescript' : 'javascript',
+ });
+ }
+ return regions;
+}
+
+/* --------------------------------------------------------------- the API -- */
+
+export interface TokenizeResult {
+ spans: SyntaxSpan[];
+ /** The grammar(s) that produced them, for the payload's `grammar` field. */
+ grammars: string[];
+}
+
+/**
+ * Parse `source` and classify it.
+ *
+ * Returns null when nothing in the file has a grammar — a plain answer, which
+ * every caller here already knows how to serve. Never throws: a grammar that
+ * fails to load or a parse that comes back empty is the same outcome as not
+ * having one.
+ */
+export async function tokenizeSource(
+ source: string,
+ language: Language
+): Promise {
+ const regions = syntaxRegionsFor(source, language);
+ if (regions === null) {
+ const spans = await tokenizeRegion(source, language, 0);
+ return spans ? { spans, grammars: [language] } : null;
+ }
+ if (regions.length === 0) return null;
+
+ const spans: SyntaxSpan[] = [];
+ const grammars = new Set();
+ for (const region of regions) {
+ const part = await tokenizeRegion(
+ source.slice(region.start, region.end),
+ region.language,
+ region.start
+ );
+ if (!part) continue;
+ grammars.add(region.language);
+ spans.push(...part);
+ }
+ if (spans.length === 0) return null;
+ spans.sort((a, b) => a.start - b.start);
+ return { spans, grammars: [...grammars] };
+}
+
+async function tokenizeRegion(
+ source: string,
+ language: Language,
+ offset: number
+): Promise {
+ try {
+ await loadGrammarsForLanguages([language]);
+ const parser = getParser(language);
+ if (!parser) return null;
+ const tree = parser.parse(source);
+ if (!tree?.rootNode) return null;
+ try {
+ return classifyTree(tree.rootNode, source, language, offset);
+ } finally {
+ tree.delete();
+ }
+ } catch {
+ // A grammar that will not load, or a parse that threw: the caller serves
+ // the source unclassified, which is the whole point of the plain path.
+ return null;
+ }
+}
diff --git a/src/ui-server/api/filecode.ts b/src/ui-server/api/filecode.ts
index 8c51551..efb29e3 100644
--- a/src/ui-server/api/filecode.ts
+++ b/src/ui-server/api/filecode.ts
@@ -18,7 +18,7 @@
* before a single page of source has arrived.
*
* The source itself does NOT ride along. A 6 800-line TypeScript file is ~1.5 s
- * of TextMate tokenising and megabytes of JSON; the viewer pages it through
+ * of parsing and megabytes of JSON; the viewer pages it through
* `/api/source` as the reader scrolls, which is also what lets the graph
* facts — ports, arcs, rail rows — be complete from the first frame while the
* text fills in behind them.
diff --git a/src/ui-server/highlight/grammars.ts b/src/ui-server/highlight/grammars.ts
deleted file mode 100644
index a8d2d08..0000000
--- a/src/ui-server/highlight/grammars.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Finding and reading the pruned TextMate grammars on disk.
- *
- * Shiki ships 700-odd grammars; the engine indexes 40-odd languages. The build
- * writes only the closure those 40 need — including the grammars they embed, so
- * a `.vue` file still gets its `