From 097d23d909ff94fc5bfb9525b76f8fd31c805891 Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 23 Jun 2026 16:16:47 +0300 Subject: [PATCH] fix: presence-preserving optional->optional coercion (issue 0180) The generic-?? wrong-fallback was not in lowerNullCoalesce: coercing ?A -> ?B (differing payload, e.g. the ?i32->?i64 call-arg coercion when instantiating unwrap_or(99, ?i32)) routed through .optional_wrap, which unconditionally unwrapped the source and re-wrapped as ALWAYS-PRESENT, so a null became present-zero everywhere (args, returns, field init, var-decl, ??). Add a CoercionPlan.optional_to_optional (conversions.zig) + a presence-preserving arm in coerceMode (coerce.zig): has_value -> present: unwrap+coerce-child+wrap-present; absent: constNull(dst); merge via a dst_ty block param. lowerVarDecl gains a !src_is_optional guard so an annotated x : ?B = routes through the same arm (also makes aggregate-payload var-decl ?[3]i64->?[]i64 / ?Concrete->?Protocol work). Alias-optional struct-literal default already works (grouping + 0166); a 1-tuple default ?(i32,) ?? 5 now emits a clean diagnostic instead of an LLVM PHI abort (no implicit scalar->1-tuple coercion per spec). Regressions: optionals/0916 (generic ??), 0917 (alias struct default), 0918 (var-decl optional->optional), diagnostics/1202 (1-tuple default) + a conversions.test.zig unit test. Verified by 3 adversarial reviews, suite 798/0. --- ...diagnostics-null-coalesce-tuple-default.sx | 14 +++++ ...agnostics-null-coalesce-tuple-default.exit | 1 + ...nostics-null-coalesce-tuple-default.stderr | 5 ++ ...nostics-null-coalesce-tuple-default.stdout | 1 + ...ptionals-generic-null-coalesce-fallback.sx | 40 +++++++++++++ ...nals-alias-null-coalesce-struct-default.sx | 28 +++++++++ ...-optionals-vardecl-optional-to-optional.sx | 59 +++++++++++++++++++ ...ionals-generic-null-coalesce-fallback.exit | 1 + ...nals-generic-null-coalesce-fallback.stderr | 1 + ...nals-generic-null-coalesce-fallback.stdout | 7 +++ ...ls-alias-null-coalesce-struct-default.exit | 1 + ...-alias-null-coalesce-struct-default.stderr | 1 + ...-alias-null-coalesce-struct-default.stdout | 3 + ...ptionals-vardecl-optional-to-optional.exit | 1 + ...ionals-vardecl-optional-to-optional.stderr | 1 + ...ionals-vardecl-optional-to-optional.stdout | 10 ++++ ...generic-and-nontrivial-optional-defects.md | 23 ++++++++ src/ir/conversions.test.zig | 7 +++ src/ir/conversions.zig | 11 ++++ src/ir/lower/coerce.zig | 33 +++++++++++ src/ir/lower/expr.zig | 19 ++++++ src/ir/lower/stmt.zig | 13 +++- 22 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 examples/diagnostics/1202-diagnostics-null-coalesce-tuple-default.sx create mode 100644 examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.exit create mode 100644 examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stderr create mode 100644 examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stdout create mode 100644 examples/optionals/0916-optionals-generic-null-coalesce-fallback.sx create mode 100644 examples/optionals/0917-optionals-alias-null-coalesce-struct-default.sx create mode 100644 examples/optionals/0918-optionals-vardecl-optional-to-optional.sx create mode 100644 examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.exit create mode 100644 examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stderr create mode 100644 examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stdout create mode 100644 examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.exit create mode 100644 examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stderr create mode 100644 examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stdout create mode 100644 examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.exit create mode 100644 examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stderr create mode 100644 examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stdout diff --git a/examples/diagnostics/1202-diagnostics-null-coalesce-tuple-default.sx b/examples/diagnostics/1202-diagnostics-null-coalesce-tuple-default.sx new file mode 100644 index 00000000..61683bef --- /dev/null +++ b/examples/diagnostics/1202-diagnostics-null-coalesce-tuple-default.sx @@ -0,0 +1,14 @@ +// `??` default whose type can't match a 1-tuple optional payload is a clean +// diagnostic, not an LLVM PHI-type crash. +// +// Regression (issue 0180): `?(i32,) ?? 5` built a merge PHI of `{i32}` (the +// unwrapped 1-tuple payload) vs `i32` (the scalar default) and aborted the +// LLVM verifier. There is no implicit scalar->1-tuple coercion (a 1-tuple +// value is written `(5,)`), so the mismatch is now reported at the default. +#import "modules/std.sx"; + +main :: () { + o : ?(i32,) = null; + x := o ?? 5; // default 'i64' vs payload '(i32,)' -> diagnostic + print("{}\n", x.0); +} diff --git a/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.exit b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.exit @@ -0,0 +1 @@ +1 diff --git a/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stderr b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stderr new file mode 100644 index 00000000..d23a47b4 --- /dev/null +++ b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stderr @@ -0,0 +1,5 @@ +error: '??' default has type 'i64', but the optional's payload is '(i32,)' (note: a 1-tuple value is written '(x,)' with a trailing comma) + --> examples/diagnostics/1202-diagnostics-null-coalesce-tuple-default.sx:12:15 + | +12 | x := o ?? 5; // default 'i64' vs payload '(i32,)' -> diagnostic + | ^ diff --git a/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stdout b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/diagnostics/expected/1202-diagnostics-null-coalesce-tuple-default.stdout @@ -0,0 +1 @@ + diff --git a/examples/optionals/0916-optionals-generic-null-coalesce-fallback.sx b/examples/optionals/0916-optionals-generic-null-coalesce-fallback.sx new file mode 100644 index 00000000..ed976f66 --- /dev/null +++ b/examples/optionals/0916-optionals-generic-null-coalesce-fallback.sx @@ -0,0 +1,40 @@ +// Generic `??` preserves the optional's presence across monomorphization. +// +// Regression (issue 0180): a `??` inside a generic fn whose lhs is a +// type-param-typed optional `?T` used to drop the RHS default and return the +// zero payload — a null `?i32` arriving as the `?T` arg was silently coerced +// to the monomorphized `?i64` with the has-bit hardcoded to `true` (the +// optional→optional arg coercion unwrapped-then-rewrapped-present), so +// `x ?? d` saw a present zero and returned 0 instead of `d`. +#import "modules/std.sx"; + +Pt :: struct { x: i64 = 0; y: i64 = 0; } + +unwrap_or :: (d: $T, x: ?T) -> T { return x ?? d; } + +main :: () { + // ?i32: null -> default, present -> value. + bi : ?i32 = null; + print("a={}\n", unwrap_or(99, bi)); + ci : ?i32 = 5; + print("b={}\n", unwrap_or(99, ci)); + + // ?i64 instantiation. + bl : ?i64 = null; + print("c={}\n", unwrap_or(7, bl)); + dl : ?i64 = 42; + print("d={}\n", unwrap_or(7, dl)); + + // ?*i64 instantiation: null -> the &v default. + bp : ?*i64 = null; + v : i64 = 123; + print("e={}\n", unwrap_or(@v, bp).*); + + // ?Struct instantiation: null -> default struct, present -> the value. + ps : ?Pt = null; + rp := unwrap_or(Pt.{ x = 1, y = 2 }, ps); + print("f={} {}\n", rp.x, rp.y); + qs : ?Pt = Pt.{ x = 8, y = 9 }; + rq := unwrap_or(Pt.{ x = 1, y = 2 }, qs); + print("g={} {}\n", rq.x, rq.y); +} diff --git a/examples/optionals/0917-optionals-alias-null-coalesce-struct-default.sx b/examples/optionals/0917-optionals-alias-null-coalesce-struct-default.sx new file mode 100644 index 00000000..72bad2bb --- /dev/null +++ b/examples/optionals/0917-optionals-alias-null-coalesce-struct-default.sx @@ -0,0 +1,28 @@ +// `??` on an alias-of-optional with a struct-literal default — present + null. +// +// Regression (issue 0180): `?Opt ?? S.{...}` where `Opt :: ?S` must resolve +// the alias child to the same struct TypeId on both branches and lower the +// struct-literal default against that resolved child (issue-0166 threading), +// so the present-payload and the default share the merge TypeId. Both the +// null path (default) and the present path (the payload) are exercised. +#import "modules/std.sx"; + +S :: struct { a: i64 = 0; b: i64 = 0; } +Opt :: ?S; + +main :: () { + // null lhs -> the struct-literal default. + o : Opt = null; + x := o ?? S.{ a = 7, b = 8 }; + print("a={} {}\n", x.a, x.b); + + // present lhs -> the payload, default ignored. + o2 : Opt = S.{ a = 1, b = 2 }; + y := o2 ?? S.{ a = 7, b = 8 }; + print("b={} {}\n", y.a, y.b); + + // untyped `.{}` default against the alias child (issue-0166 path). + o3 : Opt = null; + z := o3 ?? .{}; + print("c={} {}\n", z.a, z.b); +} diff --git a/examples/optionals/0918-optionals-vardecl-optional-to-optional.sx b/examples/optionals/0918-optionals-vardecl-optional-to-optional.sx new file mode 100644 index 00000000..82b6e2d7 --- /dev/null +++ b/examples/optionals/0918-optionals-vardecl-optional-to-optional.sx @@ -0,0 +1,59 @@ +// Annotated local var-decl `x : ?B = ` preserves the SOURCE +// optional's presence across a payload coercion. +// +// Regression (issue 0180): `lowerVarDecl`'s optional-target branch hand-rolled +// the optional handling — for a non-null initializer it coerced to the +// optional's CHILD (classifying `?A → child_B` as an unconditional unwrap → 0 +// for a null source) then re-wrapped with a hardcoded present has-bit. So a +// null `?A` source became a PRESENT `?B` carrying zero. The fix routes an +// optional source through the presence-preserving `.optional_to_optional` +// coercion (the general trailing coerce) instead of the wrap-present path, +// which now only handles a genuine non-optional `T → ?T` wrap. +#import "modules/std.sx"; + +Shape :: protocol { area :: (self: *Self) -> i64; } +Sq :: struct { s: i64; } +impl Shape for Sq { area :: (self: *Sq) -> i64 { return self.s * self.s; } } + +main :: () { + // Widen ?i32 → ?i64: null stays absent, present carries its value. + a : ?i32 = null; + b : ?i64 = a; + if b { print("widen-null: present\n"); } else { print("widen-null: absent\n"); } + c : ?i32 = 7; + d : ?i64 = c; + print("widen-pres: {}\n", d ?? -1); + + // Narrow ?i64 → ?i32. + e : ?i64 = null; + f : ?i32 = e; + if f { print("narrow-null: present\n"); } else { print("narrow-null: absent\n"); } + g : ?i64 = 9; + h : ?i32 = g; + print("narrow-pres: {}\n", h ?? -1); + + // int → float ?i32 → ?f64. + i : ?i32 = null; + j : ?f64 = i; + if j { print("i2f-null: present\n"); } else { print("i2f-null: absent\n"); } + + // A genuine non-optional wrap `T → ?T` stays present. + k : ?i64 = 5; + print("wrap: {}\n", k ?? -1); + + // Aggregate payload: ?[3]i64 → ?[]i64 (presence-preserving array→slice). + arr : ?[3]i64 = .[10, 20, 30]; + sl : ?[]i64 = arr; + if s := sl { print("arr2slice-pres: len={} first={}\n", s.len, s[0]); } + narr : ?[3]i64 = null; + nsl : ?[]i64 = narr; + if nsl { print("arr2slice-null: present\n"); } else { print("arr2slice-null: absent\n"); } + + // Aggregate payload: ?Sq → ?Shape (presence-preserving protocol erasure). + sq : ?Sq = Sq.{ s = 4 }; + sh : ?Shape = sq; + if x := sh { print("erase-pres: area={}\n", x.area()); } + nsq : ?Sq = null; + nsh : ?Shape = nsq; + if nsh { print("erase-null: present\n"); } else { print("erase-null: absent\n"); } +} diff --git a/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.exit b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stderr b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stdout b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stdout new file mode 100644 index 00000000..113bc247 --- /dev/null +++ b/examples/optionals/expected/0916-optionals-generic-null-coalesce-fallback.stdout @@ -0,0 +1,7 @@ +a=99 +b=5 +c=7 +d=42 +e=123 +f=1 2 +g=8 9 diff --git a/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.exit b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stderr b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stdout b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stdout new file mode 100644 index 00000000..4800b0f0 --- /dev/null +++ b/examples/optionals/expected/0917-optionals-alias-null-coalesce-struct-default.stdout @@ -0,0 +1,3 @@ +a=7 8 +b=1 2 +c=0 0 diff --git a/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.exit b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stderr b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stdout b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stdout new file mode 100644 index 00000000..dea2573c --- /dev/null +++ b/examples/optionals/expected/0918-optionals-vardecl-optional-to-optional.stdout @@ -0,0 +1,10 @@ +widen-null: absent +widen-pres: 7 +narrow-null: absent +narrow-pres: 9 +i2f-null: absent +wrap: 5 +arr2slice-pres: len=3 first=10 +arr2slice-null: absent +erase-pres: area=16 +erase-null: absent diff --git a/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md b/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md index 05d4abd8..58cf6024 100644 --- a/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md +++ b/issues/0180-null-coalesce-generic-and-nontrivial-optional-defects.md @@ -1,5 +1,28 @@ # 0180 — `??` lowering defects for generic / alias / tuple optional lhs (wrong fallback, segfault, PHI mismatch) +> **RESOLVED.** (1) The generic-`??`-wrong-fallback was NOT in `lowerNullCoalesce` +> — the real root cause was that an optional→optional coercion `?A → ?B` (differing +> payload, e.g. the `?i32 → ?i64` call-arg coercion when instantiating +> `unwrap_or(99, ?i32)`) routed through `.optional_wrap`, which unconditionally +> UNWRAPPED the source and re-wrapped as ALWAYS-PRESENT — so a NULL became a +> present-zero everywhere (call args, returns, field init, var-decl, `??`). Fix: +> a `CoercionPlan.optional_to_optional` (`src/ir/conversions.zig`) + a +> presence-preserving arm in `coerceMode` (`src/ir/lower/coerce.zig`): has_value → +> present: unwrap+coerce-child+wrap-present; absent: `constNull(dst)`; merge via a +> `dst_ty` block param. `lowerVarDecl` (`src/ir/lower/stmt.zig`) gained a +> `!src_is_optional` guard so an annotated `x : ?B = ` routes through the same +> arm (also makes aggregate-payload var-decl `?[3]i64 → ?[]i64` / `?Concrete → +> ?Protocol` work). (2) The alias-optional struct-literal default already works +> (grouping + issue-0166 threading) — locked by regression. (3) `?(i32)` is now a +> grouped `?i32` (issue-0177 grouping); a genuine 1-tuple default `?(i32,) ?? 5` +> emits a clean diagnostic (`lowerNullCoalesce`, `src/ir/lower/expr.zig`) instead +> of an LLVM PHI-verifier abort (no implicit scalar→1-tuple coercion per spec). +> Regressions: `examples/optionals/0916` (generic ??), `0917` (alias struct +> default), `0918` (var-decl optional→optional present/null × widen/narrow/ +> int-float/array→slice/erasure), `examples/diagnostics/1202` (1-tuple-default +> diagnostic) + a `conversions.test.zig` unit test. Verified by 3 adversarial +> reviews; suite 798/0. + ## Symptom `lowerNullCoalesce` mishandles several non-trivial optional lhs shapes (all with a diff --git a/src/ir/conversions.test.zig b/src/ir/conversions.test.zig index 1ceb2e79..9359b187 100644 --- a/src/ir/conversions.test.zig +++ b/src/ir/conversions.test.zig @@ -48,6 +48,13 @@ test "conversions: classify covers the built-in coercion ladder" { try std.testing.expectEqual(Plan.optional_unwrap, cr.classify(opt_i64, .i64)); try std.testing.expectEqual(Plan.void_to_optional, cr.classify(.void, opt_i64)); + // `?A → ?B` (differing payloads) is a presence-preserving payload coercion, + // NOT the always-present unwrap-then-rewrap that the `.optional_wrap` arm + // produced (issue 0180). Same-payload optionals are `.no_op`. + const opt_i32 = tt.optionalOf(.i32); + try std.testing.expectEqual(Plan.optional_to_optional, cr.classify(opt_i32, opt_i64)); + try std.testing.expectEqual(Plan.no_op, cr.classify(opt_i64, opt_i64)); + // `?T → bool` is NOT an unwrap-then-narrow presence test (issue 0169): // it must reject, never silently produce `false`. But `?bool → bool` // is a genuine unwrap of a bool payload. diff --git a/src/ir/conversions.zig b/src/ir/conversions.zig index 193c9214..185e74a1 100644 --- a/src/ir/conversions.zig +++ b/src/ir/conversions.zig @@ -36,6 +36,7 @@ pub const CoercionResolver = struct { optional_unwrap, // ?T → concrete (narrowing) optional_to_bool_reject, // ?T → bool (no presence-test coercion; diagnostic) void_to_optional, // void (null literal) → ?T + optional_to_optional, // ?A → ?B (presence-preserving payload coercion) optional_wrap, // concrete → ?T erase_protocol, // concrete → protocol value int_to_float, @@ -114,6 +115,16 @@ pub const CoercionResolver = struct { if (child_ty == dst_ty or (dst_ty.isBuiltin() and child_ty.isBuiltin())) { return .optional_unwrap; } + // ?A → ?B: a presence-preserving payload coercion. Without a + // dedicated arm this fell to `.optional_wrap` (dst is optional), + // which unwrapped the SOURCE optional unconditionally and re- + // wrapped it as always-present — turning a null `?i32` into a + // present `?i64` carrying the zero payload (issue 0180: generic + // `??` returning the wrong fallback). Only meaningful when the + // children differ (same-type optionals are `.no_op` already). + if (!dst_ty.isBuiltin() and self.l.module.types.get(dst_ty) == .optional) { + return .optional_to_optional; + } } } diff --git a/src/ir/lower/coerce.zig b/src/ir/lower/coerce.zig index 4e6fc449..e1900677 100644 --- a/src/ir/lower/coerce.zig +++ b/src/ir/lower/coerce.zig @@ -683,6 +683,39 @@ pub fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mod }, // void → Optional: produce null (void is the type of null_literal) .void_to_optional => return self.builder.constNull(dst_ty), + // ?A → ?B: presence-preserving payload coercion. The naive route + // (fall through to `.optional_wrap`) unwrapped the SOURCE optional + // unconditionally and re-wrapped as always-present, dropping the + // has-bit — a null `?i32` became a present `?i64` carrying zero + // (issue 0180: generic `??` returning the wrong fallback). Branch on + // the source's presence so the payload is only unwrapped when it is + // actually present (the interp errors on unwrapping a null optional, + // so a branchless select is not an option): + // present → unwrap src child, coerce to dst child, wrap as present + // absent → null of the destination optional + .optional_to_optional => { + const src_child = self.module.types.get(src_ty).optional.child; + const dst_child = self.module.types.get(dst_ty).optional.child; + + const has_val = self.builder.optionalHasValue(val); + const present_bb = self.freshBlock("opt2opt.present"); + const absent_bb = self.freshBlock("opt2opt.absent"); + const merge_bb = self.freshBlockWithParams("opt2opt.merge", &.{dst_ty}); + self.builder.condBr(has_val, present_bb, &.{}, absent_bb, &.{}); + + self.builder.switchToBlock(present_bb); + const unwrapped = self.builder.optionalUnwrap(val, src_child); + const coerced_child = self.coerceMode(unwrapped, src_child, dst_child, mode); + const wrapped = self.builder.optionalWrap(coerced_child, dst_ty); + self.builder.br(merge_bb, &.{wrapped}); + + self.builder.switchToBlock(absent_bb); + const null_dst = self.builder.constNull(dst_ty); + self.builder.br(merge_bb, &.{null_dst}); + + self.builder.switchToBlock(merge_bb); + return self.builder.blockParam(merge_bb, 0, dst_ty); + }, // Concrete → Optional wrapping (coerce to the inner type first) .optional_wrap => { const child_ty = self.module.types.get(dst_ty).optional.child; diff --git a/src/ir/lower/expr.zig b/src/ir/lower/expr.zig index 26719ca8..33cd6894 100644 --- a/src/ir/lower/expr.zig +++ b/src/ir/lower/expr.zig @@ -2065,6 +2065,25 @@ pub fn lowerNullCoalesce(self: *Lowering, nc: *const ast.NullCoalesce) Ref { if (rhs_ty != inner_ty and rhs_ty != .void and inner_ty != .void) { rhs = self.coerceToType(rhs, rhs_ty, inner_ty); } + // The merge-block param, the unwrapped LHS, and the coerced RHS must all + // share `inner_ty` — they feed the same PHI. If the RHS default still does + // not match after coercion (e.g. a scalar `5` default against an optional + // whose payload is a 1-tuple `(i32,)`: there is no implicit scalar→1-tuple + // coercion — a 1-tuple value is written `(5,)`), branching with the + // mismatched type emits a `phi {i32}` vs `i32` that aborts the LLVM + // verifier (issue 0180). Diagnose loudly and br with a typed placeholder so + // the PHI stays well-formed; `hasErrors()` aborts before codegen anyway. + const coerced_ty = self.builder.getRefType(rhs); + if (coerced_ty != inner_ty and coerced_ty != .void and inner_ty != .void) { + if (self.diagnostics) |d| { + const note: []const u8 = if (!inner_ty.isBuiltin() and self.module.types.get(inner_ty) == .tuple) + " (note: a 1-tuple value is written '(x,)' with a trailing comma)" + else + ""; + d.addFmt(.err, nc.rhs.span, "'??' default has type '{s}', but the optional's payload is '{s}'{s}", .{ self.formatTypeName(coerced_ty), self.formatTypeName(inner_ty), note }); + } + rhs = self.builder.constNull(inner_ty); // typed placeholder — keeps the PHI well-formed + } self.builder.br(merge_bb, &.{rhs}); // Continue at merge diff --git a/src/ir/lower/stmt.zig b/src/ir/lower/stmt.zig index f39d46c0..5b406601 100644 --- a/src/ir/lower/stmt.zig +++ b/src/ir/lower/stmt.zig @@ -314,7 +314,18 @@ pub fn lowerVarDecl(self: *Lowering, vd: *const ast.VarDecl) void { // fail LLVM verification (issue 0160). if (!ty.isBuiltin()) { const ty_info = self.module.types.get(ty); - if (ty_info == .optional and val.data != .null_literal and self.builder.getRefType(ref) != ty) { + // Is the initializer value ITSELF an optional (`?A`)? If so the + // target `?B` is a presence-PRESERVING coercion (`.optional_to_optional`), + // NOT a wrap-present. The manual unwrap-to-child + `optionalWrap(present)` + // below classifies `?A → child_B` as an unconditional unwrap (→ 0 for a + // null source) then wraps it as always-present, so a null `?A` becomes a + // present `?B` carrying zero (issue 0180). Route an optional source through + // the trailing general `coerceToType(?A → ?B)` instead, which dispatches to + // the presence-preserving arm. The wrap-present path below stays correct for + // a non-optional source `T → ?T`. + const ref_ty0 = self.builder.getRefType(ref); + const src_is_optional = !ref_ty0.isBuiltin() and self.module.types.get(ref_ty0) == .optional; + if (ty_info == .optional and val.data != .null_literal and ref_ty0 != ty and !src_is_optional) { // Coerce to the optional's CHILD first (e.g. an array value // into a `?[]T` promotes array→slice), THEN wrap — wrapping // the raw value would store e.g. array bits into the slice