Skip to content

Language Reference

Ion is an embeddable scripting language for Rust applications. It has Rust-flavored syntax with pattern matching, Result/Option types, immutable-by-default variables, and first-class JSON/dict support.


TypeLiteralExample
Int42, -1, 0let x = 42;
Float3.14, -0.5let pi = 3.14;
Booltrue, falselet ok = true;
String"hello"let s = "hello";
Unit()let u = ();
List[a, b, c]let xs = [1, 2, 3];
Tuple(a, b, c)let t = (1, "hi", true);
Dict#{key: val}let d = #{name: "ion"};
OptionSome(x), Nonelet opt = Some(42);
ResultOk(x), Err(e)let res = Ok(1);
Bytesb"hello", b"\x00\xff"let data = b"\x48\x49";
Functionfn, |x| xlet f = |x| x * 2;

All values support .to_string() for conversion to string. Use type_of(value) to inspect types at runtime:

type_of(42) // "int"
type_of("hello") // "string"
type_of(true) // "bool"
type_of([]) // "list"
type_of(#{}) // "dict"
type_of(()) // "unit"
type_of(3.14) // "float"

Variables are immutable by default. Use mut for mutable bindings.

let x = 10; // immutable
let mut y = 0; // mutable
y = 5; // OK
y += 1; // compound assignment
// x = 1; // ERROR: x is not mutable

+=, -=, *=, /=

let (a, b) = (1, 2);
let [x, y, z] = [10, 20, 30];

+, -, *, /, % (modulo)

==, !=, <, >, <=, >=

&& (and), || (or), ! (not)

& (and), | (or), ^ (xor), << (left shift), >> (right shift)

- (negate), ! (not)

5 |> double // equivalent to double(5)

Unwraps Ok/Some, propagates Err/None (see Error Handling).


if/else is an expression — it returns a value.

let result = if x > 0 { "positive" } else { "non-positive" };

Chained:

if x < 0 {
-1
} else if x == 0 {
0
} else {
1
}

Destructure and branch on Option/Result:

let opt = Some(10);
if let Some(v) = opt {
v + 1
} else {
0
}
let mut x = 0;
while x < 10 {
x += 1;
}
let mut items = Some(1);
while let Some(v) = items {
// process v
items = None;
}

Iterates over lists, tuples, dicts, strings, bytes, and ranges.

for x in [1, 2, 3] {
io::println(x);
}
for (key, val) in #{a: 1, b: 2} {
io::println(key, val);
}
for ch in "hello" {
io::println(ch);
}

Infinite loop. Use break to exit, optionally with a value.

let result = loop {
if done { break 42; }
};
for x in [1, 2, 3, 4, 5] {
if x == 3 { continue; }
if x == 5 { break; }
io::println(x);
}
fn find_first(items) {
for item in items {
if item > 10 { return item; }
}
return None;
}

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

The last expression is the return value (no semicolon needed).

fn greet(name = "world") {
f"hello {name}"
}
greet() // "hello world"
greet("ion") // "hello ion"

Call functions with arguments specified by name:

fn connect(host, port, timeout = 30) {
f"{host}:{port} (timeout={timeout})"
}
connect(port: 443, host: "example.com")
connect("localhost", timeout: 5, port: 8080)

Positional and named arguments can be mixed. Named arguments are resolved to parameter positions at call time.

Use *name to collect extra positional arguments into a list, and **name to collect extra keyword arguments into a dict:

fn summarize(first, *rest, **opts) {
#{first: first, rest: rest, opts: opts}
}
summarize(1, 2, 3, mode: "fast")
// #{first: 1, rest: [2, 3], opts: #{mode: "fast"}}

Call sites can spread a list into positional arguments with *expr and a dict into keyword arguments with **expr:

fn score(a, b, c) { a * 100 + b * 10 + c }
score(*[1, 2], c: 3) // 123

Method calls accept positional arguments and *expr list spreads. Keyword arguments are only for callable values, not methods.

Use / to mark earlier parameters positional-only, and a bare * to make later parameters keyword-only:

fn shaped(a, /, b, *, c) {
a + b + c
}
shaped(1, b: 2, c: 3)
let double = |x| x * 2;
let add = |a, b| a + b;

Lambdas capture variables from their enclosing scope:

let x = 10;
let add_x = |y| x + y;
add_x(5) // 15
fn make_adder(x) {
|y| x + y
}
let add5 = make_adder(5);
add5(10) // 15
fn fib(n) {
if n <= 1 { n } else { fib(n - 1) + fib(n - 2) }
}
fib(10) // 55

Tail calls are optimized (TCO) on the bytecode VM path.


match value {
pattern => result,
pattern => result,
}
// Literal patterns
match x {
0 => "zero",
1 => "one",
_ => "other", // wildcard
}
// Variable binding
match x {
n => n + 1, // binds n to x
}
// Option patterns
match opt {
Some(v) => v * 2,
None => 0,
}
// Result patterns
match res {
Ok(v) => v,
Err(e) => e,
}
// Tuple patterns
match (1, 2) {
(a, b) => a + b,
}
// List patterns
match items {
[] => "empty",
[a] => f"one: {a}",
[a, b, ...rest] => f"first: {a}", // rest pattern
}
match x {
n if n > 0 => "positive",
n if n < 0 => "negative",
_ => "zero",
}

let xs = [1, 2, 3];
xs[0] // 1 (0-indexed)
xs[-1] // 3 (negative indexing)
xs[1..3] // [2, 3] (slicing)
xs[..2] // [1, 2]
xs[1..] // [2, 3]

See List Methods for the full API.

let t = (1, "hello", true);
t.0 // 1 (field access by index)
t.1 // "hello"
let d = #{name: "ion", version: 1};
d.name // "ion" (field access)
d["name"] // "ion" (bracket access)
d.missing // None (missing key returns None)

String keys and shorthand identifier keys are equivalent:

#{name: 1} // same as #{"name": 1}

Computed keys use expressions:

let key = "x";
#{(key): 42} // #{"x": 42}

Spread operator:

let base = #{a: 1, b: 2};
let extended = #{...base, c: 3}; // #{a: 1, b: 2, c: 3}

See Dict Methods for the full API.

// List comprehension
[x * 2 for x in [1, 2, 3]] // [2, 4, 6]
// Filtered comprehension
[x for x in [1, 2, 3, 4, 5] if x > 3] // [4, 5]
// Dict comprehension
#{k: v * 10 for (k, v) in #{a: 1, b: 2}}

"hello world"
"line one\nline two" // escape sequences: \n, \t, \r, \\, \"
"\u{1F600}" // Unicode escape: 😀

Multi-line strings using triple quotes:

let text = """
This is a multi-line
string literal.
""";

A leading newline immediately after """ is stripped. Escape sequences work inside triple-quoted strings.

Triple-quoted f-strings: f"""value: {x}"""

let name = "ion";
f"hello {name}" // "hello ion"
f"2 + 2 = {2 + 2}" // "2 + 2 = 4"
f"result: {foo("bar")}" // nested quotes in interpolation
"hello"[0] // "h"
"hello"[4] // "o"
"hello"[-1] // "o" (negative indexing)
"hello" + " " + "world" // "hello world"
"ha" * 3 // "hahaha"
3 * "ab" // "ababab"

See String Methods for the full API.


Represents an optional value: Some(value) or None.

let opt = Some(42);
let empty = None;

Represents success or failure: Ok(value) or Err(error).

let ok = Ok(42);
let err = Err("something failed");

? unwraps Ok/Some or early-returns Err/None:

fn parse_and_double(s) {
let n = s.to_int()?; // propagates Err if parse fails
Ok(n * 2)
}
parse_and_double("5") // Ok(10)
parse_and_double("abc") // Err("invalid digit found in string")
fn first_item(items) {
let v = items.first()?; // propagates None if empty
Some(v + 1)
}

At the top level (outside a function), ? on Err/None returns the error/none as a value rather than crashing.

Catch runtime errors with try/catch blocks:

let result = try {
let x = 10 / 0;
x
} catch e {
f"caught: {e}"
};
// result is the error message string

try/catch is an expression — the last value of whichever branch executes is returned. Control flow signals (return, break, continue) pass through and are not caught.

// Nested try/catch
try {
try {
error_fn()
} catch inner {
f"inner: {inner}"
}
} catch outer {
f"outer: {outer}"
}

Both Option and Result support functional chaining:

Some(5).unwrap() // 5
Some(5).map(|x| x * 2) // Some(10)
None.unwrap_or(0) // 0
Ok(5).unwrap() // 5
Ok(5).map(|x| x + 1) // Ok(6)
Err("fail").unwrap_or(0) // 0

See Option Methods and Result Methods.


FunctionDescription
str(x)Convert to string
int(x)Convert to int (from float, string, bool)
float(x)Convert to float (from int, string)
type_of(x)Returns type name as string
FunctionDescription
len(x)Length of list, string, dict, or bytes
range(n)[0, 1, ..., n-1]
range(start, end)[start, start+1, ..., end-1]
enumerate(val)[(0, a), (1, b), ...] (list, string, or dict)
set(list)Create a set (deduplicates)
cell(value)Mutable reference cell for shared closure state
FunctionDescription
assert(cond)Error if cond is false
assert(cond, msg)Error with custom message if cond is false
assert_eq(a, b)Error if a != b
assert_eq(a, b, msg)Error with custom message if a != b
FunctionDescription
bytes()Empty bytes
bytes(list)Bytes from list of ints (0-255)
bytes(string)Bytes from UTF-8 string
bytes(n)Zero-filled bytes of length n
bytes_from_hex(string)Bytes from hex string
FunctionDescription
bytes::new()Empty bytes
bytes::zeroed(n)Zero-filled bytes of length n
bytes::repeat(byte, n)Bytes filled with a repeated byte
bytes::from_list(list)Bytes from list of ints (0-255)
bytes::from_str(string)Bytes from UTF-8 string
bytes::from_hex(string)Result containing bytes decoded from hex
bytes::from_base64(string)Result containing bytes decoded from Base64
bytes::concat(list)Concatenate a list of bytes
bytes::join(list, sep?)Join a list of bytes with an optional bytes separator
bytes::u16_le(n) / bytes::u16_be(n)Pack unsigned 16-bit integer
bytes::u32_le(n) / bytes::u32_be(n)Pack unsigned 32-bit integer
bytes::u64_le(n) / bytes::u64_be(n)Pack unsigned 64-bit integer that fits in int
bytes::i16_le(n) / bytes::i16_be(n)Pack signed 16-bit integer
bytes::i32_le(n) / bytes::i32_be(n)Pack signed 32-bit integer
bytes::i64_le(n) / bytes::i64_be(n)Pack signed 64-bit integer

All runtime values support .to_string(). The tables below list type-specific methods and repeat .to_string() where the stdlib manifest lists it explicitly.

MethodReturnsDescription
.len()IntByte length
.char_len()IntCharacter count (for Unicode)
.is_empty()BoolTrue if empty
.contains(sub)BoolContains substring (string or char code int)
.starts_with(pre)BoolStarts with prefix
.ends_with(suf)BoolEnds with suffix
.find(sub)Option(Int)Char index of first occurrence
.index(sub)Option(Int)Alias for .find(sub)
.count(sub)IntCount non-overlapping occurrences
.trim()StringStrip leading/trailing whitespace
.trim_start()StringStrip leading whitespace
.trim_end()StringStrip trailing whitespace
.to_upper()StringUppercase
.to_lower()StringLowercase
.split(delim)ListSplit by delimiter
.replace(from, to)StringReplace all occurrences
.chars()ListList of single-char strings
.pad_start(n, ch?)StringLeft-pad to width n (default space)
.pad_end(n, ch?)StringRight-pad to width n (default space)
.strip_prefix(s)StringRemove prefix if present
.strip_suffix(s)StringRemove suffix if present
.reverse()StringReversed string
.repeat(n)StringRepeat n times
.slice(start, end)StringSubstring by char index
.bytes()ListList of byte values (ints)
.to_int()ResultParse as integer
.to_float()ResultParse as float
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of elements
.is_empty()BoolTrue if empty
.first()OptionFirst element
.last()OptionLast element
.contains(val)BoolContains value
.push(val)ListNew list with value appended
.pop()(List, Option)New list and removed last element
.sort()ListSorted copy
.reverse()ListReversed copy
.flatten()ListFlatten one level of nesting
.join(sep)StringJoin elements with separator
.enumerate()List[(0, a), (1, b), ...]
.zip(other)List[(a1, b1), (a2, b2), ...]
.map(fn)ListApply function to each element
.filter(fn)ListKeep elements where fn returns true
.fold(init, fn)ValueReduce with accumulator
.flat_map(fn)ListMap then flatten results
.any(fn)BoolTrue if fn returns true for any element
.all(fn)BoolTrue if fn returns true for all elements
.index(val)Option(Int)Index of first occurrence
.count(val)IntNumber of occurrences
.slice(start, end?)ListSublist by index
.dedup()ListRemove consecutive duplicates
.unique()ListRemove all duplicates (preserves order)
.min()OptionMinimum element
.max()OptionMaximum element
.sum()Int/FloatSum of numeric elements
.window(n)ListSliding windows of size n
.chunk(n)ListSplit into chunks of size n
.reduce(fn)ValueReduce without an initial value
.sort_by(fn)ListSort with custom comparator (fn returns int: neg/0/pos)
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of elements
.contains(val)BoolContains value
.to_list()ListConvert to list
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of elements
.is_empty()BoolTrue if empty
.contains(val)BoolContains value
.add(val)SetNew set with value added
.remove(val)SetNew set with value removed
.union(other)SetUnion of two sets
.intersection(other)SetIntersection of two sets
.difference(other)SetDifference of two sets
.to_list()ListConvert to list
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of values in the range
.contains(n)BoolTrue if the integer is in range
.to_list()ListMaterialize the range as a list
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of entries
.is_empty()BoolTrue if empty
.keys()ListList of keys
.values()ListList of values
.entries()ListList of (key, value) tuples
.contains_key(key)BoolKey exists
.get(key)OptionGet value by key
.update(other)DictMerge other dict (overwrites existing keys)
.keys_of(val)ListKeys with the given value
.insert(key, val)DictNew dict with entry added
.remove(key)DictNew dict with entry removed
.merge(other)DictNew dict merging two dicts
.map(fn)DictApply fn(key, value) to each entry, keep keys
.filter(fn)DictKeep entries where fn(key, value) is truthy
.zip(other)DictMerge matching keys into tuples
.to_string()StringConvert to string
MethodReturnsDescription
.is_some()BoolTrue if Some
.is_none()BoolTrue if None
.unwrap()ValueExtract value, error if None
.unwrap_or(default)ValueUnwrap or return default
.expect(msg)ValueUnwrap or error with message
.map(fn)OptionApply fn to inner value
.and_then(fn)ValueFlat-map (fn should return Option)
.or_else(fn)ValueCall fn if None
.unwrap_or_else(fn)ValueUnwrap or call fn
.to_string()StringConvert to string
MethodReturnsDescription
.is_ok()BoolTrue if Ok
.is_err()BoolTrue if Err
.unwrap()ValueExtract value, error if Err
.unwrap_or(default)ValueUnwrap or return default
.expect(msg)ValueUnwrap or error with message
.map(fn)ResultApply fn to Ok value
.map_err(fn)ResultApply fn to Err value
.and_then(fn)ValueFlat-map on Ok
.or_else(fn)ValueFlat-map on Err
.unwrap_or_else(fn)ValueUnwrap or call fn with error
.to_string()StringConvert to string
MethodReturnsDescription
.len()IntNumber of bytes
.is_empty()BoolTrue if empty
.bytes()BytesIdentity byte representation
.contains(byte_or_bytes)BoolContains byte value or bytes sequence
.find(byte_or_bytes)Option(Int)Index of first occurrence
.count(byte_or_bytes)IntNumber of non-overlapping occurrences
.starts_with(prefix)BoolStarts with byte value or bytes prefix
.ends_with(suffix)BoolEnds with byte value or bytes suffix
.slice(start, end)BytesSub-slice
.split(sep)ListSplit by byte value or bytes separator
.replace(from, to)BytesReplace byte value or bytes sequence
.reverse()BytesReversed copy
.repeat(n)BytesRepeat n times
.push(byte)BytesNew bytes with byte appended
.extend(other)BytesNew bytes with other bytes appended
.set(index, byte)BytesNew bytes with byte replaced at index
.pop()(Bytes, Option(Int))New bytes and removed last byte
.to_list()ListList of int values
.to_str()ResultDecode as UTF-8
.to_hex()StringHex-encoded string
.to_base64()StringBase64-encoded string
.read_u16_le(offset) / .read_u16_be(offset)ResultRead unsigned 16-bit integer
.read_u32_le(offset) / .read_u32_be(offset)ResultRead unsigned 32-bit integer
.read_u64_le(offset) / .read_u64_be(offset)ResultRead unsigned 64-bit integer if it fits in int
.read_i16_le(offset) / .read_i16_be(offset)ResultRead signed 16-bit integer
.read_i32_le(offset) / .read_i32_be(offset)ResultRead signed 32-bit integer
.read_i64_le(offset) / .read_i64_be(offset)ResultRead signed 64-bit integer
.to_string()StringConvert to string

A Cell is a shared mutable reference cell, created with the top-level builtin cell(value). It enables shared mutable state across closures.

let c = cell(0);
c.get() // 0
c.set(42);
c.get() // 42
c.update(|v| v + 1);
c.get() // 43
MethodReturnsDescription
.get()ValueRead the current value
.set(v)UnitReplace the stored value with v
.update(fn)UnitApply fn to the current value and store the result
.to_string()StringConvert to string

Binary data type for raw byte manipulation.

let data = b"hello"; // bytes literal
let hex = b"\x48\x49"; // hex escape
let empty = bytes(); // empty bytes
let from_list = bytes([72, 73]); // from ints
let decoded = bytes::from_hex("deadbeef").unwrap();
let word = bytes::from_base64("aGVsbG8=").unwrap();
let packed = bytes::u32_be(305419896);
data[0] // 72 (int)
data[0..3] // b"hel" (slicing)
data.len() // 5
data.to_hex() // "68656c6c6f"
data.to_str() // Ok("hello")
packed.read_u16_be(0) // Ok(4660)

Native async host integration requires the async-runtime cargo feature.

Ion provides structured concurrency with async blocks, spawn, .await, select, timers, and channels. In the native async runtime, Ion code stays synchronous-looking: a script calls an async host function as a normal function, and the runtime parks the current bytecode continuation on the underlying Tokio future.

engine.register_async_fn("http_get", |args| async move {
let url = args[0].as_str().unwrap_or("").to_string();
// reqwest::get(url).await?.text().await, mapped into Value
Ok(Value::Str(url))
});
fn load_user(id) {
// No `await` at this call site. If `http_get` is a host async
// function, eval_async parks and resumes this Ion function.
json::decode(http_get(f"/users/{id}"))
}
let result = async {
let t1 = spawn compute(1);
let t2 = spawn compute(2);
t1.await + t2.await
};

All spawned tasks must complete before the async block returns. Spawned tasks are Ion tasks managed by the runtime, not OS threads.

let task = spawn expensive_work();
// ... do other things ...
let result = task.await;

spawn is only valid inside an async {} block. Awaiting a task parks the caller until the child completes. If a child fails before it is awaited, the nursery cancels sibling tasks and propagates the error.

MethodDescription
task.awaitWait for the task result
task.await_timeout(ms)Wait up to ms, returning Option
task.is_finished()Check whether the task has completed
task.cancel()Cancel the task
task.is_cancelled()Check whether the task was cancelled
task.to_string()Convert the task handle to a string
let (tx, rx) = channel(10); // buffered channel, capacity 10
tx.send(42);
let val = rx.recv(); // parks until value available
tx.close();
MethodDescription
tx.send(val)Send a value, parking if the bounded channel is full
tx.close()Close the sender
rx.recv()Receive, parking until a value arrives; returns None when closed
rx.try_recv()Non-blocking receive, returns Option
rx.recv_timeout(ms)Receive with timeout, returns Option
rx.close()Close the receiver
tx.to_string() / rx.to_string()Convert the channel endpoint to a string

Under async-runtime, channels are backed by Tokio channels and integrate with the same parking mechanism as async host functions.

Race multiple async operations:

select {
val = spawn http_get("/fast") => f"fast: {val}",
_ = spawn sleep(250) => "timeout",
}

The first completed branch wins. Losing branch tasks are cancelled and dropped.

sleep(50); // parks on a Tokio timer under eval_async
let maybe_body = timeout(250, || http_get("/slow"));
match maybe_body {
Some(body) => body,
None => "request timed out",
}

timeout(ms, fn) returns Some(value) if the callback completes before the timer, or None if the timer wins. Callback errors still propagate.

Host async functions can call back into Ion through EngineHandle:

let handle = engine.handle();
engine.register_async_fn("wait_for_event", move |_args| {
let handle = handle.clone();
async move {
let event = Value::Str("ready".into());
handle.call_async("on_event", vec![event]).await
}
});

The callback runs on the same local async runtime and may itself call async host functions.


Modules provide namespaced access to functions and values registered by the host application.

Use :: to access module members:

let result = math::add(1, 2);
let pi = math::PI;

Nested submodules:

let resp = net::http::get("https://example.com");

Import specific names into the current scope:

use math::add; // single import
use math::{add, PI}; // multiple imports
use math::*; // glob import (all names)

After importing, names can be used directly:

use math::add;
add(1, 2) // 3 (no need for math::add)

Use as to rebind an imported name to a different local name. Aliases work for single imports and inside braced lists; glob imports cannot be aliased.

use io::println as say; // single import with alias
use math::add as sum;
use math::{add as sum, PI}; // mixed: some aliased, some not
use math::{add as sum, PI as pi}; // all aliased

After aliasing, only the alias is in scope — the original name is not bound:

use math::add as sum;
sum(1, 2) // 3
add(1, 2) // error: undefined name 'add'
use net::http::get; // import from nested module
use net::http::get as fetch; // nested import with alias
use net::http::*; // glob import from nested module

The following modules are available by default in every Engine:

NameDescription
math::PIPi (3.14159…)
math::EEuler’s number (2.71828…)
math::TAUTau (2π)
math::INFPositive infinity
math::NANNot-a-number
math::abs(x)Absolute value
math::min(a, b, ...)Minimum
math::max(a, b, ...)Maximum
math::floor(x)Floor
math::ceil(x)Ceiling
math::round(x)Round to nearest
math::sqrt(x)Square root
math::pow(base, exp)Exponentiation
math::clamp(val, lo, hi)Clamp to range
math::sin(x)Sine
math::cos(x)Cosine
math::tan(x)Tangent
math::atan2(y, x)Two-argument arctangent
math::log(x)Natural logarithm
math::log2(x)Base-2 logarithm
math::log10(x)Base-10 logarithm
math::is_nan(x)Check if NaN
math::is_inf(x)Check if infinite
NameDescription
json::encode(value)Value to JSON string
json::decode(string)JSON string to value
json::pretty(value)Pretty-printed JSON string
json::msgpack_encode(value)Value to MessagePack bytes (requires the msgpack feature)
json::msgpack_decode(bytes)MessagePack bytes to value (requires the msgpack feature)

Random values, ranges, and collection sampling. Integer ranges are half-open: rand::int(10) returns a value in 0..10, and rand::int(5, 10) returns a value in 5..10.

NameDescription
rand::int()Random signed integer
rand::int(max)Random integer in 0..max
rand::int(min, max)Random integer in min..max
rand::float()Random float in 0.0..1.0
rand::float(max)Random float in 0.0..max
rand::float(min, max)Random float in min..max
rand::bool()Random boolean
rand::bool(probability)True with probability 0.0..=1.0
rand::bytes(n)Random bytes of length n
`rand::choice(liststring
`rand::shuffle(listbytes)`
`rand::sample(listbytes, n)`
NameDescription
io::print(args...)Print without newline
io::println(args...)Print with newline
io::eprintln(args...)Print to stderr with newline

Embedding hosts must install an OutputHandler on the engine before scripts can use io::print, io::println, or io::eprintln.

NameDescription
string::len(s)Alias for s.len()
string::char_len(s)Alias for s.char_len()
string::is_empty(s)Alias for s.is_empty()
string::contains(s, sub)Alias for s.contains(sub)
string::starts_with(s, pre)Alias for s.starts_with(pre)
string::ends_with(s, suf)Alias for s.ends_with(suf)
string::find(s, sub)Alias for s.find(sub)
string::index(s, sub)Alias for s.index(sub)
string::count(s, sub)Alias for s.count(sub)
string::trim(s)Alias for s.trim()
string::trim_start(s)Alias for s.trim_start()
string::trim_end(s)Alias for s.trim_end()
string::to_upper(s)Alias for s.to_upper()
string::to_lower(s)Alias for s.to_lower()
string::split(s, delim)Alias for s.split(delim)
string::replace(s, from, to)Alias for s.replace(from, to)
string::chars(s)Alias for s.chars()
string::pad_start(s, n, ch?)Alias for s.pad_start(n, ch?)
string::pad_end(s, n, ch?)Alias for s.pad_end(n, ch?)
string::strip_prefix(s, pre)Alias for s.strip_prefix(pre)
string::strip_suffix(s, suf)Alias for s.strip_suffix(suf)
string::reverse(s)Alias for s.reverse()
string::repeat(s, n)Alias for s.repeat(n)
string::slice(s, start, end?)Alias for s.slice(start, end?)
string::bytes(s)Alias for s.bytes()
string::to_int(s)Alias for s.to_int()
string::to_float(s)Alias for s.to_float()
string::to_string(x)Convert a value to a string
string::join(list, sep?)Join list elements into a string with optional separator

Semantic version parsing, comparison, and constraint matching. Versions cross the language boundary as dicts shaped #{major, minor, patch, pre, build} so scripts can inspect fields directly. Most functions accept either a string or a parsed dict — parse once for hot loops.

NameDescription
semver::parse(s)Parse a version string into #{major, minor, patch, pre, build}. Errors on invalid input.
semver::is_valid(s)true if the string parses as a valid semantic version.
semver::format(v)Render a version (string or dict) back to its canonical string form.
semver::compare(a, b)Three-way ordering: returns -1, 0, or 1.
semver::eq(a, b)true if a == b (including pre-release).
semver::gt(a, b) / gte / lt / lteBoolean comparators.
semver::satisfies(v, req)true if v matches the requirement string (e.g. ^1.0, ~1.2, >=1.0, <2.0).
semver::bump_major(v)Increment major; zero minor and patch; clear pre-release and build.
semver::bump_minor(v)Increment minor; zero patch; clear pre-release and build.
semver::bump_patch(v)Increment patch — or strip pre-release if present (1.2.3-alpha → 1.2.3).
use semver::*;
let v = parse("1.2.3-alpha.1+build.42")?;
v["major"] // 1
satisfies("1.5.0", "^1.0") // true
compare("1.2.3", "1.2.4") // -1
bump_major("1.2.3-alpha") // "2.0.0"

The module is enabled by default. Embedders can opt out by depending on ion-core with default-features = false.

OS / arch detection, environment variables, and process info. Pure-std, no extra dependencies. Detection values are module-level constants (read with os::name, no parens).

NameDescription
os::nameTarget OS — "linux", "macos", "windows", "freebsd", …
os::archTarget architecture — "x86_64", "aarch64", "arm", …
os::familyOS family — "unix" or "windows".
os::pointer_widthPointer width in bits (32 or 64).
os::dll_extensionDynamic-library extension without dot — "so", "dylib", "dll".
os::exe_extensionExecutable extension without dot — "" on Unix, "exe" on Windows.
os::env_var(name [, default])Read an env var. Errors if missing; the optional 2nd arg is returned instead.
os::has_env_var(name)true if the named env var is set.
os::env_vars()Snapshot of all env vars as a dict<string, string>.
os::cwd()Current working directory.
os::pid()Current process id.
os::args()Script arguments (host-injected via Engine::set_args; default []).
os::temp_dir()Platform temporary directory.
use os::*;
io::println(os::name, os::arch, os::family);
let home = env_var("HOME", "/");
let port_str = env_var("PORT", "8080");
if os::family == "unix" {
io::println("running on unix");
}
io::println("script args:", args());

os::args() reflects whatever the host passed to Engine::set_args. The ion CLI populates it with whatever follows the script path — ion script.ion alpha beta makes os::args() return ["alpha", "beta"].

The module is enabled by default. Embedders can opt out by depending on ion-core with default-features = false (e.g. to forbid scripts from reading environment variables).

Pure-string path manipulation. No I/O, no external dependencies, always available — these are composition primitives for working with paths before (or after) handing them to fs:: or the host. All operations work on strings and return strings; the platform separator is exposed as path::sep so scripts can stay portable.

NameDescription
path::sepPlatform path separator — / on Unix, \ on Windows.
path::join(a, b, ...)Variadic join using the platform separator.
path::parent(p)Directory containing p. Empty string if p has no parent.
path::basename(p)Final component of p.
path::stem(p)Basename of p with the extension stripped.
path::extension(p)Extension of p without the leading dot. Empty string if none.
path::with_extension(p, ext)Replace (or add) the extension on p.
path::is_absolute(p)true if p is absolute on the current platform.
path::is_relative(p)true if p is relative on the current platform.
path::components(p)Split p into its components.
path::normalize(p)Lexically normalise — collapse . and .. without touching the filesystem.
use path::*;
io::println(join("src", "lib", "main.ion")); // src/lib/main.ion (Unix)
io::println(extension("config.toml")); // toml
io::println(stem("config.toml")); // config
io::println(with_extension("notes.md", "txt"));// notes.txt
io::println(normalize("a/./b/../c")); // a/c

Filesystem I/O. The script-level surface (fs::read, fs::write, …) is identical regardless of build mode; only the underlying implementation differs. Ion is non-coloured: scripts call these like any other function.

  • In a sync build (default), each fs::* call goes through std::fs on the calling thread.
  • In an async build (async-runtime feature), each fs::* call goes through tokio::fs and cooperates with the executor instead of blocking.

The two modes are mutually exclusive at the cargo-feature level — pick one when you build ion-core. The same script runs unchanged in either.

NameDescription
fs::read(path)Read the file as UTF-8 text.
fs::read_bytes(path)Read the file as raw bytes.
fs::write(path, contents)Write contents (string or bytes) to path, replacing the existing file.
fs::append(path, contents)Append to (or create) path.
fs::append_random(path, count)Append count random bytes; returns bytes appended.
fs::pad_random(path, target_size)Grow a file to target_size with random bytes; returns bytes appended.
fs::exists(path)true if path exists.
fs::is_file(path)true if path is an existing regular file.
fs::is_dir(path)true if path is an existing directory.
fs::list_dir(path)List the entry names directly under path (non-recursive).
fs::create_dir(path)Create one directory; errors if a parent is missing.
fs::create_dir_all(path)Create the directory and any missing parents.
fs::remove_file(path)Delete the file at path.
fs::remove_dir(path)Delete the directory at path; errors if not empty.
fs::remove_dir_all(path)Recursively delete path and its contents.
fs::rename(from, to)Rename / move from to to.
fs::copy(from, to)Copy from to to; returns bytes copied.
fs::metadata(path)Returns #{size, is_file, is_dir, readonly, modified}.
fs::canonicalize(path)Resolve symlinks against the filesystem and normalise.
use fs::*;
use path::*;
let cfg_path = join(os::cwd(), "config.toml");
if exists(cfg_path) {
let raw = read(cfg_path);
io::println("loaded:", raw);
} else {
write(cfg_path, "key = \"value\"");
}
for name in list_dir(os::cwd()) {
if extension(name) == "ion" {
io::println(name);
}
}

The fs feature is enabled by default. Disable it with default-features = false for sandboxed embedders. Note that Engine::eval is removed under async-runtime — use Engine::eval_async in async builds. The two are deliberately mutually exclusive so non-coloured calls like fs::read resolve to one implementation per build.

use ion_core::module::Module;
let mut math = Module::new("math");
math.register_fn("add", |args| { /* ... */ });
math.set("PI", Value::Float(std::f64::consts::PI));
let mut engine = Engine::new();
engine.register_module(math);

With the async-runtime feature, modules can expose native async host functions. Ion code calls them normally, but the host must use eval_async:

let mut sensor = Module::new("sensor");
sensor.register_async_fn("call", |args| async move {
Ok(Value::Int(args.len() as i64))
});
let mut engine = Engine::new();
engine.register_module(sensor);
let value = engine.eval_async(r#"sensor::call("jobs.claim", #{})"#).await?;

Submodules:

let mut net = Module::new("net");
let mut http = Module::new("http");
http.register_fn("get", |args| { /* ... */ });
net.register_submodule(http);
engine.register_module(net);

Ion is designed to be embedded in Rust applications.

use ion_core::engine::Engine;
use ion_core::value::Value;
let mut engine = Engine::new();
// Evaluate a script
let result = engine.eval("2 + 2").unwrap();
assert_eq!(result, Value::Int(4));
// Inject values
engine.set("player_hp", Value::Int(100));
engine.eval("player_hp > 50").unwrap(); // Value::Bool(true)
// Read values back
let hp = engine.get("player_hp");
use ion_core::module::Module;
let mut math = Module::new("math");
math.register_fn("add", |args| {
let (a, b) = (args[0].as_int().unwrap(), args[1].as_int().unwrap());
Ok(Value::Int(a + b))
});
math.set("PI", Value::Float(std::f64::consts::PI));
engine.register_module(math);

Scripts can then use math::add(1, 2) or use math::*;.

engine.register_fn("roll_dice", |args| {
let sides = args[0].as_int().ok_or("expected int")?;
Ok(Value::Int(rand::random::<i64>() % sides + 1))
});
use ion_derive::IonType;
#[derive(IonType)]
struct Player {
name: String,
hp: i64,
alive: bool,
}
let mut engine = Engine::new();
engine.register_type::<Player>();
// Scripts can now construct and match on Player
engine.eval(r#"
let p = Player { name: "Alice", hp: 100, alive: true };
p.name
"#);
#[derive(IonType)]
enum Status {
Active,
Dead,
Poisoned(i64),
}
engine.register_type::<Status>();
engine.eval(r#"
match status {
Status::Active => "fine",
Status::Poisoned(dmg) => f"poisoned for {dmg}",
Status::Dead => "dead",
}
"#);
let player = Player { name: "Bob".into(), hp: 50, alive: true };
engine.set_typed("player", &player);
engine.eval("player.hp += 10;");
let updated: Player = engine.get_typed("player").unwrap();
use ion_core::interpreter::Limits;
engine.set_limits(Limits {
max_call_depth: 256,
max_loop_iters: 100_000,
});
// Use the bytecode VM for better performance (requires "vm" feature)
let result = engine.vm_eval("fib(30)").unwrap();

The VM automatically falls back to tree-walk interpretation for unsupported features.

FeatureDefaultDescription
vmYesBytecode VM path (vm_eval) with peephole optimization, constant folding, DCE, and TCO
deriveYes#[derive(IonType)] proc macro
async-runtimeNoNative Tokio async evaluation (eval_async, async host functions, timers, channels)
legacy-threaded-concurrencyNoLegacy sync-eval backend using OS threads and crossbeam channels

Documentation reflects Ion v0.2.0-66-g3faa376.