The problem
I wanted to understand how programming languages actually work, end to end, and I wanted to learn Rust properly. Robert Nystrom’s Crafting Interpreters (opens in a new tab) builds its first interpreter in Java. Porting it to Rust, without any coding assistance, forced me to understand every design choice instead of copying it — because half of them don’t translate.
Architecture
source ──► scanner ──► tokens ──► parser ──► AST ──► resolver ──► interpreter
(recursive (static (tree-walk,
descent) scopes) environments)
About 2,700 lines across a scanner, a recursive-descent parser, a resolver pass for static scope, and a tree-walking interpreter with a REPL. It supports variables, control flow, functions, recursion, closures, and native functions (clock, input, parse, clear) — everything in Part II of the book up to classes.
Key decisions
A resolver pass, not dynamic lookup. Closures that capture variables need to bind to the right scope even when a later declaration shadows the name. Resolving every variable’s depth statically, before interpretation, fixes that class of bug and makes lookups cheaper.
Fight the borrow checker honestly. The Java original shares mutable environments freely. In Rust, environments that closures can capture and mutate need shared ownership with interior mutability, and choosing where to pay that cost shaped the whole interpreter.
Rewrite the scanner once I knew better. The first scanner allocated constantly. A later refactor rebuilt it around lifetimes and borrowed slices of the source, which is the version that’s in the repo now.
What I’d do differently
- Design for ownership up front. I wrote Java-shaped code and then fought Rust to make it compile. Starting from who owns what — source, tokens, AST, environments — would have saved most of the early pain.
- Finish it. I stopped at classes, partly for time and partly because translating Java’s class patterns into Rust is genuinely awkward. The plan is to come back for classes and then Part III: a bytecode VM.