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

@@ -0,0 +1,17 @@
// Writing to a `#get`-only property (no matching `#set`) is rejected with a
// clear "read-only" diagnostic — not the generic "field not found" the bare
// struct-store path would emit. (The write counterpart, a `#set`-only
// property, accepts plain assignment but rejects compound `+=` because there is
// no `#get` to read the current value.)
#import "modules/std.sx";
Reading :: struct {
raw: i64 = 0;
doubled :: (self: *Reading) -> i64 #get => self.raw * 2;
}
main :: () -> i64 {
r : Reading = .{ raw = 5 };
r.doubled = 10; // ERROR: property 'doubled' is read-only (no '#set')
return 0;
}

View File

@@ -0,0 +1,5 @@
error: property 'doubled' is read-only (no '#set')
--> examples/diagnostics/1193-diagnostics-readonly-property-write.sx:15:5
|
15 | r.doubled = 10; // ERROR: property 'doubled' is read-only (no '#set')
| ^^^^^^^^^

View File

@@ -2,7 +2,7 @@
// live element count, so a List is directly iterable with a `for`-each, and
// `xs.len` reads the live count via a `#get` accessor. Exercises append (incl.
// a realloc past the initial cap of 4), for-each, parallel for-with-index,
// empty iteration, direct `for xs` over the List, and truncation via items.len.
// empty iteration, direct `for xs` over the List, and truncation via `xs.len = 0`.
#import "modules/std.sx";
main :: () -> i64 {
@@ -33,8 +33,8 @@ main :: () -> i64 {
while j < xs.len { acc = acc + xs.items[j]; j = j + 1; }
print("indexed sum={}\n", acc); // 210
// truncate to empty via items.len, then iterate (zero iterations)
xs.items.len = 0;
// truncate to empty via the `len` #set accessor, then iterate (zero iters)
xs.len = 0;
cnt := 0;
for xs.items (e) { cnt = cnt + 1; }
print("after trunc: len={} iters={}\n", xs.len, cnt); // len=0 iters=0

View File

@@ -0,0 +1,21 @@
// `List(T).len` is a `#get`/`#set` property pair: `xs.len` reads the live
// element count (delegating to `items.len`), and `xs.len = n` sets it (e.g.
// `xs.len = 0` to clear the list without freeing its buffer — `cap` and the
// backing allocation are untouched, so appends reuse the same storage).
#import "modules/std.sx";
main :: () -> i64 {
xs : List(i64) = .{};
xs.append(10);
xs.append(20);
xs.append(30);
print("len={} cap={}\n", xs.len, xs.cap); // len=3 cap=4
xs.len = 0; // clear via the #set property
print("after clear: len={} cap={}\n", xs.len, xs.cap); // len=0 cap=4
// The buffer survived the clear — re-append reuses it (cap stays 4).
xs.append(99);
print("reused: len={} cap={} first={}\n", xs.len, xs.cap, xs.items[0]); // 1 4 99
return 0;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
len=3 cap=4
after clear: len=0 cap=4
reused: len=1 cap=4 first=99

View File

@@ -0,0 +1,63 @@
// `#set` property accessors — the write counterpart of `#get`. A method
// `name :: (self: *T, value: V) #set { ... }` is invoked via field-assign
// syntax (`obj.name = rhs`) rather than `obj.name(rhs)`. A property may carry
// BOTH a `#get` and a `#set` of the same name (reads pick the getter, writes
// pick the setter); compound assignment (`+=`) reads via `#get` then writes via
// `#set`. Works on plain structs and generic-struct instances, as a multi-assign
// target, and the compound form evaluates the receiver exactly once.
#import "modules/std.sx";
// get + set pair on the same name, with a scaling setter so we can see which
// path fired.
Temp :: struct {
raw: i64 = 0;
celsius :: (self: *Temp) -> i64 #get => self.raw;
celsius :: (self: *Temp, v: i64) #set { self.raw = v; }
}
// Generic instance: setter takes the type parameter as its value type.
Box :: struct ($T: Type) {
slot: T;
val :: (self: *Box(T)) -> T #get => self.slot;
val :: (self: *Box(T), v: T) #set { self.slot = v; }
}
main :: () -> i64 {
t : Temp = .{};
t.celsius = 30; // setter
print("celsius={}\n", t.celsius); // 30 (getter)
t.celsius += 5; // get-modify-set
print("after +=5: {}\n", t.celsius); // 35
t.celsius *= 2; // get-modify-set
print("after *=2: {}\n", t.celsius); // 70
b : Box(i64) = .{ slot = 1 };
b.val = 99; // setter (value type is T = i64)
print("box={}\n", b.val); // 99
b.val -= 9;
print("box={}\n", b.val); // 90
// Multi-assign with property targets — a swap proves all RHS values are
// evaluated before any setter fires.
p : Temp = .{ raw = 1 };
q : Temp = .{ raw = 2 };
p.celsius, q.celsius = q.celsius, p.celsius;
print("swap: p={} q={}\n", p.celsius, q.celsius); // 2 1
// Compound assign through a property evaluates the receiver EXACTLY ONCE:
// a moving receiver reads and writes the SAME element (not two different ones).
g_idx = 0;
cells[0] = .{ raw = 10 };
cells[1] = .{ raw = 20 };
next_cell().celsius += 1; // reads & writes cells[0]
print("once: c0={} c1={} idx={}\n", cells[0].celsius, cells[1].celsius, g_idx); // 11 20 1
return 0;
}
g_idx : i64 = 0;
cells : [2]Temp = .[ .{}, .{} ];
next_cell :: () -> *Temp {
cur := g_idx;
g_idx = g_idx + 1;
return @cells[cur];
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,7 @@
celsius=30
after +=5: 35
after *=2: 70
box=99
box=90
swap: p=2 q=1
once: c0=11 c1=20 idx=1

View File

@@ -0,0 +1,92 @@
# 0160 — optional-chain field access: `?T` value-optional read miscompiles, and `#get`/`#set` accessors aren't reached through `?.`
## Symptom
Two related gaps in optional-chain field access (`obj?.field`), surfaced while
extending property accessors (`#get`/`#set`) — but the root problem (A) is
PRE-EXISTING and independent of accessors:
- **(A) `?T` value-optional read of a real field miscompiles.** `ot?.raw` where
`ot : ?T` (optional of a *value* struct) fails LLVM verification:
`Invalid InsertValueInst operands! ... insertvalue { { i64 }, i1 } undef, { { i64 }, i1 } %si, 0`
— the some-branch builds the result optional by inserting the WHOLE
`{payload, has_value}` aggregate where the bare payload is expected.
Observed: LLVM verification failure (compile abort). Expected: prints `7`.
*(The `?*T` pointer-optional form of the same read works correctly, so the
bug is specific to value-optionals.)*
- **(B) `#get`/`#set` accessors are not reached through `?.`.** `pt?.p` where
`p` is a `#get` accessor gives `field 'p' not found on type '*T'` — the
optional-chain read path (`lowerOptionalChain`) resolves only real fields, not
accessors. (The write form `obj?.p = x` is consistent with real fields, which
also reject optional-chain assignment, so the write side is NOT part of this
issue.)
(B) is blocked on (A): a correct `obj?.getter` read must run the getter inside
the optional's some-branch and re-wrap the result as `?R`, i.e. it reuses the
exact some-branch/merge optional-construction path that (A) miscompiles for
value-optionals. Layering accessor dispatch onto that path while it miscompiles
would bake the same bug into accessors.
## Reproduction
(A) — value-optional real-field read (LLVM verify failure):
```sx
#import "modules/std.sx";
T :: struct { raw: i64 = 7; }
main :: () {
ot : ?T = .{ raw = 7 };
print("{}\n", ot?.raw); // expected: 7 — actual: LLVM verification failure
}
```
(B) — accessor through optional chain (field-not-found):
```sx
#import "modules/std.sx";
T :: struct {
raw: i64 = 0;
p :: (self: *T) -> i64 #get => self.raw;
}
main :: () {
t : T = .{ raw = 4 };
pt : ?*T = @t;
print("{}\n", pt?.p); // expected: 4 — actual: field 'p' not found on type '*T'
}
```
## Investigation prompt
Fix (A) first; (B) builds on it.
**(A)** In `src/ir/lower/expr.zig` `lowerOptionalChain` (the some-branch around
the `optional_wrap` of `field_val`): when the optional's child is a *value*
struct (`?T`, not `?*T`), the result optional is mis-assembled — the verifier
sees a `{ {i64}, i1 }` inserted into slot 0 of `{ {i64}, i1 }` instead of the
bare `{i64}` payload. Check `field_already_optional` / the `optional_wrap`
operand type and the `inner_ty` used for `optional_unwrap` vs.
`lowerFieldAccessOnType` — the some-branch likely wraps an already-aggregate
value, or unwraps to the wrong level for a value-optional. Compare against the
working `?*T` path (pointer-optional) to see where the value-optional diverges.
Verify with repro (A): expect `7`, no LLVM verification failure. Add
`examples/optionals/09xx-optionals-value-optional-chain-read.sx`.
**(B)** Once (A) is sound: teach the optional-chain read to dispatch a `#get`
accessor. The dereferenced (optional-unwrapped, then pointer-deref'd) receiver
type may have a getter — `Lowering.getAccessorFor(deref_ty, field)`. In
`lowerOptionalChain`'s some-branch, when a getter exists, bind the unwrapped
receiver to a synthetic local (see `bindSyntheticLocal` in
`src/ir/lower/stmt.zig` for the pattern) and lower a non-optional `tmp.field`
read (which hits the existing getter intercept in `lowerFieldAccess`), then wrap
as `?R`. Mirror the type in `src/ir/expr_typer.zig` — the `.field_access`
optional-chain arm already calls `getAccessorFor` after unwrapping the optional,
but it does NOT peel the extra pointer layer for a `?*T` receiver (so
`getAccessorFor(*T, ...)` returns null); peel the pointer there too. Verify with
repro (B): expect `4`. Add a regression example.
## Provenance
Found during the `#set` accessor review (mirrors the `#get` accessor). The
`#set`/`#get` work itself is complete and green; this issue is the optional-chain
interaction it surfaced. The `#set` write side through `?.` is intentionally left
matching real-field behavior (optional-chain assignment unsupported) and is not
part of this issue.

View File

@@ -400,7 +400,7 @@ impl Platform for AndroidPlatform {
}
poll_events :: (self: *AndroidPlatform) -> []Event {
self.events.items.len = 0;
self.events.len = 0;
sx_android_drain_touches(self, @self.events);
result : []Event = ---;
result.ptr = self.events.items;

View File

@@ -144,7 +144,7 @@ impl Platform for SdlPlatform {
}
poll_events :: (self: *SdlPlatform) -> []Event {
self.events.items.len = 0;
self.events.len = 0;
sdl_event : SDL_Event = .none;
while SDL_PollEvent(@sdl_event) {
if sdl_event == {

View File

@@ -379,7 +379,7 @@ impl Platform for UIKitPlatform {
result : []Event = ---;
result.ptr = self.events.items;
result.len = self.events.len;
self.events.items.len = 0;
self.events.len = 0;
result
}

View File

@@ -15,6 +15,10 @@ List :: struct ($T: Type) {
// No-paren read accessor: `xs.len` → the live element count.
len :: (self: *List(T)) -> i64 #get => self.items.len;
// Write accessor: `xs.len = n` sets the live count (e.g. `xs.len = 0` to
// clear without freeing). Mirrors the `#get` above; the buffer / `cap` are
// untouched, so `n` must be `<= cap`.
len :: (self: *List(T), v: i64) #set { self.items.len = v; }
append :: (list: *List(T), item: T, alloc: Allocator = context.allocator) {
if list.items.len >= list.cap {

View File

@@ -647,7 +647,7 @@ remove_timer :: (self: *Scheduler, idx: i64) {
self.timers.items[i] = self.timers.items[i + 1];
i = i + 1;
}
self.timers.items.len = self.timers.items.len - 1;
self.timers.len = self.timers.len - 1;
}
// Remove a pending sleep timer referencing fiber `f`, if any. A fiber has at
@@ -676,7 +676,7 @@ remove_io_waiter :: (self: *Scheduler, idx: i64) {
self.io_waiters.items[i] = self.io_waiters.items[i + 1];
i = i + 1;
}
self.io_waiters.items.len = self.io_waiters.items.len - 1;
self.io_waiters.len = self.io_waiters.len - 1;
}
// Remove a pending fd-waiter referencing fiber `f`, if any. A fiber has at most

View File

@@ -575,7 +575,7 @@ GlyphCache :: struct {
return; // shaped_buf already has the result
}
self.shaped_buf.items.len = 0;
self.shaped_buf.len = 0;
if text.len == 0 { return; }
if is_ascii(text) {

View File

@@ -141,7 +141,7 @@ UIPipeline :: struct {
// Reset render_tree nodes (backing is stale after arena reset)
self.render_tree.nodes.items = null;
self.render_tree.nodes.items.len = 0;
self.render_tree.nodes.len = 0;
self.render_tree.nodes.cap = 0;
push Context.{ allocator = xx build_arena, data = context.data } {

View File

@@ -47,7 +47,7 @@ RenderTree :: struct {
}
clear :: (self: *RenderTree) {
self.nodes.items.len = 0;
self.nodes.len = 0;
self.generation += 1;
}

View File

@@ -313,6 +313,15 @@ List :: struct ($T: Type) {
// `#get` property accessor: read via no-paren field syntax (`xs.len`),
// not `xs.len()`. Takes only `self`; a real field of the same name wins.
len :: (self: *List(T)) -> i64 #get => self.items.len;
// `#set` property accessor: the write counterpart, invoked via field-assign
// (`xs.len = n`) rather than `xs.len(n)`. Takes `self` + one value param and
// returns void. A property may have BOTH a `#get` and `#set` of the same
// name (reads pick the getter, writes the setter); compound assignment
// (`xs.len += 1`) reads via `#get` then writes via `#set`. Writing a
// `#get`-only property is a "read-only" compile error; a real field of the
// same name still wins.
len :: (self: *List(T), v: i64) #set { self.items.len = v; }
}
```

View File

@@ -196,6 +196,11 @@ pub const FnDecl = struct {
/// Invoked via field syntax (`obj.name`) when no real field matches, rather
/// than as a `obj.name()` call. Takes only the `self` receiver.
is_get: bool = false,
/// `name :: (self: *T, value: V) #set { ... }` — the WRITE counterpart of a
/// `#get` accessor. `obj.name = rhs` dispatches to it as `obj.name(rhs)` when
/// no real field matches. Takes the `self` receiver plus exactly one value
/// parameter and returns void.
is_set: bool = false,
};
pub const Param = struct {

View File

@@ -656,6 +656,11 @@ pub const Function = struct {
/// receiver as `self`.
is_get: bool = false,
/// `#set` property accessor (ast.FnDecl.is_set). The write counterpart of
/// `is_get`: `obj.name = rhs` dispatches to it as `obj.name(rhs)` when no
/// real field matches.
is_set: bool = false,
pub const Param = struct {
name: StringId,
ty: TypeId,

View File

@@ -1746,6 +1746,9 @@ pub const Lowering = struct {
pub const fnDeclOfRaw = lower_decl.fnDeclOfRaw;
pub const structDeclOfRaw = lower_decl.structDeclOfRaw;
pub const structMethodFn = lower_decl.structMethodFn;
pub const accessorEffName = lower_decl.accessorEffName;
pub const accessorNameMatches = lower_decl.accessorNameMatches;
pub const setter_eff_suffix = lower_decl.setter_eff_suffix;
pub const typeFnAuthor = lower_decl.typeFnAuthor;
pub const selectedFuncId = lower_decl.selectedFuncId;
pub const bareAuthorFuncId = lower_decl.bareAuthorFuncId;
@@ -1968,6 +1971,7 @@ pub const Lowering = struct {
pub const lowerInitBlock = lower_expr.lowerInitBlock;
pub const getStructFields = lower_expr.getStructFields;
pub const getAccessorFor = lower_expr.getAccessorFor;
pub const getSetterFor = lower_expr.getSetterFor;
pub const fixupMethodReceiver = lower_expr.fixupMethodReceiver;
pub const getStructTypeName = lower_expr.getStructTypeName;
pub const builtinTypeName = lower_expr.builtinTypeName;

View File

@@ -2102,9 +2102,41 @@ pub const VisibleStructAuthor = struct {
/// the bare-visible author's own method (`b.Box.make`), bypassing the name-keyed
/// last-wins `fn_ast_map` ("Box.make") that a 2-flat-hop same-name template's
/// method would otherwise win (E4 #1, static-method site).
/// The suffix that distinguishes a `#set` accessor's EFFECTIVE method name from
/// the read name it shares with a same-name `#get`. `$` can never appear in an
/// sx identifier (it is the comptime-param sigil), so `len$set` is an
/// unmistakable, symbol-safe key that cannot collide with any user method name
/// — yet it keeps the getter under the plain `len`, so registration / mangling /
/// dispatch keep BOTH accessors of a get+set pair distinct. See
/// `accessorEffName` / `accessorNameMatches`.
pub const setter_eff_suffix = "$set";
/// The name a method is REGISTERED / MANGLED / DISPATCHED under: a `#set`
/// accessor is keyed as `name$set` so it never clobbers the same-name `#get`
/// (which keeps its plain `name`); every other method keeps its own name.
pub fn accessorEffName(self: *Lowering, fd: *const ast.FnDecl) []const u8 {
if (!fd.is_set) return fd.name;
return std.fmt.allocPrint(self.alloc, "{s}" ++ setter_eff_suffix, .{fd.name}) catch fd.name;
}
/// True when method `fd` is the one a name-keyed lookup for `query` should
/// resolve to. A `name$set` query resolves ONLY the `#set` accessor named
/// `name`; a plain `name` query resolves any NON-setter (a `#get` accessor or an
/// ordinary method), never a setter. This makes get/set coexistence
/// declaration-order-independent (the read query picks the getter, the
/// `…$set` write query picks the setter) without an overload table.
pub fn accessorNameMatches(fd: *const ast.FnDecl, query: []const u8) bool {
if (std.mem.endsWith(u8, query, setter_eff_suffix)) {
if (!fd.is_set) return false;
return std.mem.eql(u8, fd.name, query[0 .. query.len - setter_eff_suffix.len]);
}
if (fd.is_set) return false;
return std.mem.eql(u8, fd.name, query);
}
pub fn structMethodFn(sd: *const ast.StructDecl, method: []const u8) ?*const ast.FnDecl {
for (sd.methods) |mn| {
if (mn.data == .fn_decl and std.mem.eql(u8, mn.data.fn_decl.name, method))
if (mn.data == .fn_decl and accessorNameMatches(&mn.data.fn_decl, method))
return &mn.data.fn_decl;
}
return null;
@@ -2323,6 +2355,7 @@ pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8)
func.has_implicit_ctx = wants_ctx;
func.is_naked = (fd.abi == .naked);
func.is_get = fd.is_get;
func.is_set = fd.is_set;
self.extern_name_map.put(name, c_name) catch {};
self.fn_decl_fids.put(fd, fid) catch {};
return;
@@ -2338,6 +2371,7 @@ pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8)
func.has_implicit_ctx = wants_ctx;
func.is_naked = (fd.abi == .naked);
func.is_get = fd.is_get;
func.is_set = fd.is_set;
if (weldedCompilerFn(self, fd, name)) func.compiler_welded = true;
// A BODIED `abi(.compiler)` function is a user compiler-domain function (e.g. a
// post-link callback): the VM runs its sx body, but it NEVER runs in the binary

View File

@@ -881,6 +881,37 @@ pub fn getAccessorFor(self: *Lowering, ty: TypeId, field: []const u8) ?*const as
return null;
}
/// A `#set` property accessor for `obj_ty.field`, or null — the WRITE
/// counterpart of `getAccessorFor`. A `#set` is registered/dispatched under its
/// effective `field$set` name (so a same-name `#get` keeps the plain `field`),
/// and a REAL field of the same name wins over it (parallels the `#get` rule).
/// `ty` must be the dereferenced (non-pointer) receiver type.
pub fn getSetterFor(self: *Lowering, ty: TypeId, field: []const u8) ?*const ast.FnDecl {
if (ty.isBuiltin()) return null;
// A REAL field of this name wins over a same-name `#set` (a setter must not
// shadow stored data on the write path).
const field_id = self.module.types.internString(field);
for (self.getStructFields(ty)) |f| {
if (f.name == field_id) return null;
}
const eff = std.fmt.allocPrint(self.alloc, "{s}" ++ Lowering.setter_eff_suffix, .{field}) catch return null;
// Generic instance: keyed by the instance name (e.g. "List(i64)").
const tn = self.formatTypeName(ty);
if (self.genericInstanceMethod(tn, eff)) |m| {
return if (m.fd.is_set) m.fd else null;
}
// Plain struct: the setter stub is registered "StructName.field$set".
const info = self.module.types.get(ty);
if (info == .@"struct") {
const sname = self.module.types.getString(info.@"struct".name);
const q = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, eff }) catch return null;
if (self.program_index.fn_ast_map.get(q)) |fd| {
return if (fd.is_set) fd else null;
}
}
return null;
}
pub fn lowerFieldAccessOnType(self: *Lowering, obj: Ref, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref {
const field_name_id = self.module.types.internString(field);

View File

@@ -117,6 +117,7 @@ pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name
self.builder.currentFunc().has_implicit_ctx = wants_ctx;
self.builder.currentFunc().is_naked = (fd.abi == .naked);
self.builder.currentFunc().is_get = fd.is_get;
self.builder.currentFunc().is_set = fd.is_set;
// Create entry block
const entry_name = self.module.types.internString("entry");
@@ -1554,7 +1555,9 @@ pub fn genericInstanceMethod(self: *Lowering, inst_name: []const u8, method: []c
/// which is the template's defining module (the author's own method node).
/// Null when the function fails to resolve post-monomorphization.
pub fn ensureGenericInstanceMethodLowered(self: *Lowering, m: GenericStructMethod) ?FuncId {
const mangled = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ m.inst_name, m.fd.name }) catch return null;
// A `#set` accessor mangles as `Inst.name$set` so its monomorph never
// collides with the same-name `#get`'s `Inst.name` (coexistence).
const mangled = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ m.inst_name, self.accessorEffName(m.fd) }) catch return null;
if (!self.lowered_functions.contains(mangled)) {
self.monomorphizeFunction(m.fd, mangled, m.bindings);
}

View File

@@ -626,7 +626,10 @@ pub fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl, source_fil
for (sd.methods) |method_node| {
if (method_node.data == .fn_decl) {
const method_fd = &method_node.data.fn_decl;
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, method_fd.name }) catch continue;
// A `#set` accessor registers under `name$set` so it never
// clobbers a same-name `#get` (issue: get+set coexistence).
const eff = self.accessorEffName(method_fd);
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, eff }) catch continue;
self.program_index.fn_ast_map.put(qualified, method_fd) catch {};
}
}
@@ -724,8 +727,11 @@ pub fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl, source_fil
for (sd.methods) |method_node| {
if (method_node.data == .fn_decl) {
const method_fd = &method_node.data.fn_decl;
// Build qualified name: StructName.method
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, method_fd.name }) catch continue;
// Build qualified name: StructName.method. A `#set` accessor uses
// its `name$set` effective name so a get+set pair keeps two distinct
// fn_ast_map slots and two distinct FuncId stubs (coexistence).
const eff = self.accessorEffName(method_fd);
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, eff }) catch continue;
self.program_index.fn_ast_map.put(qualified, method_fd) catch {};
// Declare extern stub (body is lowered lazily on demand)
self.declareFunction(method_fd, qualified);

View File

@@ -951,6 +951,7 @@ pub fn monomorphizePackFn(
self.builder.currentFunc().has_implicit_ctx = wants_ctx;
self.builder.currentFunc().is_naked = (fd.abi == .naked);
self.builder.currentFunc().is_get = fd.is_get;
self.builder.currentFunc().is_set = fd.is_set;
const entry_name = self.module.types.internString("entry");
const entry = self.builder.appendBlock(entry_name, &.{});

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).

View File

@@ -113,6 +113,7 @@ pub const Lexer = struct {
.{ "#selector", Tag.hash_selector },
.{ "#property", Tag.hash_property },
.{ "#get", Tag.hash_get },
.{ "#set", Tag.hash_set },
.{ "#caller_location", Tag.hash_caller_location },
};
inline for (directives) |d| {

View File

@@ -1716,6 +1716,7 @@ pub const Server = struct {
.hash_selector,
.hash_property,
.hash_get,
.hash_set,
.hash_caller_location,
=> ST.keyword,

View File

@@ -1958,12 +1958,25 @@ pub const Parser = struct {
return_type = try self.parseTypeExpr();
}
// Optional `#get` property-accessor marker: `name :: (self) -> R #get => expr;`.
// The method is invoked via field syntax (`obj.name`) rather than `obj.name()`.
// Optional `#get` / `#set` property-accessor marker:
// read: `name :: (self) -> R #get => expr;` (invoked via `obj.name`)
// write: `name :: (self, value: V) #set { … }` (invoked via `obj.name = rhs`)
// The two share the marker slot; a `#set` has no return type (void) and
// takes the receiver plus exactly one value parameter.
var is_get = false;
var is_set = false;
if (self.current.tag == .hash_get) {
is_get = true;
self.advance();
} else if (self.current.tag == .hash_set) {
is_set = true;
self.advance();
if (return_type != null)
return self.fail("a '#set' accessor returns void — drop the '-> T' return type");
// self + exactly one value parameter. `params` here are the value/
// receiver params only (type params `$T` are collected separately).
if (params.len != 2)
return self.fail("a '#set' accessor takes exactly the receiver and one value parameter");
}
// Optional ABI / calling-convention annotation: `abi(.c)` / `abi(.zig)` /
@@ -2057,6 +2070,7 @@ pub const Parser = struct {
.name_span = name_span,
.is_raw = name_is_raw,
.is_get = is_get,
.is_set = is_set,
} });
}
@@ -3775,7 +3789,9 @@ pub const Parser = struct {
if (tag == .arrow) return self.hasFnBodyAfterArrow();
// `kw_extern`/`kw_export`: a postfix linkage modifier (e.g. `f :: () extern;`
// with no return type) marks a fn decl just like `abi(...)`.
return tag == .l_brace or tag == .hash_builtin or tag == .fat_arrow or tag == .kw_abi or tag == .kw_extern or tag == .kw_export;
// `#set` is a bodied accessor with NO return type, so it sits directly
// after `)` (`(self, v) #set { … }`) — a fn-def marker like `{`/`=>`.
return tag == .l_brace or tag == .hash_builtin or tag == .fat_arrow or tag == .hash_set or tag == .kw_abi or tag == .kw_extern or tag == .kw_export;
}
fn hasFnBodyAfterArrow(self: *Parser) bool {
@@ -3803,6 +3819,7 @@ pub const Parser = struct {
if (self.current.tag == .l_brace) return true;
if (self.current.tag == .hash_builtin) return true;
if (self.current.tag == .hash_get) return true; // `-> R #get => …` is a fn def
if (self.current.tag == .hash_set) return true; // `-> R #set { … }` is a fn def
if (self.current.tag == .kw_abi) return true;
// Postfix linkage modifier after the return type: `-> R extern;` /
// `-> R export { … }` (and `-> R abi(.c) extern`). Marks a fn def.

View File

@@ -142,6 +142,7 @@ pub const Tag = enum {
hash_selector, // `#selector("explicit:string")` per-method Obj-C selector override (Phase 3.2)
hash_property, // `#property[(modifier, ...)]` field directive — synthesizes getter/setter dispatch (M2.2)
hash_get, // `name :: (self) -> R #get => expr;` — a no-paren property accessor method (read via field syntax)
hash_set, // `name :: (self, value) #set { ... }` — the write counterpart of #get (`obj.name = rhs` dispatches here)
hash_caller_location, // `#caller_location` — as a param default, synthesizes the call site's Source_Location (ERR E4.1b)
hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic
hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity