Skip to content

Commit 5db1e1b

Browse files
Emit backend: engine-parity automation, single-source analysis, arena reclamation, lexer cleanup (#53)
1 parent a690bf1 commit 5db1e1b

14 files changed

Lines changed: 1057 additions & 1075 deletions

.github/workflows/ci.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,64 @@ jobs:
5151
- name: Test
5252
run: npm run check
5353

54+
# Engine-parity BREADTH guard. The `test` job already runs the three parity gates
55+
# (emit-parser-verify / emit-reject-messages / emit-lexer-verify) on the corpus-free
56+
# in-repo corpus — that is the standing mechanism that forces a gen-parser change to
57+
# propagate to emit-parser. This job adds the full external TS corpus for breadth, so a
58+
# divergence on some construct the in-repo corpus does not exercise still gets caught.
59+
# Gated on parser/grammar changes (like the treesitter job) so it doesn't clone the
60+
# corpus on doc-only pushes; schedule / workflow_dispatch force the full run.
61+
emit-parity:
62+
runs-on: ubuntu-latest
63+
steps:
64+
- uses: actions/checkout@v5
65+
with:
66+
fetch-depth: 0 # need history to diff against the base for the path gate below
67+
68+
- name: Did the parser/grammar inputs change?
69+
id: changed
70+
run: |
71+
if [ "${{ github.event_name }}" != "push" ] && [ "${{ github.event_name }}" != "pull_request" ]; then
72+
echo "value=true" >> "$GITHUB_OUTPUT"; echo "forced full run (${{ github.event_name }})"; exit 0
73+
fi
74+
if [ "${{ github.event_name }}" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}"; else base="${{ github.event.before }}"; fi
75+
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
76+
echo "value=true" >> "$GITHUB_OUTPUT"; echo "no usable base — running the gate"; exit 0
77+
fi
78+
if git diff --name-only "$base" HEAD | grep -qE '^src/|^[^/]+\.ts$|^test/emit-'; then
79+
echo "value=true" >> "$GITHUB_OUTPUT"; echo "parser/grammar changed — running the breadth gate"
80+
else
81+
echo "value=false" >> "$GITHUB_OUTPUT"; echo "no parser/grammar change — skipping the corpus clone"
82+
fi
83+
84+
- uses: actions/setup-node@v4
85+
if: steps.changed.outputs.value == 'true'
86+
with:
87+
node-version: 24
88+
- if: steps.changed.outputs.value == 'true'
89+
run: npm ci
90+
91+
# Pinned-SHA, shallow, sparse clone of the TS conformance corpus to the fixed path the
92+
# parity gates auto-detect (same pin + technique as the readme-bench workflow).
93+
- name: Clone the pinned TS corpus
94+
if: steps.changed.outputs.value == 'true'
95+
run: |
96+
set -euo pipefail
97+
rm -rf /tmp/ts-repo; mkdir -p /tmp/ts-repo
98+
git -C /tmp/ts-repo init -q
99+
git -C /tmp/ts-repo remote add origin https://github.com/microsoft/TypeScript
100+
git -C /tmp/ts-repo config core.sparseCheckout true
101+
printf 'tests/cases/\n' > /tmp/ts-repo/.git/info/sparse-checkout
102+
git -C /tmp/ts-repo fetch -q --depth 1 --filter=blob:none origin 6fbce89821d93a5b761581d9ac540455f38e9acb
103+
git -C /tmp/ts-repo checkout -q FETCH_HEAD
104+
105+
- name: Engine-parity over the full corpus
106+
if: steps.changed.outputs.value == 'true'
107+
run: |
108+
node test/emit-parser-verify.ts all
109+
node test/emit-reject-messages.ts
110+
node test/emit-lexer-verify.ts
111+
54112
# The derived tree-sitter highlighter is the strongest thesis proof (a real GLR
55113
# parser from the same grammar, beating the official hand-written one). Build its
56114
# wasm and gate the accuracy so the 95.9% is verified, not just claimed. The

TOTAL-PARSING.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,5 +228,10 @@ first-error agreement 57.5%.
228228
determinism on an invalid corpus, a char-by-char typing session, and
229229
exact-match diagnostic pins (synthesis quality must not silently regress to
230230
absorption).
231-
- `test/emit-parser-verify.ts` / `test/emit-lexer-verify.ts` — emitted runtime
232-
≡ interpreter on the corpus, token streams and error messages included.
231+
- `test/emit-parser-verify.ts` / `test/emit-reject-messages.ts` /
232+
`test/emit-lexer-verify.ts` — the emitted runtime ≡ the interpreter (CST,
233+
token streams, and reject messages). They run on a corpus-free in-repo corpus
234+
(`test/emit-corpus.ts`: curated snippets + the repo's own sources), so they are
235+
part of `npm run check` on every machine — the mechanism that forces a
236+
gen-parser change to propagate to emit-parser. The CI `emit-parity` job adds the
237+
full external TS corpus for breadth.

src/emit-lexer.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@ export interface LexerSymtab {
2828

2929
const J = (v: unknown) => JSON.stringify(v);
3030

31+
// The resync retract one-liner is emitted at two points in the relex loop (mid-loop and the
32+
// post-loop EOF check); a single producer keeps the two from drifting (#45 B3).
33+
const resyncRetractLine = (indent: string): string =>
34+
`${indent}if (wndHit >= 0) { tokN--; while (docLex.length > lexDiagBase && docLex[docLex.length - 1].offset >= tkOff[tokN]) docLex.length--; return wndHit; }`;
35+
36+
// The non-ASCII members of JS \s (the /u-free set), baked as a charCode test so a
37+
// non-whitespace cc>127 (e.g. a Unicode identifier char) skips the LX_WS regex entirely. The
38+
// regex `/\s+/y` matches at pos iff the lead char is \s, and ASCII \s is handled by the char
39+
// loop, so `cc>127 && lxNonAsciiWs(cc)` is EXACTLY "the regex would match here" → byte-
40+
// identical, minus the wasted exec on the common non-whitespace case (#45 B4).
41+
const NON_ASCII_WS_FN =
42+
`function lxNonAsciiWs(cc) { return cc === 0xa0 || cc === 0x1680 || (cc >= 0x2000 && cc <= 0x200a) || cc === 0x2028 || cc === 0x2029 || cc === 0x202f || cc === 0x205f || cc === 0x3000 || cc === 0xfeff; }`;
43+
// The non-ASCII whitespace fallback, emitted at the two sites that need it (after an ASCII run,
44+
// and as the lead char). `cont` appends the `continue` the lead-char site needs.
45+
const nonAsciiWsConsume = (v: string, cont: boolean, indent: string): string =>
46+
`${indent}if (${v} > 127 && lxNonAsciiWs(${v})) { LX_WS.lastIndex = pos; const m = LX_WS.exec(source); if (m !== null) { if (m[0].includes('\\n')) pendingNl = true; pos += m[0].length;${cont ? ' continue;' : ''} } }`;
47+
3148
export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
3249
// Out of scope: the markup / indentation / newline state machines.
3350
if (grammar.markup || grammar.indent || grammar.newline) return null;
@@ -103,6 +120,7 @@ export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
103120
emit(`// ── Emitted lexer (emit-lexer.ts): specialized tokenize for this grammar ──`);
104121
for (const m of matchers) emit(`const ${m.re} = new RegExp(${J(`(?:${m.pattern})`)}, ${J(m.flags)});`);
105122
emit(`const LX_WS = /\\s+/y;`);
123+
emit(NON_ASCII_WS_FN);
106124
emit(`// window-truncation retry: a matcher failing at the WINDOW edge is not a lex`);
107125
emit(`// error — the caller re-materializes a larger window (truncation cannot fake a`);
108126
emit(`// resync: suffix-zone equality makes a cut token's END mismatch the old one)`);
@@ -248,6 +266,13 @@ export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
248266
emit(` lexCore(source, 0, -1, 0, -1, 0, 0);`);
249267
emit(` return tokN;`);
250268
emit(`}`);
269+
// Verification of the WINDOWED path (issue #45 B2): emit-lexer-verify only exercises a FULL
270+
// lex (emit ≡ createLexer), and gen-lexer has no windowed counterpart to diff against — but the
271+
// windowed re-lex IS independently checked at the tree level. incremental-verify / exhaustive-
272+
// edits compare an edited parse (whose tokens come from this windowed re-lex) to a FRESH FULL
273+
// parse of the same text, byte-identical: a wrong windowed token would change the tree (or its
274+
// newlineBefore/commentBefore-driven shape) and fail there. So the oracle is the fresh full
275+
// parse, applied transitively through the parser.
251276
emit(`// The lexer core, parameterized for WINDOWED re-lexing: start at startPos with`);
252277
emit(`// the previous token's (k, t) as the regex-context seed (-1 = none / file start)`);
253278
emit(`// and EMPTY template/paren stacks (the caller restarts only at depth-0 safe`);
@@ -359,7 +384,7 @@ export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
359384
emit(` // resync retracts the duplicated token push — and any lexer diagnostics
360385
// emitted FOR it (the old stream's persisted entry survives via the shift;
361386
// keeping the window's copy too double-reports the same character)`);
362-
emit(` if (wndHit >= 0) { tokN--; while (docLex.length > lexDiagBase && docLex[docLex.length - 1].offset >= tkOff[tokN]) docLex.length--; return wndHit; }`);
387+
emit(resyncRetractLine(' '));
363388
emit(` const cc = source.charCodeAt(pos);`);
364389
emit(` // whitespace: ASCII \\s run by char loop; a non-ASCII candidate falls back to the regex`);
365390
emit(` if (cc === 32 || (cc >= 9 && cc <= 13)) {`);
@@ -369,18 +394,10 @@ export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
369394
emit(` pos++;`);
370395
emit(` wc = source.charCodeAt(pos);`);
371396
emit(` } while (wc === 32 || (wc >= 9 && wc <= 13));`);
372-
emit(` if (wc > 127) {`);
373-
emit(` LX_WS.lastIndex = pos;`);
374-
emit(` const m = LX_WS.exec(source);`);
375-
emit(` if (m !== null) { if (m[0].includes('\\n')) pendingNl = true; pos += m[0].length; }`);
376-
emit(` }`);
397+
emit(`${nonAsciiWsConsume('wc', false, ' ')}`);
377398
emit(` continue;`);
378399
emit(` }`);
379-
emit(` if (cc > 127) {`);
380-
emit(` LX_WS.lastIndex = pos;`);
381-
emit(` const m = LX_WS.exec(source);`);
382-
emit(` if (m !== null) { if (m[0].includes('\\n')) pendingNl = true; pos += m[0].length; continue; }`);
383-
emit(` }`);
400+
emit(`${nonAsciiWsConsume('cc', true, ' ')}`);
384401
if (templateToken) {
385402
const tplCloseT = kwFirstCcs.has(tplInterpClose.charCodeAt(0)) ? 'lexKwT(source, startPos, r.end)' : '0';
386403
const tplOpenT = kwFirstCcs.has(tplOpen.charCodeAt(0)) ? 'lexKwT(source, startPos, r.end)' : '0';
@@ -610,7 +627,7 @@ export function emitLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
610627
emit(` }`);
611628
emit(` throw new Error("Unexpected character at offset " + pos + ": '" + source[pos] + "'");`);
612629
emit(` }`);
613-
emit(` if (wndHit >= 0) { tokN--; while (docLex.length > lexDiagBase && docLex[docLex.length - 1].offset >= tkOff[tokN]) docLex.length--; return wndHit; }`);
630+
emit(resyncRetractLine(' '));
614631
emit(` return hasMore ? -2 : -1;`);
615632
emit(`}`);
616633
emit(`// Windowed-relex restart anchor: the last token B ending at/before the damage`);

0 commit comments

Comments
 (0)