diff --git a/examples/diagnostics/1193-diagnostics-readonly-property-write.sx b/examples/diagnostics/1193-diagnostics-readonly-property-write.sx new file mode 100644 index 00000000..9cda3541 --- /dev/null +++ b/examples/diagnostics/1193-diagnostics-readonly-property-write.sx @@ -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; +} diff --git a/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.exit b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.exit @@ -0,0 +1 @@ +1 diff --git a/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stderr b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stderr new file mode 100644 index 00000000..492b26f6 --- /dev/null +++ b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stderr @@ -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') + | ^^^^^^^^^ diff --git a/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stdout b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/diagnostics/expected/1193-diagnostics-readonly-property-write.stdout @@ -0,0 +1 @@ + diff --git a/examples/memory/0840-memory-list-foreach.sx b/examples/memory/0840-memory-list-foreach.sx index cc3a0915..41b8e0c5 100644 --- a/examples/memory/0840-memory-list-foreach.sx +++ b/examples/memory/0840-memory-list-foreach.sx @@ -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 diff --git a/examples/memory/0841-memory-list-len-set.sx b/examples/memory/0841-memory-list-len-set.sx new file mode 100644 index 00000000..cdc18278 --- /dev/null +++ b/examples/memory/0841-memory-list-len-set.sx @@ -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; +} diff --git a/examples/memory/expected/0841-memory-list-len-set.exit b/examples/memory/expected/0841-memory-list-len-set.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/memory/expected/0841-memory-list-len-set.exit @@ -0,0 +1 @@ +0 diff --git a/examples/memory/expected/0841-memory-list-len-set.stderr b/examples/memory/expected/0841-memory-list-len-set.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/memory/expected/0841-memory-list-len-set.stderr @@ -0,0 +1 @@ + diff --git a/examples/memory/expected/0841-memory-list-len-set.stdout b/examples/memory/expected/0841-memory-list-len-set.stdout new file mode 100644 index 00000000..660eb691 --- /dev/null +++ b/examples/memory/expected/0841-memory-list-len-set.stdout @@ -0,0 +1,3 @@ +len=3 cap=4 +after clear: len=0 cap=4 +reused: len=1 cap=4 first=99 diff --git a/examples/types/0198-types-set-property-accessor.sx b/examples/types/0198-types-set-property-accessor.sx new file mode 100644 index 00000000..8b72e71d --- /dev/null +++ b/examples/types/0198-types-set-property-accessor.sx @@ -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]; +} diff --git a/examples/types/expected/0198-types-set-property-accessor.exit b/examples/types/expected/0198-types-set-property-accessor.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/types/expected/0198-types-set-property-accessor.exit @@ -0,0 +1 @@ +0 diff --git a/examples/types/expected/0198-types-set-property-accessor.stderr b/examples/types/expected/0198-types-set-property-accessor.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/types/expected/0198-types-set-property-accessor.stderr @@ -0,0 +1 @@ + diff --git a/examples/types/expected/0198-types-set-property-accessor.stdout b/examples/types/expected/0198-types-set-property-accessor.stdout new file mode 100644 index 00000000..3e683961 --- /dev/null +++ b/examples/types/expected/0198-types-set-property-accessor.stdout @@ -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 diff --git a/issues/0160-optional-chain-value-optional-and-accessors.md b/issues/0160-optional-chain-value-optional-and-accessors.md new file mode 100644 index 00000000..a538861e --- /dev/null +++ b/issues/0160-optional-chain-value-optional-and-accessors.md @@ -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. diff --git a/library/modules/platform/android.sx b/library/modules/platform/android.sx index 10686ad1..7187b603 100644 --- a/library/modules/platform/android.sx +++ b/library/modules/platform/android.sx @@ -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; diff --git a/library/modules/platform/sdl3.sx b/library/modules/platform/sdl3.sx index fc13f3d4..c7ead443 100644 --- a/library/modules/platform/sdl3.sx +++ b/library/modules/platform/sdl3.sx @@ -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 == { diff --git a/library/modules/platform/uikit.sx b/library/modules/platform/uikit.sx index d77b7cc3..a4904c3b 100644 --- a/library/modules/platform/uikit.sx +++ b/library/modules/platform/uikit.sx @@ -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 } diff --git a/library/modules/std/list.sx b/library/modules/std/list.sx index 1f85f1dc..044a331c 100644 --- a/library/modules/std/list.sx +++ b/library/modules/std/list.sx @@ -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 { diff --git a/library/modules/std/sched.sx b/library/modules/std/sched.sx index 896bba45..5f56338d 100644 --- a/library/modules/std/sched.sx +++ b/library/modules/std/sched.sx @@ -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 diff --git a/library/modules/ui/glyph_cache.sx b/library/modules/ui/glyph_cache.sx index 1db85a11..f219557f 100755 --- a/library/modules/ui/glyph_cache.sx +++ b/library/modules/ui/glyph_cache.sx @@ -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) { diff --git a/library/modules/ui/pipeline.sx b/library/modules/ui/pipeline.sx index c2caa18d..3bb6a043 100755 --- a/library/modules/ui/pipeline.sx +++ b/library/modules/ui/pipeline.sx @@ -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 } { diff --git a/library/modules/ui/render.sx b/library/modules/ui/render.sx index b26941da..2d156b9d 100755 --- a/library/modules/ui/render.sx +++ b/library/modules/ui/render.sx @@ -47,7 +47,7 @@ RenderTree :: struct { } clear :: (self: *RenderTree) { - self.nodes.items.len = 0; + self.nodes.len = 0; self.generation += 1; } diff --git a/readme.md b/readme.md index bb4cbfd2..c96bac3b 100644 --- a/readme.md +++ b/readme.md @@ -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; } } ``` diff --git a/src/ast.zig b/src/ast.zig index ab81b699..02781d53 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -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 { diff --git a/src/ir/inst.zig b/src/ir/inst.zig index c96a9abc..93c34c22 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -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, diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 3aa1f0cf..72957b16 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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; diff --git a/src/ir/lower/decl.zig b/src/ir/lower/decl.zig index 74c92c28..567dfd4c 100644 --- a/src/ir/lower/decl.zig +++ b/src/ir/lower/decl.zig @@ -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 diff --git a/src/ir/lower/expr.zig b/src/ir/lower/expr.zig index 51397de4..d09d08de 100644 --- a/src/ir/lower/expr.zig +++ b/src/ir/lower/expr.zig @@ -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); diff --git a/src/ir/lower/generic.zig b/src/ir/lower/generic.zig index 8dc5bc02..00096067 100644 --- a/src/ir/lower/generic.zig +++ b/src/ir/lower/generic.zig @@ -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); } diff --git a/src/ir/lower/nominal.zig b/src/ir/lower/nominal.zig index 7253bb14..ddb89db5 100644 --- a/src/ir/lower/nominal.zig +++ b/src/ir/lower/nominal.zig @@ -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); diff --git a/src/ir/lower/pack.zig b/src/ir/lower/pack.zig index 1f3749b8..be1900b9 100644 --- a/src/ir/lower/pack.zig +++ b/src/ir/lower/pack.zig @@ -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, &.{}); diff --git a/src/ir/lower/stmt.zig b/src/ir/lower/stmt.zig index 8f7a3da8..4478a69e 100644 --- a/src/ir/lower/stmt.zig +++ b/src/ir/lower/stmt.zig @@ -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.(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); +} + +/// ` = ` 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). diff --git a/src/lexer.zig b/src/lexer.zig index c24e61b7..caca78a5 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -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| { diff --git a/src/lsp/server.zig b/src/lsp/server.zig index cc15d7ae..296bbb27 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -1716,6 +1716,7 @@ pub const Server = struct { .hash_selector, .hash_property, .hash_get, + .hash_set, .hash_caller_location, => ST.keyword, diff --git a/src/parser.zig b/src/parser.zig index 0ae3efd5..5312d1b0 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -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. diff --git a/src/token.zig b/src/token.zig index 119e6501..df233a3a 100644 --- a/src/token.zig +++ b/src/token.zig @@ -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