ADR 0014: C FFI and binding generation
Status: accepted, 2026-10-03
Context
Doc 05 section 12 plans extern c fn declarations generated by sspur bind, and export c fn with emitted headers. The standard library plan (section 14) leans on wrapping C libraries early. Neither direction existed: the native tier (ADR 0004, 0005) compiled C internally, but SSPUR code couldn’t call C, and C couldn’t call SSPUR.
Syntax
extern fn cbrt(x: F64) -> F64 ! ffi from "m"
extern fn strlen(s: Str) -> U64 ! ffi
extern fn getenv(name: Str) -> Opt[Str] ! ffi
extern fn ln(x: F64) -> F64 ! ffi from "m" as "log"
fn side(v: F64) -> F64 ! ffi
= cbrt(v)
Decisions
| # | Decision | Reason |
|---|---|---|
| 1 | extern fn declares a C function. extern is contextual (only before fn), so existing programs can still use it as a name | No new keyword breaks the corpus |
| 2 | Every extern declares exactly ! ffi, and callers must declare it too. Tests may call externs | Foreign code is outside every guarantee: purity, determinism, contracts. The effect row makes that visible to agents and reviewers, and fuzz already skips effects it doesn’t know |
| 3 | from "lib" names a library: "m" means -lm and libm.dylib/libm.so.6; a value containing / or .so/.dylib is a path. Omitted (or "c") means libc/the process. as "sym" sets the C symbol when it differs from the SSPUR name | One clause covers system and local libraries; as resolves clashes with SSPUR names (log, min) |
| 4 | C width types I8 I16 I32 U8 U16 U32 U64 F32 appear only in extern signatures. Inside SSPUR they are Int or F64. Int is int64_t, Bool is _Bool | Callers keep using Int and F64 with no casts. The declaration still carries the exact ABI width |
| 5 | Marshalling: integers are range-checked on the way in (U64 accepts 0..Int max), narrowed results are sign- or zero-extended, and a U64 result above Int max traps. Str passes a NUL-terminated copy (a NUL byte traps). Opt[Str] passes or returns NULL as none; a Str result that is NULL traps, and results must be valid UTF-8. List[W] parameters pass a packed const W* buffer, with each element range-checked | Every failure is a trap with the same text in both tiers, for example ffi: argument x of abs is out of range for I32 (value = 3000000000) |
| 6 | Externs have no body, contracts, refinements, or type parameters | Their meaning lives in C. Wrap an extern in an ordinary fn to add contracts |
| 7 | export-c is a command over a file, not an export c fn modifier. It exports every top-level, non-generic, native function whose parameters are Int, F64, Bool, or Str and whose result is one of those or Unit | No syntax to learn, and the C-compatible subset is decidable from types. Per-function opt-in can come later as an attribute |
| 8 | Exported wrappers return int32_t: 0 for success, 100 for an unhandled fail[E] error, or the trap code. The result goes through a trailing out pointer; strings are malloc’d and freed with P_free. P_last_error() returns the message, formatted like the interpreter’s (unhandled error: BadInput{...}, contract violated: pre n >= 0 in steps) | C has no exceptions, and status codes compose with any caller language |
Implementation
- Syntax:
FnDef.extholds the library and symbol. The printer, store, and hasher round-trip it, and the hash covers the library and symbol.sspur_syntax::ffimaps signatures toFfiTyand builds theInt/F64view the checker types against.//line comments are now skipped by the lexer, sosspur bindoutput can list what it skipped. Comments don’t survivefmtor import. - Release tier: an extern compiles to an ordinary
f_namefunction whose body checks and converts the arguments, then calls the symbol through an asm label (__asm__(__USER_LABEL_PREFIX__ "sym")). This avoids clashing with prototypes in the prelude’s headers, even when the declared widths differ (U64againstsize_t). Callers, function values, and entries see a normal function, so calls between SSPUR functions are unchanged. Libraries are added to the clang link line and to the build cache key. Programs without externs produce byte-identical C. - Interpreter: the symbol is resolved with
dlopen/dlsym(libloading) and cached. Integer-class and float-class arguments go into separate register sequences, which is how SysV x86_64 and AAPCS64 (including Apple arm64) pass non-variadic arguments. The call is a transmute tofn(i64 x6, f64 x8) -> i64or-> f64. More than 6 integer or 8 float arguments traps with a message pointing to the native tier. Variadic functions and struct arguments are not supported in either tier. - Cranelift tier: externs are not eligible, so they run through the interpreter path.
sspur bind header.h [--lib L]runsclang -Xclang -ast-dump=json -fsyntax-only, keeps theFunctionDecls located in that header, resolves typedefs, and mapschar/short/int/longand their unsigned forms,float,double,bool,char*(Strin,Opt[Str]out),const T*(List[T]), andvoid. Names that clash with SSPUR keywords or globals, or that don’t start with a lowercase letter, becomec_name ... as "name". Variadic,static, struct, mutable-pointer, and function-pointer declarations are listed in a trailing//comment block.sspur export-c file.ssp -o libfoo [--shared] [--prefix P]generates the same C as the release tier, plus wrappers, a C implementation of the host API (Rust-compatible float formatting; ASCII-only case mapping for non-ASCII strings), and trap-message tables. It buildslibfoo.a(or a shared library) and writeslibfoo.h. Each call runs inside its own GC scope, so nothing is retained between calls. The library isn’t thread-safe.
Verification
crates/sspur-cli/tests/ffi.rs:
sspur bindoutput ontests/ffi/sample.h, which is then type-checked.- The same libc/libm program in all three tiers, including F32, buffer,
Opt[Str]arguments, and four failure modes, with identical output. examples/ffi/geo.sspexported withexport-c, linked withexamples/ffi/main.cthrough clang with-Wall -Werror, and run. Covers success, an unhandled error, a precondition trap, overflow, and string results.
The 199-program corpus is unchanged (199/199 identical, 258/258 native), and fuzz --differential is clean on tests/programs.
Consequences
- The interpreter’s register trick covers the common case, but it isn’t a general FFI. libffi would add stack arguments, variadics, and structs if they become necessary.
- The exported library’s case mapping and word splitting are ASCII-only for non-ASCII input, and contract messages print values only for scalar types.
- Multiple SSPUR static libraries in one binary would clash on the shared runtime symbols (
sspur_set_host, entry points). Per-library symbol prefixes are the fix when someone needs that.