Skip to content

fix: parser error recovery (I-125) - #111

Open
artefactop wants to merge 1 commit into
mainfrom
fix/i-125-parser-error-recovery
Open

fix: parser error recovery (I-125)#111
artefactop wants to merge 1 commit into
mainfrom
fix/i-125-parser-error-recovery

Conversation

@artefactop

@artefactop artefactop commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes I-125 — the parser had zero error recovery: one syntax error discarded the whole AST and suppressed every semantic diagnostic in the file, violating R9 (accumulate diagnostics) and R10 (emit a diagnostic, synchronize at the next statement boundary, produce a partial AST).

  • AST: new StmtKind::Error placeholder; astgen lowers it to nothing (the parser already emitted the diagnostic)
  • Parser: statements parse as lines (stmt + terminating newlines) with chumsky recover_with(via_parser(...)) synchronization at statement and line-tail boundaries; a broken line collapses to one Error node and parsing resumes at the next line
  • Driver: parse_source returns the partial program + diagnostics; run/build/ir thread them into the middle-end sink so parse and sema diagnostics co-surface in a single run

Tests

  • Parser unit tests: recovery between good statements, inside function bodies, multi-error files, broken final line at EOF, two-statements-per-line still errors
  • Driver tests: partial program + diagnostics returned; parse (E0100) and sema (E0012) diagnostics co-surface
  • Golden multi-error integration test
  • Full workspace suite, clippy, and fmt all pass

Summary by CodeRabbit

  • New Features

    • Added parser error recovery so valid statements can be processed after syntax errors.
    • Preserved partial program structure and continued analysis when malformed statements are encountered.
    • Added clearer display of unparseable statements.
  • Bug Fixes

    • Diagnostics now report multiple issues together, including syntax and semantic errors.
    • Improved handling of errors in nested blocks, multiple statements, and unterminated lines.
  • Tests

    • Added coverage for parser recovery and combined error reporting.

I-125: the parser had zero recovery — a single syntax error made
parse_source bail, discarding the whole AST and suppressing every
semantic diagnostic in the file (violating R9's accumulation rule
and R10's recover-at-statement-boundary requirement).

- AST: new StmtKind::Error placeholder; astgen lowers it to nothing
  (the parser already emitted the diagnostic).
- Parser: statements now parse as lines (stmt + terminating
  newlines) with chumsky recover_with(via_parser(...)) at two
  points: statement-level (skip garbage to the next boundary,
  yielding an Error node) and tail-level (same-line garbage after a
  valid statement prefix collapses the whole line to one Error
  node, so half-parsed prefixes never reach sema). Blocks handle
  the unterminated final statement before Dedent/EOF explicitly.
- Driver: parse_source returns the partial program plus
  diagnostics; run/build/ir thread them into the middle-end sink so
  parse and sema diagnostics co-surface in one run.
- Tests: parser unit tests for recovery/multi-error/EOF cases,
  driver tests for partial-AST and parse+sema co-surfacing, and a
  golden multi-error integration test.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The parser now recovers from statement-level syntax errors by producing partial ASTs and diagnostics. Downstream lowering skips error nodes. Parse, semantic, and ownership diagnostics are combined across parse, IR, run, and build pipelines. Issue I-125 was removed.

Changes

Parser recovery and diagnostic propagation

Layer / File(s) Summary
Error statement AST contract
ryo-core/src/ast.rs
Adds StmtKind::Error and pretty-printing for unparseable statements.
Statement parsing recovery
ryo-frontend/src/parser.rs
Recovers at line and block boundaries, preserves valid statements, supports trailing input, and adds recovery tests.
Recovered AST lowering
ryo-frontend/src/astgen.rs
Skips error nodes during top-level validation and UIR generation.
Pipeline diagnostic propagation
ryo-driver/src/pipeline.rs, ryo/tests/integration_tests.rs
Propagates parse diagnostics through compilation stages and verifies co-surfacing parse and semantic errors.

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

Sequence Diagram(s)

sequenceDiagram
  participant Source
  participant Parser
  participant AST
  participant ASTGen
  participant Pipeline
  participant Diagnostics
  Source->>Parser: parse source statements
  Parser->>AST: create valid statements and Error nodes
  Parser->>Diagnostics: collect parse diagnostics
  Parser->>Pipeline: return partial AST and diagnostics
  Pipeline->>ASTGen: lower partial AST
  ASTGen->>Pipeline: continue with valid statements
  Pipeline->>Diagnostics: add semantic and ownership diagnostics
  Diagnostics-->>Pipeline: finalize combined diagnostics
Loading

Possibly related PRs

  • ryolang/ryo#48: Both changes implement parser recovery, accumulated diagnostics, and continued downstream analysis.
  • ryolang/ryo#100: This change resolves the parser recovery issue introduced by that PR.
  • ryolang/ryo#110: Both changes modify parser recovery and diagnostic propagation.
🚥 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 and concisely describes the main change: parser error recovery for issue I-125.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 fix/i-125-parser-error-recovery

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.

@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: 1

🧹 Nitpick comments (3)
ryo-frontend/src/parser.rs (3)

103-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the repeated garbage-skip parser.

The same garbage_token().repeated().at_least(1).ignored() chain appears three times, at lines 104-107, 114-117, and 132-135. A small named helper removes the duplication and gives the resynchronization rule one definition.

♻️ Proposed helper
+/// Skip at least one non-boundary token. Recovering over zero tokens
+/// at a clean boundary would emit a spurious error.
+fn skip_garbage<'a, I>() -> impl Parser<'a, I, (), extra::Err<Rich<'a, Token>>> + Clone + 'a
+where
+    I: ValueInput<'a, Token = Token, Span = SimpleSpan>,
+{
+    garbage_token().repeated().at_least(1).ignored()
+}
-    let stmt_rec = stmt.clone().recover_with(via_parser(
-        garbage_token()
-            .repeated()
-            .at_least(1)
-            .ignored()
-            .map_with(|_, e| error_stmt(e.span())),
-    ));
+    let stmt_rec = stmt
+        .clone()
+        .recover_with(via_parser(skip_garbage().map_with(|_, e| error_stmt(e.span()))));
🤖 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 `@ryo-frontend/src/parser.rs` around lines 103 - 139, Extract the repeated
garbage-skip chain into a named local helper near the parser definitions, then
reuse it in the recovery parsers for stmt_rec, tail, and last. Keep the helper’s
behavior unchanged: require at least one garbage token and ignore the parsed
result.

1843-1855: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for a broken block-final statement before Dedent.

recovers_from_trailing_garbage_without_newline_at_eof covers the end() terminator. The peek(Token::Dedent) terminator used by indented_block is the harder path, because it is zero-width, and the PR description names it as a specific case the implementation handles. No test exercises it.

💚 Proposed test
#[test]
fn recovers_from_broken_block_final_statement() {
    // The last body line is broken and sits directly against the
    // block's `Dedent` (no terminating newline of its own).
    let (program, errs, _pool) =
        lex_and_parse_recovering("fn main():\n\tx = 1\n\ty = = 2\nz = 3\n");
    assert_eq!(errs.len(), 1, "expected one parse error: {errs:?}");
    let program = program.expect("recovery must produce a partial program");
    let StmtKind::FunctionDef(func) = &program.statements[0].kind else {
        panic!("expected FunctionDef");
    };
    assert_eq!(func.body.len(), 2);
    assert!(matches!(func.body[1].kind, StmtKind::Error));
    assert_eq!(program.statements.len(), 2);
}
🤖 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 `@ryo-frontend/src/parser.rs` around lines 1843 - 1855, Add a regression test
beside recovers_from_trailing_garbage_without_newline_at_eof that parses a
function whose final indented statement is malformed immediately before Dedent,
followed by a valid top-level statement. Assert one parse error, successful
partial recovery, a two-statement function body with the final body statement as
StmtKind::Error, and preservation of the following top-level statement.

56-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a zero-width carrier for the lookahead.

empty().and_is(just(token)) reads as the intended pure lookahead for token and avoids the misleading end().or_not() carrier.

♻️ Proposed simplification
-    end().or_not().and_is(just(token)).ignored()
+    empty().and_is(just(token)).ignored()
🤖 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 `@ryo-frontend/src/parser.rs` around lines 56 - 64, Update the peek function’s
lookahead carrier from end().or_not() to empty(), keeping the existing
and_is(just(token)).ignored() logic unchanged so the parser remains zero-width
while checking the next token.
🤖 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 `@ryo-driver/src/pipeline.rs`:
- Around line 92-94: Reorder the operations in the parse command so
display_ast(&program, &pool) runs immediately after parse_source and before
finalize_diags(diags, &input, &name)?. Match the ordering already used by
ir_command, preserving AST output for recovered parse errors while still
returning diagnostic failures afterward.

---

Nitpick comments:
In `@ryo-frontend/src/parser.rs`:
- Around line 103-139: Extract the repeated garbage-skip chain into a named
local helper near the parser definitions, then reuse it in the recovery parsers
for stmt_rec, tail, and last. Keep the helper’s behavior unchanged: require at
least one garbage token and ignore the parsed result.
- Around line 1843-1855: Add a regression test beside
recovers_from_trailing_garbage_without_newline_at_eof that parses a function
whose final indented statement is malformed immediately before Dedent, followed
by a valid top-level statement. Assert one parse error, successful partial
recovery, a two-statement function body with the final body statement as
StmtKind::Error, and preservation of the following top-level statement.
- Around line 56-64: Update the peek function’s lookahead carrier from
end().or_not() to empty(), keeping the existing and_is(just(token)).ignored()
logic unchanged so the parser remains zero-width while checking the next token.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9bc9756e-4ee3-4720-bd66-b441bd287edb

📥 Commits

Reviewing files that changed from the base of the PR and between 171c6fa and 3ab68ac.

📒 Files selected for processing (6)
  • ISSUES.md
  • ryo-core/src/ast.rs
  • ryo-driver/src/pipeline.rs
  • ryo-frontend/src/astgen.rs
  • ryo-frontend/src/parser.rs
  • ryo/tests/integration_tests.rs
💤 Files with no reviewable changes (1)
  • ISSUES.md

Comment on lines +92 to 94
let (program, diags) = parse_source(&input, &mut pool, &name)?;
finalize_diags(diags, &input, &name)?;
display_ast(&program, &pool);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

ryo parse hides the partial AST on a recovered parse error.

finalize_diags returns Err when any diagnostic has Severity::Error. A recovered parse error always produces one. The ? therefore returns before display_ast, so ryo parse prints diagnostics and no AST.

ir_command orders these operations the other way at lines 357-360, so ryo ir --emit=ast does print the partial AST. Two commands now behave differently for the same input. Print the AST first to match ir_command and to deliver the recovery benefit this PR adds.

🐛 Proposed fix
     let (program, diags) = parse_source(&input, &mut pool, &name)?;
-    finalize_diags(diags, &input, &name)?;
     display_ast(&program, &pool);
+    finalize_diags(diags, &input, &name)?;
     Ok(())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let (program, diags) = parse_source(&input, &mut pool, &name)?;
finalize_diags(diags, &input, &name)?;
display_ast(&program, &pool);
let (program, diags) = parse_source(&input, &mut pool, &name)?;
display_ast(&program, &pool);
finalize_diags(diags, &input, &name)?;
Ok(())
🤖 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 `@ryo-driver/src/pipeline.rs` around lines 92 - 94, Reorder the operations in
the parse command so display_ast(&program, &pool) runs immediately after
parse_source and before finalize_diags(diags, &input, &name)?. Match the
ordering already used by ir_command, preserving AST output for recovered parse
errors while still returning diagnostic failures afterward.

@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will degrade performance by 20.41%

❌ 5 regressed benchmarks
✅ 14 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation parse_snippet[("fizzbuzz", "fn fizzbuzz(n: int):\n\tif n % 15 == 0:\n\t\tprint(\"FizzBuzz\\n\")\n\telif n % 3 == 0:\n\t\tprint(\"Fizz\\n\")\n\telif n % 5 == 0:\n\t\tprint(\"Buzz\\n\")\n\nfn main():\n\tfizzbuzz(3)\n\tfizzbuzz(5)\n\tfizzbuzz(15)\n\tfizzbuzz(7)\n")] 404 µs 549.9 µs -26.53%
Simulation parse_large[16] 1.1 ms 1.4 ms -20.24%
Simulation parse_large[256] 14.9 ms 18.6 ms -19.93%
Simulation parse_large[64] 3.9 ms 4.9 ms -19.88%
Simulation parse_snippet[("fibonacci", "fn fibonacci(n: int) -> int:\n\tif n <= 1:\n\t\treturn n\n\treturn fibonacci(n - 1) + fibonacci(n - 2)\n\nfn main():\n\tassert(fibonacci(40) == 102334155, \"fib(40) check\")\n\tprint(\"assert passed, fib(40) is correct\\n\")\n")] 306.9 µs 361.2 µs -15.04%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/i-125-parser-error-recovery (3ab68ac) with main (e231698)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (171c6fa) during the generation of this report, so e231698 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

1 participant