Axle v0.14.1

Conventions

Idiomatic Axle. Patterns the compiler, stdlib, and built-in tooling all expect. Following them keeps your code consistent with the stdlib examples and the axle tooling output.

Naming

KindConventionExample
Types (classes, traits)PascalCaseArrayList, IOException, Runnable
Free functions, methodscamelCaseparseInt, readLine, getName
Variables, parameters, fieldscamelCaseuserName, lineCount
Boolean queriesis… / has… / can…isEmpty, hasNext, canRead
Constants (module-level)SCREAMING_SNAKEMAX_BUFFER_SIZE
Type parameterssingle uppercase letterT, K, V, E
Modules / fileslower_snaketext_json, core_util

Stdlib follows mainstream OO conventions deliberately — coming from C#, Kotlin or Swift, every name maps to its expected shape (size() not len, isEmpty() not is_empty, IOException not IoError).

File layout

A typical source file groups one logical concern :

// 1. Imports
use std::collections::ArrayList;
use std::io::File;

// 2. Top-level constants
const MAX_LINES : i32 = 1000;

// 3. Types (traits before classes that implement them)
trait Encoder {
    fn encode(self, s : string) : string;
}

class Base64 : Encoder {
    pub fn encode(self, s : string) : string {
        return s;
    }
}

// 4. Free functions
fn loadConfig(path : string) : ArrayList<string> ! IOException {
    let lines : ArrayList<string> = new ArrayList<string>();
    lines.add(path);
    return lines;
}

// 5. Entry point (last)
fn main() : i32 {
    let cfg : ArrayList<string> = loadConfig("app.conf") catch _ {
        new ArrayList<string>();
    };
    return cfg.size() as i32;
}

Ownership idioms

The default parameter mode is borrowed (no keyword). Reach for a prefix only when the contract really differs :

class Rectangle {
    w : f64;
    h : f64;
    constructor(w : f64, h : f64) { self.w = w; self.h = h; }
}

// Borrowed — the default. The caller keeps ownership and the
// borrow cannot escape the call.
fn area(rect : Rectangle) : f64 { return rect.w * rect.h; }

// You will mutate fields of the param ; caller still owns it.
fn translate(mut rect : Rectangle, dx : f64, dy : f64) : void {
    rect.w = rect.w + dx;
    rect.h = rect.h + dy;
}

// You take ownership ; caller must not use it afterwards. Rare —
// reserve for explicit hand-off (e.g. into a queue / collection).
fn enqueue(own r : Rectangle) : void { }

// You take a refcounted handle. Cheap to copy (bumps the refcount),
// safe to keep across threads.
fn schedule(rect : Shared<Rectangle>) : void { }

When in doubt, borrow. Promoting later (borrow → mut, borrow → own) is mechanical ; demoting (own → borrow) breaks callers.

When to use which storage tier

Axle has four allocation tiers picked automatically by the compiler’s escape analysis :

TierWhen the compiler picks itWhen you write it explicitly
Stacklocal that never escapes the fnnever — automatic
Arenalocal that escapes its declaring block but not the function framerare — automatic in most code
Heap (new T(…))escape into a return value / long-lived storelet x = new Foo(…)
Shared (new shared T(…))needs to outlive a single owner or cross thread boundarieslet x = new shared Foo(…)

The escape analysis is conservative — when it can’t prove an allocation stays local, it leaves it on the heap rather than risk an unsound promotion. The two you choose explicitly are heap (new T(…)) and shared (new shared T(…)).

class Point {
    x : f64;
    y : f64;
    constructor(x : f64, y : f64) { self.x = x; self.y = y; }
}

// The compiler picks stack — `pt` never leaves `area`.
fn area() : f64 {
    let pt : Point = new Point(1.0, 2.0);
    return pt.x * pt.y;
}

// The compiler picks heap — escaped through the return.
fn makePoint() : Point {
    return new Point(1.0, 2.0);
}

// You pick shared — pt will be handed to multiple threads.
fn shared_point() : Shared<Point> {
    return new shared Point(1.0, 2.0);
}

See Memory model and Shared<T> reference counting for the lifecycle details.

Error handling

A function’s failure set lives in its return type — : R ! E1, E2. A function that can fail must list it there ; callers must either propagate (declare it onward, or mark the site with ?) or catch. This holds for every family — there is no unchecked exemption ; for a bug you never want to surface, hard-abort with std::ffi::runtime::panicMsg.

class Config {
    name : string;
    constructor(name : string) { self.name = name; }
    pub static fn defaults() : Config { return new Config("default"); }
}

fn readFile(path : string) : string ! IOException {
    if (path.isEmpty()) { throw FileNotFoundException("no path given"); }
    return "name=" + path;
}

// Declares what it can fail with — checked at compile time.
fn parseConfig(text : string) : Config ! ParseException {
    if (text.isEmpty()) {
        throw ParseException("config is empty");
    }
    return new Config(text);
}

// Caller propagates (the `!` set lists both; `?` marks each site).
fn load() : Config ! ParseException, IOException {
    let text : string = readFile("/etc/app.conf")?;
    return parseConfig(text)?;
}

// Caller catches — must restore a meaningful state.
fn loadOrDefault() : Config {
    try {
        return load();
    } catch e : ParseException {
        return Config::defaults();
    } catch e : IOException {
        return Config::defaults();
    }
}

Rules :

  • Exceptions are exceptional — don’t use them for control flow on the happy path. Narrow a nullable with if (input == null) rather than reaching for exceptions: a null value is a T | null you must check, not a NullPointerException the language throws for you.
  • Always catch the most specific subtype first.
  • Use defer for cleanup — see below.
  • Don’t swallow exceptions silently. If you really mean “ignore”, say so with a one-line comment.

Stdlib operations are held to the same rule — a fallible write like ArrayList.set / removeAt raises a checked IndexOutOfBoundsException you must discharge. For a single call, the inline catch e { … } form is lighter than a full try block. Mind the trailing ; — the whole expr catch { … } is one statement, and the block must recover or exit (an empty {} is rejected):

use std::collections::ArrayList;

fn demo() : void {
    let xs : ArrayList<i32> = new ArrayList<i32>();
    xs.add(10);
    xs.set(0, 99) catch e { println("out of range"); };   // recover in place
    xs.removeAt(3) catch e { return; };                  // or exit the fn
}

A fallible read is the mirror image — xs.get(i) returns T | null, not a throw, so you unwrap it with ?? default instead of catching.

See Reading compiler errors for diagnostic format and errors/error-handling.md for patterns.

Cleanup with defer

Pair every acquisition with a defer for the release, immediately after acquisition :

use std::io::FileInputStream;
use std::io::FileOutputStream;
use std::io::FileOpenMode;

fn copyFile(src : string, dst : string)
    : void ! IOException, FileNotFoundException
{
    let input : FileInputStream = new FileInputStream(src);
    defer input.close();                    // releases on every exit path

    let output : FileOutputStream = new FileOutputStream(dst, FileOpenMode::WRITE);
    defer output.close();

    let b : i32 = input.read();
    while (b >= 0) { output.write(b); b = input.read(); }
}

defer runs when the block that registered it exits — whether the exit is a return, an uncaught throw, a break / continue out of it, or plain fall-through. Written at the top level of a function body, that is function exit. Defers fire in LIFO order, so defer input.close() followed by defer output.close() closes output first, then input.

The same LIFO order interleaves your defers with the compiler’s own scope cleanup, and that is what makes the idiom above safe: a defer written under a declaration runs before that binding is torn down, so close() sees a live object rather than one whose buffers have already gone back to the allocator.

A defer written inside a nested if / for / { … } block fires at the end of that block, where the resource it names is still in scope. Cleanup that has to outlive the block belongs beside the acquisition, one level up.

Concurrency idioms

Shared<T> for lifetime, synchronized for access. The combination is the safe baseline ; reach for atomics only when the access is a single primitive operation :

class Counter {
    pub value : i32;
    constructor() {
        self.value = 0;
    }
}

fn worker(c : Shared<Counter>) : i32 {
    synchronized (c) { c.value = c.value + 1; }
    return 0;
}

fn main() : i32 ! InterruptedException {
    let c : Shared<Counter> = new shared Counter();

    let f1 : Task<i32> = spawn worker(c);
    let f2 : Task<i32> = spawn worker(c);

    f1.join();
    f2.join();
    return c.value;                             // 2
}

A Shared<T> is used through its handle exactly as the object is used directly: its fields read and write (c.value), and its methods are called on the handle (c.bump()) — a self or mut self method borrows the object for the call while the handle keeps it alive. Only an own self method is refused (E0504), since it would take the object away from every other handle. The examples here keep the state in fields and the operations as free functions, so the reader sees a plain field read rather than a receiver conversion.

The lock is just an object — its identity keys the per-pointer reentrant mutex inside the runtime. A common pattern is to lock on the same Shared<T> you’re mutating, so the lock travels with the data. spawn fn(args) packs the args struct into a thunk that the worker thread runs ; no lambda machinery needed for this shape.

For single-primitive shared state, an atomic class is one instruction instead of a lock dance :

use std::concurrent::AtomicI32;

fn main() : i32 {
    let counter : AtomicI32 = new AtomicI32(0);
    counter.incrementAndGet();   // thread-safe, lock-free; returns the new value
    return counter.get();
}

See concurrency/multithreading.md for the full set of patterns.

Strings

Strings are immutable UTF-8. string is a value type — passing one to a fn copies a handle, not the bytes.

== / != on strings compare content — two distinct heap strings with equal bytes are equal. .equals(...) / .equalsIgnoreCase(...) remain available (the latter for case-insensitive comparison).

use std::text::StringBuilder;

fn main() : i32 {
    let name : string = "Ada";

    // Concatenation — left-to-right
    let greeting : string = "Hello, " + name + "!";
    println(greeting);

    // Building large strings — use StringBuilder, not iterative concat.
    let sb : StringBuilder = new StringBuilder();
    for (i of 0..100) {
        sb.append("line ");
        sb.appendI32(i);
        sb.append("\n");
    }
    let result : string = sb.toString();
    println(result);
    return 0;
}

Iterative += on strings allocates a new string each time (O(n²) over n iterations). StringBuilder is O(n).

Classes — order of declarations

Inside a class :

enum Role {
    ADMIN,
    USER,
}

class User {
    // 1. Fields (immutable refs / data first, mutable state after)
    id   : i64;
    name : string;
    role : Role;

    // 2. Constructor(s)
    constructor(id : i64, name : string, own role : Role) {
        self.id = id;
        self.name = name;
        self.role = role;
    }

    // 3. Query methods (no side effects)
    pub fn isAdmin(self) : bool {
        return self.role == Role::ADMIN;
    }

    // 4. Mutators
    pub fn rename(mut self, newName : string) : void {
        self.name = newName;
    }
}

Prefer an enum over a string for a closed set of states — the compiler then rejects typos at compile time instead of letting "amdin" slip through to runtime.

Keep classes focused — one responsibility. If a class has more than 5–7 fields or 10–15 methods, split it.

Modules and use

// Group `use` declarations at the top, one per line.
use std::collections::ArrayList;     // a TYPE — import by name
use std::collections::HashMap;       // a TYPE — import by name
use std::io::File;                   // a TYPE — import by name
use std::text;                       // a MODULE — for its free functions

Import a type by its bare name; import a module for its free functions. The two forms are not interchangeable — pick by what you’re reaching for:

Reaching for…WriteCall site
a type (class / trait / exception)use std::collections::ArrayList;new ArrayList<i32>()
a module’s free functionsuse std::text;text::f64ToString(x)

Prefer importing the module for its free functions (use std::text;) and calling text::f64ToString(...) — the module:: prefix documents the origin at every call site, and one use line then covers the whole module’s surface. Importing a single free function (use std::text::f64ToString;) is accepted by the compiler, and is the natural form when a file reaches for exactly one symbol. Wildcard imports (use std::collections::*;) are not supported — spell every type name out. Greps for callers are faster and the diff says exactly what each file pulls in.

Test programs

Axle has no separate unit-test harness ; the working convention is one small program per behaviour, returning its verdict through the process exit code :

// Checks compound assignment on locals.
fn main() : i32 {
    let x : i32 = 40;
    x += 2;
    if (x == 42) { return 0; }       // 0 = pass
    return 1;                        // non-zero = which check failed
}

Conventions :

  • One behaviour per file, named after it (compound_assign.axle).
  • return 0 means “passed” ; distinct non-zero codes identify which assertion failed.
  • A one-line // description at the top says what’s being exercised.
  • Each file is a standalone program — build and run it directly : axle build tests/compound_assign.axle -o /tmp/t && /tmp/t.

See also

conventionsstyleidiomsbest-practices