A module is a directory of Atoll source files. You never write a module
statement: the compiler derives a file’s module path from the project root, the
module value in atoll.toml, and the file’s parent directory.
| Chapter | What it covers |
|---|---|
| Imports | Named imports, module namespaces, as aliases, resolution order |
| Visibility | private, pub, and the file boundary |
| Projects | atoll.toml, dependencies, units, build and run |
The example project
Every listing on this page is one file of the same small project:
ledger/
atoll.toml module = "example.dev/ledger"
main.at module example.dev/ledger
money/
amount.at module example.dev/ledger/money
tax.at module example.dev/ledger/money
report/
format.at module example.dev/ledger/report
audit/
format.at module example.dev/ledger/audit# ledger/atoll.toml
module = "example.dev/ledger"
name = "ledger"
version = "0.1.0"The whole tree is compiled together:
$ atoll check ledger
OK (0 diagnostics)
$ atoll run ledger/main.at
amount due: $156
atoll run: unit `main` returned 156A single file of a multi-file project cannot be compiled on its own, so the
cross-module listings below are marked nocheck. They are lifted verbatim from
the tree that produced the output above.
Deriving a module path
| File | Module |
|---|---|
main.at |
example.dev/ledger |
money/amount.at |
example.dev/ledger/money |
money/tax.at |
example.dev/ledger/money |
report/format.at |
example.dev/ledger/report |
audit/format.at |
example.dev/ledger/audit |
The filename is not part of the path. Renaming amount.at does not change any
import; moving it into another directory does. Two files in different
directories can share a filename — report/format.at and audit/format.at
above are different modules and collide over nothing.
One module, two files
money/amount.at owns the value type:
struct Money {
cents: int
fn dollars(self): int {
return self.cents / 100
}
fn plus(self, other: Money): Money {
return Money { cents: self.cents + other.cents }
}
}
fn usd(dollars: int, cents: int): Money {
return Money { cents: dollars * 100 + cents }
}
private fn cents_of(m: Money): int {
return m.cents
}
fn total(items: []Money): Money {
mut acc := 0
for m in items {
acc = acc + cents_of(m)
}
return Money { cents: acc }
}money/tax.at sits beside it in the same directory, so it is the same module.
It uses Money, plus and usd with no import at all:
const RATE_BP: int = 825
fn tax_on(m: Money): Money {
return Money { cents: m.cents * RATE_BP / 10000 }
}
fn with_tax(m: Money): Money {
return m.plus(tax_on(m))
}Public declarations from a sibling arrive automatically. The flip side is that duplicate top-level names across sibling files collide exactly as they would inside one file: choose names at module scope, not file scope.
Crossing a directory
report/format.at is in a different directory, so it needs an
import:
import { Money } from example.dev/ledger/money
struct Line {
label: string
amount: Money
}
fn render(l: Line): string {
return l.label + ": $" + l.amount.dollars().to_string()
}
fn line(label: string, amount: Money): Line {
return Line { label: label, amount: amount }
}Imports are file-local. A sibling of format.at does not inherit that import;
it writes its own. Only same-directory public declarations arrive without one.
The root file
main.at is the root module. It pulls both directions together and declares
the entry points:
import { usd, total, with_tax } from example.dev/ledger/money
import { Line, render } from example.dev/ledger/report
import example.dev/ledger/audit
fn main(): int {
items := [usd(19, 99), usd(4, 50), usd(120, 0)]
due := with_tax(total(items))
println(render(Line { label: "amount due", amount: due }))
return due.dollars()
}
fn entry_audit(): int {
due := with_tax(usd(19, 99))
println(audit.render(audit.line("ada", "invoice", due)))
return due.dollars()
}audit/format.at is a near-twin of report/format.at — same Line and
render names, plus an actor field. Because the two names would collide, the
root file imports report by name and audit as a namespace; see
Imports.
Two entry points means two independently linkable units:
$ atoll build ledger -o out
Wrote 2 unit(s):
- main (out/atoll-unit-main)
- entry_audit (out/atoll-unit-entry-audit)See Projects for what a unit contains.
Privacy is per file, not per directory
private hides a declaration from every other file — including siblings in
the same module. Collapsed into one file, cents_of is reachable:
struct Money {
cents: int
}
private fn cents_of(m: Money): int {
return m.cents
}
fn total(items: []Money): Money {
mut acc := 0
for m in items {
acc = acc + cents_of(m)
}
return Money { cents: acc }
}
fn main(): int {
sum := total([Money { cents: 1999 }, Money { cents: 450 }])
println("cents: " + sum.cents.to_string())
return sum.cents
}Move total into money/tax.at and the same program stops compiling, because
cents_of does not cross the file boundary even to a sibling:
error[ATOLL1009]: unresolved name `cents_of`
--> money/tax.atA named import of it from another module fails differently:
error[ATOLL1041]: module `example.dev/ledger/money` has no public export named
`cents_of` (the declaration is `private`)total is the public door; cents_of is an implementation detail of one file.
See Visibility.
Mutual imports resolve
Project checking registers every declaration across the whole source set before resolving any body, so two modules may import each other:
example.dev/ledger/report -> example.dev/ledger/money
example.dev/ledger/money -> example.dev/ledger/reportBoth directions check and run. Import order is therefore not a source-order restriction. It does not make recursive value layouts valid, and a cycle still couples the two modules for every future refactor — when two large feature modules import each other’s broad public surface, a small shared module usually expresses the design better.
No source-level module header
Do not write a module header in a source file. The parser still accepts a
dotted legacy form for compatibility, but it is inert — the loader derives
module identity from the filesystem and the manifest, and a header cannot
override it:
module example.dev.ledger
fn main(): void {
println("the header above changes nothing")
}The slash-separated spelling used by import is not accepted in that position
at all, which is a good reminder that the two are unrelated.
Identity
A declaration’s externally visible identity is its canonical module path plus its declared name. Filenames and import aliases are not part of it:
| Change | Public identity |
|---|---|
| Rename a file within its directory | unchanged |
| Split one file into sibling files | unchanged, if names are kept |
| Rename an import alias | unchanged |
| Move a file to another directory | changed |
Change the manifest module value |
changed for the whole project |
| Rename a declaration | changed |
A filesystem cleanup that moves files across directories is therefore an API migration, even when every declaration body is untouched.
Discovery
The project loader recursively reads .at files under the root, skipping
dot-prefixed directories, target, node_modules, and build. Entries are
sorted before loading so compiler input and diagnostics are deterministic; sort
order does not give an earlier file ownership of a name.
The normal root is the directory containing atoll.toml. atoll check can also
inspect a manifest-less directory as an ad-hoc project — the directory name
becomes a synthetic root module, and dependencies, datasources, and host
capabilities are unavailable.
The same program in one file
Modules are an organizational choice, not a requirement. Collapse the four files above into one and the program is identical — same output, same return value, one module, no imports:
struct Money {
cents: int
fn dollars(self): int {
return self.cents / 100
}
fn plus(self, other: Money): Money {
return Money { cents: self.cents + other.cents }
}
}
fn usd(dollars: int, cents: int): Money {
return Money { cents: dollars * 100 + cents }
}
private fn cents_of(m: Money): int {
return m.cents
}
fn total(items: []Money): Money {
mut acc := 0
for m in items {
acc = acc + cents_of(m)
}
return Money { cents: acc }
}
const RATE_BP: int = 825
fn with_tax(m: Money): Money {
return m.plus(Money { cents: m.cents * RATE_BP / 10000 })
}
struct Line {
label: string
amount: Money
}
fn render(l: Line): string {
return l.label + ": $" + l.amount.dollars().to_string()
}
fn main(): int {
items := [usd(19, 99), usd(4, 50), usd(120, 0)]
due := with_tax(total(items))
println(render(Line { label: "amount due", amount: due }))
return due.dollars()
}$ atoll run ledger.at
amount due: $156
atoll run: unit `main` returned 156What splitting bought is the boundary itself: cents_of stops being reachable
from tax code, report and audit can each own a Line and a render, and
each directory becomes a name you can move, publish, or replace.
Choosing a boundary
Pick a module boundary for ownership and dependency direction, not to shorten a file. Before moving declarations, check:
- which sibling files relied on automatic same-module visibility;
- which downstream files need new imports;
- whether file-private declarations must become public or move with their caller;
- whether the new import creates a cycle;
- whether the changed canonical path is part of a published API.
The compiler does not require one declaration per file. That remains a useful convention for primary public types, while closely related private helpers can stay beside their caller. Directory names become public import paths, so name them for stable domain or layer boundaries.
Continue to Imports for the import forms, or to Projects for the manifest and the build.