Section 2: The Reference Interpreter
At the end of Section 1 you had a calculator. At the end of this section you have a language: variables, scope, control flow, functions, recursion, and a golden test corpus that defines what every one of them means.
Then you stop, and you freeze it.
That is the unusual part of this section and the reason it exists as a separate one. The tree-walking interpreter you finish here is not a stepping stone to be discarded when the bytecode VM works — it is the semantic reference implementation, kept forever, and used in Lab 12 to prove the VM computes the same answers. Almost no teaching project does this. It is the single highest-leverage decision in the curriculum.
This section covers Milestones M3, M4, and M5.
What You Build
| Module | What it does | Lab |
|---|---|---|
src/interp/env.rs | Scopes as a stack of hash maps — deliberately naive | 5 |
src/interp/eval.rs | Statements, blocks, assignment, control flow | 5, 6 |
src/interp/mod.rs | Function objects, frames, calls, the depth limit | 7 |
src/value.rs | Value::Function, and what a callable is | 7 |
tests/golden/ | 25+ programs with expected output — the specification | 8 |
tests/harness.rs | The runner that will later drive both backends | 8 |
The Layer You Are Building
AST (from §1)
│
▼
┌────────────────────────────────────────────────────────────────┐
│ INTERPRETER interp/ │
│ │
│ eval_block ──▶ eval_stmt ──▶ eval_expr │
│ │ │ │ │
│ │ │ └──▶ Value │
│ │ │ │
│ │ └──▶ control flow: how does `break` │
│ │ get out of three nested blocks? │
│ │ │
│ └──▶ scope push/pop │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ENVIRONMENT env.rs │ │
│ │ globals: HashMap<String, Value> │ │
│ │ scopes: Vec<HashMap<String, Value>> ← naive │ │
│ │ ON PURPOSE│ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ CALL STACK mod.rs │ │
│ │ depth counter (the Rust stack is the call stack — │ │
│ │ which is exactly the problem the VM will fix) │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Note: The environment is a
Vec<HashMap<String, Value>>and it is wrong — a variable read costs a string hash and a walk up the scope chain. You will build it anyway, feel it, benchmark it in Lab 8, and then watch Section 3 replace it with an array index. Building the wrong thing deliberately, once, with a measurement, is how you earn the right to have an opinion about the right thing.
The Concepts, and Where Each Is Treated
| Concept | Chapter | Why it matters later |
|---|---|---|
| Tagged unions, NaN boxing, pointer tagging, handles | Value Representation | Value's size is in every stack slot, every table, every argument. Also read before Lab 4 |
| Lexical scope, shadowing, environments, globals vs locals | Scope and Environments | The compiler's slot resolution in §3 is this chapter's problem, solved differently |
| Statements, blocks, jumps, truthiness, short-circuit | Control Flow | Every construct here becomes a jump-patching exercise in Lab 10 |
| Function objects, frames, calling conventions, recursion | Functions and Frames | The VM's CallFrame is this chapter's Rust stack frame, made explicit |
| Reference implementations, oracles, differential testing | The Reference Implementation | ADR-003. The reason this whole section is not throwaway work |
The Labs
| Lab | Title | Milestone |
|---|---|---|
| 5 | Variables and Scope | M3 |
| 6 | Control Flow | M4 |
| 7 | Functions and Recursion | M5 |
| 8 | The Reference Interpreter | M5 |
Deliverables
-
local, assignment, blocks, and shadowing work;local x = xreads the outerx. -
if/elseif/else,while, numericfor,break, andreturnall work. -
Only
nilandfalseare falsy;and/orshort-circuit and return operands. -
A numeric
forcreates a fresh binding per iteration. - Functions, recursion, and mutual recursion work.
-
Runaway recursion returns
ErrorKind::Limitwith a traceback — it does not abort. -
tests/golden/has 25+ programs, each with a.expectedfile. -
tests/harness.rsruns the corpus and is written so a second backend can be plugged in without touching the test cases. -
A benchmark records the tree walker's
fib(25)time. This is the Section 3 baseline. -
docs/adr/ADR-003-keep-the-tree-walker.mdwritten before any VM code exists. -
docs/learning/04-interpreter.mdand07-call-frames.mdwritten.
Common Mistakes in This Section
| Mistake | Symptom | Correction |
|---|---|---|
local x = x binds before evaluating | The initializer sees nil instead of the outer x | Evaluate the initializer then declare. This is one line and it is the difference between Lua's semantics and JavaScript's temporal dead zone. |
break implemented with a Rust break | Works for one loop, silently wrong inside nested blocks or a function body | Control flow that crosses statement boundaries needs a signal — a Flow enum returned up the stack. See Control Flow. |
and/or returning booleans | nil or 5 gives true instead of 5 | They return an operand. Test with non-boolean operands, always. |
Numeric for reusing one binding | Closures made in a loop all see the last value (Lab 14 will make this visible) | Fresh binding per iteration. This is a scope decision, not a loop decision. |
| No depth limit on calls | function f() return f() end aborts the process | The evaluator recurses on the Rust stack. Add a counter in Lab 7. The parser's guard does not cover this. |
| Deleting the tree walker after Section 3 | Nothing, for two weeks. Then a VM bug you cannot localize | ADR-003. This is the most expensive mistake in the curriculum. |
| Golden tests that assert internal state | Every refactor breaks fifty tests | Assert on printed output only. That is the only thing both backends can be required to agree on. |
| Optimizing the interpreter | An afternoon spent making the environment 20% faster | It is the reference, not the product. Correctness and readability only. Section 3 is where speed comes from. |
How to Verify Success
# 1. Scope, shadowing, and the initializer rule.
ember run -e 'local x = 1; do local x = 2 end; return x' # 1
ember run -e 'local x = 1; local x = x + 1; return x' # 2
# 2. Truthiness and short-circuit, with NON-boolean operands.
ember run -e 'return nil or 5' # 5
ember run -e 'return false and error_never_called' # false
ember run -e 'return 0 and "zero is truthy"' # zero is truthy
# 3. Control flow.
ember run tests/golden/fizzbuzz.ember | head -20
# 4. Recursion, and the limit.
ember run -e 'local function f(n) if n<2 then return n end return f(n-1)+f(n-2) end return f(25)'
ember run -e 'local function f() return f() end return f()'
# → error: stack overflow (call depth limit 200 exceeded), with a traceback.
# → exit code 1. NOT a signal. Check with: echo $?
# 5. The corpus. Every program, every expected output.
cargo test --test golden
# 6. The baseline you will need in Section 3.
cargo bench --bench calls -- fib_25 | tee docs/learning/baseline-interp.txt
Every one of those must pass before you write a single opcode. Section 3 is a second implementation of a specification, and this is the specification.
Section Profile: What a Section 2 Graduate Can Do
- Implement lexical scope with shadowing, and explain why
local x = xworks. - Explain why non-local control flow (
break,return) cannot be a Rustbreakin a tree walker, and name three ways to implement it. - Describe a calling convention: what a caller does, what a callee does, and who cleans up.
- Explain why a tree-walking interpreter's recursion limit is a safety property, and where the limit has to live.
- Argue for keeping a slow reference implementation, in writing, against the obvious objection.
- Build a test corpus that is backend-agnostic, and explain what "backend-agnostic" costs.
Next: Value Representation.