Standard library Dodo 0.1.4

Byte I/O

Understand short reads, partial writes, progress errors, bounded input, buffering, and polling with portable byte I/O.

On this page

Use std/io.read_exact when a task needs a known number of bytes, and io.write_all to deliver a complete slice. std/io is portable: start with MemoryReader and MemoryWriter, then use the same helpers with files. For standard streams and bounded read_line, see console I/O. For portable printing to any writer, see formatting.

Reading and writing are allowed to make partial progress. A five-byte request may transfer two bytes successfully; that does not imply end-of-file. Completion helpers perform the repeated calls for you. Errors can also carry progress, so a failed operation does not imply that nothing happened.

Quickstart

Save this as io_start.dodo:

package io_start
import "std/io"
import "core/bytes"

fn transfer() -> void!io.Error {
    source := io.MemoryReader.new(b"hello")
    storage := [0u8; 5]
    count := io.read_exact(&mut source, &mut storage)?
    assert_eq(count, 5usize)
    assert(bytes.equal(&storage, b"hello"))
    return ok()
}
fn main() -> i32 {
    match transfer() {
        ok() => { return 0 },
        err(reason) => {
            if reason.kind == io.ErrorKind.UnexpectedEof { return 2 }
            return 1
        },
    }
}
dodo run io_start.dodo

Expected output: none; exit 0 confirms five bytes were read and equal hello. core/bytes is imported only to compare the result. The five-byte array is the entire destination; no allocator is needed. To try the failure path, shorten the input to hell: exit 2 reports incomplete input. Reject that record or ask for more input. Other I/O failures exit 1. Inspect reason.transferred before retrying: a failure can still have consumed or written a prefix.

Use read_up_to when a shorter file is acceptable, and bounded copy for streaming. Add caller-backed buffering only when needed. std/io_alloc adds append-to-owned-buffer operations; start with fixed scratch and an explicit transfer limit, then choose allocated bytes if the result must grow.

Portable byte I/O (std/io)

Choose a helper by completion rule

Requirement Helper How it stops
Make one attempt read_once, write_once After at most one provider call
Fill as much of a slice as is available read_up_to Full destination or EOF; a short result is successful
Read a fixed-size record read_exact Full destination; early EOF is an error
Deliver an entire slice write_all All bytes accepted or an error
Transfer a bounded stream copy EOF, limit, or an error; reports separate read/write counts
Read a whole bounded document read_bounded Destination plus one probe byte; detects excess input
Read through LF or a full fragment read_line Returns Newline, Eof, or Full
Attempt nonblocking progress poll_read_once, poll_write_once One attempt, including an explicit Pending state

EOF means the input has ended. Pending means no further progress is available right now. An adapter must provide the matching blocking or polling method; the helpers do not turn a blocking device into a nonblocking one.

Implementing a reader or writer

std/io imports only core/bytes, core/num, and core/mem. It operates on bytes and checked borrowed slices, with no OS, libc, heap, locale, scheduler, global initialization, or device discovery. Files, sockets, UARTs, and other platform adapters implement the same small method contracts in independently imported packages. This package provides memory implementations; it does not open files or configure hardware.

Contract Required public method
Blocking reader read(&mut self, destination: &mut[u8]) -> usize!io.Error
Blocking writer write(&mut self, source: &[u8]) -> usize!io.Error
Polling reader poll_read(&mut self, destination: &mut[u8]) -> io.Poll!io.Error
Polling writer poll_write(&mut self, source: &[u8]) -> io.Poll!io.Error
Optional absolute seek seek(&mut self, position: usize) -> usize!io.Error

These are structural contracts checked when generic helpers are instantiated. Dodo monomorphizes calls to the concrete public methods; no trait objects, reflection, closures, or scheduler are required. An adapter type exposing public methods must itself be public. See examples/io.dodo for memory-to-memory copying through caller scratch space.

Successful and failed progress

A successful primitive operation returns its processed prefix length. A reader may initialize only that prefix; a writer accepts only that prefix. Counts must never exceed the supplied slice length. A nonempty successful read returning zero means EOF. A nonempty successful write returning zero becomes WriteZero in completion helpers. Empty requests succeed with zero and helpers never invoke the underlying device. MemoryWriter and Cursor report BufferFull when full.

An io.Error contains kind, transferred, and an adapter-defined code: i32. transferred records prefix progress even when the same operation fails. The portable io.failure(kind, transferred) constructor sets code to zero. Error kinds are UnexpectedEof, WriteZero, Interrupted, BufferFull, OutOfBounds, InvalidInput, InvalidProgress, Other, WouldBlock, TimedOut, Cancelled, Closed, ConnectionReset, ConnectionRefused, BrokenPipe, and PermissionDenied. A provider is responsible for classifying recoverable device errors and preserving useful platform codes. Helpers validate returned counts before forming their next checked subslice; InvalidProgress means the provider violated its contract. This validation does not repair an unsafe provider that already wrote outside its supplied slice.

read_once and write_once perform at most one call. read_up_to fills the provided destination or successfully returns a shorter prefix at EOF. read_exact returns UnexpectedEof for a short input. write_all accepts the entire source or returns an error. Completion helpers retry Interrupted after accounting for any prefix already processed; a persistent interruption can block indefinitely. They return total progress for the entire helper invocation, preserving a terminal device error and code even if its prefix finished the requested byte count. None rolls back bytes already read or written.

Memory, limits, and copying

MemoryReader.new(&bytes) retains a shared borrow and exposes position, remaining (the unread byte slice), read, poll_read, and absolute seek. MemoryWriter.new(&mut bytes) retains an exclusive borrow and exposes position, remaining capacity, written, write, and poll_write. Cursor.new(&mut bytes) reads and overwrites initialized fixed-size storage, with bytes, position, and seek. It never grows. Seeking permits positions from zero through the end, returns the new position, and leaves the old position unchanged on failure. Generic io.seek requires only the optional seek method; read/write helpers never require it.

LimitReader.new(&mut reader, limit) yields a synthetic EOF after consuming at most limit bytes, including progress reported with errors. remaining reports its unused budget. skip(&mut reader, count, &mut scratch) discards exactly count bytes or returns UnexpectedEof with total consumed progress. Empty scratch is an InvalidInput unless the requested count is zero.

copy(&mut reader, &mut writer, &mut scratch, limit) copies at most limit bytes. CopyReport contains read, written, and eof; eof is false when the limit was reached without an additional read. A nonzero limit requires nonempty scratch. A CopyError keeps total read, total written, and the terminal cause. Thus read - written tells the caller how many bytes were consumed but not delivered. Progress from a failed read is offered to the writer before the read error is returned. If that write also fails, the write error takes precedence. Scratch retains the most recently read chunk, including its unwritten suffix. The explicit limit bounds every count and prevents count overflow; pass core/num.MAX when that target-sized limit is suitable.

Buffering requires explicit completion

BufferedReader.new(&mut reader, &mut storage) and BufferedWriter.new(&mut writer, &mut storage) reject empty storage and retain exclusive checked borrows of both arguments. The reader exposes its current buffered() view, drains read-ahead before fetching more, and preserves an error that arrived with data until its final buffered byte is delivered. Dropping a buffered reader discards unread read-ahead, so callers must consume it before resuming directly from the underlying reader when every byte matters.

The writer exposes pending() and capacity(). Writes accept source bytes into caller storage and flush when that storage becomes full and another write needs space. flush() returns physical device progress and preserves the unwritten suffix after failure so it can be retried. A write that fails while flushing old bytes reports zero accepted bytes from its current input. There is deliberately no I/O in destruction: dropping a writer discards pending bytes. Call flush and handle its Result explicitly before releasing the device. Neither adapter implicitly calls an optional platform flush operation such as a disk sync.

Here the outer writer accepts five bytes, but only flush() guarantees that all five reach the underlying memory writer. Use the same pattern for a file or socket; always handle the flush result before allowing the buffer to drop.

package buffered_output
import "std/io"
import "core/bytes"

fn example() -> void!io.Error {
    destination := [0u8; 8]
    sink := io.MemoryWriter.new(&mut destination)
    scratch := [0u8; 3]
    {
        buffered := io.BufferedWriter.new(&mut sink, &mut scratch)?
        accepted := io.write_all(&mut buffered, b"hello")?
        assert_eq(accepted, 5usize)
        core.drop(buffered.flush()?)
    }
    assert(bytes.equal(sink.written(), b"hello"))
    return ok()
}

fn main() -> i32 {
    match example() {
        ok() => { return 0 },
        err(_) => { return 1 },
    }
}

Save as buffered_output.dodo and run dodo run buffered_output.dodo. It exits with zero and prints nothing. The inner block ends the buffered writer’s exclusive borrow of sink before the final assertion. core.drop explicitly discards the successful physical byte count; the preceding ? still handles flush failure.

Polling without a scheduler

Poll contains transferred and PollState.Ready, Pending, or Eof. Pending may accompany positive progress. Callers consume that prefix and decide when to retry. poll_read_once and poll_write_once issue exactly one attempt and never spin, wait, register a waker, or allocate. For a nonempty read, ready with zero progress is invalid; EOF is explicit. For a nonempty write, ready with zero progress is WriteZero, and EOF is invalid. Empty polling requests return ready with zero progress without touching the provider. Interrupted polling errors are returned directly rather than retried. Blocking and polling methods are separate capabilities; neither is implicitly converted into the other.

Allocation-dependent I/O (std/io_alloc)

io_alloc.BufferWriter<A>.new(&mut bytes_alloc.Buffer<A>) appends to an explicitly allocated, capacity-limited buffer. The adapter holds an exclusive checked borrow of the buffer; its allocator and backing storage remain live. Each write either appends the entire source or returns BufferFull with zero progress and leaves existing contents unchanged. Error codes preserve the AllocError category: 1 = InvalidAlignment, 2 = SizeOverflow, 3 = Exhausted, and 4 = UnsupportedLayout. No global allocator is consulted.

io_alloc.append_from(&mut reader, &mut buffer, &mut scratch, limit) combines this append writer with bounded io.copy. Earlier successful chunks stay owned by the buffer if a later read or allocation fails; CopyError preserves consumed and appended counts. Both the transfer limit and the buffer’s independent growth limit apply. Construct buffers using std/arena_bytes, std/pool_bytes, or the unsafe custom-allocator constructor in std/bytes_alloc; merely importing std/io does not import any allocation package.

Checked views returned by memory or buffered adapters borrow the adapter, so it cannot be mutated, moved, or destroyed while that view is still in use. Growing a buffer likewise requires ending its outstanding views. Destruction releases borrow dependencies deterministically and never hides a pending Result.

Bounded and line-oriented input

io.read_bounded(reader, output) returns { read, eof }. It uses a one-byte probe after filling the destination: an exact fit has eof=true; overflow has eof=false and discards that probe byte. Use it for a whole bounded input, not for resumable chunk streaming. Errors retain output[..transferred].

io.read_line(&mut reader, &mut output) returns io.Line { count, end }, retaining LF and any preceding CR in count. Its LineEnd is Newline, Eof, or Full. Eof with zero length is end-of-input; with positive length it is a final unterminated line. Full consumes no lookahead: process that fragment and call again to resume. Empty storage returns Full without reading. Errors preserve the initialized prefix, even if it ends in LF. Only Interrupted is retried. Neither helper decodes UTF-8 or allocates. See bounded console input for standard streams.

Use read_line repeatedly when arbitrarily long lines should be processed in fragments. Use read_bounded when an entire document must fit and an oversize document should be rejected. Neither helper validates text; pass the accepted bytes to text.Text.new before treating external input as UTF-8. The API reference lists all adapter and result declarations.

Type to search all documentation.

Keyboard shortcuts

Search documentation
Ctrl K or /
Move through results
↑ ↓
Open selected result
Enter
Close a dialog
Esc
Show these shortcuts
?