Files
sx/src/ir/lower/stmt.zig
agra 7f3bd69bd9 lang: reject writes through constants (PLAN-CONST-AGG step 2, fixes 0116)
Any assignment / compound-assignment whose target chain is ROOTED at a
constant — a const-flagged global (array consts, #run consts) or a
module value const (struct consts incl.) — diagnoses 'cannot assign
through constant X' at compile time. A struct const's field write used
to compile and bus-error at runtime (issue 0116); scalars misfired
silently. A deref along the chain (p.*) breaks the root — pointer
writes stay the documented escape until the const-ness steps; a local
shadowing the const name stays writable.

Also: typed struct constants ('W : Color : Color.{...}') register —
the shape list skipped struct_literal, leaving the typed form
unresolved while the untyped one worked.

Examples: 1162 (all rejection shapes incl. the 0116 crash repro),
0178 (typed struct const reads + copy independence).
2026-06-11 12:33:34 +03:00

1319 lines
62 KiB
Zig

const std = @import("std");
const ast = @import("../../ast.zig");
const Node = ast.Node;
const types = @import("../types.zig");
const inst_mod = @import("../inst.zig");
const mod_mod = @import("../module.zig");
const errors = @import("../../errors.zig");
const TypeId = types.TypeId;
const Ref = inst_mod.Ref;
const Module = mod_mod.Module;
const lower = @import("../lower.zig");
const Lowering = lower.Lowering;
const Scope = lower.Scope;
pub fn lowerBlock(self: *Lowering, node: *const Node) void {
switch (node.data) {
.block => |blk| {
// Create a child scope for block-level variable shadowing
var block_scope = Scope.init(self.alloc, self.scope);
const saved_scope = self.scope;
self.scope = &block_scope;
const saved_defer_len = self.defer_stack.items.len;
defer {
self.emitBlockDefers(saved_defer_len);
self.scope = saved_scope;
block_scope.deinit();
}
for (blk.stmts) |stmt| {
if (self.block_terminated) break;
self.lowerStmt(stmt);
// A bare `return`/`raise` mid-block terminates the current
// basic block but deliberately does NOT set `block_terminated`
// (that flag would leak past an `if cond { return }` merge
// block, skipping its trailing statements — see lowerReturn).
// Stop here so dead statements after the terminator aren't
// emitted into an already-closed block (invalid LLVM IR).
if (self.currentBlockHasTerminator()) break;
}
},
else => {
// Single expression as body (arrow functions)
self.lowerStmt(node);
},
}
}
/// Lower an `inline if` branch — block body emits statements, expression returns value.
pub fn lowerInlineBranch(self: *Lowering, node: *const Node) Ref {
if (node.data == .block) {
self.lowerBlock(node);
// A `return` inside the branch terminates the current LLVM block; propagate
// that up so the enclosing block lowering stops emitting fall-through.
if (self.currentBlockHasTerminator()) {
self.block_terminated = true;
return .none;
}
return self.builder.constInt(0, .void);
}
return self.lowerExpr(node);
}
/// Lower a block and return the last expression's value (for implicit returns).
pub fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref {
// Set force_block_value so nested if-else expressions produce values
const saved = self.force_block_value;
self.force_block_value = true;
defer self.force_block_value = saved;
switch (node.data) {
.block => |blk| {
if (blk.stmts.len == 0) return null;
// Create a child scope for block-level variable shadowing
var block_scope = Scope.init(self.alloc, self.scope);
const saved_scope = self.scope;
self.scope = &block_scope;
const saved_defer_len = self.defer_stack.items.len;
defer {
self.emitBlockDefers(saved_defer_len);
self.scope = saved_scope;
block_scope.deinit();
}
// A block whose last statement is `;`-terminated (or not an
// expression) discards its value: lower every statement as a
// statement and yield nothing.
if (!blk.produces_value) {
self.force_block_value = false;
for (blk.stmts) |stmt| {
if (self.block_terminated) return null;
self.lowerStmt(stmt);
if (self.currentBlockHasTerminator()) return null;
}
return null;
}
// Lower all statements except the last normally
self.force_block_value = false; // don't force for non-last statements
for (blk.stmts[0 .. blk.stmts.len - 1]) |stmt| {
if (self.block_terminated) return null;
self.lowerStmt(stmt);
// A bare `return`/`raise` mid-block closes the current basic
// block (without setting `block_terminated`); the remaining
// statements — including the value-expr — are dead.
if (self.currentBlockHasTerminator()) return null;
}
if (self.block_terminated) return null;
// Last statement (no trailing `;`): its value is the block's.
self.force_block_value = true;
const last = blk.stmts[blk.stmts.len - 1];
return self.tryLowerAsExpr(last);
},
else => {
// Single expression as body (arrow functions)
return self.tryLowerAsExpr(node);
},
}
}
/// Lower a value-returning function body and emit the implicit return.
/// Emits a hard error when the body yields no value — its last statement is
/// `;`-terminated (value discarded) or void — and the body doesn't already
/// terminate via `return`/`raise`. Replaces the old silent default-return.
pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
const body_val = self.lowerBlockValue(body);
if (self.currentBlockHasTerminator()) return;
if (body_val) |val| {
const val_ty = self.builder.getRefType(val);
if (val_ty != .void) {
const coerced = self.coerceToType(val, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
return;
}
}
// A PURE-failable function (`-> !` / `-> !Named`, whose entire return IS
// the error channel) carries no success value — a void body is a normal
// success exit, not a missing value. `ensureTerminator` emits the
// error-slot-zero success return.
if (self.errorChannelOf(ret_ty)) |chan| {
if (chan == ret_ty) {
self.ensureTerminator(ret_ty);
return;
}
}
if (self.diagnostics) |diags| {
if (body.data == .block and body.data.block.discarded_semi != null) {
diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
} else {
const span = blk: {
if (body.data == .block) {
const stmts = body.data.block.stmts;
if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
}
break :blk body.span;
};
diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
}
}
self.ensureTerminator(ret_ty);
}
/// Try to lower a node as an expression, returning its value.
/// Statement nodes are lowered as statements (returning null).
pub fn tryLowerAsExpr(self: *Lowering, node: *const Node) ?Ref {
return switch (node.data) {
.var_decl, .const_decl, .fn_decl, .return_stmt, .raise_stmt, .assignment, .defer_stmt, .push_stmt, .multi_assign, .destructure_decl => {
self.lowerStmt(node);
return null;
},
else => self.lowerExpr(node),
};
}
pub fn lowerStmt(self: *Lowering, node: *const Node) void {
// Stamp this statement's span onto its instructions (ERR E3.0); see
// `lowerExpr`.
const saved_span = self.builder.current_span;
defer self.builder.current_span = saved_span;
if (node.span.start != 0 or node.span.end != 0) self.builder.current_span = .{ .start = node.span.start, .end = node.span.end };
switch (node.data) {
.var_decl => |vd| self.lowerVarDecl(&vd),
.const_decl => |cd| self.lowerConstDecl(&cd),
.fn_decl => |fd| self.lowerLocalFnDecl(&fd),
.return_stmt => |rs| self.lowerReturn(&rs),
.raise_stmt => |rs| self.lowerRaise(&rs, node.span),
.assignment => |asgn| self.lowerAssignment(&asgn),
.defer_stmt => |ds| self.lowerDefer(&ds),
.onfail_stmt => |ofs| self.lowerOnFail(&ofs, node.span),
.push_stmt => |ps| self.lowerPush(&ps),
.multi_assign => |ma| self.lowerMultiAssign(&ma),
.destructure_decl => |dd| self.lowerDestructureDecl(&dd),
.insert_expr => |ins| self.lowerInsertExpr(ins.expr),
.block => self.lowerBlock(node),
.jni_env_block => |eb| {
// Compile-time stack push for lexical-direct env resolution
// (2.16b — `#jni_call` in the same fn picks up env from
// jni_env_stack directly, no TL read).
//
// Runtime TL save/set/restore (2.16c) for cross-function
// helpers: callees in OTHER fns invoked from inside the
// body read the slot via `sx_jni_env_tl_get`. Storage
// lives in a separately-linked C helper (see
// library/vendors/sx_jni_runtime/sx_jni_env_tl.c) so the
// JIT doesn't need orc_rt for TLS.
const env_ref = self.lowerExpr(eb.env);
const fids = self.getJniEnvTlFids();
const ptr_ty = self.module.types.ptrTo(.void);
const saved_tl = self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty);
const set_args = self.alloc.dupe(Ref, &.{env_ref}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = set_args } }, .void);
self.jni_env_stack.append(self.alloc, env_ref) catch unreachable;
self.lowerBlock(eb.body);
_ = self.jni_env_stack.pop();
const restore_args = self.alloc.dupe(Ref, &.{saved_tl}) catch unreachable;
_ = self.builder.emit(.{ .call = .{ .callee = fids.set, .args = restore_args } }, .void);
},
// Block-local type declarations
.struct_decl => |sd| {
self.recordLocalTypeName(sd.name);
self.registerStructDecl(&node.data.struct_decl, node.source_file orelse self.current_source_file);
},
.enum_decl => {
if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
self.registerEnumDecl(&node.data.enum_decl);
},
.union_decl => {
if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
self.registerUnionDecl(&node.data.union_decl);
},
.error_set_decl => {
if (node.data.declName()) |dn| self.recordLocalTypeName(dn);
self.registerErrorSetDecl(node);
},
.ufcs_alias => |ua| {
self.program_index.ufcs_alias_map.put(ua.name, ua.target) catch {};
},
// Expression statement
else => {
_ = self.lowerExpr(node);
},
}
}
pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void {
if (vd.value) |val| {
if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
const ph = self.diagPackAsValue(val.data.identifier.name, val.span, .storage);
// Bind the name to the placeholder so later uses don't cascade
// into a second "unresolved" error after this one.
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = ph, .ty = .unresolved, .is_alloca = false });
}
return;
}
}
if (vd.type_annotation) |ta| {
// Explicit type annotation — resolve type first, then lower value
const ty = self.resolveType(ta);
const slot = self.builder.alloca(ty);
if (vd.value) |val| {
// = --- (undef_literal) on tuple types: zero-initialize
if (val.data == .undef_literal and !ty.isBuiltin()) {
const ti = self.module.types.get(ty);
if (ti == .tuple) {
var field_vals = std.ArrayList(Ref).empty;
defer field_vals.deinit(self.alloc);
for (ti.tuple.fields) |f| {
field_vals.append(self.alloc, self.builder.constInt(0, f)) catch unreachable;
}
const zero = self.builder.emit(.{
.tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable },
}, ty);
self.builder.store(slot, zero);
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
return;
}
}
// A compile-time float initializer narrowing into an integer
// local follows the unified rule (integral folds, non-integral
// errors); a runtime float / `xx` cast falls through to the
// normal lower+coerce below.
if (self.foldComptimeFloatInit(val, ty)) |folded| {
self.builder.store(slot, folded);
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
return;
}
const saved_target = self.target_type;
const saved_fbv = self.force_block_value;
self.target_type = ty;
self.force_block_value = true;
var ref = self.lowerExpr(val);
self.target_type = saved_target;
self.force_block_value = saved_fbv;
// If target is optional and value isn't null, wrap with optional_wrap
if (!ty.isBuiltin()) {
const ty_info = self.module.types.get(ty);
if (ty_info == .optional and val.data != .null_literal) {
ref = self.builder.optionalWrap(ref, ty);
} else if (ty_info == .slice) {
// Array → slice promotion: if value is an array, convert to slice
const ref_ty = self.builder.getRefType(ref);
if (!ref_ty.isBuiltin()) {
const ref_info = self.module.types.get(ref_ty);
if (ref_info == .array) {
ref = self.builder.emit(.{ .array_to_slice = .{ .operand = ref } }, ty);
}
}
} else if (self.getProtocolInfo(ty) != null) {
// Auto type erasure: concrete → protocol
const ref_ty = self.builder.getRefType(ref);
if (ref_ty != ty) {
ref = self.buildProtocolErasure(ref, val, ref_ty, ty);
}
}
}
// Coerce value to match target type (e.g. u8 → s64 widening)
{
const ref_ty = self.builder.getRefType(ref);
if (ref_ty != ty and ref_ty != .void and ty != .void) {
ref = self.coerceToType(ref, ref_ty, ty);
}
}
self.builder.store(slot, ref);
} else {
// No value: zero-initialize or apply struct defaults
const zero = self.buildDefaultValue(ty);
self.builder.store(slot, zero);
}
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
} else if (vd.value) |val| {
// No type annotation — lower expr first, then get type from result.
// This is critical for generic calls where the return type is only
// known after monomorphization.
const saved_fbv = self.force_block_value;
const saved_target = self.target_type;
self.force_block_value = true;
// An unannotated decl provides no target type: clear the ambient one
// (the enclosing fn's implicit-return target) so literal initializers
// take their spec defaults (s64/f64) instead of adopting it.
self.target_type = null;
const ref = self.lowerExpr(val);
self.force_block_value = saved_fbv;
self.target_type = saved_target;
const ty = self.builder.getRefType(ref);
const slot = self.builder.alloca(ty);
self.builder.store(slot, ref);
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
} else {
const ty = TypeId.s64;
const slot = self.builder.alloca(ty);
self.builder.store(slot, self.zeroValue(ty));
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
}
}
/// Handle a bare fn_decl node as a local function declaration.
/// The parser produces `fn_decl` (not `const_decl`) for `name :: (params) -> T { body }`.
pub fn lowerLocalFnDecl(self: *Lowering, fd: *const ast.FnDecl) void {
// Use mangled name for local functions to support block-scoped shadowing
const name = if (self.scope) |scope| blk: {
const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ fd.name, self.local_fn_counter }) catch fd.name;
self.local_fn_counter += 1;
scope.fn_names.put(fd.name, mangled) catch {};
break :blk mangled;
} else fd.name;
self.program_index.fn_ast_map.put(name, fd) catch {};
self.lazyLowerFunction(name);
}
pub fn lowerConstDecl(self: *Lowering, cd: *const ast.ConstDecl) void {
// Handle local function declarations: fx :: (s:s3) -> s3 { ... }
if (cd.value.data == .fn_decl) {
const fd = &cd.value.data.fn_decl;
// Use mangled name for local functions to support block-scoped shadowing
const name = if (self.scope != null) blk: {
const mangled = std.fmt.allocPrint(self.alloc, "{s}__{d}", .{ cd.name, self.local_fn_counter }) catch cd.name;
self.local_fn_counter += 1;
// Register the bare→mangled mapping in the current scope
if (self.scope) |scope| {
scope.fn_names.put(cd.name, mangled) catch {};
}
break :blk mangled;
} else cd.name;
// Register in fn_ast_map so it can be resolved by lowerCall
self.program_index.fn_ast_map.put(name, fd) catch {};
// Lower the function body (saves/restores builder state)
self.lazyLowerFunction(name);
return;
}
// Handle local type declarations: MyType :: struct/union/enum { ... }
if (cd.value.data == .struct_decl) {
self.recordLocalTypeName(cd.name);
self.registerStructDecl(&cd.value.data.struct_decl, self.current_source_file);
return;
}
if (cd.value.data == .enum_decl) {
self.recordLocalTypeName(cd.name);
self.registerEnumDecl(&cd.value.data.enum_decl);
return;
}
if (cd.value.data == .union_decl) {
self.recordLocalTypeName(cd.name);
self.registerUnionDecl(&cd.value.data.union_decl);
return;
}
const ref = self.lowerExpr(cd.value);
// If there's an explicit type annotation, use it. Otherwise, infer from the expression.
const ty = if (cd.type_annotation) |ta|
self.resolveType(ta)
else
self.builder.getRefType(ref);
if (self.scope) |scope| {
scope.put(cd.name, .{ .ref = ref, .ty = ty, .is_alloca = false });
}
}
pub fn lowerReturn(self: *Lowering, rs: *const ast.ReturnStmt) void {
if (rs.value) |val| {
if (val.data == .identifier and self.isPackName(val.data.identifier.name)) {
_ = self.diagPackAsValue(val.data.identifier.name, val.span, .return_value);
return;
}
}
// Set target_type to function return type so null_literal etc. get the right type.
// When inlining a comptime body, the *inlined* fn's declared return type wins
// over the caller's — otherwise `return 42` inside a `-> s64` body lowered into
// a `-> s32` caller would coerce 42 to s32 before storing into the s64 slot.
const old_target = self.target_type;
const ret_ty_for_target: TypeId = if (self.inline_return_target) |iri|
iri.ret_ty
else if (self.builder.func) |fid|
self.module.functions.items[@intFromEnum(fid)].ret
else
TypeId.s64;
// A value-carrying failable (`-> (T..., !)`) returns its VALUE part and
// the success error slot (0) is appended by lowerFailableSuccessReturn.
// Resolve a BARE returned value against that value type, NOT the failable
// tuple: a bare enum literal `.variant` resolves its tag against
// `target_type`, and against the tuple it matches no variant (tag 0) and
// is stamped with the tuple type — which the success-return path then
// mistakes for a forwarded full tuple, dropping the appended `0` slot.
// An explicit full failable tuple return (`return (v..., e)`) keeps the
// full-tuple target so its trailing error element resolves against the
// error set; it is then forwarded as-is. Applies to the inlined
// comptime-body return path too (iri.ret_ty is the failable tuple there).
const target_for_value = self.failableReturnTarget(ret_ty_for_target, rs.value);
if (target_for_value != .void) self.target_type = target_for_value;
// Evaluate return value first (before defers)
const ret_val = if (rs.value) |val| self.lowerExpr(val) else null;
self.target_type = old_target;
// Inlined-comptime-body return: store into the slot the inliner
// gave us and branch to the inliner's "return-done" basic block.
// The branch is the basic block's terminator — so subsequent
// dead code in the same block trips the LLVM verifier (the
// SAME behaviour as a regular `return X;` followed by code).
//
// We DO NOT set `block_terminated = true`: that flag would
// leak past structured control flow (e.g. an `if cond { return
// X; }` whose merge block continues to subsequent statements)
// and incorrectly skip the trailing statements. CFG-level
// termination is what we actually want — let the basic-block
// terminator do its job.
if (self.inline_return_target) |iri| {
if (ret_val) |ref| {
// Value-carrying failable inlined body: append the success error
// slot (0) exactly like the real-return path below.
// lowerFailableSuccessReturn routes through emitTupleRet, which
// stores into iri.slot and branches to iri.done_bb for an inline
// target. Defers first, so the returned SSA value is materialized
// before they run (matching the real-return ordering).
if (!iri.ret_ty.isBuiltin() and
self.module.types.get(iri.ret_ty) == .tuple and
self.errorChannelOf(iri.ret_ty) != null)
{
self.emitBlockDefers(self.func_defer_base);
self.lowerFailableSuccessReturn(ref, iri.ret_ty, rs.value.?.span);
return;
}
const val_ty = self.builder.getRefType(ref);
const coerced = if (val_ty != iri.ret_ty)
self.coerceToType(ref, val_ty, iri.ret_ty)
else
ref;
self.builder.store(iri.slot, coerced);
}
// Drain block-scoped defers up to the inlined-body base so
// they fire on this return path the same as a real fn return.
self.emitBlockDefers(self.func_defer_base);
self.builder.br(iri.done_bb, &.{});
return;
}
// Emit ALL pending defers for THIS function in LIFO order before the return
self.emitBlockDefers(self.func_defer_base);
if (ret_val) |ref| {
const ret_ty = if (self.builder.func) |fid|
self.module.functions.items[@intFromEnum(fid)].ret
else
TypeId.s64;
if (ret_ty == .void) {
// Void function — just return void (the value expression was evaluated for side effects)
self.builder.retVoid();
} else if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) {
// Value-carrying failable `-> (T..., !)`: the user returns the
// value part; the compiler appends the success error slot (0).
self.lowerFailableSuccessReturn(ref, ret_ty, rs.value.?.span);
} else {
// Coerce return value to match function return type (e.g., ?s32 → s32)
const val_ty = self.builder.getRefType(ref);
const coerced = self.coerceToType(ref, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
}
} else {
// A bare `return;` in a pure failable function (`-> !` / `-> !Named`,
// whose return type IS the error set) is the success exit — the
// error slot carries 0 ("no error"). Everything else is a void return.
const ret_ty = if (self.builder.func) |fid|
self.module.functions.items[@intFromEnum(fid)].ret
else
TypeId.void;
if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .error_set) {
self.builder.ret(self.builder.constInt(0, ret_ty), ret_ty);
} else {
self.builder.retVoid();
}
}
}
/// The ROOT identifier of an assignment-target chain (`K[0].x` → "K",
/// `WHITE.r` → "WHITE"). A deref along the chain (`p.*`, `p.*[i]`) breaks
/// it — writing through a pointer VALUE is not a write to the named root.
fn assignmentRootIdent(target: *const Node) ?[]const u8 {
var n = target;
while (true) {
switch (n.data) {
.identifier => |id| return id.name,
.index_expr => |ie| n = ie.object,
.field_access => |fa| n = fa.object,
else => return null,
}
}
}
/// True when `root` names a module CONSTANT from the current source —
/// a const-flagged global (array/struct consts, #run consts) or a
/// module value const. Locals shadow (caller checks scope first).
fn rootIsConstant(self: *Lowering, root: []const u8) bool {
switch (self.selectGlobalAuthor(root)) {
.resolved => |g| if (self.module.globals.items[g.id.index()].is_const) return true,
else => {},
}
return switch (self.selectModuleConst(root)) {
.resolved, .own_opaque => true,
.ambiguous, .none => false,
};
}
pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
// Writes through a constant are rejected at compile time (issue 0116):
// the target chain's root naming a const global (array/struct consts,
// #run consts) or a module value const cannot be stored to — for a
// struct const the store previously compiled and bus-errored at
// runtime; for scalars it silently misfired.
if (assignmentRootIdent(asgn.target)) |root| {
const shadowed = if (self.scope) |s| s.lookup(root) != null else false;
if (!shadowed and rootIsConstant(self, root)) {
if (self.diagnostics) |d|
d.addFmt(.err, asgn.target.span, "cannot assign through constant '{s}' — constants are immutable (use a '=' global or a local copy for mutable data)", .{root});
return;
}
}
// Set target_type from LHS for RHS lowering (enum literals, struct literals, etc.)
const old_target = self.target_type;
if (asgn.target.data == .identifier) {
var found_local = false;
if (self.scope) |scope| {
if (scope.lookup(asgn.target.data.identifier.name)) |binding| {
self.target_type = binding.ty;
found_local = true;
}
}
if (!found_local) {
// Quiet author-aware lookup (type inference only; the store
// site diagnoses ambiguity / visibility).
if (self.program_index.global_names.get(asgn.target.data.identifier.name)) |gi| {
switch (self.selectGlobalAuthor(asgn.target.data.identifier.name)) {
.resolved => |g| self.target_type = g.ty,
.untracked => self.target_type = gi.ty,
else => {},
}
}
}
} else if (asgn.target.data == .index_expr) {
// For array[i] = val, set target_type to the element type
const tgt_obj_ty = self.inferExprType(asgn.target.data.index_expr.object);
const elem_ty = self.ptrToArrayElem(tgt_obj_ty) orelse self.getElementType(tgt_obj_ty);
if (elem_ty != .void) self.target_type = elem_ty;
} else if (asgn.target.data == .field_access) {
// For obj.field = val, set target_type to the field's type so RHS
// sub-expressions (enum/struct literals, branch arms, xx casts) can
// resolve against it. Skipped for forms that would forward the type
// unchanged into method-call arg slots (`resolveCallParamTypes` can't
// override target_type per-arg).
const needs_target = switch (asgn.value.data) {
.enum_literal, .struct_literal, .tuple_literal, .if_expr, .match_expr, .block, .unary_op, .binary_op => true,
.call => |vc| vc.callee.data == .enum_literal,
else => false,
};
if (needs_target) {
const fa = asgn.target.data.field_access;
const obj_ty_raw = self.inferExprType(fa.object);
const obj_ty = if (!obj_ty_raw.isBuiltin()) blk: {
const pinfo = self.module.types.get(obj_ty_raw);
break :blk if (pinfo == .pointer) pinfo.pointer.pointee else obj_ty_raw;
} else obj_ty_raw;
if (!obj_ty.isBuiltin()) {
const field_name_id = self.module.types.internString(fa.field);
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields) |f| {
if (f.name == field_name_id) {
self.target_type = f.ty;
break;
}
}
}
}
}
const val = self.lowerExpr(asgn.value);
self.target_type = old_target;
switch (asgn.target.data) {
.identifier => |id| {
var handled = false;
if (self.scope) |scope| {
if (scope.lookup(id.name)) |binding| {
if (binding.is_alloca) {
handled = true;
if (asgn.op == .assign) {
// Coerce value to match binding type (e.g., f32 → ?f32, concrete → protocol)
var store_val = val;
const val_ty = self.builder.getRefType(val);
if (val_ty != binding.ty and val_ty != .void and binding.ty != .void) {
store_val = self.coerceToType(val, val_ty, binding.ty);
}
self.builder.store(binding.ref, store_val);
} else {
// Compound assignment: load, op, store
const loaded = self.builder.load(binding.ref, binding.ty);
const result = self.emitCompoundOp(loaded, val, asgn.op, binding.ty);
self.builder.store(binding.ref, result);
}
}
}
}
// Fallback: global variable assignment — source-aware (issue
// 0115): write the AUTHOR's global, never an unrelated module's
// same-named one.
if (!handled) {
if (self.resolveGlobalRef(id.name, asgn.target.span)) |gi| {
if (asgn.op == .assign) {
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != gi.ty and val_ty != .void and gi.ty != .void)
self.coerceToType(val, val_ty, gi.ty)
else
val;
self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = store_val } }, .void);
} else {
// Compound assignment: load current value, apply op, store back
const loaded = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
const result = self.emitCompoundOp(loaded, val, asgn.op, gi.ty);
self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = result } }, .void);
}
}
}
},
.field_access => |fa| {
// M2.2 — `obj.field = val` for an Obj-C `#property` field
// dispatches via objc_msgSend `setField:`. Skip struct-
// pointer / GEP entirely; receivers are opaque Obj-C ids.
// Compound ops on properties are deferred (need load-via-
// getter + op + store-via-setter — Month 4 ARC territory).
if (asgn.op == .assign) {
if (self.lookupObjcPropertyOnPointer(fa.object, fa.field)) |prop| {
self.lowerObjcPropertySetter(fa.object, prop, val);
return;
}
}
// M1.2 A.3 — `self.field [op]= val` on a sx-defined Obj-C
// class instance field (NOT a #property): write through
// the __sx_state ivar. Handles plain assignment AND
// compound ops (+=, -=, etc.) via storeOrCompound.
if (self.lookupObjcDefinedStateFieldOnPointer(fa.object, fa.field)) |info| {
const obj_ref = self.lowerExpr(fa.object);
const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return;
const ptr_void = self.module.types.ptrTo(.void);
const field_addr = self.builder.emit(.{ .struct_gep = .{
.base = state_ptr,
.field_index = info.field_idx,
.base_type = info.state_ty,
} }, ptr_void);
self.storeOrCompound(field_addr, val, asgn.op, info.field_ty);
return;
}
var obj_ptr = self.lowerExprAsPtr(fa.object);
var obj_ty = self.inferExprType(fa.object);
// Auto-deref: if the object is a pointer field from a non-identifier
// (i.e., result of structGep on a pointer slot), load the pointer value.
if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
const pinfo = self.module.types.get(obj_ty);
if (pinfo == .pointer) {
obj_ptr = self.builder.load(obj_ptr, obj_ty);
obj_ty = pinfo.pointer.pointee;
}
}
// Special .len/.ptr handling only for slices, strings, arrays — NOT structs
const is_special_container = obj_ty == .string or (if (!obj_ty.isBuiltin()) blk: {
const obj_info = self.module.types.get(obj_ty);
break :blk obj_info == .slice or obj_info == .array or obj_info == .vector;
} else false);
if (is_special_container and std.mem.eql(u8, fa.field, "len")) {
const gep = self.builder.structGepTyped(obj_ptr, 1, .s64, obj_ty);
self.storeOrCompound(gep, val, asgn.op, .s64);
} else if (is_special_container and std.mem.eql(u8, fa.field, "ptr")) {
const gep = self.builder.structGepTyped(obj_ptr, 0, .s64, obj_ty);
self.storeOrCompound(gep, val, asgn.op, .s64);
} else if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |fl| {
// Resolve the target field (struct / union direct / promoted
// anonymous-struct member / tuple element / vector lane) via
// the shared lvalue resolver — the same one the address-of
// and multi-target store paths use — so the three never
// resolve a field to a different slot or default field 0
// (two-resolver defect class). fl.ptr is
// *field_ty (the store handler unwraps one pointer level);
// fl.ty is the value type to coerce the rhs to.
const src_ty = self.builder.getRefType(val);
const coerced = self.coerceToType(val, src_ty, fl.ty);
self.storeOrCompound(fl.ptr, coerced, asgn.op, fl.ty);
} else {
// No struct / union / tuple / vector field matches the
// assignment target. Emit the same field-not-found
// diagnostic the read path uses (emitFieldError) and bail;
// building a pointer with field_ty = .unresolved would
// otherwise store through a pointer-to-.unresolved that
// panics at LLVM emission.
_ = self.emitFieldError(obj_ty, fa.field, asgn.target.span);
}
},
.index_expr => |ie| {
const idx = self.lowerExpr(ie.index);
const obj_ty = self.inferExprType(ie.object);
const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
const ptr_ty = self.module.types.ptrTo(elem_ty);
// For fixed-size array assignment targets, use the alloca pointer directly
// so that the store modifies the original variable (not a loaded copy).
const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
if (obj_alloca) |alloca_ref| {
// Array alloca: single-index GEP with element stride
const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
self.storeOrCompound(gep, val, asgn.op, elem_ty);
} else if (is_array) {
// Array in a struct field or other composite: get pointer to array in-place
const obj_ptr = self.lowerExprAsPtr(ie.object);
const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj_ptr, .rhs = idx } }, ptr_ty);
self.storeOrCompound(gep, val, asgn.op, elem_ty);
} else {
// Pointer/slice: load the pointer value and GEP
const obj = self.lowerExpr(ie.object);
const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
self.storeOrCompound(gep, val, asgn.op, elem_ty);
}
},
.deref_expr => |de| {
const ptr = self.lowerExpr(de.operand);
if (asgn.op == .assign) {
const pointee_ty = blk: {
const ptr_ty = self.inferExprType(de.operand);
if (!ptr_ty.isBuiltin()) {
const info = self.module.types.get(ptr_ty);
if (info == .pointer) break :blk info.pointer.pointee;
}
break :blk ptr_ty;
};
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
self.coerceToType(val, val_ty, pointee_ty)
else
val;
self.builder.store(ptr, store_val);
} else {
const pointee_ty = self.inferExprType(de.operand);
const elem_ty = blk: {
if (!pointee_ty.isBuiltin()) {
const info = self.module.types.get(pointee_ty);
if (info == .pointer) break :blk info.pointer.pointee;
}
break :blk pointee_ty;
};
self.storeOrCompound(ptr, val, asgn.op, elem_ty);
}
},
else => {
_ = self.emitError("assignment_target", asgn.target.span);
},
}
}
const FieldLvalue = struct { ptr: Ref, ty: TypeId };
/// Resolve `obj.field` — where `obj_ptr` already points at the aggregate —
/// to a typed pointer into the field's storage plus the field's value type.
/// Handles union direct fields, promoted anonymous-struct union members,
/// tuple elements (numeric or named), vector lanes (`.x`/`.y`/`.z`/`.w` and
/// the colour aliases), and plain struct fields. Returns null when no field
/// matches; the caller emits the field-not-found diagnostic.
///
/// `ptr`'s IR type is `*field_ty` (a pointer to the field), NOT the field
/// value type: `emitStore` reads the store-target pointer's IR type and
/// unwraps one `.pointer` level to find the stored value's type. Labelling
/// the GEP with the bare field type instead would make a field whose own
/// type is a pointer-to-aggregate (`*Pair`) coerce the stored pointer into
/// the aggregate (closure auto-promotion in `coerceArg`), storing an
/// oversized struct that clobbers the neighbouring field. `.ty` carries the
/// field's value type for the caller's coercion.
///
/// Single source of lvalue field resolution shared by all three store/
/// address-of sites — lowerAssignment (single-target store), lowerExprAsPtr
/// (address-of), and lowerMultiAssign (multi-target store) — so they never
/// resolve a field to a different slot or default field 0.
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
if (obj_ty.isBuiltin()) return null;
const field_name_id = self.module.types.internString(field);
const type_info = self.module.types.get(obj_ty);
// Union / tagged-union: variants overlay at offset 0. A direct field is
// a union_gep; a promoted anonymous-struct member is a union_gep into
// the variant followed by a struct_gep into the member.
const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
.@"union" => |u| u.fields,
.tagged_union => |u| u.fields,
else => null,
};
if (union_fields) |fields| {
for (fields, 0..) |f, i| {
if (f.name == field_name_id) {
const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
return .{ .ptr = ptr, .ty = f.ty };
}
if (!f.ty.isBuiltin()) {
const fi = self.module.types.get(f.ty);
if (fi == .@"struct") {
for (fi.@"struct".fields, 0..) |sf, si| {
if (sf.name == field_name_id) {
const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
const ptr = self.builder.structGepTyped(ug, @intCast(si), self.module.types.ptrTo(sf.ty), f.ty);
return .{ .ptr = ptr, .ty = sf.ty };
}
}
}
}
}
return null;
}
// Tuple element: `.0` (numeric) or `.name`.
if (type_info == .tuple) {
const tup = type_info.tuple;
var elem_idx: ?usize = null;
if (std.fmt.parseInt(usize, field, 10)) |n| {
if (n < tup.fields.len) elem_idx = n;
} else |_| {
if (tup.names) |names| {
for (names, 0..) |nm, i| {
if (nm == field_name_id and i < tup.fields.len) {
elem_idx = i;
break;
}
}
}
}
if (elem_idx) |idx| {
const elem_ty = tup.fields[idx];
const ptr = self.builder.structGepTyped(obj_ptr, @intCast(idx), self.module.types.ptrTo(elem_ty), obj_ty);
return .{ .ptr = ptr, .ty = elem_ty };
}
return null;
}
// Vector lane: `.x`/`.y`/`.z`/`.w` (or colour aliases `.r`/`.g`/`.b`/`.a`)
// → lane 0/1/2/3 via the same vectorLaneIndex the read path uses. A
// non-lane field on a vector is a genuine miss (caller diagnoses).
if (type_info == .vector) {
const vidx = Lowering.vectorLaneIndex(field) orelse return null;
const elem_ty = type_info.vector.element;
const ptr = self.builder.structGepTyped(obj_ptr, vidx, self.module.types.ptrTo(elem_ty), obj_ty);
return .{ .ptr = ptr, .ty = elem_ty };
}
// Plain struct field.
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
const ptr = self.builder.structGepTyped(obj_ptr, @intCast(i), self.module.types.ptrTo(f.ty), obj_ty);
return .{ .ptr = ptr, .ty = f.ty };
}
}
return null;
}
/// Get the pointer (alloca ref) for an lvalue expression, without loading.
pub fn lowerExprAsPtr(self: *Lowering, node: *const Node) Ref {
switch (node.data) {
.identifier => |id| {
const local = if (self.scope) |scope| scope.lookup(id.name) else null;
if (local) |binding| {
if (binding.is_alloca) {
// If the variable IS a pointer (e.g., p: *Vec2), load it
// to get the actual pointer value for GEP/store operations
if (!binding.ty.isBuiltin()) {
const info = self.module.types.get(binding.ty);
if (info == .pointer) {
return self.builder.load(binding.ref, binding.ty);
}
}
return binding.ref;
}
} else if (self.resolveGlobalRef(id.name, null)) |gi| {
// Module-global lvalue: address into the global's live storage
// so a downstream GEP/store targets the global itself, not a
// loaded copy. A pointer-typed global is loaded first to get
// the pointer value to GEP through (mirrors the local pointer
// case above); any other global yields its storage address.
if (!gi.ty.isBuiltin() and self.module.types.get(gi.ty) == .pointer) {
return self.builder.emit(.{ .global_get = gi.id }, gi.ty);
}
return self.builder.emit(.{ .global_addr = gi.id }, self.module.types.ptrTo(gi.ty));
}
},
.field_access => |fa| {
var obj_ptr = self.lowerExprAsPtr(fa.object);
var obj_ty = self.inferExprType(fa.object);
// Auto-deref for chained pointer field access:
// When fa.object is a field_access or index_expr, lowerExprAsPtr returns
// a structGep/pointer to the slot. If the slot holds a pointer type,
// we need to load the pointer value before GEPing into the pointee struct.
// (Identifiers are already loaded by the identifier handler in lowerExprAsPtr.)
if (fa.object.data != .identifier and !obj_ty.isBuiltin()) {
const info = self.module.types.get(obj_ty);
if (info == .pointer) {
obj_ptr = self.builder.load(obj_ptr, obj_ty);
obj_ty = info.pointer.pointee;
}
}
// Resolve the field lvalue (struct / union direct / promoted
// anonymous-struct member / tuple element) via the shared
// resolver so address-of and the multi-target store path never
// disagree on the slot. No match → emit the read path's
// field-not-found diagnostic (lowerFieldAccessOnType →
// emitFieldError) instead of silently GEPing field 0 as .s64;
// that bogus pointer reaches LLVM emission as ptrTo(.unresolved)
// and panics.
if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| return r.ptr;
return self.emitFieldError(obj_ty, fa.field, node.span);
},
.index_expr => |ie| {
const idx = self.lowerExpr(ie.index);
const obj_ty = self.inferExprType(ie.object);
const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
const ptr_ty = self.module.types.ptrTo(elem_ty);
// For fixed-size arrays, use the alloca so GEP addresses the original memory
const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
const base = if (is_array)
(self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object))
else
self.lowerExpr(ie.object);
return self.builder.emit(.{ .index_gep = .{ .lhs = base, .rhs = idx } }, ptr_ty);
},
.deref_expr => |de| {
return self.lowerExpr(de.operand);
},
else => {},
}
// Fallback: lower as expression (may produce a value, not pointer)
return self.lowerExpr(node);
}
/// Store a value to a GEP, handling both plain and compound assignment.
pub fn storeOrCompound(self: *Lowering, gep: Ref, val: Ref, op: ast.Assignment.Op, ty: TypeId) void {
if (op == .assign) {
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != ty and val_ty != .void and ty != .void)
self.coerceToType(val, val_ty, ty)
else
val;
self.builder.store(gep, store_val);
} else {
const loaded = self.builder.load(gep, ty);
const result = self.emitCompoundOp(loaded, val, op, ty);
self.builder.store(gep, result);
}
}
pub fn emitCompoundOp(self: *Lowering, lhs: Ref, rhs: Ref, op: ast.Assignment.Op, ty: TypeId) Ref {
return switch (op) {
.add_assign => self.builder.add(lhs, rhs, ty),
.sub_assign => self.builder.sub(lhs, rhs, ty),
.mul_assign => self.builder.mul(lhs, rhs, ty),
.div_assign => self.builder.div(lhs, rhs, ty),
.mod_assign => self.builder.emit(.{ .mod = .{ .lhs = lhs, .rhs = rhs } }, ty),
.and_assign => self.builder.emit(.{ .bit_and = .{ .lhs = lhs, .rhs = rhs } }, ty),
.or_assign => self.builder.emit(.{ .bit_or = .{ .lhs = lhs, .rhs = rhs } }, ty),
.xor_assign => self.builder.emit(.{ .bit_xor = .{ .lhs = lhs, .rhs = rhs } }, ty),
.shl_assign => self.builder.emit(.{ .shl = .{ .lhs = lhs, .rhs = rhs } }, ty),
.shr_assign => self.builder.emit(.{ .shr = .{ .lhs = lhs, .rhs = rhs } }, ty),
else => self.emitError("compound_assign", null),
};
}
// ── Defer / cleanup ─────────────────────────────────────────────
pub fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void {
// Push deferred expression onto the stack — emitted at every block exit, LIFO.
self.defer_stack.append(self.alloc, .{ .body = ds.expr, .is_onfail = false }) catch {};
}
/// `onfail [e] BODY` (ERR E1.7) — cleanup that runs only when an error
/// leaves the enclosing block. Recorded on the shared cleanup stack;
/// emitted (interleaved with defers, reverse) at error exits by
/// `emitErrorCleanup`, and discarded — never run — on a success exit.
pub fn lowerOnFail(self: *Lowering, ofs: *const ast.OnFailStmt, span: ast.Span) void {
// `onfail` is only meaningful inside a failable function — a
// non-failable function never error-exits, so it could never fire.
const ret_ty = self.effectiveReturnType() orelse {
self.diagOnFailNotFailable(span);
return;
};
if (self.errorChannelOf(ret_ty) == null) {
self.diagOnFailNotFailable(span);
return;
}
self.defer_stack.append(self.alloc, .{ .body = ofs.body, .is_onfail = true, .binding = ofs.binding }) catch {};
}
pub fn diagOnFailNotFailable(self: *Lowering, span: ast.Span) void {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`onfail` is only valid inside a failable function (a return type with `!` or `!Named`) — use `defer` for unconditional cleanup", .{});
}
}
/// Emit cleanups from saved_len..current in reverse (LIFO) order on a
/// SUCCESS exit: only `defer` entries run; `onfail` entries are skipped
/// (and discarded by the truncation). Truncates the stack to saved_len.
pub fn emitBlockDefers(self: *Lowering, saved_len: usize) void {
// Guard: if stack was already drained (e.g., by a return that emitted all defers)
if (saved_len > self.defer_stack.items.len) return;
if (self.currentBlockHasTerminator()) {
// Block already terminated (e.g., by return) — cleanups were already emitted
self.defer_stack.shrinkRetainingCapacity(saved_len);
return;
}
const stack = self.defer_stack.items;
var i = stack.len;
while (i > saved_len) {
i -= 1;
if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
}
self.defer_stack.shrinkRetainingCapacity(saved_len);
}
/// Emit pending `defer` cleanups for a `break`/`continue` exit: everything
/// registered since the innermost loop's body began, in LIFO order. `onfail`
/// entries are skipped (a break is a success exit). The stack is NOT
/// truncated — the same entries still belong to the fall-through lowering
/// path after the branch that contains the break; the enclosing block scopes
/// truncate as usual.
pub fn emitLoopExitDefers(self: *Lowering) void {
const stack = self.defer_stack.items;
var i = stack.len;
while (i > self.loop_defer_base) {
i -= 1;
if (!stack[i].is_onfail) self.lowerCleanupBody(stack[i].body);
}
}
/// Run a `defer`/`onfail` cleanup body for its side effects (void context).
/// A braced body lowers as statements (NOT as a value) so a trailing-`;`
/// last expression is fine here — cleanup bodies never yield a value.
pub fn lowerCleanupBody(self: *Lowering, body: *const Node) void {
if (body.data == .block) self.lowerBlock(body) else _ = self.lowerExpr(body);
}
/// Emit cleanups from `base`..current in reverse order on an ERROR exit
/// (raise / try-propagation): BOTH `defer` and `onfail` entries run,
/// interleaved in reverse declaration order. `err_tag` is the in-flight
/// error tag, bound to each `onfail e`'s binding. Does not truncate — the
/// terminating `ret` + the unwinding block-scope `emitBlockDefers` (which
/// then see the terminator and skip) leave the stack consistent.
pub fn emitErrorCleanup(self: *Lowering, base: usize, err_tag: Ref) void {
if (base > self.defer_stack.items.len) return;
const tag_ty = self.builder.getRefType(err_tag);
const stack = self.defer_stack.items;
var i = stack.len;
while (i > base) {
i -= 1;
const entry = stack[i];
if (entry.is_onfail) {
if (entry.binding) |name| {
var ofscope = Scope.init(self.alloc, self.scope);
const saved = self.scope;
self.scope = &ofscope;
ofscope.put(name, .{ .ref = err_tag, .ty = tag_ty, .is_alloca = false });
self.lowerCleanupBody(entry.body);
self.scope = saved;
ofscope.deinit();
} else {
self.lowerCleanupBody(entry.body);
}
} else {
self.lowerCleanupBody(entry.body);
}
}
}
pub fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
// push Context.{...} { body } — allocates a fresh Context on the
// stack frame, rebinds the lowering's `current_ctx_ref` to it for
// the body's lexical scope, then restores. No global, no walk.
if (!self.implicit_ctx_enabled) {
_ = self.diagnoseMissingContext("`push Context.{...}`");
self.lowerBlock(ps.body);
return;
}
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
_ = self.diagnoseMissingContext("`push Context.{...}`");
self.lowerBlock(ps.body);
return;
};
const saved_ctx_ref = self.current_ctx_ref;
defer self.current_ctx_ref = saved_ctx_ref;
const saved_target = self.target_type;
self.target_type = ctx_ty;
const ctx_val = self.lowerExpr(ps.context_expr);
self.target_type = saved_target;
const slot = self.builder.alloca(ctx_ty);
self.builder.store(slot, ctx_val);
self.current_ctx_ref = slot;
self.lowerBlock(ps.body);
}
pub fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
// Evaluate all RHS values first, then assign to LHS targets
var vals = std.ArrayList(Ref).empty;
defer vals.deinit(self.alloc);
for (ma.values) |v| {
vals.append(self.alloc, self.lowerExpr(v)) catch unreachable;
}
for (ma.targets, 0..) |target, i| {
if (i >= vals.items.len) break;
const val = vals.items[i];
switch (target.data) {
.identifier => |id| {
if (self.scope) |scope| {
if (scope.lookup(id.name)) |binding| {
if (binding.is_alloca) {
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != binding.ty and val_ty != .void and binding.ty != .void)
self.coerceToType(val, val_ty, binding.ty)
else
val;
self.builder.store(binding.ref, store_val);
}
}
}
},
.index_expr => |ie| {
const idx = self.lowerExpr(ie.index);
const obj_ty = self.inferExprType(ie.object);
const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty);
const ptr_ty = self.module.types.ptrTo(elem_ty);
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != elem_ty and val_ty != .void and elem_ty != .void)
self.coerceToType(val, val_ty, elem_ty)
else
val;
// For fixed-size arrays, use the alloca pointer directly
const is_array = !obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .array;
const obj_alloca = if (is_array) self.getExprAlloca(ie.object) else null;
if (obj_alloca) |alloca_ref| {
const gep = self.builder.emit(.{ .index_gep = .{ .lhs = alloca_ref, .rhs = idx } }, ptr_ty);
self.builder.store(gep, store_val);
} else {
const obj = self.lowerExpr(ie.object);
const gep = self.builder.emit(.{ .index_gep = .{ .lhs = obj, .rhs = idx } }, ptr_ty);
self.builder.store(gep, store_val);
}
},
.field_access => |fa| {
const obj_ptr = self.lowerExprAsPtr(fa.object);
const obj_ty = self.inferExprType(fa.object);
// Resolve the target field via the shared lvalue resolver —
// the same one address-of uses — so a missing field emits a
// diagnostic instead of defaulting to field 0 / field_ty
// .unresolved, which silently corrupted a neighbouring field
// (or panicked at LLVM emission).
if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| {
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != r.ty and val_ty != .void and r.ty != .void)
self.coerceToType(val, val_ty, r.ty)
else
val;
self.builder.store(r.ptr, store_val);
} else {
_ = self.emitFieldError(obj_ty, fa.field, target.span);
}
},
.deref_expr => |de| {
const ptr = self.lowerExpr(de.operand);
const pointee_ty = blk: {
const ptr_ty = self.inferExprType(de.operand);
if (!ptr_ty.isBuiltin()) {
const info = self.module.types.get(ptr_ty);
if (info == .pointer) break :blk info.pointer.pointee;
}
break :blk ptr_ty;
};
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != pointee_ty and val_ty != .void and pointee_ty != .void)
self.coerceToType(val, val_ty, pointee_ty)
else
val;
self.builder.store(ptr, store_val);
},
else => {
_ = self.emitError("multi_assign_target", target.span);
},
}
}
}
pub fn lowerDestructureDecl(self: *Lowering, dd: *const ast.DestructureDecl) void {
// Lower the RHS expression (must produce a tuple)
const saved_fbv = self.force_block_value;
const saved_target = self.target_type;
self.force_block_value = true;
// Same as the unannotated var-decl path: the destructure declares new
// bindings, so the ambient target type must not type the RHS literals.
self.target_type = null;
const ref = self.lowerExpr(dd.value);
self.force_block_value = saved_fbv;
self.target_type = saved_target;
const ty = self.builder.getRefType(ref);
// Get tuple field info
if (ty.isBuiltin()) return;
const ti = self.module.types.get(ty);
if (ti != .tuple) return;
const tuple = ti.tuple;
if (dd.names.len > tuple.fields.len) return;
// E1.8 (discard rejection): when the RHS is a value-carrying failable,
// the error slot (always the LAST tuple field) cannot be dropped. It is
// dropped when the destructure omits it (fewer names than fields, so the
// trailing error slot is never reached) or binds it to `_`. The `try` /
// `catch` / `or value` consumer forms all strip the error channel (their
// result type is non-failable), so this fires only on a BARE failable
// destructure — exactly the case that would let an error vanish silently.
if (self.errorChannelOf(ty) != null) {
const err_dropped = dd.names.len < tuple.fields.len or
std.mem.eql(u8, dd.names[dd.names.len - 1], "_");
if (err_dropped) {
if (self.diagnostics) |diags| {
diags.addFmt(.err, dd.value.span, "the error slot of a failable cannot be dropped — bind it (`v, err := …`) and handle it, or use `try` / `catch`", .{});
}
}
}
// Extract each field and bind to a new variable
for (dd.names, 0..) |name, i| {
if (std.mem.eql(u8, name, "_")) continue; // discard
const field_ty = tuple.fields[i];
const field_val = self.builder.emit(.{ .tuple_get = .{
.base = ref,
.field_index = @intCast(i),
.base_type = ty,
} }, field_ty);
const slot = self.builder.alloca(field_ty);
self.builder.store(slot, field_val);
if (self.scope) |scope| {
scope.put(name, .{ .ref = slot, .ty = field_ty, .is_alloca = true });
}
}
// Destructuring a failable's result binds the error slot to a variable:
// the user now owns the error explicitly, so the trace is absorbed
// (ERR E3.2). A plain (non-failable) tuple destructure clears nothing.
if (self.errorChannelOf(ty) != null) self.emitTraceClear();
}