fix(ir): serialize null pointer fields in global aggregates (issue 0081)

A module-global aggregate initializer rejected a `null` literal in a
pointer (or optional-pointer) field as "must be initialized by a
compile-time constant". `Lowering.constExprValue` had no `.null_literal`
arm, so the null leaf returned no constant and the whole aggregate looked
non-constant — even though `null` is the compile-time zero pointer (a
top-level scalar `p : *s64 = null;` already serialized fine).

Add `.null_literal => .null_val` to constExprValue. While here, make the
two LLVM constant emitters exhaustive: emitConstAggregate and the
top-level init_val switch in emit_llvm.zig previously ended in a silent
`else => LLVMConstNull(...)` catch-all (the 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). The
reject-loud path for genuinely non-constant fields is preserved.

Regression: examples/0138 (array-of-struct null ptr fields, array of
all-null pointers, nested struct-in-struct null ptr) and the negative
examples/1126 (null ptr field beside a non-const field still errors).
Fail-before/pass-after verified.
This commit is contained in:
agra
2026-06-04 04:22:43 +03:00
parent e93879816d
commit d680b320f4
11 changed files with 215 additions and 2 deletions

View File

@@ -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");
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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() } ];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@@ -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
```

View File

@@ -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) {

View File

@@ -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 },