Aether Module System
This document describes Aether's module and namespace system.
Terminology
| Term | Definition | Example |
|---|---|---|
| Package | A collection of related modules | std (standard library) |
| Module | A single importable unit with functions | std.string, std.http |
| Namespace | The prefix used when calling functions | string, http |
Current Implementation
The standard library uses namespace-style function calls:
import std.string // Import the string module
import std.file // Import the file module
main() {
// Namespace = module name after last dot
s = string.new("hello") // string namespace
len = string.length(s)
string.release(s)
if (file.exists("data.txt") == 1) { // file namespace
size = file.size("data.txt")
}
}
How It Works
import std.XregistersXas a namespaceX.func()is resolved to the C functionX_func()- Dot-style calls:
string.new(),list.free(), etc. (compiler translates to C-levelstring_new,list_free)
A module's namespace is the last segment of its path. When two loaded modules end in the same segment (mine.vk and gfx.vk, #2209), each instead gets its full path with dots as underscores (mine_vk, gfx_vk), and the merger rewrites the prefix each program or library wrote (vk., or an import … as … alias) to the module that scope imported. So a module's imports resolve in that module, by full path or alias, never program-wide by last segment; import X as Y works in a library module exactly as it does in a program.
One kind of module cannot move: one whose externs carry its last segment as a prefix. std.math declares extern math_floor, and math.floor reaches it as <namespace>_floor, so only the namespace math finds it. Such a module keeps its last segment and whatever collides with it moves (pkg.math → pkg_math, #2190). No two shipped modules may need the same last segment; tests/scripts/check_shipped_module_leaves.py enforces that in make check-docs (#2255).
A package's own files are modules too, and a package usually imports them by bare name (import parser in contrib/jq/module.ae). Such an import from a shipped package is registered by its place in the package (contrib.jq.parser), so two packages with a parser.ae each get their own, and a file already loaded under another name is not loaded twice (#2255).
Module Orchestration Phase
Before type checking, the compiler runs a module orchestration phase (added between parsing and type checking in the compilation pipeline):
- Scan: Walk the main program AST for
importstatements - Resolve: Map each import to a file path (stdlib, lib/, src/ paths)
- Parse: Lex and parse each module file into an AST, or, for an installed module with a compatible compiled artifact, read the AST from it (compiled-module-artifacts.md)
- Cache: Store the parsed AST in
global_module_registry - Recurse: Process each module's own imports (transitive dependencies)
- Cycle Check: Build a dependency graph and detect circular imports via DFS
Both the type checker and code generator then use module_find() to retrieve cached ASTs, each module file is read and parsed exactly once.
Available Modules
| Import | Namespace | Functions |
|---|---|---|
import std.string | string | string.new(), string.length(), string.release() |
import std.file | file | file.open(), file.read(), file.write(), file.exists(), file.close(), file.delete(), file.size() |
import std.dir | dir | dir.exists(), dir.create(), dir.delete(), dir.list() |
import std.path | path | path.join(), path.dirname(), path.basename(), path.extension(), path.is_absolute() |
import std.fs | fs | Monolithic file/dir/path module, call under the fs namespace, e.g. fs.read(), fs.exists(), fs.list_dir(), fs.clean() (not file.*/dir.*/path.*) |
import std.json | json | json.parse(), json.obj(), json.free() |
import std.http | http | http.get(), http.server_create(), http.server_start() |
import std.tcp | tcp | tcp.connect(), tcp.write(), tcp.listen() |
import std.net | net | Monolithic networking module under the net namespace; redeclares the same TCP/HTTP externs over shared C symbols. Does not re-export the tcp/http namespaces, so http.get() fails after import std.net import std.http/std.tcp for those |
import std.list | list | list.new(), list.add(), list.get(), list.set(), list.remove() |
import std.map | map | map.new(), map.put(), map.get(), map.has(), map.remove() |
import std.collections | collections | Monolithic list/map module. Functions keep their raw names; the qualified collections.X surface only reaches the Aether wrappers (collections.list_add(), collections.map_put(), collections.map_get()). Raw externs like list_new/map_new register globally and are callable unqualified, not as collections.list_new(). For a namespaced list.new()/map.new() surface, import std.list/std.map |
import std.math | math | math.abs_int(), math.sqrt(), math.sin(), math.random_int() |
import std.log | log | log.init(), log.write(), log.shutdown() |
import std.io | io | io.print(), io.read_file(), io.getenv() |
import std.os | os | os.system(), os.exec(), os.getenv() |
Export Visibility
Modules declare their public API via a single Erlang-style exports (…) list at the top of the file. Names in the list are accessible from importers; names not in the list are private, still usable inside the module's own functions, but rejected at qualified-call sites.
// lib/geometry/module.ae
exports (PI, distance)
const PI = 3
distance(x1, y1, x2, y2) {
dx = x1 - x2
dy = y1 - y2
return _sqrt_approx(dx * dx + dy * dy)
}
// Private helper, not in the exports list, not accessible from outside.
_sqrt_approx(n) {
return n // placeholder
}
import geometry
main() {
d = geometry.distance(0, 0, 3, 4) // OK, listed
println(geometry.PI) // OK, listed
// geometry._sqrt_approx(25) // Error: not in exports list
}
Rules:
exports (…)accepts any combination of function names, const names, and struct/actor names. The list is the contract.- A module that declares an
exports (…)list exposes only the listed names. Anything else is private. - A module with no
exports (…)list keeps the legacy default-public behavior, every top-level name is callable from outside. v1 is additive; v2 will flip this default to private once every stdlib + contrib module has been migrated. - Private names can still be referenced from inside the module's own functions, they're merged into the program, just blocked at the external call boundary.
- The boundary is every way a name can be reached from outside: the qualified form (
geometry.helper(),geometry.CONST) and a selective import (import geometry (helper)) both fail withE0303 'helper' is not exported from module 'geometry', the selective form at the import line. The same holds for standard-library modules, reached by their last segment (language.nameforstd.language). - A module may list a name in its prefixed form,
<module>_<name>: std.math exportsmath_sqrt, and that is whatmath.sqrt(x)andimport std.math (sqrt)reach. m.xis resolved by the names the module declares, not by building a C name: the module's function, builder or@externnamedxfirst, else the declaration named in the prefixed formm_x, of any kind. An Aether function so named is reached like an extern: std.md5'smd5_hexismd5.hex(and stillmd5.md5_hex), a module'srexxae_createisrexxae.create. A module function whose C name would be one of the module's own externs is merged under a name of its own andm.xreaches it (#2978).- One name per operation: a module may not export two names that answer to one short name, the short name being the export's name without its C prefix, a leading
aether_<module>_or<module>_(when what is left starts with a letter).addbesideaether_set_add, orjoinbesidestring_join, isE0305, naming both, at the later one in the list. A module with noexports (…)list is held to the same rule over its top-level functions, builders, externs and constants. A wrapper's extern is declared for the wrapper to call and left out of the list; an extern callers need beside a wrapper gets a name of its own that says it is raw,@extern("aether_set_add") add_raw(set: ptr, item: string) -> int(#2976).
Legacy export <fn> form (deprecated)
Earlier versions of Aether used a per-function export keyword:
// DEPRECATED, emits a warning at compile time.
export const PI = 3
export distance(...) { … }
This form is still accepted for one release as a deprecation alias. Modules using it get a one-shot warning to stderr. Migrate by collecting every export-tagged name into one exports (…) line at the top, then removing the per-function keywords. Mixing both forms in one module is a hard error.
Per-symbol import aliasing
An entry in a selective-import list can be renamed with as, the same token module-level aliasing already uses:
import vg (rect, fill, path as vgpath, text_paths)
vgpath now binds vg's exported path, and the bare name path is free for a local function, parameter, or another import. This is the standard resolution when exactly one name in an otherwise-convenient selective import collides: keep the other names bare, rename the one that clashes, instead of qualifying every call to rescue a single symbol.
import vg (rect, path as vgpath)
path(x: int) -> int { … } // the local `path` is unaffected
main() {
rect(0.0, 0.0, 460.0, 90.0) // bare, as before
vgpath(d) // vg's path, under the alias
path(9) // the local one
}
Aliases work for constants as well as functions (import cfg (LIMIT as CAP)), inside a module's own imports (the alias resolves when that module's bodies are merged into a consumer), and alongside module-level aliasing. The shadow rejection below applies to the alias, the name the program actually binds: import vg (path as vgpath) plus a local vgpath is the collision, while a local path is now fine.
Selective-import shadow rejection
A module (or main program) that selectively imports a name AND defines a local function with the same name silently shadowed the import:
import std.string (length)
length(s: string) -> int {
return length(s) // intent: std.string.length
// reality: self → infinite recursion
}
Pre-fix, the merger renamed the body's bare length(s) to mod_length(s) (matching the local def's namespaced name post-merge), turning the wrapper into self-recursion, a runtime stack overflow with no compile-time signal. The orchestrator rejects this pattern with error[E1000] before merging:
error[E1000]: module 'mod' (mod/module.ae) defines local function
'length' (3:1) but also selectively imports 'length' from 'std.string'
the local def silently shadows the import, so a bare call to
'length(...)' inside the local body would recurse into the local
rather than forward to the imported symbol, at runtime this is a
stack overflow with no compile-time signal.
fix one of:
- rename the local function
- drop the selective import: `import std.string` (then call via
the qualified form)
- keep both but call the imported version qualified inside the
local body
The same check applies to entry-point main.ae files when the shadow lives there directly (not inside a module). Both AST_FUNCTION_DEFINITION and export <fn> shapes are caught.
A glob import is not rejected this way, because it does not name the clash: import std.string (*) beside a local length binds every std.string name except length, and the file's own length is the one its bare calls reach, in a program and in a module, built or checked on its own (#2632).
Qualified surface survives a selective import after merge
The two import std.X forms each light up a different call syntax: a bare import std.X enables the qualified X.fn() surface; a selective import std.X (fn) enables the bare-name fn() surface. These compose per-file, but a merged compilation unit pulls definitions from several files into one program, and a module's own import statements are not cloned in (only its function/const/struct bodies are).
That dropped the qualified surface in one case: when the entry file imported a module selectively (import std.string (string_length)), the merged unit was marked selective for string, so a qualified string.concat(...) arriving from an imported module that bare-imports std.string was rejected with error[E0301]: Undefined function 'string.concat'.
The merge now injects a synthetic bare import for each merged module's own bare imports. A synthetic import marks the namespace non-selective for the unit (re-opening string.X) but is kept out of the user-explicit registry, so sealed-scope isolation is preserved: user code still cannot call into a namespace it never imported. This mirrors how transitive dependencies already get synthetic imports during merge.
A module reaches only what it imports
A qualified name, ns.name(...) or ns.CONST, resolves against the imports of the file it is written in: the entry program's own imports for the program's code, and a module's own imports (plus the module itself) for that module's functions, actors and constants, wherever they end up merged. A module that another module imports is loaded and merged into the program, but that does not make it visible to anyone else:
// lib/low/module.ae
exports (low_value)
low_value() -> int { return 7 }
// lib/mid/module.ae
import low
exports (mid_value)
mid_value() -> int { return low.low_value() + 1 }
// lib/top/module.ae
import mid
exports (top_value)
top_value() -> int { return low.low_value() + mid.mid_value() } // E0301
top calls low without importing it. Every program that imports top is rejected at that call, exactly as ae check lib/top/module.ae rejects it:
error[E0301]: Undefined function 'low.low_value'
--> lib/top/module.ae:3:32
help: module 'top' does not import 'low': add `import low` to it (another module of the program importing 'low' does not make it visible here)
Before #2614 a merged module body could reach any namespace loaded anywhere in the build, so top built in every program (because mid brought low in) while ae check of top failed. A module then worked by the accident of what else was linked in, and broke, at a line nobody had touched, the day mid stopped importing low. Now ae build and ae check apply the one rule: the stricter one, which matches what a reader of the file sees. The fix is always the import the help line names.
A module does see itself. selfq.a() or selfq.K inside module selfq names its own definition, and its exports list still applies (selfq.hidden() for a private hidden is E0303). ae check of a module file gives the file the namespace a build gives the module, the last segment it is imported under (the directory of <dir>/module.ae, or <name> of <name>.ae), so the two agree here too; before #2631 the file checked on its own knew no namespace and ae check rejected a call every build accepted. A name the module does not define falls through to its imports, so a module that imports a namespace sharing its last segment (#1780) still reaches it for the rest.
Every function of every module is checked
ae build and ae check of a program type-check every function of every module in the build, whether or not the program calls it. A library's uncalled function with a type error fails the build of every program that imports the library, at the line in the library:
// lib/mylib/module.ae
exports (used, unused)
struct A { x: float }
struct B { y: int }
used() -> int { return 1 }
unused(a: A) -> int {
b = B { y: 2 }
b.y = a // E0200 in every program that imports mylib
return b.y
}
A selective import is no exception: import mylib (used) decides which names the importing file may write bare, not which of the module's functions are checked, so unused is checked there too. Merging the whole module is also what lets a selected function read a constant the import did not select, and a qualified mylib.other() reach a function it did not select (#2630).
Checking is not emitting. The tree-shake still drops the functions nothing reaches, after they are checked, so a program emits the same functions it did and the C compiler's work is unchanged (a selective import's unselected constants now come along, as cheap unused static consts, and merged declarations may land in another order). The cost is the checking itself: on a program that pulls in the TLS stack (std.cryptography.tls13_client, about 25,000 lines of emitted C) the compiler front end takes 50 to 90 ms (10 to 20%) longer, which is within the noise of a whole ae build; on most programs it is not measurable.
Before #2613 the tree-shake ran before the typechecker, so a function nothing called was never checked at all: a library could carry a type error for months and build clean in every consumer until one of them first called it, while the same function, uncalled, in the main file failed the build. The library's own tests, which build programs, could not catch it. Now building any program that imports a library is as strict a check of the library as ae check of each of its modules.
Re-exports
A module can re-export a symbol it imports from another module by listing that symbol in its own exports. A consumer then reaches it through the re-exporting module even though that module never defined it:
// hub.ae, re-exports DERIVED from an inner module
import layout_consts (DERIVED)
exports (DERIVED)
// consumer.ae
import hub
x = hub.DERIVED // resolves to the definition in layout_consts
Re-export is opt-in through the exports list, transitive (a hub may re-export a symbol another hub re-exports), and cycle-guarded. It is also precise: a locally-defined export is never treated as a re-export, so if a module both defines a name and imports one, the local definition wins. The resolver redirects an unresolved hub.X to the origin module that defines it, and the merge pass pulls that origin definition into the program under the origin's namespace.
Cross-module method-call syntax (UFCS)
A method-style call x.f(args) is rewritten to the free-function call f(x, args) when no field or method named f applies. The target f may be defined in an imported module, not only in the same file. Same-file functions take precedence, and the rewrite is a last resort, it fires only when the call does not already resolve as a normal function or a struct field, so it never shadows an existing binding. This is what lets a fluent chain such as expect(x).to_equal(y) span module boundaries.
Circular-import diagnostics
Imports are resolved through a dependency graph, and a cycle is a hard error. The diagnostic names the actual cycle as a path, a -> b -> a rather than the entry point, so the report points at the modules that actually form the loop. The synthetic __main__ entry node never appears in the path because nothing imports it.
Future
Work planned on top of the current module system:
import math.geometry as geoimport aliases. The parser accepts them and the typechecker records the alias viaadd_module_alias, but qualified calls through the alias (geo.distance()) do not yet resolve; completing that lookup is the remaining work.- Exporting structs and actors from modules.
Creating Packages
Developers can create their own packages using the ae CLI tool.
Quick Start
# Create a new package
ae init mypackage
cd mypackage
# Build and run
ae build
ae run
Package Structure
mypackage/
├── aether.toml # Package manifest
├── src/
│ └── main.ae # Main entry point
├── lib/ # Library modules (optional)
│ └── utils/
│ └── module.ae # import utils → lib/utils/module.ae
├── tests/ # Test files (optional)
│ └── test_utils.ae
└── README.md
Package Manifest (aether.toml)
Every package has an aether.toml file. This is what ae init mypackage scaffolds:
[package]
name = "mypackage"
version = "0.1.0"
description = "A new Aether project"
license = "MIT"
[[bin]]
name = "mypackage"
path = "src/main.ae"
[dependencies]
[build]
target = "native"
# link_flags = "-lsqlite3 -lcurl" # Add extra linker flags
Only a subset of the manifest is honored today: the [[bin]] entries (name/path/extra_sources) and the [build] target, link_flags, and cflags keys. The [package] metadata and [dependencies] table are recorded but not yet acted on by the toolchain.
Creating Local Modules
Aether supports local modules within your project:
Project Structure:
myapp/
├── aether.toml
├── src/
│ └── main.ae # Main entry point
├── lib/
│ └── utils/
│ └── module.ae # Pure Aether module
└── tests/
lib/utils/module.ae, define your module:
export const MULTIPLIER = 2
export double_value(x) {
return multiply(x, MULTIPLIER)
}
export triple_value(x) {
return multiply(x, 3)
}
// Private helper
multiply(a, b) {
return a * b
}
src/main.ae, use the module:
import utils
main() {
println(utils.double_value(5)) // 10
println(utils.triple_value(5)) // 15
println(utils.MULTIPLIER) // 2
}
Module Resolution
The compiler searches for local modules in this order:
<lib-path-entry>/<module>/module.ae- Library directory with module file<lib-path-entry>/<module>.ae- Single-file module in libsrc/<module>/module.ae- Source directory with module filesrc/<module>.ae- Single-file module in src<module>/module.ae- Project root<module>.ae- Single file in project root- Steps 1 to 6 under the directory of the importing file, so a program built from another directory still finds the modules beside it
A module's own imports search its directory first (step 7, then steps 1 to 6): std.jsonpath's import parser names std/jsonpath/parser.ae wherever the compiler runs, even in a directory that has a parser.ae of its own (#3103). The entry program's imports keep the project root first.
For nested modules like import mypackage.utils:
- Dots are converted to slashes:
mypackage/utils/module.ae - Same search paths are used with the converted path
~/.aether/packages/<name>/src/module.ae- Installed package (viaae add)~/.aether/packages/<name>/module.ae- Installed package (flat layout)
Packages installed with ae add are searched after local paths. The package name is the first component of the import path.
Lib search path
<lib-path-entry> in steps 1 and 2 is a list of directories, PATH-style, : on POSIX and ; on Windows. The list is searched left-to-right; the first hit wins. Three forms compose:
ae run main.ae --lib ./lib:./vendor/stdlib # separator-string
ae run main.ae --lib ./lib --lib ./vendor # repeated flag
AETHER_LIB_DIR=./lib:./vendor ae run main.ae # env var
Both forms can be mixed (--lib a:b --lib c → [a, b, c]). The default is the single entry lib (the legacy behaviour); supplying any explicit --lib or AETHER_LIB_DIR replaces the default with the given list. Up to 8 entries. Caching invalidates on lib-path changes, two builds of the same source with different --lib chains get distinct cache slots, and every entry's mtime feeds into the cache key so a touch in a vendored module reuses the right cache bucket.
The shape matches Java -cp, Python PYTHONPATH, Ruby -I / RUBYLIB. Useful for layering project-local modules over a vendored stdlib, sharing a common lib root across two projects, or pinning a stdlib snapshot alongside HEAD-tracking deps.
Trailing slashes are normalised: --lib ./lib/ and --lib ./lib register the same entry (the dedup catches them), and lookup paths stay clean, <entry>/<module>.ae rather than ./lib//<module>.ae. Root paths (/ on POSIX, C:\ on Windows) are preserved as-is.
Introspecting the resolved chain
ae lib-path prints the resolved search order, one entry per line, in the same order the toolchain walks them. Useful when debugging "why isn't my import resolving?" without re-reading the user's shell config:
$ ae lib-path --lib ./lib:./vendor/stdlib
./lib
./vendor/stdlib
$ AETHER_LIB_DIR=/usr/share/aether:./local ae lib-path
/usr/share/aether
./local
$ ae lib-path # defaults, single entry
lib
Inputs are merged in the same priority order the build path uses: explicit --lib flags first, then AETHER_LIB_DIR, then the default lib.
Dependencies resolve onto the search path
ae add installs into ~/.aether/packages/<host>/<owner>/<name> and records the dependency, with the hash of the tree it installed:
[dependencies]
"github.com/aether-lang-dev/selaenium" = "0.2.0"
[dependency-hashes]
"github.com/aether-lang-dev/selaenium" = "sha256-..."
A build stops when an installed dependency is not the tree its manifest pins (package-fetching.md).
ae run, ae build and ae lib-path read that section and join the package's modules to the search path. No --lib is needed to import from a declared dependency. All three walk up to the project's manifest first, so they behave the same from a subdirectory as from the project root.
The publishing package decides what it exports, in its own aether.toml:
[package]
name = "selaenium"
modules = "aether, selenium_core, selenium_core/drivermgr"
Each entry names an importable module, and what joins the search path is that module's parent directory, because --lib D means "D contains modules" (D/<name>.ae or D/<name>/module.ae). So selenium_core/drivermgr puts <package>/selenium_core on the path and import drivermgr resolves. Either module form counts: aether/webdriver names a single-file aether/webdriver.ae just as readily as a directory holding module.ae.
This is why the declaration lives with the publisher rather than the consumer: a package can move its directories in a patch release without any consumer changing a path. A consumer names the dependency and nothing else. A package with no aether.toml, or none with a modules key, exports nothing and says so: the package root is never joined speculatively, since a real package is a whole repository whose root holds docs, scripts and tests as well as modules.
Exporting the package root: modules = "."
The parent-of-each-module rule leaves one shape undeclarable: a package whose modules live in a subdirectory and import each other with a dotted package prefix. For example, a package laid out as core/*.ae whose modules do import core.metadata, import core.phonenumber:
phonelib/
aether.toml
core/
phonenumber.ae # import core.metadata
metadata.ae
A dotted import core.phonenumber resolves only when the package root is on the search path (so core/ is a subdirectory under it). But there is no .ae module at the root to name, so no ordinary modules entry joins the root, and the package's own internal import core.metadata needs the root too, so even a flat-importing consumer cannot help. Such a package could be ae add-consumable only by flattening its namespace to import phonenumber throughout.
modules = "." is the explicit opt-in that joins the package root onto the consumer's search path:
[package]
name = "phonelib"
modules = "."
With it, a consumer does import core.phonenumber through a normal ae add dependency (no --lib, no path knowledge), and the package keeps its dotted namespacing. This is a deliberate, publisher-chosen export, so the "root is never joined speculatively" principle holds: the root joins only because the author wrote ., never as a guess by the toolchain. Combine it with named entries when a package exports both a rooted dotted package and other modules (modules = ".", widgets).
A dependency that is declared but not installed fails by name:
Error: dependency 'github.com/aether-lang-dev/selaenium' is not installed. Run:
ae add github.com/aether-lang-dev/selaenium
rather than as an unresolved-import error naming a module the user never typed.
Overriding a dependency
Two forms, both of which announce themselves: an override that applied silently would mean a green local build against a working copy that CI does not have:
ae run x.ae --override github.com/aether-lang-dev/selaenium=../selaenium
per-invocation, leaving no trace in the manifest, right for a one-off and for CI proving a change against an unpublished branch. And in the manifest, a separate stanza so an override cannot be shipped by accident inside a [dependencies] line:
[patch]
"github.com/aether-lang-dev/selaenium" = "../selaenium"
# or Cargo's table form, which means the same thing:
"github.com/aether-lang-dev/other" = { path = "../other" }
Only a local path override is supported. A table naming anything else (a git URL, say) is an error rather than a silently-ignored line.
Either way the build prints Overriding <dep> -> <path>. A --override for a dependency also overridden by [patch] wins, since the invocation is the more specific instruction. The override target is read exactly like an installed package: it needs its own [package] modules declaration.
Dependencies of dependencies
A dependency's own [dependencies] resolve too, transitively (#2335). A project lists only what it imports itself. A game built on an engine does not declare the engine's physics package, and it does not patch that package to a path inside the engine's checkout. The graph is walked breadth-first, so the project's direct dependencies come first on the search path, in manifest order. ae lib-path lists every package in the graph.
Each package resolves through the first of these that names it:
--overrideon the command line;- the project's
[patch], so the consumer always has the last word; - the
[patch]of the manifest that declares the dependency, a path relative to that package's root (an engine patching its physics to its owndeps/physicssubmodule works from any consumer); - the installed package.
A package reached twice at the same place (a diamond) and a cycle are both fine. A package that resolves to two different places is an error. The error names both paths and who required each, and the command stops instead of building against whichever was reached first:
Error: dependency 'example.com/pkgb' resolves to two places:
../pkga/../pkgb (required by example.com/pkga)
../pkgc/../pkgb2 (required by example.com/pkgc)
One build can use only one. Patch it in the project's
aether.toml to choose: [patch] "example.com/pkgb" = "<path>"
A transitive dependency that is not installed fails by name and says who needs it: Error: dependency 'x' (required by example.com/pkga) is not installed.
Namespace Convention
Function names must be prefixed with the namespace:
- Import:
import utils - Call:
utils.double_value(x) - C function:
utils_double_value()
The compiler converts namespace.function() to namespace_function().
Pure Aether Modules
You can write reusable modules in pure Aether, no C backing file required:
lib/mymath/module.ae:
export const PI_APPROX = 3
export double_it(x) {
return x * 2
}
export add(a, b) {
return a + b
}
// Intra-module calls work, functions can call each other
export double_and_add(x, y) {
return add(double_it(x), y)
}
src/main.ae:
import mymath
main() {
println(mymath.double_it(5)) // 10
println(mymath.add(3, 4)) // 7
println(mymath.double_and_add(5, 3)) // 13
println(mymath.PI_APPROX) // 3
}
How it works:
After module orchestration, the compiler clones each module's function and constant AST nodes into the main program with namespace-prefixed names (double_it → mymath_double_it). Intra-module calls, constant references, and constant-to-constant references (e.g., const DOUBLE_BASE = BASE * 2) are renamed automatically. Function parameters and local variables correctly shadow module constants, check(SCALE) { return SCALE } returns the parameter, not the module constant SCALE. This makes the entire downstream pipeline (type inference, type checking, codegen) work without modification, merged functions are just regular top-level functions.
Tree-shake of unused merges. Immediately after merging and before typechecking, module_mark_unreachable runs a mark over the program AST. It seeds reachability from main, every actor handler, every export statement, and every non-imported user function/builder, then closes over AST_FUNCTION_CALL, AST_IDENTIFIER, and AST_MEMBER_ACCESS references, including suffix matches that handle glob-import (import mymath (*)) and selective-import (import mymath (cube)) shorthands. Imported function and builder definitions outside the closure are only recorded: the typechecker checks them like every other function (see Every function of every module is checked), and module_sweep_unreachable drops them from the AST right after type checking, so the C compiler never sees them. Constants stay (cheap, and pruning them would need a separate pass keyed on identifier references). A program emits the same functions as when the prune ran before checking; only the checking covers more.
Native link dependencies (@link). A module that wraps a native library declares its own link flags at the top of module.ae:
@link("-laether_sqlite -lsqlite3 -lm")
When the module is in a program's resolved import closure, codegen unions every declared flag (first-seen order, deduplicated) into the // aether-link: comment on the first line of the generated C, which downstream C builds read to link without undefined-reference archaeology. Declaring is enough; there is no central registry to edit. Token order within one @link is preserved, which matters for single-pass linkers (veneer archive before the library it references).
ae build reads that comment back and puts the tokens on its own link command (#1549), so a consumer does not restate them in aether.toml. They are placed before link_flags, which therefore still wins. -L search paths remain the consumer's job (those are site-specific in a way a module cannot know), except for <lib_dir>/contrib, which is added automatically so the veneer archives make contrib builds resolve without configuration.
A header a module needs in the TU (@c_include, #1986). A module whose externs resolve to static inline helpers declares the header that carries them: @c_include("api.h") makes codegen emit #include "api.h" before anything it declares itself, deduplicated across the closure, so the calls resolve to the definitions rather than to an out-of-line symbol. Pairs with @c_import on the externs, which suppresses Aether's own prototype.
C sources a module ships (@source, #2125). The same mechanism carries a module's own C: @source("lanes.c") at the top of module.ae names a file relative to the module's directory. Orchestration resolves it, reports a missing file at the directive, and records it as a read dependency in the manifest (#1882) so the warm cache key changes with the C; codegen lists every resolved path, deduplicated, one per // aether-source: line after the link header, and ae compiles them into the program after --extra / extra_sources. One path per line because a module directory may contain a space. A directive in the entry file resolves beside it.
The link line is derived from the AST, which makes it transitive and -D-sensitive. Both properties matter and neither is obvious:
-D NAME → `when` region kept or dropped → import in or out of the closure
→ @link visible or not → // aether-link: → the link command
Transitive: a program that never names contrib.sqlite still links it when something it imports does, at any depth. -D-sensitive: an import inside a losing when defined(...) region is gone before codegen, so its @link contributes nothing and the library is absent from the binary entirely, not merely uncalled. That is the half a static link_flags cannot express, since it is passed on every build regardless of which regions survived.
What's supported:
- Functions (with type inference from call sites)
- Constants (
const NAME = value), including constants referencing other constants - Intra-module calls (functions calling other functions and referencing constants in the same module)
- Export visibility (
exportkeyword controls public API) - Multiple module imports in the same program
- Mixing pure modules with stdlib imports
- Parameter/local variable shadowing of module constants
- Actors and their message types:
spawnanactorand send amessagedeclared in an imported module. Both enter the program under their bare name (unlike structs, which are module-scoped and merged as<ns>_<Name>), sospawn(Worker())andw ! Ping { ... }resolve; the actor's handlers still have their intra-module function/constant references rewritten, and the per-program message registry assigns runtime type ids across the merge.
On the module-system roadmap (language-level):
- Module-level mutable state
Roadmap
- Done: Package initialization (
ae init) - Done: Local package building (
ae build) - Done: Local nested package imports
- Done: Pure Aether module implementations
- Done: Export visibility enforcement
- Planned: Package dependencies
- Planned: Package registry/publishing
Notes
- Single-file programs continue to work without packages
- The
stdpackage is built-in and always available