Skip to content

Repository files navigation

rlox

CI codecov

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.

Status

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 VM

The 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.

Milestone 1 — chapters 4–7 (scanner, parser, expressions)

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

Milestone 2 — chapters 8–13 (statements, classes)

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

Milestone 3 — chapters 14–30 (bytecode VM)

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

Requirements

Build, test, lint

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 warnings

The 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 warnings

Per-crate iteration is faster while working on one backend:

cargo test -p rlox-tree         # tree-walk only
cargo test -p rlox-vm           # bytecode VM only

Run

cargo 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 -- --version

REPL example

Both 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

Library quick-start

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");

Lox showcase

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.

Variables and control flow (chapters 8–9)

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

Short-circuit logical operators (chapter 9)

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

Functions, recursion, and clock (chapter 10)

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

Closures (chapter 10)

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

Closure-capture correctness via the resolver (chapter 11)

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")
}

Classes, methods, this, and init (chapter 12)

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

Inheritance and super (chapter 13)

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

Static error caught by the resolver

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.

Exit codes

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

Smoke tests

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 binary

The 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.

Testing approach

  • 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 .lox files directly.

Repository conventions

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 as docs/<slug>, fix/<slug>, or ci/<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 main is 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) and cargo audit --deny warnings.
    • pr-title.yaml — Conventional Commits validation.

License

MIT.

Acknowledgements

  • Robert Nystrom for the wonderful book and its test corpus.