@@ -178,13 +178,6 @@ function analyze(grammar: CstGrammar) {
178178 contMeta . set ( ruleName , continuations . map ( c => mixfixOf ( c , ruleName ) ) ) ;
179179 }
180180
181- // Per-alt first-token (non-recursive + left-rec atoms + pratt nuds use these).
182- const altFirst = new Map < RuleExpr , FirstTok > ( ) ;
183- for ( const rule of grammar . rules ) {
184- const alts = rule . body . type === 'alt' ? rule . body . items : [ rule . body ] ;
185- for ( const alt of alts ) altFirst . set ( alt , firstTokenOf ( alt ) ) ;
186- }
187-
188181 // Nullability.
189182 const nullableRules = new Set < string > ( ) ;
190183 function exprNullable ( e : RuleExpr ) : boolean {
@@ -257,6 +250,19 @@ function analyze(grammar: CstGrammar) {
257250 }
258251 }
259252
253+ // Deep per-alternative FIRST set + nullability for the longest-match dispatch — the
254+ // emitted mirror of gen-parser.ts's altMightStart. An alternative whose FIRST element
255+ // is a rule ref (`Decl …`, `Expr …`) is pruned when the lookahead can't begin that
256+ // rule (resolved through the transitive firstSets), not only when it begins with a
257+ // known literal/token. Sound: exprFirst over-approximates (never omits a startable
258+ // token) and a nullable alt is always tried (its empty match never wins longest-match).
259+ const altDeepFirst = new Map < RuleExpr , Set < string > | null > ( ) ;
260+ const altNullable = new Map < RuleExpr , boolean > ( ) ;
261+ for ( const rule of grammar . rules ) {
262+ const alts = rule . body . type === 'alt' ? rule . body . items : [ rule . body ] ;
263+ for ( const alt of alts ) { altDeepFirst . set ( alt , exprFirst ( alt ) ) ; altNullable . set ( alt , exprNullable ( alt ) ) ; }
264+ }
265+
260266 // ── Lever 1: integer token kinds ──
261267 // Replace the per-call string dispatch in keyMatchesTok / canStartFT /
262268 // ruleMightStart / matchLiteral / matchToken with integer compares. Two int fields
@@ -350,7 +356,7 @@ function analyze(grammar: CstGrammar) {
350356 return {
351357 grammar, tokenNames, opTable, prefixOps, noUnaryLhsOps, postfixOpValues,
352358 prattRules, leftRecSet, ruleByName, prattClassified, leftRecClassified,
353- maxBp, templateTokenName, templateTokenNames, firstTokenOf, altFirst ,
359+ maxBp, templateTokenName, templateTokenNames, firstTokenOf, altDeepFirst , altNullable ,
354360 ledMeta, contMeta, nullableRules, firstSets, symtab,
355361 } ;
356362}
@@ -382,6 +388,12 @@ class Emitter {
382388 // loops (quantifier/sep) never clash with an enclosing construct's loop.
383389 private helpers = new Map < string , string > ( ) ; // structural key → fn name
384390 private helperDefs : string [ ] = [ ] ;
391+ // Deduped FIRST-set descriptor arrays hoisted to module-level consts (keyed by the
392+ // baked literal text). The longest-match alt guards and the rule-ref guards sit on the
393+ // hottest dispatch paths; referencing a shared frozen array avoids allocating a fresh
394+ // [{…},…] on every call. Spliced at the same `//${HELPERS}` sentinel (top-level, above
395+ // the rule fns); only read at runtime inside those fns, so const TDZ is a non-issue.
396+ private descConsts = new Map < string , string > ( ) ; // baked array literal → const name
385397 private a : ReturnType < typeof analyze > ;
386398 constructor ( a : ReturnType < typeof analyze > ) { this . a = a ; }
387399 private id ( ) { return `_t${ this . tmp ++ } ` ; }
@@ -573,12 +585,35 @@ class Emitter {
573585 const a = this . a ;
574586 if ( a . nullableRules . has ( name ) ) return '' ;
575587 const fs = a . firstSets . get ( name ) ;
576- if ( ! fs ) return '' ;
577- // ruleMightStart: true iff some key in fs matches peek(); guard = NOT that.
578- // Pre-classify each FIRST key into an int descriptor (same split keyMatchesTok
579- // does) and bake the descriptor array; the runtime uses integer compares.
580- const descs = [ ...fs ] . map ( k => this . keyDescLiteral ( k ) ) ;
581- return `!ruleMightStartDescs([${ descs . join ( ', ' ) } ], peek())` ;
588+ if ( ! fs || fs . size === 0 ) return '' ;
589+ // ruleMightStart: true iff some key in fs matches peek(); guard = NOT that. Each FIRST
590+ // key is pre-classified into an int descriptor (same split keyMatchesTok does); the
591+ // hoisted shared array means the runtime does integer compares with no allocation.
592+ return `!ruleMightStartDescs(${ this . descArrayConst ( fs ) } , peek())` ;
593+ }
594+
595+ // Deep per-alternative dispatch condition (mirrors gen-parser.ts altMightStart): the
596+ // POSITIVE "this alt might start at startTok" test for the longest-match loops. `true`
597+ // when the alt is nullable or its FIRST set is unknown/empty (always tried — an empty
598+ // match never wins longest-match); else a membership test over the alt's transitive
599+ // FIRST set, baked as a hoisted int-descriptor array (same encoding firstGuard uses).
600+ altGuard ( alt : RuleExpr ) : string {
601+ const a = this . a ;
602+ if ( a . altNullable . get ( alt ) ) return 'true' ;
603+ const fs = a . altDeepFirst . get ( alt ) ;
604+ if ( ! fs || fs . size === 0 ) return 'true' ;
605+ return `ruleMightStartDescs(${ this . descArrayConst ( fs ) } , startTok)` ;
606+ }
607+
608+ // Register (deduped) a FIRST-set's baked int-descriptor array as a module-level const
609+ // and return its NAME. Keys are sorted so two FIRST sets with the same members share
610+ // one const regardless of traversal order (dedup is best-effort; the boolean membership
611+ // result `ruleMightStartDescs` computes is order-independent either way).
612+ descArrayConst ( fs : Set < string > ) : string {
613+ const lit = `[${ [ ...fs ] . sort ( ) . map ( k => this . keyDescLiteral ( k ) ) . join ( ', ' ) } ]` ;
614+ let nm = this . descConsts . get ( lit ) ;
615+ if ( ! nm ) { nm = `_fs${ this . descConsts . size } ` ; this . descConsts . set ( lit , nm ) ; this . helperDefs . push ( `const ${ nm } = ${ lit } ;` ) ; }
616+ return nm ;
582617 }
583618
584619 // ── Lever 1 emit helpers ──
@@ -896,9 +931,8 @@ function emitNonRecRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDec
896931 e . emit ( ` let bestNode = null; let bestPos = saved;` ) ;
897932 e . emit ( ` const startTok = tokens[saved] ?? null;` ) ;
898933 alts . forEach ( ( alt , i ) => {
899- const ft = a . altFirst . get ( alt ) ?? null ;
900934 e . emit ( ` // alt ${ i } ` ) ;
901- e . emit ( ` if (canStartFT( ${ e . firstTokDescLiteral ( ft ) } , startTok) ) {` ) ;
935+ e . emit ( ` if (${ e . altGuard ( alt ) } ) {` ) ;
902936 e . emit ( ` pos = saved;` ) ;
903937 e . emit ( ` const children = arm_${ sanitize ( rule . name ) } _${ i } ();` ) ;
904938 e . emit ( ` if (children !== null && pos > bestPos) {` ) ;
@@ -931,8 +965,7 @@ function emitLeftRecRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDe
931965 e . emit ( ` let node = null; let bestAtomPos = saved;` ) ;
932966 e . emit ( ` const startTok = tokens[saved] ?? null;` ) ;
933967 atoms . forEach ( ( atom , i ) => {
934- const ft = a . altFirst . get ( atom ) ?? null ;
935- e . emit ( ` if (canStartFT(${ e . firstTokDescLiteral ( ft ) } , startTok)) {` ) ;
968+ e . emit ( ` if (${ e . altGuard ( atom ) } ) {` ) ;
936969 e . emit ( ` pos = saved;` ) ;
937970 e . emit ( ` const children = atom_${ sanitize ( rule . name ) } _${ i } ();` ) ;
938971 e . emit ( ` if (children !== null && pos > bestAtomPos) {` ) ;
@@ -987,9 +1020,8 @@ function emitPrattRule(e: Emitter, a: ReturnType<typeof analyze>, rule: RuleDecl
9871020 // NUD loop.
9881021 nuds . forEach ( ( nud , i ) => {
9891022 const items = nud . type === 'seq' ? nud . items : [ nud ] ;
990- const ft = a . altFirst . get ( nud ) ?? null ;
9911023 e . emit ( ` // nud ${ i } ` ) ;
992- e . emit ( ` if (canStartFT( ${ e . firstTokDescLiteral ( ft ) } , startTok) ) {` ) ;
1024+ e . emit ( ` if (${ e . altGuard ( nud ) } ) {` ) ;
9931025 e . emit ( ` pos = saved;` ) ;
9941026 if ( items [ 0 ] ?. type === 'prefix' ) {
9951027 // prefix $ pattern: identical to parsePratt's prefix branch.
0 commit comments