Files
sx/issues/0081-global-aggregate-null-literal-rejected.md
agra d680b320f4 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.
2026-06-04 04:22:43 +03:00

4.2 KiB

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, .undefLLVMGetUndef, .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

#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:

error: global 'boxes' must be initialized by a compile-time constant

Expected:

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:
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:
zig build
zig build test
bash tests/run_examples.sh