@@ -179,12 +179,116 @@ export function createParser(grammar: CstGrammar) {
179179 return { atoms, continuations } ;
180180 }
181181
182+ // ── Left recursion = a left-corner cycle ──
183+ // What "left-recursive" MEANS in this engine is the left-corner relation, not the
184+ // syntactic `items[0]===self` shape. A rule is left-recursive iff it can derive
185+ // ITSELF as its leftmost symbol without consuming input — i.e. it can reach itself
186+ // through the transitive closure of the left-corner edge map below. That relation is
187+ // the single source of truth: it captures DIRECT recursion (A → A …), INDIRECT cycles
188+ // (A → B → A) and recursion HIDDEN behind a nullable prefix (A → opt(x) A …) alike,
189+ // all of which re-enter the rule at the same input position. The narrower syntactic
190+ // test `items[0]===self` is NOT the definition; it only identifies which alternatives
191+ // the local atom/continuation (and Pratt NUD/LED) transform can peel into an iterative
192+ // loop — see classifyAlts/classifyLeftRec and the residual graph below.
193+ //
194+ // Nullability feeds the left-corner edges (a nullable leftmost element passes through
195+ // to the next), so compute it first. op/prefix/postfix consume an operator token, so
196+ // they are left-edge BARRIERS, not pass-through.
197+ const nullableRules = new Set < string > ( ) ;
198+ function exprNullable ( e : RuleExpr ) : boolean {
199+ switch ( e . type ) {
200+ case 'literal' : return false ;
201+ case 'ref' : return tokenNames . has ( e . name ) ? false : nullableRules . has ( e . name ) ;
202+ case 'seq' : return e . items . every ( exprNullable ) ;
203+ case 'alt' : return e . items . some ( exprNullable ) ;
204+ case 'quantifier' : return e . kind === '+' ? exprNullable ( e . body ) : true ;
205+ case 'group' : return exprNullable ( e . body ) ;
206+ case 'not' : return true ; // zero-width assertion: consumes nothing
207+ case 'sep' : return true ; // sep matches zero elements
208+ default : return true ; // op/prefix/postfix markers don't consume
209+ }
210+ }
211+ for ( let changed = true ; changed ; ) {
212+ changed = false ;
213+ for ( const rule of grammar . rules ) {
214+ if ( ! nullableRules . has ( rule . name ) && exprNullable ( rule . body ) ) { nullableRules . add ( rule . name ) ; changed = true ; }
215+ }
216+ }
217+ // The set of rules reachable at the LEFT CORNER of an expression: every rule ref that
218+ // could be the leftmost symbol, looking through nullable prefixes and stopping at the
219+ // first non-nullable element or operator barrier.
220+ function leftRuleRefs ( e : RuleExpr ) : Set < string > {
221+ switch ( e . type ) {
222+ case 'ref' : return tokenNames . has ( e . name ) ? new Set ( ) : new Set ( [ e . name ] ) ;
223+ case 'seq' : {
224+ const acc = new Set < string > ( ) ;
225+ for ( const item of e . items ) {
226+ if ( item . type === 'op' || item . type === 'prefix' || item . type === 'postfix' ) break ; // consumes an operator token → barrier
227+ for ( const r of leftRuleRefs ( item ) ) acc . add ( r ) ;
228+ if ( ! exprNullable ( item ) ) break ; // a non-nullable element ends the left edge
229+ }
230+ return acc ;
231+ }
232+ case 'alt' : { const acc = new Set < string > ( ) ; for ( const b of e . items ) for ( const r of leftRuleRefs ( b ) ) acc . add ( r ) ; return acc ; }
233+ case 'quantifier' : case 'group' : return leftRuleRefs ( e . body ) ;
234+ case 'sep' : return leftRuleRefs ( e . element ) ;
235+ default : return new Set ( ) ; // literal / not / sameLine / … : no leftmost rule ref
236+ }
237+ }
238+ function altsOf ( rule : RuleDecl ) : RuleExpr [ ] {
239+ return rule . body . type === 'alt' ? rule . body . items : [ rule . body ] ;
240+ }
241+ function itemsOf ( alt : RuleExpr ) : RuleExpr [ ] {
242+ return alt . type === 'seq' ? alt . items : [ alt ] ;
243+ }
244+ // Does this alternative begin with a DIRECT self-reference (`A → A …`)? This is the
245+ // ONLY thing `items[0]===self` decides: which alts the local transform peels into an
246+ // iterative loop (and so which edges drop out of the residual graph). It is no longer
247+ // a standalone definition of "is this rule left-recursive".
248+ function peelsDirect ( rule : RuleDecl , alt : RuleExpr ) : boolean {
249+ const items = itemsOf ( alt ) ;
250+ return items [ 0 ] ?. type === 'ref' && items [ 0 ] . name === rule . name ;
251+ }
252+ // The PURE left-corner edge map, over ALL alternatives (nothing pre-excluded). This is
253+ // the relation that DEFINES left recursion.
254+ const leftCorner = new Map < string , Set < string > > ( ) ;
255+ for ( const rule of grammar . rules ) {
256+ const edges = new Set < string > ( ) ;
257+ for ( const alt of altsOf ( rule ) ) for ( const r of leftRuleRefs ( alt ) ) edges . add ( r ) ;
258+ leftCorner . set ( rule . name , edges ) ;
259+ }
260+ // The RESIDUAL left-corner edge map: same as `leftCorner` but with each rule's direct
261+ // `items[0]===self` alts removed — those are exactly the edges the local transform
262+ // turns into an iterative loop instead of a recursive descent. A left-recursive rule
263+ // is HANDLEABLE iff peeling its direct self-alts breaks every cycle through it, i.e. it
264+ // can no longer reach itself in this residual graph.
265+ const residualCorner = new Map < string , Set < string > > ( ) ;
266+ for ( const rule of grammar . rules ) {
267+ const edges = new Set < string > ( ) ;
268+ for ( const alt of altsOf ( rule ) ) {
269+ if ( peelsDirect ( rule , alt ) ) continue ; // peeled into an iterative loop → not a recursive descent
270+ for ( const r of leftRuleRefs ( alt ) ) edges . add ( r ) ;
271+ }
272+ residualCorner . set ( rule . name , edges ) ;
273+ }
274+ // Find a cycle start → … → start in a left-corner graph, returned as a path naming the
275+ // genuinely-recursive edges; null if `start` cannot reach itself.
276+ function cornerCycle ( graph : Map < string , Set < string > > , start : string ) : string [ ] | null {
277+ const stack : { node : string ; path : string [ ] } [ ] = [ { node : start , path : [ start ] } ] ;
278+ const seen = new Set < string > ( ) ;
279+ while ( stack . length ) {
280+ const { node, path } = stack . pop ( ) ! ;
281+ for ( const next of graph . get ( node ) ?? [ ] ) {
282+ if ( next === start ) return [ ...path , next ] ;
283+ if ( ! seen . has ( next ) ) { seen . add ( next ) ; stack . push ( { node : next , path : [ ...path , next ] } ) ; }
284+ }
285+ }
286+ return null ;
287+ }
288+ // THE definition of left recursion: the rule reaches itself through the transitive
289+ // closure of the pure left-corner relation.
182290 function isLeftRecursive ( rule : RuleDecl ) : boolean {
183- const alts = rule . body . type === 'alt' ? rule . body . items : [ rule . body ] ;
184- return alts . some ( alt => {
185- const items = alt . type === 'seq' ? alt . items : [ alt ] ;
186- return items [ 0 ] ?. type === 'ref' && items [ 0 ] . name === rule . name ;
187- } ) ;
291+ return cornerCycle ( leftCorner , rule . name ) !== null ;
188292 }
189293
190294 // Maximum binding power for non-operator LED patterns (member access, call, etc.)
@@ -195,8 +299,32 @@ export function createParser(grammar: CstGrammar) {
195299 // Rule lookup, left-recursion, and the NUD/LED (Pratt) / atom-continuation
196300 // (left-rec) classification are functions of the static grammar only, so we
197301 // compute them ONCE here instead of re-deriving them on every parse call.
302+ //
303+ // Left-recursive rules split two ways against the local transform:
304+ // • HANDLEABLE — peeling the direct `items[0]===self` alts breaks every cycle (the
305+ // residual graph is acyclic for this rule). These go in `leftRecSet`, and
306+ // classifyLeftRec / parseLeftRec (or the Pratt NUD/LED path) handle them unchanged.
307+ // • UNHANDLEABLE — a cycle survives in the residual graph (an INDIRECT cycle, or one
308+ // HIDDEN behind a nullable prefix so its first item is not a bare self-ref). The
309+ // local transform cannot peel it, recursive descent would not terminate, so we
310+ // reject it at build time with a diagnostic naming the residual cycle. This is the
311+ // correct product behavior — the engine does not parse indirect/hidden LR.
198312 const ruleByName = new Map < string , RuleDecl > ( grammar . rules . map ( r => [ r . name , r ] ) ) ;
199- const leftRecSet = new Set < string > ( grammar . rules . filter ( isLeftRecursive ) . map ( r => r . name ) ) ;
313+ const leftRecSet = new Set < string > ( ) ;
314+ for ( const rule of grammar . rules ) {
315+ if ( ! isLeftRecursive ( rule ) ) continue ; // not left-recursive (per the relation): ordinary rule
316+ const residual = cornerCycle ( residualCorner , rule . name ) ;
317+ if ( residual ) {
318+ throw new Error (
319+ `Unhandled left recursion in rule '${ rule . name } ': it can derive itself as its leftmost `
320+ + `symbol without consuming input (left-corner cycle ${ residual . join ( ' → ' ) } ). The engine `
321+ + `transforms only DIRECT left recursion (an alternative beginning with the rule itself); `
322+ + `this cycle is indirect or hidden behind a nullable prefix, so recursive descent would `
323+ + `not terminate. Break the cycle or rewrite it as a direct left-recursive/precedence rule.` ,
324+ ) ;
325+ }
326+ leftRecSet . add ( rule . name ) ; // handleable: the residual graph is acyclic
327+ }
200328 const prattClassified = new Map < string , ReturnType < typeof classifyAlts > > ( ) ;
201329 const leftRecClassified = new Map < string , ReturnType < typeof classifyLeftRec > > ( ) ;
202330 for ( const rule of grammar . rules ) {
@@ -333,27 +461,8 @@ export function createParser(grammar: CstGrammar) {
333461 // / prefix-operator rules, which can't be characterized). Used to skip parsing a
334462 // non-nullable rule reference outright when the lookahead can't start it — this
335463 // is what stops e.g. DecoratorExpr/TypeParams being speculatively parsed (and
336- // failing) at every member/parameter position.
337- const nullableRules = new Set < string > ( ) ;
338- function exprNullable ( e : RuleExpr ) : boolean {
339- switch ( e . type ) {
340- case 'literal' : return false ;
341- case 'ref' : return tokenNames . has ( e . name ) ? false : nullableRules . has ( e . name ) ;
342- case 'seq' : return e . items . every ( exprNullable ) ;
343- case 'alt' : return e . items . some ( exprNullable ) ;
344- case 'quantifier' : return e . kind === '+' ? exprNullable ( e . body ) : true ;
345- case 'group' : return exprNullable ( e . body ) ;
346- case 'not' : return true ; // zero-width assertion: consumes nothing
347- case 'sep' : return true ; // sep matches zero elements
348- default : return true ; // op/prefix/postfix markers don't consume
349- }
350- }
351- for ( let changed = true ; changed ; ) {
352- changed = false ;
353- for ( const rule of grammar . rules ) {
354- if ( ! nullableRules . has ( rule . name ) && exprNullable ( rule . body ) ) { nullableRules . add ( rule . name ) ; changed = true ; }
355- }
356- }
464+ // failing) at every member/parameter position. (Nullability and the left-corner
465+ // relation that DEFINES left recursion are computed earlier, above leftRecSet.)
357466 const firstSets = new Map < string , Set < string > | null > ( ) ; // null = top (anything)
358467 function exprFirst ( e : RuleExpr ) : Set < string > | null {
359468 switch ( e . type ) {
0 commit comments