compilers · parsing · rust

Crafting a parser, part 8: testing, snapshots, and fuzzing

2026-05-263 min readcompilers

A parser is a function from bytes to trees, which makes it one of the most testable things you'll ever write — and one of the easiest to crash with input you didn't imagine. This finale is about proving the parser works and that it never panics.

This wraps Crafting a parser in Rust.

Snapshot tests

Don't hand-write expected ASTs; that is how tests rot. Pretty-print the tree and snapshot it. The first run records the output; later runs diff against it, and you review changes deliberately.

tests.rs
1#[test]2fn parses_precedence() {3    insta::assert_debug_snapshot!(parse("1 + 2 * 3"));4    // recorded once; regressions show up as a diff5}

Round-trip and property tests

Two invariants catch a huge class of bugs:

Lossless round-trip
owns: fidelity
print(parse(src)) equals srconly for lossless trees
Proves no token was dropped.
Idempotent reformat
owns: stability
formatting twice equals formatting onceworks for ASTs too
Proves the printer and parser agree.

Fuzzing for panics

The contract from part one was "never panic on bad input." Prove it: throw random bytes at the parser and assert it always returns, diagnostics and all.

fuzz_target.rs
1fuzz_target!(|data: &[u8]| {2    if let Ok(src) = std::str::from_utf8(data) {3        let _ = parse(src); // must return — any panic is a bug4    }5});
Fuzzing finds what you didn't imagine

Deeply nested parens that overflow the stack, multi-byte UTF-8 split across a span, a lone bracket — fuzzers find these in seconds. Cap recursion depth and treat every panic the fuzzer surfaces as a real bug.

🦀
Ferris' hot tip

Seed the fuzzer's corpus with your snapshot-test inputs. It explores far faster when it starts from real, structurally-valid programs.

The series, one line each

1
The [lexer](/blog/crafting-a-parser-lexer) turns characters into spanned tokens.
2
[Recursive descent](/blog/crafting-a-parser-recursive-descent) maps grammar rules to functions.
3
The [AST](/blog/crafting-a-parser-ast) stays purely syntactic, with spans and ids.
4
[Pratt parsing](/blog/crafting-a-parser-pratt) handles precedence with one loop.
5
[Error recovery](/blog/crafting-a-parser-error-recovery) keeps parsing past mistakes.
6
[Ambiguity](/blog/crafting-a-parser-ambiguity) is resolved by rules and bounded lookahead.
7
[Resilient parsing](/blog/crafting-a-parser-resilient-incremental) survives every keystroke.

Takeaways

  • Snapshot the tree; never hand-maintain expected ASTs.
  • Round-trip and idempotence properties catch dropped tokens and printer drift.
  • Fuzz to enforce never-panic — and cap recursion depth.

That's a parser, end to end. Build the batch version first; everything else is an upgrade you add when the use case demands it.

Comments
Loading comments…