feat(ui): classify code from the engine's own tree-sitter parse, retiring Shiki (CG-57)

The viewer ran a second highlighter over source the engine had already parsed
with a real grammar: Shiki, plus 56 pruned TextMate grammars shipped in
dist/textmate/. The classification now comes off that tree instead, so a file is
read by exactly the grammar that decided what its symbols are.

The swap is complete rather than flagged: @shikijs/core, @shikijs/engine-javascript
and @shikijs/langs are off the dependency list, scripts/prune-grammars.mjs and
`npm run build:textmate` are deleted, and check-ui-build.mjs asserts the
tree-sitter grammars in dist/extraction/wasm instead of dist/textmate.

The wire contract is unchanged — `[classId, text]` pairs with the class names
alongside — so the viewer's decoder and code blocks did not have to be rewritten.
Two classes are added to the six: `type` (a named type reference, painted at
plain ink) and `def` (the name a definition declares, weight 600), the latter
taken from the extractors' own definition tables so it cannot drift from what
indexing calls a definition.

Three differences are not cosmetic:

* Interpolations (`${…}`, `#{…}`, `$"{…}"`, f-strings) are classified as code,
  not as string. The call-site overlay refuses to claim a token classed string,
  so calls written inside interpolated strings now link.
* Built-in type words are emitted whole and classed `type` in every language.
  The grammars disagree about whether `string` is a type_identifier or an
  anonymous token inside a predefined_type, and TextMate scoped them
  inconsistently too.
* 3 000 lines of TypeScript cost 24-41 ms instead of ~700 ms.

Given up deliberately: Liquid, Razor, YAML, Twig, XML and .properties render
plain. .svelte/.vue/.astro are classified through their <script> blocks, the same
delegation the SFC extractors do. Pulling html/css/vue out of tree-sitter-wasms
would cover them, but those ABI-13 builds are the known cause of shared-WASM-heap
corruption for every other language in the same process.

Measured parity, per-language before/after screenshots and the reproduction
recipe: docs/design/cg57-highlighting-parity.md.
This commit is contained in:
Colby McHenry
2026-08-27 06:22:11 -05:00
parent 8ac0138940
commit ad91c8fdd8
32 changed files with 1084 additions and 1293 deletions
+1 -1
View File
@@ -163,7 +163,7 @@ export interface WireSource {
truncated?: boolean;
reason?: string;
/**
* The same lines, classified by the server's TextMate grammars — one entry
* The same lines, classified by the server's tree-sitter parse — one entry
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
* covers the file. See `lib/highlight.ts`.
+8 -7
View File
@@ -60,12 +60,13 @@ export const OVERSCAN_LINES = 24;
/**
* Source lines fetched in one page.
*
* Measured on this repo's own TypeScript with the shipped Shiki setup: a warm
* grammar tokenises ~7 000 lines/second, so a page plus its lead-in is ~130 ms
* of single-threaded server. Bigger pages mean fewer, longer stalls; smaller
* ones mean the lead-in dominates. The scroll itself never waits on this —
* ports, arcs and rail rows are already drawn from the graph, and the text
* arrives behind them.
* Measured on this repo's own TypeScript with the shipped classifier: a loaded
* grammar classifies ~50 000 lines/second (CG-57 replaced the TextMate path,
* which managed ~4 000), so a page plus its lead-in is ~20 ms of
* single-threaded server. Bigger pages mean fewer, longer stalls; smaller ones
* mean the lead-in dominates. The scroll itself never waits on this — ports,
* arcs and rail rows are already drawn from the graph, and the text arrives
* behind them.
*/
export const PAGE_LINES = 800;
@@ -73,7 +74,7 @@ export const PAGE_LINES = 800;
* Lines fetched BEFORE a page and thrown away.
*
* A page that starts in the middle of a block comment, a template literal or a
* JSX block does not know it: TextMate state is built by scanning from the top.
* JSX block does not know it: a parse starts from the top of what it is given.
* Tokenising a run-up and discarding it is what keeps page 6 from rendering a
* doc comment as code. The same trick the Flow strip's source windows use, at a
* different scale — 150 lines covers every real comment block; a 3 000-line
+28 -4
View File
@@ -2,7 +2,8 @@
* Turning the server's classified source into tokens the code block can draw.
*
* The classification itself happens on the server (`src/ui-server/highlight/`),
* with real TextMate grammars via Shiki. What arrives is deliberately small:
* off the engine's own tree-sitter parse — the same grammar that decided what
* the file's symbols are. What arrives is deliberately small:
* one array per line, each entry a `[classId, text]` pair, with the class names
* carried alongside so the payload is self-describing. This module does two
* things to it and nothing else — resolve the class ids to names, and compute
@@ -23,7 +24,18 @@
* underline land on the callee's own name whatever boundaries a grammar chose.
*/
export type TokenClass = 'other' | 'ident' | 'comment' | 'string' | 'keyword' | 'number';
export type TokenClass =
| 'other'
| 'ident'
| 'comment'
| 'string'
| 'keyword'
| 'number'
/** A named type reference. Plain ink today — the class is here so a consumer
* of this payload can style it without a second round of server work. */
| 'type'
/** The name a definition declares, from the extractor's own tables. */
| 'def';
export interface Token {
cls: TokenClass;
@@ -36,7 +48,7 @@ export interface Token {
export type WireToken = [number, string];
export interface WireHighlight {
engine: 'shiki' | 'plain';
engine: 'tree-sitter' | 'plain';
grammar: string | null;
classes: string[];
lines: WireToken[][];
@@ -51,6 +63,8 @@ const CLASS_NAMES: ReadonlySet<string> = new Set<TokenClass>([
'string',
'keyword',
'number',
'type',
'def',
]);
/**
@@ -116,7 +130,15 @@ export function tokensByLine(
return byLine;
}
/** The CSS class for a token, or null where the default ink is right. */
/**
* The CSS class for a token, or null where the default ink is right.
*
* `type` deliberately returns null: the design's code colouring is
* near-monochrome and a type name is not one of the four things it moves off
* plain ink. It stays a distinct class on the wire because the classification
* is free once the tree has been walked, and re-deriving it in a consumer would
* not be.
*/
export function tokenClass(cls: TokenClass): string | null {
switch (cls) {
case 'comment':
@@ -127,6 +149,8 @@ export function tokenClass(cls: TokenClass): string | null {
return 't-k';
case 'number':
return 't-n';
case 'def':
return 't-def';
default:
return null;
}