spec/ffi.md — Foreign-function interface (`@ffi("c")`)

Reference material, synced from the compiler repo’s spec/. The guide is the gentler path in.

User-extensible bindings to external C-ABI libraries. Library authors declare extern symbols in .hl source via an @ffi("c") annotation; the compiler emits LLVM declare for the signature and the linker resolves against C source files supplied at build time. No stdlib expansion is required to bind a new library.

Syntax

Section titled “Syntax”
@ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> ();
@ffi("c") fn raylib_should_close() -> Bool;
@ffi("c") fn raylib_clear_background(c: Color) -> ();

Grammar:

ffi_annotation ::= '@' 'ffi' '(' STRING ')'
ffi_fn_decl ::= ffi_annotation 'fn' Ident '(' params ')' ('->' type_expr)? ';'

The annotation precedes the fn keyword. The fn body MUST be absent — the declaration terminates with ;. The compiler synthesizes an empty body internally so downstream passes keep the same FnDecl shape; user code MAY NOT write a {...} block.

The ABI string is the literal "c" (native C-ABI binding) or "js" (a WASM host import — see § WASM host interface). Any other ABI string is rejected at parse time.

Position

Section titled “Position”

@ffi("c") is valid only on top-level free fn declarations. The annotation is rejected on:

The position restriction matches the substrate’s expectation that the C-ABI boundary crosses at top-level program scope only; locus and perspective methods carry implicit Hale-side context (self, scratch arena, lifecycle hooks) that doesn’t translate to C.

Restrictions

Section titled “Restrictions”

An @ffi("c") fn declaration MUST NOT be:

The parser rejects all three with a diagnostic at the annotation or marker position.

Type marshalling

Section titled “Type marshalling”

The typechecker validates @ffi("c") parameter and return types against a portable subset. LLVM lowers each Hale type to a matching C-ABI representation at the call boundary:

Hale type LLVM type C type Notes
Int i64 int64_t 64-bit signed throughout.
Float double double 64-bit IEEE 754.
Bool i32 int32_t Hale’s i1 zero-extends to i32 at the call, truncates back at the return. Avoids C _Bool cross-platform ambiguity.
String ptr const char * NUL-terminated. Caller owns; callee MUST NOT retain past the call. A StringView does not implicitly coerce to a String parameter — it isn’t NUL-terminated, so a char*-expecting callee would strlen past its end. Pass a view as a StringView parameter (→ lotus_view_t, length-carrying) or materialize it first via std::str::clone.
Bytes ptr void * (header) Points at Hale’s [int64 len][payload] header — callee uses lotus_bytes_len(p) / lotus_bytes_data(p) (declared in lotus_arena.h) to inspect. Caller owns.
BytesView / StringView { ptr, i64 } (struct by value) lotus_view_t 16-byte F.30b view layout. C glue MAY use lotus_view_data to recover the payload pointer + length.
Duration / Time i64 int64_t Both are 64-bit nanosecond counts under the hood.
() (unit) void void Return-position only — declared as -> () or omitted entirely. Empty-tuple return type accepted but normalized to ().
User struct (type T { ... }) ptr const T * (param) / T *out (sret return) Passed by pointer at the boundary; struct returns use a hidden sret first arg (see User-type structs section below). Layout match is the library author’s responsibility.

Reserved at Stage 1 (typecheck rejects with a clear diagnostic):

User-type structs

Section titled “User-type structs”

User-type structs (type Color { r: Int = 0; ... }) are passed by pointer at the C-ABI boundary, not by value. The Hale side already stores user structs as heap pointers, so the natural mapping is ptr at the LLVM level. C glue authors write:

// Param-position: const T * (or T * if the callee mutates).
void raylib_clear_background(const Color *c) {
ClearBackground((::Color){
(uint8_t)c->r, (uint8_t)c->g,
(uint8_t)c->b, (uint8_t)c->a,
});
}

Struct returns use sret-style: Hale allocates the return slot in the caller’s arena and passes a pointer as a hidden first argument. The LLVM-level fn signature is void foo(T *out, <user args>); the C glue writes the struct into *out:

// Return-position: hidden T *out first param, returns void.
void vec3i_scale(Vec3i *out, const Vec3i *v, int64_t k) {
out->x = v->x * k;
out->y = v->y * k;
out->z = v->z * k;
}

The Hale-side call expression

let scaled = vec3i_scale(v, 10);

sees the sret slot’s pointer as its result — same value-shape as any other struct-returning expression. The sret transformation is hidden from user code; only the C glue author sees it.

Why pointer + sret instead of by-value: SysV / Win64 / aarch64 all classify struct-by-value differently based on size. A portable implementation would need a per-platform ABI-lowering pass. The pointer convention sidesteps that entirely — every target lowers ptr the same way — at the cost of one dereference per arg on the C side. For the workloads Hale is shaped for (locus methods, bus dispatch, FFI to system libraries), that cost is negligible compared to the portability win.

Layout contract: the Hale struct’s field order + types must match the C struct on the other side. The library author guarantees this. Future spec iteration may add a compile-time layout-assertion mechanism (@ffi_layout("c") on the type decl); today the contract is documented but not machine-checked.

Calling convention

Section titled “Calling convention”

@ffi fns differ from regular Hale free fns at the LLVM ABI level:

The LLVM symbol name is the literal Hale fn name as written. There is no __std_* mangling, no per-import alias prefix, no generic-instantiation suffix. The library author’s C glue exports a function with that exact name; the linker resolves directly.

Lifetime rules

Section titled “Lifetime rules”

The Hale-side caller of an @ffi fn owns every pointer it passes. The C-side callee MUST:

If a C function needs to RETURN heap-allocated String or Bytes, the convention matches stdlib primitives that allocate return values: call lotus_arena_alloc(lotus_caller_arena_or_global(), size, align) to land the storage in the caller’s arena, then return the pointer. The caller’s arena outlives the C-side function frame, so the returned pointer survives.

Exceptions MUST NOT cross the FFI boundary. C code that fails returns an error sentinel (NULL, -1, etc.); the Hale-side wrapper translates to a fallible(E) shape if the error needs to propagate.

Build surface

Section titled “Build surface”

The hale build CLI accepts repeatable flags that thread the library author’s C glue + link surface through to clang:

hale build mydir/ --link raylib --csrc pond/raylib/glue.c \
--link curl --csrc pond/curl/glue.c

Both flags are optional; programs that don’t use @ffi declarations don’t need either.

hale.toml [ffi] auto-pickup (Stage 2)

Section titled “hale.toml [ffi] auto-pickup (Stage 2)”

When hale build resolves an import against a directory that contains an hale.toml, it reads the file’s [ffi] section and appends those values to the build’s link surface automatically. Library authors ship:

pond/raylib/hale.toml
[ffi]
link = ["raylib"]
csrc = ["glue.c"]

Consumers then just import:

myapp/main.hl
import "vendor/raylib" as ray;
fn main() {
let w = ray::Window { width: 1280, height: 720 };
...
}

hale build myapp/ reads vendor/raylib/hale.toml, picks up link=["raylib"] + csrc=["glue.c"], and threads them through to the clang invocation. The CLI flags from the prior section still work as additive overrides (CLI first, then toml- sourced); duplicates are tolerated. Single-file imports (import "helpers"helpers.hl) have no companion toml and contribute nothing.

De-duplication: a lib referenced under two aliases or via multiple files in the same seed contributes its FFI flags once per unique resolved directory.

Transitive FFI is NOT walked at Stage 2: only the entry’s top-level imports are scanned for hale.toml. If a directly- imported lib itself imports another @ffi-using lib, the transitive lib’s [ffi] must be re-declared (or surfaced via manual --link / --csrc) at the entry. Resolved if a workload surfaces the need.

Library-author surface

Section titled “Library-author surface”

A binding library typically ships:

  1. A .hl file with @ffi("c") fn ...; declarations + the user-facing Hale wrapper (locus, types, idiomatic signatures).
  2. A .c file exporting the C-side symbols declared in the .hl. Often a thin shim from Hale’s snake_case to upstream C naming.
  3. (Stage 2) An hale.toml [ffi] section declaring link = [...] and csrc = [...].

Example skeleton (pond/raylib):

pond/raylib/raylib.hl
@ffi("c") fn raylib_init_window(w: Int, h: Int, title: String) -> ();
@ffi("c") fn raylib_close_window() -> ();
locus Window {
params { width: Int = 1280; height: Int = 720; title: String = ""; }
birth() { raylib_init_window(self.width, self.height, self.title); }
dissolve() { raylib_close_window(); }
}
pond/raylib/glue.c
#include <stdint.h>
#include "raylib.h"
void raylib_init_window(int64_t w, int64_t h, const char *t) {
InitWindow((int)w, (int)h, t);
}
void raylib_close_window(void) { CloseWindow(); }
# pond/raylib/hale.toml (Stage 2)
[ffi]
link = ["raylib"]
csrc = ["glue.c"]

Diagnostic surface

Section titled “Diagnostic surface”

Parser errors:

Typecheck errors:

Codegen errors:

WASM host interface

Section titled “WASM host interface”

On the wasm32 target (hale build --target wasm32; the program declares target wasm { }) the foreign boundary is the JavaScript host rather than a C library. The same @ffi machinery serves the inbound direction, and a dual annotation @export serves the outbound direction.

Package [ffi] under wasm32

Section titled “Package [ffi] under wasm32”

A package’s [ffi] csrc translation units are compiled for wasm32 and linked into the module (#213, 2026-08-03). Before that they were silently skipped: link_wasm was invoked without the build options, so every @ffi("c") symbol a package defined in C surfaced as an undefined env import, --allow-undefined swallowed it, and the generated JS loader stubbed unknown imports with () => 0. The build reported success and every such call returned 0 forever.

Two constraints follow from the wasm build being freestanding:

One sharp edge worth stating, because linking the C is what exposes it: Hale’s Int is 64-bit, so a C declaration must use long long, not int. A mismatch links, and then traps at call time with a wasm signature_mismatch. That is louder than the native ABI, which would quietly truncate — and far louder than the previous behaviour, where the symbol was a stub and the mismatch could not be observed at all.

The target declaration + stdlib gating

Section titled “The target declaration + stdlib gating”

The program opts into the wasm backend with a top-level target declaration whose name is wasm (or the alias browser_js — both select the same backend and gating):

target wasm { }

The portable stdlib (std::str, std::bytes, std::json, std::math, std::text, …) works unchanged. The POSIX-backed namespaces are rejected at typecheck under this target — the browser sandbox has no syscalls — with the diagnostic error: `std::...` is unavailable under `target wasm`: <reason>. The gated set (wasm_unavailable_stdlib) is exactly:

Rejected path Browser substitute
std::io::tcp a WebSocket bus adapter (ws://)
std::io::udp (no raw UDP in the browser)
std::io::tls the browser does TLS transparently for wss:// / https://
std::io::fs, std::io::file fetch via an @ffi("js") host import, or a bus message
std::io::stdin, std::io::stdout println(...) (the loader routes it to the host console)
std::term (no terminal in the browser)
std::process (no OS process control)
std::http (server is built on raw TCP)

The in-process typed bus is fully available under target wasm: topic declarations and bus { publish … } / bus { subscribe … } across loci lower the same way they do natively — a Subject <- payload is delivered to every matching in-module subscriber’s handler, payload-copied through the synthesized __serialize_T / __deserialize_T wire codec. Those codecs follow the lotus_serialize_fn / lotus_deserialize_fn ABI (ssize_t(const void *, …, size_t)), whose ssize_t / size_t widths are target-pointer-width — i32 on wasm32, i64 on the native 64-bit targets — so the runtime’s lotus_bus_dispatch indirect call matches the codec on both. Only the cross-process / network transports (shm_ring, unix, and CONNECT-role bindings) are unavailable in the sandbox, since they need syscalls.

Reach the outside world through @ffi("js") host imports and the inbox/state seam below instead.

@ffi("js") — host imports (host → into Hale’s callees)

Section titled “@ffi("js") — host imports (host → into Hale’s callees)”

@ffi("js") fn name(...); declares a function the JS loader provides at instantiation (a wasm env import), e.g.:

target wasm { }
@ffi("js") fn console_log(msg: String);
@ffi("js") fn draw_line(x1: Float, y1: Float, z1: Float,
x2: Float, y2: Float, z2: Float);

Marshalling: Float passes directly as a JS number (f64). Int and Duration are i64 internally, but at the @ffi("js") boundary they marshal as f64 (JS number), not i64 (which crosses as a JS BigInt) — the host handler receives a plain number, with no Number(x) dance, and an Int-returning host import accepts a plain JS number back (the runtime sitofps before the call and fptosis the return). The trade-off is f64’s 53-bit integer range: an Int whose magnitude exceeds 2^53 loses precision across this boundary — pass such values as a String/Bytes payload instead. (This is only @ffi("js"). @ffi("c") keeps i64 — on wasm those resolve to linked runtime C symbols that genuinely expect i64.) String/Bytes pass as a pointer into wasm linear memory (the loader reads them with a TextDecoder over the module’s memory). The generated .mjs loader supplies a built-in console_log plus the libm set (sin/cos/tan/sqrt/… mapped to JS Math.*, so std::math works under wasm with no app glue); an app wires its own imports through run(glue). Position and the generic / defaulted restrictions are the same as @ffi("c").

@export — exports (Hale → callable by the host)

Section titled “@export — exports (Hale → callable by the host)”

Two forms; both are wasm-only (a no-op on the native target) and both produce a wasm module export the host calls by its literal name.

@export fn name(...) { ... } — a top-level free fn. Unlike @ffi it has a real Hale body. It is valid only on top-level free fns (same position rule as @ffi), is not @ffi (mutually exclusive — an import is not an export), and is not fallible(E) (v1 — the host has no error channel).

@export locus L { ... } — the persistent singleton “app.” At most one per program. It is instantiated once (birth runs; it is never dissolved), and each of its non-fallible fn methods becomes a wasm export the host calls (inst.exports.<method>()). State lives in the locus’s params — ordinary Hale fields that survive across calls because the singleton persists. The locus must not define run() (it is host-driven via its methods, not a cooperative run loop); fallible methods stay internal (not exported).

Entry-inversion run-model

Section titled “Entry-inversion run-model”

A program built with @export runs inverted: instead of a blocking main, the host drives the exports. The compiler synthesizes and exports _hale_start(), which creates a persistent program arena (and bus queue) that is not torn down — and, for an @export locus, instantiates the singleton there and stashes its pointer. The generated loader calls _hale_start once at instantiation and then the host calls the exports (e.g. one per requestAnimationFrame). A program with no fn main is valid when it has any @export; if _hale_start is present the loader does not call main (its create-then-destroy of the arena would clobber the persistent one).

--wrap-main (browser-playground entry synthesis). A bare fn main program is not the @export shape a wasm build needs. The hale build … --target wasm32 --wrap-main flag synthesizes it on the parsed AST: when the program has a top-level fn main() and no @export entry, it replaces fn main with an @export locus __Main { birth() { <main's body> } } (routing the body through the _hale_start path) and injects a target wasm { } gate if absent. Because it operates on the AST — not the source text — every diagnostic keeps the user’s original line/col (no offset) and a {/} inside a string or comment can’t mis-wrap it. It is wasm-only and opt-in: a hard error without --target wasm32 (there is no native entry-inversion to wrap), never implied by the target (a wasm program may legitimately keep a bare fn main exported as main), and a no-op when an explicit @export entry already exists (prefer-explicit).

Holding state across calls:

Inbound messages use the seam in either model: lotus_wasm_alloc(n) / lotus_wasm_set_inbox(len) (wasm exports the host calls to write bytes in) + @ffi("c") fn lotus_wasm_inbox() -> Bytes; (Hale reads them and parses with std::json / std::bytes).

Cross-references

Section titled “Cross-references”

C→Hale re-entry: @export fn on native (Crumb batch 2, 2026-07-27)

Section titled “C→Hale re-entry: @export fn on native (Crumb batch 2, 2026-07-27)”

The inverse direction of @ffi: an @export fn free fn is emitted as an unmangled C-ABI symbol on native targets (the same annotation wasm entry-inversion already used; the internal arena-ABI implementation is renamed __hale_impl_<name> and the literal name is claimed by a C-callable wrapper). C code — an engine registering host functions, a callback-taking library — links the symbol and calls straight into Hale.

Marshalling is the same FFI-portable set as imports, in the same C shapes: Intint64_t, Floatdouble, Boolbool, Stringconst char*, Bytes ↔ lotus blob pointer (lotus_bytes_len/lotus_bytes_data are linkable for C-side access). Typecheck rejects non-portable params/returns, defaulted params (fixed C arity), and fallible exports (no C error channel).

The v1 contract is same-thread re-entry: the callback must fire while Hale is inside an in-flight @ffi call on that same thread (the engine-host-function shape — JS calls a host fn during crumb_js_run_file). Codegen publishes the call site’s arena in the caller-arena TLS around every @ffi call (save/set/restore, nesting-safe); the export wrapper’s prologue picks it up, so the re-entered body composes with the established context — bus publishes dispatch, eager loci instantiate and dissolve, all on the thread that owns them. Entry from a foreign thread (or outside any in-flight @ffi call) aborts with a pointed diagnostic naming this section; cross-thread entry is the documented follow-up.

Invariants the re-entered code inherits (spelled out per the Crumb request): re-entry runs on the calling thread’s context — the same single-threaded interiority as the locus/fn that made the @ffi call. Publishes enqueue exactly as they would from that caller; a re-entered export must not assume it runs on main unless the @ffi call site does. JSValue-style by-value foreign structs never cross — trampolines convert to the marshalling set C-side (the pond/sqlite handles-in/scalars-out pattern).