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