ffi 3.1: Cls.static_method(args) lowers to objc_msg_send on the class object

Implementation half of the Phase 3.1 cadence step.
`lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcStaticCall` helper
that loads the class object from a module-scoped cached slot (populated
once per module via `objc_getClass`) and dispatches `objc_msg_send`
with the same selector-mangling as Phase 3.0's instance dispatch.

Three pieces:

1. `Module.objc_class_cache` — parallel to `objc_selector_cache`,
   insertion-ordered list of (class_name, slot_GlobalId) so the
   constructor that calls `objc_getClass` per slot at module load
   is deterministic. `lookupObjcClass` / `appendObjcClass` accessors.
2. `internObjcClassObject` in lower.zig — get-or-create a
   `OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's
   naming convention. `lowerObjcStaticCall` reuses
   `deriveObjcSelector` from 3.0 for the selector, loads the class
   slot, and emits `objc_msg_send(class_obj, sel, args)`.
3. `emitObjcClassInit` in emit_llvm.zig — companion to
   `emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a
   constructor `__sx_objc_class_init` that calls `objc_getClass(name)`
   per slot, registers in `@llvm.global_ctors` for AOT (extending the
   existing array if the selector init already created it), and
   injects a direct call into main's prelude after any prior init
   calls so the ORC JIT path runs it too.

Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)`
convention rather than the plan's notional `::` — avoids extending the
parser for a new postfix operator with no other use case.

Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's
`+class` and `+description` class methods via the new syntax, asserts
both return non-null. NSObject is always available at module-load,
unlike runtime-created test classes that wouldn't exist yet when
the class-init constructor runs.

164/164 tests; chess builds + runs clean on all three platforms.
This commit is contained in:
agra
2026-05-25 16:23:24 +03:00
parent b07ee53a39
commit 8406cc1fed
7 changed files with 281 additions and 38 deletions

View File

@@ -4196,6 +4196,33 @@ pub const Lowering = struct {
return gid;
}
/// Intern an Obj-C class name into a module-scoped `Class*` slot.
/// First call creates the global; subsequent calls return the same
/// `GlobalId`. emit_llvm.zig walks `module.objc_class_cache` and
/// synthesizes a constructor that populates each slot via
/// `objc_getClass` exactly once at module load.
///
/// Slot name matches clang's convention: `OBJC_CLASSLIST_REFERENCES_<Cls>`.
fn internObjcClassObject(self: *Lowering, class_name: []const u8) inst_mod.GlobalId {
if (self.module.lookupObjcClass(class_name)) |gid| return gid;
var mangled = std.ArrayList(u8).empty;
defer mangled.deinit(self.alloc);
mangled.appendSlice(self.alloc, "OBJC_CLASSLIST_REFERENCES_") catch unreachable;
mangled.appendSlice(self.alloc, class_name) catch unreachable;
const slot_name = self.module.types.internString(mangled.items);
const vptr_ty = self.module.types.ptrTo(.void);
const gid = self.module.addGlobal(.{
.name = slot_name,
.ty = vptr_ty,
.init_val = .null_val,
.is_extern = false,
.is_const = false,
});
self.module.appendObjcClass(class_name, gid);
return gid;
}
/// Lazily declare `sel_registerName(name: *u8) -> *void` as an extern.
/// Cached per Lowering instance so multiple `#objc_call` sites share
/// one declaration.
@@ -4543,6 +4570,57 @@ pub const Lowering = struct {
} }, ret_ty);
}
/// Lower `Cls.static_method(args)` on an `#objc_class` /
/// `#objc_protocol` alias. Loads the class object through the
/// module-scoped cached slot (populated by `objc_getClass` at
/// module-init) and dispatches `objc_msg_send` with the same
/// selector mangling as instance methods (Phase 3.0).
fn lowerObjcStaticCall(
self: *Lowering,
fcd: *const ast.ForeignClassDecl,
method: ast.ForeignMethodDecl,
method_args: []const Ref,
span: ast.Span,
) Ref {
const arity = method_args.len;
const derived = self.deriveObjcSelector(method.name, arity);
if (arity > 0 and derived.keyword_count != arity) {
if (self.diagnostics) |d| {
d.addFmt(
.err,
span,
"Obj-C selector for static call '{s}.{s}' has {} keyword(s) but the call passes {} argument(s); split the sx method name on '_' so it produces exactly {} keyword(s), or override with `#selector(\"...\")` once that lands (3.2)",
.{ fcd.name, method.name, derived.keyword_count, arity, arity },
);
}
return Ref.none;
}
const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;
const vptr_ty = self.module.types.ptrTo(.void);
// Load the class object from its module-scoped cached slot.
// `objc_getClass(<name>)` runs once at module-init via the
// constructor emit_llvm synthesizes (see `emitObjcClassInit`).
const class_slot_gid = self.internObjcClassObject(fcd.foreign_path);
const class_slot_ptr = self.builder.emit(.{ .global_addr = class_slot_gid }, self.module.types.ptrTo(vptr_ty));
const class_obj = self.builder.emit(.{ .load = .{ .operand = class_slot_ptr } }, vptr_ty);
// Load the SEL from its slot.
const sel_slot_gid = self.internObjcSelector(derived.sel);
const sel_slot_ptr = self.builder.emit(.{ .global_addr = sel_slot_gid }, self.module.types.ptrTo(vptr_ty));
const sel = self.builder.emit(.{ .load = .{ .operand = sel_slot_ptr } }, vptr_ty);
const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable;
return self.builder.emit(.{ .objc_msg_send = .{
.recv = class_obj,
.sel = sel,
.args = args_owned,
} }, ret_ty);
}
/// Lower `Alias.new(args)` where `Alias` is a foreign-class identifier
/// with `static new :: (...) -> *Self;` — JNI constructor dispatch:
/// `FindClass + GetMethodID("<init>", "(args)V") + NewObject(env,
@@ -4559,6 +4637,13 @@ pub const Lowering = struct {
method_args: []const Ref,
span: ast.Span,
) Ref {
// Obj-C static dispatch (Phase 3 step 3.1). `Cls.static_method(args)`
// on an `#objc_class` alias loads the class object through a
// module-scoped cached slot (populated once per module via
// `objc_getClass`) and dispatches with the derived selector.
if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) {
return self.lowerObjcStaticCall(fcd, method, method_args, span);
}
if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) {
if (self.diagnostics) |d| d.addFmt(.err, span, "static calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)});
return Ref.none;