Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rut

rut is a small, statically typed, embeddable scripting language with Rust-flavored syntax, implemented in Rust. It is designed to be the scripting layer of host applications — typically UI apps — replacing JavaScript engines with a language whose type system is not erased: types exist at runtime, drive bytecode specialization, and make the host boundary fully checked.

Syntax feels like TypeScript (simple enum, the class shape, anonymous functions), with a Rust-flavored trait surface (trait + nominal satisfaction through impl I for T blocks) and Kotlin-style structured concurrency (async/await over poll-based futures). The runtime is a bytecode VM with no JIT — deterministic, fuel-metered, and small enough to embed everywhere a wasm binary fits.

use ink::{ Logger };
use pouch::{ Vec };

fn sieve(limit: i32) -> Vec<i32> {
    let mut marks = Vec<u8>.filled(0, limit + 1);
    let primes: Vec<i32> = Vec.new();
    for (let i = 2; i <= limit; i += 1) {
        if (marks[i] == 0) {
            primes.push(i);
            let mut m = i * i;
            while (m <= limit) {
                marks[m] = 1;
                m += i;
            }
        }
    }
    return primes;
}

pub fn main() {
    let log = Logger.new("sieve");
    let primes = sieve(100);
    log.info(f"{primes.len()} primes up to 100, last={primes[primes.len() - 1]}");
}

Where to go next

If you want to…Read
Install the toolchain and run a programQuick Start
Learn the language hands-onthe Tutorial
Understand why the language is shaped this wayCore Concepts
Study complete programsExamples
Look up exact rulesthe Reference

The book is also served live at https://rut.hpp2334.com, and the wasm playground — where every snippet on this site runs in the browser — lives at https://playground.rut.hpp2334.com.

The repo

crates/     the implementation: lexer, parser, compiler, VM, std, CLI, wasm
rut/        the in-tree rut packages (core, pouch, ink, json, http, …)
examples/   seven runnable end-to-end projects
demo/       the wasm playground
docs/       this book (built with mdbook)

Build everything from source:

cargo build --release -p rut-cli   # the `rut` binary: run / fmt / pack / dump

License

MIT OR Apache-2.0.

Installation

rut is a self-hosting-free, from-source toolchain: one Rust build gives you the rut binary (run / format / pack / dump) plus the language’s in-tree packages, which the engine mounts automatically when you run a standalone file.

Prerequisites

  • Rust (nightly) — the repo pins an exact nightly in rust-toolchain.toml; rustup installs it on the first build. Any recent rustup works.

  • git — for the checkout.

  • Node.js 20+ — only if you want to build or drive the wasm playground (the rest of the book does not need it).

Build the toolchain

From a checkout of the repository:

cargo build --release -p rut-cli

This produces the rut binary at target/release/rut. Put it on your PATH (or call it by path — the pages here use the bare name):

cargo build --release -p rut-cli
export PATH="$PWD/target/release:$PATH"
rut
# usage: rut — run <file.rut | dir | mod.rutbundle> [--fuel N] | fmt <file.rut | dir> [--check] | pack <dir> [-o out.rutbundle] | dump <file.rut>

The binary embeds the whole engine: lexer, parser, compiler, typed bytecode, and the VM. It also carries the vendored packages from the repo’s rut/ directory (core, pouch, ink, json, …) — a standalone hello.rut that says use ink::{ Logger }; just works, no manifest required.

Smoke-test it

cat > hello.rut <<'EOF'
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("hello");
    log.info(f"hello, rut!");
}
EOF
rut run hello.rut
hello, rut!

If that printed, your toolchain is complete. Continue to your first rut program.

Optional: the wasm playground

The playground runs the same engine compiled to WebAssembly. Building it needs one extra target:

rustup target add wasm32-unknown-unknown
cargo build -p rut-wasm --target wasm32-unknown-unknown --release

or, from demo/, the one-command lane that also copies the artifact into place:

cd demo && npm install && npm run build:wasm

Optional: build this book

The documentation is an mdbook site:

cargo install mdbook --locked
mdbook serve docs     # live-reload preview at http://localhost:3000
mdbook build docs     # static site in docs/book/

Your first rut program

rut feels like TypeScript on the surface and Rust underneath the hood. This page walks one file through the whole toolchain — running, formatting, inspecting, and budgeting — so every later page can assume the workflow.

Write and run

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("hello");
    let name = "rut";
    log.info(f"hello, {name}!");
}

Save it as hello.rut and run it:

rut run hello.rut
hello, rut!

Three things to notice:

  • One file is one module. There is no include form; cross-module code is reached through use paths (see the modules tutorial).
  • use ink::{ Logger }; pulls Logger out of the ink package. When you rut run a standalone file, the vendored packages mount automatically — no manifest needed.
  • f"hello, {name}!" is a format literal: {expr} interpolates any expression, rendered through the value’s display contract.

pub fn main() is the entry the CLI calls.

Values, briefly

Extend the file — rut infers, and every type is known at compile time:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("hello");
    let n = 10;            // i32 — the integer default
    let scale = 1.5;       // f32 — the float default
    let big: u64 = 10;     // u64 via annotation — fits the default
    let flags = [1, 2, 3]; // [i32] — a fixed heap cell
    let mut sum = 0;       // `mut` — this one is written to
    for (let x of flags) {
        sum += x * n;
    }
    log.info(f"sum={sum} scale={scale}");
}
sum=60 scale=1.5

The full tour — suffixes, casts, strings, bytes, structs — is the values and variables tutorial, and the exact rules live in literals and inference.

Format

The formatter is the style — there is no configuration:

rut fmt hello.rut            # rewrite in place
rut fmt hello.rut --check    # verify only (note: --check goes AFTER the path)
rut fmt .                    # walk a directory tree, every *.rut file

--check prints unformatted: <file> and exits 1 when a file needs changes, which makes it a clean CI gate. A file that does not parse is refused loudly rather than half-formatted.

Inspect

dump compiles a single module and prints its structures — useful when a snippet behaves differently than you expect:

rut dump hello.rut

dump runs the lone module through the frontend without mounting the packages, so it is the tool for surface questions (“what does this lower to?”) rather than whole-program runs. The playground’s AST and IR panes show the same two views live — see the playground.

Budget it

Every rut run is fuel-metered. Cap it with --fuel N (default 10,000,000):

rut run hello.rut --fuel 1000

A program that exhausts its fuel halts with a diagnostic naming the budget — the same contract the VM enforces for embedded hosts, along with heap ceilings and depth limits (resource limits).

Where next

  • The tutorial teaches the language hands-on, one concept per chapter.
  • The examples are seven complete projects you can run today.
  • Core concepts explains why the language is shaped the way it is.

The playground

The playground is the zero-install way to run rut: the whole engine — lexer, parser, compiler, VM — compiled to WebAssembly and served as a static page. It is deployed at https://playground.rut.hpp2334.com.

The layout

  • Case list — prepared programs, ordered the way the repo’s own gate runs them: algorithms first (sieve, quicksort, matrix multiply), then language-surface tours (classes, closures & generics, structs, literals, checked arithmetic, str views, bytes, opaque, when, maps & sets, type aliases), then memory shapes (node cycle, tree, weak cache).
  • Editor — the case’s source, live-editable. The cases are read from the same .rut files the repository’s native test gate compiles and runs, so what you edit is the real corpus, not a copy.
  • Output / AST / IR panes — every run shows the program’s real output plus the two compile views: the parsed AST and the typed IR the compiler lowers to. rut dump <file> prints the same structures from the CLI.
  • Budget control — each run executes under an explicit fuel + heap budget (default 10,000,000 fuel / 4 MiB heap). A budget that bites is a feature, not a bug: one prepared case parks on the out-of-fuel trap on purpose, and the Resume control adds fuel and continues the same frame — that is structured-concurrency-grade resumption, not a restart.

No silent fallback

The page probes the wasm artifact at boot. If the engine or the syntax-highlighting module is missing or invalid, you get a full-page error naming the exact build command — the panes never mount in a degraded mode. On the deployed site this never happens; when you run a local build (below), the preflight exists so a stale artifact cannot masquerade as a working playground.

Running it locally

cd demo
npm install
npm run build:wasm   # builds rut.wasm + rut-lsp.wasm, copies to public/
npm run dev          # http://localhost:8080 with live reload

Other lanes:

npm run build        # static build into dist/
npm run smoke        # headless gate: every case really runs
npm run dev:channel  # dev server + a throwaway public https URL for demos

The corpus itself is documented in the playground corpus, with the bigger end-to-end projects under Examples.

Values and variables

Every value in rut has a type, and every type is known at compile time. This chapter covers the primitive types, how literals infer their types, how bindings work, and the one rule that organizes everything else: primitives copy, every other value is a shared cell.

For the full grammar of literals and inference rules, see the reference on literals and inference.

The primitive types

GroupTypesNotes
unsigned integersu8 u16 u32 u64fixed width
signed integersi8 i16 i32 i64two’s complement
floatsf32 f64IEEE 754
booleanbooltrue / false
textstrimmutable UTF-8, compared by content
binarybytesimmutable octet buffer, compared by content
erasedopaquea box holding any value — see errors and optionality

There is no null, no undefined, and no character type. A str iterates as one-codepoint strs, and codepoints read as u32 (s.code(), s.code_at(i)) — see string slicing and views.

Variables: let and let mut

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("vars");
    let a = 10;          // an immutable binding
    let mut b = 10;      // a mutable binding
    b = 20;              // OK — b may be reassigned
    // a = 30;           // ERROR: a is not `mut`
    log.info(f"a={a} b={b}");
}
a=10 b=20

mut is permission to write through that name: reassigning the binding, assigning to a field (p.x = 3), or assigning to an element (xs[0] = 7). Sharing is not gated by mut — two bindings can name the same value, and what mut controls is only who may write.

Every type has a zero value: 0 for numbers, false for bool, the empty string, nil for nullables. Omit a field in a struct literal and it takes the field’s initializer if there is one, else the type’s zero value (see structs, enums, and classes).

Numbers

An unsuffixed integer literal defaults to i32; an unsuffixed float defaults to f32. The default is also a ceiling: a literal adapts to the expected type only while it fits the default.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("numbers");
    let a = 10;                        // i32
    let b = 10u8;                      // u8 via suffix
    let c: u64 = 10;                   // u64 via annotation — 10 fits
    let big = 18446744073709551615u64; // past the i32 default: suffix required
    let d = 1.5;                       // f32
    let d64: f64 = 1.5;                // f64 via annotation
    let hex = 0xFF_u32;                // 0x / 0b / 0o bases, _ separators
    log.info(f"a={a} d={d} d64={d64} hex={hex} big={big}");
}
a=10 d=1.5 d64=1.5 hex=255 big=18446744073709551615

Conversions are explicit casts: expr as T. Casts truncate like C — they never trap.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("casts");
    let cast = 300 as u8;   // 44 — keeps the low 8 bits
    log.info(f"cast={cast}");
}
cast=44

Arithmetic has two sharp edges:

  • Mixed widths do not mix. Both operands must have the same width — convert one side first. let mut total = 0.0; makes an f32, and adding an f64 to it is a type error; annotate let mut total: f64 = 0.0; when you mean double precision.
  • Overflow, division by zero, and out-of-bounds indexing trap. For the explicit non-trapping ladder (wrapping_add, saturating_mul, checked_add), see the standard library.

Text: plain, raw, and format strings

Three literal forms. A plain string is always inert — no interpolation ever happens implicitly.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("text");
    let name = "rut";
    let s = "hi\tname";              // plain: escapes processed
    let raw = r"C:\temp\log.txt";    // raw: every byte is literal
    let t = f"hi {name}!";           // format: placeholders evaluated
    log.info(f"{s} | {raw} | {t}");
}
hi	name | C:\temp\log.txt | hi rut!

Plain and format strings share the same escapes (\t \n \r \\ \" and \u{...}). In an f-string, { expr } splices any expression — identifiers, calls, arithmetic — except nested string literals (bind one to a variable first). {{ and }} are literal braces:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("text");
    log.info(f"open{{close}} braces");   // prints: open{close} braces
}
open{close} braces

What an f-string can render: integers (decimal), floats (shortest round-trip decimal: 3.5, 0.1), bool (true/false), str (contents), and enum members (their name: Color.Green renders Green). Composite values — structs, classes, arrays, vecs — are a compile error inside an f-string; write a to_string()-style method and call it, or use a debug dump for development.

Sequences and buffers

The fixed array [T] is built from a literal or a repeat:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("arrays");
    let arr = [1, 2, 3];         // [i32] — fixed length
    let zero: [u8] = [0u8; 34];  // 34 slots of 0
    log.info(f"len={arr.len()} first={arr[0]} zero.len={zero.len()}");
}
len=3 first=1 zero.len=34

The growable sequence is Vec<T> (from the pouch package — use pouch::{ Vec };): Vec.new(), Vec.from([..]), Vec<i32>.filled(0, 1024), push, pop, len. Indexing and for..of work the same on both. See the standard library for the full surface.

bytes is the binary primitive. s.encode() turns text into octets, b.decode() reads it back (lossy UTF-8), bytes.zeroed(n) and bytes.from(a) build buffers directly:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("bytes");
    let b = "rut runs".encode();
    let ok = b.decode() == "rut runs";   // true — content comparison
    log.info(f"len={b.len()} roundtrip={ok}");
}
len=8 roundtrip=true

Sharing: the one rule

Primitives and fn values copy on assignment, argument passing, and return. Every other value is a shared cell: let q = p is an O(1) handle move, and a write through any alias is visible through all of them.

use ink::{ Logger };

struct Point { x: i32; y: i32 }

pub fn main() {
    let log = Logger.new("sharing");
    let mut p = Point { x: 1, y: 2 };
    let q = p;          // q and p name ONE cell
    p.x = 4;            // q.x is 4 now
    log.info(f"q.x={q.x} same cell: {q == p}");
}
q.x=4 same cell: true

Equality follows the same split:

Operands== means
numbers, boolvalue comparison
str, bytescontent comparison
everything elsecell identity — two separately-built literals are never equal

The one copy escape hatch is bytes.clone() — a fresh buffer with the same octets. There is no generic copy for any other type; if you need a divergent value, build a new one.

Absence is nil on a nullable ?T — see errors and optionality.

Tuples

Records (A, B) are first-class values with numeric fields:

use ink::{ Logger };

fn divmod(a: i32, b: i32) -> (i32, i32) {
    return (a / b, a % b);
}

pub fn main() {
    let log = Logger.new("tuples");
    let (q, r) = divmod(17, 5);   // destructuring
    let t = (1, true);
    log.info(f"q={q} r={r} t.0={t.0}");
}
q=3 r=2 t.0=1

The pair is also rut’s standard error channel — the next chapters use it constantly.

Put it together

Save this as literals.rut and run rut run literals.rut:

use pouch::{ Vec };
use ink::{ Logger };

struct Point { x: f32; y: f32 }

fn literals(name: str) {
    let log = Logger.new("literals");
    let a = 10;                      // i32 (default)
    let b = 10u8;                    // u8 via suffix
    let c: u64 = 10;                 // u64 via annotation — fits the default
    let big = 18446744073709551615u64; // past the `i32` default: suffix REQUIRED
    let cast = 300 as u8;            // 44 — `as` truncates
    let d = 1.5;                     // f32 (default)
    let e = 1.5f32;                  // f32 via suffix
    let d64: f64 = 1.5;              // f64 via annotation
    let s = "hi\tname";              // plain string: escapes processed
    let raw = r"C:\temp\log.txt";    // raw literal: NO escape processing
    let t = f"hi {name}!";           // format literal
    let ch = "h";                    // a 1-codepoint str
    let mut arr = [1, 2, 3];         // [i32] — fixed array
    let mut zero: Vec<f32> = Vec<f32>.filled(0.0, 1024);  // growable, flat
    let grow = Vec<i32>.from([1, 2, 3]);  // array -> growable
    arr[0] = 7;                      // element write needs `mut`
    zero[0] = 9.0f32;
    let bin = bytes(64);             // 64 zeroed octets
    let p = Point { x: 1, y: 2 };    // struct literal: no `new`

    log.info(f"a={a} e={e} d64={d64} ch={ch} p.x={p.x} zero[0]={zero[0]} len={grow.len()} bin={bin.len()}");
}

pub fn main() {
    literals("rut");
}
a=10 e=1.5 d64=1.5 ch=h p.x=1 zero[0]=9 len=3 bin=64

Next: control flow and when.

Control flow and when

rut has the classic structured statements — if/else, while, for — and exactly one match construct: when, an exhaustive pattern expression. There is no switch, no case, no fallthrough. The full rules live in the reference on control flow.

if and else

Braces are always required, whatever the body length:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("flow");
    for (let hits of [0, 5, 12]) {
        let mut first = false;
        let mut warm = false;
        let mut hot = false;
        if (hits == 0) {
            first = true;
        } else if (hits < 10) {
            warm = true;
        } else {
            hot = true;
        }
        log.info(f"hits={hits} first={first} warm={warm} hot={hot}");
    }
}
hits=0 first=true warm=false hot=false
hits=5 first=false warm=true hot=false
hits=12 first=false warm=false hot=true

The condition must be a bool — there is no truthiness coercion, and no parenthesized-assignment footgun.

Loops

while repeats until its condition is false:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("sieve");
    let i = 2;
    let limit = 12;
    let mut marks = [0; 16];
    let mut m = i * i;
    while (m <= limit) {
        marks[m] = 1;
        m += i;
    }
    log.info(f"marks[4]={marks[4]} marks[5]={marks[5]} stopped at m={m}");
}
marks[4]=1 marks[5]=0 stopped at m=14

for..of iterates anything with elements: fixed arrays, Vecs, str (yielding one-codepoint strs), and bytes (yielding u8s):

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("loops");
    let mut n = 0;
    for (let w of ["rut", "runs", "rut"]) {
        // w is a str
    }
    for (let c of "héllo") {
        n += 1;             // 5 iterations — codepoints, not the 6 bytes
    }
    log.info(f"n={n}");
}
n=5

The loop variable is always spelled let and is fresh each iteration.

Indexed for is the C shape. The induction variable is loop-owned: the update clause (and the body) may assign it without mut.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("loops");
    let xs = [3, 1, 4];
    let n = xs.len();
    let mut total = 0;
    for (let i = 0; i < n; i += 1) {
        total += xs[i];
    }
    log.info(f"total={total}");
}
total=8

break and continue work in every loop form; there are no labels and no do..while.

return

return expr; exits the function. If the function returns nothing, the arrow is omitted and return; (or falling off the end) returns:

use pouch::{ Vec };
use ink::{ Logger };

fn swap(mut xs: Vec<i32>, a: i32, b: i32) {
    let t = xs[a];
    xs[a] = xs[b];
    xs[b] = t;
    // no return — the function's type is nil
}

pub fn main() {
    let log = Logger.new("swap");
    let xs = Vec<i32>.from([1, 2, 3]);
    swap(xs, 0, 2);
    log.info(f"first={xs[0]} last={xs[2]}");
}
first=3 last=1

when — the one match

when (value) { pattern -> body, ... } is an expression: the first matching arm produces the value. No fallthrough — exactly one arm runs.

use ink::{ Logger };

fn classify(n: i32) -> str {
    return when (n) {
        0       -> "zero",
        1, 2, 3 -> "small",     // comma-separated alternatives
        else    -> "big",       // the wildcard
    };
}

pub fn main() {
    let log = Logger.new("when");
    for (let n of [0, 2, 9]) {
        log.info(f"{n}: {classify(n)}");
    }
}
0: zero
2: small
9: big

The arm body before the , is an expression; arms can also be blocks:

use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn go()    { Logger.new("light").info("go"); }
fn brake() { Logger.new("light").info("brake"); }
fn stop()  { Logger.new("light").info("stop"); }

pub fn main() {
    let l = Light.Yellow;
    when (l) {
        Light.Green  -> { go(); },
        Light.Yellow -> { brake(); },
        Light.Red    -> { stop(); },
    }
}
brake

Used as a statement, a when must produce nothing — the block arms above are statement arms.

Patterns

v1 patterns are: enum members (Light.Red), literals (integers, floats, bool, str), comma-separated alternatives, and else. There are no ranges, no destructuring, and no guards — an if inside the arm body does that job.

Exhaustiveness is enforced

  • Enum scrutinee: either cover every member or add else. A partial when over an enum is a compile error — when you add a member later, the compiler finds every when you must extend.
  • Any other scrutinee: else is mandatory (integers and strings cannot be enumerated).
use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn go()    { Logger.new("light").info("go"); }
fn brake() { Logger.new("light").info("brake"); }
fn stop()  { Logger.new("light").info("stop"); }

fn drive(l: Light) {
    when (l) {
        Light.Green  -> { go(); },
        Light.Yellow -> { brake(); },
        Light.Red    -> { stop(); },   // all members: no else needed
    }
}

fn describe(n: i32) -> str {
    return when (n) {
        0    -> "zero",
        else -> "not zero",            // non-enum scrutinee: else REQUIRED
    };
}

pub fn main() {
    drive(Light.Red);
    let log = Logger.new("light");
    log.info(describe(0));
    log.info(describe(7));
}
stop
zero
not zero

when over a bool reads nicely as a two-arm check:

use ink::{ Logger };

struct Point { x: i32; y: i32 }

pub fn main() {
    let log = Logger.new("nullable");
    let p: ?Point = Point { x: 1, y: 2 };
    when (p != nil) {
        true -> { log.info(f"point {p.x} {p.y}"); },
        else -> { log.info("point: nil"); },
    }
}
point 1 2

Duplicate patterns — and arms made unreachable by an earlier one — are compile errors.

One type across the arms

All arm expressions must agree: that shared type is the when’s type. A when that builds a value usually reads best wrapped in a function — the function’s return type then pins every arm:

use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn light_for(i: i32) -> Light {
    return when (i) {
        0    -> Light.Green,
        1    -> Light.Yellow,
        else -> Light.Red,
    };
}

pub fn main() {
    let log = Logger.new("light");
    log.info(f"{light_for(0)} {light_for(1)} {light_for(5)}");
}
Green Yellow Red

Put it together

use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn action(l: Light) -> str {
    return when (l) {
        Light.Green  -> "go",
        Light.Yellow -> "brake",
        Light.Red    -> "stop",
    };
}

fn light_for(i: i32) -> Light {
    return when (i) {
        0    -> Light.Green,
        1    -> Light.Yellow,
        else -> Light.Red,
    };
}

fn classify(n: i32) -> str {
    return when (n) {
        0       -> "zero",
        1, 2, 3 -> "small",
        else    -> "big",
    };
}

pub fn main() {
    let log = Logger.new("lights");

    for (let n of [0, 2, 9]) {
        log.info(f"{n}: {classify(n)}");
    }

    let mut i = 0;
    while (i < 3) {
        log.info(action(light_for(i)));
        i += 1;
    }
}
0: zero
2: small
9: big
go
brake
stop

Next: functions, closures, and generics.

Functions, closures, and generics

Functions are declared with fn, values can be functions, and generic functions monomorphize at compile time — each instantiation gets its own specialized code. The details live in the reference on functions, closures, and generics.

Declaring functions

Parameter and return types are always written. A function whose return type is omitted returns nothing:

use pouch::{ Vec };
use calc::{ Math };
use ink::{ Logger };

struct Point { x: f32; y: f32 }

fn swap(mut xs: Vec<i32>, a: i32, b: i32) {
    let t = xs[a];
    xs[a] = xs[b];
    xs[b] = t;
}

fn length(pt: Point) -> f64 {
    return Math.sqrt((pt.x * pt.x + pt.y * pt.y) as f64);
}

pub fn main() {
    let log = Logger.new("fns");
    let xs = Vec<i32>.from([1, 2, 3]);
    swap(xs, 0, 2);
    log.info(f"first={xs[0]} length={length(Point { x: 3, y: 4 })}");
}
first=3 length=5

A mut parameter (fn step(mut p: Point)) is the callee’s permission to write through p — and because non-primitive values share, that means the caller’s value. A plain parameter promises not to write through it.

Recursion works as expected — functions are visible in their own bodies, and mutual recursion within a module needs no forward declaration.

Anonymous functions

The closure spelling is an anonymous fn with a block body. There is no arrow shorthand — every closure states its parameter and return types, which is what lets it inhabit a first-class fn type:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("closures");
    let add = fn (a: i32, b: i32) -> i32 { return a + b; };

    let area_of = fn (r: f32) -> f32 {
        let sq = r * r;
        return sq * 3.14159265f32;
    };

    add(1, 2);          // call it like any function
    log.info(f"add={add(1, 2)} area={area_of(1)}");
}
add=3 area=3.1415927

Function types are written fn(P..) -> R and appear anywhere a type does:

use ink::{ Logger };

fn apply(f: fn(i32) -> i32, v: i32) -> i32 {
    return f(v);
}

pub fn main() {
    let log = Logger.new("fns");
    let double = fn (x: i32) -> i32 { return x * 2; };
    log.info(f"apply={apply(double, 21)}");
}
apply=42

Closures capture the enclosing bindings — a closure body sees and can use the locals around it, including after the enclosing function would have returned, because captured cells stay alive as long as the closure does. Since non-primitives share, a capture of a Vec or a struct is a handle to the same cell: writes through the closure are visible to everyone else holding it.

Generics

A generic function declares type parameters in angle brackets. Each concrete instantiation is compiled separately (monomorphized), so generics cost nothing at runtime:

use ink::{ Logger };

fn first<T>(xs: [T], fallback: T) -> T {
    if (xs.len() == 0) {
        return fallback;
    }
    return xs[0];
}

pub fn main() {
    let log = Logger.new("generics");
    let head = first([10, 20], -1);      // first<i32> — inferred
    let name = first(["a", "b"], "?");   // first<str> — a separate instance
    log.info(f"head={head} name={name}");
}
head=10 name=a

T is inferred from the arguments; you rarely spell first<i32>(..) explicitly. Two rules to know:

  • A bare T has no methods — you cannot call anything on a value whose type is just T. Pass concrete values in, or take a trait-typed parameter instead (next chapter).
  • In v1 a generic class in a parameter position does not unify — fn sum(xs: Vec<i32>) is fine, but a function generic over T taking Vec<T> is not yet the shape to reach for. Concrete instantiations cover most code.

requires bounds

An inline requires bound gates which instantiations compile:

use ink::{ Logger };

trait Labeled {
    fn label(self) -> str;
}

struct Tag { id: i32 }

impl Labeled for Tag {
    fn label(self) -> str { return f"tag-{self.id}"; }
}

fn name<T requires Labeled>(x: T) -> str {
    let w: Labeled = x;     // the bound proves this widening
    return w.label();
}

pub fn main() {
    let log = Logger.new("bounds");
    log.info(name(Tag { id: 7 }));
}
tag-7

The bound is admission-only: it checks at each call site that the concrete type satisfies the named trait (or union of traits), and it proves a T-typed value may be used as that trait type — it does not put methods on T itself. Union bounds admit any member:

fn kind<T requires Ridge | Trench>(x: T) -> str { .. }

This is exactly how typed JSON entry points are spelled — decodeJson<T requires JsonDeserialize>(s: str) — see errors and optionality. Bounds and unions are covered fully in the reference on type aliases and union bounds.

Free functions are the default idiom

rut’s records carry no methods (bodies are fields only; methods live in impl blocks — see structs, enums, and classes), so the everyday shape is small data plus free functions over it:

use ink::{ Logger };

struct Point { x: f32; y: f32 }

fn nudged(pt: Point) -> Point {
    return Point { x: pt.x + 1, y: pt.y };
}

pub fn main() {
    let log = Logger.new("free-fns");
    let p = nudged(Point { x: 2, y: 5 });
    log.info(f"p.x={p.x} p.y={p.y}");
}
p.x=3 p.y=5

Reach for methods when something is genuinely the receiver’s behavior, and for trait impls — everything else is a plain function.

Put it together

use pouch::{ Vec };
use ink::{ Logger };

fn first<T>(xs: [T], fallback: T) -> T {
    if (xs.len() == 0) {
        return fallback;
    }
    return xs[0];
}

fn sum(xs: Vec<i32>) -> i32 {
    let mut total = 0;
    for (let x of xs) {
        total += x;
    }
    return total;
}

pub fn main() {
    let log = Logger.new("closures");
    let add = fn (a: i32, b: i32) -> i32 { return a + b; };
    let area_of = fn (r: f32) -> f32 {
        let sq = r * r;
        return sq * 3.14159265f32;
    };
    log.info(f"add={add(1, 2)} area={area_of(1)} sum={sum(Vec<i32>.from([1, 2, 3]))}");

    let head = first([10, 20], -1);       // first<i32> — monomorphized
    let name = first(["a", "b"], "?");    // first<str> — separate instance
    log.info(f"head={head} name={name}");
}
add=3 area=3.1415927 sum=6
head=10 name=a

Next: structs, enums, and classes.

Structs, enums, and classes

rut has three user-defined type forms, each with one job:

  • enum — a small set of named constants.
  • struct — an open data record: every field public, literal construction everywhere.
  • class — a sealed record: private fields, construction through class methods only.

In all three, the type body is fields only — every method lives in an impl block. A fn written inside a type body is a parse error. The reference pages are structs, enums, and classes and constructors.

Enums

An enum is a distinct named type over integer constants. No payloads, no methods, no computed members — where another language would use a union of literal strings, rut uses an enum:

use ink::{ Logger };

enum Light { Green, Yellow, Red }

enum Direction { Up = 1, Down, Left, Right }   // 1, 2, 3, 4

pub fn main() {
    let log = Logger.new("enums");
    log.info(f"{Light.Green} {Direction.Left} {Direction.Right}");
}
Green Left Right

Members are the enum’s values and are spelled qualified: Light.Green. Explicit initializers set where the numbering starts; the rest continue from there.

when over an enum must be exhaustive — every member, or an else arm (see control flow and when). Enums render as their member name in format strings.

Structs — open records

Declare with struct, construct with a literal — anywhere in a function body, nested inside other literals. There is no new:

use ink::{ Logger };

struct Point {
    x: f32;
    y: f32;
}

struct Rect {
    min: Point;
    max: Point;
}

struct Style {
    color: u32 = 0xff00ff;      // field initializer: literals may omit it
    width: f32 = 1;
}

pub fn main() {
    let log = Logger.new("structs");
    let p = Point { x: 1, y: 2 };                    // every field, by name
    let s = Style {};                                // defaults fill the rest
    let r = Rect { min: Point { x: 0, y: 0 }, max: p };
    log.info(f"r.max.x={r.max.x} width={s.width}");
}
r.max.x=1 width=1

All fields are public, always — member visibility in a struct is a compile error. Privacy is what classes are for.

Structs share. A struct value is a handle to a heap cell: assignment, arguments, and returns all pass the handle, and a write through any alias is visible through all of them. Writing needs a mut binding or mut parameter:

use ink::{ Logger };

struct Point { x: f32; y: f32 }

pub fn main() {
    let log = Logger.new("sharing");
    let mut p = Point { x: 1, y: 2 };
    let q = p;              // q and p name ONE cell
    p.x = 4;                // q.x is 4 now
    log.info(f"q.x={q.x} same cell: {q == p}");
}
q.x=4 same cell: true

== on struct values is cell identity — q == p is true (one cell), p == Point { x: 4, y: 2 } is false (a different cell). To compare field by field, write a function.

Recursive shapes are legal — a record may name itself through a nullable field, because ?Node is one word:

use ink::{ Logger };

struct Node {
    value: i32;
    left: ?Node;
    right: ?Node;
}

fn count(n: ?Node) -> i32 {
    let mut c = 1;
    if (n.left != nil) { c += count(n.left); }
    if (n.right != nil) { c += count(n.right); }
    return c;
}

pub fn main() {
    let log = Logger.new("nodes");
    let n = Node {
        value: 1,
        left: Node { value: 2, left: nil, right: nil },
        right: nil,
    };
    log.info(f"count={count(n)}");
}
count=2

Classes — sealed records

A class adds three things to a struct: module-private fields, construction gated through class methods, and the option of cleanup hooks. There is no constructor keyword, no new operator, and no outside literal — the only way to build a class value from outside is to call a class method that chooses to.

use ink::{ Logger };

class Counter {
    n: i32 = 0;             // module-private — the class's business
}

impl Counter {
    pub fn new() -> Self {                 // the construction surface
        return Self { };                   // the class-private literal
    }

    pub fn press(mut self) {
        self.n = self.n.wrapping_add(1);
    }

    pub fn count(self) -> i32 { return self.n; }
}

pub fn main() {
    let log = Logger.new("counter");
    let c = Counter.new();
    c.press();
    c.press();
    log.info(f"count={c.count()}");      // 2
}
count=2

The pieces:

  • A class method is just a function without self. Rect.new(w, h), Version.parse(s), Rect.from_square(s) — any no-self method returning Self is a constructor. new is a convention, not syntax.
  • The Self { .. } literal is class-private — legal anywhere in the class’s own impl block, never outside. This is the seal.
  • Instance methods spell self explicitly as the first parameter; mut self marks methods that write. There is no this, no static methods, no get/set syntax — a computed property is a method (c.count()).
  • Construction is validation. A constructor is an ordinary function — it can check arguments and refuse:
use ink::{ Logger };

class Rect {
    w: f32;
    h: f32;
}

impl Rect {
    pub fn new(w: f32, h: f32) -> Self {
        if (w <= 0 || h <= 0) {
            panic("Rect: negative extents");
        }
        return Self { w: w, h: h };
    }

    pub fn from_square(s: f32) -> Self {
        return Rect.new(s, s);
    }

    pub fn area(self) -> f32 { return self.w * self.h; }
}

pub fn main() {
    let log = Logger.new("rect");
    let r = Rect.new(3, 4);
    let sq = Rect.from_square(2);
    log.info(f"area={r.area()} square={sq.area()}");
}
area=12 square=4

A try-constructor answers the nullable — nil is a failed validation, not a crash:

impl Version {
    pub fn parse(s: str) -> ?Version {
        // ... split "1.2" — on failure:
        return nil;
    }
}

No inheritance. There is no extends, no super, no overriding. Code sharing is composition (hold a helper in a field) or free functions; polymorphism is traits — the next chapter.

Visibility recap

  • Struct members: public, always.
  • Class members: unannotated is module-private; pub exposes to importers (also pub(mod), pub(super), pub(self)).
  • The types themselves follow the same rule: pub class Vec<T> is importable, a bare class Helper stays in its module. See modules and packages.

Struct or class?

Default to a struct: data in, data out, shared like every other value. Reach for a class when the type has rules that construction must enforce, holds private state that outsiders must not read, or owns a resource that needs explicit cleanup.

Put it together

use ink::{ Logger };

class Counter {
    n: i32 = 0;
}

impl Counter {
    pub fn new() -> Self {
        return Self { };
    }

    pub fn press(mut self) {
        self.n = self.n.wrapping_add(1);
    }

    pub fn count(self) -> i32 { return self.n; }
}

class Rect {
    w: f32;
    h: f32;
}

impl Rect {
    pub fn new(w: f32, h: f32) -> Self {
        if (w <= 0 || h <= 0) {
            panic("Rect: negative extents");
        }
        return Self { w: w, h: h };
    }

    pub fn from_square(s: f32) -> Self {
        return Rect.new(s, s);
    }

    pub fn area(self) -> f32 { return self.w * self.h; }
}

pub fn main() {
    let log = Logger.new("classes");
    let c = Counter.new();
    c.press();
    c.press();
    let r = Rect.new(3, 4);
    let sq = Rect.from_square(2.0f32);
    log.info(f"count={c.count()} area={r.area()} square={sq.area()}");
}
count=2 area=12 square=4

Next: traits and impl blocks.

Traits and impl blocks

A trait declares a set of methods; a type joins the trait by writing an impl block. Satisfaction is nominal — the impl block is the only admission. A type whose members all happen to match a trait’s shapes is still not an instance of it until someone writes the block. The full dispatch story is in the reference on traits and traits and dispatch.

Declaring a trait

Traits declare method signatures — no bodies, no default implementations, no fields:

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

Methods spell their receiver self explicitly, like every rut method. Anything that looks like a property is a method: declare fn count(self) -> i32;, not a field.

Implementing a trait

impl Trait for Type { .. } registers the pair. Every method must match the trait’s signature exactly; extra methods don’t belong here (put those in an inherent block). Trait impl methods carry no pub — they are as visible as the trait.

use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

pub fn main() {
    let log = Logger.new("traits");
    let c = Circle { r: 1.0 };
    log.info(f"{c.name()}={c.area()}");
}
circle=3.14159

Rules worth knowing:

  • One impl per (trait, type) pair, program-wide. A duplicate is a link error.
  • Placement: an impl may live in a module of the trait’s package or the type’s package — at least one side must be yours. You cannot implement two foreign types to each other.
  • An empty impl block is legal and acts as a marker: impl Serializable for Point {} says “this type is in” when the trait has no required methods.
  • Primitives can implement traits too (in the trait’s own package or module) — the standard library uses this to give integer widths a common internal interface.

Inherent impls

impl Type { .. } — no trait — is where a type’s own methods live: class methods like new, instance methods, helpers. It compiles only in the module that declares the type.

use ink::{ Logger };

struct Circle { r: f64 }

impl Circle {
    fn new(r: f64) -> Self {
        return Self { r: r };
    }
}

pub fn main() {
    let log = Logger.new("traits");
    let c = Circle.new(1.0);
    log.info(f"r={c.r}");
}
r=1

Using trait values

Widening is implicit at any position that expects the trait: an annotated binding, a field, an argument, a return.

use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

pub fn main() {
    let log = Logger.new("traits");
    let c: Shape = Circle { r: 1.0 };   // Circle widens to Shape
    log.info(f"{c.name()}={c.area()}");
}
circle=3.14159

Trait-typed parameters accept any implementor — write the function once, call it with each concrete type:

use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

impl Shape for Square {
    fn area(self) -> f64 { return self.side * self.side; }
    fn name(self) -> str { return "square"; }
}

fn describe(s: Shape) -> str {
    return f"{s.name()}={s.area()}";
}

pub fn main() {
    let log = Logger.new("traits");
    log.info(describe(Circle { r: 1.0 }));
    log.info(describe(Square { side: 3.0 }));
}
circle=3.14159
square=9

Heterogeneous containers hold mixed implementors behind the trait name. Inside a single-typed context calls bind directly; iterating a collection of Shape dispatches through the value’s vtable — the compiler picks, and mis-guessing costs one hop, never wrong behavior:

use pouch::{ Vec };
use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

impl Shape for Square {
    fn area(self) -> f64 { return self.side * self.side; }
    fn name(self) -> str { return "square"; }
}

pub fn main() {
    let log = Logger.new("traits");
    let mut shapes: Vec<Shape> = Vec.new();
    shapes.push(Circle { r: 1.0 });
    shapes.push(Square { side: 3.0 });
    let mut total: f64 = 0.0;
    for (let s of shapes) {
        total += s.area();       // dispatches per element
    }
    log.info(f"total={total} n={shapes.len()}");
}
total=12.14159 n=2

Type tests: is

expr is Type answers with a bool and never traps:

  • Concrete RHS — p is Circle: an exact-type test.
  • Trait RHS — p is Shape: a capability probe (“does this value’s type have an impl of Shape?”).
use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

impl Shape for Square {
    fn area(self) -> f64 { return self.side * self.side; }
    fn name(self) -> str { return "square"; }
}

pub fn main() {
    let log = Logger.new("traits");
    let sq = Square { side: 2.0 };
    log.info(f"shape: {sq is Shape} circle: {sq is Circle}");
}
shape: true circle: false

is answers the question; it changes nothing — there is no narrowing and no downcast through a trait. A trait-typed value is used through its trait’s methods; if you need the erased-storage version — a value whose type is forgotten until recovered — that is opaque, covered in errors and optionality and the reference on opaque.

Bounds connect traits to generics

fn name<T requires Labeled>(x: T) admits exactly the instantiations whose concrete type has an impl of Labeled — see functions, closures, and generics. The bound is what makes widening legal inside the body: let w: Labeled = x;.

Making your type iterable

A type becomes a for..of target by implementing the builtin Iterator<E> contract with its single resumption member:

use pouch::{ Vec };
use ink::{ Logger };

struct CountUp { n: i32 }

impl Iterator<i32> for CountUp {
    fn __iterate(self, emit: fn(i32) -> bool) {
        for (let i = 1; i <= self.n; i += 1) {
            if (!emit(i)) { return; }   // false = stop
        }
    }
}

pub fn main() {
    let log = Logger.new("iter");
    let ups = CountUp { n: 4 };
    let got: Vec<i32> = Vec.new();   // shared: survives the loop's captures
    for (let v of ups) {
        got.push(v);
    }
    log.info(f"n={got.len()} first={got[0]} last={got[got.len()-1]}");
}
n=4 first=1 last=4

for (let v of it) desugars to it.__iterate(emit) with a synthetic closure. One consequence to know: the loop body’s captures are taken at the desugar, so reassigning an enclosing scalar inside the loop mutates a copy, not the original. Accumulate through something shared instead — vec.push(v) — or have the loop body act on values it can see directly.

The builtin sequences ([T], Vec<T>, str, bytes) iterate without the trait — their loops are fused, never a per-element call.

Put it together

use pouch::{ Vec };
use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn name(self) -> str;
}

struct Circle { r: f64 }
struct Square { side: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159 * self.r * self.r; }
    fn name(self) -> str { return "circle"; }
}

impl Shape for Square {
    fn area(self) -> f64 { return self.side * self.side; }
    fn name(self) -> str { return "square"; }
}

fn describe(s: Shape) -> str {
    return f"{s.name()}={s.area()}";
}

pub fn main() {
    let log = Logger.new("traits");

    let c: Shape = Circle { r: 1.0 };
    log.info(describe(c));

    let sq = Square { side: 2.0 };
    log.info(f"is shape: {sq is Shape}");

    let mut shapes: Vec<Shape> = Vec.new();
    shapes.push(Circle { r: 1.0 });
    shapes.push(Square { side: 3.0 });
    let mut total: f64 = 0.0;
    for (let s of shapes) {
        total += s.area();
    }
    log.info(f"total={total} n={shapes.len()}");
}
circle=3.14159
is shape: true
total=12.14159 n=2

Next: errors and optionality.

Errors and optionality

rut has no exceptions you catch, no Result enum, and no null. Two mechanisms cover everything:

  • Absence is nil on a nullable ?T.
  • Failure is a value in the second slot of a pair — the (T, err) answer channel.
  • Bugs — broken contracts — trap loudly and immediately (panic, assert, a nil deref, an out-of-bounds index).

The design background lives in everything is a value.

?T and nil: absence

?T is a nil-able cell. A plain T always holds a value; ?T is that value or nil:

use ink::{ Logger };

struct Node {
    value: i32;
    left: ?Node;      // one word — recursive shapes are legal
    right: ?Node;
}

pub fn main() {
    let log = Logger.new("nodes");
    let n = Node { value: 1, left: nil, right: nil };
    log.info(f"left is nil: {n.left == nil}");
}
left is nil: true

Lookups answer ?T: a hit is the value, a miss is nil. Deref is automatic at every use — p.x, p[i], for (let x of p), arithmetic — and dereferencing a nil traps NilDeref, so guard first:

use nmapset::{ HashMap };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("optional");
    let mut scores: HashMap<str, i32> = HashMap.new();
    scores.put("rut", 41);
    let mut n = 0;
    let hit = scores.get("rut");      // ?i32
    if (hit != nil) {
        n = hit + 1;                  // a hit auto-unwraps for `+`
    }
    log.info(f"n={n}");
}
n=42

p == nil compares against the null. There is no flow-typing: after the guard, hand the value to a typed binding when you want a plain T:

let err = map_response(resp, "no such repo or ref", "data.jsdelivr.com");
if (err != nil) {
    let why: str = err;           // the plain str behind the nullable
    eprint(why);
    return 1;
}

nil in an expected-?T position just works: let p: ?Node = nil;, or a left: nil field in a literal.

The (T, err) answer channel

Functions that can fail return a pair. The convention is uniform:

  • empty err + a value — success;
  • empty err + nil — a legitimate “not found”;
  • non-empty err — failure.

The simplest err is a str message (empty string means success):

fn hex_dec(s: str) -> (bytes, str) {
    if (s.len() % 2 != 0) {
        return (bytes.zeroed(0), "hex: odd-length input");
    }
    // ... decode ...
    return (out.freeze(), "");
}

Nullable halves make both slots explicit — this is the standard library’s own shape, (?T, ?E):

// json's entry points
fn decodeJson<T requires JsonDeserialize>(s: str) -> (?T, ?DecodeJsonError);
fn encodeJson<T requires JsonSerialize>(v: T) -> (?str, ?EncodeJsonError);

The caller destructures and checks the err half first:

use json::{ decodeJson };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("json");
    let (n, e) = decodeJson<i64>("42");
    if (e == nil) {
        log.info(f"n={n}");
    } else {
        let why = e;
        log.info(f"decode failed at {why.at}");
    }
}
n=42

Integer arithmetic’s checked ladder uses the same idea in miniature: x.checked_add(y) answers (T, bool) where .1 is false exactly when the result escaped the width — see values and variables.

Building an error type

For errors a caller might want to branch on, pair a payloadless enum (the kind) with a fixed-field struct (the details). That is the shape json uses:

pub enum DecodeErrorKind { Unexpected, Truncated, InvalidUtf8, WrongType, Depth, Trailing }

pub struct DecodeJsonError {
    kind: DecodeErrorKind;
    at: i64;        // codepoint offset in the input
    got: str;       // the offending text
    expected: str;  // what the schema asked for here
}

The caller picks: branch on kind, or just render the fields:

use json::{ decodeJson };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("json");
    let (bad, be) = decodeJson<i64>("[1,2,3]");
    if (be != nil) {
        let why = be;
        // rejected at 0: got '[', wanted an i64
        log.info(f"rejected at {why.at}: got '{why.got}', wanted {why.expected}");
    }
}
rejected at 0: got '[', wanted an i64

Enums have no payloads — the struct carries the data. This split keeps every error a plain value: copy it, store it in a log, put a location next to it, test it.

panic and assert: for bugs, not for flow

panic("Rect: negative extents");        // abort with a message
assert(total == expected, "checksum");  // abort when the condition is false

Use these when continuing would be a lie: a broken invariant, an impossible state, a caller error that is a programming mistake. Data problems — a bad byte in a file, a missing key, a network error — are values and belong in the answer channel, so callers can recover.

A trap’s message and a stack trace (opt-in via capture_stacktrace()) land in the host’s diagnostics — see diagnostics, traces, and symbolication.

Erasure when you truly need it

When a value must cross a boundary that cannot name its type — host handles, heterogeneous boxes — opaque(v) seals it and opaque.downcast<T>(o) -> ?T recovers it (nil on a wrong type, never a trap):

use ink::{ Logger };

struct Point { x: i32; y: i32 }

pub fn main() {
    let log = Logger.new("opaque");
    let box1 = opaque(Point { x: 1, y: 2 });
    let p = opaque.downcast<Point>(box1);   // ?Point
    when (p != nil) {
        true -> { log.info(f"point {p.x} {p.y}"); },
        else -> { log.info("point: nil"); },
    }
}
point 1 2

x is T probes the box without recovering it. This is rut’s only any-shaped value, and it can do nothing until recovered — see the reference on opaque.

Put it together

use json::{ decodeJson, encodeJson };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("json");

    // a successful decode: (value, nil)
    let (n, e) = decodeJson<i64>("42");
    if (e == nil) {
        log.info(f"n={n}");
    } else {
        let why = e;
        log.info(f"decode failed at {why.at}: {why.expected}");
    }

    // encode answers the same shape: (?str, ?EncodeJsonError)
    let (s, ee) = encodeJson<[i64]>([1, 2, 3]);
    if (ee == nil) {
        let text = s;
        log.info(f"s={text}");
    } else {
        log.info("encode failed");
    }

    // a bad decode: the (nil, err) half answers the failure
    let (bad, be) = decodeJson<i64>("[1,2,3]");
    if (be == nil) {
        log.info("unexpected success");
    } else {
        let why = be;
        log.info(f"rejected at {why.at}: got '{why.got}', wanted {why.expected}");
    }
}
n=42
s=[1,2,3]
rejected at 0: got '[', wanted an i64

Next: modules and packages.

Modules and packages

A rut file is a module: one namespace, one visibility scope. A directory with a rut.toml is a package — the unit you depend on and share. This chapter walks up those two levels. The reference pages are modules and visibility, project structure and rut.toml, and dependency kinds.

What lives at module scope

Module scope contains declarations only: use, let, fn, struct, class, enum, trait, impl. Every statement lives inside a function — and loading a module executes nothing. There is no load-time side-effect ordering to reason about; the host loads your module and calls its entry point (conventionally pub fn main).

Module-level let initializers must be load-time literals — 42, "app", true. Arithmetic, record literals, and calls to user functions are not accepted in this build (there is no mutable module state; programs build their state in main).

use pouch::{ Vec };
use ink::{ Logger };

struct Point { x: f32; y: f32 }

let version = 1;                       // fine: a literal
let app_name = "app";                  // any literal works

fn main_body() { /* statements live here */ }

pub fn main() {
    let log = Logger.new("app");
    let origin = Point { x: 0, y: 0 }; // record literals live in function bodies
    log.info(f"{app_name} v{version} origin.x={origin.x}");
}
app v1 origin.x=0

Visibility

Every declaration has a visibility, and unannotated means module-private — the safe default. Nothing leaks unless it says pub:

FormMeaning
pub fn ..public — importable by any module, other packages included
pub(mod) fn ..visible everywhere in this package’s module tree
pub(super) fn ..visible to the parent module only
fn ..module-private (pub(self)) — the default

The same forms apply to types, module lets, and class members. Structs are the exception: a struct is an open record — all members public, always, and its impl methods take no visibility annotation (they are as public as the type).

use — naming other modules

use imports names with Rust-like paths:

use ink::{ Logger };
use pouch::{ Vec };
use json::{ decodeJsonBytes, JsonDeserialize };

Builtin names — the primitives, str/bytes members, panic, assert, Vec-free array grammar, opaque — are ambient: no use needed. Package names from your manifest are; an unused name in a use is a lint, not an error.

Within one module, everything is visible — including declarations later in the file. Order never matters.

Packages: rut.toml

A package is a directory with a manifest. The small but complete case — one library package and one app:

greet/
├── pkg/
│   ├── rut.toml
│   └── greet.rut
└── app/
    ├── rut.toml
    └── main.rut
# greet/pkg/rut.toml
name = "greet"
entry.lib = "./greet.rut"

[deps]
pouch = { path = "../../rut/pouch" }   # the toolchain tree's pouch package
// greet/pkg/greet.rut
use pouch::{ Vec };

pub struct Greeting {
    to: str;
    lines: Vec<str>;
}

impl Greeting {
    pub fn new(to: str) -> Self {
        return Self { to: to, lines: Vec.new() };
    }

    pub fn add(mut self, line: str) {
        self.lines.push(line);
    }

    pub fn render(self) -> str {
        let mut out = f"dear {self.to},";
        for (let line of self.lines) {
            out = f"{out} {line}";
        }
        return out;
    }
}

fn shout(msg: str) -> str {   // module-private: no `pub`, never importable
    return f"{msg}!";
}

The app names its dependencies in [deps], by path — each package pulls its own dependencies along (ink brings the host surface rt; you never spell it):

# greet/app/rut.toml
name = "app"
entry.lib = "./main.rut"

[deps]
greet = { path = "../pkg" }
ink = { path = "../../rut/ink" }       # the logger package
// greet/app/main.rut
use greet::{ Greeting };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("app");
    let mut g = Greeting.new("rut");
    g.add("hello");
    g.add("from a package");
    log.info(g.render());
}

Run the app from its directory:

rut run .
dear rut, hello from a package

Resolution walks [deps] recursively (a dep’s own [deps] mount with it), with a cycle guard and first-mount-wins. Single loose files get a convenience: the rut CLI mounts the toolchain’s tree packages when your file says use ink:: or use pouch:: — see the rut CLI.

One package, several files

A package’s body can be split across files. The manifest splices them, base first, in listed order — into one module: one namespace, one visibility scope. A name private to one file is visible to every other file of the same package:

name = "app"
entry.lib = "./biz.rut"
entry.libs = ["./domain.rut", "./world.rut", "./app.rut"]

This is assembly, not an include form — cross-package references still go through use paths.

Dependency kinds

Beyond [deps], a manifest can declare two other relations:

  • [peer-deps] — a package this one integrates with but never pulls: the consumer supplies it, or (with optional = true) the integration mounts only if the peer is already in the program’s closure. The standard library’s json package uses this to attach its Vec/map serialization impls only for programs that carry the container packages.
  • [dev-deps] — mounted only when building/testing the package itself, never for a consumer.
[peer-deps]
pouch = { path = "../pouch", optional = true, lib = "./group-pouch.rut" }

[dev-deps]
pouch = { path = "../pouch" }

entry fn — the host-facing surface

pub publishes a name to other rut modules. entry publishes a function to the embedding host, with its signature checked against the boundary’s crossing rules at compile time:

entry fn hex_enc(data: bytes) -> str { .. }

An embedded application drives these entries; pub fn main is the conventional entry the rut run CLI calls. See the host boundary.

Tooling

rut run <file.rut | dir | mod.rutbundle> [--fuel N]  # compile + run
rut fmt <file.rut | dir> [--check]                   # format in place
rut pack <dir> [-o out.rutbundle]                    # a self-contained bundle
rut dump <file.rut>                                  # dump module info

Put it together

The greet project above is complete as shown — two directories, two manifests, two sources. Copy the trees into files and rut run . from greet/app to see:

dear rut, hello from a package

Next: async: tasks, workers, and channels.

Async: tasks, workers, and channels

rut’s concurrency is pull-based. An async fn compiles into a future — a cold value that runs nothing until something drives it. There are no promises that start on creation, no microtask queue, no implicit scheduling: the host owns time, and code progresses only when a driving loop pumps it. The model is described in the async model, with the full surface in async and await and tasks.

async fn and await

An async fn declares its context — cx: RunContext — as the first parameter. Calling it runs nothing: it returns a cold future. await is the one in-body suspension point, and it consumes the future:

async fn countdown(cx: RunContext, log: Logger, n: u32) {
    let mut i = n;
    while (i > 0) {
        log.info(f"t-{i}");
        await sleep(500);       // park here; the loop resumes the frame
        i -= 1;
    }
    log.info("lift-off");
}

The cx is engine-minted at call sites the way self is — you never pass it when calling an async fn:

launch_future(countdown(log, 3));   // no cx — the engine supplies it

Two consume paths, and exactly one per future:

  • await fut — drive it from this async fn’s body. Legal only inside an async fn, and only for engine-woven futures (async-fn results and sleep).
  • launch_future(fut) — hand it to the driving loop and keep a receipt. Returns a LaunchedFutureHandle<T>, which is not a future: it cannot be awaited or re-launched. Its abort() flags cancellation; the frame notices at its next checkpoint and runs its cleanup.

sleep(ms) is the one built-in pender — a Future<nil> that parks its frame until the VM’s clock reaches the deadline.

Both launch_future and sleep come from the async_host package:

use async_host::{ launch_future, sleep };

Who drives?

You never write a driving loop — the embedder does. The rut run CLI is an embedder: after main returns it pumps the loop — drain ready frames, advance the virtual clock to the next timer deadline, repeat — until nothing is pending. So a program launches work from main and the output simply appears:

rut run countdown.rut
t-3
t-2
t-1
lift-off

Calling an async function and awaiting it in the same body is the straight-line case; launching is for concurrency — independent work that overlaps through the loop’s interleaving.

Talking to the network: the http package

The standard http package is async-only where work actually waits. Everything else — the client, the verbs, the builder — is sync construction sugar:

use http::{ HttpClient };

let client = HttpClient.new();
let resp = await client.get(url).build().send(cx);

The shape, step by step:

  • client.get(url) (or request(), post, put, patch, del) hands back a RequestBuilder; .method(..), .url(..), .header(k, v) (repeatable), .body(bytes) chain on it.
  • .build() freezes a re-sendable Request.
  • .send(cx) is the async point. It resolves at headers — the wire body is still unread.
  • From the Response, take one of two readbacks:
    • await resp.body(cx) — drain the remaining body in one future;
    • resp.byte_stream() then await stream.next(cx) — one chunk per future, nil at end-of-stream, for bounded-memory downloads.

Errors come back as data, not traps: status 0 is reserved for transport failure (resp.transport_error() carries the reason), any real status — 4xx and 5xx included — is a normal response (resp.ok() is the 2xx test), and a mid-read wire death surfaces as nil from next() with the reason in stream.error().

A complete worker from the repository’s GitHub-viewer example — send, then drain:

async fn do_list(cx: RunContext, client: HttpClient, owner: str, repo: str, rf: str) -> i32 {
    let url = f"https://data.jsdelivr.com/v1/packages/gh/{owner}/{repo}@{rf}";
    let resp = await client.get(url).build().send(cx);
    if (resp.status() == 0) {
        let why: str = resp.transport_error();
        eprint(f"rgh: network error: {why}");
        return 1;
    }
    if (!resp.ok()) {
        eprint(f"rgh: CDN {resp.status()}");
        return 1;
    }
    let (tree, e) = decodeJsonBytes<Root>(await resp.body(cx));
    if (e != nil) {
        let why: DecodeJsonError = e;
        eprint(f"rgh: bad tree JSON at {why.at}");
        return 1;
    }
    // ... print the tree ...
    return 0;
}

The stream lane walks chunks — send, then loop:

let resp = await client.get(url).build().send(cx);
let stream = resp.byte_stream();
while (true) {
    let c = await stream.next(cx);
    if (c == nil) {
        let rerr = stream.error();
        if (rerr != nil) { return 1; }   // failed mid-read
        break;                            // clean end of stream
    }
    let chunk: bytes = c;
    total += chunk.len();
}

await select, join, and structured scopes

Racing futures (await select { fut1 -> .., fut2 x -> .. }) and joining a launched future’s value (await handle) are spelled in the grammar but not in this build — the compiler gates them. Cancellation is here: handle.abort() flags the frame, and the probe at its next checkpoint unwinds it deterministically, running cleanup in reverse declaration order. See tasks for the roadmap.

Workers and channels

For true parallelism, a host can spawn workers — separate VMs on separate threads with separate heaps. There is no shared memory: all data crosses typed channels, and what may cross is a checked rule — primitives and str copy; a cell (vec, record, class instance) transfers when exclusively held, else deep-copies; endpoints (Sender/Receiver) transfer; closures do not cross. A trap in a worker kills only that worker. The API is a host facility (spawn_worker, Channel<T>), so its shape depends on your embedder — see workers and channels.

Put it together

use async_host::{ launch_future, sleep };
use ink::{ Logger };

async fn countdown(cx: RunContext, log: Logger, n: u32) {
    let mut i = n;
    while (i > 0) {
        log.info(f"t-{i}");
        await sleep(500);
        i -= 1;
    }
    log.info("lift-off");
}

pub fn main() {
    let log = Logger.new("countdown");
    launch_future(countdown(log, 3));
}
t-3
t-2
t-1
lift-off

Next: the standard library.

The standard library

The standard library splits in two. core is the only true standard — a prelude of builtin names that are ambient: in scope in every compilation unit, no use needed. Everything else is a set of swappable packages shipped in the toolchain tree — a program that wants one says so (a use line; the CLI mounts the tree packages for loose files automatically, and a package program lists them in its manifest). Any of them can be replaced wholesale; the engine knows none of their names.

The reference page is core and the swappable packages.

What’s always there (core)

Reporting and bugs

panic("Rect: negative extents");       // abort with a message
assert(total == expected, "checksum"); // abort when false (message optional)

Integer safety ladder

Every integer width carries compiler-lowered methods. Plain +/-/* trap on overflow; these never do:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("ladder");
    let mut v: u8 = 250;
    v = v.wrapping_add(10);            // 4 — two's-complement wrap
    let over = 200u8.checked_add(100); // (44, false) — .1 false = escaped
    let sat = 200u8.saturating_add(100); // 255 — clamp at the bounds
    log.info(f"v={v} over=({over.0}, {over.1}) sat={sat}");
}
v=4 over=(44, false) sat=255

checked_* answers (T, bool); .0 holds the wrapped bits either way. Pick per call site: a checksum wraps, a length checks.

str members

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("str");
    let s = "héllo rut";
    log.info(f"len={s.len()} first={s.code()}");
    log.info(f"at1={s.code_at(1)} octets={s.encode().len()}");
    log.info(f"view={s.slice(6, 9)}");
    let head: str = "héllo";
    log.info(f"starts={s.starts_with(0, head)}");
    let joined = string_join(["rut", "runs"]);
    log.info(f"joined={joined}");
    let h = str.from_code(72);
    log.info(f"from_code={h}");
}
len=9 first=104
at1=233 octets=10
view=rut
starts=true
joined=rutruns
from_code=H

s.slice deserves a second look: no octets move; the view records a window over the parent, prints, compares by content, iterates, and can re-slice. Codepoint access is spelled with integers — str.from_code(n) builds the 1-codepoint str for a u32. There is no split primitive; tokenizing rides s.scan(from, set) over a caller-owned [u8] class table. See string slicing and views.

bytes members

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("bytes");
    let b = "rut runs".encode();
    log.info(f"len={b.len()} decode={b.decode()}");
    let copy = b.clone();     // the ONLY copy escape hatch
    let z = bytes.zeroed(4);
    let from = bytes.from([1, 2, 3]);
    log.info(f"same={copy == b} zeroed={z.len()} from={from.len()}");
}
len=8 decode=rut runs
same=true zeroed=4 from=3

Erasure and cleanup

opaque(v) seals any value for recovery with opaque.downcast<T>(o) -> ?T — see errors and optionality. on_drop(p, cleanup) runs a callback when a cell’s refcount reaches zero, and Weak(v) holds a non-keeping reference (upgrade() -> ?T, nil once the referent died) — the memory stories live in the Rc heap and destructors and weak references.

StrBuf — the raw growable builder

Prefer the package face below; the engine cell under it is StrBuf(cap) with push/push_code/len/finish.

One gated name

NAN is core’s single constant, deliberately name-explicit: use core::{ NAN };. The float constants live in calc.

pouch — the growable sequence

use pouch::{ Vec };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("pouch");
    let mut xs: Vec<i32> = Vec.new();         // or Vec.with_capacity(64)
    xs.push(10);                              // amortized O(1)
    let v = Vec<f32>.filled(0.0, 1024);       // n slots of one value
    let w = Vec<i32>.from([1, 2, 3]);         // from a fixed array (copies)
    let x = xs[0];                            // indexing
    xs[0] = 42;                               // needs a `mut` binding
    let last = xs.pop();                      // removes + returns; traps if empty
    let n = xs.len();
    let mut total = 0;
    for (let e of w) {                        // iteration
        total += e;
    }
    log.info(f"x={x} last={last} len-after-pop={n} filled={v.len()} total={total}");
}
x=10 last=42 len-after-pop=0 filled=1024 total=6

Vec<u8> is the mutable byte builder: push bytes, then freeze() into the immutable bytes. xs.as_array() copies the live elements into a fixed [T]. A slice(from, to) window is compiler-lowered — an O(1) view that writes through to the parent vector.

nmapset — keyed collections

use nmapset::{ HashMap, HashSet };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("nmapset");
    let mut counts: HashMap<str, i32> = HashMap.new();
    let fresh = counts.put("rut", 1);          // answers true when the key was NEWLY added
    let mut n = 0;
    let hit = counts.get("rut");   // ?i32 — nil means absent
    if (hit != nil) { n = hit + 1; }
    let there = counts.has("rut");             // membership
    let gone = counts.remove("runs");         // answers whether it was there
    let size = counts.len();

    let mut seen: HashSet<str> = HashSet.new();
    let first = seen.put("x");     // true — newly added
    let again = seen.put("x");     // false
    log.info(f"fresh={fresh} n={n} has={there} removed={gone} len={size}");
    log.info(f"first={first} again={again}");
}
fresh=true n=2 has=true removed=false len=1
first=true again=false

Keys come from a fixed set — integers, bool, str, bytes (no floats: they have no stable equality contract) — hashed by the host; a user-defined key escapes by encoding canonically to bytes. A hit returns the stored cell, not a copy. There is no iteration surface: maps and sets answer questions, they don’t walk.

strbuild — the string builder

use strbuild::{ StringBuilder };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("strbuild");
    let n = 3;
    let mut b = StringBuilder.new();     // or StringBuilder.with_cap(1024)
    b.append("count: ");
    b.append_code(33);                   // one codepoint
    b.append(f" up to {n}");             // appends are amortized O(1)
    let s = b.build();                   // the ONE materialization; builder keeps its buffer
    log.info(s);
}
count: ! up to 3

Every out = f"{out}{chunk}" loop copies the whole prefix each time; the builder appends into one growable cell and copies once, at build().

calc — float math

use calc::{ Math };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("calc");
    let x: f64 = -2.0;
    let a: f64 = 3.0;
    let b: f64 = 7.0;
    let d = Math.sqrt(2.0);        // f64 host fns: sin, pow, atan2, fma, ...
    let f = Math.sqrt_f(2.0f32);   // f32 twins under a `_f` suffix
    let mx = Math.max(a, b);
    let sg = Math.signum(x);
    log.info(f"sqrt={d} sqrt_f={f} pi={Math.PI}");
    log.info(f"abs={Math.abs(x)} min={Math.min(a, b)} max={mx} signum={sg}");
}
sqrt=1.4142135623730951 sqrt_f=1.4142135 pi=3.141592653589793
abs=2 min=3 max=7 signum=-1

rut has no overloading, so the width lives in the name. The integer ladder is not here — those are core’s, always available.

json — encode and decode

use json::{ decodeJson, encodeJson };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("json");
    let (n, e) = decodeJson<i64>("42");            // (?T, ?E) — see errors
    let (s, ee) = encodeJson<[i64]>([1, 2, 3]);    // (?str, ?EncodeJsonError)
    if (e == nil && ee == nil) {
        let text = s;
        log.info(f"n={n} s={text}");
    }
}
n=42 s=[1,2,3]

Decode is direct and schema-driven: your type’s impl JsonDeserialize reads exactly the fields it expects, no intermediate tree. Your types opt in with two small impls; the container impls (Vec<T>, the map/set family) mount automatically when those packages are in your program. Error values are a kind enum plus a struct with .at/.got/.expected — see errors and optionality.

ink — logging

There is no console, no print — all output goes through a logger:

use ink::{ Logger };

pub fn main() {
    let n = 3;
    let log = Logger.new("app");
    log.info(f"started with {n} items");
    log.debug("..."); log.warn("..."); log.error("...");
}
started with 3 items
...
...
...

Logger is a plain rut class over a host-provided handle; the embedder chooses the sink (the rut CLI prints to stdout). The same pattern — declare a host surface in a .d.rut, wrap it in a rut class — is how any embedder package reaches rut code; see embedding and native modules.

http

The async HTTP client lives with the concurrency chapter — builder construction, send(cx) resolving at headers, body drains and byte streams — in async: tasks, workers, and channels.

Put it together

use pouch::{ Vec };
use strbuild::{ StringBuilder };
use calc::{ Math };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("std");

    // Vec: build, pop, read
    let mut v: Vec<i32> = Vec.new();
    v.push(10);
    v.push(20);
    v.push(30);
    let last = v.pop();
    log.info(f"len={v.len()} last={last} first={v[0]}");

    // fixed arrays + string_join from core
    let parts: Vec<str> = Vec.from(["rut", "runs"]);
    log.info(f"joined={string_join(parts.as_array())}");

    // StringBuilder: amortized appends, one materialization
    let mut b = StringBuilder.new();
    b.append("count: ");
    b.append_code(33);
    b.append(f" up to {v.len()}");
    let s = b.build();
    log.info(s);

    // calc's Math namespace (f64) and its f32 twins (_f)
    let d = Math.sqrt(2.0);
    let f = Math.sqrt_f(2.0);
    log.info(f"sqrt2 f64~{d} f32~{f} pi={Math.PI}");

    // assert: the builtin bug-catcher
    assert(v.len() == 2, "vec should hold two");
    log.info("asserted");
}
len=2 last=30 first=10
joined=rutruns
count: ! up to 2
sqrt2 f64~1.4142135623730951 f32~1.4142135 pi=3.141592653589793
asserted

That completes the tutorial. From here, the core concepts explain why the language is shaped the way it is, and the reference pins down every rule.

Design goals

rut is a small, statically typed, embeddable scripting language with Rust-flavored syntax, implemented in Rust. It is designed to be the scripting layer of host applications — UI apps in particular — in the role a JavaScript engine usually plays: a safe, hot-reloadable language that drives the host’s object graph.

The difference is structural, not cosmetic. In an embedded JS engine the host’s types are erased (Element, Store are empty interfaces; the real types exist only in .d.ts files and get “recovered” by convention), cancellation is emulated on top of promises, and every value crossing the bridge is re-parsed and re-coerced by hand. rut is built so those three problems cannot arise: types are kept at runtime, coroutines are natively poll-based, and host values have deterministic lifetimes.

The pillars

Fully static, reified types. There is no dynamic typing, no any, no gradual typing. Every value’s exact type is known to the compiler and carried at runtime — type tests, checked erasure, and host-boundary checks all read the same runtime truth. See reified types and layouts.

Types make code faster. The bytecode is typed, generics monomorphize, and primitive buffers stay flat: a fixed array of f32 is a packed f32 buffer, not an array of boxed numbers. There is no JIT and none is needed — the code shape a JS JIT exists to speculate on (polymorphic property loads) does not exist in rut.

Everything is a value — with two honest regimes. Primitives move by value; every composite (str, bytes, records, arrays, trait objects, erasure boxes) is a reference-counted cell whose handle copies in O(1). Sharing is the default and visible; there is no hidden copying. See everything is a value.

Poll-based coroutines. async/await compiles to cold state machines with Rust-style poll semantics: a future that nobody drives costs nothing, and cancellation drops the state machine at its suspension point. No promises, no microtask queue. See the async model.

Reference counting; no collector. Objects die the moment their last reference goes away, so destructors are deterministic — host resources (threads, sockets, textures) release at a knowable point, not “sometime at GC”. Strong cycles leak by design; weak references are the first-class answer. See memory.

No JIT. All optimization happens ahead of time: fold, inline, monomorphize, emit typed bytecode. What you run is what you compiled — predictable startup, predictable per-op cost, no tiering.

Embeddable by contract. The host registers native functions whose Rust signatures are the declared surface; every crossing is type-checked against reified types, and the host owns the event loop, the fuel budget, and the clock. See the host boundary and the bytecode VM.

Why not JavaScript

The case against shipping a JS engine — not just this or that implementation — is fourfold, and each item has a structural answer:

  1. History debt compounds. Two nullish values, coercing ==, typeof null, implicit semicolons: each is a permanent liability the ecosystem re-implements forever. rut starts from a closed, small grammar — absence is nil on a nullable type, errors are values, and legacy JS keywords are reserved words whose error message tells you what to write instead. Nothing legacy can accrete.

  2. Conformance means implementing the world. A compliant engine needs BigInt, Intl, Date’s quirks, microtask ordering — thousands of person-hours before user code runs. rut’s built-in contract is deliberately tiny: primitives, sequences, channels, string building. Everything else is library — maps and sets, JSON, logging, and the host’s own domain are ordinary packages, several of them written in rut and swappable.

  3. Too slow without a JIT. Interpreter-only JS runs one to two orders of magnitude slower, because a JS engine’s speed is its JIT. rut’s no-JIT stance is viable only because the language is statically typed: calls bind statically whenever the call site names one concrete type, and dynamic dispatch is a checked vtable hop that happens only where the program actually erases types.

  4. Too much memory. JS values are boxes — per-object headers, tagged pointers, boxed array elements, collector overhead. rut’s answer is layout: untagged 8-byte slots, record payloads as slot arrays, flat primitive buffers, and reference counting with no collector pass on the hot path.

What “no dynamic typing” leaves you

Erasure is never silent — it is spelled and checked:

  • Trait-typed values (d: Drawable) for polymorphism. Dispatch is dynamic only where a value may be one of several concrete types, and a trait-typed value still carries its exact class at runtime.
  • The opaque box (opaque(v), opaque.downcast<T>(o)) for storage. Erasure mints a checked box; recovery checks the runtime type and yields nil on a mismatch — never a silent wrong-type read.

JSON-shaped data, heterogeneous collections, and any-shaped host APIs all route through these two doors, which is what keeps “every value has a runtime type” true without giving up dynamism where it earns its keep.

The execution model

source ──► lexer/parser ──► typecheck ──► IR ──► typed bytecode ──► VM
                                   (fold, inline, monomorphize)
  • One thread per VM. A Vm instance runs on one thread with one heap and no atomics; workers are separate VMs communicating by typed message passing.
  • The host owns time. The VM exposes stepping verbs — drain the ready queue, expire timers, poll with a deadline — plus op budgets and interrupt callbacks. A UI host drives script between frames and stays responsive; a test host virtualizes the clock and gets fully deterministic schedules.
  • Traps stop at the boundary. A panic in script (overflow, nil deref, a failed assert) unwinds as a Trap value to the host call — rut code never catches one. Bugs become host-visible errors with a script backtrace, not corrupted state.

What this buys you

If you are writing scripts: assignments never deep-copy; cleanup runs when the last reference dies; a miss is nil, a failure is a returned err string, and a crash is a loud trap with a real stack trace. If you are embedding: you declare your API once in a rut declaration file, bind it with typed Rust closures, and the compiler plus the load-time verifier reject every mismatched shape before any script runs — there is no bridge boilerplate to maintain.

The rest of this section walks each pillar in depth; the tutorial shows the day-to-day syntax, and the reference pins down exact rules.

Everything is a value

rut’s type system has one spine: every name binds a value, and there are exactly two ways a value can exist. Primitives and fn values are immediate — moved by copying their bits. Every other type is a cell — a heap object whose handle is what gets copied. That single split explains assignment, parameter passing, equality, and what bytes.clone() is for.

The two regimes

immediatecells
typesu8..u64, i8..i64, f32, f64, bool, fn valuesstr, bytes, struct and class records, [T] arrays, enums, trait objects, opaque boxes, ?T boxes, closures’ captured cells
assignmentcopies the bitscopies the handle (O(1))
mutationn/a — write the variablevisible through every alias
==by valuestr/bytes: by content; everything else: identity

There is no third regime. No type is “sometimes copied, sometimes shared”: a struct value never silently deep-copies, a str never silently aliases. The regime is a property of the type, known at compile time, and the compiler emits the right move for it.

Sharing is the law for cells

let b = a on any cell type is one handle move: the new binding retains the cell, the old binding releases it. Passing a million-element array to a function retains once. A loop binding iterates the stored elements, not copies of them.

use ink::{ Logger };

struct Point { x: f32; y: f32 }

pub fn main() {
    let log = Logger.new("values");
    let mut p = Point { x: 1, y: 2 };
    let q = p;          // q and p name ONE cell
    p.x = 4;            // q.x is 4 now — sharing is the law
    log.info(f"q.x = {q.x}");   // read through the other alias
}
q.x = 4

Mutation through an alias is visible through all of them, in both directions. What gates writing is the mut-binding rule — a binding must be declared mut to be written through — never the sharing itself. Sharing is always safe: a handle keeps its referent alive, so nothing dangles, and there is no borrow checker because there are no loans.

If you want a value that aliases nothing, you build one: a literal, a constructor, a copy of the fields you need. The language has exactly one copy escape hatch — bytes.clone(), a one-shot deep copy of a binary buffer, for interop hand-offs. There is no generic clone: a divergent value of any other type is unreachable by construction, which is what makes aliasing something you can reason about locally.

?T — the nullable

Absence is nil on a nullable type, spelled as a prefix on the type: ?T is “a T or nil”. ? applies to the type term that follows, so [?T] is an array of nullables while ?[T] is a nullable array, and ??T chains (and unwraps transitively at use).

The coercions are one line: T → ?T boxes; ?T → T derefs. The box is a fresh one-slot cell that aliases the value’s cell — boxing a record shares it, boxing a primitive copies its bits. The deref is implicit at every value position: field access, method calls, indexing, iteration, arithmetic all read through the box.

use ink::{ Logger };

struct User { id: i64; name: str }

fn lookup(id: i64) -> ?User {
    if (id == 7) { return User { id: 7, name: "ada" }; }
    return nil;             // a miss is nil, nothing else
}

pub fn main() {
    let log = Logger.new("values");
    let u = lookup(7);                // ?User
    log.info(f"u.name = {u.name}");   // auto-deref when non-nil
    let missing = lookup(8);
    log.info(f"miss is nil: {missing == nil}");   // guard where absence is expected
}
u.name = ada
miss is nil: true

A nil reaching a value use traps (NilDeref) — never a silent read. Guard with u == nil where absence is expected; the trap is the bug-catcher, not the semantics. A miss is nil and nothing else: a lookup that finds nothing returns ?V, and callers compare against nil.

== is honest about the regime

  • Primitives compare by value.
  • str compares by content (codepoints); bytes by octets.
  • Everything else compares by identity — two bindings are equal exactly when they name the same cell.

Identity is the only equality full sharing can defend: two separately built [1, 2] arrays are two cells, so [1,2] == [1,2] is false. Content comparison is a loop over fields, written where content is the contract. The one place identity quietly behaves like value equality: enum variants are immortal singletons, so Flavor.Sour == Flavor.Sour is true.

The answer channel: a pair

There is no exception type and no Result monad. Failures are data in the second element of a record — (value, err) — with one documented convention:

  • an empty err plus a value is success;
  • an empty err plus nil means “not found”;
  • a non-empty err means “failed”.
use ink::{ Logger };

fn parse_hex(s: str) -> (bytes, str) {
    if (s.len() % 2 != 0) {
        return (bytes.zeroed(0), "hex: odd-length input");
    }
    let n = s.len() / 2;
    let mut buf: [u8] = [0u8; n];
    for (let i = 0; i < n; i += 1) {
        let hi = hex_digit(s.code_at(i * 2));
        let lo = hex_digit(s.code_at(i * 2 + 1));
        if (hi < 0 || lo < 0) {
            return (bytes.zeroed(0), "hex: not a digit");
        }
        buf[i] = ((hi * 16) + lo) as u8;
    }
    return (bytes.from(buf), "");       // (data, "") on success
}

fn hex_digit(c: u32) -> i64 {
    if (c >= 48 && c <= 57) { return (c - 48) as i64; }   // '0'..'9'
    if (c >= 97 && c <= 102) { return (c - 87) as i64; }  // 'a'..'f'
    return -1;
}

pub fn main() {
    let log = Logger.new("values");
    let (raw, err) = parse_hex("4869");
    if (err != "") { log.info(f"err: {err}"); return; }   // propagate
    log.info(f"ok: {raw.decode()} ({raw.len()} bytes)");
    let (raw, err) = parse_hex("486");
    if (err != "") { log.info(f"err: {err}"); }           // the failure channel
}
ok: Hi (2 bytes)
err: hex: odd-length input

The pair destructure in return position is free — the compiler fuses the mint away — so this shape costs nothing on hot paths. At the host boundary the same pair crosses field by field, which makes entry fn -> (?T, err) the standard answer a Rust embedder reads.

Consequences you feel day to day

  • Reassignment is cheap. Moving containers around — returning them, storing them, restructuring them — is handle traffic, not payload traffic. Algorithms that churn records and arrays pay aliasing, not copying.
  • Loop variables are fresh per iteration but share elements. A for (let x of xs) loop hands you the stored element; writes through it mutate the sequence. Each iteration is a fresh binding.
  • Containers of primitives stay flat. A fixed [i32] is a packed i32 buffer; growable sequences of primitives store raw payloads with a one-byte nil tag — no per-element heap box.
  • Erasure is a box. opaque(v) mints an erasure box; recovery is opaque.downcast<T>(o), checked against the runtime type. See reified types and the reference on opaque.

The memory machinery under all of this — refcounts, deterministic destructors, weak references — is the next chapter’s subject: memory. The exact rules live in the reference on by-reference and nullable.

Reified types and layouts

Types in rut are not a compile-time fiction. Every value’s exact type is available at runtime — generics included: a Cache<i32> and a Cache<str> are different types to the VM, with distinct identities and distinct method sets. This chapter explains where that reified reality lives, what it is used for, and how values are actually laid out.

The mental model

Think of the VM as keeping a type table: one descriptor per instantiated type — its kind, its field list, its trait impls, and for composites the method tables its instances point at. Every heap cell’s header names an entry in that table. A value never “forgets” its type, because its type is one field read away.

Types themselves are not first-class script values — you cannot put a type in a variable or write a function over types. What you get is the language-facing surfaces of reification:

  • is type tests — x is Circle (exact) and x is Drawable (capability: does this value’s type have a registered impl?).
  • Checked erasure — opaque.downcast<T>(o) reads the box’s runtime type and recovers the payload, or nil on a mismatch.
  • Host-boundary checks — native functions declare parameter types once; every call is checked against them.
  • Debugging and traces — error messages, backtraces, and formatter output name real runtime types.

Why reification is load-bearing

The reason rut can check everything cheaply is that the runtime type is always reachable:

  1. The host boundary needs no coercion code. A native fn declares (Opaque, str, str) -> nil once; the VM checks each argument’s runtime type at the crossing. There is no bridge-side “re-parse-and-pray” layer, because a wrong-shaped call fails loudly at the boundary.
  2. Erasure is checked, not blind. opaque(v) stamps the box; opaque.downcast<T> consults the stamp. A box answers type tests by the box — o is T misses for every payload type — so the only way back to the payload is the checked recovery. Erasure without reification would be any; with reification it is a sealed box.
  3. Distinct instantiations. Vec<f32> and Vec<f64> are different types everywhere — in the type table, in method resolution, at the host boundary. Nothing about a generic’s type argument is erased.
  4. Serialization and tooling. Reflection walks the same descriptors, which is how userland JSON encoding is complete without annotations or macros on your types.

Slots: the untagged hot path

Inside the VM, registers and record fields are untagged 8-byte slots. The bytecode is typed — every register’s static type is recorded in the function’s signature and re-verified at load — so hot paths carry no type tags at all:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
pub union Slot {
    pub i: i64,            // ints, bool: the canonical scalar width
    pub f: f64,            // floats: f32 widened
    pub r: *mut CellVal,   // every non-primitive: a cell handle
}
}

A primitive is its bits; a composite is a handle to a cell. The tagged world — a value that carries its type with it — exists only at the host boundary, where Rust code that cannot trust static types receives checked, typed values.

Record layout: one slot per field

Both struct and class values live in a cell: a header (refcount + type id), a pointer to the type’s vtable when the type has methods, and the payload — a slot array, one untagged slot per field, in declaration order.

  • Primitive fields are widened into their slot; composite fields are cell handles.
  • Visibility, generic parameters, and impl blocks elsewhere add nothing — the payload depends only on the field list.
  • Field access is by index; there are no hidden members and no inheritance, so there is no base-class prefix and nothing to walk.
  • Fixed arrays of primitives ([f32]) keep their elements flat and packed at machine width — the one place values live inline. Growable sequences are rut-library classes over such arrays.

Consequences:

  • size_of/align_of do not exist, deliberately. Layout is an implementation detail, not an API — rut has no C-ABI struct surface for hosts to mirror. Hosts see records only through the checked boundary (see the host boundary).
  • A record’s memory cost is its field count times 8 bytes plus the cell header, which is exactly what the heap budget charges.
  • Widening a value to a trait type allocates nothing: the trait-typed value is the same cell, reinterpreted through its vtable.

Vtables: one per type, attached at construction

A cell’s vtable is filled when the value is constructed and never changes. It names the exact type and holds one entry per trait method the type implements, keyed by a global (trait instantiation, method) id — Slice<Point> and Slice<str> have separate slot sets, because they are separate trait instantiations.

A call through a trait object is two loads and an indirect jump:

d.draw(g)          ; d: Drawable, draw has global slot 3
  obj  <- d.cell
  code <- obj.vtable.slots[3]
  call code(d, g)

An inherent method call — c.area() on a concrete c — compiles to a direct call with no table involved. The dispatch rules that choose between the two are the subject of the next chapter, traits and dispatch.

Type tests, lowered

Every type test is a small, pure read:

  • Concrete test (d is Circle): load the value’s runtime type id, compare. When the receiver’s static type already answers, the compiler folds it to a constant.
  • Trait probe (x is Drawable): load the type id, scan the descriptor’s registered impl list. Pure in its inputs, so repeated probes deduplicate and invariant ones hoist.
  • Downcast (opaque.downcast<T>(o)): the same type-id compare, followed by the guarded payload extract.

There is no type_of(x) returning a manipulable value and no runtime layout introspection — the descriptors serve the VM, the checks, and tooling, not userland metaprogramming. Reflection over data (walking fields to serialize) is a library facility built on the same tables; see the reference on reflection.

What this buys you

  • Type errors are runtime facts, not conventions: a host call, a downcast, or a trait probe is checked against the same table the VM dispatches through.
  • One runtime truth per value — the vtable that answers dispatch is the same one that answers is.
  • The tag-free hot path keeps the interpreter’s arithmetic and field traffic at raw slot speed while reification costs only what the program’s dynamic features actually use.

Traits and dispatch

A trait in rut is a named contract: a list of method signatures that a type can implement — by name, in an impl block. Satisfaction is nominal: the impl block is the admission, and nothing else. A type whose members happen to match a trait’s shapes is not an implementor; some module must say impl I for T.

The shape of a trait

use ink::{ Logger };

trait Shape {
    fn area(self) -> f64;
    fn scale(v: f64);
}

struct Point { x: f32; y: f32 }

impl Shape for Point {
    fn area(self) -> f64 { return self.x as f64; }
    fn scale(v: f64) { }    // the impl must list every trait method
}

pub fn main() {
    let log = Logger.new("traits");
    let p = Point { x: 3, y: 4 };
    log.info(f"area = {p.area()}");     // a concrete receiver: static
}
area = 3
  • Methods only, no bodies. A trait declares signatures — no fields, no properties, and no default implementations, ever. One member kind means one dispatch candidate per call; a default body would be a second candidate with a resolution law of its own.
  • Two impl forms. impl T { .. } defines the type’s inherent methods and may live only in the module that declares T. impl I for T { .. } defines a trait implementation; its methods are exactly the trait’s, with no pub (they are as visible as the trait).
  • One impl per pair, program-wide. Two modules implementing the same trait for the same type is a link error that names both. The impl registry — merged when modules link — is the single source of truth for “who implements what”.
  • Placement is pair-local. A trait impl may live in the trait’s package or the type’s package — at least one side of every (trait, type) pair must be yours. Implementing two foreign types’ pairing is rejected outright; there is no orphan rule beyond that.
  • Any nominal type can be a target — classes, structs, and even primitives (impl MyTrait for i32 registers like any other impl). Traits may be generic (Wrap<T>, Iterator<E>); each instantiation has its own identity and its own method slots, and an impl may be parameterized by the target’s own type parameters — impl Encode<T> for Store<T> registers a template that serves every instantiation, unless a concrete impl shadows it.

Type bodies are fields only — a fn inside a struct or class body is a parse error. Every method, inherent or trait, lives in an impl block.

Trait-typed values

A trait name in type position is the bare name — there is no object-type keyword:

use ink::{ Logger };
use pouch::{ Vec };

trait Shape {
    fn area(self) -> f64;
}

struct Circle { r: f64 }
struct UnitSquare { side: f64 }

impl Shape for Circle {
    fn area(self) -> f64 { return 3.14159265358979 * self.r * self.r; }
}

impl Shape for UnitSquare {
    fn area(self) -> f64 { return self.side * self.side; }
}

struct Canvas { log: Logger }

impl Canvas {
    fn render(self, a: f64) { self.log.info(f"blitted a shape of area {a}"); }
}

fn blit(g: Canvas, s: Shape) { g.render(s.area()); }   // a parameter over any Shape

pub fn main() {
    let log = Logger.new("shapes");
    let g = Canvas { log: log };
    let mut mixed: Vec<Shape> = Vec.new();   // heterogeneous storage
    mixed.push(Circle { r: 1.0 });
    mixed.push(UnitSquare { side: 2.0 });
    for (let s of mixed) { blit(g, s); }
}
blitted a shape of area 3.14159265358979
blitted a shape of area 4

A trait-typed value is a reference to a real cell that still carries its exact class. Widening a concrete value to a trait it implements is implicit on assignment and argument passing, allocates nothing, and is gated by exactly one question: does the registry hold an impl I for T? There is no top type to widen to, and intersection types (A & B) are ruled out by design — when you need both contracts, declare a trait that spells both.

Dispatch: the two-rule law

Every method call compiles under one of exactly two rules, fixed at compile time as a property of the call site:

  1. Static — the call site names exactly one concrete type. The call binds directly to that type’s method, no table involved:
    • a concrete receiver (c.area() on c: Circle);
    • a trait-typed local whose single concrete origin the compiler tracked (let d: Shape = Point { .. }; d.area());
    • a trait-typed parameter — the function specializes per concrete argument at monomorphization, so a trait parameter is effectively an implicit generic bound;
    • inside monomorphized generics.
  2. Vtable — the receiver is trait-typed with multiple possible concrete origins:
    • elements of a heterogeneous container (Vec<Shape>);
    • trait-typed field and element loads (the cell may hold any implementor);
    • branch-merged bindings (if arms carrying different concretes into one trait-typed variable).

The origin analysis is conservative: any merge, indirection, or cross-function flow counts as multiple. Mis-analysis cannot produce wrong code — an uncertain origin costs one vtable hop, never a wrong static bind. And the same descriptor that answers vtable calls answers is probes: one runtime truth per value.

Type tests

expr is Type yields a bool and is total — it never traps. Two probes, one keyword:

  • Concrete RHS: is the value’s exact type T? (No inheritance — a single id compare.)
  • Trait RHS: does the value’s exact type have a registered impl? A capability probe, usable on any value (k is Hashable).

is has no flow-sensitive effects: a true probe does not narrow x. When the static type already answers, the probe folds at compile time. Trait-typed values cannot be downcast to a concrete type at all — if you need Circle-specific behavior behind a Drawable, put that behavior in the trait. The one recovery path for erased values is the explicit opaque box (see the reference on opaque).

Visibility: the use-both gate

Calling a trait method requires both names in scope: the type (by declaration or use) and the trait (use). Inherent methods need only the type. A call that matches a registered impl whose trait no use brings in is an error — “use I to call its methods on T” — so an added trait method can never silently change what someone else’s call site means.

The iteration protocol

A type is iterable when it implements the builtin Iterator<E> trait:

use ink::{ Logger };

class CountUp {
    n: i32;
}

impl Iterator<i32> for CountUp {
    fn __iterate(self, emit: fn(i32) -> bool) {
        for (let i = 1; i <= self.n; i += 1) {
            if (!emit(i)) { return; }
        }
    }
}

pub fn main() {
    let log = Logger.new("iter");
    for (let v of CountUp { n: 3 }) {
        log.info(f"tick {v}");
    }
    log.info("done");
}
tick 1
tick 2
tick 3
done

for (let v of it) { body } desugars to it.__iterate(emit) with a synthetic closure: the body runs, then emit returns true; break returns false. The loop variable is the closure’s parameter — a fresh binding per iteration by construction. The builtin sequences ([T], str, bytes, and the standard growable Vec) keep fused index loops instead; they never pay a per-element call.

Engine contracts are traits too

The async machinery is spelled as builtin traits — Future<T> and RunContext — which the engine names but does not close: a hand-written type can impl Future<nil> for MyFuture through the same registry as any other impl and be driven by the same loop. See the async model and the worked example in 04 — Custom async.

Equality is not a trait

== is builtin and cannot be opted into or out of: primitives compare by value, str/bytes by content, everything else by cell identity. Field-wise comparison is a loop you write, or the map packages’ own key rules. See everything is a value.

What this buys you

  • Nominal satisfaction keeps the registry exact: capability probes, widening, and vtable fills all read one table, so casts stay cheap and runtime identities stay meaningful.
  • The two-rule law makes dispatch predictable: you can tell, per call site, whether you are paying a hop.
  • Placement rules keep coherence decidable across packages without a global uniqueness proof — one of the pair is always local.

The async model

rut’s concurrency is pull-based, built on one noun: Future<T>. Calling an async fn runs nothing — it produces a cold future. The future runs when something drives it: an await inside another async fn, or a launch into the VM’s queue. There are no promises, no microtask queue, and no implicit scheduling: the host owns time, and nothing runs unless the driving loop runs it.

Pull, not push

push (promises)pull (rut futures)
createdstarts on the next microtaskcold — nothing runs until awaited or launched
idle costreactions and queues exist whether or not anyone waitsnobody drives ⇒ nobody pays
per-await allocationpromise + callback listsone hidden frame record per call
cancellationabort flags layered on topnative: the frame is dropped at a checkpoint
host integrationthe host must drain a job queuethe host polls: drain ready frames, ask for the next deadline

The surface

use async_host::{ launch_future, sleep };
use ink::{ Logger };

async fn countdown(cx: RunContext, log: Logger, n: u32) -> nil {
    for (let i = n; i > 0; i -= 1) {
        log.info(f"{i}");
        await sleep(1000);             // the only suspension spelling
    }
}

pub fn main() -> nil {
    let log = Logger.new("countdown");
    launch_future(countdown(log, 3));  // the other consume: launch
}
3
2
1

The rules:

  • The first parameter is the context. async fn f(cx: RunContext, ..) — the engine mints it at call sites and per drive, the way it mints self. It carries the frame edge: checkpoint() reads the resume state, cancelled() reads the task’s abort flag.
  • Calling does not run. async fn f(..) -> T describes a value that widens to Future<T>; it runs when awaited or launched.
  • One consume law. A future is consumed by await or by launch_future — exactly one. The launch returns its own receipt type, which is not a future and cannot be launched or awaited again.
  • await targets engine-woven futures (async-fn results and sleep). Hand-written futures are launcher-drivable — the driving loop finds their Future::yield in the vtable — which is how the standard sleep itself is built over the open trait surface.

What the compiler emits

An async fn compiles to one hidden frame — an ordinary heap cell — with a woven Future::yield that the driving loop calls once per resumption:

frame:  [0]=state      the checkpoint enum's member — the pc
        [1]=cancelled  the task's abort flag
        [2]=awaiter    the frame awaiting THIS one
        [3]=pending    the future THIS one parks on
        [4..]=locals   every binding, mirrored into the frame

entry:      dispatch on state
per await:  probe the parked future's state
              done    -> resume with its value
              pending -> link awaiter/pending edges, park, return
resume arm: cancelled-probe -> drop path | restore locals -> continue

The state field is the program counter: resume dispatch is a jump table over the checkpoint enum. Because locals are mirrored into the frame cell, parking releases only staging registers and no liveness analysis decides which locals survive a suspension. The whole weave rides existing bytecode — no new opcodes, no new runtime machinery.

Cancellation is drop

handle.abort() flags the frame’s cancellation and re-enqueues it. The probe at its next checkpoint branches to the drop path: locals release in reverse binding order, on_drop callbacks fire by refcount, and any pending sleep dies with the frame. Cancellation never interrupts mid-expression — the checkpoint probe is the only place a cancellation becomes observable, so a cancelled function dies at a known-clean boundary. This is the same machinery that frees any heap object (see memory); coroutine frames get no special case.

The driving loop

The VM owns the queues; the embedder owns the loop:

#![allow(unused)]
fn main() {
vm.call::<_, ()>("main", ())?;              // script boots, launches futures
loop {
    vm.run_ready()?;                        // one drive per ready frame
    if let Some(d) = vm.next_deadline() {   // expire due timers into ready
        sleep_until(d);                     // the wall-clock stand-in
    }
    if vm.pending_tasks() == 0 { break; }   // idle
}
}
  • sleep(ms) arms a deadline on the VM’s virtual clock (vm.now_ms() / vm.set_now()). A test host advances the clock by hand and gets fully deterministic schedules — a UI host just maps it to wall time.
  • Each queue entry owns one reference; a completed drive re-enqueues the awaiting frame through a one-directional edge pair, so wake wiring never forms a cycle.
  • Fuel accounting rides the ordinary per-op budget: a drive that runs out parks the frame at its checkpoint and resumes there later.

Host futures: pub host async fn

Native code joins the same model. A declaration file spells the row:

pub host async fn http_send(c: opaque, method: str, url: str,
                            headers: str, body: bytes) -> opaque;
pub host async fn http_stream_next(s: opaque) -> ?bytes;

and the embedder binds it with one closure that starts the work and returns the future’s state cell:

#![allow(unused)]
fn main() {
rut_vm::register_async!(hosts, "http_host::http_stream_next",
    (Opaque<HttpStream>,) -> Option<Vec<u8>>,
    move |s: Opaque<HttpStream>| -> Completer<Option<Vec<u8>>> {
        let c = Completer::new();
        let w = c.clone();
        std::thread::spawn(move || {
            w.complete(read_chunk(s));     // settle from any thread
        });
        c
    });
}

Completer<T> is a small shared cell: complete(v)/fail(msg) are callable from any thread (atomics plus a mutex — the VM stays single-threaded), fail’s message becomes the trap at the await, and an optional abort closure runs when the script cancels a pending future (late results are then simply discarded). The macro registers the whole row family the weave drives — start, probe, take, cancel — and the load-time check verifies the expansion against the declaration, so a decl and its bodies cannot drift apart. IO written with plain async/await Rust maps onto this naturally: the work leaves the VM, and completion is one enqueue back into the ready queue.

Structured concurrency, honestly

The language today keeps the vocabulary deliberately small: launch, abort, and await. Racing (await select { .. }) and joining a launched future (await handle) parse but are compile-gated — the diagnostics name them as future work, and structured scopes (a block that cancels its children on exit) are the same story. What exists now is already enough to structure real programs — the launch/abort receipt gives you explicit ownership of background work, and drop-based cancellation gives it a clean off switch — but nothing in the model silently cancels siblings on your behalf. See the reference on async and await, tasks, and the host futures bridge; the GitHub viewer CLI example shows a full program living inside this loop.

Memory: the Rc heap, weak refs, and cycles

rut’s memory management is reference counting with deterministic destructors and no collector. An object is destroyed the moment its last strong reference goes away — at a knowable statement, not “at some future GC”. The cost is honesty about cycles: a strong cycle never reaches zero, so it lives until the VM is torn down. rut makes that a stated rule and ships Weak<T> as the first-class answer, instead of hiding it behind a collector.

Everything is a cell

Every non-primitive value — strings, byte buffers, arrays, records, enums, trait objects, erasure boxes, nullable boxes, coroutine frames — is a heap cell. Each cell starts with a header: a refcount, a type id, and a few flags. The counts are plain integers: a VM runs on one thread with one heap, workers are separate VMs, and nothing is ever shared between them, so there are no atomics anywhere in the heap.

Because assignments and passes copy handles, not payloads, the refcount traffic is exactly the aliasing the program performs:

use ink::{ Logger };

struct Node { next: ?Node }

pub fn main() {
    let log = Logger.new("cells");
    let a: ?Node = Node { next: nil };
    let b = a;              // one retain
    log.info(f"a == b: {a == b}");   // one cell — it dies when the LAST of a, b goes away
}
a == b: true

The compiler knows every register’s static type, so it emits ref-aware moves only where references flow; scalar code pays nothing. Function boundaries do the paired inc/dec; a flat primitive buffer’s element traffic is plain slot moves.

Deterministic destruction

When a count reaches zero, destruction runs inline, in a fixed order:

  1. on_drop cleanups are pinned. on_drop<T>(p: ?T, cleanup: fn(?T)) attaches a cleanup to a nullable binding — and a ?T binding is the cell reference, so this is “run this when the object dies”. One callback per binding; a second attach is a compile error; the pinned callbacks drain at the next call boundary.
  2. Fields release in declaration order, recursively — a dying record’s strings, arrays, and boxes release their own references, so nothing is pinned until VM shutdown just because its owner died.
  3. Host opaques run their Rust Drop at the same point. A host handle registered by the embedder releases its socket, texture, or thread when the last script reference disappears — never “someday at GC” (see the host boundary).
use ink::{ Logger };

class Connection {
    url: str;
    log: Logger;
}

impl Connection {
    fn open(url: str, log: Logger) -> Connection {
        return Connection { url: url, log: log };
    }
    fn close(self) { self.log.info(f"closed {self.url}"); }
}

pub fn main() {
    let log = Logger.new("rc");
    let conn: ?Connection = Connection.open("tcp://edge", log);
    on_drop(conn, fn(c: ?Connection) { c.close(); });
    log.info("main is done — the count hits zero at the boundary");
}
main is done — the count hits zero at the boundary
closed tcp://edge

Compare this with finalizer-based designs: there is no finalizer that “may run later or never”, no flush phase, and no resource whose release time you cannot point at in your own code.

Two edge rules worth knowing:

  • Refcount overflow pins. If a counter would exceed its maximum, the object is deliberately immortalized (and logged) rather than wrapping — the same rule Swift uses.
  • Weak boxes null first. When a cell with weak watchers dies, every Weak box to it is nulled before any user code runs — a cleanup that calls upgrade() sees nil, deterministically.

Weak references

Weak(v) mints a Weak<T> over v’s cell that never keeps anything alive; w.upgrade() answers ?T — the retained referent, or nil once it died. Construction is a call of the type name, admission is checked (reference types only — Weak<i32> diagnoses), and weak(nil) traps.

use ink::{ Logger };

class Model {
    name: str;
}

class View {
    model: ?Model;
    observer: ?Weak<Model>;      // a back-pointer that closes no cycle
}

pub fn main() {
    let log = Logger.new("rc");
    let mut m = Model { name: "doc" };
    let v = View { model: nil, observer: Weak(m) };   // observe without owning
    log.info(f"holding {m.name}; the view holds only a weak edge");
    m = Model { name: "next" };   // the old cell's last strong reference dies here
    log.info(f"upgrade() answers nil: {v.observer.upgrade() == nil}");
}
holding doc; the view holds only a weak edge
upgrade() answers nil: true

The two shapes that cause accidental cycles — back-pointers (child → parent) and caches (a table that should not extend lifetimes) — are exactly what Weak is for. A path through a Weak does not close a strong cycle.

The cycle policy

A strong cycle is a leak — deterministically. Its members live until the VM is dropped; no collector ever runs, no pass interrupts execution, and destructor ordering never surprises you. Guidance is lint-level, not runtime:

  • resource-holding types (things with on_drop cleanups or host handles) must not participate in strong cycles;
  • parent/child and observer shapes take Weak back-pointers;
  • pure-data cycles are harmless — they cost only memory.

You are not left alone with the rule: debug builds assert refcount discipline (retain/release pairing, no underflow, no double free), and at teardown the VM emits a leak report — surviving objects grouped by type with allocation sites — which is the primary tool for hunting accidental cycles. Behavior is fully deterministic, so a leak reproduces exactly.

The self-managed heap

Host code may use std freely, but every byte backing a rut value is allocated through the VM’s own heap manager. The discipline is the point, not the allocator:

  • Cells come from fixed-size arena chunks with freelists; a dead slot is reused by the next mint.
  • Variable-size payloads (string octets, array element runs) live in size-classed blocks that never move; in-place geometric growth keeps append loops linear, and freelists recycle the common sizes.
  • Immortal singletons — interned string literals, enum variants — live in a slab freed only at VM drop, carrying the “no refcount” sentinel.
  • Nothing compacts. The heap never moves cells, which is precisely what lets the boundary hand Rust zero-copy views into buffers (see the host boundary).

Why self-manage instead of using the host’s allocator?

  1. Budgets. An embedder can say “this script gets N bytes” and get a clean, resumable out-of-memory trap instead of an OS abort. vm.heap_usage() is exact because every allocation is accounted.
  2. Teardown. Vm::drop frees the whole heap in slab units after the leak report; nothing per-object leaks past the VM — important for wasm pages and plugin hosts that spawn and kill scripts.
  3. Leak reporting walks the VM’s own headers, which requires the VM’s own headers everywhere.

The budget check runs before any write — a failed allocation leaves the heap byte-identical — and traps resumably: raise the limit and continue, or drop references and retry. See the reference on resource limits for the full knob set, and the bytecode VM for how budgets interleave with execution.

What this buys you

  • Deterministic resource release — the property the whole design is organized around. If your script opened something, the closing statement is knowable.
  • No pauses. There is no collection pass; destruction happens inline at release-to-zero, so nothing ever interrupts a frame.
  • A small, honest mental model. Sharing is by reference, copies are built explicitly (see everything is a value), lifetimes are refcounts, and the one thing refcounts cannot do — cycles — is a documented rule with a first-class tool.

The host boundary

rut exists to be embedded. The host — a Rust program — owns a VM, declares its API once in rut source, binds the implementations with typed Rust closures, and drives everything. The design rule for the whole boundary: the type system does the checking, not your bridge code. There is no argument re-parsing, no as number, no “trust-me” coercion — a value crossing either direction is checked against a reified runtime type, and a mismatch is a loud error naming both sides.

The embedding model

#![allow(unused)]
fn main() {
let mut session = Session::new();
mount_std_core(&mut session);
session.register_module("app:gfx", lower_decl_module(GFX_DECL, "gfx.d.rut")?);

let mut hosts = HostRegistry::new();
hosts.register::<_, (i32, i32), Opaque<Canvas>, _>(
    "app:gfx::newCanvas",
    |_vm, w: i32, h: i32| Ok(Canvas::new(w, h)),
);

// the join: every declared row must have a binding, and vice versa —
// verified BEFORE any script runs
hosts.verify_against(&session.expected_host_fns())?;

let mut vm = Vm::new(program, &limits, hooks, hosts)?;
vm.call::<_, ()>("main", ())?;      // an entry point
vm.run_ready()?;                    // drive async work to idle
}

The declaration file (a host package) spells the surface in rut:

pub host fn newCanvas(w: i32, h: i32) -> opaque;
pub host fn circle(h: opaque, x: f32, y: f32, r: f32) -> nil;

and the load-time join rejects three wiring bugs as panics — an embedder mistake, never a script diagnostic: a declared row with no binding, a binding with no declaration, and signature drift. “Mount what you bind”: an embedder that mounts a package declares its rows and must bind them.

The crossing set

What may cross is a compile-time property of the surface, not a runtime negotiation:

  • Parameters: the primitives, str, bytes, and opaque.
  • Returns: the same set plus the answer optionals ?str, ?bytes, ?opaque — Option<String>, Option<Vec<u8>>, and the opaque handles mint the nullable box, with None as the flat nil.
  • Tuples cross field by field, which makes the error convention — (value, err) — a first-class entry answer. The two channels stay distinct by law: a returned err is data the host reads and acts on; a panic is drift and arrives on the trap channel, never filling an err field.

Everything else — user structs and classes, Vecs, trait-typed values, closures — stays inside the VM. A declaration that violates the set is a compile error at the declaration, not a failed call at 2 a.m. There is no any: a polymorphic crossing seals its value in an erasure box (opaque(v) at the call, opaque.downcast<T> after), checked, never silent — see reified types.

The currency is typed Rust

On the Rust side the boundary speaks ordinary Rust types. Each type declares the rut type it binds against and converts checked:

  • Borrow, don’t copy. &str and &[u8] parameters read the VM’s buffers zero-copy. The borrow is call-scoped — the type system makes smuggling it past the return a compile error.
  • Copy on purpose. Owned String/Vec<u8> parameters are the explicit “I keep this data” choice.
  • Opaque<T> is the typed view of a host handle: an 8-byte store handle the host holds and passes back; the payload stays host-owned (Box<dyn Any> plus an optional finalize hook).

Borrow guards make the zero-copy views safe while script runs: a borrowed object is flagged, and script-side mutation through a re-entrant call traps with borrowed by host instead of racing. Guards clear on return. This is sound forever because the heap never moves objects (see memory).

Wrapping host state: the class-in-rut pattern

There is no “host class”. Native state crosses as an opaque handle and a rut class wraps it — ordinary source the compiler can see and optimize around:

class Canvas {
    h: opaque;
    fn circle(mut self, x: f32, y: f32, r: f32) -> nil {
        canvas_circle(self.h, x, y, r);
    }
    fn hits(self) -> i32 { return canvas_hits(self.h); }
}

Every method is exactly one host call — the same crossing a magic host class would have paid — but the wrapper can also carry impl blocks, validation, and convenience the native side never needed to know about. The payoff for keeping the boundary this small: host resources die deterministically. When the wrapper’s last reference goes, the handle’s refcount hits zero and the payload’s Rust Drop runs at that point — sockets and textures close on script schedule, not at some future collection.

Host functions are rows, dispatched by slot

Each declared row gets a stable slot id at compile time; calls compile to slot dispatch, so names are binding-time labels only — renaming a Rust-side binding target fails the join instead of silently rebinding. The surface grammar is small:

host fn name(params) -> T;        // concrete signature; no generics —
                                  // a generic has no shape to check
host struct Name { fields };      // flat record of crossing fields

builtin declarations (the engine’s own fns, classes, and traits — str methods, Weak, Future) are engine surface: the embedder cannot spell them, and users implement their traits with ordinary impl blocks. Async rows (pub host async fn) are declared like any row and expand into a small row family the driving loop uses — the embedder binds them with one closure per future; see the async model.

Re-entrancy, traps, budgets

  • A native fn receives a context that may call back into rut (vm.call) — nested calls run under the same budget on a fresh frame stack.
  • Native code runs outside the op budget: the host is trusted to be fast, or to hand back a future instead of blocking. A trap raised inside a native fn propagates to the host as an Err(Trap) with the native frame visible in the backtrace.
  • Traps are catchable only at the boundary — script never catches one. That is what keeps panics loud and state uncorrupted.

What was deliberately left out

No C ABI. There is no register_struct, no size_of, no pointer mirroring of record layouts: records are slot arrays internal to the VM, and a host sees them only through the checked crossing. The few bytes you give up to the checked boundary buy the property that makes embedding pleasant — every mismatch is a named, typed, load-time diagnostic instead of a runtime mystery.

The full details live in the reference: embedding and native modules, the value boundary and borrows, host fns and declaration files, and the withdrawn C-struct experiment recorded at repr(C) interop.

The bytecode VM

Everything a rut program becomes is a typed bytecode executed by a register VM. There is no JIT, no interpreter tiering, no runtime speculation: the compiler does all optimization ahead of time, and what runs is exactly what was compiled. This chapter is the tour of that artifact — the instruction set, the module binary, the load-time verification, and the interpreter’s execution and budgeting model.

One struct, one thread, one heap

#![allow(unused)]
fn main() {
pub struct Vm {
    types:  TypeTable,     // runtime type descriptors + vtables
    heap:   Heap,          // the refcounted, self-managed heap
    mods:   Vec<Module>,   // linked binaries
    frames: Vec<Frame>,    // the call stack
    ready:  Deque<Slot>,   // woken async frames
    timers: BTreeMap<u64, Vec<Slot>>,  // the virtual clock's wheel
    lim:    Limits,        // fuel, heap budget, interrupt interval
    host:   HostHooks,     // registered native bodies, clock, loader
}
}

A VM is single-threaded by construction (!Send); workers are separate VM instances communicating by messages. The host holds one and drives it with methods: call an entry point, run_ready to drain woken frames, next_deadline for the timer wheel, drive for a single resumption. See the host boundary for the embed loop and the async model for the queues’ role in scheduling.

Typed bytecode

The instruction set is register-based: operands are u16 indices into a per-frame slot file, and every register has a static type recorded in the function’s signature. That single decision is what lets the hot path stay tag-free (see reified types) and what makes refcounting precise: the compiler emits plain moves for scalars and retain/release moves for references, with no runtime tag checks.

Ops are small and fixed-size, with variadic operand lists (call arguments, branch tables) living in per-function pools. A representative sample:

mov / movref   rD, rS        ; move; the ref form retains new, releases old
i32add, f64mul, ...          ; typed arithmetic — the opcode selects the
                             ; kind, the width rides the op
icmp, fcmp     rD, a, b      ; compares -> bool
call / callm   f, args       ; direct call / direct method call
calli          slot, recv    ; trait vtable call (dynamic dispatch)
callnat        nat, args     ; internal natives (string concat, ...)
newcell / getf / setf        ; mint a record, read/write a field
arrget / arrset              ; indexing — bounds checked, trap on miss
tidof          rD, rO        ; read a value's runtime type id
brtable        rIdx, arms    ; enum matching, resume dispatch

The type system also decides what is not an op:

  • Nothing static. What the type table can answer at compile time becomes a constant — no “get type id” instruction, no length load for a fixed-size array.
  • Nothing polymorphic and nothing named. A trait-typed receiver has exactly one op (the vtable call); string concatenation is a native call, not an opcode.
  • Concrete memory, control flow, and two readbacks. Ops touch memory only through compile-time-known layouts, plus the two reads reification needs: “what is it” (tidof) and the guarded payload extract behind a downcast.

The module binary

Compilation produces a versioned, self-contained binary per module: types, constants, functions (with their typed register signatures), imports, and a symbol table for backtraces. Its properties matter more than its layout:

  • Deterministic. The same sources plus the same dependency versions produce byte-identical binaries, so artifacts are cacheable by content hash and shippable instead of sources.
  • Self-describing but strippable. Names and source spans are retained for traces by default; a release build can strip them, and a sidecar map file (keyed by content hash) can restore symbolication later. See the reference on diagnostics.
  • Link-time identity. Type ids are module-local at rest and rebased into the VM’s global table at link, where impl registries merge and duplicates are rejected. Cross-module trait calls specialize per concrete argument here too — the static-dispatch rule of traits and dispatch holds across module boundaries.
  • Little-endian everywhere, by law — every serialized word, so artifacts are platform-independent by construction rather than by luck.

Verification at load

Loading re-checks each function before anything executes: register operands against the signature, operand pools and jump targets in range, type operands present in the type table, native slots declared, and async state tables closed. A failed verification is a load error naming the module and function — corrupted binaries never execute. This is also where crossing rules are enforced for host-facing signatures, so a non-crossing shape is rejected before any script runs.

Module-level initialization is folded at compile time: literals, enum members, constant expressions, and interned string literals become constants in the binary. A user function call in a module initializer is a compile error — there is no load-time execution at all. Loading a module runs nothing.

The interpreter loop and traps

Execution is a decode-dispatch loop over frames. A frame is the function’s code, a program counter, its typed register file, and (for async frames) the resume state. Calls push frames; returns pop them; direct calls are an index plus jump, and trait calls are two loads plus an indirect jump through the vtable.

Failure is a trap, not an exception and not a Rust panic: integer overflow on the plain operators, a nil deref, an out-of-bounds index, a failed assert or panic(..), or an exhausted budget. A trap captures the raw frame chain (rendered lazily — symbolication happens only if someone prints it), runs destructors on the way out, and unwinds as an Err(Trap) to the host call. Rut code cannot catch one; the boundary is the catch point (see the host boundary).

Budgets and interrupts

Two knobs make it safe to run third-party code, both set at construction and mutable while running:

#![allow(unused)]
fn main() {
Limits { fuel: Option<u64>,            // one unit per op, counts down
         heap_limit_bytes: Option<u64>,// checked at every allocation
         interrupt_every: u32 }        // default: check every 1024 ops
}
  • Exhaustion is resumable: the trap parks the frame exactly where it was — the frame is the loop state — so the host can add fuel, raise the heap limit, or flip the interrupt flag and resume. A UI host yields mid-frame; a wasm page grants finite fuel; a test sets finite fuel plus a virtual clock and gets a deterministic hang proof.
  • The heap check runs before any write, so an out-of-memory failure leaves the heap byte-identical (see memory).
  • Native functions run outside the fuel budget — a hanging native is a host bug by contract — and the interrupt hook is the host’s lever at native return points.

Why no JIT is viable

A JS engine’s speed is its JIT, and the JIT exists to speculate about polymorphic property loads. rut has no polymorphic loads: field access is by known index, calls bind statically whenever the site names one concrete type, generics are monomorphized, and the remaining dynamic dispatch is an honest checked vtable hop. The optimizer’s currency is type stability — what the checker proves is everything the optimizer gets — and its passes (constant folding, inlining, monomorphization, register-level cleanup) all run ahead of time. The result is a runtime with no warmup curve, no deoptimization asymmetries, and per-op costs you can reason about from the bytecode alone.

The full tables and laws live in the reference: typed bytecode, the module binary and verification, VM core, and resource limits and fuel.

Examples index

The repo ships two kinds of example material. The examples/ directory holds six runnable Cargo projects (five console programs and one browser page) plus one parse-only corpus example. Alongside them, demo/src/examples/ holds the playground classics — short, self-contained programs the web playground runs in the browser, the same programs the repo’s gates compile and run in CI. Each project has its own page in this chapter; the classics share The playground corpus.

ProjectRunWhat it demonstrates
00 — Todolistcargo run -p todolistan entry fn surface over a rut class — the host drives CRUD through opaque handles
01 — Sortcargo run -p sortfive sorting algorithms behind one dispatcher entry; when on strings, the mut-binding law, fuel budgets
02 — Digestcargo run -p digestsbyte-level codecs and hashes (MD5/SHA/base64/CRC/FNV); the host as an independent test oracle
03 — Plugincargo run -p plugina module directory + .rutbundle chat-moderator plugin; re-entrant vm.call, both opaque directions
04 — Custom asyncparse-only — no runnable harnessa hand-written impl Future<nil> for CustomFuture plus a user launcher with per-checkpoint stats and cancellation audits
05 — Todolist webcargo test -p todolist-web + node tests/e2e-browser.mjsa full page app whose brain is a two-package rut project — ten DOM/timer crossings over web_sys on wasm32
06 — GitHub viewer CLIcargo run -p rgh -- --repo=… --ref=… listrgh — an async rut brain over the std http lane; headers-then-stream downloads, fixture-lane tests
The playground corpuscd demo && npm run smokethe classics: runnable programs, compiled and run by two gates

All Cargo commands run from the repository root. The runnable crates share one session pattern — compile the module, verify the binary, bind host fns, then drive it through typed vm.call sites — so the pages cross-link often: 00 — Todolist establishes the pattern and 06 — GitHub viewer CLI stretches it the farthest.

The three dependency kinds

Every package carries a rut.toml manifest (see Project structure and rut.toml), and a manifest relates a package to other packages through three tables, all visible in the examples:

  • [deps] — ordinary dependencies, transitively mounted. The common case: 03-plugin’s plugin/rut.toml declares server = { path = "../server" }, and 05 — Todolist web’s biz package declares ui (which itself pulls the collection packages).
  • [peer-deps] — required by default: the consumer supplies the peer and the peer is never pulled transitively. Marking a peer optional = true flips it to a presence relation: its integration file (impl-only code the peer makes compilable) mounts only when the peer is anywhere in the program’s closure. The in-tree example is the std json package’s serde-model impls — impl JsonSerialize for Vec<T> is written in json but must not force every json consumer to mount the collection packages, so those live as optional peers and as [dev-deps] for json’s own tests. 02 — Digest consumes json light; 06 — GitHub viewer CLI calls assemble_peers and mounts the impl groups for real because the collections are in its closure.
  • [dev-deps] — mounted only while building the package itself, never in a consumer’s world. json develops against the real collection packages through the both-kinds pairing while its consumers mount it without them.

The full rules — the loud missing-required-peer error, the silence of absent optional peers, dedup-by-origin at the splice, and what bundle format the packer emits — are on Dependency kinds.

Reading order

New to the language: read 00 — Todolist and 01 — Sort for the embedding shape, then The playground corpus for the language surface in small doses. For the web story, 05 — Todolist web is the centerpiece and 06 — GitHub viewer CLI is the networking counterpart. 03 — Plugin is the one to study for packaging and module loading, and 04 — Custom async for what the future trait looks like from user code.

00 — Todolist

The first runnable host example, and the template for the rest of the chapter: a Rust program embeds rut, compiles a rut library (there is no main in it), and drives the library through its entry fn surface. The split is two files — todolist.rut owns the data and the logic as a TodoList class with full CRUD; src/main.rs owns the session: compile, verify, then a scripted conversation of vm.calls. The one-sentence design rule: the host owns the session, rut owns the data.

Run it

cargo run -p todolist        # from the repo root
cargo test -p todolist       # the same session asserted as a test

The run prints an abridged transcript like this:

added: #1, #2, #3
set_done(#2, true)
title_of(#2) = Opt(Some(Str("implement the VM")))
title_of(42) = Opt(None)
remove(#1) = Res(Ok(Bool(true)))
remove(#1) again = Res(Err(Str("no todo #1")))
TodoList[2]
  #2 [x] implement the VM
  #3 [ ] ship the demo
second list #1: 0 todos
fuel used: 680 of Some(1000000)

Code tour

The data: a class with a fields-only body

examples/00-todolist/todolist.rut — the type body is fields only; methods live in an inherent impl block (Structs, Classes and constructors). Unannotated members are module-private. The block below is the class verbatim — new, add, and remove — closed with a main so it runs on its own; the example file itself has no main — it is a library the host drives through its entry fn surface:

use ink::{ Logger };
use pouch::{ Vec };

struct Todo {
    id: i32;
    title: str;
    done: bool = false;      // field initializer — `done` may be omitted
}

pub class TodoList {
    items: Vec<Todo>;         // unannotated members = module-private
    next_id: i32;
}

impl TodoList {
    pub fn new() -> Self {
        return Self { items: Vec.new(), next_id: 1 };
    }

    // ---- CREATE ----

    pub fn add(mut self, title: str) -> i32 {
        let id = self.next_id;
        self.items.push(Todo { id: id, title: title });
        self.next_id += 1;
        return id;
    }

    // ---- DELETE ----

    pub fn remove(mut self, id: i32) -> bool {
        let kept: Vec<Todo> = Vec.new();
        let mut removed = false;
        for (let t of self.items) {
            when (t.id == id) {
                true -> { removed = true; },   // drop the row
                else -> { kept.push(t); },     // keep everything else
            }
        }
        if (removed) {
            self.items = kept;
        }
        return removed;
    }
}

pub fn main() {
    let log = Logger.new("todos");
    let mut list = TodoList.new();
    list.add("implement the VM");
    list.add("ship the demo");
    let first = list.remove(1);
    let second = list.remove(1);
    log.info(f"remove(#1) = {first}, again = {second}, left: {list.items.len()}");
}
remove(#1) = true, again = false, left: 1

Note mut self: writing through a class handle requires a mut binding head, the same law 01 — Sort exercises on plain vecs — add and remove both declare it. remove filters through a when: the matching row is dropped, everything else is kept, and the list is rewritten only if something was actually removed. The read side (not shown) is ordinary code — len, title_of (which returns "" for a miss; the example pairs it with has_title because there is no ?. sugar), and a render built from f-strings.

The handle: one opaque box around the world

Instances of TodoList can never cross into Rust — the crossing set admits primitives, str, bytes, and opaque only, and entry fn signatures are checked against that rule at compile time (see the host boundary). So the library boxes its whole container once and the host holds the handle:

struct Lists {
    lists: Vec<TodoList>;    // handle = index; handles stay stable while held
}

fn at(c: opaque, h: u32) -> TodoList {
    let lists = opaque.downcast<?Lists>(c);
    return lists.lists[h as i32];
}

entry fn createContainer() -> opaque {
    let ls: ?Lists = Lists { lists: Vec.new() };
    return opaque(ls);
}

entry fn create(c: opaque) -> u32 {
    let k = opaque.downcast<?Lists>(c);
    k.lists.push(TodoList.new());
    return (k.lists.len() - 1) as u32;
}

Two things to take away. First, entry fn marks the host-callable surface — distinct from pub, which is use-visibility for other rut modules and carries no crossing limits (Modules and visibility). Second, erasure is the opaque(v) type-call and recovery is opaque.downcast<T>(o) -> ?T — a wrong box answers nil, never a trap (opaque — erasure and downcast). The internal helper at is an ordinary fn and may speak in full types.

The embedder: typed calls, budgets, and honest errors

examples/00-todolist/src/main.rs mounts core and the pouch package (for Vec), compiles in one call, verifies the binary, and then talks to the module through typed closures:

#![allow(unused)]
fn main() {
let c: rut_vm::OpaqueRef = vm.call("createContainer", ()).unwrap();
let list: u32 = vm.call("create", (c.clone(),)).unwrap();

let mut add = |title: &str| -> i32 {
    vm.call::<_, i32>("add", (c.clone(), list, title)).unwrap()
};
let vmf = add("implement the VM");
let demo = add("ship the demo");
}

The session runs under explicit budgets — fuel and a heap limit — so a runaway module fails loudly instead of hanging the host (Resource limits and fuel):

#![allow(unused)]
fn main() {
let limits = rut_vm::interp::Limits {
    fuel: Some(1_000_000),
    heap_limit_bytes: Some(4 * 1024 * 1024),
    interrupt_every: 1024,
};
}

And the transcript’s remove(#1) again = Res(Err(...)) line is the VM’s typed view of a bool answer — the second remove returns false, and the debug print shows the value shape the crossing produces (Value boundary and borrows).

Takeaways

  • entry fn is the host-callable surface; its signatures are checked against the crossing rule at compile time, so a bad plan is a diagnostic, never a call-time surprise.
  • State lives rut-side in one opaque container the host holds and re-passes; plain values cross, instances never do.
  • Erasure is opaque(v); recovery is opaque.downcast<T> -> ?T.
  • Budgets (fuel + heap) are the embedder’s call, and every embedder mistake comes back as a named trap.
  • The same file works as a library: entries are compilation roots, so a module with no main still emits every entry.

Next: 01 — Sort scales the pattern to five algorithms and shows what recursion costs in fuel. The crossing rules behind this page are in the host boundary.

01 — Sort

The second runnable host example, in the shape of 00 — Todolist: five sorting algorithms in rut — insertion, bubble, and selection (loop-shaped), plus quicksort and merge sort (recursion, in-place vs. out-of-place) — driven from Rust through one entry fn dispatcher. Where 00 is about the boundary, this one is about the language: recursion, when on strings, the mut-binding law, wrapping arithmetic, and visible fuel budgets.

Run it

cargo run -p sort          # from the repo root
cargo test -p sort         # every algorithm, edge cases, cross-agreement

The run sorts a hand-picked input, then fills the bank with a deterministic pseudo-random vector and runs the full sweep, printing fuel per algorithm:

pushed: [5, 2, 9, 2]
after insertion: [2, 2, 5, 9]
fill(16, seed=42), each algorithm:
  insertion true  fuel   2335  [208, 320, 353, ..., 854, 893]
  bubble    true  fuel   3895  [208, 320, 353, ..., 854, 893]
  selection true  fuel   3353  [208, 320, 353, ..., 854, 893]
  quick     true  fuel   2241  [208, 320, 353, ..., 854, 893]
  merge     true  fuel   4358  [208, 320, 353, ..., 854, 893]
sort(bogus) = Res(Err(Str("unknown algorithm: bogus")))
fuel used: 21142 of Some(5000000)

The fuel column is the demo’s quiet joke: quick < insertion < selection < bubble, exactly as the textbooks promise — and the VM counts it for you.

Code tour

One opaque bank; the data never leaves rut

Vec<i32> cannot cross the host boundary, so create() boxes a Bank in an opaque and the host holds the handle — the 00 — Todolist pattern again. Results come back three ways, all crossing-shaped. serialize below is verbatim, plus a main that boxes a small bank the way create() does, so the block runs on its own:

use ink::{ Logger };
use pouch::{ Vec };

struct Bank {
    data: ?Vec<i32>;
}

entry fn serialize(c: opaque) -> str {
    let b = opaque.downcast<?Bank>(c);
    let xs = b.data;
    let mut out = "[";
    for (let i = 0; i < xs.len(); i += 1) {
        if (i > 0) {
            out = f"{out}, ";
        }
        out = f"{out}{xs[i]}";
    }
    return f"{out}]";
}

pub fn main() {
    let log = Logger.new("sort");
    let bank: ?Bank = Bank { data: Vec<i32>.from([5, 2, 9, 2]) };
    let c = opaque(bank);
    log.info(f"serialized: {serialize(c)}");
}
serialized: [5, 2, 9, 2]

serialize is the workhorse: a whole result crosses as one JSON array string, so the host asserts an entire sort in one compare. get(c, i) / has(c, i) read element-by-element, and is_sorted(c) returns the verdict.

The dispatcher: when on strings, errors as values

Every algorithm sits behind a single string-keyed entry. Unknown names are an ordinary value, not a trap — the empty string means success:

entry fn sort(c: opaque, algo: str) -> str {
    let b = opaque.downcast<?Bank>(c);
    let mut unknown = false;
    when (algo) {
        "insertion" -> { insertion_sort(b.data); },
        "bubble"    -> { bubble_sort(b.data); },
        "selection" -> { selection_sort(b.data); },
        "quick"     -> { quicksort(b.data, 0, b.data.len() - 1); },
        "merge"     -> { merge_sort(b.data); },
        else        -> { unknown = true; },
    }
    if (unknown) {
        return f"unknown algorithm: {algo}";
    }
    return "";
}

when with string-literal arms is the general dispatch idiom — see Control flow and when.

Deterministic input in one call

fill replaces the bank with n pseudo-random values from a linear-congruential generator, so sizing a benchmark input is one host round-trip, not one per element. The u32 math must wrap, and rut makes that explicit — plain * traps on overflow. The entry is verbatim (the Bank inlined so the block compiles alone), with a main that fills and prints what one call produces:

use ink::{ Logger };
use pouch::{ Vec };

struct Bank { data: ?Vec<i32>; }   // inlined from above — the block runs alone

entry fn fill(c: opaque, n: u32, seed: u32) {
    let mut b = opaque.downcast<?Bank>(c);
    let fresh: Vec<i32> = Vec.new();
    let mut x = seed | 1;         // the LCG needs an odd state
    for (let i = 0; i < n as i32; i += 1) {
        x = (x.wrapping_mul(1664525)).wrapping_add(1013904223);
        fresh.push((x % 1000) as i32);
    }
    b.data = fresh;
}

pub fn main() {
    let log = Logger.new("sort");
    let bank: ?Bank = Bank { data: nil };
    let c = opaque(bank);
    fill(c, 8, 42);
    let b = opaque.downcast<?Bank>(c);
    let xs = b.data;
    let mut out = "";
    for (let x of xs) {
        out = f"{out} {x}";
    }
    log.info(f"fill(8, seed=42):{out}");
}
fill(8, seed=42): 798 893 208 375 746 353 452 555

The wrapping family (wrapping_mul, wrapping_add, wrapping_shl) is the honest spelling of two’s-complement arithmetic — the playground corpus has a whole checked-arith case on the contrast.

Recursion under fuel

Quicksort partitions in place; merge sort builds out-of-place halves and merges back through the shared handle. Both are plain recursive fns — the call-frame stack is the VM’s, counted by the same fuel budget (the bytecode VM):

fn quicksort(mut xs: ?Vec<i32>, lo: i32, hi: i32) {
    if (lo >= hi) {
        return;
    }
    let p = partition(xs, lo, hi);
    quicksort(xs, lo, p - 1);
    quicksort(xs, p + 1, hi);
}

Note the mut xs parameter heads: vecs are handles, so an in-place swap inside a call is shared with the bank — but writing through the handle requires the mut binding head, while mere reads (and push) do not. That asymmetry is the mut-binding law, and every algorithm in this file declares it (Modules and visibility).

The embedder’s sweep

src/main.rs drives the sweep and prints the fuel deltas, which is how the table above gets its numbers:

#![allow(unused)]
fn main() {
println!("fill(16, seed=42), each algorithm:");
for algo in ["insertion", "bubble", "selection", "quick", "merge"] {
    vm.call::<_, ()>("fill", (c.clone(), 16u32, 42u32)).unwrap();
    let before = vm.fuel_used;
    vm.call::<_, String>("sort", (c.clone(), algo)).unwrap();
    let sorted: bool = vm.call("is_sorted", (c.clone(),)).unwrap();
    println!("  {algo:<9} {sorted}  fuel {:>6}  {}", vm.fuel_used - before, ser(&mut vm));
}
}

Takeaways

  • Same host pattern as 00 — Todolist, one entry richer: a single string-keyed dispatcher keeps the entry fn surface small.
  • Vec<T> never crosses; a serialized JSON array makes a whole result one host-side compare.
  • Errors are values: an unknown algorithm comes back as a string, not a trap.
  • mut on a binding is a capability law, not a suggestion — and the wrapping arithmetic family is explicit about overflow.
  • Fuel budgets make algorithmic differences visible from the host, for free.

Deep-dives: the bytecode VM explains what a unit of fuel counts; 02 — Digest takes the same session shape to the byte level.

02 — Digest

The byte-level example, in the shape of 00 — Todolist and 01 — Sort — with a twist: this time the embedder is also the oracle. digest.rut is a byte-level library written in pure rut:

  • encodings — hex (encode/decode, case-insensitive) and base64 (standard + URL-safe alphabets, padding, invalid-input rejection)
  • crypto digests — MD5, SHA-1, SHA-256, SHA-512 behind one when-on-string dispatcher
  • hashmap hash keys — CRC-32, FNV-1a 32/64, djb2, sdbm
  • JSON — decode to an opaque tree, encode back, round-trip, with numbers stored as verbatim lexemes so round-trips are exact

Nothing in the rut file trusts itself: the Rust host cross-checks every algorithm against independent crates (the RustCrypto hash family, base64, crc32fast, serde_json) on canonical test vectors and on deterministic pseudo-random inputs at every padding-edge length, plus a 64 KiB stress blob. digest.rut knows nothing about the crates; only the host compares.

Run it

cargo run -p digests       # from the repo root
cargo test -p digests      # the oracle, asserted

The run prints one verified row per check — abridged:

hex([114, 117, 116, 33]) = 72757421
b64([102, 111, 111, 98, 97, 114], url=false) = Zm9vYmFy  OK
digests, rut vs crates:
  md5     empty         fuel    8419  d41d8cd98f00b204e9800998ecf8427e  OK
  sha256  "abc"         fuel   18866  ba7816bf...f20015ad  OK
  md5     1 KiB lcg(42) fuel  113117  d6c1961991b0106647e36ca4ba12d345  OK
sample_doc -> {"name":"rut","version":0.2,"tags":["tiny","fast","verified"],...}
serde_json parses it: OK
hash keys over that JSON text:
  crc32   eccc5960  OK
  fnv1a64 8ba697141da02e83  OK
hex_dec("zz")   = "hex: invalid character at index 0"
json_dec("{,}") = "json: expected a key string at index 1"
digest("md4")   = "unknown algorithm: md4"
every row agrees: OK
fuel used: 1014105 of Some(50000000)

Code tour

The integer surface is enough for real algorithms

digest.rut runs on hex literals, u32/u64, the wrapping family, and signedness-correct shifts. CRC-32 is the compact showcase — table-free, bitwise, and exactly the textbook loop. The entry is pure rut — no host, no manifest — so it runs on its own, here checked against the algorithm’s canonical test value:

use ink::{ Logger };

entry fn crc32(data: bytes) -> u32 {
    let mut crc: u32 = 0xFFFFFFFFu32;
    for (let b of data) {
        crc = crc ^ b as u32;
        for (let k = 0; k < 8; k += 1) {
            if ((crc & 1) == 1) {
                crc = (crc >> 1) ^ 0xEDB88320u32;
            } else {
                crc = crc >> 1;
            }
        }
    }
    return crc ^ 0xFFFFFFFFu32;
}

pub fn main() {
    let log = Logger.new("crc");
    let vector = "123456789".encode();   // the canonical CRC-32 test vector
    log.info(f"crc32(vector) = {crc32(vector)} — check value 0xCBF43926: {crc32(vector) == 0xCBF43926u32}");
}
crc32(vector) = 3421780262 — check value 0xCBF43926: true

SHA-512 is the demanding one: its 64-bit rotations need >> to be a logical shift on u64 and wrapping_shl to truncate to the operand width. The FNV/djb2/sdbm trio, by contrast, deliberately never shifts a 64-bit word — wrapping_mul, wrapping_add, and ^ carry them, showing how far the primitive surface alone goes.

bytes crosses directly; opaque appears exactly once

Byte payloads need no wrapper — bytes is in the crossing set, so entry fn hex_enc(data: bytes) -> str takes a host byte slice head-on (primitive types). The one erasure in the file is the recursive JSON tree: rut has no recursive dataclass, so the tree is a tagged union by hand with children boxed in opaque to break the recursion (opaque — erasure and downcast):

enum JTag { Null, False, True, Num, Str, Arr, Obj }

struct Json {
    tag:  JTag;
    num: str;          // verbatim lexeme — exact round-trips, no
    str: str;          // float formatting anywhere in this file
    arr:  Vec<opaque>;
    keys: Vec<str>;
    vals: Vec<opaque>;
}

Storing numbers as their verbatim lexemes is what makes round-trips exact: -3e2 decodes and re-encodes as -3e2, with no float formatting anywhere in the file.

The encode half rides the std json package

The encode side is not private code: it is an impl of the std json package’s serialization trait, driving the package’s writer. This is the orphan rule’s type-local case — json owns the trait, this file owns Json, so the pair is legal exactly here (Traits and dispatch):

impl JsonSerialize for Json {
    fn encode(self, mut w: JsonWriter) -> ?EncodeJsonError {
        when (self.tag) {
            JTag.Null -> { w.write_raw("null"); },
            JTag.False -> { w.write_raw("false"); },
            JTag.True -> { w.write_raw("true"); },
            JTag.Num -> { w.write_raw(self.num); },
            JTag.Str -> { w.write_str(self.str); },
            JTag.Arr -> {
                w.begin_array();
                for (let i = 0; i < self.arr.len(); i += 1) {
                    let ae: Json = opaque.downcast<Json>(self.arr[i]);
                    let e = ae.encode(w);
                    if (e != nil) { return e; }
                }
                w.end_array();
            },
            JTag.Obj -> {
                w.begin_object();
                for (let i = 0; i < self.keys.len(); i += 1) {
                    w.key(self.keys[i]);
                    let ve: Json = opaque.downcast<Json>(self.vals[i]);
                    let e = ve.encode(w);
                    if (e != nil) { return e; }
                }
                w.end_object();
            },
        }
        return nil;
    }
}

The entry surface then hands the tree to encodeJson and flattens the (str, err) pair it answers. The decode half stays a private cursor parser (strings iterate as codepoints, so the source is split once into single-char strings and walked by index) — and every decode error is an honest string value, never a trap:

hex_dec("zz")   = "hex: invalid character at index 0"
b64_dec("!*")   = "base64: invalid character `!`"
json_dec("{,}") = "json: expected a key string at index 1"

The host oracle, in miniature

The host keeps three-line reference implementations for the hash keys with no canonical crate, and crates for everything else — then compares:

#![allow(unused)]
fn main() {
// the hash-key trio with no canonical crate: three-line references
fn fnv1a32(b: &[u8]) -> u32 {
    let mut h = 0x811c9dc5u32;
    for &x in b {
        h ^= x as u32;
        h = h.wrapping_mul(16777619);
    }
    h
}
}

The same session runs under 50 M fuel for the demos and raises to 500 M for the 64 KiB stress blob — budgets are the host’s call, per call.

Takeaways

  • The integer surface (hex literals, u32/u64, wrapping ops, logical shifts) is enough for MD5-through-SHA-512 — verified, not asserted, against independent Rust.
  • bytes crosses the boundary directly; opaque is for the one place recursion needs breaking.
  • The std json package is usable from day one: implement its serialize trait for your own type and its writer does the byte work (core and the swappable packages).
  • Errors are values, and the host-as-oracle pattern is the strongest test shape in this chapter.

The oracle pattern returns in 06 — GitHub viewer CLI, where the fixture is a recorded HTTP lane instead of hash crates.

03 — Plugin

A Rust chat-room server with a rut moderator plugin — the first example built on two things at once: re-entrant vm.call (the host calls rut, and a host fn called by rut calls back into rut while the first call is still parked) and both opaque directions (rut hands the host a handle to its own state; the host hands rut a handle to the server’s event bus). It is also the packaging example: the plugin loads from a module directory and from a .rutbundle packed from that directory at runtime — the two forms of one contract (Module bundles).

Run it

cargo run -p plugin         # from the repo root
cargo test -p plugin        # transcript asserted + bundle round-trip gates

The demo drives one scripted session twice — once against the directory, once against the packed bundle — and prints both transcripts plus the round-trip proof. Abridged:

== transcript (module directory) ==
[system] *** ada joined (1 online)
[broadcast] <ada> hello world
...
[system] shutdown: 0 online, 3 msgs, 1 mutes
-- 3 messages moderated --

== packed form (examples/03-plugin/plugin -> NNNN bytes, written to /tmp/...) ==
transcript identical to the directory form: true

The test suite asserts the transcript exactly, proves every line crossed the nested render (emits == renders), and gates the bundle form: round-trip equality, byte-determinism, and refusal on an unknown format version or CRC corruption.

Code tour

The layering: callbacks at the edges, classes in the middle

Four layers, and neither business layer ever touches the crossing protocol:

RUST biz (main / tests)      ->  typed Plugin methods, no vm.call
RUST adapter (src/lib.rs)    ->  the ONLY vm.call sites
RUT adapter (plugin/plugin.rut) ->  the ONLY entry fns; one-line forwards
RUT biz (Moderator)          ->  pure logic + emit, no entry fn

The moderator rules themselves are plain rut — flood control (three consecutive messages from one sender mutes them), muted users rejected on rejoin, a !stats command, tick heartbeats:

pub fn on_msg(mut self, user: str, text: str) {
    if (self.is_muted(user)) {
        self.say("muted", user);
    } else if (text == "!stats") {
        self.say("direct", f"{user}: {self.stats()}");
    } else {
        if (user == self.last_sender) {
            self.streak += 1;
        } else {
            self.last_sender = user;
            self.streak = 1;
        }
        if (self.streak == 3) {
            self.muted.push(user);
            self.mutes += 1;
            self.say("system", f"{user} muted for flooding");
        } else {
            self.msgs += 1;
            self.say("broadcast", f"<{user}> {text}");
        }
    }
}

Every bus write funnels through one private say(topic, payload) — the business logic never spells a crossing.

The handshake: both opaque directions meet

Closures cannot cross the boundary in either direction, so callbacks register by name: the plugin hands the host the export names it wants wired to each topic, and returns the handle to its own state (init is the entire handshake — bus box in, state handle out):

entry fn init(bus: opaque) -> opaque {
    subscribe(bus, "join", "on_join");
    subscribe(bus, "msg", "on_msg");
    subscribe(bus, "leave", "on_leave");
    subscribe(bus, "tick", "on_tick");
    let mod_: ?Moderator = Moderator.new(bus);
    return opaque(mod_);
}
  • rut’s moderator state comes back as a rut-constructed opaque — the host holds the handle and passes it back on every event.
  • the host’s EventBus goes in as a host-constructed Opaque<EventBus> — subscribe and emit are that box’s callbacks, reached only through the borrow guards (Value boundary and borrows, Embedding and native modules).

The re-entrant call: emit re-enters rut mid-op

The host binds the two server rows over its bus. The emit body is the interesting one — while the emitting handler is still parked mid-op, it makes a nested vm.call("render_line") to format the wire line:

#![allow(unused)]
fn main() {
rut_vm::register!(
    hosts,
    "server::emit",
    (Opaque<EventBus>, &str, &str) -> (),
    |vm: &mut Vm, bus: Opaque<EventBus>, topic: &str, handler: &str| -> Result<(), Trap> {
        bus.with_mut(vm, |vm, b| -> Result<(), Trap> {
            let line: String =
                vm.call("render_line", (topic.to_string(), handler.to_string()))?;
            b.lines.push(line);
            b.emits += 1;
            b.renders += 1;
            Ok(())
        })?
    },
);
}

(The comment above this code in the source is worth reading too.) The bus stays mutably borrowed across the nested call — the value boundary’s borrow guard is what makes that sound: a second emit fired from inside render_line would trap on the guard instead of racing. On the rut side, render_line is the one-line entry the host re-enters — pure string work, so it runs on its own (the line it prints below is the transcript’s broadcast row):

use ink::{ Logger };

entry fn render_line(topic: str, payload: str) -> str {
    return f"[{topic}] {payload}";
}

pub fn main() {
    let log = Logger.new("bus");
    log.info(render_line("broadcast", "<ada> hello world"));
}
[broadcast] <ada> hello world

The packaging: one directory, two load forms

The plugin is a module directory — a manifest naming the entry plus its deps, loadable as-is and packable unchanged (Project structure and rut.toml):

# plugin/rut.toml
format = "rutbundle"
format_version = 4
name = "plugin"
entry.lib = "./plugin.rut"

[deps]
server = { path = "../server" }
pouch = { path = "../../../rut/pouch" }

server/ is the interesting dependency: a pure declaration surface — a host package whose manifest points at a .d.rut decl file, with the Rust embedder binding the bodies (Host fns and declaration files):

pub host fn subscribe(bus: opaque, topic: str, handler: str);

pub host fn emit(bus: opaque, topic: str, payload: str);

main.rs packs the same directory with rut_driver::pack_dir, writes plugin.rutbundle to temp, and loads it back through the identical Plugin::load — the transcript equality print is the proof. The CLI drives the same loader for any self-contained module:

rut run path/to/mod            # a module directory (rut.toml)
rut pack path/to/mod           # -> mod.rutbundle
rut run path/to/mod.rutbundle  # the packed form

The Rust side mirrors the rut layering: business code calls typed methods (p.join("ada"), p.msg(...)), and vm.call happens in exactly one dispatcher that looks up the subscribed export name per topic — unknown topics are dropped, not trapped, so a server keeps running when a plugin didn’t subscribe.

Takeaways

  • One directory is one module; the manifest names the entry and the deps, and the same directory packs to a .rutbundle unchanged.
  • Registration by name, not by closure — callbacks never cross the boundary as values, in either direction.
  • Re-entrancy is sound by construction: a host fn can call back into rut mid-op, and the borrow guard turns a would-be data race into a loud trap.
  • Both opaque directions compose: rut-state-out, host-state-in, and neither side can inspect what the other erased.
  • The layering discipline — adapters own the crossing protocol, business logic stays pure — is what keeps a plugin auditable.

The crossing rules this example lives on are in the host boundary; the load forms are specified in Module bundles.

04 — Custom async

A parse-only example: examples/04-custom-async/custom_async.rut is a single rut file with no Cargo harness — you cannot run it today, and that is a deliberate disclosure, not an oversight. What it shows is the async vocabulary from the user side: a hand-written future type, an impl of the engine’s built-in Future trait, and a user-written launcher with per-checkpoint stats and cancellation audits. The runnable harness that would drive it end to end is the follow-up work; the file is kept in the repo now because the parser’s conformance suite compiles every example, so this vocabulary is checked to stay parseable (Async and await).

“Run” it

There is no harness. The file is exercised by the parser conformance test, which walks every .rut file in examples/:

cargo test -p rut-parser --test corpus    # parses every example, incl. this one

To read the same machinery running, see 06 — GitHub viewer CLI: its brain is an async fn driven by the standard launcher this file re-spells by hand.

Code tour

The audit log — the example’s reason to exist

CustomFuture is a stage machine, but the point of the file is the accounting around it: every launch, resumption, cancellation, and completion lands in one module-owned record the embedder can read after the run:

struct AuditLog {
    launches: u32;      // futures this log saw started
    yields: u32;        // resumptions driven — one per checkpoint
    cancels: u32;       // cancellation probes that answered true
    checkpoints: u32;   // high-water mark of the context ledger
    finished: u32;      // runs that walked every step they booked
}

The launcher takes a shared ?AuditLog, so the embedder holds one cell and reads the whole run’s story out of it afterwards — summary renders the one-line audit.

The machine — what async fn weaves, spelled out

When you write async fn, the engine weaves a hidden frame whose shape is exactly this: a stage counter, the payload the stages carry, and a done flag. CustomFuture spells that shape out in source, so you can see what the keyword hides. The machine itself is plain rut — only the driving harness is missing — so the block below adds a main that advances it by hand; that is as far as one can run it today:

use ink::{ Logger };

struct CustomFuture {
    steps: u32;      // checkpoints booked before the run finishes
    stage: str;      // what the current stage does (the audit's label)
    done: bool;      // the last stage ran
}

impl CustomFuture {
    fn new(steps: u32, stage: str) -> Self {
        return Self { steps: steps, stage: stage, done: false };
    }

    /// One stage of work — what one resumption advances. `false` when
    /// no stages are left: the runner's completion signal.
    fn advance(mut self) -> bool {
        if (self.done) {
            return false;
        }
        if (self.steps == 0) {
            self.done = true;
            return false;
        }
        self.steps -= 1;
        return true;
    }
}

pub fn main() {
    let log = Logger.new("future");
    let mut f = CustomFuture.new(3, "poll");
    let mut polls = 0;
    while (f.advance()) {
        polls += 1;
    }
    log.info(f"3 steps took {polls} advancing polls, then advance() answered false (done={f.done})");
}
3 steps took 3 advancing polls, then advance() answered false (done=true)

The trait surface: a user impl of a built-in trait

Future is an engine-named built-in trait — the engine weaves it for every async fn’s hidden frame — but built-in traits are engine named, not engine closed. A hand-written machine registers through the ordinary nominal path, one impl block, and rides the same drive law (Traits and dispatch):

impl Future<nil> for CustomFuture {
    fn yield(self, cx: RunContext) {
        // the ledger view: the frame's resume state, read as data
        let at = cx.checkpoint();
        // the cancellation probe: a cancelled frame stops advancing —
        // the audit sees it as "died here, did not advance" — and runs
        // its drop path instead of the next stage
        if (cx.cancelled()) {
            self.done = true;
            self.stage = f"{self.stage}@{at}:cancelled";
        }
    }
}

The receiver is the frame — the machine’s fields are its state — and the context is the only handle a resumption needs. The frozen context protocol reads as data: checkpoint answers this frame’s resume state, cancelled answers the task’s abort flag. Cancellation is a value the frame inspects, not an exception it catches.

The user launcher

The engine’s standard launcher set (launch_future, LaunchedFutureHandle, sleep) is ordinary code any module may re-spell — that is the point of launch_custom. It supplies the driving and the audit over the same Future surface, so the machine never knows which launcher started it:

use async_engine::{ __abort, __launch };

fn launch_custom(f: Future<nil>, log: ?AuditLog) -> CustomLaunched {
    __launch(opaque(f));
    log.note_launch();
    return CustomLaunched { task: f, log: log, alive: true };
}

The returned CustomLaunched receipt is deliberately not a future — it cannot be awaited, and it is not re-launchable (that is a type error, never a runtime check). Its one real member is the cancel edge, which flags the task and lets the loop’s re-drive run the probe:

    fn cancel(mut self) -> bool {
        if (!self.alive) {
            return false;
        }
        self.alive = false;
        let t = self.task;
        if (t == nil) {
            return false;
        }
        let log = self.log;
        if (log != nil) {
            log.note_cancel();
        }
        return __abort(opaque(t));
    }

Alongside sits audit_drive, the audit twin of the engine loop’s drive columns: run a stage, count the resumption, stop on completion.

Scope, honestly

Two boundaries to keep straight when you read this file:

  • User futures are launcher-drivable — the driving loop finds the Future::yield row in the vtable, exactly as it does for woven frames.
  • await targets engine-woven futures only in the current build (async-fn results and sleep); awaiting an arbitrary user impl, and joining concurrent futures, land with the next async milestone.

So today the file is a parse-checked design corpus and a teaching document: the shape of the weave, the shape of a launcher, and the proof that the future trait is a user-extensible surface — not a runnable demo. When you want the runnable version of the same story, read 06 — GitHub viewer CLI, and for the model behind it, the async model.

Takeaways

  • async fn compiles to a hidden frame implementing Future — this file spells that frame out in user source.
  • Built-in traits are engine-named, not engine-closed: impl Future<nil> for YourType registers through the ordinary path.
  • Cancellation is data the frame reads (cx.cancelled()), and every checkpoint is observable (cx.checkpoint()).
  • A launcher is just code: driving + audit over the public Future surface, re-spellable by any module.
  • The file is parse-only by disclosure; the runnable harness is follow-up work — the machine itself is plain rut (it ran above), but nothing launches or drives it as a future yet.

05 — Todolist web

One page app — a todolist with a simulated server — where every DOM move and every timer goes through ten declared host crossings, and the page’s brain is a rut project of two packages. ui is the framework: an atom store (the jotai shape, in rut), a widget type, a keyed diff, and a component vocabulary. biz is the domain: the todolist machine expressed as store mutations, plus the app shell. The host — web-sys on wasm32 in the browser, a fake-DOM twin on native in the tests — owns the loop; rut owns the state. Pure rut: no event loop, no async keywords on the rut side; every asynchronous fact enters through a door named for its event.

Run it

cargo test -p todolist-web       # the twin + the law (95 tests), from the repo root
cd examples/05-todolist-web
node tests/e2e-browser.mjs       # the through-the-artifact gate (node tier + browser tier)

cargo test runs the whole story on the fake-DOM twin — a HashMap element tree with real DOM semantics and a virtual timer clock — over the same rut sources the browser runs. The e2e script builds nothing silently and skips nothing silently: tier 1 (plain node) drives the real wasm artifact through the loader’s own ABI on a fake DOM; tier 2 (when geckodriver + Firefox exist) types and clicks the live page.

To open the page yourself:

cd examples/05-todolist-web
cargo build -p todolist-web --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir gen --out-name web_host \
  ../../target/wasm32-unknown-unknown/release/todolist_web.wasm
python3 -m http.server        # then open http://localhost:8000/

What you’ll see: type a title — the status line echoes the typing and the counts never move (the draft is not a counts dependency). Press Add — an italic pending line paints immediately, the counts line flips to 1 in flight, and ~400 ms later the committed row replaces it. Toggle a row — the title goes italic for ~250 ms, then the check fills and the title strikes through. The row animates because the keyed diff kept the node alive — nothing is cleared and rebuilt, ever.

Code tour

The ABI is the page: main plus the doors

rut/biz/app.rut — the boot turn builds the app container and returns it as an opaque (rut has no mutable module state, so the host holds the state and re-passes it every turn — the 00 — Todolist pattern at page scale):

entry fn main() -> opaque {
    let boot: World = world_boot("booted — type a title, press Add");
    let root = AppRoot {
        world: boot,
        t1: t1_mount("app"),
    };
    paint(root);
    return opaque(root);
}

Every asynchronous fact enters through a door named for its event — on_click/on_input for DOM events, on_timer for the clock. Each door re-passes the container and answers the entry-err pair: (opaque(c), "") on a clean turn, (nil, why) on a soft failure the pump reports while keeping the page alive.

fn dom_event(mut r: AppRoot, id: str, detail: str) -> (?opaque, str) {
    let booked = t1_event(r.t1, id, detail);
    let req = opaque.downcast<Req>(booked);
    if (req != nil) {
        tim_after(req.latency, req.tag);
    }
    paint(r);
    return (opaque(r), "");
}

The “server” is a request table, not a scheduler: adding never touches the list — the machine books a request and the app books one tim_after(latency, tag); the list changes only when the timer’s turn runs the answer mutation (per-kind latency, so deadline-ordered commits are visible across kinds).

The ten crossings

web.d.rut (flat at the example root, registered by hand in both lanes) declares the whole host surface. Elements cross as opaque handles; everything else is strings, ints, and bools — no app names, no data shipping, no callbacks (the host boundary):

pub host fn ui_get(id: str) -> opaque;
pub host fn ui_create(tag: str) -> opaque;
pub host fn ui_set_text(el: opaque, text: str);
pub host fn ui_attr(el: opaque, name: str, value: str);
pub host fn ui_append(parent: opaque, child: opaque);
pub host fn ui_remove(parent: opaque, child: opaque) -> bool;
pub host fn ui_clear(el: opaque);
pub host fn ui_set_input_value(el: opaque, v: str);
pub host fn ui_listen(el: opaque, event: str) -> i64;
pub host fn tim_after(ms: i64, tag: str);

A wiring bug is loud: a missing id traps (web::ui_get: no element '#x'), a kind mismatch names both sides. The host never grew an app-specific crossing — the entire widget system fits over these ten rows, unchanged.

The store: jotai’s shape over private traits

rut/ui/store.rut is the kernel. Handles are keys, not objects — Source<T>, Derived<T>, Mutation<A, R> carry ids and have no logic of their own beyond routing through their store. The machinery traits are module-private, so the domain package cannot name them even to import them — which is how “a derived cell is not writable” is enforced at the type level (Traits and dispatch):

trait Readable<T> {
    fn atom_id(self) -> u32;
    fn materialize(self, st: Store) -> nil; // first touch: the seed lands
}

trait Writable<A, R> {
    fn atom_id(self) -> u32;
}

Freshness is pull-on-read: a write bumps a generation, and a get recomputes a stale derived at most once per write-set. There is no flush step anywhere. Dependencies are discovered, never declared — the ctx.get calls inside a derive closure ARE the dependency list, re-recorded on every recompute. The domain’s counts line is the worked example (rut/biz/world.rut):

let counts$ = store.derive(fn (ctx) -> str {
    let items = ctx.get(items$);
    let reqs = ctx.get(reqs$);
    let mut open = 0;
    for (let t of items) {
        if (t.done == false) {
            open += 1;
        }
    }
    let done_n = items.len() - open;
    return f"{open} open | {done_n} done | {reqs.len()} in flight";
});

Writes go through mutation fns — add_m, toggle_m, remove_m, answer_m — multi-step write programs that run in the one write lane; the machine’s counters are sources too, because with no domain container there is nowhere else for state to live.

Widgets are data; the diff does the DOM

view(r) is a pure function from app state to a Widget tree — it runs no crossing and reads nothing (reads are reactive props, resolved at the render door). t1_render diffs the new tree against the previous one held in T1Root and fires only deltas: match children by key, patch in place, re-append on reorder, retire gone keys’ listeners. Nothing changed = nothing fires (HashMaps and sets carry the path-keyed tables):

pub struct T1Root {
    parent: opaque; // the mounted root element
    prev: ?Widget = nil; // the previous tree — the diff's left side
    els: HashMap<str, opaque>; // path -> live handle (ops application)
    regs: HashMap<str, Ev>; // listener id (as crossed) -> the
    // widget's event wiring (a data-shaped binding)
    lids: HashMap<str, i64>; // path -> listener id (retirement)
}

Event wiring rides the widgets as data, not closures — a row’s toggle mutation carries its argument bound (.on_row(w.toggle_row, t.id)), and the framework resolves a firing listener id to that wiring. Styling is a token contract: the stylesheet keys only the lowered t1-* tokens, and the app spells no class.

Two packages, two manifests

A real project is not one file — but the package boundary is also the privacy boundary, which is why there are exactly two (Project structure and rut.toml):

# rut/biz/rut.toml — the project root
name = "app"
entry.lib = "./biz.rut"
entry.libs = ["./domain.rut", "./world.rut", "./app.rut"]

[deps]
ui = { path = "../ui" }
pouch = { path = "../../../../rut/pouch" }
nmapset = { path = "../../../../rut/nmapset" }

entry.libs splices several files into ONE module (base first, array order), so a name private to store.rut is visible to components.rut and to nothing outside ui — single-file privacy, kept at package scale. ui is marked inline = true (its generic exports splice by law), and the native lane mounts the directory — the manifest, not a Rust fn, is the module list. The wasm lane mounts the same packages by hand as a mirror, and a test pins both lanes to byte-identical binaries.

Takeaways

  • The page ABI is main plus doors named for their events; state lives in the container the host re-passes every turn.
  • Ten thin crossings carried an entire reactive framework — the host stayed generic and never learned the word “todo”.
  • The store is jotai’s shape in rut: discovered deps, pull-on-read freshness, mutations as the write lane, and type-level non-writability through private traits.
  • Widgets are values; the keyed diff is the only thing that speaks DOM, and patch-in-place is what makes the UI animatable.
  • Soft failures are data ((nil, why) and the pump keeps draining); a panic alone kills the page, loud.

The async shape this page simulates (the request table + timers) is the real thing in 06 — GitHub viewer CLI; the store’s building blocks are in the swappable packages.

06 — GitHub viewer CLI

rgh lists and downloads files from public GitHub repositories over the jsDelivr CDN — no GitHub API, no tokens. Its brain is a rut async free fn: argv carving, the URL building, the tree JSON decode, the human-size formatter, every message and exit code run in the VM. The Rust half is a pure embedder: mount packages, bind host bodies, launch the brain, pump the driving loop to idle, exit with the brain’s i32 (0 ok, 1 runtime error, 2 usage). This is the worked example for the std http lane’s redesigned face — HttpClient, builder-style construction, and async-only send with true streaming.

Run it

cargo run -p rgh -- --repo=jquery/jquery --ref=3.7.1 list
cargo run -p rgh -- --repo=jquery/jquery --ref=3.7.1 download test/data/1x1.jpg
cargo test -p rgh                  # the offline suite — zero network
RGH_LIVE=1 cargo test -p rgh live_smoke   # opt-in live CDN check

list prints one line per file, depth-first in the API’s own order (directories are walked, never printed):

<repo path>\t<human size>\t<hash8>

The size ladder is N B under 1024, then KiB/MiB/GiB with one decimal only when the remainder is nonzero (1536 → 1.5 KiB, 1048577 → 1.0 MiB); the last column is the first 8 characters of the entry’s base64 integrity hash, or - when absent. The offline suite rides a fixture lane keyed on method + URL with a virtual clock — the fixture map’s keys ARE the assertions — and covers every usage error, the boundary tree’s exact output, streamed binary downloads verified byte-verbatim, wire deaths, and status mappings.

Code tour

The brain is async; the host launches and pumps

rgh.rut — the entry point is one boot fn: the host crosses argv in as a single \n-joined string (no arg lists in the crossing set), and boot launches the brain with the standard launcher (Tasks):

entry fn boot(args: str) -> nil {
    launch_future(rgh_main(args));
}

The embedder then pumps the driving loop to idle — real reqwest workers settle the completers from their threads, and the process’s one-way exit row fires mid-pump:

#![allow(unused)]
fn main() {
loop {
    match vm.run_ready() {
        Ok(_) => {}
        Err(t) => {
            eprintln!("rgh: trap: {} — {}", t.name(), t.msg);
            std::process::exit(1);
        }
    }
    if vm.pending_tasks() == 0 {
        // the brain retired WITHOUT exiting — a brain bug: loud
        eprintln!("rgh: the brain retired without exit (a brain bug)");
        std::process::exit(1);
    }
    std::thread::sleep(std::time::Duration::from_millis(2));
}
}

The awaits live at the fetch sites and only there — everything else (argv carving with the scan/slice tokenizer primitives, flags, JSON, formatting) is the same sync code it always was.

The http face: build a request, await the send

The std http package is async-only and unsuffixed: only the operations that really wait are async points, everything else is sync construction sugar. The five verbs are build sugars (get/post/ put/patch/del — the DELETE verb spells del because delete is a removed word in rut’s grammar; the wire still sees canonical DELETE), and send(cx) is THE async point, resolving at headers — the wire body stays unread (the async model):

async fn do_list(cx: RunContext, client: HttpClient, owner: str, repo: str, rf: str) -> i32 {
    let url = f"https://data.jsdelivr.com/v1/packages/gh/{owner}/{repo}@{rf}";
    let resp = await client.get(url).build().send(cx);
    let err = map_response(resp, "no such repo or ref", "data.jsdelivr.com");
    if (err != nil) {
        let why: str = err;
        eprint(why);
        return 1;
    }
    let (tree, e) = decodeJsonBytes<Root>(await resp.body(cx));

list is the drain lane: one body(cx) await pulls the whole tree, and the JSON decode goes through the std json package’s reader with impl JsonDeserialize for Entry in this file. Status mapping is one function: status() == 0 is the reserved transport verdict, 404 names the host that was checked, 403 is the rate-limit note, anything else non-2xx is the bare status.

The download: true streaming, chunk by chunk

download walks a ByteStream — next(cx) answers one chunk per await (nil = EOF-or-failed; the sticky error() names which), and each chunk lands through the sync append_file host row. The destination is truncated once first, so a stale file never leaks its tail into a fresh download:

    let stream = resp.byte_stream();
    let mut total: i32 = 0;
    while (true) {
        let c = await stream.next(cx);
        if (c == nil) {
            // EOF — or a mid-read failure? the sticky error names it
            let rerr = stream.error();
            if (rerr != nil) {
                let why: str = rerr;
                eprint(f"rgh: {dest}: {why}");
                return 1;
            }
            break;
        }
        let chunk: bytes = c;
        let werr: ?str = append_file(dest, chunk);
        if (werr != nil) {
            let why: str = werr;
            eprint(f"rgh: {dest}: {why}");
            return 1;
        }
        total += chunk.len();
    }

Bounded memory end to end, and every failure on the data path is a message plus an exit code — never a panic, never a trap. The lane’s one-shot law is worth knowing: per response it is body() xor byte_stream(), and a late/second taker degrades (an empty drain, a dead reader) rather than trapping.

The readbacks, on the response handle

The Response class wraps the host’s opaque handle with sync readbacks — status, the 2xx test, the transport text, and the sticky mid-read failure (the std packages):

impl Response {
    /// the HTTP status word — 0 means the transport failed (0 is never
    /// a real status; any real status, 4xx/5xx included, is not one)
    pub fn status(self) -> i32 { return http_status(self.r); }

    /// the 2xx test
    pub fn ok(self) -> bool {
        let s = self.status();
        return s >= 200 && s < 300;
    }

    /// the transport-failure text — nil unless status is 0
    pub fn transport_error(self) -> ?str { return http_err(self.r); }

    /// the sticky mid-read failure — nil until one fails, then
    /// non-nil forever; meaningful after awaiting `body()`/`next()`
    pub fn read_error(self) -> ?str { return http_read_err(self.r); }

The embedder’s mount list

src/main.rs shows the whole program closure in one table — the collections, json, and the http pair, then assemble_peers runs the peer gate so json’s impl-only integration groups mount because the collections are in the closure (Dependency kinds):

#![allow(unused)]
fn main() {
const MOUNT_DIRS: &[&str] = &[
    "rut/pouch",
    "rut/nmapset",
    "rut/json",
    "rut/http_host",
    "rut/http",
];
}

The HTTP bodies bind through install_std_http (the reqwest lane); the example’s own rows are CLI I/O only — out (stdout; print is a removed core name), eprint, the file pair, and exit — declared in the example-local rgh_host decl package and verified against the bindings at boot (Host fns and declaration files). One consequence: rut run cannot host rgh itself — its rows are example-local — but ordinary HTTP programs do run under rut run, which mounts the std http pair by presence.

Takeaways

  • An async rut program in production shape: the brain awaits at the fetch sites only; the embedder launches, pumps to idle, and exits with the brain’s code.
  • Headers-first send + one-shot body/stream is the whole mental model of the std http lane — drain JSON in one await, stream files chunk by chunk.
  • Failures are values end to end: transport errors, 404s, wire deaths, and io errors all become messages plus exit codes.
  • The fixture lane is the test gate: recorded replies keyed on method + URL, deterministic chunks on a virtual clock, zero network.
  • The peer gate is load-bearing here: mounting the collections is what makes json’s Vec<T> serde impls compile into this unit.

The lane’s declarations live in the std tree; for the runner behind rut run, see the rut CLI.

The playground corpus

The classics: seventeen short, self-contained rut programs in demo/src/examples/ that the web playground offers in its picker and runs in your browser. They are not copies — the playground imports the .rut files raw, so what you edit there is what this repo ships. The classics are the fastest way to see the language surface in working code: algorithms first, then the language-surface tours, then the memory shapes. The same order is the playground’s.

Run it

The playground itself:

cd demo && npm run dev      # serve the playground locally
# or use the deployed instance: https://playground.rut.hpp2334.com

Pick a case and press run — and the classics shown in the tour below run in the book too, on their ▶ buttons. The gates that keep the corpus honest:

cargo test -p rut-cli --test playground   # from the repo root — the native gate
cd demo && npm run smoke                  # the wasm gate, headless

Concretely:

  • cargo test -p rut-cli --test playground compiles and runs every classic through the full pipeline natively — a compile failure or a trap fails the gate.
  • cd demo && npm run smoke builds the demo bundle (wasm included) and drives every case through it, headless — the same run through the wasm engine.

Both gates drive the same sources through the same engine — native and wasm — so a classic that stops running cleanly fails in CI, not in front of a reader.

The seventeen cases

CaseDemonstratesFirst line it prints
sieveSieve of Eratosthenes — flat Vec<u8>/Vec<i32> primitive buffers25 primes up to 100, last=97
quicksortin-place Vec<i32> mutation (handles, shared with the caller), recursionsorted: 1 2 2 3 5 7 8 9
matrix-mulflat Vec<f32> hot loops — unboxed buffers, no per-element refcountsout[0]=21 out[last]=107
classesclass-method construction (new/from), Self {}, member pub + sealingcount=2 area=12
closures-genericsanonymous fns (block bodies), monomorphized generics, fn types, captureadd=3 area=3.1415927 sum=6
structsreference semantics (sharing by default), identity ==len=6.324555320336759 color=16711935 area=6
literalsnumeric suffixes, plain/raw/format strings, fixed [T], bytes buffersa=10 e=1.5 d64=1.5 ch=h p.x=1 zero[0]=9 len=3 bin=64
checked-arithwrapping_* wraps two’s-complement, checked_* answers the (T, bool) tuplewrap=4 under=255
str-viewsO(1) slice views (a slice IS a str), codepoints — s.code / str.from_codeword=world len=5 eq=true
bytesthe binary primitive — encode/decode, clone as the ONE copyround=true octets=8 chars=8
opaqueopaque / opaque.downcast<T> -> ?T / is — erasure and checked recoverypoint 1 2
whenwhen pattern expressions over enums, exhaustivenesssmall
mapsthe keyed-collection lane — HashMap/HashSet, keys admitted by the compile-time union boundrut=3 runs=1
node-cyclestrong cycles keep cells alive — the program’s responsibilityhead.next alive: true
treerecursive structs (?Node nullable fields), composite fields as handle slotsnodes=15
weak-cachethe cache/observer shape, shown with today’s strong refsheld: true id=1
type-aliasestransparent aliases, bound-only unions, inline requires at the call sitetrip=1500 plain=1500 ridge/trench kind=trench

Output goes through ink’s Logger (the log.info lines above) — the same logging surface the std packages use (core and the swappable packages).

Code tour

A classic, whole

demo/src/examples/quicksort.rut is the archetype — imports, one algorithm, one log line — and the shared-handle mutation law in action (the sort writes through the caller’s vec). Verbatim, so what runs here is exactly what the playground edits:

use pouch::{ Vec };
// Quicksort — in-place Vec<i32> mutation (vecs are handles: the
// mutation is shared with the caller), recursion, explicit conversions.

use ink::{ Logger };

fn swap(mut xs: Vec<i32>, a: i32, b: i32) {
    let t = xs[a];
    xs[a] = xs[b];
    xs[b] = t;
}

fn partition(xs: Vec<i32>, lo: i32, hi: i32) -> i32 {
    let pivot = xs[hi];
    let mut i = lo - 1;
    for (let j = lo; j < hi; j += 1) {
        if (xs[j] <= pivot) {
            i += 1;
            swap(xs, i, j);
        }
    }
    swap(xs, i + 1, hi);
    return i + 1;
}

fn quicksort(xs: Vec<i32>, lo: i32, hi: i32) {
    if (lo >= hi) { return; }
    let p = partition(xs, lo, hi);
    quicksort(xs, lo, p - 1);
    quicksort(xs, p + 1, hi);
}

pub fn main() {
    let log = Logger.new("sort");
    let xs = Vec<i32>.from([5, 2, 9, 1, 7, 3, 8, 2]);   // fixed -> growable
    quicksort(xs, 0, xs.len() - 1);

    let mut out = "";
    for (let i = 0; i < xs.len(); i += 1) {
        out = f"{out}{xs[i]} ";      // `mut`: rebinding; the f-string form builds in place
    }
    log.info(f"sorted: {out}");
}
sorted: 1 2 2 3 5 7 8 9 

(The line above ends with the trailing space the program builds — one after every element — kept byte-for-byte.)

Compare with 01 — Sort: same algorithm, no host — run it here and compare with the fuel-counted host sweep.

Erasure and checked recovery, in one screen

The opaque case is the reference for the erasure primitive — opaque(v) forgets the static type, opaque.downcast<T> answers the nullable, and is names the box, never the payload (opaque — erasure and downcast). The case’s erase_and_recover verbatim, with its two payload types and a main so it runs in place:

use ink::{ Logger };

struct Point { x: f32; y: f32 }
enum Flavor { Sweet, Sour }

fn erase_and_recover() {
    let log = Logger.new("opaque");
    let box1 = opaque(Point { x: 1, y: 2 });    // erasure = type-call;
    let box2 = opaque(Flavor.Sour);             // zero-copy (shares the cell)
    let box3 = opaque("hello");

    let p = opaque.downcast<Point>(box1);       // ?Point — the nullable
    when (p != nil) {
        true -> { log.info(f"point {p.x} {p.y}"); },
        else  -> { log.info("point: nil"); },
    }
    let wrong = opaque.downcast<i32>(box2);     // wrong type: nil, no trap
    log.info(f"sour? {opaque.downcast<Flavor>(box2) != nil} wrong? {wrong == nil}");
    log.info(f"is str: {box3 is str}");   // `is` names the box — misses every payload type; downcast recovers
}

pub fn main() {
    erase_and_recover();
}
point 1 2
sour? true wrong? true
is str: false

How a case is wired

Each classic is registered in demo/src/examples/index.ts — an id, a blurb, and the raw source, in the playground’s order. The table above quotes each case’s first log line, and the tour’s blocks run right here — quicksort exactly as shipped. Two details worth noticing when you browse: the corpus is walked by the parser conformance test too (alongside examples/ and the std tree — every .rut file in the repo must parse clean), and one quicksort log line ends with a trailing space the program builds on purpose — run it above and look closely.

Takeaways

  • The classics are live sources, not string copies: the playground edits what the repo ships.
  • Every case is exercised by two gates — native and wasm — over the same engine.
  • Seventeen files cover the language surface in run-sized doses: algorithms, types and traits, strings and bytes, erasure, patterns, maps, and the memory shapes.
  • When you change the language, these files are the first smoke test — and often the clearest place to demonstrate the change.

For the bigger, host-driven versions of the same ideas, start at 00 — Todolist and work up to 06 — GitHub viewer CLI.

Lexical structure

The character-level rules of rut source: files, comments, identifiers, the keyword set, and the enforced naming conventions.

Source model

  • Files are UTF-8, extension .rut. Both LF and CRLF line endings are accepted (CRLF is normalized); a leading BOM is skipped.
  • Comments:
    • // line comment
    • /* block comment */ — block comments do not nest
    • /// doc comment (also /** ... */) — attaches to the following declaration and is kept for tooling; it is not semantic
  • There is no off-side rule: statements end with ; (required), blocks are braced.

Identifiers and $

ident := (letter | "_" | "$") (letter | digit | "_" | "$")*

$ is an ordinary identifier character, valid anywhere in a name: on_mount$, viewport_size$, $temp, $x1, even $ alone. There is no $ punctuation token and no $-interpolation (f-string holes use { } — see Literals and inference).

The trailing $ is a naming convention, not a lexical category: it marks dispatch-inverted members — anything a runtime fires or owns rather than you calling it. Event props (on_click$), lifecycle hooks (on_mount$, before_destroy$), subscription updates (on_update$), watch controls (start$, stop$), engine atoms (viewport_size$), and mutation handles generally. Never on functions you call, widget builders, or types.

Keywords

The keyword set is exactly:

fnletmutifelse
whileforofreturnwhen
enumstructclasstraitimpl
requiresusepubstaticasync
awaitexternishostselect
truefalsenil

Contextual words — ordinary identifiers elsewhere:

WordSpecial meaning
typethe alias introducer (type Km = Meters; — see Type aliases and union bounds)
entryentry fn at module scope publishes the function to the embedder
selfthe explicit receiver, first parameter of an instance method
Selfnames the enclosing class inside its body; the class-private literal Self { .. }
newnot special — the conventional construction-method name (Rect.new(..)); there is no new expression
asthe numeric cast (x as u32) and the select arm bind
superonly inside pub(super)
builtindeclaration modes of the engine’s own surface (builtin fn, builtin primitive, builtin trait, builtin impl)

panic(msg) and assert(cond, msg?) are prelude functions, not keywords.

Reserved words

Each reserved word is a hard error whose message names the rut replacement:

ReservedError replacement
switch, case, matchwhen — arms are pattern -> body
null, undefined, voidabsence is nil on a ?T
anya trait type or opaque
typeof, instanceofx is T tests at runtime
extendsno inheritance — compose instead
interfacerut spells this trait
dataclassremoved — spell it struct
deleteno dynamic properties
initeration is for (let x of ..)
with—
var, constbindings spell let / let mut
privatemembers are private by default; add pub

Naming conventions (enforced)

  • Types are PascalCase — user types and parameterized builtins: Vec<T>, [T], Weak<T>, opaque, Task<T>, Point, Drawable. Scalars and simple buffers stay lowercase: i32, u8, f32, bool, str, bytes.
  • Functions and methods are lower_snake_case — unwrap_or(d), push(v), checked_add(y), spawn_worker(..).
  • Construction is a method call, never a type-call. User classes construct through their own class methods: Rect.new(3, 4), Rect.from(other), Version.parse(s) — see Classes and constructors. Only builtin surfaces keep call forms: bytes.zeroed(n), Weak(v), opaque(v), the repeat [v; n], and Vec<T>.from(..) (see Builtin generic types).
  • The trailing-$ marker is kept meaningful by this style rule alone; the compiler attaches no semantics to it.

Modules and visibility

Module scope contains declarations only — every statement lives inside a function, and loading a module executes nothing. Visibility is private by default, with package-scoped forms for libraries.

Module structure

Allowed at module scope:

DeclarationSpelling
importuse pkg::{ A, B }; / use pkg::A;
bindinglet name: T = expr; (also under pub)
enum / struct / class / traitenum E { .. }, struct S { .. }, class C { .. }, trait I { .. }
impl blockimpl T { .. }, impl I for T { .. }
functionfn f(..) { .. }, async fn f(..) { .. }
entry pointentry fn f(..) { .. }
aliastype X = A; (see Type aliases and union bounds)

Anything else — calls, any statement — is a compile error: statements are not allowed at module scope — modules contain declarations only.

host fn / host struct and builtin declarations are signature-only native surfaces: host rows live in declaration files (.d.rut) and builtin rows in the engine’s own core surface, never as executable bodies in .rut.

Uses

use pouch::{ Vec };
use ink::Logger;

pub fn main() {
    let log = Logger.new("uses");
    let v = Vec<i32>.new();
    log.info(f"{v.len()}");
}
0

The package is one bare identifier; the names are one or more idents. rut is fully statically typed: the compiler resolves every used name and knows from usage whether it lands in type position (Vec in an annotation) or value position (Logger.new(..)), so there is nothing for the user to annotate. An unreferenced use name is a lint, not an error.

The engine’s builtin names — the primitives, opaque, panic, assert, type_id<T>(), str(x), the builtin traits — are ambient: no use is needed for them. Package code (pouch, ink, nmapset, …) mounts only through use.

Module-level let

Initializers must be load-time expressions: literals, enum members, builtin operators over load-time expressions, struct literals whose fields are load-time expressions, fixed-array literals whose elements are, type_id<T>(), and builtin zero allocations ([nil; n] with a const n). Calls to user functions are not load-time expressions.

let KIND_ADD: i64 = 1;
let ORIGIN = Point { x: 0, y: 0 };

There is no mutable module state: program state is constructed in main, or held by the embedder and passed across the boundary. No user code runs at load.

Entry points

The embedder loads a module and then explicitly calls an entry function — conventionally pub fn main, sync or async. entry fn publishes a function to the embedder; entry is orthogonal to visibility and does not combine with pub. Consequences of declarations-only loading: no use side-effect ordering, no load-order bugs, deterministic and cheap loads.

Bindings: let vs let mut

  • let x = e; binds immutably — no reassignment, no field assignment through x, and no mut self method calls on it. It is a read-only view of the shared object.
  • let mut x = e; may be reassigned and grants write access to the object it holds. Every non-primitive is a shared cell (see By-reference and nullable), so that write is visible through every other handle: mut is permission, never a copy. Every assignment path must run through a mut binding.
  • Parameters may declare mut name — the callee’s permission to mutate the caller’s object: fn step(mut p: Point) moves the caller’s point; fn area(p: Point) promises not to.

Visibility

Modules form a tree per package (files in directories; the package root is the root module). Every declaration — and every class member — carries a visibility:

FormMeaning
pub fn ..public — nameable by any rut module (other packages included)
pub(mod) fn ..visible everywhere inside this package’s module tree
pub(super) fn ..visible to the parent module only
pub(self) fn ..module-private — the default
  • Unannotated = pub(self): nothing leaks unless it says pub. There is no private keyword — the unannotated default is the private spelling.
  • Applies uniformly: let, enum, struct, class, trait, impl (an impl exports with its target type), fn, type aliases. On a class declaration it means the type name is visible.
  • Class members take the same forms: an unannotated field or method is module-private; pub (optionally scoped) exposes it. See Classes and constructors.
  • Dataclass members are always public — no visibility dial (see Structs). Trait method signatures and impl methods are as visible as their trait (see Traits and dispatch).
  • Visibility is checked at compile time; it has no runtime representation. Only pub names enter a module’s export table; a non-exported declaration is known inside its module but nameable nowhere else — which is how libraries hide implementation surfaces behind exported ones.

Primitive types

The primitive types, the one sharing regime, and integer semantics.

The type table

GroupTypesNotes
unsigned intu8 u16 u32 u64fixed width
signed inti8 i16 i32 i64two’s complement
floatf32 f64IEEE 754
miscbool
textstrimmutable UTF-8, length-prefixed; compared by content; s.slice(a, b) is an O(1) view (see String slicing and views)
binarybytesimmutable, content-compared octet buffer; the engine-level u8 array
seqVec<T>mutable, growable buffer — shared; a library class over [T] (see Builtin generic types)
seq[T]fixed array — shared; runtime length, non-growable
nullable?Tnil-able cell; nil is the null (see By-reference and nullable)
erasureopaquethe erasure box (see opaque — erasure and downcast)
userstruct / class recordsshared cell handles (see Structs, Classes and constructors)

There is no character type: 'x' does not parse, and str iteration yields one-codepoint strs. Codepoints are spelled with integers:

MemberMeaning
s.code() -> u32the FIRST codepoint of s (traps on empty)
s.code_at(i: i32) -> u32the codepoint at codepoint index i (traps out of bounds — the index is a bug, not data)
str.from_code(n: u32) -> strthe 1-codepoint str for n

There is no null and no undefined: absence is nil on a nullable ?T.

Size and members

.len() is the sequence member shared by every sequence: [T], Vec<T>, str (codepoints), bytes (octets). There is no .length property or .count() variant anywhere in the language.

str members: len(), code(), code_at(i), encode() -> bytes, slice(from, to) -> str, starts_with(from, head) -> bool, scan(from, set) -> i64. bytes members: len(), decode() -> str (UTF-8, lossy), clone() -> bytes; type-methods bytes.zeroed(n) -> bytes and bytes.from(a: [u8]) -> bytes. str.encode() and bytes.decode() convert between text and octets.

One regime: primitives by value, everything else shared

Primitives (u8..u64, i8..i64, f32/f64, bool) and fn values copy on assignment, passing, and return — plain slot moves. Every other type is a refcounted heap cell handle: assignment shares, and mutation through any alias is visible through all of them — struct and class instances, str, bytes, Vec, [T], enums, opaque boxes, trait-typed values, ?T boxes alike. Writing is gated by the mut-binding law (see Modules and visibility), never by the sharing.

There is no eager copy and no own(x): bytes.clone() is the one copy escape hatch. There is no &/* syntax anywhere. == on cells is identity (the raw slot compare); str/bytes compare by content — the full table is in Rc, dispose, and identity.

No box<T>, no loans: a loan needs an exclusivity proof, and rut has no borrow checker. The construct is rejected, not deferred. The only borrows anywhere are host-side, call-scoped ones at the embedding boundary.

Tuples and the answer channel

Tuples are first-class values: type (A, B), value (a, b), numeric field access .0, .1, .., destructuring:

let (lo, hi) = bounds;
fn checked_add(self, y: u8) -> (u8, bool);

(?T, err) — concretely (value, ok) / (T, str) — is the answer channel for results and errors. The convention is law:

  • empty err + a value = success
  • empty err + nil = “not found” (a legitimately absent value)
  • non-empty err = failed

checked_add/checked_sub/checked_mul return (value, ok) — false exactly on overflow, value the wrapped bits either way.

Integer semantics

  • Overflow in + - * << traps in debug and release. Wrapping escapes are compiler-lowered methods, ambient on every integer primitive (no use):

    FamilyMembers
    wrapping (two’s complement)wrapping_add wrapping_sub wrapping_mul wrapping_shl
    saturatingsaturating_add saturating_sub saturating_mul
    checked ((T, bool))checked_add checked_sub checked_mul
  • Division by zero traps; int / int is integer division.

  • Mixed-width arithmetic is an error — both operands must have equal width; convert first with as.

  • Conversions are the numeric cast expr as T, truncating like C/Rust — see Literals and inference. There are no implicit numeric conversions at all.

Builtin generic types

The built-in sequence and wrapper surfaces: the heap array [T], the nullable ?T, the growable Vec<T>, Weak<T>, and the keyed collections.

[T] — the heap array

[T] is the spelling of the fixed array: runtime length, non-growable. The type is grammar, resolved directly — no use names it.

use ink::{ Logger };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let xs: [i32] = [1, 2, 3];   // the literal allocates the cell
    let ys: [?Point] = [nil; 4]; // the repeat: a VALUE and a count
    log.info(f"{xs.len()} {ys.len()}");
}
3 4
  • Construction is the repeat expression [v; n] — a value and a count; there is no type-in-expression form. A scalar/nil fill is the memset-class op; a ref fill retains the cell handle n times — every slot aliases the one cell (the sharing law: the repeat never copies).
  • [T] is a cell handle — shared like every non-primitive: assignment aliases, mutation is visible through every handle.
  • a[i], a[i] = x, .len(), and for (x of a) are compiler-lowered to the fused array ops — never a per-element call. Out-of-bounds traps.
  • Fixed-length windows: v.slice(from, to) — see String slicing and views.

The old Array<T> name is removed: the array type is spelled [T], construction is the repeat [v; n].

?T — the nullable

?T is a nil-able cell: a one-slot box whose payload is a T or the null slot. nil is its null literal — and the empty type’s one value: a context-free nil has type nil, while nullable positions (let p: ?T = nil, p == nil, left: nil in a literal) type it as ?T. Dereferencing nil is the NilDeref trap — never a silent read.

The spelling is prefix-only and binds tightest — ? applies to the type term that follows:

SpellingTypeReading
?TT | nilthe nullable
[?T][T | nil]array of nullables
?[T][T] | nilnullable array
??Tchainedthe same runtime box, unwrapped transitively at use sites
  • Coercions: T → ?T boxes (the box aliases the payload’s cell — a share; primitives copy bits), ?T → T derefs (a field-0 read plus nil check). Both are implicit at the expected-type position; the funnel is transitive through ??T.
  • Auto-deref covers every value position: p.x, p.m(..), p[i], for (x of p), arithmetic on p’s payload.
  • p == nil / p != nil compare against the null slot; ?T == ?T is slot identity (see Rc, dispose, and identity).
  • A ?T binding IS the cell reference — writes through it hit the shared cell (gated by mut, see Modules and visibility).
  • on_drop<T>(p: ?T, cleanup: fn(?T)) attaches a cleanup that runs when the cell’s refcount reaches zero — one callback per nullable, a second attach is a compile error.
  • Across the host boundary ?T crosses nil-flattened when its element crosses.

The removed pointer spellings diagnose: *T and postfix T? point at ?T; expression *x/&x point at the sharing law (“pass x directly”). See By-reference and nullable.

Vec<T> — the growable sequence

Vec<T> is a library class (package pouch) over the non-growable [T]: a buf: [?T] backing plus a live len. Loads yield the ?T (uses auto-deref), stores take the coerced handle — reads and writes alias the stored cells, and binding an element copies nothing.

ConstructionMeaning
Vec.new()empty
Vec.with_capacity(n)reserve n slots
Vec.filled(v, n)n slots of v
Vec.from(arr)copy a [T]

Explicit type arguments may be spelled at the call: Vec<i32>.from([1, 2, 3]). There is no Vec<T>(..) type-call — construction is always a method call (see Classes and constructors).

MemberMeaning
push(v)append; amortized O(1) growth
pop() -> Tremove and return the last element; traps on empty — guard with len() > 0
v[i], v[i] = xelement access; out-of-bounds traps
len() -> i32live length
for (x of v)iteration; x is the shared element
slice(from, to) -> ?Vec<T>fixed-length window (compiler-lowered) — writes through it hit the parent
as_array() -> [T]copy the live elements into a fresh, exactly-sized array
freeze() -> bytesVec<u8> only: copy the live octets into the immutable bytes

Vec<u8> is the mutable binary builder; bytes is the binary type that crosses the host boundary (see Primitive types).

Weak<T> — the weak reference

Weak<T> is a builtin class whose box holds an unretained word to a referent — a weak never keeps anything alive.

use ink::{ Logger };

struct Tile { v: i32; }

pub fn main() {
    let log = Logger.new("t");
    let tile = Tile { v: 7 };
    let w = Weak(tile);            // call-of-the-type-name construction
    let got: ?Tile = w.upgrade();  // the live referent, or nil once dead
    log.info(f"{got.v}");
}
7
  • Weak(v) traps on a nil v; T must be a reference type (Weak<i32> diagnoses — primitives move by value). Weak<?U> is legal and upgrade() answers ??U.
  • The referent’s death nulls every weak box before any user code runs; upgrade() answers nil deterministically from then on.
  • A weak edge closes no cycle: strong cycles still leak — see Rc, dispose, and identity.

Keyed collections

HashMap<K, V> and HashSet<T> (package nmapset) wrap a native key table. Admission is the compile-time union bound K requires i8 | i16 | i32 | i64 | u8 | u16 | u32 | u64 | bool | str | bytes — a key type outside the set fails at the instantiation (escape hatch: encode it canonically to bytes). Float keys are absent by design: floats have no stable equality contract. The API is new/with_capacity/put/get/has/remove/len; get answers ?V — nil is absent, and a hit returns the stored cell, not a copy (the aliasing law).

Absence and errors

There are no Option/Result builtins — the spellings diagnose with their replacements:

  • Absence is nil on a nullable: a lookup returns ?V, and nil means “not found”.
  • Errors are the answer channel: (?T, err) — see Primitive types.
  • Type-erased recovery is opaque.downcast<T>(o) -> ?T — see opaque — erasure and downcast.

== on the removed sum spellings is a compile error. Compare structurally: when, a nil/!= nil guard, or the payload.

Enums

enum — simple named integer sets. This is the entire feature: rut has no data-carrying enums. Heterogeneous data goes through traits (see Traits and dispatch); absence goes through ?T (see Builtin generic types).

Syntax

enum := 'pub'? 'enum' Ident '{' member (',' member)* ','? '}'
member := Ident ('=' int)?
use ink::{ Logger };

enum Color { Red, Green, Blue }              // 0, 1, 2
enum Direction { Up = 1, Down, Left, Right } // 1, 2, 3, 4

pub fn main() {
    let log = Logger.new("t");
    log.info(f"{Color.Blue} {Direction.Right}");
}
Blue Right
  • An enum is a distinct named type over fixed-width integer constants. Members are the enum’s values: implicit numbering continues from the last value (starting at 0); an explicit initializer (a possibly negative integer literal) resets the counter.
  • No data payloads, no methods, no computed members — ever.

Using members

Members are named through the enum and compare as equal singletons:

use ink::{ Logger };

enum Light { Red, Yellow, Green }

pub fn main() {
    let log = Logger.new("t");
    let l = Light.Yellow;
    log.info(f"{l == Light.Yellow}");   // true — members are immortal singleton cells
}
true
  • An enum value renders as its member name in format strings: f"{Color.Red}" is "Red".
  • Enums are ordinary shared values: binding shares the singleton.

Exhaustive when

when over an enum must be exhaustive: cover every member (then else is optional) or add an explicit else arm — a partial when is a compile error. See Control flow and when.

use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn go() { let log = Logger.new("t"); log.info("go"); }
fn brake() { let log = Logger.new("t"); log.info("brake"); }
fn stop() { let log = Logger.new("t"); log.info("stop"); }

pub fn main() {
    let l = Light.Yellow;
    when (l) {
        Light.Green  -> { go(); },
        Light.Yellow -> { brake(); },
        Light.Red    -> { stop(); },   // all members: no else needed
    }
}
brake

Boundaries

  • Where another language would use a union of literals ("left" | "right"), rut uses an enum; where it would use a union of shapes, rut uses a trait-typed value (see Traits and dispatch).
  • The | spelling exists only for bound-only union aliases and requires bounds (see Type aliases and union bounds) — a compile-time admission gate, never a runtime union value.
  • There is no enum↔int cast: as is the numeric cast only. Enum values cross the host boundary as their runtime identity plus the integer value.

Literals and inference

Numeric suffixes, the default-width rule, casts, tuple literals, string literal forms, and the format-string desugaring.

Inference and conversions

Inference is bidirectional (literal ↔ expected type). A literal without context defaults to i32 (integers) / f32 (floats) — and the default is also a ceiling: an unsuffixed literal adapts to the expected type only while it fits that default.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let a = 10;                // i32 (default)
    let b = 10u8;              // u8 via suffix
    let c: u64 = 10;           // u64 via annotation — fits the default
    let big = 18446744073709551615u64;  // past the i32 default: suffix REQUIRED
    // let bad = 13503953896175478587;  // ERROR — exceeds i32, add `u64`
    log.info(f"{a} {b} {c} {big}");
}
10 10 10 18446744073709551615

Past the ceiling the literal must declare itself with a suffix, so a dropped or doubled digit cannot silently re-base a constant. Floats: magnitude is the trigger, precision is not — 0.1 is fine anywhere; 1.0e300 needs f64 even in an f64 position.

Numeric literals: decimal and 0x / 0b / 0o, _ separators, suffixes u8..u64 i8..i64 f32 f64 (3.14159f32, 0xFF_u32).

The cast — expr as T

Conversions are casts, truncating like C/Rust:

  • int→int keeps the target’s low bits (signed targets sign-extend);
  • float→int truncates toward zero and saturates at the target bounds (NaN → 0);
  • a conversion never traps (arithmetic still does).
use calc::{ Math };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let cast = 300 as u8;      // 44
    let x = 3;
    let y = 4;
    log.info(f"{cast} {Math.sqrt((x * x + y * y) as f64)}");
}
44 5

as binds tighter than *, is left-associative (x as u32 as u64 chains), and its right-hand side is a naming position restricted to the numeric primitives. There are no implicit numeric conversions at all.

Fixed arrays and repeats

  • [e1, .., en] has type [T] — pure data whose literal allocates the array cell; binding the value shares the cell. It infers T bidirectionally like any literal; at module scope it is a load-time expression when every element is (see Modules and visibility).
  • [v; n] is the repeat: n slots of the value v — see Builtin generic types.

Tuples

Tuples are first-class values: type (T0, T1, ..), value (a, b, ..), numeric fields .0 / .1 / .., and destructuring:

use ink::{ Logger };

fn divmod(a: i32, b: i32) -> (i32, i32) {
    return (a / b, a % b);
}

pub fn main() {
    let log = Logger.new("t");
    let pair = (1, "two");         // (i32, str)
    let (n, s) = pair;
    log.info(f"{n} {s} {divmod(7, 2).0}");
}
1 two 3

The (value, ok) / (T, err) pair is the language’s answer channel — see Primitive types.

String literals

Three forms — a plain "..." string is always inert (no interpolation ever happens implicitly):

FormExampleMeaning
plain"hi\tname"escapes processed: \t \n \r \b \f \\ \" \u{...}
rawr"C:\temp\log.txt"no escape processing; every byte is literal
formatf"hi {name}, n={n}"Rust-style placeholders, evaluated at runtime

Raw and format do not combine (rf"..." does not parse).

Format literals — f"..."

  • { expr } splices an expression’s value: identifiers, paths, calls, arithmetic — any expression except nested string literals (bind one to a name first). The lexer balances braces to find the closing }.
  • {{ and }} are literal braces. Escapes work exactly like plain strings.
  • The literal desugars at compile time to a concatenation of the literal chunks and one str(x) conversion per placeholder — a builtin per-type formatting call, no runtime parsing:
f"a={a} b={f(b())}"   ->   concat("a=", str(a), " b=", str(f(b())))

Formattable types and their rendering:

TypeRendering
intsdecimal, - for negatives
f32/f64shortest round-trip decimal (3.5, 0.1, 1e300)
booltrue / false
strcontents, verbatim
enummember name (Color.Red → "Red")

Everything else — struct/class values, vecs and fixed arrays, ?T boxes, opaque, tuples — is a compile error inside f"..." (preventing accidental implementation-detail printing). Write a to_string() -> str method on your type and call it explicitly, and route developer output through your host logger (for example the ink package’s Logger.debug(f"...")).

The accumulator idiom out = f"{out}{chunk}" is recognized by the compiler and appends in place — amortized O(1) instead of copying the whole prefix per step.

Control flow and when

The structured statements, the indexed and iterating for forms, and when — the one match construct, an exhaustive pattern expression. switch/case/default do not exist (each is a reserved word whose error names when).

Statements

  • if (cond) { .. } else if (..) { .. } else { .. } — braces required.
  • while (cond) { .. }.
  • return expr?; — expr is required unless the function returns nil.
  • break / continue exist for loops; there are no labels and no do..while.

for — two forms

Iterating form — vecs, fixed arrays, slices, strings (one-codepoint strs per step), bytes (u8 per step), and any type with a registered Iterator impl (see Traits and dispatch):

for (let x of expr) { .. }

Indexed form — the induction variable is loop-owned: the update clause and the body may assign it without mut; it is not a normal binding:

for (let i = 0; i < n; i += 1) { .. }

when

when (x) { pattern -> body, ... } is an expression: the first matching arm’s body produces its value. No fallthrough; exactly one arm runs.

use ink::{ Logger };

fn describe(n: i32) -> str {
    return when (n) {
        0         -> "zero",
        1, 2, 3   -> "small",   // comma-separated alternatives
        else      -> "big",     // non-enum scrutinee: else REQUIRED
    };
}

pub fn main() {
    let log = Logger.new("t");
    log.info(f"{describe(0)} {describe(2)} {describe(10)}");
}
zero small big

Patterns

PatternExample
enum member (dotted path)Light.Green
literal0, 3.5, true, "text"
negative literal-1
alternatives (comma list)1, 2, 3
wildcard else / _else -> ..

Ranges, destructuring, binding patterns, and guards (x if cond) do not exist; patterns are enum members, literals, alternatives, and the wildcards only.

The laws

  • Exhaustiveness is always enforced. An enum-typed scrutinee must cover every member (then else is optional) or add else — a partial when is a compile error. Any other scrutinee type (integers can’t be enumerated): else is mandatory.
  • All arms must agree on one type — that is the when’s type. Used as a statement, that type must be nil (arm bodies that are evaluated for effect are written as block arms).
  • Duplicate patterns, and arms made unreachable by earlier ones, are compile errors.
  • Arms use ->. Expression arms are comma-separated; block arms may omit the trailing comma (the corpus writes it).
  • when literal patterns match compile-time values, never runtime ==.
use ink::{ Logger };

enum Light { Green, Yellow, Red }

fn go() { let log = Logger.new("t"); log.info("go"); }
fn brake() { let log = Logger.new("t"); log.info("brake"); }
fn stop() { let log = Logger.new("t"); log.info("stop"); }

pub fn main() {
    let l = Light.Yellow;
    when (l) {
        Light.Green  -> { go(); },
        Light.Yellow -> { brake(); },
        Light.Red    -> { stop(); },   // every member: else optional
    }
}
brake

A when is also the idiomatic nullable guard, together with != nil:

use ink::{ Logger };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let p: ?Point = Point { x: 7, y: 0 };
    when (p != nil) {
        true -> { log.info(f"{p.x}"); },
        else -> { /* absent */ },
    }
}
7

Structs

struct — the open data record: all fields public, constructed only by literal, sharing its cell like every non-primitive.

Declaration

use ink::{ Logger };

struct Point {
    x: f32;
    y: f32;
}

struct Style {
    color: u32 = 0xff00ff;   // field initializer: literals may omit it
    width: f32 = 1;
}

pub fn main() {
    let log = Logger.new("t");
    let s = Style {};
    log.info(f"{s.color} {s.width}");
}
16711935 1

The keyword is struct. The removed spelling dataclass is a reserved word whose error names the replacement.

Reference semantics

A struct value is a heap cell handle (see By-reference and nullable): assignment, argument passing, and returning share the cell, and a mutation through any alias is visible through all of them.

use ink::{ Logger };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let mut p = Point { x: 1, y: 2 };
    let q = p;                       // SHARE: q and p name one cell (O(1))
    p.x = 4;                         // q.x is 4 now — sharing is the law
    log.info(f"{q.x}");
}
4

Writing is gated by the mut-binding law (see Modules and visibility): field stores and mut self methods need a let mut binding (or a mut parameter). Parameters declare their intent: fn nudge(mut pt: Point) may write the caller’s point; fn length(pt: Point) -> f64 promises not to, and returns a fresh record instead:

use ink::{ Logger };

struct Point { x: i32; y: i32; }

fn nudged(pt: Point) -> Point {     // builds a NEW record
    return Point { x: pt.x + 1, y: pt.y };
}

pub fn main() {
    let log = Logger.new("t");
    let p2 = nudged(Point { x: 1, y: 2 });
    log.info(f"{p2.x} {p2.y}");
}
2 2

== on two struct values is a cell-identity test — q == p is true exactly when they name one cell; two separately built literals are never equal. Field-wise comparison is a trait contract of your own (declare and implement it — see Traits and dispatch).

Construction — the literal, everywhere

Name { field: expr, .. } is the only construction. There is no new, no class methods, no type-call. The literal is available everywhere — function bodies and module-level let initializers alike — and it allocates the cell.

  • Fields may be given in any order, by name.
  • The literal must initialize every field that has no initializer.
  • An omitted field with an initializer takes it; {} with all-default fields is legal.
use ink::{ Logger };

struct Point { x: i32; y: i32; }
struct Style { color: u32 = 0xff00ff; width: f32 = 1; }
struct Rect { min: Point; max: Point; }

pub fn main() {
    let log = Logger.new("t");
    let q = Point { x: 9, y: 9 };
    let s = Style {};                     // zero-value defaults fill the fields
    let r = Rect { min: Point { x: 0, y: 0 }, max: q };  // max shares q's cell
    log.info(f"{s.color} {r.max.x}");
}
16711935 9

All fields public, always

A struct is an open data record: member visibility in a struct body is a compile error (privacy needs construction control, which is the class’s job — see Classes and constructors). Structs also have no static members.

Methods live in impl blocks

A struct body is fields only — a fn member in the body is a hard parse error. Inherent methods live in impl S { .. }, in the type’s module only; trait impls in impl I for S { .. } (see Traits and dispatch):

impl Point {
    fn dist(self, other: Point) -> f32 { .. }   // inherent — the type's module
}

impl Hashable for Point {
    fn hash(self) -> u64 { .. }
    fn eq(self, other: Point) -> bool { .. }
}

Free functions over data remain the default idiom; methods are for tight helpers, impl blocks for trait contracts.

Limits, exhaustively:

  • no member visibility — all fields are public, always;
  • no class methods — the literal is the only construction (open literal vs class-method-gated is the struct/class distinction);
  • no destructor — a value shared everywhere has no single death to hook; if you need one, write a class and attach on_drop (see Rc, dispose, and identity).

Everything else class-shaped is allowed, including impl blocks.

Traits and representation

  • Widening a struct to a trait I attaches the impl vtable to the same handle — no allocation, no copy: the trait-typed value aliases the record, and mutations through it are visible to every other handle.
  • Representation: one slot per field inside the cell, in declaration order — primitive fields widened into their slot, composite fields as cell-handle slots (see Reified types and layout). Recursive shapes (next: ?Node) are legal because composite fields are pointer-sized.
  • Structs are for small data (points, rects, colors, configs), but any size is allowed.

Classes and constructors

class — the sealed record: module-private fields by default, construction gated behind class methods, no inheritance.

Declaration and construction

use ink::{ Logger };

class Rect {
    w: f32;
    h: f32;
}

impl Rect {
    pub fn new(w: f32, h: f32) -> Self {   // construction VALIDATES — it is
        if (w <= 0 || h <= 0) {            // just a function
            panic("Rect: negative extents");
        }
        return Self { w: w, h: h };
    }

    pub fn from_square(s: f32) -> Self {   // named constructors are siblings
        return Rect.new(s, s);
    }

    pub fn area(self) -> f32 { return self.w * self.h; }
}

pub fn main() {
    let log = Logger.new("t");
    let r = Rect.from_square(3);
    log.info(f"area={r.area()}");
}
area=9
  • Classes construct through their own class methods — nothing else is constructible. There is no constructor keyword and no type-call: Rect(3, 4) does not parse as construction, and no outside literal exists:

    let r = Rect.new(3, 4);            // the one construction surface
    let v = Version.parse("1.2");      // ?Version — nil on failure
    // Rect { w: 1, h: 1 };            // ERROR: classes have no outside literal
    

    new is not special syntax — just the conventional primary-constructor name (from, parse, open, default are its siblings); it is an ordinary identifier. Try-construction returns the nullable: a class method fn parse(s: str) -> ?Version answers nil on failure.

  • The Self { field: expr, .. } literal is the class-private construction — legal anywhere inside the class body (class methods and instance methods alike); the class name spells it inside the body too (Rect { .. }). The literal must initialize every field without an initializer; field initializers run for omitted fields. Private fields are settable in the literal — inside the class body only. That privacy is the seal: outside code can build a class value only by calling a class method that chooses to build one.

  • No implicit default construction. A class whose fields all have initializers still needs an explicit fn new() -> Self { return Self {}; } if outsiders should build it. A class with no accessible constructing class method is sealed — constructible only inside its own body.

  • No parameter properties: parameters are parameters — the Self { field: name } literal makes the param→field mapping explicit.

  • async class methods are allowed — same function, await in the body. No partially constructed instance ever exists across an await: the Self { .. } literal is an ordinary expression, and locals live in the coroutine frame.

Methods: explicit self

  • The receiver is explicit. An instance method spells its receiver as the first parameter — fn add(self, x: i32, y: i32) — and the body reads fields through self. There is no this keyword. A method that mutates declares mut self and requires a let mut receiver (see Modules and visibility).
  • A method without a self parameter is a class method — invoked on the class itself (Rect.new(..), Self.new(..) inside the body). Presence or absence of self is the whole distinction; there is no separate “static” method form (static fn does not parse). Class methods are ordinary functions: they validate, default, cache, register, or hand out singletons.
  • No get/set accessor syntax anywhere — a computed property is a method (c.count()), and a settable one takes an argument (c.set_count(n)). One member kind, one call convention.
  • Static fields are declared static name: T = init; in the class body, with the same visibility forms as fields.

Member visibility

Members follow the same visibility forms as declarations (see Modules and visibility): an unannotated field or method is module-private; pub, pub(mod), pub(super), pub(self) expose it to the form’s audience. The construction surface is therefore explicit: pub fn new(..) builds; unannotated members stay the class’s own business within its module.

Reference semantics and equality

A class value is a heap cell handle like every non-primitive (see By-reference and nullable): assignment shares, mutation is visible through aliases. == is cell identity; field-wise comparison is an opted-in trait contract.

Reflection is opt-in: a class is walkable only where a serialization contract has been implemented for it by hand; structs are the open, auto-walkable records.

No inheritance

  • No extends for classes — no base-class constructors (super(..)), no method overriding, no super.m(), no protected. (extends is a reserved word; its error says: compose instead.)
  • Code sharing is composition (hold a helper object or struct in a field) or free functions; subtyping is only class→trait widening (see Traits and dispatch).
  • Layout stays trivial: fields at fixed offsets — identical inside every cell payload (see Reified types and layout) — no prefix layout, no fat pointers, and every object has exactly one concrete class forever. That keeps is a single descriptor check.

Rc, dispose, and identity

One memory regime: every non-primitive value is a shared, refcounted heap cell; destruction is deterministic; == is identity for cells.

Reference semantics

Every non-primitive value — struct and class records, str, bytes, Vec, [T], enums, opaque boxes, trait-typed values, ?T boxes, closures’ captured cells — is a heap cell handle:

  • assignment, argument passing, and returning copy the handle (retain/release), an O(1) move;
  • mutation is visible through every alias;
  • writing is gated by the mut-binding law (see Modules and visibility), never by the sharing.

There is no eager copy of any composite. own(x) and make_ptr(v) are removed spellings — bindings share by reference. bytes.clone() is the one copy escape hatch: a one-shot deep copy of a buffer’s octets. Every other type shares on binding; a divergent value of any other type is unreachable — build a new one instead.

Recursive shapes (next: ?Node, trees, lists) are legal: composite fields and elements are pointer-sized handle slots, and the refcount walk sees every handle field via the compile-time field table (see Reified types and layout).

Cleanups — on_drop

Destructors are attached, not implemented. The surface is the builtin

builtin fn on_drop<T>(p: ?T, cleanup: fn(?T)) -> nil;
  • cleanup(p) runs when the referenced cell’s refcount reaches zero — deterministic destruction, not a collector callback.
  • Fields are released after the cleanup body returns.
  • One callback per nullable; a second attach is a compile error.
  • The host side of the same law: a host payload’s finalizer runs at cell death, before the payload’s own Rust Drop.

Because a struct shared everywhere has no single death to hook, structs cannot carry destructors — if you need one, write a class whose handle is the ownership, and attach the cleanup where you mint it.

Weak references

Weak<T> (see Builtin generic types) demotes any handle to a non-keeping reference:

use ink::{ Logger };

struct Tile { v: i32; }

pub fn main() {
    let log = Logger.new("t");
    let tile = Tile { v: 7 };
    let w = Weak(tile);            // does NOT keep the cell alive
    let got: ?Tile = w.upgrade();  // the live referent, or nil once dead
    log.info(f"{got.v}");
}
7

The referent’s death nulls every weak box before any user code runs. A weak edge closes no cycle: strong cycles (two records holding each other, a container that holds its own observer) still leak to engine shutdown — the memory law is the program’s; break cycles with Weak.

Identity — ==

a == b is a builtin operator: no dispatch, no opting in, no element-wise story. a != b is its negation.

Operand type== meansLowering
numeric / bool primitivesvaluecompare, IEEE 754 for floats (NaN != NaN, -0.0 == 0.0)
strcontent (codepoints)content compare
bytescontent (octets)content compare
everything else — records, arrays, Vec, enums, closures, trait objects, opaque, ?Tcell identitythe raw slot compare
  • Identity is the only == sharing can defend: with aliasing everywhere, structural equality of two independently built cells is ambiguous, and identity is O(1) with no deep walk. [1, 2] == [1, 2] is false — two cells.
  • Enum members are immortal singletons, so Flavor.Sour == Flavor.Sour is true — the one place identity quietly behaves as value.
  • ?T == ?T is slot identity: two nils are equal, a null and a box are not; p == nil derefs the nullable side and compares against the null slot.
  • An identity-compare lint flags == between two obviously fresh composites (Vec.from([..]) == Vec.from([..])): “always false — compare fields”. Asserting distinctness is legitimate and suppressible.
  • Field-wise comparison is a library trait contract (hash + eq implemented per type) — the mechanism value-keyed maps ride. It is not connected to ==.
  • when literal patterns are unaffected: arms match compile-time values, never runtime ==.

Traits and dispatch

trait — methods only, no bodies, no defaults. Satisfaction is nominal: the impl block is the admission, and nothing else. Dispatch follows two positive rules, fixed at compile time per call site.

Traits

trait Shape {
    fn area(self) -> f64;        // bodiless signatures; async legal
    fn scale(v: f64);            // no-self methods legal (engine contracts)
}
  • Methods only, no bodies. No fields, no properties (there is no get/set syntax anywhere), and no default implementations, ever — one member kind, one dispatch candidate per call. Anything that reads like a property is a method.
  • Methods are instance methods and spell the self receiver like every other method (fn draw(self, g: Canvas) -> nil;), except where an engine contract spells a receiver-less descriptor method (Future). async fn signatures are legal; an impl’s method must match the trait’s async spelling exactly.
  • Trait members carry no pub — they are as visible as the trait.
  • No object-type keyword anywhere: a trait name in type position is the bare name — d: Drawable, Vec<Widget>, generic arguments included.
  • No top trait. The erased-storage type is the concrete primitive opaque (see opaque — erasure and downcast), reached by an explicit call, never by widening.
  • Intersection types (A & B) are never supported — not deferred: heterogeneous needs compose a trait that declares both method sets.

Impl blocks

Type bodies are fields only; methods live in impl blocks.

impl T { .. } (inherent)impl I for T { .. } (trait)
Lives in.rut, T’s module only.rut, any module of the trait’s pkg or the type’s pkg
Valid targetslocal struct/class, or a builtin class this module declaresany nominal type — at least one of the pair must be local to this pkg
pub(..)classes only (structs are all-public)never — as visible as the trait
asynclegallegal — must match the trait’s signature
no-self methodslegal (constructors)legal where the trait declares them
fields / empty bodynever / legalnever / legal (empty = the opt-in marker)
use ink::{ Logger };

trait Shape { fn area(self) -> f64; }
trait Serializable {}

struct Point { x: i32; }
struct User { name: str; }

impl Shape for Point {
    fn area(self) -> f64 { return self.x as f64; }
}
impl Serializable for User {}      // empty trait impl = the opt-in marker

pub fn main() {
    let log = Logger.new("t");
    let p = Point { x: 5 };
    log.info(f"{p.area()}");
}
5
  • Satisfaction is nominal. A type that declares every member by shape is still not an I until some module writes impl I for T. There is no duck typing and no orphan rule beyond placement: for every impl Trait for Type, at least one of Type or Trait must be defined in the current pkg — both foreign is a compile error. Builtin types ([T], the primitives, ?T, opaque) are in no pkg: only a local trait may be implemented for a builtin.
  • One impl per (trait, type) pair, program-wide. A duplicate — two modules, or two blocks in one — is a link error.
  • Bodies match the trait exactly: receiver form (self/mut self), params, return type, async spelling. A missing signature is an error; so is any extra method in the block (put those in an inherent block).
  • Traits are implemented for classes, structs, and primitives (impl Hashable for i32 registers like any trait impl), while an inherent impl i32 { .. } diagnoses — a primitive’s inherent surface belongs to the engine.
  • Generic traits and generic targets: trait Wrap<T> gives each type-argument list its own instantiation (Wrap<i32> ≠ Wrap<str>); impl Hashable for Pair<A, B> binds the target’s generic args as the impl’s type parameters. Parameterized trait impls are legal: impl Readable<T> for Source<T> registers a template serving every concrete instantiation; a hand-written concrete impl shadows the template; repeated parameters (impl W<T, T> for Pair2<T>) are legal. Each trait-argument must be a concrete type or a bare name of one of the target’s own parameters.
  • The element is a type argument, not an associated type: impl Iterator<char> for Counter — there are no associated type members.
  • Self in impl signatures names the impl’s target under the impl’s substitution: -> Self returns, Self { .. } constructs.

Dispatch — the two-rule law

Every method call compiles under exactly one of two rules; the rule is a property of the call site, fixed at compile time.

  • Static — the call site names exactly one concrete type; the call binds directly to the impl’s method, no vtable hop:
    • a concrete receiver — c.area() on c: Circle;
    • a trait-typed local of single concrete origin — let d: Shape = Point { .. }; d.area() binds straight to Point’s impl;
    • a trait-typed parameter — the callee specializes per concrete argument type (one clone per argument type — finite, terminating), so a trait parameter is an implicit generic bound;
    • a monomorphized generic — inside fn first<T>(..), T’s members are static per instantiation.
  • Vtable — the receiver is trait-typed with multiple possible concrete origins; the call consults the value’s descriptor and its per-(type × trait) method table:
    • heterogeneous container elements — for (s of shapes) over a Vec<Shape>;
    • trait-typed field/element loads;
    • branch-merged origins — let s = if (c) { a } else { b }; where the arms carry different concretes into one trait-typed binding.

Origin counting is conservative: any merge, indirection load, or cross-function flow counts as multiple. Mis-analysis cannot produce wrong code — an uncertain origin costs a vtable hop, never a wrong static bind.

The use-both gate: x.trait_method() requires both the type and the trait to be named at the call site’s module — the type by declaration or use, the trait by use. A call that matches a registered impl whose trait no use names is an error: “use I to call its methods on T”.

Widening is nominal and implicit: a value of T widens to I exactly where the registry holds a visible impl I for T — on assignment, argument passing, and returns. The explicit, greppable form is the trait annotation at the receiving position (let d: Drawable = s;). There is no upcast builtin. A trait-typed value cannot be downcast: use it through the trait, or erase explicitly through opaque.

Type tests — is

expr is Type → bool, at relational precedence, non-associative. The right-hand side is a naming position: a concrete type or a bare trait name/instantiation.

  • Concrete RHS — exact-type test: true when the value’s exact class or struct is T. With no inheritance this is a single descriptor lookup.
  • Trait RHS — capability probe: true when the value’s exact type has a registered impl for that trait (k is Hashable).
  • No flow sensitivity: if (x is Hashable) { .. } grants nothing — no narrowing, no widening. The keyword answers; it does not admit.
  • Static folds: when the receiver’s static type already answers, the result is a compile-time constant, with an always-true/false lint.
  • is is total: never traps, yields only bool. The same descriptor answers the vtable and the probe — one runtime truth per value.

The iteration protocol

A type is iterable when it registers impl Iterator<E> for T:

use ink::{ Logger };

class CountUp {
    n: i32;
}

impl CountUp {
    pub fn new(n: i32) -> Self { return Self { n: n }; }
}

impl Iterator<i32> for CountUp {
    fn __iterate(self, emit: fn(i32) -> bool) {
        for (let i = 1; i <= self.n; i += 1) {
            if (!emit(i)) { return; }
        }
    }
}

pub fn main() {
    let log = Logger.new("t");
    for (let v of CountUp.new(3)) {
        log.info(f"tick {v}");
    }
}
tick 1
tick 2
tick 3

for (v of it) { body } desugars to it.__iterate(emit) with a synthetic closure: the body runs, then emit returns true; break returns false (stopping the iteration); continue returns true immediately; a return inside the body stops the iteration (not the enclosing function). The loop variable is the closure’s parameter — a fresh binding per iteration; captured enclosing locals are copied by value at the desugar, so accumulate through a shared cell or a method. The builtin sequences ([T], Vec<T>, str, bytes) keep their fused index loops and never reach the protocol.

Engine contracts

builtin trait names are compiler-backed but engine-named, not engine-closed — users implement them through the ordinary nominal path:

builtin trait Future<T> { fn yield(cx: RunContext); }
builtin trait RunContext {
    fn checkpoint(self) -> u32;
    fn next_checkpoint(mut self, v: u32) -> nil;
    fn cancelled(self) -> bool;
}

impl Future<nil> for CustomFuture registers in the same registry as any other impl. See Async and await.

Equality

== is a builtin operator with no vtable dispatch: primitives by value, str/bytes by content, everything else by cell identity — see Rc, dispose, and identity. Field-wise comparison is a Hashable-style contract implemented per type; it is not connected to ==.

Functions, closures, and generics

Function declarations, anonymous closures, and monomorphized generics with admission-only bounds.

Functions

use ink::{ Logger };

fn add(a: i32, b: i32) -> i32 {
    return a + b;
}

pub fn main() {
    let log = Logger.new("t");
    log.info(f"{add(2, 3)}");
}
5
  • Parameters are name: Type; a mutable parameter declares mut name — the callee’s permission to mutate the caller’s object (see Modules and visibility).
  • A function without a result arrow returns nil; return; (or falling off the end) is its return.
  • Methods are functions in impl blocks: the first parameter spelled self (or mut self) makes it an instance method; absence of self makes it a class method (see Classes and constructors).
  • entry fn publishes a function to the embedder (see Modules and visibility); async fn declares a suspending function (see Async and await).
  • Function types are first-class: fn apply(f: fn(i32) -> i32, v: i32) -> i32.

Closures

The closure spelling is an anonymous fn — block bodies, no arrow form:

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let add = fn (a: i32, b: i32) -> i32 { return a + b; };
    let area_of = fn (r: f32) -> f32 {
        let sq = r * r;
        return sq * 3.14159265f32;
    };
    log.info(f"add={add(1, 2)} area={area_of(1)}");
}
add=3 area=3.1415927
  • An anonymous fn inhabits fn(P..) -> R directly — it is a value of the function type, copyable like a primitive.
  • Closures capture by reference to the enclosing bindings: mutation through a captured let mut binding is visible to the definer. Refcounting keeps captures alive; a closure is itself a shared cell value.
  • Closures are not transferable across isolates (a worker boundary transfers values, not closures).

Generics

use ink::{ Logger };

fn first<T>(xs: [T], fallback: T) -> T {
    if (xs.len() == 0) { return fallback; }
    return xs[0];
}

pub fn main() {
    let log = Logger.new("t");
    let head = first([10, 20], -1);      // first<i32>   — monomorphized
    let name = first(["a", "b"], "?");   // first<str>   — separate instance
    log.info(f"{head} {name}");
}
10 a
  • Generics monomorphize at compile time: each instantiation emits its own typed code. T infers from the arguments; explicit type arguments may be spelled at call sites, including method calls: self.st.get<T>(self).
  • Trait-typed arguments are ordinary arguments: a T instantiated at a trait type becomes a handle slot, satisfying no bound.
  • Generic parameters are unconstrained by default — you cannot call methods on a bare T. Pass values in, or take an I-typed parameter instead of a generic.
  • There are no const-generic user parameters. The builtin surfaces fix their shapes ([T] is one type; lengths are runtime values).

Inline bounds — requires

gparam := Ident ('requires' bound)?
bound := Type ('|' Type)*

fn f<T requires A | B>(x: T) — an admission-only bound on fn, method, and class generic parameters (struct and trait generic parameters reject requires):

  • The bound gates which instantiations compile: enforcement is at every substitution-completing site (free-fn calls, method instantiation, impl-method enqueue). A failing instantiation diagnoses with the bound spelled out — “bool does not satisfy T requires i32 | str”.
  • Members may be concrete type names (satisfied by exact type identity), aliases (expanded first — see Type aliases and union bounds), a single trait (satisfied via the impl registry), or a type union of concrete names/aliases. A trait member inside a union spelling is invalid — a trait bound stands alone.
  • Trait objects satisfy nothing: a T instantiated at a trait type fails any bound — only a concrete type with a registered impl admits.
  • A non-union bound proves the widening: the body may widen a T-typed value into a bound-member-typed slot (let w: Labeled = x;), but gains no method calls on bare T.
  • A union bound carries the whole-bound contract: a method call on a union-bounded value requires every member to provide the method — even a member that is never actually instantiated. Dispatch is untouched: each instantiation still binds the concrete member’s own impl.
  • Bounds may reference the item’s other generics: fn hold<T, U requires [T]>(x: U).
  • Generic classes take bounds on their parameters — the bound records on the class descriptor and admits every instantiation: pub class HashMap<K requires i8 | .. | bytes, V> (see Builtin generic types).

The trailing where clause does not exist: where is an ordinary identifier, and a stray clause diagnoses with the inline replacement.

opaque — erasure and downcast

opaque is the erasure-box engine primitive: a concrete type whose values box any value — forgetting the static type and keeping the runtime type for downcast only. Erasure is a call you write; the static type system never loosens, and rut has no cast syntax for it (as is the numeric cast only).

The surface

opaque is a declared engine primitive, ambient — no use gates it. Its entire API:

MemberMeaning
opaque(v)erasure: box any value; answers the opaque box
opaque.downcast<T>(o) -> ?Tchecked recovery: the box’s inner cell on a match, nil on a mismatch
x is opaquethe ordinary concrete test for the box type itself
use ink::{ Logger };

struct Point { x: i32; y: i32; }
enum Flavor { Sour, Sweet }

pub fn main() {
    let log = Logger.new("t");
    let box1 = opaque(Point { x: 1, y: 2 });   // erasure = call of the type name
    let p = opaque.downcast<Point>(box1);      // ?Point — the nullable
    when (p != nil) {
        true -> { log.info(f"point {p.x} {p.y}"); },
        else  -> { log.info("point: nil"); },
    }
    let wrong = opaque.downcast<i32>(opaque(Flavor.Sour));  // nil, no trap
    log.info(f"wrong == nil: {wrong == nil}");
}
point 1 2
wrong == nil: true

The laws

  • Construction is erasure. Every value can become opaque. Under the all-cells regime, boxing is zero-copy: the box stores the payload’s cell handle, so mutations through the original name are visible through the box (the alias law, see By-reference and nullable). A primitive payload copies the bits — value semantics where aliasing is unobservable:

    use ink::{ Logger };
    
    pub fn main() {
        let log = Logger.new("t");
        let mut n = 5;
        let b = opaque(n);    // a prim payload COPIES the bits
        n = 9;                // ...so the source moving stays out
        log.info(f"{opaque.downcast<i32>(b)} vs {n}");
    }
    
    5 vs 9
    
  • A match is the box’s own inner cell. The recovered ?T shares the box: writes through the recovery are the source’s, in both directions, through every holder. A primitive payload reads the copied value.

  • A mismatch is nil. There is no flag and no zero value — the miss is the nullable’s null; an unguarded dereference traps NilDeref, never a silent zero.

  • The type argument must be concrete. A trait-typed type argument (opaque.downcast<Drawable>) is a compile error — trait-typed values have no recovery path by design.

  • The opaque-is law: is names the box, never the payload. o is T and o is I answer by the box — false for every payload type, concrete and trait alike. The one check that names what it is — o is opaque — is true. Recovery is downcast<T> only. On a non-box receiver, x is opaque is the ordinary concrete test for the box type.

    use ink::{ Logger };
    
    pub fn main() {
        let log = Logger.new("t");
        let b = opaque("hello");
        log.info(f"{b is str} {opaque.downcast<str>(b) != nil}");
    }
    
    false true
    
  • Boxes are never equal unless identical: opaque(v) == opaque(v) is false — two boxes, two cells. Box once, compare boxes.

  • Trait-typed values cannot be boxed: erasure takes a concrete value. Fixed arrays box fine; their identity covers the element type.

Composition and costs

opaque is a concrete primitive, not a lattice node: it composes in every type position trivially — Vec<opaque>, [opaque], fields, returns, parameters.

use ink::{ Logger };
use pouch::{ Vec };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let boxes: [opaque] = [opaque(Point { x: 3, y: 4 }), opaque("two")];
    let vec: Vec<opaque> = Vec.from([opaque(5)]);
    log.info(f"{boxes.len()} {vec.len()}");
}
2 1
  • The rut-side box is one small cell: the erased value rides inline in the box, and cell churn is recycled by the heap arena. Heap accounting charges the box’s cell, as always (see Reified types and layout).
  • The host side of the same surface: an embedder can mint the box over host data with no static rut shape. rut sees only the box — o is opaque is true, o is T misses for every rut T, and opaque.downcast<T> answers nil for every T (the miss is checked, never a trap). The host borrows the payload back typed, call-scoped and borrow-guarded.
  • Crossing isolates is allowed iff the boxed value’s type is crossable, checked at runtime via the type descriptor.
  • An opaque box can do nothing until it is recovered — no methods, no fields, no format-string rendering. That is the difference from gradual typing: the erased-storage type for a hot loop is a smell; lints flag downcasts inside loop bodies and opaque parameters/returns on non-storage functions.

Reified types and layout

Every value’s type is available at runtime as a type descriptor. Slots are untagged 8-byte values; bytecode is typed, so hot paths carry no tags. This page is the language-facing contract; the descriptor itself is VM data, not a script value.

Where reification is language-facing

SurfaceMechanism
is type tests — concrete and trait RHSthe value cell’s descriptor (see Traits and dispatch)
opaque.downcast<T> recoverythe box’s recorded runtime type (see opaque — erasure and downcast)
type_id<T>()the compile-time type-identity constant
host boundary checksevery crossing is checked against the declared parameter type
diagnosticsstack traces and host tooling read the same descriptors

type_id<T>()

use ink::{ Logger };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let TID_POINT: u32 = type_id<Point>();
    log.info(f"{TID_POINT} eq={type_id<Point>() == TID_POINT}");
}
23 eq=true
  • type_id<T>() -> u32 — the identity of the instantiated type: unique per VM run, stable across modules, comparable only. Vec<f32> ≠ Vec<f64>; Point = Point wherever declared.
  • It is a compile-time constant — a load-time expression (legal in module-level let initializers, see Modules and visibility), folded from the type table, never executed.
  • There is no size_of<T>() / align_of<T>() and no runtime payload footprint accessor: value size and alignment are implementation details, not a language surface.
  • Constructing a value from raw bytes is deliberately not provided: it could forge private fields and class invariants.

Value representation — slot arrays

Every struct and class value lives in a heap cell: header + (vtable, when the type has impls) + the payload.

  • The payload is a slot array: one untagged 8-byte slot per field, in declaration order. Primitive fields are widened into their slot (sign/zero-extended; a float is stored at the canonical 64-bit width); composite fields are cell-handle slots.
  • Visibility, generic parameters, and impl blocks add nothing — the payload depends only on the field list. Field access is by index; heap accounting charges fields × 8 bytes.
  • Buffers of primitive elements (Vec<f32>, [i32]) stay flat, packed to the element’s machine width; composite elements are one handle slot each — Vec<Point> stores one handle per element.
  • Enums are tagged cells (a tag slot plus a payload slot); dataless enum variants are immortal singleton cells.
  • Nullable ?prim element storage inside sequence backings is the raw payload plus a one-byte nil tag — no per-element cell.
  • bytes at the engine level is a u8 array cell; a str is an immutable, COW-shared UTF-8 block, and a slice of it is a small view cell (see String slicing and views).

Cells and vtables

RutCell := Header { rc, type id } VTable* Payload
VTable := { exact type id, dispose trampoline, trait method slots }
  • The exact runtime type lives in the cell (via the vtable when present, the header otherwise). Every cell is minted at construction with its vtable already attached.
  • Trait method ids are assigned globally per trait instantiation at compile time (Slice<Point> ≠ Slice<str>); a type’s vtable fills every slot of every trait instantiation it has an impl for — user impl blocks, auto-fills, and registry entries alike.
  • Widening a composite to a trait I reuses the same cell and vtable: the trait-typed value is the handle plus the vtable pointer — no allocation, no copy. The vtable reserves no base-prefix room: there is no inheritance.
  • A call through a trait object is two loads and an indirect jump (the receiver’s vtable, the method slot). Trait members never devirtualize; inherent calls bind directly:
Op::CallTrait { recv, slot: 3, args }   // d.draw(g) — vtable slot 3
Op::Call     { func: "Circle$area", recv, args }   // c.area() — inherent

The type test

is with a concrete right-hand side lowers to: load the object’s exact type id, compare — a compile-time constant comparison when the static type already answers. The trait-RHS capability probe is: exact-type compare, then a flat scan of the descriptor’s registered impls — no inheritance chain to walk (see Traits and dispatch).

opaque interacts with exactly one of these reads: is reads the box’s own type, so every payload probe misses; only opaque.downcast<T>’s match test keeps reading the payload — the one legitimate see-through.

Boxing

Storing a value into an erasure box widens by the slot discipline: an integer box stores the 64-bit sign/zero-extended value, a float the 64-bit width. A kind check plus opaque.downcast<i64>() / downcast<f64>() / downcast<bool>() / downcast<str>() is therefore total in-branch: within the successful branch the value’s type is the exact concrete type, and everything downstream optimizes as if the value had never been erased.

String slicing and views

s.slice(from, to) — the O(1) string window. A slice is a view: no octets move or copy; the new str cell retains the original and records a byte window. Slicing is safe because str is immutable — a view can never observe mutation, so no pointer spelling is needed.

Surface

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let s = "hello world";
    let w = s.slice(6, 11);        // "world" — no copy
    log.info(w);
}
world
  • from/to are codepoint indices, from <= to <= s.len(); bounds violations trap (same as an out-of-range index).
  • The window is recorded internally in byte offsets — codepoint bounds are resolved once at slice time (a UTF-8 walk only when the string is non-ASCII).
  • The result is an ordinary str: it prints, compares by content (==), iterates (for (c of w)), renders in f-strings, and len() counts its own codepoints. There is no separate view type on the surface — a slice of a str is a str.
  • Binding the slice shares it like every cell (see By-reference and nullable); str has no clone — a materializing copy happens only where an operation needs one (the append fast path, the host crossing).
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let accented = "héllo!";
    log.info(f"{accented.len()} {accented.encode().len()} {accented.slice(1, 2)}");
}
6 7 é

The view cell

A slice mints a small view cell: { parent, off, len, ascii } — a retained handle to the owned parent string plus byte offsets into its block.

  • The view charges only its own header (~32 bytes) against the heap budget; the parent’s octets stay alive as long as any view does (deterministic destruction: view rc-0 → parent release).
  • View-of-view flattens: slicing a view combines offsets onto the root, so the parent is always an owned string and reads never chain.
  • The ascii flag is inherited from the root at slice time (a window of an ASCII string is ASCII).

Reads go through, writes never do

Every string operation reads through views transparently: content equality, codepoint count, indexing, iteration, f-string rendering, encode, and the host boundary (which copies the window’s bytes out).

The in-place append fast path (the out = f"{out}.." accumulator) is gated to owned, uniquely-referenced cells. Concatenating a view copies its bytes out — f"{view}!" yields a fresh owned string, and the view (and its parent) are unchanged. No operation can ever mutate through a string view.

Cost model

OperationCost
s.slice(a, b)O(1) — one small cell + one retain (UTF-8 walk only for non-ASCII bounds)
read/compare/render a viewO(window) — same as any str
concat out / the host crossingO(window) — the materializing copy
parsing a 1 KiB line out of a 1 MiB bufferone 32-byte cell, zero copies

Digest-style workloads (splitting, tokenizing, windowing) stop copying entirely; peak heap reports the buffer once instead of per-piece.

[T] windows

v.slice(from, to) on a Vec<T> (and on [T]) mints a fixed-length window over the backing array, boxed as ?Vec<T> (the T → ?T coercion — see Builtin generic types). The window is the shared cell:

  • reads: w[i], w.len(), for (x of w) — auto-deref the nullable and go through the window (element i is parent[off + i], bounds are the window’s);
  • writes: w[i] = x (and compound assignment) hit the parent;
  • fixed-length: push/pop through a view trap — copy the elements out to grow (a for-push loop does it);
  • reslicing flattens onto the root backing (w.slice(a, b));
  • parent growth detaches: push re-backs the Vec with a fresh array; the window keeps pinning the old backing — the same aliasing rule Go slices have;
  • iteration yields the stored elements as-is under the sharing law: a cell element shares its cell, so a write through the loop variable hits the parent; primitive elements copy out as slots always do.

The asymmetry is deliberate: str/bytes are immutable, so their views carry no mutability law — purely an optimization plus a nicer parsing surface. Array windows change what writes mean, which is why they surface as an explicit ?Vec<T> value while the string view is invisible.

Type aliases and union bounds

type X = A; — the transparent alias. type X = A | B; — the bound-only union. fn f<T requires A | B>(..) — the inline admission-only bound. All three are compile-time gates: no IR, no runtime representation.

Type aliases

typealias := 'pub'? ('(' vis ')')? 'type' Ident '=' Type ';'
use ink::{ Logger };

type Meters = i64;
type Km = Meters;             // chains expand: Km is Meters is i64
type Row = [i32];

let trip: Km = 1500;          // Km is Meters is i64 — all one type

pub fn main() {
    let log = Logger.new("t");
    let plain: Meters = trip;     // no conversion: the alias IS the target
    log.info(f"{trip} {plain}");
}
1500 1500
  • Transparency by construction: X resolves to the target’s type identity everywhere — params, fields, returns, lets, bounds. A value of the alias and a value of the target are the same type; there is no conversion and no wrapper.
  • Chains expand through resolution; type A = B; type B = A; is a “recursive type alias” error. Forward references are legal.
  • The alias target validates at declaration: type Foo = NotAType; is an error whether or not Foo is used.
  • Non-generic: the head admits no members. type Vec2<T> = Vec<T>; is not expressible — a generic alias would be a type constructor, not a transparent name.
  • One name = one type: an alias may not share its name with an alias, enum, struct, class, or trait in the same module — the duplicate-name check is symmetric and fires in both orders.
  • Visibility: pub, pub(mod), pub(super), pub(self) all parse on type (see Modules and visibility). Like every name, the alias is usable in a consumer only when the consumer’s use names it.

Union aliases — bound-only

unionalias := typealias   // with Type spelled `A | B`
bound      := Type ('|' Type)*
type Num = i32 | str;    // legal — here and in requires bounds ONLY
  • The union exists only in the type grammar; the compiler never interns a union runtime kind. There is no runtime union value, no subtyping, and no is/when support over unions (heterogeneous data goes through traits — see Traits and dispatch — or enums, see Enums).
  • Value positions reject unions: a union (or a union-alias name) in a param/field/return/let/is position is diagnosed as bound-only.
  • | continues a completed type as a union only where unions are legal — the alias target and requires bounds — so x as u32 | y keeps spelling the binary operator.
  • Union aliases are module-local: never exported; a cross-module use fails as an unknown type.

Inline requires — the admission-only bound

gparam := Ident ('requires' bound)?
use ink::{ Logger };

trait Labeled { fn label(self) -> str; }
struct Ridge { depth: i32; }
struct Trench { depth: i32; }

impl Labeled for Ridge { fn label(self) -> str { return "ridge"; } }

fn name<T requires Labeled>(x: T) -> str {
    let w: Labeled = x;      // the widening compiles BECAUSE the bound holds
    return w.label();
}

fn kind<T requires Ridge | Trench>(x: T) -> str { return "geo"; }

pub fn main() {
    let log = Logger.new("t");
    log.info(f"{name(Ridge { depth: 3 })} {kind(Trench { depth: 1 })}");
}
ridge geo
  • Members may be concrete type names (satisfied by exact type identity at instantiation), aliases (expanded before detection), or a type union of those. Type names only in a union: a trait member inside a union spelling parses but diagnoses at admission — a trait bound must stand alone.

  • Enforcement at every substitution-completing site: free-fn calls, direct and instance method instantiation, and impl-method enqueue. A failing instantiation diagnoses with the bound spelled out: “bool does not satisfy T requires i32 | str — no matching type or impl is registered”.

  • Trait objects satisfy nothing: instantiating T at a trait type fails any bound — only a concrete type with a registered impl admits.

  • Bounds may reference the item’s other generics: fn hold<T, U requires [T]>(x: U) — members resolve under the call-site substitution.

  • Admission-only — except the union’s whole-bound contract. A non-union bound grants NO method calls on bare T; what it proves is the widening into a bound-member-typed slot. A union bound carries the whole-bound method contract: a method call on a union-bounded value is checked against the WHOLE bound — every member must provide the method through its own impls — even when the offending member is never instantiated. Dispatch is untouched: each instantiation still binds the concrete member’s own impl.

  • Provenance — a value is known to carry a union-bounded type when it is: a generic parameter, a let whose annotation spells the generic, a copy of either, or a direct self.f field whose declared type spells the generic. Provenance rides inlining and is cleared when the name rebinds. Documented holes (the gate under-fires, never over-fires): closure bodies compile with fresh provenance; destructuring carries none; a shadowing rebind resets it.

  • where is removed. The trailing clause is gone; where is an ordinary identifier, and a stray clause diagnoses with the inline replacement: fn f<T requires B>(..).

  • Class generics take bounds; struct/trait/surface generics do not:

    pub class HashMap<K requires i8 | .. | str | bytes, V> { .. }
    

    The bound records on the class descriptor and admits every instantiation: a key type outside the set fails at the instantiation site (see Builtin generic types). Inside the class’s method bodies the union contract applies through the class’s own requires.

See also Functions, closures, and generics for the bound grammar in context.

By-reference and nullable

The sharing regime, the nullable ?T, identity ==, and the one copy escape hatch. These laws landed together and they are not optional: every other page assumes them.

The sharing regime

  • Primitives and fn values copy (immediate slots): u8..u64, i8..i64, f32/f64, bool, and closures’ function values.
  • Every cell type shares its cell: str, bytes, struct/class records, [T] arrays, enums, trait objects, opaque boxes, closures’ captured cells, and ?T boxes.
  • let b = a — any cell type — is an O(1) handle move (retain new, release old). Mutation through any alias is visible through all of them.
use ink::{ Logger };

struct Point { x: i32; y: i32; }

pub fn main() {
    let log = Logger.new("t");
    let mut p = Point { x: 1, y: 2 };
    let q = p;              // SHARE: one cell, two names — no copy
    p.x = 4;                // q.x is 4 now
    log.info(f"{q.x}");
}
4
  • Writing is gated by the mut-binding law, never by the sharing: a let binding is a read-only view; let mut grants write access to the object it holds (see Modules and visibility). Parameters declare the same intent: fn step(mut p: Point) writes the caller’s point; fn area(p: Point) promises not to.
  • A T stored into a ?T slot boxes once — the box aliases the payload’s cell (a share); primitives and nil copy the bits. There is no deref-on-load/box-on-store special case anywhere.
  • Primitive elements in a [T] stay flat slots; composite elements are handle slots. for (let x of xs) yields the shared element, not a per-element copy — a cell element writes through to the sequence.
  • The repeat [v; n] retains the handle n times: every slot aliases the one cell. The repeat never copies.

Copy-by-value is unobservable except through the two legal effects: aliasing (writes visible through every binding) and == (below).

?T — the nullable type

?T is a nil-able cell — a one-slot box. nil is the null literal; dereferencing it traps NilDeref — never a silent read.

Grammar — prefix only, binds tightest

SpellingTypeReading
?TT | nilthe nullable
[?T][T | nil]array of nullables
?[T][T] | nilnullable array
??Tchainedthe same runtime box, unwrapped transitively

There is no postfix T? and no *T — each is a diagnosed error naming ?T. In expression position, *x/&x diagnose: “bindings share by reference now — pass x directly” (binary */& operators are untouched).

Coercions and uses

T → ?T boxes; ?T → T derefs — both implicit at the expected-type position, transitive through ??T:

  • Auto-deref covers every value position: p.x, p.m(..), p[i], for (x of p), arithmetic on p’s payload.
  • p == nil / p != nil compare against the null slot; guard before use. The trap is the bug-catcher, not the semantics.
  • A ?T binding IS the cell reference: writes through it hit the shared cell (a nudge(mut pt: ?Point) moves the caller’s point).
  • nil typing: a context-free nil has type nil; in an expected-?T position it types as that ?T — so let p: ?Node = nil and a left: nil field in a literal just work. Absence reads plainly: a lookup returns ?V, and nil means “not found”.
  • on_drop<T>(p: ?T, cleanup: fn(?T)) attaches the cell-death cleanup (see Rc, dispose, and identity).
  • Across the host boundary ?T crosses nil-flattened when its element crosses — an entry fn -> (?T, err) is a first-class host answer.

== — identity for cells, content for text

Operand type== meansLowering
numeric / bool primitivesvalueIEEE 754 for floats (NaN != NaN, -0.0 == 0.0)
strcontent (codepoints)content compare
bytescontent (octets)content compare
everything else — records, arrays, enums, closures, trait objects, opaque, ?Tcell identitythe raw slot compare

[1, 2] == [1, 2] is false — two cells. Identity is O(1) with no deep walk; compare content where content is the contract (a loop, or a hash/eq trait contract for keys). ?T == ?T is slot identity: two nils are equal, a null and a box are not. See Rc, dispose, and identity for the full law and the lint on obviously-fresh composites.

bytes.clone() — the one copy escape hatch

b.clone() -> bytes mints a fresh buffer with b’s octets — a one-shot deep copy, the only copy syntax in the language. bytes.from(a) also deep-copies. There is no generic clone(x) and no own: every other type shares on binding, and a divergent value of any other type is unreachable — build a new one instead.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let header = bytes.from([1, 2, 3]);
    let alias = header;              // shares: one buffer, two names
    let diverged = header.clone();   // a fresh buffer, same octets
    log.info(f"{alias == header} {diverged == header}");
}
true true

Removed spellings

RemovedReplacement
own(x)bindings share by reference; bytes.clone() is the one copy
make_ptr(v) / *T / T?write ?T: a T widens into ?T on assignment, nil is the null
*x / &x in expressionspass x directly — sharing needs no spelling
Option<T> / Result<T, E>absence is nil on a ?T; errors are the (?T, err) pair

Containers under sharing

  • Vec<T> backs on buf: [?T] — [nil; cap] is the generic zero (nil is the slot’s zero); push takes the T → ?T-boxed handle; loads yield the ?T (uses auto-deref). See Builtin generic types.
  • Keyed collections: get(k) -> ?V — nil = absent, a hit answers the stored cell (the aliasing law: two gets of one key name one cell until a replace re-stores).
  • Array windows (v.slice(a, b) -> ?Vec<T>) are the shared cell: writes through the window hit the parent (see String slicing and views).
  • Erasure rides the same laws: opaque(v) aliases the payload’s cell, and opaque.downcast<T>(o)’s match is the box’s own inner cell (see opaque — erasure and downcast).

The answer channel

(?T, err) — concretely (value, ok) / (T, str) — is THE answer channel. The convention is law: empty err + a value = success; empty err + nil = “not found”; non-empty err = “failed”. The err channel disambiguates a legitimately-absent value from a failure. The exactly-one-non-nil invariant is the caller’s convention — the engine types the components and decodes them; it does not police the combination. A return-position destructure (let (v, ok) = f();) is optimized so the pair mints no cell where it is used as a pair.

The Rc heap and destructors

rut’s heap is reference-counted. When an object’s strong count reaches zero it is destroyed immediately, at a deterministic point in the program. There is no collector: strong cycles leak by design — weak references are the answer. Every VM owns one heap on one thread and nothing is shared between isolates (workers and channels), so the counters are plain cells with no atomics and no locks.

The cell model

Everything except primitives and fn values is a heap cell:

Inline (moved by plain copy)Heap cells (refcounted handles)
u8..u64, i8..i64, u/isize, f32, f64, bool, nilstr, bytes, Vec<T>, [T], enums, structs, classes, trait objects, opaque boxes, host boxes, ?T boxes, coroutine frames

Assignment, argument passing, and returning copy the handle (retain), never the bytes. Mutation through one alias is visible through every alias — reference semantics is the one default regime; there is no eager copy anywhere. bytes.clone() is the only copy escape hatch.

There is no borrow syntax and no lifetimes: a handle simply keeps its referent alive, so nothing dangles. Uniqueness matters only when transferring buffers across isolates, where it is detected at runtime (rc == 1) — never proven statically.

Refcounting rules

The compiler knows the static type of every register, so ref-aware ops are emitted only where a reference can flow:

  • Primitives and fn values move with a plain move — no counting.
  • References move with a ref move — retain the new, release the old; both steps are exact.
  • Function boundaries pass references in registers; call/return sequences emit the paired inc/dec. A loop over a Vec<f64> does no per-element counting.
  • Overflow: an increment past u32::MAX immortalizes the object — the count is pinned to the 0 sentinel (the same value interned literals use) — a deliberate, logged leak instead of unsoundness.
  • Debug builds assert the full counter discipline: retain/release pairing, no underflow, no double destruction.

Destruction — on_drop

A cleanup attaches to a nullable binding — the cell reference itself — and runs when that cell’s count reaches zero:

use core::{ on_drop };
use ink::{ Logger };

struct AuditLog { n: i32; }

fn load() -> ?bytes {
    return bytes.from([1, 2, 3, 4]);
}

fn audit(n: i32) {
    let log = Logger.new("audit");
    log.info(f"released {n} octets");
}

fn use_buf(buf: ?bytes) {
    let log = Logger.new("work");
    log.info(f"working with {buf.len()} octets");
}

fn work(log: ?AuditLog) {
    let buf: ?bytes = load();
    on_drop(buf, fn (b: ?bytes) { audit(b.len()); });  // runs at rc 0
    use_buf(buf);
}

pub fn main() {
    work(nil);
}
working with 4 octets
released 4 octets

Laws:

  • Signature: on_drop<T>(p: ?T, cleanup: fn(?T)) — exactly two arguments; p must be a nullable; the cleanup must be a function value. All three are compile errors otherwise.
  • One callback per cell: a second on_drop on the same reference is an error, never a silent overwrite.
  • on_drop(nil, f) traps (“on_drop on nil”); a nil cleanup traps at the attach.
  • The callback runs at a call boundary, not re-entrantly inside the release: death pins the cell, queues the cleanup, and the interpreter drains the queue between calls, passing the dying referent as the ?T argument.
  • Cancellation drops locals at the suspension point through the same machinery (tasks) — no special case.

Note the difference from finalizer-based runtimes: a cleanup always runs, exactly once, at a knowable point. There is no “later or never”.

Destruction order

When a cell’s strong count reaches zero:

  1. Every Weak box watching the cell is nulled before any user code runs — a cleanup that calls upgrade() sees nil, deterministically (weak references).
  2. A queued on_drop cleanup runs (pinned cell, then released).
  3. Ref-typed children are released recursively: record fields in declaration order, sum payloads, array elements, opaque box inners, closure captures. The walk is driven by a per-type release plan built once from the type table.
  4. Host box payloads run their Rust Drop at the same point — sockets, files, and textures die with the last handle, not “sometime later”. An opt-in finalize hook (no-op default) runs before the payload’s Drop; a rut value the payload held releases through the same walk.
  5. The block store frees the cell’s variable-size payload; the slot returns to the arena free list and is reused by the next mint (the VM heap).

Buffers, strings, and views

  • Primitive-element [T] buffers stay flat: a [T] over numeric or bool element types stores raw values inline behind the header. Element copies in and out are plain moves — no counting. This is the one place the everything-is-a-cell law does not reach.
  • Vec<T> is not flat: its backing is buf: [?T], so element traffic crosses nullable handles. push/pop/set emit the paired retain/release.
  • Composite-element buffers store handles: [Point] and Vec<Point> hold one cell pointer per element; the buffer itself is the counted unit.
  • [T] is a fixed-length cell; N is a compile-time constant and part of the type’s identity. Fixed-array literals that fold at compile time become immortal constant-pool cells.
  • Slice<T> view cells hold a handle to their owner plus off/len — never a pointer into the data block. Views dispatch through the owner, so growth keeps existing views valid; indexing bounds-checks against len ∩ owner len and traps out of range instead of reading garbage. Slices are born only from implicit widening at an argument site; they are not nameable or constructible in script.
  • str is immutable: interned literals live in the module’s constant pool (immortal); runtime-built strings are ordinary cells whose buffers are internally copy-on-write — an implementation detail no program can observe, because str has no mutation API.
  • No interior pointers exist anywhere, which is what keeps every heap walk a simple typed walk.

Internals

Header:  rc: u32        // strong count; 0 = immortal sentinel
         ty: u32        // index into the type table
         flags: u32     // weak-list bit, drop-callback bit

Cell kinds: string, vec (growable), array (fixed), slice view, record (every user struct/class — vtable + payload slots), enum (tag + payload slots; dataless variants are immortal singletons), opaque box, weak box, and the coroutine frames the async weave mints. Reified layouts are covered in reified types and layout.

The engine’s two counting ops are exact and total:

#![allow(unused)]
fn main() {
fn retain(&self, p: Slot) { /* rc += 1 */ }

fn release(&self, p: Slot) {
    let n = rc(p) - 1;                 // underflow is a bug: assert
    set_rc(p, n);
    if n == 0 { destroy(p); }          // order above; no suspect list
}
}

Destruction is fully deterministic under the virtual clock, so a drop order reproduces exactly in tests. Identity and equality semantics for cell values are covered in Rc, dispose, and identity.

Weak references and the cycle collector

Reference counting frees an object when its strong count reaches zero (the Rc heap). A strong cycle never reaches zero — so strong cycles leak until the Vm is dropped. rut states this as a rule instead of hiding it behind a collector: the heap stays trivially simple, destructors stay exactly deterministic, and Weak<T> gives the two shapes that cause accidental cycles — back-pointers and caches — a first-class answer.

Cycle policy

RuleContent
No collectorNo mark bits, no colors, no pauses beyond destructor chains. What leaks leaks wholly and predictably.
Resource holdersClasses holding resources (drop cleanups, host boxes) must not participate in strong cycles.
Back-pointersParent/child and observer shapes take a Weak back-pointer.
Pure-data cyclesHarmless — memory only, freed wholesale at teardown.
LintsThe compiler may warn on obvious self-reference (a value stored into its own field through a handle path); general cycle detection stays out of scope.

Cycles are possible through ordinary dataclass fields (next: ?Node back-pointers) as well as through collections.

Weak<T>

Weak is a builtin generic class with one member. A weak box is a side cell holding the referent’s slot word, unretained — a weak never keeps anything alive, and a path through a Weak does not close a strong cycle.

use ink::{ Logger };

class Node {
    value: u32;
    next: ?Node;          // strong — keeps the tail alive
}

impl Node {
    pub fn new(value: u32) -> Self { return Self { value: value, next: nil }; }
}

fn use_node(n: Node) {
    let log = Logger.new("t");
    log.info(f"node {n.value}");
}

pub fn main() {
    let n = Node.new(1);
    let w = Weak(n);            // Weak<Node>; T infers from n
    let b = w.upgrade();        // ?Node — a live handle
    if (b != nil) {
        use_node(b);
    }
}
node 1

Construction — Weak(v)

  • Construction is a type-call with admission at the instantiation: T must be a reference type. Weak<i32> and Weak(SomeFn) diagnose; primitives and fn values refuse.

  • Works over any cell: a class, dataclass, Vec, [T], enum, str, bytes, a user opaque box, or a host box.

  • Weak(nil) traps (“weak on nil”).

  • Weak(v) consumes the argument’s temporary and nulls its register (the engine’s one consuming op): the weak observes the binding’s lifetime, never a temporary’s. Weak(make()) watches a referent that dies as soon as the temporary is released — upgrade() answers nil. Bind the value first:

    use ink::{ Logger };
    
    struct Payload { n: i32; }
    
    fn make() -> Payload { return Payload { n: 1 }; }
    
    pub fn main() {
        let log = Logger.new("t");
        let v = make();
        let w = Weak(v);      // watches the binding v — lives as long as v does
        log.info(f"{w.upgrade() != nil}");
    }
    
    true
    
  • Two Weak(v) of one v are distinct boxes; == on weak boxes is identity, and two weaks of the same referent are not equal to each other.

Upgrade — w.upgrade() -> ?T

  • A dead-check plus retain: a live referent is retained into a fresh ?T handle; a dead one answers the null slot — true nil, never a box containing nil.
  • On Weak<?U> (legal) the answer is ??U — the sticky-? law (by-reference and nullable).
  • upgrade() on nil traps (“upgrade on nil”); on a non-weak value it traps (“upgrade on non-weak”).

Deterministic ordering

The referent’s death nulls every weak box before anything that runs user code — before the on_drop pin check, before payload teardown. A cleanup or dispose body that calls upgrade() sees nil, with no window:

class Edge { back: ?Weak<Node>; }

on_drop(p, fn (q: ?Node) {
    // the parent died before this cleanup ran:
    // every weak pointed at it already reads nil
    if (q.back.upgrade() == nil) { /* always taken here */ }
});

Weak references are not destructors — they observe; they never run code. They exist for caches, observers, and back-pointers.

Leak reporting

  • rc discipline is asserted in debug builds: retain/release pairing, no underflow, no double destruction.
  • Shutdown leak report: at Vm::drop, surviving non-immortal objects are reported grouped by type, with allocation sites in debug builds — the primary tool for finding leaked cycles. Deterministic execution means a leak reproduces exactly.
  • The host can read live usage at any time (vm.heap_usage(), resource limits).
  • Fuzz targets assert no crash and that destructor counts match allocations minus reported survivors.

Status: the Weak<T> surface above is shipped; the shutdown report and per-type live counts are the specified diagnostics tier.

If real code ever demands more, a collector could return only as a separate, opt-in module — the heap assumes nothing of it today.

The VM heap

Host Rust code may use std freely. The VM’s heap is the exception: every byte that backs a rut value comes from the VM’s own heap manager — accounted, budgeted, pooled, and freed wholesale at Vm drop. No rut allocation is an untracked Box/Vec somewhere in Rust land. The point of the discipline:

  1. Caps — an embedder says “this script gets N bytes” and gets a resumable OutOfMemory trap, not an OS abort (resource limits).
  2. Answers — vm.heap_usage() is exact only if every allocation is accounted.
  3. Teardown — Vm::drop frees the heap in slab units; nothing per-object leaks past the VM.
  4. Leak reporting — the shutdown leak report (weak references) walks the heap’s own headers, which requires that every value have one.

What the VM manages

Managed by the VM heapThe host’s business
every cell — structs, classes, enums, opaque boxes, weak boxes (the Rc heap)module binaries and maps (read zero-copy, never copied into the heap)
variable-size payload blocks — Vec/[T] data, string buffersthe shared type table
coroutine frames and register blocks (async and await)native-module state
the ready ring, the frame pool, the timer wheelhost-side handles, caches, futures, channel buffers at the boundary

Host-side Rust state is the host’s memory, charged to the host.

The manager surface

#![allow(unused)]
fn main() {
impl Heap {
    /// The one allocation path for rut values.
    /// Over budget -> Err(Trap::OutOfMemory); never aborts.
    fn alloc(&mut self, size: u32, align: u8, ty: TypeId)
        -> Result<NonNull<Header>, Trap>;

    fn usage_bytes(&self) -> u64;     // feeds vm.heap_usage()
    fn peak_bytes(&self) -> u64;      // high-water mark: vm.heap_peak()
    fn set_limit(&self, limit: Option<u64>);  // live budget change
}
}

Every route that materializes a rut value goes through it: cell mint, Vec growth, string concatenation, frame-pool growth, opaque store inserts. The contributor rule: an allocation path that materializes a rut value and does not flow through alloc is a bug — the budget would not bind, heap_usage would lie, and the leak report would miss it.

Accounting checks run before any write: a failed allocation leaves the heap byte-identical to its state before the op, and the trap is resumable (resource limits).

Allocation strategy (v1)

  • Fixed-size cell slots in the arena. Cells are one fixed-size record carved from 1024-slot chunks with a free list; a dead slot is reused by the next mint. Reuse is invisible except in heap_usage.
  • The block store. Every variable-size payload — string octets, array element runs — lives in a block carved from size-classed pages the VM owns:
    • Size classes 16 B … 2 KiB (multiples of 8); small blocks come from 32 KiB pages via per-class free lists plus a single-slot LIFO cache for the churn pattern (a temp dies, the same size is minted again).
    • In-place growth within a class keeps append-accumulation loops linear; the block header carries the class-rounded capacity, so free re-derives the class with no side table.
    • Large blocks (> 2 KiB) get dedicated allocations, freed wholesale.
    • Blocks are 1:1 with their cell (the cell is the counted unit) and never move, so a raw &[u8] into the store stays valid for the cell’s life.
  • Immortal singletons. Interned string literals and dataless enum variants live in a slab freed only at Vm drop; they carry the rc == 0 sentinel.
  • The opaque store. Every rut opaque value lives in a slab of two-kind entries (rut value / host payload) with its own borrow guards; entries charge their bytes on insert and refund on death, so the budget and the receipts stay honest.
  • No compaction in v1. Non-moving keeps host borrows into Vec buffers sound and freelists trivial.
  • Cells are addressed by raw pointer in the slot word. A 32-bit handle design was prototyped and reverted on measurement: ref-heavy workloads paid +8–15% for the decode chain per slot read.

Deterministic teardown

Teardown mirrors the destruction rules: the host drops its references, releases run inline (the Rc heap), and Vm::drop frees the survivors wholesale — leaked cycles included — after emitting the leak report. The heap never outlives the Vm, and no rut cell can outlive the heap.

#![allow(unused)]
fn main() {
let mut vm = Vm::new(prog, limits, hooks, hosts)?;
vm.call::<_, ()>("main", ())?;
println!("used {} bytes (peak {})", vm.heap_usage(), vm.heap_peak());
// dropping `vm` frees every remaining slab
}

Worker heaps are per-VM and charged to the child’s own budget (workers and channels).

Resource limits and fuel

Two knobs make rut safe for third-party code: a heap budget and fuel. Both are enforced as resumable traps, both are set at construction, and both bind on the VM’s own accounting (the VM heap) — not on the OS.

#![allow(unused)]
fn main() {
pub struct Limits {
    pub fuel: Option<u64>,             // None = unbounded ops
    pub heap_limit_bytes: Option<u64>, // None = host-enforced only
    pub interrupt_every: u32,          // check period; default 1024
}
}

The heap budget

What countseverything the VM heap tracks: cell headers + payloads, buffer blocks, string blocks, frames and register blocks, opaque store entries
What does notmodule binaries, the shared type table, host-side Rust state
Check pointsevery Heap::alloc route: cell mint, Vec growth (push reallocation), string concat, frame-pool growth, host crossing mints
On failureTrap::OutOfMemory — the check runs before any write, so the heap is byte-identical to its pre-op state; nothing is half-initialized
Resumptionthe frame is parked: raise the limit (Heap::set_limit), drop references and retry, or drop the VM
Observabilityvm.heap_usage() (live), vm.heap_peak() (high-water)

Vec growth charges the budget before the write; shrinking is never refunded (v1 overcounts rather than undercounts).

Fuel

  • One unit per executed op; fuel counts down. The counter is checked every interrupt_every ops (default 1024) and at loop back-edges.
  • Trap::OutOfFuel parks the frame exactly like any resumable stop: nothing is unwound. Resumption is vm.add_fuel(n) then vm.resume() — the frame is the loop state.
  • Fuel is the deterministic budget: the same program with the same fuel dies at the same op, every run — reproducible reports and hang proofs in tests.
  • Native fns run outside fuel. A hanging native is a host bug: keep native bodies non-blocking, or push blocking work to a thread through the host-futures lane (the host futures bridge).

The trap contract

TrapRaised byFrame stateResumption
OutOfMemoryheap budget exceeded at an allocation checkparked before the writeraise limit / free refs → resume()
OutOfFuelfuel reached 0 at a check pointparked at the exact pcadd_fuel(n) → resume()
#![allow(unused)]
fn main() {
let limits = Limits { fuel: Some(1_000_000),
                      heap_limit_bytes: Some(64 * 1024 * 1024),
                      interrupt_every: 1024 };
let mut vm = Vm::new(prog, limits, HostHooks::default(), hosts)?;

match vm.call::<_, ()>("main", ()) {
    Err(t) if t.name() == "OutOfFuel" => {
        vm.add_fuel(1_000_000);
        vm.resume::<()>()?;           // re-enters at the parked pc
    }
    r => { r?; }
}
}

The async layer’s resume granularity is the checkpoint, not the op: a drive that runs out of fuel parks the future’s frame, and a re-drive re-enters at the frame’s checkpoint state (async and await).

Hang detection

The host owns time; the recipes differ by embedder:

HostRecipe
UI / gamedrain the ready queue inside the frame loop; grant fuel per frame so a runaway script starves at the next check
wasm pagegrant finite fuel; a watchdog stops refueling — the parked frame dies at its next check
desktop servicea watchdog thread flips a flag the host honors between resume() calls; no joins, no signals
testsfinite fuel + the virtual clock (vm.set_now) → fully deterministic hang proofs

Anything blocking that cannot be bounded by fuel (IO, locks) belongs on a worker thread answering a Completer — the VM thread never blocks on it (the host futures bridge).

Workers

A worker VM is constructed with its own Limits; argument transfers are counted against the child budget before the worker starts, so a worker cannot OOM its parent (workers and channels).

Within one VM, the ready ring is round-robin: one greedy launched future cannot starve the rest of a queue drain indefinitely — each frame runs to its next park or completion, and fuel bounds each of those runs.

Async and await

rut’s concurrency is pull-based. An async fn compiles into an impl Future<T> for a hidden frame type — a cold future that only progresses when driven. There are no promises, no microtask queue, and no implicit scheduling: the host owns time. One noun (Future<T>), one consume law (await or launch — exactly one), one pender (sleep; an embedder may mount its own).

Push (promises)rut (poll)
createdstarts executing on the next microtaskcold — nothing runs until awaited or launched
completionpushes callbacks through a job queuenobody drives ⇒ nobody pays
allocationpromise + reactions per thenone hidden frame record per call; locals are cell-backed
cancellationabort flag / never settlesabort() flags the frame; the probe at its checkpoint runs the drop path
host integrationthe host must drain a job queuethe host polls the VM: run_ready(), next_deadline()

async fn and await

use ink::{ Logger };
use async_host::{ launch_future, sleep };

async fn countdown(cx: RunContext, n: u32) -> u32 {
    let log = Logger.new("count");
    let mut i = n;
    while (i > 0) {
        await sleep(1000);            // the ONLY suspension point
        i -= 1;
        log.info(f"tick {i}");
    }
    return i;
}

pub fn main() {
    launch_future(countdown(3));      // trigger; receipt ignored
}
tick 2
tick 1
tick 0

Rules:

  • The first parameter is the cx: async fn f(cx: RunContext, ..). The engine mints it at call sites and per drive — call sites do not pass it.
  • async fn f(..) -> T describes a value that widens to Future<T>. Calling it runs nothing (cold). It runs when the future is awaited or launched.
  • await is legal only inside an async fn. In this build it targets engine-woven futures — async-fn results and sleep.
  • A future is awaited or launched by exactly one driver; each path consumes it. Driving one frame from two paths is a disclosed misuse, not a soundness hole.
  • launch_future(launch_future(f)) is a type error: the receipt is not a Future (tasks).
  • Async methods are not woven in this build — async points are free fns. A sync method may mint the future internally and return it typed Future<T> (the standard HTTP face does exactly this).
  • Cancellation-by-drop: the probe at the resumed checkpoint branches to a drop path that releases the frame’s cell-backed locals (their on_drop callbacks fire, the Rc heap), retires the state, and returns.

The cx protocol

RunContext carries two reads, usable as plain data inside a future impl (the host futures bridge):

ReadAnswers
cx.checkpoint()this frame’s current resume state (a u32)
cx.cancelled()the frame’s abort flag

Cancellation is data the frame reads — never an exception it catches.

The standard launcher set

Launchers are host surface — ordinary rut code over the open Future surface, provided by the rut/async_host package (users may write their own the same way):

pub fn launch_future<T>(f: Future<T>) -> LaunchedFutureHandle<T>;
pub fn sleep(ms: u32) -> Future<nil>;
// LaunchedFutureHandle<T>: the receipt — one member, abort() -> bool

Each embedder mounts rut/async_engine (the rows __launch, __abort, __sleep, __sleep_yield) plus rut/async_host, and installs the row bodies. A session that mounts neither simply has no launcher; await still works inline.

The crossings cross as opaque — there is no any in the vocabulary: opaque(f) seals a frame on the way out, opaque.downcast<Future<nil>>(b) -> ?Future<nil> recovers it on the way in (opaque — erasure and downcast). sleep(ms) is literally that downcast over the engine’s minted sleep frame.

Host async fns declared in a declaration file — pub host async fn fetch(url: str) -> bytes; — mint the same cold woven frames; the embedder backs them with a Completer (the host futures bridge).

What the compiler emits

One async fn compiles into one code body over existing ops only — no new vocabulary:

  • a hidden frame record is the future:

    fieldcontent
    statethe checkpoint enum’s member singleton; null = completed or dropped
    cancelledthe abort flag
    awaiterthe frame awaiting this one
    pendingthe future this one parks on
    [4..]locals — every binding, cell-backed
  • a per-fn checkpoint enum whose member index is the resume state, dispatched with a branch table — the state field is the pc;

  • each await site: read the callee’s state; not done → drive one step (a plain vtable call of Future::yield); still pending → store the awaiter/pending edges, set the state, and ret — suspension is a plain return whose caller reads the state field for the answer (Done | Parked);

  • the drop path: clear the pending edges, release ref locals in binding order, retire.

Locals are mirrored into the frame cell uniformly, so no liveness analysis decides which locals survive a park. A completed frame re-drive answers at the dispatch default (a silent retire). The awaiter/pending pair is one-directional — a driven frame re-enqueues its awaiter, and no cycle forms.

The driving loop

The VM owns the queues; the embedder owns the loop:

Engine APIMeaning
vm.launch(fut)enqueue a future (the queue owns one reference)
vm.cancel(fut) -> boolflag cancellation + re-enqueue; false when already retired
vm.drive(fut) -> Driveone re-entrant call of Future::yield; reads the state field
vm.arm_timer(deadline_ms, fut)arm a sleep deadline on the virtual clock
vm.now_ms() / vm.set_now(t)read / advance the deterministic virtual clock
vm.next_deadline() -> Option<u64>expire due timers into the ready queue; answer the earliest remaining deadline
vm.run_ready() -> usizedrain the ready queue, then poll parked host futures, until a spin produces no completion
vm.pending_tasks() -> usizeready + armed timers + host futures parked on Completers — the idle test

Two embedder pumps:

#![allow(unused)]
fn main() {
// wall-clock (a CLI or service): settle from real threads
vm.call::<_, ()>("boot", (args,))?;
while vm.pending_tasks() > 0 {
    vm.run_ready()?;
    std::thread::sleep(Duration::from_millis(2));
}

// virtual clock (tests): fully deterministic
while vm.pending_tasks() > 0 {
    vm.run_ready()?;
    if let Some(d) = vm.next_deadline() { vm.set_now(d + 1); }
}
}

Fuel rides the existing per-op budget (resource limits): a drive that runs out parks the frame and propagates OutOfFuel; a re-drive re-enters at the frame’s checkpoint.

See tasks for receipts, cancellation, and the join/select tier; the host futures bridge for backing a host async fn with Rust; workers and channels for isolate parallelism. A worked user-defined future lives in the custom-async example.

Tasks

There is no Task noun in rut. A launched future’s receipt — LaunchedFutureHandle<T> — is its own type, and it is the entire task-management surface. Everything in this chapter is spelled in that vocabulary (async and await).

Surface status

FeatureSpellingStatus
launchlaunch_future(f)live
cancelh.abort()live
joinawait h on a receiptcompile-gated — “not in this build”
raceawait select { .. }parses; semantics compile-gated
first-ofselect_all(futs) (stdlib)lands with select
structured scopesscope { .. }specified, not built

The receipt — LaunchedFutureHandle<T>

pub class LaunchedFutureHandle<T> {
    pub fn abort(mut self) -> bool;
}

Laws:

  • The receipt is not a Future: await h diagnoses — the type system rejects it, never a runtime check.
  • It is not re-launchable: launch_future(h) is a type error for the same reason. A future is consumed exactly once — by await or by launch_future, never both, never twice.
  • One member: abort() — true if the frame was flagged and re-enqueued, false when it had already finished (a repeat abort is stable and harmless).

Cancellation

abort() flags the frame’s cancellation and re-enqueues it. The probe at its next checkpoint is the only place cancellation becomes observable — it never interrupts mid-expression.

At the probe, the frame runs its drop path:

  1. the pending edge is cleared — a pending sleep dies with the frame;
  2. every local with a cleanup runs it deterministically, in reverse declaration order (on_drop callbacks fire, the Rc heap);
  3. the state retires (null) — later abort() calls answer false.

Inside a future impl, cx.cancelled() reads the same flag as data, so a hand-written frame can honor cancellation at its own pace (the host futures bridge). Cancellation is synchronous at the engine level (flag + re-enqueue); a frame parked on a host future only observes the flag when the loop drives it again.

class Drops {
    n: u32;
    fn note(mut self, len: u32) { self.n += 1; }
}

async fn job(cx: RunContext, seen: ?Drops) -> nil {
    let buf: ?bytes = bytes.zeroed(64);
    on_drop(buf, fn (b: ?bytes) { seen.note(b.len()); });
    await sleep(5000);
    // if `job` is aborted while parked here, the probe at the sleep
    // checkpoint runs the drop path: seen.note fires, then buf releases
}

Join (specified)

There is no spawn and no Task<T>. The receipt is the join surface: await h joins the launched future and produces its completion value. Until the join tier lands, await h diagnoses with the join law and the receipt stays non-awaitable.

Planned laws: joining an already-cancelled receipt yields the cancelled case, never a trap; join re-enqueues the awaiter the same way an in-body await parks, so the value surfaces on the driving loop, not through a callback.

select (specified)

await select {
    http.fetch(url)  resp  -> handle(resp),
    timeout(ms)             -> handle_timeout(),
}
  • await select { .. } races futures; the winner’s value drives its arm; the losers are dropped — which means cancelled (their drop paths run, pending sleeps die with them).
  • Arms use -> like when: fut -> expr discards the resolved value; fut x -> expr binds it to x (arm-local binding).
  • select_all(futs) (stdlib, built on select) resolves with the first ready value.

The grammar parses today; the semantics are gated with “await select is not in this build”.

Structure and fairness

  • v1 tasks are unstructured: aborting a frame does not abort frames it awaits. Structured scopes — scope { .. } cancelling children on exit — are the specified remedy.
  • The ready ring is round-robin: each drive runs a frame to its next park or completion, so one greedy future cannot starve the queue. Task priorities are not in the model.
  • vm.pending_tasks() counts ready frames, armed timers, and host futures parked on Completers — the embedder’s idle test (async and await).
  • A trap inside a launched future propagates out of run_ready() / drive(); the frame is retired either way, and its locals ran their drop path.

The host futures bridge

Native Rust code can back a rut async fn. The contract is one closure per operation: spawn the work, hand out a Completer<T>, and settle it from anywhere. The engine weaves the future; the embedder never touches a frame.

Declaring a host async fn

In a declaration package (host fns and declaration files):

/// fetch `url`; the answer is the response body
pub host async fn fetch(url: str) -> bytes;

Rules:

  • Concrete crossing signatures only — no generic host async fns; parameter and answer types come from the crossing set (the host boundary).
  • Arity 0–8.
  • Calling one runs nothing: the call site mints a cold engine-woven Future<T> frame whose state field holds the host cell. The body is driven by await or launch_future exactly like any other future.
  • The declared name itself is never dispatched — calling through it traps with a teaching message (“the weave mints the future at the call site”).

Completer<T>

The completion cell — the one helper a host async fn’s body needs:

MemberThreadMeaning
Completer::new() -> Completer<T>anya fresh cell: atomic state + a mutex slot, one Arc
.clone()anyanother handle on the same cell — never a copy of the state
.complete(v: T)any threadsettle with the answer
.fail(msg: String)any threadsettle with a failure — msg becomes the trap at the await
.poll() -> i32anythe driving loop’s probe: PENDING (0) / READY (1) / FAILED (2)
.take_result() -> Result<T, Trap>VM threaddrain the answer — one take; a second take traps; taking while pending traps

T must be a crossing type (Ret). The completer itself crosses boxed under opaque — it is what the woven frame holds in its state field (opaque — erasure and downcast).

Thread law: the VM stays single-threaded. Worker threads touch only the completer’s atomics + mutex; every rut cell is minted on the VM thread when the answer is marshaled.

Late answers are disclosed best-effort: after a cancellation the thread runs to its blocking completion and the result is simply never taken.

Registering — register_async!

One closure (plus an optional abort hook) expands into the five rows the weave drives:

#![allow(unused)]
fn main() {
use rut_vm::{ register_async, Completer, Vm };

register_async!(hosts, "mypkg::fetch", (String,) -> Vec<u8>,
    |url: String| -> Completer<Vec<u8>> {
        let c = Completer::new();
        let w = c.clone();
        std::thread::spawn(move || {
            match std::fs::read(url) {
                Ok(data) => w.complete(data),
                Err(e) => w.fail(e.to_string()),
            }
        });
        c                       // returned immediately: the future parks
    });
}
Emitted rowSignatureRole
mypkg::fetch(args) -> Tthe decl row — a teaching trap, never dispatched
mypkg::fetch__start(args) -> opaquecalls the closure, boxes the Completer — the state cell
mypkg::fetch__yield(state, cx) -> i32the resumption probe: 0 pending / 1 ready / 2 failed
mypkg::fetch__take(state) -> Tmarshals the answer onto the heap on the VM thread; a failure traps here
mypkg::fetch__cancel(state)the abort closure when one is given; otherwise the disclosed no-op

The abort-closure form receives a Completer<T> clone (safe to move anywhere); without it, cancellation is the documented best-effort law — the thread finishes, the late result is discarded.

Rut side, the closure’s own surface is invisible — callers see a normal async fn:

async fn grab(cx: RunContext, path: str) -> bytes {
    let body = await fetch(path);
    return body;
}

fn main() -> nil {
    launch_future(grab("/etc/hostname"));
}

The embedder’s loop

The engine polls every future parked on a completer:

#![allow(unused)]
fn main() {
vm.call::<_, ()>("main", ())?;
while vm.pending_tasks() > 0 {
    vm.run_ready()?;                       // drain + poll + drain
    std::thread::sleep(Duration::from_millis(2));
}
}

run_ready() drains the ready queue, polls the parked host futures (each poll runs the __yield probe; a ready one is driven through __take and its awaiter re-enqueued), then drains again — one pass settles a completed host future end to end. vm.pending_tasks() counts ready frames, armed timers, and the host-pending set, so a worker that never completes keeps the loop alive — the flip side of best-effort cancellation.

The virtual clock works here too: drive with finite fuel and vm.set_now for deterministic tests (resource limits).

In-tree users

The standard HTTP lane is a register_async! client: http_send, http_body, and http_stream_next are pub host async fn rows whose bodies park Completers answered from reqwest worker threads (or the fixture lane’s virtual clock). Mount it with rut/http_host + rut/http; the full walk-through is the GitHub viewer example.

Plain host fn rows (synchronous, run to completion inside the crossing) are the other half of the boundary — see embedding and native modules.

Workers and channels

Workers are separate VMs on separate threads with separate heaps. There is no shared memory: everything that crosses is a value on a typed channel, moved or deep-copied under the rules below. Pure rut code inside one VM is always single-threaded, which is what keeps the RC heap lock-free (the Rc heap).

Status: this page specifies the isolate surface; it is not yet wired into the engine. The async surface it builds on — futures, launch, await — is live (async and await).

Channels

Channel<T>() produces a channel value with two endpoints:

EndpointTypeBehavior
sender.send(v)syncunbounded buffer; send never blocks and is cheap
receiver.recv()Future<?T>suspends until a message arrives; answers nil once every sender is gone

Semantics:

  • Channels are MPSC (many senders, one receiver), unbounded.
  • Endpoints are ordinary values and transferable: pass one to a worker through spawn_worker args, or through another channel.
  • Receiving nil is the shutdown signal — dropping the sender half is how a parent tells a worker to finish.
  • Closing semantics pair with the nullable answer, not an error type (by-reference and nullable).

Typical shape:

async fn worker(cx: RunContext, rx: Receiver<u32>, tx: Sender<u32>) -> nil {
    while (true) {
        let msg = await rx.recv();
        if (msg == nil) { return; }       // all senders gone
        tx.send(1);                       // acknowledge (send is sync)
    }
}

What may cross an isolate boundary

TypeCrossing rule
primitives (ints, floats, bool)copy
strcopy (immutable)
every other cell — Vec<T>, [T], class/dataclass instances, enums, ?T boxestransfer if rc == 1, else deep copy — the zero-copy fast path is the common case; every element/field must itself be crossable
Slice<T> viewsame rule as its owner cell; provenance (which Vec/[T] it views) is invisible across the boundary
Sender / Receivertransfer
Templatecopy — a builtin carrier; its opaque args must themselves be crossable (templates)
host opaque typestransfer only if the host registered send for them — checked at the transfer, by type
closuresnot transferable — compile-time error at the send/spawn_worker site
anything else (receipts, wakers)compile-time error at the call site

Enforcement is two-layered:

  1. Static at the send/spawn site — the checker knows T and rejects non-crossable types (closures, unregistered host types).
  2. Runtime on the receiving heap — transfer_into detects uniqueness: an rc == 1 buffer is adopted (the raw block moves, no copy); anything shared is deep-copied through a descriptor-driven walk.

Transfers move accounting, not just pointers: a moved buffer’s bytes are charged to the child heap at the transfer (the VM heap).

Worker lifecycle

let w = spawn_worker("workers/fetch", (url, ch.sender()));

spawn_worker(module, args) is a host hook:

  1. construct a child Vm sharing the type table and loader — never the heap (the VM heap);
  2. load the module;
  3. transfer the args into the child heap — charged to the child budget before the worker starts, so a worker cannot OOM its parent (resource limits);
  4. run the worker’s entry to completion on its own thread; entry parameters are the transferred args;
  5. return a Worker handle; worker.join() -> Future<Result<(), Trap>> completes when the entry returns.

Isolation laws

  • A trap inside a worker kills only that worker: join() answers the trap and the host decides — restart it, surface it, ignore it. The parent VM and its other workers are untouched.
  • The main VM blocks on nothing: join() is a future, channel receives are awaits; the parent’s driving loop keeps running while workers compute.
  • No data races by construction: nothing is shared, so there is nothing to lock. Two isolates communicate only through transferred values and channel messages.
  • A worker’s exit does not close channels it received senders from — the shutdown signal is the drop of the sender endpoints, which falls out of the worker’s heap teardown.

Open surface: bounded channels and MPMC; cross-isolate sharing of immutable data; receive fairness across multiple awaiting receivers (FIFO per channel proposed).

Embedding and native modules

The host is a Rust program. It owns the VM, mounts packages, binds native function bodies to declared surfaces, and drives rut entry points. Compiling and type-checking rut code never requires any Rust: surfaces are declared in rut source (host fns and declaration files), and the boot join proves every referenced native member has a bound, signature-equal implementation before the first instruction runs.

There is no load-time execution: loading verifies and links; the host runs entry points explicitly.

The embed loop

#![allow(unused)]
fn main() {
use std::rc::Rc;

// 1. Mount the packages the program uses. core + calc are the base.
let mut session = rut_driver::Session::new();
rut_driver::mount_std(&mut session);        // core + calc
rut_driver::mount_std_async(&mut session);  // async_engine + async_host (optional)
rut_driver::mount_dir(&mut session, "plugins/server")?;
rut_driver::assemble_peers(&mut session)?;  // peer-gated impl groups

// 2. Compile the program against the mounted surfaces.
let out = rut_driver::compile_module_in(&mut session, &src,
                                        rut_parser::Mode::Impl, "app");
if !out.diags.is_empty() { /* render and exit */ }
let prog = rut_core::binary::decode(&out.binary.unwrap())?;
rut_vm::verify::verify(&prog)?;

// 3. Bind bodies BEFORE the Vm exists — the registry is a pre-VM table.
let mut hosts = rut_vm::interp::HostRegistry::new();
rut_std::math::install_std_math(&mut hosts);
rut_vm::register!(hosts, "server::emit", (OpaqueRef, &str, &str) -> (),
    |vm: &mut rut_vm::interp::Vm, bus, topic, payload| -> Result<(), rut_vm::Trap> {
        // re-entrant rut calls are legal here (see "Native fn rules")
        Ok(())
    });
// The decl ↔ impl contract check. Panics, loudly, on any mismatch —
// an embedder wiring bug is never a rut diagnostic.
hosts.verify_against(&session.expected_host_fns());

// 4. Boot and drive.
let limits = rut_vm::interp::Limits {
    fuel: Some(1_000_000),
    heap_limit_bytes: Some(64 * 1024 * 1024),
    interrupt_every: 1024,
};
let mut vm = rut_vm::interp::Vm::new(
    Rc::new(prog), &limits, rut_vm::interp::HostHooks::default(), hosts,
)?;
let answer: i64 = vm.call("compute", (41,))?;
}

The registry is consumed by Vm::new: every host thunk the program declares is resolved against it once, at boot. A declared-but-unbound fn is a construction error, never a mid-run trap.

Driver API (rut-driver)

APIMeaning
Session::new()an empty mounting session
mount_std(&mut s)mount core + calc
mount_std_async(&mut s)mount async_engine + async_host
mount_dir(&mut s, dir)mount a package directory (rut.toml); returns its name
assemble_peers(&mut s)append peer-gated impl groups (dependency kinds)
compile_module(src, mode, name)full pipeline over one module against a fresh core+calc session
compile_module_in(&mut s, src, mode, name)the same against a caller-built session; returns diags, AST/IR dumps, and the binary
compile_graph(&s, root)compile a whole module directory graph
s.expected_host_fns()the mounted surfaces’ declared host rows — the check table for verify_against
load_path_session(path)load a module directory or .rutbundle; returns (session, root)
pack_dir(dir)pack a directory into a deterministic .rutbundle (module bundles)

mode is Mode::Impl for .rut and Mode::Decl for .d.rut (host fns and declaration files).

VM API (rut_vm::interp)

APIMeaning
HostRegistry::new()an empty binding table
hosts.register::<_, (P…), R, _>(name, f)bind one body; the closure’s Rust shape is the declared row
hosts.verify_against(&expected)panic on declared-unbound / bound-undeclared / signature drift
Vm::new(prog, &limits, hooks, hosts)boot; joins every declared host thunk to its binding
vm.call::<A, R>(export, args)call an export with Rust values, get a Rust value back (value boundary)
vm.resume::<R>()resume a budget-parked call after refueling
vm.run_ready()drain the async ready queue once; returns tasks run
vm.next_deadline() -> Option<u64>the earliest timer deadline, if any
vm.set_now(t_ms)advance the virtual clock
vm.pending_tasks() -> usizeunfinished async tasks
vm.fuel_used / vm.heap_usage()budget meters
vm.alloc_opaque_str(s)mint an opaque box over host-built text (the logger’s named logger)
vm.call_host_row(row, &[Value])dispatch a registered row by name (test/tooling reads)

The canonical async driving loop (single-threaded; the host owns it):

#![allow(unused)]
fn main() {
loop {
    vm.run_ready()?;
    match vm.next_deadline() {
        Some(d) => vm.set_now(d),          // advance to the next timer
        None if vm.pending_tasks() == 0 => break,
        None => std::thread::sleep(std::time::Duration::from_millis(2)),
    }
}
}

Cap the loop in embedders that cannot prove termination, so a program that never idles fails loudly instead of hanging.

Limits

#![allow(unused)]
fn main() {
pub struct Limits {
    pub fuel: Option<u64>,          // ops per turn; None = unlimited
    pub heap_limit_bytes: Option<u64>,
    pub interrupt_every: u32,       // ops between interrupt checks
}
}

Exhaustion and interrupts surface as traps (resource limits). A trap inside re-entrant work propagates to the embedder parked at the host op; vm.resume::<R>() re-runs the turn.

Native fn rules

  • A body is FnMut(&mut Vm, P…) -> R or -> Result<R, Trap>; the Rust parameter types are the declared row (compile error at the register site for a type that does not cross — value boundary).
  • Native fns run outside the op budget. The host is trusted to be fast — or to hand the work to a future (async host fns).
  • Re-entrancy: a body may call vm.call on rut exports. Nested calls draw the same fuel pool; borrows held by the outer body are guarded (value boundary).
  • Failures are values: return Err(Trap) — Trap { kind, msg }. The fn traps cleanly and the error propagates as an Err to the embedder with the rut backtrace intact. Traps never unwind Rust.
TrapKindraised by
OutOfFuelbudget exhaustion
OutOfMemoryheap limit
Interruptedinterrupt flag
Overflow / DivByZeroarithmetic
IndexOutOfBoundssequence access
Assert / Panicassert / panic
BadUnboxa failed unbox
NilDerefnil where a reference is required
Invalideverything else, including boundary mismatches

The engine’s own natives

The VM boots with internal native calls in the same dispatch family — the str/bytes members, the f"..." concatenation lowering, the StrBuf builder, array length/slice, and stack-trace capture. They are call slots fixed at boot, never IR-level special forms; hosts see them exactly like their own registered modules.

Host-side helpers (rut-std)

installerbinds
math::install_std_mathcalc’s float functions, both widths
logger::install_std_log(&mut hosts, sink)rt:log’s two rows, routed to a FnMut(&str) sink
nmap::install_std_nmapthe native key table behind nmapset (stdlib)
async_host::install_std_asyncthe launcher rows (__launch/__abort/__sleep/__sleep_yield)
http::install_std_httpthe std HTTP lanes (reqwest; native builds only)
bench_cross::install_std_bench_crossthe crossing-tax benchmark rows

In-tree examples

  • 03 — Plugin: a Rust chat server driving a rut moderator plugin, loaded from a module directory and from a packed .rutbundle; re-entrant emit crossings.
  • 06 — GitHub viewer CLI: mounts the std packages, binds example-local I/O rows, launches an async entry fn, and pumps the driving loop to idle.

Value boundary and borrows

The one place tagged values exist: the host boundary. Inside the VM, registers are untagged slots (the VM heap); crossing into Rust materializes a checked, borrow-guarded value. The currency of the boundary is typed Rust: the registered closure’s parameter types and the vm.call return type are the whole contract. The crate-internal marshaling formats (Value, Slot) never leave the VM crate.

The crossing set

What may cross is a compile-time property of the surface. A violation is a compile error on the declaration or binding — never a call-time failure.

  • Parameters cross over: the primitives (i8–i64, u8–u64, f32, f64, bool), str, bytes, and opaque.
  • Returns cross over the same set plus the answer optionals ?str, ?bytes, ?opaque.
  • Tuples cross field-by-field; each field must itself cross.
  • Entry fns follow the same rule in both directions: ?T crosses iff T crosses, decoded nil-flattened.
  • Everything else — dataclasses, classes, Vec<T>, [T], trait-typed values, closures — stays inside the VM. Seal polymorphic values in the erasure box: opaque(v) at the call, opaque.downcast<T>(v) after (opaque — erasure and downcast).

Rust ↔ rut type map

Rust typerut typedirectioncost
i8 i16 i32 i64sameboththe raw slot bits
u8 u16 u32 u64samebothraw bits (u64 is never narrowed through i64)
f32 f64 boolsamebothraw slot bits
()nilboththe zero word
&strstrparam onlyzero-copy borrow of the block store, scoped to the call
Stringstrbothowned copy
&[u8]bytesparam onlyzero-copy borrow
Vec<u8>bytesbothowned copy
OpaqueRefopaqueboththe handle; the rc transfers across
Opaque<T>opaquebothtyped payload view; a wrong T traps naming both sides
Option<String>?strreturn (answer lane)Some mints the opt box; None is the flat nil
Option<Vec<u8>>?bytesreturn (answer lane)as above
Option<OpaqueRef> / Option<Opaque<T>>?opaquereturn (answer lane)as above
Option<T> (other T)——no lane: traps naming ?str/?bytes/?opaque
(A, B, …) up to 8tuplebothfield-by-field under the record’s own field types
Valueanyreadpositional decode for generic tooling

The optional read is nil-flattening: the null slot is None, a some-slot decodes its payload as T. There is no separate optional currency — nil is absence.

Borrows and copies

&str/&[u8] params read the block store directly. The handler bound is higher-ranked over the borrow’s lifetime, so a borrowed param cannot outlive its call — smuggling one out is a type error, not a runtime check. Owned String/Vec<u8> params are the explicit “I keep this data” copy.

Borrow guards, the whole rule:

  • A borrow is call-scoped. The Rust lifetime prevents storing it past return; the object’s guard flag additionally excludes conflicting access.
  • While an exclusive borrow is held, a re-entrant vm.call that touches the same box traps borrowed by an outer host call instead of aliasing.
  • Shared borrows stack; an active exclusive borrow excludes them.
  • Guards clear on return. To keep data, the host copies.

The two-channel law

A rut entry typed -> (?T, err) decodes positionally as (Option<T>, String) — or generically as Value::Tuple([Nil|payload, Str(err)]):

  • A returned err is data: it crosses as the pair’s second component; the host reads it and acts. The pair’s convention belongs to the caller: empty err + a value = success; empty err + nil = “not found”; non-empty err = “failed”.
  • A panic is drift: it stays on the loud channel (Result<_, Trap> — Err means a trapped turn, a bug) and never fills an err field. The two channels are never merged.

opaque — the one cell the host holds

A host payload and a host-held rut value share the one erasure box. The store entry is one of two kinds:

entryholdsidentity
HostBox<dyn Any> + a type name + an optional finalize hookTypeId guards the payload view
Ruta rut cell slotidentity is the cell; rut-side downcast reads the cell’s runtime kind

The rut-side value addresses the entry directly (a tagged slot word); the Rust-side typed view is:

APIMeaning
Opaque::alloc(vm, val)mint a box; size_of::<T>() charged to the heap budget
Opaque::alloc_hosted(vm, val)mint a box whose payload opted into the release hook (T: HostPayload); finalize runs at entry death, before Drop
Opaque::from_handle(&h)the checked view; a wrong payload T traps naming both sides, a rut-value box names the rut-side recovery
o.with(|v| …)shared borrow of the payload; nested withs stack
o.with_mut(vm, |vm, v| …)exclusive borrow; flows &mut Vm for in-crossing rc work
o.handle()the erased OpaqueRef, for passing the box back across

Death is deterministic: at rc-0 inside the release walk, finalize(heap) runs first, then the payload’s own Drop (the Rc heap). Instances are per-map/per-logger, never per-op.

Calling in

#![allow(unused)]
fn main() {
// entry fn half(x: f64) -> f64
let y: f64 = vm.call("half", (2.0,))?;

// entry fn find(id: i64) -> (?str, err) — the two-channel shape
let (name, err): (Option<String>, String) = vm.call("find", (7i64,))?;
if !err.is_empty() { /* "failed" */ }

// an opaque round trip
let b = rut_vm::Opaque::alloc(&mut vm, MyHandle::new())?;
let back: rut_vm::OpaqueRef = vm.call("stash", (b,))?;
}
  • vm.call::<A, R>(export, args) converts the args under the export’s declared parameter types, runs, and decodes the answer under the declared return. A shape mismatch traps naming both sides.
  • vm.resume::<R>() continues a budget-parked call.
  • Argument bundles are tuples up to arity 8 (CallArgs); each element must be a CallArg (Copy prims, String, &str, Vec<u8>, &[u8], OpaqueRef, Opaque<T>).

repr(C) struct interop

rut has no C-layout struct interop. There is no #[repr(C)] mirror contract, no host-side zero-copy view over rut record fields, and none of the API that such a contract implies: register_struct::<T>(), StructRef<'v, T>, size_of<T>(), align_of<T>(), or per-field offsets exposed to the host. The title is kept because the question comes up on every FFI: here is what actually exists.

The layout rut records really have

A user record (struct or dataclass) is an array of 8-byte slots:

rulecontent
slot width8 bytes; primitive fields sit inline in their slot
composite fieldsa cell-handle slot — a reference to the shared cell (the Rc heap)
declaration orderfields keep declaration order; no hidden members; visibility and out-of-body impl blocks change nothing
heapnon-moving — a slot’s handle stays valid for the cell’s life
where it livesthe module’s reified type table, not a header contract (reified types and layout)

This layout is a VM-internal invariant, not a stable ABI. It is never promised to Rust code byte-for-byte, and nothing in the toolchain checks a host mirror against it.

What the host sees instead

A host touches record data only through the checked boundary (value boundary):

  • Flat crossing records — declare a host struct in the package’s declaration file. Fields-only, every field a crossing type, no methods. The host constructs and reads the values through the declared field table; the shape is the whole surface.

    // server.d.rut
    pub host struct Point { x: f64, y: f64, tag: str }
    pub host fn measure(p: Point) -> f64;
    
  • Opaque state — the rut side holds a class wrapping an opaque handle; the host owns the layout entirely (native containers API surface).

  • Structured reads — for reflection over arbitrary records (field names, types, children by index), use the reflection surface (reflection). It is descriptor-based and read-only; it never exposes raw memory.

Why there is no shared-layout contract

  • The boundary’s crossing set is deliberately scalars, immutable buffers, and opaque handles; a borrowed user structure with host-writable fields would reintroduce aliasing the borrow guards exist to prevent.
  • Records are shared cells, not values: two bindings alias one record, so a &mut C view would race with rut-side mutation.
  • Layout stability belongs to the module binary and its type table (module binary and verification) — a compile-checked, versioned artifact — not to a C header.

Record values at the boundary

rut shapewhat the host sees
tuple (a record of crossing-typed fields)a Rust tuple, positionally, field-by-field under the record’s declared field types — arity 1–8 (value boundary)
host structthe declared flat record, decoded through the field table; the shape is the whole surface
user record (struct/dataclass)never crosses whole — pass it as opaque, mirror it as a host struct, or walk it with reflection
Vec<T> / [T]never crosses; per-element fns, or bytes for raw payloads

The positional tuple decode is the one place record structure reaches Rust, and it is fully checked: a field whose type does not cross is a compile error on the entry, and a runtime shape mismatch traps naming both sides.

What the toolchain checks instead

A shared-layout contract would be checked once, at registration. rut replaces it with checks it can actually enforce:

guaranteeenforced by
every value a host receives has the declared typethe boundary decode (value boundary)
surfaces and bindings agreethe boot join (host fns)
a consumer and a published package agreethe decl digest at link (module binary and verification)
registers hold their declared typesthe verifier (typed bytecode)

Practical recipes

needshape
pass numeric payloads cheaplycross the fields as primitives, or pack into bytes
share mutable native stateopaque box + wrapper class
read rut records from the hostreflection descriptors, or a host struct mirror
fixed binary recordsbytes + b.len()/decode helpers (primitive types)

Host fns and declaration files

One linkage keyword, one implementer:

keywordimplementation lives inbound at link against
host fn / host structthe embedding Rust — a typed registrationthe load-time contract (below)
builtin fn / builtin class / builtin trait / builtin implthe engine itself — compiler-lowerednothing; the decl is a pure signature contract

extern does not exist: rut→rut names resolve through use paths (modules and visibility); host→rut entry points are entry fn (loading and the embed loop).

Surfaces live in declaration files — .d.rut — so compiling rut code never requires any Rust, and a published package can ship its compiled bodies plus a hand-written surface.

Host pkgs

A package directory whose manifest names a declaration file is a host pkg — pure surface, no rut source:

# server/rut.toml
name = "server"
entry.type = "./server.d.rut"
host_scope = "server"        # optional registration prefix; default: the name
// server/server.d.rut
pub host fn subscribe(bus: opaque, topic: str, handler: str) -> nil;
pub host fn emit(bus: opaque, topic: str, payload: str) -> nil;

Consumers reach a host pkg two ways:

The loader lowers the declared signatures into the mounted surface at load time; the compiler type-checks calls against them. A host pkg packs into a .rutbundle like any dep (module bundles).

The surface grammar

host fn name(params) -> T;            // concrete signature; generics are a
                                      // compile error — a generic parameter
                                      // has no shape the boundary checks
host struct Name { fields }           // flat record; every field a crossing
                                      // type; no methods, no field
                                      // initializers — the shape IS the
                                      // whole surface
builtin fn name<T>(params) -> T;      // engine fn, compiler-lowered
builtin class Name<T> { .. }          // engine type member contract
builtin trait Name<T> { .. }          // engine-woven contract
builtin impl i32 { .. }               // engine methods on a primitive

Rules:

  • The crossing set — host fn signatures are concrete over: nil, the primitives, str, bytes, opaque, tuples/?T whose elements cross, and host struct records whose fields all cross. Returns may additionally use the answer optionals ?str / ?bytes / ?opaque. Everything else (user classes, Vec<T>, [T], trait objects, closures) is a compile error on the declaration (value boundary).
  • any is not in the language. It is a reserved word; a .d.rut spelling it is the reserved-word diagnostic at parse time. Seal polymorphic values with opaque(v) / opaque.downcast<T>(v) (opaque).
  • builtin is the engine’s reservation — spelled only in the toolchain’s own decl files (core, calc). A builtin in an embedder decl is a compile error. Users implement builtin traits with ordinary impl blocks; library contracts stay plain trait (traits and dispatch).
  • builtin is a contextual keyword: .d.rut-only; elsewhere it is a legal identifier.
  • opaque wraps native state; there is no host class. rut wraps the handle in a class of its own — the wrapper-class pattern (native containers API surface). Destructors still run deterministically: when a box’s rc hits 0, the payload’s finalize hook runs, then Rust Drop (the Rc heap).
  • Workers: an opaque box crosses isolates only if the host registered the boxed type as send — checked at the transfer, by type (workers and channels).

Declaration mode

A .d.rut is parsed in declaration mode: declarations only, each complete as a surface.

allowednotes
use / pubvisibility exactly as in a module; non-exported decls are known inside the file, nameable nowhere else
letwith load-time constant initializers
enumthe member list is the whole definition
traitmethod signatures (+ requires) are the whole definition
structfields only, with load-time initializers
host fn / host structsignatures only, concrete over the crossing set
builtin fn / class / trait / impltoolchain decl files only

Forbidden — the parser errors “implementation in a declaration file”: any fn/class body, statements. Symmetrically, a .rut file that spells host errors “belongs in a .d.rut”.

Signatures are derived, not hand-written

The registered callable’s Rust shape is the .d.rut row:

#![allow(unused)]
fn main() {
let mut hosts = rut_vm::interp::HostRegistry::new();
hosts.register::<_, (&str,), OpaqueRef, _>(
    "server::make", |vm: &mut Vm, name: &str| vm.alloc_opaque_str(name.to_string()),
);
// two-plus params: spell the marker tuple once, the closure stays plain
rut_vm::register!(hosts, "server::emit", (OpaqueRef, &str, &str) -> (),
    |_vm, _bus, _topic, _payload| Ok(()));
}
  • The signature is a fixed array of type ids derived from the closure’s parameter and return types; a param type with no crossing impl is a compile error at the register site.
  • Both body shapes fit: -> R (infallible) and -> Result<R, Trap> (fallible). Errors travel an explicit trap channel — a trapped body propagates as Err(Trap) with a rut backtrace (embedding and native modules).
  • &str / &[u8] params are zero-copy borrows scoped to exactly the call (value boundary).

The load-time contract

Mounting a host pkg declares; the embedding Rust binds. The two sides are checked against each other before any rut code runs:

#![allow(unused)]
fn main() {
hosts.verify_against(&session.expected_host_fns());  // panics on mismatch
let mut vm = rut_vm::interp::Vm::new(prog, &limits, hooks, hosts)?;  // joins the rest
}

A mismatch is an embedder wiring bug — a panic, never a rut diagnostic — on exactly three classes:

  1. declared but unbound — a rut call would trap mid-run;
  2. bound but undeclared — no surface declares what the host installed;
  3. signature drift — the decl says (opaque, str, str) -> nil, the binding took (opaque, i64, str).

The law follows the mount: mount what you bind. An embedder that mounts calc must bind its float fns; an embedder that needs only core mounts only core.

Slots, not strings

Compiling a declaration file assigns every host fn a stable slot id in declaration order; the module binary carries the slot table. Calls compile to callnat { slot } — names are binding-time labels, resolved once at boot, never dispatch keys, never in IR. A typo’d binding is a startup error, never a runtime one. IR cannot inline into native bodies; if a body should be optimizable, it is rut source — the wrapper class is exactly that place.

Async host fns

// http_host.d.rut — an async row expands into a five-row family
pub host async fn http_send(c: opaque, method: str, url: str,
                            headers: str, body: bytes) -> opaque;

The embedder binds the family with one registration; the closure spawns the work, hands out a completer clone, and returns:

#![allow(unused)]
fn main() {
rut_vm::register_async!(hosts, "http_host::http_send",
    (OpaqueRef, &str, &str, &str, &[u8]) -> OpaqueRef,
    |c: OpaqueRef, method: &str, url: &str, headers: &str, body: &[u8]|
        -> rut_vm::Completer<OpaqueRef> {
        let done = rut_vm::Completer::new();
        let w = done.clone();
        std::thread::spawn(move || {
            match do_request(c, method, url, headers, body) {
                Ok(r)  => w.complete(r),
                Err(e) => w.fail(e.to_string()),
            }
        });
        done
    },
    /* optional abort hook: */ move |_c| { /* best-effort cancel */ },
);
}

register_async!(hosts, name, (P…) -> R, start [, abort]) emits the family the compiler’s weave joins:

emitted rowmeaning
<name>the decl row itself; calling it directly traps — the weave mints the future at the call site
<name>__start(P…) -> opaqueruns the closure, boxes the Completer as the future’s state cell
<name>__yield(state, cx) -> i32resumption probe: 0 pending, 1 ready, 2 failed
<name>__take(state) -> Rmarshals the answer onto the VM thread; a failed completer traps here
<name>__cancel(state)the abort hook when given; otherwise the disclosed no-op
Completer<T> APIMeaning
Completer::new()a fresh cell; clone() shares it (one Arc)
complete(v) / fail(msg)settle from any thread (atomics + mutex only)
poll() -> i32the driving loop’s probe
take_result() -> Result<T, Trap>VM thread only; a fail message becomes the trap at the await

Thread law: the VM stays single-threaded; workers touch only the completer. Cancellation is best-effort — a worker runs to its blocking completion and the late answer is discarded. See the host futures bridge and async and await.

Declaration files as artifacts

A published package ships:

artifactrole
mod.rutcthe compiled module binary with bodies (module binary and verification)
mod.d.rutthe surface — hand-written, editable, publishable text
mod.d.irthe compiled DeclIr — a local cache, never shipped as a contract
#![allow(unused)]
fn main() {
struct DeclIr {          // serialized as .d.ir — no bodies, no code
    version: CompilerVersion,   // exact compiler version
    module: String,             // the specifier this surface answers to
    exports: Vec<Symbol>,       // name, visibility, kind
    types: Vec<RutType>,        // full resolved types incl. bounds
    slots: Vec<(String, SlotId, Sig)>,  // host fn members, decl order
    digest: DeclDigest,         // hash over everything above
}
}
  • .d.ir is unstable across compiler versions: on a version mismatch the toolchain silently regenerates it from the sibling .d.rut. Nothing may link or ship it as a compatibility surface.
  • Link compares the decl digest of the consumer’s surface against the binary’s export table — a pure data compare. Drift is a load error naming both modules, never a runtime surprise.
  • Publishing is not encryption: bodies are compiled and local names are stripped (--release drops member-name strings), but code is recoverable with effort. What publishing guarantees is API discipline.

Surface resolution during compilation

When module M uses package p, the compiler resolves p’s surface, first hit wins:

  1. source — p’s rut source on the source path: compile it normally;
  2. bundle — a mounted .rutbundle: its bundled surface (regenerated from the bundled .d.rut when version-stale); explicit mounts outrank stray caches, never dev source;
  3. cache — p’s .d.ir with a matching compiler version: load directly, no parse;
  4. decl — p’s .d.rut: compile it (and write the .d.ir cache);
  5. host registry — for host pkgs, the .d.rut the embedder ships beside the registered implementation.

Bodies resolve only at link/run. Compiling M against a package whose implementation is absent is legal and complete — checking passes; only running requires the bodies.

End-to-end shape

The chat-bus plugin is the canonical walkthrough: a Rust server binds server::subscribe/server::emit over its own EventBus box, the plugin subscribes rut export names per topic, and every emit re-enters rut to format the wire line — see 03 — Plugin. For a full application host with mounted std packages and an async entry, see 06 — GitHub viewer CLI.

Native containers API surface

A “native container” is host state rut can hold: a Rust value boxed as an opaque handle, with a set of host fns as its API and a wrapper class on the rut side. No second class system, no per-instantiation machinery: the declaration is host fns with concrete signatures over opaque (host fns and declaration files), and the wrapper is ordinary rut source the compiler can optimize around.

The pattern

Declaration — rut source, ships with the plugin:

// my_map.d.rut
pub host fn my_map_new(cap: i32) -> opaque;
pub host fn my_map_set(m: opaque, k: str, v: opaque) -> nil;
pub host fn my_map_get(m: opaque, k: str) -> ?opaque;
pub host fn my_map_size(m: opaque) -> i32;

Implementation — Rust bodies only; values stay sealed boxes:

#![allow(unused)]
fn main() {
use rut_vm::interp::{HostRegistry, Vm};
use rut_vm::{Opaque, OpaqueRef, Trap};
use std::collections::HashMap;

struct MyMap { inner: HashMap<String, OpaqueRef> }

pub fn my_map_module() -> HostRegistry {
    let mut hosts = HostRegistry::new();
    rut_vm::register!(hosts, "my_map::my_map_new", (i32,) -> OpaqueRef,
        |vm: &mut Vm, cap: i32| {
            Ok(Opaque::alloc(vm, MyMap::with_capacity(cap.max(0) as usize))?
                .handle().clone())
        });
    rut_vm::register!(hosts, "my_map::my_map_set", (OpaqueRef, &str, OpaqueRef) -> (),
        |vm: &mut Vm, m: OpaqueRef, k: &str, v: OpaqueRef| {
            let map = Opaque::<MyMap>::from_handle(&m)?;
            map.with_mut(vm, |_vm, map| {         // exclusive, call-scoped
                if let Some(old) = map.inner.insert(k.to_string(), v) {
                    drop(old);                    // releases the displaced box
                }
                Ok(())
            })
        });
    rut_vm::register!(hosts, "my_map::my_map_get", (OpaqueRef, &str) -> Option<OpaqueRef>,
        |vm: &mut Vm, m: OpaqueRef, k: &str| {
            let map = Opaque::<MyMap>::from_handle(&m)?;
            Ok(map.with(|map| map.inner.get(k).cloned())?) // Option<OpaqueRef>
        });
    rut_vm::register!(hosts, "my_map::my_map_size", (OpaqueRef,) -> i32,
        |_vm, m: OpaqueRef| {
            let map = Opaque::<MyMap>::from_handle(&m)?;
            Ok(map.with(|map| map.inner.len() as i32)?)
        });
    hosts
}
}

Consumer side — an ordinary rut class; every method is exactly one host call:

use my_map::{ my_map_new, my_map_set, my_map_get, my_map_size };

pub class MyMap {
    h: opaque;
}

impl MyMap {
    pub fn new(cap: i32) -> Self { return Self { h: my_map_new(cap) }; }
    pub fn set(mut self, k: str, v: opaque) -> nil { my_map_set(self.h, k, v); }
    pub fn get(self, k: str) -> ?opaque { return my_map_get(self.h, k); }
    pub fn size(self) -> i32 { return my_map_size(self.h); }
}

Consumers recover values with opaque.downcast<V>(box) — nil on mismatch, checked, never a trap (opaque).

The connection contract — two checkpoints

checkpointwhencheckserrors to
compile/verifycompile / verifyevery call vs the declared host rows: concrete types over the crossing set. Checking needs no Rust.rut author
linkbootevery host fn referenced by rut code has a bound, signature-equal impl — a pure data compare (host fns).loader / embedder

A name bound that no decl declares is an embedder startup error (typo guard). Drift between the halves never reaches a rut runtime.

What crosses

The crossing rule is the whole story (value boundary): primitives, str (hashed host-side by content), bytes, tuples and ?T over those, and opaque. Map values cross as opaque — set stores the box unopened, get hands it back. Nothing native re-enters the VM through a vtable, and no native signature has a type parameter.

The typed box view

APIMeaning
Opaque::alloc(vm, val)mint a box; shallow size_of::<T>() charged to the heap budget
Opaque::alloc_hosted(vm, val)opt the payload into a finalize(heap) release hook
Opaque::from_handle(&h)the checked view; a wrong payload T traps naming both sides
o.with(|v| …)shared borrow; nested withs stack
o.with_mut(vm, |vm, v| …)exclusive borrow; a re-entrant call touching the same box traps borrowed by an outer host call
o.handle()the erased OpaqueRef to hand back to rut

Guards live on the store entry and clear on return; to keep data, copy (value boundary).

Notes

  • Hashing: str keys hash by content — a registered builtin impl. Structured keys are the rut wrapper’s business (stringify, or a wrapper defined strategy); no user hashing contract crosses.
  • Containers do not cross: Vec/[T] stay inside the VM. Iterate via per-element fns, keep the index rut-side, or pack into bytes.
  • Builtin answers flow back natively: a fn may return an optional built host-side (?str/?bytes/?opaque answer lanes); rut cannot tell it was not written in rut.
  • Memory: the instance lives in the box; at rc-0 the payload’s finalize hook runs first, then Rust Drop — dropping the container releases every stored handle deterministically, with no collector involvement (the Rc heap). An OpaqueRef’s own Drop releases its reference; dropping a stored handle is the release.

In-tree consumers

Templates — f“…“ across the boundary

Status: this page specifies the template surface; it is not yet wired into the engine. The format-literal behavior it extends is live (literals and inference).

A format literal normally renders to a str at the call site — it desugars to a string concatenation, and the structure of the interpolated values is gone. Hosts that need the structure — localization, structured logging, analytics — must not re-parse strings.

Template is the answer: a builtin value type built only by format literals, chosen by expected type.

One literal, two behaviors

expected typebehavior
strthe ordinary desugaring to concatenation — zero new cost on the hot path, output identical to the plain formatting path
Templatethe literal compiles to a template construction: literal chunks and boxed values, not pre-rendered text
pub host fn label(t: Template) -> nil;      // a host fn taking structure

fn work(name: str, n: i32) -> nil {
    let s = f"hi {name}, n={n}";            // str position: rendered text
    let t: Template = f"hi {name}, n={n}";  // Template position: structure
    label(t);
}

The value

A Template is { parts: [str], args: [opaque] } — the literal chunks, and the interpolated values boxed with their runtime types through the erasure box (opaque — erasure and downcast). Construction is an internal native call; there is no user-spellable constructor and no other way to mint one.

APIMeaning
t.str() -> strrender with rut’s own formatting rules — byte-identical to the str path
t.parts() -> [str]the literal chunks
t.args() -> [opaque]the boxed arguments, in order
t.type_id(i) -> u32argument i’s runtime type id

Nothing else. Like opaque, a template cannot do anything until someone renders it: recover an argument with opaque.downcast<T>(a) — i64 stays i64, so locale decimal separators and number formats are the renderer’s choice, never baked into a pre-rendered string.

At the boundary

A Template parameter arrives as { parts, args } — the chunks and the typed args:

#![allow(unused)]
fn main() {
rut_vm::register!(hosts, "app::label", (rut_vm::Template,) -> (),
    |vm: &mut rut_vm::interp::Vm, t: rut_vm::Template| {
        // per-locale formatting: reorder placeholders, re-render numbers
        let _ = (t.parts(), t.args());
        Ok(())
    });
}

Example: locale-aware rendering

// app.d.rut
pub host fn label(t: Template) -> nil;

pub fn checkout(item: str, price: f64, n: i32) -> nil {
    label(f"{item}: {n} x {price}");
}

The literal in label’s argument position compiles to the template — parts ["", ": ", " x ", ""], args boxed in order. The host renders per locale: reorder the placeholders, format price with the locale’s decimal separator, pluralize around the n arg — i64/f64 arrive as typed values, so none of that is baked into a pre-rendered string:

#![allow(unused)]
fn main() {
rut_vm::register!(hosts, "app::label", (rut_vm::Template,) -> (),
    |vm: &mut rut_vm::interp::Vm, t: rut_vm::Template| {
        for (chunk, arg) in t.parts().iter().zip(t.args().iter()) {
            // emit chunk; if the locale grammar needs arg i, recover it:
            // let n: i64 = opaque.downcast-like read on the host side
        }
        Ok(())
    });
}

Laws

  • The expected type chooses the behavior; a literal never renders “twice”. The str path and Template.str() are byte-identical by construction.
  • Arguments are retained by the template’s boxes; a template keeps its args alive as any cell does (the Rc heap).
  • A Template crosses the host boundary, and crosses isolate channels like any builtin (workers and channels) — a worker may return one where the main VM expects Template.

Reflection

Reflection is a trait, not a keyword privilege. Reflectable is the mechanism protocol; compiler auto-implementations (dataclasses, enums) and builtin-impl registry entries (enums, records, Vec, [T]) fill it for the data world; classes opt in by hand with a curated view. Libraries layer contracts on top and take trait-object-typed consumers or bounded producers. Nothing in the language names a builtin; no strings are matched; nothing changes under --release stripping. Userland serde is the proving application.

Status: this page specifies the reflection surface; the protocols are not yet wired into the engine. The surfaces it builds on — traits, opaque, reified descriptors (reified types) — are live.

The protocols

// reflect/reflect.d.rut
pub trait Reflectable {                     // the mechanism protocol
    fn reflect(self) -> opaque;             // exact descriptor handle
    fn arity(self) -> i32;                  // children of THIS value
    fn child(self, i: i32) -> ?opaque;      // i-th child, boxed
}
pub trait Deserializable requires Reflectable { }
typeReflectableDeserializablestringifydeserialize
dataclasscompiler auto-implautoopt-in (impl Serializable for T {})yes
user enumcompiler auto-implautoopt-inyes
Option/Result/Vec/[T]builtin-impl registry, every instantiationregistryas fields onlyyes ([T; N] minting excepted — deserialize targets Vec<T>)
class, no impl——compile error at the callcompile error at the call
class, manual implhand-written (curated)impossibleyes (positional view)compile error
  • Auto-impls are ordinary vtable fills: a dataclass walks its fields (arity = field count, child i = field i, boxed); an enum walks the current variant’s payloads. The descriptor’s impl list gains the entry implicitly; re-declaring one is a duplicate-impl error. Auto-impls satisfy requires edges of contract layers written on top — impl Serializable for User {} costs zero methods.
  • Deserializable is auto-only: hand-writing impl Deserializable for T is a compile error. Reflective construction is descriptor-backed, and classes construct through their own class methods — reflection never calls one. The trait is not vacuous, so it can still be a bound.
  • Manual class views are curated: private fields stay hidden because the impl does not expose them — privacy is what the impl says. They serialize positionally and cannot deserialize.

The engine surface

pub trait ReflectEngine { }                 // module capability (below)
pub enum TypeKind { Leaf, Record, Sum, Seq }
pub enum LeafKind  { Bool, Int, Float, String, Class, Trait }

builtin fn reflect<T>() -> opaque;          // static T (incl. trait T):
                                            // the descriptor, folded at
                                            // compile time
pub host fn type_of(a: opaque) -> opaque;   // content descriptor, boxed

A TypeInfo is an opaque handle; the rut surface wraps it:

pub class TypeInfo { d: opaque; .. }        // methods forward to the fns below
pub class FieldInfo { d: opaque; .. }       // name / ty / default
pub class SumVariant { d: opaque; .. }      // name / payload ordinals

The fn surface the wrapper forwards to:

fnmeaning
type_kind(t: opaque) -> i32TypeKind ordinal
type_leaf(t: opaque) -> i32LeafKind ordinal
type_name(t: opaque) -> strthe type’s name
type_id(t: opaque) -> u32equals type_id<T>() for static T
type_is_a(t: opaque, i: opaque) -> boolthe nested-node gate (descriptor walking)
type_fields_len(t: opaque) -> i32 / type_field(t, i) -> opaqueRecord: fields in declaration order
type_variants_len(t: opaque) -> i32 / type_variant(t, i) -> opaqueSum: variants in declaration order
type_elem(t: opaque) -> opaqueSeq: the element descriptor of Vec<T> / [T]
type_arity(t, a) -> i32dynamic re-entry: Seq length; Sum = current variant’s payload count
type_child(t, a, i) -> ?opaquedynamic re-entry: the i-th child, boxed
type_variant_of(t, a) -> i32Sum: the current variant index
type_construct(t, vals…) -> ?opaqueRecord mint; re-checks every box — mismatch is nil, never a trap
type_construct_variant(t, i, vals…) -> ?opaqueSum mint
type_make_vec(t, vals…) -> ?opaqueSeq → Vec<T> mint

FieldInfo/SumVariant accessors over their own handles: name: str, ty: TypeInfo box, default: ?opaque (the folded load-time initializer — the parse-time default), payload ordinals ([] for C-like enums, [T]-shaped for Some/None/Ok/Err).

Laws:

  • No builtin is named anywhere. Option/Result appear only as sum-shaped descriptors (Some/None is a {0,1} sum, Ok/Err a {1,1} sum; a C-like enum is an all-payloadless sum).
  • Composite children box the cell handle — a child of a record field aliases the parent’s field, so mutation through the original is observable in the box. Walkers treat them as their own Record/Sum nodes via the box’s descriptor.
  • Boxing widens: an opaque of an int stores i64 sign/zero-extended; a float, f64. A Leaf branch + downcast<i64> / downcast<f64> / downcast<bool> / downcast<str> is therefore total (opaque).
  • No string identity: field/variant names are descriptor data, never symbols — stripping native names never touches them. Sum policy dispatches on payload structure, never variant-name matching.
  • Accessors are total; optional/i32 results signal mismatch. There is no field setter: children alias their source cells, so v1 reflection is read-only.

Rules

  1. Engine admission: the structural symbols (reflect<T>, type_of, TypeInfo, FieldInfo, SumVariant) resolve only in modules declaring at least one impl ReflectEngine for T; the violation is a compile error naming the fix. Calling stringify/deserialize needs no engine — the argument type or the bound carries the contract. The is keyword is likewise ungated: it answers the capability bit, while descriptor walking stays behind admission — probing and walking are different powers.
  2. Walkability = implements the protocol (auto, registry, or manual). Entries may demand a contract (Serializable) or a capability (an inline requires Deserializable bound on a generic — admission-only; it grants no method calls on bare T). Nested nodes are gated by the descriptor is_a(Reflectable) query; misses are value-shaped errors, never traps.

The walker

Every call below exists in the tables above — this trace is the API’s test:

pub fn stringify(v: Serializable) -> (str, err) {
    return write_val(v.reflect(), v);    // vtable reflect(); descends to opaque
}

pub fn deserialize<T requires Deserializable>(v: str) -> (?T, err) {
    let t = reflect<T>();                // guaranteed descriptor-backed by
    let (tree, e1) = parse_tree(v);      // the bound — no runtime pre-check
    if (e1 != nil) { return (nil, e1); }
    let (built, e2) = build(t, tree);
    if (e2 != nil) { return (nil, e2); }
    let opt = opaque.downcast<T>(built); // monomorphized type compare -> ?T
    if (opt == nil) { return (nil, construction_failed(t)); }
    return (opt, nil);                   // refined to exact T in this branch
}

write_val: Record → fields + children joined; Seq → arity/child loop (Vec and [T] alike); Sum → all-payloadless renders the variant name, a {0,1} sum renders null/the payload, otherwise “unwrap first”; Leaf → downcast the widened scalars; a Leaf class → is_a(Reflectable) query, then the positional walk or an error. build: Record → member lookup, absent → the field’s folded default, then construct; Sums → construct_variant; Seq → make_vec; Leaves → opaque(v).

Reflection vs the shipped json

The in-tree json package does not ride reflection — its traits are direct, nominal, and container impls live in the package itself, which measures decisively faster for schema-driven decode (core and the swappable packages). Reflection is the right tool for generic tooling: debug walkers, schema printers, generic editors, and userland serde where flexibility outranks raw throughput.

core and the swappable packages

The standard library splits in two:

  • core — the only standard. The prelude surface, uniformly engine-implemented: it registers no host bodies and needs no mount beyond the base one.
  • the swappable packages — in-tree rut/host packages that a module declares in its manifest [deps] (project structure). The community may replace any of them wholesale; nothing in the engine knows their names.

Anything else — gfx, imaging, your own my_map — is an embedder package: a declaration file plus Rust bodies registered the same way (embedding and native modules).

core — the ambient prelude

Every builtin name is in scope in every compilation unit; no use is needed. A use core::{ … }; statement stays legal but is redundant. The one exception is the const NAN: use core::{NAN} is explicit.

Functions

signaturemeaning
assert(cond: bool, msg: str) -> niltrap Assert when false; msg may be omitted at the call site
panic(msg: str) -> nilabort with the message
on_drop<T>(p: ?T, cleanup: fn(?T)) -> nilrun cleanup(p) when p’s cell refcount reaches zero; one callback per pointer
string_join(parts: [str]) -> strjoin in one pass
capture_stacktrace() -> StackTraceopt-in stack snapshot: raw frames only, symbols resolved lazily per access
str.from_code(n: u32) -> strthe 1-codepoint string

Primitives and their members

builtin primitive str {
    fn len(self) -> i32;                     // codepoints
    fn code(self) -> u32;                    // the FIRST codepoint (traps on empty)
    fn code_at(self, i: i32) -> u32;         // traps out of bounds
    fn encode(self) -> bytes;                // the UTF-8 octets
    fn slice(self, from: i32, to: i32) -> str; // O(1) view, codepoint bounds
    fn scan(self, from: i32, set: [u8]) -> i64;  // fused classify; (stop << 8) | class
    fn starts_with(self, from: i32, head: str) -> bool;
}

builtin primitive bytes {
    fn len(self) -> i32;                     // octets
    fn decode(self) -> str;                  // UTF-8, lossy
    fn clone(self) -> bytes;                 // the one copy escape hatch
}
// type-methods: bytes.zeroed(n) -> bytes, bytes.from(a: [u8]) -> bytes

builtin primitive opaque {
    fn downcast<T>(o: Self) -> ?T;           // nil on mismatch; `x is T` probes
}

Construction is a call of the type name: opaque(v) seals, Weak(v) wraps, StrBuf(cap) pre-sizes (opaque, weak references).

Builtin classes

classmembers
StackTracelen() -> i32, name(i) -> str, line(i) -> i32, col(i) -> i32, render() -> str
StrBufStrBuf(cap), push(str), push_code(u32) (invalid scalars mint U+FFFD), len() -> i32, finish() -> str — the ONE materialization; the builder keeps its buffer
Weak<T>Weak(v) (traps on nil; reference types only), upgrade() -> ?T — nil once the referent died

Engine-woven traits

traitmembernotes
Iterator<E>fn __iterate(self, emit: fn(E) -> bool)for (x of it) desugars to it; emit returning false stops
Future<T>fn yield(cx: RunContext)every async fn’s hidden frame implements it; await consumes it
RunContextcheckpoint() -> u32, next_checkpoint(mut self, v: u32) -> nil, cancelled() -> boolthe async protocol’s cx record (async and await)

Users implement these with ordinary impl blocks; the engine has compiler-backed impls for its own types.

Numeric methods (per integer width i8–u64)

wrapping_add/sub/mul/shl   — modular, no trap
saturating_add/sub/mul     — clamped at the width's ends
checked_add/sub/mul        — the tuple (value, ok); ok is false exactly
                             when the mathematical result escaped the width

Ambient like the primitives themselves: x.wrapping_add(y) needs no use. The trap-on-overflow +/-/*/<< stay the default operators.

Constants

NAN (f64) — core’s one const, behind use core::{NAN}. All other float constants are calc’s.

What core does not have

removedreplacement
Option<T> / Result<T, E>?T with nil as absence; (T, err) tuples (by-reference and nullable)
own / make_ptr?T bindings are the cell reference
charstr of one codepoint; s.code()/str.from_code(n)
free downcast<T>(o)opaque.downcast<T>(o)
Array as a namethe [T] grammar; [v; n] repeat construction
output builtins (print, console)a logger package (ink)

No removed surface keeps compatibility routing: a removed head in an unresolvable position is an ordinary unknown-name error.

The swappable set

packagekindsurface
rthost pkgcreate_logger(name: str) -> opaque, logger_log(log: opaque, level: i32, msg: str)
inkinline rut pkgthe Logger class over rt
pouchinline rut pkgthe growable sequence Vec<T>
nmap_host / nmapsethost pkg + inline rut pkgthe native key table; HashMap/HashSet
jsoninline rut pkg, zero host fnsencodeJson / decodeJson / decodeJsonBytes + traits
strbuildinline rut pkg, zero depsthe StringBuilder class
calchost pkgthe Math namespace
async_engine / async_hosthost pkg + inline rut pkgthe launcher rows; launch_future / sleep
http_host / httphost pkg + rut pkgthe std HTTP lanes
bench-crosshost pkgthe crossing-tax benchmark rows

rt and ink — logging

There is no print, no global output builtin. All logging goes through a used logger; the host owns the sink, and an uninstalled sink is a silent no-op — a script cannot accidentally spam an embedded host’s stdout.

use ink::{ Logger };

pub fn main() {
    let log = Logger.new("app");
    log.info(f"started");
}
started
methodlevel passed to rt
debug(msg)0
info(msg) / log(msg)1
warn(msg)2
error(msg)3

Embedder side: rut_std::logger::install_std_log(&mut hosts, |s| println!("{s}")). Mounting ink pulls rt along ([deps]).

pouch — Vec<T>

The growable sequence, written in rut over the fixed [T] array:

membermeaning
new() / with_capacity(cap) / filled(v, n)construction ([v; n] under the hood)
push(v) / pop() -> Tpop returns the removed element; traps on empty — guard with len() > 0
get/set via v[i], for (x of v)compiler-lowered; element access aliases the stored cell
from([T]) / as_array() -> [T]bridges to the fixed array
freeze() -> bytesthe Vec<u8> → immutable bytes copy (exactly the live length)
slice(from, to) -> ?Vec<T>compiler-lowered O(1) fixed-length window; detaches on grow

nmapset — HashMap/HashSet

Thin wrappers over the native key table (nmap_host rows): the whole state is one opaque handle, every method one host call.

pub class HashMap<K requires i8 | i16 | i32 | i64 | u8 | u16 | u32 | u64 | bool | str | bytes, V> {
    pub fn new() -> Self;
    pub fn with_capacity(n: i32) -> Self;
    pub fn put(mut self, k: K, v: V) -> bool;      // true = newly inserted
    pub fn get(self, k: K) -> ?V;                  // the STORED cell, not a copy
    pub fn has(self, k: K) -> bool;
    pub fn remove(mut self, k: K) -> bool;
    pub fn len(self) -> i32;
}
pub class HashSet<T requires ..same key set..> { new, with_capacity, put, has, remove, len }

Laws: the key set is closed (no floats — no stable equality; encode a custom key canonically to bytes); get answers the stored cell, so two gets of one key name one value until a replace; values release host-side. str keys hash by content; str keys also carry *_range(parent, off, len) members keyed by a byte range of the parent string — a range key is the same key as its content, with no key cell minted on a probe (string views).

calc — the Math namespace

use calc::{ Math };
use ink::{ Logger };

pub fn main() {
    let log = Logger.new("t");
    let x = 3.0f64;
    let y = 4.0f64;
    let d = Math.sqrt(x * x + y * y);
    let a = Math.abs(x);
    let hf = Math.sqrt_f(2.0);            // f32 twin — the width is in the name
    log.info(f"{d} {a} {hf}");
}
5 3 1.4142135

Functions (each with an _f f32 twin): sqrt, floor, ceil, round, trunc, exp, ln, log2, log10, sin, cos, tan, asin, acos, atan, sinh, cosh, tanh, pow, atan2, hypot, copysign, fma, plus the float helpers abs, min, max, signum. Constants: Math.PI, TAU, E, SQRT_2, LN_2, LN_10, LOG2_E, LOG10_E, INFINITY, NEG_INFINITY, EPSILON, MAX, MIN, MIN_POSITIVE. Integer numeric methods are core’s, not calc’s.

json — the serde package

Pure rut, inline = true, zero host fns — a json mount adds no host bindings.

fn encodeJson<T requires JsonSerialize>(v: T) -> (?str, ?EncodeJsonError);
fn decodeJson<T requires JsonDeserialize>(s: str) -> (?T, ?DecodeJsonError);
fn decodeJsonBytes<T requires JsonDeserialize>(b: bytes) -> (?T, ?DecodeJsonError);

trait JsonSerialize   { fn encode(self, mut w: JsonWriter) -> ?EncodeJsonError; }
trait JsonDeserialize { fn decode(mut r: JsonReader) -> (?Self, ?DecodeJsonError); }
  • The pair law: (?T, ?E) with exactly one nil — success = (value, nil), failure = (nil, err). Encode returns ?EncodeJsonError because a cyclic structure is expected, recoverable data, not a trap.
  • Direct decode: no intermediate document; a type’s decode reads its expectations straight off the cursor. A Vec<Row> decode mints exactly the program’s values, once.
  • Numbers: i64 accepts integer lexemes only (overflow/decimal lexeme = WrongType, never a silent wrap); f64 parses IEEE-exact in the common range, ±1 ulp beyond (disclosed); encode renders the shortest round-trip decimal.
  • Depth: capped at 128 both directions, recoverable (DecodeErrorKind::Depth / EncodeErrorKind::Depth).
  • Errors: payloadless kind enums + fixed-field structs — DecodeErrorKind { Unexpected, Truncated, InvalidUtf8, WrongType, Depth, Trailing }, EncodeErrorKind { Depth, NotFinite, KeyUnsupported }; decode details carry at/got/expected, encode details carry at and the lazily built $.rows[3].name path.
  • decodeJsonBytes is strict UTF-8 (InvalidUtf8), never lossy.
  • Ownership: json owns the traits; all container impls live in json, gated by peer groups — Vec rows activate when pouch is anywhere in the consumer’s closure, the map/set rows when nmapset is (dependency kinds). A consumer without the peers mounts json light.
  • The writer accumulates through strbuild’s StringBuilder; the reader rides the core str.scan/starts_with primitives and O(1) string views.

strbuild — the builder

use ink::{ Logger };
use strbuild::{ StringBuilder };

pub fn main() {
    let log = Logger.new("t");
    let k = "name";
    let mut b = StringBuilder.with_cap(1024);   // octet hint
    b.append(f"{k}=");
    b.append_code(0x21);
    let s = b.build();                          // the ONE materialization
    log.info(s);
}
name=!
membermeaning
new()grow from small
with_cap(cap: i32)pre-size to an octet hint (negative traps)
append(mut self, value: str)amortized O(|s|), in place
append_code(mut self, cp: u32)one codepoint; invalid scalars mint U+FFFD
len() -> i32codepoints so far, O(1)
build() -> strfresh immutable str; the builder keeps its buffer

Sharing is the default: appends through an alias (or a mut parameter) land in the caller’s document; copies happen at exactly two engineered points — build’s materialization and growth’s prefix move.

calc’s company: async and http

  • async_engine declares the engine rows (__launch, __abort, __sleep, __sleep_yield); async_host restores the typed surface: launch_future(f: Future<T>) -> LaunchedFutureHandle<T>, LaunchedFutureHandle.abort() -> bool, sleep(ms: u32) -> Future<nil>. Each embedder mounts the pair and installs rut_std::async_host::install_std_async; a session that mounts neither has no launcher (tasks, host futures).

  • http_host declares the transport rows (three async, five sync readbacks); http wraps them in HttpClient / RequestBuilder / Request / Response / ByteStream — async only at the points that really wait:

    use http::{ HttpClient, ClientQueryMethod };
    
    let resp = await HttpClient.new().request()
        .method(ClientQueryMethod.Post)
        .url("https://example.com/api")
        .header("Accept", "application/json")
        .body(payload)
        .build()
        .send(cx);
    

    send resolves at headers; body(cx) drains the wire; byte_stream()/next(cx) walk it in chunks. Status 0 is reserved for transport failure. The reqwest lane is native-only.

Mounting

  • mount_std mounts core + calc; mount_std_async adds the async pair. Swappable packages mount by directory or bundle (module bundles); the rut CLI mounts the tree packages a loose file names by use (the rut CLI).
  • inline = true packages (ink, pouch, nmapset, json, strbuild, async_host) are source-inlined into each consumer — required for class-method and generic surfaces, which cannot cross a module link boundary (the frontend).
  • Generic functions cannot cross a module link boundary; exported linked surfaces carry only monomorphized fns.

The frontend

The frontend turns source text into a checked-syntax tree: source (UTF-8) → lexer → tokens → parser → AST, with one diagnostics model flowing out of every stage. It has no type knowledge — resolution and checking begin in the compiler pipeline.

The implementation is three dependency-free crates: rut-lexer (span, token, lexer, diag), rut-ast (the arena tree and its dumper), and rut-parser (the frame machine and its expression engine). Everything is no_std-clean and wasm-compatible, so the same code runs in the CLI, the LSP, the formatter, and the browser playground.

The contract

Four invariants hold for any input, hostile included:

invariantmechanism
C1Flat AST — nodes are records in one arena; children are NodeIds, never boxed pointers, built bottom-up. Drop/clone are flat Vec ops.AST
C2No native recursion — an explicit frame stack handles structure; expressions use an iterative operator/operand engine. Host stack use is constant regardless of input.parser
C3Depth budget, not stack exhaustion — nesting over the budget is a normal diagnostic, never a host crash.budgets
C4Lookahead discipline — local decisions peek at most 4 tokens; the two decisions that need more use read-only balancing scans. The cursor is monotone non-decreasing for the whole parse, recovery included — there is no checkpoint/rollback.lookahead

The lexer and parser are pure functions — lex(src) -> (Vec<Token>, Vec<Diag>) and parse(src, mode) -> (Ast, Vec<Diag>) — so they are fuzzable and reusable in an LSP or formatter with no VM present.

The lexer

  • One flat Tok enum for literals, punctuation, and operators. Keywords are ordinary Idents; the parser matches them by interner name. Reserved words are rejected by the lexer with a rut does not have X message (new, switch, case, ?., ??, any, …).
  • Maximal munch with an explicit longest-match table; / vs // vs /* resolves with one lookahead. Compound assignment operators are one token each (+=, <<=, &&=, …), so there is no munch ambiguity.
  • Numeric literals: 0x/0b/0o, _ separators, and per-width suffixes (u8..u64, i8..i64, f32/f64). Unsuffixed integers default to i32, unsuffixed floats to f32.
  • Comments are dropped (doc comments are re-attached by the formatter’s gap-scan, below).

Format strings

f"..." is the one construct the lexer must understand structurally, because placeholders hold expressions, not strings:

#![allow(unused)]
fn main() {
struct FStrTok { parts: Vec<FPart> }
enum FPart {
    Lit(String),      // decoded like a plain string; {{ }} -> { }
    Hole(Vec<Token>), // a FULLY LEXED token stream, `}`-terminated
}
}

The lexer enters hole mode after { (not {{), balances braces, and lexes normally until depth 0. Holes are re-lexed, not stored as text, so placeholder expressions carry real spans inside the literal’s span. A hole may contain any expression except a string literal — a quote inside a hole is a lex error with a bind it to a name first note. See Templates for the surface semantics.

Raw strings r"..." carry everything up to the closing " literally; \" inside a raw string does not close it.

Nesting budgets

Two budgets make C3 a law:

  • NEST_MAX = 1024 — the lexer’s bracket depth budget (rut-lexer/span.rs). Exceeding it is one clean diagnostic at the offending opener.
  • EXPR_MAX = 64 — the parser’s expression-nesting budget. The frame machine itself has no host-stack cost, but downstream recursive walks (AST dump, typecheck) do; the cap keeps the trees they visit shallow.

Both produce the same diagnostic — nesting too deep — and neither can overflow the host stack.

The parser

One loop over the token slice drives two mechanisms:

  • A frame stack handles structure — one frame kind per grammar rule (Module, Item, Block, TypeArgList, Pattern, Arm, Impl, …). A rule that needs a child pushes a frame; a completed frame pops and hands its NodeId to the parent.
  • An embedded Pratt engine handles expressions — an operator stack plus an operand stack of NodeIds. Binary and assignment steps push an operator; reductions pop operands and push one node, bottom-up.

There is no grammar generator and no left-recursion handling. Binding powers, loosest to tightest:

precassocoperators
1right= += -= *= /= %= &= |= ^= <<= >>= &&= ||= (target must be a path or index)
2–=> lambda bodies — decided by the scan below, not by binding powers
3left||
4left&&
5left== !=
6left< > <= >=
7left| ^
8left&
9left<< >>
10left+ -
11left* / %
12leftas — numeric cast; the RHS is a type-naming position
13rightunary - ! ~
14leftpostfix: .name, .name<..>(..), (..), [..], ?

Postfixes chain in one loop, so p.value.x and arr[i].push(x) compose without special cases. is (type test) is a keyword; as is the only cast.

Lookahead

rut’s grammar makes shallow peeking enough: statements are keyword-led, { never starts an expression, imports are flat, and paths are .-dotted.

decisionmechanism
item dispatchpeek 1 (use let enum struct class trait impl fn)
statement vs expression-statementpeek 1 (leading keyword)
for-of vs C-style forpeek 4: for ( let Ident <of or =>
instance vs class methodpeek 4: fn Ident ( <mut? self? …>
struct literal vs path expressionpeek 2: Ident { ⇒ literal (classes have no instance literal)
when-arm body formpeek 1 after -> ({ ⇒ block arm, else expression arm)
postfix loop steppeek 1 (. ( [ ?)
assignment targetno lookahead — parse, then validate

Two decisions need more than 4 tokens and use scans — read-only, commit-once, never a guess:

  • Lambda vs parenthesized expression. Scan to the matching ); it is a lambda iff the tokens form a valid parameter list and => follows (an optional -> Type may sit between). Otherwise a comma inside the parens errors at the comma — rut has no tuples-as-expressions spelling — with a note: if you meant a lambda, add =>.
  • Generic call vs < comparison (Vec<f32>(n) vs a < b > (c)). Scan from the < tracking angle depth; a >>/<< consumed where a closer/opener is expected counts as two, so Vec<Vec<i32>> needs no re-lexing. Generic arguments commit iff depth returns to 0 on a > and the very next token is (. a < b > (c) is not expressible unparenthesized — write (a < b) > (c).

Scans never mutate parser state. Peeking at arbitrary distance is allowed; checkpoint/rollback is not — the API does not exist, so the monotone-cursor invariant (debug-asserted) cannot be violated, even during error recovery.

Recovery

Within a file the parser resynchronizes at ;, }, or the token after a balanced block and keeps parsing — a file yields many diagnostics, not one. Resync only ever skips forward.

Declaration mode

The mode is chosen by file extension: Mode::Impl for .rut, Mode::Decl for .d.rut. Both share one grammar; declaration mode adds the surface declarations — host fn, host struct, builtin fn, builtin class, builtin trait — and forbids bodies: any block, impl blocks included, is rejected with implementation in a declaration file. Implementation mode rejects host/builtin with the mirror message. The full surface and how bodies bind at load time live in Host fns and declaration files.

Declarations-only module scope is enforced by the parser itself: a statement at module top level is a syntax error, not a semantic one. The grammar itself is specified in Lexical structure and Modules and visibility.

The AST

Nodes are plain records in one arena; children are NodeIds into it. Spans sit on every node; there are no String keys — names are IdentIds into an interner that lives in rut-core, shared by the AST, the type table, and the module binary’s name table. The interner pre-interns a fixed well-known table (rut_core::sym: self, Self, nil, main, __iterate, the builtin members, the primitives, …), so ids below well_known_len mean the same name in every interner instance and special names compare as IdentId equality, never by text.

#![allow(unused)]
fn main() {
struct Ast { nodes: Vec<Node>, root: NodeId }
struct Node { span: Span, kind: NodeKind }

enum NodeKind {
    // items
    Use { pkg: IdentId, names: Vec<IdentId> },
    Let { vis, name: IdentId, ty: Option<NodeId>, init: NodeId },
    Enum { vis, name, members: Vec<IdentId> },
    Struct { vis, name, generics, fields: Vec<NodeId> },  // fields only
    Class  { vis, name, generics, fields: Vec<NodeId> },  // fields only —
                                                           // methods live in impls
    Trait  { vis, name, generics, requires, methods },
    Impl   { trait_ref: Option<NodeId>, target: NodeId },
    Fn     { vis, name, generics, params, ret, body },
    SurfaceFn / SurfaceClass,                             // .d.rut only
    // statements
    LetStmt, If, While, ForOf, ForC, Return, When, ExprStmt,
    // expressions
    Lit(Lit), Path(Vec<IdentId>),
    Call { callee, generics, args },
    Method { recv, name, generics, args },
    Field { recv, name }, Index { recv, idx },
    Unary, Binary { op, lhs, rhs }, Assign,
    Lambda { params, ret, body }, When { scrut, arms },
    Try { expr },                 // postfix ?
    FStr { parts },               // holes are NodeIds
    StructLit { ty, fields }, Array { elems }, Conv { ty, expr },
}
}

Stable NodeIds are the rest of the pipeline’s currency: the checker keys side tables by node id, diagnostics reach spans through them, and the LSP, formatter, and dumper hold copyable references with no reparenting.

What the AST does not contain: no new, no switch/case, no ?. or ?? — the lexer rejects those words before parsing. There are no paren nodes either: the parse’s grouping is the tree.

Diagnostics

#![allow(unused)]
fn main() {
struct Diag { span: Span, msg: String,
              labels: Vec<(Span, String)>, notes: Vec<String> }
}

One renderer serves every frontend stage: a primary line with a caret span, secondary labels, and notes, all over byte offsets (rut_lexer::diag::render_diags). Rendering sorts by span so multi-file output is stable. Suggestions are part of the message set: did you mean when? for switch/match; bind it to a name first for string literals in format-string holes; classes have no instance literal — use Circle(..) for Circle { .. } on a class.

The compile stops before IR if any diagnostic exists — a module with diagnostics never produces bytecode. See Diagnostics, traces, and symbolication for the runtime half of the story.

The formatter

rut fmt (crate rut-fmt, CLI subcommand) reprints a parsed-clean module from the AST:

  • Comment gap-scan — comments live in the byte gaps between token spans, so the formatter recovers every // and block comment with zero lexer/parser changes: trailing runs go on their element’s line, own-line runs at the enclosing indent.
  • Parens and literals re-derived — the printer re-parens only where flat precedence would invert the tree, re-spells floats shortest round-trip from their bits, and re-quotes strings by the exact inverse of the lexer’s escape set (0xff legitimately becomes 255).
  • Tested invariants — every corpus file formats, reparses clean, and is idempotent (fmt(fmt(x)) == fmt(x)); probe programs run through the full pipeline before and after formatting and log identical output.
  • Refusal law — a source with diagnostics is refused: rendered diagnostics, exit 1, nothing rewritten. The formatter never guesses.
  • Style is the package’s — the nearest ancestor rut.toml’s [style] block sets indent_width (1–8, default 4) and max_width (≥ 20, default 100); no manifest means defaults. See Project structure and rut.toml.

Testing

The corpus is the conformance suite: every examples/**/*.rut, examples/**/*.d.rut, and playground classic must parse with zero diagnostics. Parser invariant tests include 100k-deep ((((, [[[[, {{{{, unary chains, and nested generic arguments — each yields exactly one nesting too deep diagnostic — and fuzz targets assert no host stack overflow and no cursor rollback. Formatter tests pin the round-trip and idempotence laws above.

The compiler pipeline

Compilation is AOT, in-process, and deterministic. There is no JIT and no deoptimization: whatever the checker proves is final, and the emitted bytecode is the whole story.

source ─► lex/parse (rut-lexer, rut-parser)      [The frontend](frontend.md)
      ─► resolve + typecheck (rut-lir/check)
      ─► monomorphize + compile bodies (rut-lir/lir)
      ─► optimize (fold, inline, CSE/LICM, peephole, SROA)
      ─► FuncCode — typed register bytecode       [Typed bytecode](typed-bytecode.md)
      ─► link + flatten (rut-core/link)           [Loading](loading.md)
      ─► encode (rut-core/binary)                 [Module binary](module-binary.md)

The unit of compilation is the module. A .d.rut declaration surface runs through resolve/typecheck and stops there — it has no bodies, so it publishes a signature surface, never code.

Stages

stagecratein → outnotes
lex + parserut-lexer, rut-parsersource → AST + diagsflat arena, no recursion
collectrut-lir/check/collectAST → types, traits, implsper-module symbol tables
resolverut-lir/check/resolvepaths → symbolsimports, Self, visibility, use-path routing
typecheckrut-lir/check (Ctx)expressions → TyIdsbidirectional inference, fused with body compilation
monomorphizerut-lir/check/instgeneric calls → instantiationsa work queue; HIR contains no generic code
compile bodiesrut-lir/lir (FnCompiler)instantiations → FuncCodeone function at a time
optimizerut-lir/lir (peephole, sroa, …)FuncCode → FuncCodefixed pipeline, no flags
link + flattenrut-core/linkmodules → one Programtype-id rebase, duplicate-impl check
encoderut-core/binaryProgram → bytesversioned, little-endian, hash-stable

The middle stages are deliberately one crate: the checker’s monomorphization queue drives the body compiler and the body compiler reports new instantiations back through the checking context. The fusion keeps instantiation admission exact — every substitution-completing call site is checked against its inline requires bounds as it is compiled.

Resolve

Name resolution walks the AST and binds every path to a symbol:

  • use imports resolve against the mounted session — exact, single-step: a use path resolves only if a module with that name is mounted. Missing modules diagnose against the consumer manifest (see Loading and Dependency kinds).
  • Self binds inside impls; pub visibility is checked per Modules and visibility.
  • Struct-vs-class is decided here: literals are legal only for structs; classes construct through their class methods.
  • is expressions resolve their right-hand side to a concrete type or a trait instantiation id; is on an erasure-typed receiver answers by the box (see opaque — erasure and downcast).
  • host/extern surface references resolve against the declaration surfaces of the packages the module imports, with slot ids attached to every member reference.

Typecheck

Bidirectional inference: expected types flow down, literal types flow up. Every expression node is decorated with a type id (an index into the module’s type table). Generic calls get their instantiation inferred or take it explicitly (downcast<Point>(o)).

Laws enforced here:

  • The == law — primitives and str compare by value; every other cell type compares by identity; nullable ?T operands and tuple operands are a compile error (pattern-match instead — destructure the pair). A lint flags == between two obviously fresh composites.
  • is folding — when the receiver’s static type already answers the question (a concrete receiver, d: I is I), the expression folds to a constant and an always-true/false lint fires.
  • Union bounds — a generic parameter’s requires T1 | T2 bound is checked at every site that completes the substitution (Type aliases and union bounds).
  • The crossing rule — an entry fn’s published signature may only use types that cross the host boundary: primitives, str, bytes, opaque, ?T over a crossing type (nil-flattened), and tuples of crossing types. A violation is a compile error, so a bad surface never reaches the embedder at load time (The host boundary).
  • The orphan rule — impl Trait for Type is legal only in a package that defines the trait or the type. Trait impls for foreign pairs are a compile-time rejection, not a link-time surprise (Traits and dispatch).

Monomorphization

Generic functions never reach a binary. The checker’s instantiation queue compiles one concrete copy per substitution; the queue closes because instantiating a body can enqueue more. Instantiation names ([i32], Vec<f32>) are synthesized into the shared interner, and type_id<T>() folds to a constant from the type table at this point — it never executes at runtime.

The same law has a packaging consequence: a module exporting a generic function or generic class cannot be linked against (a linked surface carries only monomorphic exports). Such packages set inline = true in their manifest — the graph compiler splices their source into every consumer instead of linking them (ink, json, nmapset, strbuild, async_host, http). See Project structure and rut.toml.

The type lattice

In a no-JIT VM, compile-time type knowledge is the only knowledge. The IR tracks a per-value lattice:

exact concrete  >  I (trait-typed, satisfies I)
  • Exact types compile to direct calls and known layouts.
  • Trait-typed values (d: Drawable) are unsized: the payload lives in a heap cell and the slot stores the cell handle. One indirect vtable call per multi-origin use; fields are inaccessible; no inlining without evidence. The cost is per-call, never per-field — and when a call’s receiver is statically concrete (a sealed impl set, a monomorphic body), the call devirtualizes at body-compile time and the dispatch overhead is zero.
  • Erasure sits off the lattice. The opaque primitive is reached only through the type-call opaque(v), and opaque.downcast<T>(o) is the refinement: the successful branch re-enters the exact lattice position, so downstream code optimizes as if nothing was erased. Downcast checks are pure dataflow — repeated checks CSE, invariant ones hoist, and a chain over one cell folds to a type-id switch. What the compiler must not assume is the type inside a box at a given program point; speculative devirtualization through opaque is JIT behavior and does not exist.
  • The slice tier parallels trait widening: Array<T, N> | Vec<T> (concrete) > Slice<T> view (unsized). N is part of the type’s identity; slices are never boxed or erased.

The intended program shape: exact types on the hot path, trait-typed values where polymorphism is real, opaque only inside heterogeneous storage. Lints flag downcast in loop bodies and erasure crossing non-storage function boundaries.

Optimization

A fixed pipeline with no flags:

  1. Folding — constant arithmetic, type_id<T>(), Array<T, N>.len() → the constant N (with const-index bounds checks folded against it), statically-decided is probes, dead branches.
  2. Inlining — single-callee calls and small bodies (a callee op-count budget). Cross-module inlining after linking is future work; v1 binaries carry no cross-function inlined code.
  3. CSE / LICM over pure operations — type-id loads, downcast checks, field loads on immutable records.
  4. Peephole + SROA — the rewriters re-intern operand pools on register remap, so pool sharing stays consistent (see Typed bytecode).
  5. Pattern lowering — downcast chains become one type-id load plus a jump table; when on enums lowers to brtable over the member value.

The pipeline is honest by construction: there is no tier-2 to fall back on, so the emitted code must be right the first time. Every gate measures against the example programs and the benchmark corpus.

Async lowering

async fn compiles to a state machine: each await is a checkpoint state in the hidden frame, resume dispatch is the existing jump-table op, locals become frame fields, and suspension is a plain return. The op set grows zero rows for this — the driven half is an ordinary trait-vtable call through the future’s yield row. The full protocol lives in Async and await and Tasks.

Determinism

Same source + same dependency versions + same compiler version ⇒ byte-identical output. Serialization is ordered and little-endian everywhere, and the graph compiles dependencies in a fixed post-order before flattening. This is what makes compile caches content-addressable, bundles diffable, and trace restoration by recompilation possible (Diagnostics, traces, and symbolication).

Typed bytecode

The compiler’s output — and the VM’s input — is LIR: a flat op stream per function over typed registers. Every register has a static type recorded in the function’s signature, and the load-time verifier re-checks all of it (Module binary and verification). The op definitions live in rut-core/src/ops.rs; the VM that executes them in VM core.

Registers and functions

Registers are u16 indices into a per-frame Vec<Slot>; a register file can never reach u16::MAX, which is reserved as the NOREG sentinel for optional single-register operands (a call destination, a native receiver). One function:

#![allow(unused)]
fn main() {
pub struct FuncCode {
    pub name: IdentId,
    pub params: Vec<TypeId>,   // methods: params[0] is the receiver
    pub ret: TypeId,
    pub is_method: bool,
    pub n_captures: u32,       // closure environment size
    pub regs: Vec<TypeId>,     // the register file's static types
    pub argv: Vec<Reg>,        // operand pool (below)
    pub labels: Vec<Label>,    // branch-table pool (below)
    pub code: Vec<Op>,         // the stream
    pub spans: Vec<(u32, u32)>,// pc -> source byte offset
    pub pos: Vec<(u32, u32)>,  // pc -> (line, col), parallel to spans
    pub host_id: Option<IdentId>, // Some = bodyless host fn (thunk)
}
}

host_id is the one name kind carried as an interner id: a host function is a bodyless FuncCode whose call dispatches to the embedder’s registered body instead of interpreting (Embedding).

Operand pools

No op owns a heap allocation. The variadic operand lists — call arguments, record/array/capture element registers, branch-table arms — live in per-function pools (FuncCode::argv for register lists, FuncCode::labels for branch targets); each op carries an (off, argc) span into its own function’s pool. Consequences:

  • Op is a fixed 24 bytes — pinned by a compile-time assert, with a never-constructed Op::Pad variant holding the reasoning. The natural 16-byte stride measured +13% on the interpreter gate (op-stream loads aliasing register-file stores at power-of-two strides); 24 measures clean and stays far narrower than the pointer-carrying layout it replaced.
  • Pools are append-only and deduplicated at emission — repeated argument shapes share one entry. The empty list is the span (0, 0) and is never stored. Optimizer rewrites re-intern on register remap; deleted ops orphan their entries, which is dead weight, not corruption.
  • argc is u16: a list can name at most as many registers as the register file holds. The emitter rejects literals beyond that bound.
  • Method calls fold the receiver into the pool as argv[0], so the span is the callee’s parameter list and one uniform copy loop transfers a frame — no special-cased slot zero.
  • The binary format serializes the pools ahead of the code; the verifier bounds-checks every span before execution.

Scalar ops are one opcode per operation — the kind (int vs float) is in the opcode and the width in a prim operand, resolved by the compiler. The VM never consults the type table for arithmetic and there is no runtime op selector.

The op families

Representative ops (exact Rust names; the table below is the whole machine):

familyopsnotes
movesMov, MovRefref move retains new, releases old
constantsConst (pool), ConstRaw (folded scalar bits)
int arithmeticAddI SubI MulI DivI ModI (+prim)+ - * / % trap on overflow / divide-by-zero
wrap arithmeticWAddI WSubI WMulI, WrapShlIthe wrapping_*/wrapping_shl builtin methods, lowered inline
float arithmeticAddF SubF MulF DivF ModF NegF
int bitwiseAndI OrI XorI ShlI ShrIshifts mask the count; overflow traps
comparesEqI…GeI (+prim), EqF…GeFbools and codepoints ride the int slots
equality on refsStrCmp (content), ArrayCmp (content), RefEq (cell identity)the == law’s three arms
controlJmp, Br, BrTableBrTable arms in the labels pool
callsCall, CallM, CallI, CallFn, CallNat, Retsee below
recordsNewCell, MakeRecord, GetF, SetFMakeRecord allocates + initializes every field in one op; field operands bake the field’s Repr
ownershipOwn (payload copy), OnDrop (cleanup at release-to-zero)
nullablesMakeOptT -> ?T: box into a one-slot cell (shares, never copies)
weak refsWeakNew, WeakUpgradeWeak references
arraysArrNew (zeroed), ArrLit (fixed), ArrGet/ArrSet, ArrGetF/ArrSetF (fused field+index)bounds trap; element repr baked in
enumsEnumNewimmortal singleton cell per member
type machineTidOf, IsType, IsTrait, Unbox, Boxthe two readbacks + their guards
closuresMakeClosure{ func, captures }, captures in the pool
panicsPanic, Assert
conversionConvi32(x) etc.; narrowing traps when the value does not fit
stringsStrCodeAtone codepoint read as u32, bounds trap
budgetsLoopHeadfuel-check back-edge marker at loop heads

Call ops:

opmeaning
Call { func, argv, dst }direct call — free functions, class construction, closure entries, and host thunks
CallM { func, argv, dst }direct method call; receiver is argv[0]
CallI { slot, argv, dst }trait vtable call — the only path for trait-declared members: always dynamic, even when the receiver’s exact class is statically known
CallFn { fval, argv, dst }call through an fn-typed value (closures: two consecutive registers, { fn_ptr, env })
CallNat { nat, recv, argv, dst }internal native, recv == NOREG for free functions

Internal natives (CallNat)

Things rut spells with a name are natives, never ops. The fixed, compiled-in table:

nativespelling
Strper-type formatting — the f"..." desugaring
Concats.concat(parts...)
StrLen / ArrLens.len() (codepoints) / array and bytes length
StrJoinjoin an array of strings in one pass
StrSlice / ArrSliceslice(from, to) — O(1) views (String slicing and views)
BytesClonebytes.clone() — the one copy escape hatch
CaptureTrace, TraceLen/Name/Line/Col/Renderthe stack-trace surface (Diagnostics)
StrScan, StrStartsWithfused host-side scan/classify and prefix test
StrBufNew/Push/PushCode/Len/Finishthe string builder’s engine rows
StrFromCodestr.from_code(n) — one codepoint to str

Everything else rut spells with a name is ordinary rut code (Vec’s methods) or a bodyless host function. There is no strcat op and no template op.

What is not an op

An op exists for exactly one of three reasons:

  1. Nothing static. What the type table answers is a constant, never an op: type_id<T>() folds at compile time; Array<T, N>.len() is the constant N (N is part of the type’s identity). Hence no typeid and no arrlen op.
  2. Nothing polymorphic, nothing named. A trait-typed receiver gets exactly one op (CallI) through its vtable. Slice-view indexing and len are ordinary vtable calls through the view’s builtin impl. The erasure primitive’s names lower to primitives — the type-call opaque(v) to the Box op, opaque.downcast<T>(o) to prelude code (below) — and both are ambient: no use gates them (opaque — erasure and downcast).
  3. Concrete memory + control + the type machine’s two readbacks. Ops touch memory only through compile-time-known layouts (inline blocks, cells with known headers), plus control flow and calls — and the two readbacks reification needs: TidOf (what is it?) and Unbox (the payload, guarded).

is and downcast lowering

  • x is T with concrete T: one TidOf + an integer compare. On an erasure box, is answers by the box — it misses for every payload type; downcast is the only see-through.
  • x is I with trait I: one IsTrait descriptor scan — pure in (recv, want), so repeated probes CSE and invariant ones hoist.
  • opaque.downcast<T>(o) (yielding ?T): TidOf; branch on the compare against T’s id; Unbox on the hit arm, a nil box on the miss. The check is visible dataflow — the compiler CSEs repeated checks, hoists invariant ones, and folds a downcast chain over one cell into a single TidOf + BrTable. The compiler always guards Unbox with the branch; an unguarded one still verifies but traps on mismatch — the safety net, like a bounds check.

Statically-answered is expressions never emit an op — the checker folded them (The compiler pipeline).

Async lowering

async fn compiles to a state machine with zero extra opcodes:

  • Each await is a checkpoint state — one enum-member-style singleton per suspension point, stored in the hidden frame’s state field.
  • Resume dispatch is the existing BrTable over that state.
  • Locals live across suspension as cell-backed frame fields (GetF/SetF).
  • The driven half is an ordinary CallI through the future’s yield vtable row; suspension is a plain Ret.

The wire format is untouched by the async plan — bundles and binaries from the same compiler version stay compatible. The driving loop that wakes these frames is VM core’s scheduler; the surface semantics are Async and await.

Budget hooks in the stream

The only budget artifacts in the op stream are LoopHead markers: loop back-edges are natural checkpoints where fuel is accounted (Resource limits and fuel). Everything else — fuel countdown, heap checks at allocation, interrupt slices — lives in the interpreter loop, not in the code.

Module binary and verification

The compiled module artifact — a .rutc file — is a versioned, deterministic, hash-stable serialization of a linked program. Encoder and decoder live in rut-core/src/binary.rs; this page is the layout, the verification contract, and the constant-pool rules. The loader that runs verification is Loading and the embed loop.

Program shape

Everything the VM runs is one Program:

#![allow(unused)]
fn main() {
pub struct Program {
    pub name: String,               // module / program name
    pub scope: ScopeId,             // stable module scope (0 if unset)
    pub interner: Interner,         // every IdentId in the program
    pub surface: Surface,           // exports, for using modules (not serialized)
    pub types: TypeTable,           // type descriptors, dense ids
    pub traits: Vec<TraitDesc>,     // trait tables
    pub trait_slots: Vec<(u32, u32)>,   // global trait-method slots
    pub vtables: Vec<Vec<Option<u32>>>, // per type: slot -> func id
    pub consts: Vec<ConstVal>,      // the constant pool
    pub funcs: Vec<FuncCode>,       // the code ([Typed bytecode](typed-bytecode.md))
    pub exports: Vec<(IdentId, u32)>,   // host-callable names
}
}

The Surface is the in-memory export record — functions, constants, types, traits, impl registrations, and the builtin names core publishes. It is how a using module binds a used module’s members at compile time; it is not part of the wire format.

Wire layout

The format is little-endian, everywhere — encoded words, hash inputs, checksums. The first byte of a byte range is the least significant byte of word 0; this is a law pinned by unit tests, not an accident of the host.

offset  field
0       magic "RUTC"                      # 4 bytes
4       version: u32                      # refuses any other value
8       name: str
        name table: u32 count, then count × str
        types: u32 count, then per type { name: IdentId, kind }
        traits: u32 count, then per trait { name, methods[] }
        trait slots: u32 count, then (trait: u32, method: u32) pairs
        vtables: u32 entries, then per entry { ty, [(slot, func)] }  # sparse
        consts: u32 count, then tag + payload (below)
        funcs: u32 count, then per func (below)
        exports: u32 count, then (name: IdentId, func: u32) pairs
  • Name table. Names are interner ids everywhere — type names, field names, enum members, trait/method names, function names, exports. The binary carries only the interner’s non-well-known tail; the fixed well-known prefix (self, nil, the builtin members, the primitives, …) is implied by the format. Decoding rebuilds the interner, so a decoded program is self-contained; at link, each module’s tail merges into the linked program’s table with the same rebase the type ids get.
  • Type kinds encode as a one-byte tag: nil, primitives, str, bytes, arrays, enums (member + value pairs), records (field table), trait objects, opaque, fn types, ?T, trace, string builder, Weak<T>.
  • Const tags: 0 i64, 1 f64, 2 bool, 4 str, 5 type id. Retired tags fail decode loudly rather than being reinterpreted.
  • Funcs serialize in full: name, param/ret types, is_method, capture count, the typed register table, both operand pools, the op stream, the span table, the parallel (line, col) position table, and the host-fn binding id when the function is a bodyless thunk.
  • Versioning policy: the version u32 must equal the toolchain’s exactly — there is no migration or best-effort decode. A byte that changes observable behavior bumps the version; artifacts from older compilers are refused with the standard version error.

Determinism

Same AST + same dependency versions + same compiler version ⇒ byte-identical bytes. Serialization order is fixed (mount order, manifest order, pool order), so binaries are cacheable by content hash, two builds diff to nothing, and a trace captured against one binary can be restored against a locally rebuilt one (Diagnostics, traces, and symbolication).

Strip levels

The symbolication data rides in every function: the spans table (pc → byte offset) and the parallel pos table (pc → line/col). A build that skips filling pos produces a binary whose traces still capture and render, degrading to pc-only text. Names ride the interner; dropping name strings is a publishing choice, and stripped traces restore through recompilation-by-determinism (see Diagnostics, traces, and symbolication).

Type ids are program-global in a linked binary only because link already rebased them. At rest (per module, pre-link) ids are module-local; the boot table — the fixed primitive and builtin prefix — is shared by every module, and each module’s remaining types append after it. Every type id reachable from a type descriptor, trait signature, constant, function signature, or op operand is remapped at link, and type_id<T>() constants rebase with everything else.

type_id<T>() values are u32, comparable, and unique per instantiated type within a VM run: Vec<f32> ≠ Vec<f64>, [i32; 3] ≠ [i32; 4] (the constant length is part of the identity), and Point = Point across modules — identity is assigned at link (Reified types and layout).

Trait and impl tables

  • Traits serialize with their method signatures; trait ids merge by name at link — a trait imported in one module and declared in another is one trait, and every IsTrait probe and vtable slot lands on the same global table.
  • Trait-method slots are a global table of (trait, method) pairs; vtables are per-type sparse maps slot → function id, merged at link. A trait impl registered in any module reaches every call site.
  • Impl registrations (trait, target) merge at link; a duplicate pair is a link error — per-module compiles cannot see each other, so the pair’s uniqueness is a link-level law (Traits and dispatch).

Verification (load time)

Before any code executes, the verifier re-checks every function in the binary:

checkrule
register rangeevery register operand < the function’s register-file size; NOREG legal only where an optional operand is defined
pool spansevery (off, argc) span lands inside its function’s argv / labels pool
type operandsevery type id names a row of the type table
jump targetsevery jmp/br/brtable target is in range
call aritycall argument counts match the callee’s declared signature
enum membersEnumNew member indices in range, target actually an enum
host thunksbodyless functions are skipped (nothing to verify)

A failed verification is a load error naming the module and function — function name (#i): op @pc: message. Corrupted binaries never execute. Verification is structural: it re-establishes what the compiler enforced at emit time, so a hand-corrupted or stale artifact fails here instead of misbehaving at runtime.

The constant pool and load-time expressions

Module-level let and static initializers are folded at compile time. The surviving forms are: literals, enum members, operators over constants, record literals (immortal constant-pool cells), fixed-array literals, Vec<T>(n) / Vec.from([...]) blobs, the layout builtins, and Array<T, N>.len() with const-index bounds checks. A call to a user function in an initializer is a compile error — there is no deferred evaluation.

type_id<T>() never executes: it folds to a constant from the type table. debug position folding and trace symbolication are covered in Diagnostics, traces, and symbolication.

What is not here

A .d.rut declaration surface does not produce a binary — it has no bodies, so it compiles to a signature surface only (Host fns and declaration files). And a .rutbundle is a source package, not a binary artifact: it carries rut.toml + sources and compiles on load (Module bundles).

VM core

One struct, one thread, one heap. The VM (rut-vm) interprets typed bytecode over an RC heap with inline destruction — no JIT, no garbage-collection pass, no background threads.

The machine

#![allow(unused)]
fn main() {
pub struct Vm {
    pub prog: Rc<Program>,        // the linked program
    heap: Heap,                   // the RC heap — [The VM heap](vm-heap.md)
    frames: Vec<SavedFrame>,      // saved frames (the call stack)
    cur_func: u32, cur_pc: u32,   // the ACTIVE frame
    cur_regs: Vec<Slot>, cur_ret_dst: Option<Reg>,
    fuel: Option<u64>,            // per-op budget
    interrupt_every: u32,         // budget check cadence (default 1024)
    hooks: HostHooks,             // embedder surface
    ready: VecDeque<Slot>,        // launched/parked frames to (re)drive
    timers: BTreeMap<u64, Vec<Slot>>,  // sleep deadlines -> wake lists
    now_ms: u64,                  // the VM's virtual clock
    ...
}
}

A Vm is single-threaded by construction: workers are separate VMs (Workers and channels). Saved frames are plain records { func, pc, regs, ret_dst }; calls push, returns pop. Register files are recycled through a pool, and per-function side tables (reference-typed register indices, per-type field layouts, baked type representations) keep the hot loop free of type-table lookups.

The interpreter loop

The loop is a match over decoded ops — decode is a table index, not a byte scan. On native targets the default engine is a threaded dispatcher (rut-vm-threaded): every op ends by tail-calling the next op’s handler, so dispatch is a jump and hot state stays in registers. A portable loop over the same handler methods backs wasm and other targets; behavior is identical, only the dispatch shape differs. Ops the threaded engine does not absorb bail to the match interpreter with the pc pinned at the op.

  • Direct calls are an index + jump; trait calls are two loads (vtable row)
    • an indirect jump.
  • Refcount retain/release are inline in the loop; a release to zero runs the cell’s on_drop cleanups and frees (The Rc heap and destructors).
  • Fuel is accounted per op, with the fuller budget check amortized every interrupt_every ops.

The host boundary

The embedder’s host-fn binding table (HostRegistry) is built before the Vm exists and is consumed at construction: every host thunk the program declares is resolved against the registry immediately, so a declared-but-unbound host fn is a construction error, never a mid-run trap. The join produces one dense dispatch row per function — an adapter code pointer, the body’s state word, the declared return type, and its is-reference verdict — so calling a host fn costs one table copy, no name work, no allocation.

  • Host bodies are plain Rust functions over &mut Vm and slots; arity is capped at 8 (the boundary’s tuple law), and signatures are derived from the body’s Rust shape, not written by hand.
  • A host body records a trap in a VM-side channel rather than returning Result; the trap fires before any destination register is written.
  • Re-entrant calls (a host fn calling back into rut) run the callee on a fresh frame stack under the same budget. The outer cursor is stash/restore: on return or trap the outer frame is exactly as it was, so a propagated nested trap parks the outer frame at the host op and resume() re-runs the host fn. A host fn that prefers to handle the situation itself can catch the trap, add fuel, and retry its nested call.
  • The async host-fn lane (register_async!) binds a Rust async body to the minted {scope}::{name}__start/__yield/__take/__cancel rows the compiler weaves for host async fn declarations (The host futures bridge).

Traps

Bugs are traps, errors are values. Arithmetic overflow, division by zero, indexing out of bounds, nil dereference, failed assert, panic(...), a failed downcast guard, and budget exhaustion all unwind as Err(Trap) — never a Rust panic:

#![allow(unused)]
fn main() {
pub struct Trap { pub kind: TrapKind, pub msg: String }

pub enum TrapKind {
    OutOfFuel, OutOfMemory, Interrupted,
    Overflow, DivByZero, IndexOutOfBounds,
    Assert, Panic, BadUnbox, Invalid, NilDeref,
}
}

Traps are catchable only at the host boundary. vm.call(...) returns Result; rut code never catches one — errors in rut are (T, err) tuples (Errors and optionality). On the way out, frames are dropped and destructors run: on_drop cleanups fire, borrow guards release. The Vm itself survives a trap — the next call starts clean.

Budgets and interrupts

#![allow(unused)]
fn main() {
pub struct Limits {
    pub fuel: Option<u64>,          // per-op countdown
    pub heap_limit_bytes: Option<u64>,
    pub interrupt_every: u32,       // default 1024
}
}
  • Fuel counts ops down. Exhaustion parks the frame at its pc — the loop state is the frame — so add_fuel(n) + resume() continues it; nothing is lost or restarted. Loop back-edges carry LoopHead markers so tight loops still park.
  • Heap budget is checked at every allocation; over-limit allocation traps OutOfMemory the same parkable way.
  • Heap accounting is live: heap_usage() reports current bytes, fuel_used accumulates across resumes.
  • Interrupts are cooperative: the budget check cadence bounds how long any host-requested stop can take. Native (host) bodies run outside the fuel budget and are expected to be fast.
  • Vm::new(prog, &limits, hooks, registry) takes the limits; a default construction uses unbounded fuel and heap — embedders that need budgets pass them explicitly (Resource limits and fuel).

Coroutines and the driving loop

Every async entry runs as a task: a root frame parked when await returns pending. There is no microtask queue and no job executor — two queues and a virtual clock:

  • ready: VecDeque<Slot> — launched or woken frames; each entry owns one reference (retained on enqueue, released on pop).
  • timers: BTreeMap<u64, Vec<Slot>> — sleep deadlines against the VM’s virtual clock (now_ms / set_now — deterministic; the host advances it explicitly, or maps it to wall time).
  • drive(fut) — one re-entrant call of a future’s yield row: a fresh engine context over the frame edge, answering Done or Parked off the frame’s state field. Fuel accounting rides the per-op budget unchanged: an exhausted drive parks the frame at its pc, and a re-drive re-enters at the checkpoint state.
  • run_ready() drains the ready queue one drive per entry; next_deadline() expires due timers into ready and answers the earliest remaining deadline; pending_tasks() is the idle test.
  • The wake pair (awaiter edge on the awaited frame, pending edge on the awaiter) is one-directional and cleared on resume — no cycle forms.
  • A completed drive re-enqueues the frame’s awaiter edge. A parked async frame is simply not in ready until its wake edge or timer fires.

The engine never owns a wall clock: sleeps are virtual-clock deadlines, and tests virtualize time by advancing the clock — full determinism (Async and await, Tasks).

Heap integration

Allocation, retain, and release are inline loop work. There is no collection pass at all: destruction happens at release-to-zero, so nothing ever interrupts a frame. Strong reference cycles leak by design; the weak-reference machinery and the shutdown leak report are the diagnostic surface (Weak references and the cycle collector, The Rc heap and destructors). Heap layout, cell formats, and the bump allocator are The VM heap.

Loading and the embed loop

Loading verifies, links, and mounts a program — it executes nothing. The host owns time, I/O, and lifetime: the VM never reads the filesystem or a clock on its own, and the host decides when (and whether) any rut code runs.

From path to machine

rut run app/            # a directory with rut.toml
rut run plugin/plugin.rutbundle

The loader side (rut-driver) turns a path into a booted Vm in five steps:

stepwhat happens
1. mountA directory is one module: its rut.toml names the package (name), its entry (entry.lib / entry.type / entry.libs), and its [deps]/[peer-deps]/[dev-deps] (Project structure and rut.toml). A .rutbundle mounts identically from a zip (Module bundles). The graph walks [deps] recursively — cycle guard, first-mount-wins, name-mismatch is an error — then runs one peer gate over the closed set (Dependency kinds).
2. resolve surfacesUse paths resolve against mounted modules, exact and single-step: a package name resolves or the diagnostic names the consumer manifest. A .d.rut surface compiles through the checker and publishes signatures only.
3. compile the graphEach module compiles (sources, in dependency post-order); inline = true packages splice into their consumers instead of linking; host packages synthesize bodyless thunks from their declared surfaces (The compiler pipeline).
4. link + flattenModule-local type/function/const ids rebase into the global tables; the shared boot prefix passes through; name tables merge; duplicate (trait, type) impl pairs and duplicate module names are link errors. Cyclic use is a compile-graph error, never a runtime event.
5. verify + bootThe load verifier re-checks every function (Module binary and verification); the Vm constructor joins the program’s host thunks against the embedder’s HostRegistry — a declared-but-unbound host fn is a boot error.

Loading a .rutc binary (vm.load_binary-style paths, and the wasm runner) skips steps 2–3 and lands directly in verify + boot.

What the host can call

Callable names are the program’s entry fns plus the conventional main. Their signatures were checked against the host-crossing rule at compile time — primitives, str, bytes, opaque, ?T over a crossing type, and crossing tuples — so a bad surface can never surprise the embedder at call time. An entry fn -> (?T, err) decodes positionally at vm.call as a (value, err) pair (below).

Use paths never execute anything: importing a package mounts its surface; only the host’s calls run code.

The embedder surface

Everything the host needs is a method on Vm (plus the registries built before it):

callpurpose
HostRegistry::register(name, f)bind a host-fn body; signatures derived from the Rust shape
register_async!(hosts, "pkg::name", ...)bind an async body to the host-future rows
install_std_log / _math / _nmap / _http / _async / _bench_crossmount the toolchain’s host bodies (Embedding and native modules)
mount_std_core(session) / mount_std(session) / mount_std_async(session)mount the builtin packages (core; core + calc; the async pair)
load_path_session(path) / load_bundle_bytes(bytes, origin)mount a directory / an in-memory bundle
compile_graph(&session, root)compile + link the mounted graph
Vm::new(prog, &limits, hooks, registry)boot; fails if a declared host fn is unbound
vm.call(export, args) -> Result<Value, Trap>sync entry — typed arg/ret adapters over the boundary
vm.resume()continue a parked frame after add_fuel
vm.run_ready(), vm.next_deadline(), vm.pending_tasks(), vm.drive(fut)the async driving verbs
vm.set_now(ms), vm.arm_timer(deadline, fut)the virtual clock
vm.add_fuel(n), vm.fuel_used, vm.heap_usage()budgets and telemetry (Resource limits and fuel)
vm.call_host_row(row, args)invoke a registered host row directly (tests, tooling)

Errors are values (Result), bugs are traps, and the host is always in charge of time, I/O, and lifetime.

The frame loop

An embedder that owns an event loop (a GUI, a game frame, a web page) runs one engine frame per tick:

#![allow(unused)]
fn main() {
fn on_vsync(&mut self) {
    self.process_host_events();                 // input, network, ...
    self.rut.run_ready()?;                      // drive ready tasks to completion
    if let Some(d) = self.rut.next_deadline() { // earliest armed sleep
        self.schedule_wake(d);                  // host parks until then,
    }                                           // then advances the clock
    self.render_frame();
}
}

pending_tasks() is the idle test — zero means the program has nothing runnable. There is no job executor inside the VM: parking, waking, and timing are these three verbs plus set_now (Async and await). A program that mounts no async packages simply has no launcher; await stays cold-poll inline (The async model).

The soft-fail law (the err channel)

An entry fn -> (?T, err) never produces Err. The pair decodes at vm.call as Ok — with data in the pair: a non-empty err string means the turn failed softly; an empty err means success (nil value = “not found”). Err remains exclusively the trapped turn: a bug, wiring drift, exhausted fuel — the loud channel.

The law for host event loops (the pump pattern): per queued event, call the turn entry, then —

  • Ok: decode the pair. A non-empty err is reported (surfaced to the user/log, retained) and the loop keeps draining: the container survives, the queue proceeds, the page stays alive. A returned err must never poison the container or kill the drain — it is data the host acts on.
  • Err: the pump aborts loud — a trapped turn is a bug, not data. The Vm itself survives for the next good turn.

On a raw JSON boundary (the wasm host), the envelope carries "err" beside "trap": a soft-fail run reads {"trap": null, "err": "…"}, a success reads "err": null, and a panic sets trap with "err": null. trap never fills err. Fuel and heap reporting are unchanged by this.

Parse-time robustness

The same embeddability pillar holds at the other end of the pipeline: the frontend is stack-bounded with explicit nesting budgets — malformed or hostile module source yields diagnostics, never a host stack overflow, and a module with diagnostics never reaches the VM.

Diagnostics, traces, and symbolication

Errors are values and bugs are traps — either way, the first question is where. The story has two halves: compile-time diagnostics (one model from the first illegal byte to the last type error) and runtime traces (raw captures, lazy symbolication).

Compile-time diagnostics

One model serves every frontend and checking stage (The frontend):

#![allow(unused)]
fn main() {
struct Diag { span: Span, msg: String,
              labels: Vec<(Span, String)>, notes: Vec<String> }
}

Byte-offset spans; one renderer (render_diags) produces the caret form — a primary line, secondary labels, notes — sorted by span so output is stable across files and phases. The parser recovers at statement boundaries and keeps going, so a file yields many diagnostics, not one. Compilation stops before IR if any diagnostic exists; there is no warnings-only lane.

A compile trap worth knowing: name-level misses that involve Dependency kinds peers have dedicated diagnostics (a missing optional peer’s integration is never a bare unresolved name).

Runtime traces

Capture — raw pcs, nothing else

capture_stacktrace() -> StackTrace (a builtin: the engine itself implements it, compiler-lowered) walks the frame stack and stores raw frames:

  • the active frame innermost, then every saved frame outward, as (func, call-site pc) pairs — about 8 bytes per frame;
  • no name lookup, no source access, no symbolication at capture — capture is cheap enough for any error path;
  • saved-frame pcs are resume points, so symbolication backs one op to the call site;
  • target-independent by construction — the same walk runs on wasm.

The result lives in a dedicated immutable trace cell: a snapshot, shared by handle, safe to store in error tuples. It takes no user impls and never crosses the host boundary — a trace is a rut-side diagnostic object (The host boundary).

builtin class StackTrace {
    fn len(self) -> i32;          // frame count (innermost first)
    fn name(self, i: i32) -> str; // frame i's function name
    fn line(self, i: i32) -> i32; // call-site line (0 when stripped)
    fn col(self, i: i32) -> i32;  // call-site column (0 when stripped)
    fn render(self) -> str;       // the symbolication string
}

Out-of-range i traps IndexOutOfBounds — the index is a bug, not data.

The tiers

tiercostwhen it exists
StackTrace captureexplicit; a depth-proportional frame walk (~30 ns base + ~0.2 ns/frame)opt-in at raise sites — a rich err carries trace: ?StackTrace only where the producer decides the cost is worth it
Trapautomatic at trap unwindbugs, overflow, panic — the loud channel carries kind + message
? / err propagationzero cost — no auto-capture on propagationalways

Opt-in is the design: the cheap error path stays cheap, and capture is pay-per-capture.

Symbolication — lazy, per index

Members symbolicate on access, against the loaded program:

  • name(i) — one interner lookup for frame i’s function name.
  • line(i) / col(i) — one direct read of the function’s position table (pc → (line, col), parallel to the pc → byte-offset span table; Module binary and verification).
  • render() — the only whole-trace pass, innermost first:
at c_big (core:27:13)          # names + positions available
at c_big (core #3 @ pc 25)     # stripped: pc-only degradation

There is no eager symbolication and no frames array — a frame array would mint rut-side data per access and fix the representation into the surface.

Restoration paths

A trace is captured against whatever is loaded — possibly a stripped, published artifact. Three ways back to names:

  1. In-VM (the default). The loaded program carries its interner and position tables; every member call symbolicates from them.
  2. Recompile by determinism. Serialization is deterministic — same source + same compiler version ⇒ byte-identical binary ⇒ identical pcs — so tooling can symbolicate a captured trace against a locally rebuilt binary (The compiler pipeline).
  3. Stripped, no artifact. Traces still work: they degrade to the pc-only render form above, and stay restorable via path 2 later.

Trap messages

A trap renders as its kind plus message, e.g. IndexOutOfBounds: index 9, len 3, OutOfFuel: fuel exhausted — the frame is parked; add fuel and resume(). Traps do not carry captures — the trace surface is the opt-in rut-side snapshot — but the host that receives Err(Trap) gets the kind, the message, and (via vm.fuel_used, heap_usage()) the budget context (VM core).

Where the surfaces live

Module bundles

rut pack mod/ reads a module directory and emits one file — mod.rutbundle — a zip archive carrying the manifest and the module’s sources. A bundle is the same contract as the directory it was packed from, in one file: the loader mounts it exactly like the directory, and the two compile to identical programs.

Layout rules

  • Entry names are source paths relative to the module root, flat — rut.toml, entry.rut, util.rut, <pkg>/… group entries for deps. No directories otherwise, no metadata entries. Unknown extra entries are ignored by loaders (forward compatibility).
  • The manifest’s name is the package name the bundle answers to — a bare [a-zA-Z0-9_]+ name, the same law as directory manifests.
  • Includes and splices never escape the archive: there is no outside.

The manifest — rut.toml

First entry in the zip; TOML; the same subset the directory form uses, which is what makes a bundle-shaped directory pack unchanged:

format = "rutbundle"
format_version = 4          # the bundle LAYOUT version — independent of
                            #   the module-binary version
name = "plugin"             # the package this bundle answers to
entry.lib = "./plugin.rut"  # the entry source, relative to the root
entry.libs = ["./store.rut"]  # optional: extra body files (multi-lib)

[deps]                      # the whole dep graph rides the archive
server = { path = "../server" }

Directory loading ignores format/format_version — a directory is not a bundle — which is why the keys are safe to write into every module manifest today: examples/03-plugin/plugin is a working directory that also packs unchanged.

The rest of the manifest grammar — deps tables, host_scope, inline, [style] — is defined in Project structure and rut.toml; the dependency semantics are Dependency kinds.

The layout ledger

format_version versions the zip layout and manifest keys themselves. Loaders refuse a version they do not know before reading anything else — refuse, never guess; the same gate makes an older loader refuse a newer bundle.

versionlayoutstatus today
1one module, sources onlyloads (the one-module special case)
2+ the whole [deps] graph as <pkg>/ groups (manifest + entry each, recursively, deduplicated)loads; no longer packs
3+ each package’s [peer-deps] lib group files beside its entryloads; no longer packs
4+ each package’s entry.libs files beside its entrythe packer’s output

Each extension exists because the added files are part of the package: a bundle that dropped peer groups or multi-lib files would load base-only — semantically wrong. Groups resolve by name (a dep’s path key is directory-time metadata only), and a v4 load splices entry.libs exactly as the directory loader does: base first, then the list in manifest order — the array is the splice order (Dependency kinds).

A manifest that declares [peer-deps] or entry.libs under a layout older than the one that introduced them is refused — those layouts have no group entries and would silently mount base-only.

Deterministic packing

Same directory + same toolchain ⇒ byte-identical .rutbundle:

  • fixed entry order — rut.toml first, then the entry source and every transitive relative include in include order, then dep groups (name order, deduplicated, recursive);
  • fixed (zeroed) timestamps;
  • STORE (no compression) — determinism over size.

This is the module binary’s determinism law extended one level up (Module binary and verification): bundles are content-cacheable, and two builds of the same module diff to nothing.

Checks on load

Every check runs at load time, before compilation and linking — a bad bundle never executes:

checkrule on mismatch
rut.toml present, format = "rutbundle"refuse — not a rut bundle
format_version knownrefuse — unknown bundle layout
zip entry CRC-32refuse — corrupt bundle (names the entry)
entry name / UTF-8 / STORE methodrefuse — corrupt or unsupported
entry.lib present; includes + declared groups/libs resolvable in the ziprefuse — load error naming the entry
a declared peer group missing from the archiverefuse — refuse, never guess

Loading

A bundle is one more resolution source alongside the directory form — same name resolution, same compile, same splice rules:

#![allow(unused)]
fn main() {
// from a file
let (session, root) = rut_driver::load_path_session(Path::new("vendor/plugin.rutbundle"))?;
// from bytes (embedders, tests, wasm)
let (session, root) = rut_driver::load_bundle_bytes(&bytes, Path::new("mem"))?;
}

After mounting, the package name resolves through the bundled sources exactly as the directory form does: the entry source is expanded (relative includes inlined, include-once), group and libs files splice in their manifest order, and the unit compiles under the manifest’s name — then the normal pipeline takes over (Loading and the embed loop). Loose directories and loose .rut files stay valid publish layouts; the bundle packages them, nothing requires it.

CLI

rut pack <dir> [-o <dir>.rutbundle]   # default output: a sibling of the dir
rut run <dir | mod.rutbundle>         # mount, compile, execute main

The packed form and its directory compile to identical programs — pinned by tests (the linked binaries are equal).

What a bundle is not

A bundle is a source contract: it carries rut.toml + .rut sources and compiles on load. A compiled-artifact payload (.rutc binaries plus declaration surfaces inside the zip) is not part of any layout version yet — when it lands it will be a new ledger row with its own consistency rules, and the source layouts above remain unchanged.

Project structure and rut.toml

One directory is one module; its rut.toml names the exact package it answers to and how to reach its surface and body. This page is the manifest grammar, the resolution laws, and where everything lives in the toolchain tree.

The manifest

# rut/pouch/rut.toml — a body package
name = "pouch"
entry.lib = "./pouch.rut"

# rut/calc/rut.toml — a pure declaration surface (a host pkg)
name = "calc"
entry.type = "./calc.d.rut"

# a consumer (an app directory)
name = "app"
entry.lib = "./app.rut"

[deps]
pouch = { path = "../pouch" }
ink   = { path = "../ink" }

The keys, all of them:

keymeaning
namethe package’s use-path name: bare [a-zA-Z0-9_]+ only. A scoped or quoted spelling is a manifest error.
entry.libthe body: one .rut file (or .rutc-style artifacts where supported)
entry.libsordered extra .rut files — the multi-lib entry (below)
entry.typethe declaration surface: one .d.rut file. entry.type alone makes a host pkg — a pure signature surface whose host fns the embedder binds at load (Host fns and declaration files)
entry.ira compiled declaration cache (.d.ir), when present
[deps]the transitively mounted dependencies — string-valued descriptors: pkg = { path = "..." }, relative to this manifest. optional is rejected here.
[peer-deps]presence-gated peers — descriptors accept path, optional, lib (Dependency kinds)
[dev-deps]mounted only while building/testing this pkg itself
host_scopethe host-fn registration prefix when it must differ from the package name (rt keeps its historical rt:log scope)
inlinetrue forces source-inlining into every consumer instead of linking (packages whose entries are generic functions or whose class methods must resolve at the call site)
format, format_versionbundle keys — ignored by directory loading, required by rut pack (Module bundles)
[style]formatter knobs: indent_width (1–8, default 4), max_width (≥ 20, default 100). Schema-free at the manifest layer — unknown keys ride; malformed values are formatter errors, never compile errors. Resolution: the nearest ancestor manifest of the formatted file; no manifest → defaults.

The parser accepts only the TOML subset the format uses (comments, key = "string", dotted keys, [section], inline tables), so the driver stays dependency-free and wasm-compatible. Descriptors are key/value maps; unknown descriptor keys are line-targeted manifest errors.

Resolution laws

  • Exact, single-step. A use path resolves only if a module with that name is mounted. Nothing is derived from directories or file layouts.
  • The walk is recursive, with a cycle guard; first mount wins. An already-mounted name (the embedder’s, the root’s, or an earlier dep’s) is never overwritten — which is what makes peer presence-by-name sound: the consumer’s own path for a package always beats the declarer’s path.
  • Name mismatch is an error. A dep whose manifest name disagrees with its key fails, naming both.
  • The cross-table law. A name in [deps] beside [peer-deps] or [dev-deps] is an error naming both rows; peer + dev together is the sanctioned pairing.
  • core needs no [deps] — the driver mounts it unconditionally; every name still requires use core::{ .. }; per core and the swappable packages.

The multi-lib entry

A package’s body may be split across files:

name = "ui"
entry.lib  = "./ui.rut"
entry.libs = ["./store.rut", "./t1.rut"]

The loader splices base-first, then libs in listed order, newline-joined, into one module — one namespace, one visibility scope. A name private to one file is visible to every other file of the package. The manifest is the canonical order (the splice never reads a directory listing — same manifest ⇒ same module). A libs row without lib, a .d.rut element, or a file named twice is a loud manifest error. This is assembly, not a language include form — use paths stay inter-module.

Contrast with peer groups: group files are presence-gated and therefore impl-only; multi-lib files are unconditional and full module citizens — types, functions, and pub surface are legal in any file.

The toolchain tree

rut/
├── crates/
│   ├── rut-lexer/        # spans, tokens, the lexer, diagnostics
│   ├── rut-ast/          # the flat arena AST + dumper
│   ├── rut-parser/       # the frame machine, Mode::Impl | Mode::Decl
│   ├── rut-lir/          # the fused middle end: check/ (resolve,
│   │                     #   typecheck, monomorphize) + lir/ (bodies,
│   │                     #   peephole, SROA, async lowering)
│   ├── rut-core/         # types, ops, binary encode/decode, link, sym
│   ├── rut-vm/           # heap, interpreter, verifier, driving loop
│   ├── rut-vm-threaded/  # tail-call threaded dispatch (nightly `become`)
│   ├── rut-driver/       # session, loader, bundles, dep graph, decl
│   ├── rut-fmt/          # the formatter
│   ├── rut-std/          # host bodies: log, math, nmap, http, async
│   ├── rut-wasm/         # the wasm ABI (compile/run envelope)
│   ├── rut-lsp/          # the language server (stdio)
│   ├── rut-lsp-wasm/     # the same queries as an in-process wasm module
│   └── rut-cli/          # the `rut` binary ([The rut CLI](cli.md))
├── rut/                  # the in-tree packages (below)
├── examples/             # 00-todolist … 06-github-viewer-cli
├── demo/                 # the wasm playground (React + rspack)
├── benches/              # cross-runtime benchmarks
└── integrations/         # editor configs; the vscode extension

Dependency line: rut-lexer ← rut-ast ← rut-parser ← rut-driver → rut-lir → rut-core ← rut-vm (with rut-vm-threaded behind it), hosts (rut-std, rut-wasm) on top, and rut-cli/demo above those. rut-lsp sits on the frontend crates only — tokens, diagnostics, and symbols need no VM.

The in-tree packages

rut/ carries the standard and swappable packages the CLI mounts on demand:

packageshape
corethe only standard package — the builtin surface, mounted unconditionally
calchost pkg: math surface (mount_calc)
rt, http_host, nmap_host, async_engine, bench_crosshost pkgs — pure .d.rut surfaces; bodies live in rut-std
ink, http, strbuild, async_hostinline rut wrappers over host rows (inline = true)
pouchthe sequence library (plain linked package)
jsonthe base pkg with [peer-deps]/[dev-deps] — the reference consumer of Dependency kinds
nmapsetnative-key maps/sets over nmap_host

Nothing in the engine knows the swappable packages’ names — peers and deps are resolved by name from manifests; a host may replace the non-core set wholesale (Embedding and native modules).

The playground

demo/ is a React + rspack + TypeScript page over the wasm build. The wasm ABI is two calls — compile(src) returning diagnostics, an AST dump, an IR dump, and an optional binary; run(binary, budget) returning output lines, an optional trap, and used fuel/heap — with every run bounded (default 10M fuel / 4 MiB heap) so unbounded loops trap and resume instead of hanging the page. The served artifact is deployed at https://playground.rut.hpp2334.com.

Dependency kinds

Every package’s rut.toml names the packages it relates to, and the kind of each relation decides how — and whether — it mounts. Three tables, one grammar:

  • [deps] — the transitively mounted dependencies.
  • [peer-deps] — required by default: not transitively pulled; the consumer must supply the peer. With optional = true, presence in the consumer’s closure mounts the integration; absence is inert.
  • [dev-deps] — mounted only when building/testing the package itself (the program root), never in a consumer’s world.

Peers are a presence relation, not a pull relation: the gate looks at what the program’s closure already contains and never adds a package. A package is either pulled transitively ([deps]) or required of the consumer / held for development ([peer-deps]/[dev-deps]) — never both.

The grammar

# rut/json/rut.toml — the reference shape
name = "json"
entry.lib = "./json.rut"
inline = true

[deps]
strbuild = { path = "../strbuild" }

[peer-deps]
pouch   = { path = "../pouch",   optional = true, lib = "./group-pouch.rut" }
nmapset = { path = "../nmapset", optional = true, lib = "./group-nmapset.rut" }

[dev-deps]
pouch   = { path = "../pouch" }
nmapset = { path = "../nmapset" }
  • Keys are bare package names — the same [a-zA-Z0-9_]+ law as name; a dep-table key outside it is a line-targeted manifest error.
  • [peer-deps]/[dev-deps] descriptors accept exactly three keys: path (string, directory-time), optional (the one bool the inline grammar learns), and lib (string; the peer-gated integration file). Anything else is the strict-manifest error. [deps] keeps string-valued descriptors and rejects optional — it has no options.
  • optional defaults to false — required by default. The zero-dep spelling { path = ".." } is valid in all three tables.
  • A lib ending in .d.rut is a load error: the integration must be a .rut source — a declaration surface does not gate.
  • The cross-table law: a name riding [deps] beside [peer-deps] or [dev-deps] is a manifest error naming both rows — pouch appears in both [deps] and [peer-deps] — a package is either pulled transitively or required of the consumer, never both. Peer + dev together is the sanctioned pairing: integration-if-present for consumers, always-present while developing the package itself.

Semantics

tablewho supplies ittransitive?missing behavior
[deps]the declarer’s own graphyes — walked recursivelymount error
[peer-deps] (required, the default)the consumer’s closurenever pulledloud resolution error at mount
[peer-deps] with optional = truethe consumer’s closure, if anywherenever pulledinert; referencing the integration is the dedicated missing-peer diagnostic
[dev-deps]the pkg’s own self-buildroot only — never walked for a depn/a (they exist to be there)

The mount law — four passes, one gate

All loader-owned; the session stays I/O-free and the compile graph never learns what a peer is.

  1. The [deps] walk — unchanged. Recursive, name order, first-mount-wins, cycle guard, name-mismatch error. While walking, the loader records each mounted package’s [peer-deps] into the session’s peer registry (it reads every dep’s manifest anyway).
  2. The dev pass — root only. The program root’s [dev-deps] mount exactly like [deps]. A dep’s dev table is never walked — a consumer’s world never contains another package’s dev table. Embedder mounting (mount_dir) offers a package to someone else’s program: it mounts no dev-deps and runs no gate (its peer declarations are still recorded).
  3. The peer gate — one post-closure pass. After the full closure exists, for every mounted package and every [peer-deps] entry:
    • required: the peer must resolve in the session, else the D1 error. Never auto-pulled.
    • optional, present (any reason): the declarer’s group file (the descriptor’s lib, an impl-only .rut) is appended to its Module.source — groups in peer-name order after the base. The combined text stays one source string, so every consumer of module sources (the splice, bundles, wasm mounts) is untouched.
    • optional, absent: inert — the group simply never mounts.
    • paths: the program root’s own peer paths are read and name-checked even when dev-deps already supplied presence — a broken path is the loud D3 packaging-bug error at the package’s own build. A dep’s peer paths are never read: presence is by name, so a broken peer path is inert for an optional peer and unreachable for a required one. The gate is post-closure because a peer may mount after its declarer alphabetically; groups add no package names, so one pass is a fixpoint.
  4. Compile — unchanged. The graph splices each compilation unit from its origin-deduplicated leaf list (first position wins, topological order preserved); in the no-collision case the composed text is byte-identical to the pre-peer law.

Groups are impl-only. A group file contains impl blocks (and their private helpers); it declares no new public names — the base owns the trait and every public surface. This is what makes the diagnostic matrix total: the only ways to reference the integration are trait-method dispatch or a use of the peer’s package name, and both have dedicated peer-aware diagnostics. A group appended to a module with no rut body (a .d.rut surface) is a load error — declaration surfaces do not gate.

The missing-peer matrix

casebehavior
required peer absent from the consumer’s closureD1 — loud at mount: names the package, the peer, and the fix. Not silent, not auto-pulled.
optional peer absent, integration never touchednothing — silent success; that is the feature
optional peer absent, integration referencedD2 — the dedicated missing-peer diagnostic; never a bare unresolved name
peer present (any reason)the integration mounts automatically — presence-based resolution
self-build / dev modedev-deps guarantee presence; no missing case exists
a [peer-deps] path that does not resolveD3 — loud manifest error at the pkg’s own build; for consumers, required ⇒ D1, optional ⇒ inert

The four diagnostics, verbatim shapes (all load/resolution-time errors, never runtime traps):

  • D1 — pkg \json` requires the peer `nmapset`, and `nmapset` is not in this program’s closure — peers are not pulled transitively: add `nmapset = { path = “..” }` to your `rut.toml` `[deps]``
  • D2 — cannot resolve \pouch` — `json`’s pouch integration is not mounted because the optional peer `pouch` is absent from this program’s closure; add `pouch = { path = “..” }` to your `rut.toml` `[deps]``. Declaring packages scan in mount order, so the diagnostic is deterministic when several packages declare the same peer. Required peers never reach this path — D1 fires at mount.
  • D3 — three shapes, all a packaging bug in json: cannot read a manifest at the peer path; the manifest there names it other; cannot read the group file.
  • D4 — the cross-table collision (both texts quoted above).

Interplay

  • Placement and orphans — the gate precedes the check. The peer gate runs at load; impl placement and pair-uniqueness run at compile/link. Peer absent ⇒ the group text was never assembled ⇒ there is no impl anywhere to place. Peer present ⇒ the group’s impl is a trait impl in the declaring package — legal placement, since trait impls may live in any module (Traits and dispatch).
  • The duplicate-pair link error is the consumer-side guard: a consumer who hand-writes the same impl Trait for Type in a world where the group mounted gets the link-time duplicate — loud and correct; the group already provides it.
  • Binary format untouched. Impl-only groups declare no host fns (no binding obligations appear or vanish), add no binary sections, and touch nothing the verifier reads. A consumer’s binary differs only by which impls registered — ordinary program content.
  • Dev-deps are invisible to bundles-as-consumers. A packed package carries its peer groups; a consumer packing their app never pulls the package’s dev table (pass 2 is root-only), so dev-only convenience packages cannot leak into consumer worlds (Module bundles).
  • The LSP completes group impls unconditionally. Peer gating is a resolution-time concept the language server does not model; it embeds the toolchain packages’ sources and never parses manifests. Recorded as accepted: the LSP is advisory; the compiler is the law.

Consuming a peer-gated package

# an app that wants json's pouch integration
[deps]
json  = { path = "vendor/json" }
pouch = { path = "vendor/pouch" }   # presence is the only requirement

The group mounts because pouch is anywhere in the closure — no extra declaration beyond having the package. Drop the pouch row and json mounts light; the first use pouch:: or trait dispatch into the integration produces D2 instead of a mystery.

The rut CLI

The rut binary is the toolchain’s command-line face. It is also a full host: running a program mounts the base packages, binds the standard native bodies, and drives the async loop — the same contract an embedded host implements (embedding and native modules).

rut run <file.rut | dir | mod.rutbundle> [--fuel N]
rut fmt <file.rut | dir> [--check]
rut pack <dir> [-o out.rutbundle]
rut dump <file.rut>

Running rut with no subcommand prints the usage line to stderr.

run

Compiles and executes. Three input forms:

inputpipeline
file.rutone file is one module unit — compile it against the base mounts, then decode → verify → run main
dir (a module directory with rut.toml)load the whole graph, compile it, run the root’s main (project structure)
mod.rutbundlethe packed form of the same contract (module bundles)

Flags and defaults:

itembehavior
--fuel Ncap the op budget per turn. Without the flag the run is uncapped; an unparsable value falls back to 10_000_000
heap limitfixed at 64 MiB
interrupt checkevery 1024 ops

Single-file convenience: a loose file that declares use ink:: (or any tree package) gets that package mounted automatically — the CLI scans the source for use <name>:: across rt, ink, pouch, nmapset, json, strbuild, async_engine, async_host, http_host, and http, then assembles peer groups, so a loose file gets json’s peer-gated container impls exactly like a module-directory program (dependency kinds).

Bodies bound by run:

installerpurpose
install_std_mathcalc’s float fns
install_std_log (sink: stdout)the logger; silent no-op unless the program uses ink
install_std_nmapthe native key table behind nmapset
install_std_bench_crossthe crossing-benchmark rows
install_std_asyncthe async launchers
install_std_httpthe std HTTP lanes

Execution: main is called with no arguments; then the async driving loop runs — drain the ready queue, advance the virtual clock to the next timer deadline, repeat until no frames and no tasks remain. The loop is capped, so a program that never idles fails loudly instead of hanging.

A .d.rut input is refused: a declaration file is a surface, not a runnable module (host fns and declaration files).

fmt

rut fmt src/            # rewrite in place
rut fmt --check main.rut

The canonical formatter. Behavior:

  • a directory argument is walked recursively; every .rut file is collected (sorted) and formatted;
  • style comes from the nearest ancestor rut.toml’s [style] block; no manifest → defaults (project structure);
  • .d.rut files are formatted in declaration mode;
  • a file that does not parse clean is refused (diagnostics listed, nonzero exit) — fmt never reformats on a parse error;
  • default mode rewrites in place and prints formatted: <path> per changed file;
  • --check writes nothing: it prints unformatted: <path> for each would-change file and exits nonzero, or fmt: N file(s) formatted when everything is clean.

pack

rut pack plugins/server -o server.rutbundle
# packed plugins/server -> server.rutbundle (18304 bytes)

Packs a module directory into a deterministic .rutbundle — same input, same bytes. Without -o, the output is written beside the input as <dir-name>.rutbundle. The bundle carries the compiled binaries, the declaration surfaces, the cached DeclIr, and symbol sidecars under a versioned manifest; run accepts it directly (module bundles).

dump

rut dump main.rut

Prints the compiler’s view of one file: the == AST == section followed by == IR == (per-function typed register tables and op listings). Compilation is the same base-mounted pipeline run uses, minus execution and verification. .d.rut inputs dump in declaration mode — useful for inspecting a host surface’s slots.

Declaration mode

The file extension selects the parser mode:

filemoderunfmtdump
*.rutimplementationruns mainformats implsAST + IR of the module
*.d.rutdeclarationrefused (exit 2)formats the surfacethe surface’s AST + IR

Exit codes

codemeaning
0success
1compile diagnostics; no binary emitted; binary decode or verify failure; VM boot failure; a trap at run; fmt refuses a file that does not parse; fmt --check found drift; pack or output-write failure
2usage errors (missing arguments); unreadable input file; run on a .d.rut; fmt finds no .rut files under a directory

Diagnostics go to stderr (run renders them with source spans on the single-file path); pack’s success line goes to stdout.

Notes

  • The CLI mounts and binds on behalf of the program, but it never injects names the program did not declare: a program that never spells use ink:: gets no logger; one that never mounts the async packages has no launcher and await stays cold-poll inline (core and the swappable packages).
  • The HTTP lane is native-only: the CLI build carries it, wasm builds do not.
  • run on a module directory is the deployment path for development; pack + run <bundle> is the shipping path — both run the identical contract (loading and the embed loop).