ajhahn.de
← Flash
Zig 1833 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.zig), 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.zig), which folds constant initializers and contributes the definite
// compile-time errors (a division by a known zero) to the same diagnostic
// list. Type checking proper stays Zig's job downstream (Tier 0); deeper
// typing arrives with the self-hosting work later on the milestone ladder.

const std = @import("std");
const ast = @import("ast.zig");
const eval = @import("eval.zig");

// Re-exported so an integration test rooted at this module can parse its
// own sources — importing src/parser.zig as a second module root would
// place the file in two module graphs at once (a compile error).
pub const Parser = @import("parser.zig").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: []const u8,
    msg: []const u8,
    note_anchor: ?[]const u8 = null,
    note_msg: ?[]const 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.zig 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: []const u8, anchor: []const u8) Loc {
    const off = @intFromPtr(anchor.ptr) - @intFromPtr(src.ptr);
    std.debug.assert(off <= src.len);
    var line: u32 = 1;
    var line_start: usize = 0; // index just past the most recent newline
    var i: usize = 0;
    while (i < off) : (i += 1) {
        if (src[i] == '\n') {
            line += 1;
            line_start = 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). Today only
// member-access root resolution runs; the provenance is plumbing for the rest.
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 (plumbing for
// the unused-binding check); nothing consumes it yet.
const Binding = struct {
    name: []const u8,
    is_mut: bool,
    origin: Origin,
    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; nothing clears it yet.
const Checker = struct {
    arena: std.mem.Allocator,
    diags: std.ArrayList(Diag),
    scope: std.ArrayList(Binding),
    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: *Checker, b: Binding) Oom!void {
        if (self.lookupIndex(b.name)) |idx| {
            const prior = self.scope.items[idx];
            if (idx >= self.frame_base) {
                try self.reportNote(
                    b.name,
                    try std.fmt.allocPrint(self.arena, "redeclaration of '{s}'", .{b.name}),
                    prior.name,
                    "previously declared here",
                );
            } else {
                try self.reportNote(
                    b.name,
                    try std.fmt.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: *Checker, name: []const u8) ?usize {
        var i = self.scope.items.len;
        while (i > 0) {
            i -= 1;
            if (std.mem.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: *Checker, name: []const u8) ?*Binding {
        return if (self.lookupIndex(name)) |i| &self.scope.items[i] else null;
    }

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

    // Enter a new scope frame at the current stack top, returning the enclosing
    // frame's base for the matching leaveFrame.
    fn enterFrame(self: *Checker) usize {
        const 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: *Checker, saved_base: usize) Oom!void {
        for (self.scope.items[self.frame_base..]) |b| {
            const checked = switch (b.origin) {
                .local, .param, .capture => true,
                .import, .global, .func => false,
            };
            if (checked and !b.used and !isUnderscore(b.name)) {
                const kind: []const u8 = switch (b.origin) {
                    .local => "local binding",
                    .param => "parameter",
                    .capture => "capture",
                    else => unreachable,
                };
                try self.report(b.name, try std.fmt.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: *Checker, e: ast.Expr) Oom!void {
        const 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: *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, .optional, .array_inferred => |inner| try self.markType(inner.*),
            .slice_sentinel, .slice_sentinel_mut, .many_ptr_sentinel, .many_ptr_sentinel_mut, .array_inferred_sentinel => |sp| {
                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 (ft.params) |p| try self.markType(p);
                if (ft.ret) |r| try self.markType(r.*);
            },
            .generic => |g| {
                self.markUsed(firstSegment(g.name));
                for (g.args) |a| try self.markExpr(a);
            },
            .tuple => |elems| for (elems) |e| try self.markType(e),
        }
    }

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

    fn reportNote(self: *Checker, anchor: []const u8, msg: []const u8, note_anchor: []const u8, note_msg: []const 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: *Checker, at: []const u8, b: Binding) Oom!void {
        const kind: []const u8 = switch (b.origin) {
            .param => "parameter",
            .capture => "capture",
            .import, .global, .func, .local => "binding",
        };
        const note: []const 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 std.fmt.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.
    fn checkFn(self: *Checker, f: ast.FnDecl) Oom!void {
        const saved = self.enterFrame();
        for (f.params) |p| {
            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 (f.params) |p| try self.markType(p.type);
        if (f.ret) |r| try self.markType(r);
        if (f.body) |body| {
            for (body) |s| try self.checkStmt(s);
            try self.leaveFrame(saved);
        } else {
            // A bodyless prototype (`extern fn …`) has no body to use its
            // parameters — they are the C-ABI signature, never unused — so the
            // frame is discarded without the unused check.
            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: *Checker, decls: []const ast.ContainerDecl) Oom!void {
        const saved = self.check_roots;
        self.check_roots = false;
        defer self.check_roots = saved;
        for (decls) |decl| switch (decl) {
            .method => |m| try self.checkFn(m),
            .constant => |c| try self.descendTypeDef(c.value),
            .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: *Checker, x: ast.Expr) Oom!void {
        switch (x) {
            .struct_def => |sd| for (sd.fields) |f| {
                try self.markType(f.type);
                if (f.default) |d| try self.markExpr(d);
            },
            .enum_def => |ed| for (ed.variants) |v| {
                if (v.value) |val| try self.markExpr(val.*);
            },
            .union_def => |ud| for (ud.variants) |v| {
                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: *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: *Checker, captures: []const []const u8, stmts: []const ast.Stmt) Oom!void {
        const saved = self.enterFrame();
        // A `_` capture (a `for _ in …` discard) binds nothing.
        for (captures) |cap| {
            if (isUnderscore(cap)) continue;
            try self.declare(.{ .name = cap, .is_mut = false, .origin = .capture });
        }
        for (stmts) |s| try self.checkStmt(s);
        try self.leaveFrame(saved);
    }

    fn checkStmt(self: *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 (d.names) |maybe| if (maybe) |name| {
                    try self.declare(.{ .name = name, .is_mut = d.is_mut, .origin = .local });
                };
            },
            .assign => |a| {
                try self.checkExpr(a.target);
                try self.checkExpr(a.value);
                // 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.
                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 (da.targets) |t| {
                    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);
            },
            .if_stmt => |iff| {
                try self.checkExpr(iff.cond);
                // An optional capture is in scope for the matched body only.
                if (iff.capture) |cap| {
                    try self.checkBlock(&.{cap}, iff.body);
                } else {
                    try self.checkBlock(&.{}, iff.body);
                }
                // The else arm's error capture (`else |err|`) is in scope for
                // that arm only.
                if (iff.else_body) |eb| {
                    if (iff.else_capture) |cap| {
                        try self.checkBlock(&.{cap}, eb);
                    } else {
                        try self.checkBlock(&.{}, eb);
                    }
                }
            },
            .while_stmt => |w| {
                try self.checkExpr(w.cond);
                // An optional payload capture is in scope for the body only.
                if (w.capture) |cap| {
                    try self.checkBlock(&.{cap}, w.body);
                } else {
                    try self.checkBlock(&.{}, w.body);
                }
                // The loop else arm; its error capture (`else |err|`) is in
                // scope for that arm only.
                if (w.else_body) |eb| {
                    if (w.else_capture) |cap| {
                        try self.checkBlock(&.{cap}, eb);
                    } else {
                        try self.checkBlock(&.{}, eb);
                    }
                }
            },
            .for_stmt => |fr| {
                try self.checkExpr(fr.iter);
                if (fr.range_hi) |hi| try self.checkExpr(hi);
                // The capture name(s) — element, and the optional index — are in
                // scope for the body only.
                try self.checkBlock(fr.captures, fr.body);
                // The loop else arm — capture-less, its own scope.
                if (fr.else_body) |eb| try self.checkBlock(&.{}, eb);
            },
            .defer_stmt => |inner| try self.checkStmt(inner.*),
            .errdefer_stmt => |inner| try self.checkStmt(inner.*),
            // The block forms open their own scope, like any `{ … }` body.
            .defer_block => |stmts| try self.checkBlock(&.{}, stmts),
            .errdefer_block => |stmts| try self.checkBlock(&.{}, stmts),
        }
    }

    fn checkExpr(self: *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 std.fmt.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 (c.args) |a| try self.checkExpr(a);
            },
            .index => |ix| {
                try self.checkExpr(ix.base.*);
                try self.checkExpr(ix.index.*);
            },
            .slice => |s| {
                try self.checkExpr(s.base.*);
                try self.checkExpr(s.lo.*);
                if (s.hi) |hi| try self.checkExpr(hi.*);
                // The sentinel is an ordinary expression (e.g. a `pkg.NUL`
                // constant), so a bad member root in it is still resolved.
                if (s.sentinel) |sen| try self.checkExpr(sen.*);
            },
            .builtin_call => |b| {
                for (b.args) |a| 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 (fields) |f| 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 (tl.fields) |f| 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 (sw.prongs) |prong| {
                    for (prong.patterns) |p| {
                        try self.checkExpr(p.lo);
                        if (p.hi) |hi| try self.checkExpr(hi);
                    }
                    if (prong.capture) |cap| {
                        const 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_expr => |c| {
                try self.checkExpr(c.lhs.*);
                // `catch |e| handler` binds the error in scope for the handler only.
                if (c.capture) |cap| {
                    const 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 (a.outputs) |op| switch (op.body) {
                    .expr => |e| try self.checkExpr(e),
                    .ret_type => {},
                };
                for (a.inputs) |op| switch (op.body) {
                    .expr => |e| try self.checkExpr(e),
                    .ret_type => {},
                };
                if (a.clobbers) |c| try self.checkExpr(c.*);
            },
            // A labeled block runs in the enclosing scope plus its own bindings;
            // the label is a break target, not a binding.
            .block_expr => |blk| try self.checkBlock(&.{}, blk.body),
            // A labelled break may carry a value (`break :blk v`) — resolve it.
            .brk => |b| if (b.value) |v| try self.checkExpr(v.*),
            .cont => {},
            .ret => |maybe| if (maybe) |vals| for (vals) |v| try self.checkExpr(v),
        }
    }
};

pub fn check(arena: std.mem.Allocator, program: ast.Program) Oom![]Diag {
    var c = Checker{
        .arena = arena,
        .diags = .empty,
        .scope = .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 (program.items) |item| {
        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 (program.items) |item| {
        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.
            .const_decl => |d| try c.descendTypeDef(d.value),
            else => {},
        }
    }
    // The compile-time evaluator (eval.zig) 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 (ev.diags.items) |d| {
        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) ?[]const 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| std.mem.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) ?[]const 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: []const u8) []const u8 {
    return if (std.mem.indexOfScalar(u8, name, '.')) |dot| name[0..dot] else name;
}

fn isUnderscore(name: []const u8) bool {
    return std.mem.eql(u8, name, "_");
}

// The noun naming a shadowed binding's kind, for the shadowing diagnostic.
fn originNoun(o: Origin) []const 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 ---------------------------------------------------------------

const testing = std.testing;

// Parse and check `src`, asserting no diagnostics. On failure the collected
// diagnostics are printed for the test log.
fn expectClean(src: []const u8) !void {
    var a = std.heap.ArenaAllocator.init(testing.allocator);
    defer a.deinit();
    const arena = a.allocator();
    var p = Parser.init(arena, src);
    const program = try p.parseProgram();
    const diags = try check(arena, program);
    if (diags.len != 0) {
        std.debug.print("expected a clean check, got {d} diagnostic(s):\n", .{diags.len});
        for (diags) |d| {
            const loc = locate(src, d.anchor);
            std.debug.print("  {d}:{d}: {s}\n", .{ loc.line, loc.col, d.msg });
        }
        return error.UnexpectedDiag;
    }
}

// Parse and check `src`, asserting at least one diagnostic whose message
// contains `frag` lands at `line`:`col`.
fn expectDiag(src: []const u8, frag: []const u8, line: u32, col: u32) !void {
    var a = std.heap.ArenaAllocator.init(testing.allocator);
    defer a.deinit();
    const arena = a.allocator();
    var p = Parser.init(arena, src);
    const program = try p.parseProgram();
    const diags = try check(arena, program);
    for (diags) |d| {
        if (std.mem.indexOf(u8, d.msg, frag) != null) {
            const loc = locate(src, d.anchor);
            if (loc.line == line and loc.col == col) return;
        }
    }
    std.debug.print("expected a diagnostic containing '{s}' at {d}:{d}; got {d}:\n", .{ frag, line, col, diags.len });
    for (diags) |d| {
        const loc = locate(src, d.anchor);
        std.debug.print("  {d}:{d}: {s}\n", .{ loc.line, loc.col, d.msg });
    }
    return error.DiagNotFound;
}

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

test "locate recovers 1-based line and column from an anchor" {
    const src = "abc\ndef\nghij";
    try testing.expectEqual(Loc{ .line = 1, .col = 1 }, locate(src, src[0..3])); // "abc"
    try testing.expectEqual(Loc{ .line = 2, .col = 1 }, locate(src, src[4..7])); // "def"
    try testing.expectEqual(Loc{ .line = 3, .col = 3 }, locate(src, src[10..12])); // "ij" in "ghij"
}

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

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

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

test "two unresolved roots are both reported, not bailed on the first" {
    try expectDiagCount(
        \\fn f() {
        \\    _ = nope.a()
        \\    _ = also.b()
        \\}
    , 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) {
        \\    if n > 0 {
        \\        x := n
        \\    }
        \\    _ = x.field
        \\}
    , "unknown name 'x'", 5, 9);
}

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

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

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

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

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

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

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

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

test "a bad member root inside a defer is rejected" {
    try expectDiag(
        \\fn f() {
        \\    defer _ = nope.close()
        \\}
    , "unknown name 'nope'", 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
        \\
        \\const Entry = struct {
        \\    info flibc.Dirent,
        \\    kind u8,
        \\}
        \\const 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 }
        \\
        \\fn fail() AllocError!void {
        \\    return error.OutOfMemory
        \\}
    );
}

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
        \\
        \\const Writer = struct {
        \\    fd i32,
        \\
        \\    const STDOUT i32 = 1
        \\
        \\    fn flush(self Writer) {
        \\        _ = flibc.sys.fsync(self.fd)
        \\    }
        \\}
    );
}

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) {
        \\    none,
        \\    eof,
        \\}
        \\
        \\fn classify(n usize) Action {
        \\    return if (n == 0) Action.eof else Action.none
        \\}
    );
}

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() {
        \\    _ = .{ .x = nope.value }
        \\}
    , "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
        \\
        \\fn f() {
        \\    switch sys.poll() {
        \\        .ready => |r| r.consume(),
        \\        else => {},
        \\    }
        \\}
    );
}

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

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

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

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

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

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

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

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

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

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

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) {
        \\    s.field = 1
        \\    a[0] = 2
        \\    p.* = 3
        \\}
    );
}

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 {
        \\    fd i32,
        \\
        \\    fn bump(self W, n i32) {
        \\        n = n + 1
        \\    }
        \\}
    , "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 {
        \\    fd i32,
        \\
        \\    fn set(self *mut W, v i32) {
        \\        self.fd = v
        \\    }
        \\}
    );
}

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 {
        \\    red,
        \\
        \\    fn next(n i32) i32 {
        \\        n = n + 1
        \\        return 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) {
        \\    eof,
        \\    int usize,
        \\
        \\    fn width(self Tok) usize {
        \\        return helpers.width(self)
        \\    }
        \\}
    );
}

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

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

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

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

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

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

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

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

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

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

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

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

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() {
        \\    const T = u8
        \\    var x T = 0
        \\    _ = x
        \\}
    );
}

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

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 {
        \\    return ?T
        \\}
    );
    // An array length is an expression inside the type — also marked.
    try expectClean(
        \\fn Ring(comptime n usize) type {
        \\    return [n]u8
        \\}
    );
}

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 {
        \\    return struct {
        \\        item T,
        \\    }
        \\}
    );
    try expectClean(
        \\fn Either(comptime A type, comptime B type) type {
        \\    return union(enum) {
        \\        left A,
        \\        right B,
        \\    }
        \\}
    );
    try expectClean(
        \\fn Tag(comptime base usize) type {
        \\    return enum(usize) {
        \\        first = base,
        \\    }
        \\}
    );
}

// --- 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 {
        \\    x := a
        \\    - b
        \\    return x
        \\}
    , "expression value is ignored", 3, 5);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

test "an else capture does not leak past its arm" {
    try expectDiag(
        \\fn f() void {
        \\    if next() |v| {
        \\        _ = v.field
        \\    } else |err| {
        \\        _ = err.field
        \\    }
        \\    _ = err.field
        \\}
    , "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 {
        \\    for x in xs {
        \\        _ = x
        \\    } else {
        \\        _ = missing.thing()
        \\    }
        \\}
    , "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 {
        \\    kind u8,
        \\}
        \\fn next(t (Tok, bool)) (Tok, bool) {
        \\    return t[0], bogus.kind
        \\}
    , "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 {
        \\    return n / 0
        \\}
    , "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}
        \\const BError = error{Worse}
        \\const Both = AError || BError
    , "cannot merge error sets", 3, 14);
}

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

test "generic application errors surface through check" {
    // Arity, anchored at the application's name.
    try expectDiag(
        \\fn Box(comptime T type) type {
        \\    return T
        \\}
        \\const 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 {
        \\    return T
        \\}
        \\const 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 {
        \\    return T
        \\}
        \\const B = Box(u8)
        \\
        \\fn f(x Box(u8)) Box(u8) {
        \\    return x
        \\}
    );
}

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 {
        \\    return T
        \\}
        \\fn Ring(comptime n usize) type {
        \\    return u8
        \\}
        \\const B = Box(u8)
        \\const 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 {
        \\    return A(T)
        \\}
        \\const 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) {
        \\    return 1, 2
        \\}
        \\fn demo() void {
        \\    a, a := pair()
        \\    _ = a
        \\}
    , "redeclaration of 'a'", 5, 8);
    // A ':='-destructured name is immutable, exactly as a single ':=' bind.
    try expectDiag(
        \\fn pair() (u8, u8) {
        \\    return 1, 2
        \\}
        \\fn demo() void {
        \\    a, b := pair()
        \\    _ = b
        \\    a = 3
        \\}
    , "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) {
        \\    return 1, 2
        \\}
        \\fn demo() void {
        \\    p := 1
        \\    var q = 2
        \\    p, q = pair()
        \\}
    , "cannot assign to immutable binding 'p'", 7, 5);
}