06 — Static Errors: Redeclaration, Self-Init, Top-Level Return

The resolver catches three categories of semantic errors before the program runs, producing precise messages that point to the exact source location.

Redeclaration in the same scope

let x = 1;
let x = 2;   // error: already declared in this scope

In the resolver's declare:

void Resolver::declare(const std::string& name, int line) {
    if (scopes_.empty()) return;
    auto& scope = scopes_.back();
    if (scope.count(name))
        throw ResolveError("[line " + std::to_string(line) +
            "] Variable '" + name + "' already declared in this scope.");
    scope[name] = false;
}

Redeclaration in a nested scope is allowed (shadowing). Only the same scope triggers the error:

let x = 1;
{
    let x = 2;  // OK — different scope
}

Self-referential initialiser

let x = x + 1;  // error: can't read 'x' in its own initialiser

Detected in visitVarExpr when the found entry is false (declared but not yet defined):

if (!scopes_.empty()) {
    auto it = scopes_.back().find(name);
    if (it != scopes_.back().end() && it->second == false)
        throw ResolveError("[line " + std::to_string(line) +
            "] Can't read '" + name + "' in its own initialiser.");
}

This distinguishes the bad case from the legitimate recursive case:

fn fib(n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);  // OK — fib is fully defined before we get here
}

fib is defined (the let fib = fn... fully finishes) before the body runs. So when visitVarExpr for fib(n-1) is resolved, the resolver finds fib as true in an outer scope — not as false.

return outside a function

return 42;  // error at top level

The resolver tracks whether it is currently inside a function:

enum class FunctionType { None, Function };
FunctionType currentFunction_ = FunctionType::None;

void Resolver::visitReturn(ReturnStmt& s) {
    if (currentFunction_ == FunctionType::None)
        throw ResolveError("[line " + std::to_string(s.line) +
            "] Can't return from top-level code.");
    if (s.value) resolve(*s.value);
}

void Resolver::resolveFunction(FnExpr& fn) {
    auto enclosing = currentFunction_;
    currentFunction_ = FunctionType::Function;
    // ... resolve body ...
    currentFunction_ = enclosing;
}

currentFunction_ is a scoped state flag, saved and restored when entering and leaving each function. Nested functions work correctly because the save/restore is a stack discipline.

Error recovery

Each error throws a ResolveError exception. In a production compiler you'd collect all errors and report them together. For the curriculum the first error terminates resolution with a clear message. Improving this to collect-and-continue is a good exercise: change the resolver to push errors into a vector<ResolveError> and only throw at the end of resolve(stmts).

The three errors together — a test

void test_static_errors() {
    // Redeclaration
    CHECK_THROWS(run("let x = 1; let x = 2;"), "already declared");
    // Self-init
    CHECK_THROWS(run("let x = x + 1;"), "own initialiser");
    // Top-level return
    CHECK_THROWS(run("return 42;"), "top-level");
}

These three static analyses, taken together, eliminate a whole class of runtime crashes that would otherwise only manifest as obscure interpreter bugs deep into execution.