@@ -186,6 +186,33 @@ export function createParser(grammar: CstGrammar) {
186186 for ( const led of leds ) ledFirst . set ( led , firstTokenOf ( { type : 'seq' , items : led . items } as RuleExpr ) ) ;
187187 }
188188
189+ // ── Mixfix operand re-bound info ──
190+ // A LED of the shape `<lit L1> $self <lit L2> …` (e.g. a ternary `? $ : $`) has
191+ // an *inner* operand (`$self`, the bit between L1 and L2) that the greedy
192+ // non-backtracking engine may over-consume — swallowing the L2 the operator
193+ // itself needs (the classic conditional-`?:` vs arrow-return-type-`:` clash:
194+ // `b ? (c) : d => e`, where `(c): d => e` parses as one arrow, leaving no `:`).
195+ // When the normal match of such a LED fails, the LED loop retries the inner
196+ // operand with a position cap so it stops before an L2 at the operator's own
197+ // bracket-nesting depth, freeing that L2 for the operator. Language-agnostic:
198+ // keyed purely on the structural shape, no knowledge of `?`/`:`/arrows.
199+ function selfRefName ( e : RuleExpr | undefined , ruleName : string ) : boolean {
200+ return ! ! e && e . type === 'ref' && e . name === ruleName ;
201+ }
202+ type MixfixInfo = { openLit : string ; sepLit : string } ;
203+ const ledMixfix = new Map < object , MixfixInfo > ( ) ;
204+ for ( const [ ruleName , { leds } ] of prattClassified . entries ( ) ) {
205+ for ( const led of leds ) {
206+ const it = led . items ;
207+ if ( it . length >= 4
208+ && it [ 0 ] ?. type === 'literal'
209+ && selfRefName ( it [ 1 ] , ruleName )
210+ && it [ 2 ] ?. type === 'literal' ) {
211+ ledMixfix . set ( led , { openLit : it [ 0 ] . value , sepLit : it [ 2 ] . value } ) ;
212+ }
213+ }
214+ }
215+
189216 // ── FIRST sets ──
190217 // The set of tokens each rule can begin with (null = "anything" — left-recursive
191218 // / prefix-operator rules, which can't be characterized). Used to skip parsing a
@@ -281,8 +308,17 @@ export function createParser(grammar: CstGrammar) {
281308 // token splice (matchLiteral), which shifts later positions.
282309 const memo = new Map < string , Map < number , { node : CstNode | null ; end : number } > > ( ) ;
283310
311+ // Bounded-parse cap (token index). When >= 0, no token at index >= parseLimit
312+ // may be consumed — i.e. a sub-parse is forced to stop before that token. Used
313+ // for the mixfix-operand re-parse (see matchMixfixLed): re-running an over-greedy
314+ // operand so it leaves a required separator for the enclosing mixfix operator.
315+ // `null`-equivalent is -1 (no cap). A capped parse is position+cap dependent,
316+ // so it bypasses the packrat memo (which is keyed by start position only).
317+ let parseLimit = - 1 ;
318+
284319 function peek ( ) : Token | null {
285320 if ( pos > maxPos ) maxPos = pos ;
321+ if ( parseLimit >= 0 && pos >= parseLimit ) return null ;
286322 return tokens [ pos ] ?? null ;
287323 }
288324
@@ -402,11 +438,13 @@ export function createParser(grammar: CstGrammar) {
402438 suppressNext = null ;
403439
404440 // Memoizable (pratt / left-recursive): look up by start position. A
405- // suppressed parse is context-dependent (its result differs from the
406- // allow-`in` parse at the same spot), so it bypasses the memo entirely.
441+ // suppressed parse (no-`in` context) or a capped parse (parseLimit active,
442+ // from the mixfix re-bind retry) is context/position+cap dependent, so it
443+ // bypasses the position-keyed packrat memo entirely.
444+ const capped = parseLimit >= 0 ;
407445 const start = pos ;
408446 let m = memo . get ( name ) ;
409- if ( ! mySup ) {
447+ if ( ! mySup && ! capped ) {
410448 const hit = m && m . get ( start ) ;
411449 if ( hit !== undefined ) { pos = hit . end ; return hit . node ; }
412450 }
@@ -422,7 +460,7 @@ export function createParser(grammar: CstGrammar) {
422460 currentPrattContext = prevContext ;
423461 suppressCur = prevSup ;
424462 }
425- if ( ! mySup ) {
463+ if ( ! mySup && ! capped ) {
426464 if ( ! m ) { m = new Map ( ) ; memo . set ( name , m ) ; }
427465 m . set ( start , { node : result , end : pos } ) ;
428466 }
@@ -565,7 +603,13 @@ export function createParser(grammar: CstGrammar) {
565603 if ( ! canStart ( ledFirst . get ( led ) , tok ) ) continue ; // first-token dispatch for LED continuations
566604
567605 pos = ledSaved ;
568- const children = matchSeq ( led . items ) ;
606+ let children = matchSeq ( led . items ) ;
607+ // Mixfix operand re-bound: if a `<L1> $ <L2> …` LED failed, the inner
608+ // operand may have over-consumed the L2 it needs — retry it capped.
609+ if ( children === null && ledMixfix . has ( led ) ) {
610+ pos = ledSaved ;
611+ children = matchMixfixLed ( led , rule . name , ledMixfix . get ( led ) ! ) ;
612+ }
569613 if ( children !== null ) {
570614 lhs = {
571615 kind : 'node' ,
@@ -674,6 +718,63 @@ export function createParser(grammar: CstGrammar) {
674718 return children ;
675719 }
676720
721+ // Mixfix operand re-bound (see ledMixfix). The LED shape is
722+ // `<openLit> $self <sepLit> …rest`; the normal match already failed. Re-parse
723+ // the inner `$self` operand with a position cap so it stops before a `sepLit`
724+ // token at the *same* bracket depth as `openLit`, freeing that token for the
725+ // operator's own separator, then match `sepLit` and the rest uncapped.
726+ // `pos` is at `openLit` on entry. Returns the LED children or null (restored).
727+ function matchMixfixLed ( led : { items : RuleExpr [ ] } , ruleName : string , info : { openLit : string ; sepLit : string } ) : CstChild [ ] | null {
728+ const saved = pos ;
729+ const openLeaf = matchLiteral ( info . openLit ) ;
730+ if ( ! openLeaf ) { pos = saved ; return null ; }
731+ const afterOpen = pos ;
732+
733+ // Greedy parse of the operand, to (a) confirm the separator is missing right
734+ // after it (otherwise the failure is elsewhere → don't apply) and (b) bound
735+ // the scan window.
736+ const operand = parseRule ( ruleName ) ;
737+ if ( ! operand ) { pos = saved ; return null ; }
738+ const greedyEnd = pos ;
739+ // If the separator DOES match here, the LED's failure was later in `rest`,
740+ // not operand over-consumption — re-bounding wouldn't help.
741+ if ( matchLiteral ( info . sepLit ) ) { pos = saved ; return null ; }
742+
743+ // Candidate separator positions: `sepLit` tokens at bracket depth 0 within
744+ // (afterOpen, greedyEnd). A nested same-shape operator (e.g. a nested
745+ // ternary) contributes its own depth-0 `sepLit`, so we try candidates in
746+ // order and accept the first where the capped operand lands exactly on it.
747+ let depth = 0 ;
748+ const candidates : number [ ] = [ ] ;
749+ for ( let i = afterOpen ; i < greedyEnd ; i ++ ) {
750+ const t = tokens [ i ] ;
751+ if ( t . type !== '' ) continue ; // only punctuation carries brackets/sep
752+ if ( t . text === '(' || t . text === '[' || t . text === '{' ) depth ++ ;
753+ else if ( t . text === ')' || t . text === ']' || t . text === '}' ) depth -- ;
754+ else if ( depth === 0 && t . text === info . sepLit ) candidates . push ( i ) ;
755+ }
756+
757+ for ( const sepIdx of candidates ) {
758+ // Re-parse the operand capped so it cannot consume the token at sepIdx;
759+ // accept only if the operand consumes everything up to exactly there.
760+ pos = afterOpen ;
761+ const prevLimit = parseLimit ;
762+ parseLimit = sepIdx ;
763+ const reOperand = parseRule ( ruleName ) ;
764+ parseLimit = prevLimit ;
765+ if ( ! reOperand || pos !== sepIdx ) continue ;
766+
767+ const sepLeaf = matchLiteral ( info . sepLit ) ;
768+ if ( ! sepLeaf ) continue ;
769+ const rest = matchSeq ( led . items . slice ( 3 ) ) ;
770+ if ( rest === null ) continue ;
771+ return [ openLeaf , reOperand , sepLeaf , ...rest ] ;
772+ }
773+
774+ pos = saved ;
775+ return null ;
776+ }
777+
677778 function matchQuantifier ( body : RuleExpr , kind : '*' | '+' | '?' ) : CstChild [ ] | null {
678779 if ( kind === '?' ) {
679780 const result = matchExpr ( body ) ;
0 commit comments