Files
sx/issues/0098-enum-literal-non-enum-target-silent-zero.md
agra 1bc60d3a35 fix(0098): enum literal resolves against the unwrapped optional child; non-enum targets are diagnosed
lowerEnumLiteral resolved the variant against the raw destination type,
so any non-enum destination fell into resolveVariantValue's silent
return-0 tail with the enum_init stamped as the wrong type:

  - ?E destinations produced variant 0 mis-typed as the optional
    (observed as variant 0 OR null, layout-dependent);
  - builtin destinations (i64) silently became 0;
  - unknown variants of real enums silently became variant 0;
  - a destination-less literal panicked LLVM emission (unresolved
    type reached codegen).

Now: optional destinations unwrap to the child enum (the coercion
layer's .optional_wrap handles E -> ?E), and the remaining shapes are
diagnosed — unknown variant (with the variant list, via the new
emitBadEnumVariant twin of emitBadVariant), non-enum destination, and
no destination (cascade-guarded: silent when the destination's type
already failed to resolve and was reported).

Regression tests: examples/0183 (return/assign/reassign into ?Enum,
non-zero variants, null path) + examples/1169/1170 (each diagnostic);
all three FAIL on pre-fix master. zig build test 426/426;
tests/run_examples.sh 598/598.
2026-06-12 12:35:20 +03:00

3.4 KiB

RESOLVED — 0098: enum literal in a non-enum target silently lowers to variant 0

RESOLVED (2026-06-12). Root cause: lowerEnumLiteral (src/ir/lower/expr.zig) resolved the variant against the RAW destination type. For any non-enum destination — an OPTIONAL ?E, a builtin like i64, or no destination at all — resolveVariantValue fell through its switch to the silent return 0 tail (the classic silent-fallback-default this repo's CLAUDE.md forbids), and the enum_init was stamped with the WRONG type (the optional itself / .unresolved). Fix: the literal now unwraps optional destinations and resolves against the CHILD (the coercion layer's .optional_wrap then wraps the well-typed E into ?E), and every other shape is DIAGNOSED instead of zeroed: unknown variant of a real enum (with the variant list), non-enum destination, and destination-less literal (cascade-guarded so a destination whose type already failed to resolve doesn't double- report; pre-fix this case PANICKED LLVM emission with an unresolved type). Regression tests: examples/0183-types-enum-literal-optional-target.sx (return + assignment + reassignment into ?Enum, non-zero variants, null path) and examples/1169/1170-diagnostics-enum-literal-*.sx (each refusal); all three FAIL on pre-fix master. Gates: zig build test 426/426, tests/run_examples.sh 598/598.

Symptom

An enum LITERAL whose destination is not literally the enum type silently lowers to variant 0 (or worse), with no diagnostic.

  • Observed: return .android_apk; from a -> ?Platform function is seen by the caller as .ios (variant 0) or even null, depending on the optional's layout. x : i64 = .foo; compiles and x == 0. x : Platform = .nonexistent; compiles to variant 0. v := .ios; panics LLVM emission ("unresolved type reached LLVM emission").
  • Expected: the optional case works (resolve against the child, wrap); every unresolvable case is a compile error.

Hit in production: /Users/agra/projects/distribution src/server/distd.sx ua_platform (2026-06-12) — every User-Agent "detected" as iOS because each return .<variant>; into ?Platform lowered to 0. The shipped workaround routed through a typed local (p : Platform = .android_apk; return p;).

Reproduction

#import "modules/std.sx";

Platform :: enum u8 { ios; android_apk; macos; linux; windows; }

classify :: (n: i64) -> ?Platform {
    if n == 1 { return .android_apk; }   // BUG: caller observes variant 0 / null
    return null;
}

main :: () -> i32 {
    p := classify(1);
    if p == null { return 1; }
    if p! == .android_apk { return 0; }
    return 2;
}

Observed at master d8076b9: exits 1 (null). Expected: exits 0.

Investigation prompt

Suspected area: src/ir/lower/expr.zig lowerEnumLiteral / resolveVariantValue. The literal resolves against self.target_type verbatim; an optional target isn't unwrapped, so resolveVariantValue's switch misses and returns 0, and the enum_init carries the optional TypeId itself. Fix: unwrap optional layers to the child enum before resolving (then the existing .optional_wrap coercion handles E?E), and emit diagnostics for unknown variants / non-enum destinations / no destination instead of the silent-zero tail. Verification: the repro exits 0; the diagnostics cases each error; zig build test and tests/run_examples.sh green; pin the repro as examples.