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:
21
examples/0165-types-nested-struct-field-assign.sx
Normal file
21
examples/0165-types-nested-struct-field-assign.sx
Normal file
@@ -0,0 +1,21 @@
|
||||
// Writing through a nested struct field lvalue (`outer.inner.x = v`) and taking
|
||||
// the address of a valid field both resolve the field pointer correctly: the
|
||||
// lvalue-pointer path (lowerExprAsPtr) GEPs the matched field, never a silent
|
||||
// field-0 default. Positive companion to the missing-field diagnostic (1145).
|
||||
#import "modules/std.sx";
|
||||
|
||||
Inner :: struct { a: s64; b: s64; }
|
||||
Outer :: struct { inner: Inner; tag: s64; }
|
||||
|
||||
bump :: (p: *s64) { p.* = p.* + 100; }
|
||||
|
||||
main :: () {
|
||||
o := Outer.{ inner = Inner.{ a = 1, b = 2 }, tag = 9 };
|
||||
o.inner.a = 11; // nested struct field store via lowerExprAsPtr
|
||||
o.inner.b = 22;
|
||||
o.tag = 33; // direct struct field store
|
||||
print("a={} b={} tag={}\n", o.inner.a, o.inner.b, o.tag);
|
||||
|
||||
bump(@o.inner.a); // address-of a matched nested field
|
||||
print("a2={}\n", o.inner.a);
|
||||
}
|
||||
24
examples/1145-diagnostics-missing-struct-field-assign.sx
Normal file
24
examples/1145-diagnostics-missing-struct-field-assign.sx
Normal file
@@ -0,0 +1,24 @@
|
||||
// Assigning to a field that does not exist on a struct produces the same
|
||||
// `field 'X' not found on type 'Y'` diagnostic as the read path (1100), and
|
||||
// exits 1 — never the `.unresolved` LLVM-emission panic.
|
||||
//
|
||||
// Regression (issue 0094): the lvalue field lookup left `field_ty = .unresolved`
|
||||
// (lowerAssignment's assignment-target path) or silently GEP'd field 0 as `.s64`
|
||||
// (lowerExprAsPtr's fallback), so a missing-field store built a
|
||||
// pointer-to-`.unresolved` that panicked at LLVM emission. Both the
|
||||
// assignment-target path (`p.q`) and the nested lvalue-pointer path
|
||||
// (`o.missing.a`) now emit the field-not-found diagnostic.
|
||||
|
||||
Point :: struct { x: s64; }
|
||||
Inner :: struct { a: s64; }
|
||||
Outer :: struct { inner: Inner; }
|
||||
|
||||
main :: () -> s32 {
|
||||
p := Point.{ x = 1 };
|
||||
p.q = 2; // site 1: lowerAssignment target path
|
||||
|
||||
o := Outer.{ inner = Inner.{ a = 1 } };
|
||||
o.missing.a = 5; // site 2: lowerExprAsPtr fallback
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
a=11 b=22 tag=33
|
||||
a2=111
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,11 @@
|
||||
error: field 'q' not found on type 'Point'
|
||||
--> examples/1145-diagnostics-missing-struct-field-assign.sx:18:5
|
||||
|
|
||||
18 | p.q = 2; // site 1: lowerAssignment target path
|
||||
| ^^^
|
||||
|
||||
error: field 'missing' not found on type 'Outer'
|
||||
--> examples/1145-diagnostics-missing-struct-field-assign.sx:21:5
|
||||
|
|
||||
21 | o.missing.a = 5; // site 2: lowerExprAsPtr fallback
|
||||
| ^^^^^^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 0094 — assigning to a missing struct field panics with "unresolved type reached LLVM emission"
|
||||
|
||||
> **RESOLVED** (F0.10). **Root cause:** the lvalue field lookup never diagnosed a
|
||||
> missing field. In `Lowering.lowerAssignment`'s `.field_access` target path
|
||||
> (`src/ir/lower.zig`), `field_ty` started as `.unresolved`; when no struct field
|
||||
> matched, the code still built `ptrTo(field_ty)` / `structGepTyped` and stored —
|
||||
> so a pointer-to-`.unresolved` reached LLVM emission and tripped the
|
||||
> `src/backend/llvm/types.zig` tripwire. The nested lvalue-pointer path
|
||||
> (`Lowering.lowerExprAsPtr`'s `.field_access` fallback) had the sibling defect:
|
||||
> on a miss it returned `structGepTyped(obj_ptr, 0, .s64, obj_ty)` — a silent
|
||||
> field-0/`.s64` default.
|
||||
>
|
||||
> **Fix (`src/ir/lower.zig`):**
|
||||
> 1. `lowerAssignment` `.field_access` target — track a `found` flag over the
|
||||
> struct-field loop; on a miss, emit the read path's field-not-found
|
||||
> diagnostic (`emitFieldError`) and bail, never constructing
|
||||
> `ptrTo(.unresolved)`.
|
||||
> 2. `lowerExprAsPtr` `.field_access` — resolve union/tagged-union fields via
|
||||
> `union_gep` (mirroring the write path; the old `.s64` fallback was silently
|
||||
> standing in for union field access), then the struct-field loop, then
|
||||
> `emitFieldError` on a genuine miss. The `.s64` sentinel is gone.
|
||||
>
|
||||
> Both sites now reuse `emitFieldError` (the exact facility the read path
|
||||
> `lowerFieldAccessOnType` uses), so the read and write paths reject identically.
|
||||
> 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),
|
||||
> and a lowering unit test in `src/ir/lower.test.zig`
|
||||
> ("assigning to a missing struct field emits field-not-found, no panic").
|
||||
|
||||
## Symptom
|
||||
Assigning to a nonexistent struct field (`p.q = ...`) panics during LLVM emission instead of reporting a source diagnostic.
|
||||
|
||||
Observed: the compiler reaches the `.unresolved` LLVM tripwire in `src/backend/llvm/types.zig:175` via `emitStore`.
|
||||
Expected: a normal compile error like `field 'q' not found on type 'Point'`, matching the read-field diagnostic path.
|
||||
|
||||
## Reproduction
|
||||
```sx
|
||||
Point :: struct { x: s64; }
|
||||
|
||||
main :: () {
|
||||
p := Point.{ x = 1 };
|
||||
p.q = 2;
|
||||
}
|
||||
```
|
||||
|
||||
Running `./zig-out/bin/sx run repro.sx` currently panics with:
|
||||
```text
|
||||
panic: unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted
|
||||
```
|
||||
|
||||
## Investigation prompt
|
||||
Fix issue 0094 in the sx compiler: assigning to a missing struct field (`p.q = 2`) panics with `.unresolved` reaching LLVM emission instead of emitting a field-not-found diagnostic.
|
||||
|
||||
Suspected area: `src/ir/lower.zig`, especially `Lowering.lowerAssignment`'s `.field_access` target path around the struct-field lookup (`field_ty` starts as `.unresolved`, no matched field diagnoses, then `ptrTo(field_ty)` is stored) and the related `Lowering.lowerExprAsPtr` field-access fallback that returns `structGepTyped(obj_ptr, 0, .s64, obj_ty)` on lookup failure. The fix should make failed lvalue field lookup loud, reusing `emitFieldError(obj_ty, field, span)` or equivalent, and should not use `.s64`, `.void`, or any real type as a sentinel.
|
||||
|
||||
Verification: run the repro and expect exit 1 with a source diagnostic `field 'q' not found on type 'Point'`; no LLVM panic. Then run `zig build`, `zig build test`, and `bash tests/run_examples.sh`.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user