From f43dea6913c00c777e0313a7d2881d2e5165e83c Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 19 May 2026 12:56:53 +0300 Subject: [PATCH] ffi 1.3 make-green: #objc_call(void)(recv, "sel:") codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100/100 regression tests pass; ffi-objc-call-02-void-return flips from xfail (codegen rejection) to passing ("ok"). Lowering for `#objc_call(void)(recv, "selector:")` lands in lower.zig as `lowerFfiIntrinsicCall`: %sel = call ptr @sel_registerName(<"selector:">) %call = call ptr @objc_msgSend(, %sel) Two extern decls (`sel_registerName(*u8) -> *void` and `objc_msgSend(*void, *void) -> *void`) are declared lazily and cached on the Lowering instance via `objc_msg_send_fid` / `sel_register_name_fid`, so multiple call sites share one declaration each. Phase 1.3 deliberately keeps scope tight: only `void` return + just (recv, selector) arity is wired. Non-void returns + variadic arity fall through with a diagnostic and are owned by subsequent phase-1 steps (1.6 primitive returns; 1.7..1.9 struct shapes; 1.10 multi- keyword selectors). Selector resolution is still per-call-site `sel_registerName` — the planned 1.5 interning turns the per-call hashtable lookup into a single static-global load. Chess Android + iOS-sim builds clean — no regression on the existing typed-`objc_msgSend`-cast pattern. --- src/ir/lower.zig | 113 ++++++++++++++++++ .../ffi-objc-call-02-void-return.exit | 2 +- .../expected/ffi-objc-call-02-void-return.txt | 2 +- 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 4172694..be0eedf 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -95,6 +95,8 @@ pub const Lowering = struct { module_scopes: ?*std.StringHashMap(std.StringHashMap(void)) = null, // per-module visible names (from import resolution) import_graph: ?*std.StringHashMap(std.StringHashMap(void)) = null, // module path → set of directly imported paths (used by param_impl_map visibility filter) current_source_file: ?[]const u8 = null, // source file of function currently being lowered + objc_msg_send_fid: ?FuncId = null, // lazily-declared `objc_msgSend` extern (for #objc_call lowering) + sel_register_name_fid: ?FuncId = null, // lazily-declared `sel_registerName` extern type_bindings: ?std.StringHashMap(TypeId) = null, // generic type param bindings ($T → concrete TypeId) current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch) force_block_value: bool = false, // set by lowerBlockValue to extract if-else values @@ -1753,6 +1755,7 @@ pub const Lowering = struct { .break_expr => self.lowerBreak(), .continue_expr => self.lowerContinue(), .call => |c| self.lowerCall(&c), + .ffi_intrinsic_call => |fic| self.lowerFfiIntrinsicCall(&fic), .field_access => |fa| self.lowerFieldAccess(&fa, node.span), .struct_literal => |sl| self.lowerStructLiteral(&sl), .array_literal => |al| self.lowerArrayLiteral(&al), @@ -3729,6 +3732,116 @@ pub const Lowering = struct { return .s64; } + // ── FFI intrinsics (#objc_call / #jni_call / #jni_static_call) ─ + + /// Lazily declare `sel_registerName(name: *u8) -> *void` as an extern. + /// Cached per Lowering instance so multiple `#objc_call` sites share + /// one declaration. + fn getSelRegisterNameFid(self: *Lowering) FuncId { + if (self.sel_register_name_fid) |fid| return fid; + var params = std.ArrayList(inst_mod.Function.Param).empty; + const name_str = self.module.types.internString("name"); + const ptr_ty = self.module.types.ptrTo(.u8); + params.append(self.alloc, .{ .name = name_str, .ty = ptr_ty }) catch unreachable; + const fn_name = self.module.types.internString("sel_registerName"); + const ret_ty = self.module.types.ptrTo(.void); + const fid = self.builder.declareExtern(fn_name, params.toOwnedSlice(self.alloc) catch unreachable, ret_ty); + const func = self.module.getFunctionMut(fid); + func.call_conv = .c; + self.sel_register_name_fid = fid; + return fid; + } + + /// Lazily declare `objc_msgSend(recv: *void, sel: *void) -> *void`. + /// Cast at the call site by the LLVM lowering (the `coerceArg` / + /// type-equivalence path). For Phase 1.3 the only return shape + /// exercised is void; the *void return is discarded. + fn getObjcMsgSendFid(self: *Lowering) FuncId { + if (self.objc_msg_send_fid) |fid| return fid; + var params = std.ArrayList(inst_mod.Function.Param).empty; + const recv_str = self.module.types.internString("recv"); + const sel_str = self.module.types.internString("sel"); + const vptr = self.module.types.ptrTo(.void); + params.append(self.alloc, .{ .name = recv_str, .ty = vptr }) catch unreachable; + params.append(self.alloc, .{ .name = sel_str, .ty = vptr }) catch unreachable; + const fn_name = self.module.types.internString("objc_msgSend"); + const fid = self.builder.declareExtern(fn_name, params.toOwnedSlice(self.alloc) catch unreachable, vptr); + const func = self.module.getFunctionMut(fid); + func.call_conv = .c; + self.objc_msg_send_fid = fid; + return fid; + } + + /// Lower `#objc_call(T)(recv, "sel:", args...)` to: + /// %sel = call ptr @sel_registerName(<"sel:">) + /// %ret = call @objc_msgSend(recv, %sel, args...) + /// For Phase 1.3 only the (void return, no extra args) form is + /// fully wired. Extra arities + non-void returns will land in + /// subsequent phase-1 steps. + fn lowerFfiIntrinsicCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref { + if (fic.kind != .objc_call) { + if (self.diagnostics) |d| { + d.add(.err, "#jni_call / #jni_static_call lowering not implemented yet (Phase 1.15+)", null); + } + return Ref.none; + } + + if (fic.args.len < 2) { + if (self.diagnostics) |d| { + d.add(.err, "#objc_call requires at least a receiver and a selector", null); + } + return Ref.none; + } + + // Resolve the return type from the syntactic slot. + const ret_ty = self.resolveType(fic.return_type); + + // For Phase 1.3 the only supported return-type / arity combo is + // (void, recv + selector). Anything else falls through to undef + // for now — the next phase-1 steps fill these in one shape at + // a time. + if (ret_ty != .void or fic.args.len != 2) { + if (self.diagnostics) |d| { + d.add(.err, "#objc_call: only `void` return + (recv, selector) is lowered today; non-void / arg-bearing arities land in later phase-1 steps", null); + } + return Ref.none; + } + + // Receiver expression. + const recv = self.lowerExpr(fic.args[0]); + + // Selector must be a literal string at parse time so we can + // intern it (Phase 1.5 will cache the SEL too). For Phase 1.3 + // we accept any expression that lowers to a string Ref. + const sel_arg_node = fic.args[1]; + const sel_ref = blk: { + if (sel_arg_node.data == .string_literal) { + const raw = sel_arg_node.data.string_literal.raw; + break :blk self.builder.constString(self.module.types.internString(raw)); + } + break :blk self.lowerExpr(sel_arg_node); + }; + + // Resolve selector via the runtime — per call site for now. + const sel_fid = self.getSelRegisterNameFid(); + var sel_args = std.ArrayList(Ref).empty; + sel_args.append(self.alloc, sel_ref) catch unreachable; + const sel_owned = sel_args.toOwnedSlice(self.alloc) catch unreachable; + const sel = self.builder.emit(.{ .call = .{ .callee = sel_fid, .args = sel_owned } }, self.module.types.ptrTo(.void)); + + // Dispatch through objc_msgSend. + const msg_fid = self.getObjcMsgSendFid(); + var call_args = std.ArrayList(Ref).empty; + call_args.append(self.alloc, recv) catch unreachable; + call_args.append(self.alloc, sel) catch unreachable; + const owned = call_args.toOwnedSlice(self.alloc) catch unreachable; + // Result type is `*void` here (objc_msgSend's declared shape). + // For `void` user-facing returns we just discard the Ref — + // the IR keeps the side-effecting call instruction either way. + _ = self.builder.emit(.{ .call = .{ .callee = msg_fid, .args = owned } }, self.module.types.ptrTo(.void)); + return Ref.none; + } + // ── Calls ─────────────────────────────────────────────────────── fn lowerCall(self: *Lowering, c: *const ast.Call) Ref { diff --git a/tests/expected/ffi-objc-call-02-void-return.exit b/tests/expected/ffi-objc-call-02-void-return.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-call-02-void-return.exit +++ b/tests/expected/ffi-objc-call-02-void-return.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-call-02-void-return.txt b/tests/expected/ffi-objc-call-02-void-return.txt index 2214600..9766475 100644 --- a/tests/expected/ffi-objc-call-02-void-return.txt +++ b/tests/expected/ffi-objc-call-02-void-return.txt @@ -1 +1 @@ -/Users/agra/projects/sx/examples/ffi-objc-call-02-void-return.sx:18:9: error: unresolved: 'unknown_expr' +ok