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.