Ion is a systems programming language designed for:
- Ownership and borrowing with move-only semantics and no-escape references
- Memory safety without garbage collection
- Message-passing parallelism via typed, bounded channels and OS threads
- Simple syntax and predictable performance
- A minimal, C-oriented runtime and ABI
This document defines Ion semantics for the current compiler: ownership, types, control flow, generics, concurrency, and the C code generation backend. It is intended to be:
- Precise enough to guide an implementation (parser, type checker, and C backend)
- Strict about memory and concurrency safety
The following constraints are core to Ion’s identity:
- Move-only, single ownership
- No-escape borrowing (references are stack-local and lexical)
- Channels-only concurrency, no shared mutable state across threads
- No GC, explicit heap allocation via
Box<T> - C backend first, with C-compatible layout by default
Violating these constraints is a compile-time error, not undefined behavior.
Ion references have the forms &T (shared) and &mut T (exclusive). They are lexically scoped, stack-only views into owned data. The compiler enforces the no-escape rule:
A reference (
&Tor&mut T) may not outlive the lexical scope in which it is created.
Concretely, references MUST NOT:
- Be returned from functions
- Be stored in struct fields or enum variants
- Be captured by closures that may escape their defining scope
- Be moved into channels
- Cross thread boundaries (e.g., passed to
spawn, stored inSendvalues)
Any construct that would require reasoning about reference lifetimes beyond the current function body is rejected at compile time. This implies that APIs which conceptually return “references” (for example, Option<&T>) must instead be expressed in terms of owned values, indices/handles, or callback-style access.
- Single ownership: Every value has exactly one owner at a time.
- Move by default: Assignment, passing as arguments, and returning values moves ownership.
- Lexical borrows:
&Tand&mut Tborrows are restricted to the current function’s stack frame. - Channel transfers: Sending a value through a channel moves ownership into the channel.
- No self-referential types: Types cannot contain references to themselves or into their own fields.
The type checker tracks moves and borrows to prevent use-after-move and use-after-free.
- OS threads only:
spawn { ... }creates a native operating system thread. - Channels-only communication: Threads communicate through typed, bounded MPSC channels.
- No shared mutable state across threads: Values cross threads only by move, via channels or
spawnarguments. Sendproperty: OnlySendtypes may cross thread boundaries.
- Stack-first: Local variables live on the stack by default.
- Explicit heap:
Box<T>allocatesTon the heap, with deterministic destruction when the box’s owner goes out of scope. - No hidden allocations: All heap allocation is explicit in Ion code.
- Deterministic destruction: Values are destroyed at scope end;
deferensures deterministic cleanup actions.
Feature coverage is validated by the integration test suite. See tests/README.md.
An Ion source file is a sequence of Unicode code points encoded as UTF-8. Identifiers are restricted to ASCII letters, digits, and _ in this draft for simplicity.
Line terminators are \n (LF) or \r\n (CRLF). For the purposes of this specification, a line ends at a line terminator or at end-of-file.
Lexical analysis divides the input into a sequence of tokens:
- Identifiers
- Keywords
- Literals
- Operators and delimiters
- Comments
Whitespace and comments are ignored except as separators.
Identifiers name variables, types, functions, and modules.
- The first character must be
A–Z,a–z, or_. - Subsequent characters may be
A–Z,a–z,0–9, or_. - Identifiers are case-sensitive.
Examples: main, Packet, _tmp1, read_file.
The following keywords are reserved and cannot be used as identifiers:
fn, let, mut, struct, enum, type, if, else, while, for, loop, match, defer, return, break, continue, spawn, channel, send, recv, true, false, import, as, pub, extern, unsafe.
Built-in type names Box, Vec, and String are tokenized as keywords for generic syntax (Box<T>, etc.) but are not reserved as identifiers elsewhere.
Note: The as keyword is used both for module aliases (import "file.ion" as name;) and for type casting (expr as Type).
This list is intentionally small; future additions must justify their complexity.
The unsafe keyword marks code blocks where Ion's safety guarantees are suspended:
- Array and slice bounds checking is disabled for indexing within
unsafeblocks - Raw pointer dereferencing is only permitted within
unsafeblocks - FFI calls to
extern "C"functions must be wrapped inunsafeblocks
Code within unsafe blocks must manually uphold the following invariants:
- No out-of-bounds array or slice access: All indices must be valid
- No null pointer dereference: All pointer dereferences must be to valid memory
- No data races: Concurrent access to shared mutable state is prohibited
Violating these invariants results in undefined behavior.
The Ion standard library provides safe wrappers around unsafe FFI operations. User code should:
- Use standard library functions (e.g.,
io::print_str) instead of direct FFI calls - Only use
unsafeblocks for performance-critical code with proven correctness - Document all
unsafeblocks with safety justifications
Example: Safe I/O
// Safe wrapper (recommended)
import "stdlib/io.ion" as io;
fn main() -> int {
io::print_str("Hello, World!\n", 14);
return 0;
}
// Direct FFI (requires unsafe)
extern "C" {
fn write(fd: int, buf: *u8, count: int) -> int;
}
fn main() -> int {
unsafe {
let _result: int = write(1, "Hello, World!\n", 14);
}
return 0;
}
Ion supports:
- Integer literals (e.g.,
0,42,0xFF,0b1010) – implemented - String literals:
"..."with complete escape sequence support\r(carriage return),\n(newline),\t(tab),\0(null)\\(backslash),\"(double quote),\'(single quote)- Can be assigned to
Stringtype
- Floating-point literals (e.g.,
3.14,1e9,.5,3.) – implemented - Boolean literals:
true,false– implemented
The exact numeric literal grammar is given in the EBNF (Section 3.1).
Ion uses the following operators:
- Arithmetic:
+,-,*,/,% - Comparison:
==,!=,<,>,<=,>= - Logical:
&&,||,! - Bitwise:
&(AND),|(OR),^(XOR),<<(left shift),>>(right shift) - Assignment:
=,+=(compound assignment desugars tox = x + efor supported+types). Assignment targets may be locals, index expressions, or field paths on owned structs or&mut Structreceivers (for examplevm.ip += 1). - Type casting:
askeyword for explicit type conversions - Field access:
. - Address-of / borrow:
&(borrow shared) and&mut(borrow exclusive) - Channel I/O:
send(sender, value)andrecv(receiver)built-ins (see Section 7.2)
Delimiters:
- Parentheses:
(,) - Braces:
{,} - Brackets:
[,] - Comma:
, - Semicolon:
; - Colon:
: - Double colon:
:: - Arrow:
->
- Line comment:
//to end of line. - Block comments are not defined (may be added later).
- Documentation uses the same
//syntax; contiguous comment lines immediately above a declaration (no blank line between the last comment and the declaration) are attached to that item for IDE hover and future doc tools. See §12.1.
Whitespace (spaces, tabs, newlines) separates tokens but is otherwise insignificant, except in string literals.
This section gives a high-level grammar for Ion, using EBNF-like notation:
a | bmeans choice.x?means optional.x*means zero or more repetitions.x+means one or more repetitions.- Terminals are in quotes; non-terminals are in plain text.
This grammar is intentionally simplified and is not tied to a particular parsing strategy.
The grammar for identifiers and literals is sketched below. Real-world implementations may accept a superset for numeric literals.
letter = "A"…"Z" | "a"…"z" | "_" ;
digit = "0"…"9" ;
identifier = letter , { letter | digit } ;
int_lit = decimal_lit | hex_lit | bin_lit ;
decimal_lit = digit , { digit } ;
hex_lit = "0x" , hex_digit , { hex_digit } ;
bin_lit = "0b" , bin_digit , { bin_digit } ;
hex_digit = digit | "A"…"F" | "a"…"f" ;
bin_digit = "0" | "1" ;
float_lit = decimal_lit , "." , decimal_lit , [ exponent ]
| decimal_lit , exponent ;
exponent = ( "e" | "E" ) , [ "+" | "-" ] , decimal_lit ;
bool_lit = "true" | "false" ;
string_lit = "\"" , { string_char } , "\"" ;
string_char = /* any character except " and newline, with backslash escapes */ ;
/* Supported escapes: \r, \n, \t, \0, \\, \", \' */
line_comment = "//" , { comment_char } ;
comment_char = /* any character except newline */ ;Line comments are discarded during lexing and do not appear in generated C. Adjacent // lines above declarations are recovered from source during parsing and attached to AST nodes (see §12.1).
file = { import_decl | top_decl } ;
import_decl = "import" , string_lit , [ "as" , identifier ] , ";" ;
top_decl = struct_decl
| enum_decl
| type_alias
| fn_decl ;Import string literals name a module file. Resolution is tooling-defined (see §10.1): file-relative paths (./, ../), same-directory modules, stdlib search paths (stdlib/io.ion, io.ion), then project-root-relative paths. The import statement grammar is unchanged.
struct_decl = "struct" , identifier , struct_body ;
struct_body = "{" , { field_decl } , "}" ;
field_decl = identifier , ":" , type_expr , ";" ;
enum_decl = "enum" , identifier , enum_body ;
enum_body = "{" , { enum_variant } , "}" ;
enum_variant = identifier , [ "(" , variant_fields? , ")" | "{" , named_fields? , "}" ] , ";" ;
variant_fields = variant_field , { "," , variant_field } ;
variant_field = type_expr ;
named_fields = named_field , { "," , named_field } ;
named_field = identifier , ":" , type_expr ;
type_alias = "type" , identifier , "=" , type_expr , ";" ;
fn_decl = "fn" , identifier , type_params? , "(" , params? , ")" ,
return_type? , block ;
type_params = "<" , type_param , { "," , type_param } , ">" ;
type_param = identifier , [ ":" , trait_bound , { "+" , trait_bound } ] ;
trait_bound = identifier ; (* built-in: Copy, Eq, Send *)
params = param , { "," , param } ;
param = identifier , ":" , type_expr ;
return_type = "->" , type_expr ;Visibility is controlled by the pub keyword. Top-level declarations (functions, structs, enums) may be prefixed with pub to make them accessible from other modules. Non-public items are only accessible within the same file. Public items can be accessed from other modules via qualified names (mod::item).
type_expr = func_type
| union_type ;
func_type = "fn" , "(" , type_list? , ")" , "->" , type_expr ;
type_list = type_expr , { "," , type_expr } ;
union_type = primary_type , { "|" , primary_type } ;
primary_type = named_type
| box_type
| channel_type
| array_type
| slice_type
| generic_type
| "(" , type_expr , ")" ;
array_type = "[" , type_expr , ";" , int_lit , "]" ;
slice_type = "[" , "]" , type_expr ;
named_type = identifier , [ "::" , identifier ] ;
box_type = "Box" , "<" , type_expr , ">" ;
channel_type = "channel" , "<" , type_expr , ">" ;
generic_type = identifier , "<" , type_expr , { "," , type_expr } , ">" ;Note: Union types (A | B) are reserved for future use and may be restricted or removed; enums are the primary sum type mechanism.
block = "{" , { stmt } , "}" ;
stmt = let_stmt
| expr_stmt
| if_stmt
| while_stmt
| for_stmt
| match_stmt
| return_stmt
| break_stmt
| continue_stmt
| defer_stmt
| spawn_stmt
| unsafe_stmt
| ";" ;
unsafe_stmt = "unsafe" , block ;
let_stmt = "let" , identifier , [ ":" , type_expr ] ,
[ "=" , expr ] , ";" ;
expr_stmt = expr , ";" ;
if_stmt = "if" , expr , block , [ "else" , ( block | if_stmt ) ] ;
while_stmt = "while" , expr , block ;
for_stmt = "for" , identifier , "in" , expr , block ;
match_stmt = "match" , expr , "{" , { match_arm } , "}" ;
match_arm = pattern , [ "if" , expr ] , "=>" , block ;
return_stmt = "return" , [ expr ] , ";" ;
break_stmt = "break" , ";" ;
continue_stmt = "continue" , ";" ;
defer_stmt = "defer" , expr , ";" ;
spawn_stmt = "spawn" , block , ";" ;else if chains use the if_stmt alternative after else (for example else if cond { ... }). The in token in for_stmt is required but is not otherwise reserved.
The expression grammar is presented in precedence levels (from lowest to highest):
expr = assign_expr ;
assign_expr = assign_target , ( "=" | "+=" ) , assign_expr
| logical_or_expr ;
assign_target = identifier | field_expr | index_expr ;
logical_or_expr = logical_and_expr , { "||" , logical_and_expr } ;
logical_and_expr = comparison_expr , { "&&" , comparison_expr } ;
comparison_expr = equality_expr ,
{ ( "<" | ">" | "<=" | ">=" ) , equality_expr } ;
equality_expr = bitwise_or_expr , { ( "==" | "!=" ) , bitwise_or_expr } ;
bitwise_or_expr = bitwise_xor_expr , { "|" , bitwise_xor_expr } ;
bitwise_xor_expr = bitwise_and_expr , { "^" , bitwise_and_expr } ;
bitwise_and_expr = shift_expr , { "&" , shift_expr } ;
shift_expr = additive_expr ,
{ ( "<<" | ">>" ) , additive_expr } ;
additive_expr = multiplicative_expr ,
{ ( "+" | "-" ) , multiplicative_expr } ;
multiplicative_expr =
unary_expr ,
{ ( "*" | "/" | "%" ) , unary_expr } ;
unary_expr = ( "!" | "-" | "&" | "&mut" ) , unary_expr
| postfix_expr ;
postfix_expr = primary_expr ,
{ selector | index | call | cast } ;
selector = "." , identifier ;
index = "[" , expr , "]" ;
call = "(" , arg_list? , ")" ;
arg_list = expr , { "," , expr } ;
cast = "as" , type_expr ; (* type casting *)
primary_expr = identifier
| literal
| "(" , expr , ")"
| struct_lit
| enum_lit
| array_lit
| fn_literal
| send_expr
| recv_expr
| channel_expr ;
send_expr = "send" , "(" , expr , "," , expr , ")" ;
recv_expr = "recv" , "(" , expr , ")" ;
channel_expr = "channel" , "<" , type_expr , ">" , "(" , ")" ;
fn_literal = "fn" , "(" , params? , ")" , [ "->" , type_expr ] , block ;
array_lit = "[" , ( expr_list | ( expr , ";" , int_lit ) ) , "]" ;
expr_list = expr , { "," , expr } ;
literal = int_lit | float_lit | bool_lit | string_lit ;
struct_lit = identifier , "{" , field_inits? , "}" ;
field_inits = field_init , { "," , field_init } ;
field_init = identifier , ":" , expr ;
enum_lit = identifier , "::" , identifier ,
[ "(" , arg_list? , ")" | "{" , named_field_inits? , "}" ] ;
named_field_inits = named_field_init , { "," , named_field_init } ;
named_field_init = identifier , ":" , expr ;
pattern = identifier
| "_"
| literal
| enum_pat ;
enum_pat = identifier , "::" , identifier ,
[ "(" , pattern_list? , ")" | "{" , named_pattern_fields? , "}" ] ;
pattern_list = pattern , { "," , pattern } ;
named_pattern_fields = named_pattern_field , { "," , named_pattern_field } ;
named_pattern_field = identifier , ":" , pattern ;Channel send and receive use the send and recv built-ins. channel<T>() takes no arguments.
Ion includes the following primitive types:
- Machine-sized integer:
int(signed integer matching the target platform'sint) - Signed integers:
i8,i16,i32,i64 - Unsigned integers:
u8(available for raw pointers),u16,u32,u64,uint - Floating point:
f32,f64 - Boolean:
bool - Unit / void:
void(function return type with no value) str: unsized UTF-8 slice type; use as&str(borrowed view, stack-local only; see Section 8.3)
Each integer primitive exposes compile-time limits as Type::MIN and Type::MAX (for example int::MIN, i32::MAX, u8::MIN). These lower to C-safe literals (avoiding -2147483648-style unary overflow in generated C).
Additional built-in generic types:
Box<T>– heap-allocatedTwith owning semantics (Box::new(),Box::unwrap())Sender<T>andReceiver<T>– move-only handles for the two ends of a bounded MPSC channelVec<T>– growable heap-allocated vector (Vec::new(),Vec::with_capacity(),Vec::push(),Vec::pop(),Vec::len(),Vec::capacity(),Vec::get(),Vec::get_ref(),Vec::set())String– UTF-8 heap-allocated string (fully implemented:String::new(),String::from(),String::push_str(),String::push_byte(),String::len())[T; N]– fixed-size array ofNelements of typeT[]T– dynamically sized slice (fat pointer) of typeT(T1, T2, ...)– fixed-size tuple value type (see §4.1.3)
Fixed-size arrays [T; N] have the following safety properties:
- Bounds checking: Array indexing
arr[i]performs runtime bounds checking by default- If
i < 0ori >= N, the program panics with an error message viaion_panic() - The panic prints "Array index out of bounds" to stderr and aborts the program
- Bounds checking can be disabled in
unsafeblocks for performance-critical code
- If
- Compile-time size: Array size
Nmust be a compile-time constant - Stack allocation: Arrays are allocated on the stack by default
- Index type: The index expression may be any integer type (
int,i32,u32, etc.).
Safe array access:
let arr: [int; 5] = [1, 2, 3, 4, 5];
let x = arr[2]; // OK: bounds checked at runtime
Unsafe array access (no bounds checking):
let arr: [int; 5] = [1, 2, 3, 4, 5];
unsafe {
let x = arr[2]; // No bounds check - faster but unsafe
}
Out-of-bounds access (panics):
let arr: [int; 3] = [1, 2, 3];
let x = arr[10]; // Runtime panic: "Array index out of bounds"
Dynamically sized slices []T (and &[]T) have the following safety properties:
- Bounds checking: Slice indexing
s[i]performs runtime bounds checking by default- If
i < 0ori >= s.len, the program panics with an error message viaion_panic() - The panic prints "Slice index out of bounds" to stderr and aborts the program
- Bounds checking can be disabled in
unsafeblocks for performance-critical code
- If
- Fat pointer representation: Slices are
(data, len)pairs at runtime
Safe slice access:
fn first(s: &[]int) -> int {
return s[0]; // OK: bounds checked against s.len at runtime
}
Unsafe slice access (no bounds checking):
unsafe {
let x = s[0]; // No bounds check - faster but unsafe
}
Out-of-bounds access (panics):
return s[10]; // Runtime panic: "Slice index out of bounds" when len <= 10
Array-to-slice coercion:
A reference to a fixed-size array &[T; N] coerces to &[]T where the type checker accepts it (let bindings and function arguments). Codegen builds a temporary ion_slice_T fat pointer with data pointing at the array and len = N.
fn sum(s: &[]int) -> int { return s[0] + s[1]; }
fn main() -> int {
let arr: [int; 3] = [10, 20, 30];
return sum(&arr); // &[int; 3] coerced to &[]int at the call site
}
Tuple types (T1, T2, ...) describe anonymous product types with positional fields f0, f1, ... accessed in source as .0, .1, etc.
let t: (int, int) = (10, 20);
let x: int = t.0;
let (a, b) = t; // destructure by move
Rules:
- Tuple literals
(expr1, expr2, ...)require at least one element; empty()is not supported. - Field indices are 0-based; out-of-range access is a compile error.
- Moving a tuple moves all non-copy fields together; destructuring
let (a, b) = tmoves each bound field out oft. - Nested tuples, tuple equality (
==), tuples in struct fields, and generic tuple types are not supported in the current compiler.
Standard library enums (user-defined):
Option<T>– optional value (Some(T)orNone) – can be defined as a generic enumResult<T, E>– success or error (Ok(T)orErr(E)) – can be defined as a generic enum
User-defined types:
struct– product types (with generic and visibility support)enum– tagged unions (sum types) with tuple-style or struct-style variants (with generic and visibility support)*T– raw pointer types (for FFI; dereferencing only inunsafeblocks)[T; N]– fixed-size array types[]T– slice types
Type aliases:
type– type aliases:type Name = T;andtype Name<T> = Type;syntax
Type aliases created with type Name = T; are transparent: Name is equivalent to T for type checking. There is no nominal distinction introduced by type.
Struct and enum types are nominal: two structs with identical fields but different names are different types.
Functions have the form:
fn name<T1, T2>(param1: T1, param2: T2) -> R { ... }
The corresponding function type is:
fn(T1, T2) -> R
Function types are first-class: they may be stored in variables, passed as arguments, and returned.
Fn literals (capture-free only): an expression fn(params) [-> R] { ... } has type fn(T1, T2, ...) -> R matching its signature. The body may reference only parameters and locals declared inside the literal; any use of a binding from an outer scope is a compile-time error (ClosureCapture). Fn literals lower to plain C function pointers (each site gets a unique static function); there is no environment payload and no heap allocation.
Named functions and capture-free fn literals may not capture references that would violate the no-escape rule (see Section 5.4). Capturing closures (literals that move owned state from outer scopes) are not implemented; use named functions with extra parameters, explicit context structs, or spawn for move-only thread capture.
Ion supports a local, Hindley–Milner-inspired inference:
-
letbindings may omit the type if the initializer is present:let x = 10; // x: int let s = String::new(); // s: String -
Function parameter and return types must be annotated (no inference across function boundaries).
-
Generic type parameters on functions and types must be explicit at declaration sites, but may usually be inferred at call sites when unambiguous.
-
matchexpressions: non-diverging arms must produce the same type (trailing expression or unit for an empty block). Arms that alwaysreturn,break, orcontinuedo not contribute a value to arm unification (see also Section 5.2). A match used as an rvalue where one arm diverges and another produces a value is a compile error. Within a single arm, control-flow paths that mixreturn/break/continuewith value-producing fall-through are also a compile error. Numeric coercion between arm types follows the same rules as assignment.
The inference engine is intentionally limited:
- No higher-rank polymorphism.
- Generic type parameters may declare optional trait bounds (
Copy,Eq,Send). Bounds are checked at monomorphization: each concrete instantiation must satisfy every bound on the corresponding parameter. There are no user-defined traits; bounds name structural capabilities checked by the compiler (see Section 4.8). - Structural
Sendstill applies per instantiation even without an explicit bound: for a generic typeWrapper<T>, each monomorphizedWrapper<U>isSendif and only if all of its fields (withTreplaced byU) areSend.
- Type casting:
expr as Typeperforms explicit numeric conversions (e.g.,f64 as int). - Array element assignment:
arr[i] = valuemutates a mutable array element. Subject to bounds checking unless insideunsafe. - Array initialization:
[value; count]fills an array withcountcopies ofvalue, wherecountis a compile-time constant.
expr.method(args) is syntactic sugar for Type::method(expr, args), where Type is the concrete type of expr.
- The compiler infers
&Tor&mut Tfor the receiver based on the function signature. - Generic methods use existing monomorphization; type arguments may be inferred from the first argument.
- Qualified calls (
Vec::push(&mut vec, 10)) remain valid.
loop { ... }: infinite loop; usebreakto exit andcontinuefor the next iteration.for identifier in expr: iterates overVec<T>,[T; N], orString. Loop variable type isTfor vectors and arrays,u8for strings (raw bytes).break: exits the innermost enclosingwhile,loop, orforloop.continue: skips to the next iteration of the innermost enclosingwhile,loop, orforloop. Inforloops, the step (index increment) still runs.- Both
breakandcontinueare compile errors outside of a loop body. - Match guards:
pattern if expr => { ... }whereexprmust bebool. - Struct-style enum variants:
enum E { Ok { value: int }; }with matching literals and patterns.
Generic functions, structs, enums, and type aliases may declare bounds on type parameters:
fn send_value<T: Send>(value: T) { ... }
struct Pair<T: Copy + Eq> { first: T; second: T; }
Syntax: identifier : Bound [ + Bound ... ] after each type parameter name. Bounds are built-in identifiers only; there is no trait declaration syntax.
| Bound | Meaning (structural) |
|---|---|
Copy |
Type is copied rather than moved at the ownership level (primitives, references, function pointers). |
Eq |
Type supports == and != with correct semantics (primitives, String, references, function pointers, arrays and tuples of Eq types, structs and enums whose fields or payloads are all Eq). |
Send |
Type may cross thread boundaries (Section 7.3). |
At each monomorphization site (generic call, struct or enum construction, type-alias substitution), the compiler substitutes concrete types for parameters and rejects any instantiation where a concrete type does not satisfy a declared bound. Unknown bound names are rejected at the declaration site.
Every value in Ion has a single owner at any point in time. Ownership is tied to:
- Local variables (
letbindings) - Struct fields
- Enum payloads
- Elements in
Vec<T>,Box<T>, etc.
Ownership transfers (moves) occur:
- When binding a value:
let y = x;moves fromxtoy. - When passing an argument by value.
- When returning a value from a function.
- When sending a value into a channel.
After a move, the previous binding becomes invalid and cannot be used.
Example (valid):
fn take(v: Vec<int>) {
// v is consumed here
}
fn main() {
let xs: Vec<int> = Vec::new();
take(xs); // xs moved into take
// xs is now invalid; any use is a compile error
}
By default, Ion types are move-only. For a small subset of primitive types (e.g., int, bool, pointers), the implementation may treat moves as cheap copies, but the semantic model is still “move”.
Whether a move is implemented as a copy is an implementation detail. The Copy bound on generic parameters (Section 4.8) names types that are copied rather than moved at the ownership level.
After an if statement, ownership is merged from branches that can reach the following code. Branches that always return, break, or continue are omitted from the merge. If two fall-through paths disagree on whether a binding is still valid, the compiler reports an error.
References provide scoped, non-owning access:
&T– shared borrow, read-only.&mut T– exclusive borrow, read-write.
Borrowing does not change ownership: the owner remains responsible for destruction. Borrows are created with:
let x = 10;
let r = &x; // r: &int
and
let mut x = 10;
let r = &mut x; // r: &mut int
The following borrowing rules apply:
- At any time, either:
- Any number of
&Tborrows, and no&mut Tborrows, or - Exactly one
&mut Tborrow, and no&Tborrows.
- Any number of
- Borrows are restricted to the lexical scope of the function in which they are created (see 5.4).
- While any lasting borrow of a variable is active, that variable cannot be used directly: no reads, assignment, or moves. This applies to copy types (e.g.
int) as well as move-only types. Use the reference binding instead;&mutand&both enforce this exclusivity on the owner for the borrow's lifetime.
Field and subpath borrows
Lasting borrows through fields or indexing (e.g. let r = &mut s.x, let r = &arr[i]) register a borrow on the root owner binding (s, arr), not on a separate field slot. Ion does not support Rust-style disjoint field borrows: at most one &mut path into an owner at a time, and while the owner is borrowed no direct use of the owner (including other field paths or whole-owner &mut s) is allowed. Multiple shared & paths into the same owner remain allowed (let a = &s.x; let b = &s.y). Ephemeral & / &mut in call arguments are checked for aliasing at the use site but do not register lasting borrow counts.
Each reference has a borrow scope equal to a syntactic region within a single function body. The compiler enforces:
- References cannot be:
- Stored in struct fields or enum variants.
- Returned from functions.
- Assigned into global or static variables.
- Captured by function literals (closures) that may escape the current function.
- Sent into channels.
- Stored in values that may cross threads (i.e., be
Send).
Example (invalid – returning a reference):
fn max_ref(a: &int, b: &int) -> &int {
return a; // ERROR: cannot return reference
}
Example (invalid – storing reference in struct):
struct Holder {
value: &int, // ERROR: struct fields cannot be references
}
Example (invalid – channel of references):
fn main() {
let (tx, rx): (Sender<&int>, Receiver<&int>) = channel<&int>(); // ERROR: references cannot be sent
}
Example (invalid – closure capturing reference and escaping):
fn make_printer(x: &int) -> fn() {
return fn() {
let _y: &int = x; // ERROR: ClosureCapture (cannot reference outer x)
}; // ERROR: closure escapes with reference
}
Capturing closures are not implemented; the compiler rejects any outer binding referenced from a fn literal body.
Example (valid – borrow within function):
struct Counter { n: int; }
fn read_twice(c: &Counter) -> int {
return c.n + c.n;
} // borrows end here
The type checker implements this by rejecting any attempt to store or return a type containing & or &mut outside the current function’s local variables. In particular, types such as Option<&T> or Result<&T, E> are only permitted as local temporaries within a function body; they cannot be returned, stored in longer-lived data structures, sent through channels, or cross thread boundaries. Standard library APIs are designed to avoid exposing such reference-carrying types across function boundaries.
When a binding goes out of scope (e.g., block exit, function return, panic unwinding), its owned value is dropped exactly once:
- For structs, fields are dropped in declaration order.
- For enums, the active variant’s payload is dropped.
- For
Box<T>,Tis dropped, then the allocation is freed.
defer schedules an expression to be executed when the current block scope is left, in last-in, first-out order. On return, all enclosing block defers and drops run innermost-first before the function returns.
fn process() {
defer log_cleanup(); // runs when process() returns
if ready {
defer arm_cleanup(); // runs when the if arm ends or on return through this arm
// ...
}
}
When a binding goes out of scope (block exit, function return), owned values with heap resources are dropped automatically:
Box<T>:ion_box_freeVec<T>:ion_vec_freeString:ion_string_freeSender<T>/Receiver<T>:ion_channel_handle_drop(refcounted; freed when both ends are dropped)- Struct fields with owned heap types (
Box,Vec,String, channels, or nested structs/enums containing them) are dropped in declaration order when the struct goes out of scope. - Enum payloads are dropped for the active variant when the enum goes out of scope.
Uninitialized Box/Vec/String bindings are zero-initialized to NULL so drop is a no-op.
- Local variables (
let) are allocated on the stack by default. Box<T>allocatesTon the heap. Destroying aBox<T>dropsTand frees the heap allocation.Vec<T>andStringinternally use heap allocations; their behavior is defined by their APIs (Section 7).
Ion does not perform implicit heap allocation for:
- Captured closures (not implemented; fn literals are capture-free and use static functions only)
- Slices or views
- Temporaries (beyond what is required for expression evaluation)
Any heap allocation must be visible in the code via Box, Vec, String, or other standard types.
Ion guarantees that every owned value is dropped exactly once when its owner’s scope ends, except when:
- The program terminates abnormally (e.g., process abort).
In particular:
- Early
returnfrom a function drops all owned locals (and runs block defers) before returning. spawnthread entry functions use the same scope-exit machinery; captures are dropped when the thread body finishes.- Panics (if implemented) unwind the stack, dropping owned values on each frame.
spawned threads manage their own stacks independently.
Given the ownership and borrowing rules:
- Ion programs cannot exhibit use-after-free or double-free at runtime.
- Data races across threads are prevented if the
Sendrules are correctly enforced (Section 7).
Any violation of safety rules is a compile-time error, not undefined behavior.
By default, struct and enum layouts are C-compatible:
- Field order and alignment follow the target C ABI.
- No hidden metadata is inserted into structs.
- Enums are compatible with “tagged unions” encoded according to a specified ABI (to be detailed later; FFI with enums may be restricted).
Functions may be declared extern "C". Raw pointer types *T are available for FFI (distinct from safe references &T). Raw pointers are pass-through only in Ion code (no dereferencing). The compiler assumes:
- Ion compiles to C functions with straightforward signatures.
- Parameter and return passing follows the C calling convention of the target platform.
The spawn statement creates a new OS thread executing the given block:
spawn {
// body
};
The block may capture owned values from the enclosing scope by move only. Capturing references is disallowed:
fn work(v: Vec<int>) { }
fn main() {
let v: Vec<int> = Vec::new();
spawn {
// v is moved into this thread; main cannot use v after this point
work(v);
};
}
Attempting to use v after spawn is a compile-time error.
Ion provides typed, bounded MPSC channels via built-in Sender<T> and Receiver<T> types and the channel<T>() function:
let (tx, rx): (Sender<int>, Receiver<int>) = channel<int>();
send(&tx, 42);
let value = recv(&mut rx);
Semantics:
channel<T>()is a built-in that returns(Sender<T>, Receiver<T>). It takes no arguments. Element typeTmust beSend. The runtime buffer capacity is fixed at 1 slot per channel in the current compiler.Sender<T>andReceiver<T>are move-only value types (not pointers).send(&tx, value)moves a value into the channel. Requires&Sender<T>.recv(&mut rx)moves a value out of the channel. Requires&mut Receiver<T>. Blocks until a value is available.sendblocks when the buffer is full;recvblocks when empty.- Tuple destructuring is supported:
let (tx, rx) = channel<int>(); - Both
Sender<T>andReceiver<T>areSendwhenT: Send, so either end may be moved between threads. - Channel handles share a refcounted backing channel. Dropping either handle decrements the refcount; the channel is freed when the last handle is dropped at scope exit.
The Send property marks types that are safe to transfer to another thread by value:
- Primitive types (
int,bool, etc.) areSend. Box<T>isSendifT: Send.Vec<T>isSendifT: Send.StringisSend.Option<T>isSendifT: Send.Result<T, E>isSendif bothT: SendandE: Send.Sender<T>andReceiver<T>areSendifT: Send.(T1, T2, ...)isSendif every element type isSend.- Any type containing a reference (
&T,&mut T) is notSend.
User-defined struct and enum types are Send if and only if all of their fields / payloads are Send. For generic types, this rule is applied per instantiation: e.g., Wrapper<int> may be Send while Wrapper<NonSend> is not, depending on the fields.
The compiler checks Send when:
- Moving a value into a
spawnbody. - Sending a value into a channel whose receiver may be on another thread.
Any attempt to move a non-Send type across threads is a compile-time error.
This section specifies the surface API and semantics of core library types. Implementations are provided in Ion and/or C and are not part of the language definition.
enum Option<T> {
Some(T);
None;
}
enum Result<T, E> {
Ok(T);
Err(E);
}
Semantics follow the conventional meaning:
Option<T>represents presence or absence of a value.Result<T, E>represents success (Ok) or failure (Err).
These enums follow standard ownership rules (payloads are moved in and out).
Vec<T> is a growable, heap-allocated sequence of T.
Essential API (implemented; pseudocode notation: Ion has no impl blocks; these are compiler builtins):
// Vec::new() -> Vec<T>
// Vec::with_capacity(cap: int) -> Vec<T>
// Vec::push(vec: &mut Vec<T>, value: T)
// Vec::pop(vec: &mut Vec<T>) -> Option<T>
// Vec::len(vec: &Vec<T>) -> int
// Vec::capacity(vec: &Vec<T>) -> int
// Vec::get(vec: &Vec<T>, index: int) -> Option<T>
// Vec::get_ref(vec: &Vec<T>, index: int) -> Option<&T>
// Vec::set(vec: &mut Vec<T>, index: int, value: T) -> int
Method syntax (vec.push(x), vec.get_ref(i)) desugars to the qualified forms above.
Note that:
Vec<T>isSendifT: Send.Vec::new()andVec::with_capacity()inferTfrom alettype annotation when present (e.g.let v: Vec<i32> = Vec::new()).Vec::get()andVec::pop()returnOption<T>to handle out-of-bounds or empty cases. Both move the element out of the vector. To preserve vector length after a read-only scan, either useVec::get_ref()(below) or copy fields andVec::set()a rebuilt struct literal to put the value back (nestedVecfields still move on put-back).Vec::get_ref()returnsOption<&T>: a local, stack-only borrow of an in-place element. It does not move or hollow the slot. The result is only valid as a short-lived binding within the current function (for example in amatcharm). Match arms bind the element as&T; for enum elements, an innermatchon that binding dispatches variants directly (no unary*deref). Copy fields in struct or enum variant patterns bind asT; non-copy fields bind as&T. Codegen usesT*for types with owned fields and copies by value for copy types, so repeated scans overVec<struct-with-nested-Vec>do not double-free nested fields. It cannot be returned, stored in structs or enums, sent on channels, or crossspawn. While an&Tfromget_refis active, the root owner of the vector (the binding behind&Vec<T>) is shared-borrowed:&mut Vec<T>on that owner,Vec::set,Vec::push, andVec::popon the same vector are rejected until the borrow ends. Out-of-range or negative indices yieldOption::None. Nested inspection (order.linesthenget_ref) follows the same root-owner borrow rules as field paths (Section 5.3). Field paths through&Structthat are already references (for exampleorder.lineswhenorder: &Order) are passed toVecmethods without an extra&.Vec::set()requires a mutable reference and returns an error code (0 for success, non-zero for failure). After shared borrows fromget_refend,Vec::seton the same index is allowed.
For cross-function or long-lived access, Ion still favors an index/handle style: helpers return indices or keys and callers re-index within their own function bodies.
String is a growable, heap-allocated UTF-8 string.
Essential API (implemented; pseudocode notation: Ion has no impl blocks; these are compiler builtins):
// String::new() -> String
// String::from(s: &str) -> String
// String::push_str(s: &mut String, other: &str)
// String::push_byte(s: &mut String, b: u8)
// String::len(s: &String) -> int
str is a primitive slice type; &str is a borrowed UTF-8 view (pointer, length).
Note that:
-
String literals can be directly assigned to
Stringtype:let s: String = "hello"; -
The same literal coercion applies when a string literal is passed as a call argument to a parameter typed
String(not only inletbindings). -
String::from()creates a heap-allocated copy of a string literal. -
String::push_str()appends a string literal or an ownedString(reads the source buffer). -
String::push_byte()appends a single byte to an existingString. -
==and!=compare UTF-8 byte content (value equality), not pointer identity. -
String::fromand stdlib APIs accepting&stralso accept string literals and&Stringat call sites. -
&stris always a borrowed view into existing UTF-8 data; it cannot be returned or stored in long-lived structures in ways that would violate the no-escape rule. The standard library intentionally avoids APIs that would expose&strvalues across function boundaries in ways that require complex lifetime reasoning (e.g.,String::as_strmethods that return borrowed views).
See Section 7.2 for channel semantics and API.
Implemented (MVP): stdlib/fs.ion provides whole-file read via POSIX open/read/close wrapped in unsafe blocks.
// stdlib/fs.ion
pub enum ReadResult { Ok(String); Err(int); }
pub fn read_to_string_result(path: String) -> ReadResult;
pathis an ownedString(typically from a string literal orString::from).- On success, returns
ReadResult::Okwith file bytes as aString(raw UTF-8; no validation). - On failure:
ReadResult::Err(-1)ifopenfails,ReadResult::Err(-2)ifreadfails. - Platform: POSIX and MinGW (
open/read/close). Not available on MSVC-only toolchains without a POSIX compatibility layer.
Generic Result<T, E> lives in stdlib/result.ion for library authors; fs uses the concrete ReadResult enum.
Import with import "stdlib/fs.ion" as fs; then fs::read_to_string_result(path).
Deferred (not implemented):
struct File { /* opaque, not Send unless specified */ }
// Sketch only; `impl` blocks are not valid Ion syntax today.
fn open(path: &String) -> Result<File, IOError>;
fn read(file: &mut File, buf: &mut Vec<u8>) -> Result<int, IOError>;
fn write(file: &mut File, buf: &Vec<u8>) -> Result<int, IOError>;
fn close(file: &mut File) -> Result<void, IOError>;
enum IOError {
NotFound;
PermissionDenied;
UnexpectedEof;
Other(int);
}
File handles would typically be not Send, to avoid subtle platform-dependent behavior across threads.
The stdlib provides safe wrappers in stdlib/io.ion, stdlib/fmt.ion, and stdlib/fs.ion:
stdlib/io.ion:
io::print(s: String)– print string to stdoutio::println(s: String)– print string with newlineio::print_str(s: *u8, len: int)– print raw bytes with length validationio::print_int(n: int)– print signed integer in decimal
stdlib/fmt.ion:
fmt::int_to_string(n: int) -> Stringfmt::print_int(n: int)fmt::println_int(n: int)
stdlib/fs.ion:
fs::read_to_string_result(path: String) -> ReadResult– read entire file (POSIX/MinGW;Err(-1)on open failure,Err(-2)on read failure)
All I/O functions wrap POSIX calls in safe Ion code. Import with import "stdlib/io.ion" as io; (or fmt.ion, fs.ion).
String exposes .data (*u8) and .len (int) fields for low-level access when needed.
These examples illustrate core semantics (moves, borrows, channels). Copy-paste idioms (index/handle search, Vec put-back, concurrency patterns) live in .cursor/skills/writing-ion-code/references/verified-patterns.md, checked against tests/ and examples/.
See verified-patterns.md (Ownership move) and tests/test_move_basic.ion.
struct Counter { n: int; }
fn bump(c: &mut Counter) {
c.n += 1;
}
See tests/test_field_assign_plus.ion.
See verified-patterns.md (Concurrency and ownership transfer), examples/spawn_channel/spawn_channel.ion, and tests/test_spawn_channel.ion.
Sender<T> and Receiver<T> are Send values moved between threads; communication is by channel, not shared references.
See verified-patterns.md (Rejected patterns) and negative tests under tests/. Summary:
- Returning references.
- Storing references in structs or enums.
- Channels of references.
- Capturing references in escaping closures.
- Moving non-
Sendvalues intospawn.
Each such pattern must produce a clear, actionable compiler error.
The ion-build binary is the default developer workflow for applications. It reads ion.toml at the project root (discovered by walking upward from the current working directory), then runs the full pipeline: transpile Ion to C, compile generated C, link with runtime/ion_runtime.c, and write the executable.
cargo build --release --bin ion-build
.\target\release\ion-build.exe buildManifest discovery walks upward from the current directory for a file named ion.toml. Pass --manifest <path> when cwd is not the example directory; the manifest's parent directory is the project root and main paths are relative to that directory.
Runtime and stdlib discovery: ion-build locates runtime/ion_runtime.h by walking upward from the project root, then from the current working directory if needed. Stdlib search paths walk upward from the project root (see below) and also check install-relative stdlib/ next to the compiler executable. A build fails with runtime/ion_runtime.h not found when neither walk finds a runtime/ directory; keep the project under a tree that contains runtime/ and stdlib/ (repo root or an unpacked release archive).
ion-compiler remains available for codegen inspection, LSP internals, and integration tests that grep .c output. It does not require ion.toml.
ion.toml fields (tooling, not language semantics):
| Field | Required | Description |
|---|---|---|
name |
yes | Project name (informational) |
main |
yes | Entry .ion file, relative to the manifest directory |
output |
yes | Executable name (.exe added on Windows when linking) |
mode |
no | single (default) or multi for per-module .c/.h codegen |
out_dir |
no | Build output directory relative to project root (default: target) |
stdlib_paths |
no | Extra stdlib search directories (relative to project root) |
cflags |
no | Extra flags when compiling generated .c files (e.g. -Drecv_sys=recv for FFI name mapping) |
cflags_windows |
no | Extra cflags applied only on Windows (e.g. -Dclose=closesocket for Winsock) |
cflags_unix |
no | Extra cflags applied only on Linux and macOS |
ldflags |
no | Extra flags passed when linking (e.g. -lm overrides) |
emit_in_source |
no | When true, emit .c/.h/.o next to sources instead of out_dir |
Stdlib import resolution: Import string literals such as import "stdlib/io.ion" as io; resolve through stdlib search paths, not only paths relative to the importing file. Search order:
- Manifest
stdlib_pathsentries ION_STDLIBenvironment variable (;-separated on Windows,:elsewhere){project_root}/stdlib- Install-relative
stdlib/next to the compiler executable (walking up)
File-relative imports (./, ../) and same-directory modules are resolved first. The CLI (ion-compiler, ion-build) and LSP share build::discover_import_config and the stdlib search order above (from ion.toml when present, otherwise by walking up for stdlib/ and install-relative paths next to the executable).
On Windows with MinGW, linking adds -lws2_32 automatically (channels, spawn, sockets). Honor CC for the C compiler (same as tests/test_runner.sh).
The ion-lsp binary and VS Code/Cursor extension provide:
- Syntax highlighting (TextMate grammar, no server required)
- Parse, import-resolution, and type diagnostics (multiple type-check errors per file when independent)
- Hover: expression types, symbol signatures, and attached
//documentation prose when present (functions, structs, enums, type aliases, fields, variants, imports, file-level overview) - Completion: prefix-filtered keywords, builtins, local symbols,
alias::itemmodule imports, and struct/enum members after.orType:: - Go to definition: variables, functions (
foo,mod::func), user methods, struct fields, enum variants, and type aliases - Find references, document outline, signature help, and semantic tokens (functions, structs, enums, types, fields)
- Workspace refresh: re-checks open files when a watched
.iondependency changes on disk
Built-in methods (Vec::push, String::len, etc.) show signature hover but have no source location for go-to-definition.
The CLI ion-compiler, ion-build, and the LSP use TypeChecker::check_program_collecting to gather multiple independent type diagnostics. Import failures are reported per import statement via Compiler::load_imports.
Build with cargo build --release --bin ion-lsp. Rebuild after compiler or LSP changes; reload the editor window so ion.lspPath picks up the new binary. A stale ion-lsp or workspace ion-compiler can disagree with a freshly built CLI. Set ion.lspPath in editor settings to the executable path.
Beta compatibility and runtime ABI notes live in docs/BETA.md and docs/ABI.md. Features listed below are either intentionally constrained in the beta subset or unstable until a later release documents a stronger contract.
- Trait bounds are limited to built-in
Copy,Eq, andSend(no user-defined traits) - String
for...initerates bytes (u8), not Unicode code points or graphemes if/elsemerge: ownership after anifis merged from branches that can fall through to the following code. A move in a branch that alwaysreturns,breaks, orcontinues does not block use after theif. If two fall-through paths disagree (one moved, one valid), it is still an error.while/forloops: a non-copy variable moved anywhere in the loop body is an error (repeated iteration would need the binding again); copy types and borrows are unchanged. For read-only scans over an ownedVec<T>, preferVec::get_ref(Section 8.2) or index/handle helpers;Vec::getmove-out still requires consume-once or put-back per iteration.- Match guards on the same variant are lowered to a single
switchcase with sequentialifchecks - LSP go-to-definition for built-in methods (
Vec::push,String::len, etc.) has no target (signature hover only) - LSP go-to-definition for type names in type annotations (no source spans on
TypeAST nodes) - Function types: capture-free fn literals implemented; no capturing closures, no generic
fn(T) -> Rtype parameters, no method values as fn pointers - Tuple values: no nested tuples,
==on tuples, struct fields holding tuples, or generic(T1, T2)parameters. Flat tuples may hold owned heap types (for example(Vec<T>, int)).
The following features are not planned for the current compiler:
- Asynchronous/await syntax and futures.
- Complex trait or typeclass systems.
- Macros and compile-time metaprogramming.
- Advanced iterator pipelines and zero-cost abstractions beyond the basics.
- Capturing closures (fn literals that move owned environment from outer scopes).
Any such addition must:
- Preserve the no-escape rule and simple ownership model.
- Not require GC or complex runtime machinery.
Canonical idioms and copy-paste examples: .cursor/skills/writing-ion-code/references/verified-patterns.md. That file is the single source checked against tests/ and examples/; update it when adding patterns rather than duplicating examples here.
| Topic | Where |
|---|---|
Index/handle instead of returning &T |
verified-patterns.md: Index and handle search |
Vec::get / Vec::set put-back |
verified-patterns.md: Mutating Vec elements; tests/test_vec_get_putback.ion |
| Owned API boundaries | verified-patterns.md: Owned API boundaries |
Compare via &Struct fields |
verified-patterns.md: Comparing borrowed structs |
spawn and channel ownership |
verified-patterns.md: Concurrency and ownership transfer |
| Compile-error anti-patterns | verified-patterns.md: Rejected patterns |
Ion has no separate /// or //! documentation syntax. Documentation is ordinary // line comments attached to declarations by adjacency during parsing (Go/Odin convention):
- Contiguous
//lines immediately above an item, with no blank line between the last comment line and the declaration, document that item. - Comments immediately above
pubattach to the following declaration (pubis not part of the doc block). - A blank line between the comment block and the declaration breaks association; the comments are not attached.
- File- or module-level overview: leading
//lines at the top of a file before the firstimportor declaration attach toProgram. - The same rule applies to struct fields and enum variants inside their bodies.
- Trailing inline
//on the same line as code is explanatory only and is not item documentation.
Doc comments supplement ION_SPEC.md for IDE hover and a future ion doc tool; they are not a second normative specification. ion-lsp shows signature or type text first, then a blank line, then attached prose when present. Imported symbols surface docs from the defining module's AST.
Examples:
// Safe console output helpers.
import "stdlib/io.ion" as io;
// Returns zero on success.
fn main() -> int {
io::println(String::from("hi"));
return 0;
}
// A point in 2D space.
struct Point {
// Horizontal coordinate.
x: int;
y: int;
}
// Success or failure with an owned message.
enum Result<T, E> {
// Operation succeeded.
Ok(T);
Err(E);
}
// Write an owned string to stdout followed by a newline.
pub fn println(s: String) {
// ...
}
Section-divider comments in examples should be separated from declarations by a blank line so they are not attached as docs:
// ============================================
// Section title
// ============================================
struct Widget { value: int; }