This page is generated from docs/language_definition.md on every deploy, and every Iron example in it is compiled and run in CI. A plain Markdown copy for tools and language models is at /llms/reference.md; see /llms.txt for the full index.

Iron Reference Manual

This manual describes the Iron programming language as implemented by the iron compiler that ships with this repository (version 5.0). It is a reference, not a tutorial: each section states what the compiler accepts and what the program does, with a small complete example. Every iron code block in this document is compiled and executed by scripts/test_doc_examples.sh, and blocks that show a compile error are checked to fail with the stated diagnostic code, so the manual cannot drift from the compiler without the test failing.

Iron is a general-purpose native programming language. It is statically typed and compiles to C, then to a native binary. It has no garbage collector, no exceptions, no operator overloading and no implicit numeric conversions. Values live on the stack by default; explicit heap, rc and arena allocation cover the other cases.

Contents:

  1. Lexical conventions
  2. Types
  3. Expressions
  4. Statements
  5. Declarations
  6. Memory
  7. Concurrency
  8. Compile-time evaluation
  9. The standard library
  10. Programs, projects and the command line
  11. Diagnostics
  12. Not yet implemented
  13. Complete syntax of Iron

1. Lexical conventions

1.1 Source text

Iron source files use the .iron extension and are UTF-8 text. Spaces, tabs and carriage returns separate tokens. Newlines are tokens for the lexer, but the parser skips them wherever they appear, so a newline never ends a statement by itself: statements are delimited by the grammar (section 1.6). Semicolons are not statement terminators; ; appears only inside list types and list literals ([Int; 4]).

1.2 Comments

Iron has one comment form: // starts a comment that runs to the end of the line. There are no block comments, and # is not a comment. A // inside a string literal is string text ("http://example.com").

Documentation. A run of // comment lines directly above a declaration, an object field, an enum variant or an interface method, with no blank line between the lines or before the declaration, is its documentation. The language server shows it on hover, in completion and in signature help, with one leading space removed from each line. A blank line breaks the association, and a comment at the end of a line of code is never documentation. The compiler otherwise ignores comments.

A comment never changes what a program means. One comment has a meaning to the compiler's own tests: a comment whose text starts with @file: (a file marker) tells the test harness's multi-file mode that the source after it belongs to another file. Only that mode (IRON_MULTI_FILE_TEST=1) honors it; in any other compile it is an ordinary comment, and diagnostics after it still name the file it is written in.

// Doubles its argument.
// Attached to `twice` because no blank line separates them.
func twice(x: Int) -> Int {
    return x * 2   // a comment at the end of a line
}

// A comment followed by a blank line documents nothing.

func main() {
    println("{twice(21)}")
}
42

Directives. A comment written //key:value, with no space after //, a key of lowercase letters, digits and _ starting with a letter, and a : right after the key, is a directive. A directive belongs to the declaration or field it sits directly above, as part of its comment run. It may instead trail an object field on that field's line. Directive lines are not part of the documentation. The value is the rest of the line. // json:id, with a space, is an ordinary comment.

object Item {
    // The catalog number.
    //db:primary_key
    pub val item_id: Int  //json:id
    pub val name: String  //json:name
}

func main() {
    val it = Item(7, "lamp")
    println("{it.item_id} {it.name}")
}
7 lamp

The compiler keeps this metadata on each relevant AST node as its doc_comment text and a source-ordered directives list. Each directive is (key, value, line, trailing); line is one-based and trailing can be true only for an object field. This is the representation available to the later compile-time reflection work in issue #455. Directives have no meaning of their own, except the iron key: //iron:<hint> is a compiler hint. A hint changes only how the compiler represents or optimizes a declaration (a function, object or field), never what the program does; anything that is part of a type or changes what type checks, such as [T, layout: soa] or [I, unordered], is language syntax instead. The hints are:

Hint On Meaning
//iron:fusible a function or method, on the line above func the function can run inside a fused list loop: no effects, no panics (sections 5.2 and 9.3.2)

An //iron: hint the compiler does not know, or one written above a declaration it does not apply to, is warning W0617, so a misspelling such as //iron:fusable does not pass silently. A //iron:fusible function that cannot keep the claim is warning W0618.

Transition. Earlier versions wrote line comments with --, documentation lines with /// and the fusion hint as @fusible. These are still accepted, with the same meaning as //, // and //iron:fusible, while code moves over; iron fmt --migrate-comments [paths] rewrites them (section 10) and a later version will reject them.

1.3 Identifiers and keywords

An identifier is a letter or _ followed by letters, digits and _ (ASCII only). A lone _ is the wildcard, usable as a binding name in val, var, tuple destructuring and match patterns; it is not an expression. Identifiers are case sensitive. By convention types and enum variants are CapitalCase; functions, variables and fields are snake_case. An identifier is not restricted by the keywords, macros, or runtime symbols of the language targeted by a compiler backend. For example, default, register, and int are valid Iron identifiers. Capitalization is also significant in one place: in X.Y(args) and X.Y, an uppercase X makes the expression a possible enum variant construction; the resolver distinguishes it from a method call or field access (section 3.6).

The following words are keywords and cannot be used as identifiers:

and       await     break     comptime  continue  copy      defer     drop
elif      else      enum      extends   extern    false     for       free
func      heap      if        impl      import    in        init      interface
is        leak      match     mut       nocopy    not       null      object
or        parallel  patch     pool      private   pub       pure      rc
readonly  return    self      spawn     static    super     true      unchecked
val       var       weak      while

extends, super, mut, private and pool are reserved but have no valid use: the compiler rejects them with an explanation (extends and super because Iron has no inheritance, mut and private because var and default privacy replaced them, pool because thread pools are not implemented). init, copy, drop, null and free may additionally appear as method names after a . or after func inside an object body, which is how Box.null() and b.free() are spelled.

The words as, layout, soa, aos, unordered, allow_drop_skip and Self are ordinary identifiers with a special meaning in specific positions (import aliases, list attributes, heap options and the receiver type). fusible is an ordinary identifier too; it is special only after @ in the transitional @fusible (section 1.2).

1.4 Literals

Integers are decimal (42), hexadecimal (0xFF, 0Xff) or binary (0b1010). Digits may not be separated by _, and a letter directly after a number is an error. An integer literal has type Int (64-bit signed) unless it initializes a binding or field of another integer type, or is an operand whose partner has another integer type (x + 1 with x: Int8 is Int8 arithmetic), in which case it takes that type. A constant expression (literals combined with -, ~ and the arithmetic, bitwise and shift operators) behaves like the literal it folds to: x + (2 + 3) is x + 5. A literal or constant expression whose value does not fit the type it takes is an error (E0292). Hexadecimal and binary literals must fit in 64 bits.

Floats are a digit sequence, a . and a digit sequence: 3.25, 0.5. There is no exponent form and no trailing-dot form (1. and .5 are not float literals). A float literal has type Float (64-bit) unless the context asks for Float32.

Booleans are true and false. null is the value of every nullable type (section 2.2).

Strings are written between double quotes on one line, or between """ and """ when they span lines (the newlines are part of the value). A string literal is at most 4095 bytes. The escape sequences are:

Escape Meaning
\n newline (byte 10)
\r carriage return (byte 13)
\t tab (byte 9)
\0 the zero byte
\xHH the byte HH: exactly two hex digits, \x00 to \xFF
\\ backslash
\" double quote
\{ and \} literal braces (not interpolation)
\u{H} the Unicode scalar H (1 to 6 hex digits), encoded as UTF-8

Any other character after a backslash is kept together with the backslash. \0 is only the zero byte: a digit after it is an ordinary character ("\012" is the three bytes 0, 1, 2; there are no octal escapes). A \x that is not followed by two hex digits, and a \u that does not name a Unicode scalar, are errors (E0002).

\xHH writes one byte, not a character. A string holds bytes (binary data is a String, section 9.12), so every value is allowed, but a byte from \x80 to \xFF on its own is not UTF-8 text: write characters as \u{...} ("\u{e9}" is the two bytes \xC3\xA9) and keep \x80 and above for binary data. A literal may contain zero bytes anywhere; they are part of its value and its byte_len():

func main() {
    val s = "a\0b\x41\r\n"
    println("{s.byte_len()} {s.byte_at(1)} {s.byte_at(3)} {s.byte_at(4)}")
}
6 0 65 13

A string literal that contains an unescaped { is an interpolated string: each {expression} is evaluated and its text spliced in (section 3.3). Interpolated expressions may themselves contain string literals.

func main() {
    val h = 0xFF
    val b = 0b1010
    val f = 3.25
    val s = "tab\there \"quoted\" braces \{x\} unicode \u{48}\u{49}"
    val m = """first line
second line"""
    val name = "iron"
    println("{h} {b} {f}")
    println(s)
    println(m)
    println("nested {name.upper()} and {"lit".len()} and {1 + 2 * 3}")
}
255 10 3.25
tab	here "quoted" braces {x} unicode HI
first line
second line
nested IRON and 3 and 7

1.5 Operators and punctuation

+    -    *    /    %    ==   !=   <    >    <=   >=
and  or   not  &    |    ^    ~    <<   >>   is
=    +=   -=   *=   /=   &=   |=   ^=   <<=  >>=
.    ..   ,    :    ;    ->   ?    @    &(address-of)
(    )    [    ]    {    }

! on its own is not a token (not is the logical negation), and neither are && and || (and, or); each is an error (E0002) that names the word to write. Section 3.1 gives the precedence of the operators.

1.6 Newlines and statement boundaries

Because the parser skips newlines everywhere, an expression continues onto the next line whenever that line starts with something that can extend it: a binary operator, (, [, ., or is. This is what allows long calls and list literals to be split across lines, but it also means that a line that begins with -, ( or [ continues the previous statement. A bare return must be the last statement of its block, since an expression on the following line would be taken as its value.

func f(x: Int) -> Int {
    return x
}

func main() {
    val a = 10
    val b = a
    - 3              -- continues the previous line: b is 7
    val c = f(
        1,
    )
    val d = 1 +
        2
    println("{b} {c} {d}")
}
7 1 3

2. Types

Iron is statically typed. Every binding, field and parameter has a type that is either written after a colon or inferred from its initializer. There are no implicit conversions between numeric types, and none between numbers and strings.

2.1 Primitive types

Type Description
Int 64-bit signed integer (the default integer type)
Int8, Int16, Int32, Int64 signed integers of the given width
UInt, UInt8, UInt16, UInt32, UInt64 unsigned integers (UInt is 64-bit)
Float 64-bit IEEE double (the default float type)
Float32, Float64 floats of the given width
Bool true or false
String an immutable sequence of Unicode characters, stored as UTF-8 (it may also hold arbitrary bytes, section 1.4)
Void the result type of a function without -> T (never written)

Arithmetic on the same integer type wraps on overflow (two's complement). Integer division truncates toward zero and division by zero panics at run time. Mixing Int with Float, or two different integer widths, in one expression is an error (E0222, E0202); convert explicitly (section 2.9).

Strings are values: copying a string copies a reference to shared, reference-counted characters and never mutates them. len(s) and s.len() count characters (code points); s.byte_len() counts bytes. Iterating a string with for yields one-character strings.

2.2 Nullable types

T? is a type whose values are either a T or null. A nullable type can be written for any non-pointer type (Int?, String?, Node?, Result[Int, String]?, [Int]? for a list that may be absent). A plain T converts to T? implicitly, so a function declared -> Int? may return i or return null. Reading a field or calling a method through a nullable value without checking it first is an error (E0204); compare with null first. x != null narrows x to T inside an if or while body (and after an early return in the other branch) until x is assigned a value that may be null: while cur != null { total += cur.value; cur = cur.next } walks a linked list. The right operand of and is narrowed by a true left operand and the right operand of or by a false one, so x != null and x > 0 and s == null or s.len() == 0 need no if. Comparing a T? that was not checked with a value (x == 3, s < "a") is an error (E0204): check it first, as in x != null and x == 3. A binding assigned inside a loop or a closure is not narrowed there by an outer check, since the assignment may already have run. Comparing a value whose type is not nullable with null warns (W0616): the answer is known before the program runs.

func find(xs: [Int], target: Int) -> Int? {
    var i = 0
    while i < len(xs) {
        if xs[i] == target {
            return i
        }
        i += 1
    }
    return null
}

func main() {
    val idx = find([4, 5, 6], 6)
    if idx != null {
        println("found at {idx}")
    }
    val missing = find([4, 5, 6], 9)
    if missing == null {
        println("missing")
    }
    var s: String? = null
    s = "now"
    println("{s}")
    println("{idx != null and idx > 1} {s != null and s == "now"} {missing == null or missing > 1}")
}
found at 2
missing
now
true true true

A nullable list [T]? (or a nullable map, set, or object holding a list) follows the list ownership rules of section 2.3: it has one owner, so binding it to a second name, storing it in a field or returning a parameter is an error (E0328); check it for null, then write xs.copy() or xs.take(). A function returning [T]? returns a fresh list or null:

func evens(n: Int) -> [Int]? {
    if n < 0 {
        return null
    }
    var out: [Int] = []
    for i in range(n) {
        if i % 2 == 0 {
            out.push(i)
        }
    }
    return out
}

func main() {
    val xs = evens(5)
    if xs != null {
        println("{xs.len()} {xs[2]}")
    }
    println("{evens(-1) == null}")
}
3 4
true

A nullable type may also be the element type of a list, fixed array or bounded vector ([String?], [Int?; 3]), the value type of a map (Map[String, Int?]) and the element type of a channel (Channel[Int?]). null and plain T values are stored as elements ([1, null, 3], xs.push(null), m.put(k, null), ch.send(null)), an element read out of the collection is a T? that is checked with != null before use like any other, and xs.contains(null) finds a null element. A map key mapped to null is present: m.has(k) is true and m.get(k) returns null, while get on an absent key still panics. Map keys and set items must be Hashable, which a nullable type is not (E0206). In expression position the type argument is written the same way: Map[String, Int?](), Map[String, [Int?]]().

func first_present(xs: [Int?]) -> Int? {
    for x in xs {
        if x != null {
            return x
        }
    }
    return null
}

func main() {
    var xs: [Int?] = [null, 2, null]
    xs.push(4)
    println("{xs.len()} {xs[0]} {first_present(xs)} {xs.contains(null)}")
    val names: [String?] = ["ann", null]
    for n in names {
        if n != null {
            println("{n.upper()}")
        } else {
            println("nobody")
        }
    }
    var ages = Map[String, Int?]()
    ages.put("ann", 30)
    ages.put("bob", null)
    println("{ages.has("bob")} {ages.get("bob")} {ages.has("cy")}")
}
4 null 2 true
ANN
nobody
true null false

Nullable pointers are written ?*T (section 6.4), and a nullable weak reference weak rc T? (section 6.3).

2.3 Lists, fixed arrays and bounded vectors

[T] is a growable list of T. [T; N] is a fixed-size array of exactly N elements, where N is an integer constant: a literal or constant expression ([Int; 2 * 8]), a comptime expression (section 8), or the name of a global val holding one of these (val N = comptime size(3), then [Int; N]); anything else is an error (E0202). [T; <=N] is a bounded vector: a list that can hold at most N elements and whose storage is inline. All three are indexed with xs[i] from 0, checked at run time (an out-of-range index panics; a constant out-of-range index on a fixed array is a compile error, E0312), and iterated with for.

A list literal [a, b, c] has the type of its elements; an empty literal [] needs a type annotation on the binding (E0229). fill(n, v) builds a list of n copies of v.

A list has exactly one owner. Assigning a list binding to another binding, storing it in a field or another list, or returning a parameter list is an error (E0328): write xs.copy() for an independent copy or xs.take() to move the contents out and leave xs empty. Passing a list to a function lends it for the duration of the call. Growing or shrinking a list and writing an element require a var binding (E0235). rc [T] is a shared list (section 6.3). The list methods are listed in section 9.3.

A fixed array and a bounded vector are values, like objects: their elements live inline, and assigning one, passing it, returning it or storing it in a field or a list copies the elements, so a write through one binding is never seen through another. filter on either returns a list [T]; copy() and take() exist only on lists.

func main() {
    var xs: [Int] = []
    xs.push(3)
    xs.push(1)
    xs.insert(0, 9)
    xs[1] = 30
    println("{xs.len()} {xs[0]} {xs[1]} {xs.contains(1)}")
    val fixed: [Int; 3] = [7, 8, 9]
    var bounded: [Int; <=4] = [1, 2]
    bounded.push(3)
    val zeros = fill(4, 0)
    println("{fixed[1]} {len(fixed)} {bounded.len()} {zeros.len()}")
    var other = fixed
    other[1] = 80
    println("{fixed[1]} {other[1]}")
    val dup = xs.copy()
    val moved = xs.take()
    println("{dup.len()} {moved.len()} {xs.len()}")
    val grid: [[Int]] = [[1, 2], [3]]
    println("{grid[0][1]} {grid.len()}")
}
3 9 30 true
8 3 3 4
8 80
3 3 0
2 2

A list type may carry attributes after the element type: [T, layout: soa] stores an object list field by field (structure of arrays) and [T, layout: aos] element by element (the default); either is used the same way. [I, unordered], for an interface type I, keeps the elements grouped by concrete type instead of in insertion order and cannot be indexed (E0329); iteration visits every element.

A layout: soa list of an object type keeps one column per field, all in one allocation: the values of the first field of every element, then those of the next field, and so on. Every list operation gives the same result, and the same panic at the same line, as for the default layout; what changes is where the bytes are:

For an element type that is not an object (numbers, String, enums) the attribute is accepted and changes nothing. Fixed arrays and bounded vectors take no attributes by definition, although the compiler currently accepts and ignores them there (#841). Traversing every field of every element is not faster in a layout: soa list; use it for lists whose hot loops read a few fields of many elements. The debugger formatters show the elements of either layout.

object Particle {
    var x: Float
    var y: Float
    init(x: Float, y: Float) {
        self.x = x
        self.y = y
    }
}

interface Drawable {
    readonly func draw() -> String
}

object Dot impl Drawable {
    val id: Int
    readonly func draw() -> String {
        return "dot{self.id}"
    }
}

object Square impl Drawable {
    val id: Int
    readonly func draw() -> String {
        return "square{self.id}"
    }
}

func main() {
    var ps: [Particle, layout: soa] = []
    ps.push(Particle(1.0, 2.0))
    ps.push(Particle(3.0, 4.0))
    var sum = 0.0
    for p in ps {
        sum += p.x
    }
    println("{sum} {ps.len()}")
    val shapes: [Drawable, unordered] = [Dot(1), Square(2), Dot(3)]
    for s in shapes {
        print("{s.draw()} ")
    }
    println("")
}
4 2
dot1 dot3 square2 

2.3.1 Borrowed views

View[T] is a read-only borrowed window over contiguous elements and ViewMut[T] is writable. Bytes is View[UInt8]. A view is a two-word value containing a pointer and a length; making or slicing one does not copy its elements.

Use s.bytes() for a String, xs.view() for a list, fixed array or bounded vector, and xs.view_mut() for a writable view of a var collection. v.slice(start, end) checks 0 <= start <= end <= v.len() once. Indexing and iteration use the same checked rules as lists. There is deliberately no unchecked view access. The optimizer removes proven checks from ordinary loops, and --report-bounds-checks lists those that remain.

Views have these methods:

Method Description
len() -> Int, slice(a, b) -> Self length and checked subview
index_of(x) -> Int first index, or -1
starts_with(v), ends_with(v), equals(v) comparison with a read-only view
to_list() -> [T] copy the elements
fill(x), copy_from(v) write a ViewMut; the source must fit
as_view() -> View[T] read-only form of a ViewMut

Bytes also has to_string() (a lossy UTF-8 copy) and load_u64_le_or(i, fill), which reads up to eight little-endian bytes and uses fill past the end. String.from_utf8(bytes) -> String? copies valid UTF-8 and returns null for invalid bytes. A String, list, fixed array or bounded vector is lent automatically to a compatible view parameter.

func middle(input: Bytes) -> Bytes {
    return input.slice(1, input.len() - 1)
}

func main() {
    val bytes = "hello".bytes()
    println("{middle(bytes).to_string()} {bytes[0]}")
    var xs = [1, 2, 3]
    val writable = xs.view_mut()
    writable[1] = 9
    println("{xs[1]}")
}
ell 104
9

A view cannot be stored in a field, global, list, map, nullable, tuple, closure or detached task (E0369). A function may return a view only when it has exactly one view, String or list parameter, and the result comes from that parameter. While a view is live, its source cannot be reassigned, resized, taken, passed to a var parameter or mutated directly (E0370); writes go through ViewMut. Liveness ends at the view's last lexical use. A temporary and a list or String reached through a heap, rc or pointer handle cannot be borrowed.

2.4 Tuples

(T1, T2, ...) with at least two element types is a tuple type. A tuple value is written (a, b). Tuples are used to return several values; they are taken apart with a destructuring val (section 4.2) and have no other operations (there is no positional access). Where a tuple type is expected (a return, an argument, an annotated binding), each element of a tuple literal converts to its component as it would to a binding of that type: a plain T or null fills a T? component (return (null, "none") from a function returning (Int?, String)). A tuple that holds a list, map or set (also inside an object or an optional component) is taken apart only where it is made, val (a, b) = f(): destructuring a binding, parameter, field or element holding one would give the list a second owner, and is an error like any implicit duplication (E0328, section 6.8).

func divmod(a: Int, b: Int) -> (Int, Int) {
    return (a / b, a % b)
}

func main() {
    val (q, r) = divmod(17, 5)
    val (_, only_r) = divmod(9, 4)
    println("{q} {r} {only_r}")
}
3 2 1

2.5 Function types

func(T1, T2) -> R is the type of functions and lambdas taking T1 and T2 and returning R; omit -> R for a function that returns nothing. Function values are created from lambda expressions (section 3.7); they can be stored in bindings, fields and lists and passed as arguments, and a function type can be a type argument (Channel[func(Int) -> Int](4), Map[String, func(Int) -> Int](), section 13). The name of a top-level function is a function value too (val f = twice, apply(twice, 4), xs.map(twice)).

2.6 Objects, enums and interfaces

Named types are declared with object (section 5.3), enum (5.6) and interface (5.5). An object is a value type: assigning it or passing it by value copies its fields. An interface type holds any object that implements the interface and dispatches method calls to that object.

2.7 Pointer and lifecycle types

These types are part of the memory model and are described in section 6:

Type Meaning
*T, *var T a checked pointer to a T (writable through *var T)
?*T a nullable checked pointer
*unchecked T, *var unchecked T an unchecked (raw) pointer for FFI and Box
rc T a strong reference-counted handle to a shared T
weak rc T, weak rc T? a weak handle that does not keep the T alive
rc [T] a shared, reference-counted list
Box[T] an owned heap cell that hands out unchecked pointers

heap is not a type: a heap T(...) expression produces a value of type T whose storage is on the heap and whose binding must be freed (section 6.2). Writing heap in a type annotation is an error (E0273).

2.8 Generics

Functions, objects and enums may take type parameters in square brackets: func largest[T](xs: [T]) -> T, object Pair[A, B], enum Result[T, E]. A parameter may carry an interface constraint, [T: Hashable], which every type argument must satisfy (E0206). Type arguments are inferred from the arguments of a call or construction (Pair(1, "one")) or written on the type (Stack[Int](), val r: Result[Int, String]). A type argument written in an expression may be any type: a function type, a tuple, an rc, weak rc or pointer type (id[func(Int) -> Int](twice), Box[rc Node](n), Stack[(Int, String)](), p.first[*Node](a, b)). Generic code is instantiated per set of type arguments at compile time. A type parameter's constraint also provides its static functions, called on the parameter itself: T.parse(text) in func read[T: Parse](text: String) -> T? (section 5.5).

func largest[T](xs: [T], less: func(T, T) -> Bool) -> T {
    var best = xs[0]
    for x in xs {
        if less(best, x) {
            best = x
        }
    }
    return best
}

object Pair[A, B] {
    val first: A
    val second: B
}

object Stack[T] {
    var items: [T]
    init() {
        self.items = []
    }
    func push(x: T) {
        self.items.push(x)
    }
    func pop() -> T {
        return self.items.pop()
    }
    readonly func size() -> Int {
        return len(self.items)
    }
}

func main() {
    val big = largest([3, 9, 4], func(a: Int, b: Int) -> Bool { return a < b })
    val p = Pair(1, "one")
    var st = Stack[Int]()
    st.push(1)
    st.push(2)
    println("{big} {p.first} {p.second} {st.pop()} {st.size()}")
}
9 1 one 2 1

A generic enum value takes the type arguments its payload does not fix from where it is used: return Maybe.Nothing in a function returning Maybe[Int], an argument, an assignment or an annotated binding.

The same holds for a generic construction or call: type arguments that neither are written nor follow from the arguments come from the expected type, the type the value is checked against. That is the annotation of a val or var, the target of an assignment, the parameter at a call (functions, methods, constructors), the return type of the enclosing function, the field in an object construction, or the element of a list or tuple whose type is known; an rc or T? expected type gives its inner type. This covers the library containers (Channel, Mutex, RWLock, Box, Map, Set), generic objects and generic functions whose type parameters appear in the return type. Written type arguments and those the arguments fix are never replaced: when they disagree with the expected type the program is rejected with a type mismatch naming both (E0202).

object Stack[T] {
    var items: [T]
    init() {
        self.items = []
    }
}

func empty[T]() -> [T] {
    return []
}

func counts() -> Map[String, Int] {
    return Map()
}

func size(s: Set[Int]) -> Int {
    return s.len()
}

func main() {
    val ch: Channel[Int] = Channel(4)
    var seen: Set[String] = Set()
    val st: Stack[Int] = Stack()
    val xs: [Int] = empty()
    val shared: rc Map[String, Int] = rc Map()
    ch.send(len(xs) + len(st.items))
    println("{ch.recv()} {seen.add("a")} {counts().len()} {size(Set())} {shared.len()}")
}
0 true 0 0 0

2.9 Type conversions

Calling a primitive type name like a function converts a value: Int(x), Float(x), Int32(x), UInt8(x) and so on. The source must be a numeric type or Bool (E0310; Bool itself is not a conversion target); float to integer truncates toward zero, and Int(true) is 1. A float outside the target's range converts to the type's largest or smallest value and NaN converts to 0 (Int8(x) with x = 300.5 is 127). A conversion to a narrower integer type warns (W0601) and a constant that does not fit, integer or float, is an error (E0311). Numbers are converted to strings with n.to_string() or by interpolation; strings are parsed with s.to_int() and s.to_float(). There is no String(x).

func main() {
    val f: Float = 3.99
    val n = Int(f)
    val small: Int8 = 100
    val u: UInt8 = 250
    val total = Int(small) + Int(u)
    println("{n} {Int(-3.99)} {Int(true)} {Float(n) / 2.0} {total}")
    println("{"42".to_int() + 1} {"2.5".to_float() * 2.0} {n.to_string()}")
}
3 -3 1 1.5 350
43 5 3

3. Expressions

3.1 Operators and precedence

Binary operators are left associative. From lowest to highest precedence:

Level Operators
1 is
2 or
3 and
4 \|
5 ^
6 &
7 == !=
8 < > <= >=
9 << >>
10 + -
11 * / %
12 unary - not ~ & heap rc weak rc comptime await
13 postfix .name [i] [a..b] (args)

is binds loosest of all: not s is Circle parses as (not s) is Circle and is a type error; write not (s is Circle). Comparison binds tighter than equality, so 1 <= 2 == true is (1 <= 2) == true. and and or short-circuit.

func main() {
    println("{2 + 3 * 4 - 8 / 2 % 3}")
    println("{1 << 4 | 1} {6 & 3 ^ 1} {~0} {-7 / 2} {-7 % 3}")
    println("{1 < 2 and 2 < 3 or false} {not true} {1 <= 2 == true}")
}
13
17 3 -1 -3 -1
true false true

3.2 Arithmetic, comparison, logical and bitwise operators

+ - * / % apply to two operands of the same numeric type. + also concatenates two strings. == != compare numbers, booleans, strings (by content), enum values and null. Two enum values are equal when they are the same variant with equal payloads; an enum with an object or list in a payload compares only against a unit variant (shape == Shape.Empty). Objects and lists have no ==: compare their fields or elements. < > <= >= compare numbers, and strings in code point order. and, or, not take Bool operands. & | ^ ~ << >> take integer operands (E0233) of any width; & | ^ need both of one type, and a shift has the type of its left operand. A shift by the operand's width or more gives 0 for << and the sign (0 or -1) for >>; a shift by a negative count is a panic. Compound assignments += -= *= /= &= |= ^= <<= >>= are statements (section 4.3).

3.3 String interpolation and concatenation

Inside a string literal, {e} evaluates e and inserts its text. Any value that has a text form may be interpolated: numbers, booleans, strings, enums (the variant name), objects with a readonly func to_string() -> String method (through that method, also behind rc), the nullable forms of all of these (null when there is no value; for an object T? or rc T?, to_string() runs only when there is a value), and the results of calls and field accesses. Interpolating a value with no text form, such as an object without to_string(), is an error (E0334). Floats print the shortest decimal that reads back to the same value, without a trailing .0 (3.0 prints as 3). Two strings are joined with +, and s += t appends to a var string.

func main() {
    val f = 3.0
    val name = "Iron"
    val s = "a" + "b" + "c"
    println("{name}: {f} {f / 4.0} {s} {s == "abc"} {true}")
}
Iron: 3 0.75 abc true true

3.4 Calls, methods, fields and indexing

f(a, b) calls a function or a function value; arguments are positional (E0101 names named arguments as unsupported) and a trailing comma is allowed. x.field reads a field, x.method(args) calls a method, Type.method(args) calls a named init, a static function (section 5.3) or a standard library function, and Type(args) constructs an object (section 5.3). xs[i] indexes a list, array or string (a string index yields a one-character string), and xs[a..b] takes the characters a (inclusive) to b (exclusive) of a string; xs[a..] runs to the end. On a list, xs[a..b] and xs[a..] return a new list of those elements.

A pointer, rc handle, heap value or Box cell is accessed with the same . syntax as the value it refers to (auto-dereference); there is no prefix * operator.

3.5 List and tuple literals

[a, b, c] is a list literal (a trailing comma is allowed and elements may be spread over lines); [T; n] is a typed array literal used to declare fixed arrays; (a, b) with two or more elements is a tuple literal.

3.6 Enum values

Color.Red names a unit variant and Shape.Circle(1.0) constructs a variant with a payload. Enum values are compared with == and taken apart with match (section 4.7).

3.7 Lambdas and closures

func(params) [-> T] { body } in expression position is a lambda. When the lambda is passed directly to a parameter of function type, its parameter and return types are taken from that parameter and may be omitted; everywhere else the parameter types must be written (E0324) and a lambda without -> T returns nothing, whatever its body does. A lambda may refer to bindings of the enclosing function. A val binding is captured by value. A var binding that a lambda captures moves into a reference counted cell shared by the enclosing function and every closure that captured it: mutations inside the lambda are visible outside, and the closure may outlive the function that declared the var (a counter closure keeps its count). A lambda may capture a list only when it is passed directly as a call argument (E0328 otherwise); to share a list with a stored closure use rc [T]. A nocopy value follows the same borrow rule (6.7): a lambda may capture one only when it is passed directly as an argument to a callee that just calls it during the call; it then reads the caller's binding, and the value is dropped once, by that binding (E0286 otherwise). A callee keeps the closure, and the capture is rejected, when it stores the parameter, returns it, hands it to a callee that keeps it, or is a constructor, push, insert or a function without an Iron body; the list methods map, filter, reduce and forEach only call it. The environment of a closure is reference counted, so a closure may be returned, stored in a field or list, and copied. A recursive lambda is written by assigning it to the var it calls through (fact = func(n: Int) -> Int { ... fact(n - 1) }); the cell and the closure then refer to each other, and the runtime frees the pair once nothing else reaches either. Two closures that call each other through two such vars are not reclaimed.

func make_adder(n: Int) -> func(Int) -> Int {
    return func(x: Int) -> Int { return x + n }
}

func apply_twice(f: func(Int) -> Int, x: Int) -> Int {
    return f(f(x))
}

object Button {
    val on_click: func(Int) -> String
}

func main() {
    val add5 = make_adder(5)
    println("{add5(1)} {apply_twice(add5, 0)}")
    println("{apply_twice(func(x) { return x * 3 }, 2)}")
    val handlers: [func(Int) -> Int] = [add5, add5]
    println("{handlers[0](4)}")
    val b = Button(func(n: Int) -> String { return "clicked {n}" })
    println(b.on_click(3))
    var local = 1
    val bump = func() { local += 10 }
    bump()
    println("{local}")
    val xs = [1, 2, 3]
    val ys = [2]
    val common = xs.filter(func(x: Int) -> Bool { return ys.contains(x) })
    println("{common.len()}")
}
6 10
18
9
clicked 3
11
1

3.8 Allocation expressions

heap T(args), heap(in: arena) T(args), rc T(args), rc [a, b], weak rc null and x.downgrade() create values with an explicit lifecycle; they are described in section 6. heap and rc may only appear in front of an allocation expression, not in a type annotation, a parameter or a binding keyword position (E0273, E0297); the reserved pool keyword is rejected with E0298. heap requires an object or collection allocation; heap of a number or Bool is a type mismatch (E0202).

3.9 Type tests

x is T is true when the interface value x currently holds an object of type T. Inside the if block guarded by x is T (and after an early return in the other branch), x is narrowed and the fields and methods of T are available. The operand must be an interface or object value (E0322).

interface Shape {
    readonly func area() -> Float
}

object Circle impl Shape {
    val r: Float
    readonly func area() -> Float {
        return 3.0 * self.r * self.r
    }
}

object Square impl Shape {
    val side: Float
    readonly func area() -> Float {
        return self.side * self.side
    }
}

func describe(s: Shape) -> String {
    if s is Circle {
        return "circle of radius {s.r}"
    }
    if not (s is Square) {
        return "unknown"
    }
    return "square with area {s.area()}"
}

func main() {
    println(describe(Circle(1.0)))
    println(describe(Square(2.0)))
}
circle of radius 1
square with area 4

3.10 comptime, spawn and await

comptime e evaluates e at compile time (section 8). spawn("name") { body } starts a thread and yields a handle; await h waits for it and yields the value the body returned (section 7).


4. Statements

4.1 Blocks and scope

A block is { statement* }. Blocks are the bodies of functions, methods, control statements and lambdas, and a bare block may appear as a statement. A binding is visible from its declaration to the end of the enclosing block. Stack values declared in a block are destroyed when the block exits, in reverse declaration order (section 6.1). A binding may shadow a binding of an enclosing scope, except that a match pattern binding may not shadow an existing name (E0227).

4.2 Bindings

val name = expr declares an immutable binding and var name = expr a mutable one. Either may carry a type (val speed: Float = 2.5), which is required when the initializer does not determine it (val xs: [Int] = [], val h = Channel[Int](4)) and lets a literal take another numeric type (val small: Int8 = 100). A var binding may be declared without an initializer and assigned later; reading it before every path has assigned it is an error (E0314).

A val cannot be reassigned (E0203), have a field written (E0234) or have a mutating method called on it (E0235); lists follow the same rule for their mutating methods and index writes. _ as a binding name evaluates the initializer and discards it.

val (a, b) = expr destructures a tuple; each position is a name or _, at least two positions are required, an initializer is mandatory and a type may be written after the pattern (val (a, b): (Int, Int) = pair()).

The initializer of a val or var may be a spawn expression (section 7.1); spawn cannot appear anywhere else inside an expression.

4.3 Assignment

target = expr stores into a var binding, a field of a var object or of a var parameter, an element xs[i] of a var list, or a field reached through a pointer, rc handle or heap binding. The compound forms += -= *= /= &= |= ^= <<= >>= compute target op expr and store it; they are statements, not expressions, and are only defined for numeric operands. Assignment to a val parameter is an error (E0266); declare the parameter var to mutate it (section 5.2).

4.4 if

if cond { ... } elif cond { ... } else { ... }

Conditions must be Bool; the braces are mandatory; elif and else are optional. if x is T narrows x inside the block (section 3.9).

4.5 while

while cond { ... } repeats its block while the condition holds.

break leaves the innermost while or for, and continue starts its next iteration (for a for over range(n), with the next number). Both first run the defers and drop the values of every scope they leave inside the loop body. They apply to the loop of the same function body: one inside a lambda, a defer or a spawn block within the loop, or outside any loop, is an error, and so is one in a parallel loop, whose iterations run independently (E0335). A match arm may be a break or continue.

func main() {
    var found = -1
    for x in [4, 7, -2, 9, -5] {
        if x > 0 {
            continue
        }
        found = x
        break
    }
    var n = 0
    while true {
        n += 1
        if n == 3 {
            break
        }
    }
    println("{found} {n}")
}
-2 3

4.6 for

for name in iterable { ... } binds name to each element in turn. The iterable may be a list, fixed array, bounded vector or rc [T] (elements), a string (one-character strings), a Set[T] (items) or range(n) (the integers 0 to n - 1); a Map[K, V] is iterated with two names, for (key, value) in m (section 9.10). The loop variable is immutable inside the body. range takes exactly one argument and is written only as the iterable of a for loop; a loop does not iterate over an integer (for i in 10 is an error: write for i in range(10)), nor over any other value (E0202). Appending parallel after the iterable runs the iterations on several threads (section 7.4).

A loop over a growable list reads the elements in place, so the list must keep them where they are until the loop is done. The body may assign to elements (xs[i] = v) and change other lists, but adding to the iterated list, removing from it or replacing it in the body is an error (E0346): collect the changes and apply them after the loop, loop over xs.copy(), or use a while loop over indices. Where the compiler cannot see the change (a function or closure the body calls, another rc handle, a list reached through a field), the list is pinned while the loop runs: growing it past its capacity, removing, inserting, popping or clearing elements, or freeing it aborts with list changed while a pointer to one of its elements is in use. The pin ends on every way out of the loop.

func main() {
    var total = 0
    for n in [1, 2, 3] {
        total += n
    }
    for i in range(3) {
        total += i
    }
    var k = 0
    while k < 3 {
        k += 1
    }
    for c in "héy" {
        print("[{c}]")
    }
    println("")
    if total > 100 {
        println("big")
    } elif total > 5 {
        println("medium: {total} {k}")
    } else {
        println("small")
    }
}
[h][é][y]
medium: 9 3

4.7 match

match subject {
    pattern -> statement
    pattern -> { statements }
    else -> statement
}

The subject must be an enum value, an integer, or an interface value (E0323; String, Bool and Float subjects are rejected). Each arm is a pattern, ->, and either a single statement or a block. The optional else arm must come last. match is a statement; arms usually return or print.

Patterns:

A match on an enum or interface must cover every variant or implementor, or have an else arm (E0224); an arm that can never match is an error (E0226). A nested payload pattern such as Outer.Some(Inner.Circle(r)) is rejected (E0332) when the inner enum has more than one variant, since the inner pattern is not tested at run time: bind the payload and match it in a second match.

enum Shape {
    Circle(Float),
    Rect(Float, Float),
    Empty,
}

interface Animal {
    readonly func sound() -> String
}

object Dog impl Animal {
    val name: String
    readonly func sound() -> String {
        return "woof"
    }
}

object Cat impl Animal {
    val lives: Int
    readonly func sound() -> String {
        return "meow"
    }
}

func area(s: Shape) -> Float {
    match s {
        Shape.Circle(r) -> return 3.0 * r * r
        Shape.Rect(w, h) -> return w * h
        Shape.Empty -> return 0.0
    }
    return 0.0
}

func size(n: Int) -> String {
    match n {
        0 -> return "zero"
        1 -> return "one"
        else -> return "many"
    }
    return "unreachable"
}

func main() {
    println("{area(Shape.Rect(2.0, 3.0))} {area(Shape.Empty)} {area(Shape.Circle(1.0))}")
    println("{size(1)} {size(7)}")
    val pets: [Animal] = [Dog("Rex"), Cat(9)]
    for a in pets {
        match a {
            Dog(d) -> println("{d.name} says {d.sound()}")
            Cat(c) -> {
                val lives = c.lives
                println("cat with {lives} lives says {c.sound()}")
            }
        }
    }
}
6 0 3
one many
Rex says woof
cat with 9 lives says meow

Generic enums work the same way, with the two extra steps noted in section 2.8:

enum Result[T, E] {
    Ok(T),
    Err(E),
}

func parse_positive(n: Int) -> Result[Int, String] {
    if n > 0 {
        val ok: Result[Int, String] = Result.Ok(n)
        return ok
    }
    val err: Result[Int, String] = Result.Err("not positive")
    return err
}

func main() {
    val r: Result[Int, String] = parse_positive(-1)
    match r {
        Result.Ok(v) -> {
            val n: Int = v
            println("ok {n}")
        }
        Result.Err(e) -> {
            val msg: String = e
            println("error: {msg}")
        }
    }
}
error: not positive

4.8 return

return expr leaves the function with a value; return alone leaves a function without a result type. Every path of a function with a result type must return (E0293), and the value must have the declared type (E0215). A return inside an init may not carry a value (E0252) and may not leave fields unassigned (E0250).

4.9 defer

defer statement and defer { block } schedule the statement to run when the enclosing block exits, whether by falling off its end or by return. Deferred statements run in reverse order of registration, after the block's own statements and before its stack values are destroyed. Inside a loop body, deferred statements run at the end of each iteration. The most common form is defer free x for a heap value (section 6.2). A deferred body runs while its block exits and cannot leave it: return inside it is an error (E0336), as are break and continue (E0335). A lambda inside a deferred body may return from itself.

func main() {
    {
        defer {
            println("cleanup 2")
        }
        defer println("cleanup 1")
        println("body")
    }
    var i = 0
    while i < 2 {
        defer println("end of iteration {i}")
        i += 1
    }
    println("done")
}
body
cleanup 1
cleanup 2
end of iteration 1
end of iteration 2
done

4.10 free and leak

free x releases the heap value bound to x and leak x marks it as intentionally never freed; both are described in section 6.2.

4.11 in arena { ... }

in a { ... } makes a (an Arena) the default allocator for every heap expression inside the block (section 6.6).

4.12 Expression statements

A call, method call or spawn expression may stand as a statement. Any other expression on its own is accepted by the parser but has no effect.


5. Declarations

A program is a sequence of top-level declarations: imports, functions, objects, patches, interfaces, enums and global bindings. Declarations may appear in any order and are visible throughout the file. Most become visible throughout the package when pub; section 5.10 records the current exceptions.

5.1 The entry point

A binary program has exactly one main, with no result type, written either func main() or func main(args: [String]) to receive the command-line arguments after the program name (UTF-8, iron run file.iron -- a b passes a and b); any other parameter list is an error (E0202). Execution starts there and the process exits with status 0 when main returns. A run-time failure (index out of range, division by zero, failed assert, stale pointer) prints a message to standard error and aborts.

func main() {
    val samples = [10, 20, 30, 40, 50]
    var total: Int = 0
    for sample in samples {
        total += sample
    }
    println("processed={len(samples)} total={total}")
}
processed=5 total=150

5.2 Functions

func name[T, U](p1: T1, var p2: T2) -> R { body }

Parameters are val by default: a parameter cannot be reassigned or mutated (E0266, E0234). A parameter declared var is a copy that the body may assign to and mutate; when the function returns, the final value is written back to the caller's var binding, so var parameters behave like in-out arguments; the same holds for a var parameter of a method or an init. The argument is a var binding, or a mutable field or list element of one (E0267 for anything read-only). The original place stays where it is for the call and receives the final value: the list it is in is pinned and the heap, arena or pointed-to value it is in is kept alive, so changing that list, or freeing or resetting that value, aborts while the call runs (section 6.4). A list parameter is lent (never copied); a var list parameter lets the body grow or shrink the caller's list. An object parameter is lent for the call; it is not copied and its copy or drop block does not run in the callee. The result type follows ->; omit it for a function that returns nothing. Argument count and types must match exactly (E0216, E0217). Functions cannot be overloaded (E0201) and there are no default or named arguments. Type parameters are written after the name (section 2.8).

object Point {
    var x: Int
    init(x: Int) {
        self.x = x
    }
    func bump() {
        self.x += 1
    }
}

func by_value(p: Point) -> Int {
    return p.x + 100
}

func by_var(var p: Point) {
    p.x = 99
    p.bump()
}

func set_seven(var n: Int) {
    n = 7
}

func grow(var xs: [Int]) {
    xs.push(4)
}

func main() {
    var a = Point(1)
    println("{by_value(a)} {a.x}")
    by_var(a)
    var n = 1
    set_seven(n)
    var xs = [1, 2, 3]
    grow(xs)
    println("{a.x} {n} {xs.len()}")
}
101 1
100 7 4

A top-level function name is a function value: val f = twice, apply(twice, 4) and [twice] work like the equivalent lambdas. Compiler built-ins such as print, println, len, range and fill are calls, not values: naming one without calling it is E0202.

Compiler hints on the line directly above a function are //iron:fusible, //iron:inline, //iron:noinline and //iron:cold. They respectively allow list-loop fusion, require inlining, prevent inlining, and put an unlikely slow function on the backend's cold path. inline and noinline together are an error; recursive inline warns with W0631 because it cannot be honored everywhere. The older @fusible means //iron:fusible and is accepted while code migrates (section 1.2).

The compiler hint //iron:fusible on the line directly above a func or a method is a checked claim: the function can run inside a fused list loop (section 9.3.2), which means that it has no observable effect and cannot panic. The compiler verifies the claim when it builds the program and reports warning W0618, with the reason, for a function that does not keep it. The hint changes nothing else: fusion is decided by the same proof for every callback, hinted or not, and a program prints the same with its hints removed. The older @fusible before func means the same and is accepted while code migrates (section 1.2).

5.3 Objects

object Name[T] impl Interface1, Interface2 {
    val immutable_field: Type
    var mutable_field: Type
    pub var exported_field: Type

    init(params) { self.field = ... }
    init named(params) { ... }

    func mutating(...) -> R { ... }
    readonly func observer(...) -> R { ... }
    pure func computation(...) -> R { ... }

    copy { ... }
    drop { ... }
}

An object is a value type made of named fields. Every field is declared with val or var and a type (E0176); a field cannot have an inline default value (E0262). An object is constructed by calling its name with the arguments of its anonymous init: Name(args). When an object declares no init, it must have only val fields (E0264) and is constructed positionally, one argument per field in declaration order.

Initializers. init(params) { ... } is the anonymous initializer and init name(params) { ... } a named one, called as Name.name(args). The body assigns every field through self exactly once for val fields (E0248) and at least once for var fields on every path (E0247); it may not read a field before assigning it (E0246), call a method on the partially built self (E0249), return early (E0250), delegate to another init (E0251) or return a value (E0252). An object has at most one anonymous init (E0201). Name.init(args) is an explicit spelling of Name(args).

//iron:align=N directly above an object, field, local val or local var raises its alignment to a power of two from 1 through 4096. //iron:align=cacheline means 64 bytes, or 128 on Apple arm64. An alignment below the natural alignment is E0368. Stack values, fields, fixed arrays and list element storage honor over-alignment. Over-aligned heap, rc, arena, interface, closure and spawned-task storage is not implemented yet and is also E0368; it is deferred until the generation-table allocator work is merged.

Methods are declared inside the object body with func, take an implicit receiver self of the object type, and are called as value.method(args). A method has one of three tiers:

Tier May write self May call I/O
func (default, mutating) yes anything yes
readonly func no (E0238) readonly and pure methods (E0239) no (E0278)
pure func no (E0244) pure methods only (E0242) no (E0240)

readonly and pure methods may be called on val bindings; a mutating method needs a var binding (E0235). A pure method may also not write its parameters (E0243) or read a global var (E0241). The modifiers are only valid inside object, patch and interface bodies (E0245), and init takes none.

Static functions. static func name(params) -> R { body } declares a function of the type rather than of a value: it has no receiver (self in its body is E0210), takes no tier modifier (E0245), and is called on the type, Name.name(args). Calling it on a value is E0350; calling a method through the type name, Name.method(args), is E0351, because there is no value for self. A static function may be pub, may take type parameters, and may be added by a patch object. A generic object cannot declare one (E0245): no value fixes its type arguments. Two members of one name, methods or static functions, are E0201.

self and Self. Inside a method self is the receiver; outside a method it is an error (E0210). The identifier Self names the enclosing object type: it may be used as a type anywhere in a method or static function (Self, Self?, [Self]), and Self(args) and Self.f(args) construct and call through the enclosing type.

Visibility. Fields and methods are private to the file that declares the object by default. pub on a field synthesizes a getter named after the field and, for pub var, a setter set_<field>; call sites keep using obj.field and obj.field = v, and a user method with one of those names is an error (E0237). pub on a method or init exports it; pub init is only allowed inside a pub object. See section 5.10 for cross-file rules.

copy and drop blocks run when a value is copied or destroyed (section 6.7); nocopy object forbids copying (section 6.7).

import math

object Vec2 {
    val x: Float
    val y: Float

    init(x: Float, y: Float) {
        self.x = x
        self.y = y
    }

    init zero() {
        self.x = 0.0
        self.y = 0.0
    }

    static func unit_x() -> Self {
        return Vec2(1.0, 0.0)
    }

    readonly func length() -> Float {
        return Math.sqrt(self.x * self.x + self.y * self.y)
    }

    readonly func scaled(k: Float) -> Self {
        return Vec2(self.x * k, self.y * k)
    }
}

object Account {
    pub var balance: Int
    pub val id: Int
    var history: Int

    init(id: Int) {
        self.id = id
        self.balance = 0
        self.history = 0
    }

    func deposit(amount: Int) {
        self.balance += amount
        self.history += 1
    }

    pure func fee(amount: Int) -> Int {
        return amount / 100
    }
}

object Point {
    val x: Int
    val y: Int
}

func main() {
    val v = Vec2(3.0, 4.0)
    val origin = Vec2.zero()
    val p = Point(1, 2)
    println("{v.length()} {v.scaled(2.0).x} {origin.y} {Vec2.unit_x().x} {p.x},{p.y}")
    var acc = Account(7)
    acc.deposit(50)
    acc.balance = 60
    acc.set_balance(acc.balance + 5)
    println("{acc.id} {acc.balance} {acc.fee(250)}")
    var copy_of_acc = acc
    copy_of_acc.deposit(1)
    println("{acc.balance} {copy_of_acc.balance}")
}
5 6 0 1 1,2
7 65 2
65 66

5.4 patch object

patch object Name { ... } adds methods and initializers to an existing object declared in the same package or in the standard library. A patch may declare impl Interface to make the type conform; it may not add fields (E0253), patch an unknown type (E0254), redefine an existing method (E0255) or take type parameters. Methods added by a patch have the same tiers and visibility rules as methods declared in the object body.

object Celsius {
    val degrees: Float
}

interface Describable {
    readonly func describe() -> String
}

patch object Celsius impl Describable {
    readonly func describe() -> String {
        return "{self.degrees} C"
    }

    init freezing() {
        self.degrees = 0.0
    }
}

patch object String {
    readonly func shout() -> String {
        return self.upper() + "!"
    }
}

func main() {
    val c = Celsius.freezing()
    println("{c.describe()} {"hey".shout()}")
}
0 C HEY!

5.5 Interfaces

interface Name {
    readonly func required() -> R
    pure func also_required(x: T) -> R
    func with_default() -> R { body }
    static func make(x: T) -> Self?
    static func with_default_too() -> R { body }
}

An interface lists method signatures, each with a tier modifier. An object conforms by naming the interface after impl and defining every listed method without a default body (E0205) with a tier at least as strict as the interface's (E0257; a pure method satisfies a readonly signature). A signature with a body is a default: an implementor that does not define the method inherits it, and may override it. Interfaces may not declare init (E0256), and the keyword is impl, not implements. An interface may have any number of implementors; a value of interface type can be built from any of them and dispatches calls at run time. Interfaces may be the element type of lists and the type of fields and parameters, and are the subjects of is (section 3.9) and type match (section 4.7). A var binding of interface type mutates the object it holds in place.

The implementing function must have the signature's shape: the same parameters (types and var) and result (E0353), and static exactly where the interface says static (E0352). In a signature Self stands for the implementing type, anywhere a type can be written: readonly func same(o: Self) -> Bool is implemented as readonly func same(o: Point) -> Bool (or with Self again). A default body may use Self the same way (Self(args), Self.make(args), var out: [Self] = []); it is copied into each implementor with Self meaning that implementor.

Static functions in an interface belong to the implementing types: static func parse(text: String) -> Self? is implemented by a static func parse in each implementor (or by the default body). A static function is called on a type: on an implementor (Point.parse(t)), or in a generic function on a type parameter constrained by the interface, T.parse(t), which each instance turns into a direct call. Calling a name on a type parameter that its constraint does not declare as a static function is E0355.

A value of interface type dispatches at run time on the object it holds, so only methods it can dispatch are callable through it: a static function, a method with a parameter of type Self (the value may hold a different implementor than the argument) and a method whose result is built from Self other than a plain -> Self are E0354 there. They are callable on the concrete type or through a constrained type parameter, which is how such interfaces are meant to be used:

interface Parse {
    static func parse(text: String) -> Self?
    readonly func same(o: Self) -> Bool
    static func label() -> String {
        return "parsed"
    }
}

object Pt impl Parse {
    val x: Int
    val y: Int
    static func parse(text: String) -> Self? {
        val parts = text.split(",")
        if parts.len() != 2 {
            return null
        }
        return Pt(parts[0].len(), parts[1].len())
    }
    readonly func same(o: Pt) -> Bool {
        return o.x == self.x and o.y == self.y
    }
}

func parse_all[T: Parse](texts: [String]) -> [T] {
    var out: [T] = []
    for t in texts {
        val v = T.parse(t)
        if v != null {
            out.push(v)
        }
    }
    return out
}

func main() {
    val pts = parse_all[Pt](["a,bb", "nope", "ccc,d"])
    println("{pts.len()} {pts[0].same(Pt(1, 2))} {Pt.label()}")
}
2 true parsed
interface Shape {
    readonly func area() -> Float
    readonly func name() -> String {
        return "shape"
    }
}

object Square impl Shape {
    val side: Float
    readonly func area() -> Float {
        return self.side * self.side
    }
}

object Circle impl Shape {
    val r: Float
    readonly func area() -> Float {
        return 3.0 * self.r * self.r
    }
    readonly func name() -> String {
        return "circle"
    }
}

func total(shapes: [Shape]) -> Float {
    var t = 0.0
    for s in shapes {
        t += s.area()
    }
    return t
}

func main() {
    val shapes: [Shape] = [Square(2.0), Circle(1.0)]
    println("{total(shapes)}")
    for s in shapes {
        println("{s.name()} {s.area()}")
    }
}
7
shape 4
circle 3

Hashable is the one interface the standard library defines (pure func hash() -> Int and pure func equals(other: Hashable) -> Bool); the integer types, Bool and String satisfy it without declaring impl.

5.6 Enums

enum Name[T] {
    Unit,
    WithValue = 5,
    Payload(T, Int),
}

An enum lists variants separated by commas (a trailing comma is allowed). A variant may carry a payload of one or more types, or an explicit integer value (= 5; a variant without one takes the previous variant's value plus one, the first 0). The value is what a variant of an enum without payloads is at run time, and what C receives through a binding (KeyboardKey.SPACE is 32). Unit variants are written Name.Variant, payload variants are constructed with Name.Variant(args). Enum values are compared with == and inspected with match. Enums have no methods and cannot be converted to integers; put behavior in functions that take the enum. Enums may be generic, and a payload may be the enum type itself (recursive enums).

enum Color {
    Red,
    Green = 5,
    Blue,
}

enum Token {
    Number(Int),
    Word(String),
    End,
}

func show(t: Token) -> String {
    match t {
        Token.Number(n) -> return "number {n}"
        Token.Word(w) -> return "word {w}"
        Token.End -> return "end"
    }
    return ""
}

func main() {
    val c = Color.Green
    if c == Color.Green and c != Color.Blue {
        println("green")
    }
    println("{show(Token.Number(4))} {show(Token.Word("hi"))} {show(Token.End)}")
}
green
number 4 word hi end

5.7 Global bindings

val and var declarations may appear at the top level. A global val is a constant initialized before main runs (its initializer may call functions); a global var is a mutable variable shared by the whole program (and by all threads, without synchronization). Like every other top-level declaration a global is private to its file; pub val and pub var share it with the package's other files (E0320 otherwise). A pure method may not read a global var (E0241).

Like every declaration, a global is visible in the whole file, also above its declaration. Globals are initialized before main, every one of them whether or not anything reads it, and each after the globals its initializer reads: directly, or through the functions, methods and initializers it calls. Globals that do not depend on each other are initialized in declaration order; in a package that is the order iron compiles its files (section 10.2). A global whose initializer depends on the global itself, such as val a = b + 1 and val b = a, or val a = f() with f reading a, is an initialization cycle (E0345).

val AREA = WIDTH * HEIGHT
val WIDTH = 4
val HEIGHT = WIDTH + 1

func main() {
    println("{AREA} {WIDTH} {HEIGHT}")
}
20 4 5
val LIMIT = 10
val ANSWER = compute()
var counter = 0

func compute() -> Int {
    return 6 * 7
}

func bump() {
    counter += 1
}

func main() {
    bump()
    bump()
    println("{LIMIT} {ANSWER} {counter}")
}
10 42 2

5.8 extern func

extern func name(params) -> R declares a C function without a body. The call compiles to a direct C call; the C name is the Iron name with each snake_case segment capitalized and the underscores removed (init_window becomes InitWindow), and a name without underscores is used unchanged. Only functions whose declarations the generated C already includes (the C standard library, and raylib when it is imported) can be called; there is no way to name a header. Int maps to int64_t, Float to double, Bool to bool, String to the runtime string struct and *unchecked T to a raw pointer. Declaring an extern with the name of an Iron built-in is a duplicate declaration (E0201).

extern func labs(x: Int) -> Int
extern func srand(seed: Int)
extern func rand() -> Int

func main() {
    srand(1)
    val r = rand()
    println("{labs(-9)} {r >= 0}")
}
9 true

5.9 Imports

import name makes a standard library module available (section 9), or documents a dependency on a source file of the package: import util matches src/util.iron or any file under a directory named util, and a dotted path import a.b matches consecutive directory components. Importing an unknown module is an error (E0209); an unused import warns (W0611). import x as y is rejected: Iron has no module namespaces, so imported declarations are used by their own names.

The modules that require an import are math, io, time, log, hint, os, atomic, net, http, websocket, url, json, crypto, encoding, mysql and raylib; their functions are called on the capitalized module object (import math, then Math.sqrt(x)). Everything else in section 9 (strings, lists, Box, Arena, Channel, Mutex, RWLock, FileHandle, Hashable, RawPtr) is available without an import.

5.10 Visibility

Functions, objects, patches, enums and global bindings are private to their file unless marked pub; global bindings use pub val and pub var. Interfaces are currently visible package-wide even without pub, while an extern function stays file-private even when written pub extern func (#842). Using any other private declaration from another file of the package is an error (E0320). Object members are private to the declaring file unless marked pub (section 5.3). The word private is not accepted (E0101). Visibility does not make names local: all files of a package share one namespace, so two files may not declare the same top-level name, whether or not either is pub (E0201).


6. Memory

Iron has no garbage collector. Every value has one of five lifecycles, chosen at the point where the value is created: stack (the default), heap, rc, weak rc, and arena. Pointers (*T) and Box[T] refer to values without owning them. The compiler and runtime check the common mistakes: freeing a non-heap value, forgetting to free, escaping a heap value, dereferencing a stale checked pointer.

6.1 Stack values

A binding initialized with a plain expression holds its value directly. Objects are copied on assignment and when stored in lists or fields; each copy is destroyed independently. Passing an object to an ordinary parameter lends it for the call, while a var parameter writes back to its original place (section 5.2). A value is destroyed when its block exits (in reverse declaration order) or, for a temporary, as soon as the expression that used it is done: a field read off a temporary (make().name) copies the field out and then drops the temporary. Destruction runs the object's drop block, if any (section 6.7), then the drops of its fields in reverse declaration order.

object Res {
    val id: Int
    drop {
        println("drop {self.id}")
    }
}

func main() {
    val a = Res(1)
    val b = Res(2)
    {
        val c = Res(3)
        println("inner")
    }
    println("end of main")
}
inner
drop 3
end of main
drop 2
drop 1

6.2 heap, free and leak

heap T(args) allocates the object on the heap and yields a binding that is used exactly like a stack value (fields through ., mutation only when the binding is var). The program must release it with free x, usually scheduled with defer free x, or declare that it is never released with leak x. Heap values are not freed automatically: a heap binding that is neither freed nor leaked warns (W0606) and the memory and the drop block are lost. A heap value cannot leave the function that allocated it: returning it or storing it where it outlives the scope is an error (E0207); to share ownership use rc. free and leak apply only to heap bindings (E0212, E0213, E0274, E0275), and an rc handle cannot be leaked (E0214). Freeing runs drop. Using a value after it was freed, including a second free, is caught at run time by a generation check and aborts the program with a "stale pointer dereference" message.

object World {
    var seed: Int
    init(seed: Int) {
        self.seed = seed
    }
    drop {
        println("drop world {self.seed}")
    }
}

func main() {
    var world = heap World(42)
    defer free world
    world.seed = 43
    println("seed {world.seed}")
    val forever = heap World(7)
    leak forever
    println("leaked {forever.seed}")
}
seed 43
leaked 7
drop world 43
object Node {
    val value: Int
}

func make() -> Node {
    val n = heap Node(1)
    return n
}

func main() {
    val m = make()
}

Inside in arena { ... } (section 6.6) heap allocates from the arena instead and needs no free.

6.3 rc and weak rc

rc T(args) allocates a shared, reference-counted object and yields a handle of type rc T. Copying the handle (assignment, passing, storing in a field or list, capturing in a closure) increments the count; destroying a copy decrements it, and when the last handle goes away the object's drop runs and the memory is released. All handles see the same object, and a field may be written, or a method that changes the object called, through any handle, including a val one. Handles are not nullable (?rc T is rejected, E0297) and cannot be leaked (E0214). The type rc T may be used for fields, parameters and results.

weak rc T is a handle that does not keep the object alive. It is obtained with handle.downgrade() on an rc value or written as the constant weak rc null, and turned back into a usable handle with w.upgrade(), which yields a nullable strong handle, written rc T?, that is null when the object has already been destroyed. A weak handle cannot be dereferenced directly (E0299). Weak handles break reference cycles, for example a child that points back to its parent.

rc [a, b, c] is a shared list: every copy of the handle reaches the same list, it can be grown and indexed through any copy, and it is freed with the last copy. It is the way to share a list with a closure or between fields.

object Player {
    val name: String
    drop {
        println("drop {self.name}")
    }
}

object Owner {
    val name: String
    var pet: weak rc Pet
    init(name: String) {
        self.name = name
        self.pet = weak rc null
    }
}

object Pet {
    val owner: rc Owner
    val nick: String
}

func make_counter() -> func() -> Int {
    val seen: rc [Int] = rc [1, 2, 3]
    return func() -> Int { return len(seen) }
}

func main() {
    val strong = rc Player("Alice")
    val weak_ref = strong.downgrade()
    {
        val another = strong
        val maybe = weak_ref.upgrade()
        if maybe != null {
            println("alive: {maybe.name}")
        }
    }
    val owner = rc Owner("Ann")
    val pet = rc Pet(owner, "Rex")
    owner.pet = pet.downgrade()
    val link: weak rc Pet = owner.pet
    val back = link.upgrade()
    if back != null {
        println("{back.owner.name} owns {back.nick}")
    }
    val shared: rc [Int] = rc [1, 2]
    val alias = shared
    alias.push(3)
    println("{shared.len()} {shared[2]} {make_counter()()}")
}
alive: Alice
Ann owns Rex
3 3 3
drop Alice

6.4 Checked pointers

&x takes the address of a binding, field or fixed array element and yields a checked pointer: *T when x is a val and *var T when x is a var. ?*T is a nullable pointer that may hold null. Fields are read and, through *var T, written with p.field, and methods are called with p.m(), also a String or number method on a pointer to one and a method of an interface value through a pointer to it; a pointer to a primitive prints its pointee when interpolated into a string, but there is no prefix * operator, so it cannot be assigned through. A pointer to a list, a fixed array, a Map or a Set can be stored and passed on, but the collection's methods, indexing and iteration are not available through it (E0220, E0202): reach the collection through a field of the value a pointer refers to (p.items.push(x)) or pass it to a var parameter. Inside a mutating method self may be passed to a pointer parameter, and &self is a pointer to the receiver. Passing a binding to a parameter of type *T takes its address automatically, and a generic parameter written *T infers T from the argument.

Lifetimes. Every pointer refers to storage with a lifetime, and every dereference (a field read or write, a method called through the pointer, a value read out of it, &p.field) checks that the storage is still alive, whatever produced the pointer: an & in the same function, a parameter, a field, a list element, a call result, a capture. A dereference of dead storage aborts the program with a message naming the kind of storage:

The pointer was taken from It stays valid while Message after that
a local (val, var, a parameter, a pattern binding) the local has not been destroyed: its block has not ended, see below dangling stack pointer to frame
a var that a closure captured (section 3.7) the variable's cell exists: the declaring block has not ended, or a closure that captured it is alive stale pointer dereference
a global always
a heap value or a field of one the value has not been freed stale pointer dereference
an arena value or a field of one the arena was not reset, restored to a save point taken before the value, or destroyed (section 6.6) stale pointer dereference (arena)
a field of an rc object some strong handle to the object exists stale pointer dereference
a field of self in a method the receiver is valid (the rules above, for the value the method was called on) as for the receiver
a list element (only as a call argument, below) the call has not returned stale pointer dereference
through another pointer (&p.field) p's storage is valid as for p

The block of a local is its innermost { ... }: a function body, the body of an if or else, a loop body (each iteration has its own locals), a match arm, or a plain block. A pointer kept past the end of its block, in an object, a list, a closure, an enum payload or an interface value that outlives it, dangles even though the function is still running. The same holds for a pointer read on another thread: a task that reads a pointer to a local of a function that has returned panics, and one that is awaited inside the block that owns the local reads it normally. The check is exact for every program without data races; a program that frees or ends storage on one thread while another reads it races, and the race is not guaranteed to be detected.

At the end of a block its defer statements run first, while every local is still alive, and then the locals are destroyed in reverse order of declaration (section 6.7). A drop block that runs then may dereference a pointer to a local declared before its own value, which is still alive (a guard holding a pointer to what it guards), and panics on a pointer to a local declared after it, which is already destroyed. A panic raised while a destructor runs is reported as destructor panicked, followed by the type, the cause (one of the messages above) and the site.

The variable of a for loop is a local of the iteration. A var narrowed from T? by a null check, or to one implementor of an interface by is, holds a value that the next assignment destroys or replaces: its address (explicit, or taken automatically for a *T parameter) can only be a call argument (E0270 otherwise), the pointer lives for that call, and so does a pointer a method called on the narrowed var takes from self. A var that is never assigned again and a narrowed val have no such limit.

Inside a lambda, a captured val is the closure's own copy: a pointer to it taken in the lambda ends when the lambda returns. A captured var is the variable itself (the table above).

List elements. The address of an element of a growable list, or of anything stored inside one (&xs[i], &xs[i].field, &xs[i].inner.field, &xs[i].fixed[0]), is only allowed directly as a call argument (E0330), in either list layout (section 2.3), and so is passing an element to a *T parameter. The pointer lives for that call: a copy the callee keeps is stale once the call returns. While the call runs the list is pinned: growing it past its capacity, removing, inserting, popping or clearing elements, or freeing it, through any handle and on any thread, aborts with list changed while a pointer to one of its elements is in use. A for loop over a list pins it in the same way (section 4.6). A method called on a list element (xs[i].m()) pins the list in the same way when it might change a list.

Receivers. A method called on an rc object read out of a field, a list or a global keeps the object alive until the method returns, even if the method drops the last other handle.

Compile-time rules. Checked pointers support no arithmetic (E0268, E0295); & cannot be applied to a temporary or to a place inside one, such as &make().field (E0270); a pointer to a local cannot be returned directly (E0271; returning the address of a global or of a place reached through a pointer is fine); & on an rc handle is rejected (E0296; &r.field is allowed and checked as above); and &list[i] is restricted as described above (E0330). A checked pointer that is not ?*T is never null: comparing it with null is a constant (W0616).

Limits. A program can have 2^30 heap values, rc objects with a pointer into them and arena segments alive at once, 4095 running threads that have taken the address of a local, and scopes with addressed locals nested 1,048,575 deep; past any of them it aborts with a message naming the limit. No count of allocations, scope entries or threads over the life of the program makes a dead pointer valid again.

object Player {
    var hp: Int
    init(hp: Int) {
        self.hp = hp
    }
}

func observe(p: *Player) -> Int {
    return p.hp
}

func heal(p: *var Player) {
    p.hp += 10
}

func maybe(p: ?*Player) -> Bool {
    return p != null
}

func main() {
    var player = Player(80)
    val view: *Player = &player
    heal(&player)
    println("{observe(player)} {view.hp} {maybe(&player)}")
}
90 90 true

A pointer into the receiver taken by a mutating method, an element borrow for a call, and a pointer to a local of an enclosing block are all valid while their storage is:

object Counter {
    var n: Int
    init(n: Int) {
        self.n = n
    }
    func handle() -> *var Int {
        return &self.n
    }
}

object Slot {
    var p: ?*Int
    init() {
        self.p = null
    }
}

func show(p: *Int) -> String {
    return "{p}"
}

func main() {
    var c = Counter(1)
    val h = c.handle()
    c.n = 5
    println("{h}")

    var xs = [10, 20, 30]
    println(show(&xs[1]))
    xs.push(40)

    var slot = Slot()
    val outer = 7
    {
        val inner = 8
        slot.p = &outer
        println("inner {inner}")
    }
    val q = slot.p
    if q != null {
        println("outer {q}")
    }
}
5
20
inner 8
outer 7
object Res {
    val id: Int
}

func escape() -> *Res {
    val local = Res(1)
    return &local
}

func main() {
    val p = escape()
}

6.5 Unchecked pointers, Box and RawPtr

*unchecked T and *var unchecked T are plain C pointers with no generation check; they exist for the FFI boundary and for Box. Iron code obtains them from Box[T]: Box(value) moves a value into an owned heap cell (Box is nocopy), b.unwrap() returns a *var unchecked T to the contents, b.is_null() tests the cell, b.free() releases it, and Box.null() is an empty cell. Fields are accessed through the pointer with . as usual. Checked and unchecked pointers are distinct types (E0289, E0294) and & never produces an unchecked pointer. RawPtr.of(x) produces a type-erased RawPtr and Ptr.cast[T](raw) turns it back into a *unchecked T.

A pointer to a collection does not expose that collection's methods or indexing. Calling a list, map, set, channel or lock operation through a pointer is E0220; pass or retain the collection value itself instead.

object Config {
    var width: Int
    var title: String
    init(width: Int, title: String) {
        self.width = width
        self.title = title
    }
}

func main() {
    val boxed = Box(Config(640, "Iron App"))
    val cfg: *var unchecked Config = boxed.unwrap()
    cfg.width = 800
    println("{cfg.width} {cfg.title} {boxed.is_null()}")
    boxed.free()
    val empty: Box[Config] = Box.null()
    println("{boxed.is_null()} {empty.is_null()}")
    var n: Int = 7
    val raw: RawPtr = RawPtr.of(n)
    val p: *unchecked Int = Ptr.cast[Int](raw)
    println("{p}")
}
800 Iron App false
true true
7

6.6 Arenas

Arena(bytes) creates a bump allocator. heap(in: a) T(args) allocates from arena a, and inside in a { ... } every plain heap T(args) does. Arena values are never freed individually: a.reset() releases everything at once, and a.save() / a.restore(point) roll the arena back to an earlier mark. Because the arena frees in bulk, the drop block of an object allocated in it does not run, and neither does the cleanup of a field that owns memory outside the arena (a list, a string, a map or set, an rc handle, a closure), which then leaks; the compiler warns about it (W0605) unless the allocation says heap(in: a, allow_drop_skip: true) T(args). Accessing an arena value after reset or restore is a stale pointer error at run time, also through a pointer kept in another object or read in a callee (section 6.4). A save point is only valid while everything allocated before it is: restoring to one taken before a reset, or before an earlier restore to an older save point, or to a save point of another arena, aborts with arena restored to a stale save point. A value allocated after a restore never reuses the validity of one the restore ended. rc allocation inside an in arena block is an error (E0301). a.used() and a.capacity() report the arena's byte counts. An arena value releases its memory when its binding ends; a heap Arena(bytes) is freed with free, like any heap value.

object Particle {
    val x: Int
    val y: Int
}

func main() {
    val frame = Arena(65536)
    in frame {
        val p1 = heap Particle(10, 20)
        val p2 = heap Particle(30, 40)
        println("p1 ({p1.x},{p1.y}) p2 ({p2.x},{p2.y})")
    }
    val mark = frame.save()
    val p3 = heap(in: frame) Particle(1, 2)
    println("{p3.x} {frame.used() > 0}")
    frame.restore(mark)
    frame.reset()
    println("{frame.used()}")
}
p1 (10,20) p2 (30,40)
1 true
0

6.7 drop, copy and nocopy

An object body may contain a drop { ... } block, run once when each instance is destroyed (stack scope exit, free, last rc handle released, list cleared), with self bound to the dying value. It may contain a copy { ... } block, run after each implicit or explicit copy of the value (x.copy() copies an object explicitly, retaining its rc fields and cloning its list fields). An object declared nocopy object cannot be copied at all: assigning it to another binding, passing it by value or storing it is an error (E0286), which is how Box, Channel, Mutex, RWLock and FileHandle guarantee a single owner. A list of nocopy objects moves its elements (take(), pop(), remove()) and lends them (field reads, for, methods), but anything that would duplicate one is E0286 too: copy(), a slice, filter, and binding an element or a field (val p = ps[i]).

A closure or a thread can only borrow a nocopy value, as it borrows a list (6.8): a lambda passed directly as an argument to a callee that only calls it (3.7), or a spawn surely awaited in the block that starts it (7.1), reads the value through its binding and never copies or drops it. Any other capture, by a lambda bound to a val or var, returned, stored in a field, list or map, passed to a callee that keeps it, or by a spawn that is not surely awaited, is E0286. The rule covers a val binding or parameter whose type is a nocopy object or holds one by value (in a field, a fixed array, an optional, a tuple or an enum payload). It does not cover a var binding, which a closure shares through its counted cell (3.7), a heap binding, rc values and pointers, or Mutex, Channel, RWLock and Arena: these are reference counted handles, and a closure or thread holds its own reference to them (7.1). To share a nocopy value with a closure that is stored or returned, or with a thread that may outlive the block, make it reference counted, val h = rc FileHandle.open(p): each closure then holds its own reference and the value is dropped once, when the last holder is gone. Two threads that both use it guard the reference with a lock, Mutex(rc Hasher.new(alg)). Each object has at most one drop and one copy block (E0284, E0285); drop may not be readonly (E0287) and may not return early (E0288).

A closure may capture a nocopy value only as a borrow: the lambda must be passed directly as a call argument and the callee must not keep it. A spawn may borrow one only when it is awaited in the same block. Binding, returning or storing such a closure, passing it to a callee that keeps it, or letting the spawn escape is E0286; the diagnostic names rc as the ownership fix.

object Point {
    val x: Int
    copy {
        println("copied {self.x}")
    }
    drop {
        println("dropped {self.x}")
    }
}

nocopy object Token {
    val id: Int
}

func main() {
    val p = Point(3)
    val q = p
    val t = Token(5)
    println("{q.x} {t.id}")
}
copied 3
3 5
dropped 3
dropped 3
nocopy object Token {
    val id: Int
}

func main() {
    val t = Token(1)
    val u = t
}
nocopy object Res {
    val id: Int
    drop {
        println("drop {self.id}")
    }
}

func twice(f: func() -> Int) -> Int {
    return f() + f()
}

func reader(r: rc Res) -> func() -> Int {
    return func() -> Int { return r.id }
}

func main() {
    val a = Res(1)
    println("{twice(func() -> Int { return a.id })}")
    val t = spawn("t") {
        return a.id * 10
    }
    println("{await t}")
    val b = rc Res(2)
    val get = reader(b)
    println("{get()}")
}
2
10
2
drop 2
drop 1
nocopy object Res {
    val id: Int
}

func main() {
    val a = Res(1)
    val get = func() -> Int { return a.id }
    println("{get()}")
}

6.8 List ownership

Lists are the one kind of value that is never copied implicitly. The rules, all enforced at compile time with E0328:

func main() {
    val a = [1, 2, 3]
    val b = a
}

7. Concurrency

7.1 spawn and await

spawn("name") { body } runs the body concurrently and evaluates to a handle. The body is a block that must return a value, and await handle blocks until the thread finishes and yields that value. A handle can be awaited once (E0325). A spawn expression may only appear as the initializer of a val or var, or as a statement (its result is then discarded and the task is never awaited). A bound spawn (val h = spawn) runs on a new operating system thread. A spawn statement is a task for the runtime's worker pool, a fixed set of one thread per CPU core but one (the environment variable IRON_WORKERS sets another size): a task that blocks (on a socket, a sleep or a database call) holds a worker until it returns, and tasks beyond the pool's size wait their turn. Run long-lived loops as bound spawns, and see Http.poller (networking guide) for servers that keep many idle connections. The body may read val bindings of the enclosing function and use Mutex, Channel and rc values; a list captured by a spawn must be awaited in the same block (E0328), and so must a nocopy value (or a value holding one), which the thread borrows from its binding without copying or dropping it (E0286, 6.7). For a nocopy value the await must be sure: a later statement of the same block that always runs, with no return, break or continue before it that could leave the block; to hand one to a thread that may outlive the block, make it rc (and guard it with a Mutex when both threads use it). Writing a captured var from a thread is a data race and warns (W0604). A thread keeps what it captured alive until it finishes, even when the block that declared it ends first (always possible for a spawn that is never awaited): it holds its own reference to each captured Mutex, Channel, RWLock, Arena, rc value and var, borrows a captured list or nocopy value (which is why such a spawn must be awaited), and keeps its own copy of the other values. A checked pointer a thread reads is checked like on the thread that took it (section 6.4): a pointer to a local of a function that has returned dangles. A thread that is still running when the program ends is stopped with it. Named thread pools are not implemented: spawn("name", pool) is rejected (E0326).

func sum_to(n: Int) -> Int {
    var total = 0
    for i in range(n) {
        total += i
    }
    return total
}

func main() {
    val a = spawn("a") {
        return sum_to(10)
    }
    val b = spawn("b") {
        return sum_to(100)
    }
    val ra = await a
    val rb = await b
    println("{ra} {rb}")
}
45 4950

7.2 Channel[T]

Channel[T](capacity) creates a bounded queue of T; the element type is written as the type argument (val ch = Channel[Int](4)), since no argument carries it. T may be any type, a function type included, so a channel can carry closures between tasks (Channel[func(Int) -> Int](4)) and rc handles (Channel[rc Job](8)). ch.send(v) blocks while the channel is full and ch.recv() blocks while it is empty. Channels are nocopy and are closed when their owner goes out of scope; there is no explicit close and no non-blocking receive. A channel owns the items it holds as a map owns its values (section 9.10): send takes its argument like put (a list, map or set binding, or an object holding one, is E0328: send xs.copy() or xs.take()), recv moves the item out, and items still queued when the channel goes away are dropped.

7.3 Mutex[T] and RWLock[T]

Mutex(value) wraps a value in a lock; m.lock() returns a MutexGuard[T] that holds the lock until the guard's scope ends, with g.get() and g.set(v) to read and write the protected value. RWLock(value) is the reader-writer variant: l.read() returns an RWReadGuard[T] with get(), and l.write() an RWWriteGuard[T] with get() and set(v). All of these are nocopy. A lock owns its value: Mutex(v), RWLock(v) and set(v) take their argument as Map.put does (a list, map or set binding, or an object holding one, is E0328), get() hands out a copy the caller owns, set drops the value it replaces, and the value is dropped with the lock.

func main() {
    val ch = Channel[Int](4)
    val producer = spawn("producer") {
        for i in range(3) {
            ch.send(i * 10)
        }
        return 0
    }
    var got = 0
    for i in range(3) {
        val x: Int = ch.recv()
        got += x
    }
    await producer
    println("{got}")

    val counter = Mutex(0)
    val worker = spawn("worker") {
        for i in range(100) {
            val g = counter.lock()
            val cur: Int = g.get()
            g.set(cur + 1)
        }
        return 0
    }
    for i in range(100) {
        val g = counter.lock()
        val cur: Int = g.get()
        g.set(cur + 1)
    }
    await worker
    val final_guard = counter.lock()
    val total: Int = final_guard.get()
    println("{total}")

    val settings = RWLock(5)
    val reader = settings.read()
    val seen: Int = reader.get()
    println("{seen}")
}
30
200
5

A channel of closures hands work to another task; each closure keeps what it captured alive until it has run:

func main() {
    val jobs = Channel[func(Int) -> Int](4)
    val base = 100
    val producer = spawn("producer") {
        for i in range(3) {
            val k = i * 10
            jobs.send(func(x: Int) -> Int { return x + k + base })
        }
        return 0
    }
    var total = 0
    for i in range(3) {
        val job: func(Int) -> Int = jobs.recv()
        total += job(i)
    }
    await producer
    println("{total}")
}
333

7.4 Parallel for

for x in xs parallel { ... } runs the iterations of the loop on several threads and waits for all of them. The body must not write bindings of the enclosing scope (E0208); use a Mutex to accumulate. range(n) and lists may be iterated in parallel; a list is iterated through a binding (for x in xs parallel), not a list expression. A parallel body cannot capture a nocopy value (E0286): share it as rc and guard it with a Mutex, since several worker threads may use it. The parallel(pool) form parses but pools are not implemented (E0326).

func main() {
    val total = Mutex(0)
    for i in range(8) parallel {
        val g = total.lock()
        val cur: Int = g.get()
        g.set(cur + i)
    }
    val g = total.lock()
    val sum: Int = g.get()
    println("{sum}")
}
28

8. Compile-time evaluation

comptime expr evaluates expr while compiling and replaces it with the resulting constant. The evaluator handles integer, float and boolean literals and arithmetic (+ - * / %, comparisons, == and !=, unary - and not), the bitwise operators and shifts (& | ^ ~ << >>), the numeric conversions (UInt64(x), Int8(x), Float(n), ...), string literals, list literals and indexing, object construction, the built-ins len, range, fill and read_file(path) (which reads a file relative to the source file and yields its contents), and calls to top-level functions whose bodies use only val, var, assignment, if, while, for and return over those values. The evaluation computes what the same call computes at run time: integer arithmetic wraps at the width of its type, unsigned values compare and divide as unsigned, a shift by the width or more gives 0 (or the sign for >> of a signed value), a float converted to an integer saturates, and fill makes independent copies. An object is built at compile time only when it has no init (positional construction). Method calls, field access, string concatenation, interpolation, reading a global, break and continue, lambdas, match, enum values, and heap or rc allocation are not available at compile time (E0231); an error while evaluating, such as an integer division by zero, a shift by a negative count or an index out of range, is E0232 (a float divided by zero is an infinity or NaN, as at run time, and % on floats is the exact remainder fmod with the sign of the dividend: 5.5 % 2.0 is 1.5, -5.5 % 2.0 is -1.5, and a zero divisor gives NaN). Evaluation is limited to one million steps and to calls nested 256 deep (E0230). The result is typically bound to a global val. A comptime expression that sizes a fixed array or bounded vector ([Int; N] with val N = comptime f(3), or [Int; comptime f(3)]) is evaluated while that type is checked, before the other compile-time evaluations; it must produce an Int, and does not read files.

val TABLE_SIZE = comptime (64 * 4)
val FACTORIAL_5 = comptime fact(5)
val SQUARES = comptime squares(4)

func fact(n: Int) -> Int {
    var acc = 1
    for i in range(n) {
        acc = acc * (i + 1)
    }
    return acc
}

func squares(n: Int) -> [Int] {
    var out = fill(n, 0)
    var i = 0
    while i < n {
        out[i] = i * i
        i += 1
    }
    return out
}

func main() {
    println("{TABLE_SIZE} {FACTORIAL_5} {SQUARES[3]} {len(SQUARES)}")
}
256 120 9 4

8.1 Compile-time field reflection

Field reflection is a source expansion facility. It runs after a generic function is materialized or an interface default is copied to an implementing object, while the reflected object type is concrete. The resulting program contains ordinary field accesses and literals. There is no run-time type or field descriptor.

comptime for f in fields(T) visits the fields of object T in declaration order. T may be Self, a type parameter that has become concrete, or a concrete object type. Inside the loop, the field descriptor has these compile-time properties:

Form Compile-time value
f.name declared field name as a String
f.index zero-based declaration index as an Int
f.type declared type, usable in a type position or comptime match
f.is_var whether the field was declared var
f.is_nullable whether the declared type is nullable
f.is_pub whether the field is public
f.visibility "public" or "private"
f.doc the field's documentation (section 1.2) as a String, or null
f.tag(name) the value of the field's //name:value directive as a String, or null

name must be a string literal. A directive belongs to the field when it is on its own line in the comment run directly above the field, or at the end of the field's line; //json: gives "", and when a key is written more than once the first value is used. The compiler gives directive keys no meaning of its own: each library reads and validates its own. The object-level forms are typeinfo(T).doc and typeinfo(T).tag(name), read from the comments above the object declaration.

interface Columns {
    readonly func columns() -> String {
        var out = ""
        comptime for f in fields(Self) {
            comptime if f.tag("db") != "skip" {
                comptime if f.tag("db") == null {
                    out = out + f.name + ";"
                } else {
                    out = out + f.tag("db") + ";"
                }
            }
        }
        return out
    }
}

object Row impl Columns {
    // The primary key.
    val id: Int          //db:row_id
    val name: String
    val secret: String   //db:skip
}

func main() {
    println(Row(1, "a", "s").columns())
}
row_id;name;

value.(f) becomes a direct access to that field. It may appear on either side of assignment when the field and value are writable. A default method in an interface may read private fields after an object opts in with impl.

interface Describe {
    readonly func describe() -> String {
        var out = ""
        comptime for f in fields(Self) {
            out = out + f.name + "="
            comptime match f.type {
                Int -> { out = out + self.(f).to_string() }
                String -> { out = out + self.(f) }
                E? -> { out = out + "nullable" }
                else -> comptime error("Describe does not support this field")
            }
            out = out + ";"
        }
        return out
    }
}

object User impl Describe {
    val id: Int
    val name: String
}

comptime if selects a branch from compile-time field metadata. An unselected branch is removed before name resolution and type checking. comptime match does the same for a type. It accepts exact named types, nullable patterns such as E?, list patterns such as [E], constructed type patterns such as Map[K, V], and an interface name implemented by the subject type. Pattern identifiers bind types for use in the selected body. If no arm applies and there is no else, the compiler reports E0360. comptime error(message) reports E0232 only when its containing branch is selected.

Fields[T]() supports construction by reflected assignment in an opted-in interface default. If T has a zero-argument init, construction calls it so its defaults are preserved. Otherwise the compiler constructs the object field by field. b.(f) = value marks a field present, b.missing() returns the first missing non-nullable field name as String?, and b.build() returns the completed T. Nullable fields are not required.

Reflection forms are compile-time-only. Using one where no concrete reflected type is available is E0357; reflecting a non-object is E0358; a condition that is not a reflection constant is E0359; and a type that cannot be safely initialized for Fields[T] is E0361. Enums and reflection over generic object declarations are not part of this stage.


9. The standard library

The standard library is a set of Iron declarations (in src/stdlib/) whose bodies are provided by the C runtime. Modules marked import must be imported by name (section 5.9); the rest is always available. Functions on a module object are called as Module.function(args); methods are called on values. A module function may be generic, like a top-level function (section 5.2): its type arguments are inferred from the arguments, as in Json.encode_list(points) (func Json.encode_list[T: JsonEncodable](xs: [T]) -> String).

9.1 Built-in functions

These are available everywhere without an import.

Function Description
println(s: String) writes s and a newline to standard output
print(s: String) writes s without a newline
len(x) -> Int number of elements of a list, array or vector, or characters of a string
range(n: Int) the sequence 0 .. n-1, only valid as the iterable of for
fill(n: Int, v: T) -> [T] a list of n copies of v
min(a: Int, b: Int) -> Int, max(a: Int, b: Int) -> Int smaller or larger of two integers
clamp(x: Int, lo: Int, hi: Int) -> Int x limited to [lo, hi]
abs(x: Int) -> Int absolute value
assert(cond: Bool), assert(cond: Bool, msg: String) abort with msg (or the source location) when cond is false
assert_eq(actual, expected), assert_ne(a, b) abort, naming the source line and both values, unless actual == expected (a != b); a statement, for values that compare with == and have a text form
read_file(path: String) -> String file contents, only inside comptime

print and println take exactly one String; interpolate other values. min, max, clamp and abs are Int only (use Math for floats).

func main() {
    println("{min(3, 9)} {max(3, 9)} {clamp(15, 0, 10)} {abs(-4)}")
    print("no newline, ")
    println("then one")
    assert(len("abc") == 3, "len counts characters")
}
3 9 10 4
no newline, then one

9.2 String methods

All string methods are readonly; indexes count characters from 0 and a missing substring gives -1. Unlike list indexing, string positions never panic: substring(start, end) clamps both ends to the string ("hello".substring(3, 10) is "lo", an empty range gives ""), char_at(i) out of range is "" and byte_at(i) out of range is -1. to_int() and to_float() accept whitespace around the number (" 42\n".to_int() is 42) and give 0 for anything else that is not entirely a number ("4 2", "3x", ""). to_int reads an optional sign and decimal digits, and gives 0 for a value outside Int; to_float reads an optional sign, digits with an optional fraction or a fraction alone (".5", "5.") and an optional exponent ("1e3"), and nothing else (no hexadecimal, inf or nan); a value past the Float range is an infinity. upper() and lower() apply the simple Unicode case mapping (one character to one character); a character whose case form is several characters (ß) keeps its case. With an empty pattern, split("") gives the characters, replace("", x) returns the string unchanged and count("") is 0. pad_left and pad_right pad with the first character of ch, or with spaces when ch is empty. String.from_byte(b) is the one-byte string for b in 0..255 and "" for any other value.

Method Description
len() -> Int, byte_len() -> Int characters, bytes
upper() -> String, lower() -> String, trim() -> String case and whitespace
contains(sub) -> Bool, starts_with(p) -> Bool, ends_with(s) -> Bool tests
index_of(sub) -> Int, rindex_of(sub) -> Int, count(sub) -> Int search
split(sep: String) -> [String], chars() -> [String] split into parts or characters
join(parts: [String]) -> String join parts with the receiver as separator
replace(old, new) -> String, repeat(n) -> String rewriting
substring(start, end) -> String, char_at(i) -> String slices (end exclusive)
pad_left(width, ch) -> String, pad_right(width, ch) -> String padding
to_int() -> Int, to_float() -> Float parsing (0 when not a number)
byte_at(i) -> Int, String.from_byte(b: Int) -> String byte access
byte_slice(start, end) -> String bytes [start, end), clamped like substring (for binary data)
release() does nothing (kept for source compatibility)

Int, Int32 and Float have a to_string() -> String method. s[i] is s.char_at(i) and s[a..b] is s.substring(a, b).

9.2.1 Scalar bit and overflow operations

Every integer type has leading_zeros(), trailing_zeros() (both return the bit width for zero), count_ones(), clear_lowest_bit(), lowest_bit(), rotate_left(k), rotate_right(k), byte_swap() and prefix_xor(). Rotations reduce k modulo the width, including negative counts. Arithmetic methods return wrapped results together with a flag:

a.add_overflow(b) -> (T, Bool)
a.sub_overflow(b) -> (T, Bool)
a.mul_overflow(b) -> (T, Bool)
a.add_carry(b, carry: Bool) -> (T, Bool)

mul_wide returns the full product as (high: T, low: U), where U is the unsigned type of the same width; mul_high returns only the high half. They are available on all integer widths. Signed integers have to_bits() -> U and T.from_bits(U). Float and Float64 use UInt64, and Float32 uses UInt32, with the same to_bits and from_bits names. These are bit casts, not numeric conversions.

func main() {
    val s = "Hello, Wörld"
    println("{s.len()} {s.byte_len()} {s.upper()} {s.index_of("o")} {s.rindex_of("o")}")
    println("{s.substring(7, 12)} {s.char_at(8)} {s.count("l")} {s.replace("l", "L")}")
    val parts = "a,b,,c".split(",")
    println("{parts.len()} {parts[2].len()} {",".join(["x", "y"])}")
    println("[{"  x ".trim()}] {"ab".repeat(3)} {"7".pad_left(3, "0")} {"7".pad_right(3, "-")}")
    println("{s.byte_at(0)} {String.from_byte(65)} {"abc".chars()[1]} {s[0]} {s[0..5]}")
}
12 13 HELLO, WÖRLD 4 4
Wörld ö 3 HeLLo, WörLd
4 0 x,y
[x] ababab 007 7--
72 A b H Hello

9.3 List methods

Methods on [T] (and, where noted, on fixed arrays, bounded vectors and rc [T]). Mutating methods need a var list.

Method Description
len() -> Int number of elements (also len(xs))
push(v: T), pop() -> T append, remove and return the last element
insert(i: Int, v: T), remove(i: Int) -> T insert before / remove at index
get(i: Int) -> T, set(i: Int, v: T) checked element access (same as xs[i])
get_unchecked(i: Int) -> T, set_unchecked(i: Int, v: T) access without the bounds check
clear(), reverse(), sort() in place; sort orders Int, Float and String ascending (NaN after every number, strings in code point order)
contains(v: T) -> Bool membership
copy() -> [T], take() -> [T] independent copy; move the contents out
map(f: func(T) -> U) -> [U] transform
filter(f: func(T) -> Bool) -> [T] keep matching elements
reduce(init: U, f: func(U, T) -> U) -> U fold
forEach(f: func(T)) call f on each element
sum() -> T sum of an [Int] or [Float]
view() -> View[T], view_mut() -> ViewMut[T] borrow the elements; the mutable form needs a var list
spare(n) -> ViewMut[T], commit(k) reserve and zero n scalar elements, then append the first k written elements (k <= n)

A slice xs[a..b] must lie within the list with a <= b; anything else is an index out of bounds panic, like xs[i]. contains compares with ==, so a list never contains NaN.

pop() on an empty list panics ("pop from an empty list"), and get, set, remove, insert and xs[i] with an index out of range panic ("index out of bounds"), naming the Iron line of the call; this holds for every element type, lists of interface values included.

The lambdas passed to map, filter, reduce and forEach must write their parameter types. map cannot produce tuples (a list cannot hold them, E0202). A chain of these calls may be fused into one loop (section 9.3.2); that never changes what the program does.

func main() {
    val ys = [1, 2, 3, 4, 5]
    val squares = ys.map(func(x: Int) -> Int { return x * x })
    val evens = ys.filter(func(x: Int) -> Bool { return x % 2 == 0 })
    val total = ys.reduce(0, func(acc: Int, x: Int) -> Int { return acc + x })
    val labels = ys.map(func(x: Int) -> String { return "n{x}" })
    println("{squares[4]} {evens.len()} {total} {ys.sum()} {labels[0]}")
    ys.forEach(func(x: Int) { print("{x},") })
    println("")
    var xs = [5, 6, 7, 8]
    xs.set(1, 60)
    val removed = xs.remove(0)
    xs.reverse()
    val popped = xs.pop()
    println("{removed} {popped} {xs.get(0)} {xs.len()}")
    var words = ["b", "c", "a"]
    words.sort()
    println("{words[0]}{words[1]}{words[2]}")
}
25 2 15 15 n1
1,2,3,4,5,
5 60 8 2
abc

9.3.1 StringBuilder

StringBuilder is a nocopy byte builder. new() starts empty and with_capacity(n) reserves bytes. push_byte, push_bytes, push_str, push_int and push_uint append without temporary Strings. reserve(n) makes room for the next n bytes. spare(n) -> ViewMut[UInt8] returns zeroed writable bytes after the current end; after writing them, commit(k) appends the first k (k <= n). bytes() borrows the built prefix, len() reports its byte length, clear() keeps the allocation, and finish() moves the buffer into a String and leaves the builder empty.

func main() {
    var out = StringBuilder.with_capacity(32)
    out.push_str("count=")
    out.push_int(12)
    val tail = out.spare(1)
    tail[0] = UInt8(33)
    out.commit(1)
    println(out.finish())
}
count=12!

9.3.2 Loop fusion

map, filter, reduce, forEach and sum each run one loop over their list, and map and filter allocate the list they return. Written as a chain, xs.map(f).filter(g).sum() is three loops and two intermediate lists. The optimizer may fuse such a chain into one loop that takes one element of xs through every stage before it reads the next, and builds no intermediate list.

The fused loop calls the callbacks in a different order (f, g, f, g, ... instead of every f and then every g). Fusion is therefore done only where no program can tell the difference. All of the following must hold; where one does not, the calls run as written.

The stages. A chain is two or more of the five methods, each called on the result of the one before. map and filter may be anywhere; reduce, sum and forEach return no list and can only be last. Any other method (copy, len, a slice, an index, ...) is not a stage: the chain ends before it and a new one may start after it. A chain is split wherever a rule below fails, and each part of two or more stages is fused on its own.

The callbacks. Each callback must be a named function or a lambda the compiler can see at the call: written there, or a val bound to one. A callback received as a parameter, read from a field or stored in a var is unknown. A lambda may capture val bindings (it gets a copy); one that captures a var shares it with the enclosing function and is not fused. The body, and the body of every function it calls, must be proven free of effects and of anything that can panic. What is allowed is:

Everything else keeps the stage out of a fused loop: any I/O, writing a global, a field, a list element or through a pointer, a var parameter, indexing a list, integer division or modulo by a value that is not a constant, a shift by a count that is not a constant, anything that builds a String or a list, allocation, reading through an rc, heap or pointer value, optionals, closures, tasks, and every function implemented outside Iron (the String and list methods among them).

The values between stages. The values one stage hands to the next must be numbers, Bool, or objects made only of those with no copy or drop hook and not nocopy. So must the accumulator of a reduce. A map that produces Strings, lists, rc handles, optionals, enums or objects that own any of them really builds its list, and every such value is created, copied and dropped exactly as written. The stage that reads that list may start a new chain. The source list of a chain may hold anything; it is read in place.

The source. A list of an interface type is never read by a fused loop (it keeps its elements per implementing type, and a loop over it must still visit them in list order). Every other list can be: a binding, a parameter, a global, a literal or call result (kept alive until the loop is done), an rc [T], a fixed-size array, a bounded vector, a slice, a list of either layout. A map or filter at the end of a chain makes a list of the layout its type asks for.

Bindings. The stages need not be in one expression. val a = xs.map(f) followed by a.filter(g).sum() is one chain when a has no other reader and nothing but argument setup stands between the two calls: constants, arithmetic, building the callbacks, constructing an object. Any call or other statement in between could change what the earlier stage reads (the source list, a global), so it ends the chain there, as does a branch or a loop between the two calls. The arguments of a later stage count as "in between": in xs.map(f).reduce(next_id(), g) the call of next_id runs after map has finished, as written.

What a fused loop does. It visits the source in list order. For each element it applies the stages in order: map replaces the value, filter drops the element when its callback returns false, reduce updates the accumulator (starting from its first argument), sum adds to a total that starts at zero, forEach calls its callback. A map or filter in last position appends to the new list. An empty source calls nothing: the result is zero, the initial value of reduce, or an empty list. The loop runs where the last stage is written, once each time that point is reached.

Fusion is local to one function body and to the stages that pass; a pipeline inside a callback is a list method call like any other (the outer stage is not fused, the inner pipeline is, on its own). It adds and removes no synchronization: a pipeline in a spawn or parallel body is fused by the same rules. Only an optimizing build fuses: with --no-optimize or --debug every call runs as written, so the two builds of a program must print the same, and that is how the test suite checks fusion.

iron build --warn-fusion-break prints, for every list method call that was left out of a fused loop, the rule that stopped it:

note: --warn-fusion-break: main.iron:12: .map() is not fused: its callback calls 'println', whose effects the compiler cannot rule out
note: --warn-fusion-break: main.iron:20: .sum() is not fused with the .map() before it: the intermediate list has another reader
var calls = 0

//iron:fusible
func double(x: Int) -> Int {
    return x * 2
}

func counted(x: Int) -> Int {
    calls += 1
    return x + 1
}

func main() {
    val xs = [1, 2, 3, 4]
    // one loop, no intermediate list
    println("{xs.map(double).filter(func(x: Int) -> Bool { return x > 2 }).sum()}")
    // counted writes a global: map runs first, then filter and sum in one loop
    println("{xs.map(counted).filter(func(x: Int) -> Bool { return x > calls }).sum()}")
}
18
5

9.4 math (import)

Math has the constants Math.PI, Math.TAU and Math.E and the functions sin, cos, tan, asin, acos, sqrt, floor, ceil, round, log, log2, exp (all (x: Float) -> Float), atan2(y, x), pow(base, exp), hypot(a, b), lerp(a, b, t) (Float arguments and results), sign(x: Float) -> Int, random() -> Float in [0, 1), random_float(min, max) -> Float, random_int(min: Int, max: Int) -> Int (inclusive at both ends, over any range including all of Int; min when max < min) and seed(n: Int). The functions are the platform C library's: round rounds halfway cases away from zero, a result outside a function's domain is NaN (sqrt(-1.0)), and sign(NaN) is 0. After seed(n) the sequence is the same on every run.

import math

func main() {
    println("{Math.PI > 3.14} {Math.floor(2.7)} {Math.pow(2.0, 10.0)} {Math.sign(-2.5)}")
    Math.seed(42)
    val r = Math.random()
    println("{r >= 0.0 and r < 1.0} {Math.random_int(3, 3)} {Math.hypot(3.0, 4.0)}")
}
true 2 1024 -1
true 3 5

9.5 io (import)

Simple functions on IO:

Function Description
read_file(path) -> String, write_file(path, content), append_file(path, content) whole-file text I/O (an unreadable file reads as "")
read_lines(path) -> [String] the lines of a file
read_line() -> String one line from standard input
write_stdout(s) -> Int, write_stderr(s) -> Int write all of s at once without buffering (lines from several threads do not interleave; println's buffer is flushed first); the bytes written, or -1
file_exists(path) -> Bool, is_dir(path) -> Bool tests
create_dir(path), delete_file(path) directories and deletion
list_files(dir) -> String the entries of a directory, one per line
basename(path), dirname(path), extension(path), join_path(a, b) path helpers (extension has no leading dot)

Result-returning functions, which report errors in the value instead of failing silently:

Function Result
read_text(path, max_bytes), read_bytes(path, max_bytes) FileReadResult { data: String, error: Int, error_message: String }
write_text(path, content), write_bytes(path, content), append_text(path, content), append_bytes(path, content) FileWriteResult { bytes: Int, error: Int, error_message: String }
copy_file(src, dst, overwrite: Bool), move_file(src, dst, overwrite: Bool) FileWriteResult
file_info(path) FileInfo { exists, is_file, is_dir: Bool, size, modified_unix, error: Int, error_message: String }

error is 0 on success; otherwise it is one of the codes of src/runtime/iron_errors.h and error_message says which:

Code Meaning
8000 not found (the file or a directory on its path does not exist)
8001 permission denied
8002 invalid argument: an empty path, a path holding a zero byte, a name too long, a negative limit, or copy / move onto the source itself
8003 the file is larger than max_bytes
8004, 8005 read failed, write failed
8007 the destination exists (copy / move without overwrite)
8008 a component of the path is a file, not a directory
8009 the path is a directory (reading or writing it as a file)

A missing file is not an error for file_info (exists is false). A path that is empty or holds a zero byte names no file for every function: the simple ones read "", write nothing and report false (the prefix before the zero byte is never used). The path helpers work on the bytes of the path with / as the separator; join_path("", b) is b. Files whose size the system does not report (/proc files, pipes) are read to their end, still within max_bytes.

FileHandle.open(path) -> FileHandle opens the file for reading and writing, creating it when it does not exist (an existing file keeps its contents); h.fd is the descriptor, -1 when the file could not be opened (an empty path, a directory, a missing directory). The handle is nocopy and closes itself when dropped; h.close() closes it at once and sets fd to -1, and a second close does nothing.

import io

func main() {
    val path = "/tmp/iron_manual_io.txt"
    val w = IO.write_text(path, "one\ntwo\n")
    val r = IO.read_text(path, 1024)
    val lines = IO.read_lines(path)
    val info = IO.file_info(path)
    println("{w.error} {w.bytes} {r.error} {r.data.len()} {lines[1]} {info.size}")
    val missing = IO.read_text("/tmp/iron_manual_missing_file", 16)
    println("{missing.error != 0} {IO.extension("a/b.iron")} {IO.basename("a/b.iron")} {IO.join_path("a", "b")}")
    IO.delete_file(path)
    println("{IO.file_exists(path)}")
}
0 8 0 8 two 8
true iron b.iron a/b
false

9.6 time (import)

Time.now() -> Float (seconds since the Unix epoch), Time.unix_ms() -> Int (milliseconds since the Unix epoch), Time.now_ms() -> Int and Time.now_ns() -> Int (milliseconds and nanoseconds of a monotonic clock with an arbitrary origin, for measuring durations), Time.sleep(ms: Int), Time.since(start: Float) -> Float (seconds elapsed since a Time.now() value), and Time.Timer(seconds: Float) -> Timer with the fields elapsed_ms and duration_ms and the methods done() -> Bool, update(dt: Float) and reset() (the last two need a var timer). A timer counts whole milliseconds: Time.Timer(seconds) and update(dt) convert like a Float to Int conversion (truncating, saturating at the Int limits, NaN to 0, section 2.9) and the elapsed time saturates instead of wrapping. Time.now_ms() and Time.now_ns() are monotonic; Time.now() is the wall clock. Time.sleep of zero or a negative time returns at once. Duration is a millisecond value type built with Duration.millis(n), Duration.seconds(n), Duration.minutes(n) or Duration.from_ms(n), read with d.ms or d.to_ms().

import time

func main() {
    val started = Time.now_ms()
    Time.sleep(5)
    println("{Time.now_ms() - started >= 5} {Duration.seconds(2).to_ms()} {Time.Timer(0.5).done()}")
}
true 2000 false

9.6.1 os (import)

OS.env(name) -> String? reads an environment variable (null when it is not set), OS.env_or(name, fallback) -> String returns fallback instead, and OS.has_env(name) -> Bool tests for it (an empty value counts as set). Names and values are UTF-8 on every platform. A name that is empty or holds = or a zero byte is never set.

For long-running programs: OS.trap_shutdown_signals() makes SIGINT and SIGTERM (Ctrl+C, Ctrl+Break and closing the console on Windows) set a flag instead of ending the process, and OS.shutdown_signal() -> Int returns the first such signal received (2 or 15), or 0, for the program to poll and shut down cleanly; a second signal ends the process at once. OS.exit(code) ends the process with an exit status (after flushing standard output; drops, defers and other tasks do not run), OS.pid() -> Int is the process id and OS.cpu_count() -> Int the number of CPUs the process may run on (its affinity mask on Linux).

import os

func main(args: [String]) {
    val home = OS.env("IRON_MANUAL_UNSET_VAR")
    if home == null {
        println("unset")
    }
    println("{OS.env_or("IRON_MANUAL_UNSET_VAR", "default")} {args.len()}")
}
unset
default 0

9.6.2 atomic (import)

AtomicInt.new(v) and AtomicBool.new(b) make a value that tasks share without a lock. Copies of the handle (a spawn's capture, a field, an argument) refer to the same value, freed with the last copy. get(), set(v), swap(v) (returns the previous value) and compare_and_set(expected, desired) (whether it stored) work on both; AtomicInt.add(d) adds and returns the new value. Every operation is sequentially consistent.

import atomic

func main() {
    val hits = AtomicInt.new(0)
    val t = spawn("worker") {
        for i in range(1000) {
            hits.add(1)
        }
        return 0
    }
    for i in range(1000) {
        hits.add(1)
    }
    await t
    println("{hits.get()} {hits.compare_and_set(2000, 1)} {hits.get()}")
}
2000 true 1

9.7 log (import)

Log.debug(msg), Log.info(msg), Log.warn(msg) and Log.error(msg) write a line [YYYY-MM-DD HH:MM:SS] [LEVEL] msg (local time; colored when standard error is a terminal) to standard error; Log.set_level(level) with Log.DEBUG (0), Log.INFO (1, the default), Log.WARN (2) or Log.ERROR (3) hides messages below the level. Any Int is a level: one below Log.DEBUG shows everything, one above Log.ERROR hides everything. The message is written as given, every byte of it (a newline in it starts a new line); a line from one task is never mixed with another's.

9.8 hint (import)

Hint.black_box(x: Int) -> Int returns its argument while preventing the C optimizer from reasoning about it; it exists for benchmarks. Hint.likely(c) and Hint.unlikely(c) return the Bool unchanged and tell the optimizer which outcome is expected.

9.9 Memory and concurrency types

These are always available and are described in sections 6 and 7. Each one is constructed like any object, Type(args) or Type[T](args) when no argument carries the element type and no expected type gives it (section 2.8); there is no .new method (E0333):

Type API
Box[T] (nocopy) Box(v) or Box[T](v) -> Box[T], Box.null() -> Box[T], b.unwrap() -> *var unchecked T, b.is_null() -> Bool, b.free()
Arena Arena(bytes), Arena.threadsafe(bytes), a.save() -> ArenaSave, a.restore(p: ArenaSave), a.reset(), a.used() -> Int, a.capacity() -> Int
RawPtr RawPtr.of(x) -> RawPtr, Ptr.cast[T](raw) -> *unchecked T
Channel[T] (nocopy) Channel[T](capacity: Int), ch.send(v: T), ch.recv() -> T
Mutex[T], MutexGuard[T] (nocopy) Mutex(v) or Mutex[T](v), m.lock() -> MutexGuard[T], g.get() -> T, g.set(v: T)
RWLock[T], RWReadGuard[T], RWWriteGuard[T] (nocopy) RWLock(v) or RWLock[T](v), l.read(), l.write(), g.get() -> T, g.set(v: T) (write guard only)
FileHandle (nocopy) FileHandle.open(path) -> FileHandle, h.close(), field fd: Int
Map[K, V], Set[T] section 9.10

9.10 Hashable, Map and Set

Map[K, V] is a hash table from keys of type K to values of type V; Set[T] is a hash set of T. Both are constructed empty, with their type arguments written out (Map[String, Int](), Set[Int]()) or taken from the expected type (var m: Map[String, Int] = Map(), return Set() in a function returning Set[Int], section 2.8), and both are library objects: there is no literal and no m[k] indexing. The key type must satisfy Hashable (E0206): the integer types, Bool and String do, and an object does when it declares impl Hashable with pure func hash() -> Int and pure func equals(other: Hashable) -> Bool (section 5.5); two keys are the same entry when their hashes agree and equals is true. The value type may be anything that can be stored in a list, including lists, rc handles, closures and other maps; function, rc and pointer types are written as type arguments like any other (Map[String, func(Int) -> Int](), Map[Int, rc Node]()). A function type, an rc handle or a pointer is not Hashable, so it cannot be a key or a set item.

Method Meaning
m.put(k, v) insert or overwrite; the old value is dropped
m.get(k) -> V the value; panics when the key is absent
m.get_or(k, d) -> V the value, or d when the key is absent
m.has(k) -> Bool whether the key is present
m.remove(k) -> Bool drop the entry; whether it was present
m.len() -> Int, m.clear() entry count; drop every entry
m.keys() -> [K], m.values() -> [V] fresh lists of copies, in no particular order
m.copy(), m.take() an independent copy; move the contents out, leaving m empty
s.add(x) -> Bool insert; whether x was new
s.has(x), s.remove(x) -> Bool, s.len(), s.clear() as for a map
s.values() -> [T], s.copy(), s.take() as for a map

A map or set owns its keys and values exactly as a list owns its elements (section 6.8): put and add take ownership of their arguments (a value copied out of a binding is retained or cloned first), get and get_or hand out a copy that the caller owns, remove, clear and scope exit drop what they remove, and a map binding is never duplicated implicitly (E0328): write copy() or take(), or share it as rc Map[K, V]. put, remove, clear, add and take need a var binding (E0235). for (k, v) in m visits every entry with read-only copies of the key and the value; for x in s visits every item. Iteration order is unspecified, and the loop body must not add or remove entries of the map it iterates. get on a missing key is a panic ("key not found in map"), so test with has or use get_or when the key may be absent. Maps and sets can be elements of a list ([Map[String, Int]], [Set[String]]): the list owns its tables, copy() clones each one, and ms[i].put(k, v) changes the table in place. A list of channels ([Channel[T]]) owns its channels and destroys them with the list.

object Pt impl Hashable {
    val x: Int
    val y: Int
    pure func hash() -> Int {
        return self.x * 31 + self.y
    }
    pure func equals(other: Hashable) -> Bool {
        if other is Pt {
            return self.x == other.x and self.y == other.y
        }
        return false
    }
}

func main() {
    var counts = Map[String, Int]()
    for w in ["a", "b", "a"] {
        counts.put(w, counts.get_or(w, 0) + 1)
    }
    var keys = counts.keys()
    keys.sort()
    println("{counts.len()} {keys[0]} {counts.get("a")} {counts.has("z")}")
    var total = 0
    for (k, v) in counts {
        total += v
    }
    println("{total} {counts.remove("b")} {counts.remove("b")}")

    var seen = Set[Pt]()
    println("{seen.add(Pt(1, 2))} {seen.add(Pt(1, 2))} {seen.has(Pt(1, 2))} {seen.len()}")

    var groups = Map[String, [Int]]()
    groups.put("even", [2, 4])
    var even = groups.get("even")
    even.push(6)
    groups.put("even", even.take())
    println("{groups.get("even").len()}")
}
2 a 2 false
3 true false
true false true 1
3

9.11 net, http, websocket and url (import)

The networking modules are documented in docs/networking.md. In summary: net provides Net.tcp_dial(host, port, timeout), Net.tcp_listen(host, port), TcpListener.accept, TcpSocket.read, TcpSocket.write, close, UDP (Net.udp_bind, Net.udp_sendto_v4, Net.udp_sendto_v6, UdpSocket.recvfrom), IPv4Addr / IPv6Addr parsing and formatting and Net.lookup_host; every fallible call returns a tuple whose second element is a NetError { code: Int } (0 on success). http provides a server (Http.listen, HttpServer.accept, HttpConnection.read_request, send_response, Http.listen_tls), a client (Http.get, Http.post_json, Http.request, HttpClient.open) and response builders (Http.response, json_response, html_response, text_response, file_response, Http.header); requests and responses are objects with status, headers, body, error and error_message fields. websocket provides WebSocket.connect (and the _with_ca, _insecure and _with_protocols variants), send_text, send_bytes, ping, receive, close, abort, is_open and the server side HttpConnection.upgrade_websocket. url provides Url.parse(s) -> (Url, UrlError), Url.build(u), Url.resolve(base, ref), Url.percent_encode, Url.percent_decode and a Url.builder(). All timeouts are integer milliseconds.

Url.parse splits RFC 3986 references without decoding or case folding: the text before the first : is the scheme when it is a letter followed by letters, digits, +, - or . and no /, ? or # comes first (otherwise the input is a relative reference); the user info runs to the last @ of the authority; a host in brackets ends at the first ], which must be followed by nothing or : and a port (2004); a host without brackets cannot hold :, [ or ] (2003); the port is decimal digits up to 65535 (2005), and an empty port is the scheme default (port is -1). Errors: 2000 for an empty input, 2009 past 8192 bytes, 2007 for a % without two hex digits in percent_decode. Url.build writes the reference back so that Url.parse of the result gives the same fields. Url.resolve follows RFC 3986 section 5.2; an empty query or authority counts as absent (a Url cannot tell them apart).

A port outside 0..65535 given to net is an invalid argument (1024). TcpSocket.write writes the whole buffer within its deadline and returns the byte count (on an error, what was written before it); TcpSocket.read returns what one receive gives, at most max_bytes. A WebSocket value names its connection: after close or abort every copy of it is stale (is_open is false, the other calls return 6005); after a receive fails (6002 framing, 6003 too large, 6004 invalid UTF-8, or the stream ending inside a frame) the connection is failed and later receives and sends return 6005; is_open is false once a close frame was received.

9.11.1 json (import)

The json module is documented in docs/json.md. Json is a JSON value: match on v.node (JsonNode.Null, Bool, Int, Float, Str, Arr, Obj) or use the typed accessors (as_int() -> Int?, get(key), at(i), get_string(key), pointer("/a/0"), path("a.0")). Arrays and objects are shared between copies of a Json (clone() makes a deep copy) and keep their insertion order; integers that fit in 64 bits stay Int. Json.parse(text) is a strict RFC 8259 parser that returns a JsonResult with value, error (0 on success, 4000..4099 otherwise), error_message, line and column; Json.parse_with(text, JsonLimits(max_depth, max_bytes)) bounds untrusted input. to_string(), pretty() and format(indent, sort_keys) serialize with shortest round-trip floats. JsonWriter streams JSON into a native buffer and JsonReader walks a document without building a tree.

import json

func main() {
    val r = Json.parse("\{\"id\": 7, \"tags\": [\"a\"], \"price\": 9.5}")
    val doc = r.value
    doc.get("tags").push(Json.str("b"))
    println("{r.error} {doc.get_int("id")} {doc.get("price").as_float()} {doc}")
    val w = JsonWriter.compact()
    w.begin_object()
    w.field_string("name", "Ann")
    w.field_int("visits", 3)
    w.end_object()
    println(w.finish())
}
0 7 9.5 {"id":7,"tags":["a","b"],"price":9.5}
{"name":"Ann","visits":3}

9.11.2 mysql (import)

import mysql is a MySQL and MariaDB client that speaks the client/server protocol itself (TLS, the caching_sha2_password, mysql_native_password, sha256_password and mysql_clear_password plugins, the text and binary protocols); it is documented in docs/mysql.md. Mysql.connect(MysqlConfig) and Mysql.connect_url(url) return a nocopy MysqlConn with query(sql), exec(sql, params: [MysqlValue]) (a prepared statement, cached per connection), run(sql), prepare(sql), begin, commit, rollback, ping, reset, select_db, escape, quote and close. A query returns a nocopy MysqlResult holding every row: row_count(), column_count(), column(i), col(name), affected_rows(), last_insert_id(), next_result() and typed readers by row and column (int, uint, float, text, bytes, bool, datetime, time returning T?, get_int and the like returning (T, MysqlError), and value returning a MysqlValue). Mysql.open_pool(config, MysqlPoolOptions(min, max)) returns an rc MysqlPool that any number of tasks share (query, exec, run, acquire). Every operation reports a MysqlError { code, sqlstate, message, server } (code 0 on success) and none panics on a server or network failure.

9.12 crypto and encoding (import)

The cryptography modules are documented in docs/crypto.md. Binary data is a String (strings hold arbitrary bytes; measure and cut it with byte_len and byte_slice). encoding provides Hex.encode, Hex.decode, Base64.encode, Base64.decode, Base64.url_encode and Base64.url_decode; decoders return DecodeResult { data: String, error: Int, error_message: String }. crypto provides digests (Crypto.hash(HashAlgorithm.Sha256, data), Crypto.sha256(data), ... and the streaming Hasher.new(alg) / Hasher.hmac(alg, key) with update and finish), Crypto.hmac, Crypto.hkdf, Crypto.pbkdf2, Crypto.scrypt, password hashing (hash_password, verify_password, password_needs_rehash), constant_time_equal, xor, random_bytes, random_int, random_token, authenticated encryption (seal, open, encrypt, decrypt with AeadAlgorithm.Aes256Gcm or ChaCha20Poly1305) and RSA-OAEP. Fallible calls return CryptoResult { data: String, error: Int, error_message: String }; a forged ciphertext is error 9004, not a panic. The module is OpenSSL 3's libcrypto and links it like http does.

import crypto
import encoding

func main() {
    println(Hex.encode(Crypto.sha256("abc")))
    val key = Crypto.random_bytes(32)
    val box = Crypto.encrypt(AeadAlgorithm.ChaCha20Poly1305, key, "secret", "")
    val back = Crypto.decrypt(AeadAlgorithm.ChaCha20Poly1305, key, box.data, "")
    println("{back.error} {back.data} {Base64.url_encode("hi?")}")
}
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
0 secret aGk_

9.13 raylib (import)

import raylib makes the raylib bindings available: the objects Vector2, Vector3, Color, Rectangle, Texture, Font and so on, the enums KeyboardKey, MouseButton, ConfigFlags and others, and the functions as snake_case methods on namespace objects (Window.init, Window.should_close, Draw.begin, Draw.text, Input.is_key_down, ...). The full binding list is src/stdlib/raylib.iron, and the graphics guide at ironlang.dev/raylib shows complete programs.


10. Programs, projects and the command line

10.1 Single files

ironc build file.iron compiles one file to a binary next to it (-o picks the path), ironc run file.iron compiles and runs it, and ironc check file.iron type-checks it. iron build file.iron, iron run file.iron and iron check file.iron do the same. A single file may import only standard library modules.

10.2 Packages

iron init name (or iron init --lib name) creates a package: a directory with an iron.toml manifest, src/main.iron (or src/lib.iron for a library) and a .gitignore. Inside a package, iron build compiles every .iron file under src/ and vendor/ into target/, iron run builds and runs it, iron check type-checks the same sources, iron test runs the package's test "name" { ... } blocks (section 10.2.1) and every tests/test_*.iron program, and iron fmt file.iron reformats a file (--check only reports, and exits with 0 when the file is formatted, 1 when it would change, 2 when it does not parse and 3 when it cannot be read). iron fmt --migrate-comments [paths] rewrites the transitional -- comments, /// documentation lines and @fusible in every .iron file under the given files or directories (the current directory by default) to // and //iron:fusible (section 1.2); it changes only comments as the lexer reads them, checks that every rewritten file still lexes, parses and documents the same, and with --check only lists the files it would change. In a package, iron build, iron run and iron check take no file argument (program arguments follow -- in iron run), and iron test takes one, a name filter (iron test -- -name for a filter that starts with -); any other argument is an error, as is a second file given to ironc. Builds and checks of the same package may run at the same time. All files of a package share one namespace: a pub declaration in one file is visible in every other file, and a private one only in its own (E0320), subject to the interface and extern exceptions in section 5.10. import of a package file is optional and documents the dependency. The files are compiled in this order, which is the order of their globals' initialization where those do not depend on each other (section 5.7): the vendored files, src/lib.iron, the other files under src/ sorted by path, src/main.iron.

[package]
name = "demo"
version = "0.1.0"
type = "bin"            # or "lib"
description = "optional"
iron = ">= 4.4.0"       # optional compiler version constraint

Values are TOML basic strings (the escapes \", \\, \t, \n and \r); a malformed string stops the build with its line. The name is the name of the output file (target/<name>, target/lib<name>.a), so it may not be empty, . or .., or contain / \ : " < > | ? * or control characters; iron init without a name derives it from the directory, with such characters replaced by _. type is "bin" or "lib".

The iron constraint uses full X.Y.Z versions with the operators >=, >, <=, <, = (or no operator for an exact version), ^ (same major, or same minor before 1.0) and ~ (same minor), and comma-separated clauses are combined with AND (">= 4.0.0, < 5.0.0"). A pre-release such as 4.4.0-alpha sorts before 4.4.0. A mismatch stops the build with the version to install. There is no [dependencies] table: a manifest that declares one fails with a vendoring hint.

10.2.1 Test blocks

A test "name" { ... } declaration, at the top level of any file, is a test: a body checked like a function's, run by iron test (or ironc test file.iron) and left out of every other build. test is a keyword only in this position. The tests of a package are compiled with its sources, so a test in src/ can call that file's private functions; each runs in its own process, and a failed assert, assert_eq, assert_ne or a panic fails it. The name is a string literal without interpolation (escapes allowed). Each selected test prints test <name> ... ok or test <name> ... FAILED followed by its output, indented; a failed assert(cond) reports its file:line (the path as the build names the file, src/a/b.iron in a package), assert_eq also the expected and actual values. The run ends with P passed, F failed (and , K filtered out when a filter, a substring of the names, left tests out) and fails when a test failed.

func parse_digit(c: String) -> Int {
    return "0123456789".index_of(c)
}

test "digits parse" {
    assert_eq(parse_digit("7"), 7)
    assert_eq(parse_digit("x"), -1)
}

10.3 Third-party code

Iron has no package manager, registry or lockfile. To use third-party Iron code, copy its source into vendor/<name>/ and commit it. iron build, iron run and iron check compile every .iron file under vendor/ together with src/: a vendored directory that has its own iron.toml and src/ contributes only its src/, other directories contribute every .iron file recursively, and tests/, examples/, target/ and hidden directories are skipped. Vendored code shares the package namespace, so its pub declarations are used directly; two vendored libraries that declare the same name are a duplicate declaration (E0201).

10.4 Build flags

iron build and iron run accept -o path / --output path, --release (optimized C compilation), --no-optimize (skip Iron's own IR optimizations), --debug (debug information on the .iron lines, without optimization or inlining, for gdb, lldb or the Visual Studio debugger), --debug-build (keep the generated C under .iron-build/), --emit-c (build only: write the C file and stop before the C compiler), --dump-ir-passes, --report-compression, --report-bounds-checks, --warn-fusion-break, --force-comptime (ignore the comptime cache), --target=web (section 10.5) and --target=<os>-<arch> (cross compilation, section 10.6). --no-strict-v3 accepts a few removed syntax forms for debugging old code. --verbose prints the generated C and the link line and --version prints the compiler version and the toolchain it compiles with. On Linux and macOS the environment variable IRON_CFLAGS adds C compiler flags (separated by spaces, at most 16) to the compilation of the program and of the runtime built into it, for example IRON_CFLAGS="-g -fsanitize=address,undefined" iron build for a sanitizer build.

--report-bounds-checks prints the source line, function and access kind for each checked list, array or view access left after Iron optimization, then a total. It does not weaken a check and omits checks in the prepended standard library.

10.5 The web target

iron build --target=web compiles a package to WebAssembly with the Emscripten toolchain pinned in .emsdk-version, producing dist/web/index.html together with its .js loader and .wasm module. It runs on every host Iron supports; emcc (or emcc.bat on Windows) must be on PATH, as emsdk_env leaves it. The [web] table of the manifest configures it:

[web]
title = "My App"                 # page title
shell = "custom_shell.html"      # optional HTML template
initial_memory = 67108864        # bytes
stack_size = 5242880             # bytes
pthread_pool_size = 4
assets = ["assets/sprites.png"]  # files preloaded into the virtual FS

A web program cannot await (E0501) and must drive its frame loop from main with a single canonical while loop (E0700 to E0703).

10.6 The backend

Iron compiles to C and hands the C to a compiler it ships. The generated C is C17 with the GNU extensions clang accepts under -std=gnu17 (statement expressions and __builtin_* intrinsics, __attribute__ annotations, _Static_assert), compiled with -fwrapv (integer overflow wraps), -fno-strict-aliasing and -ffp-contract=off (every float operation is rounded on its own; a * b + c is never fused into one multiply-add, so a float result is the same on every target). It includes only the runtime header, which itself depends only on the freestanding C headers (stdint.h, stddef.h, stdbool.h, stdarg.h, stdatomic.h); every fixture in the test suite also compiles with -ffreestanding -nostdinc.

The C compiler is a pinned toolchain, the same on every machine: clang, lld, llvm-ar, llvm-dlltool and compiler-rt from LLVM 23.1.2, built for the X86, AArch64 and WebAssembly targets and published per host (macOS arm64 and x86_64, Linux x86_64 and arm64, Windows x86_64) as the toolchain-23.1.2-1 release. ironc looks for it at $IRON_TOOLCHAIN, then next to itself in <prefix>/lib/iron/toolchain/, then in ~/.iron/toolchain/23.1.2-1/, where it downloads it on first use and verifies its SHA-256. It never uses a compiler from PATH, refuses a bundle built from another LLVM release and reports the one it uses in iron --version; iron toolchain info, iron toolchain path and iron toolchain install expose the same lookup. IRON_TOOLCHAIN is a developer override: a mismatched version there only warns.

A native build targets the host: aarch64-apple-darwin and x86_64-apple-darwin against libSystem, x86_64-linux-gnu and aarch64-linux-gnu against glibc, and x86_64-pc-windows-msvc against the Universal C Runtime. On Windows and macOS a native build links against the host's runtime bundle (below) when one is installed; the Windows release archive ships one next to ironc, so no Visual Studio Build Tools are needed there. Otherwise, and on Linux, the runtime and standard library C sources are compiled together with the program, so a native build also needs the platform's C library headers and link inputs: the Visual Studio Build Tools on Windows, the Xcode command line tools on macOS, the C library development package on Linux. When a platform prerequisite is missing ironc names it and offers to run the installer; iron toolchain check probes for them explicitly.

iron build --target=<os>-<arch> (and ironc build --target=...) cross compiles instead. The targets are linux-x86_64, linux-arm64, macos-arm64, macos-x86_64 and windows-x86_64. A cross build needs no SDK: the generated C is freestanding, and the program is linked against a runtime bundle, the Iron runtime and standard library and the compiler builtins precompiled for the target by the pinned toolchain, plus the target's C library. On Linux that is a static musl, linked with ld.lld, and the result is a static executable that runs on any distribution. On macOS the program is linked with ld64.lld against libSystem through a libSystem.tbd text stub that lists the symbols the runtime uses; dyld binds them to the real library at run time, the minimum system version is macOS 11, and arm64 executables are ad-hoc signed by the linker. On Windows the program is linked with lld-link against the Universal C Runtime and the other system DLLs it calls (ucrtbase, kernel32, ws2_32, bcrypt, all part of Windows 10 and later) through import libraries in the bundle, with the bundle's own entry point in place of the MSVC startup code; the executable needs neither the Build Tools nor the Visual C++ redistributable, and a debug build carries CodeView information in a PDB. Each release publishes iron-rt-<version>-<target>.tar.gz with a .sha256 sidecar; ironc looks for the bundle at $IRON_RT_DIR/<target>, then in <prefix>/lib/iron/rt/<target>, then in ~/.iron/rt/<version>/<target>, where it downloads and verifies it on first use. A bundle must come from the same compiler version and commit as ironc; IRON_RT_DIR is the developer override that only warns. In a package iron build --target= writes the binary to target/<target>/<name>; iron run and ironc run refuse a cross target because the binary cannot execute on the host, and --target is not accepted for library packages or together with --target=web.

Every bundle carries TLS: OpenSSL 3.5, built statically for the target, is linked into programs that import http or websocket. Servers are verified against the system's trust: the CA bundle of the Linux distribution or macOS (SSL_CERT_FILE and SSL_CERT_DIR override it), and on Windows the chain is checked by the system (CryptoAPI), which also fetches roots Windows has not stored yet. The Windows and macOS bundles also carry raylib; on macOS it is linked against .tbd stubs for the frameworks it uses (AppKit, Foundation, CoreFoundation, CoreGraphics, IOKit, libobjc). A Linux cross build cannot use raylib, because a static executable cannot load the system's OpenGL; such a program is built on the target, natively.


11. Diagnostics

The compiler reports errors as error[E0nnn] and warnings as warning[W0nnn], each with the source location and usually a hint. The codes cited in this manual:

Code Meaning
E0001 to E0005 lexical errors: unterminated string, invalid character, invalid number, out of memory, string too long
E0101, E0102 unexpected token, expected expression
E0175, E0176 keyword used as a binding name; field without val or var
E0200, E0201 undefined identifier; duplicate declaration
E0202, E0215, E0216, E0217, E0218 type mismatch; return type; argument count; argument type; not callable
E0203, E0234, E0235, E0266 reassigning a val; writing a field of a val; mutating call on a val; writing a parameter
E0204 using a nullable value without a null check
E0205, E0206 missing interface method; unsatisfied generic constraint
E0207, E0212, E0213, E0214, E0274, E0275 heap value escapes; free/leak of a non-heap value; leak of an rc; free/leak of a non-binding
E0208 write to an outer binding inside parallel
E0209 module not found
E0210 self outside a method
E0219, E0220 no such field; no such method
E0223 circular by-value type (an object that contains itself, also through T?)
E0222 mixing Int and Float
E0224, E0225, E0226, E0227, E0228 non-exhaustive match; pattern arity; unreachable arm; pattern binding shadows a name; unknown variant
E0229 empty list literal without a type
E0230, E0231, E0232 comptime step limit; unsupported comptime construct; comptime error
E0233 bitwise operator on a non-integer
E0237 method name reserved by a pub field accessor, or a method named drop (the destructor is drop { ... })
E0238 to E0245 method tier violations (readonly writes, pure I/O and calls, modifier placement)
E0246 to E0252 init rules (read before assign, unassigned field, double assign, method on partial self, early return, delegation, return value)
E0253, E0254, E0255 patch adds a field; patch target not found; patch redefines a method
E0256, E0257 init in an interface; tier mismatch with the interface
E0262, E0264, E0265 inline field default; var fields without an init; assigning a val field after its init
E0268, E0270, E0271, E0294, E0295, E0296 pointer arithmetic; address of a temporary; escaping stack reference; regime errors; & on an rc
E0273, E0274, E0297, E0298 heap/rc/pool in an invalid position
E0278 I/O in a readonly method
E0284 to E0288 duplicate drop/copy; copy of a nocopy value (also a capture that is not a borrow, 6.7); readonly drop; early return in drop
E0289 checked/unchecked pointer mismatch
E0293 missing return
E0299, E0301 dereferencing a weak rc; rc inside an arena block
E0310, E0311, E0312 invalid cast; constant does not fit; constant index out of range
E0314 possibly uninitialized var
E0320, E0321 private declaration used from another file; standalone func Type.method form
E0322, E0323, E0324, E0325, E0326 unsupported is; unsupported match subject; lambda parameter type; awaited twice; thread pools
E0328, E0329, E0330 implicit list copy or capture; indexing an unordered list; address of a growable list element or of a field inside one
E0368 invalid //iron:align, or over-aligned storage not supported yet
E0369 a view escapes its source or a returned view has no unique source parameter
E0370 a view source is not borrowable or is changed while the view is live
E0335 break / continue outside a loop, in a lambda, defer or spawn body inside one, or in a parallel loop
E0336 return inside a defer body
E0346 a for loop body adds to, removes from or replaces the list it iterates
E0350, E0351 static function called on a value; method called through the type name
E0352, E0353 implementor's function static where the interface's is not (or the reverse); implementor's parameters or result differ from the interface signature
E0354 static function, or method built from Self, called through an interface value
E0355 T.f() on a type parameter whose constraint declares no static function f
E0356 __fields, reserved for code the compiler generates
E0357 to E0361 reflection outside expansion; non-object reflection target; non-constant comptime if; unmatched comptime match; field builder cannot initialize a type
E0331, E0332, E0333, E0334 list extension with a body; refutable nested pattern; Channel.new(4) and the other .new constructor spellings; interpolating a value with no text form
E0345 initialization cycle between globals
E0501 await on the web target
E0700 to E0703 web main loop rules
W0601, W0604, W0605, W0606 narrowing cast; spawn data race; arena skips drop; heap value never freed
W0611, W0613, W0614 unused import; var never reassigned; var parameter never reassigned
W0617 unknown //iron: compiler hint, or one on a declaration it does not apply to
W0618 //iron:fusible on a function that has an effect or can panic (reported by build and run)
W0631 //iron:inline cannot be honored because the function is recursive

docs/dev/diagnostic-codes.md lists every code with its message.


12. Not yet implemented

Settled design decisions that the compiler does not implement yet, listed so that older material is not mistaken for the current language:


13. Complete syntax of Iron

This section gives the syntax of Iron in extended BNF, one production per construct, as implemented by the parser in src/parser/parser.c. { x } means zero or more repetitions of x, [ x ] means optional, ( x | y ) groups alternatives and 'x' is a literal token. IDENT, INT, FLOAT and STRING are the lexical tokens of section 1; STRING covers plain, multi-line and interpolated strings. NAME is an identifier or one of the keywords allowed in name position. Newlines and comments are not part of the grammar: the parser skips them between any two tokens (see section 1.6). The grammar is checked against the fixture corpus by scripts/grammar_check.py.

Comments are lexical (section 1.2):

comment        ::= '//' { any character except newline }
directive      ::= '//' KEY ':' { any character except newline }
KEY            ::= 'a'..'z' { 'a'..'z' | '0'..'9' | '_' }
doc_run        ::= { own-line comment, one per line, no blank line between }
                   (documents the declaration, field, enum variant or
                    interface method on the next line)

The iron directive values implemented here are fusible, inline, noinline, cold, and align=N|cacheline. Function directives attach to the following declaration. align may also precede an object, field or local binding. Directives are lexical comments, so they add no grammar production.

-- and /// are transitional spellings of //, and @fusible of the directive //iron:fusible (section 1.2).

program        ::= { decl }

decl           ::= import_decl
                 | val_decl
                 | var_decl
                 | [ ( 'nocopy' | 'pub' ) ] object_decl
                 | [ 'pub' ] ( func_decl | extern_decl | patch_decl | interface_decl | enum_decl | array_ext_decl
                             | type_method_decl )
                 | test_decl

import_decl    ::= 'import' IDENT { '.' IDENT }
func_decl      ::= [ '@' 'fusible' ] 'func' IDENT [ generic_params ] param_list [ '->' type ] block
test_decl      ::= 'test' STRING block
extern_decl    ::= 'extern' 'func' IDENT param_list [ '->' type ]
array_ext_decl ::= [ '@' 'fusible' ] 'func' '[' IDENT ']' '.' NAME [ generic_params ] param_list [ '->' type ] block
type_method_decl ::= [ '@' 'fusible' ] 'func' IDENT '.' NAME [ generic_params ] param_list [ '->' type ] block
generic_params ::= '[' generic_param { ',' generic_param } [ ',' ] ']'
generic_param  ::= IDENT [ ':' IDENT ]
param_list     ::= '(' [ param { ',' param } [ ',' ] ] ')'
param          ::= [ 'val' | 'var' ] NAME [ ':' type ]

object_decl    ::= 'object' IDENT [ generic_params ] [ impl_clause ] '{' { member } '}'
impl_clause    ::= 'impl' IDENT { ',' IDENT }
member         ::= [ 'pub' ] [ 'readonly' | 'pure' ] ( field | method | init_decl | 'copy' block | 'drop' block )
                 | [ 'pub' ] 'static' method
field          ::= ( 'val' | 'var' ) IDENT ':' type
method         ::= 'func' NAME [ generic_params ] param_list [ '->' type ] block
init_decl      ::= 'init' [ IDENT ] param_list block

patch_decl     ::= 'patch' 'object' IDENT [ impl_clause ] '{' { patch_member } '}'
patch_member   ::= [ 'pub' ] [ 'readonly' | 'pure' ] ( method | init_decl | 'copy' block | 'drop' block )
                 | [ 'pub' ] 'static' method

interface_decl ::= 'interface' IDENT '{' { iface_method } '}'
iface_method   ::= [ 'static' | 'readonly' | 'pure' ] 'func' IDENT param_list [ '->' type ] [ block ]

enum_decl      ::= 'enum' IDENT [ generic_params ] '{' [ variant { ',' variant } [ ',' ] ] '}'
variant        ::= IDENT [ '(' type { ',' type } [ ',' ] ')' | '=' INT ]

val_decl       ::= 'val' ( binding [ ':' type ] [ '=' init_expr ]
                         | '(' binding { ',' binding } [ ',' ] ')' [ ':' type ] '=' expr )
var_decl       ::= 'var' binding [ ':' type ] [ '=' init_expr ]
binding        ::= IDENT | '_'
init_expr      ::= spawn_expr | expr

block          ::= '{' { stmt } '}'
stmt           ::= val_decl | var_decl | return_stmt | if_stmt | while_stmt | for_stmt
                 | match_stmt | defer_stmt | free_stmt | leak_stmt | in_arena_stmt
                 | comptime_for_stmt | comptime_if_stmt | comptime_match_stmt
                 | spawn_expr | block | expr_stmt
return_stmt    ::= 'return' [ expr ]
if_stmt        ::= 'if' expr block { 'elif' expr block } [ 'else' block ]
while_stmt     ::= 'while' expr block
for_stmt       ::= 'for' ( IDENT | '(' IDENT ',' IDENT ')' ) 'in' expr
                   [ 'parallel' [ '(' expr ')' ] ] block
match_stmt     ::= 'match' expr '{' { match_arm } [ 'else' '->' arm_body ] '}'
match_arm      ::= pattern '->' arm_body
arm_body       ::= block | stmt
comptime_for_stmt ::= 'comptime' 'for' IDENT 'in' 'fields' '(' type ')' block
comptime_if_stmt ::= 'comptime' 'if' expr block
                     { 'elif' expr block } [ 'else' block ]
comptime_match_stmt ::= 'comptime' 'match' type '{'
                        { type '->' arm_body }
                        [ 'else' '->' arm_body ] '}'
pattern        ::= [ IDENT '.' ] IDENT [ '(' [ sub_pattern { ',' sub_pattern } [ ',' ] ] ')' ]
                 | expr
sub_pattern    ::= '_' | IDENT '.' IDENT [ '(' [ sub_pattern { ',' sub_pattern } [ ',' ] ] ')' ] | IDENT
defer_stmt     ::= 'defer' ( 'free' expr | block | stmt )
free_stmt      ::= 'free' expr
leak_stmt      ::= 'leak' expr
in_arena_stmt  ::= 'in' expr block
spawn_expr     ::= 'spawn' '(' STRING [ ',' expr ] ')' block
expr_stmt      ::= expr [ assign_op expr ]
assign_op      ::= '=' | '+=' | '-=' | '*=' | '/=' | '<<=' | '>>=' | '&=' | '|=' | '^='

expr           ::= unary { binary_op unary | 'is' IDENT }
binary_op      ::= 'or' | 'and' | '|' | '^' | '&' | '==' | '!=' | '<' | '>' | '<=' | '>='
                 | '<<' | '>>' | '+' | '-' | '*' | '/' | '%'
unary          ::= '-' unary | 'not' unary | '~' unary | '&' unary
                 | 'heap' [ heap_opts ] unary | 'rc' unary | 'weak' 'rc' ( 'null' | unary )
                 | 'comptime' unary | 'await' unary | postfix
heap_opts      ::= '(' heap_opt { ',' heap_opt } ')'
heap_opt       ::= 'in' ':' expr | 'allow_drop_skip' ':' ( 'true' | 'false' )
postfix        ::= primary { '.' NAME [ type_args ] [ call_args ]
                           | '.' '(' IDENT ')'
                           | '[' expr [ '..' [ expr ] ] ']'
                           | '[' elem { ',' elem } ']'
                           | call_args }
elem           ::= expr | type
call_args      ::= '(' [ expr { ',' expr } [ ',' ] ] ')'
type_args      ::= '[' type { ',' type } ']'
primary        ::= INT | FLOAT | STRING | 'true' | 'false' | 'null' | IDENT | 'self'
                 | '(' expr ')'
                 | '(' elem ',' elem { ',' elem } [ ',' ] ')'
                 | '[' type ';' expr ']'
                 | '[' [ elem { ',' elem } [ ',' ] ] ']'
                 | lambda
lambda         ::= 'func' param_list [ '->' type ] block

type           ::= 'weak' 'rc' type
                 | 'rc' type
                 | [ '?' ] ptr_type
                 | tuple_type
                 | list_type
                 | func_type
                 | reflected_type
                 | named_type
reflected_type ::= IDENT '.' 'type'
ptr_type       ::= '*' [ 'var' ] [ 'unchecked' ] type
tuple_type     ::= '(' type ',' type { ',' type } ')'
list_type      ::= '[' elem_type { ',' list_attr } [ ';' [ '<=' ] expr ] ']' [ '?' ]
elem_type      ::= func_type | 'rc' type | 'weak' 'rc' type | list_type | ptr_type | tuple_type | named_type
list_attr      ::= 'layout' ':' ( 'soa' | 'aos' ) | 'unordered'
func_type      ::= 'func' [ '(' [ type { ',' type } ] ')' ] [ '->' type ]
named_type     ::= IDENT '?' [ type_args ] | IDENT [ type_args ] [ '?' ]

NAME           ::= IDENT | 'init' | 'copy' | 'drop' | 'null' | 'free'

Operator precedence is given in section 3.1: expr is parsed by precedence climbing over the flat sequence of unary operands and operators, with every binary operator left associative and is binding loosest of all. In postfix, X.Y(args) and X.Y where X and Y both start with an uppercase letter denote an enum variant construction, and x.m[T](args) is only read as a generic method call when the token after [ is a type name starting with an uppercase letter, [, or a token that starts a type by the rule below, and a ( follows the ]. Between the brackets of postfix and of a list primary, and between the parentheses of a tuple primary, an element is read as a type when

Every other element is an expr; one that names a type (Int, Map[String, Int], [Int], (Int, String)) is taken as that type where a type argument is expected. In pattern, the first alternative is used when the arm starts with IDENT '.' or with an uppercase identifier followed by (, and expr otherwise. The standalone form func (r: T) method() is recognized only to report E0260. type_method_decl (func Type.method()) and array_ext_decl (func [T].method()) declare the standard library's methods; in a program they are errors (E0321, and E0331 for a list extension with a body), and patch object is how a program adds methods to a type.