From 4c12e1de38200c866ccbc3d47c6bfe20c9fbe628 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 15:34:33 +0300 Subject: [PATCH] =?UTF-8?q?fix(ir):=20unify=20float=E2=86=92int=20narrowin?= =?UTF-8?q?g=20=E2=80=94=20integral=20folds,=20non-integral=20errors=20[F0?= =?UTF-8?q?.11]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 0095: a typed local/param/field silently TRUNCATED a float initializer to an integer annotation (`y : s64 = 1.5` → 1) with no diagnostic. Agra ruled the UNIFIED rule (Option B): an implicit float→int in a typed binding behaves like the array-dimension rule — - an INTEGRAL compile-time float FOLDS to its int (`4.0` → 4, `-2.0` → -2); - a NON-integral float is a COMPILE ERROR (`1.5`, `4.5`); - explicit `xx` / `cast(T)` ALWAYS truncates (the escape hatch). Applied consistently to typed local / param-default / field-default, typed module CONST, and array dim — all reusing the single `program_index.floatToIntExact` / `evalConstIntExpr` facility (no second integral check). - `Builder.constFloatInfo` reads a compile-time `const_float` back from its Ref (value + span). - `coerceToType` is now the IMPLICIT path: its `.float_to_int` arm folds an integral const-float to `constInt`, else emits the narrowing diagnostic. `coerceExplicit` is the raw truncating path; `xx` (lowerXX) and `cast(T)` route through it so the escape still truncates. - Field-default lowering (struct-literal pad, named-field default, buildDefaultValue) now coerces the default to the field type at the IR level (was silently bit-coerced by emitStructInit). - Const path: `typedConstInitFits` accepts an integral float (literal or a `M + 2.0`-style expression folding via `evalComptimeInt`); `emitModuleConst` / `constExprValue` / `globalInitValue` fold an integral float to its int and reject a non-integral one — relaxing F0.7's blanket float rejection. Tests: examples/0168 (positive: local/field/param/const fold, xx/cast truncate), examples/1146 (negative: local/param/field error), integral-float const cases added to examples/0162; non-integral const cases in 1143 stay errors. specs.md + readme.md document the unified rule, cross-referencing the array-dim rule. issues/0095 marked RESOLVED. --- ...0162-types-typed-module-const-roundtrip.sx | 10 ++ examples/0168-types-integral-float-to-int.sx | 46 +++++++ ...46-diagnostics-nonintegral-float-to-int.sx | 26 ++++ ...-types-typed-module-const-roundtrip.stdout | 1 + .../0168-types-integral-float-to-int.exit | 1 + .../0168-types-integral-float-to-int.stderr | 1 + .../0168-types-integral-float-to-int.stdout | 6 + ...-diagnostics-nonintegral-float-to-int.exit | 1 + ...iagnostics-nonintegral-float-to-int.stderr | 17 +++ ...iagnostics-nonintegral-float-to-int.stdout | 1 + .../0095-typed-local-float-int-narrowing.md | 67 +++++++++++ readme.md | 10 ++ specs.md | 22 +++- src/ir/lower.zig | 113 ++++++++++++++++-- src/ir/module.test.zig | 25 ++++ src/ir/module.zig | 29 +++++ 16 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 examples/0168-types-integral-float-to-int.sx create mode 100644 examples/1146-diagnostics-nonintegral-float-to-int.sx create mode 100644 examples/expected/0168-types-integral-float-to-int.exit create mode 100644 examples/expected/0168-types-integral-float-to-int.stderr create mode 100644 examples/expected/0168-types-integral-float-to-int.stdout create mode 100644 examples/expected/1146-diagnostics-nonintegral-float-to-int.exit create mode 100644 examples/expected/1146-diagnostics-nonintegral-float-to-int.stderr create mode 100644 examples/expected/1146-diagnostics-nonintegral-float-to-int.stdout create mode 100644 issues/0095-typed-local-float-int-narrowing.md diff --git a/examples/0162-types-typed-module-const-roundtrip.sx b/examples/0162-types-typed-module-const-roundtrip.sx index 780eb9a..4e6c85a 100644 --- a/examples/0162-types-typed-module-const-roundtrip.sx +++ b/examples/0162-types-typed-module-const-roundtrip.sx @@ -8,6 +8,9 @@ // - integer EXPRESSION → integer (`KE : s64 : M + 2`) — usable as a count too // - integer EXPRESSION → float (`WE : f32 : M + 2`) // - MIXED int+float EXPRESSION → float (`MF : f64 : M + 0.5`, both operand orders) +// - INTEGRAL float literal → integer (`KF : s64 : 4.0` → 4) — folds under the +// unified narrowing rule (F0.11), usable as a count too +// - INTEGRAL float EXPRESSION → integer (`KFE : s64 : M + 2.0` → 4) // // Companion to the negative example 1143: the issue-0088 fix rejects a typed // const whose initializer mismatches its annotation, and these correctly-typed @@ -31,6 +34,8 @@ KE : s64 : M + 2; WE : f32 : M + 2; MF : f64 : M + 0.5; MFR : f64 : 0.5 + M; +KF : s64 : 4.0; // integral float literal → folds to 4 +KFE : s64 : M + 2.0; // integral float expression → folds to 4 main :: () { // Integer const: prints AND drives an array dimension (len 4). @@ -55,4 +60,9 @@ main :: () { // Mixed int+float const-EXPRESSION folds to the promoted float (2.5), // operand-order-independent. print("MF={} MFR={}\n", MF, MFR); + + // Integral float const (literal + expression): folds to its integer under + // the unified narrowing rule; `KF` also drives an array dimension (len 4). + cc : [KF]s64 = ---; + print("KF={} len={} KFE={}\n", KF, cc.len, KFE); } diff --git a/examples/0168-types-integral-float-to-int.sx b/examples/0168-types-integral-float-to-int.sx new file mode 100644 index 0000000..3257503 --- /dev/null +++ b/examples/0168-types-integral-float-to-int.sx @@ -0,0 +1,46 @@ +// Unified float→int narrowing rule (F0.11), POSITIVE side: an INTEGRAL float +// flowing into an integer-typed binding FOLDS to its integer — the same +// `floatToIntExact` rule an array dimension / `$K: Count` already uses — across +// a typed LOCAL, a struct FIELD default, a typed module CONST, and a function +// PARAM default. The escape hatch (`xx` / `cast`) still TRUNCATES any float, +// integral or not. +// +// Companion to the negative example 1146 (non-integral floats error). +// Regression (issue 0095): a typed local/param/field silently truncated a float +// initializer (`y : s64 = 1.5` → 1) with no diagnostic; the rule now folds an +// integral float and rejects a non-integral one. +#import "modules/std.sx"; + +Box :: struct { + n : s64 = 4.0; // integral float field default → folds to 4 +} + +withDefault :: (x : s64 = 6.0) -> s64 { return x; } // param default → 6 + +K : s64 : 8.0; // integral float module const → folds to 8 + +main :: () { + // Typed local: integral float folds. + z : s64 = 4.0; + print("local={}\n", z); + + // Negative integral float folds to its (negative) integer. + neg : s64 = -2.0; + print("neg={}\n", neg); + + // Struct field default folds. + b := Box.{}; + print("field={}\n", b.n); + + // Param default folds. + print("param={}\n", withDefault()); + + // Module const folds (and can drive an array dimension: len 8). + a : [K]s64 = ---; + print("const={} len={}\n", K, a.len); + + // Explicit escape: `xx` / `cast` always truncate, integral or not. + e : s64 = xx 4.9; + c : s64 = cast(s64) 1.5; + print("xx={} cast={}\n", e, c); +} diff --git a/examples/1146-diagnostics-nonintegral-float-to-int.sx b/examples/1146-diagnostics-nonintegral-float-to-int.sx new file mode 100644 index 0000000..eca05ed --- /dev/null +++ b/examples/1146-diagnostics-nonintegral-float-to-int.sx @@ -0,0 +1,26 @@ +// Unified float→int narrowing rule (F0.11), NEGATIVE side: a NON-INTEGRAL float +// implicitly narrowing to an integer-typed binding is a COMPILE ERROR — not a +// silent truncation. The rule fires at a typed LOCAL initializer, a function +// PARAM default, and a struct FIELD default; each emits a narrowing diagnostic +// at the offending float and aborts (exit 1). The fix is the integral-fold / +// non-integral-error rule shared with the array-dimension path. +// +// The escape hatch stays open: `y : s64 = xx 1.5` (or `cast(s64) 1.5`) +// truncates with no error — exercised on the POSITIVE side (example 0168). +// +// Regression (issue 0095): `y : s64 = 1.5` silently truncated to 1. +#import "modules/std.sx"; + +Bad :: struct { + f : s64 = 3.5; // non-integral field default → error +} + +badDefault :: (x : s64 = 2.5) -> s64 { return x; } // non-integral param default → error + +main :: () { + y : s64 = 1.5; // non-integral local initializer → error + b := Bad.{}; + print("{}\n", b.f); + print("{}\n", badDefault()); + print("{}\n", y); +} diff --git a/examples/expected/0162-types-typed-module-const-roundtrip.stdout b/examples/expected/0162-types-typed-module-const-roundtrip.stdout index aac2be0..b290a8b 100644 --- a/examples/expected/0162-types-typed-module-const-roundtrip.stdout +++ b/examples/expected/0162-types-typed-module-const-roundtrip.stdout @@ -4,3 +4,4 @@ S=hi P_is_null=true KE=4 len=4 WE=4.000000 MF=2.500000 MFR=2.500000 +KF=4 len=4 KFE=4 diff --git a/examples/expected/0168-types-integral-float-to-int.exit b/examples/expected/0168-types-integral-float-to-int.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0168-types-integral-float-to-int.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0168-types-integral-float-to-int.stderr b/examples/expected/0168-types-integral-float-to-int.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0168-types-integral-float-to-int.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0168-types-integral-float-to-int.stdout b/examples/expected/0168-types-integral-float-to-int.stdout new file mode 100644 index 0000000..3513b0c --- /dev/null +++ b/examples/expected/0168-types-integral-float-to-int.stdout @@ -0,0 +1,6 @@ +local=4 +neg=-2 +field=4 +param=6 +const=8 len=8 +xx=4 cast=1 diff --git a/examples/expected/1146-diagnostics-nonintegral-float-to-int.exit b/examples/expected/1146-diagnostics-nonintegral-float-to-int.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1146-diagnostics-nonintegral-float-to-int.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1146-diagnostics-nonintegral-float-to-int.stderr b/examples/expected/1146-diagnostics-nonintegral-float-to-int.stderr new file mode 100644 index 0000000..97dfb34 --- /dev/null +++ b/examples/expected/1146-diagnostics-nonintegral-float-to-int.stderr @@ -0,0 +1,17 @@ +error: cannot implicitly narrow non-integral float '1.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1146-diagnostics-nonintegral-float-to-int.sx:21:15 + | +21 | y : s64 = 1.5; // non-integral local initializer → error + | ^^^ + +error: cannot implicitly narrow non-integral float '3.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1146-diagnostics-nonintegral-float-to-int.sx:15:15 + | +15 | f : s64 = 3.5; // non-integral field default → error + | ^^^ + +error: cannot implicitly narrow non-integral float '2.5' to 's64'; use an explicit cast (`xx`/`cast`) + --> examples/1146-diagnostics-nonintegral-float-to-int.sx:18:26 + | +18 | badDefault :: (x : s64 = 2.5) -> s64 { return x; } // non-integral param default → error + | ^^^ diff --git a/examples/expected/1146-diagnostics-nonintegral-float-to-int.stdout b/examples/expected/1146-diagnostics-nonintegral-float-to-int.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1146-diagnostics-nonintegral-float-to-int.stdout @@ -0,0 +1 @@ + diff --git a/issues/0095-typed-local-float-int-narrowing.md b/issues/0095-typed-local-float-int-narrowing.md new file mode 100644 index 0000000..68eda00 --- /dev/null +++ b/issues/0095-typed-local-float-int-narrowing.md @@ -0,0 +1,67 @@ +# 0095 — typed local/decl silently truncates a float initializer to an integer annotation + +> **RESOLVED (F0.11).** Agra ruled the UNIFIED rule (Option B): an implicit +> float→int in a typed binding behaves exactly like the array-dimension rule — +> an **integral** float FOLDS to its integer (`4.0` → 4, `-2.0` → -2), a +> **non-integral** float is a COMPILE ERROR (`1.5`, `4.5`), and an explicit +> `xx` / `cast(T)` ALWAYS truncates (the escape). Applied consistently across +> typed local / param-default / field-default, typed module CONST, and array +> dim — all reusing the single `program_index.floatToIntExact` / +> `evalConstIntExpr` facility (no second integral check). +> +> Fix (`src/ir/lower.zig`, `src/ir/module.zig`): +> - `Builder.constFloatInfo` reads a compile-time `const_float` back from its +> Ref (value + span). +> - `coerceToType` now means IMPLICIT coercion: its `.float_to_int` arm folds an +> integral const-float to `constInt`, else emits the narrowing diagnostic. +> `coerceExplicit` is the raw truncating path; `xx` (`lowerXX`) and +> `cast(T)` route through it so the escape still truncates. +> - Field-default lowering (struct-literal pad, named-field default, +> `buildDefaultValue`) now coerces the default to the field type at the IR +> level (was silently bit-coerced by `emitStructInit`). +> - Const path: `typedConstInitFits` accepts an integral float (literal or a +> `M + 2.0`-style expression that folds via `evalComptimeInt`); `emitModuleConst` +> / `constExprValue` / `globalInitValue` fold an integral float to its int and +> reject a non-integral one. +> +> Regression tests: `examples/0168-types-integral-float-to-int.sx` (positive — +> local/field/param/const fold, `xx`/`cast` truncate), `examples/1146-diagnostics- +> nonintegral-float-to-int.sx` (negative — local/param/field error), plus the +> integral-float const cases added to `examples/0162-types-typed-module-const- +> roundtrip.sx`. Non-integral const cases in `examples/1143` stay errors. + +## Symptom +A typed LOCAL (and likely typed param/field) silently truncates a floating-point +initializer to an integer annotation instead of rejecting or requiring an explicit cast. + +Observed: +- `y : s64 = 1.5;` → y == 1 (float literal truncated, no diagnostic) +- `y : s64 = 2 + 0.5;` → y == 2 (float-valued expr truncated, no diagnostic) + +Expected: a type-mismatch / narrowing diagnostic (consistent with typed MODULE CONSTS, +which after F0.7 reject `N : s64 : 1.5` and `N : s64 : M + 0.5`). Today consts are strict +but locals are lenient — an inconsistency. + +## Reproduction +```sx +#import "modules/std.sx"; +main :: () { + y : s64 = 1.5; + print("{}\n", y); // prints 1 +} +``` + +## Investigation prompt +Decide + implement the language rule for implicit float→int narrowing in a TYPED binding +(local / param / field) initializer. Module consts already reject it (F0.7, +registerTypedModuleConst + typedConstInitFits/constExprInitFits). Make typed-local/param/field +assignment-coercion consistent: either reject a non-integral float→int initializer with a +diagnostic (matching the const path) or require an explicit `xx`/cast. Suspected area: the +assignment / typed-binding coercion path (coerceToType ladder, specs.md §"coercion") in +src/ir/lower.zig. Verify `y : s64 = 1.5` errors (or requires a cast); confirm integral-float +folding rules (specs.md: `4.0`→4 ok, `4.5` rejected) stay consistent. Then gate. + +## Disposition +Discovered during F0.7 (issue 0088) attempt-2 review. Agra ruled F0.7 fixes the +inferExprType ROOT for binary-op promotion; this typed-LOCAL narrowing is a SEPARATE +assignment-coercion concern -> its own scheduled step. diff --git a/readme.md b/readme.md index a0def48..d0e3c3b 100644 --- a/readme.md +++ b/readme.md @@ -123,6 +123,16 @@ int+float arithmetic promotes to the float in either operand order (`n + 0.5` an `0.5 + n` are both `f64`), so `C : s64 : M + 0.5` is rejected regardless of order while `F : f64 : M + 0.5` folds to `2.5`. +**Float → integer narrowing (unified rule).** A float flowing into an +integer-typed binding *without* a cast follows the same integral-fold rule an +array dimension uses: an **integral** float folds to its integer, a +**non-integral** float is a compile error. This is uniform across a typed local, +a parameter default, a struct field default, and a typed constant — +`y : s64 = 4.0` and `K : s64 : 4.0` both give `4` (and `K : s64 : M + 2.0` folds +to `4`), while `y : s64 = 1.5` and `N : s64 : 1.5` both error. An explicit +`xx` / `cast(s64)` is the escape hatch and always truncates (`y : s64 = xx 1.5` +→ `1`). + Builtin type names (`s2`, `u8`, `bool`, `string`, …) are reserved and a *bare* spelling can't be used as an identifier at a **value-binding or declaration-name** site — a value binding (`:=` / typed local / parameter), a `::` constant or diff --git a/specs.md b/specs.md index 03a516d..d66c2ce 100644 --- a/specs.md +++ b/specs.md @@ -893,6 +893,8 @@ A **count** is a compile-time integer used as an array dimension, a `Vector` lane count, or a generic value-param count. Every count must be **integral**: an integral float (`4.0`, or a float-typed const `N : f64 : 4.0`) folds to its integer (`[4.0]s64` ≡ `[4]s64`), while a non-integral float (`4.5`) is rejected. +This is the same integral-float rule a typed binding's float→integer initializer +follows (see "Implicit float → integer", §2 Type Conversions). The accepted *range* of a count is **context-dependent** — zero is legal for some counts and not others: @@ -1418,13 +1420,27 @@ isReady : ValueListenable(bool) = map( - Unsigned to strictly wider signed (`u8` → `s16`) - Any integer to any float (`u8` → `f32`, `s32` → `f64`) - Float to wider float (`f32` → `f64`) -- Integer and float literals can convert to any numeric type implicitly +- Integer literals can convert to any numeric type implicitly -**Explicit (narrowing)** — requires `xx` prefix: +**Implicit float → integer (the unified narrowing rule)** — a float flowing into +an integer-typed binding without `xx`/`cast` is governed by the SAME rule an +array dimension / lane count uses (see "Array dimensions are integral", §2): + +- An **integral** compile-time float **folds** to its integer: + `y : s64 = 4.0` ≡ `y : s64 = 4`, `n : s64 = -2.0` ≡ `-2`. +- A **non-integral** compile-time float is a **compile error**: + `y : s64 = 1.5` → "cannot implicitly narrow non-integral float '1.5' to 's64'". +- This applies uniformly to a typed **local**, a function **param default**, a + struct **field default**, and a typed module **constant** + (`K : s64 : 4.0` → 4; `N : s64 : 1.5` → error; `K : s64 : M + 2.0` → 4 when + the expression folds to an integer). + +**Explicit (narrowing)** — requires `xx` prefix (or `cast(T)`): - Integer to narrower integer (`s32` → `u8`) - Signed to unsigned (`s32` → `u32`) - Float to narrower float (`f64` → `f32`) -- Float to any integer (`f64` → `u16`) +- Float to any integer (`f64` → `u16`) — always **truncates**, integral or not + (`y : s64 = xx 1.5` → 1); this is the escape hatch from the implicit rule above - Unsigned to signed of same or narrower width (`u8` → `s8`) The `xx` prefix operator marks an expression for auto-conversion to the expected type from context (assignment, declaration, argument, return): diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 4a32877..111b938 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -975,6 +975,20 @@ pub const Lowering = struct { /// `classify(.bool, s64)` yields `.widen` and would accept the bogus /// `B : s64 : true`. fn typedConstInitFits(self: *Lowering, value: *const Node, dst_ty: TypeId) bool { + // An INTEGER-annotated constant accepts a compile-time INTEGRAL float — + // a literal (`K : s64 : 4.0`) or an expression that folds to an integer + // (`K : s64 : M + 2.0` → 4) — via the SAME `evalConstIntExpr` / + // `floatToIntExact` the array-dim path uses. A non-integral float + // (`1.5`, `M + 0.5`) folds to null and falls through to the rejecting + // checks below, matching the typed-local rule. + if (self.isIntEx(dst_ty)) { + switch (value.data) { + .float_literal, .binary_op, .unary_op => { + if (self.evalComptimeInt(value) != null) return true; + }, + else => {}, + } + } return switch (value.data) { // `---` zero-inits at any type. .undef_literal => true, @@ -1071,7 +1085,17 @@ pub const Lowering = struct { .null_literal => .null_val, .int_literal => |il| .{ .int = il.value }, .bool_literal => |bl| .{ .boolean = bl.value }, - .float_literal => |fl| .{ .float = fl.value }, + // A float initializer at an integer-typed global follows the + // implicit narrowing rule (integral folds, non-integral errors). + .float_literal => |fl| blk: { + if (self.isIntEx(var_ty)) { + if (program_index_mod.floatToIntExact(fl.value)) |iv| break :blk inst_mod.ConstantValue{ .int = iv }; + if (self.diagnostics) |d| + d.addFmt(.err, v.span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ fl.value, self.formatTypeName(var_ty) }); + break :blk null; + } + break :blk inst_mod.ConstantValue{ .float = fl.value }; + }, .string_literal => |sl| .{ .string = self.module.types.internString(sl.raw) }, .array_literal => |al| self.constArrayLiteral(al.elements, var_ty) orelse self.diagnoseNonConstGlobal(vd, v), .struct_literal => |sl| self.constStructLiteral(&sl, var_ty) orelse self.diagnoseNonConstGlobal(vd, v), @@ -1178,7 +1202,18 @@ pub const Lowering = struct { return switch (expr.data) { .int_literal => |il| .{ .int = il.value }, .bool_literal => |bl| .{ .boolean = bl.value }, - .float_literal => |fl| .{ .float = fl.value }, + // A float into an INTEGER destination follows the implicit + // narrowing rule: an integral float folds to its int, a + // non-integral one is a compile error (not a silent bit-coerce). + .float_literal => |fl| blk: { + if (self.isIntEx(expected_ty)) { + if (program_index_mod.floatToIntExact(fl.value)) |iv| break :blk inst_mod.ConstantValue{ .int = iv }; + if (self.diagnostics) |d| + d.addFmt(.err, expr.span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ fl.value, self.formatTypeName(expected_ty) }); + break :blk null; + } + break :blk inst_mod.ConstantValue{ .float = fl.value }; + }, .string_literal => |sl| .{ .string = self.module.types.internString(sl.raw) }, .undef_literal => .zeroinit, // A `null` in a pointer (or optional-pointer) field is a @@ -4616,8 +4651,13 @@ pub const Lowering = struct { if (field_defaults[fi]) |default_expr| { const saved_tt = self.target_type; self.target_type = sf.ty; - const val = self.lowerExpr(default_expr); + const raw = self.lowerExpr(default_expr); self.target_type = saved_tt; + // Coerce the default to the field type at the IR + // level (the implicit narrowing rule) so a float + // default folds/errors here instead of being + // silently bit-coerced by the backend. + const val = self.coerceToType(raw, self.builder.getRefType(raw), sf.ty); fields.append(self.alloc, val) catch unreachable; } else { fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable; @@ -4656,8 +4696,9 @@ pub const Lowering = struct { if (field_defaults[fi]) |default_expr| { const saved_tt = self.target_type; self.target_type = sf.ty; - const val = self.lowerExpr(default_expr); + const raw = self.lowerExpr(default_expr); self.target_type = saved_tt; + const val = self.coerceToType(raw, self.builder.getRefType(raw), sf.ty); fields.append(self.alloc, val) catch unreachable; continue; } @@ -7112,7 +7153,7 @@ pub const Lowering = struct { if (src_ty == .any) { return self.builder.emit(.{ .unbox_any = .{ .operand = val } }, dst_ty); } - return self.coerceToType(val, src_ty, dst_ty); + return self.coerceExplicit(val, src_ty, dst_ty); } // Runtime cast — fall through to builtin handling } @@ -13934,7 +13975,7 @@ pub const Lowering = struct { .coerce => {}, } - const result = self.coerceToType(operand, src_ty, dst_ty); + const result = self.coerceExplicit(operand, src_ty, dst_ty); // User-space fallback via `impl Into(Target) for Source`. Only fires // when the target was explicitly named (not the .s64 default), src and @@ -14362,8 +14403,9 @@ pub const Lowering = struct { if (field_defaults[i]) |default_expr| { const saved_tt = self.target_type; self.target_type = f.ty; - const val = self.lowerExpr(default_expr); + const raw = self.lowerExpr(default_expr); self.target_type = saved_tt; + const val = self.coerceToType(raw, self.builder.getRefType(raw), f.ty); field_vals.append(self.alloc, val) catch unreachable; } else { field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable; @@ -14402,6 +14444,17 @@ pub const Lowering = struct { } fn emitModuleConst(self: *Lowering, ci: ModuleConstInfo) Ref { + // An integer-typed const whose initializer is a compile-time integer — + // an int literal/expression, OR an INTEGRAL float that `typedConstInitFits` + // accepted under the unified narrowing rule — materializes as its folded + // int through the SAME `evalConstIntExpr` the count / array-dim path uses. + // (`K : s64 : 4.0` → 4; `K : s64 : M + 2.0` → 4.) Non-foldable shapes + // fall through to the per-kind emitters below. + if (self.isIntEx(ci.ty)) { + if (self.evalComptimeInt(ci.value)) |iv| { + return self.builder.constInt(iv, ci.ty); + } + } switch (ci.value.data) { .int_literal => |lit| { // If declared type is float, convert integer value to float constant @@ -14498,9 +14551,26 @@ pub const Lowering = struct { return self.emitPlaceholder(field); } + /// How a float→int conversion is treated. An IMPLICIT coercion (a typed + /// binding initializer) folds an integral compile-time float to its int and + /// REJECTS a non-integral one; an EXPLICIT `xx` / `cast` always truncates. + const CoerceMode = enum { implicit, explicit }; + /// Insert a conversion if src_ty and dst_ty differ. /// Handles int widening/narrowing, float widening/narrowing, and int↔float. + /// IMPLICIT coercion — the typed-binding initializer path. A compile-time + /// float narrowing to an integer folds when integral, errors when not. fn coerceToType(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { + return self.coerceMode(val, src_ty, dst_ty, .implicit); + } + + /// EXPLICIT coercion — the `xx` / `cast(T)` escape hatch. A float→int here + /// always truncates, bypassing the integral-fold / non-integral-error rule. + fn coerceExplicit(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId) Ref { + return self.coerceMode(val, src_ty, dst_ty, .explicit); + } + + fn coerceMode(self: *Lowering, val: Ref, src_ty: TypeId, dst_ty: TypeId, mode: CoerceMode) Ref { // PLANNING: classify the built-in coercion (conversions.zig). // EMISSION: each arm below reproduces the original lowering. switch (self.coercionResolver().classify(src_ty, dst_ty)) { @@ -14534,7 +14604,7 @@ pub const Lowering = struct { defer elems.deinit(self.alloc); for (si.tuple.fields, di.tuple.fields, 0..) |sf, df, i| { const fv = self.builder.emit(.{ .tuple_get = .{ .base = val, .field_index = @intCast(i), .base_type = src_ty } }, sf); - elems.append(self.alloc, self.coerceToType(fv, sf, df)) catch unreachable; + elems.append(self.alloc, self.coerceMode(fv, sf, df, mode)) catch unreachable; } return self.builder.emit(.{ .tuple_init = .{ .fields = self.alloc.dupe(Ref, elems.items) catch unreachable } }, dst_ty); }, @@ -14542,14 +14612,14 @@ pub const Lowering = struct { .optional_unwrap => { const child_ty = self.module.types.get(src_ty).optional.child; const unwrapped = self.builder.emit(.{ .optional_unwrap = .{ .operand = val } }, child_ty); - return self.coerceToType(unwrapped, child_ty, dst_ty); + return self.coerceMode(unwrapped, child_ty, dst_ty, mode); }, // void → Optional: produce null (void is the type of null_literal) .void_to_optional => return self.builder.constNull(dst_ty), // Concrete → Optional wrapping (coerce to the inner type first) .optional_wrap => { const child_ty = self.module.types.get(dst_ty).optional.child; - const coerced = self.coerceToType(val, src_ty, child_ty); + const coerced = self.coerceMode(val, src_ty, child_ty, mode); return self.builder.emit(.{ .optional_wrap = .{ .operand = coerced } }, dst_ty); }, // Concrete → Protocol (auto type erasure) @@ -14575,7 +14645,28 @@ pub const Lowering = struct { return self.buildProtocolValue(concrete_ptr, proto_name, ctn, dst_ty, concrete_ty, heap_copy); }, .int_to_float => return self.builder.emit(.{ .int_to_float = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), - .float_to_int => return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .float_to_int => { + // Implicit float→int narrowing follows the unified rule (the + // same `floatToIntExact` the array-dim / `$K: Count` paths use): + // a compile-time INTEGRAL float folds to its int, a NON-integral + // one is a compile error. Explicit `xx` / `cast` (mode + // `.explicit`) skips this and truncates. A runtime float has no + // compile-time value to fold — it truncates as before. + if (mode == .implicit) { + if (self.builder.constFloatInfo(val)) |info| { + if (program_index_mod.floatToIntExact(info.value)) |iv| { + return self.builder.constInt(iv, dst_ty); + } + if (self.diagnostics) |d| { + const sp = ast.Span{ .start = info.span.start, .end = info.span.end }; + d.addFmt(.err, sp, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ info.value, self.formatTypeName(dst_ty) }); + } + // Error already emitted; emit the truncating op so + // lowering finishes and `hasErrors()` aborts the build. + } + } + return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty); + }, // Ptr ↔ Int — explicit `xx ptr` to/from an integer-typed slot. // Emits a `bitcast` IR op; emit_llvm.zig's bitcast arm dispatches // to LLVMBuildPtrToInt / LLVMBuildIntToPtr at the LLVM level diff --git a/src/ir/module.test.zig b/src/ir/module.test.zig index 2046815..f865fb7 100644 --- a/src/ir/module.test.zig +++ b/src/ir/module.test.zig @@ -114,3 +114,28 @@ test "Module: globals" { try std.testing.expectEqual(GlobalId.fromIndex(0), id); try std.testing.expectEqual(TypeId.s32, mod.globals.items[0].ty); } + +test "Builder.constFloatInfo reads a const_float back, null for non-floats" { + const alloc = std.testing.allocator; + var mod = Module.init(alloc); + defer mod.deinit(); + + var b = Builder.init(&mod); + const name = mod.types.internString("f"); + const entry_name = mod.types.internString("entry"); + _ = b.beginFunction(name, &.{}, .void); + const entry = b.appendBlock(entry_name, &.{}); + b.switchToBlock(entry); + + // A const_float reads back its value (the implicit float→int rule consults + // this to fold an integral literal / locate a non-integral one). + const fref = b.constFloat(4.0, .f64); + const info = b.constFloatInfo(fref) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f64, 4.0), info.value); + + // A non-float instruction is not a const_float — null. + const iref = b.constInt(7, .s64); + try std.testing.expect(b.constFloatInfo(iref) == null); + + b.finalize(); +} diff --git a/src/ir/module.zig b/src/ir/module.zig index 77b030f..cef9c8f 100644 --- a/src/ir/module.zig +++ b/src/ir/module.zig @@ -247,6 +247,13 @@ pub const ImplTable = struct { // ── Builder ───────────────────────────────────────────────────────────── // Fluent API for constructing one function at a time. +/// A `const_float` instruction read back from its Ref: the compile-time value +/// and the span it was emitted with. +pub const ConstFloatInfo = struct { + value: f64, + span: Span, +}; + pub const Builder = struct { module: *Module, func: ?FuncId = null, @@ -347,6 +354,28 @@ pub const Builder = struct { return .unresolved; } + /// If `ref` points at a compile-time `const_float` instruction, return its + /// value and the span it was emitted with; else null. The implicit + /// float→int coercion rule reads this to fold an integral literal to its + /// int (and to locate a non-integral one for its diagnostic). + pub fn constFloatInfo(self: *Builder, ref: Ref) ?ConstFloatInfo { + if (self.func == null) return null; + const func = self.currentFunc(); + const ref_idx = @intFromEnum(ref); + if (ref_idx < func.params.len) return null; + for (func.blocks.items) |*block| { + const first = block.first_ref; + if (ref_idx >= first and ref_idx < first + @as(u32, @intCast(block.insts.items.len))) { + const i = block.insts.items[ref_idx - first]; + return switch (i.op) { + .const_float => |v| .{ .value = v, .span = i.span }, + else => null, + }; + } + } + return null; + } + // ── Emit helpers ──────────────────────────────────────────────── pub fn emit(self: *Builder, op: Op, ty: TypeId) Ref {