Projects and dependencies
Beyond compiling a single .axle file, Axle has a lightweight project
system: a manifest (axle.toml), local dependencies, and native-library
linking — driven by axle new, axle build, and axle run.
Create a project
axle new hello
cd hello
axle run axle new scaffolds:
hello/
├── axle.toml
└── src/
└── main.axle axle build and axle run with no path build the project in the current
directory; you can also pass a project directory: axle build hello.
A binary is written to target/. Passing a .axle file instead of a
directory keeps the single-file behaviour.
--lib scaffolds a library crate instead, and --workspace a root
manifest with an app binary and a core library it depends on. The command-line reference has every subcommand and
flag.
The manifest
[package]
name = "hello"
version = "0.1.0"
# entry = "src/main.axle" # the default
[dependencies]
mathx = { path = "../mathx" }
[link]
libs = ["SDL2"]
paths = ["C:/SDL2/lib"] The manifest reference lists every table and key with its default; the summaries below are the ones that change what a build does.
[package]—name(also the artefact name),version, an optionalentry(defaults tosrc/main.axle), and an optionaltargetslist of triples the project promises to serve (see code that differs by operating system).[dependencies]— local path dependencies. A dependency is itself a project (its ownaxle.tomlandsrc/), seen through itssrc/lib.axlewhen it has one — a project with asrc/main.axletoo is a program when you build it, and a library to the projects that depend on it. A symbol becomes importable withuse <name>::Symbol;when it is markedpuband reachable fromlib.axle’s import closure (declared inlib.axle, or pulled in there with ause crate::…). Such a dependency’smain.axleis never pulled into your build, so itsmaincan’t collide with yours. A dependency whose manifest names itsentryorkindis seen through that root instead.[features]and[port.<name>]— the backends this project has, and when each applies. Both are optional and most projects have neither; the section below covers them.[link]— native libraries to link (libs) and the directories to search for them (paths). On Windows these become/DEFAULTLIB:<name>.lib//LIBPATH:<dir>; on Unix-l<name>/-L<dir>.
Multiple files in one project
Inside a project, split your code across files under src/ and
import with the crate:: root — crate::a::b resolves to src/a/b.axle:
// src/config.axle
const SCREEN_W : i32 = 960; // src/main.axle
use crate::config::SCREEN_W; // import one symbol …
use crate::config; // … or bind the module: config::SCREEN_W
fn main() : i32 {
return SCREEN_W;
} The compiler discovers the import closure from the entry file — no file list to maintain in the manifest. The language server understands the same layout, so goto / rename / references work across the project’s files.
Code that differs by operating system
A module path names a capability, not a file. Most capabilities have one implementation; the ones that sit on an OS have one per backend, and the path must not change depending on which is being compiled — otherwise every portable caller would carry the backend in its own imports.
A port is one such backend: a condition, and the directories that
carry its code. You declare your own — nothing is reserved, so a program about Linux can still keep a probes/linux/ folder without surprises.
# axle.toml
[port.win32]
when = { os = "windows" }
dirs = ["win32"]
[port.x11]
when = { os = "linux" }
dirs = ["x11", "posix"] crate::platform::sys_clock then resolves to either
src/platform/sys_clock.axle one implementation, every target
src/platform/<dir>/sys_clock.axle the active port's implementation where <dir> is one of the directories that port declares. The importing
file is unchanged either way:
// src/core/timer.axle — portable, and reads as portable
use crate::platform::sys_clock::SysClock; src/
core/timer.axle portable
platform/
win32/sys_clock.axle class SysClock — QueryPerformanceCounter
posix/sys_clock.axle class SysClock — clock_gettime The build’s target decides which port is active: --target x86_64-unknown-linux-gnu takes x11, and with no --target a build is
for the host. Exactly one port is active for any target — a project whose
ports leave a target uncovered, or whose ports both apply with neither
narrower, is refused rather than resolved by declaration order. The
inactive port’s files are not compiled, so they may call anything that
platform provides: a Win32 extern "C" from "gdi32" block never reaches
a Linux link line.
Features
A port may also require a feature, which is how one platform gets two backends:
[features]
wayland = false
[port.wayland]
when = { os = "linux", feature = "wayland" }
dirs = ["wayland"] { os = "linux", feature = "wayland" } is narrower than { os = "linux" }, so it wins when the feature is on and the plain Linux
port serves when it is off. A feature a [features] table never declared
is refused: it could never be on, so the port could never apply.
A feature’s default is that table’s. Two other things can turn one on, and neither can turn one off:
axle build --features wayland # the root crate # a dependent asking the library it uses
smalt = { path = "../smalt", features = ["wayland"] } The flag reaches the root crate and stops there, because a feature
is declared in one manifest and two crates may spell one the same way
while meaning different things. A dependency’s features are asked for by
the [dependencies] edge that names it — the one place that knows which
crate is meant — and --features works the same on build, run, check and ports, so the port a build compiles is the port a check
analyses and the listing marks.
Requests only ever enable. Two crates depending on one library with different features both get theirs, in any order; nothing can take a feature away from a crate that turned it on, including its own default.
What is checked, and when
Two resolutions are errors rather than a silent choice:
- both a portable file and a port file for the same path — which one compiles would depend on the target, and the portable one would be dead on exactly one of them;
- no file for the active port while another port has one — the compiler names the file to write rather than letting the missing code surface as an unknown type a hundred lines away.
error: `src/main.axle` imports `platform::sys_audio`, which port `x11` does not implement
implemented by: win32
note: port `x11` could carry it at:
src/platform/x11/sys_audio.axle
src/platform/posix/sys_audio.axle The rest is a promise you opt into. [package] targets lists the triples
your project says it serves:
[package]
name = "smalt"
targets = ["x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] With that line, axle check and axle build also refuse a project where
some promised target has no port, or where one port implements a module
another does not — on any machine, since both compare what is on disk
rather than building anything. A Windows box says what the Linux port is
missing. Promise nothing and neither check runs, which is what lets a
port be written one module at a time.
A third refusal comes with the promise, and it is the one axle ports cannot make: two ports that implement the same seam with different
shapes. If one port’s SysClock declares frequency() and the other’s
declares freqHz(), both files exist — and a portable caller compiles for
one target and fails for the other:
error: axle.toml: `platform::sys_clock` is implemented with different surfaces:
port `win32` does not declare `SysClock.freqHz()` — x11 does
in src/platform/win32/sys_clock.axle
port `x11` does not declare `SysClock.frequency()` — win32 does
in src/platform/x11/sys_clock.axle Both halves are named because which shape is the right one is your call, not the compiler’s. It follows that a port’s private helper does not belong on a seam’s class — put it in another file of the port, which nothing outside imports and which therefore owes nothing to the others.
This refusal is a question about name sets, not about types: two ports
may declare freqHz() with different return types and pass. To ask
whether a seam still compiles for another target, check for that target:
axle check --target x86_64-unknown-linux-gnu The port that target selects is the one analysed, and nothing is generated — so this needs the other platform’s sources and no toolchain for it. It is how a port that drifted out of shape is found before a machine of that kind ever tries to build.
When only one declaration differs
A port switches a whole file. For a single declaration — a method the other platform has no API for, a constant, a small function — write the condition on the declaration:
class Window {
dark : bool;
constructor() { self.dark = false; }
@target(os = windows)
pub fn setDarkTitleBar(mut self, on : bool) : void { self.dark = on; }
}
@target(os = linux)
fn pageSize() : i32 { return 4096; }
@target(os = [windows, macos])
fn pageSize() : i32 { return 16384; } On a target the condition excludes, the declaration does not exist — calling it is an ordinary “undefined function”. Several declarations may share a name as long as exactly one applies to the target being built.
The axes are os and arch, with the same spellings the manifest uses
(os = linux, os = "linux", os = [windows, macos]); several axes in
one @target must all hold. There is deliberately no not, no any / all and no nesting — that is what keeps “does this project serve every
target it promises” a question with an answer. An axis or a value the
compiler does not know is an error, never a declaration that silently
applies nowhere.
A declaration the target excludes is removed before anything is
resolved, so it may call APIs this target does not have — that is the
whole point, and it is why axle check --target <triple> is what tells
you the other platform’s half still compiles.
feature = … inside @target is refused (E0757): a feature is declared
in axle.toml and this compiler has no way to check that the name exists,
so gating a declaration on one would make it silently apply nowhere. Gate
a whole file through a [port] whose when names the feature instead.
Seeing the seams
A portable file’s import never says it crosses a port, which is the whole
point — and it means you cannot tell by reading one file. axle ports says it out loud:
smalt (…/smalt)
promises x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu
ports
win32 when { os = windows } dirs win32, windows ← active for x86_64-pc-windows-msvc
x11 when { os = linux } dirs x11, posix
seams win32 x11
sys::sys_clock ✓ ✓
sys::sys_display ✓ ·
video::sys_window ✓ ·
· port `x11` could carry sys::sys_display at src/sys/x11/sys_display.axle | src/sys/posix/sys_display.axle One row per module path that crosses a port boundary, one column per
port, and the file to write for each hole. It is the same reading the
build refuses on for a missing file or a doubly-implemented path — but the
listing compares presence, not shape: two ports that implement a seam
with different method sets both show a tick here, and only axle check or axle build refuses them (see above).
Using a dependency
# app/axle.toml
[dependencies]
mathx = { path = "../mathx" } // mathx/src/lib.axle (mathx is a library — a lib.axle entry, no main.axle)
pub fn clampI32(v : i32, lo : i32, hi : i32) : i32 { // `pub` = importable
if (v < lo) { return lo; }
if (v > hi) { return hi; }
return v;
} // app/src/main.axle
use mathx::clampI32;
fn main() : void {
Console::println("clamped: " + clampI32(15, 0, 10)); // clamped: 10
} The clampI32 above is declared right in lib.axle. To keep a library
split across several files, re-export each one from lib.axle with a use crate::… line — only what lib.axle’s closure reaches, and only
the pub names in it, are visible to the consumer:
// mathx/src/lib.axle
use crate::clamp::clampI32; // pulls src/clamp.axle into the library surface
use crate::interp::lerp; // … and src/interp.axle Linking a native library
The same flags are available ad hoc on the command line, and combine
with the manifest’s [link] table:
axle build --link-lib SDL2 --lib-path C:/SDL2/lib This is how an Axle program reaches a C library whose API is expressed in
scalars and pointers (handles), e.g. SDL2. Libraries whose API passes C
structs by value (such as raylib’s Vector3 / Color) need a thin C
shim that flattens those structs into scalar arguments, since Axle’s FFI
marshals scalars, pointers, and strings — not by-value aggregates.
After a successful binary build, the compiler looks for each linked
library’s <name>.dll in the directories you named — the --lib-path flags and the manifest’s [link] paths — and copies the first hit next to the produced executable, so a Windows binary runs without
touching PATH. A library with no .dll in those directories is skipped
without a word, which is the right outcome for a static or system library
and for a build on a platform that has no DLLs at all.
Rebuilding only what changed
axle build --incremental skips codegen and the link when nothing that
determines the output has changed since the last successful build. It works on
a single file, on a one-crate project, and on a multi-crate workspace — an edit
in any member crate is seen, not only in the one holding main.
axle build --incremental # first run: builds
axle build --incremental # ✓ Up to date (cached) The flag is opt-in and errs towards rebuilding: a changed source, a different -O, another --emit, a new --link-lib or --lib-path, a different --target, --target-cpu or --target-features, a different --pie, --alloc-stats or --codegen-units, a different output path, or simply a
newer axle all rebuild. The stamp lives in .axle-cache/ beside the
artefact; deleting it costs one rebuild and nothing else. axle run ignores
the cache and always builds fresh.
Limitations
- Path dependencies only. There is no remote registry and no git
source, and no lockfile — a dependency is a sibling directory reached
through
path. --targetnames the artefact for the host. Cross-compiling a project builds the code for the target but derives the output name from the host; pass--outputto override.@targettakes nonot,any,all, or nesting. The closed grammar, over the axesosandarch, is what keeps “does this project serve every target it promises” a question with an answer.featureis a[port]condition, not a@targetaxis. A[port]whenmay name a feature; a@target(feature = …)is refused.