Fix: Left-recursive rule fails to grow when it occupies a non-final field of an enclosing sequence - #150
Conversation
… positions A left-recursive rule (e.g. a numeric expression ladder used as a comparison operand) could fail to grow past its seed when it started matching at the exact same text position as an already-active, unrelated left-recursive rule higher up the grammar (e.g. a boolean expression ladder wrapping it). Concretely: `Comparison: left=NumericExpr operator=RelOp right=NumericExpr;` reached through a boolean ladder failed to parse "a - b <= 0.1" on the left operand while the identical rule parsed "0.1 <= a - b" fine on the right -- because by the time the right operand is reached, the operator has already advanced the position past where the outer ladder's recursion context was created. Root cause: MatchOrCreateMatchProcessor only creates a new RecursionContext when none is active, or the active one's position differs from the current position. Grammar.FindAllRecursiveRules computes each left-recursive rule's own Continuations independently (SequenceRule.AddLeftRecursionRules only walks into a nested rule if the container itself is self-recursive, so an unrelated nested ladder's continuations are never merged into an enclosing rule's at setup time). So when two independently-left-recursive rules both start matching at the same position, the second one silently reuses the first's RecursionContext which only carries the first rule's continuations and its own growing opportunity is discarded outright. Fix: when reusing an existing RecursionContext at the same position, check whether the current rule's own continuations are already present in it; if not, build a new context with the union of both, rather than reusing the old one unchanged. Verified against a minimal repro (a boolean expression ladder with a nested Comparison whose operands are a numeric expression ladder) and against the existing AnyText.Tests suite (214/214 passing, no regressions).
⚡ Risk Assessment —
|
| Files | Summary |
|---|---|
Left-recursion continuation mergingAnyText/AnyText.Core/Matcher.csAnyText/AnyText.history |
Adds logic to merge missing continuations into existing recursion context when a left-recursive rule matches at a position already claimed by another left-recursive rule, enabling proper growth behavior; updates changelog with bug fix description. |
Sequence Diagram
sequenceDiagram
participant P as Parser
participant M as Matcher
participant RC as RecursionContext
participant Seq as SequenceRule
participant LR as LeftRecursiveRule
P->>M: MatchOrCreateMatchProcessor(rule)
alt First encounter of left-recursive rule
M->>RC: Create RecursionContext with initial continuations
else Position already has recursion context
M->>M: AllContinuationsPresent(recursionContext, rule.Continuations)
alt Not all continuations present
M->>RC: MergeContinuations(existing, additional)
M->>RC: Create new RecursionContext with merged continuations
end
end
M->>P: Return processor with recursion context
P->>LR: Continue matching with full continuation set
Dig Deeper With Commands
/review <file-path> <function-optional>/chat <file-path> "<question>"/roast <file-path>
Runs only when explicitly triggered.
|
Very cool, thank you 👍 As a bit of history, AnyText started computing continuations on the fly, closely to the paper on left-recursive packrat parsing. However, this turned out incompatible with incremental parsing given that the parser would not see all rules. |
Left-recursive rule fails to grow when it occupies a non-final field of an enclosing sequence
Summary
SequenceRulehas built-in support for direct left recursion (AddLeftRecursionRules,RecursiveContinuation/ContinuationinAnyText.Core/Rules/SequenceRule.cs), and it works correctly for the case: a ruleX: XBinary | Atom;withXBinary: left=X op=Op right=Atom;, referenced from elsewhere as a single field. But when a left-recursive rule is used as the left (non-final) operand of a different, non-recursiveSequenceRule— e.g. aComparison: left=NumericExpr operator=RelOp right=NumericExpr;whereNumericExpris itself a left-recursive additive/multiplicative ladder — growth silently fails to parse, while the exact same rule used as the right (final) operand of that same sequence works fine.Minimal reproduction
The full grammar is in
LeftRec.anytext(sibling folder) — a boolean expression ladder (ImpliesExpr→OrExpr→AndExpr→NotExpr→BoolAtom) withBoolAtomincluding aComparisonalternative whose operands are a numeric ladder (AdditiveExpr→MultiplicativeExpr→NumericAtom):Program.csparses four lines:Expected
All four lines print
[OK].Actual
Only the case where the left-recursive rule occupies the non-final field of an unrelated enclosing sequence (
Comparison.left) fails. The same rule as the final field of that sequence (Comparison.right) works, and an entirely different left-recursive rule used as the non-final field of its own binary rule (AndBinary.left, growingAndExpritself) alsoworks.
Root cause
This bug depends on whether the left-recursive rule starts matching at the same text position as an already-active, unrelated left-recursive rule higher up the grammar.
Setup time is fine.
Grammar.FindAllRecursiveRules(AnyText.Core/Grammars/Grammar.cs:84-98) runs once per rule over the whole grammar. For each rule that's left-recursive on itself (CanStartWith(this)), it computes that rule's ownContinuationsviaAddLeftRecursionRules. Critically,SequenceRule.AddLeftRecursionRulesonly walks into a nested rule if the container itself is self-recursive:Comparison(a plainSequenceRule) doesn't recurse into itself, soCanStartWith(this)is false and this method does nothing for it — the walk starting fromImpliesExprnever reachesAdditiveExprthroughComparison. That's correct and intentional:AdditiveExprgets its own, separateFindAllRecursiveRulespass later in the same loop (since it wasn't markedIsLeftRecursivebyImpliesExpr's pass), and itsContinuationsare computed correctly and independently. Both rules'Continuationsare correct in isolation.Match time is where it breaks.
Matcher.MatchOrCreateMatchProcessoronlycreates a new
RecursionContextwhen there isn't already one active at the exact same position:Trace what happens parsing
"test a - b <= 0.1": matching starts onImpliesExprat position P (right after'test 'is consumed).ImpliesExpris left-recursive, no active context yet, so aRecursionContext(P, ImpliesExpr.Continuations)is created. Parsing proceeds throughOrExpr → AndExpr → NotExpr → BoolAtom → Comparison— none of these consume any input (they're all "try this alternative"ChoiceRules or aSequenceRulewhose first field hasn't matched yet), soComparison.left=AdditiveExprstarts matching at that exact same position P.AdditiveExpris also left-recursive — butrecursionContext != null && recursionContext.Position == P, so the condition above is false: the existing context (holding onlyImpliesExpr's continuations) is reused unchanged.AdditiveExpr.Continuations— computed correctly at setup time — is never consulted for this match attempt.AdditiveExprcan match a single atom (the "seed") but has no way to grow past it, so"a - b"fails and only"a"would have been accepted.Compare with
"test 0.1 <= a - b": by the timeComparison.right=AdditiveExprstarts matching, the operator<=has already advanced the position past P, to some P2 ≠ P. SincerecursionContext.Position (P) != P2, a newRecursionContext(P2, AdditiveExpr.Continuations)is correctly created, and growth works.And
AndBinary.left=AndExpr(the case that always worked):AndExpr's own continuation isImpliesExpr's continuation for this purpose —AndExpris reached throughImpliesExpr's own recursive structure (ImpliesExpr → OrExpr → AndExpr, all part of the sameFindAllRecursiveRuleswalk since each of those is self-recursive), so whenAndBinaryneeds to growAndExprat position P, the continuation it needs is already present in the context that was seeded fromImpliesExpr.Continuations— there's no gap to fall into.Fix
In
Matcher.MatchOrCreateMatchProcessor(AnyText.Core/Matcher.cs): when an existingRecursionContextwould be reused at the same position, check whether the current rule's own continuations are already present in it. If not, build a new context with the union of both instead of reusing the old one:with two small helpers (
AllContinuationsPresent,MergeContinuations) using reference equality overRecursiveContinuation— safe here since each continuation instance is created once at grammar-init time and consistently reused by reference.Verification
"test a - b <= 0.1") now print[OK].AnyText.Testssuite: 214/214 passing, no regressions — important given this touches corematching logic used by every left-recursive rule in every grammar, not just this one shape.
Workaround
Restrict the non-final (left) operand of a comparison-like rule to non-recursive alternatives (atoms, function calls, parenthesized sub-expressions) instead of the full expression ladder, while leaving the final (right) operand as the full ladder:
Compound left-hand operands then need an explicit wrapping paren, e.g.
(a - b) <= 0.1insteadof
a - b <= 0.1.Summary by MergeMonkey