diff --git a/examples/0138-types-global-aggregate-null-pointer-field.sx b/examples/0138-types-global-aggregate-null-pointer-field.sx new file mode 100644 index 0000000..a292e57 --- /dev/null +++ b/examples/0138-types-global-aggregate-null-pointer-field.sx @@ -0,0 +1,48 @@ +// A module-global aggregate initializer may carry `null` in a pointer field: +// `null` is a compile-time constant (the zero pointer), so the field reads back +// as null with NO prior store, and its non-pointer neighbors keep their declared +// values. Covered shapes: an array-of-struct with a null pointer field, a global +// array of all-null pointers, and a nested struct-in-struct with a null pointer. +// Regression (issue 0081): the constant-aggregate serializer had no +// `.null_literal` arm, so a `null` in a pointer field made the whole aggregate +// look non-constant and the global was rejected with "must be initialized by a +// compile-time constant". The fix serializes a null literal to a constant zero +// pointer (the same way a top-level pointer global `p : *s64 = null;` does) +// while still rejecting genuinely non-constant fields (see diagnostics 1126). + +#import "modules/std.sx"; + +Box :: struct { p: *s64; marker: s64; } +Inner :: struct { q: *s64; tag: s64; } +Outer :: struct { inner: Inner; label: s64; } + +// array-of-struct with null pointer fields + scalar neighbors +boxes : [2]Box = .[ .{ p = null, marker = 11 }, .{ p = null, marker = 22 } ]; +// global array of all-null pointers +ptrs : [3]*s64 = .[ null, null, null ]; +// nested: struct containing a struct with a null pointer field +nested : [2]Outer = .[ + .{ inner = .{ q = null, tag = 1 }, label = 100 }, + .{ inner = .{ q = null, tag = 2 }, label = 200 }, +]; + +main :: () { + print("boxes ptrs={},{} markers={},{}\n", + boxes[0].p == null, boxes[1].p == null, boxes[0].marker, boxes[1].marker); + print("ptr arr nulls={},{},{}\n", ptrs[0] == null, ptrs[1] == null, ptrs[2] == null); + print("nested q nulls={},{} tags={},{} labels={},{}\n", + nested[0].inner.q == null, nested[1].inner.q == null, + nested[0].inner.tag, nested[1].inner.tag, + nested[0].label, nested[1].label); + + if boxes[0].p == null and boxes[1].p == null + and boxes[0].marker == 11 and boxes[1].marker == 22 + and ptrs[0] == null and ptrs[1] == null and ptrs[2] == null + and nested[0].inner.q == null and nested[1].inner.q == null + and nested[0].inner.tag == 1 and nested[1].inner.tag == 2 + and nested[0].label == 100 and nested[1].label == 200 { + print("PASS\n"); + } else { + print("FAIL: global aggregate null pointer field mis-serialized\n"); + } +} diff --git a/examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx b/examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx new file mode 100644 index 0000000..d2dd9e2 --- /dev/null +++ b/examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx @@ -0,0 +1,21 @@ +// A module-global aggregate with a NULL pointer field is fine (null is a +// compile-time constant), but a sibling field initialized from a NON-constant +// expression (here a runtime function call) must still be rejected loudly. The +// presence of an accepted `null` must NOT widen the gate to admit the +// non-constant neighbor. +// Regression (issue 0081): the null-pointer fix must not regress the +// reject-loud behavior for genuinely non-constant initializers (issues +// 0072/0080). Expected: "global 'boxes' must be initialized by a compile-time +// constant"; exit 1. + +#import "modules/std.sx"; + +runtime_marker :: () -> s64 { return 7; } + +Box :: struct { p: *s64; marker: s64; } +boxes : [1]Box = .[ .{ p = null, marker = runtime_marker() } ]; + +main :: () -> s32 { + print("marker={}\n", boxes[0].marker); + return 0; +} diff --git a/examples/expected/0138-types-global-aggregate-null-pointer-field.exit b/examples/expected/0138-types-global-aggregate-null-pointer-field.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0138-types-global-aggregate-null-pointer-field.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0138-types-global-aggregate-null-pointer-field.stderr b/examples/expected/0138-types-global-aggregate-null-pointer-field.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0138-types-global-aggregate-null-pointer-field.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0138-types-global-aggregate-null-pointer-field.stdout b/examples/expected/0138-types-global-aggregate-null-pointer-field.stdout new file mode 100644 index 0000000..972574d --- /dev/null +++ b/examples/expected/0138-types-global-aggregate-null-pointer-field.stdout @@ -0,0 +1,4 @@ +boxes ptrs=true,true markers=11,22 +ptr arr nulls=true,true,true +nested q nulls=true,true tags=1,2 labels=100,200 +PASS diff --git a/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.exit b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stderr b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stderr new file mode 100644 index 0000000..b2df99e --- /dev/null +++ b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stderr @@ -0,0 +1,5 @@ +error: global 'boxes' must be initialized by a compile-time constant + --> examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx:16:18 + | +16 | boxes : [1]Box = .[ .{ p = null, marker = runtime_marker() } ]; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stdout b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1126-diagnostics-global-aggregate-non-const-field-rejected.stdout @@ -0,0 +1 @@ + diff --git a/issues/0081-global-aggregate-null-literal-rejected.md b/issues/0081-global-aggregate-null-literal-rejected.md new file mode 100644 index 0000000..863f9d5 --- /dev/null +++ b/issues/0081-global-aggregate-null-literal-rejected.md @@ -0,0 +1,116 @@ +# 0081 - global aggregate null literal rejected as non-constant + +> **RESOLVED.** +> **Root cause:** `Lowering.constExprValue` (`src/ir/lower.zig`) — the +> constant-aggregate serializer for global initializers — had no +> `.null_literal` arm. A `null` in a pointer (or optional-pointer) field +> therefore returned no constant, which propagated up through +> `constStructLiteral` / `constArrayLiteral` and made the whole aggregate look +> non-constant, so `globalInitValue` rejected it with "must be initialized by a +> compile-time constant". A `null` is a compile-time constant (the zero +> pointer) and a top-level scalar pointer global (`p : *s64 = null;`) already +> serialized fine — only the nested-aggregate path was wrong. +> **Fix:** add `.null_literal => .null_val` to `constExprValue` so a null leaf +> serializes to a constant zero pointer. Made the LLVM constant emitters +> exhaustive while at it: `emitConstAggregate` and the top-level `init_val` +> switch in `src/ir/emit_llvm.zig` previously ended in a silent +> `else => LLVMConstNull(...)` catch-all (the precise silent-arm class CLAUDE.md +> mandates rooting out); they now handle every `ConstantValue` tag explicitly +> (`.null_val`/`.zeroinit` → all-zero constant, `.undef` → `LLVMGetUndef`, +> `.func_ref` resolved, nested `.vtable` is a hard `@panic` tripwire since +> vtables are top-level-only). The reject-loud path for genuinely non-constant +> fields (a runtime call, etc.) is preserved. +> **Regression:** `examples/0138-types-global-aggregate-null-pointer-field.sx` +> (array-of-struct with null pointer fields, global array of all-null pointers, +> nested struct-in-struct null pointer — asserts null reads + correct neighbors) +> and the negative `examples/1126-diagnostics-global-aggregate-non-const-field-rejected.sx` +> (a null pointer field beside a non-constant field still errors loudly). +> Verified fail-before (pre-fix rejects 0138) / pass-after. + +## Symptom + +A module-global aggregate initializer rejects a `null` literal in a pointer +field as "not a compile-time constant"; expected the null pointer to serialize +as a constant zero pointer the same way a top-level pointer global does. + +## Reproduction + +```sx +#import "modules/std.sx"; + +Box :: struct { + p: *s64; + marker: s64; +} + +boxes : [2]Box = .[ + .{ p = null, marker = 11 }, + .{ p = null, marker = 22 }, +]; + +main :: () -> s32 { + print("ptrs={} {} markers={} {}\n", + boxes[0].p == null, + boxes[1].p == null, + boxes[0].marker, + boxes[1].marker); + if boxes[0].p == null and boxes[1].p == null and boxes[0].marker == 11 and boxes[1].marker == 22 { + return 0; + } + return 1; +} +``` + +Observed: + +```text +error: global 'boxes' must be initialized by a compile-time constant +``` + +Expected: + +```text +ptrs=true true markers=11 22 +``` + +## Investigation prompt + +Fix issue 0081: module-global aggregate initializers reject `null` literals in +pointer fields even though `null` is a compile-time constant pointer value. + +Suspected area: +- `src/ir/lower.zig`, `Lowering.constExprValue` — the switch has no + `.null_literal` arm, so `constStructLiteral` treats a pointer field initialized + with `null` as non-constant and `globalInitValue` reports the whole aggregate. +- `src/ir/emit_llvm.zig`, top-level `global.init_val` emission and + `LLVMEmitter.emitConstAggregate` — both currently rely on catch-all + `else => LLVMConstNull(...)` for several `ConstantValue` tags. If `.null_val` + is threaded through aggregate constants, add explicit `.null_val` handling + there (and explicit `.zeroinit` / `.undef` handling as appropriate) rather + than depending on the catch-all. + +Likely fix: +- Add `.null_literal => .null_val` to `constExprValue` for constant aggregate + serialization. +- Ensure LLVM constant emission handles `.null_val` explicitly for both + top-level constants and nested aggregate leaves. +- Keep unsupported aggregate expressions loud: non-constant calls/field-accesses + should still diagnose instead of zero-initializing. + +Verification: +- Run the repro above and expect: + +```text +ptrs=true true markers=11 22 +``` + +- Add a pinned regression in the `01xx` types block covering a global + array-of-struct with pointer-null fields (and, if straightforward, optional + null fields too). +- Run: + +```sh +zig build +zig build test +bash tests/run_examples.sh +``` diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index e246e59..e17a91b 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -902,7 +902,12 @@ pub const LLVMEmitter = struct { .string => |sid| self.emitConstStringGlobal(self.ir_mod.types.getString(sid)), .aggregate => |agg| self.emitConstAggregate(agg, llvm_ty), .vtable => c.LLVMConstNull(llvm_ty), // placeholder — initialized in initVtableGlobals after function declarations - else => c.LLVMConstNull(llvm_ty), + // A top-level null-pointer global (`p : *s64 = null;`) and a + // zero-initialized global both emit as the all-zero constant + // of the global's type (issue 0081). + .null_val, .zeroinit => c.LLVMConstNull(llvm_ty), + .undef => c.LLVMGetUndef(llvm_ty), + .func_ref => |fid| self.func_map.get(fid.index()) orelse c.LLVMConstNull(llvm_ty), }; c.LLVMSetInitializer(llvm_global, init_val); } else { @@ -2433,7 +2438,13 @@ pub const LLVMEmitter = struct { .string => |sid| self.emitConstStringGlobal(self.ir_mod.types.getString(sid)), .aggregate => |inner| self.emitConstAggregate(inner, elem_ty), .func_ref => |fid| self.func_map.get(fid.index()) orelse c.LLVMConstNull(elem_ty), - else => c.LLVMConstNull(elem_ty), + // A null pointer field and a zero-initialized field both emit as + // the all-zero constant of the leaf type (issue 0081). + .null_val, .zeroinit => c.LLVMConstNull(elem_ty), + .undef => c.LLVMGetUndef(elem_ty), + // Vtable constants are only ever produced for top-level protocol + // vtable globals (lower.zig), never as a nested aggregate leaf. + .vtable => @panic("nested vtable constant in aggregate is unsupported — vtables are top-level globals only"), }; } if (is_struct) { diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 793d520..fa8987a 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1033,6 +1033,10 @@ pub const Lowering = struct { .float_literal => |fl| .{ .float = fl.value }, .string_literal => |sl| .{ .string = self.module.types.internString(sl.raw) }, .undef_literal => .zeroinit, + // A `null` in a pointer (or optional-pointer) field is a + // compile-time constant: the zero pointer. Without this arm the + // aggregate is wrongly rejected as non-constant (issue 0081). + .null_literal => .null_val, .unary_op => |uo| switch (uo.op) { .negate => switch (uo.operand.data) { .int_literal => |il| .{ .int = -il.value },