Writing Esper IR
Functions, values, locals, constants and source locations in esper 1.
Start a file with esper 1. Whitespace and // comments separate tokens.
An optional unit "name" gives the translation unit a display name. A file may
contain types, constants, globals, imports and functions.
Names and types
Write name type; colons belong to block labels. Function return types follow
) without an arrow. Use void for no result, or (i32, i64) for multiple
results. Bare names are convenient when writing by hand. %17 is an anonymous
name, not instruction number 17: gaps and out-of-order numbering are allowed.
Backticks escape keywords: `return` i32 = 4. Escape a backtick or backslash
inside a quoted name with a backslash. Names currently have a 62-byte reader
limit. Avoid quoted names beginning with %: the current name table shares
that namespace with numeric temporaries.
esper 1
fn add(a i32, b i32) i32 {
entry:
%90 i32 = add a, b
return %90
}
fn main() i32 {
entry:
result i32 = call add(20, 22)
return result
}
A typed definition creates one immutable value. Its definition must dominate
its uses: every path to a use must have executed that definition. Parameters
are immutable too. Integers in an expression take their type from the operation;
where no context supplies a type, write 12 as i32.
Loops without PHI nodes
Declare mutable locals before the first block. Assign without repeating the type. Every reachable path must assign before a read; there is no implicit zero.
esper 1
fn main() i32 {
local i i32
local total i32
entry:
i = 0
total = 0
jump loop
loop:
more i1 = lt.s i, 10
branch more, body, done
body:
total = add total, i
i = add i, 1
jump loop
done:
return total
}
This returns 45. snapshot i32 = copy total freezes the value at that point;
later assignment to total cannot change the snapshot. Locals are virtual
variables with no exposed address. Use an explicit stack allocation when your
language needs addressable storage.
The current reader lowers locals to temporary slots and runs mem2reg. The optimizer still uses SSA internally. Printed optimized code may contain extra locals and edge blocks: those preserve parallel assignments and conditional edges. It does not reconstruct the original source layout.
Constants and aggregate types
esper 1
#[layout(c)]
type Point = struct {
x i32
y i32
}
const origin Point = { x = 0, y = 0 }
const limit i32 = 100
#[readonly]
global bytes [?]i8 = embed hex "00 01 ff"
fn main() i32 {
entry:
point Point = copy origin
x i32 = field point, 0
return x
}
A constant is a value. A global is storage with an address. #[readonly]
describes immutable global storage. Arrays use [4]i32; [?]i8 infers a byte
count from an embed initializer. There is no implicit terminating zero.
Struct fields are named; unions overlay their members. layout(c) uses the
current core’s natural layout. It is not yet a complete target-specific C ABI
layout engine. Other agreed layout policies are described in the roadmap.
embed "asset.bin" calls the embedding application’s EsperResources.resolve
callback. The parser copies the returned borrowed bytes. Buffer and file parsing
do not automatically grant filesystem access: the application supplies the
resolver through esper_parse_resources. The printer writes embed hex so its
output is self-contained and does not depend on the original asset path.
Source locations and names
esper 1
source app = "example.toy"
#[loc(app, 1, 1)]
fn main() i32 {
entry:
#[debug(name = "answer")]
value i32 = add 20, 22 @app:2:3
return value @app:3:3
}
Prefix #[...] carries semantic properties and debug names. A trailing
@file:line:column carries an instruction location, leaving atomic ordering in
the prefix. Source coordinates are one-based. The current core associates a
file with a function; cross-file instruction locations are rejected rather
than silently attributed to the wrong file. Rich type display descriptions,
inline frames and optimized variable location tracking need further work.
The printer preserves executable behavior; it is not yet a lossless archive of
all debug type and member metadata.
Calls and machine-facing operations
Use import fn puts(text ptr) i32 for an external function and import global
for external storage. The embedding runtime or final linker resolves imports.
An import alone does not load a dynamic library. export fn is accepted;
separate visibility and symbol-resolution policies are still planned.
#[inline(hint)], #[inline(always)], and #[inline(never)] guide the inlining
pass. always bypasses size heuristics when that pass runs, but recursion and
other safety checks still apply. It does not guarantee all calls disappear.
The existing instruction set includes volatile accesses, fences and atomics.
For loads/stores use #[order(acquire)] value i32 = atomic.load address and
#[order(release)] atomic.store address, value. RMW and compare-exchange still
use their compatibility operands; compare-exchange has the existing boolean
result and single-order contract. Do not assume a C compiler’s entire atomic
model is implemented. TLS and thread runtime contracts are planned.
Compatibility text
Unversioned files beginning with func, source 0, and similar directives use
the older reader. esper_print retains that format for existing clients and
fixture snapshots. esper_print_ir and CLI --emit=text select esper 1.
Low-level opcode spellings such as ldi i32 4 remain accepted by the new reader;
the printer still uses some of these explicit spellings.
The ISA reference and existing builder tutorials include compatibility snapshots. Their operations remain useful, but use this page for the new surface grammar. Conversion preserves program semantics, not original names or comments.
Separate compilation
Use #[private] on a function or global definition when its name should stay
local to its translation unit. Imports cannot be private. For relocatable
function-table handles, write const callback i32 = function target instead of
encoding a numeric function index. See saving and linking units
for binary archives, import resolution, and current indirect-call restrictions.