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 program | Quick Start |
| Learn the language hands-on | the Tutorial |
| Understand why the language is shaped this way | Core Concepts |
| Study complete programs | Examples |
| Look up exact rules | the 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;rustupinstalls it on the first build. Any recentrustupworks. -
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
usepaths (see the modules tutorial). use ink::{ Logger };pullsLoggerout of theinkpackage. When yourut runa 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
.rutfiles 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
| Group | Types | Notes |
|---|---|---|
| unsigned integers | u8 u16 u32 u64 | fixed width |
| signed integers | i8 i16 i32 i64 | two’s complement |
| floats | f32 f64 | IEEE 754 |
| boolean | bool | true / false |
| text | str | immutable UTF-8, compared by content |
| binary | bytes | immutable octet buffer, compared by content |
| erased | opaque | a 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 anf32, and adding anf64to it is a type error; annotatelet 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, bool | value comparison |
str, bytes | content comparison |
| everything else | cell 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 partialwhenover an enum is a compile error — when you add a member later, the compiler finds everywhenyou must extend. - Any other scrutinee:
elseis 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
Thas no methods — you cannot call anything on a value whose type is justT. 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 overTtakingVec<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-selfmethod returningSelfis a constructor.newis 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
selfexplicitly as the first parameter;mut selfmarks methods that write. There is nothis, no static methods, noget/setsyntax — 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;
pubexposes to importers (alsopub(mod),pub(super),pub(self)). - The types themselves follow the same rule:
pub class Vec<T>is importable, a bareclass Helperstays 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 ofShape?”).
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
nilon 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:
| Form | Meaning |
|---|---|
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 (withoptional = true) the integration mounts only if the peer is already in the program’s closure. The standard library’sjsonpackage uses this to attach itsVec/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 anasync fn, and only for engine-woven futures (async-fn results andsleep).launch_future(fut)— hand it to the driving loop and keep a receipt. Returns aLaunchedFutureHandle<T>, which is not a future: it cannot be awaited or re-launched. Itsabort()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)(orrequest(),post,put,patch,del) hands back aRequestBuilder;.method(..),.url(..),.header(k, v)(repeatable),.body(bytes)chain on it..build()freezes a re-sendableRequest..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()thenawait stream.next(cx)— one chunk per future,nilat 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:
-
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 isnilon 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. -
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. -
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.
-
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
opaquebox (opaque(v),opaque.downcast<T>(o)) for storage. Erasure mints a checked box; recovery checks the runtime type and yieldsnilon 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
Vminstance 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 aTrapvalue 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
| immediate | cells | |
|---|---|---|
| types | u8..u64, i8..i64, f32, f64, bool, fn values | str, bytes, struct and class records, [T] arrays, enums, trait objects, opaque boxes, ?T boxes, closures’ captured cells |
| assignment | copies the bits | copies the handle (O(1)) |
| mutation | n/a — write the variable | visible through every alias |
== | by value | str/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.
strcompares by content (codepoints);bytesby 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
nilmeans “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 packedi32buffer; 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 isopaque.downcast<T>(o), checked against the runtime type. See reified types and the reference onopaque.
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:
istype tests —x is Circle(exact) andx 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, ornilon 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:
- The host boundary needs no coercion code. A native fn declares
(Opaque, str, str) -> nilonce; 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. - 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 Tmisses for every payload type — so the only way back to the payload is the checked recovery. Erasure without reification would beany; with reification it is a sealed box. - Distinct instantiations.
Vec<f32>andVec<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. - 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_ofdo 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 declaresT.impl I for T { .. }defines a trait implementation; its methods are exactly the trait’s, with nopub(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 i32registers 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:
- 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()onc: 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.
- a concrete receiver (
- 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 (
ifarms carrying different concretes into one trait-typed variable).
- elements of a heterogeneous container (
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) | |
|---|---|---|
| created | starts on the next microtask | cold — nothing runs until awaited or launched |
| idle cost | reactions and queues exist whether or not anyone waits | nobody drives ⇒ nobody pays |
| per-await allocation | promise + callback lists | one hidden frame record per call |
| cancellation | abort flags layered on top | native: the frame is dropped at a checkpoint |
| host integration | the host must drain a job queue | the 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 mintsself. It carries the frame edge:checkpoint()reads the resume state,cancelled()reads the task’s abort flag. - Calling does not run.
async fn f(..) -> Tdescribes a value that widens toFuture<T>; it runs when awaited or launched. - One consume law. A future is consumed by
awaitor bylaunch_future— exactly one. The launch returns its own receipt type, which is not a future and cannot be launched or awaited again. awaittargets engine-woven futures (async-fn results andsleep). Hand-written futures are launcher-drivable — the driving loop finds theirFuture::yieldin the vtable — which is how the standardsleepitself 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:
on_dropcleanups are pinned.on_drop<T>(p: ?T, cleanup: fn(?T))attaches a cleanup to a nullable binding — and a?Tbinding 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.- 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.
- Host opaques run their Rust
Dropat 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
Weakbox to it is nulled before any user code runs — a cleanup that callsupgrade()seesnil, 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_dropcleanups or host handles) must not participate in strong cycles; - parent/child and observer shapes take
Weakback-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?
- 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. - Teardown.
Vm::dropfrees 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. - 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, andopaque. - Returns: the same set plus the answer optionals
?str,?bytes,?opaque—Option<String>,Option<Vec<u8>>, and the opaque handles mint the nullable box, withNoneas the flatnil. - 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 returnederris 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.
&strand&[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.
| Project | Run | What it demonstrates |
|---|---|---|
| 00 — Todolist | cargo run -p todolist | an entry fn surface over a rut class — the host drives CRUD through opaque handles |
| 01 — Sort | cargo run -p sort | five sorting algorithms behind one dispatcher entry; when on strings, the mut-binding law, fuel budgets |
| 02 — Digest | cargo run -p digests | byte-level codecs and hashes (MD5/SHA/base64/CRC/FNV); the host as an independent test oracle |
| 03 — Plugin | cargo run -p plugin | a module directory + .rutbundle chat-moderator plugin; re-entrant vm.call, both opaque directions |
| 04 — Custom async | parse-only — no runnable harness | a hand-written impl Future<nil> for CustomFuture plus a user launcher with per-checkpoint stats and cancellation audits |
| 05 — Todolist web | cargo test -p todolist-web + node tests/e2e-browser.mjs | a full page app whose brain is a two-package rut project — ten DOM/timer crossings over web_sys on wasm32 |
| 06 — GitHub viewer CLI | cargo run -p rgh -- --repo=… --ref=… list | rgh — an async rut brain over the std http lane; headers-then-stream downloads, fixture-lane tests |
| The playground corpus | cd demo && npm run smoke | the 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’splugin/rut.tomldeclaresserver = { path = "../server" }, and 05 — Todolist web’sbizpackage declaresui(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 peeroptional = trueflips 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 stdjsonpackage’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 callsassemble_peersand 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 fnis 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
opaquecontainer the host holds and re-passes; plain values cross, instances never do. - Erasure is
opaque(v); recovery isopaque.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
mainstill 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 fnsurface 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.
muton 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
opaquetree, 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. bytescrosses the boundary directly;opaqueis for the one place recursion needs breaking.- The std
jsonpackage 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
EventBusgoes in as a host-constructedOpaque<EventBus>—subscribeandemitare 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
.rutbundleunchanged. - 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
opaquedirections 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::yieldrow in the vtable, exactly as it does for woven frames. awaittargets engine-woven futures only in the current build (async-fn results andsleep); 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 fncompiles to a hidden frame implementingFuture— this file spells that frame out in user source.- Built-in traits are engine-named, not engine-closed:
impl Future<nil> for YourTyperegisters 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
Futuresurface, 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
mainplus 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 playgroundcompiles and runs every classic through the full pipeline natively — a compile failure or a trap fails the gate.cd demo && npm run smokebuilds 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
| Case | Demonstrates | First line it prints |
|---|---|---|
sieve | Sieve of Eratosthenes — flat Vec<u8>/Vec<i32> primitive buffers | 25 primes up to 100, last=97 |
quicksort | in-place Vec<i32> mutation (handles, shared with the caller), recursion | sorted: 1 2 2 3 5 7 8 9 |
matrix-mul | flat Vec<f32> hot loops — unboxed buffers, no per-element refcounts | out[0]=21 out[last]=107 |
classes | class-method construction (new/from), Self {}, member pub + sealing | count=2 area=12 |
closures-generics | anonymous fns (block bodies), monomorphized generics, fn types, capture | add=3 area=3.1415927 sum=6 |
structs | reference semantics (sharing by default), identity == | len=6.324555320336759 color=16711935 area=6 |
literals | numeric suffixes, plain/raw/format strings, fixed [T], bytes buffers | a=10 e=1.5 d64=1.5 ch=h p.x=1 zero[0]=9 len=3 bin=64 |
checked-arith | wrapping_* wraps two’s-complement, checked_* answers the (T, bool) tuple | wrap=4 under=255 |
str-views | O(1) slice views (a slice IS a str), codepoints — s.code / str.from_code | word=world len=5 eq=true |
bytes | the binary primitive — encode/decode, clone as the ONE copy | round=true octets=8 chars=8 |
opaque | opaque / opaque.downcast<T> -> ?T / is — erasure and checked recovery | point 1 2 |
when | when pattern expressions over enums, exhaustiveness | small |
maps | the keyed-collection lane — HashMap/HashSet, keys admitted by the compile-time union bound | rut=3 runs=1 |
node-cycle | strong cycles keep cells alive — the program’s responsibility | head.next alive: true |
tree | recursive structs (?Node nullable fields), composite fields as handle slots | nodes=15 |
weak-cache | the cache/observer shape, shown with today’s strong refs | held: true id=1 |
type-aliases | transparent aliases, bound-only unions, inline requires at the call site | trip=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:
fn | let | mut | if | else |
while | for | of | return | when |
enum | struct | class | trait | impl |
requires | use | pub | static | async |
await | extern | is | host | select |
true | false | nil |
Contextual words — ordinary identifiers elsewhere:
| Word | Special meaning |
|---|---|
type | the alias introducer (type Km = Meters; — see Type aliases and union bounds) |
entry | entry fn at module scope publishes the function to the embedder |
self | the explicit receiver, first parameter of an instance method |
Self | names the enclosing class inside its body; the class-private literal Self { .. } |
new | not special — the conventional construction-method name (Rect.new(..)); there is no new expression |
as | the numeric cast (x as u32) and the select arm bind |
super | only inside pub(super) |
builtin | declaration 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:
| Reserved | Error replacement |
|---|---|
switch, case, match | when — arms are pattern -> body |
null, undefined, void | absence is nil on a ?T |
any | a trait type or opaque |
typeof, instanceof | x is T tests at runtime |
extends | no inheritance — compose instead |
interface | rut spells this trait |
dataclass | removed — spell it struct |
delete | no dynamic properties |
in | iteration is for (let x of ..) |
with | — |
var, const | bindings spell let / let mut |
private | members 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], andVec<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:
| Declaration | Spelling |
|---|---|
| import | use pkg::{ A, B }; / use pkg::A; |
| binding | let name: T = expr; (also under pub) |
| enum / struct / class / trait | enum E { .. }, struct S { .. }, class C { .. }, trait I { .. } |
| impl block | impl T { .. }, impl I for T { .. } |
| function | fn f(..) { .. }, async fn f(..) { .. } |
| entry point | entry fn f(..) { .. } |
| alias | type 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 throughx, and nomut selfmethod 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:mutis permission, never a copy. Every assignment path must run through amutbinding.- 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:
| Form | Meaning |
|---|---|
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 sayspub. There is noprivatekeyword — the unannotated default is the private spelling. - Applies uniformly:
let,enum,struct,class,trait,impl(an impl exports with its target type),fn,typealiases. On aclassdeclaration 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
pubnames 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
| Group | Types | Notes |
|---|---|---|
| unsigned int | u8 u16 u32 u64 | fixed width |
| signed int | i8 i16 i32 i64 | two’s complement |
| float | f32 f64 | IEEE 754 |
| misc | bool | |
| text | str | immutable UTF-8, length-prefixed; compared by content; s.slice(a, b) is an O(1) view (see String slicing and views) |
| binary | bytes | immutable, content-compared octet buffer; the engine-level u8 array |
| seq | Vec<T> | mutable, growable buffer — shared; a library class over [T] (see Builtin generic types) |
| seq | [T] | fixed array — shared; runtime length, non-growable |
| nullable | ?T | nil-able cell; nil is the null (see By-reference and nullable) |
| erasure | opaque | the erasure box (see opaque — erasure and downcast) |
| user | struct / class records | shared 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:
| Member | Meaning |
|---|---|
s.code() -> u32 | the FIRST codepoint of s (traps on empty) |
s.code_at(i: i32) -> u32 | the codepoint at codepoint index i (traps out of bounds — the index is a bug, not data) |
str.from_code(n: u32) -> str | the 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 (nouse):Family Members wrapping (two’s complement) wrapping_addwrapping_subwrapping_mulwrapping_shlsaturating saturating_addsaturating_subsaturating_mulchecked ( (T, bool))checked_addchecked_subchecked_mul -
Division by zero traps;
int / intis 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/nilfill is the memset-class op; a ref fill retains the cell handlentimes — 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(), andfor (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:
| Spelling | Type | Reading |
|---|---|---|
?T | T | nil | the nullable |
[?T] | [T | nil] | array of nullables |
?[T] | [T] | nil | nullable array |
??T | chained | the same runtime box, unwrapped transitively at use sites |
- Coercions:
T → ?Tboxes (the box aliases the payload’s cell — a share; primitives copy bits),?T → Tderefs (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 onp’s payload. p == nil/p != nilcompare against the null slot;?T == ?Tis slot identity (see Rc, dispose, and identity).- A
?Tbinding IS the cell reference — writes through it hit the shared cell (gated bymut, 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
?Tcrosses 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.
| Construction | Meaning |
|---|---|
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).
| Member | Meaning |
|---|---|
push(v) | append; amortized O(1) growth |
pop() -> T | remove and return the last element; traps on empty — guard with len() > 0 |
v[i], v[i] = x | element access; out-of-bounds traps |
len() -> i32 | live 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() -> bytes | Vec<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 anilv;Tmust be a reference type (Weak<i32>diagnoses — primitives move by value).Weak<?U>is legal andupgrade()answers??U.- The referent’s death nulls every weak box before any user code runs;
upgrade()answersnildeterministically 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
nilon a nullable: a lookup returns?V, andnilmeans “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 andrequiresbounds (see Type aliases and union bounds) — a compile-time admission gate, never a runtime union value. - There is no enum↔int cast:
asis 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 infersTbidirectionally 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:nslots of the valuev— 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):
| Form | Example | Meaning |
|---|---|---|
| plain | "hi\tname" | escapes processed: \t \n \r \b \f \\ \" \u{...} |
| raw | r"C:\temp\log.txt" | no escape processing; every byte is literal |
| format | f"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:
| Type | Rendering |
|---|---|
| ints | decimal, - for negatives |
f32/f64 | shortest round-trip decimal (3.5, 0.1, 1e300) |
bool | true / false |
str | contents, verbatim |
| enum | member 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?;—expris required unless the function returnsnil.break/continueexist for loops; there are no labels and nodo..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
| Pattern | Example |
|---|---|
| enum member (dotted path) | Light.Green |
| literal | 0, 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
elseis optional) or addelse— a partialwhenis a compile error. Any other scrutinee type (integers can’t be enumerated):elseis mandatory. - All arms must agree on one type — that is the
when’s type. Used as a statement, that type must benil(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). whenliteral 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
Iattaches 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
constructorkeyword 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 literalnewis not special syntax — just the conventional primary-constructor name (from,parse,open,defaultare its siblings); it is an ordinary identifier. Try-construction returns the nullable: a class methodfn parse(s: str) -> ?Versionanswersnilon 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. -
asyncclass methods are allowed — same function,awaitin the body. No partially constructed instance ever exists across anawait: theSelf { .. }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 throughself. There is nothiskeyword. A method that mutates declaresmut selfand requires alet mutreceiver (see Modules and visibility). - A method without a
selfparameter is a class method — invoked on the class itself (Rect.new(..),Self.new(..)inside the body). Presence or absence ofselfis the whole distinction; there is no separate “static” method form (static fndoes not parse). Class methods are ordinary functions: they validate, default, cache, register, or hand out singletons. - No
get/setaccessor 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
extendsfor classes — no base-class constructors (super(..)), no method overriding, nosuper.m(), noprotected. (extendsis 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
isa 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 | == means | Lowering |
|---|---|---|
numeric / bool primitives | value | compare, IEEE 754 for floats (NaN != NaN, -0.0 == 0.0) |
str | content (codepoints) | content compare |
bytes | content (octets) | content compare |
everything else — records, arrays, Vec, enums, closures, trait objects, opaque, ?T | cell identity | the 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.Souristrue— the one place identity quietly behaves as value. ?T == ?Tis slot identity: twonils are equal, a null and a box are not;p == nilderefs 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+eqimplemented per type) — the mechanism value-keyed maps ride. It is not connected to==. whenliteral 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/setsyntax 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
selfreceiver like every other method (fn draw(self, g: Canvas) -> nil;), except where an engine contract spells a receiver-less descriptor method (Future).async fnsignatures are legal; an impl’s method must match the trait’sasyncspelling 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 targets | local struct/class, or a builtin class this module declares | any 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 |
async | legal | legal — must match the trait’s signature |
no-self methods | legal (constructors) | legal where the trait declares them |
| fields / empty body | never / legal | never / 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
Iuntil some module writesimpl I for T. There is no duck typing and no orphan rule beyond placement: for everyimpl Trait for Type, at least one ofTypeorTraitmust 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,asyncspelling. 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 i32registers like any trait impl), while an inherentimpl 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 associatedtypemembers. Selfin impl signatures names the impl’s target under the impl’s substitution:-> Selfreturns,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()onc: Circle; - a trait-typed local of single concrete origin —
let d: Shape = Point { .. }; d.area()binds straight toPoint’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.
- a concrete receiver —
- 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 aVec<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.
- heterogeneous container elements —
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.
isis total: never traps, yields onlybool. 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 declaresmut 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(ormut self) makes it an instance method; absence ofselfmakes it a class method (see Classes and constructors). entry fnpublishes a function to the embedder (see Modules and visibility);async fndeclares 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..) -> Rdirectly — 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 mutbinding 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.
Tinfers 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
Tinstantiated 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 anI-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 — “
booldoes not satisfyTrequiresi32 | 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
Tinstantiated 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 bareT. - 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:
| Member | Meaning |
|---|---|
opaque(v) | erasure: box any value; answers the opaque box |
opaque.downcast<T>(o) -> ?T | checked recovery: the box’s inner cell on a match, nil on a mismatch |
x is opaque | the 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
?Tshares 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 trapsNilDeref, 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:
isnames the box, never the payload.o is Tando is Ianswer by the box — false for every payload type, concrete and trait alike. The one check that names what it is —o is opaque— istrue. Recovery isdowncast<T>only. On a non-box receiver,x is opaqueis 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)isfalse— 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 opaqueistrue,o is Tmisses for every rutT, andopaque.downcast<T>answersnilfor everyT(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
opaquebox 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 andopaqueparameters/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
| Surface | Mechanism |
|---|---|
is type tests — concrete and trait RHS | the value cell’s descriptor (see Traits and dispatch) |
opaque.downcast<T> recovery | the box’s recorded runtime type (see opaque — erasure and downcast) |
type_id<T>() | the compile-time type-identity constant |
| host boundary checks | every crossing is checked against the declared parameter type |
| diagnostics | stack 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=Pointwherever declared.- It is a compile-time constant — a load-time expression (legal in
module-level
letinitializers, 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 × 8bytes. - 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
?primelement storage inside sequence backings is the raw payload plus a one-byte nil tag — no per-element cell. bytesat the engine level is au8array cell; astris 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
Ireuses 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/toare 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, andlen()counts its own codepoints. There is no separate view type on the surface — a slice of astris astr. - Binding the slice shares it like every cell (see
By-reference and nullable);
strhas 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
| Operation | Cost |
|---|---|
s.slice(a, b) | O(1) — one small cell + one retain (UTF-8 walk only for non-ASCII bounds) |
| read/compare/render a view | O(window) — same as any str |
| concat out / the host crossing | O(window) — the materializing copy |
| parsing a 1 KiB line out of a 1 MiB buffer | one 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 (elementiisparent[off + i], bounds are the window’s); - writes:
w[i] = x(and compound assignment) hit the parent; - fixed-length:
push/popthrough a view trap — copy the elements out to grow (afor-push loop does it); - reslicing flattens onto the root backing (
w.slice(a, b)); - parent growth detaches:
pushre-backs theVecwith 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:
Xresolves 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 notFoois 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 ontype(see Modules and visibility). Like every name, the alias is usable in a consumer only when the consumer’susenames 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/whensupport 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/
isposition is diagnosed as bound-only. |continues a completed type as a union only where unions are legal — the alias target andrequiresbounds — sox as u32 | ykeeps 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: “
booldoes not satisfyTrequiresi32 | str— no matching type or impl is registered”. -
Trait objects satisfy nothing: instantiating
Tat 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
letwhose annotation spells the generic, a copy of either, or a directself.ffield 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. -
whereis removed. The trailing clause is gone;whereis 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
fnvalues 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,opaqueboxes, closures’ captured cells, and?Tboxes. 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: aletbinding is a read-only view;let mutgrants 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
Tstored into a?Tslot boxes once — the box aliases the payload’s cell (a share); primitives andnilcopy 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 handlentimes: 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
| Spelling | Type | Reading |
|---|---|---|
?T | T | nil | the nullable |
[?T] | [T | nil] | array of nullables |
?[T] | [T] | nil | nullable array |
??T | chained | the 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 onp’s payload. p == nil/p != nilcompare against the null slot; guard before use. The trap is the bug-catcher, not the semantics.- A
?Tbinding IS the cell reference: writes through it hit the shared cell (anudge(mut pt: ?Point)moves the caller’s point). niltyping: a context-freenilhas typenil; in an expected-?Tposition it types as that?T— solet p: ?Node = niland aleft: nilfield in a literal just work. Absence reads plainly: a lookup returns?V, andnilmeans “not found”.on_drop<T>(p: ?T, cleanup: fn(?T))attaches the cell-death cleanup (see Rc, dispose, and identity).- Across the host boundary
?Tcrosses nil-flattened when its element crosses — anentry fn -> (?T, err)is a first-class host answer.
== — identity for cells, content for text
| Operand type | == means | Lowering |
|---|---|---|
numeric / bool primitives | value | IEEE 754 for floats (NaN != NaN, -0.0 == 0.0) |
str | content (codepoints) | content compare |
bytes | content (octets) | content compare |
everything else — records, arrays, enums, closures, trait objects, opaque, ?T | cell identity | the 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
| Removed | Replacement |
|---|---|
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 expressions | pass 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 onbuf: [?T]—[nil; cap]is the generic zero (nilis the slot’s zero);pushtakes theT → ?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, andopaque.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, nil | str, 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
fnvalues 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::MAXimmortalizes the object — the count is pinned to the0sentinel (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;pmust be a nullable; the cleanup must be a function value. All three are compile errors otherwise. - One callback per cell: a second
on_dropon 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
?Targument. - 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:
- Every
Weakbox watching the cell is nulled before any user code runs — a cleanup that callsupgrade()seesnil, deterministically (weak references). - A queued
on_dropcleanup runs (pinned cell, then released). - Ref-typed children are released recursively: record fields in
declaration order, sum payloads, array elements,
opaquebox inners, closure captures. The walk is driven by a per-type release plan built once from the type table. - Host box payloads run their Rust
Dropat the same point — sockets, files, and textures die with the last handle, not “sometime later”. An opt-infinalizehook (no-op default) runs before the payload’sDrop; a rut value the payload held releases through the same walk. - 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 orboolelement 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 isbuf: [?T], so element traffic crosses nullable handles.push/pop/setemit the paired retain/release.- Composite-element buffers store handles:
[Point]andVec<Point>hold one cell pointer per element; the buffer itself is the counted unit. [T]is a fixed-length cell;Nis 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 plusoff/len— never a pointer into the data block. Views dispatch through the owner, so growth keeps existing views valid; indexing bounds-checks againstlen ∩ owner lenand 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.stris 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, becausestrhas 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
| Rule | Content |
|---|---|
| No collector | No mark bits, no colors, no pauses beyond destructor chains. What leaks leaks wholly and predictably. |
| Resource holders | Classes holding resources (drop cleanups, host boxes) must not participate in strong cycles. |
| Back-pointers | Parent/child and observer shapes take a Weak back-pointer. |
| Pure-data cycles | Harmless — memory only, freed wholesale at teardown. |
| Lints | The 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:
Tmust be a reference type.Weak<i32>andWeak(SomeFn)diagnose; primitives andfnvalues refuse. -
Works over any cell: a class, dataclass,
Vec,[T], enum,str,bytes, a useropaquebox, 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()answersnil. 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 onevare 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
?Thandle; a dead one answers the null slot — truenil, 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:
- Caps — an embedder says “this script gets N bytes” and gets a
resumable
OutOfMemorytrap, not an OS abort (resource limits). - Answers —
vm.heap_usage()is exact only if every allocation is accounted. - Teardown —
Vm::dropfrees the heap in slab units; nothing per-object leaks past the VM. - 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 heap | The 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 buffers | the shared type table |
| coroutine frames and register blocks (async and await) | native-module state |
| the ready ring, the frame pool, the timer wheel | host-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
Vmdrop; they carry therc == 0sentinel. - The
opaquestore. Every rutopaquevalue 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
Vecbuffers 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 counts | everything the VM heap tracks: cell headers + payloads, buffer blocks, string blocks, frames and register blocks, opaque store entries |
| What does not | module binaries, the shared type table, host-side Rust state |
| Check points | every Heap::alloc route: cell mint, Vec growth (push reallocation), string concat, frame-pool growth, host crossing mints |
| On failure | Trap::OutOfMemory — the check runs before any write, so the heap is byte-identical to its pre-op state; nothing is half-initialized |
| Resumption | the frame is parked: raise the limit (Heap::set_limit), drop references and retry, or drop the VM |
| Observability | vm.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;
fuelcounts down. The counter is checked everyinterrupt_everyops (default1024) and at loop back-edges. Trap::OutOfFuelparks the frame exactly like any resumable stop: nothing is unwound. Resumption isvm.add_fuel(n)thenvm.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
| Trap | Raised by | Frame state | Resumption |
|---|---|---|---|
OutOfMemory | heap budget exceeded at an allocation check | parked before the write | raise limit / free refs → resume() |
OutOfFuel | fuel reached 0 at a check point | parked at the exact pc | add_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:
| Host | Recipe |
|---|---|
| UI / game | drain the ready queue inside the frame loop; grant fuel per frame so a runaway script starves at the next check |
| wasm page | grant finite fuel; a watchdog stops refueling — the parked frame dies at its next check |
| desktop service | a watchdog thread flips a flag the host honors between resume() calls; no joins, no signals |
| tests | finite 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) | |
|---|---|---|
| created | starts executing on the next microtask | cold — nothing runs until awaited or launched |
| completion | pushes callbacks through a job queue | nobody drives ⇒ nobody pays |
| allocation | promise + reactions per then | one hidden frame record per call; locals are cell-backed |
| cancellation | abort flag / never settles | abort() flags the frame; the probe at its checkpoint runs the drop path |
| host integration | the host must drain a job queue | the 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(..) -> Tdescribes a value that widens toFuture<T>. Calling it runs nothing (cold). It runs when the future isawaited or launched.awaitis legal only inside anasync fn. In this build it targets engine-woven futures — async-fn results andsleep.- 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 aFuture(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_dropcallbacks 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):
| Read | Answers |
|---|---|
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:
field content 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, andret— 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 API | Meaning |
|---|---|
vm.launch(fut) | enqueue a future (the queue owns one reference) |
vm.cancel(fut) -> bool | flag cancellation + re-enqueue; false when already retired |
vm.drive(fut) -> Drive | one 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() -> usize | drain the ready queue, then poll parked host futures, until a spin produces no completion |
vm.pending_tasks() -> usize | ready + 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
| Feature | Spelling | Status |
|---|---|---|
| launch | launch_future(f) | live |
| cancel | h.abort() | live |
| join | await h on a receipt | compile-gated — “not in this build” |
| race | await select { .. } | parses; semantics compile-gated |
| first-of | select_all(futs) (stdlib) | lands with select |
| structured scopes | scope { .. } | 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 hdiagnoses — 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 — byawaitor bylaunch_future, never both, never twice. - One member:
abort()—trueif the frame was flagged and re-enqueued,falsewhen 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:
- the pending edge is cleared — a pending
sleepdies with the frame; - every local with a cleanup runs it deterministically, in reverse
declaration order (
on_dropcallbacks fire, the Rc heap); - the state retires (null) — later
abort()calls answerfalse.
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
->likewhen:fut -> exprdiscards the resolved value;fut x -> exprbinds it tox(arm-local binding). select_all(futs)(stdlib, built onselect) 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 byawaitorlaunch_futureexactly 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:
| Member | Thread | Meaning |
|---|---|---|
Completer::new() -> Completer<T> | any | a fresh cell: atomic state + a mutex slot, one Arc |
.clone() | any | another handle on the same cell — never a copy of the state |
.complete(v: T) | any thread | settle with the answer |
.fail(msg: String) | any thread | settle with a failure — msg becomes the trap at the await |
.poll() -> i32 | any | the driving loop’s probe: PENDING (0) / READY (1) / FAILED (2) |
.take_result() -> Result<T, Trap> | VM thread | drain 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 row | Signature | Role |
|---|---|---|
mypkg::fetch | (args) -> T | the decl row — a teaching trap, never dispatched |
mypkg::fetch__start | (args) -> opaque | calls the closure, boxes the Completer — the state cell |
mypkg::fetch__yield | (state, cx) -> i32 | the resumption probe: 0 pending / 1 ready / 2 failed |
mypkg::fetch__take | (state) -> T | marshals 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:
| Endpoint | Type | Behavior |
|---|---|---|
sender.send(v) | sync | unbounded 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_workerargs, or through another channel. - Receiving
nilis 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
| Type | Crossing rule |
|---|---|
primitives (ints, floats, bool) | copy |
str | copy (immutable) |
every other cell — Vec<T>, [T], class/dataclass instances, enums, ?T boxes | transfer if rc == 1, else deep copy — the zero-copy fast path is the common case; every element/field must itself be crossable |
Slice<T> view | same rule as its owner cell; provenance (which Vec/[T] it views) is invisible across the boundary |
Sender / Receiver | transfer |
Template | copy — a builtin carrier; its opaque args must themselves be crossable (templates) |
| host opaque types | transfer only if the host registered send for them — checked at the transfer, by type |
| closures | not 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:
- Static at the send/spawn site — the checker knows
Tand rejects non-crossable types (closures, unregistered host types). - Runtime on the receiving heap —
transfer_intodetects uniqueness: anrc == 1buffer 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:
- construct a child
Vmsharing the type table and loader — never the heap (the VM heap); - load the module;
- 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);
- run the worker’s entry to completion on its own thread; entry parameters are the transferred args;
- return a
Workerhandle;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)
| API | Meaning |
|---|---|
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)
| API | Meaning |
|---|---|
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() -> usize | unfinished 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…) -> Ror-> 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.callon 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 anErrto the embedder with the rut backtrace intact. Traps never unwind Rust.
TrapKind | raised by |
|---|---|
OutOfFuel | budget exhaustion |
OutOfMemory | heap limit |
Interrupted | interrupt flag |
Overflow / DivByZero | arithmetic |
IndexOutOfBounds | sequence access |
Assert / Panic | assert / panic |
BadUnbox | a failed unbox |
NilDeref | nil where a reference is required |
Invalid | everything 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)
| installer | binds |
|---|---|
math::install_std_math | calc’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_nmap | the native key table behind nmapset (stdlib) |
async_host::install_std_async | the launcher rows (__launch/__abort/__sleep/__sleep_yield) |
http::install_std_http | the std HTTP lanes (reqwest; native builds only) |
bench_cross::install_std_bench_cross | the 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-entrantemitcrossings. - 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, andopaque. - 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:
?Tcrosses iffTcrosses, 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 type | rut type | direction | cost |
|---|---|---|---|
i8 i16 i32 i64 | same | both | the raw slot bits |
u8 u16 u32 u64 | same | both | raw bits (u64 is never narrowed through i64) |
f32 f64 bool | same | both | raw slot bits |
() | nil | both | the zero word |
&str | str | param only | zero-copy borrow of the block store, scoped to the call |
String | str | both | owned copy |
&[u8] | bytes | param only | zero-copy borrow |
Vec<u8> | bytes | both | owned copy |
OpaqueRef | opaque | both | the handle; the rc transfers across |
Opaque<T> | opaque | both | typed payload view; a wrong T traps naming both sides |
Option<String> | ?str | return (answer lane) | Some mints the opt box; None is the flat nil |
Option<Vec<u8>> | ?bytes | return (answer lane) | as above |
Option<OpaqueRef> / Option<Opaque<T>> | ?opaque | return (answer lane) | as above |
Option<T> (other T) | — | — | no lane: traps naming ?str/?bytes/?opaque |
(A, B, …) up to 8 | tuple | both | field-by-field under the record’s own field types |
Value | any | read | positional 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.callthat touches the same box trapsborrowed by an outer host callinstead 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>—Errmeans 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:
| entry | holds | identity |
|---|---|---|
Host | Box<dyn Any> + a type name + an optional finalize hook | TypeId guards the payload view |
Rut | a rut cell slot | identity 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:
| API | Meaning |
|---|---|
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 aCallArg(Copyprims,String,&str,Vec<u8>,&[u8],OpaqueRef,Opaque<T>).
Cross-links
- Declaring and binding the surface: host fns and declaration files.
- Wrapping host state in rut classes: native containers API surface.
- Async bodies and the
Completer: the host futures bridge. - Budgets and traps: resource limits.
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:
| rule | content |
|---|---|
| slot width | 8 bytes; primitive fields sit inline in their slot |
| composite fields | a cell-handle slot — a reference to the shared cell (the Rc heap) |
| declaration order | fields keep declaration order; no hidden members; visibility and out-of-body impl blocks change nothing |
| heap | non-moving — a slot’s handle stays valid for the cell’s life |
| where it lives | the 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 structin 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
opaquehandle; 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
opaquehandles; 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
&mutC 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 shape | what 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 struct | the 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:
| guarantee | enforced by |
|---|---|
| every value a host receives has the declared type | the boundary decode (value boundary) |
| surfaces and bindings agree | the boot join (host fns) |
| a consumer and a published package agree | the decl digest at link (module binary and verification) |
| registers hold their declared types | the verifier (typed bytecode) |
Practical recipes
| need | shape |
|---|---|
| pass numeric payloads cheaply | cross the fields as primitives, or pack into bytes |
| share mutable native state | opaque box + wrapper class |
| read rut records from the host | reflection descriptors, or a host struct mirror |
| fixed binary records | bytes + b.len()/decode helpers (primitive types) |
Host fns and declaration files
One linkage keyword, one implementer:
| keyword | implementation lives in | bound at link against |
|---|---|---|
host fn / host struct | the embedding Rust — a typed registration | the load-time contract (below) |
builtin fn / builtin class / builtin trait / builtin impl | the engine itself — compiler-lowered | nothing; 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:
- declared in the manifest’s
[deps](project structure, dependency kinds); resolution is recursive, first mount wins; - mounted programmatically:
rut_driver::mount_dir(&mut session, dir)(embedding and native modules).
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 fnsignatures are concrete over: nil, the primitives,str,bytes,opaque, tuples/?Twhose elements cross, andhost structrecords 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). anyis not in the language. It is a reserved word; a.d.rutspelling it is the reserved-word diagnostic at parse time. Seal polymorphic values withopaque(v)/opaque.downcast<T>(v)(opaque).builtinis the engine’s reservation — spelled only in the toolchain’s own decl files (core,calc). Abuiltinin an embedder decl is a compile error. Users implement builtin traits with ordinaryimplblocks; library contracts stay plaintrait(traits and dispatch).builtinis a contextual keyword:.d.rut-only; elsewhere it is a legal identifier.opaquewraps 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 RustDrop(the Rc heap).- Workers: an
opaquebox crosses isolates only if the host registered the boxed type assend— 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.
| allowed | notes |
|---|---|
use / pub | visibility exactly as in a module; non-exported decls are known inside the file, nameable nowhere else |
let | with load-time constant initializers |
enum | the member list is the whole definition |
trait | method signatures (+ requires) are the whole definition |
struct | fields only, with load-time initializers |
host fn / host struct | signatures only, concrete over the crossing set |
builtin fn / class / trait / impl | toolchain 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 asErr(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:
- declared but unbound — a rut call would trap mid-run;
- bound but undeclared — no surface declares what the host installed;
- 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 row | meaning |
|---|---|
<name> | the decl row itself; calling it directly traps — the weave mints the future at the call site |
<name>__start(P…) -> opaque | runs the closure, boxes the Completer as the future’s state cell |
<name>__yield(state, cx) -> i32 | resumption probe: 0 pending, 1 ready, 2 failed |
<name>__take(state) -> R | marshals 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> API | Meaning |
|---|---|
Completer::new() | a fresh cell; clone() shares it (one Arc) |
complete(v) / fail(msg) | settle from any thread (atomics + mutex only) |
poll() -> i32 | the 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:
| artifact | role |
|---|---|
mod.rutc | the compiled module binary with bodies (module binary and verification) |
mod.d.rut | the surface — hand-written, editable, publishable text |
mod.d.ir | the 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.iris 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 (
--releasedrops 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:
- source —
p’s rut source on the source path: compile it normally; - bundle — a mounted
.rutbundle: its bundled surface (regenerated from the bundled.d.rutwhen version-stale); explicit mounts outrank stray caches, never dev source; - cache —
p’s.d.irwith a matching compiler version: load directly, no parse; - decl —
p’s.d.rut: compile it (and write the.d.ircache); - host registry — for host pkgs, the
.d.rutthe 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
| checkpoint | when | checks | errors to |
|---|---|---|---|
| compile/verify | compile / verify | every call vs the declared host rows: concrete types over the crossing set. Checking needs no Rust. | rut author |
| link | boot | every 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
| API | Meaning |
|---|---|
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:
strkeys 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 intobytes. - Builtin answers flow back natively: a fn may return an optional
built host-side (
?str/?bytes/?opaqueanswer lanes); rut cannot tell it was not written in rut. - Memory: the instance lives in the box; at rc-0 the payload’s
finalizehook runs first, then RustDrop— dropping the container releases every stored handle deterministically, with no collector involvement (the Rc heap). AnOpaqueRef’s ownDropreleases its reference; dropping a stored handle is the release.
In-tree consumers
- the logger —
rt’s two host fns behindink’sLoggerwrapper class (core and the swappable packages). nmapset—HashMap/HashSetover a native key table: every method is one host call, keys are the closed typed set, values are sealed boxes, and each key carries a stable host-side handle (core and the swappable packages).json’s peer-gated container impls ride the same packages from the rut side (core and the swappable packages).
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 type | behavior |
|---|---|
str | the ordinary desugaring to concatenation — zero new cost on the hot path, output identical to the plain formatting path |
Template | the 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.
| API | Meaning |
|---|---|
t.str() -> str | render 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) -> u32 | argument 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
strpath andTemplate.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
Templatecrosses the host boundary, and crosses isolate channels like any builtin (workers and channels) — a worker may return one where the main VM expectsTemplate.
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 { }
| type | Reflectable | Deserializable | stringify | deserialize |
|---|---|---|---|---|
dataclass | compiler auto-impl | auto | opt-in (impl Serializable for T {}) | yes |
user enum | compiler auto-impl | auto | opt-in | yes |
Option/Result/Vec/[T] | builtin-impl registry, every instantiation | registry | as fields only | yes ([T; N] minting excepted — deserialize targets Vec<T>) |
class, no impl | — | — | compile error at the call | compile error at the call |
class, manual impl | hand-written (curated) | impossible | yes (positional view) | compile error |
- Auto-impls are ordinary vtable fills: a dataclass walks its fields
(arity = field count, child
i= fieldi, 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 satisfyrequiresedges of contract layers written on top —impl Serializable for User {}costs zero methods. Deserializableis auto-only: hand-writingimpl Deserializable for Tis 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:
| fn | meaning |
|---|---|
type_kind(t: opaque) -> i32 | TypeKind ordinal |
type_leaf(t: opaque) -> i32 | LeafKind ordinal |
type_name(t: opaque) -> str | the type’s name |
type_id(t: opaque) -> u32 | equals type_id<T>() for static T |
type_is_a(t: opaque, i: opaque) -> bool | the nested-node gate (descriptor walking) |
type_fields_len(t: opaque) -> i32 / type_field(t, i) -> opaque | Record: fields in declaration order |
type_variants_len(t: opaque) -> i32 / type_variant(t, i) -> opaque | Sum: variants in declaration order |
type_elem(t: opaque) -> opaque | Seq: the element descriptor of Vec<T> / [T] |
type_arity(t, a) -> i32 | dynamic re-entry: Seq length; Sum = current variant’s payload count |
type_child(t, a, i) -> ?opaque | dynamic re-entry: the i-th child, boxed |
type_variant_of(t, a) -> i32 | Sum: the current variant index |
type_construct(t, vals…) -> ?opaque | Record mint; re-checks every box — mismatch is nil, never a trap |
type_construct_variant(t, i, vals…) -> ?opaque | Sum mint |
type_make_vec(t, vals…) -> ?opaque | Seq → 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/Resultappear only as sum-shaped descriptors (Some/Noneis a{0,1}sum,Ok/Erra{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
opaqueof an int stores i64 sign/zero-extended; a float, f64. ALeafbranch +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/
i32results signal mismatch. There is no field setter: children alias their source cells, so v1 reflection is read-only.
Rules
- Engine admission: the structural symbols (
reflect<T>,type_of,TypeInfo,FieldInfo,SumVariant) resolve only in modules declaring at least oneimpl ReflectEngine for T; the violation is a compile error naming the fix. Callingstringify/deserializeneeds no engine — the argument type or the bound carries the contract. Theiskeyword is likewise ungated: it answers the capability bit, while descriptor walking stays behind admission — probing and walking are different powers. - Walkability = implements the protocol (auto, registry, or manual).
Entries may demand a contract (
Serializable) or a capability (an inlinerequires Deserializablebound on a generic — admission-only; it grants no method calls on bareT). Nested nodes are gated by the descriptoris_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
| signature | meaning |
|---|---|
assert(cond: bool, msg: str) -> nil | trap Assert when false; msg may be omitted at the call site |
panic(msg: str) -> nil | abort with the message |
on_drop<T>(p: ?T, cleanup: fn(?T)) -> nil | run cleanup(p) when p’s cell refcount reaches zero; one callback per pointer |
string_join(parts: [str]) -> str | join in one pass |
capture_stacktrace() -> StackTrace | opt-in stack snapshot: raw frames only, symbols resolved lazily per access |
str.from_code(n: u32) -> str | the 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
| class | members |
|---|---|
StackTrace | len() -> i32, name(i) -> str, line(i) -> i32, col(i) -> i32, render() -> str |
StrBuf | StrBuf(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
| trait | member | notes |
|---|---|---|
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 |
RunContext | checkpoint() -> u32, next_checkpoint(mut self, v: u32) -> nil, cancelled() -> bool | the 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
| removed | replacement |
|---|---|
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 |
char | str of one codepoint; s.code()/str.from_code(n) |
free downcast<T>(o) | opaque.downcast<T>(o) |
Array as a name | the [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
| package | kind | surface |
|---|---|---|
rt | host pkg | create_logger(name: str) -> opaque, logger_log(log: opaque, level: i32, msg: str) |
ink | inline rut pkg | the Logger class over rt |
pouch | inline rut pkg | the growable sequence Vec<T> |
nmap_host / nmapset | host pkg + inline rut pkg | the native key table; HashMap/HashSet |
json | inline rut pkg, zero host fns | encodeJson / decodeJson / decodeJsonBytes + traits |
strbuild | inline rut pkg, zero deps | the StringBuilder class |
calc | host pkg | the Math namespace |
async_engine / async_host | host pkg + inline rut pkg | the launcher rows; launch_future / sleep |
http_host / http | host pkg + rut pkg | the std HTTP lanes |
bench-cross | host pkg | the 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
| method | level 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:
| member | meaning |
|---|---|
new() / with_capacity(cap) / filled(v, n) | construction ([v; n] under the hood) |
push(v) / pop() -> T | pop 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() -> bytes | the 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?EncodeJsonErrorbecause a cyclic structure is expected, recoverable data, not a trap. - Direct decode: no intermediate document; a type’s
decodereads its expectations straight off the cursor. AVec<Row>decode mints exactly the program’s values, once. - Numbers:
i64accepts integer lexemes only (overflow/decimal lexeme =WrongType, never a silent wrap);f64parses 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 carryat/got/expected, encode details carryatand the lazily built$.rows[3].namepath. decodeJsonBytesis strict UTF-8 (InvalidUtf8), never lossy.- Ownership: json owns the traits; all container impls live in json,
gated by peer groups —
Vecrows activate whenpouchis anywhere in the consumer’s closure, the map/set rows whennmapsetis (dependency kinds). A consumer without the peers mounts json light. - The writer accumulates through
strbuild’sStringBuilder; the reader rides the corestr.scan/starts_withprimitives 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=!
| member | meaning |
|---|---|
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() -> i32 | codepoints so far, O(1) |
build() -> str | fresh 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_enginedeclares the engine rows (__launch,__abort,__sleep,__sleep_yield);async_hostrestores the typed surface:launch_future(f: Future<T>) -> LaunchedFutureHandle<T>,LaunchedFutureHandle.abort() -> bool,sleep(ms: u32) -> Future<nil>. Each embedder mounts the pair and installsrut_std::async_host::install_std_async; a session that mounts neither has no launcher (tasks, host futures). -
http_hostdeclares the transport rows (three async, five sync readbacks);httpwraps them inHttpClient/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);sendresolves at headers;body(cx)drains the wire;byte_stream()/next(cx)walk it in chunks. Status0is reserved for transport failure. The reqwest lane is native-only.
Mounting
mount_stdmountscore+calc;mount_std_asyncadds the async pair. Swappable packages mount by directory or bundle (module bundles); therutCLI mounts the tree packages a loose file names byuse(the rut CLI).inline = truepackages (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:
| invariant | mechanism | |
|---|---|---|
| C1 | Flat AST — nodes are records in one arena; children are NodeIds, never boxed pointers, built bottom-up. Drop/clone are flat Vec ops. | AST |
| C2 | No native recursion — an explicit frame stack handles structure; expressions use an iterative operator/operand engine. Host stack use is constant regardless of input. | parser |
| C3 | Depth budget, not stack exhaustion — nesting over the budget is a normal diagnostic, never a host crash. | budgets |
| C4 | Lookahead 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
Tokenum for literals, punctuation, and operators. Keywords are ordinaryIdents; the parser matches them by interner name. Reserved words are rejected by the lexer with arut does not have Xmessage (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 toi32, unsuffixed floats tof32. - 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 itsNodeIdto 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:
| prec | assoc | operators |
|---|---|---|
| 1 | right | = += -= *= /= %= &= |= ^= <<= >>= &&= ||= (target must be a path or index) |
| 2 | – | => lambda bodies — decided by the scan below, not by binding powers |
| 3 | left | || |
| 4 | left | && |
| 5 | left | == != |
| 6 | left | < > <= >= |
| 7 | left | | ^ |
| 8 | left | & |
| 9 | left | << >> |
| 10 | left | + - |
| 11 | left | * / % |
| 12 | left | as — numeric cast; the RHS is a type-naming position |
| 13 | right | unary - ! ~ |
| 14 | left | postfix: .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.
| decision | mechanism |
|---|---|
| item dispatch | peek 1 (use let enum struct class trait impl fn) |
| statement vs expression-statement | peek 1 (leading keyword) |
for-of vs C-style for | peek 4: for ( let Ident <of or => |
| instance vs class method | peek 4: fn Ident ( <mut? self? …> |
| struct literal vs path expression | peek 2: Ident { ⇒ literal (classes have no instance literal) |
when-arm body form | peek 1 after -> ({ ⇒ block arm, else expression arm) |
| postfix loop step | peek 1 (. ( [ ?) |
| assignment target | no 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-> Typemay 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)vsa < b > (c)). Scan from the<tracking angle depth; a>>/<<consumed where a closer/opener is expected counts as two, soVec<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 (
0xfflegitimately becomes255). - 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 setsindent_width(1–8, default 4) andmax_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
| stage | crate | in → out | notes |
|---|---|---|---|
| lex + parse | rut-lexer, rut-parser | source → AST + diags | flat arena, no recursion |
| collect | rut-lir/check/collect | AST → types, traits, impls | per-module symbol tables |
| resolve | rut-lir/check/resolve | paths → symbols | imports, Self, visibility, use-path routing |
| typecheck | rut-lir/check (Ctx) | expressions → TyIds | bidirectional inference, fused with body compilation |
| monomorphize | rut-lir/check/inst | generic calls → instantiations | a work queue; HIR contains no generic code |
| compile bodies | rut-lir/lir (FnCompiler) | instantiations → FuncCode | one function at a time |
| optimize | rut-lir/lir (peephole, sroa, …) | FuncCode → FuncCode | fixed pipeline, no flags |
| link + flatten | rut-core/link | modules → one Program | type-id rebase, duplicate-impl check |
| encode | rut-core/binary | Program → bytes | versioned, 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:
useimports 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).Selfbinds inside impls;pubvisibility is checked per Modules and visibility.- Struct-vs-class is decided here: literals are legal only for structs; classes construct through their class methods.
isexpressions resolve their right-hand side to a concrete type or a trait instantiation id;ison an erasure-typed receiver answers by the box (see opaque — erasure and downcast).host/externsurface 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 andstrcompare by value; every other cell type compares by identity; nullable?Toperands and tuple operands are a compile error (pattern-match instead — destructure the pair). A lint flags==between two obviously fresh composites. isfolding — 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 | T2bound 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,?Tover 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 Typeis 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
opaqueprimitive is reached only through the type-callopaque(v), andopaque.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 throughopaqueis JIT behavior and does not exist. - The slice tier parallels trait widening:
Array<T, N> | Vec<T> (concrete) > Slice<T> view (unsized).Nis 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:
- Folding — constant arithmetic,
type_id<T>(),Array<T, N>.len()→ the constantN(with const-index bounds checks folded against it), statically-decidedisprobes, dead branches. - 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.
- CSE / LICM over pure operations — type-id loads, downcast checks, field loads on immutable records.
- Peephole + SROA — the rewriters re-intern operand pools on register remap, so pool sharing stays consistent (see Typed bytecode).
- Pattern lowering — downcast chains become one type-id load plus a
jump table;
whenon enums lowers tobrtableover 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:
Opis a fixed 24 bytes — pinned by a compile-time assert, with a never-constructedOp::Padvariant 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. argcisu16: 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):
| family | ops | notes |
|---|---|---|
| moves | Mov, MovRef | ref move retains new, releases old |
| constants | Const (pool), ConstRaw (folded scalar bits) | |
| int arithmetic | AddI SubI MulI DivI ModI (+prim) | + - * / % trap on overflow / divide-by-zero |
| wrap arithmetic | WAddI WSubI WMulI, WrapShlI | the wrapping_*/wrapping_shl builtin methods, lowered inline |
| float arithmetic | AddF SubF MulF DivF ModF NegF | |
| int bitwise | AndI OrI XorI ShlI ShrI | shifts mask the count; overflow traps |
| compares | EqI…GeI (+prim), EqF…GeF | bools and codepoints ride the int slots |
| equality on refs | StrCmp (content), ArrayCmp (content), RefEq (cell identity) | the == law’s three arms |
| control | Jmp, Br, BrTable | BrTable arms in the labels pool |
| calls | Call, CallM, CallI, CallFn, CallNat, Ret | see below |
| records | NewCell, MakeRecord, GetF, SetF | MakeRecord allocates + initializes every field in one op; field operands bake the field’s Repr |
| ownership | Own (payload copy), OnDrop (cleanup at release-to-zero) | |
| nullables | MakeOpt | T -> ?T: box into a one-slot cell (shares, never copies) |
| weak refs | WeakNew, WeakUpgrade | Weak references |
| arrays | ArrNew (zeroed), ArrLit (fixed), ArrGet/ArrSet, ArrGetF/ArrSetF (fused field+index) | bounds trap; element repr baked in |
| enums | EnumNew | immortal singleton cell per member |
| type machine | TidOf, IsType, IsTrait, Unbox, Box | the two readbacks + their guards |
| closures | MakeClosure | { func, captures }, captures in the pool |
| panics | Panic, Assert | |
| conversion | Conv | i32(x) etc.; narrowing traps when the value does not fit |
| strings | StrCodeAt | one codepoint read as u32, bounds trap |
| budgets | LoopHead | fuel-check back-edge marker at loop heads |
Call ops:
| op | meaning |
|---|---|
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:
| native | spelling |
|---|---|
Str | per-type formatting — the f"..." desugaring |
Concat | s.concat(parts...) |
StrLen / ArrLen | s.len() (codepoints) / array and bytes length |
StrJoin | join an array of strings in one pass |
StrSlice / ArrSlice | slice(from, to) — O(1) views (String slicing and views) |
BytesClone | bytes.clone() — the one copy escape hatch |
CaptureTrace, TraceLen/Name/Line/Col/Render | the stack-trace surface (Diagnostics) |
StrScan, StrStartsWith | fused host-side scan/classify and prefix test |
StrBufNew/Push/PushCode/Len/Finish | the string builder’s engine rows |
StrFromCode | str.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:
- 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 constantN(Nis part of the type’s identity). Hence notypeidand noarrlenop. - Nothing polymorphic, nothing named. A trait-typed receiver gets
exactly one op (
CallI) through its vtable. Slice-view indexing andlenare ordinary vtable calls through the view’s builtin impl. The erasure primitive’s names lower to primitives — the type-callopaque(v)to theBoxop,opaque.downcast<T>(o)to prelude code (below) — and both are ambient: nousegates them (opaque — erasure and downcast). - 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?) andUnbox(the payload, guarded).
is and downcast lowering
x is Twith concreteT: oneTidOf+ an integer compare. On an erasure box,isanswers by the box — it misses for every payload type;downcastis the only see-through.x is Iwith traitI: oneIsTraitdescriptor scan — pure in(recv, want), so repeated probes CSE and invariant ones hoist.opaque.downcast<T>(o)(yielding?T):TidOf; branch on the compare againstT’s id;Unboxon 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 singleTidOf+BrTable. The compiler always guardsUnboxwith 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
awaitis a checkpoint state — one enum-member-style singleton per suspension point, stored in the hidden frame’s state field. - Resume dispatch is the existing
BrTableover that state. - Locals live across suspension as cell-backed frame fields
(
GetF/SetF). - The driven half is an ordinary
CallIthrough the future’syieldvtable row; suspension is a plainRet.
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:
0i64,1f64,2bool,4str,5type 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
u32must 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 at rest and at link
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
IsTraitprobe 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:
| check | rule |
|---|---|
| register range | every register operand < the function’s register-file size; NOREG legal only where an optional operand is defined |
| pool spans | every (off, argc) span lands inside its function’s argv / labels pool |
| type operands | every type id names a row of the type table |
| jump targets | every jmp/br/brtable target is in range |
| call arity | call argument counts match the callee’s declared signature |
| enum members | EnumNew member indices in range, target actually an enum |
| host thunks | bodyless 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_dropcleanups and frees (The Rc heap and destructors). - Fuel is accounted per op, with the fuller budget check amortized every
interrupt_everyops.
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 Vmand 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/__cancelrows the compiler weaves forhost async fndeclarations (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 carryLoopHeadmarkers so tight loops still park. - Heap budget is checked at every allocation; over-limit allocation
traps
OutOfMemorythe same parkable way. - Heap accounting is live:
heap_usage()reports current bytes,fuel_usedaccumulates 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’syieldrow: a fresh engine context over the frame edge, answeringDoneorParkedoff 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 intoreadyand 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
readyuntil 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:
| step | what happens |
|---|---|
| 1. mount | A 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 surfaces | Use 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 graph | Each 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 + flatten | Module-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 + boot | The 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):
| call | purpose |
|---|---|
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_cross | mount 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. TheVmitself 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
| tier | cost | when it exists |
|---|---|---|
StackTrace capture | explicit; 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 |
Trap | automatic at trap unwind | bugs, overflow, panic — the loud channel carries kind + message |
? / err propagation | zero cost — no auto-capture on propagation | always |
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:
- In-VM (the default). The loaded program carries its interner and position tables; every member call symbolicates from them.
- 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).
- 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
assert(cond, msg),panic(msg),on_drop(p, cleanup)— the core builtins that raise or clean up (The Rc heap and destructors).capture_stacktrace()and theStackTraceclass — ambient builtin names, nouserequired (core and the swappable packages).- The web runner surfaces the same data in its envelope:
"trap"beside"err"(Loading and the embed loop).
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
nameis 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.
| version | layout | status today |
|---|---|---|
| 1 | one module, sources only | loads (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 entry | loads; no longer packs |
| 4 | + each package’s entry.libs files beside its entry | the 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.tomlfirst, 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:
| check | rule on mismatch |
|---|---|
rut.toml present, format = "rutbundle" | refuse — not a rut bundle |
format_version known | refuse — unknown bundle layout |
| zip entry CRC-32 | refuse — corrupt bundle (names the entry) |
| entry name / UTF-8 / STORE method | refuse — corrupt or unsupported |
entry.lib present; includes + declared groups/libs resolvable in the zip | refuse — load error naming the entry |
| a declared peer group missing from the archive | refuse — 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:
| key | meaning |
|---|---|
name | the package’s use-path name: bare [a-zA-Z0-9_]+ only. A scoped or quoted spelling is a manifest error. |
entry.lib | the body: one .rut file (or .rutc-style artifacts where supported) |
entry.libs | ordered extra .rut files — the multi-lib entry (below) |
entry.type | the 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.ir | a 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_scope | the host-fn registration prefix when it must differ from the package name (rt keeps its historical rt:log scope) |
inline | true 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_version | bundle 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
nameis 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
namedisagrees 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. coreneeds no[deps]— the driver mounts it unconditionally; every name still requiresuse 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:
| package | shape |
|---|---|
core | the only standard package — the builtin surface, mounted unconditionally |
calc | host pkg: math surface (mount_calc) |
rt, http_host, nmap_host, async_engine, bench_cross | host pkgs — pure .d.rut surfaces; bodies live in rut-std |
ink, http, strbuild, async_host | inline rut wrappers over host rows (inline = true) |
pouch | the sequence library (plain linked package) |
json | the base pkg with [peer-deps]/[dev-deps] — the reference consumer of Dependency kinds |
nmapset | native-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. Withoptional = 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 asname; 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), andlib(string; the peer-gated integration file). Anything else is the strict-manifest error.[deps]keeps string-valued descriptors and rejectsoptional— it has no options.optionaldefaults tofalse— required by default. The zero-dep spelling{ path = ".." }is valid in all three tables.- A
libending in.d.rutis a load error: the integration must be a.rutsource — 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 —pouchappears 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
| table | who supplies it | transitive? | missing behavior |
|---|---|---|---|
[deps] | the declarer’s own graph | yes — walked recursively | mount error |
[peer-deps] (required, the default) | the consumer’s closure | never pulled | loud resolution error at mount |
[peer-deps] with optional = true | the consumer’s closure, if anywhere | never pulled | inert; referencing the integration is the dedicated missing-peer diagnostic |
[dev-deps] | the pkg’s own self-build | root only — never walked for a dep | n/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.
- 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). - 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). - 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 itsModule.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.
- 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
| case | behavior |
|---|---|
| required peer absent from the consumer’s closure | D1 — loud at mount: names the package, the peer, and the fix. Not silent, not auto-pulled. |
| optional peer absent, integration never touched | nothing — silent success; that is the feature |
| optional peer absent, integration referenced | D2 — the dedicated missing-peer diagnostic; never a bare unresolved name |
| peer present (any reason) | the integration mounts automatically — presence-based resolution |
| self-build / dev mode | dev-deps guarantee presence; no missing case exists |
a [peer-deps] path that does not resolve | D3 — 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 Typein 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:
| input | pipeline |
|---|---|
file.rut | one 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.rutbundle | the packed form of the same contract (module bundles) |
Flags and defaults:
| item | behavior |
|---|---|
--fuel N | cap the op budget per turn. Without the flag the run is uncapped; an unparsable value falls back to 10_000_000 |
| heap limit | fixed at 64 MiB |
| interrupt check | every 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:
| installer | purpose |
|---|---|
install_std_math | calc’s float fns |
install_std_log (sink: stdout) | the logger; silent no-op unless the program uses ink |
install_std_nmap | the native key table behind nmapset |
install_std_bench_cross | the crossing-benchmark rows |
install_std_async | the async launchers |
install_std_http | the 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
.rutfile is collected (sorted) and formatted; - style comes from the nearest ancestor
rut.toml’s[style]block; no manifest → defaults (project structure); .d.rutfiles 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; --checkwrites nothing: it printsunformatted: <path>for each would-change file and exits nonzero, orfmt: N file(s) formattedwhen 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:
| file | mode | run | fmt | dump |
|---|---|---|---|---|
*.rut | implementation | runs main | formats impls | AST + IR of the module |
*.d.rut | declaration | refused (exit 2) | formats the surface | the surface’s AST + IR |
Exit codes
| code | meaning |
|---|---|
0 | success |
1 | compile 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 |
2 | usage 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 andawaitstays cold-poll inline (core and the swappable packages). - The HTTP lane is native-only: the CLI build carries it, wasm builds do not.
runon 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).