Skip to content

Modules

Understand how Atoll groups source files into project namespaces.

Updated View as Markdown

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 156

A 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.at

A 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/report

Both 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 156

What 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:

  1. which sibling files relied on automatic same-module visibility;
  2. which downstream files need new imports;
  3. whether file-private declarations must become public or move with their caller;
  4. whether the new import creates a cycle;
  5. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close