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}")
}