SCUA

Manual

Built-in functions

These functions are always available, with no import. They cover printing, conversions, collections, numbers, data tables (frames), results, and coroutines. Anything more specialized lives in a module you bring in with import (see Modules at the end).

A note on truthiness, since several of these depend on it: only nil and false are falsey. Everything else, including 0 and the empty string, is truthy.

#Core

#print

print(...values)

Write the values to stdout, separated by spaces, followed by a newline. For output without the newline — a progress bar or a status line that updates in place — use sys.write instead; both go to stdout, so they interleave exactly. For a diagnostic that must stay out of stdout, use sys.write_err or a log statement — those go to stderr.

print("hp", 30, "/", 40)
$ scua print.scua
hp 30 / 40

#type

type(v) -> string

The runtime type name of a value: "nil", "bool", "int", "float", "string", "array", "table", "fn", "vec3", and so on.

print(type(42), type(3.5), type("x"), type([1]), type({a=1}), type(nil))
$ scua type.scua
int float string array table nil

#tostring

tostring(v) -> string

The value rendered as text, the same way print renders it.

print(tostring(42), tostring(3.5), tostring(true))
$ scua tostring.scua
42 3.5 true

#tonumber

tonumber(s) -> int | float | nil

Parse a string as a number and return nil if it isn't one. It is radix-aware: 0x, 0b, and 0o prefixes work. A whole number parses to an int, one with a decimal point to a float. Passing a number returns it unchanged. (For an exact decimal instead of a float, use decimal.parse. For validating a string that has to be a whole number — no trimming, no radix guess, no float fallback, and a reason when it refuses — use int.parse.)

print(tonumber("0xff"), tonumber("3.14"), tonumber("nope"))
$ scua tonumber.scua
255 3.14 nil

#assert

assert(cond, msg?)

If cond is truthy, return it. Otherwise raise a fault carrying msg (or "assertion failed" if you give none). The fault is catchable with try/rescue or pcall. See Errors and faults.

try
  assert(1 + 1 == 3, "math broke")
rescue e
  print(`caught: {e}`)
end
$ scua assert.scua
caught: math broke

#assert_eq / assert_ne / assert_near

assert_eq(got, want)
assert_ne(a, b)
assert_near(got, want, eps)

The comparison assertions, mainly for tests. assert_eq faults unless got equals want (by value), assert_ne faults if a equals b, and assert_near faults unless |got - want| <= eps (float-tolerant). A failing comparison reports both values. See Test your code.

assert_eq(1 + 1, 2)
assert_ne(1, 2)
assert_near(0.1 + 0.2, 0.3, 0.001)
print("ok")
$ scua asserts.scua
ok

#error

error(value)

Raise a recoverable fault carrying value. Catch it with try/rescue or pcall. Use this for bugs, not for expected failures; for those, return an Error(...) result instead.

#pcall

pcall(body) -> Result

Call body with no arguments, catching any fault. Returns Ok(result) on success or Error(e) if body faulted, instead of letting the fault propagate.

print(pcall(fn() return 42 end))
print(pcall(fn() error("nope") end))
$ scua pcall.scua
Ok(42)
Error(nope)

#as

as(value, T) -> T

A checked downcast. Verify at runtime that value matches record type T's shape, and type the result as T so the checker accepts field access on it. Faults if the shape doesn't match.

record Point { x: int, y: int }
let p = as({ x = 1, y = 2 }, Point)
print(p.x)
$ scua as.scua
1

#project

project(value, T, audience) -> T

Build an audience-safe view of a record. Returns a deep copy of value (record type T) keeping only the fields the audience enum value is allowed to see — a field gated to a viewer the audience isn't in comes back nil. Recurses through nested records and { Record } collections, so a gated field buried in a sub-record is stripped too. The audience must be an enum value. See Audience gates.

enum Audience { Server, Client }
gate Owner    { Audience.Server, Audience.Client }
gate Internal { Audience.Server }
record Player { name: string, secret: int gated Internal, gold: int gated Owner }

let p: Player = { name = "Aria", secret = 42, gold = 100 }
let view = project(p, Player, Audience.Client)
print(`{view.name} {view.gold} {view.secret}`)
$ scua project.scua
Aria 100 nil

#to_int

to_int(e) -> int

The integer behind a payload-free enum variant or a flags value (a plain int passes through), for arithmetic, indexing, or a wire boundary. The method form e.to_int() does the same thing.

Going the other way, Enum.from_int(n) checks that n names a variant and faults if it doesn't; Enum.try_from_int(n) answers nil instead, for numbers arriving from outside where "unknown" is an expected case. Enum.variants() lists every variant, and e.variant_name() / e.enum_name() give the two names as strings.

enum Suit { Hearts, Spades, Clubs = 10, Diamonds }
print(to_int(Suit.Spades), Suit.Clubs.to_int(), to_int(Suit.Diamonds))
$ scua toint.scua
1 10 11

#big

big(s) -> big
big(n) -> big

Construct a big — the huge-magnitude number for idle/incremental games, where reach matters and precision doesn't. A string specifier (big("1.2e456")) parses digits straight to the value, so magnitudes that would overflow a float to infinity are still legal; big(5)/big(3.14) widen an int or float. There is no numeric suffix — always construct with big(...). Arithmetic (+ - * / **) and comparisons are built in; a big won't mix with a decimal (exact vs inexact is a compile error).

print(big("1.2e30"))
print(big(2) ** 100)
$ scua big.scua
1.2e30
1.26765e30

#Collections

These work on arrays, which are dense and 0-based — except delete, which is the one table operation here. For the full story see Collections.

#len

len(collection) -> int

The number of elements in an array, the number of characters in a string, or the number of fields in a table.

#push

push(array, value)

Append value to array in place.

#pop

pop(array) -> any

Remove and return the last element.

#remove

remove(array, index) -> any

Remove and return the element at index (0-based), shifting the rest down.

#delete

delete(table, key) -> bool      -- also: t.delete(key)

Remove key from table, so len and for … in no longer see it. Returns true if the key was there — not the removed value; read t[key] first if you want that. Deleting a key that isn't there is a no-op returning false.

This is different from t[key] = nil, which sets the value to nil and keeps the key: len still counts it, the loop still yields it, and json.encode still writes "key":null. Assigning nil sets the value; delete removes the key.

A record's fields are fixed, so deleting one is an error. For an array use remove(array, index). Don't delete from a table you're iterating — collect the keys first (see Removing a key).

#is

is(a, b) -> bool

Whether a and b are the same object, as opposed to two objects that happen to be equal. == compares contents, so { x = 1 } == { x = 1 } is true for two separate tables while is tells them apart. For values with no identity — numbers, strings, bytes, keys, vectors and colours — it compares the value itself, so is("hi", "h" .. "i") is true; it stays stricter than == where the values differ in a way a program can observe (is(1, 1.0) and is(1.0d, 1.00d) are both false). See Asking whether two values are the same object.

#contains

contains(array, value) -> bool
contains(string, sub) -> bool   -- also: s.contains(sub)

Whether array holds value, or whether string contains the substring sub (the dual used by frame.filter(col.contains("lit")) — see Search a text column).

#slice

slice(array, start, end) -> array

A new array of the elements in [start, end). The index syntax array[start:end] does the same thing and clamps out-of-range bounds.

#range

range(stop) -> array
range(start, stop) -> array

An array of ints. With one argument it counts from 0; with two it counts [start, stop). For a loop you usually want the allocation-free range expression start:stop instead.

let a = [1, 2, 3]
push(a, 4)
print(a)                 -- [1, 2, 3, 4]
print(pop(a))            -- 4
print(remove(a, 0))      -- 1
print(a)                 -- [2, 3]
print(contains(a, 3))    -- true
print(len("hello"))      -- 5
print(slice([10,20,30,40], 1, 3))  -- [20, 30]
print(range(5))          -- [0, 1, 2, 3, 4]
print(range(2, 6))       -- [2, 3, 4, 5]
$ scua coll.scua
[1, 2, 3, 4]
4
1
[2, 3]
true
5
[20, 30]
[0, 1, 2, 3, 4]
[2, 3, 4, 5]

#Nested data and paths

These reach into nested tables by path. A path is a /-separated string, or a first-class path"..." value. See the path operator for the inline @ form.

#get

get(root, path) -> any

Read the value at path under root. Returns nil if any segment is missing.

#getStrict

getStrict(root, path) -> any

Like get, but faults instead of returning nil when a segment is missing.

#set

set(root, path, value)

Write value at path under root, creating intermediate tables as needed.

let root = { a = { b = 1 } }
print(get(root, "a/b"))      -- 1
print(get(root, "a/z"))      -- nil
set(root, "a/c", 99)
print(get(root, "a/c"))      -- 99
$ scua paths.scua
1
nil
99

#Numbers

abs, min, max, floor, ceil, round, clamp, and sort are always available. They preserve subtype: an int stays an int and a float stays a float, with no silent demotion. The full set of transcendental and shaping functions lives in the math module.

#abs

abs(x) -> number

Absolute value.

#min / max

min(...nums) -> number
max(...nums) -> number

The smallest or largest of the arguments. Ints and floats compare by value.

#floor / ceil / round

floor(x) -> int
ceil(x) -> int
round(x) -> int

Round toward negative infinity, toward positive infinity, or to the nearest int (half away from zero). An int passes through unchanged.

#clamp

clamp(x, lo, hi) -> number

x constrained to [lo, hi].

#fill

fill(n, v) -> array

A new array of n copies of v (n >= 0) — the common [0, 0, …] init without a loop. Note: the copies share v if it's a container, so for a 2D grid allocate a fresh inner array per row (for r in 0:rows do push(grid, fill(cols, 0)) end).

print(fill(4, 0))
$ scua fill.scua
[0, 0, 0, 0]

#sort

sort(array)

Sort an array ascending in place. Numbers sort before strings; it is stable.

#reserve

reserve(array, n: int) -> array

Pre-size an array to hold at least n elements. Its length doesn't change — this only sets capacity up front so a fill loop doesn't re-allocate as it grows. For a large element-typed buffer ({ f64 }, { f32 }, …) the storage goes straight to its final home. Usually called method-style; returns the array so it chains.

On an array with no element type yet, the space is claimed by the first push rather than by reserve itself — that is the first moment the right size is knowable, since an array of numbers is stored more compactly than one of anything else. The fill loop still allocates once. You cannot observe the difference except in memory used, where it is a saving.

let samples: { f64 } = []
samples.reserve(1000000)
for i in 0:1000000 do samples.push(1.0 * i) end
print(samples.len())
$ scua reserve.scua
1000000
print(abs(-7), min(3, 1, 2), max(1.5, 9, 2))
print(floor(3.7), ceil(3.2), round(2.5), clamp(15, 0, 10))
let scores = [42, 7, 19, 3, 88]
sort(scores)
print(scores)
$ scua numbers.scua
7 1 9
3 4 3 10
[3, 7, 19, 42, 88]

#format

format(fmt, ...args) -> string

Render fmt, filling each {} or {:spec} hole from the arguments. It uses the same mini-language as string interpolation. See Strings.

print(format("{} vs {} — {0} wins", "red", "blue"))
print(format("hp {}/{} ({:.0%})", 30, 40, 30.0 / 40.0))
$ scua format.scua
red vs blue — red wins
hp 30/40 (75%)

#Random numbers

The generator is a seeded value, not hidden global state. The same seed always replays the same stream, which is what lockstep and replay need. You thread the generator through each call explicitly. Bring it in with import rand.

Function Returns
rand.seed(n) A generator seeded from number n.
rand.int(g, lo, hi) A uniform int in [lo, hi] (both ends included).
rand.float(g) A uniform float in [0, 1).
rand.range(g, lo, hi) A uniform float in [lo, hi).
rand.bool(g, p?) True with probability p (default 0.5).
rand.normal(g, mean?, std?) A Gaussian sample (default mean 0, std 1).
rand.choice(g, array) A uniformly chosen element.
rand.weighted(g, weights) An index chosen proportional to weights (a loot table).
rand.shuffle(g, array) Shuffle the array in place.
rand.fork(g) An independent child generator, deterministically derived.

Every drawing function advances g.

import rand
let g = rand.seed(2026)
print(rand.int(g, 1, 20), rand.int(g, 1, 20), rand.int(g, 1, 20))
print(`a float in [0,1): {rand.float(g):.3f}`)
let loot = ["sword", "shield", "potion", "gold"]
print(rand.choice(g, loot))
$ scua rand.scua
2 15 7
a float in [0,1): 0.792
gold

#Frames (data tables)

A frame is a small columnar data table — named columns you total, sort, group, and reshape, the way you would a spreadsheet or a database query. Build one with no import and query it with method chains. The headline is exact money: a decimal column totals and groups exactly, where a float-first tool would drift. For the full story see Data tables.

#frame

frame(columns) -> frame

Build a frame from a table of { name = array, … } — every column the same length. When you write the columns as a literal, the column names become known at compile time, so a mistyped column anywhere downstream is a compile error with a "did you mean" suggestion. Frames whose columns aren't known up front (a frame argument, or one from csv/join/pivot/a host) are checked at runtime instead.

#csv

csv(text) -> frame

Parse CSV text into a frame: the first line names the columns, each later line is a row. Cells are typed per value — a whole number becomes an int, a decimal-point number an exact decimal (money stays exact), anything else a string, and an empty cell nil. Quoted fields with embedded commas are handled (a quoted field may not span multiple lines).

#Operations

Each reads on the frame and returns a new frame (or a value) — nothing is mutated in place, and there's no hidden row index. Empty cells (nil) are skipped by total/mean. In filter and with, column names are written bare (amount, region); see Filtering and computed columns.

Operation Returns
f.rows() the number of rows (int)
f.column_names() the column names, in order (array of strings)
f.column(name) one column as an array
f.head(n) a new frame with the first n rows
f.take(indices) a new frame with the rows at the given indices (an int array), in that order
f.pick(names) a new frame with only the named columns, in that order
f.sort_by(name[, descending]) rows ordered by a column (ascending, or descending when the second argument is true)
f.total(name) the sum of a column — exact for a decimal column; int→int, float→float
f.mean(name) the average of a column (float)
f.filter(predicate) the rows where the predicate holds — column names written bare (col.contains(lit) / col.matches(re) for text)
f.search(needle[, opts]) rows whose string cells match needle — fixed-string by default; { columns, ignore_case, regex }
f.with(name, expr) a new frame with column name computed per row from expr (bare column names)
f.with_column(name, values) a new frame with column name set to the pre-computed values array (one entry per row) — the lower-level form of with, for when the values are computed outside the frame
f.group_sum(key, value) group by the key column and sum value per group → a 2-column frame (key, total)
f.join(other, key[, kind]) join two frames on a shared key column — "inner" (default) or "left"
f.pivot(index, columns, values) a pivot table: a row per index value, a column per columns value, each cell the sum of values
f.unpivot(ids, values[, name_col, value_col]) melt: keep the ids columns, stack the values columns into a long name/value pair
f.row(i) one row (0-based) as a table keyed by column name
f.to_table() the frame rendered as a DuckDB-style box-drawing table (a string, for printing or copying); numeric columns right-aligned, every row included
let sales = frame({
    region = ["EU", "US", "EU"],
    amount = [10.50d, 20.00d, 5.25d],
  })
print(sales.total("amount"))                  -- 35.75 — exact
print(sales.group_sum("region", "amount"))
$ scua frames.scua
35.75
region  total
EU      15.75
US      20.00
(2 rows × 2 cols)

#Rendering and localization

Bake a config table into a finished payload — resolving placeholders, localizing strings, and selecting variants — with no import. For the full story see Rendering and localization.

#render

render(source, locale, external) -> table

Bake source for locale, resolving every @{path} reference against two roots — self/ (the source itself) and external/ (the data you pass: one table, or a list of tables merged with later entries winning). Returns a fresh table. A lone "@{path}" yields the referenced value (a scalar or a whole subtree); an embedded @{path} interpolates into the surrounding text. Directive blocks (@ref, @when, @match, @each, @fmt, @msg, @strip) handle subtrees, conditionals, variants, iteration, locale formatting, and pruning. Pure and total, under hard limits.

#render_tracked

render_tracked(source, locale, external) -> { output, reads }

Like render, but also returns the read-set for precise cache invalidation: reads is a deduped list of { ref, origin } records, where ref is a path that was read (e.g. "external/name") and origin is which input supplied it — "self", or the index in the external list.

#localized

localized(map) -> LocalizedString

A localized string: a table of locale tag → string, e.g. localized({ en_US = "yes", es_ES = "sí" }). At render time it collapses to the active locale's string (BCP-47 tags normalize, e.g. en-US, and a missing tag falls back to its primary subtag). Keys use underscores (en_US).

#Results

Ok and Error build a result value, the way SCUA represents an expected success or failure. The postfix ? operator unwraps an Ok or short-circuits on an Error, and match takes a result apart. See Errors and faults.

Ok(value)
Error(value)
print(Ok(5))
print(Error("boom"))
$ scua result.scua
Ok(5)
Error(boom)

#Coroutines

A coroutine is a function you can suspend and resume. Its suspended state is ordinary data, so it persists and migrates with a partition like any value. See Concurrency and time.

#coroutine

coroutine(body) -> coroutine

Create a suspended coroutine from body. Drive it with resume.

#resume

resume(co, value?) -> any

Resume a suspended coroutine, optionally passing value (which becomes the result of the yield that suspended it). Returns the coroutine's next yield, or its final return value when it finishes.

#yield

yield(value) -> any

Suspend the current coroutine, handing value to whoever resumed it. It evaluates to the value passed to the next resume.

#status

status(co) -> string

The coroutine's state: "suspended", "running", or "done".

let counter = coroutine(fn()
  let i = 0
  while true do
    i = i + 1
    yield(i)
  end
end)
print(resume(counter))   -- 1
print(resume(counter))   -- 2
print(status(counter))   -- suspended
$ scua coro.scua
1
2
suspended

#Time and tasks

These belong to the cooperative scheduler. spawn and wait work inside a partition or a script; now and dt read the turn's clock. See Concurrency and time.

#spawn

spawn(body)

Schedule body to run concurrently. Inside a partition the block form spawn do … end does the same. See Partitions and the actor model.

#wait_all

wait_all(fns) -> array

Run a list of zero-argument functions and wait for all of them, returning their outcomes in order (results[0] is the first arm's). All-settled: one arm failing never cancels the others, so there's always a slot per arm. Each slot holds that arm's value as-is — a plain value, or the Ok/Error an I/O arm returned, or, if the arm faulted, an Error carrying a tagged fault record you can test with e.fault. There is no outer Ok/Error, so never put ? on the call. Waits overlap only under --io=async; the results are identical either way. See Run many things at once.

#map_all

map_all(xs, f, opts?) -> array

Map f over xs, running at most opts.concurrency at a time (default 8), wait for all, and align the results with the input. Same semantics as wait_all: ordered, all-settled, each slot the call's value as-is, and no ? on the whole. The concurrency cap throttles how many run at once, so you can fan out over a thousand inputs without opening a thousand sockets.

#wait

wait(duration)

Suspend the current task for a duration, then resume. Durations are int64 milliseconds, written with unit suffixes like 2s, 200ms, 1h.

#now

now() -> int

The current time in int64 milliseconds. It is the turn's stamped clock, frozen for the turn, so two now() calls in the same turn read the same value. Elapsed time comes from the difference between two now() readings.

#dt

dt() -> int

Milliseconds elapsed since the previous turn.

#Actors and messaging

Partitions talk to each other through messages. These functions back the message-send syntax; you usually write the surface form rather than calling them directly. See Partitions and the actor model.

  • tell target.Tag(args) sends a fire-and-forget message.
  • ask target.Tag(args) sends a request and waits for the reply.
  • reply(value) answers the in-flight ask from inside a handler.

#Math

The vector, quaternion, and matrix constructors (vec3, and the type-namespaced quat.axis_angle, mat4.translate, mat4.id, and the rest — the flat quat_axis_angle/mat4_translate names still work) are built in. Their operations are called on the value — v.length(), v.normalize(), a.dot(b), a.cross(b) — the receiver names the operation (the older bare forms like length(v) still work). So is the color type — rgb/srgb/hsv/color_hex constructors, #rrggbb hex literals, the ~148 color_<name>() CSS colors (plus color_transparent()), and operations c.lerp(b, t)/c.lighten(amt)/c.darken(amt)/c.to_hex() — plus the geometry shapes rect/aabox/sphere/capsule/ray/plane with s.volume(), s.contains(p), intersect(a, b), and friends. They have their own page: Math.

#Modules

Bring these in with import when you need them. Each is a record of functions accessed as fields, like str.trim(...).

  • math — square roots, trig, interpolation, angle helpers, the special functions erf/erfc/lgamma, and constants. See Math.
  • sys — the process surface: sys.args, sys.parse_args, sys.stdin, sys.exit, and the output verbs. sys.write(s) writes to stdout with no trailing newline (the progress bar / status line case); sys.write_err(s) is its twin on stderr — same raw bytes, no newline, no [level] prefix — for progress and status that must not land in output something else is parsing. sys.emit(value) writes one JSON result and exits; sys.fail(reason[, code]) writes {"error": …} to stderr and exits non-zero. See Write tools for AI agents.
  • sys.locales(){ string } — the language tags this build carries names for (und first). What locale data SCUA carries, stated in both directions: in — month and weekday names and their abbreviations, date field order, number separators and grouping, and currency symbol placement; not in, now or later — collation (language-correct sorting), SI/short-scale/engineering notation, accounting formats, number spellout, ordinals, timezone display names, and transliteration. Those are open-ended and large, and belong to a package or the host. sys.locale() may name a language outside the carried set; the formatters then fall back to English, which is why the list is readable at runtime rather than something you have to infer from the output. money.format follows the locale too (ADR-0102): €1,234,567.89 under en_US, 1.234.567,89 € under de_DE, 1 234 567,89 € under fr_FR. Under the neutral default und it is unchanged from before locale-awareness, so a run that never set a locale sees no difference.
  • rand — the seeded random-number generator described above.
  • str — string operations: split, join, trim, upper, lower, find, replace, pad_start, and more. See Strings.
  • regexReDoS-safe regular expressions (RE2/Rust/ripgrep dialect, linear-time): compile(pattern)Ok(re)/Error, then is_match(re, s), find(re, s), find_all(re, s), captures(re, s), replace(re, s, repl), source(re). Offsets are 0-based, half-open codepoints — the same convention as str.find/str.slice, so str.slice(s, m.start, m.stop) == m.text; \w \s \d are ASCII in v1. Backreferences/lookaround fail loudly at compile. See Match text with regular expressions.
  • list — functional array operations (signatures below).
  • jsonencode(value) (optionally encode(value, { pretty = true })) and decode(text). decode returns a Result you match: Ok(value) on success, Error(message) on bad input. Exact decimal and money values encode as lossless strings (never as JSON numbers).
  • durable — a typed, versioned binary save format (preserves int/float/decimal/big/money/datetime/string/bytes/key, plus frame data tables, vectors and colours, localized strings and regexes — none of which JSON can): encode(value, schema_version?)bytes, decode(bytes)Ok(any)/Error (assign the result to a typed record to migrate + validate it), version(bytes)Ok(int)/Error (the stamped version), digest(value)bytes (a stable SHA-256 content id), verify_roundtrip(value)Ok(nil)/Error (restore-completeness check). Pure (no capability); pair with fs to write to disk. See Evolve your saved data safely.
  • crypto — host entropy and Ed25519 signatures: random_bytes(n)bytes (1 to 1024 fresh unpredictable bytes from the host — the one source of unguessable bits in the language, and the seed keypair asks for; rand is seeded and replays, which is what replay needs and what a key or a token must never be), keypair(seed: bytes){ public: bytes, secret: bytes } (from a 32-byte seed — use crypto.random_bytes(32)), sign(message, secret)bytes, verify(message, sig, public)bool. Sign server-side; the secret key must never reach an untrusted client. See Verifying a save.
  • dotenv.env files. parse(s) returns Ok(table) of strings, or Error(message). KEY=value, # comments, an optional export prefix, 'literal' and "basic" quoting (the latter processes \n/\r/\t/\\/\" and may span lines); KEY= is the empty string, a repeated key keeps its last value, and ${VAR} is not interpolated. Pure (no capability) — to feed a .env into env.get instead, run with --env-file=PATH. See Read environment variables.
  • toml and ini — config formats. parse(s) returns Ok(table) (a nested table you can path into with cfg @ "a/b") or Error(message) on bad input. toml is full TOML 1.0: arrays become arrays, [[sections]] an array of tables, dates a datetime (a bare local time like 07:32:00 has no SCUA type and reads as a string). toml.encode(table) writes TOML back out, keys sorted; exact decimal/money become lossless strings, and a value with no TOML form faults. INI is parse-only and every INI value is text. Both are pure (no capability). See Read config files.
  • hashsha256(data) / sha384(data) / sha512(data)bytes (cryptographic digests, 32 / 48 / 64 bytes), plus crc32(data)int and fnv1a(data)int (deterministic, non-cryptographic checksums). They all take text or raw bytes, which is what makes crc32 usable for the formats that mandate it — gzip, zip, PNG — where the data is compressed bytes and never text.
  • base64 and hex — binary-in-text encoding: encode(s)string, and decode(s)Ok(string)/Error(message) (match it or use ?, since the input may be malformed). base64.encode takes text or raw bytes — signatures, keys and digests are bytes by definition — and a trailing options record picks the dialect: { url_safe = true } swaps +/ for -_, { pad = false } drops the = padding. JWT, JWS, JWK, WebAuthn and OAuth PKCE all want both together. base64.decode needs no option: it accepts either alphabet, padded or not, because -_ and +/ are disjoint so there is nothing to disambiguate. Both decode functions return bytes, because that is what decoded base64 and hex are — a signature, a key, a digest. If you want text, wrap it: bytes.to_string(base64.decode(s)?), which validates, so input that is not valid UTF-8 is refused rather than becoming a string that string functions silently misread.
  • bytes — helpers for the binary bytes type: conversions to/from string, hex, and int arrays (from_string/to_string/from_hex/to_hex/from_array/to_array), slice, and fixed-width number reads (u16le/u32be/f64le/…). See the bytes section of Strings.
  • intparse(s)Ok(int)/Error(message), the strict counterpart to tonumber: an optional sign, decimal digits and _ separators, and nothing else. It doesn't trim, doesn't read a 0x prefix, doesn't fall back to a float, and a value outside the signed 64-bit range is an Error rather than a wrap or a widening. The Error text names the fix. Use it on strings you didn't write. See Reading a number from text.
  • decimal — named operations on the exact decimal type (the 12.34d literal and + - * / are built in): parse(s)Ok(decimal)/Error, of(int), from_float(f, scale) / to_float(d) (the explicit, lossy conversions), round(d, scale) (banker's requantize), div(a, b, scale) (division at an explicit scale), scale(d).
  • money — exact money over the decimal type, plus a currency layer: of(amount, code)money (an amount tagged with an ISO 4217 currency, which travels with the value), amount(m)decimal and code(m)string (the two parts back out — of(amount(m), code(m)) is m, so use these rather than parsing format, which renders a symbol and $ names three currencies), parse(s)Ok(decimal)/Error, format(amount) or format(amount, currency) (symbol + thousands grouping + the currency's decimal places), split(total, n) (penny-exact), round(m) (to the currency's places, banker's rounding), convert(m, code, rate) (to another currency at an explicit rate — no ambient FX), scale(amount, num, den) (rate with banker's rounding). See Handle money.
  • sys — pure, no grant. args(){ string } (user args after the script path); stdin()string (whole launch-time stdin, "" when nothing is piped); args_table() → named-arg table (stdin JSON + CLI --key flags); parse_args(RecordType) → typed record with where validation (usage exit 2 on violation); write(s) → write s with no trailing newline, now — the unterminated write a carriage-return progress bar or spinner needs, on the same stream as print (the bytes go wherever stdout goes, verbatim, so gate an animation on a flag rather than assuming a terminal, and don't mix it with emit/fail, which promise an exclusive stdout); emit(value) / fail(reason[, code]) (one-shot JSON result on stdout / diagnostic on stderr, then exit); exit(code) (clean stop, 0–255); locale() → BCP-47 tag ("und" when unset); timezone() → UTC offset in minutes (0 when unset). Locale/timezone are neutral by default and set with --locale/--tz. See Read command-line arguments, Write tools for AI agents, and Set the locale and timezone.
  • time — civil dates over the millisecond clock (now()/dt()), pure (no grant). Construct with the module: time.now(), time.at(ms, offset_min?), time.of(year, month, day, hour?, minute?, second?, ms?, offset_min?), time.parse(s, offset_min?)Ok(datetime)/Error. Read a datetime with receiver methods: dt.year()/month()/day()/hour()/minute()/second()/millisecond()/weekday() (ISO 1=Mon..7=Sun)/year_day()int, dt.parts() → a record of all of those plus offset_min, dt.to_ms()int, dt.offset()int, dt.with_offset(offset_min), dt.format(pattern)string, dt.iso()string. Arithmetic & durations (plain ms): time.add(dt, ms), time.diff(later, earlier)int, time.humanize(ms)string, time.duration_parts(ms), time.parse_duration(s)Ok(int)/Error. Fixed-offset Gregorian — no IANA zones/DST. See Dates and times.
  • ids — unique identifiers, no grant needed. uuid4() → a random UUID; uuid7() → a time-ordered UUID (a millisecond timestamp first, so ids sort by creation as text); ulid() → a time-ordered ULID, 26 Crockford base32 characters, shorter in a log and with no I/L/O/U to mistranscribe; parse(s)Ok(s), the same string back unchanged, or Error(reason). Canonical spelling only: a UUID must be lowercase and a ULID uppercase, and the wrong case is refused rather than quietly corrected — parse never hands you a different string than you gave it. If a source system disagrees (.NET and SQL Server emit uppercase UUIDs), convert it yourself at the boundary, so there is one spelling in your data and you chose it. Prefer uuid7() or ulid() for database keys and log lines: v4's randomness scatters index writes while the time-ordered forms cluster them, and ids minted in the same turn come out in issue order. The timestamp is the turn's clock — the same value now() returns — so it fast-forwards under --fast and starts at zero under scua test, like every other clock reading. Not security tokens: a time-ordered id has a readable creation time and is not unguessable enough for a session token or a reset link — use crypto.random_bytes(32) for those. Not replayable — the bits come from the host, not from the seeded rand, so a re-run produces different ids; use a seeded counter if you need ids a replay reproduces. See Generate unique ids.
  • enva capability module needing the env grant (scua --allow-env[=NAMES]); without it, import env is a compile error. get(name)string | nil, an environment variable from the launch-time snapshot (or nil if unset or not in the allowlist). See Read environment variables.
  • fsa capability module, present only when the host grants it (scua --allow-fs[=DIR]); without the grant, import fs is a compile error. Reads/writes within a rooted directory (no escaping; absolute paths and .. are rejected): read(path)Ok(bytes)/Error, read_text(path)Ok(string)/Error, write(path, content)Ok(nil)/Error, list(path)Ok({ string })/Error, exists(path)bool, stat(path)Ok({ size, is_dir, is_file, mtime_ms })/Error, find(path, opts?)Ok({ record })/Error (walk a tree), grep(pattern, path?, opts?)Ok({ record })/Error (search file contents — token-economy options files_with_matches, count_only/matches, invert, max_per_file narrow the output), grep_text(pattern, path?, opts?)string (the same search rendered as one compact path:line: text string). See Read and write files.
  • httpa capability module with two authorities that don't overlap. The client needs the net grant (scua --allow-net[=hosts]): get(url) and post(url, body)Ok({ status: int, body: string })/Error, restricted to the granted host allowlist. The server needs the separate serve grant (scua --allow-serve[=ADDR:PORT]): serve(handler, { bind }) blocks and answers requests, calling handler — a plain fn(req) -> resp — once per request. req is { method, path, headers, body } (header names lowercased, body is bytes); the response is { status = 200, headers = {}, body = b"" } with a bytes body (a string body is a 500). A handler fault or returned Error becomes a 500 (text withheld unless --serve-debug). Without the matching grant, the function isn't there to call (import http compile error, or an undefined name). See Fetch data over HTTP and Serve HTTP requests.
  • neta capability module; raw TCP sockets. Dialing out needs the net grant: dial(host, port)Ok(connection)/Error (an opaque, non-sendable handle), with the host in the allowlist. Listening needs the separate serve grant (not net): listen(addr, port)Ok(listener)/Error and accept(listener)Ok(connection)/Error (waits for the next client; parks under --io=async). Read and write either connection with read(conn, max) / write(conn, data); close(x) closes either a connection or a listener. See Raw TCP sockets and Serve HTTP requests.
  • sandboxa capability module needing the sandbox grant (scua --allow-sandbox); compile and run a SCUA source string in an isolated, capability-free partition. run(source, input?, opts?)Ok(any)/Error(record) is one-shot (calls the source's fn main(input) and destroys the partition); actor(source, state?, opts?)Ok(actor)/Error(record) loads code you call repeatedly (the source defines fn handler(state, msg)) and gives back an ordinary actor to tell/ask; stop(a)Ok(bool) releases one. The sandboxed code holds no capabilities whatever the host holds, and only data crosses the boundary — never a function or an actor reference. opts is { name, fuel, memory, max_source, max_protos }, defaulting to frame-sized bounds. An Error carries { kind, line, message, name } where kind is compile_failed / no_handler / source_too_large / too_many_prototypes / out_of_fuel / out_of_memory / faulted. See Run code in a sandbox.
  • udpa capability module needing the udp grant (scua --allow-udp[=PEERS]); datagrams, which are unreliable, unordered, and unencrypted. connect(host, port)Ok(socket)/Error opens a socket locked to one peer; bind(addr, port) (also needs --allow-serve) opens one that hears from anyone. Send with send(sock, data) / send_to(sock, host, port, data), receive with recv(sock, opts?)Ok({ peer, data })/Error, answer a peer that reached you with reply(sock, peer, data). In a receive loop use recv_batch(sock, max, opts?) rather than recv. peer_addr(peer) → an address string (for logging, not a send target), local_port(sock)int, close(sock). Payloads cap at 1200 bytes (raise it with --udp-max-payload), and a peer may be sent at most 3× the bytes it has sent you. See Send datagrams with UDP.
  • clustera capability module needing the cluster grant (scua --allow-cluster[=SECRET]); without it, import cluster is a compile error. A small cluster-wide config table that converges last-writer-wins across nodes (etcd/Consul in miniature, for when you don't want to run one). open(name)shared, then set(t, key, value), get(t, key) → value | nil, has(t, key) → bool, delete(t, key) (writes a tombstone that must reach every node — not the plain-table delete, which leaves nothing behind), subtree(t, prefix) → table (the live keys under prefix/, keyed by their relative path). Keys are path-shaped and prefix-exclusive (a key is a leaf value or a namespace, never both); values are portable and small (64 KiB cap). join(opts) meshes nodes over gossip (young — the single-node surface is the solid part). See Share config across a cluster.

#The list module

import list, then call as fields: list.map(xs, f). None of these mutate the input; the ones that produce a sequence return a new array.

  • list.map(xs, f) — a new array of f(x) for each element.
  • list.filter(xs, pred) — a new array of the elements where pred(x) is true.
  • list.reduce(xs, f, init) — fold left: starts from init and computes acc = f(acc, x) across the elements. Note the argument order: the function comes before the initial value.
  • list.find(xs, pred) — the first element where pred(x) is true, or nil.
  • list.any(xs, pred) — true if pred(x) holds for at least one element.
  • list.all(xs, pred) — true if pred(x) holds for every element.
  • list.each(xs, f) — calls f(x) for each element and returns nothing (for side effects).
  • list.count(xs, pred) — how many elements satisfy pred.
  • list.sort(xs, less) — a new array sorted by a less(a, b) comparator (returns true if a comes first). Stable; the original is untouched.
  • list.sum(xs) — the sum of the elements.
  • list.reverse(xs) — a new array in reverse order.
  • list.index_of(xs, v) — the index of the first element equal to v, or nil.
  • list.unique(xs) — a new array with duplicates removed, keeping the first occurrence.
  • list.zip(xs, ys) — a new array of { a = xs[i], b = ys[i] } up to the shorter length.
  • list.flatten(xss) — concatenates an array of arrays into a single array.