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` packagecreates, in the current directory:
my-app/
iron.toml # the manifest
.gitignore # ignores target/
src/
main.iron # the entry pointand 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.99sReads 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 completedType-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 passediron 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.ironTake 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 |