diff --git a/examples/optionals/0912-null-coalesce-struct-literal.sx b/examples/optionals/0912-null-coalesce-struct-literal.sx new file mode 100644 index 00000000..97c29177 --- /dev/null +++ b/examples/optionals/0912-null-coalesce-struct-literal.sx @@ -0,0 +1,30 @@ +// Struct literal as the default of `??` (null-coalesce). +// Regression (issue 0166): a bare `.{ ... }` default was lowered with no +// target type, so its `struct_init.ty` stayed `.unresolved` and panicked at +// LLVM emission ("unresolved type reached LLVM emission"). The fix threads the +// optional's child type `T` into the RHS lowering, so the literal resolves to +// `T`. Covers both runtime paths: default TAKEN (lhs null) and NOT taken (lhs +// present), plus a nested struct-literal default and an all-defaulted `.{}`. +#import "modules/std.sx"; + +Inner :: struct { x: i64 = 0; } +T :: struct { a: i64 = 7; b: i64 = 3; inner: Inner; } + +mk :: (give: bool) -> ?T { + if give { return .{ a = 1, b = 2, inner = .{ x = 4 } }; } + return null; +} + +main :: () { + // Default NOT taken — lhs has a value. + present := mk(true) ?? .{ a = 9, inner = .{ x = 99 } }; + print("{} {} {}\n", present.a, present.b, present.inner.x); + + // Default TAKEN — lhs is null, struct-literal default selected. + absent := mk(false) ?? .{ a = 9, inner = .{ x = 99 } }; + print("{} {} {}\n", absent.a, absent.b, absent.inner.x); + + // All-defaulted `.{}` default. + empty := mk(false) ?? .{}; + print("{} {} {}\n", empty.a, empty.b, empty.inner.x); +} diff --git a/examples/optionals/expected/0912-null-coalesce-struct-literal.exit b/examples/optionals/expected/0912-null-coalesce-struct-literal.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/optionals/expected/0912-null-coalesce-struct-literal.exit @@ -0,0 +1 @@ +0 diff --git a/examples/optionals/expected/0912-null-coalesce-struct-literal.stderr b/examples/optionals/expected/0912-null-coalesce-struct-literal.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/optionals/expected/0912-null-coalesce-struct-literal.stderr @@ -0,0 +1 @@ + diff --git a/examples/optionals/expected/0912-null-coalesce-struct-literal.stdout b/examples/optionals/expected/0912-null-coalesce-struct-literal.stdout new file mode 100644 index 00000000..839d11c3 --- /dev/null +++ b/examples/optionals/expected/0912-null-coalesce-struct-literal.stdout @@ -0,0 +1,3 @@ +1 2 4 +9 3 99 +7 3 0 diff --git a/issues/0166-null-coalesce-struct-literal-default-unresolved.md b/issues/0166-null-coalesce-struct-literal-default-unresolved.md index b6253840..af36aeec 100644 --- a/issues/0166-null-coalesce-struct-literal-default-unresolved.md +++ b/issues/0166-null-coalesce-struct-literal-default-unresolved.md @@ -1,5 +1,16 @@ # 0166 — `?? .{ ... }` struct-literal default panics with "unresolved type reached LLVM emission" +> **RESOLVED.** The `??` RHS struct literal was lowered with no target type, so +> its `struct_init.ty` stayed `.unresolved` and reached `emitStructInit`. Fix: +> in `src/ir/lower/expr.zig` `lowerNullCoalesce`, save `self.target_type`, set it +> to `inner_ty` (the optional's resolved child) before lowering `nc.rhs`, and +> restore afterward (unconditional, leak-free — `lowerExpr` returns a plain +> `Ref`). Verified across struct/slice/enum/tuple/protocol/nested-optional/ +> generic child types and present/absent branches by 3 adversarial reviews. +> Regression: `examples/optionals/0912-null-coalesce-struct-literal.sx`. +> (Adjacent pre-existing bug found + filed: 0172 — `??` on a NON-optional lhs +> panics; `lowerNullCoalesce` must diagnose it.) + ## Symptom Using a struct literal as the default of a `??` (null-coalesce) operator panics: diff --git a/issues/0172-null-coalesce-non-optional-lhs-panics.md b/issues/0172-null-coalesce-non-optional-lhs-panics.md new file mode 100644 index 00000000..994f9258 --- /dev/null +++ b/issues/0172-null-coalesce-non-optional-lhs-panics.md @@ -0,0 +1,43 @@ +# 0172 — `??` with a non-optional left-hand side panics instead of diagnosing + +## Symptom + +Using `??` where the left operand is NOT an optional panics the compiler: +`panic: unresolved type reached LLVM emission` (in `emitStructInit` for a struct +default, or generally), exit 134. `??` is defined to operate on an optional lhs; +a non-optional lhs is malformed user input that must be a clean type error, not a +crash. Pre-existing (reproduces independent of the issue 0166 fix). + +## Reproduction + +```sx +#import "modules/std.sx"; +T :: struct { a: i64 = 0; } +main :: () { + x := 5 ?? .{ a = 1 }; // panic: unresolved type reached LLVM emission, exit 134 +} +``` + +Also panics: `5 ?? 7` (scalar default), `some_non_optional_struct ?? .{ a = 1 }`, +and nested `mk() ?? (5 ?? .{ a = 3 })`. Expected: a located diagnostic like +`error: left operand of '??' must be an optional, but has type 'i64'`, exit 1. + +## Investigation prompt + +`src/ir/lower/expr.zig` `lowerNullCoalesce`: `resolveOptionalInner` (~expr.zig:1900) +returns `.unresolved` when `nc.lhs` is not optional, and the function proceeds to +feed that `.unresolved` into the merge-block params, `optionalUnwrap`, and the +RHS target type — which then reaches codegen and panics. Add a guard: after +inferring the lhs type, if it is not an optional (or `resolveOptionalInner` +yields `.unresolved` for a resolved-but-non-optional lhs), emit +`self.diagnostics.addFmt(.err, nc.lhs.span, "left operand of '??' must be an +optional, but has type '{s}'", .{ formatTypeName(lhs_ty) })` and bail (return a +placeholder), mirroring the non-pointer `.*` deref diagnostic at +`lowerDerefExpr` (~expr.zig:1839). Be careful to still allow the legitimate +cases: optional lhs (incl. `a?.b` chains returning optional), and make sure an +already-`.unresolved` lhs from a PRIOR error (undefined name) doesn't +double-report (that path already diagnoses via name resolution). + +Verify: `5 ?? .{a=1}`, `5 ?? 7`, non-optional-struct `?? ...` all exit 1 with the +diagnostic and no panic; existing optional `??` cases still work. Add an +`examples/diagnostics/11xx-null-coalesce-non-optional.sx` negative regression. diff --git a/src/ir/lower/expr.zig b/src/ir/lower/expr.zig index 97cc3ebf..5184725e 100644 --- a/src/ir/lower/expr.zig +++ b/src/ir/lower/expr.zig @@ -1871,7 +1871,16 @@ pub fn lowerNullCoalesce(self: *Lowering, nc: *const ast.NullCoalesce) Ref { // RHS block: evaluate fallback and branch to merge self.builder.switchToBlock(rhs_bb); + // Thread the optional's child type as the expected/target type so an + // untyped struct literal default (`?? .{ ... }`) resolves to `T` rather + // than staying `.unresolved` and reaching codegen as a malformed + // struct_init (issue 0166). Scalar/pointer/typed defaults are unaffected: + // they ignore `target_type` or coerce identically. Restore afterwards so + // a `??` nested inside a larger expression doesn't leak this target type. + const saved_tt = self.target_type; + self.target_type = inner_ty; var rhs = self.lowerExpr(nc.rhs); + self.target_type = saved_tt; const rhs_ty = self.builder.getRefType(rhs); if (rhs_ty != inner_ty and rhs_ty != .void and inner_ty != .void) { rhs = self.coerceToType(rhs, rhs_ty, inner_ty);