ffi M1.2 A.2a: objcDefinedStateStructType helper

Builds (and interns) the hidden sx-state struct type for an
sx-defined '#objc_class'. Layout:

    __<ClassName>State {
        user_field_0,
        user_field_1,
        ...
    }

This struct is what the runtime's '__sx_state' ivar points at —
separate from the Obj-C object itself, which stays opaque. The
sx method bodies will operate on '*__SxFooState' (after '*Self'
substitution in A.2b) so 'self.field' resolves to a plain struct
field access — A.3's 'free if types align' premise.

M1.2 A.5 will prepend '__sx_allocator: Allocator' so dealloc can
free through the per-instance allocator. Field-by-name access
stays correct across the future repositioning.

Methods / '#extends' / '#implements' members are ignored — only
'.field' contributes. Three unit tests pin: typical-field case,
empty-class case, mixed-member case.

Dead code at this commit — helper isn't called yet. A.2b (body
lowering with '*Self' substitution) wires it in. 170 example
tests + zig build test green.
This commit is contained in:
agra
2026-05-25 21:51:07 +03:00
parent 6cc016cd4f
commit 7b98b3ae78
2 changed files with 142 additions and 0 deletions

View File

@@ -295,6 +295,105 @@ test "lower: objcTypeEncodingFromSignature emits pointer shapes" {
try std.testing.expectEqualStrings("v@:^v", e3);
}
// M1.2 A.2 — sx-defined #objc_class state struct construction.
test "lower: objcDefinedStateStructType collects user-declared fields" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
// Synthesize a #objc_class("SxFoo") { counter: s32; ticks: s64; } AST.
const span = ast.Span{ .start = 0, .end = 0 };
const counter_type = try alloc.create(Node);
defer alloc.destroy(counter_type);
counter_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s32", .is_generic = false } } };
const ticks_type = try alloc.create(Node);
defer alloc.destroy(ticks_type);
ticks_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s64", .is_generic = false } } };
const members = [_]ast.ForeignClassMember{
.{ .field = .{ .name = "counter", .field_type = counter_type } },
.{ .field = .{ .name = "ticks", .field_type = ticks_type } },
};
const fcd = ast.ForeignClassDecl{
.name = "SxFoo",
.foreign_path = "SxFoo",
.runtime = .objc_class,
.members = &members,
.is_foreign = false,
.is_main = false,
};
const state_ty = lowering.objcDefinedStateStructType(&fcd);
const info = module.types.get(state_ty);
try std.testing.expectEqual(@as(std.meta.Tag(@TypeOf(info)), .@"struct"), std.meta.activeTag(info));
const s = info.@"struct";
try std.testing.expectEqualStrings("__SxFooState", module.types.getString(s.name));
try std.testing.expectEqual(@as(usize, 2), s.fields.len);
try std.testing.expectEqualStrings("counter", module.types.getString(s.fields[0].name));
try std.testing.expectEqual(TypeId.s32, s.fields[0].ty);
try std.testing.expectEqualStrings("ticks", module.types.getString(s.fields[1].name));
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);
try std.testing.expectEqual(state_ty, state_ty2);
}
test "lower: objcDefinedStateStructType handles empty field set" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
const fcd = ast.ForeignClassDecl{
.name = "SxEmpty",
.foreign_path = "SxEmpty",
.runtime = .objc_class,
.members = &.{},
.is_foreign = false,
.is_main = false,
};
const state_ty = lowering.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);
}
test "lower: objcDefinedStateStructType skips non-field members" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var lowering = Lowering.init(&module);
// Mix in #extends and method members — only `.field` contributes.
const span = ast.Span{ .start = 0, .end = 0 };
const counter_type = try alloc.create(Node);
defer alloc.destroy(counter_type);
counter_type.* = .{ .span = span, .data = .{ .type_expr = .{ .name = "s32", .is_generic = false } } };
const members = [_]ast.ForeignClassMember{
.{ .extends = "NSObject" },
.{ .field = .{ .name = "counter", .field_type = counter_type } },
.{ .implements = "UIApplicationDelegate" },
};
const fcd = ast.ForeignClassDecl{
.name = "SxMixed",
.foreign_path = "SxMixed",
.runtime = .objc_class,
.members = &members,
.is_foreign = false,
.is_main = false,
};
const state_ty = lowering.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));
}
test "lower: objcTypeEncodingFromSignature emits @ for Obj-C class pointers" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);

View File

@@ -4672,6 +4672,49 @@ 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):
///
/// __<ClassName>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.
fn objcDefinedStateStructType(self: *Lowering, fcd: *const ast.ForeignClassDecl) TypeId {
const state_name = std.fmt.allocPrint(self.alloc, "__{s}State", .{fcd.name}) catch unreachable;
const name_id = self.module.types.internString(state_name);
if (self.module.types.findByName(name_id)) |existing| return existing;
var fields = std.ArrayList(types.TypeInfo.StructInfo.Field).empty;
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(self.alloc, .{ .name = f_name_id, .ty = f_ty }) catch unreachable;
},
else => {},
}
}
return self.module.types.intern(.{ .@"struct" = .{
.name = name_id,
.fields = fields.toOwnedSlice(self.alloc) catch unreachable,
} });
}
/// 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