Repository navigation
0.26.0-rc2
Pre-releasebasic-cli 0.26.0-rc2
Release candidate for 0.26.0, for trying the new APIs before they are final.
It follows 0.26.0-rc1,
which tested the Windows MSVC host, and adds the API conventions work from
PR #528. The notes below are
the draft notes for 0.26.0; please report anything that does not behave as
they say.
app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.26.0-rc2/Gj3zVDr3qrQa6aTh7Z1zgUfe9NeBE6R76T5ho3xrc8af.tar.zst" }Requires the Roc new-compiler nightly nightly-2026-10-12-2664a27 or later.
One way to do each thing
Until now each basic-cli module had grown its own habits. A timeout was a bare U64 of milliseconds in one place and a tagged value in another, and zero meant "fail at once" for TCP but "no deadline" for a command. "No more input" was Err(EndOfFile) from stdin, an empty list from a file and an empty string from a socket. Errors arrived in a different shape from every module, and some could not be compared with == at all.
0.26.0 settles these questions once and applies the answers everywhere:
- A timeout is a
Duration, andDuration.zeroalways expires immediately. - The end of input is
Err(EndOfInput), on stdin, files and sockets alike. An emptyOkis real data. - Anything you can read, you can loop over:
lines()andchunks()return aStreamon stdin, file readers, TCP connections and HTTP responses, androwsdoes the same for SQLite. - A module's failures arrive under one tag (
TcpErr(IOErr),CmdErr(Cmd.Err),SqliteErr(Sqlite.Err)), and every error can be compared with==.
The result is that code written against one module reads like code written against another:
conn = Tcp.connect!("example.org", 7, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?
for! item in conn.lines() {
match item {
Ok(line) => Stdout.line!(line)?
Err(BadUtf8(_)) => {}
Err(ReadLimitExceeded(_)) => {}
Err(TcpErr(err)) => { return Err(TcpErr(err)) }
}
}This is a large release with many breaking changes. The upgrade guide below lists every one with its replacement. The rules themselves are written down in CONTRIBUTING.md, so future modules follow them too.
Time has types
start = Monotonic.now!()
build!()?
Stdout.line!("Completed in ${start.elapsed!().to_str()}")? # "Completed in 1.5s"
stale = Utc.now!().since(path.time_modified!()?) > Duration.minutes(5)Durationis a non-negative length of time with nanosecond resolution:Duration.millis(250),Duration.seconds(5),Duration.minutes(2). It supports+,-,==, the ordering operators andList.sort, and prints as"250ms"or"1.5s". Nothing overflows or goes negative: results are clamped, sobudget - start.elapsed!()is simply zero once the budget is spent.Utcis now a real type holding a signed instant.Utc.now!()no longer returns 1970 when the clock cannot be read, and times before 1970 work, including into_iso_8601.Monotonicis an opaque clock reading.start.elapsed!()andlater.since(earlier)return aDuration, so a reading can no longer be mistaken for a length of time.- File times (
time_accessed!,time_modified!,time_created!) returnUtcand succeed for files older than 1970. Sleep.millis!(250)is unchanged. For a wait you computed, callduration.sleep!().
Loop over input
Reading lines used to mean hand-written recursion. Now it is a loop, and the same loop works on every source:
var $count = 0
for! item in reader.lines() {
match item {
Ok(_) => { $count = $count + 1 }
Err(BadUtf8(_)) => {}
Err(FileErr(err)) => { return Err(FileErr(err)) }
}
}Stdin.lines(),Stdin.chunks(),reader.lines()andreader.chunks()are new. Lines never include the\n, and one preceding\ris removed. A final line without a terminator is still a line.File.Reader.read_until!(byte)returns bytes through and including a delimiter.- A line that is not valid UTF-8 is one
Err(BadUtf8(_))item and the loop carries on. A failure of the source itself is the last item. - Streams buffer nothing in Roc. You can
breakout ofStdin.lines()after a header and hand the rest toStdin.read_to_end!()without losing a byte, and mixreader.lines()withseek!andread_exactly!.
Write files piece by piece
Path.write_utf8! replaces a file with one payload, so output had to be built in memory first. File.Writer opens a file once and writes to it as you go:
File.with_writer!("report.csv", Truncate, |writer| {
writer.write_utf8!("name,total\n")?
for row in rows {
writer.write_utf8!("${row.name},${row.total.to_str()}\n")?
}
Ok({})
})?- The mode is
Truncate,AppendorRequireNew(fails withAlreadyExistsif anything is at the path). writer.write_stream!(stream)writes any chunk stream as it arrives, so a file can be copied or transformed in bounded memory:writer.write_stream!(reader.chunks()).File.with_writer!flushes when your callback returns and reports a failed flush. WithFile.open_writer!, callflush!yourself after the last write.Path.append_bytes!andPath.append_utf8!are also new, for adding to a file in one call (#516).
Download without holding the body in memory
Http.send_streaming! returns as soon as the status and headers arrive, and hands you the body as a stream:
request = Request.from_method(GET).with_uri(url.to_str())
response = Http.send_streaming!(request, {})?
if response.status() != 200 {
return Err(UnexpectedStatus(response.status()))
}
File.with_writer!(destination, Truncate, |writer| writer.write_stream!(response.chunks()))?- You see the status before any of the body is read, so you can decline a 404's body.
idle_timeout(30 seconds unless you set it) bounds each single wait, not the whole download. A slow but healthy download keeps going; a stalled one stops. After an idle timeout the response is still usable.response.read_to_end!(MaxBytes(1_048_576))reads the rest with a bound, andresponse.content_length()isKnown(n)orUnknown.Http.send!and the functions built on it are unchanged.
TCP: typed errors and a timeout the connection remembers
# 0.25
stream = Tcp.connect!("127.0.0.1", 8085, 1_000) ? |err| ConnectFailed(err)
stream.write_utf8!("${out_msg}\n", 5_000) ? |err| TcpWriteFailed(err)
in_msg = stream.read_line!(1_048_576, 5_000) ? |err| TcpReadFailed(err)
# 0.26
conn = Tcp.connect!("127.0.0.1", 8085, { connect_timeout: Duration.seconds(1), io_timeout: Duration.seconds(5) })?
conn.write_utf8!("${out_msg}\n")?
in_msg = conn.read_line!()?Tcp.Streamis nowTcp.Connection. It holds its I/O timeout and line limit;conn.with_timeout(d)andconn.with_line_limit(n)return the same connection with another setting.- Every failure is
TcpErr(IOErr), soresult == Err(TcpErr(TimedOut))andErr(TcpErr(ConnectionRefused))can be matched directly. Windows takes about two seconds to report a refused connection, so a shorterconnect_timeoutgivesTimedOutthere.IOErrgainsTimedOut,ConnectionRefused,ConnectionReset,AddrInUseandAddrNotAvailable. - A read that times out consumes nothing. The bytes that had arrived are returned by the next read, so a client that tolerates idle periods just calls
conn.lines()again. - A line longer than the limit is
Err(ReadLimitExceeded(n)); the rest of it is discarded and the next read starts at the next line. listener.accept!({ accept_timeout: NoTimeout, io_timeout: Duration.seconds(30) })separates how long to wait for a client from the timeout that client's connection gets.
Commands: four ways to run, one result, one error
Cmd had seven ways to run a command, which disagreed on whether a non-zero exit was an error. There are now four:
Cmd.new("cargo").args(["build", "--release"]).exec!()? # inherit the terminal; must succeed
sha = Cmd.new("git").args(["rev-parse", "HEAD"]).capture!()?.trim() # stdout as text; must succeed
output = Cmd.new("cat").arg("notes.txt").run!()? # status and output as data
child = Cmd.new("server").stdout(Pipe).spawn!()? # run in the backgroundrun!returns aCmd.Outputwithstatus,stdout_bytesandstderr_bytes, plusrequire_success(),stdout_utf8(),stdout_utf8_lossy()andstderr_utf8_lossy(). It prints its output as text, not as a list of byte numbers.- Every failure is
CmdErr(Cmd.Err):StartFailed(IOErr, program),TimedOut(partial_output),OutputLimitExceeded(partial_output)orIOErr(IOErr). A missing program isErr(CmdErr(StartFailed(NotFound, _))). .timeout(Duration.minutes(5))bounds the whole run, including a spawned child, which is killed at the deadline.- Errors and
to_inspectno longer contain environment values, so a secret passed with.envcannot leak into a log. .stderr(MergeWithStdout)and.output_limit(NoLimit)say directly what used to be encoded in aBoolor a zero.
SQLite: a connection you hold, rows you can stream
decode_todo : Decoder({ id : I64, task : Str }, Sqlite.DecodeErr)
decode_todo = { id: Decoder.i64("id"), task: Decoder.str("task") }.Decoder
db = Sqlite.open!("app.db", {})?
todo = db.query_one!("SELECT id, task FROM todos WHERE id = ${Integer(id)}", decode_todo)?Sqlite.open!returns aDbthat you pass around and canclose!. Open read-only with{ mode: ReadOnly }. A locked database is now waited on (five seconds unless you setbusy_timeout) instead of failing at once.Sqlite.open_in_memory!()gives a private database on every call.${…}in a query is a bound parameter, never pasted into the SQL. Each value names its storage class (Integer(id),String(name)), and putting an interpolation inside quotes or a comment is a compile error.- Decoders are ordinary values built with record-builder syntax. You can annotate them, share them between queries, and name a column by name or by position (
Decoder.i64(0)). db.rows(query, decoder)is a lazy stream, so a large table can be read row by row and abandoned withbreak.query_one!andquery_all!cover the common cases.db.transaction!(|| { … })commits if the body returnsOkand rolls back if it returnsErr.- Errors say what went wrong. A UNIQUE violation is
Err(SqliteErr(Failed({ code: Constraint(Unique), .. }))), with no message text to search. A wrong number of parameters isWrongParamCountinstead of a silent NULL, and running two statements where one is expected isMultipleStatementsinstead of the second being ignored.execute_script!runs several statements on purpose.
Paths and OS strings compare by content
A path or OS string written as a literal never used to equal the same text returned by the platform, so these were silently always false:
verbose = args.contains("--verbose")
is_vim = Env.var!("EDITOR")? == "vim"
home = Dict.from_list(Env.dict!()).get("HOME")They now work. Path and OsStr compare and hash by their content, whichever representation holds it. Paths are not normalised: a/b and ./a/b remain different.
Terminal
Tty.enable_raw_mode! and Tty.disable_raw_mode! now return Try({}, [TtyErr(IOErr)]), so a failure reaches your program. Enabling twice is harmless, and one disable undoes it. The host restores the terminal when your program returns, exits with a code, crashes, or is stopped by a signal such as Ctrl+C (#515).
Windows
The Windows x64 host keeps the MSVC ABI and now supplies the startup and runtime inputs that current Roc nightlies require, using the C runtime that ships with Windows 10 and 11 (#512). Windows execution is tested on Server 2025 in CI; reports from clean Windows 10 and 11 installations without the Visual C++ redistributables are still wanted.
Upgrading from 0.25
Update the platform URL and rebuild: the native host interface changed, and mixing hosts and platform sources from different versions is not supported. Then work through the modules you use.
Time
| 0.25 | 0.26 |
|---|---|
Utc.now!() returns U128 |
returns Utc; epoch integers are I128 |
Utc.delta_as_millis(a, b), delta_as_nanos |
later.since(earlier), a Duration. It is directional: zero if later is the earlier one |
Monotonic.now!() returns U64 |
returns Monotonic; use start.elapsed!() or later.since(earlier) |
path.time_modified!() returns U128 |
returns Utc |
Stdin and files
| 0.25 | 0.26 |
|---|---|
Err(EndOfFile) |
Err(EndOfInput) |
Err(FileUnexpectedEOF) |
Err(UnexpectedEndOfInput) |
reader.read_line!() returns bytes with the \n, [] at the end |
returns a Str without it, Err(EndOfInput) at the end. For bytes, reader.read_until!('\n') |
reader.read_up_to!(n) returns [] at the end |
Err(EndOfInput) |
reader.chunks(n)? |
reader.chunks(); for a chosen size, call read_up_to!(n) in a loop |
Stdin.line!() strips every trailing \r |
strips one, and reports invalid UTF-8 as Err(BadUtf8(_)) |
Tcp
| 0.25 | 0.26 |
|---|---|
Tcp.Stream |
Tcp.Connection |
connect!(host, port, ms) |
connect!(host, port, { connect_timeout, io_timeout }) |
listen!(host, port, ms) |
listen!(host, port, Duration) |
accept!(ms), ListenerClosed |
accept!({ accept_timeout, io_timeout }), Closed |
read_up_to!(n, ms), Ok([]) at the end |
read_up_to!(n), Err(EndOfInput) |
read_exactly!(n, ms), TcpUnexpectedEOF |
read_exactly!(n), UnexpectedEndOfInput |
read_until!(byte, max, ms), TcpReadLimitExceeded(n) |
read_until!(byte), ReadLimitExceeded(n) |
read_line!(max, ms) keeping \n, TcpReadBadUtf8(e) |
read_line!() without it, BadUtf8(e) |
write!(bytes, ms), write_utf8!(str, ms) |
write_bytes!(bytes), write_utf8!(str) |
ConnectErr, TcpReadErr(…), TcpWriteErr(…), TcpListenErr(…) |
TcpErr(IOErr) |
Cmd
| 0.25 | 0.26 |
|---|---|
Cmd.exec!("cargo", ["build"]) |
Cmd.new("cargo").args(["build"]).exec!() |
cmd.exec_cmd!() |
cmd.exec!() |
cmd.exec_output!()?.stdout_utf8 |
cmd.capture!()? |
cmd.exec_output_bytes!() |
cmd.run!()?.require_success()?.stdout_bytes |
cmd.exec_exit_code!() |
cmd.inherit_stdio().run!()?.status |
args_str, arg_str and the other _str builders |
args, arg: an OsStr literal interpolates, and a List(Str) is names.map(OsStr.from_str) |
timeout_ms(ms), where 0 meant no deadline |
timeout(Duration); leave it out for no deadline, and zero expires immediately |
output_limit(n) |
output_limit(MaxBytes(n)) or output_limit(NoLimit) |
merge_stderr(True) |
stderr(MergeWithStdout) |
child.pid!(), try_wait!(), write!(bytes, ms) |
child.pid(), poll!(), write_bytes!(bytes, Duration) |
child.read!(max, ms), Ok(End) |
child.read!(Duration), Err(EndOfInput) |
RunOutput, RunErr, and the per-function error tags |
Cmd.Output, CmdErr(Cmd.Err), BadExitStatus(Output) |
Sqlite
| 0.25 | 0.26 |
|---|---|
Sqlite.query!({ path, query, bindings, row }) |
db = Sqlite.open!(path, {})? then db.query_one!(query, decoder) |
Sqlite.query_many! |
db.query_all!, or db.rows to stream |
Sqlite.execute!({ path, … }) |
db.execute!(query); it now rejects a statement that returns rows before running it |
bindings: [{ name: ":id", value: Integer(id) }] |
${Integer(id)} in the query, or Query.new(sql, values) with ? markers |
Sqlite.prepare!, Stmt |
removed; each Db caches prepared statements by SQL text |
Sqlite.i64("id")(cols)(stmt)? |
Decoder.i64("id") in a record builder |
Sqlite.u8("n") and the other narrow widths |
`Decoder.i64("n").map_try( |
[NotNull(x), Null] |
Try(x, [Null]) from decoder.nullable() |
SqliteErr(code, message) |
SqliteErr(Sqlite.Err); decode failures are BadRow(err) |
NoRowsReturned, TooManyRowsReturned |
NoRows, TooManyRows |
one shared :memory: database |
a private one per open! |
Everything else
IOErrhas five new variants (TimedOut,ConnectionRefused,ConnectionReset,AddrInUse,AddrNotAvailable). Amatchthat lists everyIOErrvariant needs new arms, even in code that never uses TCP.PathandOsStrvalues that differed only in representation are now equal, and collapse to oneDictorSetkey.Tty.enable_raw_mode!anddisable_raw_mode!return aTry; add?.- Write
TrueandFalse, notBool.TrueandBool.False, to match the platform's own docs and examples.
Fixes
Http.with_json_bodyandHttp.decode_json_responseare declared as the pure functions they are.Locale.get!errors now combine with other errors when called through a function value.- File readers, writers, sockets, child processes, SQLite connections and HTTP responses are released as soon as your program drops its last reference to them. The test suite now asserts on Linux, macOS and Windows that none is left open at exit.
Documentation and tooling
- CONTRIBUTING.md has a new API conventions section, the rule set every module in this release follows.
- CI now formats, lints and unit-tests the Rust host on Linux, macOS and Windows (#514).
- New examples:
file-write-stream.rocandhttp-download.roc. The TCP, command, SQLite, stdin and file examples are rewritten for the new APIs.