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, soread_file("~/.mixrc")works because the lexer rewrites~to$HOME, butread_file($p)where$pholds a literal"~/x"does not. - Errors raise, they don't return a sentinel. A failed
read_file/ls/mkdirraises a catchable runtime error (the message names the call and path). Wrap a must-succeed step intry/catch, or pre-check withexists/access/is_file/is_dir. The exceptions are the predicates themselves (exists/access/is_dir/is_filereturn aboolfor ordinary absence or denial) andglob, which returns an empty list for "no match", never an error. - Mode arguments take the VALUE, not the digits.
chmod/write_newwant an octal literal —0o755(= 493),0o600(= 384) — or an octal string ("0755"). A bare755is the decimal number 755, not the mode (see numbers for why0755is a lex error and0o755is the value). That lex error is a Mix-expression hazard only —0755inside a command string handed torun(run("install -m 0755 a b")) is plain text for/bin/shand works fine. stat'sino/devare STRINGS. They areu64and 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'tto_number()them.- Bytes vs strings.
read_file/read_linesrequire valid UTF-8 (they error on a bad byte);read_file_bytescarries the raw buffer throughValue::Bytes.write_file/append_file/write_newwrite aValue::Bytesargument 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)
0o600prints as384because Mix numbers are decimal f64 —0o600is just a source spelling of the value 384.stat'spermfield is that same number; feed it straight back intochmod.
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 todir;max_depth: 0returns only the direct children.include_dirs(bool, defaultfalse) — include directory entries too.follow_symlinks(bool, defaultfalse) — 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.
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.
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 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 |
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/devcatch 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/devsay nothing. If the new bytes differ,sizeor 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.ctimemoves 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:
-- /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
extnamekeeps the dot,path_parts.extdrops it — different consumers, a deliberate split. A path with no extension gives""fromextname, andbasenameof 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:
bytesandbuffergo out verbatim. This is the one placeprintis actively wrong — it renders a bytes value as the<bytes:N>placeholder.- Every other value renders exactly as
printrenders 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 isbytes_from([...]).) - fd 1 and fd 2 as they are — never a re-opened path.
write_file("/dev/stdout", $s)issues anopenat("/dev/stdout", O_WRONLY|O_CREAT|O_TRUNC)and therefore dies withPermission deniedwhenever fd 1 belongs to another user (arunuser -u vmaildry run, and every message under sieve).write_stdoutopens 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
bufferand write it once rather than writing per iteration. - It writes to the same sink
printwrites to. For themixbinary 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_stdoutgoes to that redirected sink likeprintdoes, 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:
| 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:
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 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.
$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]isnil, not a raise — the same answer[1,2][9],"ab"[9]andbuffer_getalready give. There is no bounds-check exception in Mix's indexing to be consistent with. sliceclamps 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
bufferreturns an independentbytessnapshot, never an alias — a laterbuffer_pushcannot change a slice you already took. for eachis pinned at entry: growing abufferinside the loop body does not extend that loop, exactly as mutating a list insidefor eachdoes not.- Index-assignment is still refused (
$b[0] = 65→ cannot index-assign into bytes).bytesis value-semantic; the mutable type isbuffer, and the in-place write isbuffer_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:
| 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:
$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/buffervalue isbytes_<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_bytespredates that rule (it would bebytes_from_stringtoday) and stays as-is. - Never add a
byte_*that takes bytes, or abytes_*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
0o755is the mode value and0755is a lex error - running-commands —
run/run_rc/run_streamfor shelling out - data —
json_parse,load_data,data_encodefor structured files - modules —
source/includefor loading.mixcode (vsload_datafor inert data) - capabilities — the FsRead/FsWrite gating an embedder can apply to these builtins
- builtins index · mix repo