Skip to content

Commit a716541

Browse files
committed
docs+lint: clarify backtrack semantics — runtime is always-on for Choice
Three docs + the lint message itself were describing the `backtrack` attribute as a runtime control. The actual behaviour (live in src/frame.cpp:22) has been: every Choice frame is always backtracked at runtime, regardless of the `Node.backtrack` flag. The flag exists only for lint introspection. External agent surfaced the discrepancy when authoring a constraint grammar: setting `backtrack: true` made the lint go quiet, but the parse behaved identically without it. Investigation showed the frame.cpp comment is the source of truth — the flag is a lint annotation now, not a parse-direction control. Docs and lint message had to be brought in line. **Fixed:** * `docs/rawast-format.md` §4.7 — was "rawast does not backtrack at the structural level". Now: alternatives are tried in source order with mark/reject wrapping (always-on); `backtrack: true` is a grammar- author annotation the linter uses to suppress LL(k) warnings, not a runtime control. * `docs/ARCHITECTURE.md` — the LL(1) lint section was claiming alts "become unreachable" without `backtrack: true`. They don't — the engine handles them correctly. Rewritten as: lint flags shared-prefix Choices as a heads-up so authors mark intentional fall-through with the flag; without the marker future readers can't tell whether the pattern is intentional or accidental. * `docs/AGENTS.md` — the "Silencing the lint on intentional shared- prefix Choices" section had the same misframing. Reframed as "lint annotation, NOT runtime control" + the explicit note that you can skip the workaround entirely if you don't mind a few lint warnings (parse output is identical either way). * `src/linter.cpp` — lint message itself updated. Was: "Without `backtrack: true` the predictive engine will commit to the first one and the others become unreachable" (incorrect — alts do get tried). Now: "The engine handles this correctly via alt-failure recovery... The lint flags it as a heads-up because the analysis can't prove disjointness statically. Set `backtrack: true` to mark this fall- through pattern as intentional (silences this warning; runtime behaviour is unchanged)." **Issue #4** (DSL syntax for `backtrack`) remains valuable but reframed: it's now "DSL needs a way to mark intentional fall-through patterns inline rather than via Python loader hook", not "DSL needs a way to control runtime backtracking". Verification: 216/216 C++ doctest + 47/47 Python pytest passing. No behaviour changes — only doc + lint message corrections.
1 parent 18968f6 commit a716541

4 files changed

Lines changed: 35 additions & 19 deletions

File tree

docs/AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,11 @@ But this trade is rarely worth it — the linter (LL(k) lookahead) won't flag th
196196

197197
### Silencing the lint on intentional shared-prefix Choices
198198

199-
The lint's LL(k) check is conservative — it flags shared-prefix Choices when one of the alternatives can begin with a generic Parse (identifier, number) that has no Key constraint at the lookahead depth. PEG handles these cases correctly via alt-failure recovery (alt A is tried, fails, alt B is tried), but the lint warns because the first-token analysis can't prove disjointness within bounded depth.
199+
The lint's LL(k) check flags Choices where two alternatives share a leading rule that can't be disambiguated by first-token analysis within bounded depth. The OR / OR_MULTI pattern above is a typical example — both branches start with `<AND>`, and the lint can't prove they diverge within its lookahead window because `<AND>` can begin with a generic Parse (identifier, number).
200200

201-
The right marker is `backtrack: true` on the Choice — it tells both the engine and the lint "this alt-failure pattern is intentional." Today the DSL doesn't have syntax for this attribute (tracked as issue #4); the workaround is to set it on the dict at load time before passing to `Grammar.from_dict()`:
201+
**Important — this is a lint annotation, NOT a runtime control.** The rawast engine *always* backtracks Choice frames at runtime: every alternative attempt is wrapped in input-cursor `mark()` / `reject()`, so partial alt-failure cleanly restores position and tries the next alt. The shared-prefix pattern parses correctly without any `backtrack: true` setting. The lint just can't see that statically, so it flags as a heads-up.
202+
203+
To silence the lint on a Choice that uses an intentional fall-through pattern, set `backtrack: true` on the Choice. Today the DSL doesn't have syntax for this attribute (tracked as issue #4); the workaround is to set it on the dict at load time before passing to `Grammar.from_dict()`:
202204

203205
```python
204206
import rawast
@@ -213,9 +215,9 @@ def _load(grammar_path: str, backtrack_rules: tuple[str, ...]) -> rawast.Grammar
213215
grammar = _load("constraint.rawast", backtrack_rules=("OR", "AND", "RELATION"))
214216
```
215217

216-
This is a **loader configuration**, not AST post-processing. The grammar still emits the host's IR shape directly via bindings; the dict gets one flag per Choice rule before construction. The lint goes clean; the parse behavior is unchanged. When the DSL syntax for `backtrack` lands (issue #4), this loader hook collapses to nothing — but `backtrack: true` in the dict is the canonical way to express the same intent today.
218+
This is a **loader-time lint annotation**, not AST post-processing. The grammar still emits the host's IR shape directly via bindings; the dict gets one flag per Choice rule before construction. The lint output goes clean; the parse behaviour is identical with or without it. When DSL syntax for `backtrack` lands (issue #4), this loader hook collapses to nothing.
217219

218-
The honest framing: this is the one piece of "Python around a rawast grammar" that ISN'T an anti-pattern. It's saying "this Choice was designed to use alt-failure intentionally" — a structural assertion about the grammar, not a transformation of parsed output.
220+
The honest framing: this is the one piece of "Python around a rawast grammar" that isn't an anti-pattern. It's a *grammar annotation* saying "this fall-through pattern is intentional, don't warn me." Skip the loader hook entirely if you don't mind a few lint warnings — the grammar still produces correct output either way.
219221

220222
## How an agent uses the parsed AST
221223

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ See `src/save_stack.cpp` for the engine. The entry point is `Grammar::save` (def
103103

104104
`Grammar::lint()` runs structural checks on the grammar tree at load time. Issues detected:
105105

106-
- **LL(1) violations on non-backtracking Choices.** A Choice without `backtrack: true` whose alternatives share a first-token signature will silently always pick the first match. The lint flags this with the specific token + alt indices that collide.
106+
- **LL(k) ambiguity on Choices.** A Choice without `backtrack: true` whose alternatives share a first-token signature (and whose LL(k) lookahead can't prove disjointness within bounded depth) is flagged. The engine actually handles these cases correctly via always-on alt-failure recovery for Choice frames — the lint flags them as a heads-up so authors mark intentional fall-through patterns with `backtrack: true`. Without the marker, future readers of the grammar can't tell whether the shared-prefix Choice is intentional or accidental.
107107
- **Wildcard-rule-with-nested-Choice-type-emit anti-pattern.** A `sequence dict` rule with no top-level `:type=…` discriminator whose body contains a nested Choice with `type=` emits. Save dispatch can't introspect through the nested Choice, so the rule looks like a catch-all and may swallow values destined for sibling alternatives, then fail on the inner Choice with "no matching grammar alternative for value at save". Fix: lift each nested-Choice alt to a sibling rule of the outer Choice.
108108
- **Raw-consume (`*`) misuse.** A `*` node not followed by a literal-key sibling in the same sequence. The literal sibling tells the engine where to stop the raw consumption.
109109

docs/rawast-format.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -746,10 +746,20 @@ permitted.
746746
choice_expr ::= 'choice' '{' items '}'
747747
```
748748

749-
Ordered alternation: the first alternative whose initial terminal
750-
accepts is selected (predictive PEG; rawast does not backtrack at the
751-
structural level). All alternatives must be terminal-prefix-
752-
distinguishable; the grammar linter (planned) flags violations.
749+
Ordered alternation: alternatives are tried in source order. Each
750+
alternative attempt is wrapped in input-cursor `mark()` / `reject()`
751+
if an alternative partially matches and then fails, the input position
752+
is restored and the next alternative is tried from the same position.
753+
This is standard PEG ordered-choice semantics and applies to every
754+
Choice node by default; the `backtrack: true` attribute on a Choice
755+
(if explicitly set) is retained as a *grammar-author annotation* that
756+
the linter uses to suppress LL(k)-ambiguity warnings on intentional
757+
shared-prefix patterns. It does not gate runtime behaviour.
758+
759+
Alternatives with overlapping first-token signatures still parse
760+
correctly via the alt-failure recovery above, but the linter flags
761+
them as a heads-up unless `backtrack: true` is set on the Choice.
762+
See the `Grammar::lint()` machinery and `docs/AGENTS.md`.
753763

754764
### 4.8 Repeat — `repeat expression [separator expression]`
755765

src/linter.cpp

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -475,18 +475,22 @@ std::vector<LintIssue> lint_grammar(const Grammar& g) {
475475
issue.alternatives.assign(still_ambiguous.begin(),
476476
still_ambiguous.end());
477477
issue.description =
478-
"Choice has an ambiguous first-token \"" + tok + "\" across " +
478+
"Choice has shared first-token \"" + tok + "\" across " +
479479
std::to_string(issue.alternatives.size()) +
480480
" alternatives whose key-paths remain indistinguishable up to "
481-
"depth " + std::to_string(kMaxDepth) + ". Without "
482-
"`backtrack: true` the predictive engine will commit to the "
483-
"first one and the others become unreachable. Either set "
484-
"`backtrack: true` on the Choice or restructure the grammar "
485-
"so the alternatives diverge at some Key position. "
486-
"(Alternatives that share the first token but diverge at a "
487-
"later Key — e.g. `\"+\", \"FIXED\"` vs `\"+\", \"ROUTED\"` — "
488-
"are not flagged: PEG's ordered choice naturally falls back "
489-
"between them on alt failure.)";
481+
"LL(" + std::to_string(kMaxDepth) + ") lookahead. The engine "
482+
"handles this correctly via alt-failure recovery (every Choice "
483+
"frame is backtracked at runtime — partial alt matches restore "
484+
"input cursor and the next alt is tried). The lint flags it as "
485+
"a heads-up because the analysis can't prove disjointness "
486+
"statically. Either set `backtrack: true` on the Choice to "
487+
"mark this fall-through pattern as intentional (silences this "
488+
"warning; runtime behaviour is unchanged), or restructure the "
489+
"grammar so the alternatives diverge within LL(" +
490+
std::to_string(kMaxDepth) + ") lookahead. "
491+
"(Choices that share the first token but diverge at a later "
492+
"Key — e.g. `\"+\", \"FIXED\"` vs `\"+\", \"ROUTED\"` — are not "
493+
"flagged: the LL(k) check sees through to the diverging Key.)";
490494
issues.push_back(std::move(issue));
491495
}
492496
}

0 commit comments

Comments
 (0)