From 81531208b23a29f692c0efd4098fd4bd2b1c7eb1 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 24 Oct 2026 01:07:20 +0200 Subject: [PATCH] fix: require exact contextual integer to float typing --- LANGUAGE.md | 2 + README.md | 1 + compiler/checker/checker.odin | 45 ++++++++++- compiler/checker/comptime.odin | 69 ++++++++++++++--- compiler_tests.odin | 137 +++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 14 deletions(-) diff --git a/LANGUAGE.md b/LANGUAGE.md index 248d389..a12c2ed 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -31,6 +31,8 @@ roadmap and milestone history. - target-dependent C scalar primitives from `c_char` through `c_longdouble`, kept semantically distinct from native scalars - contextual integer/float/character literals, backward type-demand inference through names and arithmetic/bitwise expressions, and typed compile-time evaluation for numeric constant expressions - strict numeric conversion by default, widening where valid, C scalar coercions at C boundaries, and explicit scalar casts through keywords or transparent aliases, such as `i32(x)`, `c_float(x)`, or `StringId(x)` +- implicit contextual integer constants must be exactly representable in the destination float, including folded expressions and inferred constants; `f32` rejects `16777217` but accepts `16777218`, while `f64` rejects `9007199254740993` but accepts `9007199254740994`; powers of two remain exact +- float literals retain IEEE rounding, and explicit scalar casts such as `f32(16777217)` or `f64(9007199254740993)` deliberately permit rounding; existing typed-value widening and C scalar coercion rules are unchanged - compile-time `minval!(T)` and `maxval!(T)` bounds for concrete native, C, and scalar-backed distinct integer types; the result retains `T` - arrays `[N]T`, inferred-count arrays `[_]T`, sentinel arrays `[N;S]T`, compile-time expression array counts, slices `[]T` / `[;S]T`, single-item pointers `@T`, many-item pointers `*T`, and sentinel many-item pointers `[*;S]T` - pointer mutability via `mut`, optional pointers as nullable pointers, pointer arithmetic for many-item pointers, postfix dereference `^`, and trapping optional unwrap `?` diff --git a/README.md b/README.md index 73378c7..e2cb0b6 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ Current prototype features: - String literals as immutable pointers to static zero-terminated byte arrays - Pointer-preserving `.ptr`/`.len`, pointer-to-array indexing and slicing, postfix pointer dereference and optional unwrap, and keyed struct literals - Contextual integer constants and typed compile-time evaluation of arithmetic and Zig-style bitwise expressions +- Contextual integer-to-float constants require exact representation (`f32` accepts `16777218`, not `16777217`; `f64` accepts `9007199254740994`, not `9007199254740993`), including folded and inferred constants; float literals and explicit scalar casts retain IEEE rounding - Integer `~`, `&`, `|`, `xor`, guarded `<<` / `>>`, saturating `<<|`, and their compound assignments; postfix `^` remains pointer dereference - Scalar-backed nominal `distinct` types with same-identity runtime/comptime operators, explicit backing extraction casts, integer bounds, reflection, and recursive standard formatting - Directory packages with merged declarations and file-local relative imports diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 4042718..790948f 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -6,6 +6,7 @@ import "../source" import "../symbol" import "../target" import "../types" +import "base:intrinsics" import "core:fmt" import "core:mem" import "core:slice" @@ -1206,6 +1207,33 @@ fits_integer_type :: proc(value: i128, value_type: types.Type, selected := targe return fits_signed_type(value, value_type, selected) || fits_unsigned_type(value, value_type, selected) } +integer_fits_float :: proc(value: i128, expected: types.Type, selected: target.Target) -> bool { + if !types.is_float(expected, selected) { + return false + } + precision := 0 + switch types.bits(expected, selected) { + case 32: precision = 24 + case 64: precision = 53 + case: return false + } + if value == 0 { + return true + } + magnitude := u128(0) + if value < 0 { + magnitude = u128(-(value + 1)) + 1 + } else { + magnitude = u128(value) + } + // Binary floats preserve the significant bits between the first and last one. + // Trailing zeros cost no precision, so large powers of two remain exact. + // Unsigned magnitude also handles i128's minimum without a float round-trip. + significant_bits := 128 - int(intrinsics.count_leading_zeros(magnitude)) - + int(intrinsics.count_trailing_zeros(magnitude)) + return significant_bits <= precision +} + fits_i64 :: proc(value: i128) -> bool { return fits_signed_type(value, types.I64) } @@ -7625,12 +7653,15 @@ build_constant_expr :: proc( numeric_operation := false, ) -> hir.Expr_Id { materialized := expected + for types.is_optional(materialized, &checker.module.types) { + materialized = types.child_type(materialized, &checker.module.types) + } nominal := types.INVALID if numeric_operation { - if representation, ok := types.distinct_scalar_backing(expected, &checker.module.types); + if representation, ok := types.distinct_scalar_backing(materialized, &checker.module.types); ok && !types.is_bool(representation) { + nominal = materialized materialized = representation - nominal = expected } } recovery_type := types.I64 @@ -7640,7 +7671,15 @@ build_constant_expr :: proc( // An integer constant in a float context (e.g. `pi float = 3`) folds to a // float literal, mirroring build_float_expr's bit packing. if constant.kind == .Value && types.is_float(materialized, checker.target) { - fval := f64(constant.value) // ponytail: silent precision loss past 2^53, like C int->double + if !integer_fits_float(constant.value, materialized, checker.target) { + id := source.addf( + checker.diagnostics, expr.span, + "integer constant %d is not exactly representable in %s", + constant.value, type_label(checker, materialized), + ) + return invalid_hir_expr(checker, expr.span, id, expected) + } + fval := f64(constant.value) bits := transmute(i64)fval if types.bits(materialized, checker.target) == 32 { bits = i64(transmute(u32)f32(fval)) diff --git a/compiler/checker/comptime.odin b/compiler/checker/comptime.odin index 86b32b0..5c623ad 100644 --- a/compiler/checker/comptime.odin +++ b/compiler/checker/comptime.odin @@ -761,11 +761,20 @@ ct_coerce_value :: proc(state: ^Ct_State, id: Ct_Value_Id, expected: types.Type, return ct_add_value(state, value), true } if types.is_float(expected, state.checker.target) { + if !integer_fits_float(value.integer, expected, state.checker.target) { + return INVALID_CT_VALUE, ct_failf( + state, .Not_Comptime, span, "integer constant %d is not exactly representable in %s", + value.integer, type_label(state.checker, expected), + ) + } return ct_add_value(state, Ct_Value{kind=.Float, type=expected, float=f64(value.integer)}), true } } if value.kind == .Float && types.is_float(expected, state.checker.target) { value.type = expected + if types.bits(expected, state.checker.target) == 32 { + value.float = f64(f32(value.float)) + } return ct_add_value(state, value), true } return INVALID_CT_VALUE, ct_failf( @@ -1351,6 +1360,12 @@ ct_eval_expr :: proc( if numeric_operation { if representation, distinct_ok := types.distinct_scalar_backing(expected, store); distinct_ok { if types.is_float(representation, checker.target) { + if !integer_fits_float(i128(expr.integer), representation, checker.target) { + return INVALID_CT_VALUE, ct_flow(.Normal), ct_failf( + state, .Not_Comptime, expr.span, "integer constant %d is not exactly representable in %s", + i128(expr.integer), type_label(checker, expected), + ) + } return ct_add_value(state, Ct_Value{ kind=.Float, type=expected, float=f64(expr.integer), }), ct_flow(.Normal), true @@ -1376,13 +1391,21 @@ ct_eval_expr :: proc( if numeric_operation { if representation, distinct_ok := types.distinct_scalar_backing(expected, store); distinct_ok && types.is_float(representation, checker.target) { + value := transmute(f64)expr.integer + if types.bits(representation, checker.target) == 32 { + value = f64(f32(value)) + } return ct_add_value(state, Ct_Value{ - kind=.Float, type=expected, float=transmute(f64)expr.integer, + kind=.Float, type=expected, float=value, }), ct_flow(.Normal), true } } value_type := expected if types.is_float(expected, checker.target) else types.F64 - return ct_add_value(state, Ct_Value{kind=.Float, type=value_type, float=transmute(f64)expr.integer}), ct_flow(.Normal), true + value := transmute(f64)expr.integer + if types.bits(value_type, checker.target) == 32 { + value = f64(f32(value)) + } + return ct_add_value(state, Ct_Value{kind=.Float, type=value_type, float=value}), ct_flow(.Normal), true case .String: return ct_add_value(state, Ct_Value{kind=.String, type=string_literal_type(checker, expr.integer), index=expr.integer}), ct_flow(.Normal), true case .Name: @@ -2679,6 +2702,30 @@ ct_eval_binary :: proc(state: ^Ct_State, op: ast.Expr_Kind, left_id, right_id: C if distinct_operation && (!compatible || !types.is_float(operation.representation, state.checker.target)) { return INVALID_CT_VALUE, ct_flow(.Normal), ct_fail(state, .Not_Comptime, span, "comptime binary expression requires compatible operands") } + result_type := operation.result + if !distinct_operation { + result_type = types.widest(left.type, right.type) + if left.kind != .Float { + result_type = right.type + } else if right.kind != .Float { + result_type = left.type + } + if !types.is_float(result_type, state.checker.target) { + result_type = types.F64 + } + } + representation := types.runtime_representation(result_type, store) + operands := [2]Ct_Value_Id{left_id, right_id} + for operand_id in operands { + operand := &state.values[operand_id] + if operand.kind != .Float && + !integer_fits_float(operand.integer, representation, state.checker.target) { + return INVALID_CT_VALUE, ct_flow(.Normal), ct_failf( + state, .Not_Comptime, span, "integer constant %d is not exactly representable in %s", + operand.integer, type_label(state.checker, result_type), + ) + } + } lf := left.float if left.kind == .Float else f64(left.integer) rf := right.float if right.kind == .Float else f64(right.integer) if is_compare { @@ -2702,14 +2749,7 @@ ct_eval_binary :: proc(state: ^Ct_State, op: ast.Expr_Kind, left_id, right_id: C case: return INVALID_CT_VALUE, ct_flow(.Normal), ct_fail(state, .Not_Comptime, span, "comptime binary expression requires compatible operands") } - result_type := operation.result - if !distinct_operation { - result_type = types.widest(left.type, right.type) - if !types.is_float(result_type, state.checker.target) { - result_type = types.F64 - } - } - if types.bits(result_type, state.checker.target) == 32 { + if types.bits(representation, state.checker.target) == 32 { result = f64(f32(result)) } return ct_add_value(state, Ct_Value{kind=.Float, type=result_type, float=result}), ct_flow(.Normal), true @@ -3144,13 +3184,20 @@ ct_scalar_cast :: proc(state: ^Ct_State, id: Ct_Value_Id, target: types.Type, sp if value.kind == .Float { if types.is_float(target, state.checker.target) { value.type = target + if types.bits(target, state.checker.target) == 32 { + value.float = f64(f32(value.float)) + } return ct_add_value(state, value), ct_flow(.Normal), true } return ct_add_value(state, Ct_Value{kind=.Integer, type=target, integer=i128(value.float)}), ct_flow(.Normal), true } if ct_is_integer_like(value) { if types.is_float(target, state.checker.target) { - return ct_add_value(state, Ct_Value{kind=.Float, type=target, float=f64(value.integer)}), ct_flow(.Normal), true + result := f64(value.integer) + if types.bits(target, state.checker.target) == 32 { + result = f64(f32(result)) + } + return ct_add_value(state, Ct_Value{kind=.Float, type=target, float=result}), ct_flow(.Normal), true } value.kind = .Integer value.type = target diff --git a/compiler_tests.odin b/compiler_tests.odin index ed0c0c0..4dc224f 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -15686,6 +15686,143 @@ main func() void { testing.expect(t, found) } +@(test) +contextual_integer_float_precision_loss_is_diagnosed :: proc(t: ^testing.T) { + Boundary :: struct {scalar, integer: string} + boundaries := []Boundary{ + {"f32", "16777217"}, + {"f32", "-16777217"}, + {"f64", "9007199254740993"}, + {"f64", "-9007199254740993"}, + } + Context :: struct {declarations, body: string} + contexts := []Context{ + {"bad Scalar :: VALUE", "_ = bad"}, + {"bad Scalar := VALUE", "_ = bad"}, + {"bad Scalar :: $VALUE", "_ = bad"}, + {"bad Scalar :: ${ yield VALUE }", "_ = bad"}, + {"", "value Scalar := VALUE\n_ = value"}, + {"", "local :: VALUE\nvalue Scalar := local\n_ = value"}, + {"", "local :: VALUE + 2 - 2\nvalue Scalar := local\n_ = value"}, + {"folded :: VALUE + 2 - 2", "value Scalar := folded\n_ = value"}, + {"", "value Scalar := G\n_ = value"}, + {"", "value Scalar := VALUE + 2 - 2\n_ = value"}, + {"", "_ = take(VALUE)"}, + {"", "return VALUE"}, + {"", "values [1]Scalar := [VALUE]\n_ = values"}, + {"", "value ?Scalar := VALUE\n_ = value"}, + {"", "value ?Scalar := some!(VALUE)\n_ = value"}, + {"", "seed Nominal := Nominal(0.0)\n_ = seed + VALUE"}, + {"", "seed Scalar := 0.0\n_ = seed + VALUE"}, + {"", "local :: VALUE\nseed Scalar := 0.0\n_ = local + seed"}, + {"", "seed Scalar := 0.0\n_ = seed == VALUE"}, + } + for boundary in boundaries { + for test_context in contexts { + body, body_allocated := strings.replace_all(test_context.body, "VALUE", boundary.integer) + declarations, declarations_allocated := strings.replace_all(test_context.declarations, "VALUE", boundary.integer) + evaluation_modes := [2]bool{false, true} + for comptime in evaluation_modes { + invocation := "$exercise()" if comptime else "exercise()" + text := fmt.aprintf( + "Scalar :: alias %s\nNominal :: distinct %s\nG :: %s\n%s\n"+ + "take func(value Scalar) Scalar {{ return value }}\n"+ + "exercise func() Scalar {{\n%s\nreturn 0\n}}\n"+ + "main func() void {{ _ = %s }}\n", + boundary.scalar, boundary.scalar, boundary.integer, declarations, body, invocation, + ) + source_file := source.Source{path="test.bro", text=text} + diagnostics := source.init_diagnostics(&source_file) + symbols := symbol.init_table() + stream := lexer.lex(&source_file, &diagnostics, &symbols) + ast_module := parser.parse(&stream, &source_file, &diagnostics) + hir_module := checker.check(&ast_module, &diagnostics, &symbols) + found := false + for diagnostic in diagnostics.items { + found = found || strings.contains(diagnostic.message, "not exactly representable") + } + testing.expect(t, found, text) + hir.destroy_module(&hir_module) + ast.destroy_module(&ast_module) + delete(stream.items) + symbol.destroy_table(&symbols) + source.destroy_diagnostics(&diagnostics) + delete(text) + } + if body_allocated { delete(body) } + if declarations_allocated { delete(declarations) } + } + } +} + +@(test) +exact_contextual_integer_floats_and_explicit_rounding_compile_and_run :: proc(t: ^testing.T) { + directory := "/tmp/brolang-test-exact-integer-floats" + main_path := "/tmp/brolang-test-exact-integer-floats/main.bro" + output := "/tmp/brolang-test-exact-integer-floats-output" + _ = os2.remove_all(directory) + defer _ = os2.remove_all(directory) + defer _ = os.remove(output) + testing.expect(t, os2.make_directory_all(directory) == nil) + text := `Nominal :: distinct f32 +G :: 16777216 +H :: 9007199254740992 +take32 func(value f32) f32 { return value } +take64 func(value f64) f64 { return value } +give32 func() f32 { return -16777216 } +give64 func() f64 { return -9007199254740992 } +exercise func() i32 { + local32 :: 16777216 + local64 :: 9007199254740992 + a f32 := local32 + b f64 := local64 + c f32 := G + d f64 := H + folded32 f32 := 16777215 + 1 + folded64 f64 := 9007199254740991 + 1 + power32 f32 := 9223372036854775808 + power64 f64 := -9223372036854775808 + next32 f32 := 16777218 + next64 f64 := 9007199254740994 + values32 [2]f32 := [16777216, -16777216] + values64 [2]f64 := [9007199254740992, -9007199254740992] + optional32 ?f32 := 16777216 + optional64 ?f64 := some!(-9007199254740992) + nominal := Nominal(0.0) + 16777216 + literal32 f32 := 16777217.0 + literal64 f64 := 9007199254740993.0 + cast32 f32 := f32(16777217) + cast64 f64 := f64(9007199254740993) + negative_literal64 f64 := -9007199254740993.0 + negative_cast32 f32 := f32(-16777217) + negative_cast64 f64 := f64(-9007199254740993) + if a != 16777216.0 or b != 9007199254740992.0 { return 1 } + if c != a or d != b or folded32 != a or folded64 != b { return 2 } + if take32(16777216) != a or take64(9007199254740992) != b { return 3 } + if give32() != -a or give64() != -b { return 4 } + if values32[1] != -a or values64[1] != -b { return 5 } + if (optional32 orelse 0.0) != a or (optional64 orelse 0.0) != -b { return 6 } + if f32(nominal) != a { return 7 } + if literal32 != a or literal64 != b { return 8 } + if cast32 != a or cast64 != b { return 9 } + if power32 != 9223372036854775808.0 or power64 != -9223372036854775808.0 { return 10 } + if next32 != 16777218.0 or next64 != 9007199254740994.0 { return 11 } + if negative_literal64 != -b { return 12 } + if negative_cast32 != -a or negative_cast64 != -b { return 13 } + return 0 +} +main func() i32 { + checked i32 :: $exercise() + if checked != 0 { return checked } + return exercise() +} +` + testing.expect(t, os.write_entire_file(main_path, transmute([]byte)text)) + testing.expect_value(t, compiler_core.compile_package(directory, output), 0) + state := run_executable(output) + testing.expect_value(t, state.exit_code, 0) +} + @(test) missing_qualified_signature_symbol_reports_one_root_error :: proc(t: ^testing.T) { directory := "/tmp/brolang-test-root-diagnostic"