IO Lines v1

September 10, 2026 ยท View on GitHub

Status: implemented bounded source profile; hosted-green on the named std-library-depth CI job at 1dfe12a6, outside the v0.4.0 release baseline. The broader Everyday profile and streaming std.io scope remain incomplete.

Audience: language users, compiler contributors, standard-library authors, and backend implementers.

This profile is the bundled std.io.lines package: line meaning over the std.io Reader and Writer cursors. IO Cursors v1 owns those shapes and their existing transitions; nothing here changes an existing signature, contract, identity, or byte. Line processing composes the same records through an ordinary [dependencies] std.io = "=0.1.0" import: it introduces no new type, no allocation, and no host authority.

Line processing is a sibling package rather than more of std.io for the reason Standard Library v1 already records for std.data.json and std.path.value: one library module holding both halves pushes an ordinary three-package consumer past the SPX-G171 workspace-graph pre-bound. The split is a packaging consequence of that bound, not a semantic boundary; identities are std.io.lines.* and the cursor identities stay exactly as released.

Line meaning

A line is delimited by one line feed, 0x0A. line_end(view, start) is the absolute offset of the first line feed at or after start, or the view's length when no further terminator exists, so it is total over every byte sequence and never reports a position outside the view. line_terminated(view, start) distinguishes a complete line from an unterminated tail by exactly that comparison.

A line's content excludes its terminator and one immediately preceding carriage return, so canonical LF and CRLF inputs of the same text yield byte-identical content. line_content_len is that length. A carriage return not immediately followed by a line feed is ordinary content: a\rb is one unterminated three-byte line, not two lines. This is the whole policy; the profile performs no other rewriting, no UTF-8 interpretation, and no trimming.

Empty lines are represented exactly: \nab has a zero-length first line followed by an unterminated ab, and \r\n is one zero-length complete line.

Cursor transitions

reader_line_len and reader_line_complete are borrowed observers over the Reader's live position; both preserve the owner and reject a forged position beyond the buffer through the same precondition as the existing observers.

reader_line_into(borrow Reader, own Writer) -> Writer copies the current line's content into caller-supplied Writer capacity. Its precondition preflights the exact content length against the writer's remaining capacity, so a short buffer fails before any byte is written and the borrowed Reader keeps its position and bytes. The copy starts at the Writer's live cursor: a Writer that already holds output keeps its prefix. Bytes beyond the advanced cursor retain their previous values, exactly as writer_finish already specifies.

reader_next_line(own Reader) -> Reader is the consuming transition past the current line and its terminator, clamped to the buffer length for an unterminated tail. Repeated application therefore reaches an exhausted cursor and stays there; an exhausted cursor reports a zero-length, unterminated line.

Line transitions return records, so a while body cannot step them (SPX-T252). A bounded caller unrolls the walk, as the executed two-line case does. General streaming and mutable in-loop cursor replacement remain open.

Boundaries

This profile adds no buffered reader, no stream, no standard-stream authority, no file or socket, no public export, and no descriptor. It does not widen a public nominal or generic surface, and it grants no ambient filesystem, process, or network authority. Existing Bytes, contract, cleanup, graph, and backend meanings are unchanged outside the admitted transitions. Line policy is lexical: it does not claim text, Unicode, or platform newline conversion, which remain with std.text and the open std.io scope.

Focused local evidence

cargo test --locked -p semaprax --test project standard_library::io_lines
cargo test --locked -p semaprax --test project standard_library::io_lines::io_lines_execute_on_all_three_backends
cargo test --locked -p semaprax --test project standard_library::io_cursors

Eight named cases in tests/project/standard_library/io_lines_cases.spx run as individual bounded projects, each with its own local call closure, on the interpreter, native C11 at -O0 and -O2, and repeated Core Wasm under Node with an exact live-Bytes bound of two: CRLF and LF lines, an empty line, a bare \r\n, a bare carriage return, an unterminated tail, an unrolled two-line walk, and a prefixed Writer. The shipped package's own examples and conformance modules execute on the same three backends, and the unchanged std.io package keeps its own hosted-green corpus.

A graph check derives the projection for one fixture, replays it, and pins the selected cleanup schema per shape: the line copy and the record observers select the existing semaprax.cleanup-plan.v5 contract schema, the pure view helpers select v2, the copy carries exactly the caller's Writer as its one owned parameter with the core.bytes.drop leaf lifecycle, and the borrowed Reader never enters an owned inventory. A reminted field identity and a one-byte source drift each fail replay.

Nine hostile interpreter cases reject before execution or any write: insufficient writer capacity, a live cursor that leaves too little capacity, a forged Reader position through each line observer and both transitions, and a view offset past the end through each view helper. Each fails with the exact requires-false contract status. Existing borrow-escape, no-public-descriptor, and bundled-dependency checks continue to pass unchanged.

This is source-level line processing over caller-supplied buffers. It is not evidence of a stream, a physical file, a hosted provider, or any public API.