@@ -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
@@ -390,10 +426,15 @@ export function createParser(grammar: CstGrammar) {
390426 if ( ! isPratt && ! isLeftRec ) return parseNonRec ( rule ) ;
391427
392428 // Memoizable (pratt / left-recursive): look up by start position.
429+ // A capped parse (parseLimit active) is position+cap dependent, so it must
430+ // not read or write the position-keyed packrat memo.
431+ const capped = parseLimit >= 0 ;
393432 const start = pos ;
394433 let m = memo . get ( name ) ;
395- const hit = m && m . get ( start ) ;
396- if ( hit !== undefined ) { pos = hit . end ; return hit . node ; }
434+ if ( ! capped ) {
435+ const hit = m && m . get ( start ) ;
436+ if ( hit !== undefined ) { pos = hit . end ; return hit . node ; }
437+ }
397438
398439 const prevContext = currentPrattContext ;
399440 currentPrattContext = name ;
@@ -403,8 +444,10 @@ export function createParser(grammar: CstGrammar) {
403444 } finally {
404445 currentPrattContext = prevContext ;
405446 }
406- if ( ! m ) { m = new Map ( ) ; memo . set ( name , m ) ; }
407- m . set ( start , { node : result , end : pos } ) ;
447+ if ( ! capped ) {
448+ if ( ! m ) { m = new Map ( ) ; memo . set ( name , m ) ; }
449+ m . set ( start , { node : result , end : pos } ) ;
450+ }
408451 return result ;
409452 }
410453
@@ -541,7 +584,13 @@ export function createParser(grammar: CstGrammar) {
541584 if ( ! canStart ( ledFirst . get ( led ) , tok ) ) continue ; // first-token dispatch for LED continuations
542585
543586 pos = ledSaved ;
544- const children = matchSeq ( led . items ) ;
587+ let children = matchSeq ( led . items ) ;
588+ // Mixfix operand re-bound: if a `<L1> $ <L2> …` LED failed, the inner
589+ // operand may have over-consumed the L2 it needs — retry it capped.
590+ if ( children === null && ledMixfix . has ( led ) ) {
591+ pos = ledSaved ;
592+ children = matchMixfixLed ( led , rule . name , ledMixfix . get ( led ) ! ) ;
593+ }
545594 if ( children !== null ) {
546595 lhs = {
547596 kind : 'node' ,
@@ -647,6 +696,63 @@ export function createParser(grammar: CstGrammar) {
647696 return children ;
648697 }
649698
699+ // Mixfix operand re-bound (see ledMixfix). The LED shape is
700+ // `<openLit> $self <sepLit> …rest`; the normal match already failed. Re-parse
701+ // the inner `$self` operand with a position cap so it stops before a `sepLit`
702+ // token at the *same* bracket depth as `openLit`, freeing that token for the
703+ // operator's own separator, then match `sepLit` and the rest uncapped.
704+ // `pos` is at `openLit` on entry. Returns the LED children or null (restored).
705+ function matchMixfixLed ( led : { items : RuleExpr [ ] } , ruleName : string , info : { openLit : string ; sepLit : string } ) : CstChild [ ] | null {
706+ const saved = pos ;
707+ const openLeaf = matchLiteral ( info . openLit ) ;
708+ if ( ! openLeaf ) { pos = saved ; return null ; }
709+ const afterOpen = pos ;
710+
711+ // Greedy parse of the operand, to (a) confirm the separator is missing right
712+ // after it (otherwise the failure is elsewhere → don't apply) and (b) bound
713+ // the scan window.
714+ const operand = parseRule ( ruleName ) ;
715+ if ( ! operand ) { pos = saved ; return null ; }
716+ const greedyEnd = pos ;
717+ // If the separator DOES match here, the LED's failure was later in `rest`,
718+ // not operand over-consumption — re-bounding wouldn't help.
719+ if ( matchLiteral ( info . sepLit ) ) { pos = saved ; return null ; }
720+
721+ // Candidate separator positions: `sepLit` tokens at bracket depth 0 within
722+ // (afterOpen, greedyEnd). A nested same-shape operator (e.g. a nested
723+ // ternary) contributes its own depth-0 `sepLit`, so we try candidates in
724+ // order and accept the first where the capped operand lands exactly on it.
725+ let depth = 0 ;
726+ const candidates : number [ ] = [ ] ;
727+ for ( let i = afterOpen ; i < greedyEnd ; i ++ ) {
728+ const t = tokens [ i ] ;
729+ if ( t . type !== '' ) continue ; // only punctuation carries brackets/sep
730+ if ( t . text === '(' || t . text === '[' || t . text === '{' ) depth ++ ;
731+ else if ( t . text === ')' || t . text === ']' || t . text === '}' ) depth -- ;
732+ else if ( depth === 0 && t . text === info . sepLit ) candidates . push ( i ) ;
733+ }
734+
735+ for ( const sepIdx of candidates ) {
736+ // Re-parse the operand capped so it cannot consume the token at sepIdx;
737+ // accept only if the operand consumes everything up to exactly there.
738+ pos = afterOpen ;
739+ const prevLimit = parseLimit ;
740+ parseLimit = sepIdx ;
741+ const reOperand = parseRule ( ruleName ) ;
742+ parseLimit = prevLimit ;
743+ if ( ! reOperand || pos !== sepIdx ) continue ;
744+
745+ const sepLeaf = matchLiteral ( info . sepLit ) ;
746+ if ( ! sepLeaf ) continue ;
747+ const rest = matchSeq ( led . items . slice ( 3 ) ) ;
748+ if ( rest === null ) continue ;
749+ return [ openLeaf , reOperand , sepLeaf , ...rest ] ;
750+ }
751+
752+ pos = saved ;
753+ return null ;
754+ }
755+
650756 function matchQuantifier ( body : RuleExpr , kind : '*' | '+' | '?' ) : CstChild [ ] | null {
651757 if ( kind === '?' ) {
652758 const result = matchExpr ( body ) ;
0 commit comments