From 2e9e4fe8731b82297509b09e7439725f6db4194d Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 16:48:34 +0300 Subject: [PATCH 1/5] =?UTF-8?q?WIP:=20float=20numeric-limit=20accessors=20?= =?UTF-8?q?(NL.2)=20=E2=80=94=20blocked=20on=200091=20(nan=20!=3D=20nan);?= =?UTF-8?q?=20examples/docs=20pending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ir/expr_typer.zig | 15 +++++++++----- src/ir/lower.zig | 44 +++++++++++++++++++++++++--------------- src/ir/type_resolver.zig | 43 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index 51a156d..1b8d8a9 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -126,10 +126,13 @@ pub const ExprTyper = struct { if (info.ty) |t| return t; } } - // Numeric-limit accessor: `.min` / `.max` is a comptime - // const of the queried integer type — mirrors the lowerFieldAccess - // intercept so inference reports the same type (without it the - // const would be mistyped, e.g. boxed into an Any slot). + // Numeric-limit accessor: `.min`/`.max` (int or float) or a + // float-only `.epsilon`/`.min_positive`/`.true_min`/`.inf`/`.nan` + // is a comptime const of the queried type — mirrors the + // lowerFieldAccess intercept so inference reports the same type + // (without it the const would be mistyped, e.g. boxed into an Any + // slot). Only valid folds carry a type here; the cross-type error + // cases fall through (lowerNumericLimit emits the diagnostic). { const type_name: ?[]const u8 = switch (fa.object.data) { .identifier => |id| id.name, @@ -137,7 +140,9 @@ pub const ExprTyper = struct { else => null, }; if (type_name) |tn| { - if (TypeResolver.integerLimitFor(tn, fa.field) != null) { + if (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 a83feb0..e4951f9 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4904,33 +4904,45 @@ pub const Lowering = struct { return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span); } - /// Numeric-limit accessor intercept (`.min` / `.max`), a sibling of - /// the `error.X` / `Struct.CONST` / pack-arity identifier-receiver intercepts - /// in `lowerFieldAccess`. Folds an integer type's `.min`/`.max` to a comptime - /// const of that type via the shared `TypeResolver` width logic (no second - /// width parser) + the existing `constInt` const path. Returns null when this - /// is not an integer-limit access, so the caller continues normal field - /// lowering. A `.min`/`.max` on a builtin NON-numeric receiver - /// (`bool`/`string`/`void`/`Any`/`noreturn`) is a clean diagnostic here (then - /// a placeholder, so lowering finishes and `hasErrors()` aborts the build); a - /// float receiver falls through (float limits are NL.2). + /// Numeric-limit accessor intercept (`.min`/`.max`/`.epsilon`/ + /// `.min_positive`/`.true_min`/`.inf`/`.nan`), a sibling of the `error.X` / + /// `Struct.CONST` / pack-arity identifier-receiver intercepts in + /// `lowerFieldAccess`. Folds the limit to a comptime const of the queried + /// type via the shared `TypeResolver` logic (no second computor) + the + /// existing `constInt` / `constFloat` const paths: + /// - integer `.min`/`.max` → `constInt` (NL.1, via `integerLimitFor`); + /// - float `.min`/`.max`/`.epsilon`/`.min_positive`/`.true_min`/`.inf`/ + /// `.nan` → `constFloat` (via `floatLimitFor`). + /// Returns null when the field is not a limit accessor, or the receiver is not + /// a builtin type (a user struct → ordinary field lowering reports + /// field-not-found). Two clean diagnostics (then a placeholder, so lowering + /// finishes and `hasErrors()` aborts the build): + /// - a FLOAT-only accessor on an integer type (`s32.epsilon`, `u8.inf`); + /// - any accessor on a builtin NON-numeric receiver + /// (`bool`/`string`/`void`/`Any`/`noreturn`). fn lowerNumericLimit(self: *Lowering, fa: *const ast.FieldAccess, span: ast.Span) ?Ref { const name = switch (fa.object.data) { .identifier => |id| id.name, .type_expr => |te| te.name, else => return null, }; - if (!std.mem.eql(u8, fa.field, "min") and !std.mem.eql(u8, fa.field, "max")) return null; + if (!TypeResolver.isLimitField(fa.field)) return null; const ty = TypeResolver.resolveBuiltinName(name, &self.module.types) orelse return null; if (TypeResolver.integerLimitFor(name, fa.field)) |value| { return self.builder.constInt(value, ty); } - // A builtin receiver that is not an integer: floats are NL.2 (fall - // through), every other builtin (bool/string/void/Any/noreturn) has no - // numeric limit. - if (ty == .f32 or ty == .f64) return null; + if (TypeResolver.floatLimitFor(name, fa.field)) |value| { + return self.builder.constFloat(value, ty); + } + // The field is a limit accessor, but it does not apply to this type. if (self.diagnostics) |d| { - d.addFmt(.err, span, "type '{s}' has no '.{s}' — numeric limits apply only to integer and float types", .{ name, fa.field }); + if (TypeResolver.integerWidthSign(name) != null) { + // Integer receiver + a float-only accessor. + d.addFmt(.err, span, "type '{s}' has no '.{s}' — '.{s}' applies only to float types (f32/f64); integer types expose only '.min'/'.max'", .{ name, fa.field, fa.field }); + } else { + // Non-numeric builtin receiver (bool/string/void/Any/noreturn). + d.addFmt(.err, span, "type '{s}' has no '.{s}' — numeric limits apply only to integer and float types", .{ name, fa.field }); + } } return self.emitPlaceholder(fa.field); } diff --git a/src/ir/type_resolver.zig b/src/ir/type_resolver.zig index 3b673a1..aed74e5 100644 --- a/src/ir/type_resolver.zig +++ b/src/ir/type_resolver.zig @@ -137,6 +137,49 @@ pub const TypeResolver = struct { return integerLimitBits(wi, want_max); } + /// The full numeric-limit accessor field set: `.min`/`.max` (valid on int AND + /// float) plus the float-only `.epsilon`/`.min_positive`/`.true_min`/`.inf`/ + /// `.nan`. THE single trigger for the `lowerNumericLimit` intercept — only a + /// field in this set is treated as a limit access; anything else falls through + /// to ordinary field lowering. Keeps the accessor name set in one place so the + /// intercept and `expr_typer` can't recognize different surfaces. + pub fn isLimitField(field: []const u8) bool { + const names = [_][]const u8{ "min", "max", "epsilon", "min_positive", "true_min", "inf", "nan" }; + for (names) |n| if (std.mem.eql(u8, field, n)) return true; + return false; + } + + /// `.` → the limit as an `f64` value (the queried type is + /// `f32`/`f64`; every f32 limit is exactly representable in f64, so widening + /// is lossless and the caller pairs the value with the queried `TypeId` — + /// `builder.constFloat` narrows it back at emit), or null when `name` is not a + /// builtin float type or `field` is not a limit accessor. Values come straight + /// from `std.math` (`floatMax`/`floatEps`/`floatMin`/`floatTrueMin`/`inf`/`nan`): + /// - `.min` = most-NEGATIVE finite (`-max`, NOT C's DBL_MIN) + /// - `.max` = largest finite + /// - `.epsilon` = ULP of 1.0 (`floatEps`; f64 = 2^-52, f32 = 2^-23) + /// - `.min_positive` = smallest positive NORMAL (`floatMin`; = C DBL_MIN) + /// - `.true_min` = smallest positive SUBNORMAL (`floatTrueMin`) + /// - `.inf` = +infinity, `.nan` = a quiet NaN + /// THE single name+field → float fold, shared by the value path (lower.zig) + /// and `expr_typer` so they can't disagree. + pub fn floatLimitFor(name: []const u8, field: []const u8) ?f64 { + if (std.mem.eql(u8, name, "f64")) return floatLimitValue(f64, field); + if (std.mem.eql(u8, name, "f32")) return floatLimitValue(f32, field); + return null; + } + + fn floatLimitValue(comptime T: type, field: []const u8) ?f64 { + if (std.mem.eql(u8, field, "min")) return -@as(f64, std.math.floatMax(T)); + if (std.mem.eql(u8, field, "max")) return @as(f64, std.math.floatMax(T)); + if (std.mem.eql(u8, field, "epsilon")) return @as(f64, std.math.floatEps(T)); + if (std.mem.eql(u8, field, "min_positive")) return @as(f64, std.math.floatMin(T)); + if (std.mem.eql(u8, field, "true_min")) return @as(f64, std.math.floatTrueMin(T)); + if (std.mem.eql(u8, field, "inf")) return @as(f64, std.math.inf(T)); + if (std.mem.eql(u8, field, "nan")) return @as(f64, std.math.nan(T)); + return null; + } + /// Single owner of structural AST-type-shape construction. Builds the /// shapes whose `TypeId` is fully determined by their node kind plus their /// element types resolved through `inner.resolveInner`: `*T`, `[*]T`, `[]T`, From 463557990f62a3c694ac09e64da33315c135e593 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 23:30:41 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(lang):=20float=20numeric-limit=20acces?= =?UTF-8?q?sors=20=E2=80=94=20examples,=20unit=20tests,=20docs=20[NL.2]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish NL.2 on top of the WIP compiler impl (2e9e4fe): f32/f64 expose .min/.max plus the float-only .epsilon/.min_positive/.true_min/.inf/.nan, folded via the shared lowerNumericLimit intercept + builder.constFloat. - examples/0159: pins every f32/f64 accessor by untagged-union bit reinterpret against exact IEEE-754 hex (true_min read before any arithmetic — FTZ/DAZ), plus the defining-property checks ((1+eps)!=1 / (1+eps/2)==1, inf>max, min==-max, true_min0, nan!=nan). - examples/0160: float-only accessor on an int (s32.epsilon/u8.inf/ s64.true_min) and any accessor on a non-numeric type compile-error cleanly (exit 1, pinned stderr). - type_resolver.test.zig: floatLimitFor bit-pattern + property tests for f32/f64, isLimitField coverage, null for non-float/non-limit fields. - specs.md Numeric Limits: float accessors + the min=-max / min_positive= smallest-normal / epsilon=ULP-of-1.0 / true_min=smallest-subnormal clarifications, with the mandatory FTZ/DAZ flush-to-zero caveat. readme.md overview updated. --- examples/0159-types-float-numeric-limits.sx | 89 +++++++++++++++++++ .../0160-types-float-numeric-limits-errors.sx | 27 ++++++ .../0159-types-float-numeric-limits.exit | 1 + .../0159-types-float-numeric-limits.stderr | 1 + .../0159-types-float-numeric-limits.stdout | 19 ++++ ...160-types-float-numeric-limits-errors.exit | 1 + ...0-types-float-numeric-limits-errors.stderr | 35 ++++++++ ...0-types-float-numeric-limits-errors.stdout | 1 + readme.md | 8 +- specs.md | 58 ++++++++++++ src/ir/type_resolver.test.zig | 71 +++++++++++++++ 11 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 examples/0159-types-float-numeric-limits.sx create mode 100644 examples/0160-types-float-numeric-limits-errors.sx create mode 100644 examples/expected/0159-types-float-numeric-limits.exit create mode 100644 examples/expected/0159-types-float-numeric-limits.stderr create mode 100644 examples/expected/0159-types-float-numeric-limits.stdout create mode 100644 examples/expected/0160-types-float-numeric-limits-errors.exit create mode 100644 examples/expected/0160-types-float-numeric-limits-errors.stderr create mode 100644 examples/expected/0160-types-float-numeric-limits-errors.stdout diff --git a/examples/0159-types-float-numeric-limits.sx b/examples/0159-types-float-numeric-limits.sx new file mode 100644 index 0000000..f2753ab --- /dev/null +++ b/examples/0159-types-float-numeric-limits.sx @@ -0,0 +1,89 @@ +// Float numeric-limit accessors: `f32`/`f64` expose `.min` / `.max` (sibling of +// the integer `.min`/`.max`, NL.1) plus the float-only `.epsilon`, +// `.min_positive`, `.true_min`, `.inf`, and `.nan`. Each folds, at compile time, +// to a constant of the QUERIED float type via the same `lowerNumericLimit` +// intercept as the integer case (`builder.constFloat` + the `std.math` +// constants), driven by `TypeResolver.floatLimitFor`. +// +// The lexer has no exponent notation and the default float formatter is crude +// (issue 0090), so these limits can be pinned NEITHER by literal comparison NOR +// by printing. Every accessor is asserted instead by reinterpreting its bits +// through an untagged union and comparing against the exact IEEE-754 hex +// pattern — plus the defining-property checks that no other value could satisfy. +// +// Semantics (Agra-ruled, consistent with the integer accessors): +// .min = most-NEGATIVE finite (= -max), NOT C's DBL_MIN +// .max = largest finite +// .epsilon = ULP of 1.0 (next f after 1.0 minus 1.0), NOT C#'s denormal Epsilon +// .min_positive = smallest positive NORMAL (= C DBL_MIN / Rust MIN_POSITIVE) +// .true_min = smallest positive SUBNORMAL (next value above 0.0) +// .inf = +infinity +// .nan = a quiet NaN +// +// Regression (issue 0091): `f64.nan != f64.nan` is true — native float `!=` +// lowers UNORDERED, so a NaN compares unequal to everything including itself. +#import "modules/std.sx"; + +// `bits` mirrors each float's raw IEEE-754 storage. f64 needs 64 bits, f32 32. +// The f64 union uses a `u64` view so the all-ones-ish positive patterns read as +// their true magnitude; the negative `f64.min` pattern (0xFFEF…) overflows the +// i64 literal parser, so it is pinned by the `min == -max` property instead. +Uf64 :: union { f: f64; bits: u64; } +Uf32 :: union { f: f32; bits: u32; } + +main :: () -> s32 { + o : Uf64 = ---; + + // Read `.true_min` (a subnormal) FIRST and through the union only — never via + // arithmetic. Under flush-to-zero / denormals-are-zero CPU modes a subnormal + // can flush to 0.0 on the first arithmetic op, so the bit reinterpret is the + // only reliable channel for it. + o.f = f64.true_min; + print("f64.true_min {}\n", o.bits == 0x0000000000000001); // true + + o.f = f64.max; + print("f64.max {}\n", o.bits == 0x7FEFFFFFFFFFFFFF); // true + o.f = f64.epsilon; + print("f64.epsilon {}\n", o.bits == 0x3CB0000000000000); // true + o.f = f64.min_positive; + print("f64.min_positive {}\n", o.bits == 0x0010000000000000); // true + o.f = f64.inf; + print("f64.inf {}\n", o.bits == 0x7FF0000000000000); // true + + p : Uf32 = ---; + p.f = f32.true_min; + print("f32.true_min {}\n", p.bits == 0x00000001); // true + p.f = f32.max; + print("f32.max {}\n", p.bits == 0x7F7FFFFF); // true + p.f = f32.min; + print("f32.min {}\n", p.bits == 0xFF7FFFFF); // true + p.f = f32.epsilon; + print("f32.epsilon {}\n", p.bits == 0x34000000); // true + p.f = f32.min_positive; + print("f32.min_positive {}\n", p.bits == 0x00800000); // true + p.f = f32.inf; + print("f32.inf {}\n", p.bits == 0x7F800000); // true + + // Defining-property checks — true epsilon is the ULP of 1.0: adding it to 1.0 + // changes the value, adding half of it does not (round-to-nearest-even). + print("(1+eps)!=1 {}\n", (1.0 + f64.epsilon) != 1.0); // true + print("(1+eps/2)==1 {}\n", (1.0 + f64.epsilon/2.0) == 1.0); // true + print("inf>max {}\n", f64.inf > f64.max); // true + // f64.min = -max (the 0xFFEF… bit pattern overflows the i64 literal parser). + print("min==-max {}\n", f64.min == -f64.max); // true + print("true_min0 {}\n", f64.true_min > 0.0); // true + // Quiet NaN: unequal to everything, itself included (mantissa bits not pinned). + print("nan!=nan {}\n", f64.nan != f64.nan); // true + + // Result carries the QUERIED type: each binding is declared with the float + // type and round-trips, so a mistyped fold (boxed as Any / wrong width) would + // not type-check here. + e64 : f64 = f64.epsilon; + e32 : f32 = f32.epsilon; + q : Uf64 = ---; q.f = e64; + r : Uf32 = ---; r.f = e32; + print("typed eps bits {}\n", q.bits == 0x3CB0000000000000 and r.bits == 0x34000000); // true + + return 0; +} diff --git a/examples/0160-types-float-numeric-limits-errors.sx b/examples/0160-types-float-numeric-limits-errors.sx new file mode 100644 index 0000000..f008213 --- /dev/null +++ b/examples/0160-types-float-numeric-limits-errors.sx @@ -0,0 +1,27 @@ +// Cross-type rules for the numeric-limit accessors. `.min` / `.max` are valid on +// BOTH integer and float types, but `.epsilon` / `.min_positive` / `.true_min` / +// `.inf` / `.nan` are FLOAT-ONLY. Applying a float-only accessor to an INTEGER +// type, or ANY accessor to a non-numeric type, is a clean compile error — never +// a silent value, never the `.unresolved` sentinel reaching codegen. +// +// - float-only accessor on an integer (`s32.epsilon`, `u8.inf`, +// `s64.true_min`) → a dedicated "applies only to float types" diagnostic +// from the accessor intercept, located at the access; +// - any accessor on a non-numeric builtin (`bool.nan`, `string.max`) → the +// "numeric limits apply only to integer and float types" diagnostic; +// - a user struct (`MyStruct.epsilon`) → the type name is not a builtin, so the +// intercept stays out and the existing field-not-found path reports it. +// Each case is accurate and located at the access; the program exits non-zero. +#import "modules/std.sx"; + +MyStruct :: struct { a: s64; } + +main :: () -> s32 { + a := s32.epsilon; + b := u8.inf; + c := s64.true_min; + d := bool.nan; + e := string.max; + f := MyStruct.epsilon; + return 0; +} diff --git a/examples/expected/0159-types-float-numeric-limits.exit b/examples/expected/0159-types-float-numeric-limits.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0159-types-float-numeric-limits.stderr b/examples/expected/0159-types-float-numeric-limits.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0159-types-float-numeric-limits.stdout b/examples/expected/0159-types-float-numeric-limits.stdout new file mode 100644 index 0000000..f562d64 --- /dev/null +++ b/examples/expected/0159-types-float-numeric-limits.stdout @@ -0,0 +1,19 @@ +f64.true_min true +f64.max true +f64.epsilon true +f64.min_positive true +f64.inf true +f32.true_min true +f32.max true +f32.min true +f32.epsilon true +f32.min_positive true +f32.inf true +(1+eps)!=1 true +(1+eps/2)==1 true +inf>max true +min==-max true +true_min0 true +nan!=nan true +typed eps bits true diff --git a/examples/expected/0160-types-float-numeric-limits-errors.exit b/examples/expected/0160-types-float-numeric-limits-errors.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/0160-types-float-numeric-limits-errors.stderr b/examples/expected/0160-types-float-numeric-limits-errors.stderr new file mode 100644 index 0000000..7f9b2df --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.stderr @@ -0,0 +1,35 @@ +error: type 's32' has no '.epsilon' — '.epsilon' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:20:10 + | +20 | a := s32.epsilon; + | ^^^^^^^^^^^ + +error: type 'u8' has no '.inf' — '.inf' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:21:10 + | +21 | b := u8.inf; + | ^^^^^^ + +error: type 's64' has no '.true_min' — '.true_min' applies only to float types (f32/f64); integer types expose only '.min'/'.max' + --> examples/0160-types-float-numeric-limits-errors.sx:22:10 + | +22 | c := s64.true_min; + | ^^^^^^^^^^^^ + +error: type 'bool' has no '.nan' — numeric limits apply only to integer and float types + --> examples/0160-types-float-numeric-limits-errors.sx:23:10 + | +23 | d := bool.nan; + | ^^^^^^^^ + +error: type 'string' has no '.max' — numeric limits apply only to integer and float types + --> examples/0160-types-float-numeric-limits-errors.sx:24:10 + | +24 | e := string.max; + | ^^^^^^^^^^ + +error: field 'epsilon' not found on type 'Any' + --> examples/0160-types-float-numeric-limits-errors.sx:25:10 + | +25 | f := MyStruct.epsilon; + | ^^^^^^^^^^^^^^^^ diff --git a/examples/expected/0160-types-float-numeric-limits-errors.stdout b/examples/expected/0160-types-float-numeric-limits-errors.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0160-types-float-numeric-limits-errors.stdout @@ -0,0 +1 @@ + diff --git a/readme.md b/readme.md index 0c71b8e..a6b70d9 100644 --- a/readme.md +++ b/readme.md @@ -90,7 +90,13 @@ Options: a compile-time constant of that type: `s64.max` → `9223372036854775807`, `u8.min` → `0`, `s3.max` → `3`. It works for every width `s1`..`s64` / `u1`..`u64` plus `usize`/`isize`, and is usable anywhere a constant of that type is — including -array dimensions (`[u8.max]T` is a 255-element array). +array dimensions (`[u8.max]T` is a 255-element array). The float types `f32`/`f64` +expose `.min` / `.max` too (with `.min` = most-negative finite = `-max`, **not** +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. ### Declarations diff --git a/specs.md b/specs.md index b30853e..506c6ea 100644 --- a/specs.md +++ b/specs.md @@ -283,6 +283,64 @@ n := u64.max; // 18446744073709551615 (all-ones) `string`, a pointer, a `struct`, `void`, an `enum`) is a compile error, never a silent value. +The **float** types `f32` and `f64` expose the same `.min` / `.max` plus a set of +float-only accessors. Each folds, at compile time, to a constant of the queried +float type (the same `lowerNumericLimit` intercept, via `builder.constFloat`): + +```sx +hi := f64.max; // largest finite double +lo := f64.min; // most-NEGATIVE finite = -max (NOT C's DBL_MIN) +eps := f64.epsilon; // ULP of 1.0 (f64 = 2^-52, f32 = 2^-23) +mp := f64.min_positive; // smallest positive NORMAL (= C DBL_MIN / Rust MIN_POSITIVE) +tm := f64.true_min; // smallest positive SUBNORMAL (next value above 0.0) +pin := f64.inf; // +infinity +qn := f64.nan; // a quiet NaN +``` + +- **Receiver.** `f32` or `f64`. +- **Shared with integers.** `.min` / `.max` are valid on BOTH integer and float + types. `.min` is the most-NEGATIVE finite value, i.e. `-max` — consistent with + the integer `.min`, and deliberately **NOT** C's `DBL_MIN`/`FLT_MIN` (which is + the smallest positive normal; that is `.min_positive` here). +- **Float-only accessors.** + - `.epsilon` — the ULP of `1.0`: the gap between `1.0` and the next + representable value (`f64 = 2^-52 ≈ 2.22e-16`, `f32 = 2^-23`). This is the + **machine epsilon** used for relative-tolerance comparisons, **NOT** C#'s + `Double.Epsilon` (which is the smallest denormal — that is `.true_min` here). + Defining property: `1.0 + epsilon != 1.0` while `1.0 + epsilon/2.0 == 1.0`. + - `.min_positive` — the smallest positive **NORMAL** value (`f64 = 2^-1022`, + `f32 = 2^-126`). Equals C's `DBL_MIN` / Rust's `MIN_POSITIVE`. + - `.true_min` — the smallest positive **SUBNORMAL**: the next value above `0.0` + (`f64` bits `0x0000000000000001 = 2^-1074`, `f32` bits `0x00000001 = 2^-149`). + Named `true_min` (after Zig's `floatTrueMin`) to avoid the Java/Go/JS + `MIN_VALUE` footgun, where a bare `MIN_VALUE` names the smallest *subnormal* + yet reads like the most-negative value. + - **FTZ/DAZ caveat.** Subnormals are exactly the values that vanish under + flush-to-zero (FTZ) / denormals-are-zero (DAZ) CPU modes. If such a mode is + active, a loaded `.true_min` can flush to `0.0` on the **first arithmetic + operation** that touches it. The folded constant always carries the exact + subnormal bit pattern; read or store it through a bit reinterpret *before* + any arithmetic if you need the true value to survive. Numerical-library + authors who toggle FTZ/DAZ should not be surprised when `true_min * 1.0` + reads back as `0.0`. + - `.inf` — positive infinity (`inf > max`). + - `.nan` — a quiet NaN. The exact mantissa bits are not pinned; the only + guaranteed property is that it is unequal to everything, itself included + (`nan != nan` is `true` — native float `!=` lowers unordered, issue 0091). +- **Float-only on an integer is an error.** `.epsilon` / `.min_positive` / + `.true_min` / `.inf` / `.nan` applied to an integer type (`s32.epsilon`, + `u8.inf`, `s64.true_min`) is a clean compile error — integer types expose only + `.min` / `.max`. +- **Pinning the values.** The lexer has no exponent notation and the default + float formatter is crude (issue 0090), so float limits can be asserted neither + by literal comparison nor by printing. Reinterpret the bits through an untagged + union (`union { f: f64; bits: u64 }`) and compare against the exact IEEE-754 + pattern — `f64.max = 0x7FEFFFFFFFFFFFFF`, `min = 0xFFEFFFFFFFFFFFFF`, + `epsilon = 0x3CB0000000000000`, `min_positive = 0x0010000000000000`, + `true_min = 0x0000000000000001`, `inf = 0x7FF0000000000000`; the `f32` set is + `0x7F7FFFFF` / `0xFF7FFFFF` / `0x34000000` / `0x00800000` / `0x00000001` / + `0x7F800000`. + ### 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/type_resolver.test.zig b/src/ir/type_resolver.test.zig index 70e895d..b81945f 100644 --- a/src/ir/type_resolver.test.zig +++ b/src/ir/type_resolver.test.zig @@ -246,3 +246,74 @@ test "TypeResolver.integerLimitFor: null for non-integer receivers and non-limit try std.testing.expect(TypeResolver.integerLimitFor("s64", "len") == null); try std.testing.expect(TypeResolver.integerLimitFor("u8", "epsilon") == null); } + +test "TypeResolver.isLimitField: the accessor set, nothing else" { + // The full numeric-limit surface — int .min/.max plus the float-only ones. + for ([_][]const u8{ "min", "max", "epsilon", "min_positive", "true_min", "inf", "nan" }) |f| { + try std.testing.expect(TypeResolver.isLimitField(f)); + } + // Ordinary fields / near-misses are not limit accessors. + try std.testing.expect(!TypeResolver.isLimitField("len")); + try std.testing.expect(!TypeResolver.isLimitField("ptr")); + try std.testing.expect(!TypeResolver.isLimitField("maximum")); + try std.testing.expect(!TypeResolver.isLimitField("Min")); + try std.testing.expect(!TypeResolver.isLimitField("")); +} + +test "TypeResolver.floatLimitFor: pinned f64 bit patterns" { + const L = struct { + fn bits(name: []const u8, field: []const u8) u64 { + return @bitCast(TypeResolver.floatLimitFor(name, field).?); + } + }; + // Exact IEEE-754 double bit patterns (the same values the example pins via + // a runtime bit reinterpret). + try std.testing.expectEqual(@as(u64, 0x7FEFFFFFFFFFFFFF), L.bits("f64", "max")); + try std.testing.expectEqual(@as(u64, 0xFFEFFFFFFFFFFFFF), L.bits("f64", "min")); // -max + try std.testing.expectEqual(@as(u64, 0x3CB0000000000000), L.bits("f64", "epsilon")); + try std.testing.expectEqual(@as(u64, 0x0010000000000000), L.bits("f64", "min_positive")); + try std.testing.expectEqual(@as(u64, 0x0000000000000001), L.bits("f64", "true_min")); + try std.testing.expectEqual(@as(u64, 0x7FF0000000000000), L.bits("f64", "inf")); + // .min = -max (NOT C's DBL_MIN, which is min_positive); the ordering holds. + const v = struct { + fn f(name: []const u8, field: []const u8) f64 { + return TypeResolver.floatLimitFor(name, field).?; + } + }; + try std.testing.expectEqual(v.f("f64", "min"), -v.f("f64", "max")); + try std.testing.expect(v.f("f64", "true_min") < v.f("f64", "min_positive")); + try std.testing.expect(v.f("f64", "true_min") > 0.0); + try std.testing.expect(std.math.isInf(v.f("f64", "inf"))); + // Quiet NaN: unequal to itself; exact mantissa bits intentionally not pinned. + try std.testing.expect(std.math.isNan(v.f("f64", "nan"))); +} + +test "TypeResolver.floatLimitFor: pinned f32 bit patterns (widened value narrows losslessly)" { + const L = struct { + // floatLimitFor widens every f32 limit to f64; narrowing back is lossless + // (the codegen path does the same via constFloat at the queried width). + fn bits(name: []const u8, field: []const u8) u32 { + return @bitCast(@as(f32, @floatCast(TypeResolver.floatLimitFor(name, field).?))); + } + }; + try std.testing.expectEqual(@as(u32, 0x7F7FFFFF), L.bits("f32", "max")); + try std.testing.expectEqual(@as(u32, 0xFF7FFFFF), L.bits("f32", "min")); // -max + try std.testing.expectEqual(@as(u32, 0x34000000), L.bits("f32", "epsilon")); + try std.testing.expectEqual(@as(u32, 0x00800000), L.bits("f32", "min_positive")); + try std.testing.expectEqual(@as(u32, 0x00000001), L.bits("f32", "true_min")); + try std.testing.expectEqual(@as(u32, 0x7F800000), L.bits("f32", "inf")); + const nan_v: f32 = @floatCast(TypeResolver.floatLimitFor("f32", "nan").?); + try std.testing.expect(std.math.isNan(nan_v)); +} + +test "TypeResolver.floatLimitFor: null for non-float receivers and non-limit fields" { + // Integer / non-numeric / user names are not float-limit folds. + try std.testing.expect(TypeResolver.floatLimitFor("s32", "epsilon") == null); + try std.testing.expect(TypeResolver.floatLimitFor("u64", "max") == null); + try std.testing.expect(TypeResolver.floatLimitFor("usize", "min") == null); + try std.testing.expect(TypeResolver.floatLimitFor("bool", "nan") == null); + try std.testing.expect(TypeResolver.floatLimitFor("MyStruct", "inf") == null); + // A builtin float with a non-limit field is not a fold here. + try std.testing.expect(TypeResolver.floatLimitFor("f64", "len") == null); + try std.testing.expect(TypeResolver.floatLimitFor("f32", "ptr") == null); +} From b0cc22a8c00006a66ef6ddfb258a4fec4db1be97 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 23:59:11 +0300 Subject: [PATCH 3/5] fix(lang): numeric-limit intercept no longer shadows raw value bindings [NL.2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric-limit accessor intercept (NL.1 integer `.min`/`.max`, NL.2 float `.epsilon`/`.min_positive`/`.true_min`/`.inf`/`.nan`) treated ANY receiver whose text matched a builtin numeric type name as a TYPE receiver, without first checking for 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 (issue 0092). Fix: for `.identifier` receivers, prefer an in-scope value binding (`Scope.lookup`) over the fold — defer to ordinary field lowering when the identifier resolves to a value. `.type_expr` receivers are unambiguous types and are never shadowed, so a bare `f64.epsilon`/`s32.max` still folds even in a scope where `` `f64 `` is bound (the parser classifies a bare builtin name as a `.type_expr`). Mirrored in expr_typer.zig so inference matches lowering (avoids the issue-0083 two-resolver desync). Float-only-on-int and non-numeric-receiver errors are unchanged. - src/ir/lower.zig: value-binding guard in lowerNumericLimit. - src/ir/expr_typer.zig: same guard in the numeric-limit inference arm. - src/ir/expr_typer.test.zig: unit test pinning the two-resolver agreement. - examples/0161-types-numeric-limit-value-shadow.sx: regression — raw `` `f64 ``/`` `s32 ``/`` `u8 `` value reads coexisting with bare folds. - issues/0092: RESOLVED banner. - specs.md / readme.md: receiver-vs-shadowing-value-binding note. --- .../0161-types-numeric-limit-value-shadow.sx | 50 ++++++++++++ ...0161-types-numeric-limit-value-shadow.exit | 1 + ...61-types-numeric-limit-value-shadow.stderr | 1 + ...61-types-numeric-limit-value-shadow.stdout | 9 +++ ...raw-reserved-value-numeric-limit-shadow.md | 77 +++++++++++++++++++ readme.md | 5 +- specs.md | 8 ++ src/ir/expr_typer.test.zig | 45 +++++++++++ src/ir/expr_typer.zig | 11 ++- src/ir/lower.zig | 13 ++++ 10 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 examples/0161-types-numeric-limit-value-shadow.sx create mode 100644 examples/expected/0161-types-numeric-limit-value-shadow.exit create mode 100644 examples/expected/0161-types-numeric-limit-value-shadow.stderr create mode 100644 examples/expected/0161-types-numeric-limit-value-shadow.stdout create mode 100644 issues/0092-raw-reserved-value-numeric-limit-shadow.md 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); } From 6478ccbe3c37aac1a0760d391b7163493b60c335 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 00:21:32 +0300 Subject: [PATCH 4/5] fix(lang): numeric-limit shadow guard covers all 3 value sources [NL.2] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue-0092 fix guarded the numeric-limit accessor intercept against raw value shadowing using only lexical Scope.lookup. The ordinary identifier field-access path resolves a value through THREE sources (scope / program_index.global_names / program_index.module_const_map), so a backtick raw identifier bound at module scope — a global `` `f64 := Box.{…} `` or a module constant `` `f64 :: Box.{…} `` — still folded `` `f64.epsilon `` to the numeric limit instead of reading the value's field (issue 0093, plus the module-const variant: same root cause, same fix). Fix: a single shared helper Lowering.identifierBindsValue(name) that returns true when the name resolves through scope OR global_names OR module_const_map. Used in BOTH lowerNumericLimit (lower.zig) and the numeric-limit inference arm (expr_typer.zig) so the two resolvers can't desync (issue-0083 class). A bare `f64.epsilon` / `s32.max` (a .type_expr receiver) still folds even when a raw value of the same spelling is bound — the bare receiver is never value-shadowed. - examples/0161: extended to exercise all three binding kinds — a GLOBAL `` `f32 ``, a MODULE-CONST `` `s16 ``, and LOCAL `` `f64 ``/`` `s32 ``/`` `u8 `` — each reading its field while the bare spelling still folds. - src/ir/expr_typer.test.zig: unit test pinning the global + module-const sources of the shared guard. - issues/0093: RESOLVED banner (3-source root cause + fix, module-const variant folded in). - specs.md / readme.md: numeric-limit shadow note now source-agnostic (local / global / module-const). --- .../0161-types-numeric-limit-value-shadow.sx | 50 ++++++++--- ...61-types-numeric-limit-value-shadow.stdout | 10 ++- ...3-global-raw-value-numeric-limit-shadow.md | 84 +++++++++++++++++++ readme.md | 4 +- specs.md | 10 ++- src/ir/expr_typer.test.zig | 59 +++++++++++++ src/ir/expr_typer.zig | 6 +- src/ir/lower.zig | 32 +++++-- 8 files changed, 225 insertions(+), 30 deletions(-) create mode 100644 issues/0093-global-raw-value-numeric-limit-shadow.md diff --git a/examples/0161-types-numeric-limit-value-shadow.sx b/examples/0161-types-numeric-limit-value-shadow.sx index 8e7dc20..dc6f1da 100644 --- a/examples/0161-types-numeric-limit-value-shadow.sx +++ b/examples/0161-types-numeric-limit-value-shadow.sx @@ -5,30 +5,52 @@ // 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. +// numeric limit. Both behaviors coexist: 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). +// A raw value binding can reach the intercept through THREE sources, exactly +// mirroring the ordinary identifier field-access path (scope / globals / module +// consts). This example exercises all three: a GLOBAL `` `f32 ``, a MODULE-CONST +// `` `s16 ``, and LOCAL `` `f64 ``/`` `s32 ``/`` `u8 `` — each reads its field, +// and the bare spelling of each STILL folds. +// +// Regression (issues 0092 local, 0093 global + module-const): 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). The attempt-3 fix guarded only lexical scope, so +// GLOBAL and MODULE-CONST raw bindings still folded (issue 0093). #import "modules/std.sx"; FBox :: struct { epsilon: s64; max: s64; min_positive: s64; } IBox :: struct { max: s64; min: s64; } UBox :: struct { max: s64; } +// GLOBAL raw value binding whose spelling shadows the builtin `f32`. Reachable +// via `program_index.global_names`, not lexical scope (issue 0093). +`f32 := FBox.{ epsilon = 44, max = 55, min_positive = 66 }; + +// MODULE-CONST raw value binding whose spelling shadows the builtin `s16`. +// Reachable via `program_index.module_const_map` (issue 0093, const variant). +`s16 :: IBox.{ max = 99, min = -99 }; + main :: () -> s32 { - // Raw value bindings whose spelling shadows a builtin numeric type name. + // LOCAL 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 + print("local f64: epsilon={} max={} min_positive={}\n", + `f64.epsilon, `f64.max, `f64.min_positive); // 11 22 33 + print("local s32: max={} min={}\n", `s32.max, `s32.min); // 78 -78 + print("local u8: max={}\n", `u8.max); // 7 + + // GLOBAL raw receiver → ordinary field READ (issue 0093). + print("global f32: epsilon={} max={} min_positive={}\n", + `f32.epsilon, `f32.max, `f32.min_positive); // 44 55 66 + // MODULE-CONST raw receiver → ordinary field READ (issue 0093). + print("const s16: max={} min={}\n", `s16.max, `s16.min); // 99 -99 // 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. @@ -36,15 +58,19 @@ main :: () -> s32 { 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. + // numeric limit, even though a LOCAL (`s32`/`u8`/`f64`), GLOBAL (`f32`), or + // MODULE-CONST (`s16`) value of the same spelling is bound. The bare receiver + // is never blocked by any of the three value sources. print("lim s32.max={} s32.min={}\n", s32.max, s32.min); // 2147483647 -2147483648 print("lim u8.max={}\n", u8.max); // 255 + print("lim s16.max={} s16.min={}\n", s16.max, s16.min); // 32767 -32768 // 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 + print("lim f32.inf > f32.max: {}\n", f32.inf > f32.max); // true return 0; } diff --git a/examples/expected/0161-types-numeric-limit-value-shadow.stdout b/examples/expected/0161-types-numeric-limit-value-shadow.stdout index e54c05a..0703721 100644 --- a/examples/expected/0161-types-numeric-limit-value-shadow.stdout +++ b/examples/expected/0161-types-numeric-limit-value-shadow.stdout @@ -1,9 +1,13 @@ -val f64: epsilon=11 max=22 min_positive=33 -val s32: max=78 min=-78 -val u8: max=7 +local f64: epsilon=11 max=22 min_positive=33 +local s32: max=78 min=-78 +local u8: max=7 +global f32: epsilon=44 max=55 min_positive=66 +const s16: max=99 min=-99 typed val e=11 lim s32.max=2147483647 s32.min=-2147483648 lim u8.max=255 +lim s16.max=32767 s16.min=-32768 lim (1.0+f64.epsilon)!=1.0: true lim f64.inf > f64.max: true lim f64.min == -f64.max: true +lim f32.inf > f32.max: true diff --git a/issues/0093-global-raw-value-numeric-limit-shadow.md b/issues/0093-global-raw-value-numeric-limit-shadow.md new file mode 100644 index 0000000..cc412d0 --- /dev/null +++ b/issues/0093-global-raw-value-numeric-limit-shadow.md @@ -0,0 +1,84 @@ +> **RESOLVED** (NL.2 attempt 4). Root cause: the issue-0092 fix guarded the +> numeric-limit intercept against value shadowing using ONLY lexical +> `Scope.lookup`. But the ordinary identifier field-access path resolves a +> value through THREE sources (`expr_typer.zig` `.identifier` arm): lexical +> `scope` → program `global_names` → module value constants +> `module_const_map`. A backtick raw identifier bound at MODULE scope +> (`` `f64 := Box.{…} ``, a global, or `` `f64 :: Box.{…} ``, a module const) +> is registered in `global_names` / `module_const_map`, NOT in `Scope`, so the +> scope-only guard missed it and the intercept still folded `` `f64.epsilon `` +> to the numeric limit — the same silent-wrong-value bug as 0092, one source +> deeper. The module-const variant has the same root cause and is covered by +> the same fix (no separate issue). +> +> Fix (close ALL THREE value-binding sources in one pass): a single shared +> helper `Lowering.identifierBindsValue(name)` returns true when `name` +> resolves through `scope.lookup` OR `program_index.global_names` OR +> `program_index.module_const_map`. Used in BOTH resolvers so they cannot +> desync (issue-0083 two-resolver class): +> - `src/ir/lower.zig` — `lowerNumericLimit`: defer to ordinary field lowering +> (return null) when an `.identifier` receiver `identifierBindsValue`. +> - `src/ir/expr_typer.zig` — numeric-limit inference arm: the `shadowed` +> check now calls the same helper. +> +> A bare `f64.epsilon` / `s32.max` (a `.type_expr` receiver, never an +> `.identifier`) still folds, even when a global or module-const raw value of +> the same spelling exists — the bare receiver is never value-shadowed. +> Float-only-on-int and non-numeric-receiver errors are unchanged. +> +> Regression: `examples/0161-types-numeric-limit-value-shadow.sx` now exercises +> all three binding kinds — a GLOBAL `` `f32 ``, a MODULE-CONST `` `s16 ``, and +> LOCAL `` `f64 ``/`` `s32 ``/`` `u8 `` — each reading its field while the bare +> spelling still folds. Unit test `src/ir/expr_typer.test.zig` pins the global +> + module-const sources. NL.1 (`examples/0148`) / NL.2 (`examples/0159`, +> `examples/0160`) unregressed. + +# 0093 — numeric-limit intercept hijacks global raw reserved-spelled value receivers + +## Symptom + +Field access on a **global** 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; } + +`f64 := Box.{ epsilon = 12, max = 34 }; +`s32 := Box.{ epsilon = 56, max = 78 }; + +main :: () -> s32 { + print("{} {}\n", `f64.epsilon, `s32.max); + return 0; +} +``` + +## Investigation prompt + +Investigate issue 0093: the issue-0092 value-binding precedence fix covers +lexical locals but misses global raw value bindings. In `src/ir/lower.zig`, start +at `Lowering.lowerNumericLimit` and the new issue-0092 guard around +`Scope.lookup`. That guard returns `null` for a shadowing local, but global raw +bindings are registered in `ProgramIndex.global_names` (and module constants in +`ProgramIndex.module_const_map`), not in `Scope`, so an `.identifier` receiver +whose text is `f64` / `s32` still folds to a numeric limit before ordinary +global field lowering can read the value. Mirror the same rule in +`src/ir/expr_typer.zig` so inferred types match lowering. + +Likely fix: for `.identifier` numeric-limit receivers, prefer any in-scope value +binding source over the builtin-type fold: lexical `Scope.lookup`, global values +(`program_index.global_names`), and module value constants where applicable. +Keep `.type_expr` receivers folding as type receivers, so bare `f64.epsilon` and +`s32.max` still fold even when a raw global value of the same spelling exists. + +Verification: pin the repro above as a regression. 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`, +`examples/0160-types-float-numeric-limits-errors.sx`, and +`examples/0161-types-numeric-limit-value-shadow.sx`. diff --git a/readme.md b/readme.md index ef80ca0..6bed385 100644 --- a/readme.md +++ b/readme.md @@ -98,8 +98,8 @@ 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. 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. +`` `f64.epsilon ``) reads the value's field, not the limit — for a local, global, +or module-constant binding alike. See `specs.md` → Numeric Limits. ### Declarations diff --git a/specs.md b/specs.md index 2056a43..852fea4 100644 --- a/specs.md +++ b/specs.md @@ -343,10 +343,12 @@ qn := f64.nan; // a quiet NaN - **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 + spelling shadows a type name (F0.6) is an ordinary value: `` `f64.epsilon `` + reads that value's `epsilon` field — it does **not** fold to the limit. This + holds for **every** value-binding kind — a `` `f64 := … `` local, a module-scope + global, or a `` `f64 :: … `` module constant — so the fold can never silently + hijack a raw value, whatever its scope. The two never collide: a bare builtin + name in expression position is always a type, and only the raw `` `…` `` spelling can bind a value under it. ### Enum Types diff --git a/src/ir/expr_typer.test.zig b/src/ir/expr_typer.test.zig index 70e0435..b267ada 100644 --- a/src/ir/expr_typer.test.zig +++ b/src/ir/expr_typer.test.zig @@ -118,3 +118,62 @@ test "expr_typer: raw value binding shadows numeric-limit, bare type still folds var type_fa = node(.{ .field_access = .{ .object = &type_recv, .field = "epsilon" } }); try std.testing.expectEqual(TypeId.f64, l.inferExprType(&type_fa)); } + +// issue 0093: a raw value binding can shadow a builtin numeric type name through +// any of three sources — lexical scope (issue 0092), program globals, or module +// value constants. The shared `identifierBindsValue` guard consults all three, +// so a global `` `f32 := Box.{…} `` and a module-const `` `s16 :: Box.{…} `` each +// read the value's field (NOT the numeric-limit fold), while a bare `f32.max` / +// `s16.max` (a `.type_expr` receiver) still folds. Pins the guard across the two +// non-lexical sources the attempt-3 scope-only fix missed. +test "expr_typer: global and module-const raw bindings shadow numeric-limit" { + 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); + + // `Box { max: s64 }` — the struct both raw bindings resolve to. + const box_fields = [_]ir_mod.types.TypeInfo.StructInfo.Field{ + .{ .name = module.types.internString("max"), .ty = .s64 }, + }; + const box_ty = module.types.intern(.{ .@"struct" = .{ + .name = module.types.internString("Box"), + .fields = &box_fields, + } }); + + // GLOBAL raw binding `` `f32 := Box.{…} `` — registered in global_names. + try l.program_index.global_names.put("f32", .{ .id = @enumFromInt(0), .ty = box_ty }); + // MODULE-CONST raw binding `` `s16 :: Box.{…} `` — registered in module_const_map. + var const_val = node(.{ .int_literal = .{ .value = 0 } }); + try l.program_index.module_const_map.put("s16", .{ .value = &const_val, .ty = box_ty }); + + // The shared guard sees both non-lexical bindings, but not an unbound spelling. + try std.testing.expect(l.identifierBindsValue("f32")); + try std.testing.expect(l.identifierBindsValue("s16")); + try std.testing.expect(!l.identifierBindsValue("u8")); + + // `` `f32.max `` — global raw receiver → ordinary field read, types as s64 + // (the field), not f32 (the fold). + var g_recv = node(.{ .identifier = .{ .name = "f32", .is_raw = true } }); + var g_fa = node(.{ .field_access = .{ .object = &g_recv, .field = "max" } }); + try std.testing.expectEqual(TypeId.s64, l.inferExprType(&g_fa)); + + // `` `s16.max `` — module-const raw receiver → ordinary field read, types as s64. + var c_recv = node(.{ .identifier = .{ .name = "s16", .is_raw = true } }); + var c_fa = node(.{ .field_access = .{ .object = &c_recv, .field = "max" } }); + try std.testing.expectEqual(TypeId.s64, l.inferExprType(&c_fa)); + + // bare `f32.max` — type_expr receiver, never shadowed → folds to f32, even + // though a global `` `f32 `` value is bound. + var bare_f32 = node(.{ .type_expr = .{ .name = "f32" } }); + var bare_f32_fa = node(.{ .field_access = .{ .object = &bare_f32, .field = "max" } }); + try std.testing.expectEqual(TypeId.f32, l.inferExprType(&bare_f32_fa)); + + // bare `s16.max` — type_expr receiver, never shadowed → folds to the s16 + // type, even though a module-const `` `s16 `` value is bound. + var bare_s16 = node(.{ .type_expr = .{ .name = "s16" } }); + var bare_s16_fa = node(.{ .field_access = .{ .object = &bare_s16, .field = "max" } }); + try std.testing.expectEqual(TypeId.s16, l.inferExprType(&bare_s16_fa)); +} diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index 45692d9..f9a1803 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -143,9 +143,11 @@ pub const ExprTyper = struct { // 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. + // (issues 0092, 0093). The shared helper consults all + // three value sources (scope / globals / module consts); + // 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); + self.l.identifierBindsValue(tn); if (!shadowed and (TypeResolver.integerLimitFor(tn, fa.field) != null or TypeResolver.floatLimitFor(tn, fa.field) != null)) diff --git a/src/ir/lower.zig b/src/ir/lower.zig index bfd37b1..cbaf751 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4904,6 +4904,27 @@ pub const Lowering = struct { return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span); } + /// True when an `.identifier` receiver text resolves to an in-scope VALUE + /// binding rather than a builtin type. A backtick raw identifier (F0.6) can + /// bind a value whose spelling shadows a builtin type name (`` `f64 := … ``); + /// such a value is reachable through the same three sources the ordinary + /// identifier field-access path consults (see `expr_typer` `.identifier` + /// arm): lexical `scope`, program `global_names`, and module value + /// constants `module_const_map`. The numeric-limit intercept must defer to + /// ordinary field access whenever ANY of the three binds the name, so a + /// raw value field read is never hijacked into a numeric-limit fold + /// (issues 0092 local / 0093 global + module-const). A single helper used + /// by both lowering and inference keeps the two resolvers in lockstep + /// (issue-0083 two-resolver defect class). + pub fn identifierBindsValue(self: *Lowering, name: []const u8) bool { + if (self.scope) |scope| { + if (scope.lookup(name) != null) return true; + } + if (self.program_index.global_names.get(name) != null) return true; + if (self.program_index.module_const_map.get(name) != null) return true; + return false; + } + /// Numeric-limit accessor intercept (`.min`/`.max`/`.epsilon`/ /// `.min_positive`/`.true_min`/`.inf`/`.nan`), a sibling of the `error.X` / /// `Struct.CONST` / pack-arity identifier-receiver intercepts in @@ -4933,13 +4954,10 @@ pub const Lowering = struct { // 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; - } - } + // a value binding through any of scope / globals / module consts + // (issues 0092, 0093). A `.type_expr` receiver is unambiguously a type + // and can never be value-shadowed. + if (fa.object.data == .identifier and self.identifierBindsValue(name)) return null; if (TypeResolver.integerLimitFor(name, fa.field)) |value| { return self.builder.constInt(value, ty); From bdf6433e7242f262d4bc7169aff23972314a58db Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 00:35:52 +0300 Subject: [PATCH 5/5] test(lang): pin f64.min via signed union bit view in example 0159 [NL.2] --- examples/0159-types-float-numeric-limits.sx | 13 +++++++++---- .../expected/0159-types-float-numeric-limits.stdout | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/examples/0159-types-float-numeric-limits.sx b/examples/0159-types-float-numeric-limits.sx index f2753ab..dc82d7c 100644 --- a/examples/0159-types-float-numeric-limits.sx +++ b/examples/0159-types-float-numeric-limits.sx @@ -25,10 +25,11 @@ #import "modules/std.sx"; // `bits` mirrors each float's raw IEEE-754 storage. f64 needs 64 bits, f32 32. -// The f64 union uses a `u64` view so the all-ones-ish positive patterns read as -// their true magnitude; the negative `f64.min` pattern (0xFFEF…) overflows the -// i64 literal parser, so it is pinned by the `min == -max` property instead. -Uf64 :: union { f: f64; bits: u64; } +// The f64 union's `bits` (u64) view reads the all-ones-ish positive patterns as +// their true magnitude; its `s` (s64) view pins the negative `f64.min` pattern +// (0xFFEF…), whose unsigned form overflows the u64 literal parser, by comparing +// the signed reinterpret to -4503599627370497. +Uf64 :: union { f: f64; bits: u64; s: s64; } Uf32 :: union { f: f32; bits: u32; } main :: () -> s32 { @@ -43,6 +44,10 @@ main :: () -> s32 { o.f = f64.max; print("f64.max {}\n", o.bits == 0x7FEFFFFFFFFFFFFF); // true + // f64.min = -max; its bit pattern 0xFFEFFFFFFFFFFFFF overflows an unsigned u64 + // literal, so it is pinned directly via the SIGNED s64 view: -4503599627370497. + o.f = f64.min; + print("f64.min {}\n", o.s == -4503599627370497); // true (bits 0xFFEFFFFFFFFFFFFF) o.f = f64.epsilon; print("f64.epsilon {}\n", o.bits == 0x3CB0000000000000); // true o.f = f64.min_positive; diff --git a/examples/expected/0159-types-float-numeric-limits.stdout b/examples/expected/0159-types-float-numeric-limits.stdout index f562d64..b6c69f5 100644 --- a/examples/expected/0159-types-float-numeric-limits.stdout +++ b/examples/expected/0159-types-float-numeric-limits.stdout @@ -1,5 +1,6 @@ f64.true_min true f64.max true +f64.min true f64.epsilon true f64.min_positive true f64.inf true