Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

#DecisionReason
1extern fn declares a C function. extern is contextual (only before fn), so existing programs can still use it as a nameNo new keyword breaks the corpus
2Every extern declares exactly ! ffi, and callers must declare it too. Tests may call externsForeign 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
3from "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 nameOne clause covers system and local libraries; as resolves clashes with SSPUR names (log, min)
4C 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 _BoolCallers keep using Int and F64 with no casts. The declaration still carries the exact ABI width
5Marshalling: 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-checkedEvery 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)
6Externs have no body, contracts, refinements, or type parametersTheir meaning lives in C. Wrap an extern in an ordinary fn to add contracts
7export-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 UnitNo syntax to learn, and the C-compatible subset is decidable from types. Per-function opt-in can come later as an attribute
8Exported 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.ext holds the library and symbol. The printer, store, and hasher round-trip it, and the hash covers the library and symbol. sspur_syntax::ffi maps signatures to FfiTy and builds the Int/F64 view the checker types against. // line comments are now skipped by the lexer, so sspur bind output can list what it skipped. Comments don’t survive fmt or import.
  • Release tier: an extern compiles to an ordinary f_name function 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 (U64 against size_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 to fn(i64 x6, f64 x8) -> i64 or -> 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] runs clang -Xclang -ast-dump=json -fsyntax-only, keeps the FunctionDecls located in that header, resolves typedefs, and maps char/short/int/long and their unsigned forms, float, double, bool, char* (Str in, Opt[Str] out), const T* (List[T]), and void. Names that clash with SSPUR keywords or globals, or that don’t start with a lowercase letter, become c_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 builds libfoo.a (or a shared library) and writes libfoo.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 bind output on tests/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.ssp exported with export-c, linked with examples/ffi/main.c through 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.