From 8990bd4978791dd7d2e5466b5294b6a3dad407ca Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 10 Jun 2026 13:54:59 +0300 Subject: [PATCH 1/2] refactor(B5.1): move protocol emission to lower/protocol.zig Verbatim relocation of the 13-method protocol cluster (protocol decl registration, param-protocol instantiation, thunk creation, vtable globals, protocol-value construction, dispatch emission, impl lookup) into src/ir/lower/protocol.zig. 13 fn aliases on Lowering keep all call sites unchanged. Two pub nested types travelled with the run (ProjectionPosition, PackProjection) and are re-exposed via Lowering type aliases; they are pack-domain types and may relocate to lower/pack.zig in B7.2. Method pub-flips: allocViaContext, callForeign, genericInstanceMethod, monomorphizeFunction. Gate: zig build OK; zig build test 426/426; run_examples 541/0; zero expected/ snapshot churn. --- src/ir/lower.zig | 623 ++---------------------------------- src/ir/lower/protocol.zig | 646 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 668 insertions(+), 601 deletions(-) create mode 100644 src/ir/lower/protocol.zig diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 20e43af..38e1c3c 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -39,6 +39,7 @@ const lower_stmt = @import("lower/stmt.zig"); 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 TypeId = types.TypeId; const StringId = types.StringId; @@ -1413,44 +1414,6 @@ pub const Lowering = struct { // ── Control flow ──────────────────────────────────────────────── - /// Shared implementation for the `has_impl(P, T)` builtin and its - /// `tryConstBoolCondition` arm. The protocol expression is either: - /// - Plain `Hash` (identifier / type_expr) → walks - /// `protocol_thunk_map["Hash\x00"]`. - /// - Parameterised `Into(Block)` (call) → walks `param_impl_map` - /// keyed by `"

\x00\x00"`. - /// Returns false on any malformed protocol-arg shape (caller - /// reports a diagnostic if it wants). - pub fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool { - switch (proto_node.data) { - .identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty), - .type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty), - .call => |c| { - const p_name: []const u8 = switch (c.callee.data) { - .identifier => |id| id.name, - .type_expr => |te| te.name, - else => return false, - }; - // Resolve protocol type args. Each goes through - // `resolveTypeArg` so type aliases / generics / pack- - // indexed types all work as protocol args. - var arg_mangles = std.ArrayList(u8).empty; - defer arg_mangles.deinit(self.alloc); - for (c.args, 0..) |a, i| { - if (i > 0) arg_mangles.append(self.alloc, 0) catch return false; - const aty = self.resolveTypeArg(a); - arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false; - } - const ty_mangled = self.mangleTypeName(ty); - const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{ - p_name, arg_mangles.items, ty_mangled, - }) catch return false; - return self.param_impl_map.contains(key); - }, - else => return false, - } - } - fn lowerStructLiteral(self: *Lowering, sl: *const ast.StructLiteral, span: ast.Span) Ref { // Check for tagged enum construction: .Variant.{ payload_fields } // This happens when type_expr is an enum_literal and target_type is a union @@ -4838,7 +4801,7 @@ pub const Lowering = struct { /// NOT fall back to a direct libc malloc — that was the silent escape /// hatch that bit us through the implicit-context refactor (see the /// "Silent unimplemented arms" REJECTED PATTERN in CLAUDE.md). - fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref { + pub fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref { if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) { return self.diagnoseMissingContext("heap allocation"); } @@ -4874,7 +4837,7 @@ pub const Lowering = struct { /// Used for the compiler-internal byte-copy in the protocol-erasure /// heap path and the closure env-copy path, both of which need /// libc `memcpy` after the `#builtin` form was dropped. - fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref { + pub fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref { const fid = self.resolveFuncByName(name) orelse @panic("foreign symbol missing — std.sx not imported?"); return self.builder.call(fid, args, ret_ty); } @@ -6845,7 +6808,7 @@ pub const Lowering = struct { self.builder.finalize(); } - fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name: []const u8, bindings: *std.StringHashMap(TypeId)) void { + pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name: []const u8, bindings: *std.StringHashMap(TypeId)) void { // Mark as lowered before lowering (prevents infinite recursion) // Need to dupe the name since mangled_name may be stack-allocated const owned_name = self.alloc.dupe(u8, mangled_name) catch return; @@ -9210,7 +9173,7 @@ pub const Lowering = struct { /// no such `method` (a normal unresolved-method, handled downstream). A /// confirmed instance whose author is present but whose bindings are missing is /// a LOUD invariant failure — instantiation writes both together (CP-2). - fn genericInstanceMethod(self: *Lowering, inst_name: []const u8, method: []const u8) ?GenericStructMethod { + pub fn genericInstanceMethod(self: *Lowering, inst_name: []const u8, method: []const u8) ?GenericStructMethod { const author = self.struct_instance_author.get(inst_name) orelse return null; const bindings = self.struct_instance_bindings.getPtr(inst_name) orelse std.debug.panic("generic struct instance '{s}' has an author but no bindings", .{inst_name}); @@ -9667,152 +9630,6 @@ pub const Lowering = struct { // ── Type registration ─────────────────────────────────────────── - /// Register a protocol declaration as a struct type in the IR type table. - /// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... } - /// Non-inline protocols: { ctx: *void, __vtable: *void } - /// Also stores protocol info for dispatch and vtable struct type for vtable protocols. - /// Register a protocol declaration. Thin delegation to the canonical owner - /// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub` - /// entry point because the scan pass + several unit tests reach it here. - pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void { - return self.protocolResolver().registerProtocolDecl(pd); - } - - /// Instantiate a parameterized protocol as a runtime VALUE type: - /// `VL(s64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`), - /// with method infos resolved under the type-arg binding (so `get -> T` - /// becomes `get -> s64`) and the binding recorded for projection. Cached by - /// the mangled name `VL__s64`. Mirrors the non-parameterized path in - /// `registerProtocolDecl`. - fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId { - const table = &self.module.types; - const void_ptr_ty = table.ptrTo(.void); - - var np = std.ArrayList(u8).empty; - np.appendSlice(self.alloc, pd.name) catch {}; - var tb = std.StringHashMap(TypeId).init(self.alloc); - for (pd.type_params, 0..) |tp, i| { - if (i >= args.len) break; - const ty = self.resolveTypeWithBindings(args[i]); - tb.put(tp.name, ty) catch {}; - np.appendSlice(self.alloc, "__") catch {}; - np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {}; - } - const mangled = np.items; - const name_id = table.internString(mangled); - if (table.findByName(name_id)) |existing| { - const info = table.get(existing); - if (info == .@"struct" and info.@"struct".is_protocol) return existing; - } - - // Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol). - var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; - fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable; - if (pd.is_inline) { - for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable; - } else { - fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable; - } - const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } }; - const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info); - table.updatePreservingKey(id, struct_info); - - // Method infos resolved with the type-arg binding (T → s64), pinned to - // the protocol's OWN module (E4) so a method-signature type visible only - // there resolves correctly when instantiated cross-module. `Self` and the - // bound type-args short-circuit before the leaf; a concrete library type - // in a signature is the case this pin protects. - const saved_tb = self.type_bindings; - self.type_bindings = tb; - const saved_pp_src = self.current_source_file; - defer self.setCurrentSourceFile(saved_pp_src); - if (pd.source_file) |src| self.setCurrentSourceFile(src); - var method_infos = std.ArrayList(ProtocolMethodInfo).empty; - for (pd.methods) |method| { - var ptypes = std.ArrayList(TypeId).empty; - for (method.params) |p| { - const pty = blk: { - if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty; - break :blk self.resolveTypeWithBindings(p); - }; - ptypes.append(self.alloc, pty) catch unreachable; - } - var ret_is_self = false; - const ret = if (method.return_type) |rt| blk: { - if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) { - ret_is_self = true; - break :blk void_ptr_ty; - } - break :blk self.resolveTypeWithBindings(rt); - } else .void; - method_infos.append(self.alloc, .{ - .name = method.name, - .param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable, - .ret_type = ret, - .ret_is_self = ret_is_self, - }) catch unreachable; - } - self.type_bindings = saved_tb; - - const owned = self.alloc.dupe(u8, mangled) catch return id; - self.program_index.protocol_decl_map.put(owned, .{ - .name = owned, - .is_inline = pd.is_inline, - .methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable, - }) catch {}; - // Record the type-arg binding so projection (`xs.T`, `.value`) and - // method-arg resolution on this instance can recover it. - self.struct_instance_bindings.put(owned, tb) catch {}; - - if (!pd.is_inline) { - var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; - for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable; - var vtable_name_buf: [192]u8 = undefined; - const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable"; - const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } }); - self.protocol_vtable_type_map.put(owned, vtable_ty) catch {}; - } - return id; - } - - // ── Pack projection name resolution (Feature 1, Decision 4) ────────── - // - // A `..pack.` projection can target two protocol namespaces: - // - type-arg namespace: the `protocol($T, ...)` params. - // - runtime-accessor namespace: the protocol's methods (protocols have - // no fields; a zero-arg method like `value` is the accessor). - // Resolution is POSITION-driven, not precedence-driven: type position - // consults type-args, value position consults methods, with NO - // cross-namespace fallback. - - pub const ProjectionPosition = enum { type_position, value_position }; - - pub const PackProjection = union(enum) { - type_arg: u32, // index into the protocol's `type_params` - method: u32, // index into the protocol's `methods` - not_found, // `name` absent from the position-selected namespace - }; - - /// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`). - /// Returns the `type_params` index, or null (also for unknown protocols). - pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 { - const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null; - for (pd.type_params, 0..) |tp, i| { - if (std.mem.eql(u8, tp.name, name)) return @intCast(i); - } - return null; - } - - /// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods - /// — protocols have no fields). Returns the `methods` index, or null. - pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 { - const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null; - for (pd.methods, 0..) |m, i| { - if (std.mem.eql(u8, m.name, name)) return @intCast(i); - } - return null; - } - /// Resolve `..pack.` against `protocol_name` by position (Decision 4). /// No cross-namespace fallback: a value-position name that exists only as a /// type-arg (or vice versa) is `.not_found`, letting the caller emit a @@ -10084,419 +9901,6 @@ pub const Lowering = struct { // ── Protocol dispatch ────────────────────────────────────────── - /// Check if a type name is a registered protocol. - fn isProtocolType(self: *Lowering, type_name: []const u8) bool { - return self.program_index.protocol_decl_map.contains(type_name); - } - - /// Get protocol info for a TypeId (if it's a protocol type). - /// Protocol lookup. Thin delegation to the canonical owner - /// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9 - /// callers (dispatch sites here + `calls.zig`) reach it. - pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo { - return self.protocolResolver().getProtocolInfo(ty); - } - - /// Get or create thunks for a (protocol, concrete_type) pair. - /// Returns a slice of FuncIds, one per protocol method. - fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId { - // Key: "Proto\x00Type" - const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{}; - if (self.protocol_thunk_map.get(key)) |thunks| return thunks; - - // PLANNING: which methods need a thunk (owned by the registry). - const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{}; - var thunk_ids = std.ArrayList(FuncId).empty; - defer thunk_ids.deinit(self.alloc); - - // EMISSION: materialize one thunk per method (stays in Lowering). - for (methods) |method| { - const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method); - thunk_ids.append(self.alloc, thunk_id) catch unreachable; - } - - const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable; - self.protocol_thunk_map.put(key, owned) catch {}; - return owned; - } - - /// Emit the process-wide default Context as an LLVM static constant. - /// - /// @__sx_default_context = internal constant %Context { - /// %Allocator { ptr null, - /// ptr @__thunk_CAllocator_Allocator_alloc, - /// ptr @__thunk_CAllocator_Allocator_dealloc }, - /// ptr null - /// } - /// - /// Used by FFI inbound wrappers (Step 4) and the interp's default- - /// context call entry (Step 7). Only emitted when the program imports - /// `std.sx` — without that, Context / Allocator / CAllocator aren't - /// registered and the global has no purpose. - pub fn emitDefaultContextGlobal(self: *Lowering) void { - const saved_edc = self.emitting_default_context; - self.emitting_default_context = true; - defer self.emitting_default_context = saved_edc; - const tbl = &self.module.types; - const ctx_name_id = tbl.internString("Context"); - const ctx_ty = tbl.findByName(ctx_name_id) orelse return; - if (tbl.findByName(tbl.internString("Allocator")) == null) return; - if (tbl.findByName(tbl.internString("CAllocator")) == null) return; - - // Force the CAllocator → Allocator thunks to exist so we can - // reference them by FuncId in the static initializer. - const thunks = self.getOrCreateThunks("Allocator", "CAllocator"); - if (thunks.len < 2) return; - - // Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void } - // CAllocator is stateless, so ctx is null. - const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return; - alloc_fields[0] = .null_val; - alloc_fields[1] = .{ .func_ref = thunks[0] }; - alloc_fields[2] = .{ .func_ref = thunks[1] }; - - // Context value: { allocator: Allocator, data: *void } - const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return; - ctx_fields[0] = .{ .aggregate = alloc_fields }; - ctx_fields[1] = .null_val; - - const global_name = "__sx_default_context"; - const global_name_id = tbl.internString(global_name); - const gid = self.module.addGlobal(.{ - .name = global_name_id, - .ty = ctx_ty, - .init_val = .{ .aggregate = ctx_fields }, - .is_const = true, - }); - self.putGlobal(self.current_source_file, global_name, .{ .id = gid, .ty = ctx_ty }); - } - - /// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret - /// The thunk calls ConcreteType.method(ctx, args...). - fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId { - // Build params: [__sx_ctx]? + ctx: *void + method params. - // Thunks are sx-side functions, so they get the implicit __sx_ctx - // at slot 0 when it's enabled program-wide. The concrete protocol - // receiver (ctx) follows at slot 1; user method args at slot 2+. - var params = std.ArrayList(inst_mod.Function.Param).empty; - defer params.deinit(self.alloc); - const void_ptr = self.module.types.ptrTo(.void); - const thunk_has_ctx = self.implicit_ctx_enabled; - if (thunk_has_ctx) { - params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable; - } - params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable; - for (method.param_types, 0..) |pty, i| { - var buf: [32]u8 = undefined; - const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg"; - params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable; - } - - // Generate unique name - var name_buf: [192]u8 = undefined; - const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk"; - const thunk_name_id = self.module.types.internString(thunk_name); - - // Save builder state - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - const saved_ctx_ref_thunk = self.current_ctx_ref; - defer self.current_ctx_ref = saved_ctx_ref_thunk; - - const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable; - var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type); - func.has_implicit_ctx = thunk_has_ctx; - const func_id = self.module.addFunction(func); - self.builder.func = func_id; - self.builder.inst_counter = @intCast(owned_params.len); - if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0); - const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{}); - self.builder.switchToBlock(entry_block); - - // Ensure the concrete method is lowered - const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name; - if (!self.lowered_functions.contains(qualified)) { - if (self.program_index.fn_ast_map.contains(qualified)) { - self.lazyLowerFunction(qualified); - } else if (self.genericInstanceMethod(concrete_type_name, method.name)) |gm| { - // Generic-struct instance (`Combined__s64_s64`): the impl method is - // authored on the instance's STAMPED decl (CP-4). Monomorphize it - // for this instance's bindings so the thunk has a concrete - // `Combined__s64_s64.get` to call. - self.monomorphizeFunction(gm.fd, qualified, gm.bindings); - } - } - - // Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...). - // The concrete method is itself an sx function that takes the - // implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we - // forward the thunk's own __sx_ctx. - if (self.resolveFuncByName(qualified)) |concrete_fid| { - const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)]; - var call_args = std.ArrayList(Ref).empty; - defer call_args.deinit(self.alloc); - - // Slot offsets inside the thunk: __sx_ctx at 0 (if present), - // protocol receiver (ctx) at slot user_base, user args at +1, +2... - const user_base: u32 = if (thunk_has_ctx) 1 else 0; - - // Forward our __sx_ctx to the concrete method's __sx_ctx slot. - if (concrete_func.has_implicit_ctx) { - call_args.append(self.alloc, self.current_ctx_ref) catch unreachable; - } - - // Pass ctx as the next arg (it's the concrete *Type disguised as *void). - // If the concrete method expects a value (e.g., f32) not a pointer, load from ctx. - const ctx_ref = Ref.fromIndex(user_base); - const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0; - if (concrete_receiver_idx < concrete_func.params.len) { - const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty; - const first_info = self.module.types.get(first_concrete_ty); - if (first_info != .pointer) { - // Concrete expects value — load from ctx pointer - call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable; - } else { - call_args.append(self.alloc, ctx_ref) catch unreachable; - } - } else { - call_args.append(self.alloc, ctx_ref) catch unreachable; - } - for (method.param_types, 0..) |proto_pty, i| { - var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i)); - // If protocol param is a pointer (Self→*void) but concrete method - // expects a value type, load the value from the pointer. - const concrete_idx = concrete_receiver_idx + 1 + i; - if (concrete_idx < concrete_func.params.len) { - const concrete_pty = concrete_func.params[concrete_idx].ty; - const proto_info = self.module.types.get(proto_pty); - const concrete_info = self.module.types.get(concrete_pty); - if (proto_info == .pointer and concrete_info != .pointer) { - arg_ref = self.builder.load(arg_ref, concrete_pty); - } - } - call_args.append(self.alloc, arg_ref) catch unreachable; - } - const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable; - const concrete_ret = concrete_func.ret; - const result = self.builder.call(concrete_fid, owned_args, concrete_ret); - if (method.ret_type != .void) { - // If protocol returns *void (Self) but concrete returns a value type, - // box the value: alloca+store and return the pointer - const ret_info = self.module.types.get(method.ret_type); - const concrete_ret_info = self.module.types.get(concrete_ret); - if (ret_info == .pointer and concrete_ret_info != .pointer) { - const slot = self.builder.alloca(concrete_ret); - self.builder.store(slot, result); - self.builder.ret(slot, method.ret_type); - } else { - self.builder.ret(result, method.ret_type); - } - } else { - self.builder.retVoid(); - } - } else { - // Can't resolve concrete method — emit unreachable - _ = self.builder.emit(.{ .@"unreachable" = {} }, .void); - } - self.builder.finalize(); - - // Restore builder state - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - - return func_id; - } - - /// Build a protocol value from a concrete pointer. - /// For inline protocols: struct_init { ctx, thunk1, thunk2, ... } - /// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated - /// When `heap_copy` is true, the concrete data is heap-copied so the protocol value - /// outlives the current stack frame (used when source is a value, not an explicit pointer). - /// When false, the pointer is used directly (user manages the pointee's lifetime). - fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref { - const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr; - const thunks = self.getOrCreateThunks(proto_name, concrete_type_name); - if (thunks.len != pd.methods.len) return concrete_ptr; - - const void_ptr_ty = self.module.types.ptrTo(.void); - - // When source is a value (not an explicit pointer), heap-allocate - // so the protocol value outlives the current stack frame. - // When source is an explicit pointer (xx @obj), use it directly — - // the user is responsible for the pointee's lifetime. - var ctx_ptr = concrete_ptr; - if (heap_copy) { - const concrete_size = self.module.types.typeSizeBytes(concrete_ty); - const size_ref = self.builder.constInt(@intCast(concrete_size), .s64); - const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty); - _ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty); - ctx_ptr = heap_ptr; - } - - if (pd.is_inline) { - // Inline: { ctx, fn1, fn2, ... } - var field_vals = std.ArrayList(Ref).empty; - defer field_vals.deinit(self.alloc); - field_vals.append(self.alloc, ctx_ptr) catch unreachable; - for (thunks) |thunk_id| { - const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty); - field_vals.append(self.alloc, fn_ref) catch unreachable; - } - const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable; - return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty); - } else { - // Vtable: { ctx, vtable_ptr } - // Vtable is a global constant (same function pointers for every instance - // of the same Protocol+ConcreteType pair). Cached per pair. - const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr; - - // Build cache key: "Proto\x00Type" - const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable; - - const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: { - // Create vtable global with function pointer initializer - const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable; - const global_name_id = self.module.types.strings.intern(self.alloc, global_name); - const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable; - const gid = self.module.addGlobal(.{ - .name = global_name_id, - .ty = vtable_ty, - .init_val = .{ .vtable = thunk_ids }, - .is_const = true, - }); - self.protocol_vtable_global_map.put(key, gid) catch {}; - break :blk gid; - }; - - // Reference the vtable global's address - const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty); - const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty); - - // Build protocol struct: { ctx, &vtable } - var proto_fields = std.ArrayList(Ref).empty; - defer proto_fields.deinit(self.alloc); - proto_fields.append(self.alloc, ctx_ptr) catch unreachable; - proto_fields.append(self.alloc, vtable_addr) catch unreachable; - const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable; - return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty); - } - } - - /// Emit protocol method dispatch for a protocol-typed receiver. - /// Returns the call result ref. - fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId) Ref { - // Find method index - var method_idx: ?usize = null; - var method_info: ?ProtocolMethodInfo = null; - for (proto_info.methods, 0..) |m, i| { - if (std.mem.eql(u8, m.name, method_name)) { - method_idx = i; - method_info = m; - break; - } - } - const mi = method_info orelse return self.emitError(method_name, null); - const midx = method_idx orelse 0; - - // Extract ctx from protocol struct (field 0) - const void_ptr = self.module.types.ptrTo(.void); - const ctx = self.builder.structGet(receiver, 0, void_ptr); - - // Extract fn_ptr - const fn_ptr = if (proto_info.is_inline) blk: { - // Inline: fn_ptr at field 1+method_idx - break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr); - } else blk: { - // Vtable: load vtable struct, extract fn_ptr at method_idx - const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr); - const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null); - const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty); - break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr); - }; - _ = proto_ty; - - // Build call args: [__sx_ctx]? + receiver_ctx + user args. - // Protocol thunks are sx-side, so they carry the implicit __sx_ctx - // at slot 0 when the program uses Context — forward our caller's - // ctx so the thunk's body (and the concrete method it forwards to) - // sees the same Context as the dispatching code. - var call_args = std.ArrayList(Ref).empty; - defer call_args.deinit(self.alloc); - if (self.implicit_ctx_enabled) { - call_args.append(self.alloc, self.current_ctx_ref) catch unreachable; - } - call_args.append(self.alloc, ctx) catch unreachable; - for (args, 0..) |a, i| { - const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr; - const arg_ty = self.builder.getRefType(a); - - // Untargeted `null` lowers as const_null with type .void. Re-emit it - // as a null of the expected pointer type instead of alloca'ing void. - if (arg_ty == .void and expected_ty == void_ptr) { - call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable; - continue; - } - // A protocol method that expects `*void` accepts any single-pointer - // value directly (`*T`, `[*]T`). Only wrap non-pointer values in an - // alloca-slot — wrapping a pointer would pass the stack slot's - // address instead of the actual pointer, and the callee would read - // 8 bytes of pointer plus garbage from beyond the stack. - const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: { - const info = self.module.types.get(arg_ty); - break :blk info == .pointer or info == .many_pointer; - } else false; - if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) { - const slot = self.builder.alloca(arg_ty); - self.builder.store(slot, a); - call_args.append(self.alloc, slot) catch unreachable; - } else { - // Coerce to match declared parameter type (critical for WASM strict signatures) - const coerced = self.coerceToType(a, arg_ty, expected_ty); - call_args.append(self.alloc, coerced) catch unreachable; - } - } - const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable; - const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type); - - // If the protocol method was declared `-> Self` (encoded here as *void) - // and the caller expects a value type, unbox: load the concrete value - // from the returned pointer. A literal `-> *void` return is NOT - // auto-loaded — it's a real pointer whose pointee size we don't know. - if (mi.ret_is_self) { - if (self.target_type) |target| { - const target_info = self.module.types.get(target); - if (target_info != .pointer) { - return self.builder.load(raw_result, target); - } - } - } - return raw_result; - } - - /// Resolve the concrete type name for protocol erasure. - /// Handles both direct types and pointer-to-types. - pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 { - if (ty.isBuiltin()) { - // Primitive types like s64 — check if they have toName() - return self.module.types.typeName(ty); - } - const info = self.module.types.get(ty); - if (info == .pointer) { - // *ConcreteType → resolve pointee - const pointee = info.pointer.pointee; - if (pointee.isBuiltin()) return self.module.types.typeName(pointee); - const pi = self.module.types.get(pointee); - if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name); - return null; - } - if (info == .@"struct") return self.module.types.getString(info.@"struct".name); - return null; - } - - // ── Helpers ───────────────────────────────────────────────────── - /// Infer the type of an expression from its AST node (used for untyped var decls). pub fn inferExprType(self: *Lowering, node: *const Node) TypeId { return switch (node.data) { @@ -13158,6 +12562,23 @@ pub const Lowering = struct { pub const bareVisibleStructDecl = lower_nominal.bareVisibleStructDecl; pub const bareVisibleStructTemplate = lower_nominal.bareVisibleStructTemplate; pub const registerGenericStructAlias = lower_nominal.registerGenericStructAlias; + + // --- moved to lower/protocol.zig (lower_protocol) --- + pub const ProjectionPosition = lower_protocol.ProjectionPosition; + pub const PackProjection = lower_protocol.PackProjection; + pub const registerProtocolDecl = lower_protocol.registerProtocolDecl; + pub const instantiateParamProtocol = lower_protocol.instantiateParamProtocol; + pub const lookupProtocolArg = lower_protocol.lookupProtocolArg; + pub const lookupProtocolField = lower_protocol.lookupProtocolField; + pub const isProtocolType = lower_protocol.isProtocolType; + pub const getProtocolInfo = lower_protocol.getProtocolInfo; + pub const getOrCreateThunks = lower_protocol.getOrCreateThunks; + pub const emitDefaultContextGlobal = lower_protocol.emitDefaultContextGlobal; + pub const createProtocolThunk = lower_protocol.createProtocolThunk; + pub const buildProtocolValue = lower_protocol.buildProtocolValue; + pub const emitProtocolDispatch = lower_protocol.emitProtocolDispatch; + pub const resolveConcreteTypeName = lower_protocol.resolveConcreteTypeName; + pub const computeHasImpl = lower_protocol.computeHasImpl; }; /// JNI param/return type resolution: user-declared types pass through diff --git a/src/ir/lower/protocol.zig b/src/ir/lower/protocol.zig new file mode 100644 index 0000000..fad30da --- /dev/null +++ b/src/ir/lower/protocol.zig @@ -0,0 +1,646 @@ +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; + +/// Shared implementation for the `has_impl(P, T)` builtin and its +/// `tryConstBoolCondition` arm. The protocol expression is either: +/// - Plain `Hash` (identifier / type_expr) → walks +/// `protocol_thunk_map["Hash\x00"]`. +/// - Parameterised `Into(Block)` (call) → walks `param_impl_map` +/// keyed by `"

\x00\x00"`. +/// Returns false on any malformed protocol-arg shape (caller +/// reports a diagnostic if it wants). +pub fn computeHasImpl(self: *Lowering, proto_node: *const Node, ty: TypeId) bool { + switch (proto_node.data) { + .identifier => |id| return self.protocolResolver().hasImplPlain(id.name, ty), + .type_expr => |te| return self.protocolResolver().hasImplPlain(te.name, ty), + .call => |c| { + const p_name: []const u8 = switch (c.callee.data) { + .identifier => |id| id.name, + .type_expr => |te| te.name, + else => return false, + }; + // Resolve protocol type args. Each goes through + // `resolveTypeArg` so type aliases / generics / pack- + // indexed types all work as protocol args. + var arg_mangles = std.ArrayList(u8).empty; + defer arg_mangles.deinit(self.alloc); + for (c.args, 0..) |a, i| { + if (i > 0) arg_mangles.append(self.alloc, 0) catch return false; + const aty = self.resolveTypeArg(a); + arg_mangles.appendSlice(self.alloc, self.mangleTypeName(aty)) catch return false; + } + const ty_mangled = self.mangleTypeName(ty); + const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}\x00{s}", .{ + p_name, arg_mangles.items, ty_mangled, + }) catch return false; + return self.param_impl_map.contains(key); + }, + else => return false, + } +} + +/// Register a protocol declaration as a struct type in the IR type table. +/// Inline protocols: { ctx: *void, method1: *void, method2: *void, ... } +/// Non-inline protocols: { ctx: *void, __vtable: *void } +/// Also stores protocol info for dispatch and vtable struct type for vtable protocols. +/// Register a protocol declaration. Thin delegation to the canonical owner +/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` as a `pub` +/// entry point because the scan pass + several unit tests reach it here. +pub fn registerProtocolDecl(self: *Lowering, pd: *const ast.ProtocolDecl) void { + return self.protocolResolver().registerProtocolDecl(pd); +} + +/// Instantiate a parameterized protocol as a runtime VALUE type: +/// `VL(s64)` → a 16-byte `{ctx, __vtable}` protocol value (`is_protocol`), +/// with method infos resolved under the type-arg binding (so `get -> T` +/// becomes `get -> s64`) and the binding recorded for projection. Cached by +/// the mangled name `VL__s64`. Mirrors the non-parameterized path in +/// `registerProtocolDecl`. +pub fn instantiateParamProtocol(self: *Lowering, pd: *const ast.ProtocolDecl, args: []const *const Node) TypeId { + const table = &self.module.types; + const void_ptr_ty = table.ptrTo(.void); + + var np = std.ArrayList(u8).empty; + np.appendSlice(self.alloc, pd.name) catch {}; + var tb = std.StringHashMap(TypeId).init(self.alloc); + for (pd.type_params, 0..) |tp, i| { + if (i >= args.len) break; + const ty = self.resolveTypeWithBindings(args[i]); + tb.put(tp.name, ty) catch {}; + np.appendSlice(self.alloc, "__") catch {}; + np.appendSlice(self.alloc, self.formatTypeName(ty)) catch {}; + } + const mangled = np.items; + const name_id = table.internString(mangled); + if (table.findByName(name_id)) |existing| { + const info = table.get(existing); + if (info == .@"struct" and info.@"struct".is_protocol) return existing; + } + + // Value struct: {ctx, __vtable} (or ctx + fn-ptrs for an inline protocol). + var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; + fields.append(self.alloc, .{ .name = table.internString("ctx"), .ty = void_ptr_ty }) catch unreachable; + if (pd.is_inline) { + for (pd.methods) |m| fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable; + } else { + fields.append(self.alloc, .{ .name = table.internString("__vtable"), .ty = void_ptr_ty }) catch unreachable; + } + const struct_info: types.TypeInfo = .{ .@"struct" = .{ .name = name_id, .fields = fields.items, .is_protocol = true } }; + const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info); + table.updatePreservingKey(id, struct_info); + + // Method infos resolved with the type-arg binding (T → s64), pinned to + // the protocol's OWN module (E4) so a method-signature type visible only + // there resolves correctly when instantiated cross-module. `Self` and the + // bound type-args short-circuit before the leaf; a concrete library type + // in a signature is the case this pin protects. + const saved_tb = self.type_bindings; + self.type_bindings = tb; + const saved_pp_src = self.current_source_file; + defer self.setCurrentSourceFile(saved_pp_src); + if (pd.source_file) |src| self.setCurrentSourceFile(src); + var method_infos = std.ArrayList(ProtocolMethodInfo).empty; + for (pd.methods) |method| { + var ptypes = std.ArrayList(TypeId).empty; + for (method.params) |p| { + const pty = blk: { + if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) break :blk void_ptr_ty; + break :blk self.resolveTypeWithBindings(p); + }; + ptypes.append(self.alloc, pty) catch unreachable; + } + var ret_is_self = false; + const ret = if (method.return_type) |rt| blk: { + if (rt.data == .type_expr and std.mem.eql(u8, rt.data.type_expr.name, "Self")) { + ret_is_self = true; + break :blk void_ptr_ty; + } + break :blk self.resolveTypeWithBindings(rt); + } else .void; + method_infos.append(self.alloc, .{ + .name = method.name, + .param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable, + .ret_type = ret, + .ret_is_self = ret_is_self, + }) catch unreachable; + } + self.type_bindings = saved_tb; + + const owned = self.alloc.dupe(u8, mangled) catch return id; + self.program_index.protocol_decl_map.put(owned, .{ + .name = owned, + .is_inline = pd.is_inline, + .methods = self.alloc.dupe(ProtocolMethodInfo, method_infos.items) catch unreachable, + }) catch {}; + // Record the type-arg binding so projection (`xs.T`, `.value`) and + // method-arg resolution on this instance can recover it. + self.struct_instance_bindings.put(owned, tb) catch {}; + + if (!pd.is_inline) { + var vtable_fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; + for (pd.methods) |m| vtable_fields.append(self.alloc, .{ .name = table.internString(m.name), .ty = void_ptr_ty }) catch unreachable; + var vtable_name_buf: [192]u8 = undefined; + const vtable_name = std.fmt.bufPrint(&vtable_name_buf, "__{s}__Vtable", .{mangled}) catch "__Vtable"; + const vtable_ty = table.intern(.{ .@"struct" = .{ .name = table.internString(vtable_name), .fields = vtable_fields.items } }); + self.protocol_vtable_type_map.put(owned, vtable_ty) catch {}; + } + return id; +} + +// ── Pack projection name resolution (Feature 1, Decision 4) ────────── +// +// A `..pack.` projection can target two protocol namespaces: +// - type-arg namespace: the `protocol($T, ...)` params. +// - runtime-accessor namespace: the protocol's methods (protocols have +// no fields; a zero-arg method like `value` is the accessor). +// Resolution is POSITION-driven, not precedence-driven: type position +// consults type-args, value position consults methods, with NO +// cross-namespace fallback. + +pub const ProjectionPosition = enum { type_position, value_position }; + +pub const PackProjection = union(enum) { + type_arg: u32, // index into the protocol's `type_params` + method: u32, // index into the protocol's `methods` + not_found, // `name` absent from the position-selected namespace +}; + +/// Find `name` in `protocol_name`'s type-arg namespace (`protocol($T,...)`). +/// Returns the `type_params` index, or null (also for unknown protocols). +pub fn lookupProtocolArg(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 { + const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null; + for (pd.type_params, 0..) |tp, i| { + if (std.mem.eql(u8, tp.name, name)) return @intCast(i); + } + return null; +} + +/// Find `name` in `protocol_name`'s runtime-accessor namespace (its methods +/// — protocols have no fields). Returns the `methods` index, or null. +pub fn lookupProtocolField(self: *Lowering, protocol_name: []const u8, name: []const u8) ?u32 { + const pd = self.program_index.protocol_ast_map.get(protocol_name) orelse return null; + for (pd.methods, 0..) |m, i| { + if (std.mem.eql(u8, m.name, name)) return @intCast(i); + } + return null; +} + +/// Check if a type name is a registered protocol. +pub fn isProtocolType(self: *Lowering, type_name: []const u8) bool { + return self.program_index.protocol_decl_map.contains(type_name); +} + +/// Get protocol info for a TypeId (if it's a protocol type). +/// Protocol lookup. Thin delegation to the canonical owner +/// (`ProtocolResolver`, `protocols.zig`); kept on `Lowering` because ~9 +/// callers (dispatch sites here + `calls.zig`) reach it. +pub fn getProtocolInfo(self: *Lowering, ty: TypeId) ?ProtocolDeclInfo { + return self.protocolResolver().getProtocolInfo(ty); +} + +/// Get or create thunks for a (protocol, concrete_type) pair. +/// Returns a slice of FuncIds, one per protocol method. +pub fn getOrCreateThunks(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) []const FuncId { + // Key: "Proto\x00Type" + const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch return &.{}; + if (self.protocol_thunk_map.get(key)) |thunks| return thunks; + + // PLANNING: which methods need a thunk (owned by the registry). + const methods = self.protocolResolver().protocolMethodInfos(proto_name) orelse return &.{}; + var thunk_ids = std.ArrayList(FuncId).empty; + defer thunk_ids.deinit(self.alloc); + + // EMISSION: materialize one thunk per method (stays in Lowering). + for (methods) |method| { + const thunk_id = self.createProtocolThunk(proto_name, concrete_type_name, method); + thunk_ids.append(self.alloc, thunk_id) catch unreachable; + } + + const owned = self.alloc.dupe(FuncId, thunk_ids.items) catch unreachable; + self.protocol_thunk_map.put(key, owned) catch {}; + return owned; +} + +/// Emit the process-wide default Context as an LLVM static constant. +/// +/// @__sx_default_context = internal constant %Context { +/// %Allocator { ptr null, +/// ptr @__thunk_CAllocator_Allocator_alloc, +/// ptr @__thunk_CAllocator_Allocator_dealloc }, +/// ptr null +/// } +/// +/// Used by FFI inbound wrappers (Step 4) and the interp's default- +/// context call entry (Step 7). Only emitted when the program imports +/// `std.sx` — without that, Context / Allocator / CAllocator aren't +/// registered and the global has no purpose. +pub fn emitDefaultContextGlobal(self: *Lowering) void { + const saved_edc = self.emitting_default_context; + self.emitting_default_context = true; + defer self.emitting_default_context = saved_edc; + const tbl = &self.module.types; + const ctx_name_id = tbl.internString("Context"); + const ctx_ty = tbl.findByName(ctx_name_id) orelse return; + if (tbl.findByName(tbl.internString("Allocator")) == null) return; + if (tbl.findByName(tbl.internString("CAllocator")) == null) return; + + // Force the CAllocator → Allocator thunks to exist so we can + // reference them by FuncId in the static initializer. + const thunks = self.getOrCreateThunks("Allocator", "CAllocator"); + if (thunks.len < 2) return; + + // Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void } + // CAllocator is stateless, so ctx is null. + const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return; + alloc_fields[0] = .null_val; + alloc_fields[1] = .{ .func_ref = thunks[0] }; + alloc_fields[2] = .{ .func_ref = thunks[1] }; + + // Context value: { allocator: Allocator, data: *void } + const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return; + ctx_fields[0] = .{ .aggregate = alloc_fields }; + ctx_fields[1] = .null_val; + + const global_name = "__sx_default_context"; + const global_name_id = tbl.internString(global_name); + const gid = self.module.addGlobal(.{ + .name = global_name_id, + .ty = ctx_ty, + .init_val = .{ .aggregate = ctx_fields }, + .is_const = true, + }); + self.putGlobal(self.current_source_file, global_name, .{ .id = gid, .ty = ctx_ty }); +} + +/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret +/// The thunk calls ConcreteType.method(ctx, args...). +pub fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId { + // Build params: [__sx_ctx]? + ctx: *void + method params. + // Thunks are sx-side functions, so they get the implicit __sx_ctx + // at slot 0 when it's enabled program-wide. The concrete protocol + // receiver (ctx) follows at slot 1; user method args at slot 2+. + var params = std.ArrayList(inst_mod.Function.Param).empty; + defer params.deinit(self.alloc); + const void_ptr = self.module.types.ptrTo(.void); + const thunk_has_ctx = self.implicit_ctx_enabled; + if (thunk_has_ctx) { + params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr }) catch unreachable; + } + params.append(self.alloc, .{ .name = self.module.types.internString("ctx"), .ty = void_ptr }) catch unreachable; + for (method.param_types, 0..) |pty, i| { + var buf: [32]u8 = undefined; + const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg"; + params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable; + } + + // Generate unique name + var name_buf: [192]u8 = undefined; + const thunk_name = std.fmt.bufPrint(&name_buf, "__thunk_{s}_{s}_{s}", .{ concrete_type_name, proto_name, method.name }) catch "__thunk"; + const thunk_name_id = self.module.types.internString(thunk_name); + + // Save builder state + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + const saved_ctx_ref_thunk = self.current_ctx_ref; + defer self.current_ctx_ref = saved_ctx_ref_thunk; + + const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable; + var func = inst_mod.Function.init(thunk_name_id, owned_params, method.ret_type); + func.has_implicit_ctx = thunk_has_ctx; + const func_id = self.module.addFunction(func); + self.builder.func = func_id; + self.builder.inst_counter = @intCast(owned_params.len); + if (thunk_has_ctx) self.current_ctx_ref = Ref.fromIndex(0); + const entry_block = self.builder.appendBlock(self.module.types.internString("entry"), &.{}); + self.builder.switchToBlock(entry_block); + + // Ensure the concrete method is lowered + const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, method.name }) catch method.name; + if (!self.lowered_functions.contains(qualified)) { + if (self.program_index.fn_ast_map.contains(qualified)) { + self.lazyLowerFunction(qualified); + } else if (self.genericInstanceMethod(concrete_type_name, method.name)) |gm| { + // Generic-struct instance (`Combined__s64_s64`): the impl method is + // authored on the instance's STAMPED decl (CP-4). Monomorphize it + // for this instance's bindings so the thunk has a concrete + // `Combined__s64_s64.get` to call. + self.monomorphizeFunction(gm.fd, qualified, gm.bindings); + } + } + + // Call the concrete method: ConcreteType.method(__sx_ctx?, ctx, args...). + // The concrete method is itself an sx function that takes the + // implicit __sx_ctx at slot 0 (when implicit_ctx is enabled); we + // forward the thunk's own __sx_ctx. + if (self.resolveFuncByName(qualified)) |concrete_fid| { + const concrete_func = &self.module.functions.items[@intFromEnum(concrete_fid)]; + var call_args = std.ArrayList(Ref).empty; + defer call_args.deinit(self.alloc); + + // Slot offsets inside the thunk: __sx_ctx at 0 (if present), + // protocol receiver (ctx) at slot user_base, user args at +1, +2... + const user_base: u32 = if (thunk_has_ctx) 1 else 0; + + // Forward our __sx_ctx to the concrete method's __sx_ctx slot. + if (concrete_func.has_implicit_ctx) { + call_args.append(self.alloc, self.current_ctx_ref) catch unreachable; + } + + // Pass ctx as the next arg (it's the concrete *Type disguised as *void). + // If the concrete method expects a value (e.g., f32) not a pointer, load from ctx. + const ctx_ref = Ref.fromIndex(user_base); + const concrete_receiver_idx: usize = if (concrete_func.has_implicit_ctx) 1 else 0; + if (concrete_receiver_idx < concrete_func.params.len) { + const first_concrete_ty = concrete_func.params[concrete_receiver_idx].ty; + const first_info = self.module.types.get(first_concrete_ty); + if (first_info != .pointer) { + // Concrete expects value — load from ctx pointer + call_args.append(self.alloc, self.builder.load(ctx_ref, first_concrete_ty)) catch unreachable; + } else { + call_args.append(self.alloc, ctx_ref) catch unreachable; + } + } else { + call_args.append(self.alloc, ctx_ref) catch unreachable; + } + for (method.param_types, 0..) |proto_pty, i| { + var arg_ref = Ref.fromIndex(@intCast(user_base + 1 + i)); + // If protocol param is a pointer (Self→*void) but concrete method + // expects a value type, load the value from the pointer. + const concrete_idx = concrete_receiver_idx + 1 + i; + if (concrete_idx < concrete_func.params.len) { + const concrete_pty = concrete_func.params[concrete_idx].ty; + const proto_info = self.module.types.get(proto_pty); + const concrete_info = self.module.types.get(concrete_pty); + if (proto_info == .pointer and concrete_info != .pointer) { + arg_ref = self.builder.load(arg_ref, concrete_pty); + } + } + call_args.append(self.alloc, arg_ref) catch unreachable; + } + const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable; + const concrete_ret = concrete_func.ret; + const result = self.builder.call(concrete_fid, owned_args, concrete_ret); + if (method.ret_type != .void) { + // If protocol returns *void (Self) but concrete returns a value type, + // box the value: alloca+store and return the pointer + const ret_info = self.module.types.get(method.ret_type); + const concrete_ret_info = self.module.types.get(concrete_ret); + if (ret_info == .pointer and concrete_ret_info != .pointer) { + const slot = self.builder.alloca(concrete_ret); + self.builder.store(slot, result); + self.builder.ret(slot, method.ret_type); + } else { + self.builder.ret(result, method.ret_type); + } + } else { + self.builder.retVoid(); + } + } else { + // Can't resolve concrete method — emit unreachable + _ = self.builder.emit(.{ .@"unreachable" = {} }, .void); + } + self.builder.finalize(); + + // Restore builder state + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + + return func_id; +} + +/// Build a protocol value from a concrete pointer. +/// For inline protocols: struct_init { ctx, thunk1, thunk2, ... } +/// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated +/// When `heap_copy` is true, the concrete data is heap-copied so the protocol value +/// outlives the current stack frame (used when source is a value, not an explicit pointer). +/// When false, the pointer is used directly (user manages the pointee's lifetime). +pub fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []const u8, concrete_type_name: []const u8, proto_ty: TypeId, concrete_ty: TypeId, heap_copy: bool) Ref { + const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return concrete_ptr; + const thunks = self.getOrCreateThunks(proto_name, concrete_type_name); + if (thunks.len != pd.methods.len) return concrete_ptr; + + const void_ptr_ty = self.module.types.ptrTo(.void); + + // When source is a value (not an explicit pointer), heap-allocate + // so the protocol value outlives the current stack frame. + // When source is an explicit pointer (xx @obj), use it directly — + // the user is responsible for the pointee's lifetime. + var ctx_ptr = concrete_ptr; + if (heap_copy) { + const concrete_size = self.module.types.typeSizeBytes(concrete_ty); + const size_ref = self.builder.constInt(@intCast(concrete_size), .s64); + const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty); + _ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty); + ctx_ptr = heap_ptr; + } + + if (pd.is_inline) { + // Inline: { ctx, fn1, fn2, ... } + var field_vals = std.ArrayList(Ref).empty; + defer field_vals.deinit(self.alloc); + field_vals.append(self.alloc, ctx_ptr) catch unreachable; + for (thunks) |thunk_id| { + const fn_ref = self.builder.emit(.{ .func_ref = thunk_id }, void_ptr_ty); + field_vals.append(self.alloc, fn_ref) catch unreachable; + } + const owned = self.alloc.dupe(Ref, field_vals.items) catch unreachable; + return self.builder.emit(.{ .struct_init = .{ .fields = owned } }, proto_ty); + } else { + // Vtable: { ctx, vtable_ptr } + // Vtable is a global constant (same function pointers for every instance + // of the same Protocol+ConcreteType pair). Cached per pair. + const vtable_ty = self.protocol_vtable_type_map.get(proto_name) orelse return concrete_ptr; + + // Build cache key: "Proto\x00Type" + const key = std.fmt.allocPrint(self.alloc, "{s}\x00{s}", .{ proto_name, concrete_type_name }) catch unreachable; + + const vtable_global_id = self.protocol_vtable_global_map.get(key) orelse blk: { + // Create vtable global with function pointer initializer + const global_name = std.fmt.allocPrint(self.alloc, "__{s}__{s}__vtable", .{ proto_name, concrete_type_name }) catch unreachable; + const global_name_id = self.module.types.strings.intern(self.alloc, global_name); + const thunk_ids = self.alloc.dupe(FuncId, thunks) catch unreachable; + const gid = self.module.addGlobal(.{ + .name = global_name_id, + .ty = vtable_ty, + .init_val = .{ .vtable = thunk_ids }, + .is_const = true, + }); + self.protocol_vtable_global_map.put(key, gid) catch {}; + break :blk gid; + }; + + // Reference the vtable global's address + const vtable_ptr_ty = self.module.types.ptrTo(vtable_ty); + const vtable_addr = self.builder.emit(.{ .global_addr = vtable_global_id }, vtable_ptr_ty); + + // Build protocol struct: { ctx, &vtable } + var proto_fields = std.ArrayList(Ref).empty; + defer proto_fields.deinit(self.alloc); + proto_fields.append(self.alloc, ctx_ptr) catch unreachable; + proto_fields.append(self.alloc, vtable_addr) catch unreachable; + const proto_owned = self.alloc.dupe(Ref, proto_fields.items) catch unreachable; + return self.builder.emit(.{ .struct_init = .{ .fields = proto_owned } }, proto_ty); + } +} + +/// Emit protocol method dispatch for a protocol-typed receiver. +/// Returns the call result ref. +pub fn emitProtocolDispatch(self: *Lowering, receiver: Ref, proto_info: ProtocolDeclInfo, method_name: []const u8, args: []const Ref, proto_ty: TypeId) Ref { + // Find method index + var method_idx: ?usize = null; + var method_info: ?ProtocolMethodInfo = null; + for (proto_info.methods, 0..) |m, i| { + if (std.mem.eql(u8, m.name, method_name)) { + method_idx = i; + method_info = m; + break; + } + } + const mi = method_info orelse return self.emitError(method_name, null); + const midx = method_idx orelse 0; + + // Extract ctx from protocol struct (field 0) + const void_ptr = self.module.types.ptrTo(.void); + const ctx = self.builder.structGet(receiver, 0, void_ptr); + + // Extract fn_ptr + const fn_ptr = if (proto_info.is_inline) blk: { + // Inline: fn_ptr at field 1+method_idx + break :blk self.builder.structGet(receiver, @intCast(1 + midx), void_ptr); + } else blk: { + // Vtable: load vtable struct, extract fn_ptr at method_idx + const vtable_ptr = self.builder.structGet(receiver, 1, void_ptr); + const vtable_ty = self.protocol_vtable_type_map.get(proto_info.name) orelse return self.emitError("vtable", null); + const vtable = self.builder.emit(.{ .deref = .{ .operand = vtable_ptr } }, vtable_ty); + break :blk self.builder.structGet(vtable, @intCast(midx), void_ptr); + }; + _ = proto_ty; + + // Build call args: [__sx_ctx]? + receiver_ctx + user args. + // Protocol thunks are sx-side, so they carry the implicit __sx_ctx + // at slot 0 when the program uses Context — forward our caller's + // ctx so the thunk's body (and the concrete method it forwards to) + // sees the same Context as the dispatching code. + var call_args = std.ArrayList(Ref).empty; + defer call_args.deinit(self.alloc); + if (self.implicit_ctx_enabled) { + call_args.append(self.alloc, self.current_ctx_ref) catch unreachable; + } + call_args.append(self.alloc, ctx) catch unreachable; + for (args, 0..) |a, i| { + const expected_ty = if (i < mi.param_types.len) mi.param_types[i] else void_ptr; + const arg_ty = self.builder.getRefType(a); + + // Untargeted `null` lowers as const_null with type .void. Re-emit it + // as a null of the expected pointer type instead of alloca'ing void. + if (arg_ty == .void and expected_ty == void_ptr) { + call_args.append(self.alloc, self.builder.constNull(void_ptr)) catch unreachable; + continue; + } + // A protocol method that expects `*void` accepts any single-pointer + // value directly (`*T`, `[*]T`). Only wrap non-pointer values in an + // alloca-slot — wrapping a pointer would pass the stack slot's + // address instead of the actual pointer, and the callee would read + // 8 bytes of pointer plus garbage from beyond the stack. + const is_pointer_ty = if (!arg_ty.isBuiltin()) blk: { + const info = self.module.types.get(arg_ty); + break :blk info == .pointer or info == .many_pointer; + } else false; + if (expected_ty == void_ptr and arg_ty != void_ptr and !is_pointer_ty) { + const slot = self.builder.alloca(arg_ty); + self.builder.store(slot, a); + call_args.append(self.alloc, slot) catch unreachable; + } else { + // Coerce to match declared parameter type (critical for WASM strict signatures) + const coerced = self.coerceToType(a, arg_ty, expected_ty); + call_args.append(self.alloc, coerced) catch unreachable; + } + } + const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable; + const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type); + + // If the protocol method was declared `-> Self` (encoded here as *void) + // and the caller expects a value type, unbox: load the concrete value + // from the returned pointer. A literal `-> *void` return is NOT + // auto-loaded — it's a real pointer whose pointee size we don't know. + if (mi.ret_is_self) { + if (self.target_type) |target| { + const target_info = self.module.types.get(target); + if (target_info != .pointer) { + return self.builder.load(raw_result, target); + } + } + } + return raw_result; +} + +/// Resolve the concrete type name for protocol erasure. +/// Handles both direct types and pointer-to-types. +pub fn resolveConcreteTypeName(self: *Lowering, ty: TypeId) ?[]const u8 { + if (ty.isBuiltin()) { + // Primitive types like s64 — check if they have toName() + return self.module.types.typeName(ty); + } + const info = self.module.types.get(ty); + if (info == .pointer) { + // *ConcreteType → resolve pointee + const pointee = info.pointer.pointee; + if (pointee.isBuiltin()) return self.module.types.typeName(pointee); + const pi = self.module.types.get(pointee); + if (pi == .@"struct") return self.module.types.getString(pi.@"struct".name); + return null; + } + if (info == .@"struct") return self.module.types.getString(info.@"struct".name); + return null; +} + +// ── Helpers ───────────────────────────────────────────────────── From e884e87f80e392d8d7718cd1c894a66f94537828 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 10 Jun 2026 13:58:09 +0300 Subject: [PATCH 2/2] 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); + } +}