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.)

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 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 literal0o755 (= 493), 0o600 (= 384) — or an octal string ("0755"). A bare 755 is the decimal number 755, not the mode (see numbers 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 write a Value::Bytes argument verbatim and stringify anything else.

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)
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"))
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:

try
  read_file("/tmp/does-not-exist-xyz")
catch $e
  print("caught: " .. ("" .. $e))
end
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:

-- 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))
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 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_file("/tmp/io/log.txt", "first\n")
append_file("/tmp/io/log.txt", "second\n")
print(read_file("/tmp/io/log.txt"))
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.

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
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.

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)
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)
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.

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 sudoed 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).

symlink("nowhere", "/tmp/io/dangling")
print("" .. exists("/tmp/io/dangling"))                            -- false
print("" .. exists("/tmp/io/dangling", {follow_symlinks: false}))  -- true
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.)

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)
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.

for each $e in ls("/tmp/io")
  print($e)
end
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 /.

-- 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
/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).

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.

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
--- 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.

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")
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.

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
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.

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
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
funlock(path)                            release this process's lock

flock opens the file read/write, creating it with mode 0644 (subject to the process umask), then takes a kernel advisory lock with flock(2). 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.

$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})
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, 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.

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")
copied + cleaned

Atomic replace — rename

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

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.

$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))
#!/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.

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.

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.

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
../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

Native syscalls — don't run("chmod …"). Both follow symlinks (the mode/owner is applied to the link's target, standard shell semantics). 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).

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)
before: 384
after:  420
octal literaldecimal value (what perm/mode print)
0o600384
0o644420
0o755493

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):

fieldtypemeaning
uid gid nlink sizenumberowner / group / hard-link count / bytes
modenumberfull st_mode (type bits included)
permnumbermode & 0o7777 — round-trips into chmod
ino devstringinode / device (u64 → text, see semantics)
ctime mtime atimenumberepoch seconds (f64, whole-second)
ctime_nsec mtime_nsec atime_nsecnumbersub-second part, 0..=999999999 (v0.44.0)
is_file is_dir is_symlinkbooltype flags
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"]))
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:

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.

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:

-- /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"]))
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:

$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

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)
$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"])
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 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.

-- 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 '…'):

got 38 chars
first line: piped input line 1

For richer stdin handling see strings (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([...]).)
  • 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 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 — the one difference from print

print and printf discard write errors. This family must not: its whole contract is that the named bytes reached the consumer, so a dropped write reported as success is the single failure mode it cannot have. The error is catchable and code-distinguished:

codemeaning
IO_BROKEN_PIPEthe consumer closed the pipe (| head -1, a reader that exited)
IO_WRITE_FAILEDanything 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:

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

-- 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 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 typestring_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.

$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})))
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 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] = 65cannot index-assign into bytes). bytes is value-semantic; the mutable type is buffer, and the in-place write is buffer_set.

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:

$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.

$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:

prefixsubjectargument handlingmembers
byte_*a stringcoerced via to_mix_string, like pos/substrbyte_length byte_pos byte_lastpos byte_index_of
bytes_*a bytes/buffer valuestrict — a wrong type raisesbytes_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:

$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:

sqlopen(path)              open READ-ONLY               -> numeric handle
sqlopen(path, "rw")        open read-write (WAL mode, 5s busy timeout)
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 PRAGMAs) 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.

$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)
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 (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:

$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

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 — codepoint vs byte vs grapheme; split, trim, ~ expansion
  • numbers — why 0o755 is the mode value and 0755 is a lex error
  • running-commandsrun/run_rc/run_stream for shelling out
  • datajson_parse, load_data, data_encode for structured files
  • modulessource / include for loading .mix code (vs load_data for inert data)
  • capabilities — the FsRead/FsWrite gating an embedder can apply to these builtins
  • builtins index · mix repo