feat: #set property accessors (write counterpart of #get)

A method `name :: (self: *T, value: V) #set { ... }` (or `=> expr;`) is the
write counterpart of a `#get` accessor: `obj.name = rhs` dispatches to it as
`obj.name(rhs)` when no real field matches. Plumbed parallel to `#get`:

- lexer/token `#set`; `FnDecl.is_set` + `Function.is_set`; parsed in the same
  marker slot as `#get` (no return type, exactly self + one value param).
- get+set coexistence: a setter registers/mangles/dispatches under an effective
  `name$set` name (`$` is illegal in sx identifiers, so unmistakable), keeping a
  same-name `#get` under the plain `name`. Resolution is declaration-order-
  independent: a plain read query picks the non-setter, a `name$set` write query
  picks the setter (accessorEffName / accessorNameMatches / structMethodFn).
- write dispatch in lowerAssignment via tryLowerPropertyAssignment: plain assign
  synthesizes `obj.name$set(rhs)`; compound `OP=` is get-modify-set and
  evaluates the receiver EXACTLY ONCE (bound to a synthetic local); read-only
  (#get-only) and write-only (#set-only + compound) emit clear diagnostics; a
  real field of the same name still wins. Multi-assign property targets dispatch
  the setter too (tryLowerPropertyStore, via a pre-lowered-Ref binding).

Payoff: List gains a `len` #set, so `xs.len = n` works; the `.items.len = N`
write workarounds in sched.sx + ui/* + platform/* revert to `xs.len = N`.

issues/0160 records an optional-chain interaction surfaced by the review (a
pre-existing `?T` value-optional read miscompile that blocks getter-through-`?.`).
This commit is contained in:
agra
2026-06-22 17:55:18 +03:00
parent 5cc45a2b38
commit 9523c29173
36 changed files with 526 additions and 19 deletions

View File

@@ -582,6 +582,166 @@ fn rootIsConstant(self: *Lowering, root: []const u8) bool {
};
}
/// Map a compound-assignment op to the binary op it folds with, for the
/// get-modify-set rewrite of `obj.prop OP= x` (a `#set` property).
fn compoundAssignToBinaryOp(op: ast.Assignment.Op) ast.BinaryOp.Op {
return switch (op) {
.add_assign => .add,
.sub_assign => .sub,
.mul_assign => .mul,
.div_assign => .div,
.mod_assign => .mod,
.and_assign => .bit_and,
.or_assign => .bit_or,
.xor_assign => .bit_xor,
.shl_assign => .shl,
.shr_assign => .shr,
.assign => unreachable, // plain assign never reaches the rewrite
};
}
/// Bind an already-lowered `Ref` (`val` of type `ty`) to a fresh, unspellable
/// (`$`-prefixed) local and return an identifier node that resolves to it. Lets
/// a synthesized accessor call reference a pre-computed receiver/value WITHOUT
/// re-lowering it — the basis for single-eval property writes. Null when there
/// is no scope to bind into.
fn bindSyntheticLocal(self: *Lowering, prefix: []const u8, val: Ref, ty: TypeId, span: ast.Span) ?*Node {
const s = self.scope orelse return null;
var namebuf: [48]u8 = undefined;
const tmp = std.fmt.bufPrint(&namebuf, "${s}_{d}", .{ prefix, self.block_counter }) catch prefix;
self.block_counter += 1;
const owned = self.alloc.dupe(u8, tmp) catch return null;
s.put(owned, .{ .ref = val, .ty = ty, .is_alloca = false });
const id = self.alloc.create(Node) catch return null;
id.* = .{ .span = span, .data = .{ .identifier = .{ .name = owned } } };
return id;
}
/// Synthesize and lower `recv_obj.<setter-effective-name>(value_node)` — the
/// shared tail of every `#set` dispatch.
fn emitSetterCall(self: *Lowering, recv_obj: *Node, setter: *const ast.FnDecl, value_node: *Node, span: ast.Span) void {
const callee = self.alloc.create(Node) catch return;
callee.* = .{ .span = span, .data = .{ .field_access = .{ .object = recv_obj, .field = self.accessorEffName(setter) } } };
const args = self.alloc.alloc(*Node, 1) catch return;
args[0] = value_node;
const syn_call = ast.Call{ .callee = callee, .args = args };
_ = self.lowerCall(&syn_call);
}
/// `<fa> = <already-lowered val>` where `prop` is a `#set` property and the RHS
/// `Ref` was computed by the caller (the multi-assign path evaluates ALL RHS
/// values up front, so re-lowering would double-evaluate and break ordering).
/// Binds `val` to a synthetic local and dispatches the setter through it.
/// Returns true when it consumed the store (setter write, or a read-only
/// diagnostic for a `#get`-only property); false for an ordinary field.
fn tryLowerPropertyStore(self: *Lowering, fa: ast.FieldAccess, val: Ref, span: ast.Span) bool {
var recv_ty = self.inferExprType(fa.object);
if (!recv_ty.isBuiltin()) {
const di = self.module.types.get(recv_ty);
if (di == .pointer) recv_ty = di.pointer.pointee;
}
if (recv_ty.isBuiltin()) return false;
const setter = self.getSetterFor(recv_ty, fa.field) orelse {
if (self.getAccessorFor(recv_ty, fa.field) != null) {
if (self.diagnostics) |d|
d.addFmt(.err, span, "property '{s}' is read-only (no '#set')", .{fa.field});
return true;
}
return false;
};
var recv_obj: *Node = fa.object;
if (fa.object.data == .deref_expr) recv_obj = fa.object.data.deref_expr.operand;
const val_id = bindSyntheticLocal(self, "prop_val", val, self.builder.getRefType(val), span) orelse return false;
emitSetterCall(self, recv_obj, setter, val_id, span);
return true;
}
/// `obj.prop = rhs` (or `obj.prop OP= rhs`) where `prop` is a `#set` property
/// accessor. Dispatches to the setter as `obj.prop$set(rhs)` — the write
/// counterpart of the `#get` read dispatch in `lowerFieldAccess`. Returns true
/// when it consumed the assignment (a real setter write, or a clean
/// read-only/write-only diagnostic); false to let normal field-store lowering
/// proceed (an ordinary field, or no property at all).
///
/// Must run BEFORE `lowerAssignment` lowers the RHS: a plain-assign setter call
/// lowers `rhs` itself (once, with the setter's value-param type as target), so
/// pre-lowering it here would double-evaluate.
fn tryLowerPropertyAssignment(self: *Lowering, asgn: *const ast.Assignment) bool {
const fa = asgn.target.data.field_access;
// Dereference the receiver type down to the struct that owns the accessor.
var recv_ty = self.inferExprType(fa.object);
if (!recv_ty.isBuiltin()) {
const di = self.module.types.get(recv_ty);
if (di == .pointer) recv_ty = di.pointer.pointee;
}
if (recv_ty.isBuiltin()) return false;
const setter = self.getSetterFor(recv_ty, fa.field);
const getter = self.getAccessorFor(recv_ty, fa.field);
if (setter == null) {
// No setter. A same-name `#get` (with no real field — getAccessorFor
// guarantees a real field wins) means the property is read-only: reject
// the write with a clear message rather than "field not found".
if (getter != null) {
if (self.diagnostics) |d|
d.addFmt(.err, asgn.target.span, "property '{s}' is read-only (no '#set')", .{fa.field});
return true;
}
return false; // ordinary field, or not a property → normal store path
}
// The receiver node the synthesized get/set dispatch on. An explicit-deref
// receiver `(*p).prop` dispatches on the inner pointer `p` (auto-deref takes
// the working path).
var recv_obj: *Node = fa.object;
if (fa.object.data == .deref_expr) recv_obj = fa.object.data.deref_expr.operand;
// For a compound `OP=`, the receiver is read (via `#get`) AND written (via
// `#set`), so it must be evaluated EXACTLY ONCE — otherwise a side-effecting
// receiver (`next().prop += 1`) reads one object and writes another. Bind
// the receiver's `*T` to a synthetic, unspellable local and dispatch both
// the read and the write on it. (A plain assign's single setter call already
// evaluates the receiver once, so it keeps using the original node.)
if (asgn.op != .assign) {
if (getter == null) {
if (self.diagnostics) |d|
d.addFmt(.err, asgn.target.span, "property '{s}' is write-only (no '#get'); compound assignment needs to read the current value", .{fa.field});
return true;
}
// Evaluate the receiver once into a synthetic `*T` binding. `*T` receiver
// → the pointer value itself; a `T` lvalue → its address (so the setter
// mutates the original, not a copy). Guarded on a scope being present;
// without one (e.g. a top-level init) fall back to the original node —
// the receiver re-lowers, but functionality is preserved.
if (self.scope != null) {
var ptr_ty = self.inferExprType(recv_obj);
const is_ptr = !ptr_ty.isBuiltin() and self.module.types.get(ptr_ty) == .pointer;
const recv_ptr = if (is_ptr) self.lowerExpr(recv_obj) else self.lowerExprAsPtr(recv_obj);
if (!is_ptr) ptr_ty = self.module.types.ptrTo(ptr_ty);
if (bindSyntheticLocal(self, "prop_recv", recv_ptr, ptr_ty, asgn.target.span)) |id| recv_obj = id;
}
}
// The value the setter receives. For a compound `OP=`: `(recv.prop) OP rhs`
// — the read dispatches to the `#get` on the (now single-eval) receiver.
var value_node: *Node = asgn.value;
if (asgn.op != .assign) {
const read_node = self.alloc.create(Node) catch return false;
read_node.* = .{ .span = asgn.target.span, .data = .{ .field_access = .{ .object = recv_obj, .field = fa.field } } };
const bin_node = self.alloc.create(Node) catch return false;
bin_node.* = .{ .span = asgn.value.span, .data = .{ .binary_op = .{
.op = compoundAssignToBinaryOp(asgn.op),
.lhs = read_node,
.rhs = asgn.value,
} } };
value_node = bin_node;
}
emitSetterCall(self, recv_obj, setter.?, value_node, asgn.target.span);
return true;
}
pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
// Writes through a constant are rejected at compile time (issue 0116):
// the target chain's root naming a const global (array/struct consts,
@@ -596,6 +756,13 @@ pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
return;
}
}
// `#set` property accessor: `obj.prop = rhs` (or `OP=`) dispatches to the
// setter as `obj.prop$set(rhs)`. Must run before the RHS is lowered below
// (the synthesized call lowers it itself). Falls through for ordinary fields.
if (asgn.target.data == .field_access) {
if (tryLowerPropertyAssignment(self, asgn)) return;
}
// Set target_type from LHS for RHS lowering (enum literals, struct literals, etc.)
const old_target = self.target_type;
if (asgn.target.data == .identifier) {
@@ -1445,6 +1612,10 @@ pub fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
}
},
.field_access => |fa| {
// `#set` property target: dispatch to the setter with the
// already-lowered RHS value (multi-assign evaluated all RHS up
// front). Falls through for an ordinary field.
if (tryLowerPropertyStore(self, fa, val, target.span)) continue;
const obj_ptr = self.lowerExprAsPtr(fa.object);
const obj_ty = self.inferExprType(fa.object);
// Reject a direct write to a tagged-union variant (issue 0136).