Skip to content

Language Spec

Version 0.3.0 — Phase 2 Progress


  1. Overview & Design Philosophy
  2. Source Formats
  3. Lexical Grammar
  4. Syntax Grammar
  5. Type System
  6. Expressions
  7. Declarations
  8. Module System
  9. Operator Reference
  10. Built-in Functions
  11. Memory Model
  12. Error Handling
  13. Concurrency Model

AXON (AI-Native eXecution Optimized Notation) is a programming language designed for AI models to generate high-performance native applications. It is explicitly not designed for human readability or ergonomics.

  1. AI-Native — Syntax and semantics are optimized for machine generation. The grammar is regular and context-free; every construct is fully parenthesized; there is no syntactic sugar that introduces ambiguity.

  2. Performance-First — The language compiles to native code via QBE or LLVM. There is no garbage collector, no runtime reflection, no dynamic dispatch. All memory management is explicit (arena-based in Phase 1, linear types in Phase 2).

  3. Fully Explicit — Every expression has a known type. Every literal carries a type annotation. There are no implicit conversions, no type inference beyond what is structurally determined, and no overloading.

  4. Minimal — The language has a small number of orthogonal constructs. There is one way to define functions, one way to bind variables, one way to create loops. This minimizes the state space an AI model must navigate.

  5. Deterministic Compilation — The same source always produces the same IR. There are no order-dependent name resolution rules, no macro expansion phases, and no preprocessor.

  • Human readability
  • Rich syntax sugar (operator overloading, method chaining, pattern matching in Phase 1)
  • Dynamic features (reflection, eval, dynamic types)
  • Garbage collection

2.1 S-Expression Text Format (.axs) — Phase 1

Section titled “2.1 S-Expression Text Format (.axs) — Phase 1”

The current source format is S-expressions: fully parenthesized prefix notation. This format is:

  • Unambiguous — no operator precedence, no dangling else, no semicolon insertion
  • Easy to parse — a recursive descent parser handles the entire language
  • Easy to generate — an AI model needs only emit balanced parentheses and known keywords

Files use the .axs extension. The encoding is UTF-8.

(module
(fn add_one ((x i64)) i64
(add x (i64 1))))

Phase 2 introduces a compact binary encoding of the AST. The AI model emits a byte stream of opcodes, type table indices, and De Bruijn variable references. This eliminates lexing and parsing entirely — the compiler loads the binary directly into its internal representation.

See binary-format.md for the complete specification.

In .axs files, line comments begin with ;; and extend to the end of the line:

;; This is a comment
(fn main () i32 ;; inline comment
(i32 0))

There are no block comments.


The lexer produces a flat stream of tokens from .axs source text.

TokenPatternExamples
LPAREN((
RPAREN))
IDENT[a-zA-Z_][a-zA-Z0-9_]*fn, add, my_var, i64
INT[+-]?[0-9]+ or 0x[0-9a-fA-F]+ or 0b[01]+42, -7, 0xFF, 0b1010
FLOAT[0-9]+\.[0-9]*([eE][+-]?[0-9]+)?3.14, 1e-5, 2.0
STRING"[^"]*""hello", "Hello, World!"
DOT_DOT_DOT......
TRUEtruetrue
FALSEfalsefalse
EOFend of input

Whitespace characters (space, tab, newline, carriage return) separate tokens but are otherwise ignored. Any amount of whitespace is permitted between tokens.

Identifiers start with a letter or underscore, followed by zero or more letters, digits, or underscores. Identifiers are case-sensitive.

Keywords (like fn, module, if, while, let, set, block, ret, cast, call, extern, struct, defer) are parsed contextually — they are valid identifiers that acquire special meaning only in specific syntactic positions.

Integer literals may be decimal, hexadecimal (prefix 0x), or binary (prefix 0b). Negative integers are expressed with a leading - sign. All integer literals must appear inside a typed expression: (i64 42), (i32 -7), (u8 255).

Float literals contain a decimal point and/or an exponent. They must appear inside a typed expression: (f64 3.14), (f32 1e-5).

String literals are enclosed in double quotes. The following escape sequences are recognized:

EscapeMeaning
\\Backslash
\"Double quote
\nNewline (LF)
\rCarriage return
\tTab
\0Null byte

The tokens true and false are boolean literals. They must appear inside a typed expression: (bool true), (bool false).


The following grammar is specified in EBNF. Terminals are in 'quotes' or UPPERCASE. Nonterminals are in lowercase_with_underscores.

module ::= '(' 'module' decl* ')'

A module is the top-level compilation unit. It contains zero or more declarations.

decl ::= fn_decl
| extern_decl
| struct_decl
| const_decl
| import_decl
| use_decl
fn_decl ::= '(' 'fn' IDENT '(' param* ')' type expr ')'
param ::= '(' IDENT type ')'
extern_decl::= '(' 'extern' IDENT '(' param_or_type* variadic? ')' type ')'
param_or_type ::= '(' IDENT type ')'
| type
variadic ::= '...'
struct_decl::= '(' 'struct' IDENT '(' field* ')' ')'
enum_decl ::= '(' 'enum' IDENT type '(' variant* ')' ')'
variant ::= '(' IDENT INT ')'
field ::= '(' IDENT type ')'
const_decl ::= '(' 'const' IDENT type expr ')'
import_decl ::= '(' 'import' IDENT STRING ')'
use_decl ::= '(' 'use' STRING '(' IDENT* ')' ')'
type ::= 'void'
| 'bool'
| 'i8' | 'i16' | 'i32' | 'i64'
| 'u8' | 'u16' | 'u32' | 'u64'
| 'f32' | 'f64'
| '(' 'ptr' type ')'
| '(' 'arr' type INT ')'
| '(' 'slice' type ')'
| IDENT (* struct or enum type name *)
expr ::= int_lit
| float_lit
| bool_lit
| str_lit
| var_ref
| binop
| unop
| call
| if_expr
| while_expr
| let_expr
| set_expr
| block
| ret
| defer_stmt
| cast
| index
| field_access
| deref
| addr_of
| fn_ref
| lambda
| array_lit
| struct_lit
lambda ::= '(' 'fn' '(' param* ')' type expr ')'
int_lit ::= '(' type INT ')'
float_lit ::= '(' type FLOAT ')'
bool_lit ::= '(' 'bool' BOOL ')'
str_lit ::= '(' 'str' STRING ')'
var_ref ::= IDENT
binop ::= '(' BINOP expr expr ')'
unop ::= '(' UNOP expr ')'
call ::= '(' 'call' IDENT expr* ')'
match_expr ::= '(' 'match' expr match_arm+ ')'
match_arm ::= '(' pattern expr ')'
pattern ::= '_' | expr
if_expr ::= '(' 'if' expr expr expr? ')'
while_expr ::= '(' 'while' expr expr ')'
let_expr ::= '(' 'let' '(' IDENT type? expr ')' expr ')'
set_expr ::= '(' 'set' IDENT expr ')'
block ::= '(' 'block' expr+ ')'
ret ::= '(' 'ret' expr? ')'
defer_stmt ::= '(' 'defer' expr ')'
cast ::= '(' 'cast' type expr ')'
index ::= '(' 'idx' expr expr ')'
field_access ::= '(' 'fld' expr IDENT ')'
deref ::= '(' 'deref' expr ')'
addr_of ::= '(' 'addr' IDENT ')'
array_lit ::= '(' 'arr.new' type expr* ')'
struct_lit ::= '(' 'new' IDENT field_init* ')'
field_init ::= '(' IDENT expr ')'
BINOP ::= 'add' | 'sub' | 'mul' | 'div' | 'mod'
| 'eq' | 'ne' | 'lt' | 'le' | 'gt' | 'ge'
| 'band' | 'bor' | 'bxor'
| 'shl' | 'shr'
| 'land' | 'lor'
UNOP ::= 'neg' | 'not' | 'bnot'
BOOL ::= 'true' | 'false'

TypeSizeDescription
void0No value (used for functions with no return)
bool1 byteBoolean: true or false
i81 byteSigned 8-bit integer
i162 bytesSigned 16-bit integer
i324 bytesSigned 32-bit integer
i648 bytesSigned 64-bit integer
u81 byteUnsigned 8-bit integer
u162 bytesUnsigned 16-bit integer
u324 bytesUnsigned 32-bit integer
u648 bytesUnsigned 64-bit integer
f324 bytes32-bit IEEE 754 float
f648 bytes64-bit IEEE 754 double

A pointer to a value of type T. Pointer size is platform-dependent (8 bytes on 64-bit systems).

(ptr i64) ;; pointer to i64
(ptr (ptr u8)) ;; pointer to pointer to u8

A fixed-size array of N elements of type T. The size N must be a compile-time integer constant.

(arr i32 10) ;; array of 10 i32 values
(arr f64 3) ;; array of 3 f64 values

A runtime-sized view into a contiguous sequence of T values. Represented as a pointer + length pair.

(slice u8) ;; slice of u8 (byte slice)

A named aggregate type with named fields. Defined via struct declaration, referenced by name.

;; Declaration:
(struct Point ((x f64) (y f64)))
;; Usage as type:
(fn distance ((p Point)) f64 ...)

Function Pointer — (fnptr (P1 P2 ...) R)

Section titled “Function Pointer — (fnptr (P1 P2 ...) R)”

A pointer to a function with parameter types P1, P2, ... and return type R. Function pointers are pointer-sized (8 bytes on 64-bit systems).

;; Type annotation:
(fnptr (i64) i64) ;; pointer to function taking i64, returning i64
(fnptr (i64 i64) bool) ;; pointer to function taking two i64, returning bool
(fnptr () void) ;; pointer to function taking nothing, returning void
;; In a let binding — function name becomes a fnptr value:
(let (f (fnptr (i64) i64) my_function)
(call f (i64 42))) ;; indirect call through function pointer
;; As a function parameter (higher-order functions):
(fn apply ((f (fnptr (i64) i64)) (x i64)) i64
(call f x)) ;; indirect call through parameter
;; Passing a function as argument:
(call apply double (i64 21)) ;; 'double' is coerced to fnptr

Rules:

  • A function name used outside of call position is automatically coerced to a (fnptr ...) value.
  • Indirect calls through a function pointer use the same (call ...) syntax as direct calls.
  • Function pointer types are structurally compared — two fnptr types with the same parameter and return types are equal.
  • Assigning a function to a fnptr variable checks that the function signature matches the fnptr type exactly.
  • Lambda expressions (fn ((params...)) ret body) evaluate to a (fnptr ...) value. The lambda is lifted to a top-level function at compile time.

Function types are created implicitly from function and extern declarations. They are not directly expressible in the source language as type annotations.

Enum — (enum Name BackingType (variants...))

Section titled “Enum — (enum Name BackingType (variants...))”

A named type with integer-valued variants. The backing type must be an integer type (e.g., i32, u8). Enum types are nominally typed — two enums with the same structure but different names are different types.

;; Declaration:
(enum Color i32 ((Red 0) (Green 1) (Blue 2)))
;; Enum literal (variant constructor):
(Color Red) ;; creates a Color value
(Color Blue) ;; creates a Color value
;; Use in let bindings:
(let (c Color (Color Green))
...)
;; Use as function parameter:
(fn describe_color ((c Color)) i64
(cast i64 c))
;; Equality comparison (eq/ne only):
(if (eq c (Color Red)) ...)
;; Cast to/from backing integer type:
(cast i64 (Color Green)) ;; → 1

Rules:

  • Enum types are declared at module level with (enum Name BackingType (...)).
  • Each variant has an explicit integer value.
  • Enum literals are written as (EnumName VariantName).
  • Only eq and ne comparisons are supported on enum values.
  • Arithmetic operators are not supported on enums. Cast to integer first.
  • Enums can be cast to their backing integer type and vice versa.
  • Enum values are stored with the same size and alignment as their backing type.
  1. No implicit conversions. An i32 value cannot be used where i64 is expected. Use (cast i64 expr) explicitly.

  2. Arithmetic operators require both operands to have the same type, and that type must be numeric (integer or float).

  3. Comparison operators (eq, ne, lt, le, gt, ge) require both operands to have the same type. They produce bool. Enum types support only eq and ne.

  4. Bitwise operators (band, bor, bxor, shl, shr) require integer operands of the same type. shl and shr require both operands to be the same integer type.

  5. Logical operators (not, land, lor) operate on bool values.

  6. if expressions: the condition must be bool. If both branches are present, they must have the same type (that becomes the type of the if expression). If only the then-branch is present, both branches have type void.

  7. let expressions: the initializer must match the annotated type (if provided). The body expression determines the type of the let expression.

  8. call expressions: argument types must match the function’s parameter types exactly. The call expression has the function’s return type.

  9. cast expressions: only certain casts are legal:

    • Integer ↔ Integer (widening, narrowing, sign change)
    • Float ↔ Float (f32f64)
    • Integer → Float, Float → Integer
    • Pointer → Pointer (reinterpret)
    • Integer ↔ Pointer (platform-dependent)
    • Enum ↔ Integer (extract or inject the backing value)

Phase 2 introduces linear types for compile-time memory management:

AnnotationMeaning
@onceValue must be used exactly once (linear)
@maybeValue may be used zero or one times (affine)
@manyValue may be used any number of times (unrestricted, default)
;; Phase 2 syntax (not yet implemented)
(fn consume ((@once handle ResourceHandle)) void
...)
AnnotationMeaning
@region(name)Allocate in named region
@arenaAllocate in arena (bulk free)
@poolAllocate in fixed-size pool
@stackAllocate on the stack
AnnotationMeaning
@soaStruct-of-arrays layout
@align(N)Custom alignment
@hotPlace in hot section (cache-friendly)
@coldPlace in cold section

(i64 42) ;; i64 with value 42
(i32 -7) ;; i32 with value -7
(u8 255) ;; u8 with value 255
(i64 0xFF) ;; i64 with value 255 (hex)
(u32 0b11010) ;; u32 with value 26 (binary)

The type annotation is mandatory. The literal value must fit in the specified type.

(f64 3.14) ;; f64
(f32 1.0) ;; f32
(f64 1e-5) ;; f64 with scientific notation
(bool true)
(bool false)
(str "Hello, World!")
(str "line1\nline2")
(str "") ;; empty string

String literals have type (ptr i8) — a pointer to a null-terminated C string.

x ;; reference to variable x
n ;; reference to variable n
my_counter ;; reference to variable my_counter

Bare identifiers reference variables in scope. A variable must be defined (via let, function parameter, or loop) before it can be referenced.

(add x y) ;; x + y
(sub a b) ;; a - b
(mul (i64 3) (i64 7));; 3 * 7
(eq x (i64 0)) ;; x == 0
(lt n (i64 2)) ;; n < 2
(band flags (u32 0xFF)) ;; flags & 0xFF
(shl x (i64 3)) ;; x << 3

Both operands must have the same type. The result type is the same as the operands, except for comparison operators which produce bool.

(neg x) ;; -x (arithmetic negation)
(not cond) ;; !cond (logical NOT, bool → bool)
(bnot mask) ;; ~mask (bitwise NOT)
(call fib (i64 35)) ;; call fib with one argument
(call add_pair (i64 3) (i64 7)) ;; call add_pair with two arguments
(call exit) ;; call exit with no arguments

The callee must be a function name (not an expression). Arguments are evaluated left-to-right.

;; With else branch (produces a value)
(if (lt n (i64 2))
n
(add (call fib (sub n (i64 1)))
(call fib (sub n (i64 2)))))
;; Without else branch (void type)
(if (gt x (i64 0))
(call print_i64 x))

When both branches are present, the if expression produces a value — the type of the expression is the common type of both branches.

(while (gt n (i64 0))
(block
(call print_i64 n)
(set n (sub n (i64 1)))))

The condition must be bool. The body is evaluated repeatedly while the condition is true. While loops have type void.

;; With type annotation
(let (x i64 (i64 42))
(add x (i64 1)))
;; Without type annotation (inferred from initializer)
(let (x (i64 42))
(add x (i64 1)))
;; Nested let bindings
(let (a (i64 10))
(let (b (i64 20))
(add a b)))

let introduces a new variable binding. The variable is mutable — it can be updated with set. The body expression determines the value and type of the let expression.

(set x (i64 5)) ;; x = 5
(set counter (add counter (i64 1))) ;; counter++

The target must be a variable previously introduced by let or a function parameter. The assigned value must have the same type as the variable.

(block
(call print_i64 (i64 1))
(call print_i64 (i64 2))
(i64 42))

A block evaluates a sequence of expressions in order. The value of the block is the value of the last expression. All preceding expressions must have type void or their values are discarded.

;; Match on enum (exhaustive — all variants covered):
(match color
((Color Red) (i64 0))
((Color Green) (i64 1))
((Color Blue) (i64 2)))
;; Match on integer (wildcard required):
(match x
((i32 0) (i64 100))
((i32 1) (i64 200))
(_ (i64 999)))
;; Match on bool (exhaustive):
(match flag
(true (i64 1))
(false (i64 0)))
;; Match with void body (side effects only):
(match state
((State Init) (call do_init))
((State Running) (call do_run))
((State Done) (call do_done)))

match evaluates the scrutinee expression and compares it against each arm’s pattern in order. The body of the first matching arm is evaluated and becomes the value of the match expression.

Rules:

  • All arm bodies must have the same type.
  • Each pattern must have the same type as the scrutinee.
  • Exhaustiveness: For enum types, all variants must be covered (or a _ wildcard present). For bool, both true and false must be covered (or _). For integer types, _ is optional but recommended.
  • The wildcard _ matches anything and must be the last arm.
  • Match is an expression — it can be used in let bindings, function arguments, etc.
(ret (i64 0)) ;; return 0
(ret) ;; return void

Returns from the enclosing function. The return value must match the function’s declared return type.

(cast i64 (i32 42)) ;; i32 → i64 (sign-extend)
(cast i32 (i64 100000)) ;; i64 → i32 (truncate)
(cast f64 (i64 42)) ;; i64 → f64 (int-to-float)
(cast i64 (f64 3.14)) ;; f64 → i64 (float-to-int, truncates)
(idx my_array (i64 3)) ;; my_array[3]

The first operand must be an array or pointer type. The second operand must be an integer type. The result type is the element type.

(fld my_point x) ;; my_point.x

The first operand must be a struct type. The second operand is a field name.

(addr x) ;; &x — take address of variable x
(deref ptr) ;; *ptr — dereference pointer
(arr.new i64 (i64 1) (i64 2) (i64 3)) ;; [1, 2, 3]
(new Point (x (f64 1.0)) (y (f64 2.0)))
(defer expr)

Registers expr to be executed when the enclosing function exits. Deferred expressions run at every function exit point:

  • Explicit return(ret ...): the return value is evaluated first, then deferred expressions execute, then control returns to the caller.
  • Implicit return — falling off the end of the function body: deferred expressions execute after the final expression is evaluated.
  • Error propagation(try ...): deferred expressions execute before the error is propagated to the caller.

When multiple defer statements are registered in a function, they execute in LIFO (last-in, first-out) order — the most recently registered defer runs first. This is the same ordering as Go’s defer.

Defer is per-function scope, not block-scoped. A defer registered inside a nested block, if, or while still runs at function exit, not at block exit.

;; Single defer — guaranteed cleanup
(fn read_data ((path (ptr i8))) i64
(let (fd (call open_file path))
(block
(defer (call close_file fd)) ;; runs when read_data exits
(call process fd))))
;; Multiple defers — LIFO order
;; Output: 1, 2, 3
(fn lifo_example () void
(block
(defer (call print_i64 (i64 3))) ;; runs third
(defer (call print_i64 (i64 2))) ;; runs second
(defer (call print_i64 (i64 1)))) ;; runs first
;; Defer with explicit return
(fn early_return ((x i64)) i64
(block
(defer (call cleanup))
(if (lt x (i64 0))
(ret (i64 -1))) ;; cleanup runs before returning -1
x)) ;; cleanup also runs on normal exit

Rules:

  • defer has type void.
  • The deferred expression is not evaluated when the defer statement is encountered — it is saved and executed later at function exit.
  • Deferred expressions have access to the variable bindings that were in scope at the point of the defer statement.
  • If a deferred expression itself causes an error, behavior is undefined.

Slices are runtime-sized views into contiguous memory. They are represented as a fat pointer: {ptr data, u64 len} (16 bytes on 64-bit systems).

(slice.of arr_expr)

Converts a fixed-size array into a slice. The result is a (slice T) where T is the element type of the array. The slice borrows the array’s storage — the data pointer points into the original array.

(let (arr (arr.new i64 (i64 10) (i64 20) (i64 30)))
(let (s (slice.of arr))
;; s has type (slice i64), len=3
...))
(slice.get s index_expr)

Returns the element at the given index. The index must be an integer type. If the index is out of bounds (>= length), the program panics with "slice index out of bounds".

(slice.get s (i64 0)) ;; first element
(slice.get s (i64 2)) ;; third element
(slice.set s index_expr value_expr)

Writes a value to the given index in the slice. The value type must match the slice’s element type. If the index is out of bounds, the program panics. slice.set has type void.

(slice.set s (i64 1) (i64 99)) ;; set s[1] = 99
(slice.len s)

Returns the length of the slice as u64.

(let (n (slice.len s))
(call print_i64 (cast i64 n))) ;; print length

Rules:

  • slice.of requires an array operand. Passing a non-array type is a compile error.
  • slice.get and slice.set require a (slice T) operand. Using them on other types is a compile error.
  • slice.get returns T (the element type). slice.set returns void. slice.len returns u64.
  • Index must be an integer type. Non-integer indices are a compile error.
  • slice.set value must match the element type T. Type mismatch is a compile error.
  • All indexed access is bounds-checked at runtime. Out-of-bounds access calls axon_panic.
  • Slices do not own their data — they borrow from the underlying array. The array must remain in scope while the slice is used.

Axon provides built-in primitives for heap memory management. All allocations are zero-initialized and checked for out-of-memory conditions.

Allocates space for one value of type T on the heap. Returns (ptr T).

(let (p (alloc i64)) ;; p : (ptr i64)
(block
(call print_i64 (deref p)) ;; reads zero-initialized value → 0
(free p))) ;; release memory

Allocates space for count values of type T on the heap. Returns (ptr T). The count argument must be an integer expression.

(let (arr (alloc.n i64 (i64 100))) ;; arr : (ptr i64), 100 elements
(block
(call print_i64 (deref arr)) ;; first element is 0
(free arr)))

Frees heap-allocated memory. The argument must be a pointer type. Returns void.

(let (p (alloc i32))
(free p)) ;; releases the memory

Rules:

  • alloc and alloc.n return (ptr T) where T is the specified element type.
  • Memory is zero-initialized (uses calloc internally).
  • Allocation failure (out of memory) causes a runtime panic.
  • alloc.n count must be a positive integer; zero or negative counts cause a runtime panic.
  • free requires a pointer operand. Passing a non-pointer is a compile error.
  • free accepts null pointers safely (no-op).
  • It is the programmer’s responsibility to free allocated memory. Axon does not have garbage collection.
  • Double-free and use-after-free are undefined behavior (as in C).

6.24 Lambda Expressions (Anonymous Functions)

Section titled “6.24 Lambda Expressions (Anonymous Functions)”

Lambda expressions create anonymous functions inline. They evaluate to a function pointer value of type (fnptr ...) matching the lambda’s parameter and return types.

lambda ::= '(' 'fn' '(' param* ')' type expr ')'
param ::= '(' IDENT type ')'
;; Simple identity lambda
(fn ((x i64)) i64 x)
;; Lambda with arithmetic body
(fn ((a i64) (b i64)) i64 (add a b))
;; Lambda with no parameters
(fn () i64 (i64 42))

Usage patterns:

;; Assign to a variable
(let (double (fn ((x i64)) i64 (mul x (i64 2))))
(call print_i64 (call double (i64 21)))) ;; prints 42
;; Pass directly to a higher-order function
(call apply (fn ((x i64)) i64 (mul x (i64 3))) (i64 5))
;; Store and reuse
(let (sq (fn ((x i64)) i64 (mul x x)))
(block
(call print_i64 (call sq (i64 7))) ;; prints 49
(call print_i64 (call apply sq (i64 3)))));; prints 9

Rules:

  • Lambda syntax reuses the fn keyword but without a name — (fn (...params) ret body).
  • The body expression’s type must match the declared return type.
  • Lambdas can capture variables from the enclosing scope. Captured variables are copied by value into a heap-allocated environment struct at closure creation time.
  • The lambda is internally lifted to a top-level synthetic function (e.g., __lambda_0). Closures receive a hidden __env pointer parameter. This is an implementation detail.
  • A lambda expression has type (fnptr (P1 P2 ...) R) matching its parameter and return types.
  • Lambdas can be used anywhere a function pointer is expected: let bindings, function arguments, and data structures.

Closure example:

(let (x (i64 42))
(let (f (fn ((y i64)) i64 (add x y))) ;; captures x
(call f (i64 10)))) ;; returns 52

(fn name ((param1 type1) (param2 type2)) return_type
body_expr)

Functions are the primary unit of code. Every function has:

  • A name (must be unique within the module)
  • Zero or more typed parameters
  • A return type
  • A body expression

The body expression’s type must match the declared return type.

;; No parameters, returns i32
(fn main () i32
(i32 0))
;; One parameter
(fn square ((x i64)) i64
(mul x x))
;; Multiple parameters
(fn add ((a i64) (b i64)) i64
(add a b))
;; Void return
(fn greet ((name (ptr i8))) void
(call puts name))
(extern name ((param1 type1) ...) return_type)
(extern name ((param1 type1) ... ...) return_type) ;; variadic

Extern declarations import functions from C or the runtime. They have no body.

;; Import C's puts function
(extern puts ((s (ptr i8))) i32)
;; Import C's printf (variadic)
(extern printf ((fmt (ptr i8)) ...) i32)
(struct name ((field1 type1) (field2 type2) ...))

Defines a named struct type with ordered, typed fields.

(struct Point ((x f64) (y f64)))
(struct Rect ((origin Point) (width f64) (height f64)))
(const name type value_expr)

Defines a compile-time constant.

(const PI f64 (f64 3.14159265358979))
(const MAX_SIZE u32 (u32 1024))
(test "test name" body_expr)

Test declarations define unit tests that are compiled and executed only when using the axonc test subcommand. Test files must NOT contain a main function — a synthetic main is generated.

  • name: A string literal naming the test (used in output).
  • body_expr: A single expression. Use (block ...) for multiple expressions.

Tests are isolated: if a test panics (e.g., from a failed assertion), the test is marked as failed and execution continues with the next test. A summary is printed at the end.

(module
(test "addition"
(assert_eq (add (i64 1) (i64 2)) (i64 3)))
(test "multiple checks"
(block
(assert_eq (i32 1) (i32 1))
(assert_ne (i64 0) (i64 1))))
)
(assert_eq left right) ;; panics if left ≠ right
(assert_eq left right "message") ;; panics with custom message
(assert_ne left right) ;; panics if left = right
(assert_ne left right "message")

Type constraints:

  • Both left and right must have the same type.
  • Supported types: i32, i64, u64, bool.
  • Floats and strings are not yet supported.

Runtime behavior:

  • On success: no output, execution continues.
  • On failure: prints a diagnostic (showing both values) and panics.
  • When used inside a (test ...) block, the panic is caught and the test is marked as failed.

An AXON module is a single .axs file containing a (module ...) form. The module contains a sequence of declarations. Declaration order within a module does not matter — forward references are allowed (a function can call another function declared later in the module).

Every executable module must contain a main function with signature () → i32. The return value is the process exit code.

AXON supports two forms of cross-module imports: namespaced imports and selective imports.

Namespaced Import — (import alias "path")

Section titled “Namespaced Import — (import alias "path")”

Imports an entire module file under a namespace alias. Functions from the imported module are accessed via alias.function_name dot notation.

import_decl ::= '(' 'import' IDENT STRING ')'
(module
(import math "math.axs")
(fn main () i32
(block
;; Call sqrt from the math module via qualified access
(call print_f64 (call math.sqrt (f64 2.0)))
(i32 0))))

The alias (math in this example) is a namespace identifier. It must be a valid AXON identifier. The string argument is the file path to the imported module.

Selective Import — (use "path" (names...))

Section titled “Selective Import — (use "path" (names...))”

Imports specific functions from a module directly into the current module’s scope. No namespace qualifier is needed to call them.

use_decl ::= '(' 'use' STRING '(' IDENT* ')' ')'
(module
(use "utils.axs" (helper_fn other_fn))
(fn main () i32
(block
;; Call helper_fn directly — no qualifier needed
(call print_i64 (call helper_fn (i64 42)))
(i32 0))))

All names listed in the (use ...) form must be pub functions in the target module.

By default, all functions in a module are private — they are only accessible within the module that defines them.

To make a function accessible to other modules (via import or use), wrap the function declaration in a (pub ...) form:

;; math.axs — a library module
(module
;; Public: accessible from other modules
(pub (fn sqrt ((x f64)) f64
;; implementation
x))
;; Private: only accessible within this module
(fn newton_step ((x f64) (guess f64)) f64
(div (add guess (div x guess)) (f64 2.0))))

Attempting to access a private function from another module produces a compile error:

error: function 'newton_step' is private in module 'math'

Visibility rules:

  • Only (pub (fn ...)) functions are accessible cross-module.
  • Private functions (without pub) cannot be called via qualified access (alias.fn) or imported via (use ...).
  • The main function does not need to be pub — it is the entry point, not an importable symbol.

Import paths are resolved relative to the importing file’s directory.

project/
├── main.axs ;; (import math "lib/math.axs") → resolves to project/lib/math.axs
└── lib/
├── math.axs ;; (import utils "utils.axs") → resolves to project/lib/utils.axs
└── utils.axs

Import paths must be string literals. Dynamic or computed paths are not supported.

The compiler automatically loads imported files and their transitive imports. If a.axs imports b.axs, and b.axs imports c.axs, the compiler loads all three.

Circular imports are detected and handled — the compiler will not enter an infinite loop.

Files can also be compiled together by passing multiple files on the command line:

Terminal window
axonc a.axs b.axs -o program

In this mode, all files share a flat namespace — no import or use declarations are needed, and all functions are visible to all files. This is independent of the module import system.

8.6 Complete Example — Multi-File Program

Section titled “8.6 Complete Example — Multi-File Program”

math.axs — a library module:

(module
(pub (fn square ((x i64)) i64
(mul x x)))
(pub (fn cube ((x i64)) i64
(mul x (mul x x)))))

main.axs — the executable module:

(module
;; Namespaced import
(import math "math.axs")
(fn main () i32
(block
(call print_i64 (call math.square (i64 5))) ;; prints 25
(call print_i64 (call math.cube (i64 3))) ;; prints 27
(i32 0))))

Alternatively, with selective import:

(module
;; Selective import — bring square directly into scope
(use "math.axs" (square))
(fn main () i32
(block
(call print_i64 (call square (i64 5))) ;; prints 25
(i32 0))))

OperatorSyntaxOperand TypesResult TypeDescription
add(add a b)integer, floatsame as operandsAddition
sub(sub a b)integer, floatsame as operandsSubtraction
mul(mul a b)integer, floatsame as operandsMultiplication
div(div a b)integer, floatsame as operandsDivision
mod(mod a b)integersame as operandsModulo (remainder)
eq(eq a b)any comparableboolEqual
ne(ne a b)any comparableboolNot equal
lt(lt a b)integer, floatboolLess than
le(le a b)integer, floatboolLess than or equal
gt(gt a b)integer, floatboolGreater than
ge(ge a b)integer, floatboolGreater than or equal
band(band a b)integersame as operandsBitwise AND
bor(bor a b)integersame as operandsBitwise OR
bxor(bxor a b)integersame as operandsBitwise XOR
shl(shl a b)integersame as operandsShift left
shr(shr a b)integersame as operandsShift right (arithmetic for signed)
land(land a b)boolboolLogical AND (short-circuit)
lor(lor a b)boolboolLogical OR (short-circuit)
OperatorSyntaxOperand TypeResult TypeDescription
neg(neg x)integer, floatsame as operandArithmetic negation
not(not x)boolboolLogical NOT
bnot(bnot x)integersame as operandBitwise NOT

In debug builds (compiled with -fdebug-checks or AXON_DEBUG), signed integer add, sub, and mul operations are checked for overflow at runtime. If an overflow occurs, the program panics with a diagnostic message:

AXON PANIC: integer overflow: add

In release builds (compiled with -fno-debug-checks), integer arithmetic uses wrapping semantics with zero overhead.

Build ModeBehavior on OverflowOverhead
Debug (-fdebug-checks)Panic with diagnosticFunction call per op
Release (-fno-debug-checks)Wrapping (two’s complement)None

Overflow checks apply to signed integer types only (i8, i16, i32, i64). Unsigned types always use wrapping semantics.


The AXON runtime (libaxon_rt.a) provides the following built-in functions. They do not need extern declarations — they are always available.

FunctionSignatureDescription
print_i64(i64) → voidPrint a 64-bit integer to stdout, followed by newline
print_f64(f64) → voidPrint a 64-bit float to stdout, followed by newline
print_str((ptr i8)) → voidPrint a null-terminated string to stdout, followed by newline
print_bool(bool) → voidPrint true or false to stdout, followed by newline

In Phase 1, all memory management is arena-based:

  • Stack variables: Function parameters and let bindings are stack-allocated (via alloca in the IR). They are automatically freed when the function returns.
  • String literals: Stored in the data section of the binary. They have static lifetime.
  • No heap allocation: Phase 1 does not provide malloc-like heap allocation. All data lives on the stack or in static storage.
  • No garbage collector: There is nothing to collect.

The compiler itself uses arena allocation internally (1 MiB blocks, pointer-bump allocation). This is an implementation detail and does not affect the source language semantics.

Phase 2 introduces region-based memory management with linear type enforcement:

  • Regions: Named memory regions with explicit lifetime scopes
  • Arenas: Bulk-allocate, bulk-free memory pools
  • Linear types: The type system enforces that resources are used exactly once, preventing leaks and use-after-free at compile time
  • Stack allocation: The compiler may promote small allocations to the stack

Phase 2 introduces result types for error handling:

;; Phase 2 syntax (not yet implemented)
(fn read_file ((path (ptr i8))) (result (slice u8) IOError)
...)
(fn main () i32
(match (call read_file (str "data.txt"))
(ok data (block (call process data) (i32 0)))
(err e (block (call print_str (call error_msg e)) (i32 1)))))

Phase 2 introduces structured concurrency with actor-based message passing:

;; Phase 2 syntax (not yet implemented)
(fn main () i32
(let (h (spawn compute_task (i64 42)))
(let (result (join h))
(block
(call print_i64 result)
(i32 0)))))

Key properties:

  • Structured: spawned tasks cannot outlive their parent scope
  • No shared mutable state: actors communicate via message passing
  • Linear channels: send/receive channels enforce exactly-once delivery at compile time