Foreign Function Interface (FFI)

March binds to native libraries through a stable C ABI — the same idea as BEAM’s erl_nif.h. You declare an extern block in March, write (or generate) a thin C shim against runtime/march_ffi.h, and link it through forge.toml. Rust crates bind through the ergonomic march crate, which rides the same C ABI.


Declaring externs

An extern block names a C library, carries an FFI capability, and lists the foreign functions with their March types and C symbol names:

mod Demo do
  needs IO.Foreign                       -- using an extern implies this capability

  extern "demo" : Cap(Ffi) do
    fn add(a: Int, b: Int): Int = "demo_add"
    fn shout(s: String): String = "demo_shout"
  end

  fn main() : Unit do
    println(int_to_string(add(2, 3)))    -- 5
    println(shout("hi"))                  -- HI
  end
end

The = "symbol" gives the exact C symbol; omit it and the default is <lib>_<fn>. Any module with an extern block needs the IO.Foreign capability (and IO.Foreign.Blocking if any function is blocking).


Marshalling tiers

Values cross the boundary by their static type — there is no universal runtime tag, so each side accesses a value by the type the signature declares.

March type C side (march_ffi.h)
Int / Bool passed by value (int64_t)
Float passed by value (double)
String / Bytes march_str_borrow(v)march_slice {ptr, len}; build with march_str_new(p, len)
Option(T) march_none() / march_some(v) (niche); march_some_boxed/march_none_boxed for Float/Unit payloads
Result(T, E) march_ok(v) / march_err(e), or the raises protocol below
record / variant the generated codecs (march_decode_T / march_encode_T, see below)
resource R an opaque native handle (march_resource_new / march_resource_get)

Strings and bytes are borrowed for the duration of the call — copy them with march_str_new if you need to keep them.


Ownership: borrow vs consume

Heap arguments are borrowed by default: March still owns them and frees them after the call, so a binding may read but must not retain them. Prefix a parameter with consume to transfer ownership to the binding (March will not free it — the binding must drop or store it):

extern "store" : Cap(Ffi) do
  fn put(consume key: String, consume value: Bytes): Unit = "store_put"
end

This maps directly onto March’s linear/ownership model, so the refcounting stays correct with no manual march_dup/march_drop in the common case.


Errors

A Result-returning binding can build the result itself with march_ok / march_err. Or mark it raises to use the env-routed protocol: the binding takes a hidden march_env *, returns the bare Ok payload on success, and calls march_raise(env, e) to fail — the compiler materializes Ok/Err:

extern "rt" : Cap(Ffi) do
  raises fn parse(s: String): Result(Int, String) = "rt_parse"
end
int64_t rt_parse(march_env *env, march_value s) {
    march_slice v = march_str_borrow(s);
    if (/* not a number */) { march_raise(env, march_str_new((const uint8_t*)"bad", 3)); return 0; }
    return parsed;                 /* bare Ok payload; the wrapper builds Ok(...) */
}

This is also how the Rust layer turns a panic into an Err.


blocking

A blocking binding runs on a dedicated OS thread while the calling green thread cooperatively yields, so a long C call doesn’t stall the scheduler:

extern "io" : Cap(Ffi) do
  blocking fn read_file(consume path: String): Bytes = "io_read_file"
end

Generating the C glue

forge ffi gen-c <file.march> reads your extern blocks and emits a compilable C shim skeleton: correct signatures, march_str_borrow slices for String/ Bytes, resource-get + consume-drop hints, typed Result/Option return stubs, and a TODO for the actual library call. For every record/variant type reachable from a signature it also generates codecs — a repr-C mirror struct plus march_decode_T / march_encode_T — so you work with plain C structs instead of poking value slots:

type Point = { x : Int, y : Int }
type Shape = Circle(Int) | Rect(Int, Int)
typedef struct { int64_t x, y; } Point_c;          /* generated */
Point_c march_decode_Point(march_value v);
march_value march_encode_Point(Point_c p);

Record/variant fields of Option(T)/Result(T,E) (with Int/Bool/ String/Bytes/Float/Unit payloads, and nested record/variant types) get their own typed mirrors too (Option_Int_c, Result_Int_String_c, …) so nested optionals decode into o.maybe.is_some rather than a raw march_value. List(T) fields are emitted as malloc-owned C arrays (List_T_c with data/len), with generated decode/encode that traverse the March spine.


Binding a Rust crate

The march Rust crate (rust/march) is the Rustler analog: write idiomatic Rust, and #[march] generates the extern "C" shim (decode args, catch_unwind the body, encode the result; a Result<T, Error> routes errors and panics through the error protocol).

use march::{march, Encoder, Decoder, Error, ResourceArc};

#[march]
fn parse(s: &str) -> Result<i64, Error> {
    s.trim().parse().map_err(|e: std::num::ParseIntError| Error::msg(e.to_string()))
}

#[derive(Encoder, Decoder)]
struct Point { x: i64, y: i64 }            // <-> March record (name-sorted)

#[derive(Encoder, Decoder)]
enum Shape { Circle(i64), Rect(i64, i64) } // <-> March variant (declaration-order tags)

march::init!("demo", [parse]);             // generates the March extern block
  • #[derive(Encoder, Decoder)] marshals structs↔records and enums↔variants.
  • ResourceArc<T> wraps native state behind a March resource, with Drop wired to the registered destructor.
  • march::init! prints the matching March extern block (cargo run --bin gen_extern).

forge ffi add-rust <name> scaffolds a binding crate (a cargo staticlib depending on march, plus the gen_extern helper).

  • ConsumeResourceArc<T> is the ownership-transfer variant of ResourceArc: FromMarch skips march_dup (March transfers its reference), and Drop calls march_drop. Use it for consume h parameters where the binding takes ownership.
  • Option<f64> uses the fully-boxed representation (march_some_boxed/ march_none_boxed) so that 0.0 doesn’t alias None; the macro handles this automatically.

Building & linking

List C shim sources and link flags under [ffi] in forge.toml; forge build compiles and links them:

[ffi]
sources = ["native/demo.c"]    # C shims compiled by forge
link    = ["-lz"]              # extra linker flags

For a Rust binding, [ffi.rust] triggers cargo build --release automatically and passes the resulting .a to the linker — no manual build step needed:

[ffi.rust]
crate = "native/demo_binding"  # path to the Cargo project
lib   = "demo_binding"         # [lib] name (default: basename of crate)

Editing a shim or any Rust source invalidates the content-addressed binary cache, so a rebuild relinks automatically. (Under the bare compiler the flags are --ffi-c / --ffi-link.)


Interpreter mode

Running march file.march (no --compile) now supports the full marshal layer: Int/Bool/Float args, String/Bytes args (heap-allocated for the call duration), Option(T)/Result(T,E) returns, raises externs, and variant/record args and returns. Project-specific C shims (from [ffi] sources in forge.toml) are compiled to a shared library on first use and loaded automatically — no --compile required.

The one interpreter gap is closures/upcalls as arguments: if a binding takes a function-typed parameter (fn(Int) -> Int), the interpreter reports a clear error and asks you to run with --compile. Compiled mode has no such limit.

Native→March callbacks (upcalls)

C bindings can call back into March closures via march_call(closure, nargs, args) in march_ffi.h. A binding that receives a function-typed argument calls it as a plain function pointer. Args use the native slot representation (raw int64_t for Int, not march_make_int); the return is the generic tagged word (march_get_int to unpack an Int). Verified in test/native/ffi_upcall.

Remaining limitations

  • blocking spawns a fresh OS thread per call (no pool; fine for occasional long calls, not for high-frequency ones).
  • Recursive and generic record/variant types have no generated codec — they fall back to raw march_value passthrough.
  • forge ffi import <header.h> (C header → draft extern block) is not yet implemented; it needs a C-header parser (libclang).

The full ABI is documented in runtime/march_ffi.h; the design rationale lives in specs/2026-06-19-c-ffi-abi-design.md and the Rust layer in specs/c-ffi-rust-layer.md.