fix(ir): unify float→int narrowing — integral folds, non-integral errors [F0.11]

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.
This commit is contained in:
agra
2026-06-05 15:34:33 +03:00
parent 341b62c197
commit 4c12e1de38
16 changed files with 362 additions and 14 deletions

View File

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

View File

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

View File

@@ -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 {