01 — The Resolver Pass: Why and What
The interpreter in cp-03 resolves variable names at runtime, by walking the environment chain on every access. That works but has two problems:
- Performance: O(depth) lookup on every read/write.
- Correctness: The interpreter can silently read from the wrong scope if names shadow each other across closure boundaries.
The resolver pass is a static analysis pass that runs after parsing and
before interpreting. It walks the AST once, builds a map of (VarExpr* → depth), and annotates every variable use with the exact environment
depth at which its binding lives. The interpreter can then do O(1)
direct-depth lookups.
The scope of the problem
var a = "global";
fn outer() {
fn inner() { print a; }
inner();
}
outer();
With a naive runtime-walking resolver:
- When
print aruns, the interpreter looks ininner's frame, thenouter's frame, then global, findsa = "global"and prints it. - This works correctly.
But:
var a = "global";
fn showA() {
print a;
}
fn test() {
var a = "local";
showA();
}
test();
Lexical scope says showA should print "global" — a in its body
refers to the a visible when showA was defined, not when it's called.
A runtime chain walk starting from the call site environment would
incorrectly find "local" from the caller test.
The resolver fixes this: it records, at parse time, that the a in showA
is 1 level up from its closure's capture point. At runtime the interpreter
goes exactly 1 level up in the closure's parent chain — not the caller's.
What the resolver produces
A std::unordered_map<Expr*, int> locals_ in the interpreter:
class Resolver {
std::vector<std::unordered_map<std::string, bool>> scopes_;
Interpreter& interp_; // writes into interp_.locals_
public:
void resolve(std::vector<StmtPtr>& stmts);
private:
void resolveLocal(Expr* e, const std::string& name);
void beginScope();
void endScope();
};
resolveLocal(expr, name) searches scopes_ from the innermost outward.
When it finds the name, it records scopes_.size() - 1 - depth (the
"distance" from the current scope) in interp_.locals_[expr].
The interpreter's lookUpVariable
Value Interpreter::lookUpVariable(const std::string& name, Expr* e) {
auto it = locals_.find(e);
if (it != locals_.end())
return env_->getAt(it->second, name); // O(1) direct depth
return globals_->get(name);
}
Variables not found in locals_ are globals — they're not annotated
because they live at depth 0 from the global environment, which the
interpreter always has a direct pointer to.
When to run the resolver
After parsing the full source, before execution:
auto stmts = parser.parse();
Resolver resolver(interp);
resolver.resolve(stmts);
for (auto& s : stmts) interp.execute(*s);
The resolver pass is one linear traversal of the AST. It touches every
node exactly once. After it runs, the interpreter's locals_ map is
fully populated and all subsequent variable lookups are O(1).