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; // Flow narrowing (issue 0179) is block-scoped: a guard inside this // block narrows the rest of THIS block, no further. var narrow_snap = self.narrowSnapshot(); defer { self.emitBlockDefers(saved_defer_len); self.scope = saved_scope; block_scope.deinit(); self.narrowRestore(&narrow_snap); } 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; var narrow_snap = self.narrowSnapshot(); defer { self.emitBlockDefers(saved_defer_len); self.scope = saved_scope; block_scope.deinit(); self.narrowRestore(&narrow_snap); } // 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 { // Snapshot the ERROR count so the missing-value error below can be // suppressed when the body ALREADY reported a real error (e.g. an explicit // `return ` where the pack has no runtime value). Count only `.err` // diagnostics — a warning/note emitted while lowering the body (e.g. an // ObjC selector arity warning) must NOT suppress a genuine missing-value // error, or we'd ship an uninitialized return at exit 0. const errs_before: usize = if (self.diagnostics) |d| d.errorCount() else 0; 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) { // Value-carrying failable `-> (T..., !)`: a trailing success // EXPRESSION (no explicit `return`) yields just the value part — // the compiler must append the success error slot (0). Mirror the // explicit-`return EXPR;` path; a plain `coerceToType` would leave // the error-tag slot uninitialized (phantom catch on success). if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) { 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; }; self.lowerFailableSuccessReturn(val, ret_ty, span); return; } const coerced = self.coerceToType(val, val_ty, ret_ty); self.builder.ret(coerced, ret_ty); return; } } // A NAMED multi-return function (`-> (x: A, y: B)`) with no explicit // `return`: synthesize the implicit return from the named slot LOCALS (which // the body assigned). The must-set rule is checked here — an unset, undefaulted // slot is a loud error, not a silent fill. This takes precedence over the // "produces no value" diagnostic below (the body legitimately produces its // result by assigning the slots, not via a trailing expression). if (self.named_return_names) |names| { self.synthesizeNamedReturn(body, ret_ty, names); 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| { // Only the body produced no value AND no error was reported while // lowering it — a genuine "missing trailing value", not the fallout of // an already-diagnosed failed return. (If a real error fired, surfacing // the redundant missing-value note would just be noise.) if (diags.errorCount() == errs_before) { 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); } /// Definite-assignment check for the named-return must-set rule: true iff every /// non-diverging path through `node` assigns the bare identifier `name` (or /// diverges via `return`/`raise` before reaching the implicit return). PATH- /// SENSITIVE — a slot set in only ONE branch of an `if` (no `else`) is NOT /// definitely assigned, so it errors instead of returning a stale/garbage value. /// - `return`/`raise` → vacuously true (that path never reaches the implicit /// return, so the slot need not be set on it). /// - block → the FIRST statement that definitely assigns (or diverges) settles /// it (sequential composition). /// - `if` → both branches must (an `if` with no `else` cannot). /// - `push { … }` → always runs its body. /// - `match` → all arms must AND there is an `else` arm (exhaustiveness). /// - `while`/`for`/`defer`/`catch` and everything else → not guaranteed. /// Does not descend into nested function / lambda bodies (their `return`s own). fn definitelyAssigns(node: *const Node, name: []const u8) bool { return switch (node.data) { .assignment => |a| a.target.data == .identifier and std.mem.eql(u8, a.target.data.identifier.name, name), .multi_assign => |ma| blk: { for (ma.targets) |t| { if (t.data == .identifier and std.mem.eql(u8, t.data.identifier.name, name)) break :blk true; } break :blk false; }, // Function-level divergence — this path never reaches the implicit return. .return_stmt, .raise_stmt => true, .block => |blk| { for (blk.stmts) |s| if (definitelyAssigns(s, name)) return true; return false; }, .if_expr => |ie| ie.else_branch != null and definitelyAssigns(ie.then_branch, name) and definitelyAssigns(ie.else_branch.?, name), .push_stmt => |ps| definitelyAssigns(ps.body, name), .match_expr => |me| blk: { var has_else = false; for (me.arms) |arm| { if (arm.pattern == null) has_else = true; if (!definitelyAssigns(arm.body, name)) break :blk false; } break :blk has_else; }, else => false, }; } /// Bind a NAMED multi-return signature's value slots (`-> (x: A, y: B)`) as /// in-scope assignable locals, so the body's `x = …` writes to them. Each slot /// is a zero-initialized alloca (deterministic value if a path misses it — see /// `bodyAssignsTo`). Sets `self.named_return_names`; the caller restores it. /// No-op for a positional multi-return (no names → use an explicit `return`). pub fn bindNamedReturnSlots(self: *Lowering, fd: *const ast.FnDecl, ret_ty: TypeId, scope: *Scope) void { const rt = fd.return_type orelse return; if (rt.data != .return_type_expr) return; const names = rt.data.return_type_expr.field_names orelse return; // positional → no locals const defaults = rt.data.return_type_expr.field_defaults; if (ret_ty.isBuiltin()) return; const ti = self.module.types.get(ret_ty); if (ti != .tuple) return; const fields = ti.tuple.fields; const value_count = if (self.errorChannelOf(ret_ty) != null) fields.len - 1 else fields.len; var i: usize = 0; while (i < value_count and i < names.len) : (i += 1) { const nm = names[i]; if (nm.len == 0 or std.mem.eql(u8, nm, "!")) continue; // A named-return slot that shadows a PARAMETER of the same name would // silently hide the parameter behind a fresh local — reject the collision. for (fd.params) |p| { if (std.mem.eql(u8, p.name, nm)) { if (self.diagnostics) |d| { d.addFmt(.err, rt.span, "named return '{s}' collides with a parameter of the same name — rename one", .{nm}); } } } const fty = fields[i]; const slot = self.builder.alloca(fty); // Seed the slot. A slot with a DEFAULT gets it (type-checked, lowered, // coerced). Otherwise zero/default-init for ANY type (a deterministic // value if the path-insensitive must-set can't prove a path sets it — // never raw garbage; covers string / struct / float slots too). const dflt: ?*const Node = if (defaults) |ds| (if (i < ds.len) ds[i] else null) else null; if (dflt) |dn| { const saved_target = self.target_type; self.target_type = fty; const dval = self.lowerExpr(dn); self.target_type = saved_target; const dval_ty = self.builder.getRefType(dval); // Reject a default whose type has NO coercion to the slot type and a // mismatched byte width (e.g. `sum: i32 = "hi"`) — a `.none` plan // would pass the value through unchanged and overrun / under-fill the // slot, corrupting memory (the same guard as plain annotated // assignment, issue 0197). A same-width `.none` (`p: *void = typed_ptr`) // is a legitimate reinterpretation and stays allowed. if (!self.externalErrorsExist() and dval_ty != .unresolved and self.noneReinterpretIsUnsafe(dval_ty, fty)) { if (self.diagnostics) |d| { d.addFmt(.err, dn.span, "named return '{s}' has a default of type '{s}' that does not match its declared type '{s}'", .{ nm, self.formatTypeName(dval_ty), self.formatTypeName(fty) }); self.assignability_error_count += 1; } self.builder.store(slot, self.buildDefaultValue(fty)); } else { self.builder.store(slot, self.coerceToType(dval, dval_ty, fty)); } } else { self.builder.store(slot, self.buildDefaultValue(fty)); } scope.put(nm, .{ .ref = slot, .ty = fty, .is_alloca = true }); } self.named_return_names = names; self.named_return_defaults = defaults; } /// Emit the implicit return of a NAMED multi-return body: enforce the must-set /// rule on each value slot, then synthesize and lower `return n0 = n0, n1 = n1` /// over the slot locals — reusing the ordinary return path (tuple build + /// value-carrying-failable assembly), so failable named multi-returns work too. pub fn synthesizeNamedReturn(self: *Lowering, body: *const Node, ret_ty: TypeId, names: []const []const u8) void { const ti = self.module.types.get(ret_ty); if (ti != .tuple) { self.ensureTerminator(ret_ty); return; } const fields = ti.tuple.fields; const value_count = if (self.errorChannelOf(ret_ty) != null) fields.len - 1 else fields.len; var elems = std.ArrayList(ast.TupleElement).empty; defer elems.deinit(self.alloc); var i: usize = 0; while (i < value_count and i < names.len) : (i += 1) { const nm = names[i]; if (nm.len == 0 or std.mem.eql(u8, nm, "!")) continue; // Must-set: a slot not DEFINITELY assigned (on every non-diverging path) // and with no default is an error. A defaulted slot is exempt — its // default seeds the local in `bindNamedReturnSlots`. const has_default = if (self.named_return_defaults) |ds| (i < ds.len and ds[i] != null) else false; if (!has_default and !definitelyAssigns(body, nm)) { if (self.diagnostics) |d| { d.addFmt(.err, body.span, "named return '{s}' may be unset (not assigned on every path) and has no default — assign it on every path, give it a default, or end with an explicit `return`", .{nm}); } } const id_node = self.alloc.create(Node) catch return; id_node.* = .{ .span = body.span, .data = .{ .identifier = .{ .name = nm } } }; elems.append(self.alloc, .{ .name = nm, .value = id_node }) catch return; } const tl = self.alloc.create(Node) catch return; tl.* = .{ .span = body.span, .data = .{ .tuple_literal = .{ .elements = elems.toOwnedSlice(self.alloc) catch return } } }; const rs = ast.ReturnStmt{ .value = tl }; self.lowerReturn(&rs); } /// 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), // Pointer capture, not by-value: `lowerLocalFnDecl` registers the // decl pointer in `fn_ast_map`, so it must point into the AST node, // not at a stack temporary that the next statement reuses. .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 _ = self.rejectMultiReturnValueType(ta, "variable"); const ty = self.resolveType(ta); const slot = self.builder.alloca(ty); if (vd.value) |val| { if (val.data == .undef_literal and !ty.isBuiltin()) { const ti = self.module.types.get(ty); // = --- (undef_literal) on tuple types: zero-initialize 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; } // `---` on an array: explicitly uninitialized — no store. // A whole-array undef store is a store of nothing that // LLVM's legalizer scalarizes into one DAG node per // element (issue 0124: SelectionDAG segfault at ~64K). if (ti == .array) { 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 // — UNLESS the value is already that optional (e.g. a `?T`-returning // call, or a struct literal that lowered straight to `?T`); wrapping // again would build a `??`-shaped value typed `?T` and corrupt it / // fail LLVM verification (issue 0160). if (!ty.isBuiltin()) { const ty_info = self.module.types.get(ty); // Is the initializer value ITSELF an optional (`?A`)? If so the // target `?B` is a presence-PRESERVING coercion (`.optional_to_optional`), // NOT a wrap-present. The manual unwrap-to-child + `optionalWrap(present)` // below classifies `?A → child_B` as an unconditional unwrap (→ 0 for a // null source) then wraps it as always-present, so a null `?A` becomes a // present `?B` carrying zero (issue 0180). Route an optional source through // the trailing general `coerceToType(?A → ?B)` instead, which dispatches to // the presence-preserving arm. The wrap-present path below stays correct for // a non-optional source `T → ?T`. const ref_ty0 = self.builder.getRefType(ref); const src_is_optional = !ref_ty0.isBuiltin() and self.module.types.get(ref_ty0) == .optional; if (ty_info == .optional and val.data != .null_literal and ref_ty0 != ty and !src_is_optional) { // Coerce to the optional's CHILD first (e.g. an array value // into a `?[]T` promotes array→slice), THEN wrap — wrapping // the raw value would store e.g. array bits into the slice // payload and corrupt `.len`/`.ptr`. const child = ty_info.optional.child; const rt = self.builder.getRefType(ref); if (rt != child and rt != .void and child != .void) ref = self.coerceToType(ref, rt, child); // After coercion the value MUST be the optional's payload // type. If it isn't (the coercion classified `.none` and // passed the value through unchanged — e.g. a `?i64` value // flowing into `?(?i64)`, whose payload is the 1-tuple // `(?i64)`), wrapping anyway inserts a `{i64,i1}` into a // `{{i64,i1}}` slot and builds malformed IR that aborts the // LLVM verifier (issue 0165). Diagnose loudly instead. const post_rt = self.builder.getRefType(ref); if (post_rt != child and post_rt != .void and child != .void) { if (self.diagnostics) |d| { const cs = self.builder.current_span; // Only mention the `(T)`-is-a-1-tuple gotcha when the // payload actually IS a tuple (the `?(?T)` typo). const note: []const u8 = if (self.module.types.get(child) == .tuple) " (note: '(T,)' with a trailing comma is a 1-tuple; '(T)' without a comma groups to the inner type)" else ""; d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "cannot assign a value of type '{s}' to optional '{s}': its payload type is '{s}'{s}", .{ self.formatTypeName(post_rt), self.formatTypeName(ty), self.formatTypeName(child), note }); } // Already diagnosed — store the value as-is and bail. The // trailing coerce below would re-diagnose the same mismatch // (via the `.optional_wrap` guard in coerce.zig); `hasErrors()` // aborts the build regardless of the bytes we store. self.builder.store(slot, ref); if (self.scope) |scope| { scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true }); } return; } 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 → i64 widening) { const ref_ty = self.builder.getRefType(ref); if (ref_ty != ty and ref_ty != .void and ty != .void) { // An initializer with NO coercion to the annotated slot type // (`x : i32 = "hi"`) would otherwise pass through unchanged and // bit-mangle the slot (issue 0197). Diagnose and store a safe // default so the build aborts cleanly instead of segfaulting. if (!self.checkAssignable(ref_ty, ty, val.span, "initialize", vd.name, val)) { self.builder.store(slot, self.buildDefaultValue(ty)); if (self.scope) |scope| { scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true }); } return; } 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 (i64/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.i64; 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:i3) -> i3 { ... } 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; } // For a body-local `#run` const (`L :: #run f()`), record the const NAME so // the `__ct` wrapper carries it as a display name — a comptime-init failure // then reports `comptime init of 'L' failed` instead of `__ct_N` (issue 0182). const saved_ct_name = self.comptime_const_name; if (cd.value.data == .comptime_expr) self.comptime_const_name = cd.name; defer self.comptime_const_name = saved_ct_name; 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); // An annotated constant whose initializer cannot coerce to the declared type // would be bound under a type its bytes don't match (issue 0197) — diagnose // rather than let a later read reinterpret the wrong-shape value. if (cd.type_annotation != null) { _ = self.checkAssignable(self.builder.getRefType(ref), ty, cd.value.span, "initialize", cd.name, cd.value); } if (self.scope) |scope| { scope.put(cd.name, .{ .ref = ref, .ty = ty, .is_alloca = false }); } } /// Validate an explicit `return` value against a multi-VALUE return type (≥2 /// value slots). Emits diagnostics; does not rewrite. Covers: a bare value where /// multiple are required (`return 5` for `-> (i64, i64)`), wrong arity (too few / /// too many), and named elements that disagree with the slot at their position /// (named return elements must currently be IN SLOT ORDER — reordering by name is /// a future nicety, but a mismatch is an error, never a silent wrong result). /// A single-value or single-failable return is left to the existing path. pub fn validateMultiReturn(self: *Lowering, value_node: *const Node, ret_ty: TypeId) void { const diags = self.diagnostics orelse return; const ret_is_tuple = !ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple; // A comma list / multi-element literal returned from a SINGLE-value // (non-tuple) function would silently drop the extra values — reject it. if (!ret_is_tuple and value_node.data == .tuple_literal) { const els = value_node.data.tuple_literal.elements; if (els.len > 1) { for (els) |e| if (e.value.data == .spread_expr) return; // can't count a spread diags.addFmt(.err, value_node.span, "this function returns a single value, but a list of {d} was given", .{els.len}); } return; } if (!ret_is_tuple) return; const ti = self.module.types.get(ret_ty); const fields = ti.tuple.fields; const is_failable = self.errorChannelOf(ret_ty) != null; const value_count = if (is_failable) fields.len - 1 else fields.len; if (value_count < 2) return; // single value / single failable — not multi-return if (value_node.data == .tuple_literal) { const els = value_node.data.tuple_literal.elements; // A spread (`..xs`) can expand to any arity — can't check statically. for (els) |e| if (e.value.data == .spread_expr) return; // The value-only list (n == value_count) is the bare-comma form; the full // failable tuple (n == fields, including the error slot) is also allowed. if (els.len != value_count and els.len != fields.len) { diags.addFmt(.err, value_node.span, "this function returns {d} values, but {d} {s} given", .{ value_count, els.len, if (els.len == 1) @as([]const u8, "is") else @as([]const u8, "are") }); return; } // Named elements no longer need to be in slot order — `reorderNamedReturn` // (called from `lowerReturn` before lowering) permutes them to match the // slots and diagnoses unknown / duplicate / missing names. Arity is // checked above; nothing more to validate here. } else { // A bare value (not a comma list) where ≥2 are required is valid only if // it already PRODUCES the whole multi-value tuple — forwarding another // multi-return's result, or a multi-output `asm { … }`. Any TUPLE-typed // value qualifies (names may differ from the slots); a non-tuple scalar // does not — that is the `return 5` for `-> (i64, i64)` garbage case. const vty = self.inferExprType(value_node); const v_is_tuple = vty != .unresolved and !vty.isBuiltin() and self.module.types.get(vty) == .tuple; if (vty != .unresolved and !v_is_tuple) { diags.addFmt(.err, value_node.span, "this function returns {d} values — return them as `return a, b`, not a single value", .{value_count}); } } } /// Permute a FULLY-NAMED multi-return tuple literal (`return b = …, a = …`) so /// its elements line up with the function's return slots BY NAME, returning a /// fresh reordered `tuple_literal`. Positional / mixed lists, non-tuple returns, /// and arity mismatches (diagnosed in `validateMultiReturn`) pass through /// unchanged. Diagnoses a name that matches no slot, a duplicate, or a missing /// value slot — returning the original node after diagnosing (the build aborts /// via `hasErrors`, so the unpermuted node never reaches run time). fn reorderNamedReturn(self: *Lowering, value_node: *const Node, ret_ty: TypeId) *const Node { if (value_node.data != .tuple_literal) return value_node; if (ret_ty.isBuiltin()) return value_node; const ti = self.module.types.get(ret_ty); if (ti != .tuple) return value_node; const slot_names = ti.tuple.names orelse return value_node; const els = value_node.data.tuple_literal.elements; if (els.len == 0) return value_node; // Reorder only a FULLY-named list; positional/mixed keeps positional order. for (els) |e| if (e.name == null) return value_node; const is_failable = self.errorChannelOf(ret_ty) != null; const fields_len = ti.tuple.fields.len; const value_count = if (is_failable) fields_len - 1 else fields_len; // Two accepted shapes (anything else is an arity error diagnosed by // `validateMultiReturn` — pass through): the VALUE-ONLY list (one element per // value slot, the ergonomic `return a = …, b = …` form) and the FULL-TUPLE // list (a trailing element for the error slot too, `els.len == fields_len`). // BOTH must be reordered/validated — otherwise a fully-named full-tuple // failable return silently lands values positionally (regression found in // review). `match_count` slots participate; the error slot (when present) // joins by its own slot name. const match_count = els.len; if (match_count != value_count and match_count != fields_len) return value_node; if (match_count > slot_names.len) return value_node; // Validate element names FIRST (clearer diagnostics than a downstream // "missing slot"): every name must match a participating slot, no duplicates. for (els, 0..) |e, ei| { const en = e.name.?; var matches_slot = false; var s: usize = 0; while (s < match_count) : (s += 1) { const sn = self.module.types.getString(slot_names[s]); if (sn.len != 0 and std.mem.eql(u8, en, sn)) { matches_slot = true; break; } } if (!matches_slot) { if (self.diagnostics) |d| d.addFmt(.err, value_node.span, "named return element '{s}' does not name any return slot", .{en}); return value_node; } for (els[ei + 1 ..]) |e2| { if (std.mem.eql(u8, en, e2.name.?)) { if (self.diagnostics) |d| d.addFmt(.err, value_node.span, "named return element '{s}' is given more than once", .{en}); return value_node; } } } // All names are distinct participating-slot names and arity matches, so the // mapping is a bijection: every slot has exactly one matching element. const reordered = self.alloc.alloc(ast.TupleElement, match_count) catch return value_node; var slot: usize = 0; while (slot < match_count) : (slot += 1) { const sn = self.module.types.getString(slot_names[slot]); var filled = false; for (els) |e| { if (std.mem.eql(u8, e.name.?, sn)) { reordered[slot] = e; filled = true; break; } } // Validation above guarantees a bijection, so every slot is filled. If a // slot is somehow unmatched (e.g. an empty/unnamed slot in a full-tuple // form), bail rather than lower an uninitialized element. if (!filled) return value_node; } const node = self.alloc.create(Node) catch return value_node; node.* = .{ .span = value_node.span, .data = .{ .tuple_literal = .{ .elements = reordered } } }; return node; } 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; } // Validate a multi-value return against the function's slots: arity, a // bare value where multiple are required, and named-element/slot // agreement. Catches silent garbage (`return 5` for `-> (i64, i64)`) and // silently-wrong named returns (`return b = …, a = …` ignoring names). if (self.builder.func) |fid| { self.validateMultiReturn(val, self.module.functions.items[@intFromEnum(fid)].ret); } } // 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 `-> i64` body lowered into // a `-> i32` caller would coerce 42 to i32 before storing into the i64 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.i64; // 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). A fully-named multi-return // list is permuted to slot order by name (`return b = …, a = …`) before // lowering — `reorderNamedReturn` is a no-op for positional / non-tuple // returns and for the inline-comptime case (ret_ty_for_target carries the // right tuple either way). const ret_val = if (rs.value) |val| self.lowerExpr(reorderNamedReturn(self, val, ret_ty_for_target)) 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.i64; 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., ?i32 → i32) 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, }; } /// Map a compound-assignment op to the binary op it folds with, for the /// get-modify-set rewrite of `obj.prop OP= x` (a `#set` property). fn compoundAssignToBinaryOp(op: ast.Assignment.Op) ast.BinaryOp.Op { return switch (op) { .add_assign => .add, .sub_assign => .sub, .mul_assign => .mul, .div_assign => .div, .mod_assign => .mod, .and_assign => .bit_and, .or_assign => .bit_or, .xor_assign => .bit_xor, .shl_assign => .shl, .shr_assign => .shr, .assign => unreachable, // plain assign never reaches the rewrite }; } /// Bind an already-lowered `Ref` (`val` of type `ty`) to a fresh, unspellable /// (`$`-prefixed) local and return an identifier node that resolves to it. Lets /// a synthesized accessor call reference a pre-computed receiver/value WITHOUT /// re-lowering it — the basis for single-eval property writes. Null when there /// is no scope to bind into. fn bindSyntheticLocal(self: *Lowering, prefix: []const u8, val: Ref, ty: TypeId, span: ast.Span) ?*Node { const s = self.scope orelse return null; var namebuf: [48]u8 = undefined; const tmp = std.fmt.bufPrint(&namebuf, "${s}_{d}", .{ prefix, self.block_counter }) catch prefix; self.block_counter += 1; const owned = self.alloc.dupe(u8, tmp) catch return null; s.put(owned, .{ .ref = val, .ty = ty, .is_alloca = false }); const id = self.alloc.create(Node) catch return null; id.* = .{ .span = span, .data = .{ .identifier = .{ .name = owned } } }; return id; } /// Synthesize and lower `recv_obj.(value_node)` — the /// shared tail of every `#set` dispatch. fn emitSetterCall(self: *Lowering, recv_obj: *Node, setter: *const ast.FnDecl, value_node: *Node, span: ast.Span) void { const callee = self.alloc.create(Node) catch return; callee.* = .{ .span = span, .data = .{ .field_access = .{ .object = recv_obj, .field = self.accessorEffName(setter) } } }; const args = self.alloc.alloc(*Node, 1) catch return; args[0] = value_node; const syn_call = ast.Call{ .callee = callee, .args = args }; _ = self.lowerCall(&syn_call); } /// ` = ` where `prop` is a `#set` property and the RHS /// `Ref` was computed by the caller (the multi-assign path evaluates ALL RHS /// values up front, so re-lowering would double-evaluate and break ordering). /// Binds `val` to a synthetic local and dispatches the setter through it. /// Returns true when it consumed the store (setter write, or a read-only /// diagnostic for a `#get`-only property); false for an ordinary field. fn tryLowerPropertyStore(self: *Lowering, fa: ast.FieldAccess, val: Ref, span: ast.Span) bool { var recv_ty = self.inferExprType(fa.object); if (!recv_ty.isBuiltin()) { const di = self.module.types.get(recv_ty); if (di == .pointer) recv_ty = di.pointer.pointee; } if (recv_ty.isBuiltin()) return false; const setter = self.getSetterFor(recv_ty, fa.field) orelse { if (self.getAccessorFor(recv_ty, fa.field) != null) { if (self.diagnostics) |d| d.addFmt(.err, span, "property '{s}' is read-only (no '#set')", .{fa.field}); return true; } return false; }; var recv_obj: *Node = fa.object; if (fa.object.data == .deref_expr) recv_obj = fa.object.data.deref_expr.operand; const val_id = bindSyntheticLocal(self, "prop_val", val, self.builder.getRefType(val), span) orelse return false; emitSetterCall(self, recv_obj, setter, val_id, span); return true; } /// `obj.prop = rhs` (or `obj.prop OP= rhs`) where `prop` is a `#set` property /// accessor. Dispatches to the setter as `obj.prop$set(rhs)` — the write /// counterpart of the `#get` read dispatch in `lowerFieldAccess`. Returns true /// when it consumed the assignment (a real setter write, or a clean /// read-only/write-only diagnostic); false to let normal field-store lowering /// proceed (an ordinary field, or no property at all). /// /// Must run BEFORE `lowerAssignment` lowers the RHS: a plain-assign setter call /// lowers `rhs` itself (once, with the setter's value-param type as target), so /// pre-lowering it here would double-evaluate. fn tryLowerPropertyAssignment(self: *Lowering, asgn: *const ast.Assignment) bool { const fa = asgn.target.data.field_access; // Dereference the receiver type down to the struct that owns the accessor. var recv_ty = self.inferExprType(fa.object); if (!recv_ty.isBuiltin()) { const di = self.module.types.get(recv_ty); if (di == .pointer) recv_ty = di.pointer.pointee; } if (recv_ty.isBuiltin()) return false; const setter = self.getSetterFor(recv_ty, fa.field); const getter = self.getAccessorFor(recv_ty, fa.field); if (setter == null) { // No setter. A same-name `#get` (with no real field — getAccessorFor // guarantees a real field wins) means the property is read-only: reject // the write with a clear message rather than "field not found". if (getter != null) { if (self.diagnostics) |d| d.addFmt(.err, asgn.target.span, "property '{s}' is read-only (no '#set')", .{fa.field}); return true; } return false; // ordinary field, or not a property → normal store path } // The receiver node the synthesized get/set dispatch on. An explicit-deref // receiver `(*p).prop` dispatches on the inner pointer `p` (auto-deref takes // the working path). var recv_obj: *Node = fa.object; if (fa.object.data == .deref_expr) recv_obj = fa.object.data.deref_expr.operand; // For a compound `OP=`, the receiver is read (via `#get`) AND written (via // `#set`), so it must be evaluated EXACTLY ONCE — otherwise a side-effecting // receiver (`next().prop += 1`) reads one object and writes another. Bind // the receiver's `*T` to a synthetic, unspellable local and dispatch both // the read and the write on it. (A plain assign's single setter call already // evaluates the receiver once, so it keeps using the original node.) if (asgn.op != .assign) { if (getter == null) { if (self.diagnostics) |d| d.addFmt(.err, asgn.target.span, "property '{s}' is write-only (no '#get'); compound assignment needs to read the current value", .{fa.field}); return true; } // Evaluate the receiver once into a synthetic `*T` binding. `*T` receiver // → the pointer value itself; a `T` lvalue → its address (so the setter // mutates the original, not a copy). Guarded on a scope being present; // without one (e.g. a top-level init) fall back to the original node — // the receiver re-lowers, but functionality is preserved. if (self.scope != null) { var ptr_ty = self.inferExprType(recv_obj); const is_ptr = !ptr_ty.isBuiltin() and self.module.types.get(ptr_ty) == .pointer; const recv_ptr = if (is_ptr) self.lowerExpr(recv_obj) else self.lowerExprAsPtr(recv_obj); if (!is_ptr) ptr_ty = self.module.types.ptrTo(ptr_ty); if (bindSyntheticLocal(self, "prop_recv", recv_ptr, ptr_ty, asgn.target.span)) |id| recv_obj = id; } } // The value the setter receives. For a compound `OP=`: `(recv.prop) OP rhs` // — the read dispatches to the `#get` on the (now single-eval) receiver. var value_node: *Node = asgn.value; if (asgn.op != .assign) { const read_node = self.alloc.create(Node) catch return false; read_node.* = .{ .span = asgn.target.span, .data = .{ .field_access = .{ .object = recv_obj, .field = fa.field } } }; const bin_node = self.alloc.create(Node) catch return false; bin_node.* = .{ .span = asgn.value.span, .data = .{ .binary_op = .{ .op = compoundAssignToBinaryOp(asgn.op), .lhs = read_node, .rhs = asgn.value, } } }; value_node = bin_node; } emitSetterCall(self, recv_obj, setter.?, value_node, asgn.target.span); return true; } pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void { // Reassignment kills flow narrowing (issue 0179 / specs.md §Flow-Sensitive // Narrowing): a fresh value may be null, so the name is no longer proven // present. Drop it from the narrowed set before lowering the store. if (asgn.target.data == .identifier) { _ = self.narrowed.remove(asgn.target.data.identifier.name); } // 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` property accessor: `obj.prop = rhs` (or `OP=`) dispatches to the // setter as `obj.prop$set(rhs)`. Must run before the RHS is lowered below // (the synthesized call lowers it itself). Falls through for ordinary fields. if (asgn.target.data == .field_access) { if (tryLowerPropertyAssignment(self, asgn)) 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) { // `null` / `---` (undef) carry NO type of their own — they take the // store slot's type from `target_type`. Without setting it to the // field type here, a leaked enclosing `target_type` (e.g. the // function's return type while lowering its body, decl.zig:2691) // reaches `constNull`/`constUndef` and builds a WHOLE-STRUCT-typed // null, emitting an oversized store that overruns the field's slot // and corrupts neighboring stack (issue 0154). .enum_literal, .struct_literal, .tuple_literal, .if_expr, .match_expr, .block, .unary_op, .binary_op, .null_literal, .undef_literal => 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; // Resolve the LHS member's type via the SAME resolver the lvalue- // pointer path uses (fieldLvalueResolve), so the RHS target type // and the store slot can't diverge. Covers union/tagged-union // direct + promoted members, tuple/vector lanes, and structs — // not just structs (a plain getStructFields loop returned nothing // for a union member, leaving a struct-literal RHS untyped → // struct_init.ty == .unresolved → LLVM-emission panic; issue 0133). if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| { self.target_type = res.valueType(); } } } 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) { // A reassignment with no coercion to the slot type // (`x = "hi"` for `x: i32`) would pass through and // bit-mangle the slot (issue 0197) — diagnose instead. if (!self.checkAssignable(val_ty, binding.ty, asgn.value.span, "reassign", id.name, asgn.value)) return; 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); if (val_ty != gi.ty and val_ty != .void and gi.ty != .void) { // No coercion to the global's type — bit-mangle guard (issue 0197). if (!self.checkAssignable(val_ty, gi.ty, asgn.value.span, "reassign", id.name, asgn.value)) return; } 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; } } // Reject a direct write to a tagged-union variant (issue 0136): it // sets the payload but not the tag. Construct via `x = .variant(...)`. if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, asgn.target.span)) return; // 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, .i64, obj_ty); self.storeOrCompound(gep, val, asgn.op, .i64); } else if (is_special_container and std.mem.eql(u8, fa.field, "ptr")) { const gep = self.builder.structGepTyped(obj_ptr, 0, .i64, obj_ty); self.storeOrCompound(gep, val, asgn.op, .i64); } 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); // Guard a width-mismatched `.none` store into the field slot // (`w.s = "hi"` for a struct field `s`) — it would overrun the // slot and corrupt neighbors (issue 0197). Plain `=` only; // compound ops load-op-store through the field type. if (asgn.op == .assign and !self.checkAssignable(src_ty, fl.ty, asgn.value.span, "assign", fa.field, asgn.value)) return; 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 obj_ty = self.inferExprType(ie.object); // Comptime-constant store into a tuple element — `tup[i] = v`. A tuple // is heterogeneous, so the destination is a typed `structGep` of field // `i`, never an `index_gep` (whose `ptrTo(.unresolved)` element type // panics at LLVM emit). Mirrors the read path in `lowerIndexExpr`; an // out-of-range comptime index diagnoses loudly here too rather than // falling through to that panic. if (!obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .tuple) { const tinfo = self.module.types.get(obj_ty).tuple; if (self.comptimeIndexOf(ie.index)) |ci| { if (ci >= 0 and @as(usize, @intCast(ci)) < tinfo.fields.len) { const fi: u32 = @intCast(ci); const fld_ty = tinfo.fields[fi]; const base = self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object); const gep = self.builder.structGepTyped(base, fi, self.module.types.ptrTo(fld_ty), obj_ty); if (asgn.op == .assign and !self.checkAssignable(self.builder.getRefType(val), fld_ty, asgn.value.span, "assign", "element", asgn.value)) return; const coerced = self.coerceToType(val, self.builder.getRefType(val), fld_ty); self.storeOrCompound(gep, coerced, asgn.op, fld_ty); return; } if (self.diagnostics) |d| { d.addFmt(.err, ie.index.span, "tuple index {} out of bounds — tuple '{s}' has {} field{s}", .{ ci, self.formatTypeName(obj_ty), tinfo.fields.len, if (tinfo.fields.len == 1) "" else "s", }); } return; // hasErrors() aborts before codegen } } const idx = self.lowerExpr(ie.index); const elem_ty = self.ptrToArrayElem(obj_ty) orelse self.getElementType(obj_ty); const ptr_ty = self.module.types.ptrTo(elem_ty); // Guard a width-mismatched `.none` store into an element slot // (`arr[0] = "hi"` for an i32 array) — it would overrun the element // and corrupt neighbors (issue 0197). Plain `=` only. if (asgn.op == .assign and !self.checkAssignable(self.builder.getRefType(val), elem_ty, asgn.value.span, "assign", "element", asgn.value)) return; // 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); // Guard a width-mismatched `.none` store through the pointer // (`p.* = "hi"` for a `*i32`) — overruns the pointee (issue 0197). if (!self.checkAssignable(val_ty, pointee_ty, asgn.value.span, "assign", "target", asgn.value)) return; 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 }; /// Pure description of which slot `obj.field` resolves to — the GEP path plus /// the field's value type — computed WITHOUT emitting any IR. The single /// field-matching resolver for the LVALUE/WRITE paths: `fieldLvaluePtr` builds /// GEPs from it, and the assignment target-type path reads `.valueType()` from /// it, so the lvalue-pointer path and the RHS target-type path can never /// disagree on which field (or what type) a name resolves to — the two-resolver /// defect class this codebase keeps burning on. To handle a new aggregate /// shape, add an arm here and a matching GEP arm in `fieldLvaluePtr`; both fail /// to compile until the union is exhaustive, forcing the two to stay in lockstep. /// /// NOTE: the READ path (`lowerFieldAccess`, expr.zig) and the TYPE-INFER path /// (`ExprTyper.inferType`, expr_typer.zig) still carry their OWN parallel field /// matchers (emitting `union_get`/`enum_payload`/`struct_get` value reads, and /// returning a bare `TypeId`, respectively). They are not yet routed through /// here, so a new aggregate shape must currently be taught to all three. Folding /// read + infer onto this resolver (switching the descriptor to value-read ops / /// `.valueType()`) would make it the genuine compiler-wide single matcher. const FieldResolution = union(enum) { /// Direct union/tagged-union member: union_gep(index) into the aggregate. union_direct: struct { index: u32, ty: TypeId }, /// Promoted member of an anonymous-struct union variant: union_gep into /// the variant struct `variant_ty`, then struct_gep into the member. union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId }, /// Tuple element / vector lane / plain struct field: a single /// struct_gep(index) into the aggregate. indexed: struct { index: u32, ty: TypeId }, /// The field's value type — what the caller coerces the rhs to / sets as /// the RHS target type. Identical regardless of the GEP path taken. fn valueType(self: FieldResolution) TypeId { return switch (self) { .union_direct => |u| u.ty, .union_promoted => |u| u.ty, .indexed => |s| s.ty, }; } }; /// Match `obj.field` against the aggregate `obj_ty` and return the resolution /// descriptor, or null when no field matches (the caller emits the /// field-not-found diagnostic). Emits NO IR — see `FieldResolution`. /// /// 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. pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution { 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) { return .{ .union_direct = .{ .index = @intCast(i), .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) { return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .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| { return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } }; } 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; return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } }; } // Plain struct field. const struct_fields = self.getStructFields(obj_ty); for (struct_fields, 0..) |f, i| { if (f.name == field_name_id) { return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } }; } } return null; } /// 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. /// Delegates the field MATCH to `fieldLvalueResolve` (shared with the RHS /// target-type path) and only builds the GEP(s) here. 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 GEP-building shared by all three store/ /// address-of sites — lowerAssignment (single-target store), lowerExprAsPtr /// (address-of), and lowerMultiAssign (multi-target store); the field MATCH /// itself is delegated to `fieldLvalueResolve` (above), 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 { const res = self.fieldLvalueResolve(obj_ty, field) orelse return null; switch (res) { .union_direct => |u| { const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty)); return .{ .ptr = ptr, .ty = u.ty }; }, .union_promoted => |u| { const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty)); const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty); return .{ .ptr = ptr, .ty = u.ty }; }, .indexed => |s| { const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty); return .{ .ptr = ptr, .ty = s.ty }; }, } } /// Lower a plain (untagged) `union` struct-literal `.{ member = value, ... }`. /// The generic struct-literal path can't build a union — `getStructFields` /// returns empty for a union, so a union literal would fall through to a /// malformed `structInit` whose overlapping zero-fill clobbers the named member /// (issue 0158). Instead, mirror the spec's `--- `+per-field form: write each /// named member into an (otherwise-undefined) union-sized slot via the SAME /// lvalue resolver the assignment path uses, then load the union value back. /// /// Validity: union members overlay one storage slot, so the named members must /// all belong to ONE arm — either a single direct member (`.{ f = 3.14 }`) or /// several promoted members of the SAME anonymous-struct variant /// (`.{ x = 1.0, y = 2.0 }`). Naming two direct members, or members from /// different arms, would silently let a later store clobber an earlier one — /// reject it loudly (no silent last-wins). `tagged_union`s never reach here /// (handled earlier in `lowerStructLiteral`). pub fn lowerUnionLiteral(self: *Lowering, sl: *const ast.StructLiteral, ty: TypeId, span: ast.Span) Ref { // Empty `.{}` → an undefined union value (matches the spec's `--- ` form; // `zeroValue` of a union is `constUndef`). if (sl.field_inits.len == 0) return self.zeroValue(ty); // Validate every member is named and all share one arm. const Arm = struct { promoted: bool, index: u32 }; var arm: ?Arm = null; for (sl.field_inits) |fi| { const fname = fi.name orelse { if (self.diagnostics) |d| d.addFmt(.err, span, "a union literal must name its member(s): `.{{ member = value }}` (positional union init is ambiguous)", .{}); return self.zeroValue(ty); }; const res = self.fieldLvalueResolve(ty, fname) orelse { _ = self.emitFieldError(ty, fname, span); return self.zeroValue(ty); }; const cur: Arm = switch (res) { .union_direct => |u| .{ .promoted = false, .index = u.index }, .union_promoted => |u| .{ .promoted = true, .index = u.variant_index }, // A union name never resolves to `.indexed`, but be safe rather // than silently mis-store. .indexed => { _ = self.emitFieldError(ty, fname, span); return self.zeroValue(ty); }, }; if (arm) |a| { // Allowed only when BOTH are promoted members of the SAME variant. if (!a.promoted or !cur.promoted or a.index != cur.index) { if (self.diagnostics) |d| d.addFmt(.err, span, "a union literal may set only one member, or several members of the same anonymous-struct arm — '{s}'s members overlay the same storage", .{self.formatTypeName(ty)}); return self.zeroValue(ty); } } else { arm = cur; } } // Construct: write each member at its lvalue into an undefined union slot. const slot = self.builder.alloca(ty); for (sl.field_inits) |fi| { const fname = fi.name.?; // validated above const member_ty = (self.fieldLvalueResolve(ty, fname) orelse unreachable).valueType(); const saved_tt = self.target_type; self.target_type = member_ty; const val = self.lowerExpr(fi.value); self.target_type = saved_tt; const fl = self.fieldLvaluePtr(slot, ty, fname) orelse unreachable; const coerced = self.coerceToType(val, self.builder.getRefType(val), fl.ty); self.builder.store(fl.ptr, coerced); } return self.builder.load(slot, ty); } /// True (and emits the diagnostic) when `obj.field` names a DIRECT variant of a /// tagged union — a store target that would set the payload but NOT the tag /// (issue 0136): a tagged union is laid out `{ tag, payload }`, the write path /// emits a `union_gep` into the payload only, so the discriminant goes stale and /// a later `match`/`==` takes the wrong arm. The variant is set via construction /// (`x = .variant(...)`, which writes both), so a direct member write is rejected. /// /// Returns false (keeps working) for: plain `union` (no tag); promoted / nested /// sub-field writes (`s.rect.w = ...`, where the immediate object is the payload /// struct, resolving to `.indexed`/`.union_promoted`, not `.union_direct`); and /// non-aggregates. Derefs one pointer level so a `*TaggedUnion` receiver is /// caught too. Uses the shared `fieldLvalueResolve` matcher, so the guard can't /// drift from the store path's notion of which member a name resolves to. pub fn diagTaggedUnionVariantWrite(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) bool { var ty = obj_ty; if (!ty.isBuiltin()) { const info = self.module.types.get(ty); if (info == .pointer) ty = info.pointer.pointee; } if (ty.isBuiltin() or self.module.types.get(ty) != .tagged_union) return false; const res = self.fieldLvalueResolve(ty, field) orelse return false; if (res != .union_direct) return false; if (self.diagnostics) |d| d.addFmt(.err, span, "cannot assign to tagged-union variant '{s}' directly — a member write sets the payload but leaves the tag stale; construct the variant instead (e.g. `x = .{s}(...)`)", .{ field, field }); return true; } /// 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 .i64; // 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 obj_ty = self.inferExprType(ie.object); // Comptime-constant index into a tuple VALUE — the L-value sibling of // `lowerIndexExpr`'s tuple read path. A tuple is heterogeneous, so its // element address is a `structGep` of the i-th field (typed with that // field's type), NOT an `index_gep` (which assumes a uniform element // type — `getElementType(tuple)` is `.unresolved`, and an `index_gep` // with a `ptrTo(.unresolved)` result panics at LLVM emit). Needed for // `tasks[i].waiter = …` in the `race` runtime, where the i-th element // is read back as a pointer to GEP into its pointee. if (!obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .tuple) { const tinfo = self.module.types.get(obj_ty).tuple; if (self.comptimeIndexOf(ie.index)) |ci| { if (ci >= 0 and @as(usize, @intCast(ci)) < tinfo.fields.len) { const fi: u32 = @intCast(ci); const fld_ty = tinfo.fields[fi]; const base = self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object); return self.builder.structGepTyped(base, fi, self.module.types.ptrTo(fld_ty), obj_ty); } // Comptime index out of range — diagnose loudly (mirror the // read path in `lowerIndexExpr`) rather than falling through to // the `index_gep` below, whose `ptrTo(.unresolved)` element type // would panic at LLVM emit with no source diagnostic. if (self.diagnostics) |d| { d.addFmt(.err, ie.index.span, "tuple index {} out of bounds — tuple '{s}' has {} field{s}", .{ ci, self.formatTypeName(obj_ty), tinfo.fields.len, if (tinfo.fields.len == 1) "" else "s", }); } return self.builder.constInt(0, .i64); // placeholder — hasErrors() aborts before codegen } } const idx = self.lowerExpr(ie.index); 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 slot = self.builder.alloca(ctx_ty); // Inherit-omitted semantics: a `push Context.{ ... }` is a CAPABILITY // bag — fields the literal does NOT name are inherited from the ambient // context, not zero-inited. Zero-init would install a NULL `io`/ // `allocator` vtable (a latent crash if the field is later used inside // the pushed scope). So seed the new slot from the ambient context, // then overwrite only the fields the literal explicitly names. // // This applies only to a `Context.{...}` struct-literal context-expr; // any other form (e.g. `push some_ctx_value`) keeps the whole-value // store (no field-level merge to do). const lit: ?*const ast.StructLiteral = switch (ps.context_expr.data) { .struct_literal => |*sl| sl, else => null, }; if (lit != null and self.current_ctx_ref != Ref.none) { // 1. Copy the ambient context into the fresh slot (load + store the // whole struct), so every omitted field carries its current value. const ambient = self.builder.load(self.current_ctx_ref, ctx_ty); self.builder.store(slot, ambient); // 2. Overwrite only the named fields. `push Context.{...}` always // uses named field-inits (it is a Context literal); a positional // init has no field name to target, so it is rejected loudly // rather than silently writing the wrong field. self.current_ctx_ref = slot; // body + field values see the new slot for (lit.?.field_inits) |fi| { const fname = fi.name orelse { if (self.diagnostics) |d| d.addFmt(.err, ps.context_expr.span, "`push Context.{{...}}` requires named fields (positional init not supported)", .{}); continue; }; const fl = self.fieldLvaluePtr(slot, ctx_ty, fname) orelse { _ = self.emitFieldError(ctx_ty, fname, ps.context_expr.span); continue; }; const saved_target_f = self.target_type; self.target_type = fl.ty; const fval = self.lowerExpr(fi.value); self.target_type = saved_target_f; const fval_ty = self.builder.getRefType(fval); const store_val = if (fval_ty != fl.ty and fval_ty != .void and fl.ty != .void) self.coerceToType(fval, fval_ty, fl.ty) else fval; self.builder.store(fl.ptr, store_val); } } else { // Non-literal context-expr, or no ambient context to inherit from: // lower the whole value and store it (the original behaviour). const saved_target = self.target_type; self.target_type = ctx_ty; const ctx_val = self.lowerExpr(ps.context_expr); self.target_type = saved_target; 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); // Width-mismatched `.none` store guard (issue 0197). if (!self.checkAssignable(val_ty, binding.ty, ma.values[i].span, "assign", id.name, ma.values[i])) continue; 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 obj_ty = self.inferExprType(ie.object); // Comptime-constant direct store into a tuple element — `tup[i] = v` // (the store sibling of the L-value tuple path above). Heterogeneous // elements → a typed `structGep` of field `i`, never an `index_gep` // (a uniform-element op whose `ptrTo(.unresolved)` element type would // panic at LLVM emit). if (!obj_ty.isBuiltin() and self.module.types.get(obj_ty) == .tuple) { const tinfo = self.module.types.get(obj_ty).tuple; if (self.comptimeIndexOf(ie.index)) |ci| { if (ci >= 0 and @as(usize, @intCast(ci)) < tinfo.fields.len) { const fi: u32 = @intCast(ci); const fld_ty = tinfo.fields[fi]; const base = self.getExprAlloca(ie.object) orelse self.lowerExprAsPtr(ie.object); const gep = self.builder.structGepTyped(base, fi, self.module.types.ptrTo(fld_ty), obj_ty); const v_ty = self.builder.getRefType(val); if (!self.checkAssignable(v_ty, fld_ty, ma.values[i].span, "assign", "element", ma.values[i])) continue; const sv = if (v_ty != fld_ty and v_ty != .void and fld_ty != .void) self.coerceToType(val, v_ty, fld_ty) else val; self.builder.store(gep, sv); continue; } // Comptime index out of range — diagnose loudly instead of // falling through to the `index_gep` store (whose // `ptrTo(.unresolved)` element type would panic at LLVM emit). if (self.diagnostics) |d| { d.addFmt(.err, ie.index.span, "tuple index {} out of bounds — tuple '{s}' has {} field{s}", .{ ci, self.formatTypeName(obj_ty), tinfo.fields.len, if (tinfo.fields.len == 1) "" else "s", }); } continue; // hasErrors() aborts before codegen } } const idx = self.lowerExpr(ie.index); 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); if (!self.checkAssignable(val_ty, elem_ty, ma.values[i].span, "assign", "element", ma.values[i])) continue; 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| { // `#set` property target: dispatch to the setter with the // already-lowered RHS value (multi-assign evaluated all RHS up // front). Falls through for an ordinary field. if (tryLowerPropertyStore(self, fa, val, target.span)) continue; const obj_ptr = self.lowerExprAsPtr(fa.object); const obj_ty = self.inferExprType(fa.object); // Reject a direct write to a tagged-union variant (issue 0136). if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, target.span)) continue; // 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); if (!self.checkAssignable(val_ty, r.ty, ma.values[i].span, "assign", fa.field, ma.values[i])) continue; 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); if (!self.checkAssignable(val_ty, pointee_ty, ma.values[i].span, "assign", "target", ma.values[i])) continue; 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(); }