fix(ir): missing-field multi-assign + promoted-union-member lvalue [F0.10]

Completes the issue-0094 fix. attempt-1 made single-assign and address-of
diagnose a missing struct field; the stress-review found two remaining defects
in that change:

1. lowerMultiAssign's `.field_access` target kept the pre-fix shape — a
   struct-only loop that defaulted `field_idx 0` / `field_ty .unresolved` on a
   miss, then built the GEP and stored unconditionally. A missing field
   (`p.q, y = 2, 3`) silently wrote field 0 (printed `x=2 y=3`, no diagnostic),
   and a valid promoted-union / tuple member at a non-zero offset corrupted
   field 0 instead of its own slot.

2. attempt-1's new union branch in lowerExprAsPtr resolved only DIRECT union
   field names, so `@v.x` on a promoted anonymous-struct member reported
   "field 'x' not found on type 'Vec2'" even though `v.x = 41` worked.

Both lvalue-pointer sites and the multi-assign store now route through one
shared resolver, `fieldLvaluePtr`, that handles struct fields, union direct
fields, promoted anonymous-struct union members, and tuple elements, and
returns null (no field-0 / `.unresolved` default) on a genuine miss. Each
caller emits the read path's `emitFieldError` on null. This collapses the
three previously-divergent field-lvalue walks into one, fixing the
multi-assign missing-field corruption, the promoted-member over-rejection,
and (as a side effect of correct resolution) non-zero-offset promoted-union
and tuple multi-assign stores. The types.zig tripwire is untouched.

Regression tests:
- examples/1145 extended: multi-assign missing field (`p.r, y`) errors, exit 1.
- examples/0166 (new): promoted union member written and address-of'd,
  including a non-zero-offset member (`@v.y`), compiles and runs.
- src/ir/lower.test.zig: multi-assign missing-field field-not-found unit test.
This commit is contained in:
agra
2026-06-05 14:00:24 +03:00
parent e13518e8aa
commit c98bebc4e3
9 changed files with 238 additions and 76 deletions

View File

@@ -1121,6 +1121,59 @@ test "lower: assigning to a missing struct field emits field-not-found, no panic
try std.testing.expect(found);
}
test "lower: multi-assign to a missing struct field emits field-not-found, no corruption (issue 0094)" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var module = ir_mod.Module.init(alloc);
defer module.deinit();
var diags = errors.DiagnosticList.init(alloc, "", "test.sx");
defer diags.deinit();
// Register `Point :: struct { x: s64; }` so the struct literal resolves.
const fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{
.{ .name = module.types.internString("x"), .ty = .s64 },
};
_ = module.types.intern(.{ .@"struct" = .{ .name = module.types.internString("Point"), .fields = &fields } });
const span = ast.Span{ .start = 0, .end = 0 };
// main :: () { p := Point.{ x = 1 }; y := 0; p.r, y = 3, 4; } — `r` is not a field of Point.
var x_val = Node{ .span = span, .data = .{ .int_literal = .{ .value = 1 } } };
const field_inits = [_]ast.StructFieldInit{.{ .name = "x", .value = &x_val }};
var lit = Node{ .span = span, .data = .{ .struct_literal = .{ .struct_name = "Point", .field_inits = &field_inits } } };
var decl = Node{ .span = span, .data = .{ .var_decl = .{ .name = "p", .name_span = span, .type_annotation = null, .value = &lit } } };
var y_init = Node{ .span = span, .data = .{ .int_literal = .{ .value = 0 } } };
var y_decl = Node{ .span = span, .data = .{ .var_decl = .{ .name = "y", .name_span = span, .type_annotation = null, .value = &y_init } } };
var p_ident = Node{ .span = span, .data = .{ .identifier = .{ .name = "p" } } };
var target0 = Node{ .span = span, .data = .{ .field_access = .{ .object = &p_ident, .field = "r" } } };
var target1 = Node{ .span = span, .data = .{ .identifier = .{ .name = "y" } } };
var v0 = Node{ .span = span, .data = .{ .int_literal = .{ .value = 3 } } };
var v1 = Node{ .span = span, .data = .{ .int_literal = .{ .value = 4 } } };
const targets = [_]*Node{ &target0, &target1 };
const values = [_]*Node{ &v0, &v1 };
var massign = Node{ .span = span, .data = .{ .multi_assign = .{ .targets = &targets, .values = &values } } };
const stmts = [_]*Node{ &decl, &y_decl, &massign };
var body = Node{ .span = span, .data = .{ .block = .{ .stmts = &stmts } } };
const fd = ast.FnDecl{ .name = "main", .params = &.{}, .return_type = null, .body = &body };
var lowering = Lowering.init(&module);
lowering.diagnostics = &diags;
// Pre-fix the struct-only loop defaulted field_idx 0 / field_ty .unresolved on
// a miss, silently storing into field 0 (no diagnostic); the fix resolves the
// target via the shared fieldLvaluePtr and bails with field-not-found.
lowering.lowerFunction(&fd, "main", false);
var found = false;
for (diags.items.items) |d| {
if (d.level == .err and std.mem.indexOf(u8, d.message, "field 'r' not found on type 'Point'") != null) found = true;
}
try std.testing.expect(found);
}
test "lower: reflectionArgIsType accepts spelled types, rejects plain values (issue 0090)" {
const alloc = std.testing.allocator;
var module = ir_mod.Module.init(alloc);

View File

@@ -2564,6 +2564,86 @@ pub const Lowering = struct {
}
}
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.
/// Handles union direct fields, promoted anonymous-struct union members,
/// tuple elements (numeric or named), and plain struct fields. Returns null
/// when no field matches; the caller emits the field-not-found diagnostic.
/// Single source of lvalue field resolution shared by lowerExprAsPtr
/// (address-of) and lowerMultiAssign (multi-target store) so the two never
/// resolve a field to a different slot or default field 0 (issue 0094).
fn fieldLvaluePtr(self: *Lowering, obj_ptr: Ref, obj_ty: TypeId, field: []const u8) ?FieldLvalue {
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.
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) {
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 };
}
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), sf.ty, f.ty);
return .{ .ptr = ptr, .ty = sf.ty };
}
}
}
}
}
return null;
}
// Tuple element: `.0` (numeric) or `.name`.
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| {
const elem_ty = tup.fields[idx];
const ptr = self.builder.structGepTyped(obj_ptr, @intCast(idx), elem_ty, obj_ty);
return .{ .ptr = ptr, .ty = elem_ty };
}
return null;
}
// 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), f.ty, obj_ty);
return .{ .ptr = ptr, .ty = f.ty };
}
}
return null;
}
/// Get the pointer (alloca ref) for an lvalue expression, without loading.
fn lowerExprAsPtr(self: *Lowering, node: *const Node) Ref {
switch (node.data) {
@@ -2608,40 +2688,15 @@ pub const Lowering = struct {
obj_ty = info.pointer.pointee;
}
}
const field_name_id = self.module.types.internString(fa.field);
// Union / tagged-union field address: all variants overlay at
// offset 0, so the lvalue pointer is a union_gep — mirrors the
// write path (lowerAssignment) so the lvalue-pointer and the store
// resolve the same field index. A non-struct aggregate would
// otherwise miss the struct-field loop below and fall through.
if (!obj_ty.isBuiltin()) {
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 self.builder.emit(.{ .union_gep = .{ .base = obj_ptr, .field_index = @intCast(i), .base_type = obj_ty } }, self.module.types.ptrTo(f.ty));
}
}
}
}
const struct_fields = self.getStructFields(obj_ty);
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
return self.builder.structGepTyped(obj_ptr, @intCast(i), f.ty, obj_ty);
}
}
// No struct/union field matches — emit the read path's
// field-not-found diagnostic (lowerFieldAccessOnType → emitFieldError)
// instead of silently GEPing field 0 as .s64. That bogus pointer
// mislowers the lvalue and reaches LLVM emission as
// ptrTo(.unresolved), panicking (issue 0094).
// Resolve the field lvalue (struct / union direct / promoted
// anonymous-struct member / tuple element) via the shared
// resolver so address-of and the multi-target store path never
// disagree on the slot. No match → emit the read path's
// field-not-found diagnostic (lowerFieldAccessOnType →
// emitFieldError) instead of silently GEPing field 0 as .s64;
// that bogus pointer reaches LLVM emission as ptrTo(.unresolved)
// and panics (issue 0094).
if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| return r.ptr;
return self.emitFieldError(obj_ty, fa.field, node.span);
},
.index_expr => |ie| {
@@ -8776,24 +8831,21 @@ pub const Lowering = struct {
.field_access => |fa| {
const obj_ptr = self.lowerExprAsPtr(fa.object);
const obj_ty = self.inferExprType(fa.object);
const field_name_id = self.module.types.internString(fa.field);
const struct_fields = self.getStructFields(obj_ty);
var field_idx: u32 = 0;
var field_ty: TypeId = .unresolved;
for (struct_fields, 0..) |f, fi| {
if (f.name == field_name_id) {
field_idx = @intCast(fi);
field_ty = f.ty;
break;
}
// 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
// .unresolved, which silently corrupted a neighbouring field
// (or panicked at LLVM emission) (issue 0094).
if (self.fieldLvaluePtr(obj_ptr, obj_ty, fa.field)) |r| {
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != r.ty and val_ty != .void and r.ty != .void)
self.coerceToType(val, val_ty, r.ty)
else
val;
self.builder.store(r.ptr, store_val);
} else {
_ = self.emitFieldError(obj_ty, fa.field, target.span);
}
const gep = self.builder.structGepTyped(obj_ptr, field_idx, field_ty, obj_ty);
const val_ty = self.builder.getRefType(val);
const store_val = if (val_ty != field_ty and val_ty != .void and field_ty != .void)
self.coerceToType(val, val_ty, field_ty)
else
val;
self.builder.store(gep, store_val);
},
.deref_expr => |de| {
const ptr = self.lowerExpr(de.operand);