A Rust port of Crafting Interpreters —
written as a learning project, but shaped around idiomatic Rust: edition
2024, let-chains, LazyLock, hand-rolled error types, Pratt parsing, and
enum-driven ASTs instead of the book's Visitor pattern.
Milestones 1, 2, and 3 are complete.
The repo is a Cargo workspace with three implementation crates and one umbrella binary that picks its backend at compile time:
| Crate | Role |
|---|---|
crates/rlox-shared |
Front-end primitives shared by both backends — scanner (eager scan and lazy Scanner iterator), tokens, base LoxError. |
crates/rlox-tree |
Tree-walk interpreter (jlox port, chapters 4–13). Complete through inheritance and super; depends on rlox-shared. |
crates/rlox-vm |
Bytecode VM (clox port, chapters 14–30). Complete through Part III. |
crates/rlox |
Umbrella binary. Two mutually-exclusive Cargo features (tree default, vm) select which backend the binary links to. |
cargo build # tree-walk binary (default)
cargo build --no-default-features --features vm # bytecode VM binary
cargo install --path crates/rlox \
--no-default-features --features vm # install the VMThe VM backend covers the full Part III language surface: statements,
globals, locals, control flow, functions, recursion, closures, native
functions, classes, instances, fields, methods, this, initializers,
inheritance, super, and safe handle-based mark-sweep garbage collection.
Chapter 20 uses Rust's HashMap instead of clox's custom table, and chapter
30 uses safe string interning instead of NaN-boxing because the workspace
forbids unsafe code.
| Phase | Module(s) | Status |
|---|---|---|
| 0 | Cargo + GitHub scaffolding | done |
| 1 | token, error |
done |
| 2 | scanner |
done |
| 3 | ast, value (+ pretty-print) |
done |
| 4 | parser (Pratt) |
done |
| 5 | interpreter |
done |
| 6 | lib.rs / main.rs CLI |
done |
| 7 | README polish, CONTRIBUTING | done |
| Phase | Chapter | Topic | Status |
|---|---|---|---|
| 8 | 8 | Statements, var, print, blocks, assignment |
done |
| 9 | 9 | if/else, while, for, short-circuit and/or |
done |
| 10 | 10 | Functions, closures, return, native clock() |
done |
| 11 | 11 | Resolver (static lexical-depth + diagnostics) | done |
| 12 | 12 | Classes, methods, properties, this, init |
done |
| 13 | 13 | Inheritance, super |
done |
| Phase | Chapter | Topic | Status |
|---|---|---|---|
| 14 | 14 | Chunks of Bytecode (OpCode, Chunk, RLE lines, disassembler) |
done |
| 15 | 15 | A Virtual Machine (stack, arithmetic dispatch) | done |
| 16 | 16 | Scanning on Demand (lazy Scanner iterator + rlox-shared extraction) |
done |
| 17 | 17 | Compiling Expressions (single-pass Pratt → bytecode) | done |
| 18 | 18 | Types of Values (Nil/Bool, comparisons, runtime type errors) |
done |
| 19 | 19 | Strings (safe handle-based Heap, Obj::Str, content equality, + overload) |
done |
| 20 | 20 | Hash tables — use std::HashMap for globals, fields, and methods |
done |
| 21 | 21 | Global Variables (print, expression statements, var, global read/assignment) |
done |
| 22 | 22 | Local Variables (block scopes, stack slots, shadowing) | done |
| 23 | 23 | Control flow (if/while/for/and/or) |
done |
| 24 | 24 | Calls and Functions | done |
| 25 | 25 | Closures (upvalues) | done |
| 26 | 26 | Garbage Collection (safe handle-based mark-sweep) | done |
| 27 | 27 | Classes and Instances | done |
| 28 | 28 | Methods and Initializers | done |
| 29 | 29 | Superclasses | done |
| 30 | 30 | Optimization (safe string interning; NaN-boxing skipped — unsafe_code = "forbid") |
done |
- Rust 1.95 (pinned via
rust-toolchain.toml;rustupwill fetch it automatically).
cargo build # umbrella binary, default features (tree-walk)
cargo test --workspace --all-targets --locked # every crate
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warningsThe workspace command tests the default tree feature for the umbrella
binary. Run the VM-feature binary checks explicitly when touching the CLI or
backend selection:
cargo test -p rlox --no-default-features --features vm
cargo clippy -p rlox --no-default-features --features vm --all-targets -- -D warningsPer-crate iteration is faster while working on one backend:
cargo test -p rlox-tree # tree-walk only
cargo test -p rlox-vm # bytecode VM onlycargo run --quiet # REPL
cargo run --quiet -- path/to/file.lox # run a script
cargo run --quiet --no-default-features --features vm -- path/to/file.lox
cargo run --quiet -- --help # clap-generated help
cargo run --quiet -- --versionBoth backends parse every REPL line as a Lox program and persist state across
prompts. Variable bindings, function declarations, and the global clock()
survive between lines:
$ cargo run --quiet
> var x = 1 + 2 * 3;
> print x;
7
> fun greet(name) { print "hi, " + name; }
> greet("world");
hi, world
> ^D
The default tree-walk pipeline is exposed at the rlox crate root for
embedding:
use rlox::{run, scan, parse_program, resolve, Interpreter};
// One-shot — `run` returns captured `print` output.
assert_eq!(
run("var x = (1 + 2) * 3; print x;").unwrap(),
"9\n",
);
// Or drive the four stages (scan → parse → resolve → interpret) manually:
let (tokens, scan_errors) = scan("print 1 + 2;");
assert!(scan_errors.is_empty());
let stmts = parse_program(&tokens).unwrap();
let locals = resolve(&stmts).unwrap();
let mut buf = Vec::<u8>::new();
let mut interp = Interpreter::new(&mut buf);
interp.merge_locals(locals);
interp.interpret(&stmts).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "3\n");A few snippets you can paste into either REPL or save to a .lox file and run
with cargo run --quiet -- script.lox. They progress from basic statements
to classes and inheritance.
var n = 10;
var sum = 0;
for (var i = 1; i <= n; i = i + 1) {
sum = sum + i;
}
print sum; // 55
// Block scope shadows the outer binding without leaking back out.
var greeting = "outer";
{
var greeting = "inner";
print greeting; // inner
}
print greeting; // outer
and / or return the operand value, not a coerced boolean — and never
evaluate the right side once the answer is known:
print "hi" or 2; // hi
print 1 and 2; // 2
print nil or "fallback"; // fallback
fun fib(n) {
if (n < 2) return n;
return fib(n - 2) + fib(n - 1);
}
var start = clock();
print fib(20); // 6765
print clock() - start; // elapsed seconds
The book's classic makeCounter — count captures the surrounding i:
fun makeCounter() {
var i = 0;
fun count() {
i = i + 1;
return i;
}
return count;
}
var c = makeCounter();
print c(); // 1
print c(); // 2
print c(); // 3
Re-declaring a after a closure captures it must not leak the new
binding into the closure. This is the chapter-11 motivating fragment, and
the resolver makes it work out of the box:
var a = "global";
{
fun showA() { print a; }
showA(); // global
var a = "block";
showA(); // global (NOT "block")
}
class Point {
init(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
var dx = this.x - other.x;
var dy = this.y - other.y;
return dx * dx + dy * dy; // squared
}
}
var origin = Point(0, 0);
var p = Point(3, 4);
print p.distance(origin); // 25
The book's canonical super walkthrough — C().test() runs B.test
which dispatches super.method() to A.method, not B.method:
class A { method() { print "A method"; } }
class B < A {
method() { print "B method"; }
test() { super.method(); }
}
class C < B {}
C().test(); // A method
class Cake { init() { return 42; } }
// ^^^^^^^^^^ Can't return a value from an initializer.
The selected backend flags this before any code runs, so no half-built Cake
escapes into the runtime.
Matching jlox / sysexits.h:
| Code | Meaning |
|---|---|
| 0 | success |
| 2 | clap usage error (unknown flag) |
| 64 | runtime usage error (file unreadable) |
| 65 | compile error (scan / parse) |
| 70 | runtime error |
A Python harness drives the selected binary backend against a corpus of .lox
programs under scripts/smoke/, each annotated with
// expect: / // expect_runtime_error: / // expect_compile_error:
directives (same convention as the upstream craftinginterpreters test
suite). Useful as a quick end-to-end check covering happy paths,
runtime errors, and compile-time static errors:
scripts/smoke_test.py # build release + run every .lox
scripts/smoke_test.py --debug # use the debug artifact
scripts/smoke_test.py --filter class # only files with "class" in the name
scripts/smoke_test.py --no-build # reuse an existing artifact
scripts/smoke_test.py --binary target/release/rlox # use a specific binaryThe same harness runs in CI on every push and pull request for both tree
and vm optimized native binaries on Linux, macOS, and Windows targets, so an
end-to-end regression in exit codes, stderr formatting, or artifact packaging
blocks merges just like a unit-test failure.
- Test-Driven Development every phase — Red → Green → Refactor.
- Unit tests live next to the code under
#[cfg(test)] mod tests. - Integration tests under each crate's
tests/directory drive public APIs and CLI behavior. - Reference cases (inputs + expected outputs) are transcribed from
munificent/craftinginterpreters/test;
we re-encode them as native Rust
#[test]functions rather than running the upstream.loxfiles directly.
See CONTRIBUTING.md for the full workflow. In short:
- One branch + PR per logical change. Use
phase/<n>-<slug>for milestone work and Conventional-Commit-style prefixes such asdocs/<slug>,fix/<slug>, orci/<slug>for follow-ups. - Conventional Commits are enforced on PR titles only (squash-merge means the title becomes the single commit on
main). See.github/workflows/pr-title.yaml. - Branch protection for
mainis managed via a GitHub repository ruleset committed at.github/rulesets/main.json: squash-merge only, linear history, all CI checks required, no force pushes or deletions, conversation resolution required. - CI workflows under
.github/workflows/:ci.yaml—cargo fmt --check,cargo clippy --workspace --all-targets --locked -- -D warnings,cargo test --workspace --all-targets --locked, optimized Linux/macOS/Windows binary builds, smoke tests, and artifact packaging for both backends.coverage-and-audit.yaml—cargo-llvm-cov(uploaded to Codecov) andcargo audit --deny warnings.pr-title.yaml— Conventional Commits validation.
MIT.
- Robert Nystrom for the wonderful book and its test corpus.