A translation unit is the IR produced from one compilation step. Your frontend can build one directly in memory or parse Esper text. Save that unit as a binary archive, then load it later alongside the units from other source files.

You do not need to optimize each file separately. Merge the units first and pass the result to esper_optimize. Calls across the original file boundaries then look like ordinary calls inside one unit.

Give each file its own unit

The caller declares what another file provides:

esper 1
import fn answer() i32

fn main() i32 {
entry:
  result i32 = call answer()
  return result
}

The other unit supplies the definition:

esper 1
#[private]
fn helper() i32 {
entry:
  return 42
}

fn answer() i32 {
entry:
  result i32 = call helper()
  return result
}

#[private] tells the unit linker that a definition belongs to this input alone. Other files can have their own helper. The linker gives private definitions unique names and updates their references. The builder equivalents are esper_func_set_private and esper_unit_set_global_private.

Ordinary definitions share their names across units. An import and a definition must have compatible signatures. Two definitions of the same public function or global are an error. A private import is also an error. Private currently controls Esper unit linking; it is not a promise about object-file symbol visibility in every backend.

Save a unit

The library takes a FILE *; your compiler chooses the path and handles closing or atomically replacing files. Open archives in binary mode, especially on Windows.

bool save_unit(const EsperUnit *unit, const char *path) {
  FILE *file = fopen(path, "wb");
  if (!file) return false;
  bool ok = esper_write(unit, file);
  if (fclose(file) != 0) ok = false;
  return ok;
}

esper_write reports encoding and write errors through the unit’s diagnostic sink. It does not close or flush the stream. A failed write can leave a partial file, so a build cache should write a temporary file and rename it after success.

For an in-memory cache, use esper_serialize(unit, &bytes, &size) and release its buffer with esper_serialized_free(bytes). This buffer is independent of the unit.

Load and merge

EsperUnit *load_unit(const char *path, const EsperDiagSink *sink) {
  FILE *file = fopen(path, "rb");
  if (!file) return NULL;
  EsperUnit *unit = esper_read(file, sink);
  fclose(file);
  return unit;
}

EsperUnit *link_units(const char *caller_path, const char *library_path,
                      const EsperDiagSink *sink) {
  EsperUnit *caller = load_unit(caller_path, sink);
  EsperUnit *library = load_unit(library_path, sink);
  EsperUnit *result = NULL;
  if (caller && library) {
    const EsperUnit *inputs[] = {caller, library};
    result = esper_merge(inputs, 2, sink);
  }
  esper_unit_free(caller);
  esper_unit_free(library);
  return result;
}

esper_read consumes through EOF. esper_deserialize(bytes, size, sink) reads exactly one memory buffer. Both return a new owned unit or NULL with a diagnostic. The sink is also installed on the returned unit, so its context must stay alive. Passing NULL selects stderr.

esper_merge returns a new independent unit. Successful and failed merges leave the input units unchanged; free them whenever you no longer need them. The result can be saved, merged again, optimized, inspected, or passed to an emitter. An empty input list creates an empty unit.

Imports that nobody defines remain imports. This is useful for runtime and host functions. It also means successful merging does not prove that an executable will link: the chosen runtime or backend must still provide those imports.

What follows a reference

The linker remaps types, functions, globals, constants, embedded-byte offsets, indirect-call signatures, source files, and inline-frame callees. SSA values, blocks, local scopes, and branch-table targets stay inside their owning function.

Named types must agree on their members, element types, sizes, alignment, and member offsets. Equal named types coalesce; the first declaration supplies the shared type’s declaration location. Anonymous arrays created by [N]T and [?]i8 compare by element type and length instead of a parser-generated name. Different explicit pointer-layout widths are rejected. A unit with no recorded width uses Esper’s default eight-byte pointer layout. Full target triples, endianness policies, weak/common definitions, and DLL linkage are not represented by this linker yet.

Function values need identities

A plain integer 0 is not enough information for a linker: it cannot know whether you meant zero or the first function in this particular unit. Use a relocatable constant when forming an indirect-call target:

esper 1
sig () -> i32
const callback i32 = function answer

fn answer() i32 {
entry:
  return 42
}

fn main() i32 {
entry:
  target i32 = copy callback
  result i32 = call.ind i32 target, 0
  return result
}

The sig and call.ind spellings currently retain the compatibility grammar. The reference constant is an i32 function-table handle, not a native address. The C builder uses esper_unit_add_func_ref(unit, function_index) and loads the result with esper_build_ldc.

Merging currently accepts indirect targets loaded directly from a function reference constant or a readonly global initialized by one. It rejects raw numeric targets and dynamic target flows it cannot establish, including callback parameters and values reloaded from mutable storage. Those flows need a stronger function-value representation; the linker does not guess which integers to rewrite. Serialization itself preserves legacy indirect calls unchanged.

What an archive preserves

Binary archives preserve the current core IR: signatures, all instruction operand forms, aggregate layouts, scalar bit patterns, embedded bytes, globals, annotations, private linkage, source paths and positions, debug locals and scopes, inline-call frames, and the optimized flag. They preserve SSA IDs rather than reconstructing SSA through the textual parser.

They omit process-local diagnostic callbacks, diagnostic error counters, spare array capacity, and derived hot-path analysis caches. Custom source-type display schemas are still future work; an archive cannot preserve metadata the core does not yet represent. Embedded aggregate bytes retain their target layout.

Version 1 starts with the eight bytes ESPERIR\0 and a little-endian 32-bit version. Fields are explicitly encoded; native pointers, C padding, and enum storage widths never appear in the file. Opcode and enum assignments are part of this version’s schema. A schema change must bump the version rather than reinterpret old bytes. This is the initial format version, not a commitment to perpetual backward support.

The decoder rejects unsupported versions, truncation, trailing bytes, invalid references, cyclic by-value types, inconsistent layout spans, and malformed instruction encodings before returning a unit. It also runs the IR verifier. Archive bytes and decoded owned storage each have a 512 MiB limit. This validates storage and IR invariants; it does not make executing arbitrary input programs safe.