From b163c4a3fcc88b9983abde97246a79178c033888 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 10 Jun 2026 14:14:49 +0300 Subject: [PATCH] refactor(B6.2): move Obj-C defined-class emission to lower/objc_class.zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim relocation of the 23-method defined-class cluster (IMP/property emission: class/alloc/static/dealloc IMPs, property getters/setters + ARC runtime decls, defined-state field access, property/method chain lookup, string-constant globals) plus the single-home ObjcDefinedStateField type into src/ir/lower/objc_class.zig. 23 aliases on Lowering keep all call sites (incl. expr_typer.zig facade and lower/stmt.zig) unchanged. Zero pub-flips — all callees were already public from earlier steps. Gate: zig build OK; zig build test 426/426; run_examples 541/0; all 37 .ir snapshots byte-identical, zero expected/ churn. --- src/ir/lower.zig | 1306 +--------------------------------- src/ir/lower/objc_class.zig | 1329 +++++++++++++++++++++++++++++++++++ 2 files changed, 1355 insertions(+), 1280 deletions(-) create mode 100644 src/ir/lower/objc_class.zig diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9f575b3..bd1a07f 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -42,6 +42,7 @@ const lower_nominal = @import("lower/nominal.zig"); const lower_protocol = @import("lower/protocol.zig"); const lower_coerce = @import("lower/coerce.zig"); const lower_ffi = @import("lower/ffi.zig"); +const lower_objc_class = @import("lower/objc_class.zig"); const TypeId = types.TypeId; const StringId = types.StringId; @@ -9208,1286 +9209,6 @@ pub const Lowering = struct { return 0; } - /// 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 - /// `private native sx_(...)` (declared in the bundled - /// classes.dex by `jni_java_emit`) without an explicit `RegisterNatives` - /// call — i.e. `Java___sx_1`. - /// - /// Param ABI: prepended `(env: *void, self: *void)` (JNIEnv* + jobject - /// receiver), followed by the user-declared params with pointer types - /// type-erased to `*void` (JNI carries jobjects, not sx-typed handles — - /// future work can keep richer typing inside the body when needed). - /// Eagerly lower bodied instance methods on every sx-defined - /// `#objc_class`. The Obj-C runtime invokes these via the IMP - /// pointers wired up in M1.2 A.4 — no sx-side call path triggers - /// lazy lowering, so we walk the cache and force-lower here. - /// `lowerFunction` sets `current_foreign_class` automatically based - /// on the qualified name, so `*Self` substitutions in the body - /// resolve correctly (M1.2 A.2b). After the bodies are lowered, - /// `emitObjcDefinedClassImps` wraps each with a C-ABI trampoline - /// (M1.2 A.4b.ii). - pub fn lowerObjcDefinedClassMethods(self: *Lowering) void { - for (self.module.objc_defined_class_cache.items) |entry| { - const fcd = entry.decl; - for (fcd.members) |m| { - const method = switch (m) { - .method => |md| md, - else => continue, - }; - if (method.body == null) continue; - const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue; - self.lazyLowerFunction(qualified); - } - } - // Now the bodies are lowered — emit the C-ABI IMP trampolines - // that bridge `objc_msgSend` invocations to them. - self.emitObjcDefinedClassImps(); - } - - /// If `obj_expr` is typed as a pointer to a foreign Obj-C class - /// and that class (or any of its `#extends` ancestors) declares a - /// `#property` field with the given name, return the - /// `ForeignFieldDecl`. M2.2 + M2.3. - pub fn lookupObjcPropertyOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ast.ForeignFieldDecl { - const obj_ty = self.inferExprType(obj_expr); - if (obj_ty.isBuiltin()) return null; - const ptr_info = self.module.types.get(obj_ty); - if (ptr_info != .pointer) return null; - const pointee_info = self.module.types.get(ptr_info.pointer.pointee); - if (pointee_info != .@"struct") return null; - const struct_name = self.module.types.getString(pointee_info.@"struct".name); - const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null; - if (fcd.runtime != .objc_class and fcd.runtime != .objc_protocol) return null; - return self.findForeignPropertyInChain(fcd, field_name); - } - - /// Walk the `#extends` chain looking for a method by name. M2.3. - /// Returns the owning fcd + the method decl, or null if no ancestor - /// declares it. Depth-capped at 16 to break accidental cycles - /// (real Obj-C class chains rarely exceed 6 levels). - pub fn findForeignMethodInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, method_name: []const u8) ?struct { fcd: *const ast.ForeignClassDecl, method: ast.ForeignMethodDecl } { - var current: *const ast.ForeignClassDecl = fcd; - var depth: u32 = 0; - while (depth < 16) : (depth += 1) { - for (current.members) |m| switch (m) { - .method => |md| if (std.mem.eql(u8, md.name, method_name)) return .{ .fcd = current, .method = md }, - else => {}, - }; - // Not on this level — follow `#extends ParentName`. - const parent = blk: { - for (current.members) |m| switch (m) { - .extends => |p| break :blk p, - else => {}, - }; - break :blk null; - } orelse return null; - current = self.program_index.foreign_class_map.get(parent) orelse return null; - } - return null; - } - - /// Walk the `#extends` chain looking for a `#property` field by - /// name. M2.3 companion to findForeignMethodInChain. - fn findForeignPropertyInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, field_name: []const u8) ?ast.ForeignFieldDecl { - var current: *const ast.ForeignClassDecl = fcd; - var depth: u32 = 0; - while (depth < 16) : (depth += 1) { - for (current.members) |m| switch (m) { - .field => |f| if (f.is_property and std.mem.eql(u8, f.name, field_name)) return f, - else => {}, - }; - const parent = blk: { - for (current.members) |m| switch (m) { - .extends => |p| break :blk p, - else => {}, - }; - break :blk null; - } orelse return null; - current = self.program_index.foreign_class_map.get(parent) orelse return null; - } - return null; - } - - const ObjcDefinedStateField = struct { - field_ty: TypeId, - state_ty: TypeId, - field_idx: u32, - fcd: *const ast.ForeignClassDecl, - }; - - /// State-field-access info: if obj_expr is * - /// and `field_name` is in the state struct (not a property), - /// returns the field's TypeId, the state struct's TypeId, and - /// the field's index. M1.2 A.3 supports. - pub fn lookupObjcDefinedStateFieldOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ObjcDefinedStateField { - const obj_ty = self.inferExprType(obj_expr); - if (obj_ty.isBuiltin()) return null; - const ptr_info = self.module.types.get(obj_ty); - if (ptr_info != .pointer) return null; - const pointee_info = self.module.types.get(ptr_info.pointer.pointee); - if (pointee_info != .@"struct") return null; - const struct_name = self.module.types.getString(pointee_info.@"struct".name); - const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null; - // Only sx-defined Obj-C classes have a state struct. Foreign - // classes' fields are purely declaration metadata (no state). - if (fcd.is_foreign or fcd.runtime != .objc_class) return null; - // Skip property fields — those dispatch via the M2.2 getter/setter - // path. Plain instance fields take the ivar+gep path. - for (fcd.members) |m| switch (m) { - .field => |f| { - if (std.mem.eql(u8, f.name, field_name)) { - if (f.is_property) return null; - const state_ty = self.objc().objcDefinedStateStructType(fcd); - const state_info = self.module.types.get(state_ty); - if (state_info != .@"struct") return null; - const fname_id = self.module.types.internString(f.name); - for (state_info.@"struct".fields, 0..) |sf, idx| { - if (sf.name == fname_id) { - return .{ - .field_ty = sf.ty, - .state_ty = state_ty, - .field_idx = @intCast(idx), - .fcd = fcd, - }; - } - } - return null; - } - }, - else => {}, - }; - return null; - } - - /// Lower a read of `self.field` (or `obj.field`) on a sx-defined - /// Obj-C class: `state = object_getIvar(self, load(ivar_global))` - /// then `struct_gep(state, idx)` + load. M1.2 A.3 — the runtime - /// hop through the hidden ivar. - fn lowerObjcDefinedStateFieldRead( - self: *Lowering, - obj_expr: *const ast.Node, - info: ObjcDefinedStateField, - ) Ref { - const obj_ref = self.lowerExpr(obj_expr); - const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return Ref.none; - const ptr_void = self.module.types.ptrTo(.void); - const field_addr = self.builder.emit(.{ .struct_gep = .{ - .base = state_ptr, - .field_index = info.field_idx, - .base_type = info.state_ty, - } }, ptr_void); - return self.builder.load(field_addr, info.field_ty); - } - - /// `state = object_getIvar(obj, load(___state_ivar))`. Shared - /// helper for state-field read + write (M1.2 A.3). - pub fn lowerObjcDefinedStateForObj(self: *Lowering, obj_ref: Ref, fcd: *const ast.ForeignClassDecl) ?Ref { - const ptr_void = self.module.types.ptrTo(.void); - const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null; - defer self.alloc.free(ivar_global_name); - const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return null; - const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); - const ivar_handle = self.builder.load(ivar_addr, ptr_void); - const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); - const args = self.alloc.alloc(Ref, 2) catch return null; - args[0] = obj_ref; - args[1] = ivar_handle; - return self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = args } }, ptr_void); - } - - /// Lower `obj.field` for an Obj-C `#property` field as - /// `objc_msg_send(obj, sel_)`. M2.2 — getter side. - /// The setter side lives in the assignment-statement lowering. - fn lowerObjcPropertyGetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, _: []const u8, _: ast.Span) Ref { - const obj_ref = self.lowerExpr(obj_expr); - const ret_ty = self.resolveType(field.field_type); - const vptr_ty = self.module.types.ptrTo(.void); - // The selector for a property getter is the field name verbatim - // (Obj-C convention; the override hook is for niche cases like - // `isHidden` and lands with M2.2's modifier handling). - const sel_slot_gid = self.internObjcSelector(field.name); - const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty)); - const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); - return self.builder.emit(.{ .objc_msg_send = .{ - .recv = obj_ref, - .sel = sel, - .args = &.{}, - } }, ret_ty); - } - - /// Lower `obj.field = val` for an Obj-C `#property` field as - /// `objc_msg_send(obj, sel_set:, val)`. M2.2 — setter side. - /// Selector: prepend "set", capitalize the first letter of the - /// field name, append ":". `backgroundColor` → `setBackgroundColor:`. - pub fn lowerObjcPropertySetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, val: Ref) void { - const obj_ref = self.lowerExpr(obj_expr); - const vptr_ty = self.module.types.ptrTo(.void); - - // Build the setter selector. - var sel_buf = std.ArrayList(u8).empty; - defer sel_buf.deinit(self.alloc); - sel_buf.appendSlice(self.alloc, "set") catch unreachable; - if (field.name.len > 0) { - sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; - sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; - } - sel_buf.append(self.alloc, ':') catch unreachable; - const sel_str = self.alloc.dupe(u8, sel_buf.items) catch unreachable; - - const sel_slot_gid = self.internObjcSelector(sel_str); - const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty)); - const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); - const args = self.alloc.alloc(Ref, 1) catch unreachable; - args[0] = val; - _ = self.builder.emit(.{ .objc_msg_send = .{ - .recv = obj_ref, - .sel = sel, - .args = args, - } }, .void); - } - - /// Get a FuncId for an external C-callconv function. If a function - /// with this exported name already exists in the module (e.g. - /// declared by stdlib `#foreign` decl), return it; otherwise - /// declare it fresh with the given signature. - /// - /// One helper instead of a `getFid` per runtime function — - /// avoids per-function cache fields and per-function boilerplate. - fn ensureCRuntimeDecl(self: *Lowering, name: []const u8, param_tys: []const TypeId, ret_ty: TypeId) FuncId { - const name_id = self.module.types.internString(name); - for (self.module.functions.items, 0..) |f, i| { - if (f.name == name_id) return FuncId.fromIndex(@intCast(i)); - } - var params = std.ArrayList(inst_mod.Function.Param).empty; - for (param_tys, 0..) |pty, i| { - // Param names don't matter at the LLVM ABI boundary — - // synthesize generic ones (`a0`, `a1`, ...) so we don't - // need a parallel name list per call site. - const synth = std.fmt.allocPrint(self.alloc, "a{d}", .{i}) catch unreachable; - params.append(self.alloc, .{ - .name = self.module.types.internString(synth), - .ty = pty, - }) catch unreachable; - } - const fid = self.builder.declareExtern(name_id, params.toOwnedSlice(self.alloc) catch unreachable, ret_ty); - self.module.getFunctionMut(fid).call_conv = .c; - return fid; - } - - /// For each bodied instance method on a sx-defined `#objc_class`, - /// emit a C-ABI IMP trampoline that the Obj-C runtime calls (after - /// the dispatch path from `objc_msgSend`). The trampoline: - /// 1. Loads the cached ivar handle from `@___state_ivar`. - /// 2. Calls `object_getIvar(obj, ivar)` to get the `*State` - /// state pointer. - /// 3. Calls the sx body `@.(__sx_default_context, - /// state, ...user_args)` (default sx-callconv). - /// 4. Returns the result (or `ret void`). - /// - /// IMP name: `____imp`. emit_llvm's - /// constructor (A.4b.ii companion) registers this via - /// `class_addMethod` with a derived selector + type encoding. - fn emitObjcDefinedClassImps(self: *Lowering) void { - for (self.module.objc_defined_class_cache.items) |entry| { - const fcd = entry.decl; - // Pin to the class's defining module (E4) so the IMP trampolines' - // method-signature types (`-> BOOL`, param types) resolve where they - // are visible, not at whatever lowering site triggered emission. - const saved_src = self.current_source_file; - defer self.setCurrentSourceFile(saved_src); - if (fcd.source_file) |src| self.setCurrentSourceFile(src); - // Synthesize +alloc (M1.2 A.5) and -dealloc (M1.2 A.6). emit_llvm - // registers +alloc on the metaclass and -dealloc on the class - // itself after objc_registerClassPair. - self.emitObjcDefinedClassAllocImp(fcd); - self.emitObjcDefinedClassDeallocImp(fcd); - for (fcd.members) |m| { - switch (m) { - .method => |method| { - if (method.body == null) continue; - self.emitObjcDefinedClassImp(fcd, method); - }, - .field => |field| { - // M2.2 second pass — sx-defined property fields - // synthesize getter (+ setter unless `readonly`) - // IMPs that GEP into the state struct. - if (field.is_property) { - self.emitObjcDefinedClassPropertyImps(fcd, field); - } - }, - else => {}, - } - } - } - } - - /// Lazily declare libobjc's ARC runtime helpers. Idempotent — uses - /// `ensureCRuntimeDecl` which skips already-declared symbols. Called - /// from the property setter/getter and -dealloc emission paths when - /// they need to emit a retain/release/storeWeak/etc. - fn ensureArcRuntimeDecls(self: *Lowering) void { - const ptr_void = self.module.types.ptrTo(.void); - _ = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); - _ = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); - _ = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); - _ = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); - _ = self.ensureCRuntimeDecl("objc_initWeak", &.{ ptr_void, ptr_void }, ptr_void); - _ = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); - } - - /// M2.2 second pass — emit synthesized getter/setter IMPs for a - /// property field on a sx-defined `#objc_class`. The state struct - /// already holds the field (via objcDefinedStateStructType); the - /// IMPs just dispatch a load/store through the `__sx_state` ivar. - /// - /// Getter IMP: `____imp(self, _cmd) -> T` - /// state = object_getIvar(self, load(___state_ivar)) - /// return state. - /// - /// Setter IMP (skipped if `readonly` in modifiers): - /// `___set_imp(self, _cmd, val) -> void` - /// state = object_getIvar(self, load(___state_ivar)) - /// state. = val - /// - /// Both IMPs land in the cache's methods slice with appropriate - /// selectors + encodings; emit_llvm's class_addMethod loop wires - /// them up like any other instance method. - fn emitObjcDefinedClassPropertyImps(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl) void { - const state_ty = self.objc().objcDefinedStateStructType(fcd); - const state_info = self.module.types.get(state_ty); - if (state_info != .@"struct") return; - // Find the field's index in the state struct. - const field_name_id = self.module.types.internString(field.name); - var field_idx: ?u32 = null; - for (state_info.@"struct".fields, 0..) |sf, i| { - if (sf.name == field_name_id) { - field_idx = @intCast(i); - break; - } - } - const fidx = field_idx orelse return; - const field_ty = self.resolveType(field.field_type); - - // M4.B: validate modifiers + resolve ARC kind. Side-effect: emits - // diagnostics for typos, weak-on-non-object, ambiguous *void, etc. - // For now the setter/getter still emit bare load/store; subsequent - // M4.B commits wire the actual ARC ops keyed on this kind. - _ = self.objc().objcPropertyKind(field); - - // (1) Getter: ____imp - self.emitObjcDefinedPropertyGetter(fcd, field, state_ty, fidx, field_ty); - - // (2) Setter — skipped for `readonly`. - var is_readonly = false; - for (field.property_modifiers) |mod| { - if (std.mem.eql(u8, mod, "readonly")) { - is_readonly = true; - break; - } - } - if (!is_readonly) { - self.emitObjcDefinedPropertySetter(fcd, field, state_ty, fidx, field_ty); - } - - // (3) Register in the cache's methods slice. Both IMPs use the - // method-registration pipeline that lands in class_addMethod - // calls from emit_llvm. - self.registerObjcDefinedPropertyMethodEntries(fcd, field, field_ty, is_readonly); - } - - fn emitObjcDefinedPropertyGetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void { - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, field_ty); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // state = object_getIvar(self, load @___state_ivar) - const self_ref = Ref.fromIndex(0); - const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; - defer self.alloc.free(ivar_global_name); - const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; - const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); - const ivar_handle = self.builder.load(ivar_addr, ptr_void); - const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); - const get_args = self.alloc.alloc(Ref, 2) catch return; - get_args[0] = self_ref; - get_args[1] = ivar_handle; - const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); - - const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void); - - // M4.B getter — weak fields go through objc_loadWeakRetained + - // objc_autorelease for race-safe reads. The bare-load path - // (strong/copy/assign) is the common case and reads the slot - // directly. - const kind = self.objc().objcPropertyKind(field); - if (kind == .weak) { - self.ensureArcRuntimeDecls(); - const load_weak_fid = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); - const autorelease_fid = self.ensureCRuntimeDecl("objc_autorelease", &.{ptr_void}, ptr_void); - - // retained = objc_loadWeakRetained(field_addr) - // - atomic upgrade-to-strong via libobjc's side-table; if the - // target deinitialised, returns null. The caller gets a - // +1 retained reference (or null). - const load_args = self.alloc.alloc(Ref, 1) catch return; - load_args[0] = field_addr; - const retained = self.builder.emit(.{ .call = .{ .callee = load_weak_fid, .args = load_args } }, ptr_void); - - // autoreleased = objc_autorelease(retained) - // - drops it into the current pool so the caller doesn't need - // to manually release. Returns the same pointer (typed). - const ar_args = self.alloc.alloc(Ref, 1) catch return; - ar_args[0] = retained; - const autoreleased = self.builder.emit(.{ .call = .{ .callee = autorelease_fid, .args = ar_args } }, ptr_void); - - self.builder.ret(autoreleased, field_ty); - self.builder.finalize(); - return; - } - - // strong / copy / assign — bare load. - const val = self.builder.load(field_addr, field_ty); - self.builder.ret(val, field_ty); - self.builder.finalize(); - } - - fn emitObjcDefinedPropertySetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void { - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - // Setter selector: set: → imp name: ___set_imp - var setter_field_buf = std.ArrayList(u8).empty; - defer setter_field_buf.deinit(self.alloc); - setter_field_buf.appendSlice(self.alloc, "set") catch unreachable; - if (field.name.len > 0) { - setter_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; - setter_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; - } - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_field_buf.items }) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("val"), .ty = field_ty }) catch return; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, .void); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - const self_ref = Ref.fromIndex(0); - const val_ref = Ref.fromIndex(2); - const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; - defer self.alloc.free(ivar_global_name); - const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; - const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); - const ivar_handle = self.builder.load(ivar_addr, ptr_void); - const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); - const get_args = self.alloc.alloc(Ref, 2) catch return; - get_args[0] = self_ref; - get_args[1] = ivar_handle; - const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); - - const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void); - - // M4.B setter — emit ARC ops based on the property's modifier kind. - const kind = self.objc().objcPropertyKind(field); - switch (kind) { - .assign => { - // Primitives or explicit assign: bare store, no ARC. - self.builder.store(field_addr, val_ref); - }, - .strong => { - // Retain new, release old. Order matters: retain first - // (in case val == old, we don't release before retain). - self.ensureArcRuntimeDecls(); - const retain_fid = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); - const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); - - // old = load field_addr - const old_val = self.builder.load(field_addr, field_ty); - // new = objc_retain(val) - const retain_args = self.alloc.alloc(Ref, 1) catch return; - retain_args[0] = val_ref; - _ = self.builder.emit(.{ .call = .{ .callee = retain_fid, .args = retain_args } }, ptr_void); - // store field_addr, val - self.builder.store(field_addr, val_ref); - // objc_release(old) — Apple's runtime treats release(NULL) as a no-op, - // so we skip an explicit null-check (saves a branch on every assign). - const release_args = self.alloc.alloc(Ref, 1) catch return; - release_args[0] = old_val; - _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void); - }, - .weak => { - // objc_storeWeak(field_addr, val) handles first-store - // (init) and re-store (destroy old + init new) atomically. - self.ensureArcRuntimeDecls(); - const store_weak_fid = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); - const store_args = self.alloc.alloc(Ref, 2) catch return; - store_args[0] = field_addr; - store_args[1] = val_ref; - _ = self.builder.emit(.{ .call = .{ .callee = store_weak_fid, .args = store_args } }, ptr_void); - }, - .copy => { - // copy = objc_msgSend(val, sel_copy) — returns retained - // (NSCopying contract). - // Release old, then store the copy. - self.ensureArcRuntimeDecls(); - const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); - - // Load + cache the `copy` selector slot. - const sel_copy_gid = self.internObjcSelector("copy"); - const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_copy_gid }, self.module.types.ptrTo(ptr_void)); - const sel_copy = self.builder.emit(.{ .load = .{ .operand = sel_slot_ptr } }, ptr_void); - - // copy = [val copy] - const copy_args = self.alloc.alloc(Ref, 0) catch return; - const copied = self.builder.emit(.{ .objc_msg_send = .{ - .recv = val_ref, - .sel = sel_copy, - .args = copy_args, - } }, ptr_void); - - const old_val = self.builder.load(field_addr, field_ty); - self.builder.store(field_addr, copied); - const release_args = self.alloc.alloc(Ref, 1) catch return; - release_args[0] = old_val; - _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void); - }, - } - self.builder.retVoid(); - self.builder.finalize(); - } - - /// Append the property's getter (and setter, unless readonly) - /// entries to the class's method-registration slice so emit_llvm - /// calls class_addMethod on each. Selectors + encodings derived - /// from the field type. - fn registerObjcDefinedPropertyMethodEntries(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, field_ty: TypeId, is_readonly: bool) void { - const cur = self.module.lookupObjcDefinedClass(fcd.name) orelse return; - _ = cur; - // Find the existing entry and grow its methods slice. - var new_methods = std.ArrayList(Module.ObjcDefinedMethodEntry).empty; - for (self.module.objc_defined_class_cache.items) |entry| { - if (!std.mem.eql(u8, entry.name, fcd.name)) continue; - for (entry.methods) |m| new_methods.append(self.alloc, m) catch unreachable; - - // Getter entry — selector = field name, encoding = "@:". - const getter_enc = self.objc().objcTypeEncodingFromSignature(field_ty, &.{}, null) catch return; - const getter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return; - new_methods.append(self.alloc, .{ - .sel = field.name, - .encoding = getter_enc, - .imp_name = getter_imp_name, - .is_class = false, - }) catch unreachable; - - // Setter entry — selector = set:, encoding = "v@:". - if (!is_readonly) { - var sel_buf = std.ArrayList(u8).empty; - defer sel_buf.deinit(self.alloc); - sel_buf.appendSlice(self.alloc, "set") catch unreachable; - if (field.name.len > 0) { - sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; - sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; - } - sel_buf.append(self.alloc, ':') catch unreachable; - const setter_sel = self.alloc.dupe(u8, sel_buf.items) catch return; - - const setter_enc = self.objc().objcTypeEncodingFromSignature(.void, &.{field_ty}, null) catch return; - - var setter_imp_field_buf = std.ArrayList(u8).empty; - defer setter_imp_field_buf.deinit(self.alloc); - setter_imp_field_buf.appendSlice(self.alloc, "set") catch unreachable; - if (field.name.len > 0) { - setter_imp_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; - setter_imp_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; - } - const setter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_imp_field_buf.items }) catch return; - - new_methods.append(self.alloc, .{ - .sel = setter_sel, - .encoding = setter_enc, - .imp_name = setter_imp_name, - .is_class = false, - }) catch unreachable; - } - break; - } - const slice = new_methods.toOwnedSlice(self.alloc) catch return; - self.module.setObjcDefinedClassMethods(fcd.name, slice); - } - - fn emitObjcDefinedClassImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { - // Class methods (no `*Self` first param) skip the ivar read — - // they have no instance state to thread through. - if (md.is_static) { - self.emitObjcDefinedClassStaticImp(fcd, md); - return; - } - - // Save+restore builder state — we're switching into a new fn - // mid-pass and need to restore for the next emit_llvm steps. - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - // C-ABI signature: (obj: *void, _cmd: *void, ...user_args) -> ret. - // User params skip index 0 (which is *Self). - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("obj"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - - // Set current_foreign_class so *Self in user-param resolution - // resolves to *State (M1.2 A.2b). Save+restore. - const saved_fc = self.current_foreign_class; - self.current_foreign_class = fcd; - defer self.current_foreign_class = saved_fc; - - const param_start: usize = 1; - for (md.params[param_start..], 0..) |p_node, i| { - // User params are reflected at the C-ABI boundary AS-IS — - // the runtime trampoline forwards them through to the body. - // *Self here would be a programming error (only the implicit - // self at index 0 is *Self), but we use resolveType to handle - // pointer types correctly. - const pty = self.resolveType(p_node); - params.append(self.alloc, .{ - .name = self.module.types.internString(md.param_names[param_start + i]), - .ty = pty, - }) catch return; - } - - const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, ret_ty); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // Pass the Obj-C receiver pointer through to the sx body as - // `self`. The body's `self: *Self` type resolves to the - // foreign-class stub (the opaque Obj-C type), matching Apple's - // Obj-C semantics where `self` IS the object. `self.field` - // access on a sx-defined class is rewritten by lowerFieldAccess - // to go through `object_getIvar(self, __sx_state_ivar)` and - // a struct_gep on the state struct — see M1.2 A.3. - const obj_ref = Ref.fromIndex(0); - - // Call sx body `@.(default_ctx, self, ...user_args)`. - const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return; - defer self.alloc.free(body_name); - const body_fid = self.resolveFuncByName(body_name) orelse return; - - const ctx_ref: ?Ref = blk: { - if (!self.implicit_ctx_enabled) break :blk null; - const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null; - break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); - }; - - // Build arg list: [ctx?] + self + user_args. - const num_user_args = params_slice.len - 2; // minus obj + _cmd - const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + 1 + num_user_args; - const call_args = self.alloc.alloc(Ref, num_call_args) catch return; - var idx: usize = 0; - if (ctx_ref) |c_ref| { - call_args[idx] = c_ref; - idx += 1; - } - call_args[idx] = obj_ref; - idx += 1; - var ip: usize = 2; - while (ip < params_slice.len) : (ip += 1) { - call_args[idx] = Ref.fromIndex(@intCast(ip)); - idx += 1; - } - - const call_ref = self.builder.emit(.{ .call = .{ - .callee = body_fid, - .args = call_args, - } }, ret_ty); - - // (4) Return. - if (ret_ty == .void) { - self.builder.retVoid(); - } else { - self.builder.ret(call_ref, ret_ty); - } - - self.builder.finalize(); - } - - /// Synthesize the `+alloc` IMP for an sx-defined `#objc_class`. - /// Class method registered on the metaclass — when `[SxFoo alloc]` - /// runs from Apple's runtime (Info.plist principal class, - /// NSCoder unarchive, UIKit reflection), this IMP fires. - /// - /// C-ABI: `(cls: id, _cmd: SEL) -> id`. No implicit ctx. - /// - /// Body (M4.0): - /// %instance = class_createInstance(cls, 0) - /// %ctx_addr = &__sx_default_context - /// %state = ctx_addr.allocator.alloc(STATE_SIZE) - /// memset(state, 0, STATE_SIZE) - /// state[0] = allocator ← capture for -dealloc - /// object_setIvar(instance, __sx_state_ivar, state) - /// ret instance - /// - /// Sx-side `Cls.alloc()` is intercepted at the call site (see - /// `lowerObjcStaticCall`) and emits the same sequence inline with - /// `current_ctx_ref` as the ctx — so `push Context.{ allocator = ... }` - /// flows through to per-instance allocator capture without going via - /// the IMP. - fn emitObjcDefinedClassAllocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_alloc_imp", .{fcd.name}) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, ptr_void); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // ctx_addr = &__sx_default_context — IMP runs in Apple's runtime - // context, no implicit sx ctx to inherit, so use the process-wide - // default allocator. Sx-side callers bypass this IMP entirely - // (compiler intercepts Cls.alloc()) and use their own - // `context.allocator`. - const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassAllocImp: __sx_default_context global missing for class '{s}' (compiler bug — scan pass did not register the default context)", .{fcd.name}); - } - return; - }; - const ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void); - - const cls_ref = Ref.fromIndex(0); - const instance = self.emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr) orelse return; - - self.builder.ret(instance, ptr_void); - self.builder.finalize(); - } - - /// Shared inline sequence: allocate Obj-C instance + sx state struct, - /// capture the allocator, bind to the `__sx_state` ivar. Used by both - /// the `+alloc` IMP (ctx_addr = &__sx_default_context) and the sx-side - /// `Cls.alloc()` interception (ctx_addr = current_ctx_ref). - /// - /// Returns the new instance pointer, or `null` if a required global is - /// missing (compiler bug — should be impossible after scan pass). - pub fn emitObjcDefinedAllocAndInit( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - cls_ref: Ref, - ctx_addr: Ref, - ) ?Ref { - const ptr_void = self.module.types.ptrTo(.void); - - // (1) instance = class_createInstance(cls, 0) - const create_fid = self.ensureCRuntimeDecl("class_createInstance", &.{ ptr_void, .u64 }, ptr_void); - const create_args = self.alloc.alloc(Ref, 2) catch return null; - create_args[0] = cls_ref; - create_args[1] = self.builder.constInt(0, .u64); - const instance = self.builder.emit(.{ .call = .{ .callee = create_fid, .args = create_args } }, ptr_void); - - // STATE_SIZE = max(typeSizeBytes(__State), 1). - const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); - const raw_size = self.module.types.typeSizeBytes(state_struct_ty); - const state_size: u64 = if (raw_size == 0) 1 else @intCast(raw_size); - const size_const = self.builder.constInt(@intCast(state_size), .u64); - - // (2) Dispatch through Context.allocator at ctx_addr: - // allocator = (*ctx_addr).field[0] - // state = allocator.alloc(size) (via inline-protocol fn-ptr) - const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context type not found in module for class '{s}' (compiler bug)", .{fcd.name}); - } - return null; - }; - const ctx_info = self.module.types.get(ctx_ty); - if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context has unexpected shape for class '{s}' (compiler bug)", .{fcd.name}); - } - return null; - } - const allocator_ty = ctx_info.@"struct".fields[0].ty; - const ctx_val = self.builder.load(ctx_addr, ctx_ty); - const allocator = self.builder.structGet(ctx_val, 0, allocator_ty); - const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void); - const alloc_fn_ptr = self.builder.structGet(allocator, 1, ptr_void); - const call_args = self.alloc.dupe(Ref, &.{ ctx_addr, alloc_ctx, size_const }) catch return null; - const state = self.builder.emit(.{ .call_indirect = .{ - .callee = alloc_fn_ptr, - .args = call_args, - } }, ptr_void); - - // (3) memset(state, 0, STATE_SIZE) — zero everything including the - // allocator slot; the next store re-writes the allocator slot. - const memset_fid = self.ensureCRuntimeDecl("memset", &.{ ptr_void, .s32, .u64 }, ptr_void); - const memset_args = self.alloc.alloc(Ref, 3) catch return null; - memset_args[0] = state; - memset_args[1] = self.builder.constInt(0, .s32); - memset_args[2] = size_const; - _ = self.builder.emit(.{ .call = .{ .callee = memset_fid, .args = memset_args } }, ptr_void); - - // (4) Capture allocator at state[0] — `-dealloc` reads it back. - const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{ - .base = state, - .field_index = 0, - .base_type = state_struct_ty, - } }, ptr_void); - self.builder.store(state_alloc_addr, allocator); - - // (5) object_setIvar(instance, load(@___state_ivar), state) - const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null; - defer self.alloc.free(ivar_global_name); - const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: ivar global '{s}' missing (scan-pass bug)", .{ivar_global_name}); - } - return null; - }; - const ivar_addr_v = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); - const ivar_handle = self.builder.load(ivar_addr_v, ptr_void); - const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void); - const set_args = self.alloc.alloc(Ref, 3) catch return null; - set_args[0] = instance; - set_args[1] = ivar_handle; - set_args[2] = state; - _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void); - - return instance; - } - - /// Emit a C-ABI IMP trampoline for a CLASS method (no `*Self` - /// first param) on a sx-defined `#objc_class`. M2.1(b). - /// Registered on the metaclass by emit_llvm. - /// - /// C-ABI: `(cls: Class, _cmd: SEL, ...user_args) -> ret` - /// - /// Body: - /// call @.(__sx_default_context, ...user_args) - /// ret - /// - /// No ivar read — class methods have no per-instance state. - fn emitObjcDefinedClassStaticImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - - // current_foreign_class lets `*Self` (if it appears in - // user-arg types — rare for class methods) resolve to the - // state-struct type. Save+restore. - const saved_fc = self.current_foreign_class; - self.current_foreign_class = fcd; - defer self.current_foreign_class = saved_fc; - - for (md.params, 0..) |p_node, i| { - const pty = self.resolveType(p_node); - params.append(self.alloc, .{ - .name = self.module.types.internString(md.param_names[i]), - .ty = pty, - }) catch return; - } - - const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, ret_ty); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - // Call @.(default_ctx, ...user_args). - const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return; - defer self.alloc.free(body_name); - const body_fid = self.resolveFuncByName(body_name) orelse return; - - const ctx_ref: ?Ref = blk: { - if (!self.implicit_ctx_enabled) break :blk null; - const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null; - break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); - }; - - const num_user_args = params_slice.len - 2; // minus cls + _cmd - const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + num_user_args; - const call_args = self.alloc.alloc(Ref, num_call_args) catch return; - var idx: usize = 0; - if (ctx_ref) |c_ref| { - call_args[idx] = c_ref; - idx += 1; - } - var ip: usize = 2; - while (ip < params_slice.len) : (ip += 1) { - call_args[idx] = Ref.fromIndex(@intCast(ip)); - idx += 1; - } - - const call_ref = self.builder.emit(.{ .call = .{ - .callee = body_fid, - .args = call_args, - } }, ret_ty); - - if (ret_ty == .void) self.builder.retVoid() else self.builder.ret(call_ref, ret_ty); - self.builder.finalize(); - } - - /// Synthesize the `-dealloc` IMP for an sx-defined `#objc_class`. - /// Runs when the Obj-C runtime drops the last retain on an instance. - /// - /// C-ABI: `(self: id, _cmd: SEL) -> void`. No implicit sx ctx. - /// - /// Body (M4.0c): - /// %state = object_getIvar(self, load @___state_ivar) - /// %allocator = load struct_gep(state, 0) ← __sx_allocator (M4.0a) - /// allocator.dealloc(state) ← via inline-protocol fn-ptr - /// object_setIvar(self, ivar, null) - /// [super dealloc] // objc_msgSendSuper2(&super, sel_dealloc) - /// ret void - /// - /// The state struct's first field is the allocator captured at - /// +alloc time (M4.0a + M4.0b). Reading it back lets -dealloc free - /// through the same allocator the instance was constructed with — - /// the per-instance allocator design from M1.2 A.5, now realised. - fn emitObjcDefinedClassDeallocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { - const saved_func = self.builder.func; - const saved_block = self.builder.current_block; - const saved_counter = self.builder.inst_counter; - defer { - self.builder.func = saved_func; - self.builder.current_block = saved_block; - self.builder.inst_counter = saved_counter; - } - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_dealloc_imp", .{fcd.name}) catch return; - const name_id = self.module.types.internString(imp_name); - const ptr_void = self.module.types.ptrTo(.void); - - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; - params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, .void); - const func = self.builder.currentFunc(); - func.linkage = .external; - func.call_conv = .c; - func.has_implicit_ctx = false; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - const self_ref = Ref.fromIndex(0); - - // (1) state = object_getIvar(self, load @___state_ivar) - const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; - defer self.alloc.free(ivar_global_name); - const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; - const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); - const ivar_handle = self.builder.load(ivar_addr, ptr_void); - - const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); - const get_args = self.alloc.alloc(Ref, 2) catch return; - get_args[0] = self_ref; - get_args[1] = ivar_handle; - const state = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); - - // (2) M4.B dealloc — release strong/copy property ivars and - // destroyWeak weak property ivars BEFORE freeing the state struct - // (which would invalidate the pointers we need to read). Property - // metadata is re-derived from `fcd.members`; the state struct is - // already interned via objcDefinedStateStructType. - const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); - const state_info_check = self.module.types.get(state_struct_ty); - if (state_info_check == .@"struct") { - const state_fields = state_info_check.@"struct".fields; - for (fcd.members) |m| switch (m) { - .field => |f| { - if (!f.is_property) continue; - // Find the field index in the state struct (by name — - // M4.0a's prepended __sx_allocator shifted user fields). - const field_name_id = self.module.types.internString(f.name); - var pfidx: ?u32 = null; - for (state_fields, 0..) |sf, i| { - if (sf.name == field_name_id) { - pfidx = @intCast(i); - break; - } - } - const fidx = pfidx orelse continue; - const field_ty = self.resolveType(f.field_type); - const kind = self.objc().objcPropertyKind(f); - - switch (kind) { - .assign => {}, // no ARC ops - .strong, .copy => { - // val = load field; objc_release(val) — release(NULL) is a no-op. - self.ensureArcRuntimeDecls(); - const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); - const field_addr = self.builder.emit(.{ .struct_gep = .{ - .base = state, - .field_index = fidx, - .base_type = state_struct_ty, - } }, ptr_void); - const val = self.builder.load(field_addr, field_ty); - const args = self.alloc.alloc(Ref, 1) catch continue; - args[0] = val; - _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = args } }, .void); - }, - .weak => { - // objc_destroyWeak(&field) — unregisters the slot - // from libobjc's side-table. - self.ensureArcRuntimeDecls(); - const destroy_weak_fid = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); - const field_addr = self.builder.emit(.{ .struct_gep = .{ - .base = state, - .field_index = fidx, - .base_type = state_struct_ty, - } }, ptr_void); - const args = self.alloc.alloc(Ref, 1) catch continue; - args[0] = field_addr; - _ = self.builder.emit(.{ .call = .{ .callee = destroy_weak_fid, .args = args } }, .void); - }, - } - }, - else => {}, - }; - } - - // (3) Free state through the captured allocator (M4.0a + M4.0b): - // allocator = load struct_gep(state, 0) ← __sx_allocator field - // allocator.dealloc(state) ← inline-protocol fn-ptr at field 2 - // Compare to the old `free(state)` — that ignored the per-instance - // allocator and went straight to libc. Now `push Context.{ allocator = arena }` - // round-trips correctly: arena.alloc on construction, arena.dealloc here. - const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context type not found for class '{s}' (compiler bug)", .{fcd.name}); - } - return; - }; - const ctx_info = self.module.types.get(ctx_ty); - if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context has unexpected shape for class '{s}'", .{fcd.name}); - } - return; - } - const allocator_ty = ctx_info.@"struct".fields[0].ty; - - const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{ - .base = state, - .field_index = 0, - .base_type = state_struct_ty, - } }, ptr_void); - const allocator = self.builder.load(state_alloc_addr, allocator_ty); - - // Default-context address for the implicit __sx_ctx the dealloc - // fn-ptr takes as its first arg (the dealloc body might allocate - // internally; default GPA is the safe baseline). - const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: __sx_default_context global missing for class '{s}'", .{fcd.name}); - } - return; - }; - const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void); - const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void); - const dealloc_fn_ptr = self.builder.structGet(allocator, 2, ptr_void); - const dealloc_args = self.alloc.dupe(Ref, &.{ default_ctx_addr, alloc_ctx, state }) catch return; - _ = self.builder.emit(.{ .call_indirect = .{ - .callee = dealloc_fn_ptr, - .args = dealloc_args, - } }, .void); - - // (3) object_setIvar(self, ivar, null) - const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void); - const null_ptr = self.builder.constInt(0, ptr_void); - const set_args = self.alloc.alloc(Ref, 3) catch return; - set_args[0] = self_ref; - set_args[1] = ivar_handle; - set_args[2] = null_ptr; - _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void); - - // (4) [super dealloc] - // - // objc_super = struct { receiver: id, super_class: Class } - const super_struct_ty = self.module.types.intern(.{ .@"struct" = .{ - .name = self.module.types.internString("__sx_objc_super"), - .fields = blk: { - var f = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; - f.append(self.alloc, .{ .name = self.module.types.internString("receiver"), .ty = ptr_void }) catch unreachable; - f.append(self.alloc, .{ .name = self.module.types.internString("super_class"), .ty = ptr_void }) catch unreachable; - break :blk f.toOwnedSlice(self.alloc) catch unreachable; - }, - } }); - const super_alloca = self.builder.alloca(super_struct_ty); - - // store receiver - const recv_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 0, .base_type = super_struct_ty } }, ptr_void); - self.builder.store(recv_gep, self_ref); - - // store super_class = load @___class - const class_global_name = std.fmt.allocPrint(self.alloc, "__{s}_class", .{fcd.name}) catch return; - defer self.alloc.free(class_global_name); - const class_global_id = self.lookupGlobalIdByName(class_global_name) orelse return; - const class_addr = self.builder.emit(.{ .global_addr = class_global_id }, ptr_void); - const class_val = self.builder.load(class_addr, ptr_void); - const cls_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 1, .base_type = super_struct_ty } }, ptr_void); - self.builder.store(cls_gep, class_val); - - // sel_dealloc = sel_registerName("dealloc") - const sel_reg_fid = self.ensureCRuntimeDecl("sel_registerName", &.{ptr_void}, ptr_void); - const sel_str_gid = self.internStringConstantGlobal("dealloc"); - const sel_str_addr = self.builder.emit(.{ .global_addr = sel_str_gid }, ptr_void); - const sel_args = self.alloc.alloc(Ref, 1) catch return; - sel_args[0] = sel_str_addr; - const sel_dealloc = self.builder.emit(.{ .call = .{ .callee = sel_reg_fid, .args = sel_args } }, ptr_void); - - // objc_msgSendSuper2(&super, sel_dealloc) - const send_super_fid = self.ensureCRuntimeDecl("objc_msgSendSuper2", &.{ ptr_void, ptr_void }, .void); - const send_args = self.alloc.alloc(Ref, 2) catch return; - send_args[0] = super_alloca; - send_args[1] = sel_dealloc; - _ = self.builder.emit(.{ .call = .{ .callee = send_super_fid, .args = send_args } }, .void); - - self.builder.retVoid(); - self.builder.finalize(); - } - - /// Intern a C-string constant as a `[N:0]u8` global and return - /// its GlobalId. Used by IMP trampolines that need to pass a - /// literal string to runtime helpers (e.g. selector names). - fn internStringConstantGlobal(self: *Lowering, s: []const u8) inst_mod.GlobalId { - const z = self.alloc.allocSentinel(u8, s.len, 0) catch unreachable; - @memcpy(z[0..s.len], s); - const arr_ty = self.module.types.arrayOf(.u8, @intCast(s.len + 1)); - const slot_name = std.fmt.allocPrint(self.alloc, "__sx_objc_cstr_{s}", .{s}) catch unreachable; - const name_id = self.module.types.internString(slot_name); - if (self.lookupGlobalIdByName(slot_name)) |existing| { - self.alloc.free(z); - return existing; - } - var bytes_vec = std.ArrayList(inst_mod.ConstantValue).empty; - for (z[0 .. s.len + 1]) |b| { - bytes_vec.append(self.alloc, .{ .int = b }) catch unreachable; - } - const init_val: inst_mod.ConstantValue = .{ .aggregate = bytes_vec.toOwnedSlice(self.alloc) catch unreachable }; - return self.module.addGlobal(.{ - .name = name_id, - .ty = arr_ty, - .init_val = init_val, - .is_extern = false, - .is_const = true, - }); - } - - /// Linear scan over module globals for a given name. Used for - /// looking up the per-class ivar handle global from inside IMP - /// trampoline emission. - fn lookupGlobalIdByName(self: *Lowering, name: []const u8) ?inst_mod.GlobalId { - const name_id = self.module.types.internString(name); - for (self.module.globals.items, 0..) |g, i| { - if (g.name == name_id) return inst_mod.GlobalId.fromIndex(@intCast(i)); - } - return null; - } - // --- moved to lower/error.zig (lower_error) --- pub const getTraceFids = lower_error.getTraceFids; pub const tracesEnabled = lower_error.tracesEnabled; @@ -10759,4 +9480,29 @@ pub const Lowering = struct { pub const registerNamespacedForeignClasses = lower_ffi.registerNamespacedForeignClasses; pub const synthesizeJniMainStubs = lower_ffi.synthesizeJniMainStubs; pub const synthesizeJniMainStub = lower_ffi.synthesizeJniMainStub; + + // --- moved to lower/objc_class.zig (lower_objc_class) --- + pub const lowerObjcDefinedClassMethods = lower_objc_class.lowerObjcDefinedClassMethods; + pub const lookupObjcPropertyOnPointer = lower_objc_class.lookupObjcPropertyOnPointer; + pub const findForeignMethodInChain = lower_objc_class.findForeignMethodInChain; + pub const findForeignPropertyInChain = lower_objc_class.findForeignPropertyInChain; + pub const lookupObjcDefinedStateFieldOnPointer = lower_objc_class.lookupObjcDefinedStateFieldOnPointer; + pub const lowerObjcDefinedStateFieldRead = lower_objc_class.lowerObjcDefinedStateFieldRead; + pub const lowerObjcDefinedStateForObj = lower_objc_class.lowerObjcDefinedStateForObj; + pub const lowerObjcPropertyGetter = lower_objc_class.lowerObjcPropertyGetter; + pub const lowerObjcPropertySetter = lower_objc_class.lowerObjcPropertySetter; + pub const ensureCRuntimeDecl = lower_objc_class.ensureCRuntimeDecl; + pub const ensureArcRuntimeDecls = lower_objc_class.ensureArcRuntimeDecls; + pub const emitObjcDefinedClassImps = lower_objc_class.emitObjcDefinedClassImps; + pub const emitObjcDefinedClassPropertyImps = lower_objc_class.emitObjcDefinedClassPropertyImps; + pub const emitObjcDefinedPropertyGetter = lower_objc_class.emitObjcDefinedPropertyGetter; + pub const emitObjcDefinedPropertySetter = lower_objc_class.emitObjcDefinedPropertySetter; + pub const registerObjcDefinedPropertyMethodEntries = lower_objc_class.registerObjcDefinedPropertyMethodEntries; + pub const emitObjcDefinedClassImp = lower_objc_class.emitObjcDefinedClassImp; + pub const emitObjcDefinedClassAllocImp = lower_objc_class.emitObjcDefinedClassAllocImp; + pub const emitObjcDefinedAllocAndInit = lower_objc_class.emitObjcDefinedAllocAndInit; + pub const emitObjcDefinedClassStaticImp = lower_objc_class.emitObjcDefinedClassStaticImp; + pub const emitObjcDefinedClassDeallocImp = lower_objc_class.emitObjcDefinedClassDeallocImp; + pub const internStringConstantGlobal = lower_objc_class.internStringConstantGlobal; + pub const lookupGlobalIdByName = lower_objc_class.lookupGlobalIdByName; }; diff --git a/src/ir/lower/objc_class.zig b/src/ir/lower/objc_class.zig new file mode 100644 index 0000000..016aec1 --- /dev/null +++ b/src/ir/lower/objc_class.zig @@ -0,0 +1,1329 @@ +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; + +/// 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 +/// `private native sx_(...)` (declared in the bundled +/// classes.dex by `jni_java_emit`) without an explicit `RegisterNatives` +/// call — i.e. `Java___sx_1`. +/// +/// Param ABI: prepended `(env: *void, self: *void)` (JNIEnv* + jobject +/// receiver), followed by the user-declared params with pointer types +/// type-erased to `*void` (JNI carries jobjects, not sx-typed handles — +/// future work can keep richer typing inside the body when needed). +/// Eagerly lower bodied instance methods on every sx-defined +/// `#objc_class`. The Obj-C runtime invokes these via the IMP +/// pointers wired up in M1.2 A.4 — no sx-side call path triggers +/// lazy lowering, so we walk the cache and force-lower here. +/// `lowerFunction` sets `current_foreign_class` automatically based +/// on the qualified name, so `*Self` substitutions in the body +/// resolve correctly (M1.2 A.2b). After the bodies are lowered, +/// `emitObjcDefinedClassImps` wraps each with a C-ABI trampoline +/// (M1.2 A.4b.ii). +pub fn lowerObjcDefinedClassMethods(self: *Lowering) void { + for (self.module.objc_defined_class_cache.items) |entry| { + const fcd = entry.decl; + for (fcd.members) |m| { + const method = switch (m) { + .method => |md| md, + else => continue, + }; + if (method.body == null) continue; + const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue; + self.lazyLowerFunction(qualified); + } + } + // Now the bodies are lowered — emit the C-ABI IMP trampolines + // that bridge `objc_msgSend` invocations to them. + self.emitObjcDefinedClassImps(); +} + +/// If `obj_expr` is typed as a pointer to a foreign Obj-C class +/// and that class (or any of its `#extends` ancestors) declares a +/// `#property` field with the given name, return the +/// `ForeignFieldDecl`. M2.2 + M2.3. +pub fn lookupObjcPropertyOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ast.ForeignFieldDecl { + const obj_ty = self.inferExprType(obj_expr); + if (obj_ty.isBuiltin()) return null; + const ptr_info = self.module.types.get(obj_ty); + if (ptr_info != .pointer) return null; + const pointee_info = self.module.types.get(ptr_info.pointer.pointee); + if (pointee_info != .@"struct") return null; + const struct_name = self.module.types.getString(pointee_info.@"struct".name); + const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null; + if (fcd.runtime != .objc_class and fcd.runtime != .objc_protocol) return null; + return self.findForeignPropertyInChain(fcd, field_name); +} + +/// Walk the `#extends` chain looking for a method by name. M2.3. +/// Returns the owning fcd + the method decl, or null if no ancestor +/// declares it. Depth-capped at 16 to break accidental cycles +/// (real Obj-C class chains rarely exceed 6 levels). +pub fn findForeignMethodInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, method_name: []const u8) ?struct { fcd: *const ast.ForeignClassDecl, method: ast.ForeignMethodDecl } { + var current: *const ast.ForeignClassDecl = fcd; + var depth: u32 = 0; + while (depth < 16) : (depth += 1) { + for (current.members) |m| switch (m) { + .method => |md| if (std.mem.eql(u8, md.name, method_name)) return .{ .fcd = current, .method = md }, + else => {}, + }; + // Not on this level — follow `#extends ParentName`. + const parent = blk: { + for (current.members) |m| switch (m) { + .extends => |p| break :blk p, + else => {}, + }; + break :blk null; + } orelse return null; + current = self.program_index.foreign_class_map.get(parent) orelse return null; + } + return null; +} + +/// Walk the `#extends` chain looking for a `#property` field by +/// name. M2.3 companion to findForeignMethodInChain. +pub fn findForeignPropertyInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, field_name: []const u8) ?ast.ForeignFieldDecl { + var current: *const ast.ForeignClassDecl = fcd; + var depth: u32 = 0; + while (depth < 16) : (depth += 1) { + for (current.members) |m| switch (m) { + .field => |f| if (f.is_property and std.mem.eql(u8, f.name, field_name)) return f, + else => {}, + }; + const parent = blk: { + for (current.members) |m| switch (m) { + .extends => |p| break :blk p, + else => {}, + }; + break :blk null; + } orelse return null; + current = self.program_index.foreign_class_map.get(parent) orelse return null; + } + return null; +} + +const ObjcDefinedStateField = struct { + field_ty: TypeId, + state_ty: TypeId, + field_idx: u32, + fcd: *const ast.ForeignClassDecl, +}; + +/// State-field-access info: if obj_expr is * +/// and `field_name` is in the state struct (not a property), +/// returns the field's TypeId, the state struct's TypeId, and +/// the field's index. M1.2 A.3 supports. +pub fn lookupObjcDefinedStateFieldOnPointer(self: *Lowering, obj_expr: *const ast.Node, field_name: []const u8) ?ObjcDefinedStateField { + const obj_ty = self.inferExprType(obj_expr); + if (obj_ty.isBuiltin()) return null; + const ptr_info = self.module.types.get(obj_ty); + if (ptr_info != .pointer) return null; + const pointee_info = self.module.types.get(ptr_info.pointer.pointee); + if (pointee_info != .@"struct") return null; + const struct_name = self.module.types.getString(pointee_info.@"struct".name); + const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return null; + // Only sx-defined Obj-C classes have a state struct. Foreign + // classes' fields are purely declaration metadata (no state). + if (fcd.is_foreign or fcd.runtime != .objc_class) return null; + // Skip property fields — those dispatch via the M2.2 getter/setter + // path. Plain instance fields take the ivar+gep path. + for (fcd.members) |m| switch (m) { + .field => |f| { + if (std.mem.eql(u8, f.name, field_name)) { + if (f.is_property) return null; + const state_ty = self.objc().objcDefinedStateStructType(fcd); + const state_info = self.module.types.get(state_ty); + if (state_info != .@"struct") return null; + const fname_id = self.module.types.internString(f.name); + for (state_info.@"struct".fields, 0..) |sf, idx| { + if (sf.name == fname_id) { + return .{ + .field_ty = sf.ty, + .state_ty = state_ty, + .field_idx = @intCast(idx), + .fcd = fcd, + }; + } + } + return null; + } + }, + else => {}, + }; + return null; +} + +/// Lower a read of `self.field` (or `obj.field`) on a sx-defined +/// Obj-C class: `state = object_getIvar(self, load(ivar_global))` +/// then `struct_gep(state, idx)` + load. M1.2 A.3 — the runtime +/// hop through the hidden ivar. +pub fn lowerObjcDefinedStateFieldRead( + self: *Lowering, + obj_expr: *const ast.Node, + info: ObjcDefinedStateField, +) Ref { + const obj_ref = self.lowerExpr(obj_expr); + const state_ptr = self.lowerObjcDefinedStateForObj(obj_ref, info.fcd) orelse return Ref.none; + const ptr_void = self.module.types.ptrTo(.void); + const field_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state_ptr, + .field_index = info.field_idx, + .base_type = info.state_ty, + } }, ptr_void); + return self.builder.load(field_addr, info.field_ty); +} + +/// `state = object_getIvar(obj, load(___state_ivar))`. Shared +/// helper for state-field read + write (M1.2 A.3). +pub fn lowerObjcDefinedStateForObj(self: *Lowering, obj_ref: Ref, fcd: *const ast.ForeignClassDecl) ?Ref { + const ptr_void = self.module.types.ptrTo(.void); + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return null; + const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr, ptr_void); + const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); + const args = self.alloc.alloc(Ref, 2) catch return null; + args[0] = obj_ref; + args[1] = ivar_handle; + return self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = args } }, ptr_void); +} + +/// Lower `obj.field` for an Obj-C `#property` field as +/// `objc_msg_send(obj, sel_)`. M2.2 — getter side. +/// The setter side lives in the assignment-statement lowering. +pub fn lowerObjcPropertyGetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, _: []const u8, _: ast.Span) Ref { + const obj_ref = self.lowerExpr(obj_expr); + const ret_ty = self.resolveType(field.field_type); + const vptr_ty = self.module.types.ptrTo(.void); + // The selector for a property getter is the field name verbatim + // (Obj-C convention; the override hook is for niche cases like + // `isHidden` and lands with M2.2's modifier handling). + const sel_slot_gid = self.internObjcSelector(field.name); + const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty)); + const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); + return self.builder.emit(.{ .objc_msg_send = .{ + .recv = obj_ref, + .sel = sel, + .args = &.{}, + } }, ret_ty); +} + +/// Lower `obj.field = val` for an Obj-C `#property` field as +/// `objc_msg_send(obj, sel_set:, val)`. M2.2 — setter side. +/// Selector: prepend "set", capitalize the first letter of the +/// field name, append ":". `backgroundColor` → `setBackgroundColor:`. +pub fn lowerObjcPropertySetter(self: *Lowering, obj_expr: *const ast.Node, field: ast.ForeignFieldDecl, val: Ref) void { + const obj_ref = self.lowerExpr(obj_expr); + const vptr_ty = self.module.types.ptrTo(.void); + + // Build the setter selector. + var sel_buf = std.ArrayList(u8).empty; + defer sel_buf.deinit(self.alloc); + sel_buf.appendSlice(self.alloc, "set") catch unreachable; + if (field.name.len > 0) { + sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; + sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; + } + sel_buf.append(self.alloc, ':') catch unreachable; + const sel_str = self.alloc.dupe(u8, sel_buf.items) catch unreachable; + + const sel_slot_gid = self.internObjcSelector(sel_str); + const slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty)); + const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); + const args = self.alloc.alloc(Ref, 1) catch unreachable; + args[0] = val; + _ = self.builder.emit(.{ .objc_msg_send = .{ + .recv = obj_ref, + .sel = sel, + .args = args, + } }, .void); +} + +/// Get a FuncId for an external C-callconv function. If a function +/// with this exported name already exists in the module (e.g. +/// declared by stdlib `#foreign` decl), return it; otherwise +/// declare it fresh with the given signature. +/// +/// One helper instead of a `getFid` per runtime function — +/// avoids per-function cache fields and per-function boilerplate. +pub fn ensureCRuntimeDecl(self: *Lowering, name: []const u8, param_tys: []const TypeId, ret_ty: TypeId) FuncId { + const name_id = self.module.types.internString(name); + for (self.module.functions.items, 0..) |f, i| { + if (f.name == name_id) return FuncId.fromIndex(@intCast(i)); + } + var params = std.ArrayList(inst_mod.Function.Param).empty; + for (param_tys, 0..) |pty, i| { + // Param names don't matter at the LLVM ABI boundary — + // synthesize generic ones (`a0`, `a1`, ...) so we don't + // need a parallel name list per call site. + const synth = std.fmt.allocPrint(self.alloc, "a{d}", .{i}) catch unreachable; + params.append(self.alloc, .{ + .name = self.module.types.internString(synth), + .ty = pty, + }) catch unreachable; + } + const fid = self.builder.declareExtern(name_id, params.toOwnedSlice(self.alloc) catch unreachable, ret_ty); + self.module.getFunctionMut(fid).call_conv = .c; + return fid; +} + +/// For each bodied instance method on a sx-defined `#objc_class`, +/// emit a C-ABI IMP trampoline that the Obj-C runtime calls (after +/// the dispatch path from `objc_msgSend`). The trampoline: +/// 1. Loads the cached ivar handle from `@___state_ivar`. +/// 2. Calls `object_getIvar(obj, ivar)` to get the `*State` +/// state pointer. +/// 3. Calls the sx body `@.(__sx_default_context, +/// state, ...user_args)` (default sx-callconv). +/// 4. Returns the result (or `ret void`). +/// +/// IMP name: `____imp`. emit_llvm's +/// constructor (A.4b.ii companion) registers this via +/// `class_addMethod` with a derived selector + type encoding. +pub fn emitObjcDefinedClassImps(self: *Lowering) void { + for (self.module.objc_defined_class_cache.items) |entry| { + const fcd = entry.decl; + // Pin to the class's defining module (E4) so the IMP trampolines' + // method-signature types (`-> BOOL`, param types) resolve where they + // are visible, not at whatever lowering site triggered emission. + const saved_src = self.current_source_file; + defer self.setCurrentSourceFile(saved_src); + if (fcd.source_file) |src| self.setCurrentSourceFile(src); + // Synthesize +alloc (M1.2 A.5) and -dealloc (M1.2 A.6). emit_llvm + // registers +alloc on the metaclass and -dealloc on the class + // itself after objc_registerClassPair. + self.emitObjcDefinedClassAllocImp(fcd); + self.emitObjcDefinedClassDeallocImp(fcd); + for (fcd.members) |m| { + switch (m) { + .method => |method| { + if (method.body == null) continue; + self.emitObjcDefinedClassImp(fcd, method); + }, + .field => |field| { + // M2.2 second pass — sx-defined property fields + // synthesize getter (+ setter unless `readonly`) + // IMPs that GEP into the state struct. + if (field.is_property) { + self.emitObjcDefinedClassPropertyImps(fcd, field); + } + }, + else => {}, + } + } + } +} + +/// Lazily declare libobjc's ARC runtime helpers. Idempotent — uses +/// `ensureCRuntimeDecl` which skips already-declared symbols. Called +/// from the property setter/getter and -dealloc emission paths when +/// they need to emit a retain/release/storeWeak/etc. +pub fn ensureArcRuntimeDecls(self: *Lowering) void { + const ptr_void = self.module.types.ptrTo(.void); + _ = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); + _ = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); + _ = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); + _ = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); + _ = self.ensureCRuntimeDecl("objc_initWeak", &.{ ptr_void, ptr_void }, ptr_void); + _ = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); +} + +/// M2.2 second pass — emit synthesized getter/setter IMPs for a +/// property field on a sx-defined `#objc_class`. The state struct +/// already holds the field (via objcDefinedStateStructType); the +/// IMPs just dispatch a load/store through the `__sx_state` ivar. +/// +/// Getter IMP: `____imp(self, _cmd) -> T` +/// state = object_getIvar(self, load(___state_ivar)) +/// return state. +/// +/// Setter IMP (skipped if `readonly` in modifiers): +/// `___set_imp(self, _cmd, val) -> void` +/// state = object_getIvar(self, load(___state_ivar)) +/// state. = val +/// +/// Both IMPs land in the cache's methods slice with appropriate +/// selectors + encodings; emit_llvm's class_addMethod loop wires +/// them up like any other instance method. +pub fn emitObjcDefinedClassPropertyImps(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl) void { + const state_ty = self.objc().objcDefinedStateStructType(fcd); + const state_info = self.module.types.get(state_ty); + if (state_info != .@"struct") return; + // Find the field's index in the state struct. + const field_name_id = self.module.types.internString(field.name); + var field_idx: ?u32 = null; + for (state_info.@"struct".fields, 0..) |sf, i| { + if (sf.name == field_name_id) { + field_idx = @intCast(i); + break; + } + } + const fidx = field_idx orelse return; + const field_ty = self.resolveType(field.field_type); + + // M4.B: validate modifiers + resolve ARC kind. Side-effect: emits + // diagnostics for typos, weak-on-non-object, ambiguous *void, etc. + // For now the setter/getter still emit bare load/store; subsequent + // M4.B commits wire the actual ARC ops keyed on this kind. + _ = self.objc().objcPropertyKind(field); + + // (1) Getter: ____imp + self.emitObjcDefinedPropertyGetter(fcd, field, state_ty, fidx, field_ty); + + // (2) Setter — skipped for `readonly`. + var is_readonly = false; + for (field.property_modifiers) |mod| { + if (std.mem.eql(u8, mod, "readonly")) { + is_readonly = true; + break; + } + } + if (!is_readonly) { + self.emitObjcDefinedPropertySetter(fcd, field, state_ty, fidx, field_ty); + } + + // (3) Register in the cache's methods slice. Both IMPs use the + // method-registration pipeline that lands in class_addMethod + // calls from emit_llvm. + self.registerObjcDefinedPropertyMethodEntries(fcd, field, field_ty, is_readonly); +} + +pub fn emitObjcDefinedPropertyGetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void { + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, field_ty); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // state = object_getIvar(self, load @___state_ivar) + const self_ref = Ref.fromIndex(0); + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; + const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr, ptr_void); + const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); + const get_args = self.alloc.alloc(Ref, 2) catch return; + get_args[0] = self_ref; + get_args[1] = ivar_handle; + const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); + + const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void); + + // M4.B getter — weak fields go through objc_loadWeakRetained + + // objc_autorelease for race-safe reads. The bare-load path + // (strong/copy/assign) is the common case and reads the slot + // directly. + const kind = self.objc().objcPropertyKind(field); + if (kind == .weak) { + self.ensureArcRuntimeDecls(); + const load_weak_fid = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); + const autorelease_fid = self.ensureCRuntimeDecl("objc_autorelease", &.{ptr_void}, ptr_void); + + // retained = objc_loadWeakRetained(field_addr) + // - atomic upgrade-to-strong via libobjc's side-table; if the + // target deinitialised, returns null. The caller gets a + // +1 retained reference (or null). + const load_args = self.alloc.alloc(Ref, 1) catch return; + load_args[0] = field_addr; + const retained = self.builder.emit(.{ .call = .{ .callee = load_weak_fid, .args = load_args } }, ptr_void); + + // autoreleased = objc_autorelease(retained) + // - drops it into the current pool so the caller doesn't need + // to manually release. Returns the same pointer (typed). + const ar_args = self.alloc.alloc(Ref, 1) catch return; + ar_args[0] = retained; + const autoreleased = self.builder.emit(.{ .call = .{ .callee = autorelease_fid, .args = ar_args } }, ptr_void); + + self.builder.ret(autoreleased, field_ty); + self.builder.finalize(); + return; + } + + // strong / copy / assign — bare load. + const val = self.builder.load(field_addr, field_ty); + self.builder.ret(val, field_ty); + self.builder.finalize(); +} + +pub fn emitObjcDefinedPropertySetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void { + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + // Setter selector: set: → imp name: ___set_imp + var setter_field_buf = std.ArrayList(u8).empty; + defer setter_field_buf.deinit(self.alloc); + setter_field_buf.appendSlice(self.alloc, "set") catch unreachable; + if (field.name.len > 0) { + setter_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; + setter_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; + } + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_field_buf.items }) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("val"), .ty = field_ty }) catch return; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, .void); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + const self_ref = Ref.fromIndex(0); + const val_ref = Ref.fromIndex(2); + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; + const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr, ptr_void); + const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); + const get_args = self.alloc.alloc(Ref, 2) catch return; + get_args[0] = self_ref; + get_args[1] = ivar_handle; + const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); + + const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void); + + // M4.B setter — emit ARC ops based on the property's modifier kind. + const kind = self.objc().objcPropertyKind(field); + switch (kind) { + .assign => { + // Primitives or explicit assign: bare store, no ARC. + self.builder.store(field_addr, val_ref); + }, + .strong => { + // Retain new, release old. Order matters: retain first + // (in case val == old, we don't release before retain). + self.ensureArcRuntimeDecls(); + const retain_fid = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); + const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); + + // old = load field_addr + const old_val = self.builder.load(field_addr, field_ty); + // new = objc_retain(val) + const retain_args = self.alloc.alloc(Ref, 1) catch return; + retain_args[0] = val_ref; + _ = self.builder.emit(.{ .call = .{ .callee = retain_fid, .args = retain_args } }, ptr_void); + // store field_addr, val + self.builder.store(field_addr, val_ref); + // objc_release(old) — Apple's runtime treats release(NULL) as a no-op, + // so we skip an explicit null-check (saves a branch on every assign). + const release_args = self.alloc.alloc(Ref, 1) catch return; + release_args[0] = old_val; + _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void); + }, + .weak => { + // objc_storeWeak(field_addr, val) handles first-store + // (init) and re-store (destroy old + init new) atomically. + self.ensureArcRuntimeDecls(); + const store_weak_fid = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); + const store_args = self.alloc.alloc(Ref, 2) catch return; + store_args[0] = field_addr; + store_args[1] = val_ref; + _ = self.builder.emit(.{ .call = .{ .callee = store_weak_fid, .args = store_args } }, ptr_void); + }, + .copy => { + // copy = objc_msgSend(val, sel_copy) — returns retained + // (NSCopying contract). + // Release old, then store the copy. + self.ensureArcRuntimeDecls(); + const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); + + // Load + cache the `copy` selector slot. + const sel_copy_gid = self.internObjcSelector("copy"); + const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_copy_gid }, self.module.types.ptrTo(ptr_void)); + const sel_copy = self.builder.emit(.{ .load = .{ .operand = sel_slot_ptr } }, ptr_void); + + // copy = [val copy] + const copy_args = self.alloc.alloc(Ref, 0) catch return; + const copied = self.builder.emit(.{ .objc_msg_send = .{ + .recv = val_ref, + .sel = sel_copy, + .args = copy_args, + } }, ptr_void); + + const old_val = self.builder.load(field_addr, field_ty); + self.builder.store(field_addr, copied); + const release_args = self.alloc.alloc(Ref, 1) catch return; + release_args[0] = old_val; + _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = release_args } }, .void); + }, + } + self.builder.retVoid(); + self.builder.finalize(); +} + +/// Append the property's getter (and setter, unless readonly) +/// entries to the class's method-registration slice so emit_llvm +/// calls class_addMethod on each. Selectors + encodings derived +/// from the field type. +pub fn registerObjcDefinedPropertyMethodEntries(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, field_ty: TypeId, is_readonly: bool) void { + const cur = self.module.lookupObjcDefinedClass(fcd.name) orelse return; + _ = cur; + // Find the existing entry and grow its methods slice. + var new_methods = std.ArrayList(Module.ObjcDefinedMethodEntry).empty; + for (self.module.objc_defined_class_cache.items) |entry| { + if (!std.mem.eql(u8, entry.name, fcd.name)) continue; + for (entry.methods) |m| new_methods.append(self.alloc, m) catch unreachable; + + // Getter entry — selector = field name, encoding = "@:". + const getter_enc = self.objc().objcTypeEncodingFromSignature(field_ty, &.{}, null) catch return; + const getter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return; + new_methods.append(self.alloc, .{ + .sel = field.name, + .encoding = getter_enc, + .imp_name = getter_imp_name, + .is_class = false, + }) catch unreachable; + + // Setter entry — selector = set:, encoding = "v@:". + if (!is_readonly) { + var sel_buf = std.ArrayList(u8).empty; + defer sel_buf.deinit(self.alloc); + sel_buf.appendSlice(self.alloc, "set") catch unreachable; + if (field.name.len > 0) { + sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; + sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; + } + sel_buf.append(self.alloc, ':') catch unreachable; + const setter_sel = self.alloc.dupe(u8, sel_buf.items) catch return; + + const setter_enc = self.objc().objcTypeEncodingFromSignature(.void, &.{field_ty}, null) catch return; + + var setter_imp_field_buf = std.ArrayList(u8).empty; + defer setter_imp_field_buf.deinit(self.alloc); + setter_imp_field_buf.appendSlice(self.alloc, "set") catch unreachable; + if (field.name.len > 0) { + setter_imp_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable; + setter_imp_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable; + } + const setter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_imp_field_buf.items }) catch return; + + new_methods.append(self.alloc, .{ + .sel = setter_sel, + .encoding = setter_enc, + .imp_name = setter_imp_name, + .is_class = false, + }) catch unreachable; + } + break; + } + const slice = new_methods.toOwnedSlice(self.alloc) catch return; + self.module.setObjcDefinedClassMethods(fcd.name, slice); +} + +pub fn emitObjcDefinedClassImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { + // Class methods (no `*Self` first param) skip the ivar read — + // they have no instance state to thread through. + if (md.is_static) { + self.emitObjcDefinedClassStaticImp(fcd, md); + return; + } + + // Save+restore builder state — we're switching into a new fn + // mid-pass and need to restore for the next emit_llvm steps. + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + // C-ABI signature: (obj: *void, _cmd: *void, ...user_args) -> ret. + // User params skip index 0 (which is *Self). + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("obj"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + + // Set current_foreign_class so *Self in user-param resolution + // resolves to *State (M1.2 A.2b). Save+restore. + const saved_fc = self.current_foreign_class; + self.current_foreign_class = fcd; + defer self.current_foreign_class = saved_fc; + + const param_start: usize = 1; + for (md.params[param_start..], 0..) |p_node, i| { + // User params are reflected at the C-ABI boundary AS-IS — + // the runtime trampoline forwards them through to the body. + // *Self here would be a programming error (only the implicit + // self at index 0 is *Self), but we use resolveType to handle + // pointer types correctly. + const pty = self.resolveType(p_node); + params.append(self.alloc, .{ + .name = self.module.types.internString(md.param_names[param_start + i]), + .ty = pty, + }) catch return; + } + + const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, ret_ty); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // Pass the Obj-C receiver pointer through to the sx body as + // `self`. The body's `self: *Self` type resolves to the + // foreign-class stub (the opaque Obj-C type), matching Apple's + // Obj-C semantics where `self` IS the object. `self.field` + // access on a sx-defined class is rewritten by lowerFieldAccess + // to go through `object_getIvar(self, __sx_state_ivar)` and + // a struct_gep on the state struct — see M1.2 A.3. + const obj_ref = Ref.fromIndex(0); + + // Call sx body `@.(default_ctx, self, ...user_args)`. + const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return; + defer self.alloc.free(body_name); + const body_fid = self.resolveFuncByName(body_name) orelse return; + + const ctx_ref: ?Ref = blk: { + if (!self.implicit_ctx_enabled) break :blk null; + const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null; + break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); + }; + + // Build arg list: [ctx?] + self + user_args. + const num_user_args = params_slice.len - 2; // minus obj + _cmd + const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + 1 + num_user_args; + const call_args = self.alloc.alloc(Ref, num_call_args) catch return; + var idx: usize = 0; + if (ctx_ref) |c_ref| { + call_args[idx] = c_ref; + idx += 1; + } + call_args[idx] = obj_ref; + idx += 1; + var ip: usize = 2; + while (ip < params_slice.len) : (ip += 1) { + call_args[idx] = Ref.fromIndex(@intCast(ip)); + idx += 1; + } + + const call_ref = self.builder.emit(.{ .call = .{ + .callee = body_fid, + .args = call_args, + } }, ret_ty); + + // (4) Return. + if (ret_ty == .void) { + self.builder.retVoid(); + } else { + self.builder.ret(call_ref, ret_ty); + } + + self.builder.finalize(); +} + +/// Synthesize the `+alloc` IMP for an sx-defined `#objc_class`. +/// Class method registered on the metaclass — when `[SxFoo alloc]` +/// runs from Apple's runtime (Info.plist principal class, +/// NSCoder unarchive, UIKit reflection), this IMP fires. +/// +/// C-ABI: `(cls: id, _cmd: SEL) -> id`. No implicit ctx. +/// +/// Body (M4.0): +/// %instance = class_createInstance(cls, 0) +/// %ctx_addr = &__sx_default_context +/// %state = ctx_addr.allocator.alloc(STATE_SIZE) +/// memset(state, 0, STATE_SIZE) +/// state[0] = allocator ← capture for -dealloc +/// object_setIvar(instance, __sx_state_ivar, state) +/// ret instance +/// +/// Sx-side `Cls.alloc()` is intercepted at the call site (see +/// `lowerObjcStaticCall`) and emits the same sequence inline with +/// `current_ctx_ref` as the ctx — so `push Context.{ allocator = ... }` +/// flows through to per-instance allocator capture without going via +/// the IMP. +pub fn emitObjcDefinedClassAllocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_alloc_imp", .{fcd.name}) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, ptr_void); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // ctx_addr = &__sx_default_context — IMP runs in Apple's runtime + // context, no implicit sx ctx to inherit, so use the process-wide + // default allocator. Sx-side callers bypass this IMP entirely + // (compiler intercepts Cls.alloc()) and use their own + // `context.allocator`. + const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassAllocImp: __sx_default_context global missing for class '{s}' (compiler bug — scan pass did not register the default context)", .{fcd.name}); + } + return; + }; + const ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void); + + const cls_ref = Ref.fromIndex(0); + const instance = self.emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr) orelse return; + + self.builder.ret(instance, ptr_void); + self.builder.finalize(); +} + +/// Shared inline sequence: allocate Obj-C instance + sx state struct, +/// capture the allocator, bind to the `__sx_state` ivar. Used by both +/// the `+alloc` IMP (ctx_addr = &__sx_default_context) and the sx-side +/// `Cls.alloc()` interception (ctx_addr = current_ctx_ref). +/// +/// Returns the new instance pointer, or `null` if a required global is +/// missing (compiler bug — should be impossible after scan pass). +pub fn emitObjcDefinedAllocAndInit( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + cls_ref: Ref, + ctx_addr: Ref, +) ?Ref { + const ptr_void = self.module.types.ptrTo(.void); + + // (1) instance = class_createInstance(cls, 0) + const create_fid = self.ensureCRuntimeDecl("class_createInstance", &.{ ptr_void, .u64 }, ptr_void); + const create_args = self.alloc.alloc(Ref, 2) catch return null; + create_args[0] = cls_ref; + create_args[1] = self.builder.constInt(0, .u64); + const instance = self.builder.emit(.{ .call = .{ .callee = create_fid, .args = create_args } }, ptr_void); + + // STATE_SIZE = max(typeSizeBytes(__State), 1). + const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); + const raw_size = self.module.types.typeSizeBytes(state_struct_ty); + const state_size: u64 = if (raw_size == 0) 1 else @intCast(raw_size); + const size_const = self.builder.constInt(@intCast(state_size), .u64); + + // (2) Dispatch through Context.allocator at ctx_addr: + // allocator = (*ctx_addr).field[0] + // state = allocator.alloc(size) (via inline-protocol fn-ptr) + const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context type not found in module for class '{s}' (compiler bug)", .{fcd.name}); + } + return null; + }; + const ctx_info = self.module.types.get(ctx_ty); + if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: Context has unexpected shape for class '{s}' (compiler bug)", .{fcd.name}); + } + return null; + } + const allocator_ty = ctx_info.@"struct".fields[0].ty; + const ctx_val = self.builder.load(ctx_addr, ctx_ty); + const allocator = self.builder.structGet(ctx_val, 0, allocator_ty); + const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void); + const alloc_fn_ptr = self.builder.structGet(allocator, 1, ptr_void); + const call_args = self.alloc.dupe(Ref, &.{ ctx_addr, alloc_ctx, size_const }) catch return null; + const state = self.builder.emit(.{ .call_indirect = .{ + .callee = alloc_fn_ptr, + .args = call_args, + } }, ptr_void); + + // (3) memset(state, 0, STATE_SIZE) — zero everything including the + // allocator slot; the next store re-writes the allocator slot. + const memset_fid = self.ensureCRuntimeDecl("memset", &.{ ptr_void, .s32, .u64 }, ptr_void); + const memset_args = self.alloc.alloc(Ref, 3) catch return null; + memset_args[0] = state; + memset_args[1] = self.builder.constInt(0, .s32); + memset_args[2] = size_const; + _ = self.builder.emit(.{ .call = .{ .callee = memset_fid, .args = memset_args } }, ptr_void); + + // (4) Capture allocator at state[0] — `-dealloc` reads it back. + const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state, + .field_index = 0, + .base_type = state_struct_ty, + } }, ptr_void); + self.builder.store(state_alloc_addr, allocator); + + // (5) object_setIvar(instance, load(@___state_ivar), state) + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return null; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedAllocAndInit: ivar global '{s}' missing (scan-pass bug)", .{ivar_global_name}); + } + return null; + }; + const ivar_addr_v = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr_v, ptr_void); + const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void); + const set_args = self.alloc.alloc(Ref, 3) catch return null; + set_args[0] = instance; + set_args[1] = ivar_handle; + set_args[2] = state; + _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void); + + return instance; +} + +/// Emit a C-ABI IMP trampoline for a CLASS method (no `*Self` +/// first param) on a sx-defined `#objc_class`. M2.1(b). +/// Registered on the metaclass by emit_llvm. +/// +/// C-ABI: `(cls: Class, _cmd: SEL, ...user_args) -> ret` +/// +/// Body: +/// call @.(__sx_default_context, ...user_args) +/// ret +/// +/// No ivar read — class methods have no per-instance state. +pub fn emitObjcDefinedClassStaticImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, md.name }) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("cls"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + + // current_foreign_class lets `*Self` (if it appears in + // user-arg types — rare for class methods) resolve to the + // state-struct type. Save+restore. + const saved_fc = self.current_foreign_class; + self.current_foreign_class = fcd; + defer self.current_foreign_class = saved_fc; + + for (md.params, 0..) |p_node, i| { + const pty = self.resolveType(p_node); + params.append(self.alloc, .{ + .name = self.module.types.internString(md.param_names[i]), + .ty = pty, + }) catch return; + } + + const ret_ty: TypeId = if (md.return_type) |rt| self.resolveType(rt) else .void; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, ret_ty); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + // Call @.(default_ctx, ...user_args). + const body_name = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, md.name }) catch return; + defer self.alloc.free(body_name); + const body_fid = self.resolveFuncByName(body_name) orelse return; + + const ctx_ref: ?Ref = blk: { + if (!self.implicit_ctx_enabled) break :blk null; + const dctx_gi = self.program_index.global_names.get("__sx_default_context") orelse break :blk null; + break :blk self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); + }; + + const num_user_args = params_slice.len - 2; // minus cls + _cmd + const num_call_args = (if (ctx_ref != null) @as(usize, 1) else 0) + num_user_args; + const call_args = self.alloc.alloc(Ref, num_call_args) catch return; + var idx: usize = 0; + if (ctx_ref) |c_ref| { + call_args[idx] = c_ref; + idx += 1; + } + var ip: usize = 2; + while (ip < params_slice.len) : (ip += 1) { + call_args[idx] = Ref.fromIndex(@intCast(ip)); + idx += 1; + } + + const call_ref = self.builder.emit(.{ .call = .{ + .callee = body_fid, + .args = call_args, + } }, ret_ty); + + if (ret_ty == .void) self.builder.retVoid() else self.builder.ret(call_ref, ret_ty); + self.builder.finalize(); +} + +/// Synthesize the `-dealloc` IMP for an sx-defined `#objc_class`. +/// Runs when the Obj-C runtime drops the last retain on an instance. +/// +/// C-ABI: `(self: id, _cmd: SEL) -> void`. No implicit sx ctx. +/// +/// Body (M4.0c): +/// %state = object_getIvar(self, load @___state_ivar) +/// %allocator = load struct_gep(state, 0) ← __sx_allocator (M4.0a) +/// allocator.dealloc(state) ← via inline-protocol fn-ptr +/// object_setIvar(self, ivar, null) +/// [super dealloc] // objc_msgSendSuper2(&super, sel_dealloc) +/// ret void +/// +/// The state struct's first field is the allocator captured at +/// +alloc time (M4.0a + M4.0b). Reading it back lets -dealloc free +/// through the same allocator the instance was constructed with — +/// the per-instance allocator design from M1.2 A.5, now realised. +pub fn emitObjcDefinedClassDeallocImp(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + defer { + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + } + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_dealloc_imp", .{fcd.name}) catch return; + const name_id = self.module.types.internString(imp_name); + const ptr_void = self.module.types.ptrTo(.void); + + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return; + params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, .void); + const func = self.builder.currentFunc(); + func.linkage = .external; + func.call_conv = .c; + func.has_implicit_ctx = false; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + const self_ref = Ref.fromIndex(0); + + // (1) state = object_getIvar(self, load @___state_ivar) + const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return; + defer self.alloc.free(ivar_global_name); + const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return; + const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void); + const ivar_handle = self.builder.load(ivar_addr, ptr_void); + + const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void); + const get_args = self.alloc.alloc(Ref, 2) catch return; + get_args[0] = self_ref; + get_args[1] = ivar_handle; + const state = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void); + + // (2) M4.B dealloc — release strong/copy property ivars and + // destroyWeak weak property ivars BEFORE freeing the state struct + // (which would invalidate the pointers we need to read). Property + // metadata is re-derived from `fcd.members`; the state struct is + // already interned via objcDefinedStateStructType. + const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); + const state_info_check = self.module.types.get(state_struct_ty); + if (state_info_check == .@"struct") { + const state_fields = state_info_check.@"struct".fields; + for (fcd.members) |m| switch (m) { + .field => |f| { + if (!f.is_property) continue; + // Find the field index in the state struct (by name — + // M4.0a's prepended __sx_allocator shifted user fields). + const field_name_id = self.module.types.internString(f.name); + var pfidx: ?u32 = null; + for (state_fields, 0..) |sf, i| { + if (sf.name == field_name_id) { + pfidx = @intCast(i); + break; + } + } + const fidx = pfidx orelse continue; + const field_ty = self.resolveType(f.field_type); + const kind = self.objc().objcPropertyKind(f); + + switch (kind) { + .assign => {}, // no ARC ops + .strong, .copy => { + // val = load field; objc_release(val) — release(NULL) is a no-op. + self.ensureArcRuntimeDecls(); + const release_fid = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); + const field_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state, + .field_index = fidx, + .base_type = state_struct_ty, + } }, ptr_void); + const val = self.builder.load(field_addr, field_ty); + const args = self.alloc.alloc(Ref, 1) catch continue; + args[0] = val; + _ = self.builder.emit(.{ .call = .{ .callee = release_fid, .args = args } }, .void); + }, + .weak => { + // objc_destroyWeak(&field) — unregisters the slot + // from libobjc's side-table. + self.ensureArcRuntimeDecls(); + const destroy_weak_fid = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); + const field_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state, + .field_index = fidx, + .base_type = state_struct_ty, + } }, ptr_void); + const args = self.alloc.alloc(Ref, 1) catch continue; + args[0] = field_addr; + _ = self.builder.emit(.{ .call = .{ .callee = destroy_weak_fid, .args = args } }, .void); + }, + } + }, + else => {}, + }; + } + + // (3) Free state through the captured allocator (M4.0a + M4.0b): + // allocator = load struct_gep(state, 0) ← __sx_allocator field + // allocator.dealloc(state) ← inline-protocol fn-ptr at field 2 + // Compare to the old `free(state)` — that ignored the per-instance + // allocator and went straight to libc. Now `push Context.{ allocator = arena }` + // round-trips correctly: arena.alloc on construction, arena.dealloc here. + const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context type not found for class '{s}' (compiler bug)", .{fcd.name}); + } + return; + }; + const ctx_info = self.module.types.get(ctx_ty); + if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: Context has unexpected shape for class '{s}'", .{fcd.name}); + } + return; + } + const allocator_ty = ctx_info.@"struct".fields[0].ty; + + const state_alloc_addr = self.builder.emit(.{ .struct_gep = .{ + .base = state, + .field_index = 0, + .base_type = state_struct_ty, + } }, ptr_void); + const allocator = self.builder.load(state_alloc_addr, allocator_ty); + + // Default-context address for the implicit __sx_ctx the dealloc + // fn-ptr takes as its first arg (the dealloc body might allocate + // internally; default GPA is the safe baseline). + const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, ast.Span{ .start = 0, .end = 0 }, "emitObjcDefinedClassDeallocImp: __sx_default_context global missing for class '{s}'", .{fcd.name}); + } + return; + }; + const default_ctx_addr = self.builder.emit(.{ .global_addr = default_ctx_gi.id }, ptr_void); + const alloc_ctx = self.builder.structGet(allocator, 0, ptr_void); + const dealloc_fn_ptr = self.builder.structGet(allocator, 2, ptr_void); + const dealloc_args = self.alloc.dupe(Ref, &.{ default_ctx_addr, alloc_ctx, state }) catch return; + _ = self.builder.emit(.{ .call_indirect = .{ + .callee = dealloc_fn_ptr, + .args = dealloc_args, + } }, .void); + + // (3) object_setIvar(self, ivar, null) + const set_ivar_fid = self.ensureCRuntimeDecl("object_setIvar", &.{ ptr_void, ptr_void, ptr_void }, .void); + const null_ptr = self.builder.constInt(0, ptr_void); + const set_args = self.alloc.alloc(Ref, 3) catch return; + set_args[0] = self_ref; + set_args[1] = ivar_handle; + set_args[2] = null_ptr; + _ = self.builder.emit(.{ .call = .{ .callee = set_ivar_fid, .args = set_args } }, .void); + + // (4) [super dealloc] + // + // objc_super = struct { receiver: id, super_class: Class } + const super_struct_ty = self.module.types.intern(.{ .@"struct" = .{ + .name = self.module.types.internString("__sx_objc_super"), + .fields = blk: { + var f = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; + f.append(self.alloc, .{ .name = self.module.types.internString("receiver"), .ty = ptr_void }) catch unreachable; + f.append(self.alloc, .{ .name = self.module.types.internString("super_class"), .ty = ptr_void }) catch unreachable; + break :blk f.toOwnedSlice(self.alloc) catch unreachable; + }, + } }); + const super_alloca = self.builder.alloca(super_struct_ty); + + // store receiver + const recv_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 0, .base_type = super_struct_ty } }, ptr_void); + self.builder.store(recv_gep, self_ref); + + // store super_class = load @___class + const class_global_name = std.fmt.allocPrint(self.alloc, "__{s}_class", .{fcd.name}) catch return; + defer self.alloc.free(class_global_name); + const class_global_id = self.lookupGlobalIdByName(class_global_name) orelse return; + const class_addr = self.builder.emit(.{ .global_addr = class_global_id }, ptr_void); + const class_val = self.builder.load(class_addr, ptr_void); + const cls_gep = self.builder.emit(.{ .struct_gep = .{ .base = super_alloca, .field_index = 1, .base_type = super_struct_ty } }, ptr_void); + self.builder.store(cls_gep, class_val); + + // sel_dealloc = sel_registerName("dealloc") + const sel_reg_fid = self.ensureCRuntimeDecl("sel_registerName", &.{ptr_void}, ptr_void); + const sel_str_gid = self.internStringConstantGlobal("dealloc"); + const sel_str_addr = self.builder.emit(.{ .global_addr = sel_str_gid }, ptr_void); + const sel_args = self.alloc.alloc(Ref, 1) catch return; + sel_args[0] = sel_str_addr; + const sel_dealloc = self.builder.emit(.{ .call = .{ .callee = sel_reg_fid, .args = sel_args } }, ptr_void); + + // objc_msgSendSuper2(&super, sel_dealloc) + const send_super_fid = self.ensureCRuntimeDecl("objc_msgSendSuper2", &.{ ptr_void, ptr_void }, .void); + const send_args = self.alloc.alloc(Ref, 2) catch return; + send_args[0] = super_alloca; + send_args[1] = sel_dealloc; + _ = self.builder.emit(.{ .call = .{ .callee = send_super_fid, .args = send_args } }, .void); + + self.builder.retVoid(); + self.builder.finalize(); +} + +/// Intern a C-string constant as a `[N:0]u8` global and return +/// its GlobalId. Used by IMP trampolines that need to pass a +/// literal string to runtime helpers (e.g. selector names). +pub fn internStringConstantGlobal(self: *Lowering, s: []const u8) inst_mod.GlobalId { + const z = self.alloc.allocSentinel(u8, s.len, 0) catch unreachable; + @memcpy(z[0..s.len], s); + const arr_ty = self.module.types.arrayOf(.u8, @intCast(s.len + 1)); + const slot_name = std.fmt.allocPrint(self.alloc, "__sx_objc_cstr_{s}", .{s}) catch unreachable; + const name_id = self.module.types.internString(slot_name); + if (self.lookupGlobalIdByName(slot_name)) |existing| { + self.alloc.free(z); + return existing; + } + var bytes_vec = std.ArrayList(inst_mod.ConstantValue).empty; + for (z[0 .. s.len + 1]) |b| { + bytes_vec.append(self.alloc, .{ .int = b }) catch unreachable; + } + const init_val: inst_mod.ConstantValue = .{ .aggregate = bytes_vec.toOwnedSlice(self.alloc) catch unreachable }; + return self.module.addGlobal(.{ + .name = name_id, + .ty = arr_ty, + .init_val = init_val, + .is_extern = false, + .is_const = true, + }); +} + +/// Linear scan over module globals for a given name. Used for +/// looking up the per-class ivar handle global from inside IMP +/// trampoline emission. +pub fn lookupGlobalIdByName(self: *Lowering, name: []const u8) ?inst_mod.GlobalId { + const name_id = self.module.types.internString(name); + for (self.module.globals.items, 0..) |g, i| { + if (g.name == name_id) return inst_mod.GlobalId.fromIndex(@intCast(i)); + } + return null; +}