.ax Language Reference
Looking for the formal specification? This page is the narrative, example-driven reference. The authoritative grammar, type rules, and capability semantics live in
docs/spec/ax-language-1.0.md. When the two disagree, the spec wins.
.ax is Boruna’s statically-typed, deterministic scripting language. It compiles to Boruna bytecode and runs on the Boruna VM. It is designed for workflow steps: small, focused, pure functions with explicit capability declarations for any side effects.
File structure
Every standalone .ax file must define fn main() -> Int. The main function is the entry point for boruna run.
fn main() -> Int {
42
}
Types
| Type | Description | Example |
|---|---|---|
Int | 64-bit signed integer | 42, -7 |
Float | 64-bit float | 3.14 |
String | UTF-8 string | "hello" |
Bool | Boolean | true, false |
Unit | No value | () |
Option<T> | Optional value | Some(42), None |
Result<T, E> | Success or error | Ok(42), Err("msg") |
List<T> | Ordered list | [1, 2, 3] |
Map<K, V> | Key-value map | {"a": 1, "b": 2} |
| Records | Named fields | Point { x: 1, y: 2 } |
| Enums | Tagged union | Shape::Circle(5) |
Variables
Use let with an explicit type annotation:
let name: String = "Boruna"
let count: Int = 0
let flag: Bool = true
A binding that you want to change later is declared with let mut and rebound with =:
let mut total: Int = 0
total = total + 5
Rebinding changes what the name refers to; values themselves (records, lists, maps) are never
modified in place. Reassigning a binding declared without mut still compiles, but
boruna lang check reports warning E010 and boruna lang repair adds the missing mut. It will
be a compile error in language version 2.0.
Loops
fn sum(items: List<Int>) -> Int {
let mut total: Int = 0
for x in items {
total = total + x
}
total
}
fn factorial(n: Int) -> Int {
let mut result: Int = 1
let mut i: Int = n
while i > 0 {
result = result * i
i = i - 1
}
result
}
for iterates a List in order; the loop variable and any let inside the body are scoped to
the body. A loop that never ends is stopped by the step limit (--step-limit) with a runtime
error. Recursion still works and is often the clearer choice.
No semicolons. Each statement is on its own line.
Functions
fn add(a: Int, b: Int) -> Int {
a + b
}
The last expression in a function body is the return value. No return keyword needed.
Capability annotations
Functions that perform side effects must declare the required capabilities:
fn fetch(url: String) -> String !{net.fetch} {
// live implementation
}
fn call_model(prompt: String) -> String !{llm.call} {
// live implementation
}
// Multiple capabilities
fn fetch_and_cache(url: String) -> String !{net.fetch, fs.write} {
// live implementation
}
Without the annotation, the VM will reject any attempt to call the capability at runtime.
Intent declarations
A function may declare a single machine-read purpose with an intent "..." clause
after its signature. The clause is optional and order-independent with capability
and contract clauses:
fn transfer(amount: Int) -> Int !{db.write} intent "Move funds between accounts" {
// implementation
}
Intent is captured into the run’s evidence bundle (intents.json, keyed by step id)
so an auditor sees what each step was authorized to do alongside what it actually
did. It is covered by the bundle checksums, so tampering with a captured intent makes
boruna evidence verify fail. A function may declare at most one intent; a second
is a compile error.
Contracts (requires)
A function may declare one or more requires <expr> preconditions, checked at
runtime against its arguments on entry:
fn transfer(amount: Int) -> Int !{db.write} requires amount > 0 {
// runs only if amount > 0
}
If a precondition is false when the function is called, execution traps with a
contract violation carrying a counterexample — the concrete arguments that
triggered it (e.g. [0]) — so the failing input is reproducible. In a workflow,
the violation surfaces with the stable error_kind contract_violation and the
counterexample is recorded in the run’s hash-chained audit log (tamper-evident
evidence). A violation is deterministic in the inputs, so it is not retry-eligible.
Contracts are enforced purely at runtime (concrete-trace checking) — Boruna does
not use SMT/symbolic proving. ensures postconditions are parsed but not yet
enforced.
Records
Define named record types with the type keyword:
type Point {
x: Int,
y: Int,
}
let p: Point = Point { x: 3, y: 4 }
let px: Int = p.x
Record spread creates an updated copy:
let p2: Point = Point { ..p, y: 10 }
Enums
Each variant is either a unit variant or carries a single payload value. Construct a
value with the EnumName::Variant(payload) form (unit variants take no parentheses):
enum Shape {
Circle(Float),
Square(Float),
}
let s: Shape = Shape::Circle(5.0)
Pattern matching
let result: String = match s {
Circle(radius) => "circle"
Square(side) => "square"
_ => "unknown"
}
Match on Option:
let value: Option<Int> = Some(42)
let n: Int = match value {
Some(x) => x
None => 0
}
Match on Result:
let r: Result<Int, String> = Ok(99)
let out: Int = match r {
Ok(v) => v
Err(_) => -1
}
Match on strings:
let greeting: String = match lang {
"en" => "hello"
"es" => "hola"
_ => "hi"
}
Conditionals
let label: String = if score > 90 {
"pass"
} else {
"fail"
}
Lists
let items: List<Int> = [1, 2, 3, 4, 5]
List operations are available through the standard library.
Maps
let config: Map<String, Int> = { "timeout": 30, "retries": 3 }
Framework apps
Framework apps implement the Elm architecture. They must define:
fn init() -> State { ... }
fn update(state: State, msg: Msg) -> UpdateResult { ... }
fn view(state: State) -> UINode { ... }
Where State, Msg, Effect, UpdateResult, UINode, and PolicySet are the framework protocol types. See FRAMEWORK_SPEC.md for the full protocol.
Syntax quick reference
// Comments use double-slash
// Variables (type required; add `mut` to rebind later)
let x: Int = 42
let mut n: Int = 0
n = n + 1
// Loops
for item in [1, 2, 3] { n = n + item }
while n > 0 { n = n - 1 }
// Function
fn square(n: Int) -> Int {
n * n
}
// Capability function
fn now() -> Int !{time.now} {
// implementation
}
// Record literal
Point { x: 1, y: 2 }
// Record spread
Point { ..point, x: 10 }
// Enum variant
Shape::Circle(5.0)
// Pattern match
match x {
0 => "zero"
_ => "nonzero"
}
// Option
Some(42)
None
// Result
Ok("value")
Err("message")
// List
[1, 2, 3]
// Map
{ "key": "value" }
Import statements
import "std-name"
Import statements load a standard library package at compile time. The library source is inlined into the compilation unit before type-checking. The import line itself is removed from the compiled output.
Standard library packages are resolved from the libs/ directory relative to the current working directory. When a library source is inlined, any fn main() -> Int stub present in the library file is stripped so it does not conflict with the importing program’s own main.
Example:
import "std-json"
fn main() -> Int {
let s: String = int_to_string(42)
0
}
Built-in functions
These functions are provided by the runtime and do not need to be imported:
| Function | Signature | Description |
|---|---|---|
__builtin_int_to_string | (Int) -> String | Convert an integer to its decimal string representation |
__builtin_float_to_string | (Float) -> String | Convert a float to its string representation |
__builtin_string_len | (String) -> Int | Length of a string in bytes |
__builtin_string_chars | (String) -> List<String> | Split a string into a list of single-character strings |
__builtin_string_contains | (String, String) -> Bool | True if first string contains the second |
__builtin_string_starts_with | (String, String) -> Bool | True if string starts with prefix |
__builtin_string_ends_with | (String, String) -> Bool | True if string ends with suffix |
__builtin_string_to_upper | (String) -> String | Uppercase copy |
__builtin_string_to_lower | (String) -> String | Lowercase copy |
__builtin_string_trim | (String) -> String | Strip leading/trailing whitespace |
__builtin_string_join | (List<String>, String) -> String | Join list with separator |
__builtin_string_split | (String, String) -> List<String> | Split string on a delimiter |
__builtin_string_replace | (String, String, String) -> String | Replace first occurrence of pattern |
__builtin_string_slice | (String, Int, Int) -> String | Substring by byte offsets |
__builtin_int_parse | (String) -> Result<Int, String> | Parse a decimal integer string |
__builtin_float_parse | (String) -> Result<Float, String> | Parse a float string |
__builtin_bool_to_string | (Bool) -> String | Convert a bool to “true” or “false” |
__builtin_list_len | (List<T>) -> Int | Number of elements |
__builtin_list_is_empty | (List<T>) -> Bool | True if list has zero elements |
__builtin_list_head | (List<T>) -> Option<T> | First element, or None |
__builtin_list_tail | (List<T>) -> List<T> | All elements after the first |
__builtin_list_append | (List<T>, T) -> List<T> | New list with item added at end |
__builtin_list_concat | (List<T>, List<T>) -> List<T> | Concatenate two lists |
__builtin_list_reverse | (List<T>) -> List<T> | Reversed copy |
__builtin_map_get | (Map<String, V>, String) -> Option<V> | Look up a key; returns Some(v) or None |
__builtin_map_set | (Map<String, V>, String, V) -> Map<String, V> | Return a new map with key set to value |
__builtin_map_remove | (Map<String, V>, String) -> Map<String, V> | Return a new map with key removed |
__builtin_map_contains_key | (Map<String, V>, String) -> Bool | True if key is present |
__builtin_map_keys | (Map<String, V>) -> List<String> | All keys in sorted order |
__builtin_map_values | (Map<String, V>) -> List<V> | All values in key-sorted order |
__builtin_map_len | (Map<String, V>) -> Int | Number of entries |
These built-ins are also wrapped in std-json (via int_to_string, json_escape) and can be called directly in any .ax file.
Note on naming: The __builtin_ prefix distinguishes these from user-defined functions and prevents shadowing. User-facing wrappers in stdlib packages use cleaner names.
What .ax is not
.ax is deliberately minimal. It does not have:
- Mutable variables (use record spread for state transitions)
- Loops (use recursion or standard library functions)
- Exceptions (use
Result<T, E>) - Implicit side effects (every effect must be declared)
- Generics (types are concrete at definition time)
These omissions are intentional. They keep the language deterministic and auditable.