diff --git a/examples/0161-types-numeric-limit-value-shadow.sx b/examples/0161-types-numeric-limit-value-shadow.sx new file mode 100644 index 0000000..8e7dc20 --- /dev/null +++ b/examples/0161-types-numeric-limit-value-shadow.sx @@ -0,0 +1,50 @@ +// Numeric-limit accessor vs. a raw value binding that shadows a builtin type +// name. A backtick raw identifier (F0.6) can legitimately bind a value whose +// spelling is a reserved numeric type name (`` `f64 ``, `` `s32 ``, `` `u8 ``). +// Field access on such a value is an ORDINARY field read — the numeric-limit +// intercept (NL.1 integer `.min`/`.max`, NL.2 float `.epsilon`/… ) must NOT +// hijack it. An adjacent BARE `f64.epsilon` / `s32.max` / `u8.max` — which the +// parser classifies as a type receiver, not the raw value — STILL folds to the +// numeric limit. Both behaviors coexist in one scope: the raw receiver reads +// the value, the bare receiver folds the limit. +// +// Regression (issue 0092): the intercept previously treated any identifier +// whose text matched a builtin numeric type name as a TYPE receiver, silently +// shadowing the in-scope value binding (`` `f64.epsilon `` folded to 2^-52, +// `` `s32.max `` folded to 2147483647 — a silent wrong value). +#import "modules/std.sx"; + +FBox :: struct { epsilon: s64; max: s64; min_positive: s64; } +IBox :: struct { max: s64; min: s64; } +UBox :: struct { max: s64; } + +main :: () -> s32 { + // Raw value bindings whose spelling shadows a builtin numeric type name. + `f64 := FBox.{ epsilon = 11, max = 22, min_positive = 33 }; + `s32 := IBox.{ max = 78, min = -78 }; + `u8 := UBox.{ max = 7 }; + + // Raw receiver → ordinary field READ (the value), never the numeric limit. + print("val f64: epsilon={} max={} min_positive={}\n", + `f64.epsilon, `f64.max, `f64.min_positive); // 11 22 33 + print("val s32: max={} min={}\n", `s32.max, `s32.min); // 78 -78 + print("val u8: max={}\n", `u8.max); // 7 + + // The value-field read carries the field type (s64 here): round-trips + // through a typed binding, so a mistyped/boxed read would not type-check. + e : s64 = `f64.epsilon; + print("typed val e={}\n", e); // 11 + + // Bare receiver (a type receiver, NOT the raw value) → STILL folds to the + // numeric limit, even though `s32`/`u8`/`f64` are bound in this same scope. + print("lim s32.max={} s32.min={}\n", s32.max, s32.min); // 2147483647 -2147483648 + print("lim u8.max={}\n", u8.max); // 255 + + // Bare float accessors still fold; the formatter is crude (issue 0090), so + // pin the values by their defining properties rather than by printing. + print("lim (1.0+f64.epsilon)!=1.0: {}\n", (1.0 + f64.epsilon) != 1.0); // true + print("lim f64.inf > f64.max: {}\n", f64.inf > f64.max); // true + print("lim f64.min == -f64.max: {}\n", f64.min == -f64.max); // true + + return 0; +} diff --git a/examples/expected/0161-types-numeric-limit-value-shadow.exit b/examples/expected/0161-types-numeric-limit-value-shadow.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0161-types-numeric-limit-value-shadow.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0161-types-numeric-limit-value-shadow.stderr b/examples/expected/0161-types-numeric-limit-value-shadow.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0161-types-numeric-limit-value-shadow.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0161-types-numeric-limit-value-shadow.stdout b/examples/expected/0161-types-numeric-limit-value-shadow.stdout new file mode 100644 index 0000000..e54c05a --- /dev/null +++ b/examples/expected/0161-types-numeric-limit-value-shadow.stdout @@ -0,0 +1,9 @@ +val f64: epsilon=11 max=22 min_positive=33 +val s32: max=78 min=-78 +val u8: max=7 +typed val e=11 +lim s32.max=2147483647 s32.min=-2147483648 +lim u8.max=255 +lim (1.0+f64.epsilon)!=1.0: true +lim f64.inf > f64.max: true +lim f64.min == -f64.max: true diff --git a/issues/0092-raw-reserved-value-numeric-limit-shadow.md b/issues/0092-raw-reserved-value-numeric-limit-shadow.md new file mode 100644 index 0000000..0c1520d --- /dev/null +++ b/issues/0092-raw-reserved-value-numeric-limit-shadow.md @@ -0,0 +1,77 @@ +> **RESOLVED** (NL.2 attempt 3). Root cause: the numeric-limit accessor +> intercept treated ANY receiver whose text matched a builtin numeric type +> name as a TYPE receiver, without first checking whether that identifier +> resolved to an in-scope VALUE binding. An F0.6 backtick raw identifier +> (`` `f64 := … ``) binds a local under the stripped name `f64`; field access +> on it (`` `f64.epsilon ``) parses as an `.identifier` receiver, which the +> intercept silently folded to the type's numeric limit — a silent-wrong-value +> bug. +> +> Fix (value-binding precedence for `.identifier` receivers; `.type_expr` +> receivers are unambiguous types and never shadowed): +> - `src/ir/lower.zig` — `lowerNumericLimit`: after confirming the receiver is +> a builtin numeric type name and the field is a limit accessor, return null +> (defer to ordinary field lowering) when `fa.object` is an `.identifier` +> that `Scope.lookup` resolves to a value binding. +> - `src/ir/expr_typer.zig` — numeric-limit inference arm: mirror the same +> guard so inferred types match lowering (avoids the issue-0083 two-resolver +> desync). +> +> Bare `f64.epsilon` / `s32.max` (no shadowing binding) still fold — the parser +> classifies a bare builtin name as a `.type_expr` (parser.zig:2743), so the +> bare receiver is never value-shadowed even in a scope where `` `f64 `` is +> bound. Float-only-on-int and non-numeric-receiver errors are unchanged. +> +> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` (raw +> `` `f64 ``/`` `s32 ``/`` `u8 `` value reads coexisting with bare folds) + +> unit test in `src/ir/expr_typer.test.zig`. NL.1 (`examples/0148`) / NL.2 +> (`examples/0159`, `examples/0160`) unregressed. + +# 0092 — numeric-limit intercept hijacks raw reserved-spelled value receivers + +## Symptom + +Field access on a raw reserved-spelled value binding is interpreted as a builtin +type numeric-limit access instead of an ordinary value field access. Observed: +the repro prints `0.000000 2147483647` (`f64.epsilon` / `s32.max`). Expected: +it prints `12 78` from the `Box` fields. + +## Reproduction + +```sx +#import "modules/std.sx"; + +Box :: struct { epsilon: s64; max: s64; } + +main :: () -> s32 { + `f64 := Box.{ epsilon = 12, max = 34 }; + `s32 := Box.{ epsilon = 56, max = 78 }; + print("{} {}\n", `f64.epsilon, `s32.max); + return 0; +} +``` + +## Investigation prompt + +Investigate issue 0092: raw reserved-spelled value receivers are being captured +by the numeric-limit accessor intercept. In `src/ir/lower.zig`, start at +`Lowering.lowerFieldAccess` and `Lowering.lowerNumericLimit` (currently around +`lower.zig:4826` / `lower.zig:4923`). The intercept treats any +`.identifier`/`.type_expr` receiver whose text is a builtin type name as a type +receiver, without first checking whether the identifier resolves to a value +binding in the current lexical scope. Mirror the fix in `src/ir/expr_typer.zig` +around the numeric-limit inference arm so inferred types match lowering. + +Likely fix: for `.identifier` receivers, prefer an in-scope value binding +(`Scope.lookup`) over the builtin-type numeric-limit intercept; keep the +intercept for actual type receivers (`.type_expr`, and bare reserved integer +names with no value binding). If raw provenance is available in the AST, using it +to disambiguate is also acceptable, but the observable rule must be that +`` `f64.epsilon `` reads the value field when `` `f64 `` is a value binding, +while bare `f64.epsilon` / `s32.max` still fold as numeric limits. + +Verification: pin a regression test from the repro above. It should print +`12 78`. Also verify the existing numeric-limit examples still pass: +`examples/0148-types-int-numeric-limits.sx`, +`examples/0159-types-float-numeric-limits.sx`, and the negative diagnostics in +`examples/0160-types-float-numeric-limits-errors.sx`. diff --git a/readme.md b/readme.md index a6b70d9..ef80ca0 100644 --- a/readme.md +++ b/readme.md @@ -96,7 +96,10 @@ C's `DBL_MIN`) plus the float-only `.epsilon` (ULP of 1.0, not C#'s denormal `Epsilon`), `.min_positive` (smallest normal = C `DBL_MIN`), `.true_min` (smallest subnormal — beware flush-to-zero CPU modes), `.inf`, and `.nan`. A float-only accessor on an integer (`s32.epsilon`), or any accessor on a non-numeric type, is -a clean compile error. See `specs.md` → Numeric Limits. +a clean compile error. The fold applies only to a bare type-name receiver: a raw +identifier that binds a value shadowing a type name (`` `f64 := … `` then +`` `f64.epsilon ``) reads the value's field, not the limit. See `specs.md` → +Numeric Limits. ### Declarations diff --git a/specs.md b/specs.md index 506c6ea..2056a43 100644 --- a/specs.md +++ b/specs.md @@ -340,6 +340,14 @@ qn := f64.nan; // a quiet NaN `true_min = 0x0000000000000001`, `inf = 0x7FF0000000000000`; the `f32` set is `0x7F7FFFFF` / `0xFF7FFFFF` / `0x34000000` / `0x00800000` / `0x00000001` / `0x7F800000`. +- **Type receiver vs. a shadowing value binding.** A numeric-limit access folds + only when the receiver is a builtin numeric **type name** (`f64.epsilon`, + `s32.max`, `u8.max`). A backtick raw identifier that binds a *value* whose + spelling shadows a type name (`` `f64 := … ``, F0.6) is an ordinary value: + `` `f64.epsilon `` reads that value's `epsilon` field — it does **not** fold to + the limit. The two never collide, even in the same scope — a bare builtin name + in expression position is always a type, and only the raw `` `…` `` spelling + can bind a value under it. ### Enum Types User-defined sum types with named variants. Variants may optionally carry typed data (tagged unions). Internally, payload-less enums are represented as `i64` (variant index). Enums with payloads are represented as `{ i64, [max_payload_size x i8] }` (tag + data). diff --git a/src/ir/expr_typer.test.zig b/src/ir/expr_typer.test.zig index 46db0f0..70e0435 100644 --- a/src/ir/expr_typer.test.zig +++ b/src/ir/expr_typer.test.zig @@ -9,7 +9,9 @@ const Node = ast.Node; const ir_mod = @import("ir.zig"); const TypeId = ir_mod.TypeId; +const Ref = ir_mod.Ref; const Lowering = ir_mod.Lowering; +const Scope = @import("lower.zig").Scope; fn node(data: ast.Node.Data) Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = data }; @@ -73,3 +75,46 @@ test "expr_typer: deref of a non-pointer is unresolved" { var deref_n = node(.{ .deref_expr = .{ .operand = &i } }); try std.testing.expectEqual(TypeId.unresolved, l.inferExprType(&deref_n)); } + +// issue 0092: a raw `` `f64 `` value binding shadows the builtin numeric type +// name — `` `f64.epsilon `` (an `.identifier` receiver) must type as the value's +// field, NOT the float numeric-limit fold. A bare `f64.epsilon` (a `.type_expr` +// receiver, never shadowed) still folds to the queried float type. This pins the +// two-resolver agreement: expr_typer's inference must match lowerNumericLimit. +test "expr_typer: raw value binding shadows numeric-limit, bare type still folds" { + // Arena-backed (like calls.test.zig's scope tests): the `.identifier` + // field-access path interns a `obj.field` probe string into `l.alloc`, + // which the production compiler owns via an arena — an explicit free would + // not match the real lifetime. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + var module = ir_mod.Module.init(alloc); + defer module.deinit(); + var l = Lowering.init(&module); + + // A struct `Box { epsilon: s64 }` bound to the raw name `` `f64 ``. + const box_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{ + .{ .name = module.types.internString("epsilon"), .ty = .s64 }, + }; + const box_ty = module.types.intern(.{ .@"struct" = .{ + .name = module.types.internString("Box"), + .fields = &box_fields, + } }); + + var scope = Scope.init(alloc, null); + defer scope.deinit(); + l.scope = &scope; + scope.put("f64", .{ .ref = Ref.none, .ty = box_ty, .is_alloca = false }); + + // `` `f64.epsilon `` — identifier receiver resolving to the value binding → + // ordinary field read, types as s64 (the field), not f64. + var raw_recv = node(.{ .identifier = .{ .name = "f64", .is_raw = true } }); + var raw_fa = node(.{ .field_access = .{ .object = &raw_recv, .field = "epsilon" } }); + try std.testing.expectEqual(TypeId.s64, l.inferExprType(&raw_fa)); + + // bare `f64.epsilon` — type_expr receiver, never shadowed → folds to f64. + var type_recv = node(.{ .type_expr = .{ .name = "f64" } }); + var type_fa = node(.{ .field_access = .{ .object = &type_recv, .field = "epsilon" } }); + try std.testing.expectEqual(TypeId.f64, l.inferExprType(&type_fa)); +} diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index 1b8d8a9..45692d9 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -140,8 +140,15 @@ pub const ExprTyper = struct { else => null, }; if (type_name) |tn| { - if (TypeResolver.integerLimitFor(tn, fa.field) != null or - TypeResolver.floatLimitFor(tn, fa.field) != null) + // Skip the fold when a raw value binding shadows the + // builtin type name (`` `f64 := … ``) — mirrors the + // lowerNumericLimit guard so inference matches lowering + // (issue 0092). A `.type_expr` receiver is never shadowed. + const shadowed = fa.object.data == .identifier and + (if (self.l.scope) |scope| (scope.lookup(tn) != null) else false); + if (!shadowed and + (TypeResolver.integerLimitFor(tn, fa.field) != null or + TypeResolver.floatLimitFor(tn, fa.field) != null)) { if (TypeResolver.resolveBuiltinName(tn, &self.l.module.types)) |t| return t; } diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 81611c2..bfd37b1 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4928,6 +4928,19 @@ pub const Lowering = struct { }; if (!TypeResolver.isLimitField(fa.field)) return null; const ty = TypeResolver.resolveBuiltinName(name, &self.module.types) orelse return null; + + // A backtick raw identifier (F0.6) can bind a value whose spelling + // shadows a builtin type name (`` `f64 := … ``). Field access on that + // value is an ordinary field read, not a numeric-limit fold — defer to + // the normal field-access path when the receiver identifier resolves to + // an in-scope value binding (issue 0092). A `.type_expr` receiver is + // unambiguously a type and can never be value-shadowed. + if (fa.object.data == .identifier) { + if (self.scope) |scope| { + if (scope.lookup(name) != null) return null; + } + } + if (TypeResolver.integerLimitFor(name, fa.field)) |value| { return self.builder.constInt(value, ty); }