Strings
Mix has two string literal forms, .. for concatenation, and a deliberately
codepoint-based core with explicit byte and grapheme twins. This page covers
the lexer rules (what interpolates, what stays literal, the escapes) and the
string-category builtins. Every example below was run on Mix
0.21.2; the output shown is real.
Mental model:
"double"interpolates${...}and expands a leading~;'single'is fully raw;..joins;length/pos/substrcount Unicode codepoints, withbyte_*andgrapheme_*twins when you need the other units.
The two literal forms
$name = "world"
print("hello ${name}") -- ${...} interpolates
print("hello $name") -- bare $name is LITERAL (unlike bash!)
print('raw ${name} $(date)') -- single-quote: nothing interpolates
hello world
hello $name
raw ${name} $(date)
The split mirrors bash's quoting, with one trap that bites everyone:
| Form | Interpolates ${x} | Bare $x | $(...) | leading ~ | Escapes |
|---|---|---|---|---|---|
"double" | yes (scope → env) | literal text | literal text | expands to $HOME | \n \t \r \e \" \\ \$ \~ \u{…} |
'single' | no | literal | literal | literal | only \' and \\ |
Only ${...} interpolates inside double quotes. A bare $name is the literal
three-or-more characters $name — the opposite of bash, and the single most common
error an agent makes here. When unsure, prefer .. concat (below) over
interpolation.
$(...) and $((...)) are literal text inside a Mix string — the old
build-time command-substitution footgun was removed. To splice a command's output,
use run/run_rc plus ... (Two exceptions still substitute: a
heredoc body, and the standalone $(cmd) expression — neither is a
plain string literal.)
Know the two contexts. The literal rule above is about Mix string literals. In
a run/run_rc/ssh_run command string, a literal $(...) passes through to
the target shell — so it substitutes in the /bin/sh that actually runs the
command: local for run/run_rc, the remote shell for ssh_run:
print(run("echo $(echo passthru)")) -- /bin/sh does the substitution
passthru
And on a shell-dispatch line (mix -c's shell branch, the login shell), $(...)
is command substitution — see shell mode.
Interpolation walks scope, then env
${X} looks up the name in the local scope first, then the process environment:
print("val=${MYVAR}")
# run as: MYVAR=fromenv mix -c '...'
val=fromenv
A name unbound in both scope and env is a runtime error — the same as
reading a bare $X, so a typo is caught loudly rather than silently splicing
text:
print("[${NOPE_NOT_SET}]")
Runtime error: undefined variable '$NOPE_NOT_SET' in interpolation (use ${NOPE_NOT_SET ?? default} for a fallback)
A variable whose value is nil is bound, so it still renders the literal
nil ($x = nil → ${x} → nil); likewise a missing map key in a dotted path
(${a.b} → nil), matching $a.b.
Defaults — ${x ?? default} and ${x ?: default}
Supply a fallback with the same coalescing operators Mix uses in expressions. The default is any Mix expression, evaluated only when the fallback fires:
print("[${NOPE ?? "none"}]") -- unbound → the default (no error)
$x = nil
print("[${x ?? $home}]") -- nil value → default may be a variable…
print("[${x ?? upper("hi")}]") -- …or any expression
[none]
[/home/you]
[HI]
??fires on nil only (an unbound name or a nil value).${e ?? "x"}where$eis""keeps the empty string —??does not treat it as missing.?:fires on any falsy value (nil,"",0,false,[]) — use it when you want bash's${x:-…}"empty also falls back" behaviour.- Limitation: a default cannot contain a literal
}(the scanner ends at the first}) — bind it to a variable first.
For an explicit, unambiguous environment read use env("X") (see
builtins); for scope-only data prefer .. concat.
Concatenation is ..
Join with .. — never + (that's numeric addition, see numbers) and
never . (that's map/field access). Non-string operands are stringified:
$x = 42
print("answer is " .. $x .. "!")
answer is 42!
.. is the most robust way to build strings — it sidesteps the bare-$name
interpolation trap entirely:
$user = "ada"
print("hi " .. $user) -- always works
print("hi $user") -- literal "hi $user" — usually NOT what you meant
hi ada
hi $user
Escapes and \u{XXXX}
Double-quoted strings honour \n \t \r, \e (ESC, \x1b), \" \\ \$ \~, and the
braced unicode escape \u{XXXX} (1–6 hex digits):
print("tab\there")
print("price: \$5") -- \$ for a literal dollar
print("\u{2764} love") -- U+2764 HEAVY BLACK HEART
print("strip BOM:[\u{FEFF}]") -- zero-width no-break space
tab here
price: $5
❤ love
strip BOM:[]
\u{…} is braced-only: a bare \u with no { stays literal, so a Windows path
"C:\users" or an embedded JSON \uXXXX is unchanged. Single-quoted strings keep
\u{…} literal too. A surrogate or out-of-range codepoint is a loud lex error
(\u{D800} is not a valid unicode codepoint), never a silent pass-through.
Any unrecognised escape keeps the backslash literally: "\d" is the two
characters \d, not an error. Strip a BOM with the real codepoint:
replace($s, "\u{FEFF}", "").
Leading ~ expansion
A ~ at the very start of a double-quoted string expands to $HOME at
runtime, and only when the ~ is followed by / or ends the string — i.e.
exactly "~" and "~/...". Mid-string ~ is always literal (so DNS-zone tokens
like "~example.com" survive), and '~/...' in single quotes does not expand:
print("~/.config")
/home/user/.config
Use "\~" for a literal leading tilde. ~user is not supported — "~root/x"
stays the literal text ~root/x (no expansion, no error); only the running
user's home expands.
Trap — tilde strings as text, not paths. The expansion is lexical, so it
fires on any double-quoted literal, including one you meant as a search needle.
replace($s, "~/.gh/x", "~/new/x") looks for the literal /home/you/.gh/x and
silently matches nothing against file text that says ~/.gh/x — no error, no
change. The same applies to contains, starts_with, split, index, and to
"~" .. "/x" (the bare "~" already expanded). For literal-tilde text use single
quotes, and grep_lines the result after a rewrite:
$s = "see ~/.gh/x"
print(replace($s, "~/.gh/x", "Z")) -- needle became /home/you/.gh/x: no match
print(replace($s, '~/.gh/x', "Z")) -- single quotes: literal needle
see ~/.gh/x
see Z
Heredocs
<<TAG ... TAG is a multi-line literal whose body does interpolate ${...}
(scope → env, like double quotes) and, in this context, $(...) command
substitution:
$who = "team"
$body = <<END
Dear ${who},
welcome.
END
print($body)
Dear team,
welcome.
The rules, precisely:
- The tag is ASCII letters/digits/
_. The closing tag must sit alone on its line (surrounding whitespace is allowed); the newline before it is stripped. - A bare
$namein the body stays literal, exactly as in double quotes — only${...}interpolates. $(cmd)runs via/bin/shand splices its stdout — the one string context where Mix itself substitutes a command.- Escapes
\n \t \r \e \\ \$work (\$suppresses interpolation:\${not_a_var});\u{…}is NOT processed in a heredoc body — it stays literal. Any other\xstays literal too.
Codepoint, byte, grapheme — pick the right unit
This is the design decision to internalise. The core string ops — length/len,
pos, lastpos, the string overload of index_of — count Unicode codepoints,
so they compose with substr/reverse/$s[i] (also codepoint-based). When you
need raw bytes or user-perceived characters, reach for the explicit twin family.
(The pre-0.8.0 byte value of length lives on as byte_length.)
print(length("café")) -- codepoints: c a f é
print(byte_length("café")) -- raw UTF-8 bytes (é is 2)
print(grapheme_count("café")) -- user-perceived chars
print(display_width("日本語")) -- terminal cells (CJK = 2 each)
print(pos("é", "café")) -- 1-based codepoint position
4
5
4
6
4
length/len on a list or map is the element count, not a string length.
The three boundaries, in one table:
| Want | Builtins | Unit |
|---|---|---|
| codepoints (default) | length/len, pos, lastpos, index_of, substr, reverse, left, right | Unicode scalar values |
| raw bytes | byte_length, byte_pos, byte_lastpos, byte_index_of | UTF-8 bytes |
| user-perceived chars | grapheme_count, grapheme_substr, grapheme_reverse | grapheme clusters (UAX #29) |
| terminal columns | display_width, lpad_w, rpad_w, word_wrap_w | display cells (UAX #11) |
All four rows are operations on text. Operations on a raw bytes/buffer
value are a different family, bytes_* — see
io. The two are easy to confuse and are not
interchangeable: byte_length($some_bytes) stringifies its argument first and
so measures the <bytes:N> placeholder, not the bytes.
A plain substr/reverse is codepoint-based and splits an emoji ZWJ sequence or
combining cluster; the grapheme_* ops keep it whole:
print(reverse("a👍🏽b")) -- codepoint reverse splits the skin-tone modifier
print(grapheme_reverse("a👍🏽b")) -- grapheme reverse keeps it whole
b🏽👍a
b👍🏽a
A decomposed e + combining acute is 2 codepoints but 1 grapheme and 1 display cell:
$d = "e\u{0301}" -- e + U+0301 COMBINING ACUTE
print(length($d))
print(grapheme_count($d))
print(display_width($d))
2
1
1
The spread gets dramatic with ZWJ sequences — a 👨👩👧 family emoji is 1 grapheme, 5 codepoints, 18 bytes:
$f = "👨👩👧"
print(grapheme_count($f))
print(length($f))
print(byte_length($f))
1
5
18
Indexing bases differ — a footgun
substr, array indexing, listindex_of,sliceare 0-based. (left/righttake a count, not an index —left("hello", 2)→"he"— so they have no base.)posandlastposare 1-based, returning 0 when not found.- An empty needle gives
pos == 1.
print(substr("hello world", 0, 5)) -- 0-based, length 5
print(substr("hello world", 6)) -- no len → to the end
print(pos("world", "hello world")) -- 1-based
print(pos("xyz", "hello world")) -- not found → 0
print(lastpos("o", "hello world")) -- 1-based last
hello
world
7
0
8
substr clamps a start past the end to empty and never panics on an oversized
length. The byte twins keep their codepoint counterparts' base conventions:
byte_pos/byte_lastpos are 1-based with 0 = not found (like pos/lastpos),
while byte_index_of follows the index_of convention (0-based, -1 when not
found). Watch the arg order: index_of/byte_index_of take
(haystack, needle), pos/byte_pos take (needle, haystack).
Case, search, edit
print(upper("hello"))
print(lower("HELLO"))
print(contains("hello", "ell"))
print(starts_with("hello", "he"))
print(ends_with("hello", "lo"))
print(replace("aaa", "a", "b")) -- replaces ALL occurrences
HELLO
hello
true
true
true
bbb
contains also works on a list (membership, not substring):
print(contains([1,2,3], 2))
true
Match ops are byte-exact —
contains/starts_with/ends_with/split/join/replace/templatedo no Unicode normalization and no case-folding. A precomposedé(U+00E9) will not match a decomposede+◌́. Normalize first if you need NFC-insensitive matching.
upper/lower do full Unicode case mapping (upper("café") → "CAFÉ"); the
byte-exact rule is about matching, not casing. For word extraction there are the
ARexx-flavoured words($s) (count whitespace-delimited words) and word($s, n)
(Nth word, 1-based); for pattern matching see regex.
Ordering and equality
The comparison operators < > <= >= pick their mode from both operands:
- If both coerce to numbers — real numbers, or numeric strings like
"5"— the comparison is numeric. So"5" < "10"istrue(not a bash-style string surprise). - Otherwise, if both are strings, the comparison is lexicographic by codepoint:
"apple" < "banana","Z" < "a"(uppercase sorts before lowercase), and the classic range test$ch >= "a" and $ch <= "z"all work."5" < "abc"is a string-vs-string compare (lexicographic →true). - A number against a non-numeric string is a runtime error, not a silent coercion.
print("5" < "10") -- both numeric strings → numeric compare
print("apple" < "banana")
print("Z" < "a") -- codepoint order: U+005A < U+0061
true
true
true
print(5 < "abc")
Runtime error at line 1: cannot compare 'abc' as number
==/!= never error: a number and a numeric string compare equal by value
("5" == 5 is true), but two strings compare as text ("5" == "05" is
false, "abc" == 5 is false). Full operator detail in
operators.
Split, join, slice
split defaults to a space delimiter; join to a space separator:
$parts = split("a,b,c", ",")
print(join($parts, " | "))
print(length(split("one two three"))) -- default delim = space
a | b | c
3
For substring-by-position use substr/grapheme_substr; for a sublist use slice
(0-based, half-open [start, end), see builtins):
print(join(slice([10,20,30,40], 1, 3), ","))
20,30
The delimiter family — before/after/split_once (0.63.0)
The helpers a script reaches for on line 3, replacing every
substr($s, pos(..) ± n) computation. All subject-first. Contract:
absent delimiter → nil (never "" and never the whole string — "" is
a real result, the delimiter at the edge); empty delimiter raises.
print(after("key=value", "=")) -- value
print(before("key=value", "=")) -- key
print(after_last("/a/b/base.txt", "/")) -- base.txt (basename)
print(after_last("base.txt", ".")) -- txt (extension)
$kv = split_once("k=v=w", "=") -- [head, tail] at the FIRST =
print($kv[0] .. " / " .. $kv[1]) -- k / v=w (rsplit_once: LAST)
print(between("a <b> c", "<", ">")) -- b
print(after("no-equals", "=") or "dflt")-- dflt (nil-coalesce a default)
before_last mirrors before at the last occurrence. Absence is nil,
so if after($s, "=") == nil is the "not found" test — an empty capture
and a missing delimiter never blur.
Prefix/suffix forms return the subject unchanged when there is
nothing to strip ("nothing to do" is an answer, not a failure):
strip_prefix(s, p), strip_suffix(s, x), replace_first(s, old, new)
(first occurrence only; empty old mirrors replace()), and
count_of(s, needle) (non-overlapping; 0 for an empty needle).
Lines, fields, chars (0.63.0 — lines/chars were prelude
functions, now native): lines(s) splits on \n, strips one trailing
\r per line (CRLF-safe) and drops exactly one trailing empty element
(the final newline) — lines("a\nb\n") is ["a", "b"], lines("") is
[], "a\n\n" keeps its real empty line. fields(s) splits on
whitespace runs like awk (no empties; word(s, n) is the 1-based
single-field form). chars(s) yields codepoints as 1-char strings.
last_index_of(s, v) is the 0-based codepoint twin of lastpos() (args
reversed, -1 when absent — same condition warning as index_of).
Trim, pad, repeat, reverse
trim/strip take an optional charset — a set of codepoints to
strip (PHP-style), honoured since 0.63.0 (before that the second argument
was silently ignored — the call "worked" and did nothing). One-sided:
ltrim/rtrim, same optional charset:
print(trim("xxhelloxx", "x")) -- hello
print(ltrim("zyxabczyx", "xyz")) -- abczyx
print(rtrim("zyxabczyx", "xyz")) -- zyxabc
print("[" .. trim(" hi ") .. "]") -- strip is an alias for trim
print("[" .. lpad("7", 4) .. "]") -- right-align in width 4
print("[" .. rpad("7", 4) .. "]") -- left-align in width 4
print(repeat("ab", 3))
print(reverse("hello"))
[hi]
[ 7]
[7 ]
ababab
olleh
lpad/rpad pad by codepoint count, which misaligns a column of CJK/emoji (a
wide glyph counts as 1 there but renders as 2 cells). The %Ns width of
fmt/printf pads by codepoints too (fmt("%6s", "日本") adds
four spaces, not two). Use lpad_w/rpad_w to pad by display cells:
print("[" .. rpad("日本", 6) .. "]") -- codepoint pad: 2 chars → +4 spaces
print("[" .. rpad_w("日本", 6) .. "]") -- cell pad: width 4 → +2 spaces
[日本 ]
[日本 ]
Padding with something other than a space
All four padders take an optional third argument: the fill character (v0.54.0). It defaults to a space, so existing two-argument calls are unchanged.
print(lpad("7", 4, "0")) -- zero-pad a number-as-text
print(rpad("Name", 10, ".")) -- dot leader
print(lpad_w("日本", 6, ".")) -- fill by display cells
0007
Name......
..日本
The fill must be exactly one character, and for the _w variants exactly one
display cell — a wide fill would overshoot the target by a cell per pad
character, which defeats the point of padding by width. Both raise a runtime
error naming the offending value rather than silently mis-aligning:
lpad: fill must be exactly one character, got "ab" (2 chars)
lpad_w: fill must be exactly 1 display cell wide, got '漢' (2 cells)
Padding is saturating — content already at or beyond the width is returned unchanged, never truncated.
To zero-pad a number, prefer fmt's %0Nd, which keeps the sign to the left
of the zeros (fmt("%05d", -42) → -0042). Reach for lpad(s, n, "0") when the
value is already a string, since %0Ns is undefined for strings in C and is
ignored here.
repeat/lpad/rpad cap a result at 256 MiB and raise a normal runtime error
rather than OOM on a hostile count/width. Since 0.59.0 the cap bounds the
bytes the padding would build — a multibyte fill counts at its UTF-8 width
and the original string is included in the sum — so the error fires before the
allocation it names, not after a 4-byte fill has built four times the
advertised limit. (For a width within the cap, a string already at or past that width passes
through unchanged, whatever its size — the cap meters what padding
constructs, not what you already had. A width beyond the cap errors before
anything is examined.)
Every numeric position/count/width argument in this page uses Mix numeric
coercion: a number or numeric string works (left("abcdef", "2") is ab, as
is left("abcdef", 2)). A supplied value that cannot be parsed as a number now
raises TYPE_MISMATCH, naming the builtin, 1-based argument position, value,
and type. It never silently becomes zero or the omitted default. This applies
to left, right, substr, grapheme_substr, repeat, lpad, rpad,
lpad_w, rpad_w, word, word_wrap, and word_wrap_w:
print(substr("abcdef", "2x"))
TYPE_MISMATCH: substr(): argument 2 must be a number, got "2x" (string)
sprintf — C-compatible formatting (v0.71.0)
fmt() is the Mix-native formatter for everyday alignment; sprintf()
exists for the byte-exact cases: generating fixtures, protocol fields, or
log lines that a C/Rust program will compare byte-for-byte.
sprintf(fmt, ...args) -> string
- Conversions:
%d %i %u %o %x %X %f %F %e %E %g %G %s %c %%. - Flags
- + 0 #and space; width and precision, both taking*(from the next argument; a negative*width left-justifies, a negative*precision is treated as omitted — C's rules). - Length modifiers (
hh h l ll L q j z t) are parsed and ignored: Mix numbers are one type, and the integer conversions behave as the 64-bit (ll) forms —sprintf("%u", -1)is18446744073709551615, the 64-bit two's-complement, and a C reference must use%lluto match. - Float output matches glibc
printfbyte-for-byte (fixture-tested:%.0fof2.5is2and of3.5is4— both round the exact binary value ties-to-even;%gpicks its style from the rounded value, so%.3gof999999.5is1e+06).inf/nancome out as glibc prints them: lowercase for%f %e %g, upper for%F %E %G, space-padded even under the0flag. - Integer conversions require a whole number in the exact-integer
range (±2⁵³−1 — Mix numbers are f64, so a positive value C could print
as a full u64 is unreachable above that); anything else raises rather
than truncating. Unknown conversions and missing arguments raise too,
and POSIX positional arguments (
%1$d) are not supported — the$surfaces as an unknown-conversion error.
Two documented divergences, both about Unicode: %s pads by codepoints
(C pads by bytes — identical for the ASCII output of numeric conversions,
which is what the parity claim covers), and %.Ns truncates to at most N
bytes without splitting a codepoint, so the result is always valid
UTF-8. %c takes a Unicode scalar value, not a C int cast to a byte.
print(sprintf("%08.3f|%+d|%#llx", 3.14159, 42, 255)) -- 0003.142|+42|0xff
print(sprintf("%e", 12345.6789)) -- 1.234568e+04
print(sprintf("%.*f", 2, 2.5)) -- 2.50
Templates and wrapping
template substitutes single-brace {key} placeholders from a map (distinct from
${...} interpolation, and from the %s/%d of fmt/printf):
$m = { name: "Ada", role: "dev" }
print(template("{name} is a {role}", $m))
Ada is a dev
template is single-pass: a substituted value is emitted verbatim and never
rescanned, so untrusted data containing {other_key} cannot inject a second
substitution (template("{a}", {a: "{b}", b: "X"}) → {b}), and the output does
not depend on map iteration order. A placeholder with no matching key stays
literal (template("{missing}", {}) → {missing}).
word_wrap greedily wraps to a codepoint budget (word_wrap_w to a display-cell
budget):
print(word_wrap("the quick brown fox jumps", 10))
the quick
brown fox
jumps
Quoting for shells and SQL
When a string must cross into a shell command or a SQL literal, escape it natively rather than hand-rolling quotes:
print(shell_quote("it's a test")) -- safe single-quote wrap for POSIX sh
print(sql_quote("O'Brien")) -- doubles ' (MySQL/MariaDB-safe)
'it'\''s a test'
O''Brien
sql_quote doubles ' and escapes \ (sql_quote("a\\b") → a\\b) and
strips NUL bytes — MySQL/MariaDB-safe. It is also safe for SQLite, where a
literal backslash arrives doubled; for exact bytes use sqlexec parameter binds
instead of quoting (see system) — binds are typed (nil → NULL,
whole number → INTEGER, string → TEXT, bytes → BLOB), so the value never passes
through a quoted literal at all.
sanitize makes untrusted bytes safe for a one-line diagnostic — collapsing line
breaks to spaces and replacing C0/C1 controls and Trojan-Source bidi/zero-width
characters with ?:
print(sanitize("line1\nline2\ttab"))
line1 line2?tab
For HTML output use html_escape; for Bus/SSE framing see Bus messaging
and the Datastar ds_* builtins. To embed a value as
re-parseable Mix source, use data_encode (see builtins).
Mail headers — rfc2047_decode / rfc2047_encode (0.67.0)
A mail header may only carry ASCII, so anything else travels as RFC 2047
encoded-words: =?charset?B-or-Q?data?=. A Subject: read raw comes back
as =?utf-8?B?Q2Fmw6k=?=, which is exactly the unreadable output a report
exists to prevent.
rfc2047_decode(header) -> plain string
rfc2047_encode(text[, {encoding}]) -> header value ("B" default, or "Q")
print(rfc2047_decode("=?utf-8?B?SGVsbG8gV29ybGQ=?="))
print(rfc2047_decode("=?ISO-8859-1?Q?caf=E9?="))
print(rfc2047_decode("Re: =?utf-8?B?dGVzdA==?= (fwd)"))
print(rfc2047_encode("plain ascii subject"))
print(rfc2047_encode("café"))
Hello World
café
Re: test (fwd)
plain ascii subject
=?UTF-8?B?Y2Fmw6k=?=
Decoding. The charset token is honoured — utf-8, us-ascii and
iso-8859-1 (with the usual aliases, and RFC 2231's *language suffix
stripped) decode properly; any other charset falls back to UTF-8 with U+FFFD
substitution, so a koi8-r header is legible-ish rather than exact. Adjacent
encoded-words are joined at the byte level and the whitespace between them
is dropped, per RFC 2047 §6.2 — which is not cosmetic: a long non-ASCII subject
is folded by splitting it into several words, and a multi-byte character
straddling that split only survives if the bytes are joined before decoding.
Anything that is not a well-formed encoded-word is passed through literally,
never dropped; a visible =?utf-8?X?…?= beats a silently lost subject line.
Encoding. Plain ASCII is returned unchanged (§5 permits an encoded-word
only where one is needed), unless it contains a literal =?, which would
otherwise be misread by the receiving decoder. Output is always UTF-8 — the
right answer for modern mail, and not a choice worth offering. B (base64) is
the default; {encoding: "Q"} is more readable when the text is mostly ASCII.
Each emitted word stays within §2's 75-character limit and splits on
character boundaries, because an encoded-word must be independently
decodable — a 4-byte emoji may never straddle two words.
This pair was promoted from a deployed nospam report script, with three changes the original could not justify:
- it decoded every charset as UTF-8-lossy — right for the one mailbox it served, wrong in general;
- it joined adjacent words as decoded strings rather than bytes, so a character split across the fold became two replacement characters;
- on a malformed word it either emitted the raw payload with the wrapper stripped (presenting undecoded bytes as though they had been decoded) or abandoned the whole scan, leaving every later word in the header encoded.
Everything the original got right is unchanged, including the false-terminator handling that is the reason this was promoted rather than rewritten. All three changes are pinned by tests.
Gotchas recap
"double"interpolates only${...}; a bare$nameis literal text.'single'is fully raw — nothing interpolates,~does not expand.$(...)is literal inside a Mix string (userun/run_rc+..to splice output) — but it does substitute in a heredoc body, as a standalone expression, and it passes through to the target shell in arun/ssh_runcommand string.- Concatenate with
.., never+or.. length/pos/substr/reverseare codepoints; usebyte_*for bytes,grapheme_*for emoji/combining,display_width/*_wfor terminal columns.length/lenon a list/map is the element count.pos/lastpos/byte_pos/byte_lastposare 1-based, 0 = not found;substr/arrays/index_of/byte_index_of/sliceare 0-based (index_ofreturns-1when not found).replacereplaces all occurrences; match ops are byte-exact (no case-fold, no normalization).</>compare numerically when both sides coerce to numbers ("5" < "10"istrue), lexicographically by codepoint when both are strings; a number vs a non-numeric string is a runtime error.lpad/rpad/%Nspad by codepoints —lpad_w/rpad_wfor cell-exact columns.${X}unbound in scope and env is a runtime error (like a bare$X); a nil value still rendersnil. Supply a fallback with${X ?? default}(nil-only) or${X ?: default}(any falsy).
See also
- numbers — the f64 numeric type,
+/%/**, radix literals - operators —
..concat,==/<ordering,??nil-coalesce - regex —
re_match/re_find/re_replace/re_split/grep_lines - syntax — the lexer, comments, and the shell/Mix classifier
- functions — lambdas and HOFs for transforming string lists
- builtins index — the full string/byte/grapheme/
_wbuiltin set - running commands —
run/run_rcfor splicing command output - shell mode — where
$(...)is command substitution - Bus messaging —
send/emit,ds_*SSE framing,html_escape mix help— list all builtins ·mix what NAME— describe one (e.g.mix what grapheme_substr)