Chapter 16 — Talking to C

Sometimes you need a function that already exists in C — a maths routine, a system call, a library. Ingle reaches across to C through an extern "c" block, where you declare the foreign function using ordinary Ingle syntax:

extern "c" {
    fn atan2(y: f64, x: f64) -> f64
}

fn main() -> int {
    return to_int(atan2(1.0, 1.0) * 1000.0)   // pi/4 ≈ 0.785, scaled → 785
}
=> 785

Once declared, you call it like any other function. The first slice of the FFI targets the C maths library (libm, which ships with every C runtime, so it adds no dependency): sin, cos, tan, asin, acos, atan, atan2, exp, log, log2, log10, sinh, cosh, tanh, cbrt, trunc, hypot, fmod. (You won’t need extern for sqrt, pow, abs, floor, ceil, round — those are already built in.)

The extern declaration is the trust boundary. There’s no separate unsafe keyword in Ingle; the signature you write is the contract the compiler type-checks against. That’s the one place Ingle’s guarantees stop and you’re vouching for the C side — so if you declare a signature that doesn’t match the real C function, you’ve told a lie the compiler can’t catch. Keep your declarations honest.

Structs cross the boundary too, by value, as long as they’re all-scalar — a struct Vec2 { x: f64 y: f64 } lines up with a C struct { double x, y; }:

struct Vec2 { x: f64  y: f64 }
extern "c" {
    fn cvec2_len(v: Vec2) -> f64            // struct in, scalar out
    fn cvec2_add(a: Vec2, b: Vec2) -> Vec2  // struct in AND struct out
}

There’s no marshalling library involved: Ingle flattens the struct to its scalar fields, and a tiny C wrapper reassembles a real C struct and passes it by value — so the system C compiler generates the platform’s exact calling convention for you. No hand-rolled register juggling, no libffi.

Pointers, buffers, and handles

Scalars and structs are enough for a maths library, but binding real C — file I/O, string routines, anything that takes a pointer — needs three more shapes to cross the boundary. They all now do, and the trick that keeps them safe is that each one is borrowed for the duration of the call: Ingle lends C a pointer, C uses it while the call runs, and Ingle keeps ownership and frees nothing C owns.

Ingle type Arrives in C as Notes
string const char* the string’s bytes, NUL-terminated; read-only
[u8] (any packed scalar array) a buffer pointer the array’s contiguous native storage
mut [u8] a writable buffer C may fill the elements in place
Ptr an opaque handle (FILE*, void*, …) round-trips to and from C; never dereferenced in Ingle

A Ptr is the interesting one: it’s a handle you receive from C (say, from fopen), hand back to C (fread, fclose), and never look inside from Ingle — its lifetime is C’s business. And the compiler helps you not botch that lifetime: a Ptr is linear — you must use it exactly once. At most once: it’s move-only, so an extern function that takes ownership declares it movefn fclose(move f: Ptr) — and that consumes it. Close the same handle twice (fclose(f); fclose(f)) and the second is a compile error, “use of f after it was moved” — a C double-free caught before it runs. At least once: an owned handle you never close is a compile error too — “this Ptr is opened but not closed on this path” — and the checker holds you to it on every branch, early return, and ?. So the two classic FFI footguns — double-close and leak — are both gone, with no runtime cost. (A bare Ptr has no destructor of its own, so left loose it can’t auto-close — keep it in a local and close it by hand, or return it; on its own it still can’t be stashed in a plain struct/array/Option/Map. When you want a value to own a handle and close it for you, you wrap it in a resource struct — the very next section. The null-handle case is easy: fclose(NULL) is a safe no-op, so the idiom is open → use under a null-check → one unconditional close.) Borrowing calls (fread/fwrite) leave the Ptr plain, so you can keep using it. Put the four together and you can drive libc directly. Here’s the heart of the shipped examples/16_ffi.ig, which writes a file and reads it straight back:

extern "c" {
    fn strlen(s: string) -> i64
    fn fopen(path: string, mode: string) -> Ptr
    fn fwrite(buf: [u8], n: i64, f: Ptr) -> i64
    fn fread(mut buf: [u8], n: i64, f: Ptr) -> i64   // C writes into the buffer
    fn fclose(move f: Ptr) -> i64
}
wrote 5 bytes to /tmp/ingle_ffi_demo.bin
read back 5 bytes: EMBER
strlen("EMBER") = 5
=> 5

fopen hands back a Ptr; fwrite borrows a [u8] payload; fread takes a mut [u8] and the C side fills it in place; strlen borrows a string as a const char*. Passing a heap value to a C call (a string literal, a freshly built array) lends it for the call and reclaims it right after, so nothing leaks.

One returning direction is handled now, and it’s how std/http brings a whole response body home: a C function that returns a char* can be declared to return a string. Ingle copies the bytes into an owned Ingle string on the way back and lets C free its own buffer — the safe “copy on return” rule, with no ownership puzzle to get wrong. That’s exactly what fn http_post(url: string, headers: string, body: string) -> string does inside std/http.

What’s still left out is the other shapes of returned memory: a non-string malloc‘d buffer (a [u8] has no NUL terminator, so it needs a length channel first) and the “Ingle adopts the raw pointer and frees it later” form for APIs that transfer ownership across. Both need an explicit ownership rule at the boundary, so for a buffer the workaround stays the fread pattern above: hand C a mut [u8] to write into. Arbitrary dynamic linking is likewise future work. (And a small, honest wrinkle for Chapter 18: --emit=replay records a C call’s scalar result but not the bytes it writes into a borrowed buffer, so a buffer-reading FFI program replays as diverged — replay being scrupulously honest that an effect escaped its capture, rather than faking a reproduction.)

Owning a handle: resource struct

The rules above keep a bare Ptr honest, but they leave the closing to you, and the handle can’t live inside a value. A resource struct lifts both limits. Prefix a struct with resource and give it a fn drop(self), and you’ve made the owned dual of the rc struct from Chapter 13: a uniquely-owned, move-only value that owns a resource and releases it automatically. It is the one struct allowed to hold a Ptr, and its drop runs exactly once, on every path the value leaves scope — so the handle closes itself.

extern "c" {
    fn fopen(path: string, mode: string) -> Ptr
    fn fwrite(buf: [u8], n: i64, f: Ptr) -> i64
    fn fclose(move f: Ptr) -> i64
}

resource struct File {
    fp: Ptr
    fn drop(self) {                       // runs automatically when a File leaves scope
        let _ = fclose(self.fp)           // ...closing the handle, on every path
        println("file closed")
    }
}

fn main() -> int {
    let f = File { fp: fopen("/tmp/ember_demo.bin", "wb") }
    let bytes: [u8] = [69u8, 77u8, 66u8, 69u8, 82u8]
    println("wrote {fwrite(bytes, 5, f.fp)} bytes")
    return 0
}                                          // no fclose here — `drop` already ran
wrote 5 bytes
file closed
=> 0

There is no fclose in main: the File owns the handle, so leaving the brace runs drop, which closes it — RAII, enforced by the compiler. The same rules that make this safe are strict by design: a resource can’t be cloned, can’t be copied into a plain struct/array/Map, and can’t be passed to a generic function (anywhere it might gain a second owner that double-frees), and a drop that fails to release its handle is itself a compile error. This is exactly how std/sqlite’s Db and Stmt work (Chapter 15) — a database connection that closes itself. (Resources in collections — a connection pool — are a later phase; for now a resource lives in a local or is returned, like the bare handle it wraps.) The ledger gate from the build chapter fuzzes exactly this close-on-every-path analysis.

Fireside trivia. extern "C" is one of the most quietly important incantations in systems programming. It exists because C++ “mangles” function names (encoding types into the symbol) while C does not, so extern "C" means “don’t mangle this — speak plain C.” Ingle borrows the exact spelling, partly for the C/C++/Rust muscle memory and partly because, when a tool or a model reads extern "c", it knows precisely what it’s looking at. Familiar punctuation is a feature, not a lack of imagination.