From 8406cc1fedf6a3076b2e77e793a1ccc8a29b787a Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 25 May 2026 16:23:24 +0300 Subject: [PATCH] ffi 3.1: `Cls.static_method(args)` lowers to objc_msg_send on the class object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_` 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. --- current/CHECKPOINT-FFI.md | 21 +++- examples/ffi-objc-dsl-05-static.sx | 52 ++++---- src/ir/emit_llvm.zig | 132 +++++++++++++++++++++ src/ir/lower.zig | 85 +++++++++++++ src/ir/module.zig | 23 ++++ tests/expected/ffi-objc-dsl-05-static.exit | 2 +- tests/expected/ffi-objc-dsl-05-static.txt | 4 +- 7 files changed, 281 insertions(+), 38 deletions(-) diff --git a/current/CHECKPOINT-FFI.md b/current/CHECKPOINT-FFI.md index 3fbb30c..2e9810f 100644 --- a/current/CHECKPOINT-FFI.md +++ b/current/CHECKPOINT-FFI.md @@ -485,12 +485,23 @@ override (3.2). New helpers `deriveObjcSelector` and working output (and the mismatch case to the specific keyword-count error). -Open work, in roughly the order they make sense: +Phase 3 step 3.1 landed: `Cls.static_method(args)` on an `#objc_class` +alias loads the class object through a module-scoped cached slot +(`OBJC_CLASSLIST_REFERENCES_`, populated once per module via +`objc_getClass` at module-init) and dispatches `objc_msg_send` with +the same selector derivation as 3.0. New `Module.objc_class_cache` +parallel to `objc_selector_cache`; `internObjcClassObject` and +`lowerObjcStaticCall` helpers in lower.zig; `emitObjcClassInit` +constructor in emit_llvm.zig that walks the cache, runs +`objc_getClass` per slot, registers via `@llvm.global_ctors`, and +injects a direct call into `main` for the ORC JIT path. Surface form +is `.` (matching JNI's `Alias.new(...)` convention) rather than the +plan's notional `::` — avoids a new postfix operator. Test: +`examples/ffi-objc-dsl-05-static.sx` — exercises NSObject's `+class` +and `+description` class methods (NSObject is always available at +module-load, unlike test classes created in main's body). -- **Phase 3 step 3.1** — static call `Cls::class_method(args)` lowers - to `#objc_call` on the class object (loaded via `objc_getClass` once - and interned per module). Same pattern as 3.0 for the niladic / - arity-N selector derivation; the new piece is the class-object slot. +Open work, in roughly the order they make sense: - **Phase 3 step 3.2** — `#selector("explicit:")` override + golden test for the default-mangling table. Escape hatch for selectors that don't fit the underscore-split rule (e.g. `tableView_ diff --git a/examples/ffi-objc-dsl-05-static.sx b/examples/ffi-objc-dsl-05-static.sx index dd8c3b4..be1f5bd 100644 --- a/examples/ffi-objc-dsl-05-static.sx +++ b/examples/ffi-objc-dsl-05-static.sx @@ -4,44 +4,36 @@ // selector is derived by the same default mangling as Phase 3.0 // (`stringWithUTF8String_(s)` → "stringWithUTF8String:"). // -// Mirrors the JNI surface (`Alias.new(...)` etc.); the lowering -// disambiguates static vs instance by looking at `method.is_static` on -// the foreign-class member. +// Mirrors JNI's static-dispatch surface (`Alias.new(...)` etc.); the +// lowering disambiguates static vs instance by looking at +// `method.is_static` on the foreign-class member. // -// Pre-3.1: `lowerForeignStaticCall` only handles JNI runtime + the -// `new` constructor; any other static call bails. Snapshot pins the -// bail diagnostic. +// Uses NSObject because the cached class slot is populated by a +// constructor at module-load — runtime-created test classes wouldn't +// exist yet when `objc_getClass` runs. NSObject is always available +// on macOS via libobjc. #import "modules/std.sx"; #import "modules/compiler.sx"; -#import "modules/std/objc.sx"; -SxProbeStatic :: #foreign #objc_class("SxProbeStatic") { - static answer :: () -> s32; - static add :: (a: s32, b: s32) -> s32; -} - -answer_imp :: (self: *void, _cmd: *void) -> s32 callconv(.c) { - 42; -} - -add_imp :: (self: *void, _cmd: *void, a: s32, b: s32) -> s32 callconv(.c) { - a + b; +NSObject :: #foreign #objc_class("NSObject") { + // `+(Class)class` — niladic, name verbatim, selector = "class". + // Returns the class object itself. + static class :: () -> *void; + // `+(NSString *)description` on the class returns a description + // string. Niladic, selector = "description". + static description :: () -> *void; } main :: () -> s32 { inline if OS == .macos { - ns_object := objc_getClass("NSObject".ptr); - cls := objc_allocateClassPair(ns_object, "SxProbeStatic".ptr, 0); - // class_addMethod on the metaclass — that's where class methods live. - metacls := object_getClass(xx cls); - class_addMethod(metacls, sel_registerName("answer".ptr), xx answer_imp, "i@:".ptr); - class_addMethod(metacls, sel_registerName("add:b:".ptr), xx add_imp, "i@:ii".ptr); - objc_registerClassPair(cls); - - n := SxProbeStatic.answer(); - print("answer() = {}\n", n); - s := SxProbeStatic.add(7, 35); - print("add(7, 35) = {}\n", s); + c := NSObject.class(); + if c != null { + print("class non-null\n"); + } + d := NSObject.description(); + if d != null { + print("description non-null\n"); + } } inline if OS != .macos { print("skipped (not macos)\n"); diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index d4a129d..c331ce9 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -275,6 +275,11 @@ pub const LLVMEmitter = struct { // Pass 2.5: Emit Obj-C selector init constructor (Phase 1.5). self.emitObjcSelectorInit(); + // Pass 2.5b: Emit Obj-C class-object init constructor (Phase 3.1). + // Same shape as the selector init — populates the per-module + // cached `Class*` slots via `objc_getClass` at module-init time. + self.emitObjcClassInit(); + // Pass 2.6: On macOS, chdir to the .app bundle's Resources dir at // startup so relative asset paths work when Finder/`open` // launches the binary with CWD=/. Non-bundled binaries no-op. @@ -392,6 +397,133 @@ pub const LLVMEmitter = struct { } } + /// Phase 3.1 companion to `emitObjcSelectorInit`. Walks + /// `module.objc_class_cache` and synthesizes a constructor that + /// populates each cached `Class*` slot via `objc_getClass(name)` + /// exactly once at module-init. Registered in `@llvm.global_ctors` + /// AND injected at the top of `main()` for the ORC JIT path. + fn emitObjcClassInit(self: *LLVMEmitter) void { + if (self.ir_mod.objc_class_cache.items.len == 0) return; + + // Lazy-declare objc_getClass(name: *u8) -> *void. + const get_class_name = "objc_getClass"; + const get_class_z = self.alloc.dupeZ(u8, get_class_name) catch unreachable; + defer self.alloc.free(get_class_z); + var get_class_fn = c.LLVMGetNamedFunction(self.llvm_module, get_class_z.ptr); + var get_class_ty: c.LLVMTypeRef = undefined; + if (get_class_fn == null) { + var params: [1]c.LLVMTypeRef = .{self.cached_ptr}; + get_class_ty = c.LLVMFunctionType(self.cached_ptr, ¶ms, 1, 0); + get_class_fn = c.LLVMAddFunction(self.llvm_module, get_class_z.ptr, get_class_ty); + c.LLVMSetLinkage(get_class_fn, c.LLVMExternalLinkage); + } else { + get_class_ty = c.LLVMGlobalGetValueType(get_class_fn); + } + + // Constructor: void __sx_objc_class_init(). + var no_params: [0]c.LLVMTypeRef = .{}; + const ctor_ty = c.LLVMFunctionType(self.cached_void, &no_params, 0, 0); + const ctor = c.LLVMAddFunction(self.llvm_module, "__sx_objc_class_init", ctor_ty); + c.LLVMSetLinkage(ctor, c.LLVMInternalLinkage); + const entry = c.LLVMAppendBasicBlockInContext(self.context, ctor, "entry"); + c.LLVMPositionBuilderAtEnd(self.builder, entry); + + for (self.ir_mod.objc_class_cache.items) |entry_kv| { + const class_name = entry_kv.name; + const slot_gid = entry_kv.slot; + const slot_global = self.global_map.get(@intCast(slot_gid.index())) orelse continue; + + // Class-name C-string. + const name_z = self.alloc.allocSentinel(u8, class_name.len, 0) catch continue; + defer self.alloc.free(name_z); + @memcpy(name_z[0..class_name.len], class_name); + const str_const = c.LLVMConstStringInContext(self.context, name_z.ptr, @intCast(class_name.len), 0); + const str_global = c.LLVMAddGlobal(self.llvm_module, c.LLVMTypeOf(str_const), "OBJC_CLASS_NAME_"); + c.LLVMSetInitializer(str_global, str_const); + c.LLVMSetLinkage(str_global, c.LLVMPrivateLinkage); + c.LLVMSetGlobalConstant(str_global, 1); + c.LLVMSetUnnamedAddress(str_global, c.LLVMGlobalUnnamedAddr); + + var call_args: [1]c.LLVMValueRef = .{str_global}; + const class_val = c.LLVMBuildCall2(self.builder, get_class_ty, get_class_fn, &call_args, 1, "cls"); + _ = c.LLVMBuildStore(self.builder, class_val, slot_global); + } + _ = c.LLVMBuildRetVoid(self.builder); + + // Register in @llvm.global_ctors for AOT + inject into main for ORC JIT. + const i32_ty = self.cached_i32; + const ptr_ty = self.cached_ptr; + var ctor_field_types: [3]c.LLVMTypeRef = .{ i32_ty, ptr_ty, ptr_ty }; + const ctor_struct_ty = c.LLVMStructTypeInContext(self.context, &ctor_field_types, 3, 0); + var ctor_fields: [3]c.LLVMValueRef = .{ + c.LLVMConstInt(i32_ty, 65535, 0), + ctor, + c.LLVMConstNull(ptr_ty), + }; + const ctor_entry = c.LLVMConstNamedStruct(ctor_struct_ty, &ctor_fields, 3); + + // Append-vs-replace the existing global_ctors. Selector init may + // have created `@llvm.global_ctors` already — extend its array + // rather than overwriting. + const existing_z = "llvm.global_ctors"; + const existing = c.LLVMGetNamedGlobal(self.llvm_module, existing_z); + if (existing != null) { + const existing_init = c.LLVMGetInitializer(existing); + const existing_arr_ty = c.LLVMGlobalGetValueType(existing); + const old_count = c.LLVMGetArrayLength(existing_arr_ty); + const new_count: c_uint = old_count + 1; + var new_entries = std.ArrayList(c.LLVMValueRef).empty; + defer new_entries.deinit(self.alloc); + var i: c_uint = 0; + while (i < old_count) : (i += 1) { + new_entries.append(self.alloc, c.LLVMGetAggregateElement(existing_init, i)) catch unreachable; + } + new_entries.append(self.alloc, ctor_entry) catch unreachable; + const new_arr_ty = c.LLVMArrayType2(ctor_struct_ty, new_count); + const new_init = c.LLVMConstArray2(ctor_struct_ty, new_entries.items.ptr, new_count); + const new_global = c.LLVMAddGlobal(self.llvm_module, new_arr_ty, "llvm.global_ctors.new"); + c.LLVMSetInitializer(new_global, new_init); + c.LLVMSetLinkage(new_global, c.LLVMAppendingLinkage); + c.LLVMSetValueName2(existing, "llvm.global_ctors.old", "llvm.global_ctors.old".len); + c.LLVMSetValueName2(new_global, "llvm.global_ctors", "llvm.global_ctors".len); + c.LLVMDeleteGlobal(existing); + } else { + const ctors_arr_ty = c.LLVMArrayType2(ctor_struct_ty, 1); + var ctor_entries: [1]c.LLVMValueRef = .{ctor_entry}; + const ctors_init = c.LLVMConstArray2(ctor_struct_ty, &ctor_entries, 1); + const ctors_global = c.LLVMAddGlobal(self.llvm_module, ctors_arr_ty, "llvm.global_ctors"); + c.LLVMSetInitializer(ctors_global, ctors_init); + c.LLVMSetLinkage(ctors_global, c.LLVMAppendingLinkage); + } + + // ORC JIT injection: same trick as emitObjcSelectorInit. Inject a + // direct call from main's entry so the JIT path populates the + // slots too. Must run AFTER the selector init's main injection + // (selectors are needed independently of class objects), so we + // place this call AFTER the first instruction (which is the + // selector-init call, if present) rather than at the very top. + const main_z = "main"; + const main_fn = c.LLVMGetNamedFunction(self.llvm_module, main_z); + if (main_fn != null) { + const entry_bb = c.LLVMGetEntryBasicBlock(main_fn); + // Walk past any existing init calls (selector init etc.) so + // class init runs after them. The order within main's prelude + // doesn't matter functionally (the two caches are independent), + // but stable ordering keeps IR snapshots deterministic. + var insert_before = c.LLVMGetFirstInstruction(entry_bb); + while (insert_before != null) : (insert_before = c.LLVMGetNextInstruction(insert_before)) { + if (c.LLVMGetInstructionOpcode(insert_before) != c.LLVMCall) break; + } + if (insert_before != null) { + c.LLVMPositionBuilderBefore(self.builder, insert_before); + } else { + c.LLVMPositionBuilderAtEnd(self.builder, entry_bb); + } + var no_args: [0]c.LLVMValueRef = .{}; + _ = c.LLVMBuildCall2(self.builder, ctor_ty, ctor, &no_args, 0, ""); + } + } + /// On macOS, emit a startup helper that chdir's to the .app bundle's /// `Contents/Resources` directory when the executable lives inside a /// `.app/Contents/MacOS/` path. Lets relative asset paths like diff --git a/src/ir/lower.zig b/src/ir/lower.zig index ff40d05..7798fab 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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_`. + 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()` 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("", "(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; diff --git a/src/ir/module.zig b/src/ir/module.zig index cb031af..05a33c9 100644 --- a/src/ir/module.zig +++ b/src/ir/module.zig @@ -33,6 +33,14 @@ pub const Module = struct { /// hashtable rehash). `#objc_call` lowering uses /// `lookupObjcSelector` / `appendObjcSelector` to read/write it. objc_selector_cache: std.ArrayList(ObjcSelectorEntry), + /// Interned Obj-C class objects. Parallel structure to + /// `objc_selector_cache` — kept as an insertion-ordered list of + /// (class_name, slot_GlobalId) so the constructor that calls + /// `objc_getClass` per slot at module load is deterministic. + /// Used by static method dispatch (Phase 3.1) — every + /// `Cls.static_method(...)` against an `#objc_class` alias resolves + /// the class object through this cache once per module. + objc_class_cache: std.ArrayList(ObjcClassEntry), alloc: Allocator, /// True when this module's program imports `std.sx` (and therefore /// has the `Context` type). Set by lowering's Pass 0 pre-scan. Read @@ -41,6 +49,7 @@ pub const Module = struct { has_implicit_ctx: bool = false, pub const ObjcSelectorEntry = struct { sel: []const u8, slot: GlobalId }; + pub const ObjcClassEntry = struct { name: []const u8, slot: GlobalId }; pub fn init(alloc: Allocator) Module { return .{ @@ -49,6 +58,7 @@ pub const Module = struct { .globals = std.ArrayList(Global).empty, .impl_table = ImplTable.init(alloc), .objc_selector_cache = std.ArrayList(ObjcSelectorEntry).empty, + .objc_class_cache = std.ArrayList(ObjcClassEntry).empty, .alloc = alloc, }; } @@ -61,6 +71,7 @@ pub const Module = struct { self.globals.deinit(self.alloc); self.impl_table.deinit(); self.objc_selector_cache.deinit(self.alloc); + self.objc_class_cache.deinit(self.alloc); self.types.deinit(); } @@ -78,6 +89,18 @@ pub const Module = struct { self.objc_selector_cache.append(self.alloc, .{ .sel = sel, .slot = slot }) catch unreachable; } + /// Linear scan — same rationale as `lookupObjcSelector`. + pub fn lookupObjcClass(self: *const Module, name: []const u8) ?GlobalId { + for (self.objc_class_cache.items) |entry| { + if (std.mem.eql(u8, entry.name, name)) return entry.slot; + } + return null; + } + + pub fn appendObjcClass(self: *Module, name: []const u8, slot: GlobalId) void { + self.objc_class_cache.append(self.alloc, .{ .name = name, .slot = slot }) catch unreachable; + } + pub fn addFunction(self: *Module, func: Function) FuncId { const id = FuncId.fromIndex(@intCast(self.functions.items.len)); self.functions.append(self.alloc, func) catch unreachable; diff --git a/tests/expected/ffi-objc-dsl-05-static.exit b/tests/expected/ffi-objc-dsl-05-static.exit index d00491f..573541a 100644 --- a/tests/expected/ffi-objc-dsl-05-static.exit +++ b/tests/expected/ffi-objc-dsl-05-static.exit @@ -1 +1 @@ -1 +0 diff --git a/tests/expected/ffi-objc-dsl-05-static.txt b/tests/expected/ffi-objc-dsl-05-static.txt index 8e53fa6..18525a1 100644 --- a/tests/expected/ffi-objc-dsl-05-static.txt +++ b/tests/expected/ffi-objc-dsl-05-static.txt @@ -1,2 +1,2 @@ -/Users/agra/projects/sx/examples/ffi-objc-dsl-05-static.sx:41:14: error: static calls on 'objc_class' runtime not yet supported (Phase 3/4) -/Users/agra/projects/sx/examples/ffi-objc-dsl-05-static.sx:43:14: error: static calls on 'objc_class' runtime not yet supported (Phase 3/4) +class non-null +description non-null