From 638a048cc517cd754d1f5015f2756bdf2c16fc24 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 10 Jun 2026 14:03:21 +0300 Subject: [PATCH] refactor(B6.1): move Obj-C/JNI call lowering to lower/ffi.zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbatim relocation of the 24-method FFI cluster (selector/class-object interning, FFI intrinsic + JNI calls, foreign instance/static method lowering, super calls, foreign-class registration, Obj-C defined-class method registration, JNI env TLS fids, JNI main-stub synthesis) plus the file-scope jniMapParamType (no alias needed — all callers moved) into src/ir/lower/ffi.zig. 24 aliases on Lowering keep all call sites unchanged. Method pub-flips: emitObjcDefinedAllocAndInit, findForeignMethodInChain. 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 | 1187 +---------------------------------------- src/ir/lower/ffi.zig | 1205 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1234 insertions(+), 1158 deletions(-) create mode 100644 src/ir/lower/ffi.zig diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 9e0fc9c..9f575b3 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -41,6 +41,7 @@ const lower_decl = @import("lower/decl.zig"); 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 TypeId = types.TypeId; const StringId = types.StringId; @@ -2977,765 +2978,6 @@ pub const Lowering = struct { // ── FFI intrinsics (#objc_call / #jni_call / #jni_static_call) ─ - /// Intern an Obj-C selector string into a module-scoped `SEL*` slot. - /// First call creates the global; subsequent calls return the same - /// `GlobalId`. emit_llvm.zig walks `module.objc_selector_cache` and - /// synthesizes a constructor that populates each slot via - /// `sel_registerName` exactly once at module load. - /// - /// Slot name matches clang's convention: `OBJC_SELECTOR_REFERENCES_` - /// with `:` replaced by `_` to keep the symbol name valid. - fn internObjcSelector(self: *Lowering, sel_str: []const u8) inst_mod.GlobalId { - if (self.module.lookupObjcSelector(sel_str)) |gid| return gid; - - // Mangle selector: replace colons with underscores. Apple's - // toolchain does the same (foo:bar: → foo_bar_). - var mangled = std.ArrayList(u8).empty; - defer mangled.deinit(self.alloc); - mangled.appendSlice(self.alloc, "OBJC_SELECTOR_REFERENCES_") catch unreachable; - for (sel_str) |ch| { - mangled.append(self.alloc, if (ch == ':') '_' else ch) 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.appendObjcSelector(sel_str, gid); - 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. - 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; - } - - /// 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 == .jni_call or fic.kind == .jni_static_call) { - return self.lowerJniCall(fic); - } - - 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); - - 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; - } - - // Receiver expression. - const recv = self.lowerExpr(fic.args[0]); - - // Selector. Literal selectors get interned into a module- - // scoped `SEL*` slot — emit_llvm.zig tags the slot into - // `__DATA,__objc_selrefs` so dyld populates it at load time - // (matches clang's `@selector(...)` lowering exactly). - // Non-literal selectors keep the per-call `sel_registerName` - // fallback. - const sel_arg_node = fic.args[1]; - const vptr_ty = self.module.types.ptrTo(.void); - const sel = blk: { - if (sel_arg_node.data == .string_literal) { - const raw = sel_arg_node.data.string_literal.raw; - const slot_gid = self.internObjcSelector(raw); - const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty)); - break :blk self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); - } - const sel_ref = self.lowerExpr(sel_arg_node); - 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; - break :blk self.builder.emit(.{ .call = .{ .callee = sel_fid, .args = sel_owned } }, vptr_ty); - }; - - // Additional args after recv + selector. - var extra = std.ArrayList(Ref).empty; - var ai: usize = 2; - while (ai < fic.args.len) : (ai += 1) { - extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable; - } - const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable; - - return self.builder.emit(.{ .objc_msg_send = .{ - .recv = recv, - .sel = sel, - .args = extra_owned, - } }, ret_ty); - } - - fn lowerJniCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref { - // env is always implicit: lexical-direct from the enclosing `#jni_env(env)` - // block (2.16b, cheap), else the thread-local slot the block populated - // at runtime (2.16c, one TL load per call). Surface form is uniform: - // #jni_call(T)(target, "name", "sig", method-args...) (≥3 args) - if (fic.args.len < 3) { - if (self.diagnostics) |d| { - d.add(.err, "#jni_call requires target, method name, and signature", null); - } - return Ref.none; - } - - const ret_ty = self.resolveType(fic.return_type); - - const env_ref = if (self.jni_env_stack.items.len > self.jni_env_stack_base) - self.jni_env_stack.items[self.jni_env_stack.items.len - 1] - else blk: { - const fids = self.getJniEnvTlFids(); - const ptr_ty = self.module.types.ptrTo(.void); - break :blk self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty); - }; - - const target_idx: usize = 0; - const name_idx: usize = 1; - const sig_idx: usize = 2; - const first_method_arg_idx: usize = 3; - - const target_ref = self.lowerExpr(fic.args[target_idx]); - const name_node = fic.args[name_idx]; - const sig_node = fic.args[sig_idx]; - const name_ref = self.lowerExpr(name_node); - const sig_ref = self.lowerExpr(sig_node); - - // Capture the (name, sig) literal content when both args are - // string literals — emit_llvm uses this as the intern key for - // the shared `jclass`/`jmethodID` slot pair (step 1.17). - const cache_key: ?inst_mod.CacheKey = if (name_node.data == .string_literal and sig_node.data == .string_literal) - inst_mod.CacheKey{ - .name_str = name_node.data.string_literal.raw, - .sig_str = sig_node.data.string_literal.raw, - } - else - null; - - var extra = std.ArrayList(Ref).empty; - var ai: usize = first_method_arg_idx; - while (ai < fic.args.len) : (ai += 1) { - extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable; - } - const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable; - - return self.builder.emit(.{ .jni_msg_send = .{ - .env = env_ref, - .target = target_ref, - .name = name_ref, - .sig = sig_ref, - .args = extra_owned, - .is_static = fic.kind == .jni_static_call, - .cache_key = cache_key, - } }, ret_ty); - } - - /// Lower an `inst.method(args)` call where `inst`'s type is a foreign-class - /// alias declared by `#jni_class("...") { ... }` (or its parallel forms). - /// JNI runtimes lower directly to `jni_msg_send` with a descriptor derived - /// from the method's sx signature; Obj-C / Swift runtimes are deferred to - /// Phase 3/4 and currently surface a clear diagnostic. - fn lowerForeignMethodCall( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - method_name: []const u8, - target: Ref, - method_args: []const Ref, - span: ast.Span, - ) Ref { - // M2.3 — walk the `#extends` chain when the method isn't - // declared directly on this fcd. The dispatch target stays - // the original receiver — objc_msgSend's runtime walks the - // class hierarchy by isa, so we just need to find ANY - // ancestor that declared the method (for the selector - // mangling + signature info). The receiver-class fcd is - // still used for `*Self` substitution at the dispatch site - // — the inherited method's *Self should resolve to the - // child receiver, not the parent. - const found = self.findForeignMethodInChain(fcd, method_name) orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "no method '{s}' on foreign class '{s}' (or any `#extends` ancestor)", .{ method_name, fcd.name }); - } - return Ref.none; - }; - const method = found.method; - - // Obj-C instance dispatch (Phase 3 step 3.0 + M1.2 A.7). - // `inst.method(args)` on an `#objc_class` / `#objc_protocol` - // receiver derives a selector from the sx method name (default - // mangling: split on `_`, each piece becomes a keyword with a - // trailing `:`; niladic stays verbatim) and lowers to - // `objc_msg_send`. Both foreign and sx-defined classes flow - // through the same path — sx-defined classes have their IMPs - // registered at module-init (M1.2 A.4b.iii) so `objc_msgSend` - // finds them. The Swift runtimes still bail — Phase 4. - if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) { - return self.lowerObjcMethodCall(fcd, method, target, method_args, span); - } - if (!fcd.is_foreign) { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "sx-defined classes on non-Obj-C runtimes can't yet be dispatched into (class '{s}', runtime '{s}')", .{ fcd.name, @tagName(fcd.runtime) }); - } - return Ref.none; - } - if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "method calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)}); - } - return Ref.none; - } - - if (self.jni_env_stack.items.len == 0) { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "method call on '{s}' requires an enclosing '#jni_env' scope", .{fcd.name}); - } - return Ref.none; - } - const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; - - // Build a ClassRegistry snapshot so descriptor derivation can - // resolve `*Foo` cross-class refs to their foreign paths. - var registry = jni_descriptor.ClassRegistry.init(self.alloc); - defer registry.deinit(); - var it = self.program_index.foreign_class_map.iterator(); - while (it.next()) |entry| { - registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; - } - - const desc_str = jni_descriptor.deriveMethod(self.alloc, .{ - .enclosing_path = fcd.foreign_path, - .classes = ®istry, - }, method) catch |err| { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.{s}': {s}", .{ fcd.name, method.name, @errorName(err) }); - } - return Ref.none; - }; - - const name_sid = self.module.types.internString(method_name); - const name_ref = self.builder.constString(name_sid); - const sig_sid = self.module.types.internString(desc_str); - const sig_ref = self.builder.constString(sig_sid); - - const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; - - // Reject return types the JNI emit path can't dispatch — emit_llvm's - // CallMethod switch only covers void / bool / s32 / s64 / f32 / f64 - // / pointer-returning. Anything else (s8 / s16 / u8 / u16 / aggregates) - // would silently lower to LLVMGetUndef and produce wrong arguments at - // the call site (chess Android touch shipped broken because s32→s32+ - // f32 returns hit the undef path before .f32 was wired up). - if (!jni_descriptor.isJniReturnTypeSupported(&self.module.types, ret_ty)) { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "JNI method '{s}.{s}' returns '{s}', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up", .{ fcd.name, method.name, self.module.types.typeName(ret_ty) }); - } - return Ref.none; - } - - const cache_key: inst_mod.CacheKey = .{ - .name_str = method_name, - .sig_str = desc_str, - }; - - const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; - return self.builder.emit(.{ .jni_msg_send = .{ - .env = env_ref, - .target = target, - .name = name_ref, - .sig = sig_ref, - .args = args_owned, - .is_static = method.is_static, - .cache_key = cache_key, - } }, ret_ty); - } - - // Pure Obj-C decision helpers (selector derivation, type-encoding, ARC - // property-kind, class-pointer recognition, state-struct planning) live in - // `ffi_objc.zig` (`ObjcLowering`, a `*Lowering` facade). Reached via - // `self.objc()`. Emission-heavy IMP builders + `lowerObjc*Call` stay here. - - /// Resolve a foreign-class member type, substituting `Self` (and `*Self`) - /// with the foreign class's own struct type. Without this substitution - /// chained calls like `Cls.alloc().init()` see the inner result as a - /// fictitious `Self` struct and the next dispatch lookup fails. - fn resolveForeignClassMemberType( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - type_node: *const ast.Node, - ) TypeId { - if (type_node.data == .type_expr and std.mem.eql(u8, type_node.data.type_expr.name, "Self")) { - return self.foreignClassStructType(fcd); - } - if (type_node.data == .pointer_type_expr) { - const pt = type_node.data.pointer_type_expr; - if (pt.pointee_type.data == .type_expr and std.mem.eql(u8, pt.pointee_type.data.type_expr.name, "Self")) { - return self.module.types.ptrTo(self.foreignClassStructType(fcd)); - } - } - return self.resolveType(type_node); - } - - pub fn resolveForeignMethodReturnType( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - method: ast.ForeignMethodDecl, - ) TypeId { - const rt = method.return_type orelse return .void; - return self.resolveForeignClassMemberType(fcd, rt); - } - - fn foreignClassStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId { - const name_id = self.module.types.internString(fcd.name); - if (self.module.types.findByName(name_id)) |existing| return existing; - return self.module.types.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } }); - } - - /// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol` - /// receiver. The selector is derived by `deriveObjcSelector`; arity - /// is validated against the keyword count produced by the mangling - /// (excluding self). Dispatch then runs through `objc_msg_send`, - /// sharing the cached-SEL slot path with explicit `#objc_call`. - fn lowerObjcMethodCall( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - method: ast.ForeignMethodDecl, - target: Ref, - method_args: []const Ref, - span: ast.Span, - ) Ref { - const arity = method_args.len; - const derived = self.objc().deriveObjcSelector(method, arity); - - // Arity validation: the keyword count (number of `:` in the - // selector) must equal the number of args passed at the call - // site. For methods using the default mangling rule, a mismatch - // is an error because the user can fix the sx-side name. For - // `#selector("...")` overrides, the user has deliberately - // chosen the selector — downgrade to a warning so the build - // proceeds, but still surface the typo case (Obj-C's runtime - // doesn't validate colon-vs-arg, so this is the last defense). - if (arity > 0 and derived.keyword_count != arity) { - if (self.diagnostics) |d| { - if (derived.is_override) { - d.addFmt( - .warn, - span, - "Obj-C selector \"{s}\" (override for '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string", - .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity }, - ); - } else { - d.addFmt( - .err, - span, - "Obj-C selector for '{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(\"...\")`", - .{ fcd.name, method.name, derived.keyword_count, arity, arity }, - ); - return Ref.none; - } - } - } - - const ret_ty = self.resolveForeignMethodReturnType(fcd, method); - - // Cache the SEL slot per (selector-string, module) like - // `#objc_call` does. The mangling produces the literal selector - // string; we don't need a runtime sel_registerName call at the - // dispatch site because the global initializer already does it. - const vptr_ty = self.module.types.ptrTo(.void); - const slot_gid = self.internObjcSelector(derived.sel); - const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty)); - const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); - - const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; - return self.builder.emit(.{ .objc_msg_send = .{ - .recv = target, - .sel = sel, - .args = args_owned, - } }, 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.objc().deriveObjcSelector(method, arity); - - if (arity > 0 and derived.keyword_count != arity) { - if (self.diagnostics) |d| { - if (derived.is_override) { - d.addFmt( - .warn, - span, - "Obj-C selector \"{s}\" (override for static call '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string", - .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity }, - ); - } else { - 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(\"...\")`", - .{ fcd.name, method.name, derived.keyword_count, arity, arity }, - ); - return Ref.none; - } - } - } - - const ret_ty = self.resolveForeignMethodReturnType(fcd, method); - - 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); - - // M4.0b: intercept `Cls.alloc()` for sx-defined classes — emit the - // inline alloc-and-init sequence using the caller's `context.allocator` - // instead of going through `objc_msgSend` (which would land in the - // +alloc IMP and use `__sx_default_context.allocator`). This honors - // a surrounding `push Context.{ allocator = ... }`. - if (!fcd.is_foreign and - fcd.runtime == .objc_class and - method_args.len == 0 and - std.mem.eql(u8, method.name, "alloc")) - { - const ctx_addr = if (self.current_ctx_ref != Ref.none) - self.current_ctx_ref - else blk: { - // Fallback: no current ctx (e.g. compiler-internal callers). - // Use the default context — same as the IMP would. - const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "Cls.alloc() on sx-defined class '{s}': no current context and __sx_default_context missing", .{fcd.name}); - } - return Ref.none; - }; - break :blk self.builder.emit(.{ .global_addr = default_ctx_gi.id }, vptr_ty); - }; - const instance = self.emitObjcDefinedAllocAndInit(fcd, class_obj, ctx_addr) orelse return Ref.none; - // class_createInstance returns *void; bitcast to the method's - // declared return type (typically `*` or `?*`) so - // downstream `let f := Cls.alloc();` binds f at the right type - // (lowerVarDecl reads the Ref's IR type when no annotation is - // present). coerceToType is a no-op for ptr→ptr; we need an - // explicit bitcast IR op to retype the Ref. - if (ret_ty == vptr_ty) return instance; - // Optional-wrapped returns (e.g. `-> ?*Cls`): emit optional_wrap. - if (!ret_ty.isBuiltin()) { - const ret_info = self.module.types.get(ret_ty); - if (ret_info == .optional) { - const inner = ret_info.optional.child; - const cast = if (inner == vptr_ty) - instance - else - self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = inner } }, inner); - return self.builder.optionalWrap(cast, ret_ty); - } - } - return self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = ret_ty } }, ret_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, - /// clazz, mid, args...)`. Returns the new jobject. - /// - /// Non-`new` static methods aren't supported via this path yet — the - /// user can use `#jni_static_call(T)(class, "name", sig, args...)` - /// for those. Constructor is the common case for #jni_main bodies - /// that need to instantiate Android classes (SurfaceView, etc.). - fn lowerForeignStaticCall( - self: *Lowering, - fcd: *const ast.ForeignClassDecl, - method: ast.ForeignMethodDecl, - 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; - } - if (!std.mem.eql(u8, method.name, "new")) { - if (self.diagnostics) |d| d.addFmt(.err, span, "static foreign-class call '{s}.{s}' not yet supported via `Alias.method()` syntax \u{2014} only `new` is wired today; use `#jni_static_call` directly for other static methods", .{ fcd.name, method.name }); - return Ref.none; - } - - if (self.jni_env_stack.items.len <= self.jni_env_stack_base) { - if (self.diagnostics) |d| d.addFmt(.err, span, "constructor `{s}.new(...)` requires an enclosing `#jni_env` scope (or `#jni_main` body)", .{fcd.name}); - return Ref.none; - } - const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; - - // Build class registry snapshot for `*Foo` cross-class refs. - var registry = jni_descriptor.ClassRegistry.init(self.alloc); - defer registry.deinit(); - var it = self.program_index.foreign_class_map.iterator(); - while (it.next()) |entry| { - registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; - } - - // For `new`, the JNI descriptor's return position is `V` (the - // constructor returns void; the new jobject comes back from - // `NewObject` itself). Patch the AST by overriding return_type - // to null during derivation. - const m_for_desc: ast.ForeignMethodDecl = .{ - .name = method.name, - .params = method.params, - .param_names = method.param_names, - .return_type = null, - .is_static = method.is_static, - .jni_descriptor_override = method.jni_descriptor_override, - .body = method.body, - }; - - const descriptor = jni_descriptor.deriveMethod(self.alloc, .{ - .enclosing_path = fcd.foreign_path, - .classes = ®istry, - }, m_for_desc) catch |err| { - if (self.diagnostics) |d| d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.new': {s}", .{ fcd.name, @errorName(err) }); - return Ref.none; - }; - - // sx-side return type is `*Self` — resolve to a pointer to the - // foreign-class struct type so method dispatch on the new - // jobject works (`view := SurfaceView.new(ctx); view.getHolder()`). - // At LLVM level still ptr; the sx type table is what method - // resolution consults. - const self_struct_name = self.module.types.internString(fcd.name); - const self_struct_id = if (self.module.types.findByName(self_struct_name)) |existing| - existing - else blk: { - const info: types.TypeInfo = .{ .@"struct" = .{ .name = self_struct_name, .fields = &.{} } }; - break :blk self.module.types.intern(info); - }; - const ret_ty = self.module.types.ptrTo(self_struct_id); - - const name_sid = self.module.types.internString(""); - const name_ref = self.builder.constString(name_sid); - const sig_sid = self.module.types.internString(descriptor); - const sig_ref = self.builder.constString(sig_sid); - - const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; - return self.builder.emit(.{ .jni_msg_send = .{ - .env = env_ref, - .target = Ref.none, // unused for ctor — class is resolved via parent_class_path - .name = name_ref, - .sig = sig_ref, - .args = args_owned, - .is_static = false, - .is_constructor = true, - .parent_class_path = self.alloc.dupe(u8, fcd.foreign_path) catch fcd.foreign_path, - .cache_key = null, - } }, ret_ty); - } - - /// Lower `super.method(args)` inside a `#jni_main` / sx-defined - /// `#jni_class` bodied method. Resolves the parent class from the - /// enclosing fcd's `#extends` clause (default `android.app.Activity`) - /// and emits a `JniMsgSend` with `is_nonvirtual=true`, which - /// emit_llvm expands into a `FindClass(parent) + GetMethodID + - /// CallNonvirtualMethod` chain. - /// - /// Signature derivation: when `method_name` matches the enclosing - /// method's name (the common case — `super.onCreate(b)` from inside - /// `onCreate :: (self, b)` override), the enclosing method's - /// signature is reused. Other method names require the parent class - /// to be declared via `#foreign #jni_class` so the signature can be - /// looked up. - fn lowerSuperCall( - self: *Lowering, - method_name: []const u8, - method_args: []const Ref, - span: ast.Span, - ) Ref { - const fcd = self.current_foreign_class orelse { - if (self.diagnostics) |d| d.addFmt(.err, span, "'super' is only valid inside a `#jni_class` method body", .{}); - return Ref.none; - }; - - // Resolve parent foreign_path from the fcd's `#extends`. Default to - // android.app.Activity to match the jni_java_emit default. - var parent_path: []const u8 = "android/app/Activity"; - for (fcd.members) |m| switch (m) { - .extends => |alias| { - if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| { - parent_path = parent_fcd.foreign_path; - } else { - parent_path = alias; - } - break; - }, - else => {}, - }; - - // Resolve method signature. Same-name fast path reuses the - // enclosing method's descriptor; cross-method super calls require - // the parent class to be declared via `#foreign #jni_class`. - var descriptor: []const u8 = ""; - var resolved_method: ?ast.ForeignMethodDecl = null; - if (self.current_foreign_method) |em| { - if (std.mem.eql(u8, em.name, method_name)) { - resolved_method = em; - } - } - if (resolved_method == null) { - const parent_fcd = blk: for (fcd.members) |m| switch (m) { - .extends => |alias| if (self.program_index.foreign_class_map.get(alias)) |pf| break :blk pf else continue, - else => {}, - } else null; - if (parent_fcd) |pf| { - for (pf.members) |pm| switch (pm) { - .method => |pmd| if (std.mem.eql(u8, pmd.name, method_name)) { - resolved_method = pmd; - break; - }, - else => {}, - }; - } - } - const method = resolved_method orelse { - if (self.diagnostics) |d| d.addFmt(.err, span, "no method '{s}' found for `super.{s}(...)` — declare the parent class via `#foreign #jni_class` to make cross-method super calls available", .{ method_name, method_name }); - return Ref.none; - }; - - // Derive descriptor against the parent path (used as enclosing_path - // for `*Self` resolution). - var registry = jni_descriptor.ClassRegistry.init(self.alloc); - defer registry.deinit(); - var it = self.program_index.foreign_class_map.iterator(); - while (it.next()) |entry| { - registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; - } - descriptor = jni_descriptor.deriveMethod(self.alloc, .{ - .enclosing_path = parent_path, - .classes = ®istry, - }, method) catch |err| { - if (self.diagnostics) |d| d.addFmt(.err, span, "super-call descriptor derivation failed for '{s}.{s}': {s}", .{ parent_path, method_name, @errorName(err) }); - return Ref.none; - }; - - // env from the lexical stack (pushed by synthesizeJniMainStub). - if (self.jni_env_stack.items.len <= self.jni_env_stack_base) { - if (self.diagnostics) |d| d.addFmt(.err, span, "`super.{s}(...)` requires an enclosing `#jni_main` method scope (env is unavailable)", .{method_name}); - return Ref.none; - } - const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; - - // `self` is the first param of the synthesized `Java_*` fn. Bound - // in scope as `self` by synthesizeJniMainStub. - const self_binding = if (self.scope) |s| s.lookup("self") else null; - const self_ref = if (self_binding) |b| (if (b.is_alloca) self.builder.load(b.ref, b.ty) else b.ref) else Ref.none; - - const name_sid = self.module.types.internString(method_name); - const name_ref = self.builder.constString(name_sid); - const sig_sid = self.module.types.internString(descriptor); - const sig_ref = self.builder.constString(sig_sid); - - const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; - - const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; - return self.builder.emit(.{ .jni_msg_send = .{ - .env = env_ref, - .target = self_ref, - .name = name_ref, - .sig = sig_ref, - .args = args_owned, - .is_static = false, - .is_nonvirtual = true, - .parent_class_path = self.alloc.dupe(u8, parent_path) catch parent_path, - .cache_key = null, // per-call FindClass + GetMethodID; caching is a follow-up - } }, ret_ty); - } - - // ── Calls ─────────────────────────────────────────────────────── - fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { var c = c_in; // A bare reserved-type-name spelling in call position parses as a @@ -9653,255 +8895,6 @@ pub const Lowering = struct { }; } - /// Register a foreign-class declaration. The alias goes into - /// `foreign_class_map` for method-dispatch lookup. The underlying - /// type (e.g. `*Activity`) is resolved via the existing struct - /// fallback in `type_bridge.resolveTypeName` (which interns unknown - /// named types as 0-field structs). - /// - /// sx-defined Obj-C classes (no `#foreign`, runtime == .objc_class) - /// also land in `module.objc_defined_class_cache` in declaration - /// order AND have their bodied methods registered into `fn_ast_map` - /// under qualified names `.`. Lazy lowering - /// then handles the body via the standard path; `*Self` is - /// substituted to `*State` during body lowering (M1.2 A.2b). - pub fn registerForeignClassDecl(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { - self.program_index.foreign_class_map.put(fcd.name, fcd) catch {}; - if (!fcd.is_foreign and fcd.runtime == .objc_class) { - if (self.module.lookupObjcDefinedClass(fcd.name) == null) { - self.module.appendObjcDefinedClass(fcd.name, fcd); - // M2.3 — resolve the `#extends` alias to the actual - // Obj-C runtime class name. `#extends NSObjectBase` - // where NSObjectBase is aliased to "NSObject" must - // pass "NSObject" to objc_allocateClassPair, otherwise - // the runtime's class-hierarchy link is broken and - // inherited-method dispatch fails. - self.module.setObjcDefinedClassParent(fcd.name, self.resolveObjcParentName(fcd)); - // M1.2 A.4b.i: per-class ivar handle global. The class-pair - // init constructor (emit_llvm) populates it via - // class_getInstanceVariable after the class is registered; - // IMP trampolines read it to find the __sx_state ivar. - self.declareObjcDefinedStateIvarGlobal(fcd.name); - // M1.2 A.6: per-class class-object global. -dealloc reads - // it to build an `objc_super` struct for `[super dealloc]` - // dispatch via `objc_msgSendSuper2`. - self.declareObjcDefinedClassGlobal(fcd.name); - } - self.registerObjcDefinedClassMethods(fcd); - } - } - - /// Resolve the `#extends ParentAlias` declaration on a sx-defined - /// `#objc_class` to the actual Obj-C runtime class name. Falls - /// back to "NSObject" when no `#extends` is declared. - /// Aliases that resolve to foreign Obj-C classes use the - /// foreign_path; aliases for OTHER sx-defined classes use the - /// alias name directly (which equals the Obj-C class name for - /// sx-defined classes). - fn resolveObjcParentName(self: *Lowering, fcd: *const ast.ForeignClassDecl) []const u8 { - for (fcd.members) |m| switch (m) { - .extends => |alias| { - if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| { - if (parent_fcd.is_foreign) return parent_fcd.foreign_path; - // Sx-defined parent — its alias IS its Obj-C name. - return parent_fcd.name; - } - // Unknown alias — pass through as-is and let the - // runtime diagnose if it's genuinely wrong. - return alias; - }, - else => {}, - }; - return "NSObject"; - } - - /// Declare a per-class global `___state_ivar : *void = null`. - /// emit_llvm's `emitObjcDefinedClassInit` constructor fills it in via - /// `class_getInstanceVariable(cls, "__sx_state")` once per module load. - fn declareObjcDefinedStateIvarGlobal(self: *Lowering, class_name: []const u8) void { - const gname = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{class_name}) catch return; - const name_id = self.module.types.internString(gname); - _ = self.module.addGlobal(.{ - .name = name_id, - .ty = self.module.types.ptrTo(.void), - .init_val = .null_val, - .is_extern = false, - .is_const = false, - }); - } - - /// Declare a per-class global `___class : *void = null`. - /// emit_llvm's `emitObjcDefinedClassInit` constructor stores the - /// freshly-allocated Class pointer into it after objc_registerClassPair. - /// The synthesized `-dealloc` IMP reads it to construct an `objc_super` - /// for `[super dealloc]` dispatch. - fn declareObjcDefinedClassGlobal(self: *Lowering, class_name: []const u8) void { - const gname = std.fmt.allocPrint(self.alloc, "__{s}_class", .{class_name}) catch return; - const name_id = self.module.types.internString(gname); - _ = self.module.addGlobal(.{ - .name = name_id, - .ty = self.module.types.ptrTo(.void), - .init_val = .null_val, - .is_extern = false, - .is_const = false, - }); - } - - /// For each bodied instance method on an sx-defined `#objc_class`, - /// synthesize an `FnDecl` from the `ForeignMethodDecl`, register it - /// in `fn_ast_map` under `.`, declare the IR - /// function, AND collect per-method registration data (selector - /// mangling + type encoding + IMP symbol name) into the class's - /// cache entry so emit_llvm can wire up `class_addMethod` calls - /// (M1.2 A.4b.iii). Bodyless declarations are skipped — they - /// reference inherited / external methods, not sx-side bodies. - fn registerObjcDefinedClassMethods(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { - // Set current_foreign_class so `*Self` substitutions in - // declareFunction's type resolution find the state struct. - const saved = self.current_foreign_class; - self.current_foreign_class = fcd; - defer self.current_foreign_class = saved; - - var method_infos = std.ArrayList(Module.ObjcDefinedMethodEntry).empty; - - for (fcd.members) |m| { - const method = switch (m) { - .method => |md| md, - else => continue, - }; - const body = method.body orelse continue; - const fd = self.synthesizeFnDeclFromObjcMethod(method, body) orelse continue; - const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue; - self.program_index.fn_ast_map.put(qualified, fd) catch {}; - self.declareFunction(fd, qualified); - - // Selector mangling — A.1's deriveObjcSelector handles - // `#selector("...")` override + the default rule. Static - // methods use the same mangling rule (their first param - // ISN'T *Self, so no offset). - // - // ABI for the IMP signature (both instance + class methods): - // `(recv: id|Class, _cmd: SEL, ...user_args) -> ret` - // For instance methods the user-declared self is at param[0] - // (skipped); class methods have no self in the AST. - const user_param_start: usize = if (method.is_static) 0 else 1; - const user_arg_count = if (method.params.len > user_param_start) method.params.len - user_param_start else 0; - const sel_info = self.objc().deriveObjcSelector(method, user_arg_count); - - const ret_ty: TypeId = if (method.return_type) |rt| self.resolveType(rt) else .void; - var arg_tys = std.ArrayList(TypeId).empty; - defer arg_tys.deinit(self.alloc); - if (method.params.len > user_param_start) { - for (method.params[user_param_start..]) |p_node| { - arg_tys.append(self.alloc, self.resolveType(p_node)) catch unreachable; - } - } - const encoding = self.objc().objcTypeEncodingFromSignature(ret_ty, arg_tys.items, null) catch continue; - - const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, method.name }) catch continue; - - method_infos.append(self.alloc, .{ - .sel = sel_info.sel, - .encoding = encoding, - .imp_name = imp_name, - .is_class = method.is_static, - }) catch unreachable; - } - - if (method_infos.items.len > 0) { - const methods_slice = method_infos.toOwnedSlice(self.alloc) catch return; - self.module.setObjcDefinedClassMethods(fcd.name, methods_slice); - } - } - - /// Build an `FnDecl` whose params are zipped from the - /// `ForeignMethodDecl.params` (type nodes) and `param_names`. Used - /// to feed sx-defined class methods through the standard - /// fn-lowering pipeline. Allocator-owned; lives for the duration - /// of the Lowering pass. - fn synthesizeFnDeclFromObjcMethod(self: *Lowering, method: ast.ForeignMethodDecl, body: *ast.Node) ?*ast.FnDecl { - if (method.params.len != method.param_names.len) return null; - var params = std.ArrayList(ast.Param).empty; - for (method.params, method.param_names) |type_node, p_name| { - params.append(self.alloc, .{ - .name = p_name, - .name_span = .{ .start = 0, .end = 0 }, - .type_expr = type_node, - }) catch unreachable; - } - const fd = self.alloc.create(ast.FnDecl) catch return null; - fd.* = .{ - .name = method.name, - .params = params.toOwnedSlice(self.alloc) catch unreachable, - .return_type = method.return_type, - .body = body, - }; - return fd; - } - - /// If `name` matches an sx-defined `#objc_class`'s qualified-method - /// pattern (`.`), return the class's - /// ForeignClassDecl. Used by `lowerFunction` to set - /// `current_foreign_class` so `*Self` resolves to the state struct - /// during body lowering. - pub fn lookupObjcDefinedClassForMethod(self: *Lowering, name: []const u8) ?*const ast.ForeignClassDecl { - const dot = std.mem.indexOf(u8, name, ".") orelse return null; - return self.module.lookupObjcDefinedClass(name[0..dot]); - } - - /// Lazily declare the `sx_jni_env_tl_get` / `sx_jni_env_tl_set` - /// runtime externs (step 2.16c). The storage lives in - /// `library/vendors/sx_jni_runtime/sx_jni_env_tl.c` as a - /// `_Thread_local` slot — keeping it OUT of the user's IR module - /// is what lets the LLVM ORC JIT load the module cleanly without - /// orc_rt platform support. AOT targets get the same .c file - /// linked in via `needs_jni_env_tl_runtime`, which Compilation - /// reads to append a synthetic c_import alongside the user's. - pub fn getJniEnvTlFids(self: *Lowering) struct { get: FuncId, set: FuncId } { - self.needs_jni_env_tl_runtime = true; - const ptr_ty = self.module.types.ptrTo(.void); - if (self.jni_env_tl_get_fid == null) { - const name = self.module.types.internString("sx_jni_env_tl_get"); - const fid = self.builder.declareExtern(name, &.{}, ptr_ty); - const func = self.module.getFunctionMut(fid); - func.call_conv = .c; - self.jni_env_tl_get_fid = fid; - } - if (self.jni_env_tl_set_fid == null) { - const name = self.module.types.internString("sx_jni_env_tl_set"); - const env_param = self.module.types.internString("env"); - var params = std.ArrayList(inst_mod.Function.Param).empty; - params.append(self.alloc, .{ .name = env_param, .ty = ptr_ty }) catch unreachable; - const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void); - const func = self.module.getFunctionMut(fid); - func.call_conv = .c; - self.jni_env_tl_set_fid = fid; - } - return .{ .get = self.jni_env_tl_get_fid.?, .set = self.jni_env_tl_set_fid.? }; - } - - /// When a namespaced import (`Ns :: #import "..."`) contains foreign-class - /// declarations, ALSO register them under their qualified name `Ns.Class` - /// so receiver types like `*Ns.Class` can find the fcd. The recursive - /// scan/lower already handles bare-name registration; this only adds the - /// qualified-name entry, so cross-class refs in method signatures - /// (`*View` → bare lookup) still work. - pub fn registerNamespacedForeignClasses(self: *Lowering, ns: ast.NamespaceDecl) void { - for (ns.decls) |inner| { - if (inner.data == .foreign_class_decl) { - const fcd = &inner.data.foreign_class_decl; - const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ ns.name, fcd.name }) catch fcd.name; - self.program_index.foreign_class_map.put(qualified, fcd) catch {}; - } else if (inner.data == .namespace_decl) { - // Nested namespaces — qualify with both prefixes. - self.registerNamespacedForeignClasses(inner.data.namespace_decl); - } - } - } - - - // ── Protocol dispatch ────────────────────────────────────────── - /// Infer the type of an expression from its AST node (used for untyped var decls). pub fn inferExprType(self: *Lowering, node: *const Node) TypeId { return switch (node.data) { @@ -10274,7 +9267,7 @@ pub const Lowering = struct { /// 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). - fn findForeignMethodInChain(self: *Lowering, fcd: *const ast.ForeignClassDecl, method_name: []const u8) ?struct { fcd: *const ast.ForeignClassDecl, method: ast.ForeignMethodDecl } { + 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) { @@ -11056,7 +10049,7 @@ pub const Lowering = struct { /// /// Returns the new instance pointer, or `null` if a required global is /// missing (compiler bug — should be impossible after scan pass). - fn emitObjcDefinedAllocAndInit( + pub fn emitObjcDefinedAllocAndInit( self: *Lowering, fcd: *const ast.ForeignClassDecl, cls_ref: Ref, @@ -11495,145 +10488,6 @@ pub const Lowering = struct { return null; } - pub fn synthesizeJniMainStubs(self: *Lowering) void { - var seen = std.StringHashMap(void).init(self.alloc); - defer seen.deinit(); - - var it = self.program_index.foreign_class_map.iterator(); - while (it.next()) |entry| { - const fcd = entry.value_ptr.*; - if (!fcd.is_main) continue; - if (fcd.is_foreign) continue; - if (fcd.runtime != .jni_class) continue; - if (seen.contains(fcd.foreign_path)) continue; - seen.put(fcd.foreign_path, {}) catch continue; - - for (fcd.members) |m| switch (m) { - .method => |md| { - if (md.body == null) continue; - if (md.is_static) continue; // future: emit static native ABI without `self` - self.synthesizeJniMainStub(fcd, md); - }, - else => {}, - }; - } - } - - fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { - const mangled = jni_descriptor.jniMangleNativeName(self.alloc, fcd.foreign_path, md.name) catch return; - const name_id = self.module.types.internString(mangled); - - 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("env"), - .ty = ptr_void, - }) catch return; - params.append(self.alloc, .{ - .name = self.module.types.internString("self"), - .ty = ptr_void, - }) catch return; - - // User's declared params (skip the implicit `*Self` at index 0 for - // instance methods — we synthesized `self` above as the jobject). - const param_start: usize = 1; - for (md.params[param_start..], 0..) |p_node, i| { - const pty = jniMapParamType(self, p_node); - params.append(self.alloc, .{ - .name = self.module.types.internString(md.param_names[param_start + i]), - .ty = pty, - }) catch return; - } - - const ret_ty = if (md.return_type) |rt| jniMapParamType(self, rt) else .void; - const params_slice = params.toOwnedSlice(self.alloc) catch return; - - _ = self.builder.beginFunction(name_id, params_slice, ret_ty); - self.builder.currentFunc().linkage = .external; - self.builder.currentFunc().call_conv = .c; - - const entry_name = self.module.types.internString("entry"); - const entry = self.builder.appendBlock(entry_name, &.{}); - self.builder.switchToBlock(entry); - - var scope = Scope.init(self.alloc, self.scope); - defer scope.deinit(); - const saved_scope = self.scope; - self.scope = &scope; - defer self.scope = saved_scope; - - for (params_slice, 0..) |p, i| { - const slot = self.builder.alloca(p.ty); - const param_ref = Ref.fromIndex(@intCast(i)); - self.builder.store(slot, param_ref); - scope.put(self.module.types.getString(p.name), .{ .ref = slot, .ty = p.ty, .is_alloca = true }); - } - - // Push the JNIEnv* arg onto the lexical `#jni_env` stack so the - // method body's `#jni_call(...)` / `super.method(...)` sites pick - // it up without an explicit `#jni_env(env) { ... }` wrapper. The - // JNI runtime guarantees the env passed to a native method is - // valid for the calling thread. - const env_slot = scope.lookup("env").?.ref; - const env_loaded = self.builder.load(env_slot, ptr_void); - const env_stack_base = self.jni_env_stack_base; - self.jni_env_stack_base = self.jni_env_stack.items.len; - self.jni_env_stack.append(self.alloc, env_loaded) catch {}; - defer { - _ = self.jni_env_stack.pop(); - self.jni_env_stack_base = env_stack_base; - } - - // Record method context so `super.method(args)` inside the body - // can find the parent class (via `#extends`) and the method's - // signature. - const saved_fcd = self.current_foreign_class; - const saved_method = self.current_foreign_method; - self.current_foreign_class = fcd; - self.current_foreign_method = md; - defer { - self.current_foreign_class = saved_fcd; - self.current_foreign_method = saved_method; - } - - // JNI native methods are C-callable entry points — install the - // static default Context so `context.X` reads in the method body - // resolve through `current_ctx_ref`. Mirror the same binding - // `lowerFunction` does for callconv(.c) / isExportedEntryName. - const saved_ctx_ref_jni = self.current_ctx_ref; - defer self.current_ctx_ref = saved_ctx_ref_jni; - if (self.implicit_ctx_enabled) { - if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { - self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); - } - } - - const saved_target = self.target_type; - self.target_type = if (ret_ty != .void) ret_ty else null; - if (ret_ty != .void) { - const body_val = self.lowerBlockValue(md.body.?); - if (!self.currentBlockHasTerminator()) { - if (body_val) |val| { - const val_ty = self.builder.getRefType(val); - if (val_ty == .void) { - self.ensureTerminator(ret_ty); - } else { - const coerced = self.coerceToType(val, val_ty, ret_ty); - self.builder.ret(coerced, ret_ty); - } - } else { - self.ensureTerminator(ret_ty); - } - } - } else { - self.lowerBlock(md.body.?); - self.ensureTerminator(ret_ty); - } - self.target_type = saved_target; - - self.builder.finalize(); - } - // --- moved to lower/error.zig (lower_error) --- pub const getTraceFids = lower_error.getTraceFids; pub const tracesEnabled = lower_error.tracesEnabled; @@ -11879,13 +10733,30 @@ pub const Lowering = struct { pub const diagNonIntegralNarrow = lower_coerce.diagNonIntegralNarrow; pub const promoteCVariadicArgs = lower_coerce.promoteCVariadicArgs; pub const coerceCallArgs = lower_coerce.coerceCallArgs; -}; -/// JNI param/return type resolution: user-declared types pass through -/// `resolveType` so the method body can dispatch on richer foreign-class -/// types (`holder.getSurface()` etc.). At LLVM level both `*SurfaceHolder` -/// and `*void` lower to the same `ptr`, so the C ABI shape Java sees is -/// unchanged — only sx-side method resolution benefits. -fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId { - return self.resolveType(type_node); -} + // --- moved to lower/ffi.zig (lower_ffi) --- + pub const internObjcSelector = lower_ffi.internObjcSelector; + pub const internObjcClassObject = lower_ffi.internObjcClassObject; + pub const getSelRegisterNameFid = lower_ffi.getSelRegisterNameFid; + pub const lowerFfiIntrinsicCall = lower_ffi.lowerFfiIntrinsicCall; + pub const lowerJniCall = lower_ffi.lowerJniCall; + pub const lowerForeignMethodCall = lower_ffi.lowerForeignMethodCall; + pub const resolveForeignClassMemberType = lower_ffi.resolveForeignClassMemberType; + pub const resolveForeignMethodReturnType = lower_ffi.resolveForeignMethodReturnType; + pub const foreignClassStructType = lower_ffi.foreignClassStructType; + pub const lowerObjcMethodCall = lower_ffi.lowerObjcMethodCall; + pub const lowerObjcStaticCall = lower_ffi.lowerObjcStaticCall; + pub const lowerForeignStaticCall = lower_ffi.lowerForeignStaticCall; + pub const lowerSuperCall = lower_ffi.lowerSuperCall; + pub const registerForeignClassDecl = lower_ffi.registerForeignClassDecl; + pub const resolveObjcParentName = lower_ffi.resolveObjcParentName; + pub const declareObjcDefinedStateIvarGlobal = lower_ffi.declareObjcDefinedStateIvarGlobal; + pub const declareObjcDefinedClassGlobal = lower_ffi.declareObjcDefinedClassGlobal; + pub const registerObjcDefinedClassMethods = lower_ffi.registerObjcDefinedClassMethods; + pub const synthesizeFnDeclFromObjcMethod = lower_ffi.synthesizeFnDeclFromObjcMethod; + pub const lookupObjcDefinedClassForMethod = lower_ffi.lookupObjcDefinedClassForMethod; + pub const getJniEnvTlFids = lower_ffi.getJniEnvTlFids; + pub const registerNamespacedForeignClasses = lower_ffi.registerNamespacedForeignClasses; + pub const synthesizeJniMainStubs = lower_ffi.synthesizeJniMainStubs; + pub const synthesizeJniMainStub = lower_ffi.synthesizeJniMainStub; +}; diff --git a/src/ir/lower/ffi.zig b/src/ir/lower/ffi.zig new file mode 100644 index 0000000..03a8e1d --- /dev/null +++ b/src/ir/lower/ffi.zig @@ -0,0 +1,1205 @@ +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; + +/// Intern an Obj-C selector string into a module-scoped `SEL*` slot. +/// First call creates the global; subsequent calls return the same +/// `GlobalId`. emit_llvm.zig walks `module.objc_selector_cache` and +/// synthesizes a constructor that populates each slot via +/// `sel_registerName` exactly once at module load. +/// +/// Slot name matches clang's convention: `OBJC_SELECTOR_REFERENCES_` +/// with `:` replaced by `_` to keep the symbol name valid. +pub fn internObjcSelector(self: *Lowering, sel_str: []const u8) inst_mod.GlobalId { + if (self.module.lookupObjcSelector(sel_str)) |gid| return gid; + + // Mangle selector: replace colons with underscores. Apple's + // toolchain does the same (foo:bar: → foo_bar_). + var mangled = std.ArrayList(u8).empty; + defer mangled.deinit(self.alloc); + mangled.appendSlice(self.alloc, "OBJC_SELECTOR_REFERENCES_") catch unreachable; + for (sel_str) |ch| { + mangled.append(self.alloc, if (ch == ':') '_' else ch) 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.appendObjcSelector(sel_str, gid); + 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_`. +pub 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. +pub 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; +} + +/// 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. +pub fn lowerFfiIntrinsicCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref { + if (fic.kind == .jni_call or fic.kind == .jni_static_call) { + return self.lowerJniCall(fic); + } + + 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); + + 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; + } + + // Receiver expression. + const recv = self.lowerExpr(fic.args[0]); + + // Selector. Literal selectors get interned into a module- + // scoped `SEL*` slot — emit_llvm.zig tags the slot into + // `__DATA,__objc_selrefs` so dyld populates it at load time + // (matches clang's `@selector(...)` lowering exactly). + // Non-literal selectors keep the per-call `sel_registerName` + // fallback. + const sel_arg_node = fic.args[1]; + const vptr_ty = self.module.types.ptrTo(.void); + const sel = blk: { + if (sel_arg_node.data == .string_literal) { + const raw = sel_arg_node.data.string_literal.raw; + const slot_gid = self.internObjcSelector(raw); + const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty)); + break :blk self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); + } + const sel_ref = self.lowerExpr(sel_arg_node); + 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; + break :blk self.builder.emit(.{ .call = .{ .callee = sel_fid, .args = sel_owned } }, vptr_ty); + }; + + // Additional args after recv + selector. + var extra = std.ArrayList(Ref).empty; + var ai: usize = 2; + while (ai < fic.args.len) : (ai += 1) { + extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable; + } + const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable; + + return self.builder.emit(.{ .objc_msg_send = .{ + .recv = recv, + .sel = sel, + .args = extra_owned, + } }, ret_ty); +} + +pub fn lowerJniCall(self: *Lowering, fic: *const ast.FfiIntrinsicCall) Ref { + // env is always implicit: lexical-direct from the enclosing `#jni_env(env)` + // block (2.16b, cheap), else the thread-local slot the block populated + // at runtime (2.16c, one TL load per call). Surface form is uniform: + // #jni_call(T)(target, "name", "sig", method-args...) (≥3 args) + if (fic.args.len < 3) { + if (self.diagnostics) |d| { + d.add(.err, "#jni_call requires target, method name, and signature", null); + } + return Ref.none; + } + + const ret_ty = self.resolveType(fic.return_type); + + const env_ref = if (self.jni_env_stack.items.len > self.jni_env_stack_base) + self.jni_env_stack.items[self.jni_env_stack.items.len - 1] + else blk: { + const fids = self.getJniEnvTlFids(); + const ptr_ty = self.module.types.ptrTo(.void); + break :blk self.builder.emit(.{ .call = .{ .callee = fids.get, .args = &.{} } }, ptr_ty); + }; + + const target_idx: usize = 0; + const name_idx: usize = 1; + const sig_idx: usize = 2; + const first_method_arg_idx: usize = 3; + + const target_ref = self.lowerExpr(fic.args[target_idx]); + const name_node = fic.args[name_idx]; + const sig_node = fic.args[sig_idx]; + const name_ref = self.lowerExpr(name_node); + const sig_ref = self.lowerExpr(sig_node); + + // Capture the (name, sig) literal content when both args are + // string literals — emit_llvm uses this as the intern key for + // the shared `jclass`/`jmethodID` slot pair (step 1.17). + const cache_key: ?inst_mod.CacheKey = if (name_node.data == .string_literal and sig_node.data == .string_literal) + inst_mod.CacheKey{ + .name_str = name_node.data.string_literal.raw, + .sig_str = sig_node.data.string_literal.raw, + } + else + null; + + var extra = std.ArrayList(Ref).empty; + var ai: usize = first_method_arg_idx; + while (ai < fic.args.len) : (ai += 1) { + extra.append(self.alloc, self.lowerExpr(fic.args[ai])) catch unreachable; + } + const extra_owned = extra.toOwnedSlice(self.alloc) catch unreachable; + + return self.builder.emit(.{ .jni_msg_send = .{ + .env = env_ref, + .target = target_ref, + .name = name_ref, + .sig = sig_ref, + .args = extra_owned, + .is_static = fic.kind == .jni_static_call, + .cache_key = cache_key, + } }, ret_ty); +} + +/// Lower an `inst.method(args)` call where `inst`'s type is a foreign-class +/// alias declared by `#jni_class("...") { ... }` (or its parallel forms). +/// JNI runtimes lower directly to `jni_msg_send` with a descriptor derived +/// from the method's sx signature; Obj-C / Swift runtimes are deferred to +/// Phase 3/4 and currently surface a clear diagnostic. +pub fn lowerForeignMethodCall( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + method_name: []const u8, + target: Ref, + method_args: []const Ref, + span: ast.Span, +) Ref { + // M2.3 — walk the `#extends` chain when the method isn't + // declared directly on this fcd. The dispatch target stays + // the original receiver — objc_msgSend's runtime walks the + // class hierarchy by isa, so we just need to find ANY + // ancestor that declared the method (for the selector + // mangling + signature info). The receiver-class fcd is + // still used for `*Self` substitution at the dispatch site + // — the inherited method's *Self should resolve to the + // child receiver, not the parent. + const found = self.findForeignMethodInChain(fcd, method_name) orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "no method '{s}' on foreign class '{s}' (or any `#extends` ancestor)", .{ method_name, fcd.name }); + } + return Ref.none; + }; + const method = found.method; + + // Obj-C instance dispatch (Phase 3 step 3.0 + M1.2 A.7). + // `inst.method(args)` on an `#objc_class` / `#objc_protocol` + // receiver derives a selector from the sx method name (default + // mangling: split on `_`, each piece becomes a keyword with a + // trailing `:`; niladic stays verbatim) and lowers to + // `objc_msg_send`. Both foreign and sx-defined classes flow + // through the same path — sx-defined classes have their IMPs + // registered at module-init (M1.2 A.4b.iii) so `objc_msgSend` + // finds them. The Swift runtimes still bail — Phase 4. + if (fcd.runtime == .objc_class or fcd.runtime == .objc_protocol) { + return self.lowerObjcMethodCall(fcd, method, target, method_args, span); + } + if (!fcd.is_foreign) { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "sx-defined classes on non-Obj-C runtimes can't yet be dispatched into (class '{s}', runtime '{s}')", .{ fcd.name, @tagName(fcd.runtime) }); + } + return Ref.none; + } + if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "method calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)}); + } + return Ref.none; + } + + if (self.jni_env_stack.items.len == 0) { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "method call on '{s}' requires an enclosing '#jni_env' scope", .{fcd.name}); + } + return Ref.none; + } + const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; + + // Build a ClassRegistry snapshot so descriptor derivation can + // resolve `*Foo` cross-class refs to their foreign paths. + var registry = jni_descriptor.ClassRegistry.init(self.alloc); + defer registry.deinit(); + var it = self.program_index.foreign_class_map.iterator(); + while (it.next()) |entry| { + registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; + } + + const desc_str = jni_descriptor.deriveMethod(self.alloc, .{ + .enclosing_path = fcd.foreign_path, + .classes = ®istry, + }, method) catch |err| { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.{s}': {s}", .{ fcd.name, method.name, @errorName(err) }); + } + return Ref.none; + }; + + const name_sid = self.module.types.internString(method_name); + const name_ref = self.builder.constString(name_sid); + const sig_sid = self.module.types.internString(desc_str); + const sig_ref = self.builder.constString(sig_sid); + + const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; + + // Reject return types the JNI emit path can't dispatch — emit_llvm's + // CallMethod switch only covers void / bool / s32 / s64 / f32 / f64 + // / pointer-returning. Anything else (s8 / s16 / u8 / u16 / aggregates) + // would silently lower to LLVMGetUndef and produce wrong arguments at + // the call site (chess Android touch shipped broken because s32→s32+ + // f32 returns hit the undef path before .f32 was wired up). + if (!jni_descriptor.isJniReturnTypeSupported(&self.module.types, ret_ty)) { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "JNI method '{s}.{s}' returns '{s}', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up", .{ fcd.name, method.name, self.module.types.typeName(ret_ty) }); + } + return Ref.none; + } + + const cache_key: inst_mod.CacheKey = .{ + .name_str = method_name, + .sig_str = desc_str, + }; + + const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; + return self.builder.emit(.{ .jni_msg_send = .{ + .env = env_ref, + .target = target, + .name = name_ref, + .sig = sig_ref, + .args = args_owned, + .is_static = method.is_static, + .cache_key = cache_key, + } }, ret_ty); +} + +// Pure Obj-C decision helpers (selector derivation, type-encoding, ARC +// property-kind, class-pointer recognition, state-struct planning) live in +// `ffi_objc.zig` (`ObjcLowering`, a `*Lowering` facade). Reached via +// `self.objc()`. Emission-heavy IMP builders + `lowerObjc*Call` stay here. + +/// Resolve a foreign-class member type, substituting `Self` (and `*Self`) +/// with the foreign class's own struct type. Without this substitution +/// chained calls like `Cls.alloc().init()` see the inner result as a +/// fictitious `Self` struct and the next dispatch lookup fails. +pub fn resolveForeignClassMemberType( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + type_node: *const ast.Node, +) TypeId { + if (type_node.data == .type_expr and std.mem.eql(u8, type_node.data.type_expr.name, "Self")) { + return self.foreignClassStructType(fcd); + } + if (type_node.data == .pointer_type_expr) { + const pt = type_node.data.pointer_type_expr; + if (pt.pointee_type.data == .type_expr and std.mem.eql(u8, pt.pointee_type.data.type_expr.name, "Self")) { + return self.module.types.ptrTo(self.foreignClassStructType(fcd)); + } + } + return self.resolveType(type_node); +} + +pub fn resolveForeignMethodReturnType( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + method: ast.ForeignMethodDecl, +) TypeId { + const rt = method.return_type orelse return .void; + return self.resolveForeignClassMemberType(fcd, rt); +} + +pub fn foreignClassStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId { + const name_id = self.module.types.internString(fcd.name); + if (self.module.types.findByName(name_id)) |existing| return existing; + return self.module.types.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } }); +} + +/// Lower `inst.method(args)` on an `#objc_class` / `#objc_protocol` +/// receiver. The selector is derived by `deriveObjcSelector`; arity +/// is validated against the keyword count produced by the mangling +/// (excluding self). Dispatch then runs through `objc_msg_send`, +/// sharing the cached-SEL slot path with explicit `#objc_call`. +pub fn lowerObjcMethodCall( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + method: ast.ForeignMethodDecl, + target: Ref, + method_args: []const Ref, + span: ast.Span, +) Ref { + const arity = method_args.len; + const derived = self.objc().deriveObjcSelector(method, arity); + + // Arity validation: the keyword count (number of `:` in the + // selector) must equal the number of args passed at the call + // site. For methods using the default mangling rule, a mismatch + // is an error because the user can fix the sx-side name. For + // `#selector("...")` overrides, the user has deliberately + // chosen the selector — downgrade to a warning so the build + // proceeds, but still surface the typo case (Obj-C's runtime + // doesn't validate colon-vs-arg, so this is the last defense). + if (arity > 0 and derived.keyword_count != arity) { + if (self.diagnostics) |d| { + if (derived.is_override) { + d.addFmt( + .warn, + span, + "Obj-C selector \"{s}\" (override for '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string", + .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity }, + ); + } else { + d.addFmt( + .err, + span, + "Obj-C selector for '{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(\"...\")`", + .{ fcd.name, method.name, derived.keyword_count, arity, arity }, + ); + return Ref.none; + } + } + } + + const ret_ty = self.resolveForeignMethodReturnType(fcd, method); + + // Cache the SEL slot per (selector-string, module) like + // `#objc_call` does. The mangling produces the literal selector + // string; we don't need a runtime sel_registerName call at the + // dispatch site because the global initializer already does it. + const vptr_ty = self.module.types.ptrTo(.void); + const slot_gid = self.internObjcSelector(derived.sel); + const slot_ptr = self.builder.emit(.{ .global_addr = slot_gid }, self.module.types.ptrTo(vptr_ty)); + const sel = self.builder.emit(.{ .load = .{ .operand = slot_ptr } }, vptr_ty); + + const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; + return self.builder.emit(.{ .objc_msg_send = .{ + .recv = target, + .sel = sel, + .args = args_owned, + } }, 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). +pub 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.objc().deriveObjcSelector(method, arity); + + if (arity > 0 and derived.keyword_count != arity) { + if (self.diagnostics) |d| { + if (derived.is_override) { + d.addFmt( + .warn, + span, + "Obj-C selector \"{s}\" (override for static call '{s}.{s}') has {} keyword(s) but the call passes {} argument(s); the runtime will dispatch but the colon count is inconsistent with the arity — double-check the selector string", + .{ derived.sel, fcd.name, method.name, derived.keyword_count, arity }, + ); + } else { + 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(\"...\")`", + .{ fcd.name, method.name, derived.keyword_count, arity, arity }, + ); + return Ref.none; + } + } + } + + const ret_ty = self.resolveForeignMethodReturnType(fcd, method); + + 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); + + // M4.0b: intercept `Cls.alloc()` for sx-defined classes — emit the + // inline alloc-and-init sequence using the caller's `context.allocator` + // instead of going through `objc_msgSend` (which would land in the + // +alloc IMP and use `__sx_default_context.allocator`). This honors + // a surrounding `push Context.{ allocator = ... }`. + if (!fcd.is_foreign and + fcd.runtime == .objc_class and + method_args.len == 0 and + std.mem.eql(u8, method.name, "alloc")) + { + const ctx_addr = if (self.current_ctx_ref != Ref.none) + self.current_ctx_ref + else blk: { + // Fallback: no current ctx (e.g. compiler-internal callers). + // Use the default context — same as the IMP would. + const default_ctx_gi = self.program_index.global_names.get("__sx_default_context") orelse { + if (self.diagnostics) |d| { + d.addFmt(.err, span, "Cls.alloc() on sx-defined class '{s}': no current context and __sx_default_context missing", .{fcd.name}); + } + return Ref.none; + }; + break :blk self.builder.emit(.{ .global_addr = default_ctx_gi.id }, vptr_ty); + }; + const instance = self.emitObjcDefinedAllocAndInit(fcd, class_obj, ctx_addr) orelse return Ref.none; + // class_createInstance returns *void; bitcast to the method's + // declared return type (typically `*` or `?*`) so + // downstream `let f := Cls.alloc();` binds f at the right type + // (lowerVarDecl reads the Ref's IR type when no annotation is + // present). coerceToType is a no-op for ptr→ptr; we need an + // explicit bitcast IR op to retype the Ref. + if (ret_ty == vptr_ty) return instance; + // Optional-wrapped returns (e.g. `-> ?*Cls`): emit optional_wrap. + if (!ret_ty.isBuiltin()) { + const ret_info = self.module.types.get(ret_ty); + if (ret_info == .optional) { + const inner = ret_info.optional.child; + const cast = if (inner == vptr_ty) + instance + else + self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = inner } }, inner); + return self.builder.optionalWrap(cast, ret_ty); + } + } + return self.builder.emit(.{ .bitcast = .{ .operand = instance, .from = vptr_ty, .to = ret_ty } }, ret_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, +/// clazz, mid, args...)`. Returns the new jobject. +/// +/// Non-`new` static methods aren't supported via this path yet — the +/// user can use `#jni_static_call(T)(class, "name", sig, args...)` +/// for those. Constructor is the common case for #jni_main bodies +/// that need to instantiate Android classes (SurfaceView, etc.). +pub fn lowerForeignStaticCall( + self: *Lowering, + fcd: *const ast.ForeignClassDecl, + method: ast.ForeignMethodDecl, + 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; + } + if (!std.mem.eql(u8, method.name, "new")) { + if (self.diagnostics) |d| d.addFmt(.err, span, "static foreign-class call '{s}.{s}' not yet supported via `Alias.method()` syntax \u{2014} only `new` is wired today; use `#jni_static_call` directly for other static methods", .{ fcd.name, method.name }); + return Ref.none; + } + + if (self.jni_env_stack.items.len <= self.jni_env_stack_base) { + if (self.diagnostics) |d| d.addFmt(.err, span, "constructor `{s}.new(...)` requires an enclosing `#jni_env` scope (or `#jni_main` body)", .{fcd.name}); + return Ref.none; + } + const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; + + // Build class registry snapshot for `*Foo` cross-class refs. + var registry = jni_descriptor.ClassRegistry.init(self.alloc); + defer registry.deinit(); + var it = self.program_index.foreign_class_map.iterator(); + while (it.next()) |entry| { + registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; + } + + // For `new`, the JNI descriptor's return position is `V` (the + // constructor returns void; the new jobject comes back from + // `NewObject` itself). Patch the AST by overriding return_type + // to null during derivation. + const m_for_desc: ast.ForeignMethodDecl = .{ + .name = method.name, + .params = method.params, + .param_names = method.param_names, + .return_type = null, + .is_static = method.is_static, + .jni_descriptor_override = method.jni_descriptor_override, + .body = method.body, + }; + + const descriptor = jni_descriptor.deriveMethod(self.alloc, .{ + .enclosing_path = fcd.foreign_path, + .classes = ®istry, + }, m_for_desc) catch |err| { + if (self.diagnostics) |d| d.addFmt(.err, span, "JNI descriptor derivation failed for '{s}.new': {s}", .{ fcd.name, @errorName(err) }); + return Ref.none; + }; + + // sx-side return type is `*Self` — resolve to a pointer to the + // foreign-class struct type so method dispatch on the new + // jobject works (`view := SurfaceView.new(ctx); view.getHolder()`). + // At LLVM level still ptr; the sx type table is what method + // resolution consults. + const self_struct_name = self.module.types.internString(fcd.name); + const self_struct_id = if (self.module.types.findByName(self_struct_name)) |existing| + existing + else blk: { + const info: types.TypeInfo = .{ .@"struct" = .{ .name = self_struct_name, .fields = &.{} } }; + break :blk self.module.types.intern(info); + }; + const ret_ty = self.module.types.ptrTo(self_struct_id); + + const name_sid = self.module.types.internString(""); + const name_ref = self.builder.constString(name_sid); + const sig_sid = self.module.types.internString(descriptor); + const sig_ref = self.builder.constString(sig_sid); + + const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; + return self.builder.emit(.{ .jni_msg_send = .{ + .env = env_ref, + .target = Ref.none, // unused for ctor — class is resolved via parent_class_path + .name = name_ref, + .sig = sig_ref, + .args = args_owned, + .is_static = false, + .is_constructor = true, + .parent_class_path = self.alloc.dupe(u8, fcd.foreign_path) catch fcd.foreign_path, + .cache_key = null, + } }, ret_ty); +} + +/// Lower `super.method(args)` inside a `#jni_main` / sx-defined +/// `#jni_class` bodied method. Resolves the parent class from the +/// enclosing fcd's `#extends` clause (default `android.app.Activity`) +/// and emits a `JniMsgSend` with `is_nonvirtual=true`, which +/// emit_llvm expands into a `FindClass(parent) + GetMethodID + +/// CallNonvirtualMethod` chain. +/// +/// Signature derivation: when `method_name` matches the enclosing +/// method's name (the common case — `super.onCreate(b)` from inside +/// `onCreate :: (self, b)` override), the enclosing method's +/// signature is reused. Other method names require the parent class +/// to be declared via `#foreign #jni_class` so the signature can be +/// looked up. +pub fn lowerSuperCall( + self: *Lowering, + method_name: []const u8, + method_args: []const Ref, + span: ast.Span, +) Ref { + const fcd = self.current_foreign_class orelse { + if (self.diagnostics) |d| d.addFmt(.err, span, "'super' is only valid inside a `#jni_class` method body", .{}); + return Ref.none; + }; + + // Resolve parent foreign_path from the fcd's `#extends`. Default to + // android.app.Activity to match the jni_java_emit default. + var parent_path: []const u8 = "android/app/Activity"; + for (fcd.members) |m| switch (m) { + .extends => |alias| { + if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| { + parent_path = parent_fcd.foreign_path; + } else { + parent_path = alias; + } + break; + }, + else => {}, + }; + + // Resolve method signature. Same-name fast path reuses the + // enclosing method's descriptor; cross-method super calls require + // the parent class to be declared via `#foreign #jni_class`. + var descriptor: []const u8 = ""; + var resolved_method: ?ast.ForeignMethodDecl = null; + if (self.current_foreign_method) |em| { + if (std.mem.eql(u8, em.name, method_name)) { + resolved_method = em; + } + } + if (resolved_method == null) { + const parent_fcd = blk: for (fcd.members) |m| switch (m) { + .extends => |alias| if (self.program_index.foreign_class_map.get(alias)) |pf| break :blk pf else continue, + else => {}, + } else null; + if (parent_fcd) |pf| { + for (pf.members) |pm| switch (pm) { + .method => |pmd| if (std.mem.eql(u8, pmd.name, method_name)) { + resolved_method = pmd; + break; + }, + else => {}, + }; + } + } + const method = resolved_method orelse { + if (self.diagnostics) |d| d.addFmt(.err, span, "no method '{s}' found for `super.{s}(...)` — declare the parent class via `#foreign #jni_class` to make cross-method super calls available", .{ method_name, method_name }); + return Ref.none; + }; + + // Derive descriptor against the parent path (used as enclosing_path + // for `*Self` resolution). + var registry = jni_descriptor.ClassRegistry.init(self.alloc); + defer registry.deinit(); + var it = self.program_index.foreign_class_map.iterator(); + while (it.next()) |entry| { + registry.put(entry.key_ptr.*, entry.value_ptr.*.foreign_path) catch {}; + } + descriptor = jni_descriptor.deriveMethod(self.alloc, .{ + .enclosing_path = parent_path, + .classes = ®istry, + }, method) catch |err| { + if (self.diagnostics) |d| d.addFmt(.err, span, "super-call descriptor derivation failed for '{s}.{s}': {s}", .{ parent_path, method_name, @errorName(err) }); + return Ref.none; + }; + + // env from the lexical stack (pushed by synthesizeJniMainStub). + if (self.jni_env_stack.items.len <= self.jni_env_stack_base) { + if (self.diagnostics) |d| d.addFmt(.err, span, "`super.{s}(...)` requires an enclosing `#jni_main` method scope (env is unavailable)", .{method_name}); + return Ref.none; + } + const env_ref = self.jni_env_stack.items[self.jni_env_stack.items.len - 1]; + + // `self` is the first param of the synthesized `Java_*` fn. Bound + // in scope as `self` by synthesizeJniMainStub. + const self_binding = if (self.scope) |s| s.lookup("self") else null; + const self_ref = if (self_binding) |b| (if (b.is_alloca) self.builder.load(b.ref, b.ty) else b.ref) else Ref.none; + + const name_sid = self.module.types.internString(method_name); + const name_ref = self.builder.constString(name_sid); + const sig_sid = self.module.types.internString(descriptor); + const sig_ref = self.builder.constString(sig_sid); + + const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void; + + const args_owned = self.alloc.dupe(Ref, method_args) catch unreachable; + return self.builder.emit(.{ .jni_msg_send = .{ + .env = env_ref, + .target = self_ref, + .name = name_ref, + .sig = sig_ref, + .args = args_owned, + .is_static = false, + .is_nonvirtual = true, + .parent_class_path = self.alloc.dupe(u8, parent_path) catch parent_path, + .cache_key = null, // per-call FindClass + GetMethodID; caching is a follow-up + } }, ret_ty); +} + +// ── Calls ─────────────────────────────────────────────────────── + +/// Register a foreign-class declaration. The alias goes into +/// `foreign_class_map` for method-dispatch lookup. The underlying +/// type (e.g. `*Activity`) is resolved via the existing struct +/// fallback in `type_bridge.resolveTypeName` (which interns unknown +/// named types as 0-field structs). +/// +/// sx-defined Obj-C classes (no `#foreign`, runtime == .objc_class) +/// also land in `module.objc_defined_class_cache` in declaration +/// order AND have their bodied methods registered into `fn_ast_map` +/// under qualified names `.`. Lazy lowering +/// then handles the body via the standard path; `*Self` is +/// substituted to `*State` during body lowering (M1.2 A.2b). +pub fn registerForeignClassDecl(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { + self.program_index.foreign_class_map.put(fcd.name, fcd) catch {}; + if (!fcd.is_foreign and fcd.runtime == .objc_class) { + if (self.module.lookupObjcDefinedClass(fcd.name) == null) { + self.module.appendObjcDefinedClass(fcd.name, fcd); + // M2.3 — resolve the `#extends` alias to the actual + // Obj-C runtime class name. `#extends NSObjectBase` + // where NSObjectBase is aliased to "NSObject" must + // pass "NSObject" to objc_allocateClassPair, otherwise + // the runtime's class-hierarchy link is broken and + // inherited-method dispatch fails. + self.module.setObjcDefinedClassParent(fcd.name, self.resolveObjcParentName(fcd)); + // M1.2 A.4b.i: per-class ivar handle global. The class-pair + // init constructor (emit_llvm) populates it via + // class_getInstanceVariable after the class is registered; + // IMP trampolines read it to find the __sx_state ivar. + self.declareObjcDefinedStateIvarGlobal(fcd.name); + // M1.2 A.6: per-class class-object global. -dealloc reads + // it to build an `objc_super` struct for `[super dealloc]` + // dispatch via `objc_msgSendSuper2`. + self.declareObjcDefinedClassGlobal(fcd.name); + } + self.registerObjcDefinedClassMethods(fcd); + } +} + +/// Resolve the `#extends ParentAlias` declaration on a sx-defined +/// `#objc_class` to the actual Obj-C runtime class name. Falls +/// back to "NSObject" when no `#extends` is declared. +/// Aliases that resolve to foreign Obj-C classes use the +/// foreign_path; aliases for OTHER sx-defined classes use the +/// alias name directly (which equals the Obj-C class name for +/// sx-defined classes). +pub fn resolveObjcParentName(self: *Lowering, fcd: *const ast.ForeignClassDecl) []const u8 { + for (fcd.members) |m| switch (m) { + .extends => |alias| { + if (self.program_index.foreign_class_map.get(alias)) |parent_fcd| { + if (parent_fcd.is_foreign) return parent_fcd.foreign_path; + // Sx-defined parent — its alias IS its Obj-C name. + return parent_fcd.name; + } + // Unknown alias — pass through as-is and let the + // runtime diagnose if it's genuinely wrong. + return alias; + }, + else => {}, + }; + return "NSObject"; +} + +/// Declare a per-class global `___state_ivar : *void = null`. +/// emit_llvm's `emitObjcDefinedClassInit` constructor fills it in via +/// `class_getInstanceVariable(cls, "__sx_state")` once per module load. +pub fn declareObjcDefinedStateIvarGlobal(self: *Lowering, class_name: []const u8) void { + const gname = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{class_name}) catch return; + const name_id = self.module.types.internString(gname); + _ = self.module.addGlobal(.{ + .name = name_id, + .ty = self.module.types.ptrTo(.void), + .init_val = .null_val, + .is_extern = false, + .is_const = false, + }); +} + +/// Declare a per-class global `___class : *void = null`. +/// emit_llvm's `emitObjcDefinedClassInit` constructor stores the +/// freshly-allocated Class pointer into it after objc_registerClassPair. +/// The synthesized `-dealloc` IMP reads it to construct an `objc_super` +/// for `[super dealloc]` dispatch. +pub fn declareObjcDefinedClassGlobal(self: *Lowering, class_name: []const u8) void { + const gname = std.fmt.allocPrint(self.alloc, "__{s}_class", .{class_name}) catch return; + const name_id = self.module.types.internString(gname); + _ = self.module.addGlobal(.{ + .name = name_id, + .ty = self.module.types.ptrTo(.void), + .init_val = .null_val, + .is_extern = false, + .is_const = false, + }); +} + +/// For each bodied instance method on an sx-defined `#objc_class`, +/// synthesize an `FnDecl` from the `ForeignMethodDecl`, register it +/// in `fn_ast_map` under `.`, declare the IR +/// function, AND collect per-method registration data (selector +/// mangling + type encoding + IMP symbol name) into the class's +/// cache entry so emit_llvm can wire up `class_addMethod` calls +/// (M1.2 A.4b.iii). Bodyless declarations are skipped — they +/// reference inherited / external methods, not sx-side bodies. +pub fn registerObjcDefinedClassMethods(self: *Lowering, fcd: *const ast.ForeignClassDecl) void { + // Set current_foreign_class so `*Self` substitutions in + // declareFunction's type resolution find the state struct. + const saved = self.current_foreign_class; + self.current_foreign_class = fcd; + defer self.current_foreign_class = saved; + + var method_infos = std.ArrayList(Module.ObjcDefinedMethodEntry).empty; + + for (fcd.members) |m| { + const method = switch (m) { + .method => |md| md, + else => continue, + }; + const body = method.body orelse continue; + const fd = self.synthesizeFnDeclFromObjcMethod(method, body) orelse continue; + const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ fcd.name, method.name }) catch continue; + self.program_index.fn_ast_map.put(qualified, fd) catch {}; + self.declareFunction(fd, qualified); + + // Selector mangling — A.1's deriveObjcSelector handles + // `#selector("...")` override + the default rule. Static + // methods use the same mangling rule (their first param + // ISN'T *Self, so no offset). + // + // ABI for the IMP signature (both instance + class methods): + // `(recv: id|Class, _cmd: SEL, ...user_args) -> ret` + // For instance methods the user-declared self is at param[0] + // (skipped); class methods have no self in the AST. + const user_param_start: usize = if (method.is_static) 0 else 1; + const user_arg_count = if (method.params.len > user_param_start) method.params.len - user_param_start else 0; + const sel_info = self.objc().deriveObjcSelector(method, user_arg_count); + + const ret_ty: TypeId = if (method.return_type) |rt| self.resolveType(rt) else .void; + var arg_tys = std.ArrayList(TypeId).empty; + defer arg_tys.deinit(self.alloc); + if (method.params.len > user_param_start) { + for (method.params[user_param_start..]) |p_node| { + arg_tys.append(self.alloc, self.resolveType(p_node)) catch unreachable; + } + } + const encoding = self.objc().objcTypeEncodingFromSignature(ret_ty, arg_tys.items, null) catch continue; + + const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, method.name }) catch continue; + + method_infos.append(self.alloc, .{ + .sel = sel_info.sel, + .encoding = encoding, + .imp_name = imp_name, + .is_class = method.is_static, + }) catch unreachable; + } + + if (method_infos.items.len > 0) { + const methods_slice = method_infos.toOwnedSlice(self.alloc) catch return; + self.module.setObjcDefinedClassMethods(fcd.name, methods_slice); + } +} + +/// Build an `FnDecl` whose params are zipped from the +/// `ForeignMethodDecl.params` (type nodes) and `param_names`. Used +/// to feed sx-defined class methods through the standard +/// fn-lowering pipeline. Allocator-owned; lives for the duration +/// of the Lowering pass. +pub fn synthesizeFnDeclFromObjcMethod(self: *Lowering, method: ast.ForeignMethodDecl, body: *ast.Node) ?*ast.FnDecl { + if (method.params.len != method.param_names.len) return null; + var params = std.ArrayList(ast.Param).empty; + for (method.params, method.param_names) |type_node, p_name| { + params.append(self.alloc, .{ + .name = p_name, + .name_span = .{ .start = 0, .end = 0 }, + .type_expr = type_node, + }) catch unreachable; + } + const fd = self.alloc.create(ast.FnDecl) catch return null; + fd.* = .{ + .name = method.name, + .params = params.toOwnedSlice(self.alloc) catch unreachable, + .return_type = method.return_type, + .body = body, + }; + return fd; +} + +/// If `name` matches an sx-defined `#objc_class`'s qualified-method +/// pattern (`.`), return the class's +/// ForeignClassDecl. Used by `lowerFunction` to set +/// `current_foreign_class` so `*Self` resolves to the state struct +/// during body lowering. +pub fn lookupObjcDefinedClassForMethod(self: *Lowering, name: []const u8) ?*const ast.ForeignClassDecl { + const dot = std.mem.indexOf(u8, name, ".") orelse return null; + return self.module.lookupObjcDefinedClass(name[0..dot]); +} + +/// Lazily declare the `sx_jni_env_tl_get` / `sx_jni_env_tl_set` +/// runtime externs (step 2.16c). The storage lives in +/// `library/vendors/sx_jni_runtime/sx_jni_env_tl.c` as a +/// `_Thread_local` slot — keeping it OUT of the user's IR module +/// is what lets the LLVM ORC JIT load the module cleanly without +/// orc_rt platform support. AOT targets get the same .c file +/// linked in via `needs_jni_env_tl_runtime`, which Compilation +/// reads to append a synthetic c_import alongside the user's. +pub fn getJniEnvTlFids(self: *Lowering) struct { get: FuncId, set: FuncId } { + self.needs_jni_env_tl_runtime = true; + const ptr_ty = self.module.types.ptrTo(.void); + if (self.jni_env_tl_get_fid == null) { + const name = self.module.types.internString("sx_jni_env_tl_get"); + const fid = self.builder.declareExtern(name, &.{}, ptr_ty); + const func = self.module.getFunctionMut(fid); + func.call_conv = .c; + self.jni_env_tl_get_fid = fid; + } + if (self.jni_env_tl_set_fid == null) { + const name = self.module.types.internString("sx_jni_env_tl_set"); + const env_param = self.module.types.internString("env"); + var params = std.ArrayList(inst_mod.Function.Param).empty; + params.append(self.alloc, .{ .name = env_param, .ty = ptr_ty }) catch unreachable; + const fid = self.builder.declareExtern(name, params.toOwnedSlice(self.alloc) catch unreachable, .void); + const func = self.module.getFunctionMut(fid); + func.call_conv = .c; + self.jni_env_tl_set_fid = fid; + } + return .{ .get = self.jni_env_tl_get_fid.?, .set = self.jni_env_tl_set_fid.? }; +} + +/// When a namespaced import (`Ns :: #import "..."`) contains foreign-class +/// declarations, ALSO register them under their qualified name `Ns.Class` +/// so receiver types like `*Ns.Class` can find the fcd. The recursive +/// scan/lower already handles bare-name registration; this only adds the +/// qualified-name entry, so cross-class refs in method signatures +/// (`*View` → bare lookup) still work. +pub fn registerNamespacedForeignClasses(self: *Lowering, ns: ast.NamespaceDecl) void { + for (ns.decls) |inner| { + if (inner.data == .foreign_class_decl) { + const fcd = &inner.data.foreign_class_decl; + const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ ns.name, fcd.name }) catch fcd.name; + self.program_index.foreign_class_map.put(qualified, fcd) catch {}; + } else if (inner.data == .namespace_decl) { + // Nested namespaces — qualify with both prefixes. + self.registerNamespacedForeignClasses(inner.data.namespace_decl); + } + } +} + + +// ── Protocol dispatch ────────────────────────────────────────── + +pub fn synthesizeJniMainStubs(self: *Lowering) void { + var seen = std.StringHashMap(void).init(self.alloc); + defer seen.deinit(); + + var it = self.program_index.foreign_class_map.iterator(); + while (it.next()) |entry| { + const fcd = entry.value_ptr.*; + if (!fcd.is_main) continue; + if (fcd.is_foreign) continue; + if (fcd.runtime != .jni_class) continue; + if (seen.contains(fcd.foreign_path)) continue; + seen.put(fcd.foreign_path, {}) catch continue; + + for (fcd.members) |m| switch (m) { + .method => |md| { + if (md.body == null) continue; + if (md.is_static) continue; // future: emit static native ABI without `self` + self.synthesizeJniMainStub(fcd, md); + }, + else => {}, + }; + } +} + +pub fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { + const mangled = jni_descriptor.jniMangleNativeName(self.alloc, fcd.foreign_path, md.name) catch return; + const name_id = self.module.types.internString(mangled); + + 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("env"), + .ty = ptr_void, + }) catch return; + params.append(self.alloc, .{ + .name = self.module.types.internString("self"), + .ty = ptr_void, + }) catch return; + + // User's declared params (skip the implicit `*Self` at index 0 for + // instance methods — we synthesized `self` above as the jobject). + const param_start: usize = 1; + for (md.params[param_start..], 0..) |p_node, i| { + const pty = jniMapParamType(self, p_node); + params.append(self.alloc, .{ + .name = self.module.types.internString(md.param_names[param_start + i]), + .ty = pty, + }) catch return; + } + + const ret_ty = if (md.return_type) |rt| jniMapParamType(self, rt) else .void; + const params_slice = params.toOwnedSlice(self.alloc) catch return; + + _ = self.builder.beginFunction(name_id, params_slice, ret_ty); + self.builder.currentFunc().linkage = .external; + self.builder.currentFunc().call_conv = .c; + + const entry_name = self.module.types.internString("entry"); + const entry = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry); + + var scope = Scope.init(self.alloc, self.scope); + defer scope.deinit(); + const saved_scope = self.scope; + self.scope = &scope; + defer self.scope = saved_scope; + + for (params_slice, 0..) |p, i| { + const slot = self.builder.alloca(p.ty); + const param_ref = Ref.fromIndex(@intCast(i)); + self.builder.store(slot, param_ref); + scope.put(self.module.types.getString(p.name), .{ .ref = slot, .ty = p.ty, .is_alloca = true }); + } + + // Push the JNIEnv* arg onto the lexical `#jni_env` stack so the + // method body's `#jni_call(...)` / `super.method(...)` sites pick + // it up without an explicit `#jni_env(env) { ... }` wrapper. The + // JNI runtime guarantees the env passed to a native method is + // valid for the calling thread. + const env_slot = scope.lookup("env").?.ref; + const env_loaded = self.builder.load(env_slot, ptr_void); + const env_stack_base = self.jni_env_stack_base; + self.jni_env_stack_base = self.jni_env_stack.items.len; + self.jni_env_stack.append(self.alloc, env_loaded) catch {}; + defer { + _ = self.jni_env_stack.pop(); + self.jni_env_stack_base = env_stack_base; + } + + // Record method context so `super.method(args)` inside the body + // can find the parent class (via `#extends`) and the method's + // signature. + const saved_fcd = self.current_foreign_class; + const saved_method = self.current_foreign_method; + self.current_foreign_class = fcd; + self.current_foreign_method = md; + defer { + self.current_foreign_class = saved_fcd; + self.current_foreign_method = saved_method; + } + + // JNI native methods are C-callable entry points — install the + // static default Context so `context.X` reads in the method body + // resolve through `current_ctx_ref`. Mirror the same binding + // `lowerFunction` does for callconv(.c) / isExportedEntryName. + const saved_ctx_ref_jni = self.current_ctx_ref; + defer self.current_ctx_ref = saved_ctx_ref_jni; + if (self.implicit_ctx_enabled) { + if (self.program_index.global_names.get("__sx_default_context")) |dctx_gi| { + self.current_ctx_ref = self.builder.emit(.{ .global_addr = dctx_gi.id }, ptr_void); + } + } + + const saved_target = self.target_type; + self.target_type = if (ret_ty != .void) ret_ty else null; + if (ret_ty != .void) { + const body_val = self.lowerBlockValue(md.body.?); + if (!self.currentBlockHasTerminator()) { + if (body_val) |val| { + const val_ty = self.builder.getRefType(val); + if (val_ty == .void) { + self.ensureTerminator(ret_ty); + } else { + const coerced = self.coerceToType(val, val_ty, ret_ty); + self.builder.ret(coerced, ret_ty); + } + } else { + self.ensureTerminator(ret_ty); + } + } + } else { + self.lowerBlock(md.body.?); + self.ensureTerminator(ret_ty); + } + self.target_type = saved_target; + + self.builder.finalize(); +} + +/// JNI param/return type resolution: user-declared types pass through +/// `resolveType` so the method body can dispatch on richer foreign-class +/// types (`holder.getSurface()` etc.). At LLVM level both `*SurfaceHolder` +/// and `*void` lower to the same `ptr`, so the C ABI shape Java sees is +/// unchanged — only sx-side method resolution benefits. +fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId { + return self.resolveType(type_node); +}