fix(ir): missing struct field assignment errors cleanly, no LLVM panic [F0.10]

Assigning to a nonexistent struct field (`p.q = 2` where Point has no `q`)
aborted the compiler with the `.unresolved` LLVM tripwire instead of a source
diagnostic (issue 0094). The lvalue field lookup never diagnosed a miss:

- `lowerAssignment`'s `.field_access` target left `field_ty = .unresolved` when
  no struct field matched, then built `ptrTo(field_ty)` and stored — so a
  pointer-to-`.unresolved` reached LLVM emission and tripped the panic.
- `lowerExprAsPtr`'s `.field_access` fallback returned
  `structGepTyped(obj_ptr, 0, .s64, obj_ty)` on a miss — a silent field-0/`.s64`
  default that mislowered the lvalue.

Both sites now reuse the read path's `emitFieldError` (the exact facility
`lowerFieldAccessOnType` uses), so read and write reject identically with
`field 'q' not found on type 'Point'`. `lowerExprAsPtr` also resolves
union/tagged-union fields via `union_gep` (the old `.s64` fallback was silently
standing in for union field access — e.g. `u.a[0] = v`), so that path is fixed,
not just made loud. The `types.zig` tripwire is untouched: the fix is to never
produce `.unresolved` for a missing-field store.

Regression tests:
- examples/1145-diagnostics-missing-struct-field-assign.sx — negative, both
  sites error, exit 1.
- examples/0165-types-nested-struct-field-assign.sx — positive, nested struct
  field write + address-of a matched field still work.
- src/ir/lower.test.zig — lowering unit test asserting the field-not-found
  diagnostic for a missing-field assignment.
This commit is contained in:
agra
2026-06-05 13:24:15 +03:00
parent 22c2e60efc
commit e13518e8aa
11 changed files with 210 additions and 2 deletions

View File

@@ -1074,6 +1074,53 @@ test "lower: vectorLaneIndex maps swizzle components, colour aliases, rejects no
try std.testing.expectEqual(@as(?u32, null), Lowering.vectorLaneIndex(""));
}
test "lower: assigning to a missing struct field emits field-not-found, no panic (issue 0094)" {
// Arena keeps the leak checker quiet — DiagnosticList.addFmt allocates
// messages it never frees in deinit (mixed ownership with borrowed literals).
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 }; p.q = 2; } — `q` 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 p_ident = Node{ .span = span, .data = .{ .identifier = .{ .name = "p" } } };
var target = Node{ .span = span, .data = .{ .field_access = .{ .object = &p_ident, .field = "q" } } };
var rhs = Node{ .span = span, .data = .{ .int_literal = .{ .value = 2 } } };
var assign = Node{ .span = span, .data = .{ .assignment = .{ .target = &target, .op = .assign, .value = &rhs } } };
const stmts = [_]*Node{ &decl, &assign };
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 this stored through a pointer-to-`.unresolved` that panicked at LLVM
// emission; the fix bails with the read path's field-not-found diagnostic.
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 'q' 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

@@ -2472,13 +2472,25 @@ pub const Lowering = struct {
const struct_fields = self.getStructFields(obj_ty);
var field_idx: u32 = 0;
var field_ty: TypeId = .unresolved;
var found = false;
for (struct_fields, 0..) |f, i| {
if (f.name == field_name_id) {
field_idx = @intCast(i);
field_ty = f.ty;
found = true;
break;
}
}
if (!found) {
// No struct field matches the assignment target. Emit the
// same field-not-found diagnostic the read path uses
// (lowerFieldAccessOnType → emitFieldError) and bail; building
// ptrTo(field_ty) with field_ty = .unresolved would otherwise
// store through a pointer-to-.unresolved that panics at LLVM
// emission (issue 0094).
_ = self.emitFieldError(obj_ty, fa.field, asgn.target.span);
return;
}
// Wrap in ptrTo so the store handler sees *field_ty (consistent
// with index_gep which uses ptrTo(elem_ty)). Without this, a
// [*]BigNode field makes the store handler extract BigNode as the
@@ -2596,14 +2608,41 @@ pub const Lowering = struct {
obj_ty = info.pointer.pointee;
}
}
const struct_fields = self.getStructFields(obj_ty);
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);
}
}
return self.builder.structGepTyped(obj_ptr, 0, .s64, 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).
return self.emitFieldError(obj_ty, fa.field, node.span);
},
.index_expr => |ie| {
const idx = self.lowerExpr(ie.index);