Canonical idiom reference. ION_SPEC.md §12 indexes this file; keep long examples here, not in the spec. Every ion block should match a pattern in tests/ or examples/ (linked inline). When semantics change, update this file and tests together.
Copy patterns from here or from linked test files. Do not add constructs not shown in ION_SPEC.md or this repo.
Primitives: int, bool, f32, f64, i8..i64, u8..u64, *T.
Collections: Vec<T>, String, Box<T>, [T; N], []T (slice).
Channels: Sender<T>, Receiver<T> from channel<T>() / channel<T>(cap); clone_sender; send -> SendResult<T>; recv -> Option<T>.
Function types and capture-free fn literals: let f: fn(int) -> int = add; and let g: fn(int) -> int = fn(x: int) -> int { return x + 1; };. Generic enum literals at fn-pointer call sites keep the instantiated C type (f(Result::Ok(7)) is Result_int_int, not a bare Result).
let p: Point = Point { x: 1, y: 2 };
Tuple: Option::Some(42), Option::None. take(Option::None) infers T from the parameter type (tests/test_option_none_call_arg.ion); send(&tx, Option::None) infers from Sender<T> (tests/test_send_option_none.ion); Box::new(Option::None) infers from an expected Box<Option<...>> (tests/test_box_new_option_none.ion); [Option::None] and (Option::None, 1) infer from an adjacent array or tuple type (tests/test_array_option_none.ion, tests/test_tuple_option_none.ion); enum Hold { H(Option<int>) } with Hold::H(Option::None) and Hold::H(Option::Some(n)) emits Option_int (tests/test_enum_option_payload_none.ion, tests/test_enum_option_payload_some.ion); unannotated let empty = Option::None still needs an annotation.
Struct: Status::Ok { value: 10 }.
Match:
match opt {
Option::Some(v) => { return v; }
Option::None => { return 0; }
};
Guard: Option::Some(v) if v > 0 => { ... }
Nested constructors: Outer::Wrap(Inner::A), Outer::Wrap { inner: Inner::A }. Every covering arm that nests constructors must exhaust the inner enum.
Result<T, E> from stdlib/result.ion accepts a user-defined enum as E (tests/test_result_custom_enum.ion):
import "stdlib/result.ion" as result;
enum MyError {
Bad;
}
fn f1(x: int) -> Result<int, MyError> {
if x < 0 {
return Result::Err(MyError::Bad);
}
return Result::Ok(x);
}
Postfix ? is match-plus-return sugar on owned Option<T> / Result<T, E> (same E; no From) and on another owned enum with one success variant when the function returns that same enum. ReadResult { Ok(String); Err(int); } qualifies. SetResult and a bare error enum do not. Not legal on a reference or inside spawn (tests/test_try_result_ok.ion, tests/test_try_readresult.ion):
fn wrap(r: Result<int, int>) -> Result<int, int> {
let x = r?;
return Result::Ok(x + 1);
}
let mut v: Vec<int> = Vec::new();
v.push(1);
let n: int = v.len();
let mut s: String = String::new();
s.push_str("hi");
let lit: String = "hello";
fn owned(s: String) -> int { return s.len(); }
// owned("hello") is valid; literals coerce to owned String at call sites too.
Vec::get / Vec::pop return Option<T> and move the element out. For read-only scans use Vec::get_ref(&v, i) which returns Option<&T> (the place must be a local; an owned temporary is ReferenceEscape; cannot return or store). In match Option::Some(x), x is &T: copy types bind by value; structs and enums with non-copy fields bind as a pointer. Inner match x on &Enum dispatches variants without deref. Matching &UserEnum<Concrete> substitutes type parameters into arm bindings the same way as an owned match (tests/test_match_ref_generic_enum_arith.ion). Vec::set(&mut v, index, value) returns SetResult (Ok / OutOfBounds); declare the enum. Method syntax (v.push(x), v.get_ref(i)) desugars to Vec:: builtins with correct receiver borrows. Qualified Vec::get_ref(&place, i) loans that same place (the binding or the field path). A disjoint field stays legal in both forms.
String::get(&s, i) returns Option<u8> without panicking (negative/OOB are None). Prefer it over s[i] when the index may be invalid. There is no String::get_ref / escaping as_str; byte peeks stay by-value. String is well-formed UTF-8; String::from_utf8(v) consumes Vec<u8> and returns Option<String>. push_byte accepts ASCII only (0x00..=0x7F). Copy a String with push_str, not a push_byte loop over for bytes. An owned String passed to push_str stays with the caller.
Slice::len(&s) returns the element count as int (s.len() desugars; &[T; N] coerces). Field access s.len is invalid. Slice::get_ref(&s, i) mirrors Vec::get_ref for &[]T (and arrays via coercion): local Option<&T>, shared borrow of the reference argument's place while live (the binding or the field path; a disjoint field stays legal; arr[i] = ... and an assign of that place are rejected), None on OOB/negative. See tests/test_slice_len.ion, tests/test_slice_get_ref_scan.ion, tests/test_slice_get_ref_mut_error.ion, and tests/test_string_get.ion.
struct VM { ip: int; stack: Vec<int>; }
fn step(vm: &mut VM) {
vm.stack.push(1);
vm.ip += 1;
}
Field paths are valid assignment targets on owned structs and &mut parameters. There is no unary * and no assign-through a bound scalar &mut int (or other primitive reference): mutate via a field path on &mut Struct, a callee that takes &mut T, or a direct write to the owner while no conflicting borrow is live. See tests/test_field_assign_plus.ion and tests/test_slice_get_ref_mut_error.ion.
struct Packet {
id: i32;
data: Vec<u8>;
}
fn make_packet(data: Vec<u8>) -> Packet {
Packet { id: 1, data: data }
}
fn main() -> int {
let buf: Vec<u8> = Vec::new();
let pkt = make_packet(buf);
// buf is invalid after the move; pkt owns the data
return 0;
}
See tests/test_move_basic.ion.
When another language would return &T, return an index (int) or handle and re-index locally. Read-only scans use Vec::get_ref (does not hollow the vector). When slots in a growable table can be reused, prefer import "stdlib/handle.ion" as handle; (Handle is index plus generation) over a raw int. Peek stays in the caller: arena.get_ref(h), or arena.slots.get_ref(h.index) then match Slot::Occupied. See tests/test_arena_get_ref.ion, tests/test_arena_get_ref_field.ion, tests/test_vec_search_index_ok.ion, tests/test_vec_get_ref_scan.ion, tests/test_handle_arena_basic.ion, and examples/handle_table/.
enum Option<T> {
Some(T);
None;
}
struct Customer {
id: int;
active: int;
}
fn find_customer_index(customers: &Vec<Customer>, target_id: int) -> int {
let mut i: int = 0;
let len: int = Vec::len(customers);
while i < len {
match Vec::get_ref(customers, i) {
Option::Some(c) => {
if c.id == target_id {
return i;
}
}
Option::None => {
return -1;
}
};
i = i + 1;
}
return -1;
}
fn main() -> int {
let mut customers: Vec<Customer> = Vec::new();
let idx: int = find_customer_index(&customers, 42);
if idx >= 0 {
match Vec::get(&customers, idx) {
Option::Some(c) => {
let id: int = c.id;
Vec::set(
&mut customers,
idx,
Customer {
id: id,
active: 1,
},
);
}
Option::None => {}
};
}
return 0;
}
When the table reuses slots, store a Handle and peek locally. Annotate let mut arena: Arena<int> = handle::new();. Use handle::copy(&h) before a by-value contains / remove / peek. Prefer arena.get_ref(h) (tests/test_arena_get_ref.ion). Through &World / &mut World, world.entities.get_ref(h) and Arena::get_ref(&world.entities, h) loan that field (tests/test_arena_get_ref_field.ion). On an owned World, pass &mut world.entities. The slots.get_ref form remains valid (tests/test_handle_arena_get_ref.ion).
import "stdlib/handle.ion" as handle;
enum Option<T> {
Some(T);
None;
}
fn peek_hp(arena: &Arena<int>, h: Handle) -> int {
let mut found: int = -1;
match arena.get_ref(h) {
Option::Some(v) => {
found = v;
}
Option::None => {}
}
return found;
}
That peek binds v because int is Copy. For T with owned fields, match value: _ and use remove for the owned value.
Vec::get moves the element out. Copy fields, rebuild the struct, and Vec::set it back (tests/test_vec_get_putback.ion). Vec::set returns SetResult (Ok / OutOfBounds); declare the enum. A named local (let row = Todo { ... }; Vec::set(..., row)) is valid as the last match-arm statement (tests/test_vec_get_putback_named.ion). Helpers can take &mut T on an owned local:
fn mark_active(c: &mut Customer) {
c.active = 1;
}
Read fields through &Struct parameters. Ion does not allow reference fields in structs. Non-copy fields (for example Vec<T>) through &Struct are already &T and pass to &T parameters without an extra & (tests/test_ref_struct_field_to_ref_vec_param.ion).
struct Customer {
id: int;
score: int;
}
fn compare(a: &Customer, b: &Customer) -> int {
if a.score != b.score {
return a.score - b.score;
}
return a.id - b.id;
}
Move owned values into spawn and channels; no shared mutable state across threads. See examples/spawn_channel/spawn_channel.ion and examples/channel_worker/channel_worker.ion.
enum Option<T> {
Some(T);
None;
}
enum SendResult<T> {
Sent;
Closed(T);
}
struct Job {
data: Vec<u8>;
}
fn worker(rx: Receiver<Job>) {
let mut rx_mut: Receiver<Job> = rx;
loop {
match recv(&mut rx_mut) {
Option::Some(job) => {
// use job.data
}
Option::None => {
break;
}
}
}
}
fn main() -> int {
let (tx, rx): (Sender<Job>, Receiver<Job>) = channel<Job>();
spawn {
worker(rx);
};
let job = Job { data: Vec::new() };
send(&tx, job);
return 0;
}
At module exports and public functions, prefer owned results (Vec<T>, String, structs with owned fields) over borrowed views. Use &T, &str, and local Option<&T> from Vec::get_ref / Slice::get_ref inside a single function only.
See tests/test_vm_execute.ion: match vm.code.get_ref(vm.ip) then inner match op on &Op, field updates, and break inside match within loop.
let b: Box<int> = Box::new(42);
let x: int = Box::unwrap(b);
Boxing a struct literal as a let initializer is supported (tests/test_box_new_struct_let_annotated.ion, tests/test_box_new_struct_let.ion). Unannotated unwrap of Box<Struct> is valid (tests/test_box_unwrap_struct_let.ion):
let boxed = Box::new(Node { a: 1, b: 2, c: 3, d: 4 });
let n = Box::unwrap(boxed);
Unannotated enum literals type as the enum, not default int (tests/test_enum_unannotated_let.ion, tests/test_enum_generic_unannotated_let.ion):
let flag = Flag::On;
let x = Option::Some(42);
Recursive owned types need indirection (Box, Vec, or a raw pointer). Bare nests (next: Node, next: Option<Node>) are infinite size. Linked lists use Option<Box<Node>> (tests/test_recursive_struct_box.ion); declare a local enum Option<T> when constructing Option::None / Option::Some (same pattern as tests/test_enum_generic.ion).
enum Option<T> {
Some(T);
None;
}
struct Node {
value: int;
next: Option<Box<Node>>;
}
Fixed arrays [T; N] and slices []T / &[]T support bounds-checked indexing (panic on OOB). Query length with Slice::len (s.len()); field access s.len is not valid. For non-panicking element access use Slice::get_ref (Option<&T>, local only), including after &[T; N] -> &[]T coercion. Nested arrays, Box<[T; N]>, and Vec<[T; N]> compile (tests/test_nested_array.ion, tests/test_box_array.ion, tests/test_vec_array.ion). See tests/test_slice_len.ion and tests/test_slice_get_ref_from_array.ion.
let arr: [int; 3] = [1, 2, 3];
let s: &[]int = &arr;
match Slice::get_ref(s, 1) {
Option::Some(x) => { /* x is int (copy) or &T for non-copy */ }
Option::None => {}
};
let fill: [u8; 4] = [0; 4];
let elem: int = arr[0]; // bounds checked unless unsafe
fn read_len(v: &Vec<int>) -> int {
return v.len();
}
let a = &mut s.x; let b = &mut s.y is allowed. s.a.b conflicts with s.a.c and with s.a. A lasting borrow stays live until the last use of every binding that holds it. A copy, field or index reborrow, tuple, match result, assignment, enum value, or struct literal that stores the reference carries that loan. A reference created inside that value (Hold { v: &mut s }, Option::Some(&mut s), (&mut s.x, 1), or a match arm that yields &mut s.x) is the same loan. A match arm that stores the reference in a local and then yields the local keeps that loan on the match result. An enum, struct, or array that holds it keeps the loan until that binding leaves scope. A nested block does not end an outer loan. Assigning a reference ends that binding's previous loan. The new owner must enclose the binding. A narrower owner is ReferenceEscape when the reference is used again outside that block or the assignment is inside a loop (test_ref_assign_shorter_error.ion, test_ref_reassign.ion). A match arm cannot yield & of a local declared in that arm. Copying a get_ref binding keeps that loan. A carrier used only in one if arm is not live in the other arm. After if, match, select, or a loop, a binding holds every loan a reachable path still held. A literal index is its own path, so &mut a[0] and &mut a[1] do not conflict. A non-literal index borrows the whole owner. Match on &Struct or &mut Struct reborrows fields. Copy fields bind as the value. Other fields bind as &T or &mut T. The arm does not move or drop the referent. A reference stored in an enum or struct binds as that reference. A field that is already a reference is passed as that pointer.
defer expr; // LIFO; runs before that block's remaining locals
break and continue still run those defers and drops, through and including the loop body. See tests/test_defer_basic.ion and tests/test_defer_before_break.ion.
sum += v desugars to sum = sum + v for supported + types.
let b: u8 = (48 + digit) as u8;
math.ion (library):
pub fn add(a: int, b: int) -> int {
return a + b;
}
main.ion:
import "math.ion" as math;
fn main() -> int {
return math::add(1, 2);
}
Non-pub items are file-private. Qualified names use alias::item.
Multi-file build (ion-build with mode = "multi" in ion.toml):
cd examples\data_lib
..\..\target\release\ion-build.exe buildManual codegen only (emits .c/.h in cwd):
./target/release/ion-compiler --mode multi --output app path/to/main.ionMulti-file mode prefixes each module's C symbols (io_print_int, fmt_print_int) so stdlib modules can link together. The compiler walks up from the current directory to find runtime/. Spawn captures move by value even for int; use a helper function if the same constant is needed after spawn (see examples/channel_worker/channel_worker.ion).
send(&tx, make()) is valid (tests/test_channel_send_call_expr.ion). send(&tx, Option::None) infers T from Sender<T> (tests/test_send_option_none.ion). send returns SendResult<T>; a statement send(&tx, v); still drops Closed(T). recv(&mut rx) returns Option<T> (see examples/spawn_channel/spawn_channel.ion). clone_sender(&tx) is MPSC (tests/test_channel_contention.ion).
A move in an if branch that always returns does not block use after the if. Two fall-through branches must agree on whether a binding is still valid.
A non-copy binding moved on a path that can reenter the loop (continue or body fall-through) is an error. Move then break or return is allowed; after break, the binding is moved for later code. Exit paths that disagree do not error at the loop. Each exit that still owns the value drops it there, and the binding is moved after the loop, so a later use is UseAfterMove. A while whose body falls through is still a reentry edge.
extern "C" {
fn write(fd: int, buf: *u8, count: int) -> int;
}
fn main() -> int {
unsafe {
let _n: int = write(1, "ok\n", 3);
}
return 0;
}
Do not write these expecting them to work:
// Box of a reference
let b: Box<&int> = Box::new(&x);
// return reference
fn bad() -> &int { ... }
// reference in struct
struct S { r: ∫ }
// channel of references
channel<&int>()
// spawn captures reference
spawn { use_ref(&x); };
// use after move
let v: Vec<int> = Vec::new();
let w = v;
let _ = v.len();
See tests/test_ref_return_error2.ion, tests/test_spawn_ref_error.ion, and ION_SPEC.md §9.4.
When another language would return &T:
- Return an owned value or
Option<T>by move - Return an index or handle; caller re-indexes locally (see Index and handle search above)
- Call a helper with
&mut Ton an owned local afterVec::get/ beforeVec::set