Skip to content

Commit a4ef43d

Browse files
committed
Generate tree-sitter, Lezer, Monarch, and CST-type targets
From the one grammar, four more generators (each with a smoke test): - gen-treesitter.ts -> grammar.js + queries/highlights.scm + external-scanner scaffold - gen-lezer.ts -> CodeMirror 6 grammar + styleTags + JS external tokenizer - gen-monarch.ts -> Monaco tokenizer (context-based type highlighting + regex disambiguation, improving on Monaco's hand-written capitalization heuristic) - gen-ast-types.ts -> TypeScript CST types (discriminated union keyed by rule) Every highlighter target reuses gen-tm's structural scope-inference, retargeted per format, so highlighting stays consistent across ecosystems. cli.ts emits all of them; CI's gen-sync check covers the new artifacts. Also includes an InterfaceMember grammar refactor (mapped/index split) that lifts parser conformance to 3584/3776 (+5, 0 regressions). The tree-sitter C scanner and some precedence/typing edges are first-pass scaffolds — see ROADMAP.
1 parent 15afd7d commit a4ef43d

22 files changed

Lines changed: 6571 additions & 12 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
- name: Generate editor artifacts (must be in sync)
3030
run: |
3131
node src/cli.ts examples/typescript.ts
32-
git diff --exit-code -- examples/typescript.tmLanguage.json examples/typescript.language-configuration.json
32+
git diff --exit-code -- examples/typescript.tmLanguage.json examples/typescript.language-configuration.json examples/typescript.monarch.json examples/typescript.cst-types.ts examples/tree-sitter examples/lezer
3333
3434
# Self-contained suites (each exits non-zero on failure). The conformance /
3535
# coverage / bench tools are excluded here: they need the external TypeScript

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
node_modules
2+
.claude/

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ From one grammar definition (a small TypeScript combinator API):
2525
- **A TextMate grammar** — a `.tmLanguage.json` for editor syntax highlighting, derived from the same rules.
2626
- **A VS Code language configuration** — a `language-configuration.json` (comments, bracket pairs, auto-closing/surrounding pairs, folding, indentation) for editor behavior.
2727

28+
And — from the same grammar — first-pass generators for the rest of the editor ecosystem (validated structurally; some parts are scaffolds):
29+
30+
- **tree-sitter**`grammar.js` + `queries/highlights.scm` + an external-scanner scaffold (Neovim / Helix / Zed / GitHub).
31+
- **Lezer** — a CodeMirror 6 grammar + `styleTags` + a JS external tokenizer.
32+
- **Monarch** — a Monaco (web) tokenizer.
33+
- **CST node types** — TypeScript types (a discriminated union keyed by rule) for typed tree consumers.
34+
2835
## Results
2936

3037
Validated on TypeScript (grammar: [`examples/typescript.ts`](examples/typescript.ts), 521 lines):
@@ -152,13 +159,18 @@ examples/typescript.ts one grammar (TypeScript combinator API)
152159
│ run against the conformance suite = the grammar's proof)
153160
154161
├─ src/gen-tm.ts ───────────▶ typescript.tmLanguage.json (TextMate highlighter)
155-
156-
└─ src/gen-vscode-config.ts ▶ typescript.language-configuration.json (editor behavior)
162+
├─ src/gen-vscode-config.ts ▶ typescript.language-configuration.json (editor behavior)
163+
├─ src/gen-treesitter.ts ───▶ tree-sitter/ (grammar.js + highlights.scm + scanner.c)
164+
├─ src/gen-lezer.ts ────────▶ lezer/ (grammar + styleTags + tokenizer)
165+
├─ src/gen-monarch.ts ──────▶ typescript.monarch.json
166+
└─ src/gen-ast-types.ts ────▶ typescript.cst-types.ts
157167
158168
shared src/grammar-utils.ts structural helpers used across stages
159169
src/api.ts, types.ts the grammar's combinator + type surface
160170
```
161171

172+
Every highlighter target (TextMate, tree-sitter queries, Lezer styleTags, Monarch) is produced by the *same* structural scope-inference (`gen-tm`), retargeted per format — so highlighting stays consistent across ecosystems.
173+
162174
- **One grammar, many derived artifacts.** `gen-lexer` builds a tokenizer from the token definitions + lexer hints; `gen-parser` composes that lexer and interprets the rules to build a CST; `gen-tm` reads the same rule *shapes* to derive TextMate patterns; `gen-vscode-config` derives the editor config (comments, brackets, auto-close) from the same tokens and `scopes`. Shared structural primitives (`grammar-utils.ts`) — e.g. one keyword/punctuation predicate — keep them consistent.
163175
- **CST, not AST.** The parser keeps every token (punctuation, keywords) as a node — required for the highlighter and for lossless source reconstruction. Roughly 2× the nodes of an AST, by design.
164176
- **Every stage is language-agnostic.** All language specifics live in the grammar; lexer, parser and generator are generic, reusable runtimes.

ROADMAP.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Monogram — Roadmap
22

3+
## Generation targets (one grammar → every ecosystem)
4+
5+
All emitted by `node src/cli.ts examples/typescript.ts`. Highlighting for every target is derived from one structural inference (`gen-tm`), retargeted per format.
6+
7+
| Target | Artifact | Status |
8+
|---|---|---|
9+
| lexer | `createLexer` → tokens ||
10+
| CST parser | `createParser` → CST | ✅ 94.9% conformance (3584/3776) |
11+
| TextMate | `.tmLanguage.json` | ✅ 99.3% vs VS Code |
12+
| VS Code language-config | `.language-configuration.json` | ✅ comments/folding/`/**` match official |
13+
| tree-sitter | `grammar.js` + `queries/highlights.scm` + `scanner.c` | 🟡 first pass — external C scanner is a scaffold |
14+
| Lezer | grammar + `styleTags` + JS tokenizer | 🟡 first pass — Pratt→precedence may need hand-tuning |
15+
| Monarch | Monaco tokenizer JSON | 🟡 first pass — JS-regex bounded (still beats Monaco's capitalization heuristic) |
16+
| CST node types | TS discriminated union | 🟡 structural — named-field accessors need grammar field labels |
17+
18+
Remaining: finish the tree-sitter C scanner; hand-tune Lezer operator precedence; (optional) add field labels to the grammar DSL for richer AST types.
19+
320
## Current State (v2.5)
421

522
**Authoring format**: TypeScript API (`token()`, `rule()`, `defineGrammar()`)

examples/lezer/highlight.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { styleTags, tags as t } from "@lezer/highlight";
2+
3+
// Contextual definition-name positions (give these their own node names):
4+
// after "class" → name node should be styled t.typeName (entity.name.type)
5+
// after "interface" → name node should be styled t.typeName (entity.name.type)
6+
// after "type" → name node should be styled t.typeName (entity.name.type)
7+
// after "enum" → name node should be styled t.typeName (entity.name.type)
8+
// after "namespace" → name node should be styled t.typeName (entity.name.type)
9+
10+
export const highlighting = styleTags({
11+
"Shebang": t.lineComment,
12+
"JSDoc": t.docComment,
13+
"TripleSlash": t.lineComment,
14+
"LineComment": t.lineComment,
15+
"BlockComment": t.blockComment,
16+
"HexNumber": t.number,
17+
"OctalNumber": t.number,
18+
"BinaryNumber": t.number,
19+
"BigInt": t.number,
20+
"Number": t.number,
21+
"String": t.string,
22+
"Template": t.special(t.string),
23+
"Regex": t.regexp,
24+
"Decorator": t.meta,
25+
"PrivateField": t.propertyName,
26+
"Ident": t.variableName,
27+
"kw_as/kw_asserts/kw_delete/kw_infer/kw_instanceof/kw_is/kw_keyof/kw_new/kw_satisfies/kw_typeof/kw_void": t.operatorKeyword,
28+
"kw_abstract/kw_accessor/kw_async/kw_declare/kw_override/kw_private/kw_protected/kw_public/kw_readonly/kw_static": t.modifier,
29+
"kw_extends/kw_implements/kw_meta/kw_out/kw_unique": t.keyword,
30+
"kw_false/kw_true": t.bool,
31+
"kw_null/kw_undefined": t.null,
32+
"kw_super/kw_this": t.special(t.variableName),
33+
"kw_any/kw_bigint/kw_boolean/kw_never/kw_number/kw_object/kw_string/kw_symbol/kw_unknown": t.standard(t.typeName),
34+
"kw_export/kw_from/kw_import": t.moduleKeyword,
35+
"kw_await/kw_break/kw_case/kw_catch/kw_continue/kw_debugger/kw_default/kw_do/kw_else/kw_finally/kw_for/kw_if/kw_in/kw_of/kw_return/kw_switch/kw_throw/kw_try/kw_while/kw_with/kw_yield": t.controlKeyword,
36+
"kw_class/kw_const/kw_constructor/kw_enum/kw_function/kw_get/kw_interface/kw_let/kw_module/kw_namespace/kw_set/kw_type/kw_using/kw_var": t.definitionKeyword,
37+
"kw_Array/kw_Date/kw_Error/kw_Function/kw_Map/kw_Object/kw_Promise/kw_RegExp/kw_Set/kw_Symbol/kw_WeakMap/kw_WeakSet": t.standard(t.className),
38+
"kw_console/kw_document/kw_exports/kw_global/kw_globalThis/kw_process/kw_require/kw_window": t.standard(t.variableName),
39+
"Comma": t.separator,
40+
"BracketL/BracketR": t.squareBracket,
41+
"Op_26/Op_3c3c/Op_3e3e/Op_3e3e3e/Op_5e/Op_7c": t.bitwiseOperator,
42+
"ParenL/ParenR": t.paren,
43+
"Arrow": t.function(t.punctuation),
44+
"BraceL/BraceR": t.brace,
45+
"Semi": t.punctuation,
46+
"Op_25/Op_2a/Op_2a2a/Op_2b/Op_2d/Op_2f": t.arithmeticOperator,
47+
"QuestionDot": t.derefOperator,
48+
"Op_253d/Op_26263d/Op_263d/Op_2a2a3d/Op_2a3d/Op_2b3d/Op_2d3d/Op_2f3d/Op_3c3c3d/Op_3d/Op_3e3e3d/Op_3e3e3e3d/Op_3f3f3d/Op_5e3d/Op_7c3d/Op_7c7c3d": t.definitionOperator,
49+
"Op_21/Op_2626/Op_3f3f/Op_7c7c/Op_7e": t.logicOperator,
50+
"Op_213d/Op_213d3d/Op_3d3d/Op_3d3d3d": t.compareOperator,
51+
"Op_2b2b/Op_2d2d": t.operator,
52+
});

examples/lezer/tokens.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// External tokenizer for typescript (generated by gen-lezer.ts).
2+
//
3+
// Lezer external tokenizers are plain JS, so we implement the context-sensitive
4+
// lexing that the grammar's token HINTS describe — the same logic gen-lexer.ts
5+
// uses — more fully than a Lezer-native C scanner could. Wire the exported
6+
// tokenizers into the .grammar via:
7+
//
8+
// @external tokens contextTokens from "./tokens.js" { Regex, Template, TemplateHead, TemplateMiddle, TemplateTail }
9+
//
10+
// This module must also supply these tokens that exceeded Lezer's @tokens syntax
11+
// (straightforward longest-match scanners — INCOMPLETE, regex listed for porting):
12+
// JSDoc: /\/\*\*[\s\S]*?\*\// (skip)
13+
// BlockComment: /\/\*[\s\S]*?\*\// (skip)
14+
// Ident: /(?:[a-zA-Z_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})(?:[a-zA-Z0-9_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})*/
15+
// Number: /[0-9]+(_[0-9]+)*(?:\.[0-9]*(_[0-9]+)*)?(?:[eE][+-]?[0-9]+)?/
16+
// String: /"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'/
17+
// Decorator: /@(?:[a-zA-Z_$][a-zA-Z0-9_$.]*)?/
18+
//
19+
// NOTE: `@lezer/generator` passes the matching term ids in; the names below must
20+
// match the @external tokens { ... } list. Replace the term imports accordingly.
21+
22+
import { ExternalTokenizer } from "@lezer/lr";
23+
// import { Regex, Template, TemplateHead, TemplateMiddle, TemplateTail } from "./parser.terms.js";
24+
25+
// Characters/texts after which `/` is DIVISION (value-producing), not a regex.
26+
const divisionAfterTexts = [")","]","++","--","this","super","true","false","null","undefined"];
27+
// Keywords that re-enter expression position, so `/` after them IS a regex.
28+
const regexAfterTexts = ["in","of","instanceof","typeof","delete","void","await","yield","throw","return","case","do","else","new"];
29+
30+
// The JS regex for a regex literal (from the grammar's @regex token).
31+
// Built via RegExp(source) so the pattern's own \/ escapes survive verbatim
32+
// (embedding in a /.../ literal would double-escape them). 'y' = sticky.
33+
const REGEX_LITERAL = new RegExp("\\/(?:[^\\/\\\\\\[\\n]|\\\\.|\\[(?:[^\\]\\\\\\n]|\\\\.)*\\])+\\/[gimsuydv]*", "y");
34+
35+
const IDENT_CHAR = /[\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Mc}\p{Pc}_$]/u;
36+
37+
/**
38+
* Regex-vs-division tokenizer. Lezer calls this where a `Regex` token is valid.
39+
* We decide using the PREVIOUS significant token's text/type, mirroring
40+
* gen-lexer.ts's `divisionPrevTexts`/`expressionStartKeywords` logic.
41+
*
42+
* INCOMPLETE: Lezer's `InputStream` does not expose the previous token's TYPE,
43+
* only characters. We approximate the "division after a value" rule by scanning
44+
* backwards over whitespace to the previous non-space char and checking the
45+
* divisionAfterTexts set. Token-TYPE-sensitive cases (Ident/Number/String/…)
46+
* are handled by Lezer's own tokens taking priority; this hook only fires the
47+
* regex when a `/` is genuinely in expression position.
48+
*/
49+
export const contextTokens = new ExternalTokenizer((input, stack) => {
50+
const next = input.next;
51+
if (next !== 47 /* '/' */) return;
52+
53+
// Look back at the previous non-whitespace char already consumed.
54+
let back = -1;
55+
let prev = input.peek(back);
56+
while (prev === 32 || prev === 9 || prev === 10 || prev === 13) { back--; prev = input.peek(back); }
57+
58+
if (prev >= 0) {
59+
const prevChar = String.fromCharCode(prev);
60+
// After a closing bracket / identifier char / digit → division, not regex.
61+
if (prevChar === ")" || prevChar === "]" || prevChar === "}") return;
62+
if (/[A-Za-z0-9_$]/.test(prevChar)) {
63+
// Could be an identifier OR an expression-start keyword (return, typeof, …).
64+
// Read the whole preceding word and consult regexAfterTexts.
65+
let w = "", b = back;
66+
let ch = input.peek(b);
67+
while (ch >= 0 && /[A-Za-z0-9_$]/.test(String.fromCharCode(ch))) { w = String.fromCharCode(ch) + w; b--; ch = input.peek(b); }
68+
if (!regexAfterTexts.includes(w)) return; // a plain identifier → division
69+
// else: keyword like `return` → fall through and match a regex literal
70+
}
71+
}
72+
73+
// Try to match a regex literal at the current position.
74+
REGEX_LITERAL.lastIndex = 0;
75+
let s = "";
76+
// Re-read from the input stream char-by-char to feed the sticky regex.
77+
// (InputStream is forward-only; accumulate then test.)
78+
let i = 0, c = input.peek(i);
79+
// Bound the scan to a single line to avoid runaway on malformed input.
80+
while (c >= 0 && c !== 10 && i < 5000) { s += String.fromCharCode(c); i++; c = input.peek(i); }
81+
const m = REGEX_LITERAL.exec(s);
82+
if (m && m.index === 0) {
83+
input.advance(m[0].length);
84+
input.acceptToken(/* Regex */ 1);
85+
}
86+
});
87+
88+
// ── Template-literal tokenizer ──
89+
// Splits an interpolated template into Head / Middle / Tail around `${…}`
90+
// holes, matching gen-lexer.ts's scanTemplateSpan. Delimiters come from the
91+
// grammar's template token hint (language-agnostic).
92+
const TPL_OPEN = "`";
93+
const TPL_INTERP_OPEN = "${";
94+
const TPL_INTERP_CLOSE = "}";
95+
96+
/**
97+
* INCOMPLETE: a production template tokenizer must track interpolation-brace
98+
* nesting depth across tokenizer invocations (Lezer re-enters per token). The
99+
* scaffold below tokenizes a non-interpolated template fully and emits a
100+
* Head token up to the first interpolation; the Middle/Tail transitions need a
101+
* small stack threaded through the parser's context (`stack`). Mark and finish
102+
* when wiring into a concrete @lezer/lr build.
103+
*/
104+
export const templateTokens = new ExternalTokenizer((input, stack) => {
105+
let i = 0;
106+
// expect the opening delimiter
107+
for (let k = 0; k < TPL_OPEN.length; k++) {
108+
if (input.peek(i) !== TPL_OPEN.charCodeAt(k)) return;
109+
i++;
110+
}
111+
// scan to closing delimiter or interpolation hole
112+
while (true) {
113+
const c = input.peek(i);
114+
if (c < 0) return; // unterminated
115+
if (c === 92 /* \\ */) { i += 2; continue; }
116+
if (startsWith(input, i, TPL_INTERP_OPEN)) {
117+
input.advance(i + TPL_INTERP_OPEN.length);
118+
input.acceptToken(/* TemplateHead */ 2); // INCOMPLETE: distinguish Head vs Middle via stack
119+
return;
120+
}
121+
if (startsWith(input, i, TPL_OPEN)) {
122+
input.advance(i + TPL_OPEN.length);
123+
input.acceptToken(/* Template */ 3);
124+
return;
125+
}
126+
i++;
127+
}
128+
});
129+
130+
function startsWith(input, at, s) {
131+
for (let k = 0; k < s.length; k++) {
132+
if (input.peek(at + k) !== s.charCodeAt(k)) return false;
133+
}
134+
return true;
135+
}

0 commit comments

Comments
 (0)