# io — files & I/O builtins

The **io** builtin category — read, write, walk, and stat the filesystem, plus
stdin, path-string helpers, and the embedded SQLite client. Every example below
was run against **mix 0.21.2** and shows real output, except the sub-second
timestamp section, which was run against **mix 0.44.0** — the release that added
the field it demonstrates. List the category live with
`mix builtins io`; one-line help for any name with `mix what NAME`. (That listing
also shows `print` / `eprint` — those are *statements*, not builtin calls; see
[keywords](keywords.md).)

Mix's filesystem builtins are *native* `std::fs`/`std::os::unix` calls — there is
no `/bin/sh` in the loop. Reach for them instead of shelling out: `stat(p)` not
`run_rc("stat -c …")`, `chmod(p, 0o755)` not `run("chmod 755 …")`. They are
structured-return by design — `stat` hands back a map, `read_lines` a list, `walk`
a flat list of paths — so the result is data you index into, never string soup you
re-parse.

## Common semantics

- **Paths are plain strings.** A relative path resolves against the process CWD. No tilde expansion happens *inside* a builtin — `"~/x"` only expands at lex time in a double-quoted [string](strings.md) literal, so `read_file("~/.mixrc")` works because the lexer rewrites `~` to `$HOME`, but `read_file($p)` where `$p` holds a literal `"~/x"` does not.
- **Errors raise, they don't return a sentinel.** A failed `read_file` / `ls` / `mkdir` raises a catchable runtime error (the message names the call and path). Wrap a must-succeed step in `try/catch`, or pre-check with `exists` / `access` / `is_file` / `is_dir`. The exceptions are the predicates themselves (`exists`/`access`/`is_dir`/`is_file` return a `bool` for ordinary absence or denial) and `glob`, which returns an empty list for "no match", never an error.
- **Mode arguments take the VALUE, not the digits.** `chmod`/`write_new` want an octal *literal* — `0o755` (= 493), `0o600` (= 384) — or an octal *string* (`"0755"`). A bare `755` is the decimal number 755, not the mode (see [numbers](numbers.md) for why `0755` is a lex error and `0o755` is the value). That lex error is a Mix-*expression* hazard only — `0755` inside a command string handed to `run` (`run("install -m 0755 a b")`) is plain text for `/bin/sh` and works fine.
- **`stat`'s `ino`/`dev` are STRINGS.** They are `u64` and Mix numbers are f64, which loses precision above 2^53 — so they come back as text, ready to use verbatim as a dedupe key. Don't `to_number()` them.
- **Bytes vs strings.** `read_file`/`read_lines` require valid UTF-8 (they error on a bad byte); `read_file_bytes` carries the raw buffer through `Value::Bytes`. `write_file`/`append_file`/`write_new` accept exactly a string, `bytes` or `buffer` — a `bytes`/`buffer` argument is written verbatim, a string as UTF-8, and **nothing else is coerced**: a map, list, function, number, bool or nil raises `TYPE_MISMATCH` *before* the file is opened, so encode first with `json_encode`/`data_encode`/`to_string`.

## Native filesystem events

```mix
$watch = fs_watch("/srv/scenes", {
  recursive: true,
  events: ["created", "modified", "deleted", "moved"]
})
on fs.changed
  $batch = $event.args
  -- Reconcile affected paths; overflow means rescan the watched root.
  print($batch)
end
-- fs_unwatch($watch) cancels this registration and its pending changes.
```

`fs_watch(path[, opts]) -> handle` requires an existing root. The handle is an
opaque string owned by this evaluator generation; do not interpret or persist it.
Defaults: `recursive:false`, all four event kinds. Unknown options and event
names are refused. All three watch builtins require `FsRead` capability.

`fs_unwatch(handle) -> nil` removes the watch and pending changes. An already
dispatched handler may still finish; a waiting `fs_wait` wakes with cancellation.
`fs_wait(handle) -> batch` suspends outside serve mode, without polling. Dropping
the waiting evaluation does not consume an event. In `--serve`, use `on fs.changed`:
`fs_wait` is refused so there cannot be competing consumers. Expression mode
also denies `fs_wait`. In a plain script, a top-level `sleep()` dispatches native
events only for families with a registered handler. An unrelated handler (or
only `on proc.exited`) leaves filesystem batches available to `fs_wait`.

Each batch is `{watch, changes:[{path,kind,old_path?}], overflow}`. Paths are
absolute, with parent symlinks resolved during registration. Changes coalesce
per path at delivery; a write or close-write does not erase a pending `created`
or paired rename.
Coalescing that would discard a different rename origin sets overflow.
`moved` has `old_path` only when inotify supplied both endpoints. A half move
invalidates its reported path without guessing the other endpoint. Consumers
must reconcile current filesystem state, including absence. Separate writes can
produce separate batches; there is no promise of one notification per save.

The backend is Linux `notify::INotifyWatcher`, never `PollWatcher`. File roots
watch their parent directory, so atomic-save replacement remains visible.
Directory roots watch their parent too, permitting deletion and recreation of
the root while that parent survives. Recursive watches enumerate existing and
new directories on registration or structural events, never on a clock. Root
symlinks are refused; discovered directory symlinks are skipped. This is not a
security boundary against concurrent path/symlink substitution. Moving or deleting
the stable parent itself requires a new registration. Pseudo-filesystems which
do not emit inotify events cannot be observed by this API.

Per evaluator: at most 128 handles, 8192 distinct directory registrations and
4096 pending coalesced changes. All handles share one inotify instance and its
event-loop thread, one reconciliation worker and a bounded 256-record backend
queue. Overlapping handles share directory registrations; removing one handle
does not remove coverage needed by another. The instance and workers retire
after the last handle is removed or the evaluator closes its native sources.
Kernel/queue loss, directory limits or later observation failures set sticky
`overflow:true`; the bit clears only when that batch is delivered. A newly
watched directory also sets overflow because files may have appeared before its
registration. Rescan once for each overflow batch. Non-UTF-8 event paths request
a rescan rather than emitting a lossy path. Event filtering never suppresses
overflow. A failed initial registration releases everything it acquired.
If a later permission/resource failure prevents reinstalling coverage, resolve
that failure and recreate the watch; a rescan alone cannot grant missing watches.
Registration, enumeration and teardown can wait on the underlying filesystem;
there is no hard latency guarantee for a stalled filesystem.

Refusals raise; an uncaught refusal exits nonzero. The structured catch value
contains `{error_code,message}` (`code` remains an alias). Codes:

| Code | Meaning |
|---|---|
| `FS_WATCH_MISSING` | Initial root/parent is absent. |
| `FS_WATCH_PERMISSION` | Kernel denied registration or enumeration. |
| `FS_WATCH_LIMIT` | Handle/directory/kernel resource limit. |
| `FS_WATCH_UNSUPPORTED` | Platform is not Linux. |
| `FS_WATCH_SYMLINK` | Root is a symlink. |
| `FS_WATCH_OPTIONS`, `FS_WATCH_ARGUMENT` | Invalid options or path/handle type. |
| `FS_WATCH_HANDLE` | Unknown handle passed to unwatch. |
| `FS_WATCH_CANCELLED` | Wait names a removed or unknown watch. |
| `FS_WAIT_SERVE` | Blocking-consumer API used in serve mode. |
| `FS_WATCH_IO` | Other initial filesystem/backend failure. |
| `NATIVE_CLOSED` | This evaluator's native registrations have retired. |
| `NATIVE_CONSUMER` | Attempted to enter a second event pump in this evaluator. |

See [serve lifecycle](serve.md#native-events-and-generation-lifetime) for reload
and shutdown ownership. Network and audio changes have their own subscriptions
(`net_watch`, `audio_watch`) — see
[desktop status events](system.md#desktop-status-events--net_watch-audio_watch);
do not watch `/sys/class/net`, which does not emit inotify events. These examples specify the new API; the older version
claims at the top of this page do not apply to this section.

## Reading files

```
read_file(path)          -> string   (whole file; errors if not UTF-8)
read_lines(path)         -> list      (lines, trailing \n stripped, trailing empty dropped)
head(path[, n])          -> list      (first n lines, default 10 — stops reading at n)
tail(path[, n])          -> list      (last n lines, default 10 — reads backwards from EOF)
line_count(path)         -> number   (count lines by streaming; works on non-UTF-8 files)
read_file_bytes(path)    -> bytes     (raw; binary-safe)
read_file_bytes(p, max)  -> bytes     (read at most `max` bytes — header-sniff)
stdin_copy(path)         -> nil       (stream stdin to a file, flushing every chunk)
tty_mode("raw"|"cooked") -> nil       (raw/cooked terminal mode; original restored on exit)
```

```mix
write_file("/tmp/io/hello.txt", "line one\nline two\nline three\n")

print(read_file("/tmp/io/hello.txt"))
$lines = read_lines("/tmp/io/hello.txt")
print("count: " .. length($lines))
print("first: " .. $lines[0])
print("line_count: " .. line_count("/tmp/io/hello.txt"))
```

```text
line one
line two
line three

count: 3
first: line one
line_count: 3
```

`read_lines` strips each trailing newline (`\r\n` too — the `\r` is stripped
with it) and **drops the empty last line** a file ending in `\n` would otherwise
produce — so `"x\ny\n"` is two lines, not three. `line_count` streams the file
in blocks and never materializes a list — the right call when you only want the
count. It is byte-oriented (counts `\n`), so unlike the readers it works on
non-UTF-8 files too.

`head` and `tail` (v0.28.1) are the **no-slurp** twins of
`take(read_lines(p), n)` and `take(read_lines(p), -n)`: same line semantics,
same list result, but `head` stops reading after `n` lines and `tail` reads
64 KiB blocks backwards from EOF — the last 10 lines of a multi-GB log cost
kilobytes, not the file's size in RAM. `n` defaults to 10 and must be a
non-negative integer *number*; a numeric string or bool is a loud error, same
discipline as `read_file_bytes`'s cap. Reach for `read_lines`+`take` only when
you need the whole file anyway.

A missing file raises:

```mix
try
  read_file("/tmp/does-not-exist-xyz")
catch $e
  print("caught: " .. ("" .. $e))
end
```

```text
caught: read_file '/tmp/does-not-exist-xyz': No such file or directory (os error 2)
```

### Reading bytes (binary-safe)

`read_file_bytes` is the binary companion. The optional second argument caps the
read at *max* bytes via `File::take`, so you can sniff a header out of a
multi-megabyte file without slurping it all. The cap must be a real `Number`
(a numeric string or bool is rejected — a silent surprise on a read cap would be
a footgun). Pair it with the [bytes helpers](#bytes-helpers):

```mix
-- a file whose first bytes are the JPEG magic \xFF\xD8\xFF
$all = read_file_bytes("/tmp/io/img.bin")
print("full: " .. bytes_len($all))

$head = read_file_bytes("/tmp/io/img.bin", 4)
print("capped: " .. bytes_len($head))
```

```text
full: 34
capped: 4
```

To pull an ASCII header out of a not-quite-UTF-8 block, decode the capped buffer
with the explicit lossy escape hatch — `bytes_to_string($head, {lossy: true})`
(see below). For a strict-data `.mix` file, use `load_data(path)` — the
non-executing twin of [`include`](functions.md) that parses bare-key `key: value`
data without running it.

## Writing files

```
write_file(path, content)            overwrite (or create); follows symlinks
append_file(path, content)           create-if-missing, then append
write_new(path, content, mode)       atomically create; FAILS if path exists
write_atomic(path, data[, opts])     replace all-or-nothing: old OR new, never partial
```

`content` is a string, `bytes` or `buffer` — nothing else is coerced (a wrong
type raises `TYPE_MISMATCH` before the file is touched).

**FIFOs.** When the path is a named pipe, `read_file`, `write_file` and
`append_file` wait for the other end **without holding the runtime thread**
(since 0.117.0): Ctrl-C raises `interrupted`, SIGTERM takes the graceful
shutdown path, and a `--serve` citizen whose `async` handler waits on a FIFO
still answers `QUIT` and its other verbs. `read_file` returns once every
writer has closed; a write returns once the data is in the pipe. Other
builtins (`read_file_bytes`, `read_lines`, …), stuck NFS/FUSE paths and
character devices still block the thread, and only the SIGTERM backstop
bounds them. A regular file pays one extra `stat` to be told apart.

```mix
write_file("/tmp/io/log.txt", "first\n")
append_file("/tmp/io/log.txt", "second\n")
print(read_file("/tmp/io/log.txt"))
```

```text
first
second
```

### `write_new` — atomic, mode-at-creation, secret-safe

`write_new` is `O_EXCL` at the syscall level (`create_new(true)`): it **fails if
the path already exists**, with no TOCTOU window between a check and the write.
The mode is applied *at creation* via `OpenOptions::mode()`, so the file is never
briefly umask-permissioned — secret material hits disk at the configured mode
from the very first byte. This is the call for DKIM keys and similar single-shot
secrets; for ordinary mutable writes use `write_file`.

```mix
write_new("/tmp/io/secret.key", "TOPSECRET\n", 0o600)
print("perm: " .. stat("/tmp/io/secret.key")["perm"])    -- 0o600 == 384

try
  write_new("/tmp/io/secret.key", "x", 0o600)            -- already exists
catch $e
  print("second write_new: refused (exists)")
end
```

```text
perm: 384
second write_new: refused (exists)
```

> `0o600` prints as `384` because Mix numbers are decimal f64 — `0o600` is just a
> source spelling of the value 384. `stat`'s `perm` field is that same number;
> feed it straight back into `chmod`.

### `write_atomic` — replace a file all-or-nothing

`write_file` truncates first and then writes. A crash, a kill, a full disk or a
permission failure part-way leaves the target cut short, and a concurrent
reader can see a half-written file at any time. `write_atomic` is the one call
for "replace this file safely". Every reader and every crash sees either the
**old complete file** or the **new complete file**:

```mix
write_atomic("/etc/myapp/config.json", json_encode($cfg, true))
write_atomic("/srv/state.db", $bytes, {durability: "full"})
write_atomic("/home/u/.ssh/authorized_keys", $keys, {mode: 0o600})
```

1. It claims a hidden temp name beside the target, `.NAME.mixtmp-…`, with
   `O_EXCL`, so it never follows a planted symlink. Being in the same
   directory keeps the final rename on one filesystem.
2. It writes all the bytes to the temp and, if asked, `fsync`s it.
3. It `rename`s the temp over the target. That is the atomic step.

**Any failure before the rename removes the temp and leaves the target exactly
as it was.** That covers a short write, `ENOSPC`, a failed sync and a failed
rename. If the process is *killed* mid-write, nothing can run to clean up. The
target is still intact, but a stale `.NAME.mixtmp-*` file may be left beside
it, and it is safe to delete.

Durability is explicit, and the default claims none it did not request:

| `durability` | what is done | after a power loss or kernel crash |
|---|---|---|
| `"none"` (default) | temp, write, rename | no promise: the data may still be in the page cache |
| `"file"` | `fsync` the temp before the rename | old complete file or new complete file |
| `"full"` | `"file"`, then `fsync` the directory after the rename | the new file, once the call has returned |

`"none"` is still fully atomic against readers and against the *process*
dying. Only the machine dying can lose the new content. With `"full"`, a
failed directory sync raises even though the new file is already in place.
It is the one failure that happens *after* the replace, so it has its own
code, `WRITE_NOT_DURABLE`, with `$err.details.replaced == true`. Every other
failure leaves the target untouched. A gate must never "roll back" on
`WRITE_NOT_DURABLE`, because the new content is already the file. The
details also carry `directory`. The same error covers a directory you may
write and search but not read, mode `0300`: `write_atomic` works there at any
level, but such a directory cannot be fsynced, so `"full"` reports that
instead of claiming durability.

Other rules:

- **data** must be a string, bytes or a buffer. Nothing else is coerced. A
  wrong value written atomically is still a wrong value.
- **Mode.** An existing target keeps its permission bits, including
  setuid/setgid. A new file gets `write_file`'s `0o666 & ~umask`. `mode:`
  (octal number or string, as for `chmod`) sets it exactly, not masked by the
  umask. The mode is applied after the data is written, so the kernel's
  rule that a write clears setuid does not strip a requested setuid bit.
- **Access ACL.** An existing target's POSIX access ACL is copied onto the
  new file exactly. Copying only the mode bits would widen access. For example,
  a file with `group::---` and a named-user entry has its group bits showing
  the ACL mask, and a plain file with those bits would grant the whole owning
  group that access. If the ACL cannot be set, the call raises and leaves the
  target untouched. If the old file had no ACL, one inherited from the
  directory's default ACL is removed, so the replacement matches the original.
  Owner, mode and ACL are all read from one handle on the old file, so they
  always describe the same file. Without `/proc` mounted the ACL is read by
  path instead. If the name has been swapped in between, the call raises
  `WRITE_NOT_ATOMIC` and writes nothing.
- **Owner.** An existing target keeps its owner and group. The new file is a
  new inode, so root rewriting a user's file would otherwise hand it to root.
  If the owner cannot be kept, typically because you are not root, the call
  raises and leaves the target untouched. It never silently changes who owns
  a file.
- **It is a new inode.** The replacement is a different file that takes over
  the name, and that has consequences `write_file` does not have:
  - A **hard link** to the old file keeps the old content. Only this name
    moves to the new file.
  - A process that already has the file **open** keeps reading the old
    content. An **inotify** watch on the file itself follows the old inode;
    watch the directory for the rename instead.
  - Only the owner, mode and POSIX access ACL are carried over. **Other
    extended attributes are not**: an SELinux label is re-derived from policy
    and may differ, and `security.capability` (`setcap` file capabilities) is
    dropped. For a `setcap` binary or an SELinux-labelled `/etc` config, run
    `setcap`, `restorecon` or `chcon` after the replace, or use `write_file`,
    which keeps the inode.
- **Symlinks.** A symlink path replaces the file the link names and keeps the
  link. That is `write_file`'s behaviour, and it avoids turning the link into
  a regular file. A dangling link raises.
- **Concurrent changes.** The target's directory is opened once, at the
  start. Checking the target, creating the temp, the rename, cleanup and the
  directory fsync all happen relative to that open directory. Renaming or
  replacing the directory, or any parent, while the call runs cannot
  redirect the write elsewhere. If the resolved target has turned into a
  symlink by the time it is checked, the call raises and writes nothing. The
  final rename replaces whatever the name holds at that instant, and
  `rename` never follows the last component. So if another process swaps in
  a symlink between the check and the rename, the rename replaces the link
  itself: the last writer to the name wins, and the call never writes
  through the link.
- **max_bytes** bounds the write. If the data is larger, the call raises
  `WRITE_TOO_LARGE` before anything touches the disk.
- **Refusals.** A target that exists but is not a regular file, such as a
  directory, raises, and so does a path ending in `/`. A target that is a
  mount point, such as a bind-mounted file, cannot be replaced by `rename`,
  so it raises `WRITE_NOT_ATOMIC` and writes nothing. Use `write_file` there,
  which rewrites in place, non-atomically. Bad options raise `OPTION_INVALID`. A wrong argument type
  raises `TYPE_MISMATCH`.

```mix
write_atomic("/tmp/io/state", "v1\n")
write_atomic("/tmp/io/state", "v2\n", {durability: "file"})
print(read_file("/tmp/io/state"))
```

```text
v2
```

## Existence & type tests

```
exists(path[, opts])  -> bool   opts: {follow_symlinks: true|false}  (default true)
access(path, mode)     -> bool   mode: non-empty combination of r, w, x, f
is_dir(path)          -> bool
is_file(path)         -> bool   (regular file only — false for a dir or a dangling link)
```

```mix
print("" .. exists("/etc/hosts"))     -- true
print("" .. is_dir("/etc"))           -- true
print("" .. is_file("/etc/hosts"))    -- true
print("" .. is_file("/etc"))          -- false  (it's a dir)
print("" .. is_file("/no/such"))      -- false  (missing — no error)
```

```text
true
true
true
false
false
```

### Kernel permission checks — `access`

`access(path, mode)` asks the kernel whether this process can access a path
*right now*. `mode` is a non-empty string containing any combination of `r`,
`w`, and `x`, in any order. `f` means existence (`F_OK`): it may appear alone,
and is redundant when combined with another letter. A repeated letter, an empty
string, or any other character raises rather than turning a typo into a
permissive check.

```mix
if access(".git/hooks/pre-commit", "x") then
  print("the kernel will not refuse to execute this on permission grounds")
end

print("" .. access("/etc/hosts", "f"))       -- existence
print("" .. access("/no/such/path", "r"))    -- false, no catch needed
```

This is the sound answer to a question that `stat(path)["perm"]` cannot answer.
Mode bits are only part of the kernel's permission calculation: a POSIX ACL can
grant or deny a named user independently of what `0o755` appears to say.
`access` uses `faccessat(AT_FDCWD, path, mask, AT_EACCESS)`, so ACLs are honoured
and the check uses the process's **effective** uid/gid — the identity under which
this process would actually do the thing.

On Linux that is issued as the **`faccessat2(2)` syscall directly**, not through
the C library's `faccessat` wrapper, and the difference is not academic. The
older kernel call takes no flags, so glibc implements `AT_EACCESS` itself — and
where `faccessat2` is unavailable (glibc ≤ 2.32, or a kernel before Linux 5.8)
that emulation decides from `fstatat(2)` mode bits and, in the man page's own
words, *"does not take ACLs into account"*. That is exactly the arithmetic this
builtin exists to replace, so rather than fall back to it silently, `access`
**raises** on such a kernel. The answer is the kernel's or there is no answer. A
caller that genuinely wants mode arithmetic can read `stat(path)["perm"]` and own
that choice out loud.

That is deliberately *not* the same rule as POSIX `access(2)`, which uses the
**real** ids; some programs (git's hook lookup among them) use that older call.
The two agree except under a setuid or setgid binary, where real and effective
diverge. Mix is neither, and neither is a shell, a daemon, or a `sudo`ed
command, so predicting such a program's answer with this builtin is sound
wherever the two ids match — which is everywhere Mix normally runs, but is an
assumption worth naming rather than one the builtin can enforce.

On Linux `x` also accounts for a **`noexec` mount**, which no combination of
mode bits reveals: a 0755 file on a `noexec` filesystem answers `false`, exactly
as `execve` would. On a *directory*, `x` means search permission rather than
execute, so it does not answer "can programs be run from here" — for that, ask
about a file inside it.

Symlinks are followed deliberately: the question is whether the thing they
resolve to can be read, written, or executed. Ordinary negative answers return
`false`, including a missing path, a denied permission, a non-directory path
component, a symlink loop, an overlong path, a read-only filesystem on a write
query, a `w` query against a file currently being executed, and a `w` query
against an immutable file. Malformed input (including an interior NUL in the
path) and an unexpected syscall failure raise.

That last case is the one genuinely ambiguous errno: a seccomp filter or an LSM
that blocks the syscall itself also reports `EPERM`, and is reported here as an
ordinary "no". That is the safe direction — a caller asking "may I?" is better
served by a false than by an exception thrown at code that was only trying to
describe a file — but on a sandboxed system a blanket `false` from `access` is
worth reading as *"the question could not be put"* rather than as a fact about
the file.

The answer is a snapshot, not a lease: a mode change, an ACL change or a remount
between the check and the use invalidates it. For a step that must succeed,
attempt it inside `try`/`catch` and let the failure be authoritative; `access` is
for reporting, for choosing a branch, and for saying *why* something will not
work before trying it.

**`exists` answers two different questions and you have to pick.** By default it
**follows symlinks**, so it means *"can I open something here"* — and a dangling
link reads as **absent**. `exists(path, {follow_symlinks: false})` is the `lstat`
form: *"is there an entry at this name"*, which sees the link itself (v0.39.0;
same option name and default as `stat`).

```mix
symlink("nowhere", "/tmp/io/dangling")
print("" .. exists("/tmp/io/dangling"))                            -- false
print("" .. exists("/tmp/io/dangling", {follow_symlinks: false}))  -- true
```

```text
false
true
```

Use the default for *"is there a file I can read or run"*. Use the `lstat` form
for *"is this name free"*, *"is there something here I must not destroy"*, and
*"is there something here I must not silently ignore"* — those three all have to
see a dangling link, and the default hides it. (The same distinction, one layer
down, is why `write_new` is the right way to claim a pathname: see
[Claim the temp name before you write into it](#atomic-replace--rename).)

`realpath` canonicalises a path — it resolves every symlink and `.`/`..` to the absolute real path (like `readlink -f` / `realpath(3)`). The path **must exist**; a missing component, a symlink loop, or a non-UTF-8 resolved path returns `nil` (never an error), so the caller decides. It is the same canonicalisation `require`/`include` use for their per-path module cache. It is **normalisation only, not a race-free authorization primitive** — canonicalise-then-use is not atomic. For an exec/open safety check, operate on the **returned canonical path** (which has no symlinks left to re-traverse), not the original, so a symlink hop can't be repointed between the check and the use.

```
realpath(path)  -> string | nil   (absolute real path, or nil if unresolvable)
```

```mix
print("" .. realpath("/usr/bin/traceroute"))  -- e.g. /usr/bin/traceroute.db (Debian alt-link)
print("" .. realpath("/etc/../etc/hosts"))    -- /etc/hosts
print("" .. realpath("/no/such/path"))        -- nil  (missing — no error)
```

These never raise — they answer a question, so a missing path is simply `false`.
Use them to guard a read that *would* raise.

## Listing directories — `ls`, `glob`, `walk`

```
ls([dir])                     one level, names only (sorted; default ".")
glob(pattern)                 paths matching a glob (* ? and ** globstar)
walk(dir[, opts])             recursive list of paths under dir (sorted)
```

`ls` returns the **entry names** in a single directory, sorted, with no path
prefix. It raises on a missing or unreadable directory.

```mix
for each $e in ls("/tmp/io")
  print($e)
end
```

```text
a.txt
b.log
sub
```

`glob` matches a shell-style pattern and returns **full matching paths**, sorted.
`*` and `?` match within one path component; `**` is a globstar that descends
through zero or more directory levels. No match → an empty list (not an error).
A relative pattern starts from `.` (with no stray `./` prefix on results); an
absolute pattern starts from `/`.

```mix
-- top level only
for each $g in glob("/tmp/io/*.txt")
  print($g)
end
print("---")
-- recurse with **
for each $g in glob("/tmp/io/**/*.txt")
  print($g)
end
```

```text
/tmp/io/a.txt
---
/tmp/io/a.txt
/tmp/io/sub/c.txt
/tmp/io/sub/deep/d.txt
```

`walk` is the depth-first recursive walk. By default it returns **files only**;
the options map flips the defaults:

- `max_depth` (number, default unlimited) — nesting depth relative to `dir`; `max_depth: 0` returns only the direct children.
- `include_dirs` (bool, default `false`) — include directory entries too.
- `follow_symlinks` (bool, default `false`) — follow symlink dirs (loop-safe; the walker tracks visited inodes).
- `strict` (bool, default `false`) — raise instead of skipping what cannot be read (below).

The boolean options take a real `bool` only — `"false"`, `0`, `nil` and the
like raise `TYPE_MISMATCH` before traversal starts rather than being read as a
truthiness.

`max_depth` accepts a number or numeric string. If the option is present but
cannot be parsed as a number, `walk` raises `TYPE_MISMATCH`; it never silently
discards the limit and performs an unlimited traversal.

`walk` errors only on a **missing** top-level `dir`. Everything else is
best-effort: an unreadable directory is silently skipped — even the top-level
one (it exists, so the walk proceeds and simply finds nothing, returning `[]`) —
and so is an individual entry that fails to stat. One bad file deep in a subtree
shouldn't abort the whole walk.

A gate cannot tell "no matches" from "could not look" that way, so it passes
`strict: true`. Then the first directory that cannot be read (the top-level one
included, a `dir` that cannot even be stat'ed, or a `dir` that is a file) or
entry that cannot be stat'ed raises
`WALK_UNREADABLE`; `details.path` names it and `details.error` says why. An
entry or directory that vanished since it was listed, or a dangling symlink under
`follow_symlinks`, is still skipped: it was looked at, and nothing is there.

```
try
  $files = walk("/srv/repo", {strict: true})
catch $msg, $e
  if $e.code == "WALK_UNREADABLE" then
    die("cannot audit " .. $e.details.path)
  end
  raise($e.code, $msg, $e.details)
end
```

```mix
print("--- files only, full depth ---")
for each $p in walk("/tmp/io")
  print($p)
end
print("--- direct children, include dirs ---")
for each $p in walk("/tmp/io", {max_depth: 0, include_dirs: true})
  print($p)
end
```

```text
--- files only, full depth ---
/tmp/io/a.txt
/tmp/io/b.log
/tmp/io/sub/c.txt
/tmp/io/sub/deep/d.txt
--- direct children, include dirs ---
/tmp/io/a.txt
/tmp/io/b.log
/tmp/io/sub
```

The split: `ls` = one level / names, `glob` = pattern / full paths, `walk` =
recurse / full paths. Reach for `walk` over a hand-rolled `glob("**")` loop when
you need depth limits or directory entries.

## Creating directories — `mkdir`

`mkdir(path)` creates the directory **and any missing parents** (it is
`create_dir_all`, i.e. `mkdir -p`), and is idempotent — calling it on an existing
directory is a no-op, not an error.

```mix
mkdir("/tmp/io/a/b/c")
print("" .. is_dir("/tmp/io/a/b/c"))   -- true
mkdir("/tmp/io/a/b/c")                 -- again: fine, no error
print("idempotent ok")
```

```text
true
idempotent ok
```

`mkdir(path, {parents: false})` creates **only the final component** (plain
`create_dir`) and raises if the parent is missing. Reach for it when the parent
was placed deliberately and its disappearance is news: the recursive default
would quietly re-create it — and every level above it — so a script that checked
`is_dir($TMPDIR)` a moment earlier ends up manufacturing the very directory it
meant to refuse, and its cleanup removes only the leaf it knows about.

```mix
mkdir("/tmp/io/exists")
mkdir("/tmp/io/exists/leaf", {parents: false})     -- fine: the parent is there
print("" .. is_dir("/tmp/io/exists/leaf"))
try
  mkdir("/tmp/io/absent/leaf", {parents: false})
catch $e
  print("refused")
end
print("" .. exists("/tmp/io/absent"))              -- the parent was NOT created
```

```text
true
refused
false
```

The option map is checked strictly, because every way of misspelling a safety
switch would otherwise select the unsafe default in silence. `parents` is the
only key `mkdir` accepts and it must be a boolean; `{parent: false}` (singular)
and `{parents: "false"}` (a string, which is truthy) both raise rather than
recursing.

```mix
try
  mkdir("/tmp/io/x/y", {parent: false})
catch $e
  print("unknown key refused")
end
try
  mkdir("/tmp/io/x/y", {parents: "false"})
catch $e
  print("non-boolean refused")
end
print("" .. exists("/tmp/io/x"))                   -- neither call created it
```

```text
unknown key refused
non-boolean refused
false
```

## Advisory locks — `flock`, `funlock`

```
flock(path)                              exclusive, non-blocking
flock(path, {shared: true})              shared, non-blocking
flock(path, {wait: 2.5})                 wait for up to 2.5 seconds
flock(path, {create: false})             refuse a missing lock file
funlock(path)                            release this process's lock
```

`flock` opens the file, creating it with mode `0644` (subject to the process
umask) when it is missing, then takes a kernel advisory lock with `flock(2)`. An
exclusive lock opens the file read/write. A **shared** lock opens an existing
file **read-only**, so a read-only diagnostic can share-lock a lock file it
cannot write (root-owned, or on a read-only image). `create: false` never
creates the file: a missing one raises `LOCK_FILE_MISSING` and nothing is
written, which a diagnostic wants on a host where the lock file should already
exist. A directory raises either way. The default is
exclusive and non-blocking: `true` means acquired and `false` means another
process currently holds an incompatible lock. Contention is normal control flow,
not an exception. A positive `wait` retries without busy-spinning until the
deadline, then returns `false`; genuine open or locking failures raise.

```mix
$lock = "/tmp/deploy.lock"
if not flock($lock, {wait: 5}) then
  die "another deploy is running"
end

try
  print("deploy while the kernel owns the lock lifetime")
catch $e
  funlock($lock)
  die $e
end
funlock($lock)
```

The fd lives in a process-global registry under the file's canonical path, so it
stays open after `flock` returns and the kernel releases it automatically when
the process exits — including signal death. Calling `flock` again for the same
canonical path in the same process returns `true` without opening a second
retained fd. The first acquisition fixes shared versus exclusive mode until
`funlock`; a repeated call is idempotent, not an upgrade or downgrade — so
`flock(p)` followed by `flock(p, {shared: true})` answers `true` while the
held lock stays exclusive (the same rule applies to `fcntl_lock` below).

`funlock` returns `true` when it released a held fd and `false` when this process
held no lock for that path. The not-held case never raises.

These are **advisory** locks: every cooperating process must use `flock` on the
same file. Do not remove, rename, or replace a live lock file. A path can then
name a new inode while an older process still holds the old inode locked, letting
both processes proceed. Leave the lock file in place permanently; only acquire
and release its kernel lock.

### POSIX record locks — `fcntl_lock`, `fcntl_unlock` (v0.71.0)

```
fcntl_lock(path)                         exclusive, non-blocking
fcntl_lock(path, {shared: true, wait: 2.5, create: false})
fcntl_unlock(path)                       release this process's record lock
```

Linux keeps **two separate advisory-lock families** that never see each
other: `flock(2)` locks (above) and POSIX `fcntl(2)` record locks — the
family C/Rust daemons use for pidfiles and lock files (`F_SETLK`/`F_SETLKW`).
A Mix `flock()` cannot contend with a daemon's fcntl lock; `fcntl_lock()`
can. **Pick the family the other side of the coordination uses**; for
Mix-to-Mix locking either works, and `flock` is the established default.

The caller contract is identical to `flock`/`funlock` in every respect:
exclusive + non-blocking by default, contention returns `false`, real errors
raise, a shared lock opens an existing file read-only (a read lock needs only
read access), `create: false` refuses a missing file with `LOCK_FILE_MISSING`,
repeated acquisition in one process is idempotent-`true` by canonical
path, the fd lives in a process-global registry and the kernel cleans up on
exit, and the do-not-delete-a-live-lock-file rule applies unchanged. The
lock always covers the **whole file**.

One implementation detail worth knowing: these are *open-file-description*
locks (`F_OFD_SETLK`). They conflict with other processes' traditional
record locks exactly as required — but unlike traditional records they are
**not** dropped when this process opens and closes the same path elsewhere.
A traditional fcntl lock would be silently released by a mere `read_file`
of the locked path (POSIX drops a process's record locks when *any* fd for
the file closes); the OFD form removes that footgun.

## Copying & removing — `copy`, `copy_tree`, `remove`, `remove_dir`

```
copy(src, dst)          copy a single file; overwrites dst, preserves the source mode
copy_tree(src, dst)     recursive dir copy — files (mode kept) + symlinks (as symlinks);
                        creates dst and merges into an existing one
remove(path)            remove a file/symlink; no-op if already gone (rm -f)
remove_dir(path)        recursive remove of a dir + contents; no-op if gone (rm -rf)
```

`copy_tree` mirrors the parts of `cp -a` scripts actually need: it recreates the
tree, copies files with `std::fs::copy` (which carries the permission bits), and
recreates symlinks **as symlinks** (target verbatim — no dereference). `remove` and
`remove_dir` follow `rm -f` / `rm -rf` semantics: an already-missing path is a no-op,
not an error (so a "clear then rebuild" step needs no `exists()` pre-check), while a
real failure (a permission error, or `remove` aimed at a directory) still raises.

```mix
mkdir("/tmp/io/rc/sub")
write_file("/tmp/io/rc/a.mix", "-- alias file\n")
copy_tree("/tmp/io/rc", "/root/.rc")   -- e.g. bundling ~/.rc into an image rootfs
remove_dir("/tmp/io/rc")               -- rm -rf the staging copy
remove_dir("/tmp/io/rc")               -- again: fine, already gone
print("copied + cleaned")
```

```text
copied + cleaned
```

## Atomic replace — `rename`

```
rename(src, dst)        rename(2) — moves src onto dst within one filesystem
```

For an ordinary safe file replacement, use
[`write_atomic`](#write_atomic--replace-a-file-all-or-nothing). It is the
sequence below packaged with temp claiming, cleanup and durability levels.
`rename` remains the primitive when you need the steps yourself.

`rename` exists for the one guarantee `copy` cannot give: replacing an existing
`dst` is **atomic**. A concurrent reader — or a `git` about to `exec` a hook, or a
daemon re-reading its config — sees either the old file or the new one, never a
half-written one. That makes it the second half of the safe in-place update:
**write a temp file beside the target, `chmod` it, then `rename` it over the
target.** `write_file` alone truncates first, so a crash or a permission failure
mid-write leaves the target destroyed.

Errors surface verbatim rather than being papered over. `EXDEV` (a cross-filesystem
move) raises — that is a genuinely different operation with different failure
modes, and silently falling back to copy + remove would hand back the
non-atomicity the caller came here to avoid. A missing `src` raises `ENOENT`;
unlike `remove`, this is never a no-op, because renaming something that isn't
there is always a caller bug.

```mix
$live = "/tmp/io/hooks/pre-commit"
$tmp  = $live .. ".tmp"
write_file($tmp, "#!/bin/sh\nexec my-gate\n")
chmod($tmp, 0o755)
rename($tmp, $live)                    -- the swap is all-or-nothing
print(read_file($live))
```

```text
#!/bin/sh
exec my-gate
```

**Claim the temp name before you write into it.** A *fixed* staging name like
`pre-commit.tmp` is a pathname anyone can get to first, and `write_file` follows
a symlink sitting there — so `pre-commit.tmp -> pre-commit` turns the "safe"
temp write into a write straight through the live target, and the `rename` then
points the target at itself. `write_new` is `O_EXCL`: it fails on *any* existing
entry, including a dangling symlink, and never follows one. Use it to take the
name, and the rest of the sequence is honest.

```mix
write_new($tmp, "", 0o755)             -- refuses if anything is already there
write_file($tmp, $body)
rename($tmp, $live)
```

Also note `rename` reports the *client's* view. On NFS a client can be told the
rename failed after a server crash even though the server performed it, so
"raised" is not proof the old file survived; re-read the target if you must know.

## Durability — `fsync` (v0.117.0)

```
fsync(path)                      a file's data, or a directory's entries
fsync(dir, {recursive: true})    every file and directory beneath, then dir
```

A `rename`, `remove` or new file is atomic but not durable: after a power loss
it can be gone, or be there without the journal entry written just before it.
`fsync` asks the kernel to put it on disk and returns how many inodes it synced.
On a **file** it syncs the file's data and metadata. On a **directory** it syncs
the directory itself, which is what persists the entries created, renamed or
removed in it (sync the directory after a `rename` into it, and after a
`remove` from it). `recursive: true` syncs every regular file and directory
beneath, children first, then `dir` itself: a tree built with `copy_tree` is
durable when it returns. Inside the tree symlinks are never followed, and
symlinks and special files are not opened (their directory's sync persists the
entry); the `path` argument itself follows a symlink, as elsewhere.

`write_atomic(path, data, {durability: "full"})` already does this for one file
and its directory. `fsync` is the general form, without `sync -f`'s flush of the
whole filesystem. A failure raises `FSYNC_FAILED`; `details.path` names what
could not be synced (a missing path, an unreadable directory in the tree).

```
rename("/srv/app/.next", "/srv/app/current")
fsync("/srv/app")                                -- the rename is now durable
```

## Symbolic links — `symlink`, `read_link`

```
symlink(target, linkpath)   symlink(2) — creates linkpath pointing at target
read_link(path) -> string   readlink(2) — the target exactly as stored
```

Argument order is `symlink(2)`'s, **not** `ln`'s reading order: the target comes
first, the link to create second. `target` is stored verbatim and is neither
resolved nor validated, so a relative target resolves against the *link's own*
directory, and creating a dangling link is legal — both are usually the point.
`linkpath` must not exist (`EEXIST`); `remove` deletes a symlink itself rather
than what it points at.

`read_link` is not `realpath`. It hands back what the link literally says,
relative or dangling and unresolved; `realpath` answers "where does this land".
Auditing a link — *is this temp file secretly aimed at the file next to it?* —
needs the unresolved answer. It raises `EINVAL` on anything that is not a
symlink, so test first with `stat(path, {follow_symlinks: false})`, which is the
`lstat` form and the only way to `stat` the link rather than its target.

```mix
symlink("../elsewhere/absent", "/tmp/io/link")
print(read_link("/tmp/io/link"))
print(stat("/tmp/io/link", {follow_symlinks: false})["is_symlink"])
print(exists("/tmp/io/link"))          -- follows: a dangling link reads as absent
```

```text
../elsewhere/absent
true
false
```

That last line is the trap worth remembering: plain `exists` follows the link, so
a dangling symlink answers "nothing is here" while very much being something. Ask
the other question — `exists(path, {follow_symlinks: false})` — whenever you mean
"is this name taken" rather than "can I open this".

## Permissions & ownership — `chmod`, `chown`

```
chmod(path, mode)            mode as octal literal 0o755 or string "0755"
chown(path, uid, gid)        numeric uid/gid only; follows symlinks
chown(path, uid, gid, {follow_symlinks: false})   the link itself (lchown)
```

Native syscalls — don't `run("chmod …")`. Both follow symlinks (the mode/owner is
applied to the link's *target*, standard shell semantics). `chown` with
`{follow_symlinks: false}` changes the symlink itself, as `lchown(2)` does and
as `stat`/`exists` read it: a root script that makes links in another user's
tree can hand the links to that user too. It works on a dangling link, which
the following form cannot chown. (Linux has no `lchmod`, and a link's mode is
never used, so `chmod` has no such option.) `chmod`'s mode is the
**value** (`0o755`, range `0..=0o7777`); a number out of range or with a fraction
is rejected. `chown` takes numeric uid/gid only — no name resolution — and
requires real `Number` arguments (a bool or numeric string is refused, so
`chown(p, true, false)` can't silently become uid 1 / gid 0).

```mix
write_new("/tmp/io/f", "x\n", 0o600)
print("before: " .. stat("/tmp/io/f")["perm"])   -- 384  (0o600)
chmod("/tmp/io/f", 0o644)
print("after:  " .. stat("/tmp/io/f")["perm"])   -- 420  (0o644)
```

```text
before: 384
after:  420
```

| octal literal | decimal value (what `perm`/`mode` print) |
|---|---|
| `0o600` | 384 |
| `0o644` | 420 |
| `0o755` | 493 |

## Inspecting metadata — `stat`

`stat(path)` returns a map; `stat(path, {follow_symlinks: false})` is `lstat`
(reports the link itself). Fields (a frozen surface — scripts index into them):

| field | type | meaning |
|---|---|---|
| `uid` `gid` `nlink` `size` | number | owner / group / hard-link count / bytes |
| `mode` | number | full `st_mode` (type bits included) |
| `perm` | number | `mode & 0o7777` — round-trips into `chmod` |
| `ino` `dev` | **string** | inode / device (u64 → text, see semantics) |
| `ctime` `mtime` `atime` | number | epoch **seconds** (f64, whole-second) |
| `ctime_nsec` `mtime_nsec` `atime_nsec` | number | sub-second part, `0..=999999999` (v0.44.0) |
| `is_file` `is_dir` `is_symlink` | bool | type flags |

```mix
write_new("/tmp/io/k", "hello\n", 0o600)
$st = stat("/tmp/io/k")
print("size:  " .. $st["size"])
print("perm:  " .. $st["perm"])
print("ino:   " .. $st["ino"])           -- a STRING
print("file?: " .. ("" .. $st["is_file"]))
```

```text
size:  6
perm:  384
ino:   1909
file?: true
```

### "Did this file change?" needs the timestamp *pair*

Whole seconds cannot answer that question. A rewrite that puts *equal* bytes
back inside the same second is invisible to a content comparison (the bytes
match) **and** to an `mtime` comparison (the second matches) — so a tamper check
built on those two reads it as untouched. This is not hypothetical: a hook
installer was rewriting live git hooks on every run and the check stayed green.

Compare `(mtime, mtime_nsec)` together:

```mix
fn changed($path, $was)
  $now = stat($path)
  return $now["mtime"] != $was["mtime"] or $now["mtime_nsec"] != $was["mtime_nsec"]
end
```

The sub-second part is a **separate** field rather than one combined nanosecond
timestamp on purpose. Mix numbers are `f64`, exact for integers only up to
2^53; a nanosecond epoch passed that about 104 days after 1970, so a combined
value would silently round. Split, both halves are exact.

**The pair narrows the blind spot; it does not close it.** `mtime_nsec` reports
what the filesystem recorded, and timestamp granularity is a filesystem
property, not a guarantee — a filesystem that stores whole seconds reports `0`,
and even one that stores nanoseconds is not obliged to give two writes distinct
values.

Which field catches a change depends on *how* the writer writes:

- A writer that **replaces** the file — new file, then `rename()` over the old
  one, the way any careful writer updates a live file — gets a **new inode**.
  `ino`/`dev` catch that on their own, whether or not the timestamps are
  compared. This is the common case, and a check that compares only timestamps
  is doing less work than it looks like it is.
- A writer that rewrites **in place** keeps the inode, so `ino`/`dev` say
  nothing. If the new bytes differ, `size` or the content still show it. The
  case that needs the timestamps is the byte-identical one — same inode, same
  size, same bytes — where `(mtime, mtime_nsec)` is the field pair a caller
  normally compares, and only helps if the sub-second half is compared too when
  the rewrite lands inside one second. `ctime` moves as well; see below for what
  that is worth.

So compare identity *and* the timestamp pair; neither subsumes the other.

Both are still read-side signals, and neither survives a deliberate adversary:
`mtime` is writable — `utimensat` lets a file's owner set any value it likes,
including the previous one — so a change can be made and its evidence put back.

`ctime` is the exception worth knowing. It records when the *inode* last
changed, and no syscall accepts a caller-supplied value for it: a write marks
it, and the `utimensat` used to put `mtime` back marks it again, so the cover-up
signs itself. That is why "no `stat` field sees a byte-identical in-place
rewrite" would be wrong, and why `(ctime, ctime_nsec)` is worth comparing
alongside the `mtime` pair.

It is not proof, in two separate ways. It is a *timestamp*, so the granularity
paragraph above applies to it unchanged — a coarse filesystem, or two events
inside one tick, record the same pair for both, and an unchanged `ctime` is
therefore evidence of nothing having *been recorded*, not of nothing having
happened. And it is the *inode's* clock rather than the content's, so a `chmod`,
a `chown` or a new hard link moves it with no content change at all, while
anyone who can set the system clock or write the raw device moves it wherever
they like. Read it as tamper-*evidence* at best: a changed `ctime` says
something touched the inode, and does not say what.

A check that has to be sound needs evidence from the write side — the
writer announcing its intent before it writes, or an append-only log. A kernel
watch (`inotify`) is better than polling but is not proof either: it can miss
what happened before the watch was established, and its queue can overflow
(`IN_Q_OVERFLOW`) and drop events. Treat `stat` as the cheap first filter, not
the proof.

### The symlink mixed view

`stat` follows symlinks by default, but `is_symlink` is **always** derived from
`lstat`, so it flags the path itself regardless of the follow mode. When
following, the *other* fields describe the target while `is_symlink` flags the
link — a deliberate mixed view matching how callers reason about links:

```mix
-- /tmp/io/link.txt -> /tmp/io/target.txt (a regular file)
$lnk = stat("/tmp/io/link.txt", {follow_symlinks: false})   -- lstat
print("lstat:  is_symlink=" .. ("" .. $lnk["is_symlink"]) .. " is_file=" .. ("" .. $lnk["is_file"]))

$tgt = stat("/tmp/io/link.txt")                             -- follow
print("follow: is_symlink=" .. ("" .. $tgt["is_symlink"]) .. " is_file=" .. ("" .. $tgt["is_file"]))
```

```text
lstat:  is_symlink=true is_file=false
follow: is_symlink=true is_file=true
```

Use `ino`/`dev` straight as a dedupe-set key for a `walk` that should visit each
real file once — never `to_number()` them:

```mix
$seen = {}
for each $p in walk("/srv")
  $st = stat($p)
  $key = $st["ino"]
  if $seen[$key] == nil then
    $seen[$key] = true
    -- ... process $p once ...
  end
end
```

## File types — `mime_type` (v0.113.0)

```
mime_type(path[, {content}]) -> {mime, by, uncertain, exists, parents}
```

The type the desktop gives a file, from the freedesktop shared-mime-info
database (`globs2`, `magic`, `aliases` and `subclasses` under each
`$XDG_DATA_HOME` and `$XDG_DATA_DIRS` `mime` directory), decided by GLib's
rules, so it agrees with the file manager and `gio info`:

1. a directory or other non-regular file is its `inode/*` type, and an empty
   file is `application/x-zerosize`;
2. one glob match is the answer;
3. otherwise the content decides: a magic rule, or `text/plain` when it reads
   as text;
4. among several glob matches, a magic match of priority 80 or more wins,
   else the first glob type that is a subtype of the sniffed one.

- `by` says which: `stat`, `glob`, `magic`, `content` (empty, or text) or
  `fallback` (`application/octet-stream`).
- `parents` lists every supertype, nearest first, ending `text/plain` for
  `text/*` and `application/octet-stream` for anything but `inode/*`. Ask
  `contains($r.parents, "text/plain")` to know whether a type is text.
- `{content: false}` never opens the file: names only.
- A missing path is typed by its name, with `exists: false`; so is a file the
  script may not read, with `uncertain: true`.
- Symlinks are followed. The database is read once and again whenever one of
  its directories changes, so a newly installed application's types appear
  without a restart.

```mix
$r = mime_type("/etc/hosts")
print($r.mime .. " by " .. $r.by)
if contains($r.parents, "text/plain") then
  print("text")
end
```

One known limit: a database pattern using POSIX bracket classes (such as
`[[:alpha:]]`) is matched with Unicode's character predicates, not the
locale's tables, so a rare character can be typed differently from GIO. The
stock freedesktop database uses only plain ASCII ranges.

Not `xdg-mime query filetype`: outside GNOME and KDE that asks libmagic's
`file`, which names many types differently (a Markdown file is
`text/plain` there and `text/markdown` here).

## Path-string helpers (pure, no filesystem)

These operate purely on the path *string* — they never touch disk, so they work
on a path that doesn't exist.

```
basename(path)    filename component                ("/a/b/x.eml" -> "x.eml")
dirname(path)     directory component               ("/a/b/x.eml" -> "/a/b")
extname(path)     extension WITH the leading dot     ("/a/x.eml"   -> ".eml")
path_join(a, b)   join with the native separator     ("/a", "x")   -> "/a/x"
path_parts(path)  -> {dir, base, stem, ext}          (ext has NO dot)
```

```mix
$p = "/srv/mail/inbox/msg.2.eml"
print("basename: " .. basename($p))
print("dirname:  " .. dirname($p))
print("extname:  " .. extname($p))
print("join:     " .. path_join("/srv/mail", "new.eml"))

$pp = path_parts($p)
print("dir=" .. $pp["dir"] .. " base=" .. $pp["base"] .. " stem=" .. $pp["stem"] .. " ext=" .. $pp["ext"])
```

```text
basename: msg.2.eml
dirname:  /srv/mail/inbox
extname:  .eml
join:     /srv/mail/new.eml
dir=/srv/mail/inbox base=msg.2.eml stem=msg.2 ext=eml
```

> **`extname` keeps the dot, `path_parts.ext` drops it** — different consumers, a
> deliberate split. A path with no extension gives `""` from `extname`, and
> `basename` of a trailing-slash path returns the last real component
> (`basename("/srv/mail/")` → `"mail"`).

## Standard input — `readline`, `read_stdin`, `read_stdin_bytes`

```
readline([prompt])      read one line from stdin (trailing \n stripped); optional prompt
read_stdin()            read ALL of stdin to EOF as one string (strict UTF-8)
read_stdin_bytes([max]) read ALL of stdin to EOF as raw bytes (v0.65.0)
read_stdin_bytes(8192)  read at most 8192 bytes — same cap as read_file_bytes
```

`read_stdin` is the call for a pipe or a hook payload — it slurps the whole stream
to EOF. `readline` reads a single line and, if given a prompt argument, writes it
to stdout (flushed) first — the interactive-input path. Both read UTF-8 *text*
and both **raise** on a stream that will not decode, matching `read_file`.

`read_stdin_bytes` is the binary twin, and it is the only way to read a stream
that is not valid UTF-8:

```
$ printf '\x00\x01\xff\xfe binary' | mix -c 'print(read_stdin())'
Runtime error at line 1: read_stdin: stream did not contain valid UTF-8
$ printf '\x00\x01\xff\xfe binary' | mix -c 'print(bytes_len(read_stdin_bytes()))'
11
```

That is not an edge case for mail: 8-bit transfer encodings and legacy charsets
routinely produce bodies that never decode, which is why `read_stdin` alone left
Mix unable to be a mail filter. The cap is `read_file_bytes`'s — a non-negative
whole number, applied with `Read::take`, strictly a Number (a numeric string or
a bool is refused, not coerced) — so `read_stdin_bytes(8192)` sniffs a header
without slurping a stream of unknown size, and `read_stdin_bytes(0)` reads
nothing. **One deliberate difference:** an explicit `nil` means *no cap* here
(`read_stdin_bytes(nil)` reads everything, so `read_stdin_bytes($limit)` works
when `$limit` may be unset), whereas `read_file_bytes(path, nil)` raises. Nil-as-
absent is the convention `slice` and `bytes_to_string` already use for a trailing
optional argument.

It reads fd 0 with no decode step at all, so nothing can be substituted or lost;
pair it with [`write_stdout`](#byte-exact-output--write_stdout-write_stderr) for
a filter that is byte-exact end to end.

Until 0.33.0 an undecodable stream returned an **empty string** instead. That is
indistinguishable from EOF, and it is silent: a `pre-push` hook reading git's ref
list saw one non-UTF-8 ref name as no refs at all, iterated nothing, and exited 0
having checked nothing. Catch it with `try` if a caller genuinely wants to
continue past undecodable input.

```mix
-- echo 'piped line 1\npiped line 2' | mix script.mix
$in = read_stdin()
print("got " .. length($in) .. " chars")
print("first line: " .. split($in, "\n")[0])
```

Piped in (`printf 'piped input line 1\npiped input line 2\n' | mix -c '…'`):

```text
got 38 chars
first line: piped input line 1
```

For richer stdin handling see [strings](strings.md) (`split`, `trim`) — a common
pattern is `for each $line in split(read_stdin(), "\n")`.

## Byte-exact output — `write_stdout`, `write_stderr`

```
write_stdout(v, ...)   write to fd 1 as it is: no newline, no separator, flushed
write_stderr(v, ...)   the same, to fd 2
print_raw(v, ...)      ALIAS of write_stdout
eprint_raw(v, ...)     ALIAS of write_stderr
```

`print` always appends a newline and joins its arguments with a space, so before
0.65.0 there was no way to write **exactly these bytes and stop**. That is
disqualifying for any program whose stdout *is* the interface — dovecot's sieve
`execute :pipe :output "VAR"` captures stdout verbatim into a sieve variable, and
one trailing newline corrupts the variable parsing.

`printf` is not this. It also omits the trailing newline, but it takes a *format
template* (so `%` is significant and a literal one has to be escaped) and it
formats through a string, so it cannot carry a byte that is not valid UTF-8.
Reach for `printf` to lay out text and `write_stdout` to emit exact bytes.

**One family, one contract, four names.** `print_raw`/`eprint_raw` are aliases of
`write_stdout`/`write_stderr`, not variants: the same builtin under the spelling
that matches whichever family you are thinking in. If they ever behave
differently that is a bug, and a test pins the equality.

The contract is `print`'s, minus the newline and the separator:

- **`bytes` and `buffer` go out verbatim.** This is the one place `print` is
  actively wrong — it renders a bytes value as the `<bytes:N>` placeholder.
- **Every other value renders exactly as `print` renders it**, including a
  list as `[1, 2]` and a map as `{k: v}`. There is no stricter type rule to
  learn. (If you meant those numbers as bytes, that is
  [`bytes_from([...])`](#bytes-as-a-sequence-v0640).)
- **fd 1 and fd 2 as they are** — never a re-opened path. `write_file("/dev/stdout", $s)`
  issues an `openat("/dev/stdout", O_WRONLY|O_CREAT|O_TRUNC)` and therefore dies
  with `Permission denied` whenever fd 1 belongs to another user (a `runuser -u vmail`
  dry run, and every message under sieve). `write_stdout` opens nothing.
- **Flushed on every call.** The default stdout is line-buffered and this family
  exists to emit output with no newline in it, so an unflushed write would sit in
  the buffer past the moment the consumer reads. In a hot loop, build a
  [`buffer`](buffer.md) and write it once rather than writing per iteration.
- **It writes to the same sink `print` writes to.** For the `mix` binary that
  sink *is* the process's fd 1/2, which is the whole point. Inside an **embedder**
  that redirects output — webd rendering a response, a test harness capturing it
  — `write_stdout` goes to that redirected sink like `print` does, not around it
  to the real descriptor. There is deliberately no way to bypass the host's
  output policy; a program that must reach the process descriptor regardless is
  not a Mix script.

### A failed write RAISES — `print` included since 0.99.0

Since 0.99.0 (B9) `print`, `eprint`, `printf` and `eprintf` raise the same
write errors as this family: a dropped write reported as success was the
one failure mode an agent reading rc could not see, on every output path.
The error is catchable and code-distinguished:

| code | meaning |
|---|---|
| `IO_BROKEN_PIPE` | the consumer closed the pipe (`\| head -1`, a reader that exited) |
| `IO_WRITE_FAILED` | anything else — a full disk, a closed descriptor |

The `mix` binary does not change the process SIGPIPE disposition — Rust ignores
SIGPIPE at startup — so a closed pipe arrives as this error rather than as a
signal. That is a property of the *process*, not of the library: an **embedder**
whose host has restored the default SIGPIPE disposition would get signal 13
instead of a catchable error, because nothing in Mix asserts the disposition.

The quiet-exit idiom, when a filter should behave like `head` and stop:

```mix
try
  write_stdout($body)
catch $msg, $err
  if $err.code == "IO_BROKEN_PIPE" then
    exit(0)
  end
  raise($err.code, $msg)
end
```

### The filter, end to end

```mix
-- byte-for-byte: whatever arrives on fd 0 leaves on fd 1 unchanged
write_stdout(read_stdin_bytes())
```

```
$ printf '\x00\x01\xff\xfe mix \xc3\x28' > in.bin
$ mix -c 'write_stdout(read_stdin_bytes())' < in.bin > out.bin
$ cmp in.bin out.bin && echo identical
identical
```

`cmp`, not eyes: a lossy decode substitutes U+FFFD for each undecodable byte and
so *grows* the output — **11 bytes in, 17 out** for the input above, where
`write_stdout(read_stdin_bytes())` gives 11. A visual check will not catch that;
a byte comparison always will, and a digest taken over the 17 bytes is a digest
of a message that never arrived. Dropping a header line
before hashing a body is
[`slice`](#bytes-as-a-sequence-v0640) on the bytes, never a string round trip.

## Bytes helpers

The bytes type (`Value::Bytes`) carries raw 8-bit data without a UTF-8
round-trip. These convert to and from it (they live in the `system` category but
are the natural companions to `read_file_bytes`):

```
bytes_len(b)               byte length of a Value::Bytes buffer
string_to_bytes(s)         UTF-8 encode a string -> bytes
bytes_to_string(b)         decode bytes -> string (strict UTF-8; errors on bad byte)
bytes_to_string(b, {lossy: true})   from_utf8_lossy decode (bad bytes -> U+FFFD)
```

All three are **strict about their argument type** — `string_to_bytes` rejects a
non-string, `bytes_to_string`/`bytes_len` reject a non-bytes — so a `to_mix_string`
placeholder like `<bytes:N>` can never silently leak in. The `lossy` option is a
strict bool too: `{lossy: "true"}` or `{lossy: 1}` raises `TYPE_MISMATCH` rather
than being read as a truthiness.

```mix
$b = string_to_bytes("héllo")
print("byte length: " .. bytes_len($b))     -- 6 (é is 2 UTF-8 bytes)
print("round-trip:  " .. bytes_to_string($b))

-- sniff an ASCII header out of a non-UTF-8 head with the lossy escape hatch
$head = read_file_bytes("/tmp/io/img.bin", 4)
print("lossy: " .. length(bytes_to_string($head, {lossy: true})))
```

```text
byte length: 6
round-trip:  héllo
lossy: 4
```

Note `bytes_len` counts **bytes**, while `length` on a string counts **codepoints**
(`length("héllo")` is 5) — see [strings](strings.md) for the codepoint / byte /
grapheme split.

## bytes as a sequence (v0.64.0)

Before 0.64.0 a `bytes` value was something you could hold but not look
inside: `$b[3]` said *cannot index bytes with number*, `length($b)` said *len()
not supported for bytes*, `slice` refused it, and `for each` refused it. The only
way in was `bytes_to_string({lossy: true})`, which **destroys the very bytes you
are inspecting** — a lossy decode replaces every invalid sequence with U+FFFD, so
a digest taken afterwards is a digest of something that never arrived.

The generic sequence operations now accept `bytes` **and** `buffer`, in
byte units — four since 0.64.0, five more since 0.70.0:

```
$b[i]                      the byte at i as a NUMBER 0-255
length($b) / len($b)       byte count (same answer as bytes_len)
slice($b, start[, end])    a new value-semantic `bytes`
for each $x in $b          iterates NUMBERS 0-255
take($b, n) / drop($b, n)  first n / all-but-first n bytes (negative n from the
                           end, like the list forms) → a new `bytes` (v0.70.0)
reverse($b)                byte-wise → a new `bytes` (v0.70.0)
index_of($b, needle)       0-based BYTE offset, -1 absent — bytes_find's answer
                           through the generic name (v0.70.0)
contains($b, needle)       bool (v0.70.0)
```

`index_of` and `contains` take their needle in the `bytes_*` family's
vocabulary — bytes, buffer, string, or a single byte number 0-255 — and, like
`bytes_find`, **refuse an empty needle** on a bytes/buffer subject (an empty
needle is almost always an empty variable, and a trivially-true answer for it
is a silent wrong answer). ⚠ The vocabulary follows the **subject**, not the
needle: a bytes needle on a *string* subject is stringified to its
`<bytes:N>` placeholder and searched as that literal text — silently absent.
Searching a string for raw bytes means converting one side first
(`string_to_bytes($s)` or `bytes_to_string($needle)`). This is a deliberate divergence from the string
arms, where `contains($s, "")` remains `true`. That is the complete list of
generic operations bytes participates in — anything else (`sort`, `unique`,
`join`, …) still refuses bytes with a clean error.

The boundary rules are the list's, not new ones:

- **Negative indices count from the end.** `$b[-1]` is the last byte;
  `slice($b, -5, -1)` is the last five minus one.
- **An out-of-range `$b[i]` is `nil`, not a raise** — the same answer
  `[1,2][9]`, `"ab"[9]` and `buffer_get` already give. There is no
  bounds-check exception in Mix's indexing to be consistent with.
- **`slice` clamps and never raises**: `slice($b, -100, 100)` is the whole
  value, and a **reversed** range (`slice($b, 4, 1)`) is empty bytes, not a
  wrap-around and not an error.
- **Slicing a `buffer` returns an independent `bytes` snapshot**, never an
  alias — a later `buffer_push` cannot change a slice you already took.
- **`for each` is pinned at entry**: growing a `buffer` inside the loop body
  does not extend that loop, exactly as mutating a list inside `for each`
  does not.
- **Index-assignment is still refused** (`$b[0] = 65` → *cannot index-assign
  into bytes*). `bytes` is value-semantic; the mutable type is `buffer`, and
  the in-place write is [`buffer_set`](buffer.md).

The framing case this was built for — drop a leading header line from a raw
mail body without a lossy round-trip, so the digest is over the bytes that
actually arrived:

```mix
$framed = read_file_bytes("/tmp/io/body.raw")   -- "text:\n" .. raw 8-bit body
$body = slice($framed, 6)
print(bytes_to_hex($body))
```

### The `bytes_*` family

```
bytes_find(b, needle[, from])   0-based offset, -1 absent; `from` is signed+clamped
bytes_starts_with(b, prefix)    bool (an empty prefix is true)
bytes_ends_with(b, suffix)      bool (an empty suffix is true) (v0.70.0)
bytes_split(b, sep)             -> list of bytes
bytes_concat(a, b, ...)         1+ bytes/buffer/string -> one new bytes
bytes_from(list)                int 0-255 / string / bytes / buffer, flat-spliced
bytes_to_hex(b)                 lowercase hex, 2 chars per byte, no separator
bytes_from_hex(s)               strict decode: even length, [0-9a-fA-F] only
```

`needle`, `sep`, `prefix` and `suffix` may each be a `bytes`, a `buffer`, a
**string** (taken as its own UTF-8) or a **single byte number 0-255** — so
`bytes_split($b, 0)` splits on NUL, which no string separator could express.
The **subject** (argument 1) is always strict bytes/buffer.

**Scanning a `buffer` repeatedly? `freeze()` it first.** Every `bytes_*` call
(and every v0.70.0 generic-op arm) on a *buffer* subject snapshots the whole
buffer for the duration of the call — correct (the needle may alias the
subject), and invisible for one-shot calls, but a marching parse
(`bytes_find($buf, $sep, $cursor)` stepping through a large buffer) copies the
entire buffer at every step and turns a linear scan into O(n²) of memcpy.
The same loop over `freeze($buf)`'s value-semantic `bytes` borrows without
copying (measured ~3× on a 4 MB subject with short scans).

`bytes_find` uses `index_of`'s convention, **not** `pos`'s: 0-based, `-1` when
absent. ⚠ A bare `bytes_find(...)` in a condition is therefore backwards on both
branches (`-1` is truthy, `0` is falsy) — `mix lint` flags it as **MIX-W2305**.
Compare explicitly: `bytes_find($b, "\r\n\r\n") >= 0`. The optional `from` is
what keeps a scanning parser linear; the index it returns is **absolute** into
`$b`, never relative to `from`.

`bytes_split` follows `split`'s piece rules exactly — an absent separator yields
one whole piece, a leading or trailing separator yields an empty piece at that
end, and an empty input yields one empty piece. The single divergence is that an
**empty separator raises** rather than splitting between every byte.

`bytes_to_hex` is deliberately option-free (no uppercase flag, no separator) so
that `bytes_from_hex(bytes_to_hex($b))` is an exact round trip and a separated
form — one `join` away — can never be fed back in by mistake. `bytes_from_hex`
accepts either case but refuses whitespace, `:` and `-`: a lenient decoder would
silently accept a truncated digest.

```mix
$b = string_to_bytes("hello, world")
print(bytes_find($b, "world"))              -- 7
print(bytes_find($b, "l", 4))               -- 10 (absolute, not 6)
print(bytes_starts_with($b, "hell"))        -- true
$parts = bytes_split($b, ", ")
print(bytes_to_string($parts[1]))           -- world
print(bytes_to_hex(bytes_from([222, 173, 190, 239])))   -- deadbeef
```

### Naming: `byte_*` vs `bytes_*`

Two families, one letter apart, and they are **not duplicates**:

| prefix | subject | argument handling | members |
|---|---|---|---|
| `byte_*` | a **string** | coerced via `to_mix_string`, like `pos`/`substr` | `byte_length` `byte_pos` `byte_lastpos` `byte_index_of` |
| `bytes_*` | a **`bytes`/`buffer` value** | strict — a wrong type raises | `bytes_len` `bytes_to_string` `bytes_find` `bytes_starts_with` `bytes_ends_with` `bytes_split` `bytes_concat` `bytes_from` `bytes_to_hex` `bytes_from_hex` |

`byte_*` answers *"where is this, measured in UTF-8 bytes rather than
codepoints?"* about **text**. `bytes_*` operates on a **byte sequence**.

The pair that looks most like a duplicate, `byte_length` and `bytes_len`, is the
sharpest example of why the split matters:

```mix
$b = string_to_bytes("héllo")
print(bytes_len($b))        -- 6   the bytes
print(byte_length($b))      -- 9   the length of the string "<bytes:6>"
print(byte_length("héllo")) -- 6   the same 6, but only because the subject is text
```

`byte_length` stringifies its argument first, so on a `bytes` value it measures
the **`<bytes:N>` placeholder**. They agree on strings and disagree on
everything else. Neither is going away — the fleet runs both — and neither
should be "unified".

**Going forward:**

- A new operation on a `bytes`/`buffer` value is `bytes_<verb>` and must
  **reject** a non-bytes subject rather than stringify it.
- A new byte-offset operation on text is `byte_<verb>` and takes a string.
- Conversions use the direction in the name: `bytes_to_<x>` out of bytes,
  `bytes_from_<x>` into bytes. `string_to_bytes` predates that rule (it would be
  `bytes_from_string` today) and stays as-is.
- Never add a `byte_*` that takes bytes, or a `bytes_*` whose subject is text.

## SQLite — `sqlopen`, `sqlexec`, `sqlclose`

The io category includes an embedded SQLite client (feature-gated `sqlite`; the
`mix` binary turns it on). Handles are plain numbers:

The full reference, with modes and examples, is `mix man sqlopen`.

```
sqlopen(path)              open READ-ONLY; the file must exist -> numeric handle
sqlopen(path, "rwc")       open read-write, creating a missing file (WAL mode,
                           5s busy timeout); "rw" is the same mode
sqlexec(h, sql[, params])  run one statement            -> rows or {affected}
sqlclose(h)                close the handle (errors on an unknown handle)
```

`sqlexec` returns a **list of row maps** for anything that produces columns
(`SELECT`, row-returning `PRAGMA`s) and `{affected: n}` for a write — decided by
SQLite itself (`sqlite3_stmt_readonly`), not by keyword-sniffing the SQL, so
`REPLACE` and `WITH … INSERT` take the write path correctly. Column values map
NULL → `nil`, INTEGER/REAL → number, TEXT → string; a BLOB comes back as the
placeholder string `<blob N bytes>`, not bytes.

`params` binds `?` placeholders — a list binds positionally, a single non-list
value is one bind. Binds are **TYPED** (since 0.21): `nil` → NULL, bool →
INTEGER 0/1, whole number → INTEGER, fractional → REAL, string → TEXT, bytes →
BLOB; a list/map param is a loud error. (Before 0.21 every param was bound as
TEXT, so `nil` arrived as the 3-char string `"nil"`.) Always bind data — never
splice values into the SQL string.

```mix
$db = sqlopen("/tmp/io/t.db", "rw")
sqlexec($db, "CREATE TABLE t (id INTEGER, name TEXT, score REAL)")
print("insert: " .. json_encode(sqlexec($db, "INSERT INTO t VALUES (?, ?, ?)", [1, "alice", 2.5])))
print("rows:   " .. json_encode(sqlexec($db, "SELECT * FROM t WHERE id = ?", 1)))
sqlclose($db)
```

```text
insert: {"affected":1}
rows:   [{"id":1,"name":"alice","score":2.5}]
```

A write against a read-only (default) handle raises `attempt to write a readonly
database` — open with `"rw"` when you mean to mutate.

## When to shell out instead

The native builtins cover read/write/list/stat/perms. For an operation Mix has no
builtin for — `cp -a`, `rsync`, `tar`, `mv` across filesystems — call the real
tool via [`run`/`run_rc`](system.md) (both take an optional `{timeout: seconds}`
opts map since 0.21, so a hung copy can't wedge the script), remembering the
PATH is minimal inside `/bin/sh`, so use a full binary path:

```mix
$r = run_rc("/usr/bin/install -m 0755 /tmp/io/a.txt /tmp/io/b.txt")
if $r.rc != 0 then
  print("install failed: " .. $r.stderr)
end
```

## Retained directory roots (0.108.0)

`dir_open(path)` retains a native root directory and returns a numeric handle
owned by the evaluator. `dir_rename(handle, source, destination)` moves a
regular file or directory between relative names beneath that root, atomically
refusing an existing destination. `dir_close(handle)` closes it; evaluator
retirement also closes remaining handles.

```mix
$root = dir_open("/srv/scenes")
try
  dir_rename($root, "panel", ".recovery/panel-backup")
finally
  dir_close($root)
end
```

The destination parent must already exist. Absolute operands, dot or dotdot
components, empty components, backslashes and control characters are refused.
Root and parent resolution follows no symlinks or magic links; parents cannot
cross mounts. A final symlink or special source is refused, including a source
swapped after the initial type check. A parent moved outside the retained root
is denied by the kernel at rename time.

This requires Linux `openat2`, `renameat2` and Landlock ABI 2 with the
[disconnected-directory erratum 3 fix](https://docs.kernel.org/userspace-api/landlock.html#landlock-errata).
Unsupported kernels raise `DIR_UNSUPPORTED`; there is no unrestricted fallback.
Each rename confines a fresh worker thread; it does not confine the evaluator's
other threads. Opening and closing require FsRead; renaming requires FsWrite.

A root replaced at its original pathname before an operation raises
`DIR_ROOT_REPLACED`. That pathname check is not atomic with rename: if the root
itself moves afterwards, containment remains anchored to its retained inode.
The API does not promise that the original pathname remains unchanged, nor
does it confine other path-based filesystem builtins.

Other structured failures are `DIR_INVALID_HANDLE`, `DIR_INVALID_PATH`,
`DIR_OPEN_FAILED` and `DIR_RENAME_FAILED`. Existing destinations are never
overwritten, including dangling symlinks. Handles are local to their evaluator;
closing an unknown or already closed handle raises.

## See also

```
mix builtins io       list every io builtin with its one-line description
mix what NAME         one-line description of a single builtin (e.g. mix what stat)
mix help              the full categorized builtin reference
```

- [strings](strings.md) — codepoint vs byte vs grapheme; `split`, `trim`, `~` expansion
- [numbers](numbers.md) — why `0o755` is the mode value and `0755` is a lex error
- [running-commands](system.md) — `run`/`run_rc`/`run_stream` for shelling out
- [data](data.md) — `json_parse`, `load_data`, `data_encode` for structured files
- [modules](functions.md) — `source` / `include` for loading `.mix` code (vs `load_data` for inert data)
- [capabilities](capabilities.md) — the FsRead/FsWrite gating an embedder can apply to these builtins
- [builtins index](builtins.md) · [mix repo](https://github.com/markc/mixos)
