Axle v0.14.1

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 optional entry (defaults to src/main.axle), and an optional targets list 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 own axle.toml and src/), seen through its src/lib.axle when it has one — a project with a src/main.axle too is a program when you build it, and a library to the projects that depend on it. A symbol becomes importable with use <name>::Symbol; when it is marked pub and reachable from lib.axle’s import closure (declared in lib.axle, or pulled in there with a use crate::…). Such a dependency’s main.axle is never pulled into your build, so its main can’t collide with yours. A dependency whose manifest names its entry or kind is 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.
  • --target names the artefact for the host. Cross-compiling a project builds the code for the target but derives the output name from the host; pass --output to override.
  • @target takes no not, any, all, or nesting. The closed grammar, over the axes os and arch, is what keeps “does this project serve every target it promises” a question with an answer.
  • feature is a [port] condition, not a @target axis. A [port] when may name a feature; a @target(feature = …) is refused.
projectsdependenciesaxle-tomlbuild