From 5fd8d9eb89faeffcbc1bac0305555846a58b1f51 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 24 Oct 2026 01:02:20 +0200 Subject: [PATCH] fix: reject inexact integer literals in float context --- README.md | 4 +++- TODO.md | 3 --- source/diagnostics/diagnostics.hon | 2 ++ source/lower/lower.hon | 15 ++++++++++++ source/lower/lower.test.hon | 38 ++++++++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77f5d33..f73907c 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ type checking (`@source/sema/check`) runs after name resolution. `check.check` b the checker keeps union-find parent links and group sizes in a compact array separate from per-node inference metadata. root walks and path compression access only that link storage; both temporary arrays are released before `check.check` returns. -numeric typing follows contextual rules within Honey's current `int` and `float` families. integer literals can adopt either family; float literals remain float. numeric demands propagate through arithmetic and inferred bindings before undemanded integers default to `int`. conflicting demands are errors; fixed `int` values do not implicitly convert to `float`. `+`, `-`, `*` and unary `-` require compatible numeric operands; `/` requires and supplies float context. `3 / 2`, `3.0 / 2`, and `ratio float :: 3 / 2` are valid; division involving fixed `int` values requires explicit conversion. +numeric typing follows contextual rules within Honey's current `int` and `float` families. integer literals can adopt a supported numeric family only when their value is exactly representable; float literals remain float and opt into ordinary IEEE rounding. numeric demands propagate through arithmetic and inferred bindings before undemanded integers default to `int`. conflicting demands are errors; fixed `int`/`i64`, `usize`, and `float`/`f64` values do not implicitly convert between families. `+`, `-`, `*` and unary `-` require compatible numeric operands; `/` requires and supplies float context. `3 / 2`, `3.0 / 2`, and `ratio float :: 3 / 2` are valid. `value f64 :: 9007199254740993` is diagnosed, including when float context arrives through aliases, division, or array elements; `9007199254740993.0` explicitly permits rounding. exactly representable integers beyond the signed/unsigned integer ranges remain valid in float context. explicit numeric casts are not yet implemented. + +the conversion policy is value-preserving by default: any future implicit widening must preserve every value of the source type, not merely the value observed at one use. narrowing, potentially inexact integer-to-float conversion, float-to-integer conversion, and signedness changes that cannot preserve the full source range require an explicit operation. integer arithmetic remains checked rather than wrapping; floating arithmetic retains IEEE rounding. type errors carry source spans and diagnostic identities; invalid operands propagate their existing identity without duplicate errors, while independent expressions, arguments and nested bodies are still checked. inference cycles without a type anchor are diagnosed. function signatures are checked independently of their bodies. bare `return` and same-line `return expression` are supported inside function bodies and must match the declared result type. nonvoid return-path checking is structural: every path must return or terminate in a diagnostic trap, and both conditional successors are considered without constant folding. a missing `else` or a loop permits fallthrough, even for a literal `true` condition; a later return can close that path. statements after a return and untaken branches or loops are still type-checked but are not executed. diff --git a/TODO.md b/TODO.md index e1de3ac..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +0,0 @@ -- [ ] consider whether numeric conversion semantics are properly tuned. - - e.g. are we disallowing implicit conversion between types where loss of precission or wrapping could/would occur? - - should we do that or should we not? \ No newline at end of file diff --git a/source/diagnostics/diagnostics.hon b/source/diagnostics/diagnostics.hon index b582a61..35858b9 100644 --- a/source/diagnostics/diagnostics.hon +++ b/source/diagnostics/diagnostics.hon @@ -48,6 +48,7 @@ Code :: enum { missing_entry, invalid_entry, literal_out_of_range, + inexact_integer_literal, invalid_escape, read_before_initialization, write_before_initialization, @@ -131,6 +132,7 @@ messages :: $enummap.init(Code, []u8, { missing_entry = "program requires a main function", invalid_entry = "main must be a function with no parameters", literal_out_of_range = "numeric literal is outside its runtime type's range", + inexact_integer_literal = "integer literal is not exactly representable as float; use a floating literal to allow rounding", invalid_escape = "invalid string escape", read_before_initialization = "local value was read before initialization", write_before_initialization = "local value was assigned before initialization", diff --git a/source/lower/lower.hon b/source/lower/lower.hon index 71d0aee..c69925f 100644 --- a/source/lower/lower.hon +++ b/source/lower/lower.hon @@ -872,6 +872,17 @@ integer_magnitude proc(text []u8, limit u64) ?u64 { return value } +@hide:file +exact_float_integer proc(text []u8, value f64) bool { + # every finite f64 integer has at most 309 decimal digits, plus a terminator. + decimal [310]mut u8 := undefined + count :: c.snprintf(ptrcast!((&decimal).ptr), c_ulong(decimal.len), "%.0f", value) + if (count < 0 or usize(count) >= decimal.len) return false + start := usize(0) + while (start + 1 < text.len and text[start] == '0') start += 1 + return mem.eql(text[start..], decimal[..usize(count)]) +} + @hide:file lower_number proc(state @mut State, id ast.NodeId) ir.ValueId ! (mem.AllocError | diagnostics.CapacityError) { span :: ast.node_span(state.tree, id) @@ -894,6 +905,10 @@ lower_number proc(state @mut State, id ast.NodeId) ir.ValueId ! (mem.AllocError value :: f64(c.strtod(ptrcast!(buffer.ptr), null)) # libc reports ERANGE for representable subnormals as well as zero/infinity. if (c.__error()?^ != c.ERANGE or (value > 0 and value < 1)) { + if (ast.expr_kind(state.tree, id) == .literal_int and !exact_float_integer(text, value)) { + trap(state, try diagnostics.add(state.diagnostics, .inexact_integer_literal, span), span) + return ir.no_value + } return try constant(state, ir.Value{ type = .float, data = ir.ValueData{ real = value } }, span) } } diff --git a/source/lower/lower.test.hon b/source/lower/lower.test.hon index 63f4fde..e3b04e9 100644 --- a/source/lower/lower.test.hon +++ b/source/lower/lower.test.hon @@ -357,6 +357,44 @@ parses_float_context_without_integer_range_limits_and_rejects_float_overflow tes try expect_trap(trapped, &diagnostic_store, .literal_out_of_range) } +rejects_inexact_integer_literals_in_float_context test { + sources [4][]u8 :: [ + "main proc() f64 {\n return 9007199254740993\n}\n", + "main proc() f64 {\n value :: 9007199254740993\n return value\n}\n", + "main proc() f64 {\n return 9007199254740993 / 1\n}\n", + "main proc() f64 {\n values [1]f64 :: [9007199254740993]\n return values[0]\n}\n", + ] + for (sources) |source| { + diagnostic_store := diagnostics.init(mem.c_allocator) + defer diagnostics.deinit(&diagnostic_store) + module := try compile_owned(source, &diagnostic_store) + defer ir.deinit(&module) + outcome := try interpreter.run(mem.c_allocator, &module, &diagnostic_store) + defer interpreter.deinit(&outcome) + try expect_trap(outcome, &diagnostic_store, .inexact_integer_literal) + } +} + +preserves_exact_large_integers_and_explicit_float_rounding test { + sources [3][]u8 :: [ + "main proc() f64 {\n return 9007199254740994 - 9007199254740992\n}\n", + "main proc() f64 {\n return 9007199254740993.0 - 9007199254740992\n}\n", + "main proc() f64 {\n return 0003\n}\n", + ] + expected [3]f64 :: [2, 0, 3] + for sources |source, i| { + diagnostic_store := diagnostics.init(mem.c_allocator) + defer diagnostics.deinit(&diagnostic_store) + module := try compile_owned(source, &diagnostic_store) + defer ir.deinit(&module) + outcome := try interpreter.run(mem.c_allocator, &module, &diagnostic_store) + defer interpreter.deinit(&outcome) + try testing.expect_equal(0, diagnostics.count(&diagnostic_store)) + try testing.expect(outcome.trap == null) + try testing.expect_equal(expected[i], outcome.value.data.real) + } +} + accepts_representable_subnormals_and_rejects_underflow_to_zero test { zeros [2]usize :: [307, 400] for (zeros) |count| {