learn almac

ortfero

almac is the language you write programs in on aldan. it's small, imperative, and tries not to do anything you didn't ask for — nothing is allocated, nothing is collected, and nothing fails without saying so. this walks you through it in fifteen short sections. when you want the exact rules, the formal grammar lives in => almac_report.moff almac language report.

1. modules

every .alm file is one module. its name is the file's basename — `notes.alm` becomes a module called `notes`, and that's how other modules refer to it.

the layout is fixed. imports first if you have any. then your public declarations. then a line with just `...`, and below that the bodies of those declarations plus anything else that's nobody else's business.

[ import ... ]
{ declarations }          -- public
[ ...
  { definitions } ]       -- private

importing is a list of paths without the `.alm` extension:

import 'core/random, 'core/tty

2. public declarations

a const is a name for a value known at compile time — a literal, another const, or a type (in which case you get the type's size in bytes).

const max_items = 64
const greeting  = "hello, aldan"
const pi_third  = 1.047197551
const u8_size   = u8           -- s64, value 1

a failure is a closed set of kinds you can raise. failures are module-local, just like types: you declare one in your module and others reach it qualified as `yourmod.thefault`. the standard library keeps the common faults in small owner modules, each declaring one failure named `fault` — `storage.fault`, `logic.fault`, `bytes.fault`, `resource.fault` (see appendix b.2 of the report) — so you import the owner and write `storage.fault`. declaring your own is the same shape:

failure account { disk_full, no_such_user }

a failure can also be an alias to another, written with `as` — the failure twin of `type x as y`. `failure io as storage.fault` makes `io` a second name for `storage.fault`, the same failure under two names: you can `raise` under one and handle `on` the other, and `io` borrows the target's kinds (`io.not_found`). like a type alias, it takes no `{ ... }` of its own.

failure io as storage.fault

`type` names a type. you only have to do this when the type refers to itself by pointer — otherwise the structure is fine inline wherever you use it.

type byte_count as s64
type bitset     as b64
type buffer     [256]u8
type lineview   []u8

an alias to another *named* type takes `as` — `type byte_count as s64`. a structural right-hand side (an array, slice, record, enum, union, pointer, or `fn` type) takes none, as above. the `as` is only for the declaration; everywhere else you name a type bare (`var n byte_count`), and `as` there is the cast operator.

function prototypes go up here too. the bodies live below the `...`.

fn add s64 (a s64, b s64)
fn fill (buffer& []u8, c u8)
fn read_line (h file.id, out& string)

a prototype may end with `as module.fn` to *re-export* an imported function — the function twin of `type x as y`. it binds the name to that imported function (the signature must match), so importers of your module call it as if you wrote it. no body follows; the import supplies the code.

import 'core/file
fn open file.id (path string) raises storage.fault as file.open

3. body

everything from the `...` down is private. you can mix declarations and definitions freely below it; this is where most of the code lives in any non-trivial module. the separator is the first top-level `...`, written alone on its own line:

...

4. vocabulary

identifiers are a letter followed by letters or digits. underscores count as letters. a handful of words are reserved and can't be used as identifiers — the report's section 3 has the full list.

the operators:

=  ==  !=  <  <=  >  >=  +  -  *  /  +=  -=
^  &  |  .  ,  ;  :  (  )  [  ]  {  }

a few do double duty:

function bodies, `for` bodies, `if` branches, and record field lists are all brace-delimited: `{ ... }`. inside the braces every simple statement ends with its own `;`. the `:` appears only in slice syntax (`a[lo:hi]`) and after a handler's failure list (`on f:`).

5. scalar types

bool       1 byte
s64        signed 64-bit integer
u8         unsigned 8-bit integer
f64        ieee-754 double
address    untyped 8-byte machine address
b64        64-bit bitfield
string     alias for []u8
integer    the type of an unconverted number literal

`true`, `false`, and `none` are the literals you have. `none` is the nil pointer — it slots into any pointer type or into `address`.

`integer` is the type a number wears until something pins it down. `count = 0` works whatever `count`'s type is, because the `0` adopts that type.

fn scalar_demo ()
    var flag bool, count s64, byte u8,
    ratio f64, bits b64 {
    flag  = true;
    count = -123;
    byte  = 0xff;
    ratio = 6.28;
    bits  = 0xdeadbeef;
}

6. records, pointers, self-reference

a record is a struct with named fields and natural alignment. it can hold a pointer to itself — and that's the one case where you have to give the type a name before using it (otherwise the recursion has nothing to refer to).

pointers are 8 bytes. they're either `none` or the address of a value of their base type. deref is postfix `^`.

type node record {
    value s64,
    next  ^node              -- self-pointer
}

fn list_head_value s64 (head ^node) {
    if head == none { return 0; }
    return head^.value;      -- deref, then field
}

7. arrays and slices

`[n]t` is a fixed-length array. `[]t` is a slice — a (data, length) pair, 16 bytes, 8-byte aligned. an array becomes a slice wherever a slice is expected; no cast needed.

`a[lo:hi]` slices a range. the storage is shared — the slice is a view, not a copy. on a `b64` the same syntax pulls out the bits in positions lo..hi-1.

assigning slices copies the (data, length) pair, not the elements.

fn slice_demo ()
    var fixed [4]s64, view []s64, tail []s64 {
    fixed[0] = 1; fixed[1] = 2;
    fixed[2] = 3; fixed[3] = 4;
    view = fixed;                   -- array -> slice
    tail = view[1:3];               -- borrows fixed[1..3]
    tty.say "view len: ", (length view), "tail len: ", (length tail);
}

8. enums and tagged unions

an enum is a list of named tags. it's `u8`-sized, and distinct from every other enum — you can't accidentally mix them.

a tagged union is the same idea with a payload per variant: tag at offset 0, payload area after, sized to the biggest variant. each variant `v` names a subtype `u.v` — the full union size, but fixed to that variant. a variable of a subtype exposes only that variant's fields; a variable of the union type exposes none. build a value through a subtype, then pass it where the union is expected (by reference, no copy). to go the other way — union to subtype — narrow with the `guard` statement: `guard u u.v | escape`. on a tag match it retypes `u` to the subtype for the rest of the block, so `u.field` works directly; on a mismatch it takes the escape (`return` or `raise`).

type colour enum red | green | blue

type shape union
  point
| circle { radius f64 }
| rect   { width f64, height f64 }

fn make_circle (c& shape.circle, r f64) {
    c.radius = r;              -- subtype: only circle's fields
}

fn area f64 (s shape) {        -- a union: guard before use
    guard s shape.circle | return 0.0;
    return float.pi * s.radius * s.radius;
}

9. variables

module-level `var` lives in the module's bss, zero-initialised. function locals appear between the signature and the body's `{`, introduced by the same `var` keyword — one `var` then a comma-separated list of `name type` pairs. locals are zero-initialised, like module vars — which is also why neither may contain a non-null pointer (`^t`): a zeroed one would be null. use `^t|none` and `guard`.

var instances s64
var palette   [3]colour

10. functions

a function is `[inline] fn name signature [var locals] body`. the signature is an optional return type, the parameter list in parens, and an optional `raises` marker. an `inline` marker, if present, leads the whole thing (`inline fn ...`, see 10.3). locals, introduced by `var`, come right after the signature, then the body.

fndef    = [ "inline" ] "fn" ident signature
           ( "..." | [ "var" vardefs ] ( body | asmbody ) ) .
body     = "{" [ stmts ] { handler } "}" .
asmbody  = "asm" [ "naked" ] compound .
handler  = "on" ident { "," ident } ":" stmts .
compound = "{" [ stmts ] "}" .
stmts    = stmt { stmt } .
stmt     = return | defer | raise | for | if | guard | break
         | continue | ( expr ";" ) .
return   = "return" expr ";" .
vardefs  = vardef { "," vardef } .
vardef   = ident type .

a body-less signature is a forward declaration. in the public section (before the `...`) that is a plain prototype: `fn foo ()`, defined later below the `...`. inside the body section a definition normally carries a body, so a forward declaration there — a *deferred definition*, defined later in the section — is marked with a trailing `...`: `fn foo () ...`. note the two uses are distinct: a standalone `...` on its own line is the section separator, while a `...` trailing a signature is a deferred definition. this lets you declare a group and define them top to bottom in calling order. a body-less signature without `...` in the body section (or at the prompt) is incomplete — the prompt simply asks for the next line.

remember: each simple statement carries its own `;`; the braces carry none. compound-form statements (`for`, `if`) end at their body's closing `}` and don't carry a `;` of their own.

fn add s64 (a s64, b s64) {
    a + b;
}

fn greet (who string) {
    tty.say "hi,", who;
}

10.1 out-parameters

put `&` after a parameter name and the caller must supply a writable lvalue with `&`. without the `&`, the parameter is read-only inside the function.

scalars pass by value, aggregates always pass by reference — the `&` is about *writability*, not how things get there.

fn fill (buffer& []u8, c u8)
    var i s64 {
    i = 0; for i != length buffer; i += 1 {
        buffer[i] = c;
    }
}

fn fill_caller ()
    var page [256]u8 {
    fill page&, 0x20;         -- `&` at the call site too
}

10.2 returning aggregates

a function can't return an aggregate (array, record, slice, or tagged union) by value. when you need one out, pass it as an out-param and write into it.

type point record { x f64, y f64 }

fn make_point (p& point, x f64, y f64) {
    p.x = x;
    p.y = y;
}

10.3 inline functions

a leading `inline` expands the body at the call site. that means the body can't declare locals or use `return`, `defer`, `raise`, `for`, `break`, or `continue` — just an expression. `inline` isn't part of the function type, so it leads the declaration rather than trailing the signature: you can't have a first-class `inline fn` value, can't take its address, and can't alias it.

inline fn min2 s64 (a s64, b s64) {
    if a < b { a; } else { b; }
}

10.4 forward declarations

a signature-only declaration binds the name with no code; a later definition with the same signature fills in the body (a *different* signature replaces the binding — handy at the repl, a footgun elsewhere). in the public section it is a plain prototype. inside the body section a definition normally carries a body, so a forward declaration there — a *deferred definition* — is marked with a trailing `...`. that lets you write callers above callees, in reading order:

fn helper s64 (x s64) ...        -- deferred: defined below
fn run s64 () { helper 41; }     -- calls helper before it appears
fn helper s64 (x s64) { x + 1; } -- the body, filled in here

don't confuse the trailing `...` (deferred: a body follows later) with a trailing `as target` (a *function alias*: no body at all, the target is the code). an alias names an existing function — a local one already defined, or an imported `m.f` — the function analogue of a type or failure alias. the target must already be defined, and the signature must match it exactly.

fn inc s64 (x s64) { return x + 1; }
fn bump s64 (x s64) as inc        -- alias: bump is inc

10.5 assembly bodies

`asm { ... }` hands the body to the rv64 assembler verbatim. normally the compiler wraps it in a prologue and epilogue — unless the body needs no frame (it touches neither `fp` nor `ra`, and the function has no locals or `with`), in which case it's emitted as a frameless leaf: the arguments stay in their registers and a lone `ret` is appended.

`asm naked` forces that frameless form even when the body *does* use `fp` or `ra`. a naked body owns its frame end to end — it saves and restores `ra`/`sp` itself and supplies its own `ret` (or a tail jump into another context), and the compiler adds nothing around it to corrupt. that's what a context switch needs. `naked` isn't a reserved word; it's recognised only right after `asm`.

fn nop () asm { nop }

11. expressions

operator precedence, tightest first: postfix (call, index, field, deref), cast (`as`), unary `-`, multiplicative (`*`, `/`, `mod`), additive (`+`, `-`), relational (`==`, `!=`, `<`, `<=`, `>`, `>=`), assignment (`=`, `+=`, `-=`).

function calls have no parentheses around their arguments — the arg list starts at the first atom after the callee and ends at the first token that isn't an atom or a comma:

and b0, b1, ...     or b0, b1, ...     not b

a few rules:

fn expr_demo s64 (x s64, y s64)
    var z s64, ok bool {
    z = (x + y) * 2;
    z += 1;
    ok = and (x > 0), (y > 0);
    if not ok { return -1; }
    return z mod 10;
}

11.1 if as an expression

`if` is both a statement and an expression. as an expression every branch has to be there and they all have to yield the same type:

fn sign s64 (x s64) {
    return if x > 0 { 1; }
        else if x < 0 { -1; }
        else { 0; };
}

11.2 casts

`as` is the explicit conversion. it covers:

integer <-> integer
integer <-> f64           (f64 -> int truncates toward zero)
scalar   -> bool          (non-zero is true)
[n]t     -> []t           (array to slice)
pointer <-> address

widening from `s64` sign-extends; widening from `u8` zero-extends. narrowing to `u8` keeps the low 8 bits.

fn cast_demo ()
    var c u8, n s64, x f64, flag bool {
    c = 200;
    n = c as s64;          -- zero-extended
    x = n as f64;
    flag = c as bool;      -- non-zero -> true
}

11.3 bitfields

`b64` supports two postfix forms — `b[i]` for a single bit (yields `bool`) and `b[i:j]` for a range of bits (yields a `b64`). the `bit` module has the rest: `shil`, `shir`, `incl`, `excl`.

fn bitfield_demo b64 (b b64) {
  if b[7] { return b[0:8]; }   -- low byte
  return 0;
}

12. statements

12.1 for

`for` is the only loop. both the condition and the step are optional. dropping the condition gives an infinite loop. `break` exits; `continue` jumps to the step (or the condition, if there's no step).

fn count_down (n s64)
    var i s64 {
    i = n; for i > 0; i -= 1 {
        tty.say i;
    }
}

fn global_should_stop bool () {
    return false;
}

fn forever () {
    for {
        if global_should_stop { break; }
    }
}

12.2 defer

`defer expr` schedules `expr` to run when the function returns — whether normally or via a failure. defers run in reverse order, last registered first. up to eight per function. if the deferred expression is a function value, it's auto-called with no args.

fn open_and_print (path string) raises storage.fault
    var h file.id {
    h = file.open path;
    defer file.close h;
    cat path;
}

12.3 raise and handlers

`raise f.k` abandons the current function, marking failure `f` active with kind `k`. registered defers run first, then control transfers to a handler — if there is one — or propagates to the caller.

`raises` lists the failures — never kinds — a function can propagate, and is part of its type. you mark a function `raises f` if (and only if) some kind of `f` can escape its body, whether from a `raise` or a call to a raising function. the compiler tells you if you forget a failure or declare one that can't escape.

handlers live at the end of a function body, before the body's closing `}`. each one is `on f: stmts` (a whole failure) or `on f.k: stmts` (one kind) — no own close, the body's `}` closes both the last handler's stmts and the body. completing a handler converts the failure into a normal return.

fn parse_or_zero u8 (s string)
    var v u8 {
    v = byte.parse s;
    return v;
on bytes.fault:
    return 0;
}

fn save_user (name string) raises logic.fault, account {
    if length name == 0 { raise logic.fault.invalid_argument; }
    if instances >= max_items { raise account.disk_full; }
    instances += 1;
}

`parse_or_zero` needs no `raises`: `byte.parse` can raise `bytes.fault`, but the `on bytes.fault` handler discharges it. `save_user` declares the failures it lets escape — the library's `logic.fault` (from `core/logic`) and your own `account`. a failure name in `raise`/`raises`/`on` may carry a module qualifier (`logic.fault`) or be bare when it is your own module's failure, exactly like a type name.

13. things you don't have to import

the built-in vocabulary is small. these four forms, and the primitive type names, are the only things in scope without an import:

length x            element count of an array or slice
and / or / not      short-circuit logical ops

everything else that used to be a compiler builtin is now an ordinary standard-library module written in almac — you import it and qualify its members. printing is the one that catches people out: `say`, `sayin`, and `ask` are not keywords, they live in `tty`, so it's `tty.say` after `import 'core/tty`. allocation is in `heap` (`heap.claim` returns the address of a fresh block, `heap.release` frees it). the shell-style file ops — cd, ls, mkdir, rmdir, rm, cat, touch, cp, mv — aren't builtins either: the repl defines them in rc.alm over the file and directory modules.

import 'core/tty, 'core/heap

fn predef_demo () raises resource.fault
    var buffer address, reply [64]u8 {
    buffer = heap.claim 1024;
    defer heap.release buffer;
    tty.say "hello,", (length "hello,"), " bytes long";
    tty.sayin (tty.ask "your name? ", reply&);
}

14. standard modules

these all live under core/ in the source tree, so you import them by path — `import 'core/file` — and the local name is the basename (`file.open`). they're written in almac, not magic: the only thing the host supplies is the firmware and kernel boundary, which two interface modules (core/machine/aldan and core/platform/aldos) wrap, and the rest is layered on top. the report has the full signatures; here's the elevator pitch for each:

byte       single-byte access and conversion ('x')
bytes      operations on byte slices -- clear, fill, copy, move,
           equal, compare, parse, format. copy is a forward
           memcpy; move is safe for overlapping ranges.
bit        b64 shift, xor, and bit/range include/exclude
signed     s64 arithmetic, conversion, limits
float      f64 arithmetic, trig/log/exp, constants
boolean    parse / format for bool, and []bool fills
heap       claim / release memory; query bytes available
random     splitmix64 prng
clock      monotonic timing
time       wall clock and calendar decomposition
file       open, read, write, seek, close, etc.
directory  make, remove, iterate, query
tty        console output and input -- say / sayin / ask
text       formattable union; format values into a buffer
debug      trace one diagnostic line to the tracer device
screen     off-screen text canvas + keyboard
sound      pcm audio playback
task       cooperative yield
storage    the storage.fault failure (files / directories)
logic      the logic.fault failure (contract violations)
resource   the resource.fault failure (out of memory)
bytes      also owns bytes.fault (parse / format errors)

read a whole file into a buffer:

fn read_all (path string, out& string) raises storage.fault, bytes.fault
    var h file.id, size s64 {
    size = file.size path;
    if size > length out { raise bytes.fault.buffer_overflow; }
    h = file.open path;
    defer file.close h;
    file.read h, out&;
}

time a block:

fn time_loop s64 ()
    var t clock.tick, i s64 {
    t = clock.now;
    i = 0; for i < 1000; i += 1 {
        -- ... work ...
    }
    return clock.elapsed_us t;
}

15. putting it together

a program is a module with an entry function the host calls — by convention, `run`. this one reads a name, greets, and stops on empty input. `tty.ask` reads a line into the buffer and returns the bytes read; neither it nor `tty.say` raises, so `run` needs no `raises`:

import 'core/tty

fn run ()
    var name [64]u8, line string {
    for {
        line = tty.ask "name? ", name&;
        if length line == 0 { break; }
        tty.say "welcome, ", line;
    }
}

that's the language. when you want chapter and verse, the formal grammar is in => almac_report.moff almac language report.