ajhahn.de
← Flash
Flash 1538 lines
// Flash sema — native semantic checks over the parsed program.
//
// Tier 0 leans on Zig's type checker downstream: the emitted source is fully
// type-checked when FlashOS builds it, so Flash sema is deliberately thin — it
// owns the checks Zig cannot phrase against Flash source lines (bindings,
// scopes, mutability), never types. It walks the program with a single scope
// stack of `Binding`s — the file-level frame seeded from the `use` / `const` /
// `fn` declarations, then a frame pushed per function body and per block — and
// *collects* `Diag`s instead of failing on the first error, so one run reports
// every problem at once.
//
// A `Diag` is anchored by a source slice, not a line number. Because every AST
// string is a byte-slice into the original source (see ast.flash), a
// diagnostic's line and column are recovered from the anchor's address at
// render time (`locate`); the AST carries no span bookkeeping. This makes the
// slice invariant load-bearing: an anchor must always be a real slice into the
// source buffer, never a synthesized string.
//
// Active checks (the binding/scope/mutability teeth — never types):
//   * member-access root resolution — every `X.field…` must have a root `X`
//     that is an import, a top-level declaration, a parameter, or a binding in
//     scope. A bare identifier (a function passed by name, a direct call) is
//     not checked: it resolves downstream against the emitted Zig.
//   * mutability — a store to an immutable bare-identifier target (a `const`/
//     `:=` local, a global `const`, a parameter, a capture) is rejected; a
//     projection (`s.f`, `a[i]`, `p.*`) is left to the downstream type checker.
//   * unused bindings — a local, parameter, or capture declared and never
//     referenced is rejected; `_` and a `_ = name` discard are the escapes.
//   * ignored value — a bare expression statement that only produces a value
//     (the statement-split hazard) is rejected; effectful and control-flow
//     statements are exempt.
//   * redeclaration and shadowing — reusing a name already in scope is rejected
//     (Flash forbids shadowing outright, Zig-exact).
//
// After the binding passes, `check` also drives the compile-time evaluator
// (eval.flash), which folds constant initializers and contributes the definite
// compile-time errors (a division by a known zero) to the same diagnostic
// list. The evaluator defines its own Diag (the same shape) and this module
// copies its entries — the evaluator never imports sema. Type checking proper
// stays Zig's job downstream (Tier 0).

use "ast"
use "eval"
use "parser"
use "support" as sup

// Re-exported so a harness driving this module (parse, then check) reaches
// the parser through one import — the same public surface the handwritten
// sema exposes to its consumers.
pub const Parser = parser.Parser

// A collected diagnostic. `anchor` is a slice into the source buffer whose
// address *is* the location — `locate` turns it into a line:col at render time.
// `note_*` is an optional secondary location (e.g. a prior declaration).
pub const Diag = struct {
    anchor []u8,
    msg []u8,
    note_anchor ?[]u8 = null,
    note_msg ?[]u8 = null,
}

// A 1-based source position, recovered from an anchor by `locate`.
pub const Loc = struct {
    line u32,
    col u32,
}

// Recover the 1-based line and column of `anchor` within `src`. `anchor` MUST
// be a slice into `src` (the ast.flash invariant): the byte offset is the
// pointer difference, the line is one plus the newline count before it, and
// the column counts from the last newline.
pub fn locate(src []u8, anchor []u8) Loc {
    off := #intFromPtr(anchor.ptr) - #intFromPtr(src.ptr)
    sup.assert(off <= src.len)
    var line u32 = 1
    // Index just past the most recent newline.
    var line_start usize = 0
    var i usize = 0
    while i < off {
        if src[i] == '\n' {
            line += 1
            line_start = i + 1
        }
        i += 1
    }
    return .{ .line = line, .col = #as(u32, #intCast(off - line_start)) + 1 }
}

// Where a name came into scope. Recorded on every binding as the substrate the
// binding checks key on — a mutability check on `is_mut`, an unused-binding
// check on `origin` — so the file-level origins (import / global / func) are
// distinguished from the in-body ones (param / capture / local).
const Origin = enum {
    import,
    global,
    func,
    param,
    capture,
    local,
}

// One name in scope. The declaring occurrence (`name`) doubles as the
// diagnostic anchor. `used` is set when the name is referenced.
const Binding = struct {
    name []u8,
    is_mut bool,
    origin Origin,
    used bool = false,
}

// One label in scope — a labeled loop (`outer: while`) or a labeled block
// (`blk: { … }`). The declaring occurrence doubles as the Diag anchor (the
// same rule a Binding follows). `is_loop` gates `continue :label` — only a
// loop can be continued; `used` is set when a break/continue targets it.
const LabelInfo = struct {
    name []u8,
    is_loop bool,
    used bool = false,
}

const Oom = error{OutOfMemory}

// The walk state: an arena for diagnostic text, the collected diagnostics, and
// the single scope stack (frame 0 is the file level). `check_roots` is cleared
// inside struct-method bodies, whose roots (`self`, the type name, sibling
// decls) this thin pass does not model.
const Checker = struct {
    arena sup.Allocator,
    diags sup.List(Diag),
    scope sup.List(Binding),
    // The label stack: every enclosing labeled loop / labeled block, innermost
    // last. Pushed around the labeled body only (a loop's else arm, like a
    // sibling statement, sees nothing), so resolution is purely lexical.
    labels sup.List(LabelInfo),
    check_roots bool = true,
    frame_base usize = 0, // start index of the current (innermost) scope frame

    // Declare a name in the current frame. Flash forbids reusing a name already
    // visible (Zig-exact): a clash inside the current frame is a redeclaration,
    // a clash with an enclosing frame is shadowing — both are errors. The
    // binding is still pushed afterwards so later references resolve.
    fn declare(self *mut Checker, b Binding) Oom!void {
        if self.lookupIndex(b.name) |idx| {
            prior := self.scope.items[idx]
            if idx >= self.frame_base {
                try self.reportNote(b.name, try sup.allocPrint(self.arena, "redeclaration of '{s}'", .{b.name}), prior.name, "previously declared here")
            } else {
                try self.reportNote(b.name, try sup.allocPrint(self.arena, "'{s}' shadows {s}", .{ b.name, originNoun(prior.origin) }), prior.name, "declared here")
            }
        }
        try self.scope.append(self.arena, b)
    }

    // Innermost-wins lookup by linear back-scan, returning the stack index.
    fn lookupIndex(self *mut Checker, name []u8) ?usize {
        var i = self.scope.items.len
        while i > 0 {
            i -= 1
            if sup.eql(u8, self.scope.items[i].name, name) {
                return i
            }
        }
        return null
    }

    // The binding for `name`, or null. The pointer is valid only until the next
    // `declare` (which may reallocate the stack), so callers use it at once.
    fn lookup(self *mut Checker, name []u8) ?*mut Binding {
        if self.lookupIndex(name) |i| {
            return &self.scope.items[i]
        }
        return null
    }

    fn markUsed(self *mut Checker, name []u8) void {
        if self.lookup(name) |b| {
            b.used = true
        }
    }

    // Push a label for the duration of its labeled body. A name clash with an
    // enclosing label is shadowing — Zig rejects it downstream (a label is not
    // a binding, so the dup/shadow machinery above does not apply).
    fn pushLabel(self *mut Checker, name []u8, is_loop bool) Oom!void {
        try self.labels.append(self.arena, .{ .name = name, .is_loop = is_loop })
    }

    // Pop the innermost label, reporting it when no break/continue ever
    // targeted it (Zig-exact: an unused label is an error).
    fn popLabel(self *mut Checker) Oom!void {
        l := self.labels.items[self.labels.items.len - 1]
        self.labels.items.len -= 1
        if !l.used {
            const kind []u8 = if (l.is_loop) "loop" else "block"
            try self.report(l.name, try sup.allocPrint(self.arena, "unused {s} label '{s}'", .{ kind, l.name }))
        }
    }

    // Resolve a `break :name` / `continue :name` target against the label
    // stack, innermost-first. The pointer is valid only until the next
    // pushLabel (which may reallocate), so callers use it at once.
    fn resolveLabel(self *mut Checker, name []u8) ?*mut LabelInfo {
        var i = self.labels.items.len
        while i > 0 {
            i -= 1
            if sup.eql(u8, self.labels.items[i].name, name) {
                return &self.labels.items[i]
            }
        }
        return null
    }

    // Enter a new scope frame at the current stack top, returning the enclosing
    // frame's base for the matching leaveFrame.
    fn enterFrame(self *mut Checker) usize {
        saved := self.frame_base
        self.frame_base = self.scope.items.len
        return saved
    }

    // Leave the current frame: report any binding in it that was never
    // referenced, then pop the frame and restore the enclosing one. The
    // file-level origins (import / global / func) never sit in a popped frame,
    // so they are never flagged; a `_`-named binding is the explicit discard and
    // is exempt. A write-only binding counts as used (a read/write split is
    // later sema work).
    fn leaveFrame(self *mut Checker, saved_base usize) Oom!void {
        for b in self.scope.items[self.frame_base..] {
            checked := switch b.origin {
                .local, .param, .capture => true,
                .import, .global, .func => false,
            }
            if checked && !b.used && !isUnderscore(b.name) {
                const kind []u8 = switch b.origin {
                    .local => "local binding",
                    .param => "parameter",
                    .capture => "capture",
                    else => unreachable,
                }
                try self.report(b.name, try sup.allocPrint(self.arena, "unused {s} '{s}'", .{ kind, b.name }))
            }
        }
        self.scope.items.len = self.frame_base
        self.frame_base = saved_base
    }

    // Walk an expression only to mark the bindings it uses, with root resolution
    // suppressed. Used for type-position and `align(…)` expressions, whose names
    // resolve downstream (Tier 0) — the walk marks uses but never reports.
    fn markExpr(self *mut Checker, e ast.Expr) Oom!void {
        saved := self.check_roots
        self.check_roots = false
        defer self.check_roots = saved
        try self.checkExpr(e)
    }

    // Mark every in-scope binding a type reference uses, so a binding referenced
    // only in type position — a local `const T = u8` used as `var x T`, or a
    // `comptime T type` parameter used as another parameter's type — is not
    // flagged unused. Names resolve downstream, so this only marks, never reports.
    fn markType(self *mut Checker, t ast.TypeRef) Oom!void {
        switch t {
            // The first dotted segment is the binding root (`T` in `T`, `pkg` in
            // `pkg.Type`); a builtin like `u8` resolves to nothing.
            .name => |n| self.markUsed(firstSegment(n)),
            .slice, .slice_mut, .many_ptr, .many_ptr_mut, .many_ptr_volatile, .many_ptr_mut_volatile, .ptr, .ptr_mut, .ptr_volatile, .ptr_mut_volatile => |p| {
                if p.align_expr |ae| {
                    try self.markExpr(ae.*)
                }
                try self.markType(p.elem.*)
            },
            .optional, .array_inferred => |inner| try self.markType(inner.*),
            .slice_sentinel, .slice_sentinel_mut, .many_ptr_sentinel, .many_ptr_sentinel_mut, .array_inferred_sentinel => |sp| {
                if sp.align_expr |ae| {
                    try self.markExpr(ae.*)
                }
                try self.markExpr(sp.sentinel.*)
                try self.markType(sp.elem.*)
            },
            .array => |arr| {
                try self.markExpr(arr.len.*)
                try self.markType(arr.elem.*)
            },
            .array_sentinel => |arr| {
                try self.markExpr(arr.len.*)
                try self.markExpr(arr.sentinel.*)
                try self.markType(arr.elem.*)
            },
            .errunion => |eu| {
                if eu.set |s| {
                    try self.markType(s.*)
                }
                try self.markType(eu.payload.*)
            },
            .fn_type => |ft| {
                for p in ft.params {
                    try self.markType(p)
                }
                if ft.call_conv |cc| {
                    try self.markExpr(cc.*)
                }
                if ft.ret |r| {
                    try self.markType(r.*)
                }
            },
            .generic => |g| {
                self.markUsed(firstSegment(g.name))
                for a in g.args {
                    try self.markExpr(a)
                }
            },
            .tuple => |elems| {
                for e in elems {
                    try self.markType(e)
                }
            },
        }
    }

    fn report(self *mut Checker, anchor []u8, msg []u8) Oom!void {
        try self.diags.append(self.arena, .{ .anchor = anchor, .msg = msg })
    }

    fn reportNote(self *mut Checker, anchor []u8, msg []u8, note_anchor []u8, note_msg []u8) Oom!void {
        try self.diags.append(self.arena, .{ .anchor = anchor, .msg = msg, .note_anchor = note_anchor, .note_msg = note_msg })
    }

    // An assignment whose target is an immutable binding `b`, anchored at the
    // target occurrence `at`. The message names the binding's kind and the note
    // points at its declaration with the reason or the fix.
    fn reportImmutableAssign(self *mut Checker, at []u8, b Binding) Oom!void {
        const kind []u8 = switch b.origin {
            .param => "parameter",
            .capture => "capture",
            .import, .global, .func, .local => "binding",
        }
        const note []u8 = switch b.origin {
            .param => "parameters are immutable",
            .capture => "captures are immutable",
            .func => "a function is not an assignable binding",
            .import => "an import is not an assignable binding",
            .global, .local => "declared here; use 'var' for a mutable binding",
        }
        try self.reportNote(at, try sup.allocPrint(self.arena, "cannot assign to immutable {s} '{s}'", .{ kind, at }), b.name, note)
    }

    // Check a function: parameters and the body share one frame (as in Zig), so
    // a body binding reusing a parameter name is a same-scope redeclaration. A
    // bodyless `extern fn` prototype has nothing to walk — its parameters are
    // the C-ABI signature, never unused, so its frame is discarded without the
    // unused check.
    fn checkFn(self *mut Checker, f ast.FnDecl) Oom!void {
        saved := self.enterFrame()
        for p in f.params {
            if p.name |n| {
                try self.declare(.{ .name = n, .is_mut = false, .origin = .param })
            }
        }
        // Mark signature uses once the parameters are in scope, so a
        // `comptime T type` used only as another parameter's or the return type
        // is not flagged unused.
        for p in f.params {
            try self.markType(p.type)
        }
        if f.ret |r| {
            try self.markType(r)
        }
        if f.body |body| {
            for s in body {
                try self.checkStmt(s)
            }
            try self.leaveFrame(saved)
        } else {
            self.scope.items.len = self.frame_base
            self.frame_base = saved
        }
    }

    // Descend a container type definition's associated declarations (struct,
    // enum, or union alike) for binding checks: method bodies are walked with
    // root resolution suppressed, and a nested type-defining constant recurses
    // (its own methods are descended). Data fields, variants, field defaults,
    // and constant values are not walked — they root at sibling decls and
    // types, resolved downstream (Tier 0).
    fn checkContainerDecls(self *mut Checker, decls []ast.ContainerDecl) Oom!void {
        saved := self.check_roots
        self.check_roots = false
        defer self.check_roots = saved
        for decl in decls {
            switch decl {
                .method => |m| try self.checkFn(m),
                // An associated constant always carries a value (`extern var`
                // is file-scope-only), but the field is optional — unwrap.
                .constant => |c| {
                    if c.value |v| {
                        try self.descendTypeDef(v)
                    }
                },
                .use_import => {},
            }
        }
    }

    // Mark the bindings a container definition's data shape uses — field and
    // variant payload types, field defaults, and explicit discriminants — so a
    // binding referenced only there (a generic's `return struct { item T }`) is
    // not flagged unused. Mark-only: these positions also root at sibling decls
    // and type names this pass does not model, resolved downstream (Tier 0).
    fn markContainerShape(self *mut Checker, x ast.Expr) Oom!void {
        switch x {
            .struct_def => |sd| {
                for f in sd.fields {
                    try self.markType(f.type)
                    if f.default |d| {
                        try self.markExpr(d)
                    }
                }
            },
            .enum_def => |ed| {
                for v in ed.variants {
                    if v.value |val| {
                        try self.markExpr(val.*)
                    }
                }
            },
            .union_def => |ud| {
                for v in ud.variants {
                    if v.payload |p| {
                        try self.markType(p)
                    }
                }
            },
            else => {},
        }
    }

    // A top-level or associated constant's value is not name-checked (its roots
    // are siblings and type names, resolved downstream), but when the value is a
    // container type definition its method bodies are descended.
    fn descendTypeDef(self *mut Checker, value ast.Expr) Oom!void {
        switch value {
            .struct_def => |sd| try self.checkContainerDecls(sd.decls),
            .enum_def => |ed| try self.checkContainerDecls(ed.decls),
            .union_def => |ud| try self.checkContainerDecls(ud.decls),
            else => {},
        }
    }

    // A nested block (an `if`/`while`/`for` body, a labeled block, the top-level
    // comptime block) with zero or more leading captures. The captures and the
    // block's own bindings live in one frame, popped on exit, so a name declared
    // in the block is invisible to a sibling block or to code after it.
    fn checkBlock(self *mut Checker, captures [][]u8, stmts []ast.Stmt) Oom!void {
        saved := self.enterFrame()
        for cap in captures {
            // A `_` capture (a `for _ in …` discard) binds nothing.
            if isUnderscore(cap) {
                continue
            }
            try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture })
        }
        for s in stmts {
            try self.checkStmt(s)
        }
        try self.leaveFrame(saved)
    }

    fn checkStmt(self *mut Checker, s ast.Stmt) Oom!void {
        switch s {
            .discard => |x| try self.checkExpr(x),
            .expr => |x| {
                try self.checkExpr(x)
                // A bare expression statement whose top node yields a value but
                // has no effect is almost always a mistake: most often a
                // continuation line a leading `-`/`&` split into its own
                // statement, or a value that wants an explicit `_ =` discard.
                // Effectful and control-flow shapes (a call, `try`/`catch`, a
                // statement `if`/`switch`, `break`/`return`, …) are exempt.
                if !stmtExprAllowed(x) {
                    if firstLexeme(x) |anchor| {
                        try self.report(anchor, "expression value is ignored; discard it with '_ = expr', or end the previous line with its operator to continue it")
                    }
                }
            },
            // A binding's value is checked before the name is declared, so a
            // binding cannot refer to itself. Its type and `align(…)` reference
            // names too (a local type alias, a length constant) — marked as uses
            // so a binding used only there is not flagged unused.
            .bind => |b| {
                try self.checkExpr(b.value)
                if b.type |t| {
                    try self.markType(t)
                }
                if b.align_expr |ae| {
                    try self.markExpr(ae)
                }
                try self.declare(.{ .name = b.name, .is_mut = b.is_mut, .origin = .local })
            },
            // A destructuring bind: the value is checked first (no
            // self-reference, as with .bind), then every non-`_` name is
            // declared in the current frame — the dup/shadow rules unchanged.
            .destructure => |d| {
                try self.checkExpr(d.value)
                for maybe in d.names {
                    if maybe |name| {
                        try self.declare(.{ .name = name, .is_mut = d.is_mut, .origin = .local })
                    }
                }
            },
            // Mutability — only a bare-identifier target is checked. A
            // projection (`s.f`, `a[i]`, `p.*`) turns on pointee/aggregate
            // mutability this thin pass has no types for, so Zig owns those
            // downstream (Tier 0). An ident that names no binding resolves
            // downstream too, so an absent lookup is silently skipped.
            .assign => |a| {
                try self.checkExpr(a.target)
                try self.checkExpr(a.value)
                switch a.target {
                    .ident => |name| {
                        if self.lookup(name) |b| {
                            if !b.is_mut {
                                try self.reportImmutableAssign(name, b.*)
                            }
                        }
                    },
                    else => {},
                }
            },
            // A destructuring assignment: every target is checked as a use and
            // — when it is a bare identifier — for mutability, exactly as the
            // single assign above; projections resolve downstream the same way.
            .destructure_assign => |da| {
                for t in da.targets {
                    try self.checkExpr(t)
                    switch t {
                        .ident => |name| {
                            if self.lookup(name) |b| {
                                if !b.is_mut {
                                    try self.reportImmutableAssign(name, b.*)
                                }
                            }
                        },
                        else => {},
                    }
                }
                try self.checkExpr(da.value)
            },
            // An optional capture is in scope for the matched body only; the
            // else arm's error capture (`else |err|`) for that arm only.
            .if_stmt => |iff| {
                try self.checkExpr(iff.cond)
                if iff.capture |cap| {
                    try self.checkBlock(&.{cap}, iff.body)
                } else {
                    try self.checkBlock(&.{}, iff.body)
                }
                if iff.else_body |eb| {
                    if iff.else_capture |cap| {
                        try self.checkBlock(&.{cap}, eb)
                    } else {
                        try self.checkBlock(&.{}, eb)
                    }
                }
            },
            // An optional payload capture is in scope for the body only; the
            // loop else arm's error capture (`else |err|`) for that arm only.
            // A loop label is targetable from the body only — the else arm runs
            // after the loop, so it sees nothing (matching Zig).
            .while_stmt => |w| {
                try self.checkExpr(w.cond)
                if w.label |l| {
                    try self.pushLabel(l, true)
                }
                if w.capture |cap| {
                    try self.checkBlock(&.{cap}, w.body)
                } else {
                    try self.checkBlock(&.{}, w.body)
                }
                if w.label != null {
                    try self.popLabel()
                }
                if w.else_body |eb| {
                    if w.else_capture |cap| {
                        try self.checkBlock(&.{cap}, eb)
                    } else {
                        try self.checkBlock(&.{}, eb)
                    }
                }
            },
            // The capture name(s) — element, and the optional index — are in
            // scope for the body only; the loop else arm is capture-less and
            // its own scope.
            .for_stmt => |fr| {
                try self.checkExpr(fr.iter)
                if fr.range_hi |hi| {
                    try self.checkExpr(hi)
                }
                if fr.label |l| {
                    try self.pushLabel(l, true)
                }
                try self.checkBlock(fr.captures, fr.body)
                if fr.label != null {
                    try self.popLabel()
                }
                if fr.else_body |eb| {
                    try self.checkBlock(&.{}, eb)
                }
            },
            .defer_stmt => |inner| try self.checkStmt(inner.*),
            // `errdefer |err| …` binds the error in scope for the deferred
            // statement / block only — by value, like the catch capture.
            .errdefer_stmt => |ed| {
                if ed.capture |cap| {
                    saved := self.enterFrame()
                    try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture })
                    try self.checkStmt(ed.body.*)
                    try self.leaveFrame(saved)
                } else {
                    try self.checkStmt(ed.body.*)
                }
            },
            // The block forms open their own scope, like any `{ … }` body.
            .defer_block => |stmts| try self.checkBlock(&.{}, stmts),
            .errdefer_block => |ed| {
                if ed.capture |cap| {
                    saved := self.enterFrame()
                    try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture })
                    try self.checkBlock(&.{}, ed.body)
                    try self.leaveFrame(saved)
                } else {
                    try self.checkBlock(&.{}, ed.body)
                }
            },
        }
    }

    fn checkExpr(self *mut Checker, x ast.Expr) Oom!void {
        switch x {
            .int, .float, .string, .multiline_str, .char, .value_word => {},
            // A bare identifier is not a member root: it resolves downstream, so
            // it never errors here — but mark it used if it names a binding.
            .ident => |name| self.markUsed(name),
            .member => |m| {
                try self.checkExpr(m.base.*)
                // Resolve the chain's root exactly once, at the innermost member
                // (the one whose base holds no further member). Any enclosing
                // member shares the same root, so resolving there too would
                // double-report it now that diagnostics are collected, not bailed.
                if !spineHasMember(m.base.*) {
                    if rootIdent(m.base.*) |root| {
                        if self.lookup(root) |b| {
                            // A member root is a use even where roots are not
                            // reported (inside a struct method), so mark it
                            // regardless of `check_roots`.
                            b.used = true
                        } else if self.check_roots {
                            try self.report(root, try sup.allocPrint(self.arena, "unknown name '{s}': not an import, parameter, or binding in scope", .{root}))
                        }
                    }
                }
            },
            // `p.*` / `opt.?` resolve at their root, like any other postfix form.
            .deref => |d| try self.checkExpr(d.*),
            .optional_unwrap => |u| try self.checkExpr(u.*),
            .call => |c| {
                try self.checkExpr(c.callee.*)
                for a in c.args {
                    try self.checkExpr(a)
                }
            },
            .index => |ix| {
                try self.checkExpr(ix.base.*)
                try self.checkExpr(ix.index.*)
            },
            // The sentinel is an ordinary expression (e.g. a `pkg.NUL`
            // constant), so a bad member root in it is still resolved.
            .slice => |s| {
                try self.checkExpr(s.base.*)
                try self.checkExpr(s.lo.*)
                if s.hi |hi| {
                    try self.checkExpr(hi.*)
                }
                if s.sentinel |sen| {
                    try self.checkExpr(sen.*)
                }
            },
            .builtin_call => |b| {
                for a in b.args {
                    try self.checkExpr(a)
                }
            },
            .unary => |u| try self.checkExpr(u.operand.*),
            .binary => |b| {
                try self.checkExpr(b.lhs.*)
                try self.checkExpr(b.rhs.*)
            },
            .struct_lit => |fields| {
                for f in fields {
                    try self.checkExpr(f.value)
                }
            },
            // A typed initializer `Type{ .x = v }`: the type prefix is an
            // ordinary expression (a bad member root in a `pkg.Type` is caught),
            // and each field value resolves like an anonymous literal's.
            .typed_lit => |tl| {
                try self.checkExpr(tl.type.*)
                for f in tl.fields {
                    try self.checkExpr(f.value)
                }
            },
            // A container definition's data fields/variants and their types are
            // downstream concerns — though the bindings they reference are
            // marked used, so a generic's `return struct { item T }` does not
            // flag `T` — and its method bodies ARE descended for binding checks
            // (mutability, …) — with root resolution off, since a method roots
            // at the container's type name, `self`, and sibling decls this thin
            // pass does not model. Reached here when a container type is
            // defined as a local or nested value; the top-level `const T =
            // struct …` form is descended directly from `check`.
            .struct_def => |sd| {
                try self.markContainerShape(x)
                try self.checkContainerDecls(sd.decls)
            },
            .enum_def => |ed| {
                try self.markContainerShape(x)
                try self.checkContainerDecls(ed.decls)
            },
            .union_def => |ud| {
                try self.markContainerShape(x)
                try self.checkContainerDecls(ud.decls)
            },
            // A composite type in value position (`return ?T` in a generic's
            // body) references bindings in type position only — mark them, so
            // the parameter spelt only there is not flagged unused.
            .type_lit => |t| try self.markType(t.*),
            // An inferred enum literal `.red` is a bare tag; an `error.Name` /
            // `error{ … }` is a pure declaration. Neither is a name reference.
            .enum_lit, .error_lit, .error_set => {},
            .group => |g| try self.checkExpr(g.*),
            .if_expr => |iff| {
                try self.checkExpr(iff.cond.*)
                try self.checkExpr(iff.then.*)
                try self.checkExpr(iff.else_.*)
            },
            // A switch resolves its subject, every pattern bound (a pattern may be
            // a constant reference like `pkg.MAX`), and every prong body. A
            // `=> |x|` capture binds the active variant's payload in the prong
            // body only.
            .switch_expr => |sw| {
                try self.checkExpr(sw.subject.*)
                for prong in sw.prongs {
                    for p in prong.patterns {
                        try self.checkExpr(p.lo)
                        if p.hi |hi| {
                            try self.checkExpr(hi)
                        }
                    }
                    if prong.capture |cap| {
                        saved := self.enterFrame()
                        try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture })
                        try self.checkExpr(prong.body)
                        try self.leaveFrame(saved)
                    } else {
                        try self.checkExpr(prong.body)
                    }
                }
            },
            .try_expr => |t| try self.checkExpr(t.*),
            // `catch |e| handler` binds the error in scope for the handler only.
            .catch_expr => |c| {
                try self.checkExpr(c.lhs.*)
                if c.capture |cap| {
                    saved := self.enterFrame()
                    try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture })
                    try self.checkExpr(c.handler.*)
                    try self.leaveFrame(saved)
                } else {
                    try self.checkExpr(c.handler.*)
                }
            },
            // Inline assembly. The template and constraint strings are opaque
            // assembler/LLVM text, but the operand bodies and the clobber
            // expression are ordinary expressions — a bad member root in them is
            // still resolved. A `(-> T)` output body is a type reference, resolved
            // downstream.
            .asm_expr => |a| {
                try self.checkExpr(a.template.*)
                for op in a.outputs {
                    switch op.body {
                        .expr => |e| try self.checkExpr(e),
                        .ret_type => {},
                    }
                }
                for op in a.inputs {
                    switch op.body {
                        .expr => |e| try self.checkExpr(e),
                        .ret_type => {},
                    }
                }
                if a.clobbers |cl| {
                    try self.checkExpr(cl.*)
                }
            },
            // A labeled block runs in the enclosing scope plus its own bindings;
            // the label is a break target, not a binding — it rides the label
            // stack for the body (an unlabelled block, a switch-prong body,
            // pushes nothing).
            .block_expr => |blk| {
                if blk.label |l| {
                    try self.pushLabel(l, false)
                }
                try self.checkBlock(&.{}, blk.body)
                if blk.label != null {
                    try self.popLabel()
                }
            },
            // A labelled break targets an enclosing labeled loop or block —
            // unknown labels are reported here (the value, when present, is an
            // ordinary expression).
            .brk => |b| {
                if b.label |l| {
                    if self.resolveLabel(l) |target| {
                        target.used = true
                    } else {
                        try self.report(l, try sup.allocPrint(self.arena, "no enclosing loop or block is labeled '{s}'", .{l}))
                    }
                }
                if b.value |v| {
                    try self.checkExpr(v.*)
                }
            },
            // A labelled continue targets an enclosing labeled LOOP — a block
            // label is a break-only target (there is no next iteration).
            .cont => |maybe| {
                if maybe |l| {
                    if self.resolveLabel(l) |target| {
                        target.used = true
                        if !target.is_loop {
                            try self.reportNote(l, try sup.allocPrint(self.arena, "cannot continue the block label '{s}' — only a loop label can be continued", .{l}), target.name, "label declared here")
                        }
                    } else {
                        try self.report(l, try sup.allocPrint(self.arena, "no enclosing loop is labeled '{s}'", .{l}))
                    }
                }
            },
            .ret => |maybe| {
                if maybe |vals| {
                    for v in vals {
                        try self.checkExpr(v)
                    }
                }
            },
        }
    }
}

pub fn check(arena sup.Allocator, program ast.Program) Oom![]mut Diag {
    var c = Checker{ .arena = arena, .diags = .empty, .scope = .empty, .labels = .empty }
    // Seed the file-level frame (frame 0): every `use` binds its alias (or the
    // bare module name), and every top-level declaration — a `const` (including a
    // type like `union(enum)`) or a `fn` — is referenceable by name. Declared in
    // a first pass so a body may reference a sibling declared later in the file.
    for item in program.items {
        switch item {
            .use_decl => |u| try c.declare(.{ .name = u.alias orelse u.module, .is_mut = false, .origin = .import }),
            .const_decl => |d| try c.declare(.{ .name = d.name, .is_mut = d.is_mut, .origin = .global }),
            .fn_decl => |f| try c.declare(.{ .name = f.name, .is_mut = false, .origin = .func }),
            // A test block declares no binding — its string name is not an
            // identifier and cannot be referenced.
            .link_decl, .comptime_block, .test_decl => {},
        }
    }
    for item in program.items {
        switch item {
            .fn_decl => |f| try c.checkFn(f),
            // A top-level `comptime { … }` block's body is checked like a
            // function body — against the file's ambient frame, with no
            // parameters of its own.
            .comptime_block => |stmts| try c.checkBlock(&.{}, stmts),
            // A test block's body is checked like a function body — against
            // the file's ambient frame, with no parameters of its own.
            .test_decl => |t| try c.checkBlock(&.{}, t.body),
            // A top-level const's value is not name-checked, but a `struct` type
            // definition's method bodies are descended for binding checks. An
            // `extern var` carries no value — nothing to descend.
            .const_decl => |d| {
                if d.value |v| {
                    try c.descendTypeDef(v)
                }
            },
            else => {},
        }
    }
    // The compile-time evaluator (eval.flash) runs after the binding passes:
    // it folds the constant initializers it can reach and reports the
    // definite compile-time errors (a division by a known zero) at the Flash
    // source line; everything outside its boundary stays `unknown` — silent,
    // checked downstream as before. Its diagnostics share the Diag shape and
    // join the one collected list.
    var pool = try eval.Pool.init(arena)
    var ev = eval.Evaluator.init(arena, &pool)
    try ev.run(program)
    for d in ev.diags.items {
        try c.diags.append(arena, .{ .anchor = d.anchor, .msg = d.msg, .note_anchor = d.note_anchor, .note_msg = d.note_msg })
    }
    return c.diags.toOwnedSlice(arena)
}

// The leftmost identifier a chain bottoms out at, or null if it bottoms out at a
// literal (a member/call on an int or string is not a name reference).
fn rootIdent(x ast.Expr) ?[]u8 {
    return switch x {
        .ident => |i| i,
        .member => |m| rootIdent(m.base.*),
        .call => |c| rootIdent(c.callee.*),
        .index => |ix| rootIdent(ix.base.*),
        .slice => |s| rootIdent(s.base.*),
        .deref => |d| rootIdent(d.*), // `p.*.field` roots at `p`
        .optional_unwrap => |u| rootIdent(u.*), // `opt.?.field` roots at `opt`
        .unary => |u| rootIdent(u.operand.*),
        .group => |g| rootIdent(g.*),
        .try_expr => |t| rootIdent(t.*),
        .int, .float, .string, .multiline_str, .char, .value_word, .builtin_call, .binary, .struct_lit, .typed_lit, .type_lit, .enum_lit, .error_lit, .error_set, .block_expr, .struct_def, .enum_def, .union_def, .catch_expr, .if_expr, .switch_expr, .asm_expr, .brk, .cont, .ret => null,
    }
}

// May this expression stand alone as a statement? True for the effectful and
// control-flow shapes — a call, a `#builtin` call, `try`/`catch`, inline asm, a
// `break`/`continue`/`return`, a statement `if`/`switch`, a labeled block, the
// `unreachable` assertion, and the anchorless `.{}` literal. Every other shape
// yields a value that, as a bare statement, is silently dropped.
fn stmtExprAllowed(x ast.Expr) bool {
    return switch x {
        .call, .builtin_call, .try_expr, .catch_expr, .asm_expr, .brk, .cont, .ret, .if_expr, .switch_expr, .block_expr, .struct_lit => true,
        .value_word => |w| sup.eql(u8, w, "unreachable"),
        else => false,
    }
}

// The leftmost source slice of an expression — its diagnostic anchor. Descends
// the access/operator spine to the leftmost stored lexeme. Null when no single
// stored slice exists (an empty `.{}`, a type-in-value-position); the caller
// skips the diagnostic rather than anchor it nowhere, as with `struct_lit`.
fn firstLexeme(x ast.Expr) ?[]u8 {
    return switch x {
        .int, .float, .string, .char, .ident, .value_word, .enum_lit, .error_lit => |s| s,
        .multiline_str => |lines| if (lines.len > 0) lines[0] else null,
        .error_set => |names| if (names.len > 0) names[0] else null,
        .member => |m| firstLexeme(m.base.*),
        .deref => |d| firstLexeme(d.*),
        .optional_unwrap => |u| firstLexeme(u.*),
        .index => |ix| firstLexeme(ix.base.*),
        .slice => |s| firstLexeme(s.base.*),
        .call => |c| firstLexeme(c.callee.*),
        .binary => |b| firstLexeme(b.lhs.*),
        .unary => |u| u.op, // the prefix operator is the leftmost token
        .group => |g| firstLexeme(g.*),
        .try_expr => |t| firstLexeme(t.*),
        .typed_lit => |tl| firstLexeme(tl.type.*),
        .struct_def => |sd| if (sd.fields.len > 0) sd.fields[0].name else null,
        .enum_def => |ed| if (ed.variants.len > 0) ed.variants[0].name else null,
        .union_def => |ud| if (ud.variants.len > 0) ud.variants[0].name else null,
        // Allowed-as-statement shapes never reach here; `.{}` and a bare type
        // have no single stored leftmost slice.
        .struct_lit, .type_lit, .builtin_call, .catch_expr, .if_expr, .switch_expr, .block_expr, .asm_expr, .brk, .cont, .ret => null,
    }
}

// The first dotted segment of a (possibly qualified) type name — the binding
// root: `T` from `T`, `pkg` from `pkg.Type`.
fn firstSegment(name []u8) []u8 {
    if sup.indexOfScalar(u8, name, '.') |dot| {
        return name[0..dot]
    }
    return name
}

fn isUnderscore(name []u8) bool {
    return sup.eql(u8, name, "_")
}

// The noun naming a shadowed binding's kind, for the shadowing diagnostic.
fn originNoun(o Origin) []u8 {
    return switch o {
        .param => "a parameter",
        .capture => "a capture",
        .local => "a local binding",
        .global => "a file-scope binding",
        .import => "an import",
        .func => "a function",
    }
}

// Does the spine of `x` (the access path `rootIdent` peels) pass through a
// member node? When it does, a deeper member already resolves the shared root,
// so the enclosing member must not resolve it again.
fn spineHasMember(x ast.Expr) bool {
    return switch x {
        .member => true,
        .call => |c| spineHasMember(c.callee.*),
        .index => |ix| spineHasMember(ix.base.*),
        .slice => |s| spineHasMember(s.base.*),
        .deref => |d| spineHasMember(d.*),
        .optional_unwrap => |u| spineHasMember(u.*),
        .unary => |u| spineHasMember(u.operand.*),
        .group => |g| spineHasMember(g.*),
        .try_expr => |t| spineHasMember(t.*),
        else => false,
    }
}

// --- tests ---------------------------------------------------------------
// The handwritten checker's test suite, ported — the evaluator-driven
// diagnostics (definite errors, generic application checks) included, since
// `check` drives the evaluator.

// Parse and check `src`, asserting no diagnostics.
fn expectClean(src []u8) !void {
    var arena = sup.ArenaAllocator.init(sup.testAlloc)
    defer arena.deinit()
    var p = Parser.init(arena.allocator(), src)
    prog := try p.parseProgram()
    diags := try check(arena.allocator(), prog)
    try sup.expectEqual(0, diags.len)
}

// Parse and check `src`, asserting at least one diagnostic whose message
// contains `frag` lands at `line`:`col`.
fn expectDiag(src []u8, frag []u8, line u32, col u32) !void {
    var arena = sup.ArenaAllocator.init(sup.testAlloc)
    defer arena.deinit()
    var p = Parser.init(arena.allocator(), src)
    prog := try p.parseProgram()
    diags := try check(arena.allocator(), prog)
    for d in diags {
        if sup.indexOf(u8, d.msg, frag) != null {
            loc := locate(src, d.anchor)
            if loc.line == line && loc.col == col {
                return
            }
        }
    }
    return error.DiagNotFound
}

// Parse and check `src`, asserting exactly `n` diagnostics.
fn expectDiagCount(src []u8, n usize) !void {
    var arena = sup.ArenaAllocator.init(sup.testAlloc)
    defer arena.deinit()
    var p = Parser.init(arena.allocator(), src)
    prog := try p.parseProgram()
    diags := try check(arena.allocator(), prog)
    try sup.expectEqual(n, diags.len)
}

test "locate recovers 1-based line and column from an anchor" {
    src := "abc\ndef\nghij"
    try sup.expectEqual(Loc{ .line = 1, .col = 1 }, locate(src, src[0..3]))
    try sup.expectEqual(Loc{ .line = 2, .col = 1 }, locate(src, src[4..7]))
    try sup.expectEqual(Loc{ .line = 3, .col = 3 }, locate(src, src[10..12]))
}

test "imported module, parameter, and binding roots all resolve" {
    try expectClean("use flibc\n\nexport fn main(_ usize, _ argv) noreturn {\n    msg := \"hi\"\n    _ = flibc.sys.write_fd(1, msg.ptr, msg.len)\n    flibc.exit()\n}")
}

test "a use alias resolves under its alias name" {
    try expectClean("use console_ui as ui\n\nfn f() {\n    ui.screen.clear()\n}")
}

test "a member root that is no import, param, or binding is rejected" {
    try expectDiag("fn f() {\n    _ = nope.sys.write()\n}", "unknown name 'nope'", 2, 9)
}

test "two unresolved roots are both reported, not bailed on the first" {
    try expectDiagCount("fn f() {\n    _ = nope.a()\n    _ = also.b()\n}", 2)
}

test "a binding from an inner block does not leak past it" {
    // `x` is bound inside the if-body; using `x` as a member root after the
    // block must fail, proving block-locals are popped on exit.
    try expectDiag("fn f(n usize) {\n    if n > 0 {\n        x := n\n    }\n    _ = x.field\n}", "unknown name 'x'", 5, 9)
}

test "a for-loop capture resolves as a member root in the body" {
    try expectClean("fn f(xs []u8) {\n    for e in xs {\n        _ = e.len\n    }\n}")
}

test "a bad member root inside a loop body is rejected" {
    try expectDiag("fn f(n usize) {\n    while n > 0 {\n        _ = nope.x()\n    }\n}", "unknown name 'nope'", 3, 13)
}

test "an optional-capture if binds its capture as a member root in the body" {
    try expectClean("use pwfile\n\nfn f(xs []u8) {\n    if pwfile.lookup(xs) |entry| {\n        _ = entry.user\n    }\n}")
}

test "an if capture does not leak past the if body" {
    try expectDiag("use pwfile\n\nfn f(xs []u8) {\n    if pwfile.lookup(xs) |entry| {\n        _ = entry\n    }\n    _ = entry.user\n}", "unknown name 'entry'", 7, 9)
}

test "a catch error capture resolves in the handler only" {
    try expectClean("use sys\n\nfn f() {\n    _ = sys.run() catch |e| e.code()\n}")
}

test "a catch handler's capture does not leak to a sibling statement" {
    try expectDiag("use sys\n\nfn f() {\n    _ = sys.run() catch |e| e.code()\n    _ = e.code()\n}", "unknown name 'e'", 5, 9)
}

test "a catch capture resolves inside a recovery block" {
    try expectClean("use sys\n\nfn f() {\n    sys.run() catch |e| {\n        _ = e.code()\n    }\n}")
}

test "an unused capture on a catch recovery block is rejected" {
    try expectDiag("fn f() {\n    run() catch |e| {}\n}", "unused capture 'e'", 2, 18)
}

test "a bad member root inside a defer is rejected" {
    try expectDiag("fn f() {\n    defer _ = nope.close()\n}", "unknown name 'nope'", 2, 15)
}

test "an errdefer capture resolves in the deferred statement and block" {
    try expectClean("fn f() !void {\n    errdefer |err| _ = err.code()\n    errdefer |err| {\n        _ = err.code()\n    }\n}")
}

test "an errdefer capture does not leak to a sibling statement" {
    try expectDiag("fn f() !void {\n    errdefer |err| _ = err.code()\n    _ = err.code()\n}", "unknown name 'err'", 3, 9)
}

test "an unused errdefer capture is rejected on both forms" {
    try expectDiag("fn f() !void {\n    errdefer |err| cleanup()\n}", "unused capture 'err'", 2, 15)
    try expectDiag("fn f() !void {\n    errdefer |err| {\n        cleanup()\n    }\n}", "unused capture 'err'", 2, 15)
}

test "struct and enum definitions resolve without false references" {
    // Field types (`flibc.Dirent`) are type references, not member-access roots,
    // and variant names are declarations — neither should trip name resolution.
    try expectClean("use flibc\n\nconst Entry = struct {\n    info flibc.Dirent,\n    kind u8,\n}\nconst Kind = enum(u8) { file, dir }")
}

test "error origination and error sets do not trip name resolution" {
    // `error.X` is an origination (a bare error name, not a member-access root)
    // and `error{ … }` is a set definition — neither resolves against the name
    // environment, so a function originating an error resolves clean.
    try expectClean("const AllocError = error{ OutOfMemory, Overflow }\n\nfn fail() AllocError!void {\n    return error.OutOfMemory\n}")
}

test "a struct method body does not trip name resolution" {
    // Method bodies inside a struct are checked downstream by Zig, not here:
    // they root at the struct's type name and sibling decls, which this thin
    // pass does not model. The definition must therefore resolve clean — no
    // false diagnostic from `self`, the type name, or the sibling constant.
    try expectClean("use flibc\n\nconst Writer = struct {\n    fd i32,\n\n    const STDOUT i32 = 1\n\n    fn flush(self Writer) {\n        _ = flibc.sys.fsync(self.fd)\n    }\n}")
}

test "a top-level declaration resolves as a member-access root" {
    // A top-level `const` (here a union type) is in scope everywhere in the file,
    // so `Action.eof` resolves the same way an imported module's member would —
    // no false diagnostic from a free function referencing a sibling type.
    try expectClean("const Action = union(enum) {\n    none,\n    eof,\n}\n\nfn classify(n usize) Action {\n    return if (n == 0) Action.eof else Action.none\n}")
}

test "a named struct-literal field value is still resolved" {
    // The value side of `.field = value` is a real expression: a bad member root
    // there must still be rejected.
    try expectDiag("fn f() {\n    _ = .{ .x = nope.value }\n}", "unknown name 'nope'", 2, 17)
}

test "a switch prong capture resolves as a member root in its body" {
    // A `=> |x|` capture binds the variant payload for the prong body, so
    // `x.field` there resolves — like a `catch |e|` handler.
    try expectClean("use sys\n\nfn f() {\n    switch sys.poll() {\n        .ready => |r| r.consume(),\n        else => {},\n    }\n}")
}

test "a switch prong capture does not leak past the switch" {
    try expectDiag("use sys\n\nfn f() {\n    switch sys.poll() {\n        .ready => |r| r.consume(),\n        else => {},\n    }\n    _ = r.consume()\n}", "unknown name 'r'", 8, 9)
}

// --- mutability: assign-to-immutable -------------------------------------

test "assigning to a const local is rejected" {
    try expectDiag("fn f() {\n    const x = 1\n    x = 2\n}", "cannot assign to immutable binding 'x'", 3, 5)
}

test "a compound assignment to a const local is rejected" {
    try expectDiag("fn f() {\n    const x = 1\n    x += 1\n}", "cannot assign to immutable binding 'x'", 3, 5)
}

test "a wrapping compound assignment to a const local is rejected" {
    try expectDiag("fn f() {\n    const x = 1\n    x +%= 1\n}", "cannot assign to immutable binding 'x'", 3, 5)
}

test "export var and extern var declare assignable globals" {
    // The consumer-side `extern var` binds like any mutable global — typed,
    // valueless, and writable; the defining `export var` likewise.
    try expectClean("extern var nr_tasks i32\n\nfn bump() {\n    nr_tasks += 1\n}")
    try expectClean("export var next_pid i32 = 1\n\nfn alloc_pid() i32 {\n    next_pid += 1\n    return next_pid\n}")
    // The forms share the global namespace — a duplicate is a redeclaration.
    try expectDiag("export var n i32 = 0\nextern var n i32", "redeclaration of 'n'", 2, 12)
}

test "assigning to a parameter is rejected" {
    try expectDiag("fn f(n usize) {\n    n = 1\n}", "cannot assign to immutable parameter 'n'", 2, 5)
}

test "assigning to a for-loop capture is rejected" {
    try expectDiag("fn f(xs []u8) {\n    for e in xs {\n        e = 1\n    }\n}", "cannot assign to immutable capture 'e'", 3, 9)
}

test "assigning to an if-capture is rejected" {
    try expectDiag("fn f(o ?u8) {\n    if o |x| {\n        x = 1\n    }\n}", "cannot assign to immutable capture 'x'", 3, 9)
}

test "a pointer capture binds the bare name: write-through is clean, reassignment is not" {
    // `for *p in arr` — `p` is a binding named without the `*` (the sigil is a
    // flag, not part of the name), so `p.*` resolves and the write-through is a
    // projection this pass leaves to the downstream checker. The POINTER itself
    // stays an immutable capture.
    try expectClean("fn f(arr *mut [4]u8, o ?u8) {\n    for *p in arr {\n        p.* = 0\n    }\n    if o |*x| {\n        x.* += 1\n    }\n}")
    try expectDiag("fn f(arr *mut [4]u8) {\n    for *p in arr {\n        p = undefined\n    }\n}", "cannot assign to immutable capture 'p'", 3, 9)
}

test "an unused pointer capture is flagged under its bare name" {
    try expectDiag("fn f(arr *mut [4]u8) {\n    for *p in arr {\n        work()\n    }\n}", "unused capture 'p'", 2, 10)
}

test "assigning to a global const is rejected" {
    try expectDiag("const N = 1\n\nfn f() {\n    N = 2\n}", "cannot assign to immutable binding 'N'", 4, 5)
}

test "assigning to a var local is clean" {
    try expectClean("fn f() {\n    var x = 1\n    x = 2\n}")
}

test "assigning to a global var is clean" {
    try expectClean("var N = 1\n\nfn f() {\n    N = 2\n}")
}

test "a projection target is not checked for mutability" {
    // `s.f`, `a[i]`, `p.*` turn on pointee/aggregate mutability this pass has no
    // types for, so they are left to the downstream checker, not flagged here —
    // even though `s`, `a`, `p` are all immutable parameters.
    try expectClean("fn f(s S, a []u8, p *u8) {\n    s.field = 1\n    a[0] = 2\n    p.* = 3\n}")
}

test "a struct method body is checked for assign-to-immutable" {
    // Proves method bodies are descended: the immutable parameter `n` is caught,
    // while the struct's type name `W` and the receiver `self` (whose roots this
    // pass does not model) raise no false diagnostic.
    try expectDiag("const W = struct {\n    fd i32,\n\n    fn bump(self W, n i32) {\n        n = n + 1\n    }\n}", "cannot assign to immutable parameter 'n'", 5, 9)
}

test "a projection target inside a method is left to the downstream checker" {
    try expectClean("const W = struct {\n    fd i32,\n\n    fn set(self *mut W, v i32) {\n        self.fd = v\n    }\n}")
}

test "enum and union method bodies are descended like struct methods" {
    // The container-decl descent is one path for all three containers: the
    // immutable parameter `n` inside an enum method is caught …
    try expectDiag("const Color = enum {\n    red,\n\n    fn next(n i32) i32 {\n        n = n + 1\n        return n\n    }\n}", "cannot assign to immutable parameter 'n'", 5, 9)
    // … and a well-formed union method (self-reference, sibling roots this
    // pass does not model) raises no false diagnostic.
    try expectClean("const Tok = union(enum) {\n    eof,\n    int usize,\n\n    fn width(self Tok) usize {\n        return helpers.width(self)\n    }\n}")
}

// --- unused bindings -----------------------------------------------------

test "an unused local binding is rejected" {
    try expectDiag("fn f() {\n    x := 1\n}", "unused local binding 'x'", 2, 5)
}

test "an unused parameter is rejected" {
    try expectDiag("fn f(n usize) {\n}", "unused parameter 'n'", 1, 6)
}

test "an unused for-loop capture is rejected" {
    try expectDiag("fn f(xs []u8) {\n    for e in xs {\n    }\n}", "unused capture 'e'", 2, 9)
}

test "an unused for-loop index capture is rejected" {
    try expectDiag("fn f(xs []u8) {\n    for x, i in xs {\n        _ = x\n    }\n}", "unused capture 'i'", 2, 12)
}

test "an unused catch capture is rejected" {
    try expectDiag("fn f() {\n    _ = run() catch |e| 0\n}", "unused capture 'e'", 2, 22)
}

test "an unused switch-prong capture is rejected" {
    try expectDiag("fn f() {\n    switch poll() {\n        .ready => |r| consume(),\n        else => {},\n    }\n}", "unused capture 'r'", 3, 20)
}

test "a discarded local counts as used" {
    try expectClean("fn f() {\n    x := 1\n    _ = x\n}")
}

test "a written-but-unread local counts as used" {
    try expectClean("fn f() {\n    var x = 0\n    x = 5\n}")
}

test "an underscore for-capture binds nothing and is exempt" {
    try expectClean("fn f(n usize) {\n    for _ in 0..n {\n    }\n}")
}

test "an underscore index capture is exempt" {
    try expectClean("fn f(xs []u8) {\n    for x, _ in xs {\n        _ = x\n    }\n}")
}

test "an underscore parameter is exempt" {
    try expectClean("fn f(_ usize) {\n}")
}

test "a binding used only in type position is not unused" {
    // `T` is referenced only as `x`'s type; the type walker marks it used so it
    // is not falsely flagged.
    try expectClean("fn f() {\n    const T = u8\n    var x T = 0\n    _ = x\n}")
}

test "a comptime type parameter used only in the signature is not unused" {
    try expectClean("fn id(comptime T type, x T) T {\n    return x\n}")
}

test "a bodyless extern prototype's parameters are exempt from the unused check" {
    try expectClean("extern fn exit(code i32) noreturn")
}

test "a parameter used only in a returned type expression is not unused" {
    // `?T` in value position is a type_lit; the mark-only type walk reaches it,
    // so a generic whose parameter is spelt only there is not falsely flagged.
    try expectClean("fn Opt(comptime T type) type {\n    return ?T\n}")
    // An array length is an expression inside the type — also marked.
    try expectClean("fn Ring(comptime n usize) type {\n    return [n]u8\n}")
}

test "a parameter used only in a returned container's data shape is not unused" {
    // A struct field's type, a union variant's payload, and an enum variant's
    // explicit discriminant are downstream concerns, but the bindings they
    // reference are marked used.
    try expectClean("fn List(comptime T type) type {\n    return struct {\n        item T,\n    }\n}")
    try expectClean("fn Either(comptime A type, comptime B type) type {\n    return union(enum) {\n        left A,\n        right B,\n    }\n}")
    try expectClean("fn Tag(comptime base usize) type {\n    return enum(usize) {\n        first = base,\n    }\n}")
}

// --- ignored value / the statement-split hazard --------------------------

test "a continuation line split by a leading '-' is rejected" {
    // The hazard: `x := a` then `- b` parses as two statements, not `x := a - b`.
    // The orphaned `- b` is a value with no effect, anchored at the `-`.
    try expectDiag("fn f(a usize, b usize) usize {\n    x := a\n    - b\n    return x\n}", "expression value is ignored", 3, 5)
}

test "a leading '&' statement is rejected" {
    try expectDiag("fn f(p usize) {\n    &p\n}", "expression value is ignored", 2, 5)
}

test "a bare identifier statement is rejected" {
    try expectDiag("fn f(n usize) {\n    n\n}", "expression value is ignored", 2, 5)
}

test "a bare comparison statement is rejected" {
    try expectDiag("fn f(a usize, b usize) {\n    a == b\n}", "expression value is ignored", 2, 5)
}

test "a bare member access statement is rejected" {
    try expectDiag("use pkg\n\nfn f() {\n    pkg.field\n}", "expression value is ignored", 4, 5)
}

test "an ignored 'orelse' value statement is rejected" {
    try expectDiag("fn f(o ?usize) {\n    o orelse return\n}", "expression value is ignored", 2, 5)
}

test "a bare call statement is allowed" {
    try expectClean("fn f() {\n    doThing()\n}")
}

test "a 'try' statement is allowed" {
    try expectClean("fn f() !void {\n    try doThing()\n}")
}

test "a 'catch' statement is allowed" {
    try expectClean("fn f() {\n    doThing() catch |e| handle(e)\n}")
}

test "an 'unreachable' statement is allowed" {
    try expectClean("fn f() {\n    unreachable\n}")
}

test "a switch used as a statement is allowed" {
    try expectClean("fn f() {\n    switch poll() {\n        .ready => consume(),\n        else => {},\n    }\n}")
}

// --- redeclaration and shadowing (forbidden, Zig-exact) ------------------

test "a same-block redeclaration is rejected" {
    try expectDiag("fn f() {\n    x := 1\n    x := 2\n    _ = x\n}", "redeclaration of 'x'", 3, 5)
}

test "a body binding reusing a parameter name is a redeclaration" {
    try expectDiag("fn f(n usize) {\n    n := 1\n    _ = n\n}", "redeclaration of 'n'", 2, 5)
}

test "a duplicate parameter is rejected" {
    try expectDiag("fn f(a usize, a usize) {\n    _ = a\n}", "redeclaration of 'a'", 1, 15)
}

test "a duplicate top-level declaration is rejected" {
    try expectDiag("const N = 1\nconst N = 2", "redeclaration of 'N'", 2, 7)
}

test "an inner binding shadowing an outer local is rejected" {
    try expectDiag("fn f(o ?u8) {\n    x := 1\n    if o |x| {\n        _ = x\n    }\n    _ = x\n}", "'x' shadows a local binding", 3, 11)
}

test "an inner binding shadowing a parameter is rejected" {
    try expectDiag("fn f(x usize) {\n    if true {\n        x := 1\n        _ = x\n    }\n    _ = x\n}", "'x' shadows a parameter", 3, 9)
}

test "a local shadowing a file-scope binding is rejected" {
    try expectDiag("const N = 1\n\nfn f() {\n    N := 2\n    _ = N\n}", "'N' shadows a file-scope binding", 4, 5)
}

test "sibling blocks may reuse a name (separate frames)" {
    try expectClean("fn f(c bool) {\n    if c {\n        x := 1\n        _ = x\n    } else {\n        x := 2\n        _ = x\n    }\n}")
}

test "a test block body sees imports and file-scope declarations" {
    try expectClean("use std\n\nconst Answer = 42\n\nfn add(a i32, b i32) i32 {\n    return a + b\n}\n\ntest \"add\" {\n    try std.testing.expectEqual(Answer, add(40, 2))\n}")
}

test "an unknown member root in a test body is rejected" {
    try expectDiag("test \"broken\" {\n    _ = missing.thing()\n}", "unknown name 'missing'", 2, 9)
}

test "a test body is its own scope — bindings do not leak between tests" {
    try expectClean("test \"first\" {\n    n := 1\n    _ = n\n}\n\ntest \"second\" {\n    n := 2\n    _ = n\n}")
}

test "an else capture is in scope for its arm; the arm is its own scope" {
    try expectClean("fn f() void {\n    if next() |v| {\n        _ = v.field\n    } else |err| {\n        _ = err.field\n    }\n    while next() |v| {\n        _ = v.field\n    } else |err| {\n        _ = err.field\n    }\n}")
}

test "an else capture does not leak past its arm" {
    try expectDiag("fn f() void {\n    if next() |v| {\n        _ = v.field\n    } else |err| {\n        _ = err.field\n    }\n    _ = err.field\n}", "unknown name 'err'", 7, 9)
}

test "a loop else arm is checked: an unknown root inside it is rejected" {
    try expectDiag("fn f(xs []u8) void {\n    for x in xs {\n        _ = x\n    } else {\n        _ = missing.thing()\n    }\n}", "unknown name 'missing'", 5, 13)
}

test "names inside a tuple type are marked used; multi-return values are checked" {
    // `Tok` is used only inside tuple types (parameter + return position) —
    // the tuple descent must mark it, or it would be flagged unused; both
    // multi-return values are each walked (the unknown member root `bogus`
    // is caught; a bare unknown ident is outside this checker's scope).
    try expectDiag("const Tok = struct {\n    kind u8,\n}\nfn next(t (Tok, bool)) (Tok, bool) {\n    return t[0], bogus.kind\n}", "unknown name 'bogus'", 5, 18)
}

test "the evaluator's definite errors surface through check" {
    // A division by a known zero is reported with the standard Diag shape,
    // anchored at the expression's leftmost lexeme.
    try expectDiag("const D = 1 / 0", "division by zero", 1, 11)
    try expectDiag("fn f(n usize) usize {\n    return n / 0\n}", "division by zero", 2, 12)
    // `||` on error sets (a type operand) is rejected at the Flash line
    // instead of surfacing as invalid emitted Zig.
    try expectDiag("const AError = error{Bad}\nconst BError = error{Worse}\nconst Both = AError || BError", "cannot merge error sets", 3, 14)
}

test "folded constants raise no diagnostics through check" {
    try expectClean("const N = 1 + 2 * 3\nconst T = ?u8\nconst S = \"hi\"\n\nfn f() usize {\n    const k = N * 2\n    return k\n}")
}

test "generic application errors surface through check" {
    // Arity, anchored at the application's name.
    try expectDiag("fn Box(comptime T type) type {\n    return T\n}\nconst B = Box(u8, u8)", "generic 'Box' expects 1 argument, found 2", 4, 11)
    // Kind, anchored at the offending argument.
    try expectDiag("fn Box(comptime T type) type {\n    return T\n}\nconst B = Box(5)", "argument 1 to generic 'Box' expects a type, found a value", 4, 15)
}

test "well-formed generic applications check clean" {
    try expectClean("fn Box(comptime T type) type {\n    return T\n}\nconst B = Box(u8)\n\nfn f(x Box(u8)) Box(u8) {\n    return x\n}")
}

test "instance typing surfaces through check" {
    // `B` folds to the type `Box(u8)` denotes, so a value parameter
    // receiving it is a kind error, anchored at the argument.
    try expectDiag("fn Box(comptime T type) type {\n    return T\n}\nfn Ring(comptime n usize) type {\n    return u8\n}\nconst B = Box(u8)\nconst R = Ring(B)", "argument 1 to generic 'Ring' expects a value, found a type", 8, 16)
}

test "runaway generic recursion is reported through check" {
    // Anchored at the recursive application inside the generic's body.
    try expectDiag("fn A(comptime T type) type {\n    return A(T)\n}\nconst X = A(u8)", "generic 'A' exceeds the instantiation depth limit (64)", 2, 12)
}

test "a destructure declares its names: redeclaration and immutable assignment are flagged" {
    // The names land in the current frame under the unchanged dup rules.
    try expectDiag("fn pair() (u8, u8) {\n    return 1, 2\n}\nfn demo() void {\n    a, a := pair()\n    _ = a\n}", "redeclaration of 'a'", 5, 8)
    // A ':='-destructured name is immutable, exactly as a single ':=' bind.
    try expectDiag("fn pair() (u8, u8) {\n    return 1, 2\n}\nfn demo() void {\n    a, b := pair()\n    _ = b\n    a = 3\n}", "cannot assign to immutable binding 'a'", 7, 5)
    // A destructuring assignment checks each bare-identifier target's
    // mutability, like the single assign.
    try expectDiag("fn pair() (u8, u8) {\n    return 1, 2\n}\nfn demo() void {\n    p := 1\n    var q = 2\n    p, q = pair()\n}", "cannot assign to immutable binding 'p'", 7, 5)
}

test "labeled loops check clean: break and continue resolve through nesting" {
    // `break :outer` from inside a nested loop resolves to the enclosing
    // labeled while; the labelled continue resolves to its own for.
    try expectClean("fn f(xs []u8) void {\n    outer: while true {\n        for x in xs {\n            _ = x\n            break :outer\n        }\n    }\n}")
    try expectClean("fn g(xs []u8) void {\n    scan: for x in xs {\n        if x == 0 {\n            continue :scan\n        }\n        _ = x\n    }\n}")
    // The existing labeled-block break is untouched by the unified stack.
    try expectClean("fn h() usize {\n    v := blk: {\n        break :blk 1\n    }\n    return v\n}")
}

test "an unknown break or continue label is reported at the label" {
    try expectDiag("fn f() void {\n    while true {\n        break :outer\n    }\n}", "no enclosing loop or block is labeled 'outer'", 3, 16)
    try expectDiag("fn f() void {\n    while true {\n        continue :scan\n    }\n}", "no enclosing loop is labeled 'scan'", 3, 19)
}

test "a loop label is not visible from the loop's else arm" {
    // The else arm runs after the loop — a break there cannot target it
    // (matching Zig); the label then also goes unused.
    try expectDiag("fn f(it Iter) void {\n    outer: while it.next() |x| {\n        _ = x\n    } else {\n        break :outer\n    }\n}", "no enclosing loop or block is labeled 'outer'", 5, 16)
}

test "continuing a block label is rejected with a note at the declaration" {
    try expectDiag("fn f() usize {\n    return blk: {\n        continue :blk\n    }\n}", "cannot continue the block label 'blk'", 3, 19)
}

test "an unused label is reported at its declaration" {
    try expectDiag("fn f() void {\n    outer: while true {\n        break\n    }\n}", "unused loop label 'outer'", 2, 5)
    try expectDiag("fn f() void {\n    blk: {\n        _ = 1\n    }\n}", "unused block label 'blk'", 2, 5)
}

test "a binding used only inside a type-position align is not flagged unused" {
    // PAGE is referenced only by the align qualifier inside the pointer
    // type — markType walks the align expression, so the use registers.
    try expectClean("fn f() {\n    const PAGE usize = 4096\n    var buf []align(PAGE) u8 = undefined\n    _ = buf\n}")
    // The same through a sentinel form's shared payload.
    try expectClean("fn g() {\n    const A usize = 16\n    var s [:0]align(A) u8 = undefined\n    _ = s\n}")
}