From e884e87f80e392d8d7718cd1c894a66f94537828 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 10 Jun 2026 13:58:09 +0300 Subject: [PATCH] refactor(B5.2): move coercions to lower/coerce.zig Verbatim relocation of the 19-method coercion cluster (lowerXX, user conversions, protocol erasure, default-value construction, zero values, coerceToType implicit/explicit ladder, C-variadic promotion, call-arg coercion) plus the nested single-home CoerceMode enum into src/ir/lower/coerce.zig. 19 aliases on Lowering keep all call sites unchanged. Method pub-flip: prependCtxIfNeeded. ParamImplEntry stays a Lowering nested type (field type of param_impl_map) and is reached via an alias const. Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero expected/ snapshot churn. --- src/ir/lower.zig | 746 ++------------------------------------ src/ir/lower/coerce.zig | 772 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 795 insertions(+), 723 deletions(-) create mode 100644 src/ir/lower/coerce.zig diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 38e1c3c..9e0fc9c 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -40,6 +40,7 @@ const lower_control_flow = @import("lower/control_flow.zig"); const lower_decl = @import("lower/decl.zig"); const lower_nominal = @import("lower/nominal.zig"); const lower_protocol = @import("lower/protocol.zig"); +const lower_coerce = @import("lower/coerce.zig"); const TypeId = types.TypeId; const StringId = types.StringId; @@ -4850,7 +4851,7 @@ pub const Lowering = struct { /// themselves with __sx_ctx already prepended (protocol thunks, FFI /// wrappers in Step 4) should NOT call this — they already manage /// slot 0. - fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) []Ref { + pub fn prependCtxIfNeeded(self: *Lowering, callee: *const Function, args: []Ref) []Ref { if (!callee.has_implicit_ctx) return args; const new_args = self.alloc.alloc(Ref, args.len + 1) catch return args; new_args[0] = self.current_ctx_ref; @@ -9948,534 +9949,6 @@ pub const Lowering = struct { return .{ .l = self }; } - /// Lower the `xx` operator (type coercion). - /// Uses self.target_type for context when available. Handles: - /// - Any → concrete type: unbox_any - /// - int → int: widen/narrow - /// - int ↔ float: int_to_float/float_to_int - fn lowerXX(self: *Lowering, operand: Ref, operand_node: *const Node) Ref { - // Use the operand's *actual* lowered Ref type rather than reaching - // back through inferExprType — the latter doesn't cover every - // expression shape (notably lambdas), and a wrong src_ty here can - // route the cast through coerceToType (e.g. a bogus s64→ptr bitcast) - // and silently skip the user-space Into fallback. - const src_ty = self.builder.getRefType(operand); - const target_explicit = self.target_type != null; - const dst_ty = self.target_type orelse .unresolved; - - // PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls - // through to the built-in ladder + the user-`Into` fallback below. - switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) { - // Any → concrete type: unbox. - .unbox_any => { - // When inside a float match arm covering both f32 and f64, - // and target is f64, we need a mini-dispatch to unbox correctly. - // f32 values are stored as zext(bitcast(f32→i32), i64) in Any, - // so bitcasting i64→f64 directly gives wrong results for f32. - if (dst_ty == .f64) { - if (self.current_match_tags) |tags| { - var has_f32 = false; - var has_f64 = false; - for (tags) |t| { - const tid = TypeId.fromIndex(@intCast(t)); - if (tid == .f32) has_f32 = true; - if (tid == .f64) has_f64 = true; - } - if (has_f32 and has_f64) { - return self.lowerAnyToF64Dispatch(operand); - } - if (has_f32 and !has_f64) { - // Only f32 values: unbox as f32, then widen - const f32_val = self.builder.emit(.{ .unbox_any = .{ - .operand = operand, - } }, .f32); - return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64); - } - } - } - return self.builder.emit(.{ .unbox_any = .{ - .operand = operand, - } }, dst_ty); - }, - // Same type: no-op. - .no_op => return operand, - // Concrete → Protocol: build protocol value. - .erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty), - // Protocol → pointer: recover the typed ctx pointer (field 0). - // The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or - // `{ ctx, vtable_ptr }` — either way, ctx lives at field 0. - .protocol_to_pointer => { - const void_ptr_ty = self.module.types.ptrTo(.void); - const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty); - if (dst_ty == void_ptr_ty) return ctx_ref; - return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty); - }, - .coerce => {}, - } - - const result = self.coerceExplicit(operand, src_ty, dst_ty); - - // User-space fallback via `impl Into(Target) for Source`. Only fires - // when the target was explicitly named (not the .s64 default), src and - // dst differ, and the built-in ladder made no progress. Built-ins - // always win. - if (target_explicit and src_ty != dst_ty and result == operand) { - if (self.tryUserConversion(operand, operand_node, src_ty, dst_ty)) |converted| { - return converted; - } - // Pointer-target fallback: `xx ` whose surrounding context - // expects `*T` (a fn arg slot, a var typed as a pointer-to-aggregate) - // can be satisfied by `impl Into(T) for src` plus an implicit - // alloca+store on the result. Lets users write - // `fn(xx () => { ... })` instead of materialising a named Block local - // just to take its address. - if (!dst_ty.isBuiltin()) { - const dst_info = self.module.types.get(dst_ty); - if (dst_info == .pointer) { - const pointee = dst_info.pointer.pointee; - if (pointee != src_ty) { - if (self.tryUserConversion(operand, operand_node, src_ty, pointee)) |converted| { - const slot = self.builder.alloca(pointee); - self.builder.store(slot, converted); - return slot; - } - } - } - } - } - return result; - } - - /// Detect the `xx closure : Block` cast pattern so `tryUserConversion` - /// can emit a focused diagnostic when no `Into(Block) for Closure(...)` - /// impl is reachable. Replaces what was briefly a compiler-synthesised - /// trampoline path with a "declare an impl" requirement — the stdlib - /// covers common signatures (see modules/std/objc_block.sx), users - /// add their own for unusual ones. - fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool { - if (src_ty.isBuiltin()) return false; - const src_info = self.module.types.get(src_ty); - if (src_info != .closure) return false; - if (dst_ty.isBuiltin()) return false; - const dst_info = self.module.types.get(dst_ty); - if (dst_info != .@"struct") return false; - const block_name = self.module.types.internString("Block"); - return dst_info.@"struct".name == block_name; - } - - /// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]` - /// and returns a call ref when a single pack impl matches `src_ty`'s - /// shape (concrete src closure / fn with the same fixed prefix as - /// the impl's source pack closure). Binds the pack-var to the source's - /// tail param types and the return-var (when generic) to the source's - /// return type, then monomorphises the convert method. - /// Returns null if no pack impls registered for this (proto, dst) or - /// none of them match `src_ty`'s shape. - fn tryPackImplMatch( - self: *Lowering, - operand: Ref, - operand_node: *const Node, - src_ty: TypeId, - dst_ty: TypeId, - proto_name: []const u8, - pack_key: []const u8, - guard_key: u64, - ) ?Ref { - _ = operand_node; - // PLANNING: select the matching pack impl + its `convert` (registry). - const match = self.protocolResolver().matchPackImpl(src_ty, pack_key) orelse return null; - const entry = match.entry; - const fd = match.convert_fd; - const src_params = match.src_params; - const src_ret = match.src_ret; - const table = &self.module.types; - // EMISSION: bind the pack tail + ret-var, monomorphise, call (Lowering). - - // Build bindings. Target → dst_ty (already in the protocol's type - // params), pack-var → src tail TypeIds, ret-var (when generic) → - // src ret. - const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?; - const tail = src_params[ent_pack_start..]; - const tail_owned = self.alloc.dupe(TypeId, tail) catch return null; - - var bindings = std.StringHashMap(TypeId).init(self.alloc); - defer bindings.deinit(); - const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null; - bindings.put(pd.type_params[0].name, dst_ty) catch return null; - if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null; - - var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc); - defer pack_bindings.deinit(); - pack_bindings.put(entry.pack_var_name, tail_owned) catch return null; - - // Mangled name keyed on the CONCRETE source so distinct shapes - // monomorphise separately. Same scheme as the concrete path: - // ".convert__". - const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{ - self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), - }) catch return null; - - self.xx_reentrancy.put(guard_key, {}) catch {}; - defer _ = self.xx_reentrancy.remove(guard_key); - - if (!self.lowered_functions.contains(mangled)) { - const saved_pack = self.pack_bindings; - self.pack_bindings = pack_bindings; - defer self.pack_bindings = saved_pack; - self.monomorphizeFunction(fd, mangled, &bindings); - } - - const fid = self.resolveFuncByName(mangled) orelse return null; - const func = &self.module.functions.items[@intFromEnum(fid)]; - const ret_ty = func.ret; - const params = func.params; - var single = [_]Ref{operand}; - const final_args = self.prependCtxIfNeeded(func, single[0..]); - self.coerceCallArgs(final_args, params); - return self.builder.call(fid, final_args, ret_ty); - } - - /// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise - /// the impl's `convert` method and emit a direct call. Returns null when - /// no impl matches (caller falls back to the built-in result, which is - /// the unchanged operand — Phase 3 emits no diagnostic for v0). - fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref { - // Reentrancy guard — pack (src, dst) into a u64. - const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index()); - if (self.xx_reentrancy.contains(guard_key)) { - if (self.diagnostics) |diags| { - diags.addFmt(.err, operand_node.span, "recursive xx conversion from '{s}' to '{s}'", .{ - self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), - }); - } - return operand; - } - - // Build lookup key: "Into\x00\x00". - // Hardcoded to the "Into" protocol for v1. Generalising to other - // parameterised protocols would walk protocol_decl_map looking for - // protocols that take a single type-param and have a `convert` method. - const proto_name = "Into"; - const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null; - if (pd.type_params.len != 1) return null; - - var key_buf = std.ArrayList(u8).empty; - key_buf.appendSlice(self.alloc, proto_name) catch return null; - key_buf.append(self.alloc, 0) catch return null; - key_buf.appendSlice(self.alloc, self.mangleTypeName(dst_ty)) catch return null; - key_buf.append(self.alloc, 0) catch return null; - key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null; - const key = key_buf.items; - - // Pack-only key (proto + dst) — used if the concrete lookup misses. - // Same prefix as the concrete key, minus the `\x00` tail. - const dst_mangled_len = self.mangleTypeName(dst_ty).len; - const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len]; - - const entries_opt = self.param_impl_map.get(key); - const has_concrete = entries_opt != null and entries_opt.?.items.len > 0; - if (!has_concrete) { - // Concrete miss — try the pack map before emitting a diagnostic. - if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| { - return result; - } - if (self.isClosureToBlockCast(src_ty, dst_ty)) { - if (self.diagnostics) |diags| { - const saved = diags.current_source_file; - diags.current_source_file = operand_node.source_file orelse self.current_source_file; - defer diags.current_source_file = saved; - diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)}); - } - return operand; - } - return null; - } - const entries = entries_opt.?; - - // Filter by import visibility: only impls in modules that the current - // file transitively imports (or the current file itself) are reachable. - // Falls open when import_graph isn't wired (e.g. comptime callers). - var visible_impls = std.ArrayList(ParamImplEntry).empty; - defer visible_impls.deinit(self.alloc); - self.protocolResolver().findVisibleImpls(entries.items, &visible_impls); - - if (visible_impls.items.len == 0) { - if (self.diagnostics) |diags| { - const saved = diags.current_source_file; - diags.current_source_file = operand_node.source_file orelse self.current_source_file; - defer diags.current_source_file = saved; - diags.addFmt(.err, operand_node.span, "no visible xx conversion from '{s}' to '{s}' — impl exists in another module but is not imported", .{ - self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), - }); - } - return operand; - } - if (visible_impls.items.len > 1) { - if (self.diagnostics) |diags| { - const saved = diags.current_source_file; - diags.current_source_file = operand_node.source_file orelse self.current_source_file; - defer diags.current_source_file = saved; - diags.addFmt(.err, operand_node.span, "duplicate xx conversion from '{s}' to '{s}': impls in {s} and {s}", .{ - self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), - visible_impls.items[0].defining_module, visible_impls.items[1].defining_module, - }); - } - return operand; - } - const entry = visible_impls.items[0]; - - // Find the `convert` method on this impl. - var convert_fd: ?*const ast.FnDecl = null; - for (entry.methods) |m| { - if (std.mem.eql(u8, m.name, "convert")) { - convert_fd = m; - break; - } - } - const fd = convert_fd orelse return null; - - // Bind Target → dst_ty. - var bindings = std.StringHashMap(TypeId).init(self.alloc); - defer bindings.deinit(); - bindings.put(pd.type_params[0].name, dst_ty) catch return null; - - // Mangled name: ".convert__". - const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{ - self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), - }) catch return null; - - self.xx_reentrancy.put(guard_key, {}) catch {}; - defer _ = self.xx_reentrancy.remove(guard_key); - - if (!self.lowered_functions.contains(mangled)) { - self.monomorphizeFunction(fd, mangled, &bindings); - } - - const fid = self.resolveFuncByName(mangled) orelse return null; - const func = &self.module.functions.items[@intFromEnum(fid)]; - const ret_ty = func.ret; - const params = func.params; - var single = [_]Ref{operand}; - const final_args = self.prependCtxIfNeeded(func, single[0..]); - self.coerceCallArgs(final_args, params); - return self.builder.call(fid, final_args, ret_ty); - } - - /// True for expression shapes that name an addressable storage location - /// (variables, fields, array elements, dereferenced pointers). Used by - /// `xx ` to decide between borrow (lvalue → take the - /// address) and heap-copy (rvalue → allocate a fresh copy). - fn isLvalueExpr(self: *Lowering, node: *const Node) bool { - _ = self; - return switch (node.data) { - .identifier, .field_access, .index_expr, .deref_expr => true, - else => false, - }; - } - - /// Build a protocol value from a concrete value via xx conversion. - /// Coerce `val` (type `src`) to `dst`: if `dst` is a protocol, `xx`-erase - /// the concrete value into it; otherwise fall back to numeric/struct - /// coercion. Used to materialize a pack into a protocol-typed tuple field. - fn coerceOrErase(self: *Lowering, val: Ref, src: TypeId, dst: TypeId, node: *const Node) Ref { - if (src == dst) return val; - if (!dst.isBuiltin()) { - const di = self.module.types.get(dst); - if (di == .@"struct" and di.@"struct".is_protocol) { - return self.buildProtocolErasure(val, node, src, dst); - } - } - return self.coerceToType(val, src, dst); - } - - pub fn buildProtocolErasure(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) Ref { - const dst_info = self.module.types.get(dst_ty); - if (dst_info != .@"struct") return operand; - const proto_name = self.module.types.getString(dst_info.@"struct".name); - - // Determine concrete type name and type — resolve through pointer if needed - var concrete_ptr = operand; - var concrete_type_name: ?[]const u8 = null; - var concrete_ty: TypeId = src_ty; - var heap_copy = false; - - if (!src_ty.isBuiltin()) { - const src_info = self.module.types.get(src_ty); - if (src_info == .pointer) { - // xx @acc — operand is already a pointer (user manages lifetime) - const pointee = src_info.pointer.pointee; - concrete_type_name = self.resolveConcreteTypeName(pointee); - concrete_ty = pointee; - heap_copy = false; - } else if (src_info == .@"struct") { - // Struct-typed operand. Split on lvalue-ness: - // - lvalue (identifier, field, index, deref): borrow the - // storage the operand already names. No heap copy; the - // protocol value's ctx points at the caller's slot, and - // mutations through the protocol are visible to the - // original. Lifetime is the caller's responsibility. - // - rvalue (struct literal, call result, etc.): heap-copy - // into a fresh allocation so the protocol value is - // self-contained and outlives this expression. - concrete_type_name = self.module.types.getString(src_info.@"struct".name); - concrete_ty = src_ty; - if (self.isLvalueExpr(operand_node)) { - concrete_ptr = self.lowerExprAsPtr(operand_node); - heap_copy = false; - } else { - heap_copy = true; - const slot = self.builder.alloca(src_ty); - self.builder.store(slot, operand); - concrete_ptr = slot; - } - } - } - - // Also try from the operand node for struct literals: xx Accumulator.{ total = 0 } - if (concrete_type_name == null) { - concrete_type_name = self.inferConcreteTypeName(operand_node); - if (concrete_type_name != null) heap_copy = true; - } - - if (concrete_type_name) |ctn| { - return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy); - } - return operand; - } - - /// Try to infer the concrete type name from an AST node (for struct literals etc.) - fn inferConcreteTypeName(self: *Lowering, node: *const Node) ?[]const u8 { - return switch (node.data) { - .struct_literal => |sl| if (sl.struct_name) |n| n else null, - .unary_op => |uop| if (uop.op == .address_of) self.inferConcreteTypeName(uop.operand) else null, - .identifier => |id| blk: { - // Check if identifier's type resolves to a struct - if (self.scope) |scope| { - if (scope.lookup(id.name)) |binding| { - if (!binding.ty.isBuiltin()) { - const bi = self.module.types.get(binding.ty); - if (bi == .@"struct") break :blk self.module.types.getString(bi.@"struct".name); - if (bi == .pointer) { - const pointee = bi.pointer.pointee; - if (!pointee.isBuiltin()) { - const pi = self.module.types.get(pointee); - if (pi == .@"struct") break :blk self.module.types.getString(pi.@"struct".name); - } - } - } - } - } - break :blk null; - }, - else => null, - }; - } - - /// Generate a mini-dispatch for unboxing Any to f64 when the value might be f32 or f64. - /// Uses alloca-based merge: create result slot, branch, store in each arm, load after merge. - fn lowerAnyToF64Dispatch(self: *Lowering, any_val: Ref) Ref { - // Create result alloca BEFORE the branch - const result_slot = self.builder.alloca(.f64); - - // Extract type tag from Any - const tag = self.builder.structGet(any_val, 0, .s64); - - const f32_bb = self.freshBlock("f32.unbox"); - const f64_bb = self.freshBlock("f64.unbox"); - const merge_bb = self.freshBlock("float.merge"); - - // Branch: tag == f32_tag ? f32_bb : f64_bb - const f32_tag = self.builder.constInt(TypeId.f32.index(), .s64); - const cond = self.builder.emit(.{ .cmp_eq = .{ .lhs = tag, .rhs = f32_tag } }, .bool); - self.builder.condBr(cond, f32_bb, &.{}, f64_bb, &.{}); - - // f32 block: unbox as f32, fpext to f64, store - self.builder.switchToBlock(f32_bb); - const f32_val = self.builder.emit(.{ .unbox_any = .{ - .operand = any_val, - } }, .f32); - const f64_from_f32 = self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64); - self.builder.store(result_slot, f64_from_f32); - self.builder.br(merge_bb, &.{}); - - // f64 block: unbox as f64 directly, store - self.builder.switchToBlock(f64_bb); - const f64_val = self.builder.emit(.{ .unbox_any = .{ - .operand = any_val, - } }, .f64); - self.builder.store(result_slot, f64_val); - self.builder.br(merge_bb, &.{}); - - // Merge block: load result - self.builder.switchToBlock(merge_bb); - return self.builder.load(result_slot, .f64); - } - - /// Produce a default value for a type, applying struct field defaults. - /// For structs with defaults (e.g., `b: s32 = 99`), creates a struct_literal with defaults applied. - /// For other types, returns a zero value. - pub fn buildDefaultValue(self: *Lowering, ty: TypeId) Ref { - if (ty.isBuiltin()) return self.builder.constInt(0, ty); - const info = self.module.types.get(ty); - if (info != .@"struct" and info != .tuple) return self.zeroValue(ty); - // For tuples, build a zero-initialized tuple - if (info == .tuple) { - var field_vals = std.ArrayList(Ref).empty; - defer field_vals.deinit(self.alloc); - for (info.tuple.fields) |f| { - field_vals.append(self.alloc, self.zeroValue(f)) catch unreachable; - } - return self.builder.emit(.{ - .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable }, - }, ty); - } - // Check for struct defaults - const struct_name_str = self.module.types.getString(info.@"struct".name); - const field_defaults = self.struct_defaults_map.get(struct_name_str) orelse - return self.builder.constUndef(ty); - const fields = info.@"struct".fields; - var field_vals = std.ArrayList(Ref).empty; - defer field_vals.deinit(self.alloc); - for (fields, 0..) |f, i| { - if (i < field_defaults.len) { - if (field_defaults[i]) |default_expr| { - field_vals.append(self.alloc, self.lowerCoercedDefault(default_expr, f.ty)) catch unreachable; - } else { - field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable; - } - } else { - field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable; - } - } - return self.builder.emit(.{ - .struct_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable }, - }, ty); - } - - /// Wrap ty in ?ty, but flatten: if ty is already ?U, return ?U (not ??U) - pub fn optionalOfFlattened(self: *Lowering, ty: TypeId) TypeId { - if (!ty.isBuiltin()) { - const info = self.module.types.get(ty); - if (info == .optional) return ty; - } - return self.module.types.optionalOf(ty); - } - - /// Produce a zero/default value for any type — constInt(0) for integers, - /// constNull for pointers, constUndef for structs/complex types. - pub fn zeroValue(self: *Lowering, ty: TypeId) Ref { - if (ty.isBuiltin()) return self.builder.constInt(0, ty); - const info = self.module.types.get(ty); - return switch (info) { - // Arbitrary-width integer types (u1, u2, s4, ...) interned as - // `.signed`/`.unsigned` variants — fall through `isBuiltin()`. - .signed, .unsigned => self.builder.constInt(0, ty), - .pointer, .tuple, .optional => self.builder.constNull(ty), - .@"struct", .array, .slice, .many_pointer => self.builder.constNull(ty), - else => self.builder.constUndef(ty), - }; - } - /// Check if a name refers to a known type (primitive or registered struct/enum/union). /// Used to distinguish type-as-value (silent placeholder) from genuinely unresolved names. pub fn isKnownTypeName(self: *Lowering, name: []const u8) bool { @@ -10551,155 +10024,6 @@ pub const Lowering = struct { return self.emitPlaceholder(field); } - /// Emit the unified non-integral float→int narrowing diagnostic (F0.11 / - /// issue 0095). ONE wording, ONE place: every site that rejects an implicit - /// narrowing of a non-integral compile-time float to an integer type calls - /// this, so the message + fix-it stay identical across the typed-binding - /// coerce arm, the field/param-default sites, the typed-const path, and the - /// global-initializer path. - pub fn diagNonIntegralNarrow(self: *Lowering, span: ast.Span, value: f64, dst_ty: TypeId) void { - if (self.diagnostics) |d| - d.addFmt(.err, span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ value, self.formatTypeName(dst_ty) }); - } - - /// Lower a struct field default `default_expr`, coerced to the field type - /// `field_ty`. A compile-time float default narrowing into an integer field - /// follows the unified rule via `foldComptimeFloatInit`; everything else - /// lowers under the field type as target and coerces at the IR level. - fn lowerCoercedDefault(self: *Lowering, default_expr: *const Node, field_ty: TypeId) Ref { - if (self.foldComptimeFloatInit(default_expr, field_ty)) |folded| return folded; - const saved_tt = self.target_type; - self.target_type = field_ty; - const raw = self.lowerExpr(default_expr); - self.target_type = saved_tt; - return self.coerceToType(raw, self.builder.getRefType(raw), field_ty); - } - - /// How a float→int conversion is treated. An IMPLICIT coercion (a typed - /// binding initializer) folds an integral compile-time float to its int and - /// REJECTS a non-integral one; an EXPLICIT `xx` / `cast` always truncates. - const CoerceMode = enum { implicit, explicit }; - - /// Insert a conversion if src_ty and dst_ty differ. - /// Handles int widening/narrowing, float widening/narrowing, and int↔float. - /// IMPLICIT coercion — the typed-binding initializer path. A compile-time - /// float narrowing to an integer folds when integral, errors when not. - pub fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { - return self.coerceMode(val, src_ty, dst_ty, .implicit); - } - - /// EXPLICIT coercion — the `xx` / `cast(T)` escape hatch. A float→int here - /// always truncates, bypassing the integral-fold / non-integral-error rule. - fn coerceExplicit(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { - return self.coerceMode(val, src_ty, dst_ty, .explicit); - } - - fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mode: CoerceMode) Ref { - // PLANNING: classify the built-in coercion (conversions.zig). - // EMISSION: each arm below reproduces the original lowering. - switch (self.coercionResolver().classify(src_ty, dst_ty)) { - .no_op, .none => return val, - // Unbox Any → concrete type - .unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty), - // Box concrete → Any - .box_any => return self.builder.boxAny(val, src_ty), - // Closure VALUE → bare function-pointer slot: not soundly representable. - // A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env - // arg, but a closure's underlying fn takes an env slot — so passing a - // closure value's fn_ptr drops the env and shifts the args (UB for a - // matching ABI, a wrong-tuple read for ∅-widening, a segfault when the - // closure captures). Only a closure LITERAL can cross this boundary, - // via the static adapter `lowerLambda` emits (so a literal arrives here - // already typed `.function`). Reject the variable case loudly. - .closure_to_fn_reject => { - if (self.diagnostics) |d| { - const cs = self.builder.current_span; - d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{}); - } - return val; - }, - // Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal - // flowing into a `(s32, s32)` slot — the multi-value failable success - // tuple). Same arity: extract each slot, coerce it, rebuild. - .tuple_elementwise => { - const si = self.module.types.get(src_ty); - const di = self.module.types.get(dst_ty); - var elems = std.ArrayList(Ref).empty; - defer elems.deinit(self.alloc); - for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| { - const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf); - elems.append(self.alloc, self.coerceMode(fv, sf, df, mode)) catch unreachable; - } - return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty); - }, - // Optional → Concrete unwrapping (flow-sensitive narrowing coercion) - .optional_unwrap => { - const child_ty = self.module.types.get(src_ty).optional.child; - const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty); - return self.coerceMode(unwrapped, child_ty, dst_ty, mode); - }, - // void → Optional: produce null (void is the type of null_literal) - .void_to_optional => return self.builder.constNull(dst_ty), - // Concrete → Optional wrapping (coerce to the inner type first) - .optional_wrap => { - const child_ty = self.module.types.get(dst_ty).optional.child; - const coerced = self.coerceMode(val, src_ty, child_ty, mode); - return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty); - }, - // Concrete → Protocol (auto type erasure) - .erase_protocol => { - const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name); - const ctn = self.resolveConcreteTypeName(src_ty).?; - // If src is a pointer, use directly; otherwise alloca+store + heap-copy - var concrete_ptr = val; - var concrete_ty = src_ty; - var heap_copy = false; - if (!src_ty.isBuiltin()) { - const si = self.module.types.get(src_ty); - if (si == .pointer) { - concrete_ty = si.pointer.pointee; - heap_copy = false; - } else { - const slot = self.builder.alloca(src_ty); - self.builder.store(slot, val); - concrete_ptr = slot; - heap_copy = true; - } - } - return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy); - }, - .int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), - .float_to_int => { - // Implicit float→int narrowing follows the unified rule (the - // same `floatToIntExact` the array-dim / `$K: Count` paths use): - // a compile-time INTEGRAL float folds to its int, a NON-integral - // one is a compile error. Explicit `xx` / `cast` (mode - // `.explicit`) skips this and truncates. A runtime float has no - // compile-time value to fold — it truncates as before. - if (mode == .implicit) { - if (self.builder.constFloatInfo(val)) |info| { - if (program_index_mod.floatToIntExact(info.value)) |iv| { - return self.builder.constInt(iv, dst_ty); - } - // Non-integral: diagnose, then fall through to the - // truncating op below so lowering finishes and - // `hasErrors()` aborts the build. - self.diagNonIntegralNarrow(.{ .start = info.span.start, .end = info.span.end }, info.value, dst_ty); - } - } - return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty); - }, - // Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot. - // Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches - // to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level - // since LLVMBuildBitCast itself doesn't accept ptr↔int. - .ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), - .narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), - .widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), - .array_to_slice => return self.builder.emit(.{ .array_to_slice = .{ .operand = val } }, dst_ty), - } - } - /// Get the alloca Ref for an expression, if it's a simple variable reference. /// Returns null for complex expressions (field access, function calls, etc.) pub fn getExprAlloca(self: *Lowering, node: *const Node) ?Ref { @@ -10891,51 +10215,6 @@ pub const Lowering = struct { return 0; } - /// Apply C default argument promotion to variadic-tail args. These rules - /// (bool/s8/s16/u8/u16 → s32, f32 → f64) match the C calling convention's - /// implicit promotions when an argument is passed through `...`. - fn promoteCVariadicArgs(self: *Lowering, args: []Ref, fixed_count: usize) void { - if (args.len <= fixed_count) return; - for (args[fixed_count..]) |*arg| { - const src_ty = self.builder.getRefType(arg.*); - const promoted: TypeId = switch (src_ty) { - .bool, .s8, .s16, .u8, .u16 => .s32, - .f32 => .f64, - else => continue, - }; - arg.* = self.coerceToType(arg.*, src_ty, promoted); - } - } - - /// Coerce call arguments in-place to match function parameter types. - fn coerceCallArgs(self: *Lowering, args: []Ref, params: []const Function.Param) void { - for (0..@min(args.len, params.len)) |i| { - const src_ty = self.builder.getRefType(args[i]); - const dst_ty = params[i].ty; - if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) { - const src_info = self.module.types.get(src_ty); - const dst_info = self.module.types.get(dst_ty); - // Array → many_pointer decay: alloca the array, GEP to first element - if (src_info == .array and dst_info == .many_pointer) { - const slot = self.builder.alloca(src_ty); - self.builder.store(slot, args[i]); - const zero = self.builder.constInt(0, .s64); - args[i] = self.builder.emit(.{ .index_gep = .{ .lhs = slot, .rhs = zero } }, dst_ty); - continue; - } - // Implicit address-of: passing T value where *T is expected → alloca + store - // Only when the pointee type matches the source type. - if (dst_info == .pointer and src_info != .pointer and dst_info.pointer.pointee == src_ty) { - const slot = self.builder.alloca(src_ty); - self.builder.store(slot, args[i]); - args[i] = slot; - continue; - } - } - args[i] = self.coerceToType(args[i], src_ty, dst_ty); - } - } - /// Emit a C-ABI exported function for every bodied method on a /// `#jni_main #jni_class("...")` declaration. The symbol name follows /// JNI's name-mangling convention so Android's JNI runtime can resolve @@ -12579,6 +11858,27 @@ pub const Lowering = struct { pub const emitProtocolDispatch = lower_protocol.emitProtocolDispatch; pub const resolveConcreteTypeName = lower_protocol.resolveConcreteTypeName; pub const computeHasImpl = lower_protocol.computeHasImpl; + + // --- moved to lower/coerce.zig (lower_coerce) --- + pub const lowerXX = lower_coerce.lowerXX; + pub const isClosureToBlockCast = lower_coerce.isClosureToBlockCast; + pub const tryPackImplMatch = lower_coerce.tryPackImplMatch; + pub const tryUserConversion = lower_coerce.tryUserConversion; + pub const isLvalueExpr = lower_coerce.isLvalueExpr; + pub const coerceOrErase = lower_coerce.coerceOrErase; + pub const buildProtocolErasure = lower_coerce.buildProtocolErasure; + pub const inferConcreteTypeName = lower_coerce.inferConcreteTypeName; + pub const lowerAnyToF64Dispatch = lower_coerce.lowerAnyToF64Dispatch; + pub const buildDefaultValue = lower_coerce.buildDefaultValue; + pub const optionalOfFlattened = lower_coerce.optionalOfFlattened; + pub const zeroValue = lower_coerce.zeroValue; + pub const lowerCoercedDefault = lower_coerce.lowerCoercedDefault; + pub const coerceToType = lower_coerce.coerceToType; + pub const coerceExplicit = lower_coerce.coerceExplicit; + pub const coerceMode = lower_coerce.coerceMode; + pub const diagNonIntegralNarrow = lower_coerce.diagNonIntegralNarrow; + pub const promoteCVariadicArgs = lower_coerce.promoteCVariadicArgs; + pub const coerceCallArgs = lower_coerce.coerceCallArgs; }; /// JNI param/return type resolution: user-declared types pass through diff --git a/src/ir/lower/coerce.zig b/src/ir/lower/coerce.zig new file mode 100644 index 0000000..84fc822 --- /dev/null +++ b/src/ir/lower/coerce.zig @@ -0,0 +1,772 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +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 type_bridge = @import("../type_bridge.zig"); +const unescape = @import("../../unescape.zig"); +const parser_mod = @import("../../parser.zig"); +const interp_mod = @import("../interp.zig"); +const errors = @import("../../errors.zig"); +const jni_descriptor = @import("../jni_descriptor.zig"); +const program_index_mod = @import("../program_index.zig"); +const resolver_mod = @import("../resolver.zig"); +const imports_mod = @import("../../imports.zig"); +const ProgramIndex = program_index_mod.ProgramIndex; +const GlobalInfo = program_index_mod.GlobalInfo; +const StructTemplate = program_index_mod.StructTemplate; +const TemplateParam = program_index_mod.TemplateParam; +const ProtocolDeclInfo = program_index_mod.ProtocolDeclInfo; +const ProtocolMethodInfo = program_index_mod.ProtocolMethodInfo; +const ModuleConstInfo = program_index_mod.ModuleConstInfo; +const TypeResolver = @import("../type_resolver.zig").TypeResolver; +const ResolveEnv = @import("../type_resolver.zig").ResolveEnv; +const PackResolver = @import("../packs.zig").PackResolver; +const ExprTyper = @import("../expr_typer.zig").ExprTyper; +const CallResolver = @import("../calls.zig").CallResolver; +const GenericResolver = @import("../generics.zig").GenericResolver; +const ProtocolResolver = @import("../protocols.zig").ProtocolResolver; +const CoercionResolver = @import("../conversions.zig").CoercionResolver; +const ErrorAnalysis = @import("../error_analysis.zig").ErrorAnalysis; +const ErrorFlow = @import("../error_flow.zig").ErrorFlow; +const ObjcLowering = @import("../ffi_objc.zig").ObjcLowering; +const semantic_diagnostics = @import("../semantic_diagnostics.zig"); + +const TypeId = types.TypeId; +const StringId = types.StringId; +const Ref = inst_mod.Ref; +const BlockId = inst_mod.BlockId; +const FuncId = inst_mod.FuncId; +const Function = inst_mod.Function; +const Module = mod_mod.Module; +const Builder = mod_mod.Builder; + + +const lower = @import("../lower.zig"); +const Lowering = lower.Lowering; +const Scope = lower.Scope; +const ParamImplEntry = Lowering.ParamImplEntry; + +/// Lower the `xx` operator (type coercion). +/// Uses self.target_type for context when available. Handles: +/// - Any → concrete type: unbox_any +/// - int → int: widen/narrow +/// - int ↔ float: int_to_float/float_to_int +pub fn lowerXX(self: *Lowering, operand: Ref, operand_node: *const Node) Ref { + // Use the operand's *actual* lowered Ref type rather than reaching + // back through inferExprType — the latter doesn't cover every + // expression shape (notably lambdas), and a wrong src_ty here can + // route the cast through coerceToType (e.g. a bogus s64→ptr bitcast) + // and silently skip the user-space Into fallback. + const src_ty = self.builder.getRefType(operand); + const target_explicit = self.target_type != null; + const dst_ty = self.target_type orelse .unresolved; + + // PLANNING: the `xx`-head decision (conversions.zig). `.coerce` falls + // through to the built-in ladder + the user-`Into` fallback below. + switch (self.coercionResolver().classifyXX(src_ty, dst_ty)) { + // Any → concrete type: unbox. + .unbox_any => { + // When inside a float match arm covering both f32 and f64, + // and target is f64, we need a mini-dispatch to unbox correctly. + // f32 values are stored as zext(bitcast(f32→i32), i64) in Any, + // so bitcasting i64→f64 directly gives wrong results for f32. + if (dst_ty == .f64) { + if (self.current_match_tags) |tags| { + var has_f32 = false; + var has_f64 = false; + for (tags) |t| { + const tid = TypeId.fromIndex(@intCast(t)); + if (tid == .f32) has_f32 = true; + if (tid == .f64) has_f64 = true; + } + if (has_f32 and has_f64) { + return self.lowerAnyToF64Dispatch(operand); + } + if (has_f32 and !has_f64) { + // Only f32 values: unbox as f32, then widen + const f32_val = self.builder.emit(.{ .unbox_any = .{ + .operand = operand, + } }, .f32); + return self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64); + } + } + } + return self.builder.emit(.{ .unbox_any = .{ + .operand = operand, + } }, dst_ty); + }, + // Same type: no-op. + .no_op => return operand, + // Concrete → Protocol: build protocol value. + .erase_protocol => return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty), + // Protocol → pointer: recover the typed ctx pointer (field 0). + // The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or + // `{ ctx, vtable_ptr }` — either way, ctx lives at field 0. + .protocol_to_pointer => { + const void_ptr_ty = self.module.types.ptrTo(.void); + const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty); + if (dst_ty == void_ptr_ty) return ctx_ref; + return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty); + }, + .coerce => {}, + } + + const result = self.coerceExplicit(operand, src_ty, dst_ty); + + // User-space fallback via `impl Into(Target) for Source`. Only fires + // when the target was explicitly named (not the .s64 default), src and + // dst differ, and the built-in ladder made no progress. Built-ins + // always win. + if (target_explicit and src_ty != dst_ty and result == operand) { + if (self.tryUserConversion(operand, operand_node, src_ty, dst_ty)) |converted| { + return converted; + } + // Pointer-target fallback: `xx ` whose surrounding context + // expects `*T` (a fn arg slot, a var typed as a pointer-to-aggregate) + // can be satisfied by `impl Into(T) for src` plus an implicit + // alloca+store on the result. Lets users write + // `fn(xx () => { ... })` instead of materialising a named Block local + // just to take its address. + if (!dst_ty.isBuiltin()) { + const dst_info = self.module.types.get(dst_ty); + if (dst_info == .pointer) { + const pointee = dst_info.pointer.pointee; + if (pointee != src_ty) { + if (self.tryUserConversion(operand, operand_node, src_ty, pointee)) |converted| { + const slot = self.builder.alloca(pointee); + self.builder.store(slot, converted); + return slot; + } + } + } + } + } + return result; +} + +/// Detect the `xx closure : Block` cast pattern so `tryUserConversion` +/// can emit a focused diagnostic when no `Into(Block) for Closure(...)` +/// impl is reachable. Replaces what was briefly a compiler-synthesised +/// trampoline path with a "declare an impl" requirement — the stdlib +/// covers common signatures (see modules/std/objc_block.sx), users +/// add their own for unusual ones. +pub fn isClosureToBlockCast(self: *Lowering, src_ty: TypeId, dst_ty: TypeId) bool { + if (src_ty.isBuiltin()) return false; + const src_info = self.module.types.get(src_ty); + if (src_info != .closure) return false; + if (dst_ty.isBuiltin()) return false; + const dst_info = self.module.types.get(dst_ty); + if (dst_info != .@"struct") return false; + const block_name = self.module.types.internString("Block"); + return dst_info.@"struct".name == block_name; +} + +/// Pack-variadic impl matching. Walks `param_impl_pack_map[pack_key]` +/// and returns a call ref when a single pack impl matches `src_ty`'s +/// shape (concrete src closure / fn with the same fixed prefix as +/// the impl's source pack closure). Binds the pack-var to the source's +/// tail param types and the return-var (when generic) to the source's +/// return type, then monomorphises the convert method. +/// Returns null if no pack impls registered for this (proto, dst) or +/// none of them match `src_ty`'s shape. +pub fn tryPackImplMatch( + self: *Lowering, + operand: Ref, + operand_node: *const Node, + src_ty: TypeId, + dst_ty: TypeId, + proto_name: []const u8, + pack_key: []const u8, + guard_key: u64, +) ?Ref { + _ = operand_node; + // PLANNING: select the matching pack impl + its `convert` (registry). + const match = self.protocolResolver().matchPackImpl(src_ty, pack_key) orelse return null; + const entry = match.entry; + const fd = match.convert_fd; + const src_params = match.src_params; + const src_ret = match.src_ret; + const table = &self.module.types; + // EMISSION: bind the pack tail + ret-var, monomorphise, call (Lowering). + + // Build bindings. Target → dst_ty (already in the protocol's type + // params), pack-var → src tail TypeIds, ret-var (when generic) → + // src ret. + const ent_pack_start = table.get(entry.source_pack_ty).closure.pack_start.?; + const tail = src_params[ent_pack_start..]; + const tail_owned = self.alloc.dupe(TypeId, tail) catch return null; + + var bindings = std.StringHashMap(TypeId).init(self.alloc); + defer bindings.deinit(); + const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null; + bindings.put(pd.type_params[0].name, dst_ty) catch return null; + if (entry.ret_var_name) |rv| bindings.put(rv, src_ret) catch return null; + + var pack_bindings = std.StringHashMap([]const TypeId).init(self.alloc); + defer pack_bindings.deinit(); + pack_bindings.put(entry.pack_var_name, tail_owned) catch return null; + + // Mangled name keyed on the CONCRETE source so distinct shapes + // monomorphise separately. Same scheme as the concrete path: + // ".convert__". + const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{ + self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), + }) catch return null; + + self.xx_reentrancy.put(guard_key, {}) catch {}; + defer _ = self.xx_reentrancy.remove(guard_key); + + if (!self.lowered_functions.contains(mangled)) { + const saved_pack = self.pack_bindings; + self.pack_bindings = pack_bindings; + defer self.pack_bindings = saved_pack; + self.monomorphizeFunction(fd, mangled, &bindings); + } + + const fid = self.resolveFuncByName(mangled) orelse return null; + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + var single = [_]Ref{operand}; + const final_args = self.prependCtxIfNeeded(func, single[0..]); + self.coerceCallArgs(final_args, params); + return self.builder.call(fid, final_args, ret_ty); +} + +/// Look up `Into(dst_ty)` impl for `src_ty` and, if found, monomorphise +/// the impl's `convert` method and emit a direct call. Returns null when +/// no impl matches (caller falls back to the built-in result, which is +/// the unchanged operand — Phase 3 emits no diagnostic for v0). +pub fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) ?Ref { + // Reentrancy guard — pack (src, dst) into a u64. + const guard_key: u64 = (@as(u64, src_ty.index()) << 32) | @as(u64, dst_ty.index()); + if (self.xx_reentrancy.contains(guard_key)) { + if (self.diagnostics) |diags| { + diags.addFmt(.err, operand_node.span, "recursive xx conversion from '{s}' to '{s}'", .{ + self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), + }); + } + return operand; + } + + // Build lookup key: "Into\x00\x00". + // Hardcoded to the "Into" protocol for v1. Generalising to other + // parameterised protocols would walk protocol_decl_map looking for + // protocols that take a single type-param and have a `convert` method. + const proto_name = "Into"; + const pd = self.program_index.protocol_ast_map.get(proto_name) orelse return null; + if (pd.type_params.len != 1) return null; + + var key_buf = std.ArrayList(u8).empty; + key_buf.appendSlice(self.alloc, proto_name) catch return null; + key_buf.append(self.alloc, 0) catch return null; + key_buf.appendSlice(self.alloc, self.mangleTypeName(dst_ty)) catch return null; + key_buf.append(self.alloc, 0) catch return null; + key_buf.appendSlice(self.alloc, self.mangleTypeName(src_ty)) catch return null; + const key = key_buf.items; + + // Pack-only key (proto + dst) — used if the concrete lookup misses. + // Same prefix as the concrete key, minus the `\x00` tail. + const dst_mangled_len = self.mangleTypeName(dst_ty).len; + const pack_key = key_buf.items[0 .. proto_name.len + 1 + dst_mangled_len]; + + const entries_opt = self.param_impl_map.get(key); + const has_concrete = entries_opt != null and entries_opt.?.items.len > 0; + if (!has_concrete) { + // Concrete miss — try the pack map before emitting a diagnostic. + if (self.tryPackImplMatch(operand, operand_node, src_ty, dst_ty, proto_name, pack_key, guard_key)) |result| { + return result; + } + if (self.isClosureToBlockCast(src_ty, dst_ty)) { + if (self.diagnostics) |diags| { + const saved = diags.current_source_file; + diags.current_source_file = operand_node.source_file orelse self.current_source_file; + defer diags.current_source_file = saved; + diags.addFmt(.err, operand_node.span, "no `Into(Block) for {s}` impl — add a per-signature `__block_invoke_` trampoline + Into impl alongside the existing ones in modules/std/objc_block.sx, or declare it in your own code", .{self.mangleTypeName(src_ty)}); + } + return operand; + } + return null; + } + const entries = entries_opt.?; + + // Filter by import visibility: only impls in modules that the current + // file transitively imports (or the current file itself) are reachable. + // Falls open when import_graph isn't wired (e.g. comptime callers). + var visible_impls = std.ArrayList(ParamImplEntry).empty; + defer visible_impls.deinit(self.alloc); + self.protocolResolver().findVisibleImpls(entries.items, &visible_impls); + + if (visible_impls.items.len == 0) { + if (self.diagnostics) |diags| { + const saved = diags.current_source_file; + diags.current_source_file = operand_node.source_file orelse self.current_source_file; + defer diags.current_source_file = saved; + diags.addFmt(.err, operand_node.span, "no visible xx conversion from '{s}' to '{s}' — impl exists in another module but is not imported", .{ + self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), + }); + } + return operand; + } + if (visible_impls.items.len > 1) { + if (self.diagnostics) |diags| { + const saved = diags.current_source_file; + diags.current_source_file = operand_node.source_file orelse self.current_source_file; + defer diags.current_source_file = saved; + diags.addFmt(.err, operand_node.span, "duplicate xx conversion from '{s}' to '{s}': impls in {s} and {s}", .{ + self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), + visible_impls.items[0].defining_module, visible_impls.items[1].defining_module, + }); + } + return operand; + } + const entry = visible_impls.items[0]; + + // Find the `convert` method on this impl. + var convert_fd: ?*const ast.FnDecl = null; + for (entry.methods) |m| { + if (std.mem.eql(u8, m.name, "convert")) { + convert_fd = m; + break; + } + } + const fd = convert_fd orelse return null; + + // Bind Target → dst_ty. + var bindings = std.StringHashMap(TypeId).init(self.alloc); + defer bindings.deinit(); + bindings.put(pd.type_params[0].name, dst_ty) catch return null; + + // Mangled name: ".convert__". + const mangled = std.fmt.allocPrint(self.alloc, "{s}.convert__{s}", .{ + self.mangleTypeName(src_ty), self.mangleTypeName(dst_ty), + }) catch return null; + + self.xx_reentrancy.put(guard_key, {}) catch {}; + defer _ = self.xx_reentrancy.remove(guard_key); + + if (!self.lowered_functions.contains(mangled)) { + self.monomorphizeFunction(fd, mangled, &bindings); + } + + const fid = self.resolveFuncByName(mangled) orelse return null; + const func = &self.module.functions.items[@intFromEnum(fid)]; + const ret_ty = func.ret; + const params = func.params; + var single = [_]Ref{operand}; + const final_args = self.prependCtxIfNeeded(func, single[0..]); + self.coerceCallArgs(final_args, params); + return self.builder.call(fid, final_args, ret_ty); +} + +/// True for expression shapes that name an addressable storage location +/// (variables, fields, array elements, dereferenced pointers). Used by +/// `xx ` to decide between borrow (lvalue → take the +/// address) and heap-copy (rvalue → allocate a fresh copy). +pub fn isLvalueExpr(self: *Lowering, node: *const Node) bool { + _ = self; + return switch (node.data) { + .identifier, .field_access, .index_expr, .deref_expr => true, + else => false, + }; +} + +/// Build a protocol value from a concrete value via xx conversion. +/// Coerce `val` (type `src`) to `dst`: if `dst` is a protocol, `xx`-erase +/// the concrete value into it; otherwise fall back to numeric/struct +/// coercion. Used to materialize a pack into a protocol-typed tuple field. +pub fn coerceOrErase(self: *Lowering, val: Ref, src: TypeId, dst: TypeId, node: *const Node) Ref { + if (src == dst) return val; + if (!dst.isBuiltin()) { + const di = self.module.types.get(dst); + if (di == .@"struct" and di.@"struct".is_protocol) { + return self.buildProtocolErasure(val, node, src, dst); + } + } + return self.coerceToType(val, src, dst); +} + +pub fn buildProtocolErasure(self: *Lowering, operand: Ref, operand_node: *const Node, src_ty: TypeId, dst_ty: TypeId) Ref { + const dst_info = self.module.types.get(dst_ty); + if (dst_info != .@"struct") return operand; + const proto_name = self.module.types.getString(dst_info.@"struct".name); + + // Determine concrete type name and type — resolve through pointer if needed + var concrete_ptr = operand; + var concrete_type_name: ?[]const u8 = null; + var concrete_ty: TypeId = src_ty; + var heap_copy = false; + + if (!src_ty.isBuiltin()) { + const src_info = self.module.types.get(src_ty); + if (src_info == .pointer) { + // xx @acc — operand is already a pointer (user manages lifetime) + const pointee = src_info.pointer.pointee; + concrete_type_name = self.resolveConcreteTypeName(pointee); + concrete_ty = pointee; + heap_copy = false; + } else if (src_info == .@"struct") { + // Struct-typed operand. Split on lvalue-ness: + // - lvalue (identifier, field, index, deref): borrow the + // storage the operand already names. No heap copy; the + // protocol value's ctx points at the caller's slot, and + // mutations through the protocol are visible to the + // original. Lifetime is the caller's responsibility. + // - rvalue (struct literal, call result, etc.): heap-copy + // into a fresh allocation so the protocol value is + // self-contained and outlives this expression. + concrete_type_name = self.module.types.getString(src_info.@"struct".name); + concrete_ty = src_ty; + if (self.isLvalueExpr(operand_node)) { + concrete_ptr = self.lowerExprAsPtr(operand_node); + heap_copy = false; + } else { + heap_copy = true; + const slot = self.builder.alloca(src_ty); + self.builder.store(slot, operand); + concrete_ptr = slot; + } + } + } + + // Also try from the operand node for struct literals: xx Accumulator.{ total = 0 } + if (concrete_type_name == null) { + concrete_type_name = self.inferConcreteTypeName(operand_node); + if (concrete_type_name != null) heap_copy = true; + } + + if (concrete_type_name) |ctn| { + return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy); + } + return operand; +} + +/// Try to infer the concrete type name from an AST node (for struct literals etc.) +pub fn inferConcreteTypeName(self: *Lowering, node: *const Node) ?[]const u8 { + return switch (node.data) { + .struct_literal => |sl| if (sl.struct_name) |n| n else null, + .unary_op => |uop| if (uop.op == .address_of) self.inferConcreteTypeName(uop.operand) else null, + .identifier => |id| blk: { + // Check if identifier's type resolves to a struct + if (self.scope) |scope| { + if (scope.lookup(id.name)) |binding| { + if (!binding.ty.isBuiltin()) { + const bi = self.module.types.get(binding.ty); + if (bi == .@"struct") break :blk self.module.types.getString(bi.@"struct".name); + if (bi == .pointer) { + const pointee = bi.pointer.pointee; + if (!pointee.isBuiltin()) { + const pi = self.module.types.get(pointee); + if (pi == .@"struct") break :blk self.module.types.getString(pi.@"struct".name); + } + } + } + } + } + break :blk null; + }, + else => null, + }; +} + +/// Generate a mini-dispatch for unboxing Any to f64 when the value might be f32 or f64. +/// Uses alloca-based merge: create result slot, branch, store in each arm, load after merge. +pub fn lowerAnyToF64Dispatch(self: *Lowering, any_val: Ref) Ref { + // Create result alloca BEFORE the branch + const result_slot = self.builder.alloca(.f64); + + // Extract type tag from Any + const tag = self.builder.structGet(any_val, 0, .s64); + + const f32_bb = self.freshBlock("f32.unbox"); + const f64_bb = self.freshBlock("f64.unbox"); + const merge_bb = self.freshBlock("float.merge"); + + // Branch: tag == f32_tag ? f32_bb : f64_bb + const f32_tag = self.builder.constInt(TypeId.f32.index(), .s64); + const cond = self.builder.emit(.{ .cmp_eq = .{ .lhs = tag, .rhs = f32_tag } }, .bool); + self.builder.condBr(cond, f32_bb, &.{}, f64_bb, &.{}); + + // f32 block: unbox as f32, fpext to f64, store + self.builder.switchToBlock(f32_bb); + const f32_val = self.builder.emit(.{ .unbox_any = .{ + .operand = any_val, + } }, .f32); + const f64_from_f32 = self.builder.emit(.{ .widen = .{ .operand = f32_val, .from = .f32, .to = .f64 } }, .f64); + self.builder.store(result_slot, f64_from_f32); + self.builder.br(merge_bb, &.{}); + + // f64 block: unbox as f64 directly, store + self.builder.switchToBlock(f64_bb); + const f64_val = self.builder.emit(.{ .unbox_any = .{ + .operand = any_val, + } }, .f64); + self.builder.store(result_slot, f64_val); + self.builder.br(merge_bb, &.{}); + + // Merge block: load result + self.builder.switchToBlock(merge_bb); + return self.builder.load(result_slot, .f64); +} + +/// Produce a default value for a type, applying struct field defaults. +/// For structs with defaults (e.g., `b: s32 = 99`), creates a struct_literal with defaults applied. +/// For other types, returns a zero value. +pub fn buildDefaultValue(self: *Lowering, ty: TypeId) Ref { + if (ty.isBuiltin()) return self.builder.constInt(0, ty); + const info = self.module.types.get(ty); + if (info != .@"struct" and info != .tuple) return self.zeroValue(ty); + // For tuples, build a zero-initialized tuple + if (info == .tuple) { + var field_vals = std.ArrayList(Ref).empty; + defer field_vals.deinit(self.alloc); + for (info.tuple.fields) |f| { + field_vals.append(self.alloc, self.zeroValue(f)) catch unreachable; + } + return self.builder.emit(.{ + .tuple_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable }, + }, ty); + } + // Check for struct defaults + const struct_name_str = self.module.types.getString(info.@"struct".name); + const field_defaults = self.struct_defaults_map.get(struct_name_str) orelse + return self.builder.constUndef(ty); + const fields = info.@"struct".fields; + var field_vals = std.ArrayList(Ref).empty; + defer field_vals.deinit(self.alloc); + for (fields, 0..) |f, i| { + if (i < field_defaults.len) { + if (field_defaults[i]) |default_expr| { + field_vals.append(self.alloc, self.lowerCoercedDefault(default_expr, f.ty)) catch unreachable; + } else { + field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable; + } + } else { + field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable; + } + } + return self.builder.emit(.{ + .struct_init = .{ .fields = self.alloc.dupe(Ref, field_vals.items) catch unreachable }, + }, ty); +} + +/// Wrap ty in ?ty, but flatten: if ty is already ?U, return ?U (not ??U) +pub fn optionalOfFlattened(self: *Lowering, ty: TypeId) TypeId { + if (!ty.isBuiltin()) { + const info = self.module.types.get(ty); + if (info == .optional) return ty; + } + return self.module.types.optionalOf(ty); +} + +/// Produce a zero/default value for any type — constInt(0) for integers, +/// constNull for pointers, constUndef for structs/complex types. +pub fn zeroValue(self: *Lowering, ty: TypeId) Ref { + if (ty.isBuiltin()) return self.builder.constInt(0, ty); + const info = self.module.types.get(ty); + return switch (info) { + // Arbitrary-width integer types (u1, u2, s4, ...) interned as + // `.signed`/`.unsigned` variants — fall through `isBuiltin()`. + .signed, .unsigned => self.builder.constInt(0, ty), + .pointer, .tuple, .optional => self.builder.constNull(ty), + .@"struct", .array, .slice, .many_pointer => self.builder.constNull(ty), + else => self.builder.constUndef(ty), + }; +} + +/// Emit the unified non-integral float→int narrowing diagnostic (F0.11 / +/// issue 0095). ONE wording, ONE place: every site that rejects an implicit +/// narrowing of a non-integral compile-time float to an integer type calls +/// this, so the message + fix-it stay identical across the typed-binding +/// coerce arm, the field/param-default sites, the typed-const path, and the +/// global-initializer path. +pub fn diagNonIntegralNarrow(self: *Lowering, span: ast.Span, value: f64, dst_ty: TypeId) void { + if (self.diagnostics) |d| + d.addFmt(.err, span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ value, self.formatTypeName(dst_ty) }); +} + +/// Lower a struct field default `default_expr`, coerced to the field type +/// `field_ty`. A compile-time float default narrowing into an integer field +/// follows the unified rule via `foldComptimeFloatInit`; everything else +/// lowers under the field type as target and coerces at the IR level. +pub fn lowerCoercedDefault(self: *Lowering, default_expr: *const Node, field_ty: TypeId) Ref { + if (self.foldComptimeFloatInit(default_expr, field_ty)) |folded| return folded; + const saved_tt = self.target_type; + self.target_type = field_ty; + const raw = self.lowerExpr(default_expr); + self.target_type = saved_tt; + return self.coerceToType(raw, self.builder.getRefType(raw), field_ty); +} + +/// How a float→int conversion is treated. An IMPLICIT coercion (a typed +/// binding initializer) folds an integral compile-time float to its int and +/// REJECTS a non-integral one; an EXPLICIT `xx` / `cast` always truncates. +const CoerceMode = enum { implicit, explicit }; + +/// Insert a conversion if src_ty and dst_ty differ. +/// Handles int widening/narrowing, float widening/narrowing, and int↔float. +/// IMPLICIT coercion — the typed-binding initializer path. A compile-time +/// float narrowing to an integer folds when integral, errors when not. +pub fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { + return self.coerceMode(val, src_ty, dst_ty, .implicit); +} + +/// EXPLICIT coercion — the `xx` / `cast(T)` escape hatch. A float→int here +/// always truncates, bypassing the integral-fold / non-integral-error rule. +pub fn coerceExplicit(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { + return self.coerceMode(val, src_ty, dst_ty, .explicit); +} + +pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mode: CoerceMode) Ref { + // PLANNING: classify the built-in coercion (conversions.zig). + // EMISSION: each arm below reproduces the original lowering. + switch (self.coercionResolver().classify(src_ty, dst_ty)) { + .no_op, .none => return val, + // Unbox Any → concrete type + .unbox_any => return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty), + // Box concrete → Any + .box_any => return self.builder.boxAny(val, src_ty), + // Closure VALUE → bare function-pointer slot: not soundly representable. + // A bare `(T) -> U` slot is called as `fn_ptr(ctx, args)` with NO env + // arg, but a closure's underlying fn takes an env slot — so passing a + // closure value's fn_ptr drops the env and shifts the args (UB for a + // matching ABI, a wrong-tuple read for ∅-widening, a segfault when the + // closure captures). Only a closure LITERAL can cross this boundary, + // via the static adapter `lowerLambda` emits (so a literal arrives here + // already typed `.function`). Reject the variable case loudly. + .closure_to_fn_reject => { + if (self.diagnostics) |d| { + const cs = self.builder.current_span; + d.addFmt(.err, ast.Span{ .start = cs.start, .end = cs.end }, "a closure value cannot be passed as a bare function-pointer `(...) -> ...` — its environment can't be carried across the bare ABI; pass the closure literal directly at the call site, or declare the parameter type as `Closure(...)`", .{}); + } + return val; + }, + // Tuple → Tuple element-wise coercion (e.g. a `(s64, s64)` literal + // flowing into a `(s32, s32)` slot — the multi-value failable success + // tuple). Same arity: extract each slot, coerce it, rebuild. + .tuple_elementwise => { + const si = self.module.types.get(src_ty); + const di = self.module.types.get(dst_ty); + var elems = std.ArrayList(Ref).empty; + defer elems.deinit(self.alloc); + for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| { + const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf); + elems.append(self.alloc, self.coerceMode(fv, sf, df, mode)) catch unreachable; + } + return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty); + }, + // Optional → Concrete unwrapping (flow-sensitive narrowing coercion) + .optional_unwrap => { + const child_ty = self.module.types.get(src_ty).optional.child; + const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty); + return self.coerceMode(unwrapped, child_ty, dst_ty, mode); + }, + // void → Optional: produce null (void is the type of null_literal) + .void_to_optional => return self.builder.constNull(dst_ty), + // Concrete → Optional wrapping (coerce to the inner type first) + .optional_wrap => { + const child_ty = self.module.types.get(dst_ty).optional.child; + const coerced = self.coerceMode(val, src_ty, child_ty, mode); + return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty); + }, + // Concrete → Protocol (auto type erasure) + .erase_protocol => { + const proto_name = self.module.types.getString(self.module.types.get(dst_ty).@"struct".name); + const ctn = self.resolveConcreteTypeName(src_ty).?; + // If src is a pointer, use directly; otherwise alloca+store + heap-copy + var concrete_ptr = val; + var concrete_ty = src_ty; + var heap_copy = false; + if (!src_ty.isBuiltin()) { + const si = self.module.types.get(src_ty); + if (si == .pointer) { + concrete_ty = si.pointer.pointee; + heap_copy = false; + } else { + const slot = self.builder.alloca(src_ty); + self.builder.store(slot, val); + concrete_ptr = slot; + heap_copy = true; + } + } + return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy); + }, + .int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .float_to_int => { + // Implicit float→int narrowing follows the unified rule (the + // same `floatToIntExact` the array-dim / `$K: Count` paths use): + // a compile-time INTEGRAL float folds to its int, a NON-integral + // one is a compile error. Explicit `xx` / `cast` (mode + // `.explicit`) skips this and truncates. A runtime float has no + // compile-time value to fold — it truncates as before. + if (mode == .implicit) { + if (self.builder.constFloatInfo(val)) |info| { + if (program_index_mod.floatToIntExact(info.value)) |iv| { + return self.builder.constInt(iv, dst_ty); + } + // Non-integral: diagnose, then fall through to the + // truncating op below so lowering finishes and + // `hasErrors()` aborts the build. + self.diagNonIntegralNarrow(.{ .start = info.span.start, .end = info.span.end }, info.value, dst_ty); + } + } + return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty); + }, + // Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot. + // Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches + // to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level + // since LLVMBuildBitCast itself doesn't accept ptr↔int. + .ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .array_to_slice => return self.builder.emit(.{ .array_to_slice = .{ .operand = val } }, dst_ty), + } +} + +/// Apply C default argument promotion to variadic-tail args. These rules +/// (bool/s8/s16/u8/u16 → s32, f32 → f64) match the C calling convention's +/// implicit promotions when an argument is passed through `...`. +pub fn promoteCVariadicArgs(self: *Lowering, args: []Ref, fixed_count: usize) void { + if (args.len <= fixed_count) return; + for (args[fixed_count..]) |*arg| { + const src_ty = self.builder.getRefType(arg.*); + const promoted: TypeId = switch (src_ty) { + .bool, .s8, .s16, .u8, .u16 => .s32, + .f32 => .f64, + else => continue, + }; + arg.* = self.coerceToType(arg.*, src_ty, promoted); + } +} + +/// Coerce call arguments in-place to match function parameter types. +pub fn coerceCallArgs(self: *Lowering, args: []Ref, params: []const Function.Param) void { + for (0..@min(args.len, params.len)) |i| { + const src_ty = self.builder.getRefType(args[i]); + const dst_ty = params[i].ty; + if (!src_ty.isBuiltin() and !dst_ty.isBuiltin()) { + const src_info = self.module.types.get(src_ty); + const dst_info = self.module.types.get(dst_ty); + // Array → many_pointer decay: alloca the array, GEP to first element + if (src_info == .array and dst_info == .many_pointer) { + const slot = self.builder.alloca(src_ty); + self.builder.store(slot, args[i]); + const zero = self.builder.constInt(0, .s64); + args[i] = self.builder.emit(.{ .index_gep = .{ .lhs = slot, .rhs = zero } }, dst_ty); + continue; + } + // Implicit address-of: passing T value where *T is expected → alloca + store + // Only when the pointee type matches the source type. + if (dst_info == .pointer and src_info != .pointer and dst_info.pointer.pointee == src_ty) { + const slot = self.builder.alloca(src_ty); + self.builder.store(slot, args[i]); + args[i] = slot; + continue; + } + } + args[i] = self.coerceToType(args[i], src_ty, dst_ty); + } +}