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).
Each one can also be written std.<name>
(std.print(x), std.round(2.5)), which always
reaches the built-in even in a file that declares its own of that name.
You can't assign to a built-in; declaring your own hides it in that
file, with a warning. See Your names and
the built-ins.
A note on truthiness, since several of these depend on it: only
nil and false are falsey. Everything else,
including 0, the empty string, empty arrays and tables, and
enum variants, is truthy, so t[k] or 0 and
if Color.try_from_int(n) then work as presence tests. Two
kinds of value are refused as a condition instead, because the language
already defines their yes or no: a Result
(Ok/Error) and a flags set. Test
a Result with r matches Ok or match, and a set
with in or & … != Set.none; see Testing a Result and Flags.
#Core
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. Anything that isn't a string or a number gives nil
too, including nil itself, so
tonumber(env.get("PORT")) or 8080 works. A table, array or
bool can never be a number, so passing one is almost always a mistake:
the compiler warns when it can see one
(warning: `tonumber` is given a table …, and the program
still runs), and under scua test and the debugger the first
time each call gets one while running. 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). A
Result or a flags set is refused rather than tested, since
assert(check(x)) would otherwise pass for an
Error too: write assert(check(x) matches Ok).
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)
#parse
parse(value, T, opts?) -> T
Convert loose data to type T, checking it all the way
down, and type the result as T so the checker accepts field
access on it. T is any type an annotation takes: a record,
Point?, { Point }, int, an enum,
shapes.Point from a module. A record is built new, with
defaults filled and a migrate hook run where the data needs
one, and every nested record and element is checked with its
where; value itself is never changed. Faults
if the value doesn't fit, with e.path
(raw.ledger[1].id) and e.at
(ledger[1].id) naming where. Keys T doesn't
declare are dropped rather than refused, so data from a newer version
still loads; parse(value, T, { unknown = "refuse" })
refuses them instead, naming the nearest field. See the
boundary rule.
record Point { x: int, y: int }
let p = parse({ x = 1, y = 2 }, Point)
print(p.x)
$ scua parse.scua
1
as(value, T) is its old name. It does the same, with a
warning to write parse; a later release refuses it.
scua fmt --fix file.scua renames every one in a file.
#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).
#freeze
freeze(value) -> value
A frozen copy of value: the copy, and every table, array
and record inside it, refuses changes from then on, while
value itself is left as it was. It is how you make a
constant you build step by step: fill a let, then
const BY_NAME = freeze(by_name). Like a const,
it takes data only: a partition reference, a capability, a host object
or resource, a builder, or a function that captures variables is
refused, and the message says which. See const freezes the
value.
#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, kind?) -> 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).
With kind — "u8", "u16",
"u32", "i32", "f32",
"f64", "vec2", "vec3" or
"vec4" — it builds that typed array directly, at its final
size. Use it for a large buffer:
fill(12_000_000, 0.5, "f32") takes 48 MB, where
let p: { f32 } = fill(12_000_000, 0.5) first builds an
array of 8-byte numbers and then converts it, peaking near three times
that. A v the kind can't hold is refused.
print(fill(4, 0))
let pixels = fill(4, 255, "u8")
print(elem_kind(pixels), pixels)
$ scua fill.scua
[0, 0, 0, 0]
u8 [255, 255, 255, 255]
#elem_kind
elem_kind(v) -> string?
The element kind of a typed array — "u8",
"u16", "u32", "i32",
"f32", "f64", "vec2",
"vec3" or "vec4" — or nil for a
plain array or anything that isn't an array. type says
"array" for all of them; this is the check a library makes
before trusting a buffer it was handed. It reads the array's header, so
it costs the same for any length, and it never converts anything.
let p: { u8 } = [1, 2, 3]
print(elem_kind(p), elem_kind([1, 2, 3]), elem_kind("text"))
$ scua elem-kind.scua
u8 nil nil
#copy_range
copy_range(dst, at: int, src, start?: int, end?: int) -> array
Write src[start:end) (by default all of
src) over dst starting at index
at, growing dst when the range runs past its
end, and return dst. at may be at most
len(dst), which appends. Between two typed arrays of the
same kind it is one block copy with no work per element: the row copy or
blit of an image or audio buffer. The two ranges may overlap, even
inside one array. Otherwise each element is checked against
dst's kind before anything is written, so a refused copy
leaves dst unchanged. Usually called method-style.
let stripe = fill(3, 9, "u8")
let image = fill(6, 0, "u8")
image.copy_range(2, stripe)
print(image)
image.copy_range(0, image, 2, 4)
print(image)
$ scua copy-range.scua
[0, 0, 9, 9, 9, 0]
[9, 9, 9, 9, 9, 0]
A typed array longer than 256 elements prints its first 16 and a
count of the rest ([0, 0, …, … 11999984 more]), in
print, tostring, string interpolation and
fault messages alike, so printing a record that holds an image doesn't
write megabytes of numbers. A plain array always prints in full. A
bytes value over 256 bytes prints the same way: its first
16 bytes as hex and a count of the rest
(bytes(00000000000000000000000000000000, … 65520 more)).
#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 the whole number n. A float
that holds a whole number seeds exactly like it (2026.0 and
4052 / 2 are the same seed as 2026); a
fraction, NaN or infinity is an error. |
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}`)
const 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). name may be an
array of columns for a multi-key sort, and
descending an array of one flag per key.
descending must be true or false
(or left out); anything else is an error |
f.tail(n) |
a new frame with the last n rows — head's
twin, for the other end of a sort |
f.drop(names) |
every column except those named —
pick's complement. A name that isn't there is an error, not
a no-op |
f.rename(map) |
the same columns under new names, { old = "new" };
column order is unchanged |
f.distinct() |
duplicate rows removed, keeping the first occurrence and the original order |
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.median(name) |
the middle value — unmoved by an outlier the way mean
is. An actual cell when the row count is odd, an exact
midpoint when it is even (so a money column stays exact) |
f.quantile(name, q) |
the value at fraction q through the sorted column —
0.25 is the lower quartile, 0.75 the
upper |
f.std(name) |
the sample standard deviation (float, n-1 as in a spreadsheet's
STDEV); nil for fewer than two values |
f.variance(name) |
the sample variance (float), same convention |
f.min(name) / f.max(name) |
the smallest / largest value, keeping the column's kind — works on text and dates too, not just numbers |
f.count(name) |
how many rows have a value there — compare with
f.rows() to see how much is missing |
f.count_distinct(name) |
how many different values the column holds |
f.first(name) / f.last(name) |
the first / last non-nil value in row order — most useful after a
sort_by |
f.product(name) |
every value multiplied together (compound rates); exact for int and decimal columns |
f.mode(name) |
the most frequent value — the one statistic a text column has. A tie keeps the value seen first |
f.describe() |
a summary frame: one row per numeric column with count, mean, std, min, p25, median, p75, max. The call to make before you know what you are looking at |
f.value_counts(name) |
every distinct value with how often it occurs, most frequent first — a 2-column frame |
f.group_by(key, spec) |
group by key and compute every
aggregate in spec in one pass:
{ revenue = "sum:amount", orders = "count" }. Each value is
"<reducer>:<column>" (sum, mean, median, min,
max, std, variance, count, count_distinct, first, last, product, mode)
or a bare "count" for rows per group |
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)
x matches Ok and x matches Error test which
one a value is, and are false for anything that isn't a
Result. A Result is never true or false on its own, so
if r then is an error: see Testing a Result.
Ok and Error are reserved names: no contract,
record, type alias, interface, enum variant, flags member, message or
partition handler can be called either.
#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. Addtimeout d—ask target.Tag(args) timeout 200ms— to say how long. Without one you get five seconds.reply(value)answers the in-flightaskfrom 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 CSS colors in
the color module, color.<name>() (plus
color.transparent(); import color first), 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(...).
A name the module does not have is a compile error, as it is for a
module of your own: str.len(s) stops with "str
has no function len — len is a built-in, so
call it directly: len(…)", and a near miss such as
str.uper suggests str.upper. A verb that needs
a grant this run lacks is still a name the module has, so it is not
refused here.
An option that switches something on or off takes
true or false.
json.encode's pretty, the fs.find
and fs.grep switches, base64.encode's
url_safe and pad,
crypto.aes_ctr's little_endian and
sort_by's descending all refuse any other
value with an error that names the option, such as "option
pretty of json.encode must be true or false,
got a string". Leaving an option out still gives its default.
Iterating a capability module is not feature
detection. A capability module holds the verbs this
run is allowed to use, not the verbs the runtime has — that is
least authority working as intended. net iterates to four
entries under --allow-net and six under
--allow-serve, because listen and
accept belong to the serve grant. So a loop
over fs or net answers a question about the
grant the caller gave you, and the answer can differ between two runs of
the same binary. To ask what the runtime can do, compare
sys.version() (listed under sys below) — it
needs no grant and describes the runtime rather than the run. A module
that looks uniform today (no fs verb currently sits behind
a second grant) is not promised to stay that way.
ffi.open(id),ffi.bind(library, symbol, result_type, argument_types),ffi.call(symbol, ...args),ffi.close(handle)— experimental scalar native calls, requiring an FFI-enabled build and explicit host library grants. No blanket CLI permission. See Dynamic FFI for ABI types, lifetime and trust restrictions.host.is_detached(value)→bool— true for a host-resource placeholder loaded from an explicitly redacted checkpoint. It carries no native authority and cannot be revived by re-registering a type. Ordinary/live values return false. See userdata checkpoints.sys.platform()→string— the running binary's target OS (macos,linux,windows,wasi, etc.). No capability grant required.sys.arch()→string— the running binary's target CPU architecture (aarch64,x86_64,wasm32, etc.). Under emulation this describes the binary, not the physical CPU.sys.version()→string— the runtime version, including any development suffix (e.g.0.22.1-dev.abc1234); the same asscua --versionwithout thescuaprefix. These three functions describe the current runtime, so their results can change when moving a saved program between hosts or runtime versions.sys.locale()→stringandsys.timezone()→int— the partition's configured locale tag and UTC offset in minutes, defaulting toundand0. See Set the locale and timezone.sys.script_path()→string | nilandsys.executable()→string | nil— the absolute path of the script being run (the test file underscua test, symlinks resolved) and of the runningscuabinary. Together they give the exact command that launches the program, for a tool that prints its own launch line or config entry.nilwhen there is no such file: a host that embeds SCUA and runs source it was handed has no script path. They need no grant, and a sandbox cannot call them, because they would tell code you did not write where your files are.sys.executable()resolves symlinks, so it names one installed version: a launch line built from it keeps running that version after an upgrade that moves ascuasymlink (a package manager or a version manager). For a line that follows upgrades, writescuaand letPATHfind it.math— square roots, trig, interpolation, angle helpers, the special functionserf/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 (undfirst). 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.formatfollows the locale too:€1,234,567.89underen_US,1.234.567,89 €underde_DE,1 234 567,89 €underfr_FR. Under the neutral defaultundit 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.regex— ReDoS-safe regular expressions (RE2/Rust/ripgrep dialect, linear-time):compile(pattern)→Ok(re)/Error, thenis_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 asstr.find/str.slice, sostr.slice(s, m.start, m.stop) == m.text;\w \s \dare ASCII in v1. Backreferences/lookaround fail loudly at compile. See Match text with regular expressions.list— functional array operations (signatures below).json—encode(value)(optionallyencode(value, { pretty = true });prettymust betrueorfalse) anddecode(text).encode(value, { canonical = true })writes one spelling for one value, for signing a request or comparing two documents byte for byte: every object's keys in byte order (UTF-8), at every level; no whitespace; numbers as always (an int in plain decimal, a float as the shortest decimal that reads back as the same float, with.0on a whole one,-0.0kept); strings escaped only where JSON requires it. It can't be combined withpretty.decodereturns a Result you match:Ok(value)on success,Error(message)on bad input, where the message gives the line and column (invalid JSON at line 3, column 14). With a type,decode(text, T, opts?)givesOk(T), checked and converted asparse(value, T, opts)would, or anErrorholding the fault record (message,path,at, andline/columnfor bad bytes), andencode(value, T, opts?)checks the value againstTall the way down first, givingOk(text)or anErrorand no text. An object that repeats a key is refused too, with a message that says so. That is deliberate: the JSON standard allows a repeated key but leaves its meaning open, so two programs can read the same document differently (one takes the first value, another the last), which is a known way to slip a value past a check. Refusing means the document means one thing or nothing. Exactdecimalandmoneyvalues encode as lossless strings (never as JSON numbers). A tagged enum variant (andOk/Error) encodes as{ "kind": "<Variant>", "value": … },valueomitted when the variant carries nothing and an array when it carries several fields; decoding into a record field declared with the enum turns that object back into the variant, and refuses any other shape by field (see Enums). A payload-free enum encodes as its variant's name ("Replayed"), and a record field declared with it decodes from its name or its number ("Replayed"or1); aflagsset encodes as its bitmask and aflagsfield decodes from the bitmask or a list of member names. A function parameter still needs a real variant.durable— a typed, versioned binary save format (preservesint/float/decimal/big/money/datetime/string/bytes/key, plusframedata tables, vectors and colours, localized strings and regexes — none of which JSON can):encode(value, schema_version?)→bytes,decode(bytes)→Ok(any)/Error; with a type,decode(bytes, T, opts?)→Ok(T)/Error, migrated and validated all the way down, andencode(value, schema_version, T)→Ok(bytes)/Error, checked all the way down first (a value with no saved form is anErrornaming its path),version(bytes)→Ok(int)/Error(the stamped version),format(bytes)→Ok(int)/Error(the format SCUA wrote it in, read without decoding) andcurrent_format()→int(the format this release writes; a blob in an older format still loads, see Keeping saves across SCUA releases),digest(value)→bytes(a stable SHA-256 content id),verify_roundtrip(value)→Ok(nil)/Error(restore-completeness check). Pure (no capability); pair withfsto write to disk. See Evolve your saved data safely.crypto— host entropy, signatures, key agreement, encryption and key derivation. Every crypto call returnsOk(value)orError({ kind, message }), exceptverify, which answerstrueorfalse, and nothing in crypto stops the program: take the value out with?(let key = crypto.random_bytes(32)?), or handle both withmatch. The crypto reference below gives every function's signature, what it returns, and which problem gives whichkind.random_bytes(n)gives 1 to 1024 fresh unpredictable bytes from the host — the one source of unguessable bits in the language, and the seedkeypairasks for;randis seeded and replays, which is what replay needs and what a key or a token must never be.keypair(seed, alg?)derives a key pair from a 32-byte seed (Ed25519 by default;"ES256"/"ES384"for ECDSA;"P-256","P-384"or"X25519"for key agreement),sign(message, secret, alg?)signs, andverify(message, sig, public, alg?)checks. The algorithm name is the JWT one:"RS256","RS384","RS512"and"PS256","PS384","PS512"(RSA, with a public key{ n, e }and a private key{ n, e, d }) and"ES256"/"ES384"(ECDSA P-256 / P-384, with a public key as SEC1 bytes or{ x, y }and a private key as its scalard). Keys are the fields of a JWK, decoded from base64url to bytes.crypto.verify(token_body, sig, jwk, "RS256")checks a token from Google, Auth0, Okta or Entra. An ECDSA signature is raw r‖s (64 bytes for ES256);verifyalso accepts DER.verifynever fails: a malformed key or signature, a message that is not bytes, and an unknown,HS*or"none"algorithm are all justfalse, since tokens and keys arrive from the network.check_key(key, alg)→Ok("public"),Ok("private")or anErrortells you why a key would be refused, for rejecting a bad entry when you load a key set. ECDSA signing mixes fresh randomness into the standard deterministic nonce, so the same message signs differently each time: test by verifying, not by comparing bytes. RSA keys are not generated here, so use ES256 or Ed25519 for new keys, and RSA keys may be any size from 2048 to 4096 bits. Every random value these use, such as the PSS salt, the OAEP seed and ECDSA's hedging, comes from the host, asrandom_bytesdoes, never from the seededrand.aes_gcm_encrypt(key, nonce, plaintext, aad?)andaes_gcm_decrypt(key, nonce, sealed, aad?)encrypt and authenticate with a 16-, 24- or 32-byte key (AES-128, AES-192 or AES-256) and a 12-byte nonce that must never repeat under one key (crypto.random_bytes(12)?); if anything was changed, decrypting gives anErrorand no plaintext at all.aes_ctr(key, counter, data, opts?)is plain counter mode for formats that specify it, such as encrypted zip ({ little_endian = true }for WinZip's counter); it encrypts and decrypts with the same call and does not detect tampering, so authenticate the result withhash.hmac. To process a large file in pieces, make every piece but the last a multiple of 16 bytes, and start each piece at the counter plus the number of 16-byte blocks already done. For encrypted JWTs (JWE) there arersa_encrypt/rsa_decrypt("RSA-OAEP"or"RSA-OAEP-256"),aes_key_wrap/aes_key_unwrap(A128KW / A192KW / A256KW) andaes_cbc_hmac_encrypt/aes_cbc_hmac_decrypt(A128CBC-HS256, A192CBC-HS384 and A256CBC-HS512, with a 32-, 48- or 64-byte key).aadis opaque bytes that the tag covers. For JWE it is the ASCII of the base64url-encoded protected header, the same text that goes in the token, not the header's JSON (RFC 7516 §5.1 step 14); passing the JSON gives a tag that checks against itself and fails everywhere else. The same holds foraes_gcm_encryptunder A128GCM/A192GCM/A256GCM.ecdh(secret, peer, curve)computes the shared secret for JWE's ECDH-ES on"P-256","P-384"or"X25519"(derive the key from it with JWE's Concat KDF); make an ephemeral key withkeypair(seed, "P-256"). The shared secret is always the curve's full length, leading zero bytes included, as JWE requires. A P-256 or P-384 key fromkeypairis the same shape for signing and for key agreement, but never use one key for both. Outside JWE, preferaes_gcm_encrypt.pbkdf2(digest, password, salt, iterations, length, opts?)derives a key from a password (PBKDF2, RFC 8018) over HMAC withdigest"sha1","sha256","sha384"or"sha512":lengthis 1 to 1024 bytes. Store a random salt (crypto.random_bytes(16)?) and the iteration count with the data. Because the count is then read back from that data, it is checked like data, and so is the work it asks for: PBKDF2 runs the whole count once for every digest-sized block of output (20 bytes for SHA-1, 32 for SHA-256, 48 for SHA-384, 64 for SHA-512), and the limit counts those iterations in all. By default it is 10,000,000 (2,500,000 for SHA-384 and SHA-512, whose iterations cost about four times as much), so no call runs for much more than a second. A count over the limit, or below 1, gives anErrorwith kindlimit, such asError({kind = limit, message = pbkdf2: 20000000 iterations is above this call's limit of 10000000. If the count comes from data you trust, raise it with { max_iterations = … }}). A format that needs more passes{ max_iterations = n }(up to 4,294,967,295), withncounted the same way: a 66-byte SHA-1 key is four blocks, so 1,000 iterations of it is 4,000. It counts one step per iteration in all against--max-ops, and checks that charge before it starts, so a budget stops a huge iteration count at once. Seeexamples/crypto-results.scua,examples/password-key.scuaand Verifying a save.dotenv—.envfiles.parse(s)returnsOk(table)of strings, orError(message).KEY=value,# comments, an optionalexportprefix,'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.envintoenv.getinstead, run with--env-file=PATH. See Read environment variables.tomlandini— config formats.parse(s)returnsOk(table)(a nested table you can path into withcfg @ "a/b") orError(message)on bad input;parse(s, T, opts?)givesOk(T), converted asparse(value, T)would, or anErrorholding the fault record.tomlis full TOML 1.0: arrays become arrays,[[sections]]an array of tables, dates adatetime(a bare local time like07:32:00has no SCUA type and reads as a string).toml.encode(table)writes TOML back out, keys sorted; exactdecimal/moneybecome 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.hash—sha256(data)/sha384(data)/sha512(data)→bytes(cryptographic digests, 32 / 48 / 64 bytes), pluscrc32(data, start?, end?)→intandfnv1a(data)→int(deterministic, non-cryptographic checksums).crc32(data, start, end)checksums only the bytesdata[start:end), in place, so a PNG chunk or zip entry inside a file you already read costs no copy; a range past the end stops with an error. They all take text or rawbytes, which is what makescrc32usable for the formats that mandate it — gzip, zip, PNG — where the data is compressed bytes and never text.hmac(algorithm, key, data)→Ok(bytes)orError({ kind, message })is the keyed signature behind TOTP codes, JWT HS256/384/512 and webhook signatures, withalgorithmone of"sha1","sha256","sha384","sha512":hash.hmac("sha256", secret, body)?. Like every crypto call it never stops the program; see the crypto reference.sha1(data)→bytes(20 bytes) is there for protocols that still require SHA-1, such as TOTP; it is broken for collision resistance, so never use it to check that content is what you expect.equal(a, b)→boolcompares two signatures or tokens in constant time: check a received signature withhash.equal(hash.hmac("sha256", secret, body)?, received), not==, which stops at the first byte that differs and so lets an attacker time their way to a valid signature. It never fails: it isfalsewhen either side is not bytes or a string (a missing header isnil) andfalsewhen either side is empty, since an empty signature is never a valid one.base64andhex— binary-in-text encoding:encode(s)→string, anddecode(s)→Ok(string)/Error(message)(match it or use?, since the input may be malformed).base64.encodetakes text or rawbytes— 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. Both must betrueorfalse. JWT, JWS, JWK, WebAuthn and OAuth PKCE all want both together.base64.decodeneeds no option: it accepts either alphabet, padded or not, because-_and+/are disjoint so there is nothing to disambiguate. Bothdecodefunctions returnbytes, 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 binarybytestype: conversions to/from string, hex, and int arrays (from_string/to_string/from_hex/to_hex/from_array/to_array;to_array(b, "u8")gives a typed{ u8 }, copied in one block),slice, fixed-width number reads (u8/u16le/u32be/f64le/…), andinflate(data, opts?)→Ok(bytes)/Error(message), which decompresses DEFLATE data:{ format = "zlib" }(the default: PNG image data and most protocols),"gzip"(.gzfiles) or"raw"(zip entries).{ max_bytes = N }caps the decompressed size and is checked as the output grows, so a small hostile input can't expand past it.{ size = N }gives the exact decompressed size: the result is built in place, and any other length is anError.{ ranges = [[start, end], ...] }decodes the stream made of those pieces ofdata, in order, without joining them (a PNG'sIDATchunks); each pair must lie withindata.{ as = "u8" }gives a typed{ u8 }instead ofbytes, built in place when the size is known (size, or a gzip file's recorded length) and copied once at the end when it isn't. An option it doesn't know is refused.deflate(data, opts?)→bytescompresses:datais abytesvalue or a{ u8 }array (from/topick a range of it),formatas forinflate,level0 (store) to 9, 6 by default.deflater(opts?)compresses in pieces:deflate_push(d, data, { from, to }?)as often as needed, thendeflate_finish(d)→ the same bytesdeflategives for the pieces joined. See the bytes section of Strings.int—parse(s)→Ok(int)/Error(message), the strict counterpart totonumber: an optional sign, decimal digits and_separators, and nothing else. It doesn't trim, doesn't read a0xprefix, doesn't fall back to a float, and a value outside the signed 64-bit range is anErrorrather than a wrap or a widening. TheErrortext names the fix. Use it on strings you didn't write. See Reading a number from text.decimal— named operations on the exactdecimaltype (the12.34dliteral 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 thedecimaltype, plus a currency layer:of(amount, code)→money(an amount tagged with an ISO 4217 currency, which travels with the value),amount(m)→decimalandcode(m)→string(the two parts back out —of(amount(m), code(m))ism, so use these rather than parsingformat, which renders a symbol and$names three currencies; on any money value they are also its methods,m.amount()andm.code(), with noimportneeded),parse(s)→Ok(decimal)/Error,format(amount)orformat(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--keyflags);parse_args(RecordType)→ typed record withwherevalidation (usage exit 2 on violation);write(s)→ writeswith no trailing newline, now — the unterminated write a carriage-return progress bar or spinner needs, on the same stream asprint(the bytes go wherever stdout goes, verbatim, so gate an animation on a flag rather than assuming a terminal, and don't mix it withemit/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 (0when 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. Clocks that move within a turn:time.wall()→datetimeandtime.mono_ms()→int(monotonic) read the clock on every call, for leases, deadlines and timing work;now()andtime.now()stay put within a turn (see Two clock modes). Read adatetimewith 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 plusoffset_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.next()→ a 64-bitint, from a per-partition counter that starts at a random point (unique in practice, not ordered between partitions, not a secret);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, orError(reason). Canonical spelling only: a UUID must be lowercase and a ULID uppercase, and the wrong case is refused rather than quietly corrected —parsenever 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. Preferuuid7()orulid()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 valuenow()returns — so it fast-forwards under--fastand starts at zero underscua 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 — usecrypto.random_bytes(32)for those. Not replayable — the bits come from the host, not from the seededrand, so a re-run produces different ids; use a seeded counter if you need ids a replay reproduces. See Generate unique ids.env— a capability module needing theenvgrant (scua --allow-env[=NAMES]); without it,import envis 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.fs— a capability module, present only when the host grants it (scua --allow-fs[=DIR], or a per-user folder with--allow-user=IDor--user-dir=PATH); without a grant,import fsis a compile error. Reads/writes within a rooted directory (no escaping; absolute paths and..are rejected); a path startinguser:is in the per-user folder and one startingres:is the program's own read-only data:read(path)→Ok(bytes)/Error,read_text(path)→Ok(string)/Error,write(path, content)→Ok(nil)/Error({ kind, message })(kindisexists,denied,not_found,bad_pathorio),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; each record is{ path, name, rel, is_dir, bad_name }),grep(pattern, path?, opts?)→Ok({ record })/Error(search file contents — token-economy optionsfiles_with_matches,count_only/matches,invert,max_per_filenarrow the output),grep_text(pattern, path?, opts?)→string(the same search rendered as one compactpath:line: textstring). It also hasmkdir,remove,remove_all,rename,lstatandsame_file; the stream verbsopen,read_chunk,read_line,write_chunk,seek,syncandclose;lock(path, { timeout, shared }?)→Ok(file)/Error({ kind, message }), a lock other running programs respect, released bycloseor when the program ends (kindistimeout,denied,not_found,bad_path,unsupportedorio);mountandwhereforres:layers; andmounts()→{ state, user, layers, missing }, wherestatenames the roots this run can reach ("host","res"or"user", joined by+) anduserisnilwithout the per-user folder, otherwise{ id, path, created }. See Read and write files, Ship data in packs and Save the player's progress.http— a capability module with two authorities that don't overlap. The client needs thenetgrant (scua --allow-net[=hosts]):get(url)andpost(url, body)→Ok({ status: int, headers: table, body: bytes })/Error({ kind, message }), restricted to the granted host allowlist. The responsebodyisbytes, not text — a response can be an image as easily as JSON.json.decodetakes it directly; for text usebytes.to_string(body)?, which validates the UTF-8 and so returns aResult. The server needs the separateservegrant (scua --allow-serve[=ADDR:PORT]):serve(handler, { bind })blocks and answers requests, callinghandler— a plainfn(req) -> resp— once per request.reqis{ method, path, headers, body }(header names lowercased,bodyisbytes); the response is{ status = 200, headers = {}, body = b"" }with abytesbody (a string body is a 500). A header value is a string, or an array of strings to send that header once for each item (severalset-cookielines); any other value is a 500 naming the header. A handler fault or returnedErrorbecomes a 500 whose JSON body carries a reference the server's log line also names (the text is withheld unless--serve-debug). Without the matching grant, the function isn't there to call (import httpcompile error, or an undefined name). See Fetch data over HTTP and Serve HTTP requests.net— a capability module; raw TCP sockets. Dialing out needs thenetgrant:dial(host, port)→Ok(connection)/Error(an opaque, non-sendable handle), with the host in the allowlist; it parks under--io=async, and returnsError("net.dial: the resolver is busy")if 64 host-name lookups are already waiting. Listening needs the separateservegrant (notnet):listen(addr, port)→Ok(listener)/Errorandaccept(listener)→Ok(connection)/Error(waits for the next client; parks under--io=async). Read and write either connection withread(conn, max)/write(conn, data)— both park under--io=asynctoo, and calls on one connection in the same direction complete in the order they were made;close(x)closes either a connection or a listener, and a call still waiting on it returnsError("the connection is closed")(Error("the listener is closed")for anaccept). See Raw TCP sockets and Serve HTTP requests.actors— addresses, snapshotting and reviving live actors, no grant needed. (snapshotwas calledfreezebefore 0.34; the old name is a compile error that names the new one.)me()→ the calling partition's own send-only reference (an error in the main script);send_only(a)→ a copy of a reference that can only send, equal to the original;sender()→ who sent the message a handler is handling,"main","host"or"actor:"and a locator;locator(a)→"actor:"and the 16 hex digits a reference prints as;stats()→ the runtime's counts (main script only).snapshot(a)→Ok(bytes)/Error({ kind, message })turns a running actor into a self-contained blob — its state, its queued mail, and a handler parked mid-turn — and leaves it running;drop(a)→Ok(bool)/Errorstops it and releases its memory (a snapshot without drop is a backup, not hibernation);revive(blob, opts?)→Ok(actor)/Errorbrings it back as the same actor, so references handed out before the snapshot still reach it, with the capabilities a newly created actor would get — refused withalready_livewhile that actor is running, unlessoptssays{ fork = true }for a copy with its own identity, and{ generation = g }passes the number your store claimed;generation(blob)→Ok(int)/Erroris a number that climbs with every snapshot and keeps climbing across a revive — store it beside the bytes and refuse a write whose generation is not higher, so a process that woke an old copy cannot overwrite newer state.blob_version(blob)→Ok(int)/Erroris the actor format the blob was written in (this version reads 7; 0.28 and 0.29 wrote 6), read without reviving it.e.kindis one ofgone/misuse(a send-only reference) /already_live/stale_generation/not_granted(this partition was not given the right to revive, or to fork:{ fork = true }needs--allow-fork) /busy/host_resource/sandbox/bad_state(a snapshot refused because what the actor holds is not its partition's state, so no revive could load it) /bad_blob/truncated/corrupt/old_format/newer_format/schema_mismatch/identity_refused; a revive finds each record type the actor holds by name and refuses (identity_refused, naming it) one whose fields changed. A blob carries data and a parked turn, never code: revive it with the program that wrote it, or a later version of that program. Not every edit is safe yet; Editing your code while sessions are frozen lists the ones that refuse the load or leave a revived actor with old values. See Partitions and the actor model.sandbox— a capability module needing thesandboxgrant (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'sfn main(input)and destroys the partition);actor(source, state?, opts?)→Ok(actor)/Error(record)loads code you call repeatedly (the source definesfn handler(state, msg)) and gives back an ordinary actor totell/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.optsis{ name, fuel, memory, max_source, max_protos }, defaulting to frame-sized bounds. AnErrorcarries{ kind, line, message, name }wherekindiscompile_failed/no_handler/source_too_large/too_many_prototypes/out_of_fuel/out_of_memory/faulted. See Run code in a sandbox.udp— a capability module needing theudpgrant (scua --allow-udp[=PEERS]); datagrams, which are unreliable, unordered, and unencrypted.connect(host, port)→Ok(socket)/Erroropens a socket locked to one peer;bind(addr, port)(also needs--allow-serve) opens one that hears from anyone. Send withsend(sock, data)/send_to(sock, host, port, data), receive withrecv(sock, opts?)→Ok({ peer, data })/Error, answer a peer that reached you withreply(sock, peer, data). In a receive loop userecv_batch(sock, max, opts?)rather thanrecv.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.cluster— a capability module needing theclustergrant (scua --allow-cluster[=SECRET]); without it,import clusteris 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, thenset(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-tabledelete, which leaves nothing behind),subtree(t, prefix)→ table (the live keys underprefix/, 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 off(x)for each element.list.filter(xs, pred)— a new array of the elements wherepred(x)is true.list.reduce(xs, f, init)— fold left: starts frominitand computesacc = f(acc, x)across the elements. Note the argument order: the function comes before the initial value.list.find(xs, pred)— the first element wherepred(x)is true, ornil.list.any(xs, pred)— true ifpred(x)holds for at least one element.list.all(xs, pred)— true ifpred(x)holds for every element.list.each(xs, f)— callsf(x)for each element and returns nothing (for side effects).list.count(xs, pred)— how many elements satisfypred.list.sort(xs, less)— a new array sorted by aless(a, b)comparator (returns true ifacomes first). Stable; the original is untouched.list.sum(xs)— the sum of the elements.list.min(xs)/list.max(xs)— the smallest / largest element, ornilfor an empty list (which has no smallest element — returning 0 would be a claim). The globalsmin/maxtake several arguments (min(a, b, c)), which is a different question; these take one collection.list.mean(xs)— the average of the elements, ornilfor an empty list.list.reverse(xs)— a new array in reverse order.list.index_of(xs, v)— the index of the first element equal tov, ornil.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.
#Crypto reference
Every crypto call returns Ok(value) or
Error({ kind, message }), except
crypto.verify and hash.equal, which answer
true or false. No crypto call stops the
program, whatever it is given, so a hostile token, key file or archive
can never choose to stop it either. message is a sentence
for a person, led by the function's name
(random_bytes: n must be 1 to 1024, got 0), except
check_key's, which is the reason a key is refused and
nothing else; it never contains key material. kind is one
of these, and both are always strings, so e.kind and
e.message always work:
kind |
Meaning |
|---|---|
"rejected" |
Data from outside, a key, or an algorithm, curve or digest name did not check out. |
"misuse" |
This program's own working value has the wrong shape: a count, the nonce or iv you encrypt with, data that is not bytes, an options table. A mistake to fix. |
"limit" |
A limit the call sets, which you can raise, was reached
(pbkdf2's work). |
"unavailable" |
The host could not do it now: it has no entropy source. |
misuse is decided by which argument is wrong,
never by the data. Every key, secret and seed, every algorithm,
curve or digest name, and every argument of a call that opens something
(aes_gcm_decrypt, aes_cbc_hmac_decrypt,
aes_key_unwrap, ecdh,
rsa_decrypt, check_key, and
aes_ctr, which runs both ways) gives rejected:
programs load those from files, key sets and tokens. So a hostile input
cannot make a call claim a bug in your program, and you can treat
e.kind == "misuse" as a bug and every other kind as bad
data. If your program feeds outside data into an argument marked
misuse below, validate it first; until then the
misuse is accurate. Under scua test and
scua debug, a misuse Error also prints a
warning naming the call. Across an ask or a sandbox
boundary, misuse and limit arrive as
upstream_misuse and upstream_limit, so
"misuse" in your code always means a call your own code
made. At the top level of a script, ? on a crypto
Error stops it with exit 1 and
{"error":{"kind":"misuse","message":"random_bytes: n must be 1 to 1024, got 0"}}
on stderr.
| Function | Returns | misuse |
rejected |
Other kinds |
|---|---|---|---|---|
crypto.random_bytes(n: int) |
Ok(bytes) |
n not an int from 1 to 1024 |
unavailable: no host entropy |
|
crypto.keypair(seed: bytes, alg?: string) |
Ok({ public: bytes, secret: bytes }) |
a seed that is not 32 bytes or makes no key; an unknown,
HS* or RSA algorithm |
||
crypto.sign(message, secret, alg?) |
Ok(bytes) |
a message that is not bytes or a string | an unknown or HS* algorithm; a secret key of the wrong
length or shape |
unavailable: no host entropy |
crypto.verify(message, sig, public, alg?) |
true or false |
never: anything malformed, an unknown, HS* or
"none" algorithm, or a message that is not bytes is
false |
||
crypto.check_key(key, alg) |
Ok("public") or Ok("private") |
anything: the key, or the algorithm name | ||
crypto.aes_gcm_encrypt(key, nonce, plaintext, aad?) |
Ok(bytes): ciphertext then tag |
a nonce that is not 12 bytes; a plaintext or extra data that is not bytes or a string | a key that is not 16, 24 or 32 bytes | |
crypto.aes_gcm_decrypt(key, nonce, sealed, aad?) |
Ok(bytes) |
anything; a key of the wrong length gets the same message as a failed tag | ||
crypto.aes_ctr(key, counter, data, opts?) |
Ok(bytes) |
a malformed options table | a key, counter or data of the wrong length or kind | |
crypto.aes_cbc_hmac_encrypt(key, iv, plaintext, aad?) |
Ok(bytes): ciphertext then tag |
an iv that is not 16 bytes; a plaintext or extra data that is not bytes or a string | a key that is not 32, 48 or 64 bytes | |
crypto.aes_cbc_hmac_decrypt(key, iv, sealed, aad?) |
Ok(bytes) |
anything; a key of the wrong length gets the same message as a failed tag | ||
crypto.aes_key_wrap(kek, key) |
Ok(bytes) |
a key to wrap that is not bytes, not a multiple of 8, or under 16 bytes | a key-encryption key that is not 16, 24 or 32 bytes | |
crypto.aes_key_unwrap(kek, wrapped) |
Ok(bytes) |
anything | ||
crypto.rsa_encrypt(data, public, alg) |
Ok(bytes) |
data that is not bytes, or too long for the key | an unknown algorithm; a public key that is not usable | unavailable: no host entropy |
crypto.rsa_decrypt(data, secret, alg) |
Ok(bytes) |
anything; every bad ciphertext gives the same message | ||
crypto.ecdh(secret, peer, curve) |
Ok(bytes) |
anything: an unknown curve, your own key, and any problem with the peer key, which is one message, "key agreement failed" | ||
crypto.pbkdf2(digest, password, salt, iterations, length, opts?) |
Ok(bytes) |
a malformed options table | an unknown digest; a length out of range; a password, salt, count or length of the wrong kind | limit: a count below 1, or more work than the
limit |
hash.hmac(algorithm, key, data) |
Ok(bytes) |
data that is not bytes or a string | an unknown algorithm; a key that is not bytes or a string | |
hash.equal(a, b) |
true or false |
never: either side not bytes, or empty, is false |
The digests (hash.sha1, sha256,
sha384, sha512) and the checksums
(hash.crc32, fnv1a) still return their value
directly: their only problem is an argument that is not bytes or a
string.
Handle a crypto Result where it is made.
? and match take the value out; a default with
?? is never a right key, nonce, MAC or signature, and gets
a warning. When code passes a crypto call's Result on without taking the
value out — to another function, into a field or an array, inside
Ok(…), into a comparison, or out of a helper that returned
the value before — scua warns, naming the call and the fix,
in your own files and once per package in a dependency:
scua: tokens.scua:7: warning: `crypto.random_bytes` returns Ok or Error, and this passes the Result on to `b64`, not the bytes. Take the bytes out first: `crypto.random_bytes(n)?`.
`?`, `-> Result` and a `Result` annotation need scua 0.32 or later. A package that also supports scua 0.31 wraps each crypto call instead: see "Supporting scua 0.31 and 0.32 in one package" in the manual's crypto reference.
A variable that holds a crypto call's Result keeps holding a Result:
giving it a plain value later, such as the value inside an
Ok arm, is refused before anything runs. Take the value out
where the variable is declared
(let z = crypto.random_bytes(32)?), or give the new value
its own name.
A helper that hands a crypto call's Result on as it is, on purpose,
says so with -> Result:
import crypto
fn session_id() -> Result
return crypto.random_bytes(16)
end
match session_id()
Ok(id) -> print(len(id))
Error(e) -> print(e.kind, e.message)
end
$ scua session.scua
16
A crypto Error carries crypto's kinds.
If your package has its own error vocabulary, match the
crypto call and return your own Error, rather than passing
crypto's on with ?, so code that reads your package's
kind only ever meets your words:
import crypto
-- This package's Errors use its own kinds, such as "bad_key".
fn load_key(key)
match crypto.check_key(key, "ES256")
Ok(_) -> return Ok(key)
Error(e) -> return Error({ kind = "bad_key", message = e.message })
end
end
match load_key(b"not a key")
Ok(_) -> print("loaded")
Error(e) -> print(e.kind, e.message)
end
$ scua keys.scua
bad_key not a usable P-256 public key: it is not a point on the curve, or it is the point at infinity
#Supporting scua 0.31 and 0.32 in one package
The forms above (? on a crypto call,
-> Result, a Result annotation, and
? at the top level of a script) are scua 0.32's. On scua
0.31, several crypto calls return their value directly
(random_bytes, keypair, sign,
aes_gcm_encrypt, aes_cbc_hmac_encrypt,
aes_key_wrap, rsa_encrypt,
hash.hmac), so ? on one stops the program
("? needs an Ok or Error value, got bytes"), and a
match with only Ok and Error arms
stops it too, with the value, which may be key bytes, in the fault
message. Result is not a type there at all.
If your package declares that it needs scua 0.32 or later
(scua-version = "0.32" under [package] in
scua.toml; see Requiring a minimum
scua), use the 0.32 forms directly. A package that also supports
0.31 puts each crypto call it makes behind a small wrapper, in one
module, that gives Ok or Error on both: a
match with a third arm that catches 0.31's plain value. It
compiles on both releases, and scua 0.32 gives no warning for it:
import crypto
-- The package's crypto calls, as a Result on every scua it runs on.
fn random_bytes(n)
match crypto.random_bytes(n)
Ok(b) -> return Ok(b)
Error(e) -> return Error(e)
b -> return Ok(b) -- scua 0.31 gives the bytes themselves
end
end
-- An Error's payload is a string on 0.31 and `{ kind, message }` on 0.32.
fn text(e)
if type(e) == "table" then return e.message end
return tostring(e)
end
fn new_token()
match random_bytes(16)
Ok(b) -> return Ok(len(b))
Error(e) -> return Error(`could not draw a token: {text(e)}`)
end
end
print(new_token())
$ scua token.scua
Ok(16)
Three more things that make such a package correct on both:
- Keep the checks 0.31 needs. On 0.31 a crypto call still stops the program on a bad argument (a key of the wrong length, an unknown algorithm), so keep any length or name check you make before calling. On 0.32 they are harmless.
- Turn a crypto
Errorinto your package's own. A cryptoErrorholds{ kind, message }, with crypto's kinds. If your package's own Errors have another shape, or akindfield with your own words in it, translate the crypto one at the call, asnew_tokendoes above, rather than passing it on with?: code that reads your package'skindshould only ever see your package's words. scua 0.32 refuses a function that returns an Error of its own on one path and passes a crypto Error on with?on another, and shows thematchthat maps it. - Use the wrapper's name, not
crypto., everywhere else in the package, so there is one place to delete once you drop 0.31: each wrapper then becomes the plain call with?after it.