Foreign Function Interface (FFI)¶
Sushi's Foreign Function Interface lets you call external C functions directly, with no runtime boundary. It is the escape hatch that makes bootstrapping toward self-hosting possible (calling libc and, eventually, LLVM) and the only way to bind a C symbol for which no Sushi equivalent exists.
FFI is deliberately an anti-pattern. The guiding rule is "FFI is not Sushi": prefer writing it in Sushi. Where you cannot, wall the foreign call off behind a hand-audited safe wrapper. This guide explains the syntax, the type rules at the boundary, the safety contract, and the diagnostics that enforce it.
See also: Error Handling (the Result-exemption) and Memory Management (the unmanaged
ptrand the no-leak string marshalling).
The unsafe external block¶
Foreign functions are declared in a single walled-off block, the "danger zone":
unsafe external "C" as libc because "bootstrap: call libc for the backend":
fn strlen(string s) i64 = "strlen"
fn malloc(i64 n) ptr = "malloc"
fn free(ptr p) ~ = "free"
| Element | Meaning |
|---|---|
unsafe external |
A contextual keyword pair, valid only as a top-level declaration. |
"C" |
The ABI. Only "C" is accepted in v1; the slot reserves room for others. |
as libc |
The namespace binding, chosen by you. Foreign names never enter Sushi's global scope. |
because "<reason>" |
Optional. The acknowledgment that silences the CW5001 warning (see below). |
fn name(params) ret = "symbol" |
One foreign declaration. No body. The Sushi-visible name and the C link symbol are separated. |
Call sites are always namespaced¶
let i64 n = libc.strlen(s) # visibly foreign; cannot clash with any Sushi name
let ptr h = libc.malloc(16 as i64)
libc.foo(args) is member access on the namespace introduced by as libc - the
direct analog of Go's C.foo(). There is no unsafe { } block at the call site;
in Sushi the namespace prefix is the call-site marker. A local variable shadows
the namespace: if a local named libc is in scope, libc.foo() resolves against
that local instead.
Link-name separation¶
The Sushi-visible name and the C symbol are decoupled
(fn c_printf(string fmt, ...) i32 = "printf"). You may name the Sushi side
anything; the linker resolves the symbol after =. This is what lets you bind
printf without shadowing any Sushi name.
Types at the boundary¶
External signatures are limited to the C-representable subset:
- Primitives:
i8..i64,u8..u64,f32,f64,bool ptr- the opaque foreign pointer type (below)~- genuine Cvoidstring- auto-marshalled to/from Cchar*(below)
Anything else (Result<T,E>, Maybe<T>, structs, arrays T[], references,
named user types) is a hard error: CE5003. The check is a strict allowlist,
so an unknown user type cannot slip through.
The ptr type¶
C traffics in raw pointers (char*, FILE*). FFI introduces an opaque,
unmanaged handle type ptr (LLVM i8*):
- The borrow checker ignores it - aliasing through a
ptris not tracked. - RAII never frees it - a
ptrhas no destructor; you call the matching C free yourself. - No null/bounds guarantees - a returned
ptrmay be null; dereferencing is your responsibility.
Sushi is and stays a null-free language. There is no null literal. In v1 a
returned ptr is callable and returnable but not null-checkable; if a real need
arises it will become an is_null(ptr) -> bool intrinsic, never a null literal.
Return types and the Result-exemption¶
Sushi's universal rule is that every fn implicitly returns Result<T, E>.
External functions are the single exception: a C function returns a raw value
with no error channel and cannot construct a Sushi Result across the ABI.
fn strlen(string s) i64 = "strlen" # returns raw i64, NOT Result<i64, StdError>
fn malloc(i64 n) ptr = "malloc" # returns raw ptr (may be null)
fn free(ptr p) ~ = "free" # ~ here is genuine C void, NOT Result<~>
Because libc.strlen(s) yields a plain i64, you cannot apply ?? or
.realise() to it - it is not a Result/Maybe. Attempting libc.strlen(s)??
is a clean type error (CE2507). This is not an ad-hoc carve-out: externals
live in a separate list and never reach the implicit-Result wrapping at all.
String auto-marshalling (and the no-leak contract)¶
A Sushi string is a {ptr, len} UTF-8 struct; C expects a null-terminated
char*. At the boundary the compiler marshals automatically:
- A
stringargument is copied into a fresh null-terminatedchar*for the call. That temporary is registered in a per-scope cleanup list and freed via libcfreeat scope exit - on every path (normal block end, earlyreturn, and??propagation). It is freed exactly once. No leak. - A
stringreturn is converted from the Cchar*back into a Sushi fat pointer.
The copy is per-call. The marshalling is invisible in your source, but the
freeing is real: inspect the IR with ./sushic --dump-ll and you will see a
free of the marshalled char* in the function's cleanup path.
Variadic externs¶
C variadic functions (printf-family) are bound with a bare trailing ... after
at least one fixed parameter:
unsafe external "C" as libc because "formatted output via libc":
fn printf(string fmt, ...) i32 = "printf"
The ... lowers to an LLVM var_arg declaration. Untyped C varargs are confined
to unsafe external blocks on purpose - they carry no type or count information,
so they are exactly as unsafe as in C, and that danger belongs only at the foreign
boundary. Native Sushi variadics use the safe, typed ...T array form instead (see
the variadics guide); they are a different mechanism and never produce a C
var_arg call.
Two boundary rules apply to the trailing arguments at each call:
- Default-argument promotion, exactly as C performs it:
i8/i16/boolwiden toi32, andf32widens tof64. A format string must match the promoted type -%dfor any narrow integer,%ffor anf32(it arrives as adouble). This is the classic C varargs footgun; the compiler performs the promotion but cannot check it against your format string. - C-ABI-only: each trailing argument must be a C-representable value
(a primitive,
ptr, orstring). Astringtrailing argument is marshalled tochar*and freed at scope exit on every path, identical to a fixedstringargument. Passing a non-C-ABI value (a struct,Maybe, array, ...) isCE5005.
A variadic extern must declare at least one fixed parameter (CE5004): the C ABI's
va_start needs a named argument to anchor on.
The safety contract: four suspended guarantees¶
unsafe suspends exactly four Sushi guarantees:
| # | Guarantee | What FFI suspends |
|---|---|---|
| 1 | Borrow checking (&peek/&poke) |
aliasing is not tracked through foreign pointers |
| 2 | RAII / move semantics | a foreign-returned ptr is unmanaged; you free it yourself |
| 3 | Result / Maybe |
no auto-wrapping; check C sentinels (errno/-1/NULL) by hand |
| 4 | Bounds / null safety | a returned ptr may be null and is not bounds-checked |
A block without because "<reason>" compiles (exit 1) but emits one
non-fatal warning, CW5001, stating the contract plus signature-driven
notes (a ptr return is unmanaged and may be null; a primitive return is raw,
not Result; a string param/return needs marshalling; a ptr param is not
aliasing-tracked). It is one speed bump per danger zone, never per call.
Adding because "<reason>" silences the warning and records the rationale.
Writing the reason means you have read the contract; afterward the build is
clean (important: the self-hosting compiler uses FFI heavily and must build
clean).
unsafe does not propagate (the Go model): a normal Sushi function calling
libc.foo() is still a normal Sushi function. There is no viral unsafe fn
coloring.
The safe-wrapper pattern¶
The four guarantees are restored in a hand-written wrapper. The wrapper is
ordinary Sushi (so it does follow the implicit-Result rule), and it marshals
data, folds C sentinels into Result, and manages pointer lifetimes:
unsafe external "C" as libc because "string length via libc strlen":
fn strlen(string s) i64 = "strlen"
# Safe wrapper - normal Sushi, upholds all four guarantees again.
fn length(string s) i64:
return Result.Ok(libc.strlen(s))
fn main() i32:
let i64 n = length("Mostly Harmless").realise(0 as i64)
println("len = {n}")
return Result.Ok(0)
The boundary is sharp: raw, exempt, namespaced foreign calls inside
unsafe external; Result-clean, guarantee-upholding Sushi everywhere else.
That convention is load-bearing, not cosmetic.
A wrapper that restores RAII for a foreign handle looks like:
fn close_handle(ptr h) ~:
libc.free(h) # guarantee 2 (RAII) restored by hand
return Result.Ok(~)
A wrapper may also return the handle it acquired - ptr flows through the
implicit Result wrapping (and through Maybe<ptr>) like any other value:
fn grab() ptr:
let ptr p = libc.malloc(8 as i64)
return Result.Ok(p)
fn main() i32:
match grab():
Result.Ok(p) -> libc.free(p)
Result.Err(_) -> println("alloc failed")
return Result.Ok(0)
Holding a ptr is the safe half of the FFI contract (it cannot be dereferenced
in Sushi); wrapping one in Result/Maybe adds the error channel, not RAII
or null-checking - freeing the handle is still your job.
ptr is unit-confined¶
A public fn may not expose ptr anywhere in its signature - not as a
parameter, not as a return type, not inside Result<ptr, E> or Maybe<ptr>.
Violations are rejected at compile time with CE5008.
FFI is a private implementation detail of the unit that declares the
unsafe external block. What a unit exports must be Sushi-shaped: either
fully digested values (string, i64, ...) or a wrapper struct:
struct Handle:
ptr raw # struct fields MAY carry ptr across units
i64 size
public fn open_buffer(i64 n) Handle:
return Result.Ok(Handle(libc.malloc(n), n))
public fn close_buffer(Handle h) ~:
libc.free(h.raw)
return Result.Ok(~)
The wrapper-struct escape hatch is deliberate (the Rust newtype idiom): a ptr
riding inside a named struct is self-documenting, gives extension methods a
receiver to attach to (h.close()), and is inert in other units anyway - the
foreign namespace it came from is not visible there. Private functions are
unrestricted: inside the FFI unit, ptr parameters and returns flow freely.
The same rule already held at the library boundary (CE5002); CE5008
enforces it one level down, between the units of a single program.
No danger zone, no ptr¶
The type name ptr may only be spelled in a unit that declares an
unsafe external block (CE5009). A unit without externals has no way to
ever produce a ptr value - there is no null literal, no cast yields a
ptr (CE2014), and uninitialized let does not exist - so a ptr type
written there is dead plumbing at best. The gate makes the unsafe realm
textually identifiable: grep a codebase for unsafe external and you have
found every file that can traffic in raw foreign handles.
Other units still hold handles - through the wrapper structs the FFI unit declares. They just never name the raw type themselves.
What ptr cannot do¶
A ptr is an opaque token, not a value with behavior. The compiler
rejects every operation that would pretend otherwise:
| Attempt | Diagnostic |
|---|---|
a == b, a < b, arithmetic, not/~/- on a ptr |
CE5010 - no comparable identity, no arithmetic, no truthiness |
p.hash() or any method call on a ptr |
CE5011 - an opaque handle has no methods |
HashMap<i32, ptr>, List<ptr>, Tagged<ptr> (any generic argument) |
CE5012 - only Result<ptr, E> and Maybe<ptr> carry a ptr |
"{p}" interpolation |
CE2035 - no string form |
0 as ptr, p as i64 |
CE2014 - cannot be forged from or laundered into an integer |
What remains is exactly the holding set: local variables, private function
parameters and returns, Result<ptr, E>/Maybe<ptr>, struct fields, and
plain arrays (ptr[]). If a handle needs behavior - equality, hashing,
methods, a place in a collection - wrap it in a concrete struct and give the
struct those things; the struct is real Sushi and plays by all the rules.
If null-checking is ever needed it will arrive as an is_null(ptr) -> bool
intrinsic, never as == or a null literal.
Diagnostics¶
| Code | Severity | Rule |
|---|---|---|
CW5001 |
warning (exit 1) | A block without because. Silenced by adding a reason. |
CE5001 |
error | A link-name clashes with a compiler built-in extern of a different signature. An identical signature is allowed (LLVM deduplicates). |
CE5002 |
error | An external - or any public function whose signature exposes a foreign ptr - appears in a .slib public API. FFI is a private unit detail and cannot propagate through Nori packages. |
CE5003 |
error | An external signature uses a non-C-ABI type, or the ABI string is not "C". |
CE5004 |
error | A variadic external (...) declares no fixed parameter. The C ABI needs at least one named argument for va_start. |
CE5005 |
error | A non-C-ABI value is passed as a variadic (...) argument at a call site. |
CE5008 |
error | A public fn exposes a foreign ptr in its signature (parameter, return, or inside Result/Maybe). Keep the function private or wrap the pointer in a struct. |
CE5009 |
error | ptr is named in a unit that declares no unsafe external block. No danger zone, no ptr. |
CE5010 |
error | A ptr is used with an operator (comparison, arithmetic, bitwise, logical). An opaque handle has no identity or arithmetic. |
CE5011 |
error | A method is called on a ptr. Wrap the handle in a struct and extend the struct. |
CE5012 |
error | A ptr appears as a generic type argument outside Result/Maybe (e.g. HashMap<i32, ptr>, List<ptr>). |
Linking: what can actually be resolved¶
The = "symbol" string is the link name. It becomes an undefined external
symbol reference in the generated object file; the linker must satisfy it. What
satisfies it is the important part.
Sushi links binaries by invoking the C compiler driver as clang prog.o -o prog
(plus -lm on Linux). There is no explicit -lc and no way to pass
-l<lib> or -L<path>. libc is linked anyway, for two compounding reasons:
- The clang driver links the C runtime into every executable by default (libc/libSystem plus the startup objects). This happens for any program, FFI or not.
- Every Sushi binary already depends on libc. The core runtime and stdlib
emit direct calls to
malloc,free,realloc,printf,fopen,fread,fwrite,fclose,exit, and friends. libc is a structural dependency of the output, not an optional add-on.
So an FFI declaration resolves at link time if and only if its symbol already
lives in an always-linked library - libc/libSystem, plus libm on Linux. That
is exactly why the bootstrapping leaf targets (strlen, fopen, malloc) were
chosen: they cost nothing to link because the binary already pulls libc in.
The namespace in as libc is a pure Sushi-side label and drives nothing at
link time. It emits no -l directive and names no library file - as banana
would link identically. It only controls how call sites read.
Consequence. A symbol that is not in an always-linked library (a
third-party libfoo, or anything needing -l/-L) will compile but fail to
link with an undefined symbol error. There is currently no mechanism to tell
the linker about additional libraries. v1 FFI is therefore limited to the
default-linked C runtime surface.
v1 scope and limitations¶
- Only the
"C"ABI is accepted. - Only symbols in always-linked libraries (libc/libSystem, plus libm on
Linux) can be resolved. There is no way to link an external library - no
-l/-Lmechanism - so a non-libc symbol compiles but fails at link time. See Linking above. - Sushi stays null-free: no
nullliteral; ptr-null-check is a futureis_nullintrinsic. - String marshalling is a per-call copy, freed at scope exit.
- No errno/sentinel auto-mapping into
Result(raw only). The= "symbol"suffix reserves room for an optional future error-convention annotation. - No reverse FFI (exporting Sushi functions to C) and no callbacks into C.
- Externals and foreign
ptrcannot appear in a library public API (CE5002). - Externals are currently visible across compilation units rather than strictly per-unit; treat the namespace as program-global for now.
- A user type named
ptris shadowed by the reservedptrtype in type position; avoidptras a user type name.
Future work: external (non-libc) libraries¶
Support for linking arbitrary external libraries (a -l/-L mechanism) is
deliberately deferred, not forgotten. The reasoning:
- The near-term goal is self-hosting, and the chosen route is to emit LLVM IR
as text and shell out to the C toolchain (
clang/llc). That needs only process-spawning and file I/O - both libc - so it links today. For example,fn system(string cmd) i32 = "system"is already callable and is enough to invoke the toolchain. The self-host route does not require external-library linking. - A real external-library feature is not just a flag. It pulls in library
resolution and portability (install paths, pkg-config, versioning, rpath),
ABI struct-by-value marshalling (real libraries pass structs, which
CE5003currently forbids), and distribution (a binary needinglibfoois no longer self-contained). It also reopens the package-boundary thatCE5002and the Nori/Omakase model intentionally close.
When Sushi turns toward general-purpose use, this will be planned as a
first-class concern (linking + struct marshalling + resolution together), not
bolted on as a -l passthrough. Until a concrete non-libc need appears, FFI
stays scoped to the always-linked C runtime.
Worked example¶
See examples/28-ffi.sushi for the runnable strlen
example used above.