cp-04 — Symbol Tables & the Resolver Pass

Status: ✅ Built · all tests passing

A second compiler pass that runs between parsing and execution. The resolver:

  • maintains a scope stack (lexical scopes seen so far),
  • annotates every variable use with the lexical depth at which its binding lives,
  • statically detects a class of bugs the cp-03 interpreter would only catch at runtime — or worse, miss entirely.

The interpreter is then changed to do O(depth) lookups using the annotation — no more hash-map walk up the parent chain.

What's new vs cp-03

Aspectcp-03cp-04
Phaseslex → parse → interpretlex → parse → resolve → interpret
Variable lookupdynamic walk of parent envsstatic depth + one hash lookup at the right scope
let vs varparsed but treated identicallylet immutable (resolver rejects assignment), var mutable
Errors caughtmostly at runtimeundefined, redecl, self-init, assign-to-let, top-level return

Source layout (src/cpp/)

src/
  token.hpp / lexer.{hpp,cpp}     # unchanged from cp-03
  value.hpp                       # unchanged
  ast.hpp                         # adds DeclKind + depth fields
  parser.{hpp,cpp}                # records DeclKind & node line numbers
  environment.hpp                 # adds getAt(depth)/assignAt(depth)
  resolver.{hpp,cpp}              # NEW — the static-analysis pass
  interpreter.{hpp,cpp}           # uses depth when available
  main.cpp                        # runs resolver before interpreter
tests/test_resolver.cpp           # 15 assertions (regression + new diagnostics)

Build & test

cd src/cpp
cmake -S . -B build -G "Unix Makefiles"
cmake --build build -j
ctest --test-dir build --output-on-failure

Expected: cp-04 tests: 15/15 PASS.

Sample diagnostics

$ cat > bad.ml <<'EOF'
{ let x = 1; let x = 2; x = 3; return 99; }
EOF
$ build/mli bad.ml
[line 1] resolver: redeclaration of 'x' in the same scope (previous at line 1)
[line 1] resolver: cannot assign to immutable binding 'x' (declared with 'let')
[line 1] resolver: 'return' outside of a function

The resolver reports all problems at once, then main exits 1 without running a single instruction. This is the same shape as a C compiler: fail in the front end, never reach codegen.

See src/cpp/steps/ for the build-up

  1. 01-why-a-separate-pass.md — what runtime-only resolution costs us
  2. 02-ast-changes.md — adding DeclKind and the depth slot
  3. 03-the-resolver-walk.md — visitor over Stmt/Expr, scope stack
  4. 04-declare-then-define.md — the trick that catches let x = x;
  5. 05-let-vs-var.md — immutability check on assignment
  6. 06-getAt-and-fast-lookup.md — wiring depth into the interpreter
  7. 07-error-recovery.md — collecting diagnostics instead of throwing