From 31c2cad9f953e1a8da0f7015e95fc809e00af25c Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 22 Oct 2026 23:21:41 +0200 Subject: [PATCH] feat: add bounded read-to-end and compatible file paths --- README.md | 2 + source/main.hon | 37 +++---------- std/io/file.hon | 8 +-- std/io/io.hon | 37 ++++++++++++- std/io/io.test.hon | 127 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 std/io/io.test.hon diff --git a/README.md b/README.md index dece8d2..9d09329 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ main proc() int { this prints `42`. `main` must be a top-level function with no parameters; nonvoid results are printed, not used as process exit statuses. the current macOS bootstrap obtains arguments from the native startup environment. +source loading uses the supplied `io.Io` for opening, reading, and closing files. `io.open` accepts a borrowed zero-terminated pointer, including paths returned by `process.argument`. `io.read_all(allocator, reader, maximum)` reads to EOF into a caller-owned `ArrayList(u8)`; release it with `arraylist.deinit`. it frees partial storage on error and consumes one extra byte to distinguish an exact-size input from a limit violation. the driver maps `limit_exceeded` to `source_too_large` at its `u32` source-size limit. + the parser supports arithmetic expressions with nested parenthesized groups, such as `a + (b - c) * 2 / 3`. groups control precedence without adding AST nodes; inner expression spans exclude the grouping parentheses. parsing accepts integer division syntax, but type checking requires float operands for `/`. function calls are postfix expressions: `f()`, `f(a, b)`, and `factory()(x)`. calls bind tighter than unary and binary arithmetic and can appear in initializers or as expression statements inside function bodies. argument lists allow a trailing comma; newlines remain statement boundaries. type checking validates callable values, argument counts and argument types. diff --git a/source/main.hon b/source/main.hon index 3a374a4..e83a20d 100644 --- a/source/main.hon +++ b/source/main.hon @@ -1,4 +1,3 @@ -import "@ffi/c" import "@std/mem" import "@std/arraylist" import "@std/io" @@ -21,6 +20,7 @@ test import "@std/arraylist" test import "@std/hashmap" test import "@std/strmap" test import "@std/meta" +test import "@std/io" test import "@source/diagnostics" test import "@source/strpool" test import "@source/lexer" @@ -102,41 +102,18 @@ argument_text proc(pointer [*;0]u8) []u8 { @hide:file read_source proc(allocator mem.Allocator, runtime io.Io, path [*;0]u8) arraylist.ArrayList(u8) ! DriverError { - # bootstrap cannot construct a dynamic sentinel slice from argv's sentinel pointer. - descriptor := c_int(-1) - while true { - descriptor = c.open(ptrcast!(path), c.O_RDONLY) - if (descriptor >= 0) break - if (c.__error()?^ != c.EINTR) return .open_failed - } - file :: io.File{ io = runtime, handle = io.Handle{ file_desc = descriptor } } + file :: try io.open(runtime, path, .read_only) closing := false defer { if (!closing) io.close(file) catch |_| {} } - bytes := arraylist.init(u8, allocator) - errdefer arraylist.deinit(&bytes) - input :: io.reader(file) - maximum :: usize(maxval!(u32)) - while true { - length :: bytes.items.len - if (length == maximum) { - probe [1]mut u8 := undefined - if (try io.read(input, (&probe)[..]) != 0) return .source_too_large - break + bytes := io.read_all(allocator, io.reader(file), usize(maxval!(u32))) catch |err| { + match err { + .limit_exceeded: return .source_too_large + else: return err } - if (length == bytes.capacity) { - growth := usize(4096) - if (growth > maximum - length) growth = maximum - length - try arraylist.reserve(&bytes, length + growth) - } - storage :: bytes.items.ptr[..bytes.capacity] - end := bytes.capacity - if (end > maximum) end = maximum - count :: try io.read(input, storage[length..end]) - if (count == 0) break - bytes.items = storage[..length + count] } + errdefer arraylist.deinit(&bytes) closing = true try io.close(file) return bytes diff --git a/std/io/file.hon b/std/io/file.hon index 547fc3d..7227ac4 100644 --- a/std/io/file.hon +++ b/std/io/file.hon @@ -19,8 +19,8 @@ CloseError :: enum { close_failed } -#! open a caller-owned handle; release it with close. -open proc(io Io, path [;0]u8, mode FileMode) File ! OpenError { +#! open a caller-owned handle from a borrowed zero-terminated path; release it with close. +open proc(io Io, path [*;0]u8, mode FileMode) File ! OpenError { handle Handle :: try io.vtable.open(io.context, path, mode) return File{ io = io, handle = handle } } @@ -83,7 +83,7 @@ system_write proc(_ ?@mut anyopaque, handle Handle, bytes []u8) usize ! WriteErr } @hide:file -system_open proc(_ ?@mut anyopaque, path [;0]u8, mode FileMode) Handle ! OpenError { +system_open proc(_ ?@mut anyopaque, path [*;0]u8, mode FileMode) Handle ! OpenError { flags := c.O_RDONLY match mode { .read_only: flags = c.O_RDONLY @@ -91,7 +91,7 @@ system_open proc(_ ?@mut anyopaque, path [;0]u8, mode FileMode) Handle ! OpenErr .read_write: flags = c.O_RDWR } while true { - fd c_int :: c.open(ptrcast!(path.ptr), flags) + fd c_int :: c.open(ptrcast!(path), flags) if (fd >= 0) return Handle{ file_desc = fd } if (c.__error()?^ != c.EINTR) return .open_failed } diff --git a/std/io/io.hon b/std/io/io.hon index 152d246..5b0fc81 100644 --- a/std/io/io.hon +++ b/std/io/io.hon @@ -1,5 +1,6 @@ import "@ffi/c" import "@std/mem" +import "@std/arraylist" # typeinfo! requires this import even without a qualified meta reference. import "@std/meta" @@ -8,6 +9,12 @@ ReadError :: enum { read_failed } +ReadLimitError :: enum { + limit_exceeded +} + +ReadAllError :: alias (mem.AllocError | ReadError | ReadLimitError) + WriteError :: enum { not_open_for_writing write_failed @@ -34,7 +41,7 @@ Io :: struct { IoVTable :: struct { read @proc(context ?@mut anyopaque, handle Handle, buffer []mut u8) usize ! ReadError write @proc(context ?@mut anyopaque, handle Handle, bytes []u8) usize ! WriteError - open @proc(context ?@mut anyopaque, path [;0]u8, mode FileMode) Handle ! OpenError + open @proc(context ?@mut anyopaque, path [*;0]u8, mode FileMode) Handle ! OpenError close @proc(context ?@mut anyopaque, handle Handle) void ! CloseError stdin @proc(context ?@mut anyopaque) Handle stdout @proc(context ?@mut anyopaque) Handle @@ -64,6 +71,34 @@ read proc(input Reader, buffer []mut u8) usize ! ReadError { return count } +#! read to EOF, accepting at most `maximum` bytes; the reader remains caller-owned. +#! exceeding the limit consumes one extra byte and returns limit_exceeded. +#! returned storage retains `allocator`; release it with arraylist.deinit. +#! all allocated storage is released on error. +read_all proc(allocator mem.Allocator, input Reader, maximum usize) arraylist.ArrayList(u8) ! ReadAllError { + bytes := arraylist.init(u8, allocator) + errdefer arraylist.deinit(&bytes) + while true { + length :: bytes.items.len + if (length == maximum) { + probe [1]mut u8 := undefined + if (try read(input, (&probe)[..]) != 0) return .limit_exceeded + return bytes + } + if (length == bytes.capacity) { + growth := usize(4096) + if (growth > maximum - length) growth = maximum - length + try arraylist.reserve(&bytes, length + growth) + } + storage :: bytes.items.ptr[..bytes.capacity] + end := bytes.capacity + if (end > maximum) end = maximum + count :: try read(input, storage[length..end]) + if (count == 0) return bytes + bytes.items = storage[..length + count] + } +} + write proc(output Writer, bytes []u8) usize ! WriteError { if (bytes.len == 0) return 0 diff --git a/std/io/io.test.hon b/std/io/io.test.hon new file mode 100644 index 0000000..fa797dd --- /dev/null +++ b/std/io/io.test.hon @@ -0,0 +1,127 @@ +import "@std/mem" +import "@std/arraylist" +import "@std/testing" + +@hide:file +Input :: struct { + bytes []u8 + offset usize = 0 + chunk usize = 137 + fail_at usize = maxval!(usize) + remaining usize = maxval!(usize) + live usize = 0 +} + +@hide:file +input_state proc(context ?@mut anyopaque) @mut Input { + if (context) |pointer| return ptrcast!(pointer) + unreachable +} + +@hide:file +input_read proc(context ?@mut anyopaque, _ Handle, buffer []mut u8) usize ! ReadError { + state :: input_state(context) + if (state.offset >= state.fail_at) return .read_failed + count := state.bytes.len - state.offset + if (count > buffer.len) count = buffer.len + if (count > state.chunk) count = state.chunk + memcopy!(buffer[..count], state.bytes[state.offset..state.offset + count]) + state.offset += count + return count +} + +@hide:file +input_reader proc(state @mut Input) Reader { + return Reader{ context = state, handle = Handle{ file_desc = -1 }, read = input_read } +} + +@hide:file +tracked_alloc proc(context ?@mut anyopaque, size usize, alignment usize) ?*mut u8 { + state :: input_state(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 :: input_state(context) + if (state.remaining == 0) return null + state.remaining -= 1 + resized :: mem.raw_realloc(mem.c_allocator, memory, old_size, new_size, alignment) + 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 :: input_state(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 } + +read_all_preserves_short_reads_across_buffer_growth test { + source [9000]mut u8 := undefined + for (0..source.len) |i| source[i] = u8(rem!(i, 251)) + state := Input{ bytes = source[..] } + bytes := try read_all(mem.c_allocator, input_reader(&state), maxval!(usize)) + defer arraylist.deinit(&bytes) + try testing.expect_equal(source[..], bytes.items) +} + +read_all_accepts_exact_limits_and_probes_for_excess test { + for (usize(0)..4) |maximum| { + state := Input{ bytes = "abc"[..maximum], chunk = 1 } + bytes := try read_all(mem.c_allocator, input_reader(&state), maximum) + defer arraylist.deinit(&bytes) + try testing.expect_equal(state.bytes, bytes.items) + + excess := Input{ bytes = "abcd", chunk = 1 } + unexpected := read_all(mem.c_allocator, input_reader(&excess), maximum) catch |err| { + try testing.expect(err == .limit_exceeded) + try testing.expect_equal(maximum + 1, excess.offset) + continue + } + arraylist.deinit(&unexpected) + try testing.expect(false) + } +} + +read_all_releases_partial_storage_on_read_failure_and_limit_excess test { + for (0..2) |failure| { + state := Input{ bytes = "abcdef", chunk = 2 } + if (failure == 0) state.fail_at = 2 + allocator :: mem.Allocator{ context = &state, vtable = &tracked_vtable } + unexpected := read_all(allocator, input_reader(&state), 4) catch |err| { + expected ReadAllError :: if (failure == 0) .read_failed else .limit_exceeded + try testing.expect_equal(expected, err) + try testing.expect_equal(0, state.live) + continue + } + arraylist.deinit(&unexpected) + try testing.expect(false) + } +} + +read_all_releases_storage_at_each_failed_growth test { + source [9000]mut u8 := undefined + mem.set(source[..], u8('x')) + for (usize(0)..3) |remaining| { + state := Input{ bytes = source[..], remaining = remaining } + allocator :: mem.Allocator{ context = &state, vtable = &tracked_vtable } + unexpected := read_all(allocator, input_reader(&state), source.len) catch |err| { + try testing.expect(err == .out_of_memory) + try testing.expect_equal(0, state.live) + continue + } + arraylist.deinit(&unexpected) + try testing.expect(false) + } +}