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.
#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 total = dice.roll(6, 4)
print(`rolled {total} ({dice.label(total)})`)
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, so
import dice always means the dice.scua sitting
beside game.scua, wherever you run the command from.
#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.
If you have code that works around this by redeclaring a library's enum verbatim in your own file, it still works — that is why it worked — but you no longer need it. Delete the copy and name the original, and you stop having two declarations that can drift apart. (If they have drifted, the compiler now tells you: two different enums with the same name, visible in one file, produce values that match each other's patterns, so it reports them rather than letting the mix-up run.)
Record types are not exported this way, and do not need to be: a record is structural, so your own annotation checks a module's record already.
#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 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.
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/, and uses the first match. Repeat
--mod-path for several folders; they're searched in the
order given. The built-in modules (math, list,
…) always resolve first. 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 / — letters, digits, ., _ and
- — so import "sub/helper" is fine and
import "../other/thing" is refused rather than quietly
walking up 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.
If you are deliberately testing a local copy of a dependency,
--mod-override name=DIR replaces exactly one module and
says what it replaced. 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.
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.
#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.