Chapter 7: Scope, and catching mistakes before running anything
This chapter:
- Add a static resolution pass: walk the AST once, before anything executes, and bind every variable reference to a scope depth and slot.
- Catch undefined variables, use-before-declaration, duplicate declarations, and wrong-arity calls as resolution errors, not as wherever-it-happens-to-blow-up runtime bugs.
- Grow
musc check, the last of Part I-II's three inspection subcommands.
Where we left off: musc parse builds a correct AST for the whole language so far, but every Expr_Ident, Expr_Assign and Stmt_Var still carries depth = -1, slot = -1: nothing has ever looked at what a name actually refers to.
7.1 Why resolve scope statically at all
Nothing about Muspel's grammar requires a separate resolution pass. A tree-walking interpreter (next chapter) could just look a name up dynamically, at the moment it's read, by walking a chain of live environments. Plenty of real, working interpreters do exactly that. Muspel doesn't, and it's worth being honest about why before writing the pass.
Design decision: a static resolution pass before execution, rather than dynamic lookup at run time. Alternatives considered: skip this chapter's pass entirely; let the Chapter 8 tree-walker resolve every variable reference dynamically, by walking its environment chain each time a name is read. Trade-off: dynamic lookup is less code, right now, for exactly the reason Chapter 1 warned about: it defers a real problem rather than solving it. A script with an undefined variable only fails the moment execution actually reaches that line, which might be deep inside a rarely-hit branch of a boss pattern, three plays into a session. Static resolution costs a real extra pass, paid here, once, before anything runs. Choice: resolve statically, for two reasons that only partly overlap. First, predictability:
musc checkcan tell a script author every scope mistake in a file without ever running it, the same category of confidencemusc parse's error recovery already gives for syntax. Second, and this is the one that doesn't pay off until Part III: the(depth, slot)pair this pass computes for every variable reference is exactly the information the Chapter 11-12 bytecode compiler needs to emitGET_LOCAL/SET_LOCALwith a literal slot index instead of a runtime name lookup. This chapter's "extra" work gets spent twice.
7.2 Lexical scoping rules
Muspel is block-scoped: a var declared inside { ... } is visible from that point to the end of the enclosing block, and disappears when the block closes. A block may shadow an outer name freely, declaring a new binding that temporarily hides an outer one with the same name; that's ordinary and not an error. Function parameters introduce their own scope, the function's body, exactly like a block. What is an error is declaring the same name twice in the same scope, covered in section 7.4.
7.3 The resolver as a scope-stack walk
Resolver tracks a stack of scopes, innermost last, plus three lookup tables that live outside the stack because they're not block-scoped at all:
Resolver :: struct {
scopes: [dynamic]map[string]Local,
globals: map[string]bool,
functions: map[string]Func_Info,
builtins: map[string]bool,
errors: [dynamic]Resolve_Error,
}
Local :: struct {
slot: int,
defined: bool,
}
Local.defined is the mechanism behind Muspel's declare-then-define-then-use discipline: a name is declared into the current scope (so it's visible for duplicate-detection and shadowing) before its initialiser expression is resolved, but not marked defined until that initialiser has finished. This is precisely what catches var x = x;: the outer x's initialiser resolves before the inner x is declared at all, so the read on the right resolves to whatever x exists further out (or reports "undefined" if there isn't one), never to the not-yet-defined local it's part of declaring.
resolve_stmt :: proc(r: ^Resolver, s: Stmt, allocator: runtime.Allocator) {
switch node in s {
case ^Stmt_Var:
if node.init != nil do resolve_expr(r, node.init, allocator)
node.slot = declare(r, node.line, node.name)
define(r, node.name)
node.depth = 0 if len(r.scopes) > 0 else -1
// ...
Note the order: resolve_expr on the initialiser happens before declare. That single line ordering is the whole use-before-declaration guarantee.
resolve_name is the read side: walk the scope stack from innermost outward, and the first match wins, exactly mirroring how a running program would actually search nested blocks:
resolve_name :: proc(r: ^Resolver, line: int, name: string) -> (depth: int, slot: int) {
for i := len(r.scopes) - 1; i >= 0; i -= 1 {
if local, ok := r.scopes[i][name]; ok {
if !local.defined {
resolve_error(r, line, fmt_undeclared(name, "can't be used in its own initialiser"))
}
return len(r.scopes) - 1 - i, local.slot
}
}
if _, ok := r.functions[name]; ok do return -1, -1
if _, ok := r.builtins[name]; ok do return -1, -1
if _, ok := r.globals[name]; ok do return -1, -1
resolve_error(r, line, fmt_undeclared(name, "is undefined"))
return -1, -1
}
depth counts scopes outward from the point of use, 0 meaning "the innermost scope currently open." A name found in functions, builtins, or top-level globals instead resolves to -1: not because it's an error, but because none of those three live in the block-scope stack at all (top-level vars are tracked in globals rather than a pushed scope, since there's no enclosing block for the top level to be a scope of).
Why predeclare
r.builtins["print"] = trueat resolver construction means example scripts can callprint(...)without Muspel ever having seen a declaration for it. This is a deliberate small anticipation of Chapter 16's real host-embedding registration: for now it's one hard-coded name, arity unchecked (native functions are the embedding boundary's business, not this pass's), just enough formusc checkto accept the same example scripts every other chapter already runs.
7.4 What gets caught, and how
Duplicate declaration in the same scope is a declare-time check: if the current innermost scope already has an entry for this name, it's an error immediately, no need to wait for a use:
declare :: proc(r: ^Resolver, line: int, name: string) -> int {
if len(r.scopes) == 0 {
if r.globals[name] {
resolve_error(r, line, fmt_undeclared(name, "is already declared"))
}
r.globals[name] = true
return -1
}
scope := &r.scopes[len(r.scopes) - 1]
if _, exists := scope[name]; exists {
resolve_error(r, line, fmt_undeclared(name, "is already declared in this scope"))
return -1
}
slot := len(scope)
scope[name] = Local{slot = slot, defined = false}
return slot
}
Undefined variables and use-before-declaration are both caught in resolve_name, shown above: falling off the end of the scope stack with no match anywhere is "undefined"; finding a match whose defined flag is still false is "used in its own initialiser."
Wrong-arity calls need functions to be known before their call sites are resolved, including call sites that appear earlier in the file than the function's own declaration. resolve_program handles this with two passes over the top level: first, register every top-level func's name and parameter count; only then walk every statement's body. Only the top level, deliberately: Chapter 4's grammar already restricts func to top-level declarations, precisely so this pass never has to go looking for a function nested inside a block, an r.functions table with no scoping of its own would have no correct way to represent that.
resolve_program :: proc(r: ^Resolver, stmts: []Stmt, allocator := context.allocator) {
for s in stmts {
if fn, ok := s.(^Stmt_Func); ok {
if _, exists := r.functions[fn.name]; exists {
resolve_error(r, fn.line, fmt_undeclared(fn.name, "is already declared"))
continue
}
r.functions[fn.name] = Func_Info{arity = len(fn.params)}
}
}
for s in stmts {
resolve_stmt(r, s, allocator)
}
}
The exists check guards against two top-level func add declarations silently letting the second overwrite the first: duplicate top-level functions are exactly as much an error as duplicate variables in a block, just checked in this first pass instead of inside declare.
The arity check itself lives in resolve_expr's Expr_Call case, comparing a call's argument count against the callee's registered arity, but only when the callee is a known, resolved function name, not an arbitrary expression:
if ident, ok := node.callee.(^Expr_Ident); ok {
if info, known := r.functions[ident.name]; known {
if len(node.args) != info.arity {
resolve_error(r, node.line, fmt_arity(ident.name, info.arity, len(node.args)))
}
}
}
7.5 Struct field checking
Chapter 5 left two fields on Expr_Struct_Literal unresolved: field_indices and all_fields. This is where they get filled in. Stmt_Struct_Decls are hoisted in the same first pass resolve_program already uses for functions (section 7.4), registering every top-level struct's declared field order:
Struct_Info :: struct {
fields: []string,
}
Resolving a literal means matching every key it wrote against that declared order:
case ^Expr_Struct_Literal:
if info, ok := r.structs[node.type_name]; ok {
node.all_fields = info.fields
for key, i in node.keys {
idx := -1
for f, fi in info.fields {
if f == key {
idx = fi
break
}
}
if idx == -1 {
resolve_error(r, node.line, fmt.tprintf("struct '%s' has no field '%s'", node.type_name, key))
}
node.field_indices[i] = idx
}
} else {
resolve_error(r, node.line, fmt.tprintf("'%s' is not a known struct type", node.type_name))
}
A typo'd field name (Vec2{ x: 1, z: 2 } when Vec2 only declares x/y) is now a resolution error, caught before a single instruction runs:
./run.sh check examples/struct_err.mus
5: error: struct 'Vec2' has no field 'z'
Worth being precise about what this check doesn't do: it validates a struct literal's keys against its own declared type, the type named right there in the literal. It does not track which struct type a given variable holds across the rest of the program (enemy.pos.x's .pos access isn't checked against any particular struct's field list here), because that would need a real static type system, tracking a variable's type through every assignment and branch, which is a different, considerably larger project than this book's scope. enemy.pos.x is still checked, just later and differently: Chapter 8's tree-walker (and, ahead of it, Chapter 13's VM) look a field up by name against whatever struct instance the object expression actually evaluates to at run time, a Muspel-level "no such field" error if it's wrong, never a silent success on a typo, just not one this pass can catch before the line runs.
7.6 Enum member checking
Phase.Attacking parses (Chapter 5) as an ordinary Expr_Field_Access, indistinguishable at parse time from enemy.pos. This pass is where the two finally split apart, by checking whether the object side reads back as a known type name rather than an ordinary value expression:
Enum_Info :: struct {
members: []string,
}
// qualified_prefix reads `e` back as a dotted name ("Phase", "common", "common.Phase"), so the
// caller can check it against r.enums/r.functions before assuming `e` is an ordinary runtime
// value. Chapter 20 is what makes the multi-level "common.Phase" case matter; for now every
// enum this chapter's examples use resolves at the first, single-identifier level.
qualified_prefix :: proc(e: Expr) -> (string, bool) {
#partial switch node in e {
case ^Expr_Ident:
return node.name, true
case ^Expr_Field_Access:
if node.is_enum_member || node.is_qualified_ref do return "", false
if base, ok := qualified_prefix(node.object); ok {
return fmt.tprintf("%s.%s", base, node.field), true
}
}
return "", false
}
case ^Expr_Field_Access:
if base, ok := qualified_prefix(node.object); ok {
if einfo, is_enum := r.enums[base]; is_enum {
tag := -1
for m, mi in einfo.members {
if m == node.field {
tag = mi
break
}
}
if tag == -1 {
resolve_error(r, node.line, fmt.tprintf("enum '%s' has no member '%s'", base, node.field))
}
node.is_enum_member = true
node.enum_tag = tag
return
}
// ... Chapter 20's qualified-function check goes here too, same shape ...
}
resolve_expr(r, node.object, allocator)
Phase.Fleeing, if Phase never declared a Fleeing member, is now caught the same way a struct typo is:
./run.sh check examples/enum_err.mus
5: error: enum 'Phase' has no member 'Fleeing'
Once resolved, node.enum_tag holds a plain integer, Phase's member list index; nothing about Phase.Attacking needs a struct instance, a field lookup, or even a runtime value at all beyond that one number, exactly the "cheap under the hood" property section 5.5 promised.
7.7 for item in list introduces its own scope
Stmt_For_In resolves almost exactly like Stmt_Var: the list expression resolves in the outer scope (it can't see the loop variable, which doesn't exist until the loop starts), then a new scope opens to hold the item binding itself:
case ^Stmt_For_In:
resolve_expr(r, node.list, allocator)
begin_scope(r, allocator)
node.slot = declare(r, node.line, node.item_name)
define(r, node.item_name)
node.depth = 0
resolve_stmt(r, node.body, allocator)
end_scope(r)
node.body is itself a Stmt_Block, which pushes a second, nested scope of its own when it's resolved: the loop variable and the body's own locals end up one scope apart, not sharing one, the same "an extra scope layer beyond conceptual necessity" shape if/while bodies already have by the same mechanism. It costs nothing beyond one more entry in r.scopes, and it means the loop variable's own (depth, slot) is always predictable: depth = 1 from anywhere directly inside the loop body, never 0.
7.8 musc check
musc check runs the full pipeline so far, lex, parse, resolve, and reports either every static error found or a clean bill of health:
./run.sh check examples/scope.mus
resolved cleanly
Given a file with real mistakes:
func add(a, b) {
return a + b
}
var total = 0
{
var total = total
}
print(missing)
print(add(1, 2, 3))
./run.sh check examples/scope_errors.mus
9: error: 'missing' is undefined
10: error: 'add' expects 2 argument(s), got 3
Two things worth noticing in that output. First, var total = total inside the block reports no error at all: the inner total's initialiser resolves before the inner total is declared, so it correctly finds the outer, global total instead, exactly the shadowing-during-init behaviour section 7.3 described, not a bug. Second, musc check never ran a single line of this program to find either mistake.
Checkpoint
musc check reports every static error in a deliberately broken example (an undefined variable, a wrong-arity call, a duplicate declaration in one scope, an unknown struct field, an unknown enum member) and confirms a correct program "resolved cleanly," all without executing a single statement.
Exercises
- Write a program with a duplicate
var xdeclaration in the same block and confirmmusc checkcatches it; then move the second declaration into a nested block and confirm it's now accepted as ordinary shadowing. (examples/duplicate.musis exactly this exercise, already written; check your version reports the same error at the same line before reading it.) - Call a function with too few arguments instead of too many, and confirm the arity error's wording still reads correctly.
- Trace through
resolve_nameby hand for a three-level-nested reference (a name read inside a block, inside a block, inside a function body) and write down whatdepthvalue you'd expect it to resolve to, then confirmmusc parse's[depth=... slot=...]annotation (parse it, don't just check it) agrees. Note:musc parsealone never resolves anything; you'll need to check the AST some other way, or add a temporary print statement tocmd_check, to see the resolved depth directly. That's deliberate: it's a small nudge toward reading the actual resolver code rather than trusting this paragraph. - Write a file with two top-level
func adddeclarations and confirmmusc checkreports the second as already declared; then readresolve_program's first pass again and explain why this check has to happen in its own pass over all top-level statements, rather than being folded intodeclarethe way local-scope duplicates are. - Declare
struct A { x }andstruct B { x }, and confirmA{ x: 1 }andB{ x: 1 }both resolve cleanly, each against its own struct's field list. Then writeenemy.xwhereenemyholds aBand explain, from section 7.5's own text, why this pass has nothing to say about whether.xis valid there at all. - Write
for item in 5 { print(item) }, looping over a plain number instead of an array, and confirmmusc checkreports nothing wrong; predict what Chapter 8's tree-walker will do with it instead, before reading ahead to find out.
Further reading
- Crafting Interpreters, the resolving-and-binding chapter, the direct ancestor of this chapter's declare/define/use discipline: https://craftinginterpreters.com/resolving-and-binding.html
- A short mental model worth carrying forward: this pass is a much smaller version of what a real compiler's semantic analysis phase does, the stage between parsing and code generation where a program is checked for meaning, not just shape.