Merge branch 'flow/sx-foundation/NL.2' into dist-foundation

This commit is contained in:
agra
2026-06-05 00:45:38 +03:00
21 changed files with 806 additions and 22 deletions

View File

@@ -0,0 +1,94 @@
// 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'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 {
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
// 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;
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_min<min_pos {}\n", f64.true_min < f64.min_positive); // true
print("true_min>0 {}\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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,76 @@
// 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: the raw receiver reads the value, the
// bare receiver folds the limit.
//
// 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 {
// 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("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.
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 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;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,20 @@
f64.true_min true
f64.max true
f64.min 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_min<min_pos true
true_min>0 true
nan!=nan true
typed eps bits true

View File

@@ -0,0 +1 @@
1

View File

@@ -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;
| ^^^^^^^^^^^^^^^^

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,13 @@
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

View File

@@ -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`.

View File

@@ -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`.

View File

@@ -90,7 +90,16 @@ 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. 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 — for a local, global,
or module-constant binding alike. See `specs.md` → Numeric Limits.
### Declarations

View File

@@ -283,6 +283,74 @@ 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`.
- **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 (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
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).

View File

@@ -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,105 @@ 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));
}
// 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));
}

View File

@@ -126,10 +126,13 @@ pub const ExprTyper = struct {
if (info.ty) |t| return t;
}
}
// Numeric-limit accessor: `<IntType>.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: `<Type>.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,18 @@ pub const ExprTyper = struct {
else => null,
};
if (type_name) |tn| {
if (TypeResolver.integerLimitFor(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
// (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
self.l.identifierBindsValue(tn);
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;
}
}

View File

@@ -4904,33 +4904,76 @@ pub const Lowering = struct {
return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span);
}
/// Numeric-limit accessor intercept (`<IntType>.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).
/// 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 (`<Type>.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;
// 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
// 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);
}
// 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);
}

View File

@@ -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);
}

View File

@@ -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;
}
/// `<FloatType>.<field>` → 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`,