Skip to content

Commit d401809

Browse files
committed
bench: measure Monogram's own tree-sitter through the tsc oracle
Add an opt-in `--engines` mode to highlight-bench that grades Monogram's derived tree-sitter (loaded from a locally-built wasm via MONOGRAM_TS_WASM) with the same family oracle the README chart already uses for the official engines. It stays out of the auto-generated chart because building the wasm needs the wasi-sdk toolchain, which CI does not run; the result (88.8%) is recorded as a hand-written note. Also document the Lezer finding: Monogram's grammar trips Lezer's LR(1) shift/reduce wall (the `<` type-vs-expression ambiguity tsc resolves with semantics), so there is no derived Lezer highlighter number — a ceiling of Lezer's parser class, not a gap in the grammar.
1 parent e08c797 commit d401809

3 files changed

Lines changed: 67 additions & 13 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ TypeScript — token-family accuracy (higher = more correct)
7777
<sub>Graded over 102 ambiguity-rich snippets ([`test/issue-cases.ts`](test/issue-cases.ts)) against tsc's own parse tree. The three parser-based highlighters (Monogram, tree-sitter, Lezer) cluster well above the hand-written TextMate grammar — that gap is the whole point. Per-engine vocabulary→family maps (frozen, auditable): [`test/highlight-engines.ts`](test/highlight-engines.ts). JavaScript: not yet on the bench. Regenerate: `npm run bench:readme`.</sub>
7878
<!-- bench:end -->
7979

80+
> **Why the Monogram row is its *TextMate* output.** That's the one CI can rebuild on every commit. Monogram *also* derives a full **tree-sitter** grammar + query from the same definition; built locally to wasm and graded through this *same* `tsc` oracle, it scores **88.8%** — above both TextMate grammars (its own 87.6%, official 76.2%) and within range of the official parsers (tree-sitter 92.7%, Lezer 89.5%). The lift is *structural*: type references are captured by node position — `(type (ident) @type)` — not a hardcoded builtin list, so it never paints a value as a type. It's opt-in (needs a local wasm build), hence out of the CI chart: `MONOGRAM_TS_WASM=… node test/highlight-bench.ts --corpus adversarial --engines`. *(There is no Monogram-**Lezer** number: the grammar is expressive enough to trip Lezer's LR(1) shift/reduce wall — it parses under tree-sitter's GLR but won't build under Lezer.)*
81+
8082
> **The other side of the ledger (honesty check).** On the *broad* TS parser-conformance corpus — not just the documented bugs — the two are now neck-and-neck: token-role accuracy is **tied at ~99%**, Monogram **leads on per-cell coverage** (100% of strict cells vs official's ~97%), and whole-file-perfect snippets are essentially level (~88% vs ~89%). The small residual is niche multiline type-annotation scoping, **not** the ambiguity class above. Clone the TS corpus to `/tmp/ts-repo` and run `node test/highlight-bench.ts` to see both corpora.
8183
8284
## What you get
@@ -89,10 +91,10 @@ From one grammar definition (a small TypeScript combinator API), three outputs a
8991
- **A VS Code language configuration**`language-configuration.json` (comments, bracket pairs, auto-close/surround, folding) derived from the same tokens.
9092
- **CST node types** — a TypeScript discriminated union (keyed by rule) for typed tree consumers.
9193

92-
And — from the same grammar — **first-pass generators** for the rest of the ecosystem. These are structurally valid and a strong starting point, but each carries known incomplete sections (marked in the output) that need hand-tuning before production:
94+
And — from the same grammar — generators for the rest of the ecosystem, at varying maturity:
9395

94-
- **tree-sitter**`grammar.js` + `queries/highlights.scm` + an external-scanner scaffold (template state machine stubbed).
95-
- **Lezer** — a CodeMirror 6 grammar + `styleTags` + a JS tokenizer (a handful of token regexes can't be expressed in Lezer's static model; marked `INCOMPLETE`).
96+
- **tree-sitter**`grammar.js` + a **structural** `queries/highlights.scm` + an external scanner for context-sensitive lexing. Builds end-to-end (tree-sitter's GLR absorbs the grammar; compiles to wasm) and the *derived* query scores **88.8%** through the same oracle as the chart above — above both TextMate grammars, just under the official parsers. Opt-in (needs a local wasm build), so it stays out of the CI chart.
97+
- **Lezer** — a CodeMirror 6 grammar + `styleTags` + a JS tokenizer. Does **not** build: the grammar trips Lezer's **LR(1) shift/reduce wall** (TypeScript's `<` type-vs-expression ambiguity, which `tsc` resolves with semantic information LR(1) lacks). That's a ceiling of Lezer's parser class rather than a gap in the grammar — so there is no derived Lezer highlighter number.
9698
- **Monarch** — a Monaco (web) tokenizer (functional, bounded by JS-regex limits).
9799

98100
## The grammar is the source of truth

test/highlight-bench.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import onig from 'vscode-oniguruma';
3737
import { R, ROLE_SPEC, gradeScope, isCorrect, normScope, roleFamily, acceptableFamilies } from './scope-roles.ts';
3838
import type { RoleName, Verdict, Family } from './scope-roles.ts';
3939
import { tests as issueTests, multiLineTests as issueMultiLine } from './issue-cases.ts';
40-
import { scopeFamily, lezerFamilies, treesitterFamilies, loadTreeSitter, familyAt } from './highlight-engines.ts';
40+
import { scopeFamily, lezerFamilies, treesitterFamilies, loadTreeSitter, familyAt, loadMonogramTreeSitter, monogramTreesitterFamilies } from './highlight-engines.ts';
4141
import type { Span } from './highlight-engines.ts';
4242

4343
const normScopeShort = (s: string): string => (s ? normScope(s) : '(none)');
@@ -589,13 +589,16 @@ const tmSpans = (grammar: vsctm.IGrammar, text: string): Span[] =>
589589
tmTokenize(grammar, text).map((t) => ({ start: t.start, end: t.end, family: scopeFamily(t.scope) }));
590590

591591
// Grade each engine's token-FAMILY classification against the tsc oracle over `inputs`.
592-
async function engineFamilyScores(inputs: { name: string; text: string }[], tsOk: boolean): Promise<EngineScore[]> {
592+
// mtsOk = Monogram's own compiled tree-sitter is available (opt-in via MONOGRAM_TS_WASM;
593+
// kept OUT of the auto-chart since CI can't build that wasm — surfaced only by --engines).
594+
async function engineFamilyScores(inputs: { name: string; text: string }[], tsOk: boolean, mtsOk = false): Promise<EngineScore[]> {
593595
const engines: { name: string; spans: (t: string) => Span[] }[] = [
594596
{ name: 'Monogram (TextMate, derived)', spans: (t) => tmSpans(monogramGrammar, t) },
595597
{ name: 'official TextMate', spans: (t) => tmSpans(officialGrammar, t) },
596598
{ name: 'official Lezer', spans: lezerFamilies },
597599
];
598600
if (tsOk) engines.splice(2, 0, { name: 'official tree-sitter', spans: treesitterFamilies });
601+
if (mtsOk) engines.push({ name: 'Monogram (tree-sitter, derived)', spans: monogramTreesitterFamilies });
599602

600603
const acc: Record<string, { correct: number; total: number }> = {};
601604
for (const e of engines) acc[e.name] = { correct: 0, total: 0 };
@@ -725,10 +728,30 @@ if (WRITE_README) {
725728
...issueMultiLine.map((t) => ({ name: t.label, text: t.lines.join('\n') })),
726729
];
727730
const tsOk = await loadTreeSitter();
728-
const scores = await engineFamilyScores(advInputs, tsOk);
731+
const scores = await engineFamilyScores(advInputs, tsOk); // auto-chart: 4 engines (CI-reproducible)
729732
const graded = advInputs.filter((i) => {
730733
const sf = ts.createSourceFile('c.ts', i.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
731734
return ((sf as any).parseDiagnostics?.length ?? 0) === 0;
732735
}).length;
733736
writeReadmeBlock(buildBenchMarkdown(scores, graded, tsOk));
734737
}
738+
739+
// --engines: print per-engine family accuracy to the terminal (NOT the README).
740+
// Set MONOGRAM_TS_WASM (+ MONOGRAM_TS_QUERY) to also grade Monogram's OWN compiled
741+
// tree-sitter — the opt-in measurement that needs a locally-built wasm.
742+
if (argv.includes('--engines')) {
743+
const advInputs = [
744+
...issueTests.map((t) => ({ name: t.label, text: t.input })),
745+
...issueMultiLine.map((t) => ({ name: t.label, text: t.lines.join('\n') })),
746+
];
747+
const tsOk = await loadTreeSitter();
748+
const mtsWasm = process.env.MONOGRAM_TS_WASM;
749+
const mtsOk = mtsWasm
750+
? await loadMonogramTreeSitter(mtsWasm, process.env.MONOGRAM_TS_QUERY ?? 'examples/tree-sitter/typescript/queries/highlights.scm')
751+
: false;
752+
const scores = await engineFamilyScores(advInputs, tsOk, mtsOk);
753+
console.log('\n── token-family accuracy vs tsc oracle (issue-cases corpus) ──');
754+
for (const s of [...scores].sort((a, b) => b.correct / b.total - a.correct / a.total)) {
755+
console.log(` ${s.name.padEnd(32)} ${((s.correct / s.total) * 100).toFixed(1)}% (${s.correct}/${s.total})`);
756+
}
757+
}

test/highlight-engines.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,13 @@ export async function loadTreeSitter(): Promise<boolean> {
127127
}
128128
}
129129

130-
export function treesitterFamilies(code: string): Span[] {
131-
if (!tsParser || !tsQuery) throw new Error('call loadTreeSitter() first');
132-
const tree = tsParser.parse(code);
133-
// Per captured node keep the family from the HIGHEST pattern index — tree-sitter
134-
// highlighting is "last matching pattern wins", which is why the TS additions'
135-
// capitalized-identifier `@type` rule overrides the ecma base `@variable`.
130+
// Shared capture→family reduction. Per captured node keep the family from the
131+
// HIGHEST pattern index — tree-sitter highlighting is "last matching pattern wins"
132+
// (e.g. the TS additions' capitalized-identifier `@type` overrides the ecma base `@variable`).
133+
function tsFamiliesWith(parser: any, query: any, code: string): Span[] {
134+
const tree = parser.parse(code);
136135
const best = new Map<string, { start: number; end: number; family: Family; pat: number }>();
137-
for (const m of tsQuery.matches(tree.rootNode)) {
136+
for (const m of query.matches(tree.rootNode)) {
138137
const pat = m.patternIndex ?? m.pattern ?? 0;
139138
for (const c of m.captures) {
140139
const fam = tsCaptureToFamily(c.name);
@@ -148,6 +147,36 @@ export function treesitterFamilies(code: string): Span[] {
148147
return [...best.values()].sort((a, b) => a.start - b.start || a.end - b.end);
149148
}
150149

150+
export function treesitterFamilies(code: string): Span[] {
151+
if (!tsParser || !tsQuery) throw new Error('call loadTreeSitter() first');
152+
return tsFamiliesWith(tsParser, tsQuery, code);
153+
}
154+
155+
// Monogram's OWN generated tree-sitter (compiled from examples/tree-sitter, loaded
156+
// from a prebuilt wasm). Gated behind explicit paths because building that wasm needs
157+
// the wasi-sdk toolchain — not something CI does — so it's a local/opt-in measurement.
158+
let mtsParser: any = null;
159+
let mtsQuery: any = null;
160+
export async function loadMonogramTreeSitter(wasmPath: string, queryPath: string): Promise<boolean> {
161+
if (mtsParser) return true;
162+
try {
163+
const { Parser, Language, Query } = await import('web-tree-sitter');
164+
await Parser.init();
165+
const lang = await Language.load(wasmPath);
166+
mtsParser = new Parser();
167+
mtsParser.setLanguage(lang);
168+
mtsQuery = new Query(lang, readFileSync(queryPath, 'utf8'));
169+
return true;
170+
} catch (e) {
171+
console.error('monogram tree-sitter init failed:', (e as Error).message);
172+
return false;
173+
}
174+
}
175+
export function monogramTreesitterFamilies(code: string): Span[] {
176+
if (!mtsParser || !mtsQuery) throw new Error('call loadMonogramTreeSitter() first');
177+
return tsFamiliesWith(mtsParser, mtsQuery, code);
178+
}
179+
151180
// ─── self-test: dump families for a diagnostic snippet across all engines ───────
152181
if (import.meta.url === `file://${process.argv[1]}`) {
153182
const code = 'const x: NS.Foo = bar(Baz); function g(p: number){ return p; } class C { m(): void {} } /* c */ const re = /ab/g;';

0 commit comments

Comments
 (0)