fix(ir): narrow non-integral const-float EXPRESSIONS at typed local/field/param; align const message [F0.11]

Completes issue 0095 (attempt 2). The attempt-1 coerce arm only caught a direct
`const_float` literal, so a non-integral const-folded float EXPRESSION still
truncated silently at a typed local / field default / param default:

  M :: 2;
  local : s64 = M + 0.5;   // → 2  (silent truncation — BUG; now ERRORS)
  fld   : s64 = M + 0.5;   // field default — same
  take(x : s64 = M + 0.5)  // param default — same

while the typed-CONST site already errored. The integral expression
(`M + 2.0` → 4) folded but the runtime/explicit-cast paths must stay untouched.

Fix:
- New `program_index.evalConstFloatExpr` — the f64 counterpart to
  `evalConstIntExpr`, delegating every integer subtree back to it (no parallel
  integer logic) and adding only the float literal / unary-negate / `+ - * /`
  arms. Pure (no diagnostics, no resolution side effects).
- `Lowering.foldComptimeFloatInit` applies the unified rule to a typed-binding
  initializer EXPRESSION: an integral comptime float folds to its `constInt`, a
  non-integral one errors, a genuine runtime float / `xx`-cast falls through to
  the normal path. It runs `evalConstFloatExpr` FIRST (pure) so a `$pack[i]`
  argument is never spuriously type-resolved outside an active binding, then
  gates on `isFloat(inferExprType)` so a plain comptime int is left alone.
  Wired into the typed-local path, the three struct field-default sites (via a
  shared `lowerCoercedDefault`), and the call-argument loop (covers expanded
  param defaults).
- One `Lowering.diagNonIntegralNarrow` now emits the narrowing wording at all
  five sites (coerce arm, global init, const-expr value, the typed-binding
  sites, and the typed-const path). The typed-CONST non-integral diagnostic
  therefore reads "cannot implicitly narrow non-integral float …" instead of
  the stale "initializer is a float literal / floating-point expression".

Tests: examples/1146 (negative) extended with non-integral const-EXPRESSION
cases at local/field/param; examples/0168 (positive) extended with integral
const-EXPRESSION folds and `xx (M + 0.5)` truncation; examples/1143 reconciled
to the aligned const message (G/BAD/BAD2 stay errors); unit test
`evalConstFloatExpr folds comptime float expressions`. Full gate green (447).
This commit is contained in:
agra
2026-06-05 16:28:12 +03:00
parent 4c12e1de38
commit 43d44fff75
11 changed files with 306 additions and 85 deletions

View File

@@ -943,6 +943,18 @@ pub const Lowering = struct {
// const → a bogus pointer that segfaults at the use site) and let the
// count path fold it (`[N]s64` → 4). Issue 0088.
if (!self.typedConstInitFits(cd.value, ty)) {
// A non-integral compile-time float into an integer const is the
// same implicit-narrowing failure as a typed local/field/param —
// report it with the unified wording (integral floats now FOLD here,
// so the old generic "initializer is a float literal/expression"
// message is stale). Every other mismatch keeps the generic wording.
if (self.isIntEx(ty) and isFloat(self.inferExprType(cd.value))) {
if (program_index_mod.evalConstFloatExpr(cd.value, self)) |fv| {
self.diagNonIntegralNarrow(cd.value.span, fv, ty);
_ = self.program_index.module_const_map.remove(cd.name);
return;
}
}
if (self.diagnostics) |d| {
d.addFmt(.err, cd.value.span, "type mismatch: constant '{s}' is declared '{s}' but its initializer is {s}", .{
cd.name, self.formatTypeName(ty), self.initializerDescription(cd.value),
@@ -1090,8 +1102,7 @@ pub const Lowering = struct {
.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) });
self.diagNonIntegralNarrow(v.span, fl.value, var_ty);
break :blk null;
}
break :blk inst_mod.ConstantValue{ .float = fl.value };
@@ -1208,8 +1219,7 @@ pub const Lowering = struct {
.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) });
self.diagNonIntegralNarrow(expr.span, fl.value, expected_ty);
break :blk null;
}
break :blk inst_mod.ConstantValue{ .float = fl.value };
@@ -2044,6 +2054,17 @@ pub const Lowering = struct {
return;
}
}
// A compile-time float initializer narrowing into an integer
// local follows the unified rule (integral folds, non-integral
// errors); a runtime float / `xx` cast falls through to the
// normal lower+coerce below.
if (self.foldComptimeFloatInit(val, ty)) |folded| {
self.builder.store(slot, folded);
if (self.scope) |scope| {
scope.put(vd.name, .{ .ref = slot, .ty = ty, .is_alloca = true });
}
return;
}
const saved_target = self.target_type;
const saved_fbv = self.force_block_value;
self.target_type = ty;
@@ -4649,16 +4670,11 @@ pub const Lowering = struct {
// Field not specified — use default if available, else zero
if (fi < field_defaults.len) {
if (field_defaults[fi]) |default_expr| {
const saved_tt = self.target_type;
self.target_type = sf.ty;
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;
fields.append(self.alloc, self.lowerCoercedDefault(default_expr, sf.ty)) catch unreachable;
} else {
fields.append(self.alloc, self.zeroValue(sf.ty)) catch unreachable;
}
@@ -4694,12 +4710,7 @@ pub const Lowering = struct {
for (struct_fields[fields.items.len..], fields.items.len..) |sf, fi| {
if (fi < field_defaults.len) {
if (field_defaults[fi]) |default_expr| {
const saved_tt = self.target_type;
self.target_type = sf.ty;
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;
fields.append(self.alloc, self.lowerCoercedDefault(default_expr, sf.ty)) catch unreachable;
continue;
}
}
@@ -7030,6 +7041,17 @@ pub const Lowering = struct {
if (enum_payload_ty) |ept| {
if (ai == 0) self.target_type = ept;
}
// Implicit float→int narrowing of a compile-time float argument
// (incl. an expanded `param: T = expr` default) follows the unified
// rule: an integral comptime float folds, a non-integral one errors.
// A runtime float / `xx` cast is unaffected and coerces as before.
if (ai < param_types.len) {
if (self.foldComptimeFloatInit(arg, param_types[ai])) |folded| {
args.append(self.alloc, folded) catch unreachable;
self.target_type = saved_target;
continue;
}
}
// Implicit address-of: when param expects *T and arg is an identifier
// with an alloca of type T, pass the alloca pointer directly (reference
// semantics, so mutations through the pointer are visible to the caller).
@@ -14401,12 +14423,7 @@ pub const Lowering = struct {
for (fields, 0..) |f, i| {
if (i < field_defaults.len) {
if (field_defaults[i]) |default_expr| {
const saved_tt = self.target_type;
self.target_type = f.ty;
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;
field_vals.append(self.alloc, self.lowerCoercedDefault(default_expr, f.ty)) catch unreachable;
} else {
field_vals.append(self.alloc, self.zeroValue(f.ty)) catch unreachable;
}
@@ -14551,6 +14568,65 @@ pub const Lowering = struct {
return self.emitPlaceholder(field);
}
/// Emit the unified non-integral float→int narrowing diagnostic (F0.11 /
/// issue 0095). ONE wording, ONE place: every site that rejects an implicit
/// narrowing of a non-integral compile-time float to an integer type calls
/// this, so the message + fix-it stay identical across the typed-binding
/// coerce arm, the field/param-default sites, the typed-const path, and the
/// global-initializer path.
fn diagNonIntegralNarrow(self: *Lowering, span: ast.Span, value: f64, dst_ty: TypeId) void {
if (self.diagnostics) |d|
d.addFmt(.err, span, "cannot implicitly narrow non-integral float '{d}' to '{s}'; use an explicit cast (`xx`/`cast`)", .{ value, self.formatTypeName(dst_ty) });
}
/// Apply the unified float→int narrowing rule to a typed-binding initializer
/// EXPRESSION `node` whose declared type is `dst` (a typed local, a struct
/// field default, or a call argument incl. an expanded param default). When
/// `node` is a COMPILE-TIME float narrowing into an integer type:
/// - an INTEGRAL value (`4.0`, `M + 2.0`) folds to its `constInt`;
/// - a NON-integral value (`1.5`, `M + 0.5`) emits the narrowing
/// diagnostic and returns a placeholder so lowering finishes.
/// Returns null — so the caller lowers `node` normally — when the rule does
/// not apply: `dst` is not an integer, `node` is not statically float-typed,
/// or `node` is not a compile-time constant (a genuine runtime float keeps
/// truncating, and `xx` / `cast` keep their explicit-truncation escape since
/// a cast node's inferred type is the destination integer, not a float).
/// Reuses `program_index.evalConstIntExpr` (exact integral fold) +
/// `evalConstFloatExpr` (non-integral detection) + `floatToIntExact`.
fn foldComptimeFloatInit(self: *Lowering, node: *const Node, dst: TypeId) ?Ref {
if (!self.isIntEx(dst)) return null;
// PURE & side-effect-free, so it runs FIRST: a runtime / non-comptime /
// non-numeric node — incl. a `$pack[i]` index expression — folds to null
// and is left to the normal path untouched. (Calling `inferExprType` on
// a pack-index value before this guard would spuriously resolve the
// enclosing pack type outside an active binding.)
const fv = program_index_mod.evalConstFloatExpr(node, self) orelse return null;
// Only a FLOAT-flavored initializer narrows here; a plain comptime int
// (`5`, `M + 2`) is left to the normal integer path. Safe to infer now —
// `evalConstFloatExpr` only succeeds for literal / const-arithmetic
// nodes, never an unbound pack index.
if (!isFloat(self.inferExprType(node))) return null;
// Integral comptime float folds to its int (`floatToIntExact`, the same
// facility the array-dim / `$K: Count` paths use); a non-integral one is
// the narrowing error.
if (program_index_mod.floatToIntExact(fv)) |iv| return self.builder.constInt(iv, dst);
self.diagNonIntegralNarrow(node.span, fv, dst);
return self.builder.constInt(0, dst);
}
/// Lower a struct field default `default_expr`, coerced to the field type
/// `field_ty`. A compile-time float default narrowing into an integer field
/// follows the unified rule via `foldComptimeFloatInit`; everything else
/// lowers under the field type as target and coerces at the IR level.
fn lowerCoercedDefault(self: *Lowering, default_expr: *const Node, field_ty: TypeId) Ref {
if (self.foldComptimeFloatInit(default_expr, field_ty)) |folded| return folded;
const saved_tt = self.target_type;
self.target_type = field_ty;
const raw = self.lowerExpr(default_expr);
self.target_type = saved_tt;
return self.coerceToType(raw, self.builder.getRefType(raw), field_ty);
}
/// 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.
@@ -14657,12 +14733,10 @@ pub const Lowering = struct {
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.
// Non-integral: diagnose, then fall through to the
// truncating op below so lowering finishes and
// `hasErrors()` aborts the build.
self.diagNonIntegralNarrow(.{ .start = info.span.start, .end = info.span.end }, info.value, dst_ty);
}
}
return self.builder.emit(.{ .float_to_int = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty);

View File

@@ -315,3 +315,43 @@ test "evalConstIntExpr folds an integral float literal, halts on a fractional on
try std.testing.expectEqual(@as(?i64, 5), eval(&add, ctx));
try std.testing.expect(eval(&addbad, ctx) == null);
}
test "evalConstFloatExpr folds comptime float expressions, halts on runtime leaves" {
const eval = pi.evalConstFloatExpr;
const ctx = DimCtx{}; // M = 4, N = 6
var half = nFloat(0.5);
var two_f = nFloat(2.0);
var m = nIdent("M");
var z = nIdent("Z"); // unbound — genuinely runtime
// Leaves: a float literal is itself; an int leaf delegates to the int folder
// and promotes (`M` → 4.0); an unbound name is not a compile-time float.
try std.testing.expectEqual(@as(?f64, 0.5), eval(&half, ctx));
try std.testing.expectEqual(@as(?f64, 4.0), eval(&m, ctx));
try std.testing.expect(eval(&z, ctx) == null);
// Mixed int+float arithmetic promotes to f64, order-independent
// (`M + 0.5` and `0.5 + M` → 4.5). `M + 2.0` is integral (6.0) but still a
// float value here — `floatToIntExact` is what the narrowing rule applies.
var mp = nBin(.add, &m, &half);
var pm = nBin(.add, &half, &m);
var mi = nBin(.add, &m, &two_f);
try std.testing.expectEqual(@as(?f64, 4.5), eval(&mp, ctx));
try std.testing.expectEqual(@as(?f64, 4.5), eval(&pm, ctx));
try std.testing.expectEqual(@as(?f64, 6.0), eval(&mi, ctx));
// Unary negate of a float expression.
var neg = nNeg(&mp);
try std.testing.expectEqual(@as(?f64, -4.5), eval(&neg, ctx));
// A runtime operand poisons the whole fold; a non-arithmetic operator and a
// float division by zero are not compile-time float leaves → null.
var zp = nBin(.add, &z, &half);
var cmp = nBin(.lt, &m, &half);
var zero_f = nFloat(0.0);
var divz = nBin(.div, &half, &zero_f);
try std.testing.expect(eval(&zp, ctx) == null);
try std.testing.expect(eval(&cmp, ctx) == null);
try std.testing.expect(eval(&divz, ctx) == null);
}

View File

@@ -218,6 +218,48 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 {
};
}
/// Compile-time FLOAT value of a numeric expression, or null when it is not a
/// compile-time constant (some leaf is a runtime value) or is not a numeric
/// shape. THE float counterpart to `evalConstIntExpr`, used by the unified
/// float→int narrowing rule to (1) tell a compile-time float initializer apart
/// from a runtime one and (2) recover its value for `floatToIntExact` (integral
/// → fold) / the non-integral diagnostic.
///
/// An all-integer-foldable subtree is delegated to `evalConstIntExpr` (so module
/// / comptime consts, `<IntType>.min`/`.max`, and integer arithmetic resolve
/// through the SINGLE int folder — no parallel integer logic here); only the
/// genuinely float-producing shapes — a float literal, a unary negate, and
/// `+ - * /` arithmetic involving a float — are evaluated here in `f64`. A `%`,
/// comparison, or any other shape is not a compile-time float leaf → null.
pub fn evalConstFloatExpr(node: *const Node, ctx: anytype) ?f64 {
// Delegate any integer-foldable subtree (incl. an INTEGRAL float like `4.0`
// / `M + 2.0`) to the single int folder, then promote — keeps named consts
// and `.min`/`.max` resolution in one place.
if (evalConstIntExpr(node, ctx)) |iv| return @floatFromInt(iv);
return switch (node.data) {
.float_literal => |lit| lit.value,
.unary_op => |u| switch (u.op) {
.negate => {
const v = evalConstFloatExpr(u.operand, ctx) orelse return null;
return -v;
},
else => null,
},
.binary_op => |b| {
const l = evalConstFloatExpr(b.lhs, ctx) orelse return null;
const r = evalConstFloatExpr(b.rhs, ctx) orelse return null;
return switch (b.op) {
.add => l + r,
.sub => l - r,
.mul => l * r,
.div => if (r == 0.0) null else l / r,
else => null,
};
},
else => null,
};
}
/// The outcome of folding a comptime-int and narrowing it to a `u32` count
/// (array dimension / Vector lane / value-param count). `foldDimU32` is the
/// SINGLE place a folded integer becomes a `u32`, so the i64→u32 narrowing is