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

ModuleWhat it doesLab
src/interp/env.rsScopes as a stack of hash maps — deliberately naive5
src/interp/eval.rsStatements, blocks, assignment, control flow5, 6
src/interp/mod.rsFunction objects, frames, calls, the depth limit7
src/value.rsValue::Function, and what a callable is7
tests/golden/25+ programs with expected output — the specification8
tests/harness.rsThe runner that will later drive both backends8

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

ConceptChapterWhy it matters later
Tagged unions, NaN boxing, pointer tagging, handlesValue RepresentationValue's size is in every stack slot, every table, every argument. Also read before Lab 4
Lexical scope, shadowing, environments, globals vs localsScope and EnvironmentsThe compiler's slot resolution in §3 is this chapter's problem, solved differently
Statements, blocks, jumps, truthiness, short-circuitControl FlowEvery construct here becomes a jump-patching exercise in Lab 10
Function objects, frames, calling conventions, recursionFunctions and FramesThe VM's CallFrame is this chapter's Rust stack frame, made explicit
Reference implementations, oracles, differential testingThe Reference ImplementationADR-003. The reason this whole section is not throwaway work

The Labs


Deliverables

  • local, assignment, blocks, and shadowing work; local x = x reads the outer x.
  • if/elseif/else, while, numeric for, break, and return all work.
  • Only nil and false are falsy; and/or short-circuit and return operands.
  • A numeric for creates a fresh binding per iteration.
  • Functions, recursion, and mutual recursion work.
  • Runaway recursion returns ErrorKind::Limit with a traceback — it does not abort.
  • tests/golden/ has 25+ programs, each with a .expected file.
  • tests/harness.rs runs 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.md written before any VM code exists.
  • docs/learning/04-interpreter.md and 07-call-frames.md written.

Common Mistakes in This Section

MistakeSymptomCorrection
local x = x binds before evaluatingThe initializer sees nil instead of the outer xEvaluate 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 breakWorks for one loop, silently wrong inside nested blocks or a function bodyControl flow that crosses statement boundaries needs a signal — a Flow enum returned up the stack. See Control Flow.
and/or returning booleansnil or 5 gives true instead of 5They return an operand. Test with non-boolean operands, always.
Numeric for reusing one bindingClosures 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 callsfunction f() return f() end aborts the processThe 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 3Nothing, for two weeks. Then a VM bug you cannot localizeADR-003. This is the most expensive mistake in the curriculum.
Golden tests that assert internal stateEvery refactor breaks fifty testsAssert on printed output only. That is the only thing both backends can be required to agree on.
Optimizing the interpreterAn afternoon spent making the environment 20% fasterIt 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 = x works.
  • Explain why non-local control flow (break, return) cannot be a Rust break in 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.