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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user