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.
302 lines
13 KiB
Markdown
302 lines
13 KiB
Markdown
# 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
|
|
`union` compiles to an `.unresolved`-typed `struct_init` and trips the
|
|
LLVM-emission tripwire. The RHS struct literal never receives its target
|
|
type (the union member's type), so it lowers as `.unresolved`.
|
|
|
|
- **Observed:** `thread … panic: unresolved type reached LLVM emission —
|
|
a type resolution failure was not diagnosed/aborted`
|
|
(`src/backend/llvm/types.zig:176`), reached from
|
|
`emitStructInit` (`src/backend/llvm/ops.zig:1211`) because the
|
|
`struct_init` instruction's `ty` is `.unresolved`.
|
|
- **Expected:** the literal types itself as the union member's struct type
|
|
(here `S`) and stores into the member — exactly as it already does when
|
|
the left-hand side is a STRUCT field.
|
|
|
|
This is PRE-EXISTING (reproduces on `master` / before any issue-0132
|
|
work) and ORTHOGONAL to type-name resolution: it reproduces with a
|
|
unique, non-colliding type name. Surfaced while testing issue 0132's
|
|
broader-latent fix (making enum/union payload registration
|
|
visibility-aware) — that fix makes a *colliding*-name union member
|
|
resolve to the correct type, at which point this separate codegen bug is
|
|
what blocks the end-to-end union case.
|
|
|
|
## Reproduction
|
|
|
|
Minimal, standalone (only `modules/std.sx`):
|
|
|
|
```sx
|
|
#import "modules/std.sx";
|
|
|
|
S :: struct { code: i64; }
|
|
U :: union { a: i64; b: S; }
|
|
|
|
main :: () {
|
|
u : U = ---;
|
|
u.b = .{ code = 9 }; // <-- panics: struct literal has no target type
|
|
print("code={}\n", u.b.code);
|
|
}
|
|
```
|
|
|
|
Run: `./zig-out/bin/sx run issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
|
|
→ panics today; the fix should make it print `code=9`, exit 0.
|
|
|
|
### Bisection (what does / does not trigger it)
|
|
|
|
| Variant | Result |
|
|
|---|---|
|
|
| `u.b = .{ code = 9 }` (union member ← struct LITERAL) | **PANICS** |
|
|
| `o.b = .{ code = 9 }` where `o : Outer = struct { a; b: S }` (STRUCT member ← literal) | **OK** |
|
|
| `s : S = .{ code = 9 }; u.b = s` (union member ← pre-made value) | **OK** |
|
|
| `u : U = ---` then only read (no literal assign) | **OK** |
|
|
|
|
So the trigger is exactly the conjunction **(LHS is a union member) AND
|
|
(RHS is a struct literal)**. A struct-field LHS propagates the target
|
|
type to the literal; a pre-made value needs no target type. Only the
|
|
union-member-lvalue + literal-RHS combination drops it.
|
|
|
|
## Investigation prompt
|
|
|
|
> Assigning a struct literal to a NAMED-struct member of a plain `union`
|
|
> panics with "unresolved type reached LLVM emission". Repro:
|
|
> `issues/0133-union-member-struct-literal-assign-unresolved-panic.sx`
|
|
> (expect a panic today; the fix should make it print `code=9`, exit 0).
|
|
>
|
|
> The `struct_init` instruction for the RHS literal `.{ code = 9 }` has
|
|
> `ty == .unresolved` — the literal was lowered without a target type, so
|
|
> it could not resolve to the union member's struct type `S`. The panic
|
|
> is the codegen tripwire in `src/backend/llvm/types.zig:176`
|
|
> (`toLLVMTypeInfo`), reached from `emitStructInit`
|
|
> (`src/backend/llvm/ops.zig:1211`).
|
|
>
|
|
> Root area: assignment lowering in `src/ir/lower.zig` —
|
|
> `lowerAssignment`'s `.field_access` target path. Issue 0094 already
|
|
> routes the lvalue POINTER through the shared `fieldLvaluePtr` (which
|
|
> correctly resolves union/tagged-union direct members — that's why a
|
|
> pre-made value stores fine). The gap is the RHS TARGET TYPE: for a
|
|
> STRUCT-field LHS the code sets `self.target_type` to the field's type
|
|
> before lowering the RHS (so a struct literal types itself), but for a
|
|
> UNION-member LHS that target-type propagation is missing, so the
|
|
> literal lowers under a null/unresolved target → `struct_init.ty ==
|
|
> .unresolved`.
|
|
>
|
|
> Suspected fix: before lowering the RHS expression in
|
|
> `lowerAssignment`'s field-access path, compute the LHS member's type
|
|
> for union / tagged-union members too (reuse the same member-type lookup
|
|
> `fieldLvaluePtr` already performs — ideally have it RETURN the resolved
|
|
> field type, or factor a `fieldLvalueType` helper, so the lvalue-pointer
|
|
> path and the target-type path cannot diverge — the two-resolver defect
|
|
> class this codebase keeps burning on) and set `self.target_type` to it
|
|
> for the RHS lowering. Do NOT paper over with an `.unresolved`→default;
|
|
> per CLAUDE.md, resolve the real member type or emit a diagnostic.
|
|
>
|
|
> Verification: the repro prints `code=9` exit 0; then `zig build &&
|
|
> zig build test` green. Add positive coverage (a union member written
|
|
> via struct literal, then read back) — extend
|
|
> `examples/0166-types-union-promoted-member-lvalue.sx` or add a new
|
|
> `examples/01xx-types-union-member-struct-literal-assign.sx`. When
|
|
> 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`
|
|
(`toLLVMTypeInfo`, `.unresolved` arm) via `emitStructInit`
|
|
(`src/backend/llvm/ops.zig:1211`).
|
|
- Root area (cause): `Lowering.lowerAssignment` `.field_access` target
|
|
path in `src/ir/lower.zig` — RHS target-type not set for union/
|
|
tagged-union members.
|
|
- Related but distinct: issue 0094 (RESOLVED) fixed the lvalue-POINTER
|
|
field resolution (missing-field panic + `.i64`/field-0 defaults). This
|
|
issue is the RHS-literal TARGET-TYPE path, which 0094 did not touch.
|