07 — O(depth) Interpreter Lookups and Testing
This final step ties everything together, reviews the performance model, and verifies the full resolver + interpreter integration.
The complete variable lookup chain
From source text to value:
Source → Lexer → [Token stream] → Parser → [AST]
→ Resolver → [locals_ map: Expr* → depth] → Interpreter
→ lookUpVariable(name, expr*) → env_->getAt(depth, name) → Value
The resolver runs once. After it populates locals_, every variable
reference in every execution of any function body is a direct-depth lookup
with no chain scanning.
When depth can be wrong
There is one subtle case: if the interpreter creates extra environment
layers that the resolver didn't see, getAt(depth) overshoots. This can
happen if you add an implicit scope (e.g. around a single-expression if
body without braces). The resolver must create a beginScope/endScope
wherever the interpreter creates a new Environment. Keep them in sync.
The test suite for cp-04 includes a "depth sync" test:
void test_depth_sync() {
// Deep nesting should still resolve correctly
auto out = run(R"(
let a = 10;
fn f() {
let b = 20;
fn g() {
let c = 30;
return a + b + c;
}
return g();
}
print f();
)");
CHECK_EQ(out, "60\n");
}
This exercises a 3-level closure chain. If a has depth 2 from inside
g, getAt(2) climbs: g's frame → f's frame → f's closure (global),
finds a = 10. Any off-by-one in the depth computation fails this test.
Testing the static errors
void test_redeclare() {
bool threw = false;
try { run("let x = 1; let x = 2;"); }
catch (const ResolveError& e) { threw = true; }
CHECK_EQ(threw, true);
}
void test_self_init() {
bool threw = false;
try { run("let x = x + 1;"); }
catch (const ResolveError& e) { threw = true; }
CHECK_EQ(threw, true);
}
void test_top_level_return() {
bool threw = false;
try { run("return 42;"); }
catch (const ResolveError& e) { threw = true; }
CHECK_EQ(threw, true);
}
Testing closure correctness
void test_closure_captures_definition_site() {
auto out = run(R"(
var a = "global";
fn showA() { print a; }
fn test() {
var a = "local";
showA();
}
test();
)");
CHECK_EQ(out, "global\n"); // lexical scope, not dynamic
}
Without the resolver, a dynamic-scope interpreter prints "local".
With the resolver, the a reference inside showA is annotated as
a global (depth not in locals_), so the interpreter looks in globals_,
finds "global", and prints it correctly.
Summary: what the resolver gives you
| Feature | Without resolver | With resolver |
|---|---|---|
| Variable lookup | O(depth) chain walk per access | O(1) direct-depth |
| Closure semantics | Accidental dynamic scope possible | Lexical scope enforced |
| Self-init bug | Crashes at runtime | Static error |
| Redeclaration | Silent shadowing | Static error |
| Top-level return | Runtime crash (ReturnSignal uncaught) | Static error |
The resolver is a small investment — ~150 lines — that pays dividends in every subsequent phase. The bytecode compiler in cp-06 uses the same scope-stack technique to allocate stack slots; the type checker in cp-05 uses it to track type annotations.