Skip to content

feat(parser): configurable max rule-nesting depth to bound adversarial input - #199

Merged
tinovyatkin merged 6 commits into
mainfrom
feat/198-max-rule-depth
Jul 25, 2026
Merged

feat(parser): configurable max rule-nesting depth to bound adversarial input#199
tinovyatkin merged 6 commits into
mainfrom
feat/198-max-rule-depth

Conversation

@tinovyatkin

@tinovyatkin tinovyatkin commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #198. Stacked on #194 (contains its commits; the depth cap builds on the same dispatch boundary the stack guard added).

What

Parser::set_max_rule_depth(Option<usize>) — default None (unlimited). When set, generated rule dispatch aborts at the first rule entry past the cap with a positioned error:

parser error at 1:20: rule nesting depth limit of 200 exceeded

The violation is sticky: rule-level recovery would otherwise absorb the error and keep spending exactly the resources the cap exists to bound. Every subsequent rule entry fails immediately, the generated top-level entry drains the recorded violation and fails the parse even when recovery produced a tree, and reset()/set_token_stream() clear it so a reused parser starts clean.

Why

After #193 deeply nested input no longer crashes — but it still costs unbounded CPU and tree memory. Consumers parsing attacker-supplied input need a parse-time bound: cel-rust (the cel crate, CEL policy engines) enforces depth 96 via an ANTLR parse listener (RecursionListener), an API we don't have. This gives its port a first-class replacement, checked at the same dispatch boundary as the stack guard (zero new state reads on the hot path when unset — one Option branch).

Verification

  • e2e (deep-nesting fixture): shallow input parses under the cap; depth-1000 input against cap 64 fails with the positioned message even though recovery would produce a tree; the same parser instance then parses clean input fine.
  • Uncapped behavior unchanged: 10k/20k nesting still parses (bug(codegen): generated recursive-descent rules overflow the native stack on deeply nested input #193 test still green).
  • cargo test --locked all green; clippy -D warnings all-targets all-features clean.
  • Conformance sweep: running, will report in a comment (no descriptor sets a cap, so 357/357 expected — the unset path is one None check).

Summary by CodeRabbit

  • New Features
    • Added configurable maximum rule-nesting depth for generated parsers via max_rule_depth / set_max_rule_depth.
    • Depth cap enforcement is improved across generated rule dispatch, including left-recursive operator expansions.
  • Bug Fixes
    • Rule-depth violations now trigger early, prompt failures inside operator loops and are prioritized over other errors, including during/after recovery.
    • Parser instances now reliably clear stale depth-cap violations between parses.
  • Tests
    • Expanded deep-nesting and recursive expression coverage, including parser reuse and cap boundary checks.
  • Documentation
    • Updated the deep-nesting fixture grammar to better reflect depth-cap scenarios.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The parser runtime and generated parser now support configurable rule-nesting depth limits, including left-recursive expansion accounting, fatal error propagation through recovery, and parser reuse after violations. Deep-nesting fixtures and CLI tests cover capped, uncapped, recursive, and recovery scenarios.

Changes

Generated parser safety

Layer / File(s) Summary
Runtime depth controls
src/parser.rs
Adds parser depth-cap APIs, sticky depth errors, recursion-expansion tracking, reset handling, and limit enforcement helpers.
Generated dispatch integration
src/bin/antlr4-rust-gen.rs
Routes capped parsing through generated dispatch, checks limits in rules and operator loops, prioritizes depth errors, and renders configuration methods.
Deep nesting validation
tests/fixtures/antlr4-rust-gen/deep-nesting/Nest.g4, tests/antlr4_rust_gen_cli.rs
Updates the recursive grammar and tests capped nesting, operator expansion limits, recovery behavior, uncapped parsing, and parser reuse.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant GeneratedParser
  participant BaseParser
  Caller->>GeneratedParser: set_max_rule_depth(Some(limit))
  Caller->>GeneratedParser: parse input
  GeneratedParser->>BaseParser: check rule and recursion depth
  BaseParser-->>GeneratedParser: continue or return positioned depth error
  GeneratedParser-->>Caller: parse result
Loading

Possibly related issues

  • Issue 193 — The depth cap extends existing deep-recursion hardening by aborting excessive generated-parser nesting during parsing.

Possibly related PRs

  • ophi-dev/antlr-rust-runtime#194 — Both changes update the generated rule-dispatch boundary in src/bin/antlr4-rust-gen.rs; this PR adds depth-cap failure handling while that PR adds native-stack growth handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a configurable parser rule-nesting depth cap.
Linked Issues check ✅ Passed The changes implement the requested max rule-depth cap across generated, interpreted, and left-recursive parsing paths with sticky positioned errors.
Out of Scope Changes check ✅ Passed The PR stays focused on the depth-cap feature, with only supporting parser, test, and fixture updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/198-max-rule-depth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Deeply nested input parses safely since the segmented-stack guard
(#193), but each nesting level still costs CPU and tree memory. Callers
parsing attacker-supplied text (CEL policy engines cap at depth 96 via
an ANTLR parse listener) need to bound that work at parse time, not
after the tree is already built.

Parser::set_max_rule_depth(Option<usize>) (default None = unlimited)
makes generated rule dispatch abort with a positioned
"rule nesting depth limit of {n} exceeded" error at the first rule
entry past the cap. The violation is sticky: rule-level recovery cannot
absorb it and keep spending bounded resources — the top-level entry
drains it and fails the parse even when recovery produced a tree, and
reset() clears it so a reused parser starts clean.

Fixes #198
@tinovyatkin
tinovyatkin force-pushed the feat/198-max-rule-depth branch from be15c73 to cc92376 Compare July 25, 2026 13:46
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Copy/Paste Detection

Found 15 duplication(s) across 3 changed Rust file(s) (threshold: 100 tokens).

Show duplications

Found a 27 line (145 tokens) duplication in the following files:

  • Starting at line 15529 of src/parser.rs
  • Starting at line 15662 of src/parser.rs
    fn generated_match_token_recovers_missing_token_from_context_follow() {
        let atn = generated_match_recovery_atn();
        let data = RecognizerData::new(
            "Mini.g4",
            Vocabulary::new(
                [None, Some("'X'"), Some("'Y'")],
                [None, Some("X"), Some("Y")],
                [None::<&str>, None, None],
            ),
        );
        let mut parser = BaseParser::new(
            CommonTokenStream::new(Source {
                tokens: vec![TestToken::eof("parser-test", 3, 1, 3)],
                index: 0,
            }),
            data,
        );
        parser.rule_context_stack = vec![
            RuleContextFrame {
                rule_index: 0,
                invoking_state: 0,
            },
            RuleContextFrame {
                rule_index: 1,
                invoking_state: 1,
            },
        ];
```rust

---

Found a 22 line (132 tokens) duplication in the following files:
* Starting at line 1100 of tests/antlr4_rust_gen_cli.rs
* Starting at line 1200 of tests/antlr4_rust_gen_cli.rs

```rust
        "parser grammar Delegate;\ndelegated: {isTypeName()}? ID;\n",
    )
    .expect("delegate grammar should be writable");
    fs::write(&tokens, "lexer grammar Tokens;\nID: [a-z]+;\n")
        .expect("token grammar should be writable");

    let output = run_antlr4_rust_gen(&[
        root.as_os_str(),
        tokens.as_os_str(),
        OsStr::new("-I"),
        temp.path().as_os_str(),
        OsStr::new("--out-dir"),
        out.as_os_str(),
    ]);
    assert!(
        output.status.success(),
        "stdout: {}\nstderr: {}",
        utf8(&output.stdout),
        utf8(&output.stderr)
    );
    let parser = fs::read_to_string(out.join("root.rs")).expect("parser should be emitted");
    assert!(parser.contains("pub trait RootHooks"), "{parser}");

Found a 27 line (127 tokens) duplication in the following files:

  • Starting at line 14487 of src/parser.rs
  • Starting at line 14559 of src/parser.rs
        let mut atn = ParserAtnBuilder::new(2);
        assert_eq!(
            atn.add_state(AtnStateKind::RuleStart, Some(0))
                .expect("state")
                .index(),
            0
        );
        assert_eq!(
            atn.add_state(AtnStateKind::BlockStart, Some(0))
                .expect("state")
                .index(),
            1
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            2
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            3
        );
        assert_eq!(
            atn.add_state(AtnStateKind::BlockEnd, Some(0))
```rust

---

Found a 22 line (125 tokens) duplication in the following files:
* Starting at line 14513 of src/parser.rs
* Starting at line 14585 of src/parser.rs

```rust
            atn.add_state(AtnStateKind::BlockEnd, Some(0))
                .expect("state")
                .index(),
            4
        );
        assert_eq!(
            atn.add_state(AtnStateKind::RuleStop, Some(0))
                .expect("state")
                .index(),
            5
        );
        atn.set_rule_to_start_state(vec![0])
            .expect("rule start states");
        atn.set_rule_to_stop_state(vec![5])
            .expect("rule stop states");
        atn.add_decision_state(1).expect("decision state");
        atn.add_transition(0, ParserTransitionSpec::Epsilon { target: 1 })
            .expect("transition");
        atn.add_transition(
            1,
            ParserTransitionSpec::Atom {
                target: 2,

Found a 34 line (119 tokens) duplication in the following files:

  • Starting at line 9913 of src/parser.rs
  • Starting at line 9988 of src/parser.rs
                        outcomes.extend(
                            self.recognize_state(
                                atn,
                                RecognizeRequest {
                                    state_number: *target,
                                    stop_state,
                                    index,
                                    rule_start_index,
                                    decision_start_index: next_decision_start_index,
                                    init_action_rules,
                                    predicates,
                                    semantics,
                                    rule_args,
                                    member_actions,
                                    return_actions,
                                    local_int_arg,
                                    member_values: member_values.clone(),
                                    return_values: return_values.clone(),
                                    rule_alt_number: next_alt_number,
                                    track_alt_numbers,
                                    consumed_eof,
                                    committed_decision: transition_committed,
                                    precedence,
                                    depth: depth + 1,
                                    recovery_symbols: epsilon_recovery_symbols.clone(),
                                    recovery_state: epsilon_recovery_state,
                                },
                                visiting,
                                memo,
                                expected,
                            )
                            .into_iter()
                            .map(|mut outcome| {
                                prepend_decision(&mut outcome, decision);
```rust

---

Found a 15 line (113 tokens) duplication in the following files:
* Starting at line 15587 of src/parser.rs
* Starting at line 15863 of src/parser.rs

```rust
    fn generated_match_token_counts_single_token_deletion_recovery() {
        let atn = generated_match_recovery_atn();
        let data = RecognizerData::new(
            "Mini.g4",
            Vocabulary::new(
                [None, Some("'X'"), Some("'Y'"), Some("'Z'")],
                [None, Some("X"), Some("Y"), Some("Z")],
                [None::<&str>, None, None, None],
            ),
        );
        let mut parser = BaseParser::new(
            CommonTokenStream::new(Source {
                tokens: vec![
                    TestToken::new(3).with_text("z"),
                    TestToken::new(2).with_text("y"),

Found a 12 line (112 tokens) duplication in the following files:

  • Starting at line 13468 of src/parser.rs
  • Starting at line 13551 of src/parser.rs
        let mut atn = ParserAtnBuilder::new(1);
        for (state, kind, rule) in [
            (0, AtnStateKind::RuleStart, 0),
            (1, AtnStateKind::StarLoopEntry, 0),
            (2, AtnStateKind::Basic, 0), // ops hub
            (3, AtnStateKind::Basic, 0), // shift prec
            (4, AtnStateKind::Basic, 0), // shift first >
            (5, AtnStateKind::Basic, 0), // shift second >
            (6, AtnStateKind::Basic, 0), // rel prec
            (7, AtnStateKind::Basic, 0), // rel >
            (8, AtnStateKind::LoopEnd, 0),
            (9, AtnStateKind::RuleStop, 0),
```rust

---

Found a 22 line (112 tokens) duplication in the following files:
* Starting at line 14924 of src/parser.rs
* Starting at line 15125 of src/parser.rs

```rust
    fn predicate_after_token_atn() -> Atn {
        let mut atn = ParserAtnBuilder::new(2);
        assert_eq!(
            atn.add_state(AtnStateKind::RuleStart, Some(0))
                .expect("state")
                .index(),
            0
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            1
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            2
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))

Found a 22 line (111 tokens) duplication in the following files:

  • Starting at line 13290 of src/parser.rs
  • Starting at line 14924 of src/parser.rs
  • Starting at line 15125 of src/parser.rs
    fn left_recursive_loop_with_caller_follow_atn(caller_symbol: i32) -> Atn {
        let mut atn = ParserAtnBuilder::new(2);
        assert_eq!(
            atn.add_state(AtnStateKind::RuleStart, Some(0))
                .expect("state")
                .index(),
            0
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            1
        );
        assert_eq!(
            atn.add_state(AtnStateKind::Basic, Some(0))
                .expect("state")
                .index(),
            2
        );
        assert_eq!(
            atn.add_state(AtnStateKind::RuleStart, Some(1))
```rust

---

Found a 14 line (110 tokens) duplication in the following files:
* Starting at line 15258 of src/parser.rs
* Starting at line 18014 of src/parser.rs

```rust
    fn parser_matches_token_and_reports_mismatch() {
        let source = Source {
            tokens: vec![
                TestToken::new(1).with_text("x"),
                TestToken::eof("parser-test", 1, 1, 1),
            ],
            index: 0,
        };
        let data = RecognizerData::new(
            "Mini.g4",
            Vocabulary::new([None, Some("'x'")], [None, Some("X")], [None::<&str>, None]),
        );
        let mut parser = BaseParser::new(CommonTokenStream::new(source), data);
        let matched = parser.match_token(1).expect("token 1 should match");

Found a 13 line (109 tokens) duplication in the following files:

  • Starting at line 15258 of src/parser.rs
  • Starting at line 18039 of src/parser.rs
    fn parser_matches_token_and_reports_mismatch() {
        let source = Source {
            tokens: vec![
                TestToken::new(1).with_text("x"),
                TestToken::eof("parser-test", 1, 1, 1),
            ],
            index: 0,
        };
        let data = RecognizerData::new(
            "Mini.g4",
            Vocabulary::new([None, Some("'x'")], [None, Some("X")], [None::<&str>, None]),
        );
        let mut parser = BaseParser::new(CommonTokenStream::new(source), data);
```rust

---

Found a 22 line (108 tokens) duplication in the following files:
* Starting at line 7708 of src/parser.rs
* Starting at line 8099 of src/parser.rs

```rust
    ) -> Option<RecognizeOutcome> {
        let (error_index, message) = self.expected_error_message(rule_index, start_index, expected);
        let diagnostic = diagnostic_for_token(self.token_at(error_index), message);
        let mut next_index = error_index;
        loop {
            let symbol = self.token_type_at(next_index);
            if sync_symbols.contains(&symbol) {
                if next_index == error_index {
                    return None;
                }
                break;
            }
            if symbol == TOKEN_EOF {
                break;
            }
            let after = self.consume_index(next_index, symbol);
            if after == next_index {
                break;
            }
            next_index = after;
        }
        let mut nodes = NodeSeqId::EMPTY;

Found a 15 line (108 tokens) duplication in the following files:

  • Starting at line 18305 of src/parser.rs
  • Starting at line 18329 of src/parser.rs
    fn outcome_ties_keep_later_non_recursive_alternative() {
        let arena = RecognitionArena::default();
        let first = RecognizeOutcome {
            index: 1,
            consumed_eof: false,
            alt_number: 0,
            member_values: BTreeMap::new(),
            return_values: BTreeMap::new(),
            diagnostics: DiagnosticSeqId::EMPTY,
            decisions: Vec::new(),
            actions: vec![ParserAction::new(1, 0, 0, None)],
            nodes: NodeSeqId::EMPTY,
        };
        let second = RecognizeOutcome {
            actions: vec![ParserAction::new(2, 0, 0, None)],
```rust

---

Found a 16 line (105 tokens) duplication in the following files:
* Starting at line 6847 of src/parser.rs
* Starting at line 7467 of src/parser.rs

```rust
        let start_state = atn.rule_to_start_state().get(rule_index).ok_or_else(|| {
            AntlrError::Unsupported(format!("rule {rule_index} has no start state"))
        })?;
        let stop_state = atn
            .rule_to_stop_state()
            .get(rule_index)
            .filter(|state| *state != usize::MAX)
            .ok_or_else(|| {
                AntlrError::Unsupported(format!("rule {rule_index} has no stop state"))
            })?;

        let start_index = self.current_visible_index();
        self.clear_prediction_diagnostics();
        self.reset_per_parse_caches();
        self.reset_recognition_arena();
        let caller_follow_state = self.pending_invoking_follow_state(atn);

Found a 13 line (100 tokens) duplication in the following files:

  • Starting at line 6575 of src/parser.rs
  • Starting at line 6599 of src/parser.rs
        let mut expected = BTreeSet::new();
        for index in (1..self.rule_context_stack.len()).rev() {
            let invoking_state = self.rule_context_stack[index].invoking_state;
            let Ok(state_number) = usize::try_from(invoking_state) else {
                continue;
            };
            let Some(Transition::Rule { follow_state, .. }) = atn
                .state(state_number)
                .and_then(|state| state.transitions().first())
                .map(ParserTransition::data)
            else {
                continue;
            };
```rust

</details>

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown

Claude encountered an error after 1s —— View job


I'll analyze this and get back to you.

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 49.38272% with 41 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/parser.rs 22.64% 41 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

📊 Source Code Metrics (this PR vs main)

File Cyclomatic Cognitive Functions LLOC MI
src/parser.rs 2116 (main: 2102) 🔴 1409 (main: 1404) 🔴 664 (main: 656) 🔴 4609 (main: 4586) 🔴 0 ⚪
src/bin/antlr4-rust-gen.rs 2249 ⚪ 1415 ⚪ 498 ⚪ 3940 (main: 3939) 🔴 0 ⚪

Generated by mehen v1.7.0 — the code quality watcher.

@tinovyatkin

Copy link
Copy Markdown
Contributor Author

Local conformance sweep on this branch (rebased onto main incl. #194): 357 passed, 0 failed, 0 skipped. No descriptor sets a depth cap, so the unset path (Option check per rule entry) is what the sweep exercises.

@tinovyatkin

Copy link
Copy Markdown
Contributor Author

Kotlin-parity dumper timings on this branch (cap unset, min of 3×30 iters, quiet machine): 01-nested-types 0.108 ms, 02-dataframe 0.770 ms, 03-string-templates 0.323 ms — identical to the pre-#193 baseline (0.105/0.767/0.321). The cap check adds no measurable cost when disabled.

Three review findings on #199, all reproduced and fixed:

1. Sticky violation leaked on the Err exit path: a cap hit absorbed by
   recovery followed by an outer sync failure surfaced the derived
   syntax error and left the flag set, poisoning subsequent entry-rule
   calls on the same instance. The generated top-level entry now clears
   a stale violation at true entry and drains it in the Err branch,
   preferring the depth error over errors derived from it.

2. ATN-preferred rules bypassed the cap entirely (their dispatch arm
   routed to the interpreted path, which never checks it). A configured
   cap now overrides the ATN preference via has_rule_depth_cap() —
   correctness of the resource bound beats the long-call-chain
   optimization. The deep-nesting fixture grew to an 8-rule chain so
   the classifier fires and the e2e test pins this path.

3. Left-recursive operator expansions deepened the tree without
   counting: a 2000-term a+a+... chain built a 2000-deep tree under any
   cap. Both recursion-context pushes now count expansions (mirroring
   upstream Parser.pushNewRecursionContext firing triggerEnterRuleEvent),
   scoped per invocation so finished rules release their depth, and the
   generated LR loop checks the cap each iteration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bin/antlr4-rust-gen.rs`:
- Around line 6944-6953: Adjust the generated left-recursive expansion logic
around push_new_recursion_context_with_previous in
src/bin/antlr4-rust-gen.rs:6944-6953 so rule_depth_limit_error() uses the
pre-expansion depth and permits an expansion reaching exactly max_rule_depth,
rejecting only the first entry beyond the limit. Add coverage in
tests/antlr4_rust_cli.rs:1633-1659 for both an exact-limit expansion that
succeeds and one additional expansion that fails.

In `@src/parser.rs`:
- Around line 1155-1159: Update the ATN interpreter fallback path, including
parse_atn_rule* and recursive expansions, to enforce the configured depth cap at
every rule-entry stack push. Ensure interpreter-only rules cannot bypass the
limit; alternatively reject interpreter fallback whenever the cap is enabled.
Revise the adjacent documentation to describe the enforced behavior rather than
claiming those rules are uncapped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0b941170-acca-4767-8f09-57f12b1f30fa

📥 Commits

Reviewing files that changed from the base of the PR and between 3634641 and 3270fb2.

📒 Files selected for processing (4)
  • src/bin/antlr4-rust-gen.rs
  • src/parser.rs
  • tests/antlr4_rust_gen_cli.rs
  • tests/fixtures/antlr4-rust-gen/deep-nesting/Nest.g4

Comment thread src/bin/antlr4-rust-gen.rs Outdated
Comment thread src/parser.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3270fb2650

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

writeln!(
out,
" {index} if self.generated_only() => Some(self.parse_generated_rule_{index}_dispatch(precedence, allow_fallback)),"
" {index} if self.generated_only() || self.base.has_rule_depth_cap() => Some(self.parse_generated_rule_{index}_dispatch(precedence, allow_fallback)),"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the stale ATN-preferred dispatch test

This changes the emitted arm to include || self.base.has_rule_depth_cap(), but renders_atn_preferred_dispatch_only_for_generated_only_mode still requires the old substring without that condition. Consequently, cargo test --locked --features codegen --bin antlr4-rust-gen renders_atn_preferred_dispatch_only_for_generated_only_mode fails at src/bin/antlr4-rust-gen.rs:12593; update the assertion and its description to match the new guarded dispatch.

Useful? React with 👍 / 👎.

Comment thread src/bin/antlr4-rust-gen.rs Outdated
// dispatch) still abort promptly instead of at the top-level drain.
writeln!(
out,
"{pad} if let Some(__depth_error) = self.base.rule_depth_limit_error() {{\n\

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow left-recursive depth equal to the configured maximum

For a left-recursive operator iteration, push_new_recursion_context_with_previous has already incremented recursion_expansions before this check. rule_depth_limit_error uses effective_depth >= max because dispatch calls it before adding a rule frame, so reusing it here rejects an expansion whose resulting depth is exactly the advertised maximum. For example, with a cap one level above the active s -> expr frames, the first a+a expansion is rejected even though it only reaches the cap; this post-increment path needs to allow equality or check before incrementing.

Useful? React with 👍 / 👎.

@tinovyatkin

Copy link
Copy Markdown
Contributor Author

Review findings addressed in 3270fb2 + bd7309d:

  1. Sticky Err-path leak — the generated top-level entry now clears a stale violation at true entry (beside reset_unknown_semantic_hits()) and drains it in the Err branch, preferring the depth error over errors derived from it. New e2e test covers recovery-absorbed violation + reuse via a different entry rule without reset().
  2. ATN-preferred bypass — a configured cap now overrides the ATN preference (generated_only() || has_rule_depth_cap()), so guarded generated bodies always run when a bound is set. The deep-nesting fixture grew to an 8-rule leading chain (+ left recursion) so the classifier fires and the e2e test pins this path.
  3. LR expansions uncounted — both push_new_recursion_context* now count expansions (matching upstream triggerEnterRuleEvent), scoped per invocation via a marks stack, and the generated LR loop checks the cap each iteration. New e2e test: 2000-term a+a+… chain rejected at cap 64, parses uncapped, short chain fits.

Local verification on the revised branch: conformance 357/357, unit+all-features tests green, clippy -D warnings clean.

CI parse-bench flagged a 1.15x regression on the Java
issue-174-return-expression fixture: every generated dispatch paid an
out-of-line #[cold] call for depth accounting even with no cap set (the
default). Gate both emitted checks (dispatch boundary and LR loop) on
the const-inlinable has_rule_depth_cap() load so uncapped parses keep a
single predictable branch, and mark rule_depth_limit_error #[cold] —
it only runs when a cap is configured.
Second review round on #199, all three findings addressed:

1. The emitted checks used let-chains, breaking generated output for
   edition-2021 consumers (generated code was previously edition-clean;
   the CI check crate pins 2024 so nothing caught it). The gate and the
   cold error construction fold into one #[inline] runtime helper,
   rule_depth_cap_violation(), and both sites emit a plain `if let`.
   This also un-pessimizes the capped path: the always-on state for the
   cel-rust use case now pays an inlined add+compare per entry instead
   of an out-of-line #[cold] call. Verified: generated Nest parser has
   zero let-chains and cargo-checks in an edition = "2021" crate.

2. The deep-nesting fixture never actually made a rule ATN-preferred
   (7-rule chain, and every decision has an LL(1) fast path so the
   decision-cost gate cannot fire). Corrected the fixture and test
   comments to say so — the cap-overrides-ATN-preference guard is
   pinned by the dispatch-rendering unit test.

3. The LR-loop probe ran after push_new_recursion_context_with_previous
   had already counted the expansion, admitting expansions only to
   max-1 while rule frames reached max. The probe now runs before the
   push, and a boundary e2e test pins that one more operator costs
   exactly one more depth level.

Also corrected two stale comments: recovery DOES absorb the Fatal
depth error (the sticky flag + top-level drain enforce the abort), and
the ATN-preferred dispatch arm is now guarded by generated_only() OR
has_rule_depth_cap().

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/parser.rs (1)

5825-5833: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Interpreter fallback still bypasses the cap.

has_rule_depth_cap() only redirects ATN-preferred generated rules. Rules without generated bodies can still reach parse_atn_rule*, whose rule transitions and left-recursive boundaries never call rule_depth_cap_violation(). A capped parse can therefore recurse unbounded through an interpreted child. Enforce the cap in interpreter rule entry/expansion paths, or reject all interpreted fallback while capped; add an interpreted-only regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/parser.rs` around lines 5825 - 5833, Extend rule-depth enforcement beyond
has_rule_depth_cap and the generated recursive-descent path: ensure interpreted
entry and expansion paths such as parse_atn_rule* check the configured cap,
including rule transitions and left-recursive boundaries, before recursing.
Alternatively, disable interpreted fallback whenever a cap is configured, but
preserve capped parsing for generated rules. Add a regression test that
exercises an interpreted-only rule and verifies the depth cap is enforced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/parser.rs`:
- Around line 5825-5833: Extend rule-depth enforcement beyond has_rule_depth_cap
and the generated recursive-descent path: ensure interpreted entry and expansion
paths such as parse_atn_rule* check the configured cap, including rule
transitions and left-recursive boundaries, before recursing. Alternatively,
disable interpreted fallback whenever a cap is configured, but preserve capped
parsing for generated rules. Add a regression test that exercises an
interpreted-only rule and verifies the depth cap is enforced.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 53b64db3-a8e2-4df5-8408-2be826e93e85

📥 Commits

Reviewing files that changed from the base of the PR and between 3270fb2 and 708866e.

📒 Files selected for processing (4)
  • src/bin/antlr4-rust-gen.rs
  • src/parser.rs
  • tests/antlr4_rust_gen_cli.rs
  • tests/fixtures/antlr4-rust-gen/deep-nesting/Nest.g4

@tinovyatkin

Copy link
Copy Markdown
Contributor Author

Second review round addressed in 708866e — local verification: conformance 357/357, unit + codegen + e2e tests green, clippy -D warnings clean, and the generated Nest parser now cargo checks in an edition = "2021" consumer crate (zero let-chains emitted). The LR-loop probe moved before the expansion push, with a boundary e2e test pinning that one extra operator costs exactly one depth level.

@tinovyatkin
tinovyatkin merged commit 66ebc82 into main Jul 25, 2026
12 of 14 checks passed
@tinovyatkin
tinovyatkin deleted the feat/198-max-rule-depth branch July 25, 2026 16:33
@ophiarch ophiarch Bot mentioned this pull request Jul 25, 2026
tinovyatkin added a commit that referenced this pull request Jul 26, 2026
… (ANTLR parity) (#204)

* feat(runtime): pass the offending token to ErrorListener::syntax_error

ErrorListener::syntax_error now receives offending: Option<TokenView>
right after the recognizer, matching ANTLR's canonical
syntaxError(recognizer, offendingSymbol, ...) contract. Every reference
runtime passes the offending symbol; span-building error reporters
(e.g. avdl's miette diagnostics with byte-offset underlines) need
start_byte/stop_byte from the token, not just (line, column).

ParserDiagnostic records the anchoring TokenId at each creation site
(diagnostic_for_token already had the token in hand; the extraneous/
missing recovery paths record the current token) and dispatch resolves
it to a TokenView from the token store. Lexer-originated diagnostics
pass None, matching ANTLR's null offendingSymbol for lexer errors.

Fixes #195

* fix(runtime): carry the offending token on AntlrError::ParserError

Review on #196 found the offending-token contract held only for
extraneous-input/missing-token recovery and prediction diagnostics:
generated parsers route ordinary mismatched-input, no-viable-alternative,
failed-predicate, and sync errors through AntlrError::ParserError, whose
diagnostic arm hard-coded offending: None — exactly the errors a real
recognizer reports most.

ParserError gains offending: Option<TokenId>, recorded where each error
is built (recover_generated_match, generated sync, failed-predicate
builders, recognition_error) rather than resolved at reporting time:
prediction restores the input cursor, so lt(1) at dispatch can point at
the decision start instead of the error index —
no_viable_alternative_error_at now forwards the anchor its diagnostic
already computed.

Also converts the new listener test to a named insta snapshot per house
style.

* ci: displace wedged Copy/Paste Detection run

* merge: combine offending-token (#195) with main incl. depth cap (#199)

The depth-cap violation error now carries its offending token like
every other ParserError built at a known input position.

* feat(runtime): add_parse_listener — parse-time rule enter/exit events

ANTLR's addParseListener delivers enterEveryRule/exitEveryRule during
recognition; cel-rust's RecursionListener (live expr-depth counting with
parse abort) is built on it. Our runtime only had post-parse listeners,
forcing ports to rework such listeners into post-parse tree walks.

ParseListener (enter fallible for aborts, exit infallible) dispatches
from generated rule bodies: enter before the body after the depth-cap
probe, exit on every exit path, one simulated enter per left-recursive
operator expansion (upstream Parser.pushNewRecursionContext fires
triggerEnterRuleEvent) with matching exits as the rule unrolls — pairs
always balance. A listener abort is sticky through rule-level recovery,
drained at the top-level entry via take_parse_abort() (unified with the
depth-cap violation, depth error preferred), and cleared at entry so
instances never poison the next parse.

Costs nothing when unused: dispatch sites gate on list emptiness (one
predictable branch; Kotlin parse timings unchanged, ktor parse-bench
fixture at baseline). When a listener is registered, generated dispatch
routes ATN-preferred rules through their generated bodies so real
grammars observe every rule; interpreter-only rules do not fire events
(documented divergence, matching the depth cap). Emitted probes are
plain if-let — generated output stays edition-2021 clean.

e2e: cel-rust's RecursionListener ported verbatim in the deep-nesting
fixture test — live counting, positioned abort error, LR expansion
entries counted, clean reuse after abort.

Closes #202

* fix(runtime): address parse-listener review findings

1. Reserved-name collision: add_parse_listener/remove_parse_listeners
   join GENERATED_PARSER_RESERVED_RULE_METHODS so a grammar rule named
   addParseListener renames to add_parse_listener_rule instead of
   colliding with the facade (E0592).
2. Exit events now fire in reverse registration order, matching
   upstream Parser.triggerExitRuleEvent; new e2e test registers two
   tracing listeners and pins enter A,B / exit B,A plus pair balance
   on both the success and recovery paths.
3. Doc correction: the aborting enter_every_rule receives no matching
   exit (same as Java, where enterRule throws before try/finally);
   listener state shared across parses must be reset after an abort.
4. remove_parse_listeners clears the sticky abort and returns the
   boxed listeners, giving callers their accumulated state back
   without external shared handles.
5. enter_every_rule takes #[non_exhaustive] EnterRuleEvent so future
   fields extend the event without breaking implementors; the dispatch
   helper loses its #[cold] (it is the hot path once registered).
6. New coverage: multi-listener order, recovery balance, depth-cap +
   listener coexistence (either bound trips first and surfaces).

Measured with-listener cost (finding 7): on the CEL grammar — the
migration target, no ATN-preferred rules — a counting listener adds
~5% (0.0433 -> 0.0453 ms on cel-rust's criterion stress expression).
On grammars with ATN-preferred rules the dominant cost is the routing
override those rules take (Kotlin ktor fixture 10.2 -> 86 ms), shared
byte-for-byte with the depth cap (cap-only run measures identically);
listener dispatch on top of it is noise. Documented on the trait.

* fix(runtime): parse-listener round-2 review findings

1. Parser trait gains add_parse_listener(Box<dyn ParseListener>) /
   remove_parse_listeners() (mirroring set_max_rule_depth), fixing the
   broken intra-doc link and letting code generic over P: Parser reach
   listener registration.
2. LR-expansion aborts now match Java exactly: the listener enter probe
   fires AFTER push_new_recursion_context_with_previous (upstream
   assigns _ctx before triggerEnterRuleEvent), so an aborting
   expansion is already counted and the unroll fires its matching exit
   — Java parity via the finally-driven unrollRecursionContexts.
   Ordinary-rule aborts keep the documented no-exit behavior (also
   Java parity). Trait doc states both cases precisely.
3. Success-path LR coverage: a+a+a+a under a tracing listener pins 7
   RULE_EXPR enters (1 dispatch + 3 expansions + 3 operands) and full
   enter/exit balance through the unroll loop.
4. Stale ATN-preferred routing comment updated for the listener guard.

* fix(runtime): make removed parse listeners re-registrable

CodeRabbit round 3: Box<dyn ParseListener> returned by
remove_parse_listeners could not be passed back to the inherent
add_parse_listener (Box did not implement ParseListener). Add the
forwarding impl for Box<T: ParseListener + ?Sized> and pin the
round-trip in the e2e test — a removed listener re-registers with its
accumulated state and still enforces its limit.

Also cover the left-recursive success path: one listener instance
parses the same under-limit operator chain twice; identical high-water
marks prove the live depth counter returned to zero after the first
LR unroll (balanced enter/exit through expansions).

* fix(runtime): match upstream left-recursive listener exit timing

Round-4 review built a Java oracle (ANTLR 4.13.2 on this PR's own
Nest.g4) and caught that batching expansion exits at unroll diverges
from upstream on live depth: recRuleSetPrevCtx fires triggerExitRuleEvent
at the TOP of each operator-loop pass, so the outgoing iteration exits
before the next expansion enters and flat chains never accumulate depth
(a+a+...+a peaks at 2 in every ANTLR target; ours peaked at chain width).
The motivating cel-rust RecursionListener would have rejected wide flat
CEL expressions every other target accepts.

The generated operator loop now exits the outgoing iteration first, and
unroll_recursion_context drops the batched exits (upstream's
unrollRecursionContexts walks exactly one link — the dispatch wrapper's
single exit plays it). Event counts unchanged; timing now matches the
oracle: 7/7 depth 2, 79/79 depth 2, bracket cases unchanged. The e2e
that encoded the divergence now pins the Java-oracle depth of 2 for a
40-term chain.

Also from review: generated impl Parser blocks forward
add_parse_listener/remove_parse_listeners (they inherited the no-op
trait defaults — generic registration silently dropped listeners and
trait-removal left them firing with the sticky abort uncleared), and
the trait doc gains the expansion-anchor note (EnterRuleEvent::current
is the operator-side lookahead, not Java's whole-expression start).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(parser): configurable max rule-nesting depth to bound adversarial input (parse-time abort)

1 participant