Language
Standard Library
Zornux ships a set of built-in functions for everyday work — text, lists, math, dates, files, and JSON. They're always available (no imports) and strictly typed.
How built-ins work
- Call them by name, like any function:
uppercase("hi"). - Strict types, no coercion. Passing the wrong kind of value is an error, not a silent conversion — convert first with
text(...). - Values aren't mutated. List helpers like
add_itemreturn a new list rather than changing the original. - Friendly errors. Misuse raises a clear diagnostic in the
ZX1000–1099range (wrong argument count, wrong type, index out of range, …).
Because Number + Text never coerces, text(value) turns any value into Text so you can join it: show "Score: " + text(42). It converts the kind but keeps the trust level — untrusted input stays untrusted (see Trust-aware text).
Asking what a value is
type_of gives back a descriptor — a Zornux
value kind, never a host-language type — which you can compare like any
other value:
show type_of("hi") # <type Text>
show type_of(p) # <class Person>
show type_of(p) is equal to Person # true
show type_of("a") is equal to type_of("b") # true — same kind
show type_of(1) is equal to type_of("x") # false
Text
| Function | Result |
|---|---|
text(value) | Convert any value to Text. |
length(text) | Number of characters (also works on lists). |
is_empty(text) | Truth — whether it has no characters (also works on lists). |
uppercase(text) / lowercase(text) | Change case. |
trim(text) | Remove leading and trailing whitespace. |
trim_start(text) / trim_end(text) | Remove whitespace from only the leading / trailing side (same whitespace set as trim). |
contains(text, part) | Truth — whether part appears in text. |
starts_with(text, part) / ends_with(text, part) | Truth — prefix / suffix test. |
replace(text, find, with) | Replace every occurrence of find. |
split(text, separator) | Split into a List of Text. |
create name = " Ada Lovelace "
show uppercase(trim(name)) # ADA LOVELACE
show contains(name, "Ada") # true
create parts = split("a,b,c", ",") # ["a", "b", "c"]
show length(parts) # 3
Regular expressions
Six built-ins bring safe pattern matching. A pattern is compiled
once from trusted text with regex(...), then reused; every
operation runs under a finite match timeout, so a pathological
pattern is a clean diagnostic rather than a hang. Untrusted text may be the
subject of a match, but never the pattern — closing off ReDoS.
| Function | Result |
|---|---|
regex(pattern) | Compile trusted Text into a reusable Regex value. Case-sensitive, Unicode-aware (\w \d \s). An invalid or untrusted pattern is refused. |
regex_matches(pattern, text) | Truth — whether the pattern matches anywhere. Anchor with ^…$ for a whole-string check. text may be untrusted. |
regex_find(pattern, text) | The first matched substring, or nothing. A match of untrusted text stays untrusted. |
regex_find_all(pattern, text) | A List of every non-overlapping match, in order (empty when none). |
regex_replace(pattern, text, replacement) | A new text with every match replaced by the literal replacement ($1 is inserted verbatim, not expanded). |
regex_split(pattern, text) | A List of the pieces between matches. |
create digits = regex("[0-9]+") # compiled once, reused
show regex_matches(digits, "order 12") # true
show regex_find_all(digits, "a12b3") # [12, 3]
show regex_replace(digits, "a12b3", "#") # a#b#
# The subject may be untrusted; the pattern must be trusted:
create raw = read_text("input.txt") # UntrustedText
show regex_matches(regex("^[a-z]+$"), raw) # ok — untrusted subject
regex(...) only accepts trusted Text as the pattern — an untrusted pattern is refused, because a caller-supplied regex is a denial-of-service risk. The subject you search may be untrusted. A bad pattern raises ZX1050; one that backtracks past its timeout raises ZX1051 — never a hang.
Lists
List positions are zero-based — the first item is at index 0.
| Function | Result |
|---|---|
add_item(list, value) | A new list with value appended. |
remove_at(list, index) | A new list with the item at index removed. |
item_at(list, index) | The item at index. |
join(list, separator) | Join a list of Text into one Text. |
sort_by(list, selector) | A new list ordered by the key a selector gives back for each item — the structured-data counterpart of sort. Stable (equal keys keep input order); the source list is unchanged. |
length(list) / is_empty(list) | Count of items / whether there are none. |
create fruits = ["apple", "banana"]
create more = add_item(fruits, "cherry") # ["apple", "banana", "cherry"]
show item_at(more, 0) # apple
show join(more, ", ") # apple, banana, cherry
create people = [{"name": "Bo", "age": 30}, {"name": "Al", "age": 25}]
create by_age = sort_by(people, function with p give back p["age"] end)
show by_age[0]["name"] # Al (youngest first, stable)
add_item and remove_at don't change the original list — capture the result: create more = add_item(fruits, "cherry"). An out-of-range index raises a clear diagnostic.
Maps
A map holds Text keys mapped to any value. Literals use { … }; read and write entries with [ … ]. Maps are mutable and keep insertion order.
| Function | Result |
|---|---|
map_keys(map) | A List of the keys, in insertion order. |
map_values(map) | A List of the values, in insertion order. |
map_has(map, key) | Truth — whether the key is present. |
map_remove(map, key) | Removes the key in place; returns the map. |
create user = {"name": "Alice", "age": 30}
show user["name"] # Alice
user["age"] = 31 # update
user["email"] = "a@b.com" # add
for each key in map_keys(user)
show key + ": " + text(user[key])
end
show to_json(user) # {"name":"Alice","age":31,"email":"a@b.com"}
Reading a missing key returns nothing rather than failing — use map_has(map, key) to test membership. Keys must be Text, and a duplicate key in a literal is a diagnostic. Maps are distinct from parsed JSON objects and OOP items.
Math
| Function | Result |
|---|---|
round(number) | Nearest whole number (ties round away from zero). |
floor(number) / ceiling(number) | Round down / up to a whole number. |
truncate(number) | Drop the fractional part, toward zero. |
is_whole(number) / is_decimal(number) | Whether the number has no / some fractional part. |
absolute(number) | Magnitude, without sign. |
min(a, b) / max(a, b) | The smaller / larger of two numbers. |
random_between(low, high) | A random whole number in the inclusive range low … high. |
show round(3.7) # 4
show floor(3.7) # 3
show max(10, 25) # 25
show random_between(1, 6) # a dice roll, 1 to 6
Date & time
| Function | Result |
|---|---|
now() | The current date and time. |
today() | The current date (no time). |
format_date(date, pattern) | Format a date/time as Text using a pattern. |
show format_date(today(), "yyyy-MM-dd") # e.g. 2026-06-28
show format_date(now(), "HH:mm:ss") # e.g. 09:05:03
Use patterns like yyyy (year), MM (month), dd (day), HH:mm:ss (time). An unknown pattern raises a diagnostic suggesting a valid one.
Files
| Function | Result |
|---|---|
read_text(path) | File contents as UntrustedText (it came from outside). |
write_text(path, content) | Write content to a file. |
file_exists(path) | Truth — whether the file is there. |
if file_exists("notes.txt")
create raw = read_text("notes.txt") # UntrustedText
show sanitize(raw) # clean it before trusting
end
write_text("greeting.txt", "Hello!")
File built-ins only touch a single folder the host allows, and path traversal (../) outside it is rejected. If the host enables no folder, file access is disabled entirely. Because read_text returns UntrustedText, you must sanitize or validate it before combining it with trusted text.
JSON
| Function | Result |
|---|---|
parse_json(text) | Parse JSON text into a value you can read fields from (a JSON object becomes a map). |
to_json(value) | Serialize a value to JSON Text. |
to_canonical_json(value) | Deterministic canonical JSON — object keys sorted by code point at every level, 2-space indent, one trailing newline. The same logical value always produces the same bytes, for hashing, signing, and reproducible manifests. |
create data = parse_json("{ \"name\": \"Ada\", \"age\": 36 }")
show data.name # Ada
show to_json(["a", "b", "c"]) # ["a","b","c"]
# Stable bytes for a signature or content hash:
show sha256(to_canonical_json(data))
Environment & streams
For command-line tools, Zornux reads environment variables and the standard
streams. Interactive input from the operator is trusted Text;
the write built-ins accept any value (like text(...)).
| Function | Result |
|---|---|
environment(name) | A variable's value as Text, or nothing if unset. |
environment_or(name, fallback) | The value, or the fallback text when unset. |
read_line() / read_line(prompt) | One line from input (optionally after a prompt); nothing at end of input. |
write(value) / write_line(value) | Write to standard output, without / with a newline. |
write_error(value) / write_error_line(value) | Write to standard error, without / with a newline. |
function main with args
create mode = environment_or("APP_MODE", "dev")
create name = read_line("Your name: ")
write_line("Hello " + name + " (" + mode + ")")
write_error_line("done")
give back 0
end
show and write_line go to standard output; write_error goes to standard error — so zornux run app.zx > out.txt 2> errors.log keeps them separate. zornux test feeds empty input, so read_line() never blocks a test.
Time & timers
Read the clock and pause without touching threads. wait is the
friendly timer; sleep is for finer precision. Timing runs
through an injectable clock and delay, so tests stay instant and
deterministic.
| Function | Result |
|---|---|
wait(seconds) | Pause for a number of seconds (fractions allowed). |
sleep(milliseconds) | Pause for a number of milliseconds. |
current_time() | The current time of day (HH:mm:ss). |
current_date() | Today's date (yyyy-MM-dd). |
current_datetime() | The current date and time. |
elapsed_time(start) | Seconds elapsed since a date/time captured earlier. |
create start = current_datetime()
wait(1)
show "Elapsed: " + text(elapsed_time(start)) + " seconds"
These timers share the same injectable clock as Zornux's structured concurrency — background tasks, wait for, and message channels — so concurrent programs stay just as testable.
Trust-aware text
Five built-ins manage the boundary between outside input and trusted
Text. They're the foundation of Zornux's security model —
read_text and network input arrive as UntrustedText,
and these decide when (and whether) it becomes trusted.
| Function | Result |
|---|---|
untrusted(text) | Mark a value as UntrustedText. |
sanitize(text) | Strip dangerous characters and return trusted Text. |
sanitize_lines(text) | Like sanitize, but keeps newlines and tabs so multi-line input keeps its structure — for file bodies, request bodies, and other multi-line untrusted text. |
validate(text) | Truth — whether the value is already clean. |
trust(text) | Advanced override: trust a value without cleaning it. |
text(...) converts kinds (a Number to Text), but it preserves taint — untrusted input passed through text() stays UntrustedText. To cross the trust boundary you must go through sanitize, sanitize_lines, or trust; the compiler points you there rather than letting text() smuggle unchecked data into a trusted sink.
Full details, including the ZX1205 rule, are on the Security page.
Next: Zornux's take on objects — Classes.