Skip to content

Classify anchors after alternations per branch #410

Description

@zharinov

Problem

The . anchor rule is "skip anonymous tokens only when both sides are named." Classification of the alternation side is asymmetric:

  • Anchor before alternation — per-branch (fixed on feat/strict-anchors): (a) . [(b) ","] uses NextSkip for (b) and NextSkipExtras for ",".
  • Anchor after alternation — conservative whole-alternation: [(b) ","] . (a) uses NextSkipExtras for every branch, because some branch may match an anonymous node.

Consequence: adding an anonymous branch to an alternation silently strictens the anchor for all sibling branches. This is the same action-at-a-distance / silent-non-match failure mode the per-branch fix eliminated in the other direction. Currently documented as a known limitation in lang-reference.md ("Anchor Behavior") and tree-navigation.md ("Anchor Lowering").

Why it's structural

The anchor's skip policy is encoded as the Nav mode on the destination instruction. Branch entry instructions exist per branch (which made the before-side fix local), but all branches converge on a single continuation instruction — one instruction, one nav mode, branch identity lost:

  10  ─◦─  (number) [Node Set(M0)]   12   ← anchor's nav lives here, shared
  14   !   (string)                  10   ← named branch
  15   !   ","                       10   ← anonymous branch

Approach: compile-time path duplication

Partition branches into two policy classes (may-match-anonymous vs. not) and emit two copies of the continuation's entry instruction — one NextSkip, one NextSkipExtras — converging on the same successors. Bounded ×2, fully static: dump stays predictive, VM unchanged, everything pinnable by emit snapshots.

Sub-cases to scope:

  1. Plain sibling ([(b) ","] . (a)) — alternation needs two exit labels; main cost is threading dual exits through compile_seq_items/compile_alt (continuation-passing API change).
  2. Trailing anchor (([(b) ","] .)) — policy lives on the Up instruction; duplicate per class. Verify collapse_up doesn't merge the copies (modes differ, but add a test).
  3. Quantified ([(b) ","]* . (a)) — the last iteration's branch class decides, so the quantifier exit edge must split per class. Trickiest; acceptable to keep conservative behavior here in the first PR.
  4. Effects — the duplicated instruction clones capture effects; only one copy executes per path. Assert this invariant.
  5. Refs ((Mixed) . (a)) — return edge is per call-site, so duplication at the call site should fall out of (1); verify with a snapshot.

Suggested staging: land (1) + (2) with conservative fallback for (3); follow up on (3) separately.

Alternative considered: runtime-decided nav

A NextSkipAuto/UpSkipAuto variant deciding policy from the actually-matched node (cursor.node().is_named() before moving). Cheaper (no compiler restructuring, fixes quantified/ref/_ cases for free) but rejected: makes bytecode non-predictive (dump shows "auto" instead of a concrete policy), moves semantics from pattern to runtime tree, and shifts test coverage from emit snapshots to runtime witnesses.

Notes

  • Coordinate with the up-merge miscompilation fix (level-less checked up-modes) — both touch the Nav/UpMode encoding; do one format revision, not two.
  • Acceptance: [(b) ","] . (a) emits ─•─ on the (b) path and ─◦─ on the "," path; docs drop the "anchor after an alternation is conservative" caveat.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions