Files
sx/issues/0133-union-member-struct-literal-assign-unresolved-panic.md
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

13 KiB

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

#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.ziglowerAssignment'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 buildProtocolErasurelowerExprAsPtr(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 --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:

-            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:

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.