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

FeatureWithout resolverWith resolver
Variable lookupO(depth) chain walk per accessO(1) direct-depth
Closure semanticsAccidental dynamic scope possibleLexical scope enforced
Self-init bugCrashes at runtimeStatic error
RedeclarationSilent shadowingStatic error
Top-level returnRuntime 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.