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.