Project Configuration
Every Saule project has a saule.config file at the root:
name: "myproject"version: "1.0.0"entry: "main.sau"src_dirs: ["src"]dependencies: ["../shared-lib", "~/code/json", "lauriszz123@saule-uikit:main"]min_saule_version: "26.1"indent_style: "space"indent_width: 2Recognised keys:
| Key | Purpose |
|---|---|
name |
Project name; also the import prefix exposed to dependents |
version |
Free-form version string (semver recommended) |
entry |
Path to the entry .sau file, relative to the project root (apps only) |
kind |
"app" (default) or "library" — a library has no entry point and is imported rather than run |
src_dirs |
List of directories to search when resolving imports |
dependencies |
Packages (owner@repo, pinned with :version) and paths to other Saule projects (each must itself contain a saule.config); ~/ expands to the home directory. Managed with saule install / update / remove — see Packages |
min_saule_version |
Refuses to run if the toolchain reports a lower version |
indent_style |
Formatting: "tab" or "space" (default "space") |
indent_width |
Formatting: columns per indent level, 1–16 (default 2) |
Unknown keys are ignored.
Packages
Section titled “Packages”A dependencies: entry is one of two things, told apart by the @:
dependencies: ["lauriszz123@saule-uikit:main", "../shared-lib"]A package is owner@repo, optionally pinned with :version, and is
fetched from GitHub into SAULE_HOME where every project on the machine
shares one copy. A path is anything else — absolute, ~-prefixed, or
relative to the project root — and is used where it lies, so nothing is
copied and edits to it take effect immediately.
saule install lauriszz123@saule-uikit # the newest tagged versionsaule install lauriszz123@saule-uikit:main # a specific tag or branchsaule install ../shared-lib # a project on this machinesaule install # everything this project recordssaule update # move packages to their newest tagsaule update --check # report what is behind, change nothingsaule remove uikit # drop it from this projectsaule list # what this project usesLeaving the version off installs the newest tag and then records it, so a
fresh clone of the project gets that same version rather than whatever is
newest that day. Pre-releases are passed over unless you pin one. A repository
with no tags falls back to its default branch, and saule update compares such
a package by commit.
An entry always holds an exact version — there are no ranges, so saule update
is the only thing that ever changes one. --check exits 1 when something is
behind and 2 when a package could not be reached, so CI can tell the two apart.
A tag or a branch can move, so the exact commit of each package is also written
to saule.lock, next to saule.config. Commit it: saule install restores
those commits on a fresh clone, and saule update is what moves them. Packages
are shared across projects, one copy per ref, so two projects locking the same
branch to different commits will replace each other’s copy when each runs
saule install. Pinning a tag avoids this.
Installing a package can run code: a native package is built with cargo build.
See SECURITY.md.
saule test runs every top-level function named test_... that takes no
parameters, in any .sau file under tests/ and any file ending _test.sau in
your source directories:
fn add(a: integer, b: integer) -> integer return a + bend
fn test_adds() assert(add(1, 2) == 3, "one and two make three")endA test fails if an assert fails, a throw goes uncaught, or a runtime error
occurs, and the rest still run. saule test --filter adds runs only the tests
whose name contains adds, and the command exits 1 if any test failed. Each file
runs as its own program, so tests in one file share its top-level variables.
Give a test file a name no module has: tests/calc.sau importing "calc" finds
itself first.
A package’s import name comes from its own name:, not from its repository
name, which is why Saule needs no central index. saule install prints the
import line to use.
The two indent_* keys are what saule fmt and the language server both read,
so a project’s declared style survives a Reformat in the IDE and a saule fmt -w
in a terminal alike. They override the editor’s own Code Style settings; the
saule fmt --indent <n> / --tabs / --spaces flags override them in turn.
Recommended Project Structure
Section titled “Recommended Project Structure”myproject/├── saule.config├── main.sau├── entities/│ ├── Entity.sau│ ├── Player.sau│ └── Enemy.sau├── data/│ ├── Repository.sau│ └── PlayerRepository.sau├── utils/│ ├── Math.sau│ └── Logger.sau└── enums/ ├── Direction.sau └── Status.sauEntry Point
Section titled “Entry Point”There are two ways to run Saule code, with different rules about what the entry file must contain:
Project mode — saule run in a directory containing saule.config, or saule run <dir> naming one. The file pointed to by entry: must declare:
class Main static fn main() -- your code here endendTop-level statements in the entry file still execute first (handy for one-off setup or imports), and then Main.main() is called. Without a Main class the runner exits with error: '<entry>' must declare 'class Main' with a 'static fn main()' entry point.
Single-file mode — saule run path/to/file.sau, naming a file rather than a directory. The file is executed top-to-bottom like a Lua script; no class Main is required, and any surrounding saule.config is ignored. If the script happens to define a Main with a static fn main(), it is invoked as a convenience after the top-level body finishes.
Whether the target is a directory is the only thing that picks between the two modes. Arguments for the program itself go after --, where the CLI passes them through untouched to Os.args():
saule run -- input.bf # project in the cwd, Os.args() = ["input.bf"]saule run tool.sau -- -v file # single file; script args may start with `-`Execution
Section titled “Execution”saule run compiles the whole program — the entry file and every module it
imports — to bytecode, then runs it on a register-based virtual machine.
Everything is checked before anything runs: a type error in an imported
module is reported against the import that led to it, and so is a module
that cannot be found or parsed.
A typical project-mode entry file:
from "entities/Player" import Playerfrom "utils/Math" import clamp
class Main static fn main() local p: Player = Player("Arthur", 100, 1.5) p.greet()
local dmg: integer = clamp(50, 0, 100) p.damage(dmg) endend