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.
This commit is contained in:
34
examples/0183-types-enum-literal-optional-target.sx
Normal file
34
examples/0183-types-enum-literal-optional-target.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
// Enum literals flowing into OPTIONAL destinations resolve against the
|
||||
// optional's CHILD type and wrap (issue 0098). Pre-fix, the literal fell
|
||||
// into resolveVariantValue's non-enum fallback: variant 0, mis-typed as
|
||||
// the optional itself — `return .android_apk;` was observed by callers as
|
||||
// `.ios` or even null, layout-dependent.
|
||||
#import "modules/std.sx";
|
||||
|
||||
Platform :: enum u8 { ios; android_apk; macos; linux; windows; }
|
||||
|
||||
classify :: (n: i64) -> ?Platform {
|
||||
if n == 1 { return .android_apk; } // return: literal into ?Platform
|
||||
if n == 2 { return .windows; } // non-zero, non-adjacent variant
|
||||
return null;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
p := classify(1);
|
||||
if p == null { print("BUG: null\n"); return 1; }
|
||||
if p! != .android_apk { print("BUG: wrong variant\n"); return 2; }
|
||||
|
||||
w := classify(2);
|
||||
if w == null or w! != .windows { print("BUG: windows\n"); return 3; }
|
||||
|
||||
if classify(9) != null { print("BUG: not null\n"); return 4; }
|
||||
|
||||
// assignment + reassignment: literal into a ?Platform slot
|
||||
q : ?Platform = .macos;
|
||||
if q == null or q! != .macos { print("BUG: assign\n"); return 5; }
|
||||
q = .linux;
|
||||
if q! != .linux { print("BUG: reassign\n"); return 6; }
|
||||
|
||||
print("ok\n");
|
||||
return 0;
|
||||
}
|
||||
14
examples/1169-diagnostics-enum-literal-bad-target.sx
Normal file
14
examples/1169-diagnostics-enum-literal-bad-target.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
// Enum literals against unusable destinations are DIAGNOSED, never a
|
||||
// silent variant 0 (issue 0098's sibling holes): an unknown variant of a
|
||||
// real enum, a non-enum destination type, and a destination-less literal
|
||||
// each get their own error.
|
||||
#import "modules/std.sx";
|
||||
|
||||
Platform :: enum u8 { ios; android_apk; }
|
||||
|
||||
main :: () -> i32 {
|
||||
a : Platform = .nonexistent; // unknown variant: lists the real ones
|
||||
b : i64 = .foo; // non-enum destination
|
||||
print("{}{}\n", a, b);
|
||||
return 0;
|
||||
}
|
||||
11
examples/1170-diagnostics-enum-literal-no-target.sx
Normal file
11
examples/1170-diagnostics-enum-literal-no-target.sx
Normal file
@@ -0,0 +1,11 @@
|
||||
// A destination-less enum literal is diagnosed (issue 0098's third hole —
|
||||
// it previously panicked the LLVM backend with an unresolved type). Kept
|
||||
// as the ONLY error in this file: the diagnostic is cascade-guarded, so it
|
||||
// stays silent when the destination type itself already failed to resolve.
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () -> i32 {
|
||||
c := .ios;
|
||||
print("{}\n", c);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,11 @@
|
||||
error: 'nonexistent' is not a variant of 'Platform' (variants are: ios, android_apk)
|
||||
--> examples/1169-diagnostics-enum-literal-bad-target.sx:10:20
|
||||
|
|
||||
10 | a : Platform = .nonexistent; // unknown variant: lists the real ones
|
||||
| ^^^^^^^^^^^^
|
||||
|
||||
error: enum literal '.foo' cannot type itself from non-enum destination 'i64'
|
||||
--> examples/1169-diagnostics-enum-literal-bad-target.sx:11:15
|
||||
|
|
||||
11 | b : i64 = .foo; // non-enum destination
|
||||
| ^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
error: enum literal '.ios' has no destination type to resolve against
|
||||
--> examples/1170-diagnostics-enum-literal-no-target.sx:8:10
|
||||
|
|
||||
8 | c := .ios;
|
||||
| ^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
78
issues/0098-enum-literal-non-enum-target-silent-zero.md
Normal file
78
issues/0098-enum-literal-non-enum-target-silent-zero.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
|
||||
```sx
|
||||
#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.
|
||||
@@ -1887,6 +1887,7 @@ pub const Lowering = struct {
|
||||
pub const lowerTaggedEnumLiteral = lower_expr.lowerTaggedEnumLiteral;
|
||||
pub const findTaggedVariant = lower_expr.findTaggedVariant;
|
||||
pub const emitBadVariant = lower_expr.emitBadVariant;
|
||||
pub const emitBadEnumVariant = lower_expr.emitBadEnumVariant;
|
||||
pub const resolveVariantValue = lower_expr.resolveVariantValue;
|
||||
pub const resolveVariantIndex = lower_expr.resolveVariantIndex;
|
||||
pub const lowerArrayLiteral = lower_expr.lowerArrayLiteral;
|
||||
|
||||
@@ -893,11 +893,102 @@ pub fn lowerFieldAccessOnType(self: *Lowering, obj: Ref, obj_ty: TypeId, field:
|
||||
}
|
||||
|
||||
pub fn lowerEnumLiteral(self: *Lowering, el: *const ast.EnumLiteral) Ref {
|
||||
const target = self.target_type orelse .unresolved;
|
||||
var target = self.target_type orelse .unresolved;
|
||||
|
||||
// An OPTIONAL destination types the literal by its CHILD: `.x` flowing
|
||||
// into a `?E` slot must produce an `E` for the coercion layer to wrap
|
||||
// (`.optional_wrap`). Resolving against the optional itself fell into
|
||||
// resolveVariantValue's non-enum fallback — variant 0, mis-typed as
|
||||
// the optional (issue 0098).
|
||||
while (!target.isBuiltin()) {
|
||||
const info = self.module.types.get(target);
|
||||
if (info != .optional) break;
|
||||
target = info.optional.child;
|
||||
}
|
||||
|
||||
const cs = self.builder.current_span;
|
||||
const span = ast.Span{ .start = cs.start, .end = cs.end };
|
||||
|
||||
// The destination must be a known enum / tagged union that carries the
|
||||
// named variant — every other shape used to lower to a silent 0.
|
||||
if (target == .unresolved) {
|
||||
// Cascade guard: an unresolved destination usually means the slot's
|
||||
// TYPE already failed to resolve and was diagnosed (not-visible /
|
||||
// ambiguous); a second error on the same line is noise.
|
||||
if (self.diagnostics) |d| {
|
||||
if (!d.hasErrors()) {
|
||||
d.addFmt(.err, span, "enum literal '.{s}' has no destination type to resolve against", .{el.name});
|
||||
}
|
||||
}
|
||||
return self.builder.enumInit(0, Ref.none, target);
|
||||
}
|
||||
var known_variant = false;
|
||||
if (!target.isBuiltin()) {
|
||||
const info = self.module.types.get(target);
|
||||
const name_id = self.module.types.internString(el.name);
|
||||
switch (info) {
|
||||
.@"enum" => |e| {
|
||||
for (e.variants) |v| {
|
||||
if (v == name_id) {
|
||||
known_variant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!known_variant) self.emitBadEnumVariant(target, e, el.name, span);
|
||||
},
|
||||
.tagged_union => |u| {
|
||||
for (u.fields) |f| {
|
||||
if (f.name == name_id) {
|
||||
known_variant = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!known_variant) self.emitBadVariant(target, u, el.name, span);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
if (!known_variant) {
|
||||
if (self.diagnostics) |d| {
|
||||
const builtin_or_non_enum = target.isBuiltin() or switch (self.module.types.get(target)) {
|
||||
.@"enum", .tagged_union => false,
|
||||
else => true,
|
||||
};
|
||||
if (builtin_or_non_enum) {
|
||||
d.addFmt(.err, span, "enum literal '.{s}' cannot type itself from non-enum destination '{s}'", .{ el.name, self.formatTypeName(target) });
|
||||
}
|
||||
}
|
||||
return self.builder.enumInit(0, Ref.none, target);
|
||||
}
|
||||
|
||||
const tag = self.resolveVariantValue(target, el.name);
|
||||
return self.builder.enumInit(tag, Ref.none, target);
|
||||
}
|
||||
|
||||
/// The enum twin of `emitBadVariant`: an unknown variant of a plain enum,
|
||||
/// with the legal variants listed.
|
||||
pub fn emitBadEnumVariant(
|
||||
self: *Lowering,
|
||||
enum_ty: TypeId,
|
||||
enum_info: types.TypeInfo.EnumInfo,
|
||||
variant_name: []const u8,
|
||||
span: ast.Span,
|
||||
) void {
|
||||
const diags = self.diagnostics orelse return;
|
||||
const ty_name = self.formatTypeName(enum_ty);
|
||||
var list: std.ArrayList(u8) = .empty;
|
||||
for (enum_info.variants, 0..) |v, i| {
|
||||
if (i > 0) list.appendSlice(self.alloc, ", ") catch return;
|
||||
list.appendSlice(self.alloc, self.module.types.getString(v)) catch return;
|
||||
}
|
||||
diags.addFmt(
|
||||
.err,
|
||||
span,
|
||||
"'{s}' is not a variant of '{s}' (variants are: {s})",
|
||||
.{ variant_name, ty_name, list.items },
|
||||
);
|
||||
}
|
||||
|
||||
/// Lower an `error.X` tag literal to its global tag id (a `u32`). When the
|
||||
/// destination context (`target_type`) is a named error set, the value is
|
||||
/// typed as that set and `X`'s membership is validated; otherwise the value
|
||||
|
||||
Reference in New Issue
Block a user