Mix Language Reference
Mix is a scripting language designed for systems work — filesystem operations,
process management, HTTP, structured data handling — with first-class ABP mesh
messaging when running in a cosmix environment. In non-cosmix environments, ABP
operations (send, emit, address, on) raise a clear runtime error; the
rest of the language works identically. The amp_available() builtin lets
scripts detect the environment and branch when needed.
The language is defined by its reference implementation in cosmix-lib-mix.
There is one interpreter, shippable standalone as mix or compiled into cosmix
binaries. This chapter documents what that implementation actually accepts, not
an abstract specification. When this document and the source disagree, the
source wins.
Source of truth: $COSMIX/src/crates/cosmix-lib-mix/src/{lexer,parser,ast,evaluator}.rs.
Doc claims cite file.rs:line for any non-obvious behaviour.
Scope: This chapter covers the Mix language itself — syntax, operators,
control flow, scoping, and patterns. The standard library (~160 builtins as of
mix 0.15.6 — ~150 in the builtins.rs registry plus ~12 HOF helpers) is
documented inline where relevant but a
comprehensive builtin reference is planned as Chapter 04a. (The exact count
churns — the builtins.rs registry + mix builtins are canonical; this
spec deliberately does not pin a number.) ABP-specific
keywords are documented in §ABP below; everything else is available in any
environment.
Quick reference for writing Mix scripts correctly on the first try. Read this before writing new Mix code — especially if you're coming from ARexx, Lua, or shell, because Mix differs from all three in ways that are easy to get wrong.
v0.2.3 shipped — v0.2.x series complete. All six phases of the stdlib-and-ergonomics plan have landed: anonymous functions + HOF list helpers (Phases 1–2),
fmt/printf(Phase 3), recursiveglob/walk/path_parts(Phase 4), terminator unification —endeverywhere (Phase 5), and JSONL helpersread_lines/read_json/read_jsonl(Phase 6). Deferred to v0.3: destructuring assignment, lexical closures converging named-and-anonymous semantics, catchableexit, two-registry HOF unification.
Quick gotcha list
Top-of-doc shortlist. If you only read this section, you'll avoid the common traps:
- Concatenation is
..(not||).||is the statement-chain operator (run-if-failure), same as shell. String concat in expressions uses... - Command-line args are positional:
$1,$2, ... (ARexx/shell style). There is no upper bound —$1through$Nfor as many args as the script was invoked with.args()returns the same args as a list (use it when you needlength()or iteration). onblocks useon event.name(unquoted) and close withend. Example:on dbview.page ... end.onis statement-position only — it registers a global handler and is not legal insidefunctionbodies.send/emit/addressare ordinary statements — they work at script top level, insideonhandlers, and insidefunctionbodies. Reuse via library helpers (e.g.apps/lib/ui.mix) is a style choice, not a requirement. (Earlier doc revisions claimed a top-level restriction; that was never enforced by the parser or evaluator. See evaluator.rs:1074–1088 and call_function at 1644.)- String interpolation does NOT recurse. If
$var = "hello"and$template = "${var}", then"${template}"produces the literal string${var}, nothello. This matters when passing complex strings through heredocs. - Heredoc interpolation expands
${var}eagerly — one pass only.${X}(in both double-strings and heredocs) walks scope → process env →nil; bare$Xoutside${...}stays literal by design. $var.fielddot access in strings walks the full chain:"host: ${cfg.host}"and"timeout: ${cfg.db.opts.timeout}"both resolve identically to their expression-position counterparts ($cfg.host,$cfg.db.opts.timeout). Each step honours theValue::Map*fallback. Mid-chain non-map values yieldnil. (Shipped in Phase 0 of v0.2.0; earlier v0.1.0 revisions only honoured the first dot.)- Comparison operators have asymmetric coercion.
==/!=cross-type-coerce string↔number only via parse-as-f64 (value.rs:221–238), so"5" == 5istruebutfalse == 0isfalse(Bool↔Number is not coerced).</>/<=/>=route throughnum_cmp: if both operands parse as numbers (numbers or numeric strings) they compare numerically; if both are strings they compare lexicographically by codepoint (mix 0.15.6+, so"apple" < "banana"istrueand$ch >= "a" and $ch <= "z"works); otherwise (a non-both-numeric string vs a number, ornil) it errors — sonil > -1is still a runtime error.eq/neare strict string comparisons with no coercion. - Keywords
and/orfor expressions,&&/||for statement chaining. They are NOT interchangeable. - All blocks close with
end(since v0.2.2). Legacydone/nextstill parse with deprecation warnings — removal deferred (still legacy-with-deprecation as of mix 0.15.6). See table below. - Event fields live in
$event["headers"], not top-level$event.$eventhas only three keys:command,headers,body. Value::Mapfield access falls back to the*key.$cfg.hostreturns the map's"*"value if"host"isn't set (evaluator.rs:1547). This is a deliberate defaults pattern — set$cfg.* = "default"and unknown lookups return that. Surprising the first time you see it."","0",0, empty list/map, andnilare all falsy (is_truthy, value.rs:25–35). The canonical "missing config / blank value" guard isif not $x then ... end. Useif is_empty($x) then ... endwhen"0"should count as present (it ignores the"0" => falserule). Don't reach forif $x == ""— it missesnil. Truthiness rules are documented in full in_doc/mix/overview.md§3.1.
Block terminators
v0.2.2: every block closes with end. The legacy done / next
forms for while / loop / for / for each / on still parse
but emit a deprecation warning to stderr. (Removal was originally
targeted at v0.3 but deferred — they still parse as of mix 0.15.6.)
| Block | Terminator |
|---|---|
if...then...end | end |
for...to | end |
for each...in | end |
while | end |
loop | end |
function | end |
try...catch | end |
select...when | end |
address | end |
on | end |
Migration: new scripts should use end universally. Existing scripts
will keep working but should be migrated when next touched. A Mix
script that does the line-level rewrite is in the tree:
mix _bin/mix_migrate_terminators.mix path/to/script.mix [...]
The migration script only rewrites bare done / next lines
(trimmed content equals the keyword) — it won't touch keywords in
strings or mid-line comments. Always run under version control so
you can diff the result.
Lexical elements
Variables
$name— identifier starting with$, then[a-zA-Z_][a-zA-Z0-9_]*$1..$9— positional command-line arguments. Returnnilwhen not provided.$_— result of pipe LHS in pipe expressions$event— only available insideonhandlers, read-only, map withcommand/headers/bodykeys$rc— return code from most recent ABPsendstatement
String literals
Single-quoted '...' — no interpolation. Escapes: \' and \\ only.
$literal = 'This is ${not} interpolated'
Double-quoted "..." — interpolation enabled.
$greeting = "Hello, ${name}!"
$cmd_output = "Today is $(date +%Y-%m-%d)"
Escapes: \n \t \r \e \" \\ \$ (\$ produces literal $, preventing interpolation).
Heredoc <<TAG ... TAG — multiline, interpolation enabled.
$body = <<MD
# Title
Content line with ${variable} interpolated.
MD
Closing tag must be on its own line, exact match. Trailing newline is removed automatically.
Comments
Both # and -- run to end of line. No block comments.
Statement separators (v0.31.0)
Executable Mix statements are separated by a physical newline or ;.
Semicolons are emitted even inside ()/[]/{} and are accepted only where
the nested grammar contains a real statement boundary (for example an
expression-position if or function body); ordinary call arguments, lists,
maps, and parenthesized expressions reject them. Leading, trailing, and repeated
semicolons are empty statements. Comments continue to own the rest of their
physical line. Strict-data does not accept ;: its separators remain newline
and comma. Shell-dispatch lines retain shell command-list semantics, and the
classifier never mixes shell and Mix segments within one line.
; is the loosest executable-Mix boundary: &&/|| bind before it. A Mix
chain operand can be a pipeline, but after | the parser captures raw external
command text through newline, ;, or EOF, so any later &&/|| belongs to
that external tail. Unlike newline, which permits a Mix &&/|| chain to
continue across the physical line boundary, ; is hard: a; && b and
a &&; b are parse errors.
Keywords (case-sensitive)
Control: if then else end for each in to step next while done loop break continue
Functions: function return
Case: select when otherwise
Logic: and or not true false nil
Parse: parse with
ABP: send address emit on
Errors: try catch die
Env: export alias source sh
I/O: print eprint
Operators
Precedence low to high (parser.rs:908-947):
| Prec | Op | Name | Notes |
|---|---|---|---|
| 1 | or | Logical OR | Short-circuit |
| 2 | and | Logical AND | Short-circuit |
| 3 | == != < > <= >= | Comparison | Cross-type coerce |
| 3 | eq ne | String comparison | No coercion |
| 4 | ?? | Nil coalesce | x ?? y — y if x is nil |
| 5 | .. | Concatenation | String concat, both sides coerced |
| 6 | + - | Add / subtract | + also concats if either side isn't numeric |
| 7 | * / % | Multiply / divide / modulo | Numeric only |
| 8 | ** | Power | Right-associative |
Unary: - (negation), not or ! (logical NOT).
Statement-level chain operators (NOT expression operators):
stmt && other— runotheronly if$rc == 0afterstmtstmt || other— runotheronly if$rc != 0afterstmtstmt | external_cmd— pipestmtstdout to external command
These are the operators that caused dbview bugs: || is run-if-failure (like shell), not concat. Use .. for string concatenation.
Statements
Assignment
$var = expression
$obj.field = value
$arr[0] = value
$obj.* = value -- sets all fields of a map
Conditionals
if condition then
body
else if other_cond then
body
else
body
end
select $value
when "a" then
body
when "b" then
body
otherwise
default_body
end
Loops
for $i = 1 to 10 step 2
print $i
next
for each $item in $list
print $item
next
for each $i, $item in $list -- with index
print $i, $item
next
while $condition
body
done
loop
body
break if $done
done
break [label] [if cond] and continue [label] [if cond] support optional conditions and loop labels.
Functions
function name($arg1, $arg2)
body
return $value
end
Expression form:
function double($x) = $x * 2
Parameters can have defaults: function greet($name, $greeting = "Hello").
Anonymous functions (v0.2.0)
function is also valid in expression position with no name, producing
a first-class function value that can be stored in a variable,
passed as an argument, or returned from another function. Both body
forms work:
$double = function($x) = $x * 2
print $double(5) -- 10
$inc = function($x)
return $x + 1
end
print $inc(4) -- 5
type($double) returns "function". Functions compare as never-equal
(identity comparison at the Mix level is deliberately disabled —
it's rarely useful and tends to encourage bugs).
Calling a function value uses the same $var(args) syntax as
calling a named function, but dispatched through the value in scope
rather than a bareword function name:
-- $sort_by is the function VALUE in a variable (whatever that means
-- for your script — maybe built by a factory). Dispatches via scope.
$sort_by($items, $key_fn)
Bareword calls like sort_by($items, $key_fn) still dispatch through
the builtin / user-function tables as before.
Closure semantics (v0.2.0)
Two-tier rule:
Top-level lambdas (constructed at script top level) see globals live at call time. Mutating a global after capture is visible to the lambda:
$t = 5
$gt = function($x) = $x > $t
$t = 100
print $gt(50) -- false (uses current $t, not 5)
Inner-frame lambdas (constructed inside a named function) see a
frozen snapshot of the enclosing function's frame. This is
capture-by-value, taken at the moment the function(...) expression
evaluates. It's how closures over a caller's parameters work:
function make_adder($n)
return function($x) = $x + $n -- $n captured by value
end
$plus40 = make_adder(40)
print $plus40(2) -- 42 (even though make_adder has returned)
The divergence between "top-level = live" and "inner-frame = frozen" is acknowledged and narrower than the v0.1.0 state (where inner lambdas saw nothing useful at all). v0.3 will converge both named and anonymous functions on a single rule; until then, inner-frame lambdas are capture-by-value and cannot observe later mutations to the captured frame.
Named functions continue to see globals live and cannot capture
enclosing function locals — the captures slot is only populated
for anonymous function(...) expressions.
Event handlers (on)
on noded.ping
print "received:", $event["body"]
done
on dbview.page
$page = to_number($event["page"])
-- handle page change
done
-- Class C handler: yields at every `send` so concurrent callers
-- interleave through a slow downstream `send` (SPEC 18 §3.7 Phase 2).
on slow.echo async
$body = json_parse($event["body"])
$r = send "slowsvc" slow.sleep ms=$body["ms"]
reply rc=0 body=$r["body"]
done
- No quotes around the event name. Dotted identifiers work:
dbview.page,noded.ping. - Closes with
done, notend. $eventis a map with exactly three keys:$event["command"]→ the full command string (e.g."dbview.sort")$event["headers"]→ map of ABP headers. All user-defined event fields live here (e.g.$event["headers"]["column"])$event["body"]→ raw body string (if any)
- Event fields are in
headers, not top-level. A common mistake:$event["column"]isnil. The correct access is$event["headers"]["column"], or destructure first:$h = $event["headers"]then$h["column"]. - Default handlers are Class S (sequential). Without the
asyncmodifier, handlers run to completion before the next event is dispatched. Asendinside a Class S handler blocks the dispatch loop until the reply arrives; concurrent callers serialise behind it (head-of-line blocking). This preserves the run-to-completion atomicity every pre-Phase-2 Mix script (sysmon.mix, the CMM scheduler scripts) was authored under. asyncmodifier (SPEC 18 §3.7 Phase 2, since mix 0.2.x). A handler declaredon <cmd> async ... doneis Class C: it yields the dispatch reader at everysend,reply, andsleep_msawait point, letting another invocation of the same citizen (typically a different caller's event chain) interleave through. The handler is never re-entered while it is suspended — a Class C invocation's frame is its own; the yield is at the scheduler boundary, not inside the handler's local state. Synchronous request cycles (A→B→A across two single-threaded citizens) remain prohibited by design (SPEC 18 §3.7 deadlock corollary; the deadlock is silent — the broker does not detect cycles).asyncis a contextual identifier at handler-header position, not a global reserved word — existing scripts that useasyncas a variable or function name ($async = 1,async()) are unaffected.- Place after the optional filter clauses if present (Ch05 §7.3
inline
on ui.event from "X" action "Y" asyncshape). - When to use: pick
asyncwhen a handler issues asendto a downstream that may be slow AND the citizen is registered (request-addressable, structurally concurrently-callable). Leave it off for pure-local-state handlers, fast handlers, or unregistered transient ABP clients (cron/oneshot/CLI invocations — they have no concurrent callers to head-of-line-block; see SPEC 18 §3.7 sole-caller carve-out).
- Per-
sendtimeout=<sec>kwarg (SPEC 18 Phase 2 WS4). Anysendmay carry atimeout=<sec>kwarg (fractional seconds permitted). On timeout thesendwrites the ARexx-shaped result vars and returnsnilto its expression position:$rc = "-1"(string — same convention as other transport failures; not a newrc="timeout"namespace)$result = "timeout: send to <target> exceeded <sec>s"Cancellation is cooperative: the citizen does not abort the in-flight downstream request, it just stops waiting. The pending-reply slot is freed immediately so the caller's frame doesn't leak — a late downstream reply arrives at the broker with no matching correlation id and is dropped.timeout=is scoped to a singlesend; there is no handler-wide timeout.
on slow.echo.timed async send "slowsvc" slow.sleep ms="2000" timeout=0.5 if to_number($rc) != 0 then reply(to_number($rc), "timed out: " .. to_string($result)) else reply(0, $result) end done
ABP messaging (send, emit, address)
-- Statement form
send "target" command.name arg1 arg2 key=value
-- Expression form: assign result
$result = send "noded" noded.ping
-- Fire-and-forget (no $rc)
emit "display" ui.window id="main" body=$content
-- Multiple sends to same target
address "display"
ui.status target="main" body="Ready"
ui.data target="list" body=$json
end
send/emit/address are ordinary statements and work in any statement position, including inside function bodies. Wrapping them in helpers is a style choice for reuse, not a workaround for a parser restriction:
-- This is fine — define and call a helper directly.
function window($id, $title, $body)
emit "display" ui.window id=$id title=$title body=$body
end
window("main", "My Window", $body)
Library files in apps/lib/ exist to share these helpers across scripts via source, not because top-level is required.
Two real constraints to remember:
onIS statement-position only — it registers a global event handler and is not legal inside afunctionbody. Define handlers at script top level.- Closures do not capture enclosing function frames (v0.1.0). A function defined inside another function sees globals + its own params only — not the outer function's locals. v0.2.0 will likely add capture-by-value at construction time for inner-frame lambdas; named functions may keep live-binding lookup. Plan tracks this.
Error handling
try
$result = risky_op()
catch $err
print "failed:", $err
end
die "fatal: " .. $reason
Parse statement
parse "host:port" with $host ":" $port
Captures $host = "host" and $port = "port" using literal delimiters.
Source, sh, export, alias
source "path/to/lib.mix" -- run another mix file in current scope
sh "ls -la /tmp" -- statement form, inherits stdio
$output = sh "ls -la /tmp" -- expression form, captures stdout
export PATH = "/opt/cosmix/bin:${PATH}"
alias ll = "ls -la"
Expressions
Literals
- Numbers:
42,3.14,1_000_000(underscores as separators), always f64 - Strings:
'single',"double",<<HEREDOC...HEREDOC - Booleans:
true,false - Nil:
nil - Lists:
[1, 2, 3],["a", "b"]— trailing comma allowed - Maps:
{host: "localhost", port: 8080}— keys are bare identifiers or strings, colon separator
Function calls
length($list)
sqlexec($db, $sql, [$id])
$list.length() -- method form, desugars to length($list)
func()[0] -- postfix chaining
Field / index access
$config.host
$config["host"]
$list[0]
$list[$i + 1]
$list[-1] -- negative indices count from the end (v0.2.0)
$s[-1] -- same for strings (unicode-safe, returns a char)
Out-of-bounds index returns nil silently — index expressions never
raise. Use length($xs) if you need to check bounds explicitly.
Send / sh in expression position
$info = send "noded" noded.ping
$files = sh "ls /tmp"
Command substitution in strings
"today is $(date +%F)"
$result = $(whoami)
Balanced parens are tracked, so $(echo (foo)) works.
String interpolation details
Variable expansion in double-quoted strings and heredocs:
${name}— variable lookup: scope first, then process env, then literalnil. The three-step chain is the same for both the sync and async evaluator arms (evaluator.rsExpr::InterpolatedStringconsumers) and fires for every head shape, not just uppercase env-var-shaped names. Thenilsentinel is preserved when neither scope nor env has the name — keeping the loud typo signal intact for the genuine-missing case. Heredoc${X}walks the same path because the lexer's heredoc producer (lex_heredoc) emits the sameToken::InterpStringconsumed by both evaluator arms.${cfg.host}— single-level dot access for maps. The head (cfg) walks the same scope → env → nil chain as a bare${cfg}; the dotted suffix then walksValue::Mapfields. When env-fallback resolves the head to aString, the next dot step has no map to descend into and yieldsnilfor the rest of the chain (same "non-map intermediate → nil" rule that applies inside scope-bound maps).${cfg.db.opts.timeout}— chained dot access; walks the full path. Each step looks the field up against the currentValue::Map, falling back to the*key if present. A non-map intermediate value yieldsnilfor the rest of the chain. Resolves identically to expression-position$cfg.db.opts.timeout.$(cmd)— command substitution\$— literal$(prevents interpolation)- Bare
$Xoutside${...}is literal by design —"$5","$cwd","$path"all print verbatim. The env-fallback chain applies only to the${...}form, so existing scripts that print awk-style$5or shell-doc-style$cwdare unaffected.
No recursive expansion. This is the trap:
$var = "hello"
$template = "${var} world"
$result = "${template}" -- prints "${var} world", not "hello world"
Interpolation happens once at expression evaluation time. Once a string is stored in a variable, its content is literal text — even if that text contains ${...}, it won't expand when re-interpolated.
Practical implication: when building markdown bodies with JSON inside, prefer building the full string with .. concatenation over nesting variables in heredocs. The heredoc form works if all variables are simple scalars, but it's easy to forget one case and produce malformed output.
Truthiness
- Falsy:
nil,false,"",0,"0", empty list[], empty map{} - Truthy: everything else
Command-line arguments
-- Positional (ARexx/shell style)
$cmd = $1
$arg = $2
if $1 == nil then
print "usage: script <cmd>"
exit(1)
end
-- Or as a list
$argv = args()
print length($argv), "arguments"
args() skips the binary name and script path — it returns just the user-provided args.
No cap on positional indexes. $1 through $N work for any N the script was invoked with. The lexer accepts $<digits> (lexer.rs:595–619) and main.rs:64 binds every script arg as (i+1).to_string(). Out-of-range positions return nil, not an error (evaluator.rs:1192–1195).
Common patterns
Building markdown for a widget with JSON content
Use .. concatenation when the value contains ${}-like characters (JSON):
$col_defs = '[{"key":"name","label":"Name"}]'
$dt = "~~~datatable id=data page_size=" .. $page_size .. " total_rows=" .. $total
$body = "# " .. $title .. "\n\n" .. $dt .. "\ncolumns: " .. $col_defs .. "\n~~~"
Loading a library
-- Resolve absolute path so scripts work from any CWD
$_cos = env("COSMIX_SRC")
if $_cos == "" then
$_cos = env("HOME") .. "/.cosmix"
end
source "${_cos}/apps/lib/ui.mix"
Event loop keep-alive
-- At end of a GUI script, sleep forever; on handlers still fire
sleep(86400)
Functional list helpers (v0.2.0)
All 12 higher-order helpers take a function value as their last argument. Lambdas are the idiomatic source:
-- Transform
$doubled = map($xs, function($x) = $x * 2)
$odds = filter($xs, function($x) = $x % 2 == 1)
$sum = reduce($xs, 0, function($acc, $x) = $acc + $x)
-- Sort. Key function returns the sort key (number or string).
-- Descending: negate a numeric key, or build a reversed string.
$by_score_desc = sort_by($users, function($u) = 0 - $u.score)
-- Top-N: chain sort_by with slicing
$top10 = take(sort_by($users, function($u) = 0 - $u.score), 10)
-- Folds
print any($xs, function($x) = $x > 100)
print all($xs, function($x) = $x > 0)
print count($xs, function($x) = $x % 2 == 0)
-- _by family returns the ITEM, not the key
$worst = min_by($users, function($u) = $u.score)
$total = sum_by($users, function($u) = $u.score)
-- Grouping / dedup
$by_role = group_by($users, function($u) = $u.role)
$by_email = unique_by($users, function($u) = $u.email)
Pure list helpers (no function argument):
slice($xs, 1, 4) -- sublist [1, 4) — end exclusive
slice($xs, -3, nil) -- last three — nil end means "to end"
take($xs, 5) -- first 5 (or last 5 with take($xs, -5))
drop($xs, 5) -- skip first 5 (or drop last 5 with drop($xs, -5))
zip($keys, $vals) -- list of [k, v] pairs
slice, take, drop all clamp on out-of-range boundaries rather
than erroring — slice($xs, -100, 100) on a 3-element list returns
the whole list. slice also works on strings (unicode-safe).
Reading structured files (v0.2.3)
Three helpers avoid the read_file + parse pattern scripts were
writing by hand:
-- Line-oriented text: trailing newline stripped, empty trailing
-- line dropped so callers don't filter it out.
$lines = read_lines("/var/log/auth.log")
print length($lines), "lines"
-- Single-record JSON file → Mix value directly
$cfg = read_json("/etc/cosmix/config.json")
print $cfg.listen
-- JSON-lines (jsonl): one record per line → list
-- Strict by default: a single malformed line aborts the read.
$events = read_jsonl("/srv/mail/msg/alice/.spamlite-stats.jsonl")
print length($events)
-- Lenient mode: silently skip malformed lines. Use when log rotation
-- can leave a truncated tail and you'd rather lose one record than
-- fail the whole aggregation.
$clean = read_jsonl("/srv/rotating/events.jsonl", {skip_errors: true})
Strict-by-default for read_jsonl is deliberate: the canonical use
case is aggregating stats across dozens of files, and silently
dropping bad lines would make aggregate numbers unreliable without
telling the script author. Lenient mode is the explicit escape
hatch for known-noisy inputs.
Filesystem ergonomics (v0.2.1)
Recursive glob, walk, and path_parts:
-- Single-component (unchanged from v0.1.0)
glob("*.txt") -- files in CWD
glob("/var/log/*.gz") -- one wildcard component
-- Multi-component and recursive (v0.2.1)
glob("/srv/*/msg/*/.spamlite-stats.jsonl") -- * in any component
glob("src/**/*.rs") -- ** = zero-or-more dirs
-- Enumerate every file under a tree
walk("/srv/mail") -- files only, sorted
walk("/srv/mail", {max_depth: 2}) -- cap depth (0 = direct children)
walk("/srv/mail", {include_dirs: true}) -- add dirs to the list
walk("/srv/mail", {follow_symlinks: true}) -- with loop protection
-- Decompose a path
path_parts("/home/user/report.tar.gz")
-- → {dir: "/home/user", base: "report.tar.gz", stem: "report.tar", ext: "gz"}
glob rules:
*and?work in any path component, not just the last.**matches zero or more directory levels (think shell globstar).- Leading
/→ absolute pattern; otherwise relative to CWD. - Results are sorted lexicographically and
./foonormalizes tofoo. - Nonexistent intermediate components silently return an empty list.
walk rules:
- Returns a flat list of paths, sorted lexicographically.
- Unreadable subdirectories are skipped silently — a single bad file
in a deep tree doesn't abort the walk. The top-level
$direrrors only if it doesn't exist. max_depth: 0means "direct children only, don't descend."None(omit the key) means unlimited.- Symlink loop protection only activates with
follow_symlinks: true; uses(dev, inode)tracking on Unix.
path_parts rules:
- Pure, no filesystem access. Works on nonexistent paths.
extis WITHOUT the leading dot.extnamekeeps the dot — they're different consumers (extfor comparison,extnamefor reconstruction).stemis everything before the last dot:path_parts("foo.tar.gz").stemreturns"foo.tar", not"foo".
Formatted output (v0.2.0)
Three functions share the same minimal format grammar:
fmt("hello %s", "world") -- returns "hello world"
printf("count=%d\n", 42) -- writes to stdout, returns nil
eprintf("error: %s\n", $msg) -- writes to stderr, returns nil
| Spec | Meaning |
|---|---|
%s | any value via to_mix_string |
%d | integer (truncates floats) |
%f | float, default 6 decimals |
%.Nf | float, N decimals |
%Nd | integer, min-width N (right-aligned) |
%Ns | string, min-width N (right-aligned) |
%-Ns | string, min-width N (left-aligned) |
%% | literal % |
No {}-style templates — Mix already has ${...} interpolation;
a second template syntax would confuse scripts. No %x/%o/%e/%g
— add them if a real script needs them.
Unknown specifiers and too-few-args errors raise RuntimeError
rather than substituting silently, so typos surface loudly. printf
does not add a trailing newline — include \n explicitly, like C.
The canonical tabular-output pattern:
for each $u in $users
printf("%-12s %5d %5d\n", $u.name, $u.fn, $u.fp)
next
.. concatenation already coerces every value through to_mix_string
(so "count=" .. 5 works), which means printf is sugar for
readability rather than an escape from a broken behaviour. Prefer
printf for tabular output where alignment matters; prefer ${}
interpolation or .. concat for simple string building.
Differences from ARexx / Lua / shell
From ARexx
- Concat is
.., not||.||is the run-if-failure chain operator. - Variables need
$prefix. Bare identifiers are function names or keywords. - Strings don't auto-concatenate:
"a" "b"is a parse error; use"a" .. "b".
From Lua
- Maps use
{key: value}with colon (not=). - No metatables, no
localscope (all vars in current function frame). endcloses every block (since v0.2.2). Legacydone/nextstill parse with deprecation warnings; removal deferred (still legacy-with-deprecation as of mix 0.15.6).
From shell
$varin expressions is OK;${var}only works inside strings.- No glob expansion:
*.txtis literal. Useglob("*.txt"). - No word splitting:
"a b c"is one string, not three. Usesplit(x, " "). &&/||/|are statement-level only.- Errors don't abort the script — use
try/catchordie.
Mix Outside Cosmix
Mix works as a standalone shell and scripting language without the cosmix stack.
The interpreter detects at startup whether an ABP broker (cosmix-noded) is
available (via COSMIX_NODED env var or local socket probe). The detection
result is stored in the interpreter context and exposed via amp_available().
Environment detection
if amp_available() then
send "maild" "account.list"
else
print "No ABP broker — reading from file"
$accounts = read_file("/tmp/accounts.json")
end
ABP keyword behaviour without a broker
| Keyword | Behaviour | Error message |
|---|---|---|
send | Runtime error | send: no ABP broker available |
emit | Runtime error | emit: no ABP broker available |
address | Runtime error | address: no ABP broker available |
on | Runtime error | on: no ABP broker available |
Scripts that don't use ABP keywords run identically in both environments. The All builtins (filesystem, HTTP, JSON, regex, crypto, process management) are available without a broker.
The ARexx precedent
ARexx was the Amiga's scripting language but also worked as a general-purpose language — standalone REXX scripts that did nothing Amiga-specific ran fine. The ARexx port system was the standout feature, but the language existed in both contexts. Mix follows the same model: a useful shell on its own, with mesh messaging as a capability that activates when a broker is present.
Distribution
The mix binary is a single statically-linked Rust executable. It can be
distributed independently of the cosmix stack. The ABP wire-format library
(cosmix-lib-bus) is linked but dormant without a broker connection. Binary size
overhead for the ABP code is minimal (~1000 lines of Rust).
Known sharp edges
- Heredoc + JSON /
${...}content: if the value contains literal${...}sequences that should not expand, prefer explicit..concat. Heredocs interpolate eagerly and there's no opt-out per-segment. - Comparison asymmetry:
==cross-type-coerces (so"5" == 5istrue), but</>error on coerce failure (sonil > -1raises a runtime error rather than returningfalse). This is value.rs:79 vs evaluator.rs:2022 — two code paths, two rules. If you might comparenil, guard with??or an explicitif $x == nil. - Mid-chain non-map in interpolation yields nil:
${cfg.host.foo}returns nil if$cfg.hostis a string, not an error. Same rule as expression-position field access. The chain walks as far as it can onValue::Map, then short-circuits tonil. Value::Mapfalls back to*:$cfg.unknown_keyreturns the value of$cfg.*if set. Convenient as a defaults pattern, surprising if you forgot you set*.exit(N)is not catchable:builtin_exit(builtins.rs:745) callsstd::process::exitdirectly. It bypassestry/catch, skips Rust-side cleanup, and kills the host process if Mix is embedded. Usedie "msg"(catchable, raisesMixError::DieError) when you want intercept-able termination. v0.3 will convertexitto a catchable error variant.- Named functions vs anonymous lambdas capture differently (v0.2.0): Named
function foo(...)definitions still see globals + their own params only — they do not capture an enclosing function's locals. Anonymousfunction(...)expressions built inside a named function capture that frame by value (frozen snapshot). Top-level$f = function(...)sees globals live — no capture needed. The divergence is deliberate for v0.2.0 and is acknowledged in the plan's Q2. v0.3 will converge both forms on a single lexical-closure rule. args()vs$N: both work, but$1is idiomatic for command scripts.args()is better when you needlength()or iteration.
See also
$COSMIX/src/crates/cosmix-lib-mix/src/parser.rs— authoritative grammar$COSMIX/src/crates/cosmix-lib-mix/src/builtins.rs— all builtins (future Ch 04a)apps/sshm.mix— complete example of a Mix GUI appapps/lib/ui.mix— library ofpanel/data/statushelpersmix/dbview— SQLite viewer using the datatable features_doc/2026-03-29-mix-language-plan.md— original design doc (historical)_doc/2026-04-11-mix-structured-pipelines.md— planned pipeline syntax (historical)