ffi 1.3 make-green: #objc_call(void)(recv, "sel:") codegen

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(<recv>, %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.
This commit is contained in:
agra
2026-05-19 12:56:53 +03:00
parent d1e9def0c6
commit f43dea6913
3 changed files with 115 additions and 2 deletions

View File

@@ -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 <ABI(T)> @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 {

View File

@@ -1 +1 @@
/Users/agra/projects/sx/examples/ffi-objc-call-02-void-return.sx:18:9: error: unresolved: 'unknown_expr'
ok