fix: reject inexact integer literals in float context

This commit is contained in:
2026-10-24 01:02:20 +02:00
parent 9c1bd2a20e
commit 5fd8d9eb89
5 changed files with 58 additions and 4 deletions
+3 -1
View File
@@ -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.
-3
View File
@@ -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?
+2
View File
@@ -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",
+15
View File
@@ -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)
}
}
+38
View File
@@ -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| {