Once a program outgrows a single file, you split it into modules. A
module is an ordinary .scua file that hands back some
values for other files to use. You load one with import,
and you reach into it with field access.
#A module is a file that returns its exports
Any .scua file whose top-level code ends in a
return is a module. Whatever it returns is what other files
see when they import it. The idiom is to return a record of
functions.
Put this in dice.scua:
-- dice.scua — a small module. Its exports are the record it returns.
fn roll(sides, n)
let total = 0
let i = 0
while i < n do
total = total + (i % sides) + 1 -- deterministic stand-in for a real roll
i = i + 1
end
return total
end
fn label(total)
if total >= 18 then return "great" else return "ok" end
end
return { roll = roll, label = label }
The two functions are private to the file until you name them in the returned record. The record is the public surface; anything you leave out stays internal.
The exports are read-only from outside the module. Every file that
imports dice shares the one record, so
dice.roll = … is a compile error, and the same write
through a function parameter that holds the module stops with an error
when it runs. Every value a module exports is frozen when its top level
finishes, all the way down, as a const is: a table or array
written straight into the returned table
(return { sizes = [1, 2, 3] }) as much as one from a
const. So a file that imports it can't change it, and the
library keeps behaving as its author wrote it. Functions stay callable.
The module's own code can't change an exported table either, once its
top level has finished, so keep a value that changes in
state and export a function that reads it. The error names
what you wrote and gives you a copy to change instead: for
chart.palette[0] = "green" it is
let my_palette = chart.palette[0:], and for a table, a loop
that copies its fields into one of your own. When the write goes through
a variable of yours (let p = chart.palette and then
p.push("green")), the error names p, and the
copy goes back into it: p = p[0:], after which the same
line runs. A module's names also stay in its file: two modules can each
have their own helper without meeting, and a module that
declares its own round hides the built-in only inside that
module, with the warning described in Your names and the
built-ins (none for a name the module exports).
#Importing a module
import dice looks for dice.scua next to the
file doing the import, runs it once, and binds its exports to the name
dice. You then call its functions as fields, with
dice.member(...).
Put this in game.scua, in the same folder as
dice.scua:
import dice
let rolled = dice.roll(6, 4)
print(`rolled {rolled} ({dice.label(rolled)})`)
Run it:
$ scua game.scua
rolled 10 (ok)
The name after import is both the file to load (without
the .scua) and the name you use to reach the exports.
Imports resolve relative to the importing file: import dice
in game.scua means the dice.scua beside it,
and import helper inside lib/mid.scua means
lib/helper.scua if there is one, wherever you run the
command from. A module in a subdirectory is named by its path,
import lib/mid, and binds its last segment
(mid) unless you write as.
#Exporting a type, not just functions
A module's return record is its value surface.
Its enum and flags declarations are its
type surface, and consumers name those through the import alias
— you do not list them in the returned record.
Put this in traffic.scua:
enum Signal {
Go,
Caution(int),
Stop(string),
}
fn next(tick)
if tick % 4 == 0 then return Signal.Go end
if tick % 4 == 1 then return Signal.Caution(3) end
return Signal.Stop("roadworks")
end
return { next = next }
And name the type from the file that imports it:
import traffic
match traffic.next(1)
traffic.Signal.Go -> print("go")
traffic.Signal.Caution(secs) -> print(`caution for {secs}s`)
traffic.Signal.Stop(reason) -> print(`stop: {reason}`)
end
Three things to know:
alias.Enum.Variantworks everywhere the local spelling does — in amatchpattern, and as an expression that constructs the variant.traffic.Signal.Stop("roadworks")built here is the same valuetraffic.nextbuilds there.- Exhaustiveness crosses the boundary. Drop an arm and the compiler names the variant you missed.
type Local = traffic.Signalis an abbreviation, not a second type. Use it when repeating the alias in every arm gets noisy; the two spellings are interchangeable and compare equal.
An enum belongs to the module that declares it.
traffic.Signal and a Signal you declare
yourself are two different types, even if the variants are spelled the
same, and so are two libraries' enum Outcomes: you can
import both, a value of one never matches the other's patterns, and the
compiler refuses to pass one where the other is expected. Printing a
value shows the declared name (Signal.Stop("roadworks")),
not the module.
If you have code that works around the old limits by redeclaring a library's enum verbatim in your own file, delete the copy and name the original: since 0.26 the copy is a separate type, so a value the library builds silently matches none of the copy's arms. The compiler hints when it sees an identical declaration in two places. Saves written by an older release that hold values of a module-declared enum are refused on load with a schema mismatch; saves holding only your own file's enums load as before.
#Naming a module's record type
A library's record types are reachable the same way, as
alias.Record. You never needed this to read one
back: a record is structural, so your own annotation already checks a
value a module hands you. Where it matters is when you have to
build one. A library whose
transaction(body) expects the callback to return its own
Error type used to leave the caller two bad choices,
hand-writing { code = …, message = … } and drifting from
the real declaration, or asking every library to ship a constructor. Now
the caller names the type:
import sqlite
let result = sqlite.transaction(fn()
let err: sqlite.Error = { code = 5, message = "abort" } -- checked against the library's declaration
return err
end)
The literal is checked field by field against the library's
record Error, an unknown field is refused, and the instance
you build is the same runtime type the library builds:
its methods dispatch on it (err.describe() resolves to the
library's describe), it is sealed, and any
where refinement the library declared holds for your
instance too. A record the module does not declare is reported with the
list of types it does. type Local = sqlite.Error
abbreviates it, exactly as for an enum.
An annotation checks records inside records too, wherever
they were declared.
let doc: pdf.Document = json.decode(input)? checks every
page, and every item on every page, against the library's declarations
and their where rules, just as it would for a type declared
in your own file. A list of records written out in code
({ pages = [{ … }, { … }] }) is checked element by element.
An enum case that carries one of the library's records is built the same
way, pdf.Block.Image({ … }), and its record is checked
too.
A value a module returns from a literal in a typed return
(fn make() -> Error return { … } end) is the record it
says it is: sealed, its defaults filled, its methods callable from any
file, exactly as a typed let would have built it. An export
may give a method a second name
(return { query_frame = to_frame }); the method answers to
both, db.query_frame(sql) and
sqlite.to_frame(db, sql). A member named after a built-in
(frame, keys, len) dispatches on
the library's own record like any other method; on a value that is not
that record, db.frame(sql) calls the built-in, and when the
arguments do not fit it the error says which won and how to reach the
library's function (sqlite.frame(db, sql)).
#Modules are type-checked
A module you import is type-checked just like the file you run — same rules, same strictness. Its errors are reported against its own file and line:
$ scua game.scua
scua: dice.scua:4: type error: value is int, but the binding is declared string
That is worth knowing before you publish something. Annotations,
where refinements and arity inside a package are enforced,
so a wrong-arity call in a library is a compile error rather than an
argument that silently binds nil. It also means adding a
dependency can surface its type errors in your build. If the
module is yours, fix it. If it is not, and you need to ship now, pin the
toolchain version you were on and file the bug with the file and line
the error gave you — a module inside deps/ cannot be edited
in place, because that would break the hash check its lock entry is
holding it to.
A package written for an older SCUA can also break a rule this
version added, such as a variable at a module's top level. The error
then names the package and its version and tells you to upgrade it,
rather than offering an edit to a file in deps/ that the
hash check would refuse. That takes two commands:
scua upgrade @scope/name raises the version in your
scua.toml and scua.lock, and
scua vendor puts the new release in deps/. If
deps/ still has the same version afterwards, no release
made for this SCUA exists yet. Its maintainer needs to publish one, and
until then the project stays on the SCUA it last ran on.
A file that opts out of checking with a leading
--!dynamic stays opted out when it is imported. That means
"do not check me"; its enums are still nameable.
A package's warnings are a different matter from its
errors. You can't fix them except by upgrading the package, so the
warnings of a module that comes from your lock (deps/) or
from a package in an application bundle are quiet: not printed, and
never a reason for --deny-warnings or the package's own
--!strict to stop your run. Pass --dep-warnings
to see them, and add --deny-warnings to make them count.
Modules beside your script and on --mod-path are your own,
and their warnings show.
Runtime faults follow the same rule: a fault raised inside a module names the module's file, and every line of the backtrace names the file that frame is in.
#Keeping modules in another folder
When shared modules live somewhere other than next to the importing
file, point the runner at the extra folder with
--mod-path:
$ scua --mod-path libs game.scua
Now import dice is tried next to game.scua
first, then in libs/. Repeat --mod-path for
several folders; they're searched in the order given. A module that two
of those places both provide is refused rather than silently picked;
remove the duplicate, or choose one with --mod-override.
The built-in modules (math, list, …) always
resolve first, and a file of your own that carries a built-in's name is
refused the moment you import that name, instead of being silently
ignored. See the CLI
reference for details.
--mod-path is the only way to reach another
folder. A module name is one or more name segments joined
by /, so import sub/helper is fine (it binds
helper) and a spec that reaches upward,
../other/thing, is refused rather than quietly walking out
of the folder it was searched in. That keeps a search directory an
actual boundary, which matters the moment you run a script somebody else
wrote.
#Modules you depend on by name: packages
--mod-path is for code you already have on disk. For
code you depend on by name and version — someone else's, or
your own shared across projects — SCUA has a package manager,
scua-pkg, and the toolchain dispatches to it:
$ scua add @acme/json # records a minimum in scua.toml, resolves, and writes scua.lock
$ scua vendor # materialises deps/
$ scua main.scua # just works — no --mod-path, no wrapper command
That last line is the point: once deps/ exists, running
your program is the ordinary command. SCUA finds your project by looking
for scua.toml next to your file and upward — stopping at
the top of your repository, so a stray manifest somewhere above can
never capture your script — and resolves imports from the lock.
It checks what it loads. Every dependency file is
hashed against the record that shipped with it, and that record is
hashed against your lock. If anything in deps/ has been
edited since it was fetched, the program refuses to run and says which
file. That check is on by default and costs about a millisecond.
A script that ships inside a package, such as its own dev tool or an
example, runs as part of that package:
scua deps/acme/tool/tool_dev.scua with
import tool gets the package's checked copy of
tool, the same one your program gets. The script itself is
checked too: if it was edited after the package was fetched (or the
release never shipped it), it is refused before it runs, as an edited
module would be.
If you are deliberately testing a local copy of a dependency,
--mod-override name=DIR replaces exactly one module and
says what it replaced. The files in DIR are
not checked against your lock, which is what makes it
useful for editing a dependency, so keep it for that. Pointing
--mod-path at something the lock already provides is
refused rather than silently preferred, so you always know which copy
you got.
Four things are worth knowing before you reach for it:
- Nothing floats. A dependency records a minimum, and the version you get is the highest minimum anything in your graph asks for. Publishing a new release moves nobody until someone raises a minimum on purpose.
- No code runs at install time. There are no install scripts and no executable manifests — adding a dependency cannot execute anything its author wrote. Adding native power is still a capability grant, exactly as above.
- A registry is a directory. There is no account to
create;
scua publish --registry ~/some-dirworks offline, and the same layout serves from a share or a CDN. - It is a separate binary.
scua-pkgships on its own release stream, so upgrading the toolchain never drags package infrastructure with it — and running programs never needs it installed at all. If it is missing,scua addsays so and points at the install. A scua newer than yourscua-pkgwas verified against still works (from scua-pkg 0.3.10 it prints a one-timeW-TOOLCHAIN-NEWERnote and carries on); a scua older than its window is refused, because it may not read what scua-pkg writes.
If scua add reports an I/O error that makes no
sense — "check permissions and free space" when nothing is
wrong with either — your scua-pkg is probably older than
the lock in front of you. Early versions did not have a message for
"this lock was written by a newer package manager" and fell back to a
generic one. Upgrading scua-pkg fixes it; nothing is wrong
with your project.
#Requiring a minimum scua
If your project relies on something a recent scua added, say so in
its scua.toml:
[package]
name = "@acme/report"
scua-version = "0.31"
scua-version is the oldest scua your code runs correctly
on: "0.31" means 0.31.0 or later, and "0.31.2"
means 0.31.2 or later. Write just the version, with no
>=, range or pre-release; anything else is refused with
an E-MANIFEST error rather than read as "no
requirement".
A scua older than that refuses to start, before it reads a line of
your code, and exits with status 3:
scua: this project needs scua 0.31 or later (scua-version in its scua.toml); this is 0.30.2. Upgrade scua.
Packages declare it the same way. When you scua add one,
scua-pkg copies its requirement into your lock, so a
package that needs a newer scua is named when it stops your program, and
you get a choice:
scua: @scua/jwt 0.7.0 needs scua 0.31 or later; this is 0.30.2. Upgrade scua, or pin @scua/jwt to an earlier version.
A scua built from source rather than downloaded as a release is let through for a requirement up to the next minor version, since that is the version it is being built towards, and prints a one-line note saying so.
A script with no scua.toml above it is never checked: it
just runs. So is a project whose scua.toml has no
scua-version.
scua 0.30 and earlier do not know
scua-version. In a project with dependencies that
doesn't matter: a lock carrying a requirement of 0.31 or later is one
those versions refuse to read. A project with no
dependencies has no lock, though, so on 0.30 or earlier its
scua-version is ignored and the program runs anyway.
scua-pkg documents the same limit.
To hand a finished tool to someone else as a single file,
scua-pkg bundle --app main.scua writes an application
bundle that scua runs directly, with the same imports and
the same checks. See Ship
a tool as one file.
See the scua-pkg documentation for the full model.
Coming from Lua: where modules come from, vs. what they can touch
If you've used Lua's require, the
--mod-path directories above are SCUA's analog of
package.path: an ordered list of places a module name is
looked up, first match wins. Lua keeps that list in a mutable global you
can rewrite at runtime; SCUA keeps it per-program (and, for isolated
partitions, per-partition), and today you set it on the command line. A
future release adds setting it in code for trusted programs —
closer to Lua's dynamic feel, but scoped to the partition rather than
the whole process, so one tenant can't change another's lookups.
There's one place SCUA deliberately parts ways with Lua, and it's
worth understanding up front. Lua's path system does
two jobs: it finds your .lua files
and, via package.cpath, it dynamically loads
native .so/.dll C libraries — arbitrary native
code with full machine access. SCUA splits those two jobs apart on
purpose:
- Finding code is what
importand the search path do. What they load is always SCUA source — never a native binary. There is nocpath, and no "drop a compiled library on the path and load it." - Reaching native power — the network, the
filesystem, a database, the clock — is a separate
system: capabilities.
You don't load a library to get network access; the host running your
program grants a
net(orfs,http, …) capability to the partition, off by default. The granted capability arrives as an ordinary import (import net) that simply isn't there unless it was granted.
The short version: the search path decides where your code comes from; capabilities decide what that code is allowed to touch. Lua bundles both into the dynamic path, which is convenient but is exactly why Lua code is hard to sandbox — any module can pull in a native library and do anything. Keeping them separate is what lets SCUA run untrusted code safely. (Genuine native extensions — your own C compiled against SCUA — are a possible future via a stable host ABI, but a deliberate, gated one, not a path you load binaries off of.)
#Renaming an import
If the module name clashes with something else, or you just want
something shorter, rename it with as:
import dice as d
print(d.roll(20, 1))
$ scua game2.scua
1
d now refers to the same exports dice would
have.
#What an import binds
import name gives you exactly the value the module
returned, under the name name. If a module returns a
record, you get a record and reach members with a dot. A module can
return anything, though: a single function, a number, a record of
records. The convention is a record of functions because it reads well
at the call site (geom.add(...),
dice.roll(...)) and keeps related code together.
A module's top-level code runs once, the first time it's imported. If two files import the same module, they share the one result.
#State in a module
A module's top level holds functions, types and constants. It holds
no let:
a module's top level can't hold a `let`. Use `const count = …` if it never changes, or `state count = …` if each partition should keep its own copy
Every such line in every file your program imports is reported at once, so you can move a module over in one pass.
Most top-level values in a library never change after the file runs:
a table of names, a lookup built once, a default. Those are
const.
A lookup table that a loop fills is the one that needs a different
shape. In the main script you'd build it in a let and then
freeze it, but a module's top level can't hold the let.
Build it in a function instead, and keep what the function returns in a
const:
-- colours.scua
const NAMES = ["red", "green", "blue"]
fn build_index()
let index = {}
for i, name in NAMES do index[name] = i end
return index
end
const INDEX = build_index()
fn index_of(name) return INDEX[name] end
return { index_of = index_of }
The function returns the table as it is, with no freeze:
a const always holds a frozen copy of its value. Written
with a let and a loop at the top level, the module is
refused, and the error shows this fix with your table's name:
scua: colours.scua:3: a module's top level can't hold a `let`, and `INDEX` is filled after it's declared. Build it in a function that returns it: `fn build_index() let index = {} … return index end`, then `const INDEX = build_index()` if it never changes after that, or `state INDEX = build_index()` if each partition should keep its own copy
When a library does need something that changes — a cache, a counter,
a list of things in flight — it declares it with state:
-- tween.scua — a small library that keeps track of running tweens
import ids
state active = {}
fn start(obj, to, secs)
let id = ids.next()
active[id] = { obj = obj, to = to, left = secs }
return id
end
fn running() return len(active) end
return { start = start, running = running }
One per partition, created on first use. Every partition that uses
tween gets its own active table, and the main
script has one too. Nothing is shared between them: a tween started in
one zone is not running in another. A partition that never calls into
tween never creates one, so it costs nothing until it is
used.
import tween
partition Zone
on Spawn(what)
tween.start(what, 10, 2)
print(`{what}: {tween.running()} running here`)
end
end
tween.start("title", 1, 1)
tell Zone().Spawn("goblin")
tell Zone().Spawn("bat")
print(`main script: {tween.running()} running`)
$ scua game.scua
main script: 1 running
goblin: 1 running here
bat: 1 running here
Each copy starts from the declaration, so fill it in a
function. A partition makes its copy of active
from the line that declares it, not from what the module's top level did
afterwards. The top level runs once, for the file that imports the
module, so a loop there that filled a state would fill that
one copy and leave every partition's empty. The compiler refuses such a
write and shows the fix. A write the compiler can't see, because the top
level calls a function that does it (fill(), or
fill(INDEX)), stops the program when it runs, with the same
message. While the top level runs, it reads its state as
declared, and can't change it. Build the table in a function, as
build_index does above, and start the state
from it: state INDEX = build_index(). Every copy is then
built the same way. A write inside one of the module's functions, like
start above, is fine once the top level has finished: it
changes the copy of whichever partition called it.
scua: colours.scua:4: `INDEX` is this module's `state`, and the module's top level fills it. The top level runs once, for the file that imports the module, so each partition's own copy would stay as declared. Build it in a function that returns it: `fn build_index() let index = {} … return index end`, then `state INDEX = build_index()`, and every copy is built the same way
Private to its module, by name. active
is a name only tween.scua can write. That is a naming rule,
not a wall: whatever the module hands out (a table it returns, a value a
caller passes back in) is an ordinary value. The walls are the partition
and the sandbox.
How it differs from let and
const.
| where | who sees it | |
|---|---|---|
const |
anywhere | the same frozen value, in every partition and the main script |
let at the top level |
the main script only | the main script; a partition can't reach it |
state in a module |
a module's top level | each partition (and the main script) its own copy |
state in a partition |
inside partition … end |
that one partition |
Why the main script has no top-level
state. The main script keeps its changing values
in let. A top-level state there would quietly
give every partition its own copy of what looks like one counter, so a
partition that adds to it would add to its own, and the main script
would never see the change. The compiler refuses it and says to use
let; a partition that needs a counter keeps it in its own
state block.
Typed, like let.
state count: int = 0 is checked against its type when it is
created, the way a typed let is.
Initialisers. The value after = is
computed in whichever partition first touches the field, at that moment
— not when the module is imported:
- It can't wait. An initialiser that contains an
ask, await_foror awaitis a compile error, because it runs in the middle of whatever code first used the field. - If two fields' initialisers each need the other, the first touch faults, naming both fields.
- If an initialiser faults, the field is not created. The fault reaches the code that touched the field, and the next touch tries the initialiser again.
A state in a partition with the same
name. Inside a partition, a state
field declared in its block is the one its handlers mean. The module's
top-level field of the same name is a different variable, out of reach
inside that partition, and SCUA warns where the partition declares it,
the way it warns when a declaration hides a built-in. The warning never
stops a program; rename one of the two if you meant them to be different
things.
Saved with the partition. When a partition is snapshotted,
its copy of each module's state goes into the blob with it,
keyed by the module and the field's name, and a revive gives it back. A
field the actor never touched is created on first use after the revive,
as before.
For library authors. Keep anything your library
changes in state, never in a value you handed out. A
function that uses the module's state doesn't capture it
(each partition reaches its own copy when it calls), so such a function
can still be held in a const. An id that must be unique
everywhere — across partitions, processes and restarts — comes from
ids.next(): a 64-bit int from a per-partition
counter that starts at a random point, so no partition needs to ask
another. It is a name, not a secret: it is easy to guess the next one
from the last. For a session token, a reset link or anything else where
guessing is the attack, use crypto.random_bytes, as Generate
unique ids shows.
#Related pages
- Functions and closures for the
fnsyntax these modules are built from. - Records and gradual types for the record literal that holds the exports.
- Partitions and the actor model, the other way SCUA splits a program up: not by file, but by isolated unit of state.