From 0593786553c353ff94a6c722d52d454758f01b09 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 24 Oct 2026 04:02:32 +0200 Subject: [PATCH] feat: parse floating-point text through the standard library --- README.md | 2 + source/lower/lower.hon | 25 +++--- source/main.hon | 1 + std/strconv/strconv.hon | 67 ++++++++++++++++ std/strconv/strconv.test.hon | 150 +++++++++++++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 std/strconv/strconv.hon create mode 100644 std/strconv/strconv.test.hon diff --git a/README.md b/README.md index f73907c..03424e1 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ numeric typing follows contextual rules within Honey's current `int` and `float` 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. +`@std/strconv.parse_float(allocator, text)` parses a complete borrowed `[]u8` decimal into `f64`. it accepts an optional sign, decimal point, and exponent; whitespace, embedded NUL bytes, trailing input, hexadecimal, infinities, and NaNs return `invalid_number`. finite subnormals and signed zero are preserved; overflow and nonzero values rounded to zero return `out_of_range`. allocation failures propagate as `out_of_memory`, and temporary storage is always released. the Darwin backend uses libc's C-locale conversion without changing the process locale; rounding follows the caller's floating-point rounding mode. numeric lowering uses this interface, keeping exact integer-to-float validation compiler-specific. + 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. `lower.lower` turns the checked AST and binding table into an owned `@source/ir` module. functions, globals, local slots and instruction-result temporaries have explicit IDs; typed instructions execute in source evaluation order and basic blocks end in jump, conditional-branch, return or diagnostic-trap terminators. conditional arms join only when execution can continue; loops use condition/body/exit blocks and backedges. short-circuit expressions use branches and synthetic boolean local slots to merge results; call arguments are packed in the block reached after evaluating them. local slots and temporaries are function-wide, without SSA or phi nodes. literals are parsed and strings decoded during lowering; the module retains no AST, binding-table or source-buffer references. release it with `ir.deinit`; keep and reuse the diagnostic store used during lowering so diagnostic IDs remain valid. the IR is target-independent and is intended to feed later native backends as well as the current interpreter. diff --git a/source/lower/lower.hon b/source/lower/lower.hon index c69925f..717ebd4 100644 --- a/source/lower/lower.hon +++ b/source/lower/lower.hon @@ -1,5 +1,6 @@ import "@std/mem" import "@std/arraylist" +import "@std/strconv" import "@ffi/c" import "@source/ast" import "@source/diagnostics" @@ -897,20 +898,20 @@ lower_number proc(state @mut State, id ast.NodeId) ir.ValueId ! (mem.AllocError return try constant(state, ir.Value{ type = .usize, data = ir.ValueData{ unsigned = magnitude } }, span) } } else { - buffer :: try mem.alloc(u8, state.module.allocator, text.len + 1) - defer mem.free(state.module.allocator, buffer) - for (text) |byte, i| buffer[i] = byte - buffer[text.len] = 0 - c.__error()?^ = 0 - 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 + value :: strconv.parse_float(state.module.allocator, text) catch |err| { + match err { + .out_of_memory: return err + .invalid_number, .out_of_range: { + trap(state, try diagnostics.add(state.diagnostics, .literal_out_of_range, span), span) + return ir.no_value + } } - return try constant(state, ir.Value{ type = .float, data = ir.ValueData{ real = value } }, span) } + 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) } trap(state, try diagnostics.add(state.diagnostics, .literal_out_of_range, span), span) return ir.no_value diff --git a/source/main.hon b/source/main.hon index ea2930e..56f5d72 100644 --- a/source/main.hon +++ b/source/main.hon @@ -24,6 +24,7 @@ test import "@std/strmap" test import "@std/meta" test import "@std/io" test import "@std/process" +test import "@std/strconv" test import "@source/diagnostics" test import "@source/strpool" test import "@source/lexer" diff --git a/std/strconv/strconv.hon b/std/strconv/strconv.hon new file mode 100644 index 0000000..00b2d84 --- /dev/null +++ b/std/strconv/strconv.hon @@ -0,0 +1,67 @@ +import "@ffi/c" +import "@std/mem" + +ParseError :: enum { + invalid_number + out_of_range +} + +#! parse a complete ASCII decimal with an optional sign, decimal point, and exponent. +#! at least one mantissa digit and, after an exponent marker, one exponent digit are required. +#! whitespace, embedded NUL bytes, trailing bytes, hexadecimal, infinities, and NaNs are invalid. +#! finite subnormals and signed zero succeed; overflow and nonzero values rounded to zero are out of range. +#! temporary storage uses `allocator` and is released before returning; the input is only borrowed. +#! the Darwin backend uses strtod_l with LC_C_LOCALE, independent of the process and thread locales. +#! decimal rounding follows native libc and the caller's floating-point rounding mode. +parse_float proc(allocator mem.Allocator, text []u8) f64 ! (mem.AllocError | ParseError) { + if (text.len == 0) return .invalid_number + cursor := usize(0) + if (text[cursor] == '+' or text[cursor] == '-') cursor += 1 + + digits := usize(0) + nonzero := false + point := false + while cursor < text.len { + byte :: text[cursor] + if (byte >= '0' and byte <= '9') { + digits += 1 + if (byte != '0') nonzero = true + } else if (byte == '.' and !point) { + point = true + } else break + cursor += 1 + } + if (digits == 0) return .invalid_number + + if (cursor < text.len and (text[cursor] == 'e' or text[cursor] == 'E')) { + cursor += 1 + if (cursor < text.len and (text[cursor] == '+' or text[cursor] == '-')) cursor += 1 + start :: cursor + while (cursor < text.len and text[cursor] >= '0' and text[cursor] <= '9') cursor += 1 + if (cursor == start) return .invalid_number + } + if (cursor != text.len) return .invalid_number + if (text.len == maxval!(usize)) return .out_of_memory + + buffer :: try mem.alloc(u8, allocator, text.len + 1) + defer mem.free(allocator, buffer) + memcopy!(buffer[..text.len], text) + buffer[text.len] = 0 + c.__error()?^ = 0 + value :: f64(strtod_l(ptrcast!(buffer.ptr), null, null)) + range_error :: c.__error()?^ == c.ERANGE + + # binary64 magnitudes preserve subnormals despite libc's ERANGE; infinity starts at 9218868437227405312. + # ERANGE at magnitude >= 1 also catches overflow saturated by directed rounding. + magnitude :: ptrcast!(@u64, &value)^ & u64(9223372036854775807) + if ( + magnitude >= u64(9218868437227405312) + or (magnitude == 0 and nonzero) + or (range_error and magnitude >= u64(4607182418800017408)) + ) return .out_of_range + return value +} + +# Darwin's locale_t is an opaque pointer and LC_C_LOCALE is null (<_locale_posix2008.h>). +@hide:file +strtod_l c_func(text ?*c_char, end ?*mut ?*mut c_char, locale ?*mut anyopaque) c_double diff --git a/std/strconv/strconv.test.hon b/std/strconv/strconv.test.hon new file mode 100644 index 0000000..fa6e5a0 --- /dev/null +++ b/std/strconv/strconv.test.hon @@ -0,0 +1,150 @@ +import "@std/mem" +import "@std/testing" + +@hide:file +expect_bits proc(text []u8, expected u64) void ! testing.Error { + value :: parse_float(mem.c_allocator, text) catch |_| { return .expectation_failed } + try testing.expect_equal(expected, ptrcast!(@u64, &value)^) +} + +accepts_signed_decimal_forms_and_exponents test { + texts [7][]u8 :: ["1", "+1", "01", "1.", ".1e1", "10e-1", "1E+0"] + for (texts) |text| try testing.expect_equal(f64(1), try parse_float(mem.c_allocator, text)) + try testing.expect_equal(f64(-1.25), try parse_float(mem.c_allocator, "-1.25")) + try testing.expect_equal(f64(0.5), try parse_float(mem.c_allocator, "+.5")) + try testing.expect_equal(f64(125), try parse_float(mem.c_allocator, "1.25e+2")) + try testing.expect_equal(f64(-0.125), try parse_float(mem.c_allocator, "-125E-3")) +} + +accepts_borrowed_slices_without_a_terminator test { + bytes [5]mut u8 := ['x', '1', '.', '5', 'x'] + try testing.expect_equal(f64(1.5), try parse_float(mem.c_allocator, bytes[1..4])) + try testing.expect_equal(u8('x'), bytes[0]) + try testing.expect_equal(u8('x'), bytes[4]) +} + +rejects_malformed_or_partially_consumed_input test { + texts [33][]u8 :: [ + "", "+", "-", ".", "+.", "-.", "e1", ".e1", + "1e", "1e+", "1e-", "1ee2", "1e1.0", "1.2.3", "--1", "+-1", + " 1", "1 ", "\t1", "1\n", "1x", "1_0", "1,5", "1\x00", "1\x002", + "0x1", "0x1p0", "inf", "-Infinity", "nan", "NaN(1)", "\xff", "1\xff", + ] + for texts |text| { + _ = parse_float(mem.c_allocator, text) catch |error| { + try testing.expect(error == .invalid_number) + continue + } + try testing.expect(false) + } +} + +preserves_signed_zero_even_with_extreme_exponents test { + positive [4][]u8 :: ["0", "+0.0", "0e999999", "0e-999999"] + negative [4][]u8 :: ["-0", "-0.0", "-0e999999", "-0e-999999"] + for positive |text| { + try expect_bits(text, u64(0)) + } + for negative |text| { + try expect_bits(text, maxval!(u64) - u64(9223372036854775807)) + } +} + +rounds_decimal_midpoints_to_nearest_even test { + # IEEE-754 binary64 encodings: 0.1, 1, and the next two values above 1. + try expect_bits("0.1", u64(4591870180066957722)) + try expect_bits("1.00000000000000011102230246251565404236316680908203125", u64(4607182418800017408)) + try expect_bits("1.00000000000000011102230246251565404236316680908203126", u64(4607182418800017409)) + try expect_bits("1.00000000000000033306690738754696212708950042724609375", u64(4607182418800017410)) + try expect_bits("9007199254740993", u64(4845873199050653696)) +} + +accepts_finite_representational_boundaries_and_subnormals test { + # smallest subnormal, largest subnormal, smallest normal, and largest finite binary64. + try expect_bits("4.9406564584124654e-324", u64(1)) + try expect_bits("2.4703282292062328e-324", u64(1)) + try expect_bits("-4.9406564584124654e-324", maxval!(u64) - u64(9223372036854775806)) + try expect_bits("2.225073858507201e-308", u64(4503599627370495)) + try expect_bits("2.2250738585072014e-308", u64(4503599627370496)) + try expect_bits("1.7976931348623157e308", u64(9218868437227405311)) + try expect_bits("1.7976931348623158e308", u64(9218868437227405311)) + try expect_bits("-1.7976931348623157e308", maxval!(u64) - u64(4503599627370496)) +} + +rejects_overflow_and_nonzero_underflow_to_zero test { + texts [12][]u8 :: [ + "1e309", "-1e309", "1.7976931348623159e308", "-1.7976931348623159e308", + "1e999999", "-1e999999", "1e-999999", "-1e-999999", "1e-324", "-1e-324", + "2.4703282292062327e-324", "-2.4703282292062327e-324", + ] + for texts |text| { + _ = parse_float(mem.c_allocator, text) catch |error| { + try testing.expect(error == .out_of_range) + continue + } + try testing.expect(false) + } +} + +@hide:file +AllocationState :: struct { + remaining usize = maxval!(usize) + live usize = 0 +} + +@hide:file +tracked_alloc proc(context ?@mut anyopaque, size usize, alignment usize) ?*mut u8 { + state @mut AllocationState :: ptrcast!(context?) + if (state.remaining == 0) return null + state.remaining -= 1 + memory :: mem.raw_alloc(mem.c_allocator, size, alignment) + if (memory) |_| state.live += 1 + return memory +} + +@hide:file +tracked_realloc proc(context ?@mut anyopaque, memory ?*mut u8, old_size usize, new_size usize, alignment usize) ?*mut u8 { + state @mut AllocationState :: ptrcast!(context?) + if (state.remaining == 0) return null + state.remaining -= 1 + resized :: mem.raw_realloc(mem.c_allocator, memory, old_size, new_size, alignment) + if (new_size == 0) { + if (memory) |_| state.live -= 1 + } else if (resized) |_| { + if (memory) |_| {} else state.live += 1 + } + return resized +} + +@hide:file +tracked_free proc(context ?@mut anyopaque, memory ?*mut u8, size usize, alignment usize) void { + state @mut AllocationState :: ptrcast!(context?) + if (memory) |_| state.live -= 1 + mem.raw_free(mem.c_allocator, memory, size, alignment) +} + +@hide:file +tracked_vtable :: mem.AllocatorVTable{ alloc = tracked_alloc, realloc = tracked_realloc, free = tracked_free } + +releases_temporary_storage_and_propagates_allocation_failure test { + state := AllocationState{} + allocator :: mem.Allocator{ context = &state, vtable = &tracked_vtable } + try testing.expect_equal(f64(1.5), try parse_float(allocator, "1.5")) + try testing.expect_equal(usize(0), state.live) + texts [2][]u8 :: ["1e999999", "1e-999999"] + for texts |text| { + _ = parse_float(allocator, text) catch |error| { + try testing.expect(error == .out_of_range) + try testing.expect_equal(usize(0), state.live) + continue + } + try testing.expect(false) + } + state.remaining = 0 + _ = parse_float(allocator, "1.5") catch |error| { + try testing.expect(error == .out_of_memory) + try testing.expect_equal(usize(0), state.live) + return + } + try testing.expect(false) +}