03 — Declaring and Resolving Names

The resolver's two core operations are declaring a name (introducing a new binding) and resolving a reference (computing its depth).

Declaring a name

void Resolver::declare(const std::string& name) {
    if (scopes_.empty()) return;  // global — skip
    auto& scope = scopes_.back();
    if (scope.count(name))
        reportError("Variable '" + name + "' already declared in this scope.");
    scope[name] = false;  // declared, not yet defined
}

void Resolver::define(const std::string& name) {
    if (scopes_.empty()) return;  // global — skip
    scopes_.back()[name] = true;  // fully defined
}

The declare/define split means:

  1. let x = x;declare("x") sets x→false, resolve initialiser x → finds false → error "can't read local in its own initialiser".
  2. let x = 1;declare("x"), resolve initialiser 1 (no names), define("x") → no error.

The visitLet method

void Resolver::visitLet(LetStmt& s) {
    declare(s.name);
    if (s.init) resolve(*s.init);  // initialiser can NOT see s.name yet
    define(s.name);
}

The visitVar / visitFunction

var works the same as let for the resolver — mutability is an interpreter-level concern (cp-04 step 05), not a resolution concern.

Functions:

void Resolver::resolveFunction(FnExpr& fn) {
    beginScope();
    for (auto& param : fn.params) {
        declare(param);
        define(param);  // params are immediately defined
    }
    resolve(*fn.body);
    endScope();
}

Parameters are both declared and defined before the body is resolved. There's no initialiser for parameters — they're always provided by the caller.

The visitVarExpr method

void Resolver::visitVarExpr(VarExpr& e) {
    if (!scopes_.empty()) {
        auto it = scopes_.back().find(e.name);
        if (it != scopes_.back().end() && it->second == false)
            reportError("Can't read '" + e.name + "' in its own initialiser.");
    }
    resolveLocal(&e, e.name);
}

The self-initialiser check only looks at scopes_.back() — the current scope. If the name is in an outer scope and is false, it's a different (outer) variable in mid-initialisation, not a problem for the current reference.

Block scopes don't "hoist"

In JavaScript, var declarations are hoisted to the top of the function scope. In MiniLang, let and var are not hoisted — a reference before the declaration is a static error:

print x;   // error: "x" not found (resolver reports it)
let x = 1;

The resolver only adds a name to scopes_.back() when it encounters the let/var statement. Any VarExpr seen before that point falls through the entire scope stack and is treated as a global. If x is not a global either, the error is caught at resolve time. This is strictly better than runtime "undefined variable" errors.