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.