02 — The Scope Stack
The resolver needs to track which names are in scope at each point in the
AST. It does this with a scope stack: a vector of maps, each map
representing one lexical scope.
The structure
std::vector<std::unordered_map<std::string, bool>> scopes_;
Each map entry has type bool:
false— the variable has been declared (the slot exists) but its initialiser hasn't finished evaluating yet.true— the variable has been fully defined (initialiser is done).
This two-stage state catches the self-initialisation error:
let x = x + 1; // error: x used in its own initialiser
When the resolver sees let x = ..., it first declares x (pushes
"x" → false), evaluates the initialiser (during which x is false),
then defines x (sets "x" → true). If the initialiser references x,
resolveLocal sees false and reports an error before interpreting.
Scope boundaries
void Resolver::beginScope() {
scopes_.emplace_back(); // push empty map
}
void Resolver::endScope() {
scopes_.pop_back();
}
Called around:
- Every
BlockStmt - Every function body (one scope for the function's parameters, one for the body block — or combined)
void Resolver::visitBlock(BlockStmt& b) {
beginScope();
for (auto& s : b.body) resolve(*s);
endScope();
}
Looking up across the stack
void Resolver::resolveLocal(Expr* e, const std::string& name) {
for (int i = (int)scopes_.size() - 1; i >= 0; --i) {
if (scopes_[i].count(name)) {
interp_.locals_[e] = (int)scopes_.size() - 1 - i;
return;
}
}
// Not found → global; no annotation needed
}
The depth (size - 1 - i) is 0 for the innermost scope, 1 for one level
up, and so on. The interpreter's getAt(depth, name) walks the environment
chain exactly depth steps:
Value& Environment::getAt(int depth, const std::string& name) {
Environment* env = this;
for (int i = 0; i < depth; ++i) env = env->parent_.get();
return env->vars_.at(name);
}
Why a vector of maps, not a single map?
A single global map can't track shadowing: if x is declared at depth 2
and redeclared at depth 0, both need entries that point to different depths.
A vector of maps makes the stack structure explicit and indexable. The
outermost scope is scopes_[0], the innermost is scopes_.back().
The global scope
The top-level scope is not pushed onto scopes_. Global variables are
resolved by falling through the entire stack search without finding the
name. The interpreter then looks them up directly in globals_. This
separation means globals can be referenced before they're declared (e.g.
mutual recursion at the top level), which is a valid and useful feature.