From e13518e8aada6ff2808b6d4f14501f9d3d2ed9a4 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 13:24:15 +0300 Subject: [PATCH] fix(ir): missing struct field assignment errors cleanly, no LLVM panic [F0.10] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0165-types-nested-struct-field-assign.sx | 21 +++++++ ...diagnostics-missing-struct-field-assign.sx | 24 ++++++++ ...0165-types-nested-struct-field-assign.exit | 1 + ...65-types-nested-struct-field-assign.stderr | 1 + ...65-types-nested-struct-field-assign.stdout | 2 + ...agnostics-missing-struct-field-assign.exit | 1 + ...nostics-missing-struct-field-assign.stderr | 11 ++++ ...nostics-missing-struct-field-assign.stdout | 1 + ...truct-field-assignment-unresolved-panic.md | 60 +++++++++++++++++++ src/ir/lower.test.zig | 47 +++++++++++++++ src/ir/lower.zig | 43 ++++++++++++- 11 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 examples/0165-types-nested-struct-field-assign.sx create mode 100644 examples/1145-diagnostics-missing-struct-field-assign.sx create mode 100644 examples/expected/0165-types-nested-struct-field-assign.exit create mode 100644 examples/expected/0165-types-nested-struct-field-assign.stderr create mode 100644 examples/expected/0165-types-nested-struct-field-assign.stdout create mode 100644 examples/expected/1145-diagnostics-missing-struct-field-assign.exit create mode 100644 examples/expected/1145-diagnostics-missing-struct-field-assign.stderr create mode 100644 examples/expected/1145-diagnostics-missing-struct-field-assign.stdout create mode 100644 issues/0094-missing-struct-field-assignment-unresolved-panic.md diff --git a/examples/0165-types-nested-struct-field-assign.sx b/examples/0165-types-nested-struct-field-assign.sx new file mode 100644 index 0000000..0aa8880 --- /dev/null +++ b/examples/0165-types-nested-struct-field-assign.sx @@ -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); +} diff --git a/examples/1145-diagnostics-missing-struct-field-assign.sx b/examples/1145-diagnostics-missing-struct-field-assign.sx new file mode 100644 index 0000000..5740eb6 --- /dev/null +++ b/examples/1145-diagnostics-missing-struct-field-assign.sx @@ -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; +} diff --git a/examples/expected/0165-types-nested-struct-field-assign.exit b/examples/expected/0165-types-nested-struct-field-assign.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0165-types-nested-struct-field-assign.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0165-types-nested-struct-field-assign.stderr b/examples/expected/0165-types-nested-struct-field-assign.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0165-types-nested-struct-field-assign.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0165-types-nested-struct-field-assign.stdout b/examples/expected/0165-types-nested-struct-field-assign.stdout new file mode 100644 index 0000000..ec81546 --- /dev/null +++ b/examples/expected/0165-types-nested-struct-field-assign.stdout @@ -0,0 +1,2 @@ +a=11 b=22 tag=33 +a2=111 diff --git a/examples/expected/1145-diagnostics-missing-struct-field-assign.exit b/examples/expected/1145-diagnostics-missing-struct-field-assign.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1145-diagnostics-missing-struct-field-assign.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1145-diagnostics-missing-struct-field-assign.stderr b/examples/expected/1145-diagnostics-missing-struct-field-assign.stderr new file mode 100644 index 0000000..239852e --- /dev/null +++ b/examples/expected/1145-diagnostics-missing-struct-field-assign.stderr @@ -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 + | ^^^^^^^^^ diff --git a/examples/expected/1145-diagnostics-missing-struct-field-assign.stdout b/examples/expected/1145-diagnostics-missing-struct-field-assign.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1145-diagnostics-missing-struct-field-assign.stdout @@ -0,0 +1 @@ + diff --git a/issues/0094-missing-struct-field-assignment-unresolved-panic.md b/issues/0094-missing-struct-field-assignment-unresolved-panic.md new file mode 100644 index 0000000..73f8793 --- /dev/null +++ b/issues/0094-missing-struct-field-assignment-unresolved-panic.md @@ -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`. diff --git a/src/ir/lower.test.zig b/src/ir/lower.test.zig index 04dc510..7fec684 100644 --- a/src/ir/lower.test.zig +++ b/src/ir/lower.test.zig @@ -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); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 26ae1eb..88b24c9 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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);