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:
+1
-1
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user