about things notes.zzstoatzz.io
4.7 kB · 110 lines
Markdown
at main

terminal CLIs #

written against zig 0.16; the APIs used are unchanged in 0.17.0.

the std surface a small single-binary tool touches: entry point, stdout and stderr, broken pipes, one HTTPS fetch, a pager, child processes.

entry point + stdout/stderr #

pub fn main(init: std.process.Init) hands you gpa/io/args/env (see the 0.16 migration). wire the writers like this:

var out_buf: [16 * 1024]u8 = undefined;
var fw = std.Io.File.stdout().writer(io, &out_buf);
const out = &fw.interface;                       // buffered; flush at the end
var ew = std.Io.File.stderr().writer(io, &.{});  // unbuffered — diagnostics show immediately
const err = &ew.interface;

convention worth keeping: data on stdout, diagnostics on stderr (the rg/kubectl split). a "3 sections matched, narrow it" hint is stderr; the list itself is stdout, so it stays pipeable.

broken pipe is normal — catch it at the top #

writing to stdout fails with error.WriteFailed the moment a downstream reader exits (| head, quitting less). don't let it crash the program:

pub fn main(init: std.process.Init) !void {
    run(init) catch |e| switch (e) {
        error.WriteFailed => {}, // stdout/pager closed early — clean exit
        else => return e,
    };
}

one-shot https GET into memory #

std.http.Client needs .allocator + .io; fetch writes the body into a *Io.Writer:

var client: std.http.Client = .{ .allocator = a, .io = io };
defer client.deinit();
var body: Io.Writer.Allocating = .init(a);
errdefer body.deinit();
const res = try client.fetch(.{ .location = .{ .url = url }, .response_writer = &body.writer });
if (res.status != .ok) return error.HttpStatus;
return body.toOwnedSlice();

zig's TLS is pure-zig — https needs no libc, so the binary still cross-compiles fully static (see build/distribution).

page on a tty, raw when piped #

detect the terminal, then route long output through $PAGER (default less -FRX — -F makes short output print and exit instead of trapping you). pipe into the child's stdin:

const want_pager = !no_pager and (std.Io.File.stdout().isTty(io) catch false);

fn page(io: Io, env: Environ.Map, text: []const u8) bool {
    const pager = if (env.get("PAGER")) |p| (if (p.len > 0) p else "less -FRX") else "less -FRX";
    var child = std.process.spawn(io, .{
        .argv = &.{ "sh", "-c", pager },   // sh -c so $PAGER can carry flags
        .stdin = .pipe, .stdout = .inherit, .stderr = .inherit,
    }) catch return false;                 // no pager -> caller prints raw
    if (child.stdin) |sin| {
        var pbuf: [16 * 1024]u8 = undefined;
        var cw = sin.writer(io, &pbuf);
        cw.interface.writeAll(text) catch {}; // EPIPE if the user quits the pager early
        cw.interface.flush() catch {};
        sin.close(io);                        // EOF so the pager finishes
        child.stdin = null;
    }
    _ = child.wait(io) catch {};
    return true;
}

spawning a child #

std.process.spawn(io, .{ .argv = ... }) returns a Child; child.wait(io) returns a Term union (exited, signal, stopped, unknown). with .stdin = .pipe, child.stdin is a ?File.

expand_arg0 defaults to .no_expand: argv[0] is a literal path, and a bare name like git fails with error.FileNotFound. set .expand_arg0 = .expand to resolve it through PATH. the pager above avoids the question by running sh -c. a browser opens the same way, with argv = &.{ opener, url } (opener is $BROWSER, else open on macOS or xdg-open on linux) and .stdin = .ignore.

shipping gotchas #

  • completion install: document eval "$(tool --completions zsh)", not source <(tool ...). process-substitution + a builtin like complete silently fails to register in bash (tested — nothing registers). and the zsh script must end with compdef _f tool (sourceable) — ending with _f "$@" is autoload-only and errors compadd: can only be called from completion function when eval'd.
  • never pipe zig build through head: it SIGPIPEs the build and kills RunSteps mid-execution, so a codegen step (e.g. generating a file) writes partial/stale output and the build "succeeds" with a wrong artifact.

sources #

  • zigman: CLI patterns, completions, SIGPIPE through head
  • typeahead: std.process.spawn and expand_arg0, turso CLI export experiment
  • zig 0.16.0 and 0.17.0 lib/std/process.zig (SpawnOptions.expand_arg0), lib/std/process/Child.zig (Term)