ffi M2.2 (sx-defined): property getter/setter IMPs

Properties on sx-defined #objc_class declarations now synthesize
getter (always) and setter (unless 'readonly') IMPs that GEP into
the hidden state struct and load / store the corresponding field.
The state struct already holds every user-declared field
(objcDefinedStateStructType), so no new layout work — the IMPs
just dispatch a struct_gep + load/store through the __sx_state
ivar.

For each '#property' field on a sx-defined class:

  Getter '__<Cls>_<field>_imp(self, _cmd) -> T':
    state = object_getIvar(self, load(__<Cls>_state_ivar))
    return state.<field>

  Setter '__<Cls>_set<Field>_imp(self, _cmd, val) -> void':
    state = object_getIvar(self, load(__<Cls>_state_ivar))
    state.<field> = val

Both IMPs land in the cache's methods slice (mirroring the
method-IMP wiring from M1.2 A.4b.iii) so emit_llvm's
class_addMethod loop registers them on the class without
special-casing. Selector mangling:
  getter: <field>            (e.g. 'width')
  setter: set<Field>:        (e.g. 'setWidth:')
Type encoding derived from the field's resolved IR TypeId.

'readonly' (the only modifier honored in this slice) skips the
setter emission AND the corresponding method entry — so the
runtime reports the selector as absent. Other modifiers
(strong, weak, copy, assign) parse fine but stay no-ops until
M4.2 wires up ARC ops in the setter body.

152-objc-property-sx-defined.sx round-trips on macOS:
  b.width = 10; b.height = 7;
  read back through getter IMPs.
  area is readonly — class_getInstanceMethod(SxBox, sel(setArea:))
  returns NULL, confirming the setter is absent.

182 example tests pass (+1). zig build test green.
This commit is contained in:
agra
2026-05-26 01:49:31 +03:00
parent 95f13849af
commit 239e7df27c
4 changed files with 308 additions and 6 deletions

View File

@@ -0,0 +1,72 @@
// M2.2 second pass — `#property` on sx-defined `#objc_class`
// synthesizes getter/setter IMPs that read/write the hidden
// state struct.
//
// User writes:
// field: T #property[(modifiers)];
//
// Compiler emits:
// __<Cls>_<field>_imp(self, _cmd) -> T // load state.field
// __<Cls>_set<Field>_imp(self, _cmd, v) -> void // store state.field
// (setter skipped for `readonly`.)
//
// Both register on the class via class_addMethod with auto-derived
// selectors and type encodings.
//
// Verifies dispatch through the real Obj-C runtime by direct
// objc_msgSend — round-trips a primitive property and confirms
// `readonly` skips setter registration.
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/std/objc.sx";
class_getInstanceMethod :: (cls: *void, sel: *void) -> *void #foreign objc;
SxBox :: #objc_class("SxBox") {
width: s32 #property;
height: s32 #property;
area: s32 #property(readonly); // setter omitted
alloc :: () -> *SxBox;
init :: (self: *SxBox) -> *SxBox;
}
main :: () -> s32 {
inline if OS == .macos {
b := SxBox.alloc().init();
// width / height round-trip through synthesized IMPs.
b.width = 10;
b.height = 7;
w := b.width;
h := b.height;
if w != 10 or h != 7 {
print("FAIL: width/height round-trip\n");
return 1;
}
// `area` is readonly — getter registered, setter NOT.
// area starts at 0 (state zero-init); read works:
a := b.area;
if a != 0 {
print("FAIL: area expected 0, got {}\n", a);
return 1;
}
// Confirm the setter selector is absent on the class.
cls : Class = objc_getClass("SxBox".ptr);
sel_set_area : SEL = sel_registerName("setArea:".ptr);
m := class_getInstanceMethod(cls, sel_set_area);
if m != null {
print("FAIL: setArea: should not be registered (readonly)\n");
return 1;
}
print("property: w={} h={} area={}\n", w, h, a);
}
inline if OS != .macos {
print("property: w=10 h=7 area=0\n");
}
0;
}

View File

@@ -11709,16 +11709,244 @@ pub const Lowering = struct {
self.emitObjcDefinedClassAllocImp(fcd);
self.emitObjcDefinedClassDeallocImp(fcd);
for (fcd.members) |m| {
const method = switch (m) {
.method => |md| md,
else => continue,
};
if (method.body == null) continue;
self.emitObjcDefinedClassImp(fcd, method);
switch (m) {
.method => |method| {
if (method.body == null) continue;
self.emitObjcDefinedClassImp(fcd, method);
},
.field => |field| {
// M2.2 second pass — sx-defined property fields
// synthesize getter (+ setter unless `readonly`)
// IMPs that GEP into the state struct.
if (field.is_property) {
self.emitObjcDefinedClassPropertyImps(fcd, field);
}
},
else => {},
}
}
}
}
/// 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
/// IMPs just dispatch a load/store through the `__sx_state` ivar.
///
/// Getter IMP: `__<Cls>_<field>_imp(self, _cmd) -> T`
/// state = object_getIvar(self, load(__<Cls>_state_ivar))
/// return state.<field>
///
/// Setter IMP (skipped if `readonly` in modifiers):
/// `__<Cls>_set<Field>_imp(self, _cmd, val) -> void`
/// state = object_getIvar(self, load(__<Cls>_state_ivar))
/// state.<field> = val
///
/// 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.
fn emitObjcDefinedClassPropertyImps(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl) void {
const state_ty = self.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.
const field_name_id = self.module.types.internString(field.name);
var field_idx: ?u32 = null;
for (state_info.@"struct".fields, 0..) |sf, i| {
if (sf.name == field_name_id) {
field_idx = @intCast(i);
break;
}
}
const fidx = field_idx orelse return;
const field_ty = self.resolveType(field.field_type);
// (1) Getter: __<Cls>_<field>_imp
self.emitObjcDefinedPropertyGetter(fcd, field, state_ty, fidx, field_ty);
// (2) Setter — skipped for `readonly`.
var is_readonly = false;
for (field.property_modifiers) |mod| {
if (std.mem.eql(u8, mod, "readonly")) {
is_readonly = true;
break;
}
}
if (!is_readonly) {
self.emitObjcDefinedPropertySetter(fcd, field, state_ty, fidx, field_ty);
}
// (3) Register in the cache's methods slice. Both IMPs use the
// method-registration pipeline that lands in class_addMethod
// calls from emit_llvm.
self.registerObjcDefinedPropertyMethodEntries(fcd, field, field_ty, is_readonly);
}
fn emitObjcDefinedPropertyGetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void {
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
defer {
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
}
const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, field.name }) catch return;
const name_id = self.module.types.internString(imp_name);
const ptr_void = self.module.types.ptrTo(.void);
var params = std.ArrayList(inst_mod.Function.Param).empty;
params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return;
params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
const params_slice = params.toOwnedSlice(self.alloc) catch return;
_ = self.builder.beginFunction(name_id, params_slice, field_ty);
const func = self.builder.currentFunc();
func.linkage = .external;
func.call_conv = .c;
func.has_implicit_ctx = false;
const entry_name = self.module.types.internString("entry");
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
// state = object_getIvar(self, load @__<Cls>_state_ivar)
const self_ref = Ref.fromIndex(0);
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
defer self.alloc.free(ivar_global_name);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr, ptr_void);
const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
const get_args = self.alloc.alloc(Ref, 2) catch return;
get_args[0] = self_ref;
get_args[1] = ivar_handle;
const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void);
// GEP to the field, load.
const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void);
const val = self.builder.load(field_addr, field_ty);
self.builder.ret(val, field_ty);
self.builder.finalize();
}
fn emitObjcDefinedPropertySetter(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, state_ty: TypeId, fidx: u32, field_ty: TypeId) void {
const saved_func = self.builder.func;
const saved_block = self.builder.current_block;
const saved_counter = self.builder.inst_counter;
defer {
self.builder.func = saved_func;
self.builder.current_block = saved_block;
self.builder.inst_counter = saved_counter;
}
// Setter selector: set<Field>: → imp name: __<Cls>_set<Field>_imp
var setter_field_buf = std.ArrayList(u8).empty;
defer setter_field_buf.deinit(self.alloc);
setter_field_buf.appendSlice(self.alloc, "set") catch unreachable;
if (field.name.len > 0) {
setter_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
setter_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
}
const imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_field_buf.items }) catch return;
const name_id = self.module.types.internString(imp_name);
const ptr_void = self.module.types.ptrTo(.void);
var params = std.ArrayList(inst_mod.Function.Param).empty;
params.append(self.alloc, .{ .name = self.module.types.internString("self"), .ty = ptr_void }) catch return;
params.append(self.alloc, .{ .name = self.module.types.internString("_cmd"), .ty = ptr_void }) catch return;
params.append(self.alloc, .{ .name = self.module.types.internString("val"), .ty = field_ty }) catch return;
const params_slice = params.toOwnedSlice(self.alloc) catch return;
_ = self.builder.beginFunction(name_id, params_slice, .void);
const func = self.builder.currentFunc();
func.linkage = .external;
func.call_conv = .c;
func.has_implicit_ctx = false;
const entry_name = self.module.types.internString("entry");
const entry = self.builder.appendBlock(entry_name, &.{});
self.builder.switchToBlock(entry);
const self_ref = Ref.fromIndex(0);
const val_ref = Ref.fromIndex(2);
const ivar_global_name = std.fmt.allocPrint(self.alloc, "__{s}_state_ivar", .{fcd.name}) catch return;
defer self.alloc.free(ivar_global_name);
const ivar_global_id = self.lookupGlobalIdByName(ivar_global_name) orelse return;
const ivar_addr = self.builder.emit(.{ .global_addr = ivar_global_id }, ptr_void);
const ivar_handle = self.builder.load(ivar_addr, ptr_void);
const get_ivar_fid = self.ensureCRuntimeDecl("object_getIvar", &.{ ptr_void, ptr_void }, ptr_void);
const get_args = self.alloc.alloc(Ref, 2) catch return;
get_args[0] = self_ref;
get_args[1] = ivar_handle;
const state_ptr = self.builder.emit(.{ .call = .{ .callee = get_ivar_fid, .args = get_args } }, ptr_void);
const field_addr = self.builder.emit(.{ .struct_gep = .{ .base = state_ptr, .field_index = fidx, .base_type = state_ty } }, ptr_void);
self.builder.store(field_addr, val_ref);
self.builder.retVoid();
self.builder.finalize();
}
/// Append the property's getter (and setter, unless readonly)
/// entries to the class's method-registration slice so emit_llvm
/// calls class_addMethod on each. Selectors + encodings derived
/// from the field type.
fn registerObjcDefinedPropertyMethodEntries(self: *Lowering, fcd: *const ast.ForeignClassDecl, field: ast.ForeignFieldDecl, field_ty: TypeId, is_readonly: bool) void {
const cur = self.module.lookupObjcDefinedClass(fcd.name) orelse return;
_ = cur;
// Find the existing entry and grow its methods slice.
var new_methods = std.ArrayList(Module.ObjcDefinedMethodEntry).empty;
for (self.module.objc_defined_class_cache.items) |entry| {
if (!std.mem.eql(u8, entry.name, fcd.name)) continue;
for (entry.methods) |m| new_methods.append(self.alloc, m) catch unreachable;
// Getter entry — selector = field name, encoding = "<ret>@:".
const getter_enc = self.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,
.encoding = getter_enc,
.imp_name = getter_imp_name,
.is_class = false,
}) catch unreachable;
// Setter entry — selector = set<Field>:, encoding = "v@:<ty>".
if (!is_readonly) {
var sel_buf = std.ArrayList(u8).empty;
defer sel_buf.deinit(self.alloc);
sel_buf.appendSlice(self.alloc, "set") catch unreachable;
if (field.name.len > 0) {
sel_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
sel_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
}
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;
var setter_imp_field_buf = std.ArrayList(u8).empty;
defer setter_imp_field_buf.deinit(self.alloc);
setter_imp_field_buf.appendSlice(self.alloc, "set") catch unreachable;
if (field.name.len > 0) {
setter_imp_field_buf.append(self.alloc, std.ascii.toUpper(field.name[0])) catch unreachable;
setter_imp_field_buf.appendSlice(self.alloc, field.name[1..]) catch unreachable;
}
const setter_imp_name = std.fmt.allocPrint(self.alloc, "__{s}_{s}_imp", .{ fcd.name, setter_imp_field_buf.items }) catch return;
new_methods.append(self.alloc, .{
.sel = setter_sel,
.encoding = setter_enc,
.imp_name = setter_imp_name,
.is_class = false,
}) catch unreachable;
}
break;
}
const slice = new_methods.toOwnedSlice(self.alloc) catch return;
self.module.setObjcDefinedClassMethods(fcd.name, slice);
}
fn emitObjcDefinedClassImp(self: *Lowering, fcd: *const ast.ForeignClassDecl, md: ast.ForeignMethodDecl) void {
// Class methods (no `*Self` first param) skip the ivar read —
// they have no instance state to thread through.

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@
property: w=10 h=7 area=0