Compare commits

...

4 Commits

Author SHA1 Message Date
agra
e386a0d0b4 fix: reject direct assignment to a tagged-union variant member
A tagged union (enum-with-payload) is laid out { tag, payload }, but a
direct member write `s.rect = payload` lowered to a payload-only store
(union_gep into field 1) with no tag store — the discriminant went stale,
so a later match/== took the wrong arm with no diagnostic (issue 0136).
The read path already distinguishes tagged unions (enum_payload/enum_tag);
the write path treated them like plain unions.

A variant is set via construction (`s = .variant(payload)`, which writes
both tag and payload). A direct member write can't safely set the tag (the
active variant isn't known at the write site), so it is now rejected with a
diagnostic pointing to construction. A new diagTaggedUnionVariantWrite guard
— reusing the shared fieldLvalueResolve matcher, applied at both store sites
(lowerAssignment, lowerMultiAssign) — fires only for a whole-variant write
on a tagged union. Plain `union` writes and nested sub-field writes
(`s.rect.w = ...`) are unaffected.

Resolves issue 0136. Tests: examples/0185 (rejected), 0186 (nested write +
construction still work). specs.md / readme.md updated.
2026-06-13 21:18:40 +03:00
agra
4d32a4d4fb fix: propagate union-member type to a struct-literal RHS
Assigning a struct literal to a named-struct member of a plain union
(`u.b = .{ ... }`) lowered the RHS as .unresolved and tripped the
LLVM-emission tripwire: lowerAssignment's .field_access target-type
path used getStructFields, which returns nothing for a union, so the
literal never received its target type.

Unify the lvalue field matcher into a pure fieldLvalueResolve consumed
by both fieldLvaluePtr (GEP builder) and the target-type path, so the
store slot and the RHS target type can't diverge (covers union direct +
promoted members, tuple/vector lanes, and structs).

Resolves issue 0133 (depended on 0135). Regression test: examples/0184.
Notes the now end-to-end union path in issue 0132.
2026-06-13 18:55:41 +03:00
agra
8c47268539 fix: xx pack[i] to a protocol target heap-copies the element
Erasing a single comptime-pack element to a protocol value
(`xx sources[0]` with a protocol target) tripped the pack-as-value
error: buildProtocolErasure treated the index_expr as an lvalue and
took its address via lowerExprAsPtr, whose .index_expr arm lowers the
bare pack as a value (a pack is comptime-only with no runtime storage).

isLvalueExpr now reports a comptime pack index as an rvalue, decided
via the same packArgNodeAt predicate the value path uses — so the value
and lvalue paths can't diverge on what counts as a pack element — and
erasure heap-copies the already-materialized element instead.

Resolves issue 0135. Regression tests: examples/0547, 0548.
2026-06-13 18:55:10 +03:00
agra
d3f5cb20cb fix: visibility-aware type resolution for protocol method signatures
`registerProtocolDecl` resolved each method's param/return type NAME
through the flat, visibility-unaware `type_bridge.resolveAstType`, so a
type name colliding across modules bound to the wrong author. In the
repro the user's `Event` enum collides with the stdlib `event.Event`
struct (pulled in by `modules/std.sx`): the protocol grabbed the stdlib
struct, typed an inferred `g_plat.one_event()` as a fieldless struct,
bound the `case .key_up:(e)` payload to `.unresolved`, and emitted
"enum literal '.escape' has no destination type to resolve against".

Resolve both param and return types through
`resolveTypeInSource(pd.source_file, …)` — the visibility-aware resolver
pinned to the protocol's own declaring module, keeping the `Self → *void`
short-circuit. Brings the non-parameterized path to parity with
`instantiateParamProtocol` and concrete-fn signatures. No silent default:
not-visible / ambiguous names still diagnose and poison with `.unresolved`.

Closes issue 0132 — the protocol-return case left open by f13f4ab (which
fixed the enum/union/inline/error-set registration class). Regression
test: examples/0417-protocols-protocol-return-name-collision.sx.
2026-06-13 15:44:11 +03:00
36 changed files with 894 additions and 119 deletions

View File

@@ -0,0 +1,22 @@
// Assigning a struct LITERAL to a named-struct member of a plain `union`.
// `u.b = .{ code = 9 }` types the literal as the union member's struct type
// `S` and stores it — the target type propagates to a union-member lvalue
// exactly as it does to a struct field.
//
// Regression (issue 0133): the literal used to lower as `.unresolved` (the
// target-type path only inspected struct fields, not union members) and trip
// the LLVM-emission tripwire in emitStructInit.
#import "modules/std.sx";
S :: struct { code: i64; }
U :: union { a: i64; b: S; }
main :: () {
u : U = ---;
u.b = .{ code = 9 }; // union member <- struct literal
print("code={}\n", u.b.code); // 9
u.a = 5; // scalar member still works
print("a={}\n", u.a); // 5
}

View File

@@ -0,0 +1,20 @@
// A direct write to a tagged-union (enum-with-payload) variant member is
// rejected: a tagged union is laid out `{ tag, payload }`, and a member write
// would set the payload but leave the tag stale. The variant is set via
// construction (`s = .rect(...)`), which writes both tag and payload.
//
// Regression (issue 0136): `s.rect = .{...}` used to silently store the payload
// only, desyncing the tag so a later `match` took the wrong arm. It now errors.
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .circle(1.0);
s.rect = .{ w = 4.0, h = 2.0 }; // rejected — use `s = .rect(.{...})` instead
print("unreachable: {}\n", s.rect.w);
}

View File

@@ -0,0 +1,22 @@
// A write to a sub-field of a tagged-union variant's payload (`s.rect.w = ...`)
// is NOT rejected: the immediate object is the payload struct, so it mutates a
// field of the already-active variant in place and leaves the tag alone. This
// pins the scope of issue 0136's guard — only a WHOLE-variant member write
// (`s.rect = ...`) is rejected; nested sub-field writes keep working.
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .rect(.{ w = 1.0, h = 2.0 });
s.rect.w = 9.0; // nested sub-field write — allowed
r := s.rect;
print("w={} h={}\n", r.w, r.h); // 9 2
s = .circle(3.5); // construction reassign — allowed
print("c={}\n", s.circle); // 3.5
}

View File

@@ -0,0 +1,44 @@
// Protocol method signatures resolve their param/return type NAMES in the
// protocol's OWN declaring module (own-wins visibility), so a bare type name
// that collides with a same-name namespaced import binds to the local author.
//
// Here the user's `Event` enum shares its name with the stdlib
// `std/event.sx` `Event :: struct` (pulled in, namespaced as `event`, by
// `#import "modules/std.sx"`). `Plat.one_event` returns the user's `Event`;
// `ev := g_plat.one_event()` infers that type, so the `case .key_up:(e)`
// payload binds a `KeyData` and `.escape` resolves against `Keycode`.
//
// Regression (issue 0132): `registerProtocolDecl` used to resolve method
// signature types through the flat, visibility-UNAWARE `type_bridge`
// resolver, which picked the stdlib `event.Event` struct instead — typing
// `ev` as a fieldless struct, binding `.unresolved`, and emitting
// "enum literal '.escape' has no destination type to resolve against". The
// fix pins resolution to `pd.source_file`, mirroring the parameterized-
// protocol and concrete-fn signature paths.
//
// Expect: prints `escape!`, exit 0.
#import "modules/std.sx";
Keycode :: enum { unknown; escape; enter; }
KeyData :: struct { key: Keycode; }
Event :: enum { none; key_up: KeyData; }
Plat :: protocol { one_event :: () -> Event; }
Impl :: struct { dummy: i64; }
impl Plat for Impl {
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
}
main :: () {
impl : Impl = .{ dummy = 0 };
g_plat : Plat = xx @impl;
ev := g_plat.one_event(); // type INFERRED from protocol return
if ev == {
case .key_up: (e) {
// `e` is KeyData (payload of the user's Event), `.escape` a Keycode
if e.key == .escape { print("escape!\n"); }
}
}
}

View File

@@ -0,0 +1,27 @@
// `xx <pack>[i]` erased to a protocol-typed local.
//
// Erasing a single comptime-pack element to a protocol scalar routes through
// buildProtocolErasure. A pack index is a comptime rvalue (a pack has no
// runtime storage — `sources[i]` resolves to the call-site arg, which only
// gets storage when lowered as a value), so the erasure must heap-copy the
// materialized element rather than take its address.
//
// Regression (issue 0135): `xx sources[0]` used to lower the bare pack as a
// value and error with "pack 'sources' has no runtime value".
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
first :: (..sources: VL) -> i64 {
x : VL(i64) = xx sources[0]; // erase element 0 to VL(i64)
return x.get();
}
main :: () -> i32 {
print("{}\n", first(IntCell.{ v = 7 })); // 7
print("{}\n", first(IntCell.{ v = 42 }, IntCell.{ v = 99 })); // 42 (element 0)
0
}

View File

@@ -0,0 +1,25 @@
// Erase two DISTINCT comptime-pack elements to protocol locals — each gets
// its own heap copy and resolves to its OWN concrete type's method (IntCell.get
// vs Doubler.get), proving the per-element erasure picks the right vtable.
//
// Regression (issue 0135): single-element `xx pack[i]` erasure to a protocol
// scalar was unsupported (the bare pack lowered as a value and errored).
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
Doubler :: struct { n: i64; }
impl VL(i64) for Doubler { get :: (self: *Doubler) -> i64 => self.n * 2; }
sum_two :: (..sources: VL) -> i64 {
a : VL(i64) = xx sources[0]; // erase element 0
b : VL(i64) = xx sources[1]; // erase element 1
return a.get() + b.get();
}
main :: () -> i32 {
print("{}\n", sum_two(IntCell.{ v = 10 }, Doubler.{ n = 16 })); // 10 + (16*2) = 42
0
}

View File

@@ -0,0 +1,2 @@
code=9
a=5

View File

@@ -0,0 +1,5 @@
error: cannot assign to tagged-union variant 'rect' directly — a member write sets the payload but leaves the tag stale; construct the variant instead (e.g. `x = .rect(...)`)
--> examples/0185-types-tagged-union-member-assign-rejected.sx:18:5
|
18 | s.rect = .{ w = 4.0, h = 2.0 }; // rejected — use `s = .rect(.{...})` instead
| ^^^^^^

View File

@@ -0,0 +1,2 @@
w=9.000000 h=2.000000
c=3.500000

View File

@@ -0,0 +1 @@
escape!

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
7
42

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@
42

View File

@@ -1,5 +1,22 @@
# 0132 — protocol method return/param type resolves to the WRONG same-name type (visibility-unaware registration)
> **RESOLVED (2026-06-13).** Root cause: `registerProtocolDecl`
> (`src/ir/protocols.zig`) resolved each method's param/return type NAME
> through the flat, visibility-UNAWARE `type_bridge.resolveAstType`, so a
> name colliding across modules (the user's `Event` enum vs the stdlib
> `event.Event` struct) bound to the wrong author. Fix: resolve both
> through `self.l.resolveTypeInSource(pd.source_file, …)` — the
> visibility-aware stateful resolver pinned to the protocol's OWN
> declaring module — keeping the `Self → *void` short-circuit. This
> brings the non-parameterized path to parity with the parameterized
> path (`instantiateParamProtocol`) and concrete-fn signatures, which
> already pin to the defining module. The broader enum/union/inline/
> error-set registration class was already fixed in `f13f4ab`; this
> commit closes the protocol-return case it left open. Regression test:
> `examples/0417-protocols-protocol-return-name-collision.sx` (prints
> `escape!`, exit 0). The error-set reference path remains dormant
> pending error-set per-decl nominal identity (issue 0134).
> **ROOT CAUSE CORRECTED (2026-06-13).** The original write-up (kept in
> "Original hypothesis" below) guessed this was about an inferred
> protocol-return enum TypeId "not carrying payload struct field types".
@@ -201,3 +218,14 @@ inferred and annotated `Event` are two DIFFERENT registered types (a
same-name shadow), and the divergence is purely that protocol signature
registration uses a flat, visibility-unaware lookup. The payload-field
machinery is fine once the correct `Event` reaches the binding.
## Follow-up (2026-06-13)
The broader-latent union case this fix enabled — a colliding-name union
member now resolving to the correct type — was further blocked at codegen by
a separate bug: assigning a struct literal to a union member lowered as
`.unresolved` ([issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
now RESOLVED, which in turn required
[issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md)).
With both fixed, the union-member-via-struct-literal path is demonstrable
end-to-end (`examples/0184-types-union-member-struct-literal-assign.sx`).

View File

@@ -1,44 +0,0 @@
// issue 0132 — protocol method return/param type resolves to the WRONG
// same-name type (visibility-unaware registration).
//
// ROOT CAUSE (corrected — see the .md): `registerProtocolDecl` resolves
// the method return type `Event` through a flat, visibility-UNAWARE lookup
// (type_bridge.resolveAstType → findByName). The user's `Event` enum
// collides by NAME with the stdlib `std/event.sx` `Event :: struct`
// (pulled in by `#import "modules/std.sx"`, namespaced as `event`). The
// flat lookup picks the stdlib struct, so `ev := g_plat.one_event()` is
// typed as a fieldless struct; the `case .key_up:(e)` payload then binds
// `.unresolved`, and `.escape` has no destination type.
//
// EXPECT (today): build FAILS —
// error: enum literal '.escape' has no destination type to resolve against
// EXPECT (after fix): prints `escape!`, exit 0.
//
// Proof it's a name collision: rename `Event` -> `Evt` everywhere and the
// inferred form compiles and prints `escape!`. Annotating
// `ev : Event = g_plat.one_event();` also sidesteps it (the annotation
// path is visibility-aware). See the .md for the full bisection.
#import "modules/std.sx";
Keycode :: enum { unknown; escape; enter; }
KeyData :: struct { key: Keycode; }
Event :: enum { none; key_up: KeyData; }
Plat :: protocol { one_event :: () -> Event; }
Impl :: struct { dummy: i64; }
impl Plat for Impl {
one_event :: (self: *Impl) -> Event { return .key_up(.{ key = .escape }); }
}
main :: () {
impl : Impl = .{ dummy = 0 };
g_plat : Plat = xx @impl;
ev := g_plat.one_event(); // type INFERRED from protocol return
if ev == {
case .key_up: (e) {
if e.key == .escape { print("escape!\n"); } // <-- errors here
}
}
}

View File

@@ -1,5 +1,21 @@
# 0133 — assigning a struct LITERAL to a union member panics ("unresolved type reached LLVM emission")
> **RESOLVED (2026-06-13).** Root cause: `lowerAssignment`'s `.field_access`
> target-type path used `getStructFields`, which returns nothing for a
> `union`, so a union-member LHS never set `target_type` and the RHS struct
> literal lowered as `.unresolved` → LLVM-emission tripwire. Fix: a single
> pure field-matching resolver `fieldLvalueResolve` (in `src/ir/lower/stmt.zig`)
> that both `fieldLvaluePtr` (builds GEPs) and the target-type path
> (`res.valueType()`) consume — covering union direct + promoted members,
> tuple/vector lanes, and structs, so the lvalue-pointer path and the
> target-type path can't diverge. Landing this required first fixing the
> latent [issue 0135](0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.md)
> (the unified resolver newly types tuple-element LHSs, which routed
> `examples/0540`'s `c.sources.0 = xx sources[0]` through pack-index protocol
> erasure). Regression test:
> [examples/0184-types-union-member-struct-literal-assign.sx](../examples/0184-types-union-member-struct-literal-assign.sx).
> The § Confirmed fix block below records the exact patch that landed.
## Symptom
One-line: `u.b = .{ ... }` where `b` is a NAMED-struct member of a plain
@@ -101,6 +117,177 @@ union-member-lvalue + literal-RHS combination drops it.
> resolved, also note in issue 0132 that the broader-latent union case is
> now demonstrable end-to-end.
## Confirmed fix (landed)
Root cause confirmed exactly as the investigation prompt hypothesized: the
target-type path in `lowerAssignment`'s `.field_access` case used
`getStructFields`, which returns `&.{}` for a `union` (only `.@"struct"` is
handled). So a union-member LHS never set `self.target_type`, and the RHS
struct literal lowered with no target → `struct_init.ty == .unresolved`
LLVM-emission tripwire.
The fix unifies the resolver (per this issue's prompt — "factor a
`fieldLvalueType` helper … so the lvalue-pointer path and the target-type
path cannot diverge"): a pure `fieldLvalueResolve(obj_ty, field)
-> ?FieldResolution` matcher that both `fieldLvaluePtr` (builds GEPs) and the
target-type path (`res.valueType()`) consume. With it, the 0133 union repro
prints `code=9`, exit 0.
**Why it was blocked on 0135:** the unified matcher also resolves *tuple
element* LHS types (not just structs, as the old `getStructFields` path did).
That makes `c.sources.0 = xx sources[0]` in
`examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`,
routing `xx sources[0]` through `buildProtocolErasure`
`lowerExprAsPtr(sources[0])` → the pre-existing pack-index-address-of bug
(issue 0135). Narrowing the fix to dodge tuples would reintroduce the
two-resolver divergence this issue explicitly set out to remove — i.e. a
workaround — so 0135 was fixed first, then this landed unchanged.
The patch that landed (`src/ir/lower.zig` + `src/ir/lower/stmt.zig`):
```diff
diff --git a/src/ir/lower.zig b/src/ir/lower.zig
--- a/src/ir/lower.zig
+++ b/src/ir/lower.zig
@@ pub const Lowering = struct {
pub const lowerAssignment = lower_stmt.lowerAssignment;
+ pub const fieldLvalueResolve = lower_stmt.fieldLvalueResolve;
pub const fieldLvaluePtr = lower_stmt.fieldLvaluePtr;
```
In `src/ir/lower/stmt.zig`, replace the struct-only target-type loop in
`lowerAssignment`'s `.field_access` branch:
```diff
- if (!obj_ty.isBuiltin()) {
- const field_name_id = self.module.types.internString(fa.field);
- const struct_fields = self.getStructFields(obj_ty);
- for (struct_fields) |f| {
- if (f.name == field_name_id) {
- self.target_type = f.ty;
- break;
- }
- }
- }
+ // Resolve the LHS member's type via the SAME resolver the lvalue-
+ // pointer path uses (fieldLvalueResolve), so the RHS target type
+ // and the store slot can't diverge. Covers union/tagged-union
+ // direct + promoted members, tuple/vector lanes, and structs —
+ // not just structs (a plain getStructFields loop returned nothing
+ // for a union member, leaving a struct-literal RHS untyped →
+ // struct_init.ty == .unresolved → LLVM-emission panic; issue 0133).
+ if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| {
+ self.target_type = res.valueType();
+ }
```
Then refactor `fieldLvaluePtr` into a pure `FieldResolution` matcher
(`fieldLvalueResolve`) + a thin GEP-builder. Full hunk:
```zig
const FieldResolution = union(enum) {
union_direct: struct { index: u32, ty: TypeId },
union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId },
indexed: struct { index: u32, ty: TypeId },
fn valueType(self: FieldResolution) TypeId {
return switch (self) {
.union_direct => |u| u.ty,
.union_promoted => |u| u.ty,
.indexed => |s| s.ty,
};
}
};
pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution {
if (obj_ty.isBuiltin()) return null;
const field_name_id = self.module.types.internString(field);
const type_info = self.module.types.get(obj_ty);
const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
.@"union" => |u| u.fields,
.tagged_union => |u| u.fields,
else => null,
};
if (union_fields) |fields| {
for (fields, 0..) |f, i| {
if (f.name == field_name_id) {
return .{ .union_direct = .{ .index = @intCast(i), .ty = f.ty } };
}
if (!f.ty.isBuiltin()) {
const fi = self.module.types.get(f.ty);
if (fi == .@"struct") {
for (fi.@"struct".fields, 0..) |sf, si| {
if (sf.name == field_name_id) {
return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .ty = sf.ty } };
}
}
}
}
}
return null;
}
if (type_info == .tuple) {
const tup = type_info.tuple;
var elem_idx: ?usize = null;
if (std.fmt.parseInt(usize, field, 10)) |n| {
if (n < tup.fields.len) elem_idx = n;
} else |_| {
if (tup.names) |names| {
for (names, 0..) |nm, i| {
if (nm == field_name_id and i < tup.fields.len) {
elem_idx = i;
break;
}
}
}
}
if (elem_idx) |idx| {
return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } };
}
return null;
}
if (type_info == .vector) {
const vidx = Lowering.vectorLaneIndex(field) orelse return null;
return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } };
}
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } };
}
}
return null;
}
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
const res = self.fieldLvalueResolve(obj_ty, field) orelse return null;
switch (res) {
.union_direct => |u| {
const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty));
return .{ .ptr = ptr, .ty = u.ty };
},
.union_promoted => |u| {
const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty));
const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty);
return .{ .ptr = ptr, .ty = u.ty };
},
.indexed => |s| {
const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty);
return .{ .ptr = ptr, .ty = s.ty };
},
}
}
```
(`fieldLvalueResolve` is also registered on `Lowering` in `lower.zig` — the
first diff hunk above.) Landed after 0135; the repro moved to
`examples/0184-types-union-member-struct-literal-assign.sx` and
`examples/0540` stays green.
## Notes
- Tripwire site (symptom): `src/backend/llvm/types.zig:176`

View File

@@ -1,23 +0,0 @@
// issue 0133 — assigning a struct LITERAL to a union member panics
// ("unresolved type reached LLVM emission").
//
// `u.b = .{ code = 9 }` where `b` is a named-struct member of a plain
// `union`: the RHS struct literal never receives its target type (the
// member's type `S`), so it lowers as `.unresolved` and trips the LLVM
// tripwire in emitStructInit. A STRUCT-field LHS propagates the target
// type fine; a pre-made value needs none — only the union-member-lvalue
// + struct-literal-RHS combination drops it. PRE-EXISTING, orthogonal to
// name resolution (reproduces with this unique, non-colliding name).
//
// EXPECT (today): panic. EXPECT (after fix): prints `code=9`, exit 0.
#import "modules/std.sx";
S :: struct { code: i64; }
U :: union { a: i64; b: S; }
main :: () {
u : U = ---;
u.b = .{ code = 9 };
print("code={}\n", u.b.code);
}

View File

@@ -0,0 +1,174 @@
# 0135 — `xx <pack>[i]` to a protocol target lowers the pack as a value ("pack has no runtime value")
> **RESOLVED (2026-06-13).** Root cause: `buildProtocolErasure`
> (`src/ir/lower/coerce.zig`) treated `pack[i]` as an lvalue (any `index_expr`
> returned true from `isLvalueExpr`) and tried to take its address via
> `lowerExprAsPtr`, whose `.index_expr` arm lowers the bare pack as a value →
> the pack-as-value error. Fix (preferred option 1): `isLvalueExpr` now reports
> a comptime pack index as an **rvalue**, so erasure falls into its heap-copy
> branch and copies the already-materialized element. It decides pack-ness with
> the SAME predicate the value path uses — `packArgNodeAt` (the `pack_arg_nodes`
> map) — not `isPackName` (`pack_param_count`), since the comptime-call path
> installs only `pack_arg_nodes`; sharing one predicate keeps the value and
> lvalue paths from diverging on what counts as a pack element. Regression tests:
> [examples/0547-packs-xx-pack-index-to-protocol.sx](../examples/0547-packs-xx-pack-index-to-protocol.sx)
> (single element) and
> [examples/0548-packs-xx-pack-index-two-elements.sx](../examples/0548-packs-xx-pack-index-two-elements.sx)
> (two distinct concrete types, each resolving to its own vtable). This also
> unblocked [issue 0133](0133-union-member-struct-literal-assign-unresolved-panic.md),
> now landed. The standalone repro `.sx` was removed (superseded by 0547).
## Symptom
One-line: erasing a single comptime-pack element to a protocol value —
`xx sources[0]` where the target type is a protocol (`VL(i64)`) — spuriously
errors with **"pack 'sources' has no runtime value — a pack is comptime-only
and can't be used as a value here"**, pointing at the pack name.
- **Observed:** the pack-as-value diagnostic fires on `sources` in
`x : VL(i64) = xx sources[0];`, even though `sources[0]` is a valid
compile-time pack index.
- **Expected:** `sources[0]` resolves to the call-site arg (the concrete
`IntCell`), gets erased to the protocol `VL(i64)`, and the program prints
`7`, exit 0.
This is **PRE-EXISTING** and reproduces on clean `master`, independent of any
union / tuple / issue-0133 work. Issue 0053 added the `xx <whole-pack>`
`[]Any`/`[]P` slice bridge (`lowerPackToSlice`), but **single-element**
`xx pack[i]` erasure to a protocol scalar was never handled.
## Reproduction
Minimal, standalone (only `modules/std.sx`):
```sx
#import "modules/std.sx";
VL :: protocol(T: Type) { get :: () -> T; }
IntCell :: struct { v: i64; }
impl VL(i64) for IntCell { get :: (self: *IntCell) -> i64 => self.v; }
make :: (..sources: VL) -> i64 {
x : VL(i64) = xx sources[0]; // protocol local <- xx pack[0]
return x.get();
}
main :: () -> i32 {
print("{}\n", make(IntCell.{ v = 7 })); // 7
0
}
```
Run: `./zig-out/bin/sx run issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
→ errors today; the fix should make it print `7`, exit 0.
### Root cause (traced)
The error chain (from a stack trace at the diagnostic site):
```
lowerAssignment / lowerVarDecl sets target_type = VL(i64) for the RHS
lowerExpr(xx sources[0]) unary_op .xx
operand = lowerExpr(sources[0]) → packArgNodeAt resolves to the
call-site arg IntCell.{v=7} (OK)
lowerXX(operand, sources[0]) classifies .erase_protocol
buildProtocolErasure(..., operand_node = sources[0], dst = VL(i64))
isLvalueExpr(sources[0]) == true (it's an index_expr)
concrete_ptr = lowerExprAsPtr(sources[0]) ← HERE
lowerExprAsPtr .index_expr arm: lowerExpr(ie.object)
lowerExpr(sources) → bare pack name → diagPackAsValue ✗
```
The defect: **`lowerExprAsPtr`'s `.index_expr` arm does NOT perform the
pack-arg-node substitution that `lowerIndexExpr` does.** `lowerIndexExpr`
intercepts `<pack>[<comptime-int>]` via `packArgNodeAt` and lowers the
call-site arg node directly; `lowerExprAsPtr` skips straight to
`lowerExpr(ie.object)`, lowering the bare pack `sources` as a value — which is
the (correct) pack-as-value error for a context where there is genuinely no
pointer to take.
So the value path (`lowerIndexExpr`) handles a pack index but the
address-of path (`lowerExprAsPtr`) does not — a two-resolver divergence on
the pack-index case. `buildProtocolErasure` only reaches the address-of path
because `isLvalueExpr(sources[0])` returns `true` (any index_expr looks like
an lvalue), so it tries to alias the operand's storage instead of heap-copying
the already-materialized rvalue.
## Investigation prompt
> `xx <pack>[i]` erased to a protocol target spuriously errors with "pack
> '<name>' has no runtime value". Repro:
> `issues/0135-xx-pack-index-protocol-erasure-lowers-pack-as-value.sx`
> (errors today; the fix should make it print `7`, exit 0).
>
> Root cause: `buildProtocolErasure` (`src/ir/lower/coerce.zig`, ~line 389)
> sees `isLvalueExpr(operand_node) == true` for the index_expr `sources[0]`
> and takes the alias-the-storage branch:
> `concrete_ptr = self.lowerExprAsPtr(operand_node)`. But
> `lowerExprAsPtr`'s `.index_expr` arm (`src/ir/lower/stmt.zig`, the
> `.index_expr => |ie|` case, `self.lowerExpr(ie.object)` at stmt.zig:1005
> on clean master) does
> NOT do the pack-arg-node substitution that `lowerIndexExpr`
> (`src/ir/lower/expr.zig:1343`, via `packArgNodeAt`) performs. It lowers the
> bare pack `sources` as a value → `diagPackAsValue` (the pack-as-value
> error at `src/ir/lower/expr.zig:1722`).
>
> A comptime pack index has no addressable storage of its own — `sources[0]`
> is the call-site arg node, which only acquires storage when lowered as a
> value. So the address-of path is the wrong path for a pack index.
>
> Suspected fix (pick the one that keeps the value/address paths from
> diverging — the recurring two-resolver defect class in this codebase):
> 1. **Preferred — teach `isLvalueExpr` that a comptime pack index is NOT
> an lvalue.** Add a check: an `index_expr` whose object is a pack name
> (`isPackName(ie.object...)` / a `packArgNodeAt(ie) != null` hit) is an
> rvalue. Then `buildProtocolErasure` falls into its existing
> `else { heap_copy = true; alloca + store(operand) }` branch and erases
> the already-materialized `IntCell` value correctly. Smallest, and
> matches the semantic truth (a pack element is a comptime rvalue).
> 2. Alternatively, make `lowerExprAsPtr`'s `.index_expr` arm resolve a
> pack index the way `lowerIndexExpr` does (`packArgNodeAt` → lower the
> arg node as a value → `addr_of` an alloca holding it). More plumbing,
> and it manufactures storage the caller could already manufacture.
> Do NOT paper over with a silent default — per CLAUDE.md, resolve the real
> path or emit a diagnostic.
>
> Verification: the repro prints `7`, exit 0; then `zig build &&
> zig build test` green. Add positive coverage (a new
> `examples/05xx-packs-xx-pack-index-to-protocol.sx`, packs category) and a
> sibling that erases two distinct pack elements. When resolved, this also
> UNBLOCKS issue 0133 (see below) — re-apply the 0133 unified-resolver fix
> and confirm `examples/0540-packs-pack-type-arg-spread.sx` stays green.
## Relationship to issue 0133
Surfaced while fixing **issue 0133** (assigning a struct literal to a union
member panics — the RHS never gets its target type). The clean 0133 fix
(per its own investigation prompt) unifies the lvalue field resolver so the
**target-type path** and the **lvalue-pointer path** share one matcher
(`fieldLvalueResolve`), which then resolves *tuple element* LHS types too
(not just structs). That makes `c.sources.0 = xx sources[0]` in
`examples/0540-packs-pack-type-arg-spread.sx` set `target_type = VL(i64)`
for the RHS — which routes `xx sources[0]` through `buildProtocolErasure`
(it previously erased later, at the store, via `coerceToType` on the
already-materialized value). That is exactly this bug.
So **issue 0133's correct (unified-resolver) fix is BLOCKED on this issue.**
The ready-to-apply 0133 patch is recorded in
`issues/0133-union-member-struct-literal-assign-unresolved-panic.md`; after
0135 lands, re-apply it and confirm both the 0133 union repro (`code=9`) and
`examples/0540` stay green.
## Notes
- Diagnostic site (symptom): `src/ir/lower/expr.zig:1723` (`isPackName` at
1722 → `diagPackAsValue`, `.generic`) via `src/ir/lower/expr.zig:1386`
(`lowerExpr(ie.object)` in `lowerIndexExpr`) — reached here through
`lowerExprAsPtr`'s `.index_expr` arm, NOT `lowerIndexExpr`.
- Root area (cause): `buildProtocolErasure` (`src/ir/lower/coerce.zig:389-390`)
+ `lowerExprAsPtr` `.index_expr` arm (`src/ir/lower/stmt.zig:1005`) +
`isLvalueExpr`.
- Prior art (RESOLVED, not duplicates): 0052 (slice-of-protocol variadic
erasure), 0053 (`xx <whole-pack>` → slice bridge), 0054 (generic-struct →
param protocol erasure). None handle single-element `xx pack[i]` → protocol
scalar.

View File

@@ -0,0 +1,139 @@
# 0136 — direct write to a tagged-union member updates the payload but not the tag
> **RESOLVED (2026-06-13).** Root cause: the read path distinguishes a tagged
> union (`enum_payload`/`enum_tag`, field 1/field 0) but the write path treated
> it like a plain union (`fieldLvalueResolve` → `.union_direct` → `union_gep`),
> storing the payload (field 1) with no tag store — so the discriminant went
> stale. Fix (chosen option 1 — reject; the spec only blesses construction /
> read / match for tagged unions, and no corpus relied on member writes): a new
> `diagTaggedUnionVariantWrite` guard (`src/ir/lower/stmt.zig`, reusing the
> shared `fieldLvalueResolve` matcher, registered in `src/ir/lower.zig`) rejects
> a direct whole-variant member assignment at both store sites (`lowerAssignment`
> and `lowerMultiAssign`) with a diagnostic pointing to `s = .variant(...)`.
> Plain `union` writes and nested sub-field writes (`s.rect.w = ...`) are
> unaffected (they don't resolve to `.union_direct` on a tagged union).
> Regression tests: `examples/0185-types-tagged-union-member-assign-rejected.sx`
> (rejected), `examples/0186-types-tagged-union-nested-field-write.sx` (nested
> write + construction still work). Spec/readme updated (enum section).
## Symptom
One-line: `s.rect = .{ ... }` on a tagged union (`enum`-with-payload) stores
into the payload area but leaves the discriminant (tag) untouched, so a later
`match`/`==` reads the STALE tag while the payload holds the new variant — a
silent tag/payload desync with no diagnostic.
- **Observed:** after `s : Shape = .circle(1.0); s.rect = .{ w=4, h=2 };`, a
`match` on `s` takes the `.circle` arm (tag never updated) even though the
payload now holds the rect. The wrong-variant payload read (`s.rect`) returns
the written bytes, masking the inconsistency.
- **Expected:** either a compile error directing the user to construction
(`s = .rect(...)`), or the member write sets the tag too so `s` becomes the
`.rect` variant and `match` sees `.rect`.
Same-variant write is fine (`s.circle = 9.0` while the tag is already
`circle`): the payload updates and the tag already matched. Only a write whose
variant differs from the current tag desyncs — and the compiler can't know the
runtime tag at the write site, so the danger is inherent to the operation.
## Reproduction
`issues/0136-tagged-union-member-write-does-not-set-tag.sx` (standalone, only
`modules/std.sx`):
```sx
#import "modules/std.sx";
Shape :: enum {
circle: f32;
rect: struct { w, h: f32; };
}
main :: () {
s : Shape = .circle(1.0); // tag = circle, payload = 1.0
s.rect = .{ w = 4.0, h = 2.0 }; // writes rect payload; tag stays circle
if s == {
case .circle: print("tag=circle (STALE — wrote rect)\n");
case .rect: print("tag=rect (correct)\n");
}
r := s.rect;
print("rect.w={} rect.h={}\n", r.w, r.h);
}
```
Run: `./zig-out/bin/sx run issues/0136-tagged-union-member-write-does-not-set-tag.sx`
→ today prints `tag=circle (STALE — wrote rect)` then `rect.w=4 rect.h=2`.
## Root cause (traced)
A tagged union is laid out `{ tag (field 0), payload (field 1) }`
(`src/ir/types.zig` sizeOf: `tag_sz + max_field`). The READ and WRITE paths
treat it asymmetrically:
- **Read** distinguishes a tagged union: `lowerFieldAccess`
(`src/ir/lower/expr.zig`) emits `enum_payload``emitEnumPayload`
(`src/backend/llvm/ops.zig:1392`) GEPs **field 1** (payload); `.tag` /
`==` use `enum_tag` (field 0).
- **Write** treats a tagged union like a plain union: `fieldLvalueResolve`
(`src/ir/lower/stmt.zig`) maps a tagged-union member to `.union_direct`
`fieldLvaluePtr` emits `union_gep``emitUnionGep`
(`src/backend/llvm/ops.zig:1430`) GEPs **field 1** (payload) and stores there.
So the payload OFFSET is correct (both use field 1 — not an out-of-bounds /
clobber bug), but the write never emits the tag (field 0) store that
construction does. Construction `s = .rect(...)` lowers via `enum_init`
(`src/ir/inst.zig:170`), which writes BOTH tag and payload; the member-write
path emits only the payload store.
## Investigation prompt
> A direct write to a tagged-union member (`s.rect = .{...}`) updates the
> payload but not the discriminant, silently desyncing tag and payload. Repro:
> `issues/0136-tagged-union-member-write-does-not-set-tag.sx` (today prints the
> STALE tag; the fix should make it either a compile error or set the tag).
>
> Root: `fieldLvalueResolve` (`src/ir/lower/stmt.zig`) maps a tagged-union
> member to `.union_direct`, so `fieldLvaluePtr` emits a bare `union_gep`
> (payload pointer, field 1) and `lowerAssignment` stores only the payload. The
> tag (field 0) is never written. Construction (`enum_init`) writes both.
>
> Two reasonable fixes (pick one — both beat the silent desync):
> 1. **Reject** direct assignment to a tagged-union variant member with a
> diagnostic ("set a tagged-union variant via `s = .rect(...)`; direct
> member assignment can't set the discriminant"). Simplest and safe — the
> construction syntax already covers the intent, and the compiler can't
> know the runtime tag to validate a same-variant write anyway. Detect in
> `lowerAssignment`'s `.field_access` arm when the object type is a
> `tagged_union` and the field is a variant.
> 2. **Set the tag too**: make `s.rect = payload` equivalent to
> `s = .rect(payload)` — emit a tag store (the variant discriminant, which
> `fieldLvalueResolve` already knows as the variant index) alongside the
> payload store. More ergonomic but more plumbing (the assignment path
> must emit two stores, and compound ops like `s.rect.w += 1` need
> thought). Mirror how `enum_init` sets the tag.
> Do NOT leave the silent payload-only write. Note the read/write asymmetry
> (read uses `enum_payload`, write uses `union_gep`) is the structural root;
> whichever fix, keep plain `union` (no tag) working — only `tagged_union`
> needs the new behavior.
>
> Verification: the repro errors (option 1) or `match` sees `.rect` (option 2);
> then `zig build && zig build test` green. Add coverage under
> `examples/01xx-types-...` (a tagged-union member write: rejected, or
> tag-setting round-trips through `match`).
## Notes
- PRE-EXISTING and orthogonal to issues 0133 / 0135. Surfaced while reviewing
the 0133 fix (which unified the lvalue field resolver): the read path
special-cases `tagged_union` but the write path does not. The 0133 fix itself
is about PLAIN `union` (no tag) and did not introduce or change this — it
preserved the existing `union_direct → union_gep` routing for tagged-union
members.
- Related read-side gap (probably same fix family, not verified here): reading
the WRONG variant (`s.rect` while tag is `circle`) also returns raw payload
bytes with no tag check (`emitEnumPayload` doesn't check the discriminant).
A variant-safe accessor / checked read is a separate consideration.
- Sites: write — `fieldLvalueResolve`/`fieldLvaluePtr` (`src/ir/lower/stmt.zig`),
`emitUnionGep` (`src/backend/llvm/ops.zig:1430`); read — `emitEnumPayload`
(`src/backend/llvm/ops.zig:1392`); construction — `enum_init`
(`src/ir/inst.zig:170`); layout — `src/ir/types.zig` (tagged_union sizeOf).

View File

@@ -254,6 +254,11 @@ Perms :: enum flags { read; write; execute; }
rw := Perms.read | Perms.write;
```
Set a variant by construction (`s = .circle(2.0)`), which writes the tag and
payload together. Direct member assignment to a variant (`s.circle = 2.0`) is
rejected — it would set the payload but not the tag. Mutating a sub-field of the
active variant in place (`s.rect.w = 9.0`) is fine.
### Optionals
```sx

View File

@@ -394,6 +394,14 @@ s = Shape.rect(42); // explicit prefix
r := s.circle; // load payload as f32 (undefined behavior if wrong variant active)
```
#### Setting a Variant
A variant is set by construction — `s = .rect(payload)` — which writes both the
tag and the payload together. Direct member assignment to a variant
(`s.rect = payload`) is **rejected at compile time**: it would store the payload
but not the tag, leaving the two desynced so a later `match` takes the wrong arm.
Mutating a sub-field of the *active* variant's payload in place is allowed
(`s.rect.w = 9.0`).
#### Pattern Matching
```sx
if s == {

View File

@@ -1607,7 +1607,9 @@ pub const Lowering = struct {
pub const lowerConstDecl = lower_stmt.lowerConstDecl;
pub const lowerReturn = lower_stmt.lowerReturn;
pub const lowerAssignment = lower_stmt.lowerAssignment;
pub const fieldLvalueResolve = lower_stmt.fieldLvalueResolve;
pub const fieldLvaluePtr = lower_stmt.fieldLvaluePtr;
pub const diagTaggedUnionVariantWrite = lower_stmt.diagTaggedUnionVariantWrite;
pub const lowerExprAsPtr = lower_stmt.lowerExprAsPtr;
pub const storeOrCompound = lower_stmt.storeOrCompound;
pub const emitCompoundOp = lower_stmt.emitCompoundOp;

View File

@@ -333,9 +333,26 @@ pub fn tryUserConversion(self: *Lowering, operand: Ref, operand_node: *const Nod
/// `xx <struct-typed expr>` to decide between borrow (lvalue → take the
/// address) and heap-copy (rvalue → allocate a fresh copy).
pub fn isLvalueExpr(self: *Lowering, node: *const Node) bool {
_ = self;
return switch (node.data) {
.identifier, .field_access, .index_expr, .deref_expr => true,
.identifier, .field_access, .deref_expr => true,
// A comptime pack index (`pack[i]`) is NOT an lvalue: a pack is
// comptime-only with no runtime storage — `pack[i]` resolves to the
// call-site arg node, which only acquires storage when lowered as a
// value. Taking its address via `lowerExprAsPtr` would lower the bare
// pack as a value and trip the pack-as-value error (issue 0135).
// Reporting it as an rvalue routes `buildProtocolErasure` into its
// heap-copy branch, which copies the already-materialized element.
// A non-pack index (array/slice element) is a genuine lvalue.
//
// Decide pack-ness with the SAME predicate the value path uses —
// `packArgNodeAt` (the `pack_arg_nodes` substitution map) — NOT
// `isPackName` (the `pack_param_count` map). The two maps are set
// together in the pack-fn path but the comptime-call path
// (comptime.zig) installs only `pack_arg_nodes`; using `isPackName`
// there would disagree with the value substitution and mis-route the
// erasure. Sharing one predicate keeps the value/lvalue paths from
// diverging on what counts as a pack element.
.index_expr => self.packArgNodeAt(&node.data.index_expr) == null,
else => false,
};
}

View File

@@ -640,15 +640,15 @@ pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
const pinfo = self.module.types.get(obj_ty_raw);
break :blk if (pinfo == .pointer) pinfo.pointer.pointee else obj_ty_raw;
} else obj_ty_raw;
if (!obj_ty.isBuiltin()) {
const field_name_id = self.module.types.internString(fa.field);
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields) |f| {
if (f.name == field_name_id) {
self.target_type = f.ty;
break;
}
}
// Resolve the LHS member's type via the SAME resolver the lvalue-
// pointer path uses (fieldLvalueResolve), so the RHS target type
// and the store slot can't diverge. Covers union/tagged-union
// direct + promoted members, tuple/vector lanes, and structs —
// not just structs (a plain getStructFields loop returned nothing
// for a union member, leaving a struct-literal RHS untyped →
// struct_init.ty == .unresolved → LLVM-emission panic; issue 0133).
if (self.fieldLvalueResolve(obj_ty, fa.field)) |res| {
self.target_type = res.valueType();
}
}
}
@@ -741,6 +741,10 @@ pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
}
}
// Reject a direct write to a tagged-union variant (issue 0136): it
// sets the payload but not the tag. Construct via `x = .variant(...)`.
if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, asgn.target.span)) return;
// Special .len/.ptr handling only for slices, strings, arrays — NOT structs
const is_special_container = obj_ty == .string or (if (!obj_ty.isBuiltin()) blk: {
const obj_info = self.module.types.get(obj_ty);
@@ -837,34 +841,59 @@ pub fn lowerAssignment(self: *Lowering, asgn: *const ast.Assignment) void {
const FieldLvalue = struct { ptr: Ref, ty: TypeId };
/// Resolve `obj.field` — where `obj_ptr` already points at the aggregate —
/// to a typed pointer into the field's storage plus the field's value type.
/// Pure description of which slot `obj.field` resolves to — the GEP path plus
/// the field's value type — computed WITHOUT emitting any IR. The single
/// field-matching resolver for the LVALUE/WRITE paths: `fieldLvaluePtr` builds
/// GEPs from it, and the assignment target-type path reads `.valueType()` from
/// it, so the lvalue-pointer path and the RHS target-type path can never
/// disagree on which field (or what type) a name resolves to — the two-resolver
/// defect class this codebase keeps burning on. To handle a new aggregate
/// shape, add an arm here and a matching GEP arm in `fieldLvaluePtr`; both fail
/// to compile until the union is exhaustive, forcing the two to stay in lockstep.
///
/// NOTE: the READ path (`lowerFieldAccess`, expr.zig) and the TYPE-INFER path
/// (`ExprTyper.inferType`, expr_typer.zig) still carry their OWN parallel field
/// matchers (emitting `union_get`/`enum_payload`/`struct_get` value reads, and
/// returning a bare `TypeId`, respectively). They are not yet routed through
/// here, so a new aggregate shape must currently be taught to all three. Folding
/// read + infer onto this resolver (switching the descriptor to value-read ops /
/// `.valueType()`) would make it the genuine compiler-wide single matcher.
const FieldResolution = union(enum) {
/// Direct union/tagged-union member: union_gep(index) into the aggregate.
union_direct: struct { index: u32, ty: TypeId },
/// Promoted member of an anonymous-struct union variant: union_gep into
/// the variant struct `variant_ty`, then struct_gep into the member.
union_promoted: struct { variant_index: u32, variant_ty: TypeId, member_index: u32, ty: TypeId },
/// Tuple element / vector lane / plain struct field: a single
/// struct_gep(index) into the aggregate.
indexed: struct { index: u32, ty: TypeId },
/// The field's value type — what the caller coerces the rhs to / sets as
/// the RHS target type. Identical regardless of the GEP path taken.
fn valueType(self: FieldResolution) TypeId {
return switch (self) {
.union_direct => |u| u.ty,
.union_promoted => |u| u.ty,
.indexed => |s| s.ty,
};
}
};
/// Match `obj.field` against the aggregate `obj_ty` and return the resolution
/// descriptor, or null when no field matches (the caller emits the
/// field-not-found diagnostic). Emits NO IR — see `FieldResolution`.
///
/// Handles union direct fields, promoted anonymous-struct union members,
/// tuple elements (numeric or named), vector lanes (`.x`/`.y`/`.z`/`.w` and
/// the colour aliases), and plain struct fields. Returns null when no field
/// matches; the caller emits the field-not-found diagnostic.
///
/// `ptr`'s IR type is `*field_ty` (a pointer to the field), NOT the field
/// value type: `emitStore` reads the store-target pointer's IR type and
/// unwraps one `.pointer` level to find the stored value's type. Labelling
/// the GEP with the bare field type instead would make a field whose own
/// type is a pointer-to-aggregate (`*Pair`) coerce the stored pointer into
/// the aggregate (closure auto-promotion in `coerceArg`), storing an
/// oversized struct that clobbers the neighbouring field. `.ty` carries the
/// field's value type for the caller's coercion.
///
/// Single source of lvalue field resolution shared by all three store/
/// address-of sites — lowerAssignment (single-target store), lowerExprAsPtr
/// (address-of), and lowerMultiAssign (multi-target store) — so they never
/// resolve a field to a different slot or default field 0.
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
/// the colour aliases), and plain struct fields.
pub fn fieldLvalueResolve(self: *Lowering, obj_ty: TypeId, field: []const u8) ?FieldResolution {
if (obj_ty.isBuiltin()) return null;
const field_name_id = self.module.types.internString(field);
const type_info = self.module.types.get(obj_ty);
// Union / tagged-union: variants overlay at offset 0. A direct field is
// a union_gep; a promoted anonymous-struct member is a union_gep into
// the variant followed by a struct_gep into the member.
// Union / tagged-union: variants overlay at offset 0. A direct field is a
// union_gep; a promoted anonymous-struct member is a union_gep into the
// variant followed by a struct_gep into the member.
const union_fields: ?[]const types.TypeInfo.StructInfo.Field = switch (type_info) {
.@"union" => |u| u.fields,
.tagged_union => |u| u.fields,
@@ -873,17 +902,14 @@ pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []co
if (union_fields) |fields| {
for (fields, 0..) |f, i| {
if (f.name == field_name_id) {
const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
return .{ .ptr = ptr, .ty = f.ty };
return .{ .union_direct = .{ .index = @intCast(i), .ty = f.ty } };
}
if (!f.ty.isBuiltin()) {
const fi = self.module.types.get(f.ty);
if (fi == .@"struct") {
for (fi.@"struct".fields, 0..) |sf, si| {
if (sf.name == field_name_id) {
const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
const ptr = self.builder.structGepTyped(ug, @intCast(si), self.module.types.ptrTo(sf.ty), f.ty);
return .{ .ptr = ptr, .ty = sf.ty };
return .{ .union_promoted = .{ .variant_index = @intCast(i), .variant_ty = f.ty, .member_index = @intCast(si), .ty = sf.ty } };
}
}
}
@@ -909,9 +935,7 @@ pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []co
}
}
if (elem_idx) |idx| {
const elem_ty = tup.fields[idx];
const ptr = self.builder.structGepTyped(obj_ptr, @intCast(idx), self.module.types.ptrTo(elem_ty), obj_ty);
return .{ .ptr = ptr, .ty = elem_ty };
return .{ .indexed = .{ .index = @intCast(idx), .ty = tup.fields[idx] } };
}
return null;
}
@@ -921,22 +945,85 @@ pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []co
// non-lane field on a vector is a genuine miss (caller diagnoses).
if (type_info == .vector) {
const vidx = Lowering.vectorLaneIndex(field) orelse return null;
const elem_ty = type_info.vector.element;
const ptr = self.builder.structGepTyped(obj_ptr, vidx, self.module.types.ptrTo(elem_ty), obj_ty);
return .{ .ptr = ptr, .ty = elem_ty };
return .{ .indexed = .{ .index = vidx, .ty = type_info.vector.element } };
}
// Plain struct field.
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
const ptr = self.builder.structGepTyped(obj_ptr, @intCast(i), self.module.types.ptrTo(f.ty), obj_ty);
return .{ .ptr = ptr, .ty = f.ty };
return .{ .indexed = .{ .index = @intCast(i), .ty = f.ty } };
}
}
return null;
}
/// Resolve `obj.field` — where `obj_ptr` already points at the aggregate —
/// to a typed pointer into the field's storage plus the field's value type.
/// Delegates the field MATCH to `fieldLvalueResolve` (shared with the RHS
/// target-type path) and only builds the GEP(s) here. Returns null when no
/// field matches; the caller emits the field-not-found diagnostic.
///
/// `ptr`'s IR type is `*field_ty` (a pointer to the field), NOT the field
/// value type: `emitStore` reads the store-target pointer's IR type and
/// unwraps one `.pointer` level to find the stored value's type. Labelling
/// the GEP with the bare field type instead would make a field whose own
/// type is a pointer-to-aggregate (`*Pair`) coerce the stored pointer into
/// the aggregate (closure auto-promotion in `coerceArg`), storing an
/// oversized struct that clobbers the neighbouring field. `.ty` carries the
/// field's value type for the caller's coercion.
///
/// Single source of lvalue field GEP-building shared by all three store/
/// address-of sites — lowerAssignment (single-target store), lowerExprAsPtr
/// (address-of), and lowerMultiAssign (multi-target store); the field MATCH
/// itself is delegated to `fieldLvalueResolve` (above), so they never resolve
/// a field to a different slot or default field 0.
pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
const res = self.fieldLvalueResolve(obj_ty, field) orelse return null;
switch (res) {
.union_direct => |u| {
const ptr = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.index, .base_type = obj_ty } }, self.module.types.ptrTo(u.ty));
return .{ .ptr = ptr, .ty = u.ty };
},
.union_promoted => |u| {
const ug = self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = u.variant_index, .base_type = obj_ty } }, self.module.types.ptrTo(u.variant_ty));
const ptr = self.builder.structGepTyped(ug, u.member_index, self.module.types.ptrTo(u.ty), u.variant_ty);
return .{ .ptr = ptr, .ty = u.ty };
},
.indexed => |s| {
const ptr = self.builder.structGepTyped(obj_ptr, s.index, self.module.types.ptrTo(s.ty), obj_ty);
return .{ .ptr = ptr, .ty = s.ty };
},
}
}
/// True (and emits the diagnostic) when `obj.field` names a DIRECT variant of a
/// tagged union — a store target that would set the payload but NOT the tag
/// (issue 0136): a tagged union is laid out `{ tag, payload }`, the write path
/// emits a `union_gep` into the payload only, so the discriminant goes stale and
/// a later `match`/`==` takes the wrong arm. The variant is set via construction
/// (`x = .variant(...)`, which writes both), so a direct member write is rejected.
///
/// Returns false (keeps working) for: plain `union` (no tag); promoted / nested
/// sub-field writes (`s.rect.w = ...`, where the immediate object is the payload
/// struct, resolving to `.indexed`/`.union_promoted`, not `.union_direct`); and
/// non-aggregates. Derefs one pointer level so a `*TaggedUnion` receiver is
/// caught too. Uses the shared `fieldLvalueResolve` matcher, so the guard can't
/// drift from the store path's notion of which member a name resolves to.
pub fn diagTaggedUnionVariantWrite(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) bool {
var ty = obj_ty;
if (!ty.isBuiltin()) {
const info = self.module.types.get(ty);
if (info == .pointer) ty = info.pointer.pointee;
}
if (ty.isBuiltin() or self.module.types.get(ty) != .tagged_union) return false;
const res = self.fieldLvalueResolve(ty, field) orelse return false;
if (res != .union_direct) return false;
if (self.diagnostics) |d|
d.addFmt(.err, span, "cannot assign to tagged-union variant '{s}' directly — a member write sets the payload but leaves the tag stale; construct the variant instead (e.g. `x = .{s}(...)`)", .{ field, field });
return true;
}
/// Get the pointer (alloca ref) for an lvalue expression, without loading.
pub fn lowerExprAsPtr(self: *Lowering, node: *const Node) Ref {
switch (node.data) {
@@ -1231,6 +1318,8 @@ pub fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
.field_access => |fa| {
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).
if (self.diagTaggedUnionVariantWrite(obj_ty, fa.field, target.span)) continue;
// Resolve the target field via the shared lvalue resolver —
// the same one address-of uses — so a missing field emits a
// diagnostic instead of defaulting to field 0 / field_ty

View File

@@ -284,19 +284,27 @@ pub const ProtocolResolver = struct {
const id = if (table.findByName(name_id)) |existing| existing else table.intern(struct_info);
table.updatePreservingKey(id, struct_info);
// Build protocol method info for dispatch
// Build protocol method info for dispatch. Resolve each method's
// param/return type NAMES in the protocol's OWN declaring module
// (`pd.source_file`, stamped by `resolveImports`), via the
// visibility-aware stateful resolver — NOT the flat, visibility-unaware
// `type_bridge.resolveAstType`. The flat lookup picks the WRONG author
// when the type name collides across modules (issue 0132: the user's
// `Event` enum vs the stdlib `event.Event` struct pulled in by
// `modules/std.sx`). This mirrors the parameterized-protocol path
// (`instantiateParamProtocol`, lower/protocol.zig) and concrete-fn
// signatures, which already pin to the defining module. `Self` short-
// circuits to `*void` before the leaf, as before. `pd.source_file ==
// null` (synthesized decl) falls back to the current context.
var method_infos = std.ArrayList(ProtocolMethodInfo).empty;
for (pd.methods) |method| {
var ptypes = std.ArrayList(TypeId).empty;
for (method.params) |p| {
// Self → *void for protocol context; everything else
// goes through `resolveAstType`, threaded with the canonical
// alias map (`ProgramIndex.type_alias_map`).
const pty = blk: {
if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) {
break :blk void_ptr_ty;
}
break :blk type_bridge.resolveAstType(p, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map);
break :blk self.l.resolveTypeInSource(pd.source_file, p);
};
ptypes.append(self.l.alloc, pty) catch unreachable;
}
@@ -306,7 +314,7 @@ pub const ProtocolResolver = struct {
ret_is_self = true;
break :blk void_ptr_ty;
}
break :blk type_bridge.resolveAstType(rt, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map);
break :blk self.l.resolveTypeInSource(pd.source_file, rt);
} else .void;
method_infos.append(self.l.alloc, .{
.name = method.name,