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:
130
src/ir/lower.zig
130
src/ir/lower.zig
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user