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.
This commit is contained in:
@@ -1609,6 +1609,7 @@ pub const Lowering = struct {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
@@ -993,6 +997,33 @@ pub fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []co
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -1287,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
|
||||
|
||||
Reference in New Issue
Block a user