Compare commits
4 Commits
45befed698
...
e386a0d0b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e386a0d0b4 | ||
|
|
4d32a4d4fb | ||
|
|
8c47268539 | ||
|
|
d3f5cb20cb |
22
examples/0184-types-union-member-struct-literal-assign.sx
Normal file
22
examples/0184-types-union-member-struct-literal-assign.sx
Normal 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
|
||||
}
|
||||
20
examples/0185-types-tagged-union-member-assign-rejected.sx
Normal file
20
examples/0185-types-tagged-union-member-assign-rejected.sx
Normal 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);
|
||||
}
|
||||
22
examples/0186-types-tagged-union-nested-field-write.sx
Normal file
22
examples/0186-types-tagged-union-nested-field-write.sx
Normal 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
|
||||
}
|
||||
44
examples/0417-protocols-protocol-return-name-collision.sx
Normal file
44
examples/0417-protocols-protocol-return-name-collision.sx
Normal 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"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
27
examples/0547-packs-xx-pack-index-to-protocol.sx
Normal file
27
examples/0547-packs-xx-pack-index-to-protocol.sx
Normal 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
|
||||
}
|
||||
25
examples/0548-packs-xx-pack-index-two-elements.sx
Normal file
25
examples/0548-packs-xx-pack-index-two-elements.sx
Normal 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
code=9
|
||||
a=5
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -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
|
||||
| ^^^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
w=9.000000 h=2.000000
|
||||
c=3.500000
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
escape!
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
7
|
||||
42
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
42
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
139
issues/0136-tagged-union-member-write-does-not-set-tag.md
Normal file
139
issues/0136-tagged-union-member-write-does-not-set-tag.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
8
specs.md
8
specs.md
@@ -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 == {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user