From 00122287960cebcba8804c67ffe16898d808caad Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 07:15:56 +0300 Subject: [PATCH 1/5] test(ir): lock pure Obj-C decision helpers before A6.1 extraction (A6.1 scaffolding step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-first scaffolding for the Obj-C FFI domain (Phase A6.1) before the pure helpers move into src/ir/ffi_objc.zig. Visibility-only change to the targets — no behavior change. - 3 new lower.test.zig tests for the pure helpers the ARCH-SAFETY A6.1 row names that lacked direct unit coverage: - deriveObjcSelector: niladic (bare name) / single-keyword (name:) / multi-keyword (_ -> : + trailing) / #selector(...) override (verbatim, keyword_count = #colons). - objcPropertyKind: assign default (primitive), strong default (object ptr), explicit weak/copy/assign win over the default. - isObjcClassPointer: pointer-to-foreign-Obj-C-class true; plain-struct ptr / *void / builtin false. - objcTypeEncodingFromSignature (x6) + objcDefinedStateStructType (x3) already covered — no new tests. - Widened deriveObjcSelector + objcPropertyKind to pub (they become facade methods in step 2; the ObjcPropertyKind enum stays private — tests compare via enum-literal == .strong). No logic touched. - Recorded the A6.1 coverage inventory + residual gaps (resolveObjcParentName, class-method metadata, property/state lookups — example-guarded) in ARCH-SAFETY.md. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (no .ir churn; Obj-C snapshots 1309/1329/1332/1347 green). --- src/ir/lower.test.zig | 115 ++++++++++++++++++++++++++++++++++++++++++ src/ir/lower.zig | 4 +- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 5cba51e..6afaa59 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -616,6 +616,121 @@ test "lower: objcTypeEncodingFromSignature emits nested structs (CGRect)" { try std.testing.expectEqualStrings("v@:{CGRect={CGPoint=dd}{CGSize=dd}}", e2); } +// ── A6.1 scaffolding: pure Obj-C decision helpers ─────────────────── +// Lock selector derivation, property-kind classification, and Obj-C +// class-pointer recognition before they move to `ffi_objc.zig`. + +fn objcMethod(name: []const u8) ast.ForeignMethodDecl { + return .{ .name = name, .params = &.{}, .param_names = &.{}, .return_type = null }; +} + +test "lower: deriveObjcSelector — niladic / keyword / multi-keyword / override" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var lowering = Lowering.init(&module); + + // arity 0 → bare name, no colons, not an override. + const niladic = lowering.deriveObjcSelector(objcMethod("count"), 0); + try std.testing.expectEqualStrings("count", niladic.sel); + try std.testing.expectEqual(@as(usize, 0), niladic.keyword_count); + try std.testing.expectEqual(false, niladic.is_override); + + // arity ≥ 1, no `_` → single trailing colon, one keyword. + const single = lowering.deriveObjcSelector(objcMethod("setValue"), 1); + defer alloc.free(single.sel); + try std.testing.expectEqualStrings("setValue:", single.sel); + try std.testing.expectEqual(@as(usize, 1), single.keyword_count); + try std.testing.expectEqual(false, single.is_override); + + // each `_` → `:`, plus a trailing `:`; piece count = (#`_`) + 1. + const multi = lowering.deriveObjcSelector(objcMethod("setValue_forKey"), 2); + defer alloc.free(multi.sel); + try std.testing.expectEqualStrings("setValue:forKey:", multi.sel); + try std.testing.expectEqual(@as(usize, 2), multi.keyword_count); + try std.testing.expectEqual(false, multi.is_override); + + // `#selector(...)` override: used verbatim, keyword_count = #colons. + var m = objcMethod("init_with_frame_style"); + m.selector_override = "initWithFrame:style:"; + const overridden = lowering.deriveObjcSelector(m, 2); + try std.testing.expectEqualStrings("initWithFrame:style:", overridden.sel); + try std.testing.expectEqual(@as(usize, 2), overridden.keyword_count); + try std.testing.expectEqual(true, overridden.is_override); +} + +test "lower: isObjcClassPointer recognises pointer-to-foreign-Obj-C-class" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var lowering = Lowering.init(&module); + + // *NSString where NSString is a registered Obj-C class → true. + const ns_name = module.types.internString("NSString"); + const ns_struct = module.types.intern(.{ .@"struct" = .{ .name = ns_name, .fields = &.{} } }); + const ns_ptr = module.types.ptrTo(ns_struct); + var ns_fcd = ast.ForeignClassDecl{ + .name = "NSString", + .foreign_path = "NSString", + .runtime = .objc_class, + .members = &.{}, + .is_foreign = true, + .is_main = false, + }; + try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd); + try std.testing.expect(lowering.isObjcClassPointer(ns_ptr)); + + // *Plain where Plain is a non-foreign struct → false. + const plain_name = module.types.internString("Plain"); + const plain_struct = module.types.intern(.{ .@"struct" = .{ .name = plain_name, .fields = &.{} } }); + try std.testing.expect(!lowering.isObjcClassPointer(module.types.ptrTo(plain_struct))); + + // *void and a builtin scalar → false (not object pointers). + try std.testing.expect(!lowering.isObjcClassPointer(module.types.ptrTo(.void))); + try std.testing.expect(!lowering.isObjcClassPointer(.s32)); +} + +test "lower: objcPropertyKind defaults + explicit ARC modifiers" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var lowering = Lowering.init(&module); + + // Register NSString so `*NSString` resolves to an object pointer. + const ns_name = module.types.internString("NSString"); + _ = module.types.intern(.{ .@"struct" = .{ .name = ns_name, .fields = &.{} } }); + var ns_fcd = ast.ForeignClassDecl{ + .name = "NSString", + .foreign_path = "NSString", + .runtime = .objc_class, + .members = &.{}, + .is_foreign = true, + .is_main = false, + }; + try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd); + + // Primitive field, no modifiers → assign (the non-object default). + const prim = ast.ForeignFieldDecl{ .name = "count", .field_type = typeKeyword(alloc, "s32"), .is_property = true }; + defer alloc.destroy(prim.field_type); + try std.testing.expect(lowering.objcPropertyKind(prim) == .assign); + + // Object-pointer field, no modifiers → strong (the object default). + const obj_ty = typeKeyword(alloc, "*NSString"); + defer alloc.destroy(obj_ty); + const obj_default = ast.ForeignFieldDecl{ .name = "title", .field_type = obj_ty, .is_property = true }; + try std.testing.expect(lowering.objcPropertyKind(obj_default) == .strong); + + // Explicit modifiers on an object pointer win over the default. + const weak_mods = [_][]const u8{"weak"}; + try std.testing.expect(lowering.objcPropertyKind(.{ .name = "delegate", .field_type = obj_ty, .is_property = true, .property_modifiers = &weak_mods }) == .weak); + + const copy_mods = [_][]const u8{"copy"}; + try std.testing.expect(lowering.objcPropertyKind(.{ .name = "name", .field_type = obj_ty, .is_property = true, .property_modifiers = ©_mods }) == .copy); + + const assign_mods = [_][]const u8{"assign"}; + try std.testing.expect(lowering.objcPropertyKind(.{ .name = "raw", .field_type = obj_ty, .is_property = true, .property_modifiers = &assign_mods }) == .assign); +} + // ── Pack projection name resolution (Feature 1, Step 2.2) ──────────── const errors = @import("../errors.zig"); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index ada1672..395ba6e 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -6031,7 +6031,7 @@ pub const Lowering = struct { /// - arity ≥ 1: split the sx name on `_`; each piece becomes a /// keyword with a trailing `:` (`addObject` → `addObject:`, /// `combine_and` → `combine:and:`). - fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } { + pub fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } { if (method.selector_override) |sel| { var colons: usize = 0; for (sel) |ch| { @@ -15790,7 +15790,7 @@ pub const Lowering = struct { /// - `weak` on a non-object field type → diagnostic /// - `strong` (explicit or defaulted) on `*void` (ambiguous: Obj-C /// object vs raw memory) → require explicit modifier - fn objcPropertyKind(self: *Lowering, field: ast.ForeignFieldDecl) ObjcPropertyKind { + pub fn objcPropertyKind(self: *Lowering, field: ast.ForeignFieldDecl) ObjcPropertyKind { // Survey the modifier list. var has_strong = false; var has_weak = false; From b5119e85879da3d74a11163afc3aa5e685f66547 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 07:45:10 +0300 Subject: [PATCH 2/5] test(ir): cover Obj-C protocol pointers in isObjcClassPointer/objcPropertyKind (A6.1 scaffolding review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of 0012228 noted isObjcClassPointer's contract is `fcd.runtime == .objc_class or fcd.runtime == .objc_protocol`, but the new tests only exercised the class case. Test-only fix (no visibility/behavior change — still exactly the two pub widenings from the parent commit): - isObjcClassPointer: add a *NSCopying case where NSCopying is a registered .objc_protocol foreign class -> true (alongside the .objc_class *NSString case). - objcPropertyKind: add a *NSCoding protocol-pointer field -> strong default assertion, since it uses the same class/protocol object-pointer predicate. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0. --- src/ir/lower.test.zig | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 6afaa59..ecad5d1 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -680,6 +680,22 @@ test "lower: isObjcClassPointer recognises pointer-to-foreign-Obj-C-class" { try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd); try std.testing.expect(lowering.isObjcClassPointer(ns_ptr)); + // *NSCopying where NSCopying is a registered Obj-C *protocol* → also true + // (the predicate accepts .objc_class OR .objc_protocol). + const proto_name = module.types.internString("NSCopying"); + const proto_struct = module.types.intern(.{ .@"struct" = .{ .name = proto_name, .fields = &.{} } }); + const proto_ptr = module.types.ptrTo(proto_struct); + var proto_fcd = ast.ForeignClassDecl{ + .name = "NSCopying", + .foreign_path = "NSCopying", + .runtime = .objc_protocol, + .members = &.{}, + .is_foreign = true, + .is_main = false, + }; + try lowering.program_index.foreign_class_map.put("NSCopying", &proto_fcd); + try std.testing.expect(lowering.isObjcClassPointer(proto_ptr)); + // *Plain where Plain is a non-foreign struct → false. const plain_name = module.types.internString("Plain"); const plain_struct = module.types.intern(.{ .@"struct" = .{ .name = plain_name, .fields = &.{} } }); @@ -720,6 +736,24 @@ test "lower: objcPropertyKind defaults + explicit ARC modifiers" { const obj_default = ast.ForeignFieldDecl{ .name = "title", .field_type = obj_ty, .is_property = true }; try std.testing.expect(lowering.objcPropertyKind(obj_default) == .strong); + // Protocol-pointer field → also strong by default (same object-pointer + // predicate accepts .objc_protocol). + const proto_name = module.types.internString("NSCoding"); + _ = module.types.intern(.{ .@"struct" = .{ .name = proto_name, .fields = &.{} } }); + var proto_fcd = ast.ForeignClassDecl{ + .name = "NSCoding", + .foreign_path = "NSCoding", + .runtime = .objc_protocol, + .members = &.{}, + .is_foreign = true, + .is_main = false, + }; + try lowering.program_index.foreign_class_map.put("NSCoding", &proto_fcd); + const proto_ty = typeKeyword(alloc, "*NSCoding"); + defer alloc.destroy(proto_ty); + const proto_default = ast.ForeignFieldDecl{ .name = "coder", .field_type = proto_ty, .is_property = true }; + try std.testing.expect(lowering.objcPropertyKind(proto_default) == .strong); + // Explicit modifiers on an object pointer win over the default. const weak_mods = [_][]const u8{"weak"}; try std.testing.expect(lowering.objcPropertyKind(.{ .name = "delegate", .field_type = obj_ty, .is_property = true, .property_modifiers = &weak_mods }) == .weak); From 9bde1dd590c0f0aff6ceac07a09303aa7a0d99e2 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 08:00:42 +0300 Subject: [PATCH 3/5] refactor(ir): extract ObjcLowering (ffi_objc.zig) for pure Obj-C decision helpers (A6.1 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pure Obj-C decision helpers out of lower.zig into src/ir/ffi_objc.zig behind an ObjcLowering *Lowering facade (Principle 5, like the A4/A5 resolvers). Behavior-preserving relocation — the only non-self.l rewrites are facade plumbing. Moved verbatim (self. -> self.l. for Lowering members): - deriveObjcSelector (selector derivation) - objcTypeEncodingFromSignature + appendObjcEncoding + bailObjcEncoding + the ObjcEncodingStack type - objcPropertyKind + the ObjcPropertyKind enum - isObjcClassPointer - objcDefinedStateStructType + objcStateAllocatorType Emission-heavy code stays in lower.zig per PLAN A6.1 step 6: emitObjc* IMP builders, lowerObjc*Call, registerObjc*, declareObjc*, the lookupObjc* property/ state lookups, and the Self-substitution resolvers. - Call sites rerouted through a new objc() accessor: 15 in lower.zig, 1 in expr_typer.zig, 39 in lower.test.zig (the A6.1 scaffolding tests now drive the facade). No Lowering wrappers kept. Barrel-wired ffi_objc + ObjcLowering. - No new visibility widening beyond sub-step 1's two pubs — the facade reads self.l.{alloc,module,program_index,diagnostics} (fields) + the already-pub resolveType. lower.zig -478 (->16615); ffi_objc.zig 428. - Doc-only re-home: the property-IMP getter/setter comment was attached (a pre-existing artifact) to the moving ObjcPropertyKind enum, two decls away from its real subject emitObjcDefinedClassPropertyImps (which had no doc). Re-homed it there so the move neither orphans a `///` block (Zig errors on a dangling doc comment) nor misattributes it to ensureArcRuntimeDecls. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (48 13xx Obj-C examples + 4 Obj-C .ir snapshots green, no churn). --- src/ir/expr_typer.zig | 2 +- src/ir/ffi_objc.zig | 428 ++++++++++++++++++++++++++++++++++++ src/ir/ir.zig | 2 + src/ir/lower.test.zig | 78 +++---- src/ir/lower.zig | 491 ++++-------------------------------------- 5 files changed, 508 insertions(+), 493 deletions(-) create mode 100644 src/ir/ffi_objc.zig diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index 59b6bd8..ee05d26 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -127,7 +127,7 @@ pub const ExprTyper = struct { } // M1.3 — `obj.class` on an Obj-C-class pointer returns Class (*void). if (std.mem.eql(u8, fa.field, "class")) { - if (self.l.isObjcClassPointer(self.l.inferExprType(fa.object))) { + if (self.l.objc().isObjcClassPointer(self.l.inferExprType(fa.object))) { return self.l.module.types.ptrTo(.void); } } diff --git a/src/ir/ffi_objc.zig b/src/ir/ffi_objc.zig new file mode 100644 index 0000000..808a00a --- /dev/null +++ b/src/ir/ffi_objc.zig @@ -0,0 +1,428 @@ +const std = @import("std"); +const ast = @import("../ast.zig"); +const lower = @import("lower.zig"); +const types = @import("types.zig"); + +const Lowering = lower.Lowering; +const TypeId = types.TypeId; + +/// Tracks struct TypeIds currently being emitted so a struct field of +/// `*Self` (or a transitive pointee that cycles back) emits the +/// abbreviated `{Name}` form instead of recursing forever. Bounded to +/// `cap` — well above any realistic Obj-C struct nesting depth. +const ObjcEncodingStack = struct { + const cap = 16; + items: [cap]TypeId = undefined, + len: u8 = 0, + + fn push(self: *ObjcEncodingStack, tid: TypeId) bool { + if (self.len >= cap) return false; + self.items[self.len] = tid; + self.len += 1; + return true; + } + + fn pop(self: *ObjcEncodingStack) void { + std.debug.assert(self.len > 0); + self.len -= 1; + } + + fn contains(self: *const ObjcEncodingStack, tid: TypeId) bool { + var i: usize = 0; + while (i < self.len) : (i += 1) { + if (self.items[i] == tid) return true; + } + return false; + } +}; + +/// `assign` is the default for primitives (direct store, no ARC ops); +/// `strong` is the default for pointer-to-object types (retain on +/// assign, release on dealloc); `weak` and `copy` are explicit. The +/// helper rejects ambiguous combinations loudly per the silent-error +/// budget — `*void` requires explicit modifier, `weak` requires an +/// object-pointer slot. +const ObjcPropertyKind = enum { + assign, // primitives or explicitly opted-out object slots + strong, // default for * — retain on assign, release on dealloc + weak, // objc_storeWeak / objc_loadWeakRetained — auto-nilling + copy, // [val copy] on assign — for immutable-wanting String/Array slots + + pub fn isObject(k: ObjcPropertyKind) bool { + return k == .strong or k == .weak or k == .copy; + } +}; + +/// Pure Obj-C decision helpers (architecture phase A6.1), extracted from +/// `Lowering`. A `*Lowering` facade (Principle 5, like `ErrorAnalysis`/ +/// `CoercionResolver`): selector derivation, type-encoding-string derivation, +/// ARC property-kind classification, Obj-C class-pointer recognition, and +/// hidden-state-struct planning. No IR is emitted here — the emission-heavy IMP +/// builders / `lowerObjc*Call` dispatch stay in `Lowering` (PLAN-ARCH A6.1 +/// step 6). Reads `self.l.{alloc, module, program_index, diagnostics}` and the +/// `self.l.resolveType` resolver. +pub const ObjcLowering = struct { + l: *Lowering, + + pub fn deriveObjcSelector(self: ObjcLowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } { + if (method.selector_override) |sel| { + var colons: usize = 0; + for (sel) |ch| { + if (ch == ':') colons += 1; + } + return .{ .sel = sel, .keyword_count = colons, .is_override = true }; + } + if (arity == 0) { + return .{ .sel = method.name, .keyword_count = 0, .is_override = false }; + } + // Each `_` in the sx name becomes a `:` (one-byte-for-one), plus + // one trailing `:` regardless of how many pieces. Piece count + // = (number of `_`) + 1. + var pieces: usize = 1; + for (method.name) |ch| { + if (ch == '_') pieces += 1; + } + const out = self.l.alloc.alloc(u8, method.name.len + 1) catch unreachable; + for (method.name, 0..) |ch, i| { + out[i] = if (ch == '_') ':' else ch; + } + out[method.name.len] = ':'; + return .{ .sel = out, .keyword_count = pieces, .is_override = false }; + } + + /// Derive an Obj-C type-encoding string for a synthesized IMP + /// signature (M1.2 A.1). Apple's runtime accepts these strings on + /// `class_addMethod(cls, sel, imp, types)`; the encoding tells the + /// runtime the IMP's argument layout for KVC, NSCoder, and reflective + /// dispatch. + /// + /// Layout: ` @ : ...`. The `@` slot is the + /// receiver (self); `:` is `_cmd`. Caller passes user-declared params + /// AFTER stripping `self`. + /// + /// Single-character encodings (the common case): + /// v=void B=bool c=s8/BOOL s=s16 i=s32 q=s64 + /// C=u8 S=u16 I=u32 Q=u64 f=f32 d=f64 + /// @=id #=Class :=SEL *=C string ^v=void* / generic ptr + /// + /// Foreign-class pointers (`*UIView` etc.) encode as `@` (object + /// pointer). Other pointers fall to `^v` — the encoding is metadata, + /// not ABI, so being conservative here is safe. Pass-by-value + /// structs encode as `{Name=field0field1...}`; nested structs + /// recurse with cycle-break via `ObjcEncodingStack`. Tagged-union / + /// array / vector / function shapes BAIL loudly via diagnostics + /// rather than silently mis-encoding (per CLAUDE.md rejected- + /// patterns rule). + /// + /// Returns an allocator-owned slice; caller frees via `self.l.alloc`. + pub fn objcTypeEncodingFromSignature( + self: ObjcLowering, + return_ty: TypeId, + param_tys: []const TypeId, + span: ?ast.Span, + ) ![]const u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(self.l.alloc); + + var stack: ObjcEncodingStack = .{}; + try self.appendObjcEncoding(&out, return_ty, span, &stack); + try out.append(self.l.alloc, '@'); // self + try out.append(self.l.alloc, ':'); // _cmd + for (param_tys) |pty| { + try self.appendObjcEncoding(&out, pty, span, &stack); + } + + return try out.toOwnedSlice(self.l.alloc); + } + + fn appendObjcEncoding( + self: ObjcLowering, + out: *std.ArrayList(u8), + ty: TypeId, + span: ?ast.Span, + stack: *ObjcEncodingStack, + ) !void { + const info = self.l.module.types.get(ty); + switch (info) { + .void => try out.append(self.l.alloc, 'v'), + .bool => try out.append(self.l.alloc, 'B'), + .signed => |bits| { + const ch: u8 = switch (bits) { + 8 => 'c', + 16 => 's', + 32 => 'i', + 64 => 'q', + else => return self.bailObjcEncoding(span, "signed integer with non-standard bit width", bits), + }; + try out.append(self.l.alloc, ch); + }, + .unsigned => |bits| { + const ch: u8 = switch (bits) { + 8 => 'C', + 16 => 'S', + 32 => 'I', + 64 => 'Q', + else => return self.bailObjcEncoding(span, "unsigned integer with non-standard bit width", bits), + }; + try out.append(self.l.alloc, ch); + }, + .f32 => try out.append(self.l.alloc, 'f'), + .f64 => try out.append(self.l.alloc, 'd'), + // sx-target arm64 — pointer-sized aliases match s64/u64. + .isize => try out.append(self.l.alloc, 'q'), + .usize => try out.append(self.l.alloc, 'Q'), + .pointer => |p| { + // Pointer to a foreign Obj-C class (or sx-defined #objc_class) + // encodes as `@`. Anything else falls to `^v` — generic + // pointer; the runtime treats it as opaque. + const pointee_info = self.l.module.types.get(p.pointee); + const is_objc_obj = blk: { + if (pointee_info != .@"struct") break :blk false; + const name = self.l.module.types.getString(pointee_info.@"struct".name); + break :blk self.l.program_index.foreign_class_map.get(name) != null; + }; + if (is_objc_obj) { + try out.append(self.l.alloc, '@'); + } else { + try out.appendSlice(self.l.alloc, "^v"); + } + }, + .many_pointer => |mp| { + // `[*]u8` is the canonical C-string carrier — encode as `*`. + // Other element types fall to generic `^v`. + const el = self.l.module.types.get(mp.element); + if (el == .unsigned and el.unsigned == 8) { + try out.append(self.l.alloc, '*'); + } else { + try out.appendSlice(self.l.alloc, "^v"); + } + }, + .optional => |o| { + // sx's `?T` is a nullable T. At the Obj-C ABI boundary + // nullability is just "this pointer may be null" — the + // wire-level encoding is the same as T. Unwrap and + // recurse. (Same goes for `?*UIView` etc. — the + // underlying pointer kind drives the encoding char.) + return self.appendObjcEncoding(out, o.child, span, stack); + }, + .@"struct" => |s| { + // Pass-by-value struct argument or return: Apple's + // encoding is `{Name=field0field1...}`. A struct + // already on the encoding stack (i.e. transitively + // referenced through a struct field — extremely rare + // since sx structs don't recurse by value) gets the + // abbreviated `{Name}` form. Recursion through + // POINTERS is fine because `.pointer` collapses to + // `^v` regardless of pointee shape. + const name = self.l.module.types.getString(s.name); + try out.append(self.l.alloc, '{'); + try out.appendSlice(self.l.alloc, name); + if (stack.contains(ty)) { + try out.append(self.l.alloc, '}'); + return; + } + if (!stack.push(ty)) { + return self.bailObjcEncoding(span, "Obj-C struct encoding nested deeper than supported", ObjcEncodingStack.cap); + } + defer stack.pop(); + try out.append(self.l.alloc, '='); + for (s.fields) |f| { + try self.appendObjcEncoding(out, f.ty, span, stack); + } + try out.append(self.l.alloc, '}'); + }, + else => return self.bailObjcEncoding(span, "type kind not yet supported by Obj-C encoding", @intFromEnum(std.meta.activeTag(info))), + } + } + + fn bailObjcEncoding(self: ObjcLowering, span: ?ast.Span, reason: []const u8, detail: anytype) anyerror { + if (self.l.diagnostics) |d| { + d.addFmt(.err, span, "cannot derive Obj-C type encoding: {s} (detail={any})", .{ reason, detail }); + } + return error.ObjcEncodingUnsupported; + } + + /// Build (and cache) the hidden sx-state struct type for an sx-defined + /// `#objc_class`. The state struct is what the runtime's `__sx_state` + /// ivar points at — separate from the Obj-C object itself, which stays + /// opaque. Layout (M1.2 A.2): + /// + /// __State { + /// user_field_0, + /// user_field_1, + /// ... + /// } + /// + /// M1.2 A.5 will prepend `__sx_allocator: Allocator` so `-dealloc` + /// can free through the per-instance allocator and method bodies can + /// access `self.allocator`. For A.2 the struct holds only the + /// user-declared fields — sufficient for the body lowering + + /// `self.field` access work in A.2/A.3. Field-by-name resolution + /// stays correct across the future repositioning. + /// + /// Foreign-class members other than `.field` are ignored here — + /// methods / `#extends` / `#implements` don't contribute to the + /// state layout. + pub fn objcDefinedStateStructType(self: ObjcLowering, fcd: *const ast.ForeignClassDecl) TypeId { + const state_name = std.fmt.allocPrint(self.l.alloc, "__{s}State", .{fcd.name}) catch unreachable; + defer self.l.alloc.free(state_name); // internString copies; the temp isn't needed after. + const name_id = self.l.module.types.internString(state_name); + if (self.l.module.types.findByName(name_id)) |existing| return existing; + + // The interned struct's `fields` slice lives for the module's lifetime; + // allocate it (and the building ArrayList) in the module arena so it's + // freed at module deinit rather than leaking through `self.l.alloc`. + const field_alloc = self.l.module.slice_arena.allocator(); + var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; + // M4.0: prepend __sx_allocator at field index 0 — captured at +alloc + // time, read at -dealloc time to free the state struct through the + // same allocator. Lookup by name (the existing by-name resolution in + // emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer) + // naturally finds user fields at their post-shift indices. + if (self.objcStateAllocatorType()) |allocator_ty| { + fields.append(field_alloc, .{ + .name = self.l.module.types.internString("__sx_allocator"), + .ty = allocator_ty, + }) catch unreachable; + } + for (fcd.members) |m| { + switch (m) { + .field => |f| { + const f_name_id = self.l.module.types.internString(f.name); + const f_ty = self.l.resolveType(f.field_type); + fields.append(field_alloc, .{ .name = f_name_id, .ty = f_ty }) catch unreachable; + }, + else => {}, + } + } + return self.l.module.types.intern(.{ .@"struct" = .{ + .name = name_id, + .fields = fields.toOwnedSlice(field_alloc) catch unreachable, + } }); + } + + /// Return the `Allocator` protocol TypeId (the value-shape used in + /// Context.allocator). Falls back to null if Context isn't registered + /// yet (early-init paths); callers omit the field in that case. + fn objcStateAllocatorType(self: ObjcLowering) ?TypeId { + const ctx_name = self.l.module.types.internString("Context"); + const ctx_ty = self.l.module.types.findByName(ctx_name) orelse return null; + const ctx_info = self.l.module.types.get(ctx_ty); + if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) return null; + return ctx_info.@"struct".fields[0].ty; + } + + pub fn isObjcClassPointer(self: ObjcLowering, ty: TypeId) bool { + if (ty.isBuiltin()) return false; + const ptr_info = self.l.module.types.get(ty); + if (ptr_info != .pointer) return false; + const pointee_info = self.l.module.types.get(ptr_info.pointer.pointee); + if (pointee_info != .@"struct") return false; + const struct_name = self.l.module.types.getString(pointee_info.@"struct".name); + const fcd = self.l.program_index.foreign_class_map.get(struct_name) orelse return false; + return fcd.runtime == .objc_class or fcd.runtime == .objc_protocol; + } + + /// Resolve a `#property(...)` field's ARC kind. Loud at compile time + /// for known footguns (per the silent-error budget in the plan): + /// - unknown modifier name (typo) → diagnostic + /// - `weak` on a non-object field type → diagnostic + /// - `strong` (explicit or defaulted) on `*void` (ambiguous: Obj-C + /// object vs raw memory) → require explicit modifier + pub fn objcPropertyKind(self: ObjcLowering, field: ast.ForeignFieldDecl) ObjcPropertyKind { + // Survey the modifier list. + var has_strong = false; + var has_weak = false; + var has_copy = false; + var has_assign = false; + for (field.property_modifiers) |mod| { + if (std.mem.eql(u8, mod, "strong")) has_strong = true + else if (std.mem.eql(u8, mod, "weak")) has_weak = true + else if (std.mem.eql(u8, mod, "copy")) has_copy = true + else if (std.mem.eql(u8, mod, "assign")) has_assign = true + else if (std.mem.eql(u8, mod, "readonly")) { + // Orthogonal to ARC kind — no-op here. + } + else if (std.mem.eql(u8, mod, "nonatomic") or std.mem.eql(u8, mod, "atomic")) { + // Atomicity — recorded for the property attribute string; + // doesn't affect the ARC kind. + } + else if (std.mem.startsWith(u8, mod, "getter(") or std.mem.startsWith(u8, mod, "setter(")) { + // Selector overrides — handled elsewhere. + } + else { + if (self.l.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "unknown #property modifier '{s}' on field '{s}' — expected one of: strong, weak, copy, assign, readonly, nonatomic, atomic, getter(\"...\"), setter(\"...\")", .{ mod, field.name }); + } + } + } + + // Mutually-exclusive ARC modifiers — at most one. + const explicit_count: u32 = + (@as(u32, if (has_strong) 1 else 0)) + + (@as(u32, if (has_weak) 1 else 0)) + + (@as(u32, if (has_copy) 1 else 0)) + + (@as(u32, if (has_assign) 1 else 0)); + if (explicit_count > 1) { + if (self.l.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "conflicting #property modifiers on field '{s}' — strong/weak/copy/assign are mutually exclusive", .{field.name}); + } + } + + // Resolve the field's type to decide defaults + validate. + const field_ty = self.l.resolveType(field.field_type); + const is_pointer = !field_ty.isBuiltin() and self.l.module.types.get(field_ty) == .pointer; + const is_object_ptr = is_pointer and blk: { + const pointee = self.l.module.types.get(field_ty).pointer.pointee; + // `*void` is NOT considered an object pointer — ambiguous. + if (pointee == .void) break :blk false; + // `*T` where T is a foreign-class struct (Obj-C class). + if (pointee.isBuiltin()) break :blk false; + const pointee_info = self.l.module.types.get(pointee); + if (pointee_info != .@"struct") break :blk false; + const struct_name = self.l.module.types.getString(pointee_info.@"struct".name); + const fcd = self.l.program_index.foreign_class_map.get(struct_name) orelse break :blk false; + break :blk fcd.runtime == .objc_class or fcd.runtime == .objc_protocol; + }; + + // `weak` requires an object pointer — `weak s32` is meaningless and + // would invoke objc_storeWeak on a non-object slot. + if (has_weak and !is_object_ptr) { + if (self.l.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "#property(weak) on field '{s}' requires a pointer-to-Obj-C-class type; got '{s}'", .{ field.name, self.l.module.types.typeName(field_ty) }); + } + } + + // `copy` requires an object pointer — `copy s32` makes no sense. + if (has_copy and !is_object_ptr) { + if (self.l.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "#property(copy) on field '{s}' requires a pointer-to-Obj-C-class type (typically NSString or NSArray)", .{field.name}); + } + } + + // `*void` is ambiguous (Obj-C object vs raw memory): require explicit + // modifier so the user opts into ARC semantics consciously. + if (is_pointer) { + const pointee = self.l.module.types.get(field_ty).pointer.pointee; + if (pointee == .void and explicit_count == 0) { + if (self.l.diagnostics) |d| { + const span = ast.Span{ .start = 0, .end = 0 }; + d.addFmt(.err, span, "#property on field '{s}' of type '*void' is ambiguous — specify `#property(strong|weak|copy|assign)` explicitly (Obj-C object vs raw memory)", .{field.name}); + } + return .assign; // assume safe default to keep compilation going + } + } + + // Apply explicit modifier or default. + if (has_weak) return .weak; + if (has_copy) return .copy; + if (has_strong) return .strong; + if (has_assign) return .assign; + // Default: object pointers → strong; everything else → assign. + return if (is_object_ptr) .strong else .assign; + } +}; diff --git a/src/ir/ir.zig b/src/ir/ir.zig index f4a3773..144f77d 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -14,6 +14,7 @@ pub const protocols = @import("protocols.zig"); pub const conversions = @import("conversions.zig"); pub const error_analysis = @import("error_analysis.zig"); pub const error_flow = @import("error_flow.zig"); +pub const ffi_objc = @import("ffi_objc.zig"); pub const semantic_diagnostics = @import("semantic_diagnostics.zig"); pub const TypeId = types.TypeId; @@ -54,6 +55,7 @@ pub const CoercionResolver = conversions.CoercionResolver; pub const CoercionPlan = conversions.CoercionResolver.CoercionPlan; pub const ErrorAnalysis = error_analysis.ErrorAnalysis; pub const ErrorFlow = error_flow.ErrorFlow; +pub const ObjcLowering = ffi_objc.ObjcLowering; pub const ErrorFacts = error_analysis.ErrorFacts; pub const compiler_hooks = @import("compiler_hooks.zig"); diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index ecad5d1..f957eb8 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -296,42 +296,42 @@ test "lower: objcTypeEncodingFromSignature emits primitive shapes" { var lowering = Lowering.init(&module); // Niladic void method: -(void)greet → "v@:" - const e1 = try lowering.objcTypeEncodingFromSignature(.void, &.{}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("v@:", e1); // Returns s32, takes s32: -(int)add:(int)x → "i@:i" - const e2 = try lowering.objcTypeEncodingFromSignature(.s32, &.{.s32}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(.s32, &.{.s32}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("i@:i", e2); // s64 return, two s64 args: "q@:qq" - const e3 = try lowering.objcTypeEncodingFromSignature(.s64, &.{ .s64, .s64 }, null); + const e3 = try lowering.objc().objcTypeEncodingFromSignature(.s64, &.{ .s64, .s64 }, null); defer alloc.free(e3); try std.testing.expectEqualStrings("q@:qq", e3); // BOOL return (s8): "c@:" - const e4 = try lowering.objcTypeEncodingFromSignature(.s8, &.{}, null); + const e4 = try lowering.objc().objcTypeEncodingFromSignature(.s8, &.{}, null); defer alloc.free(e4); try std.testing.expectEqualStrings("c@:", e4); // Float/double: "f@:d" - const e5 = try lowering.objcTypeEncodingFromSignature(.f32, &.{.f64}, null); + const e5 = try lowering.objc().objcTypeEncodingFromSignature(.f32, &.{.f64}, null); defer alloc.free(e5); try std.testing.expectEqualStrings("f@:d", e5); // bool (i1) is `B` — distinct from BOOL (`c`). - const e6 = try lowering.objcTypeEncodingFromSignature(.bool, &.{.bool}, null); + const e6 = try lowering.objc().objcTypeEncodingFromSignature(.bool, &.{.bool}, null); defer alloc.free(e6); try std.testing.expectEqualStrings("B@:B", e6); // usize / isize on the 64-bit target. - const e7 = try lowering.objcTypeEncodingFromSignature(.usize, &.{.isize}, null); + const e7 = try lowering.objc().objcTypeEncodingFromSignature(.usize, &.{.isize}, null); defer alloc.free(e7); try std.testing.expectEqualStrings("Q@:q", e7); // Unsigned variants u8/u16/u32/u64. - const e8 = try lowering.objcTypeEncodingFromSignature(.u32, &.{ .u8, .u16, .u64 }, null); + const e8 = try lowering.objc().objcTypeEncodingFromSignature(.u32, &.{ .u8, .u16, .u64 }, null); defer alloc.free(e8); try std.testing.expectEqualStrings("I@:CSQ", e8); } @@ -344,19 +344,19 @@ test "lower: objcTypeEncodingFromSignature emits pointer shapes" { // Generic `*void` → `^v`. const void_ptr = module.types.ptrTo(.void); - const e1 = try lowering.objcTypeEncodingFromSignature(void_ptr, &.{void_ptr}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(void_ptr, &.{void_ptr}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("^v@:^v", e1); // `[*]u8` C-string carrier → `*`. const u8_many = module.types.intern(.{ .many_pointer = .{ .element = .u8 } }); - const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{u8_many}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{u8_many}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("v@:*", e2); // `[*]s32` (non-u8 many-pointer) → `^v`. const s32_many = module.types.intern(.{ .many_pointer = .{ .element = .s32 } }); - const e3 = try lowering.objcTypeEncodingFromSignature(.void, &.{s32_many}, null); + const e3 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{s32_many}, null); defer alloc.free(e3); try std.testing.expectEqualStrings("v@:^v", e3); } @@ -390,7 +390,7 @@ test "lower: objcDefinedStateStructType collects user-declared fields" { .is_main = false, }; - const state_ty = lowering.objcDefinedStateStructType(&fcd); + const state_ty = lowering.objc().objcDefinedStateStructType(&fcd); const info = module.types.get(state_ty); try std.testing.expectEqual(@as(std.meta.Tag(@TypeOf(info)), .@"struct"), std.meta.activeTag(info)); @@ -403,7 +403,7 @@ test "lower: objcDefinedStateStructType collects user-declared fields" { try std.testing.expectEqual(TypeId.s64, s.fields[1].ty); // Idempotency: a second call returns the same TypeId (cache hit on name). - const state_ty2 = lowering.objcDefinedStateStructType(&fcd); + const state_ty2 = lowering.objc().objcDefinedStateStructType(&fcd); try std.testing.expectEqual(state_ty, state_ty2); } @@ -422,7 +422,7 @@ test "lower: objcDefinedStateStructType handles empty field set" { .is_main = false, }; - const state_ty = lowering.objcDefinedStateStructType(&fcd); + const state_ty = lowering.objc().objcDefinedStateStructType(&fcd); const info = module.types.get(state_ty); try std.testing.expectEqualStrings("__SxEmptyState", module.types.getString(info.@"struct".name)); try std.testing.expectEqual(@as(usize, 0), info.@"struct".fields.len); @@ -454,7 +454,7 @@ test "lower: objcDefinedStateStructType skips non-field members" { .is_main = false, }; - const state_ty = lowering.objcDefinedStateStructType(&fcd); + const state_ty = lowering.objc().objcDefinedStateStructType(&fcd); const info = module.types.get(state_ty); try std.testing.expectEqual(@as(usize, 1), info.@"struct".fields.len); try std.testing.expectEqualStrings("counter", module.types.getString(info.@"struct".fields[0].name)); @@ -482,12 +482,12 @@ test "lower: objcTypeEncodingFromSignature emits @ for Obj-C class pointers" { try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd); // Return *NSString, no args: "@@:" - const e1 = try lowering.objcTypeEncodingFromSignature(ns_ptr, &.{}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(ns_ptr, &.{}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("@@:", e1); // Return *NSString, take *NSString: "@@:@" - const e2 = try lowering.objcTypeEncodingFromSignature(ns_ptr, &.{ns_ptr}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(ns_ptr, &.{ns_ptr}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("@@:@", e2); } @@ -515,14 +515,14 @@ test "lower: objcTypeEncodingFromSignature unwraps optional to wire type" { // `?s64 -> ?*NSString` collapses to `q -> @` at the Obj-C boundary. const opt_s64 = module.types.optionalOf(.s64); const opt_ns = module.types.optionalOf(ns_ptr); - const e1 = try lowering.objcTypeEncodingFromSignature(opt_ns, &.{opt_s64}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(opt_ns, &.{opt_s64}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("@@:q", e1); // Nested optional unwrap (`??f64`) — same as `f64` at the wire. const opt_f64 = module.types.optionalOf(.f64); const opt_opt_f64 = module.types.optionalOf(opt_f64); - const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{opt_opt_f64}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{opt_opt_f64}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("v@:d", e2); } @@ -544,12 +544,12 @@ test "lower: objcTypeEncodingFromSignature emits structs as {Name=fields...}" { const cgpoint = module.types.intern(.{ .@"struct" = .{ .name = cgpoint_name, .fields = &cgpoint_fields } }); // `-(void)setOrigin:(CGPoint)p` → `v@:{CGPoint=dd}` - const e1 = try lowering.objcTypeEncodingFromSignature(.void, &.{cgpoint}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{cgpoint}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("v@:{CGPoint=dd}", e1); // `-(CGPoint)origin` → `{CGPoint=dd}@:` - const e2 = try lowering.objcTypeEncodingFromSignature(cgpoint, &.{}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(cgpoint, &.{}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("{CGPoint=dd}@:", e2); @@ -564,7 +564,7 @@ test "lower: objcTypeEncodingFromSignature emits structs as {Name=fields...}" { .{ .name = len_name, .ty = .u64 }, }; const nsrange = module.types.intern(.{ .@"struct" = .{ .name = nsrange_name, .fields = &nsrange_fields } }); - const e3 = try lowering.objcTypeEncodingFromSignature(nsrange, &.{ nsrange, .s64 }, null); + const e3 = try lowering.objc().objcTypeEncodingFromSignature(nsrange, &.{ nsrange, .s64 }, null); defer alloc.free(e3); try std.testing.expectEqualStrings("{_NSRange=QQ}@:{_NSRange=QQ}q", e3); } @@ -606,12 +606,12 @@ test "lower: objcTypeEncodingFromSignature emits nested structs (CGRect)" { const cgrect = module.types.intern(.{ .@"struct" = .{ .name = cgrect_name, .fields = &cgrect_fields } }); // `-(CGRect)frame` → `{CGRect={CGPoint=dd}{CGSize=dd}}@:` - const e1 = try lowering.objcTypeEncodingFromSignature(cgrect, &.{}, null); + const e1 = try lowering.objc().objcTypeEncodingFromSignature(cgrect, &.{}, null); defer alloc.free(e1); try std.testing.expectEqualStrings("{CGRect={CGPoint=dd}{CGSize=dd}}@:", e1); // `-(void)setFrame:(CGRect)f` round-trip. - const e2 = try lowering.objcTypeEncodingFromSignature(.void, &.{cgrect}, null); + const e2 = try lowering.objc().objcTypeEncodingFromSignature(.void, &.{cgrect}, null); defer alloc.free(e2); try std.testing.expectEqualStrings("v@:{CGRect={CGPoint=dd}{CGSize=dd}}", e2); } @@ -631,20 +631,20 @@ test "lower: deriveObjcSelector — niladic / keyword / multi-keyword / override var lowering = Lowering.init(&module); // arity 0 → bare name, no colons, not an override. - const niladic = lowering.deriveObjcSelector(objcMethod("count"), 0); + const niladic = lowering.objc().deriveObjcSelector(objcMethod("count"), 0); try std.testing.expectEqualStrings("count", niladic.sel); try std.testing.expectEqual(@as(usize, 0), niladic.keyword_count); try std.testing.expectEqual(false, niladic.is_override); // arity ≥ 1, no `_` → single trailing colon, one keyword. - const single = lowering.deriveObjcSelector(objcMethod("setValue"), 1); + const single = lowering.objc().deriveObjcSelector(objcMethod("setValue"), 1); defer alloc.free(single.sel); try std.testing.expectEqualStrings("setValue:", single.sel); try std.testing.expectEqual(@as(usize, 1), single.keyword_count); try std.testing.expectEqual(false, single.is_override); // each `_` → `:`, plus a trailing `:`; piece count = (#`_`) + 1. - const multi = lowering.deriveObjcSelector(objcMethod("setValue_forKey"), 2); + const multi = lowering.objc().deriveObjcSelector(objcMethod("setValue_forKey"), 2); defer alloc.free(multi.sel); try std.testing.expectEqualStrings("setValue:forKey:", multi.sel); try std.testing.expectEqual(@as(usize, 2), multi.keyword_count); @@ -653,7 +653,7 @@ test "lower: deriveObjcSelector — niladic / keyword / multi-keyword / override // `#selector(...)` override: used verbatim, keyword_count = #colons. var m = objcMethod("init_with_frame_style"); m.selector_override = "initWithFrame:style:"; - const overridden = lowering.deriveObjcSelector(m, 2); + const overridden = lowering.objc().deriveObjcSelector(m, 2); try std.testing.expectEqualStrings("initWithFrame:style:", overridden.sel); try std.testing.expectEqual(@as(usize, 2), overridden.keyword_count); try std.testing.expectEqual(true, overridden.is_override); @@ -678,7 +678,7 @@ test "lower: isObjcClassPointer recognises pointer-to-foreign-Obj-C-class" { .is_main = false, }; try lowering.program_index.foreign_class_map.put("NSString", &ns_fcd); - try std.testing.expect(lowering.isObjcClassPointer(ns_ptr)); + try std.testing.expect(lowering.objc().isObjcClassPointer(ns_ptr)); // *NSCopying where NSCopying is a registered Obj-C *protocol* → also true // (the predicate accepts .objc_class OR .objc_protocol). @@ -694,16 +694,16 @@ test "lower: isObjcClassPointer recognises pointer-to-foreign-Obj-C-class" { .is_main = false, }; try lowering.program_index.foreign_class_map.put("NSCopying", &proto_fcd); - try std.testing.expect(lowering.isObjcClassPointer(proto_ptr)); + try std.testing.expect(lowering.objc().isObjcClassPointer(proto_ptr)); // *Plain where Plain is a non-foreign struct → false. const plain_name = module.types.internString("Plain"); const plain_struct = module.types.intern(.{ .@"struct" = .{ .name = plain_name, .fields = &.{} } }); - try std.testing.expect(!lowering.isObjcClassPointer(module.types.ptrTo(plain_struct))); + try std.testing.expect(!lowering.objc().isObjcClassPointer(module.types.ptrTo(plain_struct))); // *void and a builtin scalar → false (not object pointers). - try std.testing.expect(!lowering.isObjcClassPointer(module.types.ptrTo(.void))); - try std.testing.expect(!lowering.isObjcClassPointer(.s32)); + try std.testing.expect(!lowering.objc().isObjcClassPointer(module.types.ptrTo(.void))); + try std.testing.expect(!lowering.objc().isObjcClassPointer(.s32)); } test "lower: objcPropertyKind defaults + explicit ARC modifiers" { @@ -728,13 +728,13 @@ test "lower: objcPropertyKind defaults + explicit ARC modifiers" { // Primitive field, no modifiers → assign (the non-object default). const prim = ast.ForeignFieldDecl{ .name = "count", .field_type = typeKeyword(alloc, "s32"), .is_property = true }; defer alloc.destroy(prim.field_type); - try std.testing.expect(lowering.objcPropertyKind(prim) == .assign); + try std.testing.expect(lowering.objc().objcPropertyKind(prim) == .assign); // Object-pointer field, no modifiers → strong (the object default). const obj_ty = typeKeyword(alloc, "*NSString"); defer alloc.destroy(obj_ty); const obj_default = ast.ForeignFieldDecl{ .name = "title", .field_type = obj_ty, .is_property = true }; - try std.testing.expect(lowering.objcPropertyKind(obj_default) == .strong); + try std.testing.expect(lowering.objc().objcPropertyKind(obj_default) == .strong); // Protocol-pointer field → also strong by default (same object-pointer // predicate accepts .objc_protocol). @@ -752,17 +752,17 @@ test "lower: objcPropertyKind defaults + explicit ARC modifiers" { const proto_ty = typeKeyword(alloc, "*NSCoding"); defer alloc.destroy(proto_ty); const proto_default = ast.ForeignFieldDecl{ .name = "coder", .field_type = proto_ty, .is_property = true }; - try std.testing.expect(lowering.objcPropertyKind(proto_default) == .strong); + try std.testing.expect(lowering.objc().objcPropertyKind(proto_default) == .strong); // Explicit modifiers on an object pointer win over the default. const weak_mods = [_][]const u8{"weak"}; - try std.testing.expect(lowering.objcPropertyKind(.{ .name = "delegate", .field_type = obj_ty, .is_property = true, .property_modifiers = &weak_mods }) == .weak); + try std.testing.expect(lowering.objc().objcPropertyKind(.{ .name = "delegate", .field_type = obj_ty, .is_property = true, .property_modifiers = &weak_mods }) == .weak); const copy_mods = [_][]const u8{"copy"}; - try std.testing.expect(lowering.objcPropertyKind(.{ .name = "name", .field_type = obj_ty, .is_property = true, .property_modifiers = ©_mods }) == .copy); + try std.testing.expect(lowering.objc().objcPropertyKind(.{ .name = "name", .field_type = obj_ty, .is_property = true, .property_modifiers = ©_mods }) == .copy); const assign_mods = [_][]const u8{"assign"}; - try std.testing.expect(lowering.objcPropertyKind(.{ .name = "raw", .field_type = obj_ty, .is_property = true, .property_modifiers = &assign_mods }) == .assign); + try std.testing.expect(lowering.objc().objcPropertyKind(.{ .name = "raw", .field_type = obj_ty, .is_property = true, .property_modifiers = &assign_mods }) == .assign); } // ── Pack projection name resolution (Feature 1, Step 2.2) ──────────── diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 395ba6e..2573880 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -29,6 +29,7 @@ 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; @@ -4714,7 +4715,7 @@ pub const Lowering = struct { // typed Class(T) parameterization is M1.1.b). if (std.mem.eql(u8, fa.field, "class")) { const expr_ty = self.inferExprType(fa.object); - if (self.isObjcClassPointer(expr_ty)) { + if (self.objc().isObjcClassPointer(expr_ty)) { const obj_ref = self.lowerExpr(fa.object); const ptr_void = self.module.types.ptrTo(.void); const get_class_fid = self.ensureCRuntimeDecl("object_getClass", &.{ptr_void}, ptr_void); @@ -6020,224 +6021,10 @@ pub const Lowering = struct { } }, ret_ty); } - /// Resolve the Obj-C selector for a foreign-class method, honoring - /// any `#selector("...")` override on the declaration. When an - /// override is present the selector string is the user's literal; - /// `keyword_count` is the `:` count in the literal (so callers can - /// still cross-check arity, downgrading the diagnostic to a - /// warning). When no override exists, the default mangling rule - /// runs: - /// - niladic: name verbatim (`length` → `length`). - /// - arity ≥ 1: split the sx name on `_`; each piece becomes a - /// keyword with a trailing `:` (`addObject` → `addObject:`, - /// `combine_and` → `combine:and:`). - pub fn deriveObjcSelector(self: *Lowering, method: ast.ForeignMethodDecl, arity: usize) struct { sel: []const u8, keyword_count: usize, is_override: bool } { - if (method.selector_override) |sel| { - var colons: usize = 0; - for (sel) |ch| { - if (ch == ':') colons += 1; - } - return .{ .sel = sel, .keyword_count = colons, .is_override = true }; - } - if (arity == 0) { - return .{ .sel = method.name, .keyword_count = 0, .is_override = false }; - } - // Each `_` in the sx name becomes a `:` (one-byte-for-one), plus - // one trailing `:` regardless of how many pieces. Piece count - // = (number of `_`) + 1. - var pieces: usize = 1; - for (method.name) |ch| { - if (ch == '_') pieces += 1; - } - const out = self.alloc.alloc(u8, method.name.len + 1) catch unreachable; - for (method.name, 0..) |ch, i| { - out[i] = if (ch == '_') ':' else ch; - } - out[method.name.len] = ':'; - return .{ .sel = out, .keyword_count = pieces, .is_override = false }; - } - - /// Derive an Obj-C type-encoding string for a synthesized IMP - /// signature (M1.2 A.1). Apple's runtime accepts these strings on - /// `class_addMethod(cls, sel, imp, types)`; the encoding tells the - /// runtime the IMP's argument layout for KVC, NSCoder, and reflective - /// dispatch. - /// - /// Layout: ` @ : ...`. The `@` slot is the - /// receiver (self); `:` is `_cmd`. Caller passes user-declared params - /// AFTER stripping `self`. - /// - /// Single-character encodings (the common case): - /// v=void B=bool c=s8/BOOL s=s16 i=s32 q=s64 - /// C=u8 S=u16 I=u32 Q=u64 f=f32 d=f64 - /// @=id #=Class :=SEL *=C string ^v=void* / generic ptr - /// - /// Foreign-class pointers (`*UIView` etc.) encode as `@` (object - /// pointer). Other pointers fall to `^v` — the encoding is metadata, - /// not ABI, so being conservative here is safe. Pass-by-value - /// structs encode as `{Name=field0field1...}`; nested structs - /// recurse with cycle-break via `ObjcEncodingStack`. Tagged-union / - /// array / vector / function shapes BAIL loudly via diagnostics - /// rather than silently mis-encoding (per CLAUDE.md rejected- - /// patterns rule). - /// - /// Returns an allocator-owned slice; caller frees via `self.alloc`. - pub fn objcTypeEncodingFromSignature( - self: *Lowering, - return_ty: TypeId, - param_tys: []const TypeId, - span: ?ast.Span, - ) ![]const u8 { - var out = std.ArrayList(u8).empty; - errdefer out.deinit(self.alloc); - - var stack: ObjcEncodingStack = .{}; - try self.appendObjcEncoding(&out, return_ty, span, &stack); - try out.append(self.alloc, '@'); // self - try out.append(self.alloc, ':'); // _cmd - for (param_tys) |pty| { - try self.appendObjcEncoding(&out, pty, span, &stack); - } - - return try out.toOwnedSlice(self.alloc); - } - - /// Tracks struct TypeIds currently being emitted so a struct field of - /// `*Self` (or a transitive pointee that cycles back) emits the - /// abbreviated `{Name}` form instead of recursing forever. Bounded to - /// `cap` — well above any realistic Obj-C struct nesting depth. - const ObjcEncodingStack = struct { - const cap = 16; - items: [cap]TypeId = undefined, - len: u8 = 0, - - fn push(self: *ObjcEncodingStack, tid: TypeId) bool { - if (self.len >= cap) return false; - self.items[self.len] = tid; - self.len += 1; - return true; - } - - fn pop(self: *ObjcEncodingStack) void { - std.debug.assert(self.len > 0); - self.len -= 1; - } - - fn contains(self: *const ObjcEncodingStack, tid: TypeId) bool { - var i: usize = 0; - while (i < self.len) : (i += 1) { - if (self.items[i] == tid) return true; - } - return false; - } - }; - - fn appendObjcEncoding( - self: *Lowering, - out: *std.ArrayList(u8), - ty: TypeId, - span: ?ast.Span, - stack: *ObjcEncodingStack, - ) !void { - const info = self.module.types.get(ty); - switch (info) { - .void => try out.append(self.alloc, 'v'), - .bool => try out.append(self.alloc, 'B'), - .signed => |bits| { - const ch: u8 = switch (bits) { - 8 => 'c', - 16 => 's', - 32 => 'i', - 64 => 'q', - else => return self.bailObjcEncoding(span, "signed integer with non-standard bit width", bits), - }; - try out.append(self.alloc, ch); - }, - .unsigned => |bits| { - const ch: u8 = switch (bits) { - 8 => 'C', - 16 => 'S', - 32 => 'I', - 64 => 'Q', - else => return self.bailObjcEncoding(span, "unsigned integer with non-standard bit width", bits), - }; - try out.append(self.alloc, ch); - }, - .f32 => try out.append(self.alloc, 'f'), - .f64 => try out.append(self.alloc, 'd'), - // sx-target arm64 — pointer-sized aliases match s64/u64. - .isize => try out.append(self.alloc, 'q'), - .usize => try out.append(self.alloc, 'Q'), - .pointer => |p| { - // Pointer to a foreign Obj-C class (or sx-defined #objc_class) - // encodes as `@`. Anything else falls to `^v` — generic - // pointer; the runtime treats it as opaque. - const pointee_info = self.module.types.get(p.pointee); - const is_objc_obj = blk: { - if (pointee_info != .@"struct") break :blk false; - const name = self.module.types.getString(pointee_info.@"struct".name); - break :blk self.program_index.foreign_class_map.get(name) != null; - }; - if (is_objc_obj) { - try out.append(self.alloc, '@'); - } else { - try out.appendSlice(self.alloc, "^v"); - } - }, - .many_pointer => |mp| { - // `[*]u8` is the canonical C-string carrier — encode as `*`. - // Other element types fall to generic `^v`. - const el = self.module.types.get(mp.element); - if (el == .unsigned and el.unsigned == 8) { - try out.append(self.alloc, '*'); - } else { - try out.appendSlice(self.alloc, "^v"); - } - }, - .optional => |o| { - // sx's `?T` is a nullable T. At the Obj-C ABI boundary - // nullability is just "this pointer may be null" — the - // wire-level encoding is the same as T. Unwrap and - // recurse. (Same goes for `?*UIView` etc. — the - // underlying pointer kind drives the encoding char.) - return self.appendObjcEncoding(out, o.child, span, stack); - }, - .@"struct" => |s| { - // Pass-by-value struct argument or return: Apple's - // encoding is `{Name=field0field1...}`. A struct - // already on the encoding stack (i.e. transitively - // referenced through a struct field — extremely rare - // since sx structs don't recurse by value) gets the - // abbreviated `{Name}` form. Recursion through - // POINTERS is fine because `.pointer` collapses to - // `^v` regardless of pointee shape. - const name = self.module.types.getString(s.name); - try out.append(self.alloc, '{'); - try out.appendSlice(self.alloc, name); - if (stack.contains(ty)) { - try out.append(self.alloc, '}'); - return; - } - if (!stack.push(ty)) { - return self.bailObjcEncoding(span, "Obj-C struct encoding nested deeper than supported", ObjcEncodingStack.cap); - } - defer stack.pop(); - try out.append(self.alloc, '='); - for (s.fields) |f| { - try self.appendObjcEncoding(out, f.ty, span, stack); - } - try out.append(self.alloc, '}'); - }, - else => return self.bailObjcEncoding(span, "type kind not yet supported by Obj-C encoding", @intFromEnum(std.meta.activeTag(info))), - } - } - - fn bailObjcEncoding(self: *Lowering, span: ?ast.Span, reason: []const u8, detail: anytype) anyerror { - if (self.diagnostics) |d| { - d.addFmt(.err, span, "cannot derive Obj-C type encoding: {s} (detail={any})", .{ reason, detail }); - } - return error.ObjcEncodingUnsupported; - } + // 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 @@ -6275,76 +6062,6 @@ pub const Lowering = struct { return self.module.types.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } }); } - /// Build (and cache) the hidden sx-state struct type for an sx-defined - /// `#objc_class`. The state struct is what the runtime's `__sx_state` - /// ivar points at — separate from the Obj-C object itself, which stays - /// opaque. Layout (M1.2 A.2): - /// - /// __State { - /// user_field_0, - /// user_field_1, - /// ... - /// } - /// - /// M1.2 A.5 will prepend `__sx_allocator: Allocator` so `-dealloc` - /// can free through the per-instance allocator and method bodies can - /// access `self.allocator`. For A.2 the struct holds only the - /// user-declared fields — sufficient for the body lowering + - /// `self.field` access work in A.2/A.3. Field-by-name resolution - /// stays correct across the future repositioning. - /// - /// Foreign-class members other than `.field` are ignored here — - /// methods / `#extends` / `#implements` don't contribute to the - /// state layout. - pub fn objcDefinedStateStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId { - const state_name = std.fmt.allocPrint(self.alloc, "__{s}State", .{fcd.name}) catch unreachable; - defer self.alloc.free(state_name); // internString copies; the temp isn't needed after. - const name_id = self.module.types.internString(state_name); - if (self.module.types.findByName(name_id)) |existing| return existing; - - // The interned struct's `fields` slice lives for the module's lifetime; - // allocate it (and the building ArrayList) in the module arena so it's - // freed at module deinit rather than leaking through `self.alloc`. - const field_alloc = self.module.slice_arena.allocator(); - var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty; - // M4.0: prepend __sx_allocator at field index 0 — captured at +alloc - // time, read at -dealloc time to free the state struct through the - // same allocator. Lookup by name (the existing by-name resolution in - // emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer) - // naturally finds user fields at their post-shift indices. - if (self.objcStateAllocatorType()) |allocator_ty| { - fields.append(field_alloc, .{ - .name = self.module.types.internString("__sx_allocator"), - .ty = allocator_ty, - }) catch unreachable; - } - for (fcd.members) |m| { - switch (m) { - .field => |f| { - const f_name_id = self.module.types.internString(f.name); - const f_ty = self.resolveType(f.field_type); - fields.append(field_alloc, .{ .name = f_name_id, .ty = f_ty }) catch unreachable; - }, - else => {}, - } - } - return self.module.types.intern(.{ .@"struct" = .{ - .name = name_id, - .fields = fields.toOwnedSlice(field_alloc) catch unreachable, - } }); - } - - /// Return the `Allocator` protocol TypeId (the value-shape used in - /// Context.allocator). Falls back to null if Context isn't registered - /// yet (early-init paths); callers omit the field in that case. - fn objcStateAllocatorType(self: *Lowering) ?TypeId { - const ctx_name = self.module.types.internString("Context"); - const ctx_ty = self.module.types.findByName(ctx_name) orelse return null; - const ctx_info = self.module.types.get(ctx_ty); - if (ctx_info != .@"struct" or ctx_info.@"struct".fields.len < 1) return null; - return ctx_info.@"struct".fields[0].ty; - } - /// 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 @@ -6359,7 +6076,7 @@ pub const Lowering = struct { span: ast.Span, ) Ref { const arity = method_args.len; - const derived = self.deriveObjcSelector(method, arity); + 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 @@ -6422,7 +6139,7 @@ pub const Lowering = struct { span: ast.Span, ) Ref { const arity = method_args.len; - const derived = self.deriveObjcSelector(method, arity); + const derived = self.objc().deriveObjcSelector(method, arity); if (arity > 0 and derived.keyword_count != arity) { if (self.diagnostics) |d| { @@ -12851,7 +12568,7 @@ pub const Lowering = struct { // (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.deriveObjcSelector(method, user_arg_count); + 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; @@ -12861,7 +12578,7 @@ pub const Lowering = struct { arg_tys.append(self.alloc, self.resolveType(p_node)) catch unreachable; } } - const encoding = self.objcTypeEncodingFromSignature(ret_ty, arg_tys.items, null) catch continue; + 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; @@ -13482,6 +13199,10 @@ pub const Lowering = struct { return .{ .l = self }; } + pub fn objc(self: *Lowering) ObjcLowering { + return .{ .l = self }; + } + /// Lower the `xx` operator (type coercion). /// Uses self.target_type for context when available. Handles: /// - Any → concrete type: unbox_any @@ -15463,21 +15184,6 @@ pub const Lowering = struct { self.emitObjcDefinedClassImps(); } - /// True if `ty` is a pointer to a struct whose name is registered - /// in `foreign_class_map` under an Obj-C runtime. Used by the - /// `obj.class` accessor (M1.3) to decide whether to lower the - /// field access as a struct GEP or as `object_getClass(obj)`. - pub fn isObjcClassPointer(self: *Lowering, ty: TypeId) bool { - if (ty.isBuiltin()) return false; - const ptr_info = self.module.types.get(ty); - if (ptr_info != .pointer) return false; - const pointee_info = self.module.types.get(ptr_info.pointer.pointee); - if (pointee_info != .@"struct") return false; - const struct_name = self.module.types.getString(pointee_info.@"struct".name); - const fcd = self.program_index.foreign_class_map.get(struct_name) orelse return false; - return fcd.runtime == .objc_class or fcd.runtime == .objc_protocol; - } - /// If `obj_expr` is typed as a pointer to a foreign Obj-C class /// and that class (or any of its `#extends` ancestors) declares a /// `#property` field with the given name, return the @@ -15571,7 +15277,7 @@ pub const Lowering = struct { .field => |f| { if (std.mem.eql(u8, f.name, field_name)) { if (f.is_property) return null; - const state_ty = self.objcDefinedStateStructType(fcd); + const state_ty = self.objc().objcDefinedStateStructType(fcd); const state_info = self.module.types.get(state_ty); if (state_info != .@"struct") return null; const fname_id = self.module.types.internString(f.name); @@ -15749,6 +15455,20 @@ pub const Lowering = struct { } } + /// Lazily declare libobjc's ARC runtime helpers. Idempotent — uses + /// `ensureCRuntimeDecl` which skips already-declared symbols. Called + /// from the property setter/getter and -dealloc emission paths when + /// they need to emit a retain/release/storeWeak/etc. + fn ensureArcRuntimeDecls(self: *Lowering) void { + const ptr_void = self.module.types.ptrTo(.void); + _ = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); + _ = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); + _ = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); + _ = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); + _ = self.ensureCRuntimeDecl("objc_initWeak", &.{ ptr_void, ptr_void }, ptr_void); + _ = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); + } + /// M2.2 second pass — emit synthesized getter/setter IMPs for a /// property field on a sx-defined `#objc_class`. The state struct /// already holds the field (via objcDefinedStateStructType); the @@ -15766,143 +15486,8 @@ pub const Lowering = struct { /// Both IMPs land in the cache's methods slice with appropriate /// selectors + encodings; emit_llvm's class_addMethod loop wires /// them up like any other instance method. - /// M4.B — interpretation of `#property(...)` modifiers for ARC. - /// `assign` is the default for primitives (direct store, no ARC ops); - /// `strong` is the default for pointer-to-object types (retain on - /// assign, release on dealloc); `weak` and `copy` are explicit. The - /// helper rejects ambiguous combinations loudly per the silent-error - /// budget — `*void` requires explicit modifier, `weak` requires an - /// object-pointer slot. - const ObjcPropertyKind = enum { - assign, // primitives or explicitly opted-out object slots - strong, // default for * — retain on assign, release on dealloc - weak, // objc_storeWeak / objc_loadWeakRetained — auto-nilling - copy, // [val copy] on assign — for immutable-wanting String/Array slots - - pub fn isObject(k: ObjcPropertyKind) bool { - return k == .strong or k == .weak or k == .copy; - } - }; - - /// Resolve a `#property(...)` field's ARC kind. Loud at compile time - /// for known footguns (per the silent-error budget in the plan): - /// - unknown modifier name (typo) → diagnostic - /// - `weak` on a non-object field type → diagnostic - /// - `strong` (explicit or defaulted) on `*void` (ambiguous: Obj-C - /// object vs raw memory) → require explicit modifier - pub fn objcPropertyKind(self: *Lowering, field: ast.ForeignFieldDecl) ObjcPropertyKind { - // Survey the modifier list. - var has_strong = false; - var has_weak = false; - var has_copy = false; - var has_assign = false; - for (field.property_modifiers) |mod| { - if (std.mem.eql(u8, mod, "strong")) has_strong = true - else if (std.mem.eql(u8, mod, "weak")) has_weak = true - else if (std.mem.eql(u8, mod, "copy")) has_copy = true - else if (std.mem.eql(u8, mod, "assign")) has_assign = true - else if (std.mem.eql(u8, mod, "readonly")) { - // Orthogonal to ARC kind — no-op here. - } - else if (std.mem.eql(u8, mod, "nonatomic") or std.mem.eql(u8, mod, "atomic")) { - // Atomicity — recorded for the property attribute string; - // doesn't affect the ARC kind. - } - else if (std.mem.startsWith(u8, mod, "getter(") or std.mem.startsWith(u8, mod, "setter(")) { - // Selector overrides — handled elsewhere. - } - else { - if (self.diagnostics) |d| { - const span = ast.Span{ .start = 0, .end = 0 }; - d.addFmt(.err, span, "unknown #property modifier '{s}' on field '{s}' — expected one of: strong, weak, copy, assign, readonly, nonatomic, atomic, getter(\"...\"), setter(\"...\")", .{ mod, field.name }); - } - } - } - - // Mutually-exclusive ARC modifiers — at most one. - const explicit_count: u32 = - (@as(u32, if (has_strong) 1 else 0)) + - (@as(u32, if (has_weak) 1 else 0)) + - (@as(u32, if (has_copy) 1 else 0)) + - (@as(u32, if (has_assign) 1 else 0)); - if (explicit_count > 1) { - if (self.diagnostics) |d| { - const span = ast.Span{ .start = 0, .end = 0 }; - d.addFmt(.err, span, "conflicting #property modifiers on field '{s}' — strong/weak/copy/assign are mutually exclusive", .{field.name}); - } - } - - // Resolve the field's type to decide defaults + validate. - const field_ty = self.resolveType(field.field_type); - const is_pointer = !field_ty.isBuiltin() and self.module.types.get(field_ty) == .pointer; - const is_object_ptr = is_pointer and blk: { - const pointee = self.module.types.get(field_ty).pointer.pointee; - // `*void` is NOT considered an object pointer — ambiguous. - if (pointee == .void) break :blk false; - // `*T` where T is a foreign-class struct (Obj-C class). - if (pointee.isBuiltin()) break :blk false; - const pointee_info = self.module.types.get(pointee); - if (pointee_info != .@"struct") break :blk false; - const struct_name = self.module.types.getString(pointee_info.@"struct".name); - const fcd = self.program_index.foreign_class_map.get(struct_name) orelse break :blk false; - break :blk fcd.runtime == .objc_class or fcd.runtime == .objc_protocol; - }; - - // `weak` requires an object pointer — `weak s32` is meaningless and - // would invoke objc_storeWeak on a non-object slot. - if (has_weak and !is_object_ptr) { - if (self.diagnostics) |d| { - const span = ast.Span{ .start = 0, .end = 0 }; - d.addFmt(.err, span, "#property(weak) on field '{s}' requires a pointer-to-Obj-C-class type; got '{s}'", .{ field.name, self.module.types.typeName(field_ty) }); - } - } - - // `copy` requires an object pointer — `copy s32` makes no sense. - if (has_copy and !is_object_ptr) { - if (self.diagnostics) |d| { - const span = ast.Span{ .start = 0, .end = 0 }; - d.addFmt(.err, span, "#property(copy) on field '{s}' requires a pointer-to-Obj-C-class type (typically NSString or NSArray)", .{field.name}); - } - } - - // `*void` is ambiguous (Obj-C object vs raw memory): require explicit - // modifier so the user opts into ARC semantics consciously. - if (is_pointer) { - const pointee = self.module.types.get(field_ty).pointer.pointee; - if (pointee == .void and explicit_count == 0) { - if (self.diagnostics) |d| { - const span = ast.Span{ .start = 0, .end = 0 }; - d.addFmt(.err, span, "#property on field '{s}' of type '*void' is ambiguous — specify `#property(strong|weak|copy|assign)` explicitly (Obj-C object vs raw memory)", .{field.name}); - } - return .assign; // assume safe default to keep compilation going - } - } - - // Apply explicit modifier or default. - if (has_weak) return .weak; - if (has_copy) return .copy; - if (has_strong) return .strong; - if (has_assign) return .assign; - // Default: object pointers → strong; everything else → assign. - return if (is_object_ptr) .strong else .assign; - } - - /// Lazily declare libobjc's ARC runtime helpers. Idempotent — uses - /// `ensureCRuntimeDecl` which skips already-declared symbols. Called - /// from the property setter/getter and -dealloc emission paths when - /// they need to emit a retain/release/storeWeak/etc. - fn ensureArcRuntimeDecls(self: *Lowering) void { - const ptr_void = self.module.types.ptrTo(.void); - _ = self.ensureCRuntimeDecl("objc_retain", &.{ptr_void}, ptr_void); - _ = self.ensureCRuntimeDecl("objc_release", &.{ptr_void}, .void); - _ = self.ensureCRuntimeDecl("objc_storeWeak", &.{ ptr_void, ptr_void }, ptr_void); - _ = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); - _ = self.ensureCRuntimeDecl("objc_initWeak", &.{ ptr_void, ptr_void }, ptr_void); - _ = self.ensureCRuntimeDecl("objc_destroyWeak", &.{ptr_void}, .void); - } - fn emitObjcDefinedClassPropertyImps(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl) void { - const state_ty = self.objcDefinedStateStructType(fcd); + const state_ty = self.objc().objcDefinedStateStructType(fcd); const state_info = self.module.types.get(state_ty); if (state_info != .@"struct") return; // Find the field's index in the state struct. @@ -15921,7 +15506,7 @@ pub const Lowering = struct { // diagnostics for typos, weak-on-non-object, ambiguous *void, etc. // For now the setter/getter still emit bare load/store; subsequent // M4.B commits wire the actual ARC ops keyed on this kind. - _ = self.objcPropertyKind(field); + _ = self.objc().objcPropertyKind(field); // (1) Getter: ____imp self.emitObjcDefinedPropertyGetter(fcd, field, state_ty, fidx, field_ty); @@ -15992,7 +15577,7 @@ pub const Lowering = struct { // objc_autorelease for race-safe reads. The bare-load path // (strong/copy/assign) is the common case and reads the slot // directly. - const kind = self.objcPropertyKind(field); + const kind = self.objc().objcPropertyKind(field); if (kind == .weak) { self.ensureArcRuntimeDecls(); const load_weak_fid = self.ensureCRuntimeDecl("objc_loadWeakRetained", &.{ptr_void}, ptr_void); @@ -16078,7 +15663,7 @@ pub const Lowering = struct { const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void); // M4.B setter — emit ARC ops based on the property's modifier kind. - const kind = self.objcPropertyKind(field); + const kind = self.objc().objcPropertyKind(field); switch (kind) { .assign => { // Primitives or explicit assign: bare store, no ARC. @@ -16160,7 +15745,7 @@ pub const Lowering = struct { for (entry.methods) |m| new_methods.append(self.alloc, m) catch unreachable; // Getter entry — selector = field name, encoding = "@:". - const getter_enc = self.objcTypeEncodingFromSignature(field_ty, &.{}, null) catch return; + const getter_enc = self.objc().objcTypeEncodingFromSignature(field_ty, &.{}, null) catch return; const getter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return; new_methods.append(self.alloc, .{ .sel = field.name, @@ -16181,7 +15766,7 @@ pub const Lowering = struct { sel_buf.append(self.alloc, ':') catch unreachable; const setter_sel = self.alloc.dupe(u8, sel_buf.items) catch return; - const setter_enc = self.objcTypeEncodingFromSignature(.void, &.{field_ty}, null) catch return; + const setter_enc = self.objc().objcTypeEncodingFromSignature(.void, &.{field_ty}, null) catch return; var setter_imp_field_buf = std.ArrayList(u8).empty; defer setter_imp_field_buf.deinit(self.alloc); @@ -16412,7 +15997,7 @@ pub const Lowering = struct { const instance = self.builder.emit(.{ .call = .{ .callee = create_fid, .args = create_args } }, ptr_void); // STATE_SIZE = max(typeSizeBytes(__State), 1). - const state_struct_ty = self.objcDefinedStateStructType(fcd); + const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); const raw_size = self.module.types.typeSizeBytes(state_struct_ty); const state_size: u64 = if (raw_size == 0) 1 else @intCast(raw_size); const size_const = self.builder.constInt(@intCast(state_size), .u64); @@ -16639,7 +16224,7 @@ pub const Lowering = struct { // (which would invalidate the pointers we need to read). Property // metadata is re-derived from `fcd.members`; the state struct is // already interned via objcDefinedStateStructType. - const state_struct_ty = self.objcDefinedStateStructType(fcd); + const state_struct_ty = self.objc().objcDefinedStateStructType(fcd); const state_info_check = self.module.types.get(state_struct_ty); if (state_info_check == .@"struct") { const state_fields = state_info_check.@"struct".fields; @@ -16658,7 +16243,7 @@ pub const Lowering = struct { } const fidx = pfidx orelse continue; const field_ty = self.resolveType(f.field_type); - const kind = self.objcPropertyKind(f); + const kind = self.objc().objcPropertyKind(f); switch (kind) { .assign => {}, // no ARC ops From 0a4a240e314f36103fa0b342726161077cd931ac Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 08:14:46 +0300 Subject: [PATCH 4/5] test(ir): lock pure JNI decision helpers before A6.2 extraction (A6.2 scaffolding step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-first scaffolding for the JNI FFI domain (Phase A6.2) before the pure helpers move out of lower.zig. Visibility-only change — no behavior change. - 2 new lower.test.zig tests for the pure JNI helpers lacking unit coverage: - jniMangleNativeName: `/`->`_` separator, `_`->`_1` escape (path AND method), `Java_` prefix, `_sx_1` infix (2 cases lock all rules). - isJniReturnTypeSupported: void/bool/s32/s64/f32/f64 + pointer/many-pointer -> true; other widths (s8/s16/u8/u32/u64) + by-value struct -> false. - JNI descriptor derivation (writeType/deriveMethod) is already extracted into jni_descriptor.zig (15 tests) — not part of A6.2. - Widened jniMangleNativeName -> pub (file-scope free fn; isJniReturnTypeSupported already pub). Reached from the test via ir_mod.lower.*. No logic touched. - Recorded the A6.2 coverage inventory + residual emission-bound gaps (synthesizeJniMainStub*/lowerJniCall/lowerJniConstructor/lowerSuperCall/ getJniEnvTlFids stay in lower.zig; jniMapParamType is a trivial resolveType wrapper) in ARCH-SAFETY.md. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (no .ir churn; 9 JNI .ir snapshots green). --- src/ir/lower.test.zig | 49 +++++++++++++++++++++++++++++++++++++++++++ src/ir/lower.zig | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index f957eb8..263bb4f 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -765,6 +765,55 @@ test "lower: objcPropertyKind defaults + explicit ARC modifiers" { try std.testing.expect(lowering.objc().objcPropertyKind(.{ .name = "raw", .field_type = obj_ty, .is_property = true, .property_modifiers = &assign_mods }) == .assign); } +// ── A6.2 scaffolding: pure JNI decision helpers ───────────────────── +// Lock JNI native-name mangling and return-type support before they move +// out of lower.zig (to the JNI module) in A6.2 sub-step 2. + +const lower_mod = @import("lower.zig"); + +test "lower: jniMangleNativeName mangles package path + method (/ -> _, _ -> _1)" { + const alloc = std.testing.allocator; + + // Plain path + method: `/` separators collapse to `_`, `Java_` prefix, + // `_sx_1` infix before the (mangled) method name. + const m1 = try lower_mod.jniMangleNativeName(alloc, "com/sx/App", "tick"); + defer alloc.free(m1); + try std.testing.expectEqualStrings("Java_com_sx_App_sx_1tick", m1); + + // Underscores in BOTH the path and the method escape to `_1` (so the JNI + // resolver can round-trip them), distinct from the `/`->`_` separator. + const m2 = try lower_mod.jniMangleNativeName(alloc, "a_b/C", "do_it"); + defer alloc.free(m2); + try std.testing.expectEqualStrings("Java_a_1b_C_sx_1do_1it", m2); +} + +test "lower: isJniReturnTypeSupported accepts the dispatchable set + pointers only" { + const alloc = std.testing.allocator; + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + const t = &module.types; + + // The CallMethod-dispatchable primitives. + inline for (.{ TypeId.void, TypeId.bool, TypeId.s32, TypeId.s64, TypeId.f32, TypeId.f64 }) |ty| { + try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, ty)); + } + + // Other primitive widths are NOT dispatchable (would hit emit_llvm's + // undef-producing `else` arm — the footgun this predicate guards). + inline for (.{ TypeId.s8, TypeId.s16, TypeId.u8, TypeId.u32, TypeId.u64 }) |ty| { + try std.testing.expect(!lower_mod.isJniReturnTypeSupported(t, ty)); + } + + // Pointer / many-pointer returns route through CallObjectMethod → true. + try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, module.types.ptrTo(.void))); + try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, module.types.manyPtrTo(.u8))); + + // A pass-by-value struct return is unsupported. + const sname = module.types.internString("CGRectish"); + const sty = module.types.intern(.{ .@"struct" = .{ .name = sname, .fields = &.{} } }); + try std.testing.expect(!lower_mod.isJniReturnTypeSupported(t, sty)); +} + // ── Pack projection name resolution (Feature 1, Step 2.2) ──────────── const errors = @import("../errors.zig"); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 2573880..e8da498 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -16590,7 +16590,7 @@ pub fn isJniReturnTypeSupported(table: *const @import("types.zig").TypeTable, re /// `Java___sx_1`. JNI mangling: /// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side /// `private native sx_(...)` delegate. -fn jniMangleNativeName(allocator: std.mem.Allocator, foreign_path: []const u8, method_name: []const u8) ![]u8 { +pub fn jniMangleNativeName(allocator: std.mem.Allocator, foreign_path: []const u8, method_name: []const u8) ![]u8 { var buf = std.ArrayList(u8).empty; try buf.appendSlice(allocator, "Java_"); for (foreign_path) |ch| { From 20c767e33654ab623dae0e9180e3ed0202ff51b3 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 08:28:41 +0300 Subject: [PATCH 5/5] refactor(ir): move pure JNI helpers into jni_descriptor.zig (A6.2 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the two pure JNI decision helpers out of lower.zig into jni_descriptor.zig (already the JNI helper module), alongside the descriptor derivation. Behavior-preserving move — no facade, since neither takes *Lowering. - jniMangleNativeName(allocator, foreign_path, method_name) and isJniReturnTypeSupported(table, ret_ty) moved verbatim as pub free fns; added a types import + TypeId alias to jni_descriptor.zig. - Rerouted lower.zig's 2 call sites (synthesizeJniMainStub; the JNI return-type guard at lower.zig:6000) through jni_descriptor.* — lower.zig already imported the module. - Moved the 2 unit tests lower.test.zig -> jni_descriptor.test.zig (re-pointed to desc.*; a standalone TypeTable.init replaces the Module setup). Dropped the now-unused lower_mod alias. - Stayed in lower.zig per PLAN A6.2 step 5/6: jniMapParamType (trivial resolveType wrapper), synthesizeJniMainStub(s), lowerJniCall, lowerJniConstructor, lowerSuperCall, getJniEnvTlFids. Java rendering stays in jni_java_emit.zig. Phase A6 complete. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (9 JNI .ir snapshots + 26 14xx examples green, no churn). --- src/ir/jni_descriptor.test.zig | 47 ++++++++++++++++++++++++++++++++ src/ir/jni_descriptor.zig | 47 ++++++++++++++++++++++++++++++++ src/ir/lower.test.zig | 49 ---------------------------------- src/ir/lower.zig | 49 ++-------------------------------- 4 files changed, 96 insertions(+), 96 deletions(-) diff --git a/src/ir/jni_descriptor.test.zig b/src/ir/jni_descriptor.test.zig index 85b269e..13c8551 100644 --- a/src/ir/jni_descriptor.test.zig +++ b/src/ir/jni_descriptor.test.zig @@ -352,3 +352,50 @@ test "deriveMethod with slice param" { defer a.free(out); try std.testing.expectEqualStrings("([B)I", out); } + +// ── A6.2: native-name mangling + return-type dispatchability ───────── + +const types = @import("types.zig"); + +test "jniMangleNativeName mangles package path + method (/ -> _, _ -> _1)" { + const alloc = std.testing.allocator; + + // Plain path + method: `/` separators collapse to `_`, `Java_` prefix, + // `_sx_1` infix before the (mangled) method name. + const m1 = try desc.jniMangleNativeName(alloc, "com/sx/App", "tick"); + defer alloc.free(m1); + try std.testing.expectEqualStrings("Java_com_sx_App_sx_1tick", m1); + + // Underscores in BOTH the path and the method escape to `_1` (so the JNI + // resolver can round-trip them), distinct from the `/`->`_` separator. + const m2 = try desc.jniMangleNativeName(alloc, "a_b/C", "do_it"); + defer alloc.free(m2); + try std.testing.expectEqualStrings("Java_a_1b_C_sx_1do_1it", m2); +} + +test "isJniReturnTypeSupported accepts the dispatchable set + pointers only" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const t = &table; + + // The CallMethod-dispatchable primitives. + inline for (.{ types.TypeId.void, types.TypeId.bool, types.TypeId.s32, types.TypeId.s64, types.TypeId.f32, types.TypeId.f64 }) |ty| { + try std.testing.expect(desc.isJniReturnTypeSupported(t, ty)); + } + + // Other primitive widths are NOT dispatchable (would hit emit_llvm's + // undef-producing `else` arm — the footgun this predicate guards). + inline for (.{ types.TypeId.s8, types.TypeId.s16, types.TypeId.u8, types.TypeId.u32, types.TypeId.u64 }) |ty| { + try std.testing.expect(!desc.isJniReturnTypeSupported(t, ty)); + } + + // Pointer / many-pointer returns route through CallObjectMethod → true. + try std.testing.expect(desc.isJniReturnTypeSupported(t, table.ptrTo(.void))); + try std.testing.expect(desc.isJniReturnTypeSupported(t, table.manyPtrTo(.u8))); + + // A pass-by-value struct return is unsupported. + const sname = table.internString("CGRectish"); + const sty = table.intern(.{ .@"struct" = .{ .name = sname, .fields = &.{} } }); + try std.testing.expect(!desc.isJniReturnTypeSupported(t, sty)); +} diff --git a/src/ir/jni_descriptor.zig b/src/ir/jni_descriptor.zig index 2d51278..4dd2a80 100644 --- a/src/ir/jni_descriptor.zig +++ b/src/ir/jni_descriptor.zig @@ -24,8 +24,10 @@ const std = @import("std"); const ast = @import("../ast.zig"); +const types = @import("types.zig"); const Node = ast.Node; +const TypeId = types.TypeId; pub const DeriveError = error{ UnknownPrimitive, @@ -149,3 +151,48 @@ fn primitiveChar(name: []const u8) ?u8 { } return null; } + +/// Whether emit_llvm's `jni_msg_send` lowering can dispatch a CallMethod +/// for this return type. Anything outside this set falls into the `else` +/// arm of the switches in `emit_llvm.zig` and would silently produce +/// `LLVMGetUndef` — a footgun that previously shipped (chess Android touch +/// went undef because `MotionEvent.getX() -> f32` wasn't in the switch). +/// Pointer-typed returns route through `CallObjectMethod`. +pub fn isJniReturnTypeSupported(table: *const types.TypeTable, ret_ty: TypeId) bool { + return switch (ret_ty) { + .void, .bool, .s32, .s64, .f32, .f64 => true, + else => blk: { + if (ret_ty.isBuiltin()) break :blk false; + const info = table.get(ret_ty); + break :blk info == .pointer or info == .many_pointer; + }, + }; +} + +/// Encode a (foreign_path, method_name) pair as the JNI-resolved symbol +/// `Java___sx_1`. JNI mangling: +/// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side +/// `private native sx_(...)` delegate. +pub fn jniMangleNativeName(allocator: std.mem.Allocator, foreign_path: []const u8, method_name: []const u8) ![]u8 { + var buf = std.ArrayList(u8).empty; + try buf.appendSlice(allocator, "Java_"); + for (foreign_path) |ch| { + if (ch == '/') { + try buf.append(allocator, '_'); + } else if (ch == '_') { + try buf.appendSlice(allocator, "_1"); + } else { + try buf.append(allocator, ch); + } + } + try buf.append(allocator, '_'); + try buf.appendSlice(allocator, "sx_1"); + for (method_name) |ch| { + if (ch == '_') { + try buf.appendSlice(allocator, "_1"); + } else { + try buf.append(allocator, ch); + } + } + return buf.toOwnedSlice(allocator); +} diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 263bb4f..f957eb8 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -765,55 +765,6 @@ test "lower: objcPropertyKind defaults + explicit ARC modifiers" { try std.testing.expect(lowering.objc().objcPropertyKind(.{ .name = "raw", .field_type = obj_ty, .is_property = true, .property_modifiers = &assign_mods }) == .assign); } -// ── A6.2 scaffolding: pure JNI decision helpers ───────────────────── -// Lock JNI native-name mangling and return-type support before they move -// out of lower.zig (to the JNI module) in A6.2 sub-step 2. - -const lower_mod = @import("lower.zig"); - -test "lower: jniMangleNativeName mangles package path + method (/ -> _, _ -> _1)" { - const alloc = std.testing.allocator; - - // Plain path + method: `/` separators collapse to `_`, `Java_` prefix, - // `_sx_1` infix before the (mangled) method name. - const m1 = try lower_mod.jniMangleNativeName(alloc, "com/sx/App", "tick"); - defer alloc.free(m1); - try std.testing.expectEqualStrings("Java_com_sx_App_sx_1tick", m1); - - // Underscores in BOTH the path and the method escape to `_1` (so the JNI - // resolver can round-trip them), distinct from the `/`->`_` separator. - const m2 = try lower_mod.jniMangleNativeName(alloc, "a_b/C", "do_it"); - defer alloc.free(m2); - try std.testing.expectEqualStrings("Java_a_1b_C_sx_1do_1it", m2); -} - -test "lower: isJniReturnTypeSupported accepts the dispatchable set + pointers only" { - const alloc = std.testing.allocator; - var module = ir_mod.Module.init(alloc); - defer module.deinit(); - const t = &module.types; - - // The CallMethod-dispatchable primitives. - inline for (.{ TypeId.void, TypeId.bool, TypeId.s32, TypeId.s64, TypeId.f32, TypeId.f64 }) |ty| { - try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, ty)); - } - - // Other primitive widths are NOT dispatchable (would hit emit_llvm's - // undef-producing `else` arm — the footgun this predicate guards). - inline for (.{ TypeId.s8, TypeId.s16, TypeId.u8, TypeId.u32, TypeId.u64 }) |ty| { - try std.testing.expect(!lower_mod.isJniReturnTypeSupported(t, ty)); - } - - // Pointer / many-pointer returns route through CallObjectMethod → true. - try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, module.types.ptrTo(.void))); - try std.testing.expect(lower_mod.isJniReturnTypeSupported(t, module.types.manyPtrTo(.u8))); - - // A pass-by-value struct return is unsupported. - const sname = module.types.internString("CGRectish"); - const sty = module.types.intern(.{ .@"struct" = .{ .name = sname, .fields = &.{} } }); - try std.testing.expect(!lower_mod.isJniReturnTypeSupported(t, sty)); -} - // ── Pack projection name resolution (Feature 1, Step 2.2) ──────────── const errors = @import("../errors.zig"); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index e8da498..6a84203 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -5997,7 +5997,7 @@ pub const Lowering = struct { // 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 (!isJniReturnTypeSupported(&self.module.types, ret_ty)) { + 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) }); } @@ -16445,7 +16445,7 @@ pub const Lowering = struct { } fn synthesizeJniMainStub(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void { - const mangled = jniMangleNativeName(self.alloc, fcd.foreign_path, md.name) catch return; + 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); @@ -16568,48 +16568,3 @@ pub const Lowering = struct { fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId { return self.resolveType(type_node); } - -/// Whether emit_llvm's `jni_msg_send` lowering can dispatch a CallMethod -/// for this return type. Anything outside this set falls into the `else` -/// arm of the switches in `emit_llvm.zig` and would silently produce -/// `LLVMGetUndef` — a footgun that previously shipped (chess Android touch -/// went undef because `MotionEvent.getX() -> f32` wasn't in the switch). -/// Pointer-typed returns route through `CallObjectMethod`. -pub fn isJniReturnTypeSupported(table: *const @import("types.zig").TypeTable, ret_ty: TypeId) bool { - return switch (ret_ty) { - .void, .bool, .s32, .s64, .f32, .f64 => true, - else => blk: { - if (ret_ty.isBuiltin()) break :blk false; - const info = table.get(ret_ty); - break :blk info == .pointer or info == .many_pointer; - }, - }; -} - -/// Encode a (foreign_path, method_name) pair as the JNI-resolved symbol -/// `Java___sx_1`. JNI mangling: -/// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side -/// `private native sx_(...)` delegate. -pub fn jniMangleNativeName(allocator: std.mem.Allocator, foreign_path: []const u8, method_name: []const u8) ![]u8 { - var buf = std.ArrayList(u8).empty; - try buf.appendSlice(allocator, "Java_"); - for (foreign_path) |ch| { - if (ch == '/') { - try buf.append(allocator, '_'); - } else if (ch == '_') { - try buf.appendSlice(allocator, "_1"); - } else { - try buf.append(allocator, ch); - } - } - try buf.append(allocator, '_'); - try buf.appendSlice(allocator, "sx_1"); - for (method_name) |ch| { - if (ch == '_') { - try buf.appendSlice(allocator, "_1"); - } else { - try buf.append(allocator, ch); - } - } - return buf.toOwnedSlice(allocator); -}