This page is generated from docs/guide.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/guide.md; see /llms.txt for the full index.

Projects

Create, build, run and test Iron projects, and bring in third-party code by vendoring it. This guide covers the iron project commands; the language itself is in the reference manual. It is published at ironlang.dev/guide, generated from this file.

Two binaries

Iron ships two binaries, in the spirit of Cargo and rustc:

Binary Role
iron The project tool: scaffolding, builds, runs, checks, tests, formatting
ironc The compiler: compiles one .iron file to a native binary

For most work you only use iron. It reads the project manifest (iron.toml), gathers the sources (including anything vendored under vendor/) and invokes ironc. Given a .iron file argument (iron build hello.iron, iron run script.iron) it forwards to ironc for a single-file build, so one command covers both.

Iron has no package manager: no registry, no download step, no lockfile. See Third-party code.

Both binaries are installed side by side in ~/.iron/bin/ and iron finds ironc next to itself. Programs are compiled with Iron's own pinned C toolchain, downloaded on first use; iron --version prints which one (see the manual's backend section).

Creating a project

$ iron init
     Created binary `my-app` package

creates, in the current directory:

my-app/
    iron.toml          # the manifest
    .gitignore         # ignores target/
    src/
        main.iron      # the entry point

and runs git init when the directory is not already in a repository. iron init --lib creates a library package with src/lib.iron instead. iron init works in a non-empty directory: it creates the files that do not exist and leaves the others alone.

A grown project looks like this:

my-app/
    iron.toml
    .gitignore
    src/
        main.iron      # bin entry point (lib.iron for a library)
    vendor/            # third-party source, committed (optional)
    tests/
        test_math.iron # found by iron test
    target/
        my-app         # the built binary
        combined.iron  # the sources as one program (vendored code, several files)

The entry point is a convention: src/main.iron for a binary, src/lib.iron for a library; there is no entry field.

The manifest

[package]
name        = "my-app"
version     = "0.1.0"
type        = "bin"             # "bin" (default) or "lib"
description = "A cool project"  # optional
iron        = ">= 4.6.0-alpha"  # optional compiler version constraint
Field Required Meaning
name yes The package name; also the binary's file name
version yes A semantic version
type no "bin" (default) or "lib"
description no A short description
iron no A Cargo-style semver constraint on the compiler, checked by iron build and iron run

An optional [fmt] table configures the formatter (indent_width, default 4, line_width, use_tabs), and a [web] table configures the web target (see the manual's web target section). There is no dependency table.

Commands

iron build

$ iron build
   Compiling my-app v0.1.0
Built: /home/you/my-app/target/my-app
    Finished dev [unoptimized] in 1.99s

Reads iron.toml, gathers vendor/ and src/, compiles them as one program and writes the binary to target/; a type = "lib" package produces target/lib<name>.a and a <name>.iron-stub describing its public surface. --release builds with optimization and --verbose prints the generated C and the compiler command line; both pass through to ironc.

iron build --target=linux-x86_64 (or linux-arm64) cross compiles a binary package into a static Linux executable under target/<target>/, --target=macos-arm64 (or macos-x86_64) into a macOS executable and --target=windows-x86_64 into a Windows .exe, from any host and without an SDK: the program is linked against a precompiled runtime bundle that ironc downloads once per compiler version into ~/.iron/rt/ (see the manual's backend section). Apart from that first download iron build never touches the network.

Debugging

iron build --debug (or ironc build file.iron --debug, and the same for run) builds a binary whose debug information points at the .iron source: a breakpoint on main.iron:12 stops on that line, stepping moves from Iron line to Iron line and the call stack lists Iron functions. The build skips optimization and function inlining so every function keeps its frame. Any C debugger reads it: gdb and lldb on Linux and macOS, the Visual Studio debugger (a PDB is written next to the .exe) on Windows.

$ iron debug                       # in a package; or: iron debug main.iron
(gdb) break main.iron:12
(gdb) run

A panic (an index out of bounds, a failed assert, a missing map key) stops the debugger on the Iron line that failed: iron debug and iron dap put a breakpoint on the C library's abort, where every panic ends, and select the Iron frame that panicked (Iron panic at main.iron:6), so print and the locals show that function's values. Through iron dap the stop is reported as an exception with the panic's message and the call stack starts at the Iron frame; "stopOnPanic": false in the launch configuration turns this off. In LLDB started by hand, run iron-panic-stop after loading the formatters; in gdb, break abort. A --debug build stops there in any debugger, even with nothing loaded (plain gdb or LLDB, the Visual Studio debugger): when a debugger is attached, a failing check (an assert, an index, a division by zero, unwrap() of a null Box, pop() on an empty list, remove, insert or set out of range, read_file of a file it cannot read) runs a breakpoint instruction in the Iron function and prints panic: <message> at <file>:<line>; continuing runs the panic as usual. Panics the program cannot check where it calls (out of memory, a full arena) still end in abort.

iron debug builds with --debug and starts the debugger on the program with the value formatters below already loaded: LLDB on macOS and Windows, gdb on Linux (--gdb / --lldb choose). Arguments after -- go to the program. Building with iron build --debug and starting a debugger yourself works the same way.

iron dap is the debug adapter for editors: a Debug Adapter Protocol server on standard input and output that VS Code, Neovim (nvim-dap) and Zed can run. Its launch request takes program (a .iron file, a package directory, or a binary that is already built), args, cwd, env and stopOnEntry, and test, the name of a test "..." block of the .iron file: the file is built with --test and only that test runs, so its breakpoints stop. It builds the program with --debug, runs lldb-dap (from LLVM or Xcode) or, without it, gdb 14 or later in its DAP mode, loads the value formatters below, lists locals under their Iron names without the compiler's temporaries, and shows Iron function names in the call stack. editors/neovim (nvim-dap) and editors/zed (Zed's debugger) configure it; their READMEs have the details. --adapter <path> (or IRON_DAP_ADAPTER) picks the debugger. It needs Python 3.8 or later; the pinned toolchain does not include lldb-dap. The value formatters need an LLDB built with Python (Xcode's and the distributions' are); with an lldb-dap built without it, breakpoints, stepping, the stack and panics still work, values show as their C structs, and the debug console says so.

iron debug --check (or iron dap --check) shows what debugging needs on this machine, what it found and how to install what is missing; it exits 0 when both iron debug and iron dap can run. When a piece is missing, iron debug and iron dap say which and how to get it, and an editor shows the same message when its debug session fails to start. Where the pieces come from:

iron debug iron dap (editors)
macOS lldb, from the Xcode Command Line Tools (xcode-select --install), which building already needs Python 3 and lldb-dap, both from the Command Line Tools
Linux gdb or lldb from the distribution (sudo apt install gdb, sudo dnf install gdb) Python 3 (installed on most distributions) and lldb-dap (sudo apt install lldb) or gdb 14 or later
Windows lldb from LLVM's installer (winget install LLVM.LLVM) and Python 3.10 or later Python 3.10 or later (winget install Python.Python.3.12) and lldb-dap from LLVM's installer (winget install LLVM.LLVM); the lldb-dap.exe in Visual Studio's LLVM cannot run (it has no liblldb.dll). Without them VS Code falls back to the C/C++ extension's debugger

On Windows LLDB needs Python even to start (LLVM's build loads python3.dll, version 3.10 or later), so iron dap uses the same Python and hands its directory to lldb-dap; the value formatters then load as on the other systems.

Through iron dap, what you type while paused is Iron: hover, watch expressions and the debug console take xs.len() > 2 and not done, name == "ann", ages.get("ann"), xs[i] * 2 or color == Color.Green. A name or a field (total, p.x) is read from the debugger, with its type and children; anything else is computed by the adapter with Iron's rules: integer arithmetic wraps at the width of its operands (a literal takes its partner's type), Int and Float or two widths do not mix, / truncates, float division by zero gives inf or NaN and % on floats is fmod, s[i] is s.char_at(i) ("" out of range), string literals take Iron's escapes and {expr} interpolation, a map's has looks at its keys, a set's at its items, and a global is found by its Iron name. The list, map, set and string methods len, is_empty, has, get, get_or, contains, starts_with, ends_with, index_of, substring, char_at, upper, lower, trim, replace, repeat and String.from_byte are available, as are the conversions Int(x), UInt8(x), Float(x) and the like. An expression that fails gives the error the program would panic with: integer division by zero, index out of bounds (index 5, bound 3), key not found in map; an unknown name gives no variable `name` here. Breakpoint conditions are Iron too, and so are hit counts (5, >= 5, % 5) and logpoint messages, whose {expr} parts are filled in like an Iron string (i = {i}, total = {total}). An expression that is not Iron (a debugger command typed in the console, a C expression) goes to the debugger as written; start it with a backquote to skip the Iron reading. The debug console completes the Iron names in scope.

Values show as Iron writes them: integers of every width as numbers, floats as Iron prints them (0.1, 5, inf), strings as Iron literals ("a\u{0}b", "line\n"), objects as Point {x = 3, y = 4} (generic instances as Box[Int] {...}), lists as their elements, maps and sets with their entries as children (["ann"] = 31), enums as Green or Rect(2, 3), an rc value with its counts, a closure with the values it captured (a captured var as its current value), a checked pointer or heap value as the value it points at (freed once freed), a Mutex as Mutex(5) locked by thread 4711 or unlocked, a Json value as its JSON text, a JsonWriter as what it wrote so far, and a MysqlResult, MysqlConn, MysqlPool or HttpClient as what its handle holds (rows, columns and the first rows; the server and connection state; the pool's counts; the origin and how many of its connections are open and busy). The Globals scope lists the program's global bindings. C declares a function's locals at its top, so a binding declared further down the function is listed too: until its statement runs it holds whatever its memory held (<not assigned yet> for a collection or string the formatters can tell is not one yet).

Step Into stays in Iron code: it enters square(4), a method, an init, a lambda or a generic instance, but steps over the runtime behind println(...) or xs.push(v), instead of walking through the runtime's C. Stepping visits each statement's line, a loop's header between passes and a function's closing brace before it returns; Step Over or Step Out of main lets the program run to its end. The call stack names Iron functions as written: Counter.add, gid[Int], func for a lambda. A spawned task's thread carries the task's name (spawn("db-worker"); pool workers are iron pool), its body's frame reads spawn "db-worker", and under it a frame spawned in main points at the spawn statement.

In VS Code, F5 in a .iron file debugs it with the Iron extension's iron debug type ("type": "iron", "program" a .iron file or the package directory), which runs iron dap; on Windows without lldb-dap and Python it uses the C/C++ extension's Visual Studio debugger instead (the iron.debug.windowsDebugger setting chooses); see the extension's README.

Without the extension's debug type, pair a C debugger extension (C/C++ from Microsoft, or CodeLLDB) with a build task:

// .vscode/tasks.json
{ "version": "2.0.0",
  "tasks": [{ "label": "iron: build --debug", "type": "shell",
              "command": "iron build --debug", "problemMatcher": [] }] }

// .vscode/launch.json
{ "version": "0.2.0",
  "configurations": [{
    "name": "Iron: debug",
    "type": "cppdbg",            // "cppvsdbg" on Windows, "lldb" with CodeLLDB
    "request": "launch",
    "program": "${workspaceFolder}/target/my-app",
    "cwd": "${workspaceFolder}",
    "MIMode": "gdb",             // "lldb" on macOS
    "preLaunchTask": "iron: build --debug"
  }]
}

Parameters and local bindings show under their Iron names (w, total); a name used twice in one function, or one that is also a C keyword, carries a suffix (total_14). The compiler's own temporaries carry no debug info, so no debugger lists them (set IRON_DEBUG_TEMPORARIES=1 when building to keep them, as _v12: a C name that starts with _ is never an Iron binding). C cannot leave a parameter out of the debug info, so the synthetic ones get names a debugger front end hides: a closure's environment is _env, and a var parameter's pointer to the caller's binding keeps a _v name (the copy the function works on is listed as the parameter's Iron name, n). A list parameter passed as a C array is xs with its length xs_len, which iron dap shows as one list. A var that a closure captures lives in a shared cell and appears as _ref_count, a pointer to it; inside the closure, each captured binding appears the same way. The locals command that the formatters below add to gdb and LLDB lists the Iron bindings only: it leaves those out and shows _ref_count as count with its value.

lib/debug/ in the Iron installation holds formatters that show values as Iron values: a String as its text, a list or set as its elements, a map as its entries, a T? as its value or null, an enum value as its variant and payload (Ok(5), Rect(2, 3)), an interface value as the implementing type and its value, an rc or weak rc value as the object with its counts (rc Point {x = 3, y = 4} (strong=2, weak=0)) and a closure as its function and source line, with the bindings it captured as children. Load iron_gdb.py in gdb and iron_lldb.py in LLDB:

(gdb) source ~/.iron/lib/debug/iron_gdb.py
(lldb) command script import ~/.iron/lib/debug/iron_lldb.py

In the VS Code configuration above, add "setupCommands": [{ "text": "source ~/.iron/lib/debug/iron_gdb.py" }] (cppdbg with gdb) or "initCommands": ["command script import ~/.iron/lib/debug/iron_lldb.py"] (CodeLLDB). Adjust the path to where Iron is installed.

iron run

$ iron run
   Compiling my-app v0.1.0
Built: /home/you/my-app/target/run/my-app
    Finished dev [unoptimized] in 2.02s
     Running /home/you/my-app/target/run/my-app
Hello, Iron!

Arguments after -- go to the program: iron run -- --port 8080. The program receives them as its main parameter, func main(args: [String]), and reads environment variables with OS.env(name) (import os).

iron check

$ iron check
    Checking my-app v0.1.0
    Finished check completed

Type-checks the project, vendored code included, without producing a binary.

iron test

A test is a test "name" { ... } block, in any file of src/ (next to the code it tests, with access to its private functions) or tests/ (with the project's pub declarations):

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

test "double doubles" {
    assert_eq(double(21), 42)
}

test "double of a negative" {
    assert_eq(double(-3), -6)
    assert_ne(double(1), 1)
}
$ iron test
     Testing my-app v0.1.0
test double doubles ... ok
test double of a negative ... ok

2 passed, 0 failed
    Finished all tests passed

iron test compiles vendor/, src/ and tests/ into one test program and runs each test in its own process, several at a time: a failed assert, assert_eq or a panic fails that test, with its output and the source line, and the others still run. iron test <filter> runs the tests whose name contains <filter>. The exit status is 1 when a test fails. iron build and iron run type-check test blocks but leave them out of the program, and the program's main is not run by the tests.

A tests/test_*.iron file with its own func main is a standalone test program, run as a whole: it passes when it exits with status 0.

ironc test file.iron [filter] runs the test blocks of a single file, and ironc build --test builds the test program itself (--iron-list prints the test names, --iron-test <n> runs one).

iron fmt

$ iron fmt src/main.iron
$ iron fmt --check src/main.iron
would reformat src/main.iron

Formats a file in place: it re-indents each line from the nesting of the brackets around it (four spaces per level, or [fmt] indent_width and use_tabs in iron.toml), removes trailing whitespace and runs of blank lines, and changes nothing else, so comments and the line breaks you chose stay as written. --check rewrites nothing and exits 0 when the file is already formatted, 1 when it would change, 2 on a syntax error.

Third-party code

Iron deliberately has no package manager: no registry, no iron add, no dependency resolver, no lockfile. The standard library is meant to cover the common ground (collections, strings, math, I/O, time, logging, networking, HTTP) and raylib ships with the compiler.

When you need someone else's code you take a copy of it, as Odin projects do: the source goes into your repository, you read it, and you own it. Builds are reproducible because everything they compile is committed, and they never reach out to the network.

Vendoring

Put third-party code under vendor/, one subdirectory per library. Nothing is declared in iron.toml:

my-game/
    iron.toml
    src/
        main.iron
    vendor/
        greeter/           # an Iron library project, copied as is
            iron.toml
            LICENSE
            src/
                lib.iron
        noise/             # or loose .iron files
            perlin.iron

Take the copy however suits you:

# a release archive
mkdir -p vendor/greeter
curl -sSL https://example.com/greeter-0.3.0.tar.gz | tar -xz --strip-components=1 -C vendor/greeter

# or track an upstream repository with git subtree
git subtree add --prefix vendor/greeter https://example.com/greeter.git v0.3.0 --squash

Vendored code is compiled into the same program as your own, so its pub functions and types are available directly. A library's src/lib.iron:

pub func greet(name: String) -> String {
    return "Hello, {name}!"
}

and the project's src/main.iron, where import greeter is optional and documents where the names come from:

import greeter

func main() {
    println(greet("Iron"))
}

Vendored code shares the project's namespace. If two libraries declare the same name the build fails with a duplicate declaration error; rename one of them in your copy. Aliased imports (import greeter as g) are not supported for vendored or project-local modules yet.

What gets compiled

iron build, iron run and iron check collect every .iron file under vendor/, then the project's own src/*.iron, into target/combined.iron and compile that as one program:

Under vendor/ Compiled
A directory with its own iron.toml and src/ only its src/, so a library project can be dropped in unchanged
any other directory every .iron file, recursively
tests/, examples/, target/ no
hidden directories (.git, ...) no
other files (LICENSE, README.md, ...) no; keep the license next to the code

Files are compiled in sorted path order. An error in vendored code is reported against target/combined.iron; the -- vendor: comment above each file says where it came from.

Updating vendored code

Updating a dependency is an ordinary change to your repository: replace the directory with a newer copy (or git subtree pull), rebuild, and review the diff before committing. Local patches are edits; keep a note in the vendored directory if they have to be reapplied after the next update.

Sharing a library

Publish its source (a repository or an archive) with an iron.toml and a src/ directory, as iron init --lib creates them. Users copy it into their vendor/.

C libraries

Not yet: extern func can call C functions the compiler already declares (the C library, raylib), but a project cannot yet declare and link an arbitrary vendored C library.

Environment

Variable Meaning
NO_COLOR Disable colored output when set to any value
FORCE_COLOR Force colored output when not writing to a terminal
IRON_TOOLCHAIN Use the C toolchain bundle at this path (developer override)
IRON_LIB_DIR Use the standard library at this path (developer override)

Errors

Message Cause Fix
iron.toml declares dependency '...' the manifest still has a [dependencies] table vendor the code and delete the table
duplicate declaration two vendored libraries, or a library and your code, declare the same name rename one of them in your copy
no iron.toml found not in a project directory iron init, or cd into the project
the Iron toolchain ... is not installed the pinned C toolchain could not be downloaded iron toolchain install, see INSTALL.md