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

Introduction

WeaveFFI generates idiomatic bindings for 11 languages from one API definition, over a stable C ABI. Write the API as safe Rust annotated with #[weaveffi::module], or as a YAML, JSON, or TOML IDL implemented by any library that can export C symbols (C, C++, Zig, and so on). WeaveFFI emits packages for C, C++, Swift, Kotlin, Node.js, WebAssembly, Python, .NET, Dart, Go, and Ruby that all call the same C functions.

The design in one page

One definition, two front doors. A Rust producer annotates ordinary modules; the #[weaveffi::module] macro emits the extern "C" thunks and embeds a description of the API in the compiled library, and the CLI reads that description back out to emit the bindings, so the bindings you ship describe exactly the library you compiled, #[cfg] included. A non-Rust producer writes an IDL instead and implements the C header WeaveFFI generates from it.

Identity drives every name. A library has one resolved identity: a package name, a C symbol prefix, and a native library base name. For a Rust producer all three come from the crate (kvstore gives kvstore_kv_Store_open, libkvstore.so, and an npm, PyPI, or NuGet package named kvstore). Nothing a generator emits is named after WeaveFFI, so any number of WeaveFFI-built libraries can live in one process.

A small, explicit C ABI. Revision 4 has five value families: direct scalars, UTF-8 strings and bytes as (ptr, len) runs, serialized value buffers for records, enums, optionals, lists, and maps, reference-counted objects, and consumer-implemented callback vtables. Every fallible call reports through a {prefix}_error struct. Async functions complete through a callback that fires exactly once, and cancellation is a first-class runtime code. Every consumer checks the ABI revision and each module’s contract table (a hash of every declaration’s signature) when it loads the library, so a stale binding fails loudly, naming the declaration that changed, instead of misreading memory.

Idiomatic generators. Each target maps the model onto its own idioms: objects become classes with deterministic disposal and a garbage-collector backstop, async functions become async/await, coroutines, promises, or tasks with native cancellation, iterators stay lazy, and error domains become typed exceptions or error values. The capability matrix summarizes all eleven.

Standalone output. Generated packages are self-contained: helper code (the value-buffer codec, error types, object wrappers, loaders) is generated into each package, so consumers never install WeaveFFI. weaveffi package bundles prebuilt native libraries into publishable packages per ecosystem.

Where to next

  • Getting Started: a Rust producer called from Python and C in a few minutes, plus the IDL path for a C implementation.
  • The Rust Producer Macro: every supported signature and what the macro rejects.
  • C ABI Contract: the normative description of the boundary.
  • Comparison and FAQ: how WeaveFFI relates to UniFFI, Diplomat, cbindgen, and the single-language bridges.
  • Stability and Versioning: what the version numbers promise, and how to migrate to schema 0.11 and ABI revision 4.

Getting Started

This guide builds a small Rust library, generates bindings for it, and calls it from Python and from C. A second, shorter path at the end shows how to start from an IDL instead and implement the generated C header in C.

Install the CLI

You need a stable Rust toolchain. Install the weaveffi CLI from crates.io (or grab a prebuilt binary with cargo binstall weaveffi-cli):

cargo install weaveffi-cli
weaveffi --version

Write the producer

Create a library crate, add the weaveffi crate, and build it as a C dynamic library:

cargo new --lib mathlib
cd mathlib
cargo add weaveffi
# Cargo.toml
[lib]
crate-type = ["cdylib"]

Replace src/lib.rs with an annotated module. Everything here is safe Rust; the macro writes the extern "C" layer.

#![allow(unused)]
fn main() {
/// Integer arithmetic and greetings.
#[weaveffi::module]
pub mod math {
    /// Errors the math functions report.
    #[weaveffi::error]
    #[derive(Debug)]
    pub enum MathError {
        /// Division by zero.
        DivisionByZero = 1,
    }

    impl std::fmt::Display for MathError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.write_str("division by zero")
        }
    }

    /// Add two integers.
    #[weaveffi::export]
    pub fn add(a: i32, b: i32) -> i32 {
        a + b
    }

    /// Divide `a` by `b`, failing when `b` is zero.
    #[weaveffi::export]
    pub fn div(a: i32, b: i32) -> Result<i32, MathError> {
        a.checked_div(b).ok_or(MathError::DivisionByZero)
    }

    /// Greet someone by name.
    #[weaveffi::export]
    pub fn greet(name: &str) -> String {
        format!("Hello, {name}!")
    }
}

// Export the runtime symbols (errors, memory, cancel tokens) once per library.
weaveffi::export_runtime!();
}

A few things to notice:

  • Every C symbol starts with the crate’s library name, so this crate exports mathlib_math_add, mathlib_math_div, and mathlib_math_greet, plus the runtime (mathlib_error_clear, mathlib_free_bytes, and so on).
  • Result<i32, MathError> makes div a throwing function. The enum’s discriminants are the stable error codes, and its Display output is the message consumers see.
  • weaveffi::export_runtime!() appears exactly once, at the crate root.

The producer macro guide covers records, enums, objects, callbacks, iterators, and async functions.

Configure and generate

Run weaveffi init in the crate. It writes a weaveffi.toml whose [project] table points at the crate itself, then lists anything the crate still needs (a cdylib crate type, the export_runtime! call, a #[weaveffi::module]):

weaveffi init

The file it writes generates all eleven targets; uncomment targets to pick a subset:

# weaveffi.toml
[project]
input = "."                 # this crate
out = "bindings"
targets = ["c", "python"]

Generate the bindings. With a [project] table, weaveffi generate needs no arguments and works from any directory in the project:

weaveffi generate

weaveffi generate runs cargo build, then reads the API from the library it built: the macro embeds a description of every exported declaration in the library, so the bindings match the compiled code exactly, #[cfg] included. weaveffi extract prints that description as an IDL.

The output has one directory per target. The C target writes bindings/c/mathlib.h; the Python target writes an installable package named mathlib. Run weaveffi generate again after any change: it rewrites only the files whose contents changed and removes files a previous run wrote that are no longer produced.

Call it from Python

Run weaveffi dev instead of weaveffi generate while you iterate: it builds the debug library, generates, and copies the library into the generated Python package, which loads a bundled copy first. Then install the package:

weaveffi dev
pip install ./bindings/python

To load a library from somewhere else, set MATHLIB_LIBRARY to its full path; the Python, Ruby, .NET, Dart, and Kotlin (JVM) packages all honor that {PREFIX}_LIBRARY variable at run time, and weaveffi dev prints the value for them. A packaged release bundles the library instead (see Packaging).

import mathlib

print(mathlib.add(2, 3))          # 5
print(mathlib.greet("Python"))    # Hello, Python!

try:
    mathlib.div(1, 0)
except mathlib.MathError as e:
    print(e.code, e.message)      # 1 division by zero

On import the package checks the library’s ABI revision and the math module’s contract table, so a library built from incompatible source fails to load with an error naming the declaration that’s missing or changed. The Python page documents the generated surface.

Call it from C

The header is the contract every other binding is built on. Strings cross as UTF-8 (ptr, len) runs, the caller owns a zeroed mathlib_error, and returned strings are released with mathlib_free_bytes:

#include <stdio.h>
#include <string.h>
#include "mathlib.h"

int main(void) {
    if (mathlib_abi_version() != MATHLIB_ABI_VERSION ||
        mathlib_math_contract_check() != 0) {
        fprintf(stderr, "mathlib.h does not match the loaded library\n");
        return 1;
    }

    mathlib_error err = {0};
    printf("2 + 3 = %d\n", mathlib_math_add(2, 3, &err));

    mathlib_math_div(1, 0, &err);
    if (err.code == mathlib_math_MathError_DivisionByZero) {
        printf("error %d: %s\n", err.code, err.message);
        mathlib_error_clear(&err);
    }

    const char* name = "C";
    size_t len = 0;
    const uint8_t* text =
        mathlib_math_greet((const uint8_t*)name, strlen(name), &len, &err);
    printf("%.*s\n", (int)len, (const char*)text);
    mathlib_free_bytes((uint8_t*)text, len);
    return 0;
}
cc -I bindings/c main.c -L target/debug -lmathlib -o main
DYLD_LIBRARY_PATH=target/debug ./main    # LD_LIBRARY_PATH on Linux

Errors and Memory states every ownership rule the generated bindings follow for you.

Ship it

weaveffi package --target python,node

weaveffi package builds the library in release mode for this machine (that’s weaveffi build), then writes installable artifacts to dist/: here a wheel with the library inside, and npm tarballs carrying the library and a prebuilt addon. Neither needs MATHLIB_LIBRARY or a compiler:

pip install dist/python/mathlib-*.whl
npm install dist/node/*.tgz

Packaging covers more platforms (iOS, Android, Windows, wasm32), every target’s artifacts, and publishing.

Implementing an IDL in C

A producer doesn’t have to be Rust. Start from an IDL, generate the C header, and implement it in any language that can export C symbols. Outside a Rust crate, weaveffi init writes a starter IDL and a weaveffi.toml:

mkdir greeter && cd greeter
weaveffi init           # writes greeter.yml and weaveffi.toml

Edit greeter.yml to declare the API (the IDL reference has the full schema):

version: "0.11.0"
modules:
  - name: greeter
    functions:
      - name: add
        params:
          - { name: a, type: i32 }
          - { name: b, type: i32 }
        return: i32

For an IDL input the identity comes from [package] in weaveffi.toml (name = "greeter"), so the prefix and the library are both greeter. Generate the header and implement it:

weaveffi generate --target c     # writes bindings/c/greeter.h
/* greeter.c */
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#include "greeter.h"

uint32_t greeter_abi_version(void) { return GREETER_ABI_VERSION; }

/* The contract table the header was generated with. */
const greeter_contract_entry* greeter_greeter_contract(size_t* out_len) {
    static const greeter_contract_entry table[] = GREETER_GREETER_CONTRACT;
    *out_len = GREETER_GREETER_CONTRACT_LEN;
    return table;
}

int32_t greeter_greeter_add(int32_t a, int32_t b, greeter_error* out_err) {
    (void)out_err;   /* written only on failure */
    return a + b;
}

void greeter_error_set(greeter_error* err, int32_t code, const char* message) {
    greeter_error_clear(err);
    err->code = code;
    err->message = message ? strdup(message) : NULL;
}

void greeter_error_clear(greeter_error* err) {
    free((void*)err->message);
    free((void*)err->payload_ptr);
    memset(err, 0, sizeof *err);
}

void greeter_error_set_payload(greeter_error* err, const uint8_t* ptr, size_t len) {
    free((void*)err->payload_ptr);
    err->payload_ptr = NULL;
    err->payload_len = 0;
    if (ptr && len) {
        uint8_t* copy = malloc(len);
        memcpy(copy, ptr, len);
        err->payload_ptr = copy;
        err->payload_len = len;
    }
}

void greeter_error_free(greeter_error* err) {
    if (err) { greeter_error_clear(err); free(err); }
}

/* One allocator for every byte run, returned or consumer-allocated. */
uint8_t* greeter_alloc(size_t len) { return len ? calloc(len, 1) : NULL; }
void greeter_free_bytes(uint8_t* ptr, size_t len) { (void)len; free(ptr); }

/* Cancel tokens (used by async functions; this API has none, but the runtime
   surface is the same for every library). */
struct greeter_cancel_token { atomic_bool cancelled; };
greeter_cancel_token* greeter_cancel_token_create(void) {
    return calloc(1, sizeof(greeter_cancel_token));
}
void greeter_cancel_token_cancel(greeter_cancel_token* token) {
    if (token) atomic_store(&token->cancelled, true);
}
bool greeter_cancel_token_is_cancelled(const greeter_cancel_token* token) {
    return token && atomic_load(&((greeter_cancel_token*)token)->cancelled);
}
void greeter_cancel_token_destroy(greeter_cancel_token* token) { free(token); }

/* Leak counters: a producer that doesn't count returns 0 for every kind. */
uint64_t greeter_debug_live(int32_t kind) { (void)kind; return 0; }

The library must export every runtime symbol the header declares, each top-level module’s contract function, and the API itself; the C ABI contract lists them, and conformance/c/producer.c is a complete hand-written producer to copy from. Build it as libgreeter.so (libgreeter.dylib on macOS, greeter.dll on Windows):

cc -shared -fPIC -I bindings/c greeter.c -o libgreeter.so

Then run weaveffi generate for the other targets; they load it exactly as they load a Rust producer. From Python, for example:

weaveffi generate --target python
pip install ./bindings/python
GREETER_LIBRARY=$PWD/libgreeter.so python3 -c "import greeter; print(greeter.add(2, 3))"   # 5

Next steps

  • Project Configuration: [package] metadata, [build] settings, per-target options, and how regeneration cleans up stale files.
  • Packaging: building every platform and publishing the artifacts.
  • Samples: three complete producers, from the minimal calculator to the feature-complete kvstore.
  • Generators: what each language gets.
  • Gate CI on weaveffi diff --check so committed bindings can’t drift; see Stability and Versioning.

The Rust Producer Macro

A Rust producer is an ordinary library crate. You annotate the modules you want to export with #[weaveffi::module], tag the items inside, and call weaveffi::export_runtime!() once. The macro emits the extern "C" thunks, marshalling every argument through the audited weaveffi::abi runtime, so the crate contains no unsafe glue. The macro also embeds a description of every exported declaration in the library, and the CLI reads the API from there (weaveffi generate in the crate builds it first), so the generated bindings describe exactly what the library was compiled with.

Setup

[lib]
crate-type = ["cdylib"]          # add "staticlib" for iOS, "rlib" for in-crate tests

[dependencies]
weaveffi = "0.24"

The prefix is the crate name. Every C symbol starts with the crate’s library name ([lib] name, else the package name with - mapped to _), which the macro reads from CARGO_CRATE_NAME at expansion time. The CLI derives the same prefix from cargo metadata, and it isn’t configurable: setting [package] c_prefix or library in weaveffi.toml for a Rust producer is an error. A crate named kvstore with a module kv exports kvstore_kv_Store_open, kvstore_error_clear, kvstore_kv_contract, and so on, and builds libkvstore.so.

export_runtime!() exactly once, at the crate root. It takes no arguments and exports the runtime surface under the crate’s prefix: abi_version, the error helpers (error_set, error_set_payload, error_clear, error_free), alloc, free_bytes, the cancel-token functions, and debug_live.

Each top-level module exports its contract table, {prefix}_{module}_contract(): one {id, hash} entry per declaration in the module tree, which generated bindings check at load time (see load-time checks). The macro computes the table with the same function the CLI uses for the bindings (weaveffi_model::contract::entries), so a library and bindings generated from it always agree, and adding a declaration never breaks bindings generated before it.

Each declaration is described in the library, as an exported static named {PREFIX}_META_{HASH} holding a small JSON frame (on wasm32, in the weaveffi_meta custom section). weaveffi generate, diff, validate, extract, and every other command that reads a Rust producer’s API read these frames out of the built library; see Library Mode.

The attributes

AttributeOnEffect
#[weaveffi::module]inline mod name { ... }An exported namespace and the driver of the expansion. Nested #[weaveffi::module] modules become IDL submodules.
#[weaveffi::export]fn, async fnExports a free function. Result<T, E> makes it throwing; async fn makes it async.
#[weaveffi::record]struct with named fieldsA by-value record, serialized as a value buffer.
#[weaveffi::enumeration]#[repr(i32)] enum, or enum with named-field variantsA C-style enum (explicit = N on every variant) or a rich enum.
#[weaveffi::interface]struct plus its inherent implA reference-counted object type; the impl’s pub fns become constructors, methods, and statics.
#[weaveffi::callback_interface]trait Name: Send + SyncMethods the consumer implements, each returning Result<T, weaveffi::ForeignError>; accepted as Arc<dyn Name> or Option<Arc<dyn Name>>.
#[weaveffi::throws]callback-interface methodThe consumer may fail with a code of the error domain in scope.
#[weaveffi::error]enum with explicit discriminantsThe module’s error domain. Must implement Display.
#[weaveffi::cancellable]exported async fn or async methodTakes a weaveffi::CancelToken as its last parameter.

Only tagged items are exported; private helpers, use items, and state are left alone. Doc comments flow into the IDL and every binding, and #[deprecated(note = "...")] becomes the IDL’s deprecated: text.

The macro validates the module tree with the same validator the CLI applies to an IDL (duplicate names, C symbol collisions, a Result with no error domain in scope, iterators out of place, and so on), and each error is a compile error on the offending item, member, or type.

Types

RustIDLCrosses the ABI as
i8..i64, u8..u64, f32, f64, boolsameone value
usize, isize, u128, i128, charnone (rejected)use u64, i64, or String
String, &strstringUTF-8 (ptr, len)
Vec<u8>, &[u8]bytes(ptr, len)
#[weaveffi::record] struct, rich enumthe typevalue buffer
#[repr(i32)] enumthe enumint32_t
Option<T>, Vec<T> or &[T], HashMap<K, V>, BTreeMap<K, V>T?, [T], {K:V}value buffer
&T, Arc<T>, Option<Arc<T>> (interface T); Self or T as a returnT, T?object pointer
Arc<dyn Trait>, Option<Arc<dyn Trait>> (callback interface)Trait, Trait?ctx plus vtable (parameters only)
weaveffi::Iter<T>iter<T>iterator handle (returns only)
weaveffi::CancelTokennonethe launcher’s cancel-token slot

A reference (&str, &[u8], &Contact, &Store) is a calling convention, not an IDL distinction: the thunk lends the lifted argument for the call. Objects compose with every buffered shape, so Vec<Arc<T>>, Option<Arc<T>>, an Arc<T> record field, and weaveffi::Iter<Arc<T>> all work. Library Mode has the full mapping.

Functions and errors

#![allow(unused)]
fn main() {
#[weaveffi::module]
pub mod calculator {
    /// The calculator's error domain.
    #[weaveffi::error]
    #[derive(Debug)]
    pub enum CalcError {
        /// Division by zero.
        DivisionByZero = 1,
    }

    impl std::fmt::Display for CalcError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.write_str("division by zero")
        }
    }

    /// Divide two integers, failing on a zero divisor.
    #[weaveffi::export]
    pub fn div(a: i32, b: i32) -> Result<i32, CalcError> {
        a.checked_div(b).ok_or(CalcError::DivisionByZero)
    }
}
}

A Result<T, E> return is throws: true in the IDL with return type T; () and Result<(), E> return nothing. The #[weaveffi::error] enum’s discriminants are the codes (positive and unique), and its runtime message is the enum’s Display output; a variant’s doc comment is the documented default message that appears in the IDL and in generated docs. Without a Display impl the macro fails with a trait-bound error naming the enum.

A variant may carry named fields, which travel as the error’s structured payload (this needs a primitive repr):

#![allow(unused)]
fn main() {
#[weaveffi::error]
#[derive(Debug)]
#[repr(i32)]
pub enum QuotaError {
    /// Quota exceeded.
    Exceeded { limit: i64, used: i64 } = 3001,
    /// Quota service unavailable.
    Unavailable = 3002,
}
}

A module declares at most one domain; it’s in scope for that module and every module nested in it, and a Result with no domain in scope is a compile error, whatever its error type. With a domain in scope, Result<T, String> compiles too (it reports the generic code -1), and any type implementing weaveffi::ErrorReport can be an error type. A panic in producer code is caught at the boundary and reported as code -2. See Errors and Memory.

Records and enums

#![allow(unused)]
fn main() {
#[weaveffi::record]
#[derive(Clone, Debug)]
pub struct Contact {
    /// Stable identifier.
    pub id: i64,
    pub name: String,
    pub email: Option<String>,
    pub kind: ContactType,
}

#[weaveffi::enumeration]
#[repr(i32)]
#[derive(Clone, Copy, Debug)]
pub enum ContactType {
    Personal = 0,
    Work = 1,
}

#[weaveffi::enumeration]
#[derive(Clone, Debug)]
pub enum Shape {
    Empty,
    Circle { radius: f64 },
    Rect { width: f32, height: f32 },
}
}

Records and rich enums get a generated weaveffi::abi::BufferValue implementation, and a C-style enum implements weaveffi::abi::CEnum; none of them exports a C function. Rich-enum variants use named fields (tuple variants are rejected), and tags follow declaration order unless the variant declares a discriminant.

Interfaces

An interface is shared across the boundary (by consumer wrappers, records, collections, and in-flight async calls), so the type must be Send + Sync and keeps mutable state behind interior mutability.

#![allow(unused)]
fn main() {
#[weaveffi::interface]
pub struct Shelf {
    titles: std::sync::Mutex<Vec<String>>,
}

impl Shelf {
    /// Constructor: no receiver, returns `Self` or `Arc<Self>`.
    pub fn new() -> Self {
        Self { titles: Default::default() }
    }

    /// Fallible constructor.
    pub fn open(name: String) -> Result<Arc<Self>, LibraryError> { /* ... */ }

    /// Method on `&self`.
    pub fn count(&self) -> i64 { /* ... */ }

    /// Method on `self: Arc<Self>`, returning another reference to itself.
    pub fn share(self: Arc<Self>) -> Arc<Shelf> { self }

    /// Iterator return.
    pub fn titles(&self) -> weaveffi::Iter<String> { /* ... */ }

    /// Async method.
    pub async fn duplicate(self: Arc<Self>) -> Arc<Shelf> { /* ... */ }

    /// Static: no receiver, returns something other than `Self`.
    pub fn capacity() -> i64 { 10_000 }

    /// Async static factory: an `async fn` returning `Self` is a static.
    pub async fn load(name: String) -> Result<Arc<Self>, LibraryError> { /* ... */ }
}
}
  • Constructors are synchronous associated functions that return Self, Arc<Self>, or the type by name, optionally in a Result.
  • An async fn associated function that returns the type is an async static factory, since constructors can’t be async. Bindings call it like any other async static (await Shelf.load("x")).
  • Methods take &self or self: Arc<Self>. &mut self and self by value are rejected.
  • An object parameter &T is borrowed for the call; Arc<T> (or Option<Arc<T>>) is a new strong reference the thunk took for you, so you may store it. A by-value T parameter is rejected.
  • An object you return transfers one strong reference to the consumer.

The macro also emits {prefix}_{path}_{Type}_clone and _destroy. Every binding wraps the reference in a class that releases it exactly once.

Callback interfaces

A callback interface is the inverse of an interface: the consumer implements it and the producer calls it, from any thread, for as long as it holds the Arc. When the last clone drops, the consumer’s release hook runs exactly once (on whichever thread drops it).

Every method returns Result<T, weaveffi::ForeignError>, because the consumer’s implementation can fail and the Err is how its failure reaches you; T maps to the IDL return. These are two of the kvstore sample’s callback interfaces:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use weaveffi::ForeignError;

/// Retained by `Store::subscribe` and told about every change.
#[weaveffi::callback_interface]
pub trait Listener: Send + Sync {
    /// A direct return.
    fn accepts(&self, key: &str) -> Result<bool, ForeignError>;
    /// No return; the change (a rich enum) is lent for the call.
    fn on_change(&self, change: &Change) -> Result<(), ForeignError>;
}

/// Consulted by every `put` while installed.
#[weaveffi::callback_interface]
pub trait Policy: Send + Sync {
    /// A record return. The consumer may fail with a `KvError` code.
    #[weaveffi::throws]
    fn admit(&self, entry: &Entry) -> Result<Entry, ForeignError>;
    /// The consumer adopts `home` and returns a reference you adopt.
    fn route(&self, key: &str, home: Arc<Store>) -> Result<Arc<Store>, ForeignError>;
}
}

The rules:

  • The receiver is &self. Methods are synchronous.
  • Parameters may be any IDL type except a callback interface or an iterator. Strings, bytes, and buffered values are lent to the consumer for the call; an object parameter transfers one strong reference to the consumer, so it’s spelled Arc<T> (or Option<Arc<T>>), never &T.
  • The return is Result<T, ForeignError>, where T is () or any IDL type except an iterator or a callback interface: a direct value, a String, a Vec<u8>, a record, rich enum, Option, Vec, or map, or an object (Arc<T>, or Option<Arc<T>> when the consumer may return none). A plain T is a compile error. The consumer allocates a returned string, bytes, or buffer with {prefix}_alloc, and the runtime adopts it for you.
  • A callback interface may appear only as a top-level parameter of a function or an interface member, either bare (Arc<dyn Trait>) or optional (Option<Arc<dyn Trait>>, where None is a null vtable), never in a return, a record, a collection, or another callback.

When the consumer fails, the method returns Err(ForeignError { code, message, payload }). Nothing unwinds and nothing is deferred; the failure is a value you handle or propagate. code is -4 for any consumer failure, except that a method marked #[weaveffi::throws] keeps a code of the error domain in scope, and it’s -3 when the consumer returned a value the runtime couldn’t accept (text that isn’t UTF-8, a null object, an undeclared enum value, a malformed buffer).

To propagate the failure, return an error type that reports a ForeignError’s own code, message, and payload, and use ?. That’s ForeignError itself (its ErrorReport implementation does exactly this), or your own enum implementing weaveffi::ErrorReport that wraps either a domain error or a ForeignError, as the kvstore sample’s StoreError does for Store::put. The function is throws, so its module needs an error domain in scope. The original caller sees the consumer’s code, message, and payload, so a typed domain error from a throws method reaches it typed.

Typed callback errors. A method marked #[weaveffi::throws] is throws in the IDL, which needs an error domain in scope for its module. Its consumer may fail with one of the domain’s codes, fields included (but no object fields), and ForeignError::domain::<E>() decodes that code back into your #[weaveffi::error] enum, returning None for any other failure:

#![allow(unused)]
fn main() {
#[weaveffi::error]
#[derive(Debug)]
#[repr(i32)]
pub enum LookupError {
    /// No such key.
    Missing { key: String } = 1,
    /// The source is busy.
    Busy = 2,
}

#[weaveffi::callback_interface]
pub trait Source: Send + Sync {
    /// Look up a key, failing with a `LookupError`.
    #[weaveffi::throws]
    fn lookup(&self, key: &str) -> Result<i64, ForeignError>;
}

/// Describe the outcome of a lookup without failing.
#[weaveffi::export]
pub fn lookup_via(source: Arc<dyn Source>, key: String) -> String {
    match source.lookup(&key) {
        Ok(v) => format!("ok {v}"),
        Err(e) => match e.domain::<LookupError>() {
            Some(LookupError::Missing { key }) => format!("missing {key}"),
            Some(LookupError::Busy) => "busy".to_string(),
            None => format!("foreign {}: {}", e.code, e.message),
        },
    }
}
}

(LookupError needs a Display impl, omitted here.)

Optional callbacks. Accept Option<Arc<dyn Trait>> when the consumer may pass none:

#![allow(unused)]
fn main() {
#[weaveffi::export]
pub fn has_source(source: Option<Arc<dyn Source>>) -> bool {
    source.is_some()
}
}

Don’t hold a Mutex guard across a callback call: the consumer’s implementation may call back into your library and wait on the same lock. Snapshot the state you need, release the lock, then call out, as samples/kvstore does.

Iterators

Return weaveffi::Iter<T> (built with Iter::new from any Send + 'static iterator) when the consumer should pull elements lazily instead of receiving a materialized list. It may be wrapped in a Result, and T may be any IDL type, including an object. Iterators are returns of synchronous callables only; an Iter parameter, a nested Iter, or an async function returning one is rejected.

Async functions and cancellation

An async fn (free function, method, or static) lowers to a launcher that returns immediately and a completion callback that fires exactly once. #[weaveffi::cancellable] adds a weaveffi::CancelToken parameter:

#![allow(unused)]
fn main() {
#[weaveffi::export]
#[weaveffi::cancellable]
pub async fn wait(timeout_ms: i64, cancel: weaveffi::CancelToken) -> i64 {
    let _ = cancel; // the runtime drops this future when the token fires
    Delay::new(timeout_ms as u64).await; // any future the executor can drive
    timeout_ms
}
}

When the consumer cancels, the runtime drops your future at its next suspension point and completes the call with code -5; you don’t need to poll the token. Poll cancel.is_cancelled() only for cooperative cleanup, such as work running on another thread. Futures run on a small pool of worker threads by default, on Tokio with the weaveffi crate’s tokio feature, or on the executor installed with weaveffi::set_spawner; a future the executor drops without finishing also completes with -5. See Async and Cancellation.

Modules and cross-module references

Each #[weaveffi::module] at the top level of the crate is a root; nested #[weaveffi::module] modules are its submodules, and their symbols carry the joined path (kvstore_kv_stats_summarize). Inside one root tree, any module may use any declaration in the tree: records, enums, interfaces, callback interfaces, and the error domains of its ancestors.

Names are global across every root: type, free-function, and error-code names must each be unique in the whole crate’s API (see Naming), so two modules can’t both export a function open. weaveffi generate reports a clash with the module paths of both declarations.

The macro expands each root on its own and can’t see a sibling root’s declarations, so it validates a tree assuming that a name declared outside it is a record or rich enum (a value buffer), and asserts that at compile time. Records and rich enums pass, so sharing a record between roots works. A C-style enum, an interface, or a callback interface from another root doesn’t, and the build fails on the use:

error[E0277]: `Color` is not a WeaveFFI record or rich enum; declare C-style enums and interfaces in the module tree that uses them
   = note: a `#[weaveffi::module]` only sees the declarations inside its own tree, so it can pass a type from another tree only as a value buffer (a record or rich enum)
   = note: nest the modules under one `#[weaveffi::module]` root (as inner `mod`s) so the macro can see the declaration

The fix is in the message: put the modules under one root. The kvstore sample shows both shapes: a nested kv::stats that uses the parent’s interface and enum and reports the parent’s error domain, and a sibling report root that shares only the Entry record.

Conditional compilation and type aliases

A #[cfg] on an exported item (a function, an impl block, a record, enum, error domain, interface struct, callback trait, or nested #[weaveffi::module]) applies to everything the macro generates for it: its thunks and its contract table entries. An item the build compiles out has no symbols, no entry, and no metadata, so the library never claims a declaration it doesn’t have. To make a single interface member conditional, move it into its own impl block and put the #[cfg] on that block:

#![allow(unused)]
fn main() {
#[weaveffi::interface]
pub struct Store { /* ... */ }

impl Store {
    pub fn new() -> Self { /* ... */ }
}

#[cfg(feature = "admin")]
impl Store {
    pub fn purge(&self) { /* ... */ }
}
}

Because weaveffi generate reads the API from the built library, the bindings follow the build’s #[cfg]: generate from a build without the admin feature and purge isn’t in the bindings. Loading bindings generated from a build that had an item against a build that compiles it out fails the contract check with the item’s name (“is missing from the library”); a build with extra items loads fine.

A #[cfg] on a member (a record field, an enum variant, an error code, a method inside an impl block, a callback trait method) is a compile error, because the generated bindings can’t follow it, and so is an out-of-line submodule (mod x;), whose body the macro can’t read.

A non-generic type alias declared in the module tree is substituted wherever it’s used, so the IDL and the bindings see the target type:

#![allow(unused)]
fn main() {
#[weaveffi::module]
pub mod tree {
    pub type Id = u64;
    pub type Ids = Vec<Id>;

    #[weaveffi::export]
    pub fn first(ids: Ids) -> Option<Id> {
        ids.first().copied()
    }
}
}

Leak checks

The leak-check cargo feature (on weaveffi) counts live objects, foreign callbacks, iterators, cancel tokens, and returned allocations. {prefix}_debug_live(kind) reports them (0 objects, 1 callbacks, 2 iterators, 3 tokens, 4 byte runs), and kind -1 returns 1 to say the library counts. Without the feature the symbol still exists and returns 0 for every kind. Every sample enables it, and every conformance consumer asserts all five counts are zero at exit.

[dependencies]
weaveffi = { version = "0.24", features = ["leak-check"] }

What the macro rejects

Each rejection is a spanned compile error, most of them pinned by a trybuild test in crates/weaveffi-macros/tests/ui/:

SourceWhy
*const T, *mut TDeclare the pointee as an interface and pass &T or Arc<T>.
usize, isize, u128, i128, charUse u64 or i64 (the same width everywhere), or String.
interface parameter by value (T)Take &T to borrow it or Arc<T> to keep it.
Box<T>, Rc<T>, Box<dyn Trait>Objects and callbacks are shared: use Arc<T>, Arc<dyn Trait>.
&mut T parameterTake &T or a value and return the result.
interface method on self or &mut selfUse &self or self: Arc<Self> with interior mutability.
interface that isn’t Send + SyncThe compiler names the offending field.
callback method on &mut selfTake &self; the producer may call it from any thread.
callback method returning a plain TReturn Result<T, weaveffi::ForeignError>.
Result with no error domain in scopeDeclare a #[weaveffi::error] enum in the module or an ancestor.
#[weaveffi::error] enum without DisplayDisplay supplies the runtime message.
C-style enum without #[repr(i32)]The discriminant crosses as int32_t.
Iter<T> parameter or nested IterIterators are outermost returns only.
non-value type from another rootSee cross-module references.
#[cfg] on a field, variant, error code, method, or callback methodPut it on the whole item (an impl block for a method).
out-of-line submodule (mod x;)The macro reads the tree from its tokens; write the module inline.

Also rejected: tuple-style rich-enum variants, error variants without an explicit discriminant, two #[weaveffi::error] enums in one module, an Arc<dyn A + B> naming more than one trait, and anything else the validator rejects in the tree. The macro validates one root tree at a time, so the rules that span roots (names unique across the whole crate’s API, and C symbol collisions between roots) are checked by the CLI; run weaveffi validate or weaveffi generate in CI.

See also

Errors and Memory

This guide states who owns every value that crosses the C ABI and how failures travel back to the caller. The generated bindings follow these rules for you; they matter when you consume the C header directly, implement it by hand, or audit a binding. The C ABI contract is the normative text. Examples use the kvstore sample, whose prefix is kvstore.

Ownership at a glance

FamilyParameterReturn, async result, iterator elementRelease
direct (scalars, bool, C-style enums)copiedcopiednothing
string, bytesborrowed (ptr, len)owned (ptr, len){prefix}_free_bytes(ptr, len)
value buffers (records, rich enums, T?, [T], {K:V})borrowed (ptr, len)owned (ptr, len){prefix}_free_bytes(ptr, len)
objects (interfaces)borrowed pointerone strong reference{Type}_destroy
callback interfacesctx plus vtable, owned by the consumernever returnedthe producer calls free(ctx) once

Borrowed means the callee may read the value only until the call returns and copies anything it keeps. Everything a producer hands back belongs to the receiver, who releases it exactly once with the function in the last column. The one transfer in the other direction is a callback method’s return value, which the producer adopts (see Callback interfaces).

Byte runs

Every string, bytes, and value buffer the producer returns is a byte run it allocated, and the producer owns the allocator. Two runtime functions manage runs:

uint8_t* kvstore_alloc(size_t len);                 /* a zero-filled run, NULL for 0 */
void kvstore_free_bytes(uint8_t* ptr, size_t len);  /* returned runs and alloc runs */

A consumer releases a returned run with {prefix}_free_bytes(ptr, len), passing the exact length it received. It allocates a run with {prefix}_alloc(len) only when it hands bytes to the producer: a callback method’s string, bytes, or buffer return, which the producer adopts and frees, or (on wasm32) an argument staged in the module’s linear memory, which the consumer frees itself with {prefix}_free_bytes. Null with length 0 is the empty run everywhere: {prefix}_alloc(0) returns null, and {prefix}_free_bytes(ptr, 0) is a no-op. Never free a run with the language’s own allocator, and never hand the producer memory that didn’t come from {prefix}_alloc.

Strings and bytes

A string or bytes parameter is a borrowed const uint8_t* {name}_ptr, size_t {name}_len pair. Strings are UTF-8 and never NUL-terminated, so an interior NUL survives the trip. A null pointer is valid only with length 0 (the empty value); a null pointer with a non-zero length, or a string that isn’t valid UTF-8, fails the call with the marshalling code -3.

A returned string or byte run is a producer allocation: the function returns const uint8_t* and writes the length through a trailing size_t* out_len. The consumer copies it and frees it with {prefix}_free_bytes(ptr, len), passing the exact length it received. An empty result may come back as NULL with length 0.

/* The calculator sample's `greet(name: string) -> string`. */
calculator_error err = {0};
size_t len = 0;
const uint8_t* text =
    calculator_calculator_greet((const uint8_t*)"Ada", 3, &len, &err);
if (err.code == 0) {
    printf("%.*s\n", (int)len, (const char*)text);
    calculator_free_bytes((uint8_t*)text, len);
}

Value buffers

Records, rich enums, optionals (except Interface?), lists, and maps cross as one serialized value buffer, however deep the nesting. A parameter is a borrowed (ptr, len) the caller encodes and frees itself. A return is one producer allocation the consumer decodes and then releases once with {prefix}_free_bytes; the strings and records it decodes are copies, so nothing inside is freed separately. The exception is an object token inside a buffer, which carries one strong reference the reader adopts.

Objects

Interface objects are reference counted by the producer (an Arc<T> in a Rust producer). Each interface has two lifecycle symbols:

kvstore_kv_Store* kvstore_kv_Store_clone(const kvstore_kv_Store* self);  /* +1 */
void kvstore_kv_Store_destroy(kvstore_kv_Store* self);                   /* -1 */

A pointer is a strong reference. _clone returns another reference to the same object (the pointer value is unchanged), _destroy releases one, and the object is dropped when the last reference goes. Both accept null as a no-op.

PositionRule
parameter (Store, Store?)borrowed for the call; the producer takes its own reference if it keeps the object
return, async result, iterator elementone strong reference transfers; the consumer adopts it
callback-interface method parameterone strong reference transfers to the consumer
token inside a value buffer (either direction)carries one strong reference; the reader adopts it

Two consequences follow. A consumer that encodes an object into a buffer must call _clone and write the new pointer, never the one its wrapper holds. And a buffer that contains objects is decoded exactly once, because each token is one reference.

Every generated wrapper owns exactly one reference and releases it through the language’s disposal idiom (close(), Dispose(), deinit, a C++ destructor, Close()), with a finalizer or cleaner backstop where the runtime has one. Each binding also keeps the wrapper alive for the duration of every native call, so a wrapper that becomes unreachable mid-call can’t free the object under it. An in-flight async call holds its own reference. The capability matrix lists each target’s idiom.

Callback interfaces

A callback-interface parameter lowers to void* {name}_ctx plus const {vtable}* {name}_vtable. The consumer owns ctx (a generated binding uses a key into a table that keeps the implementation alive) and a static vtable per interface, whose header records its size and free hook. The producer may call any entry, any number of times, from any thread, until it calls free(ctx) exactly once; free may also run on any producer thread. An optional callback parameter (Cb?) passes a null vtable for none.

Arguments to a callback method follow the parameter rules above, seen from the consumer: strings, bytes, and buffers are borrowed for the duration of the call and must be copied or decoded before returning; objects transfer one reference the consumer adopts.

A callback method’s return transfers to the producer:

ReturnSlotOwnership
direct valuethe C returncopied
object (I, I?)the C return, {prefix}_{path}_{I}*one strong reference (a fresh _clone) the producer adopts; I must not be null
string, bytes, value buffertrailing uint8_t** out_ptr, size_t* out_lena run the consumer allocated with {prefix}_alloc, which the producer adopts and frees

The producer adopts whatever the slots hold even when the method fails, so a consumer that fails after allocating doesn’t leak. A return the producer can’t accept (text that isn’t UTF-8, a null I, an out-of-range enum value, a malformed buffer) fails the method with -3.

Iterators

An iter<T> return is an opaque handle. {Iter}_next writes one element (returning 1) or reports the end (returning 0); each element is owned by the consumer under the return rules above. {Iter}_destroy is called exactly once, either on exhaustion or when the consumer abandons the iteration early. The generated wrappers do both. An iterator is advanced by one caller at a time: a _next that arrives while another is in progress on the same iterator (from another thread, or re-entrantly from a callback the producer’s next calls) fails with -3 rather than blocking.

Errors

The error struct

Every synchronous symbol except _clone, _destroy, and the iterator’s _destroy takes a trailing {prefix}_error* out_err:

typedef struct kvstore_error {
    int32_t code;               /* 0 success, >0 domain code, <0 runtime code */
    const char* message;        /* NUL-terminated UTF-8, producer-owned */
    const uint8_t* payload_ptr; /* the code's fields as a value buffer, or NULL */
    size_t payload_len;
} kvstore_error;

The caller owns the struct and zero-initializes it. The producer writes it only on failure. After a failure the caller reads code, message, and the payload, then calls {prefix}_error_clear(&err), which frees the message and payload and resets code to 0. On failure the function’s return value is a zero sentinel (0, false, NULL) that must not be used or freed.

kvstore_error err = {0};
int64_t n = kvstore_kv_Store_count(store, &err);
if (err.code != 0) {
    fprintf(stderr, "count failed (%d): %s\n", err.code, err.message);
    kvstore_error_clear(&err);
}

An async completion receives a heap-boxed error instead (or NULL on success), released with {prefix}_error_free, which also frees the box.

Codes

CodeMeaning
0success
> 0a declared code of the module’s error domain
-1generic: an untyped producer error (Result<T, String>, an error type without a domain code), or an async call the executor couldn’t start
-2the producer panicked; the message carries the panic text
-3marshalling failure: a null or invalid argument, non-UTF-8 text, an out-of-range enum value, a malformed value buffer (including a map with a repeated key), a callback vtable smaller than the producer’s, an iterator advanced concurrently
-4a consumer’s callback-interface implementation failed; the message is the consumer’s
-5cancelled: an async call’s cancel token fired, or its executor dropped it (async completions only)

Domain codes are validated positive, so the two ranges never collide. The C header declares each domain as an enum (kvstore_kv_KvError_KeyNotFound = 1001).

Domain errors and the trap channel

How a binding surfaces a non-zero code depends on the callable’s throws flag, and every target applies the same rule:

  • throws: true. A positive code is a typed domain error: an exception subclass, a Swift Error case, a Go error value, carrying the code, the producer’s message, and the payload fields as properties. A negative code surfaces as the package’s root error type with that code.
  • No throws. The function can’t report a domain error, so any non-zero code is a bug, and the binding traps on it (see The trap policy). It never dresses the code up as a domain error.
  • -5 on an async call always surfaces as the language’s own cancellation error (Swift’s CancellationError, Kotlin’s CancellationException, .NET’s OperationCanceledException, Python’s asyncio.CancelledError, and so on; see Async and Cancellation).

Error types are named after the package and the IDL, never after WeaveFFI; each language page lists its names.

A Rust producer’s domain error message is its Display output. An IDL message: (or a Rust variant’s doc comment) is the documented default that generated docs and consumer-side constructors use.

The trap policy

A failure of a call that isn’t throws is a producer bug (a panic, an argument the producer couldn’t lift, a callback failure it let through a call that can’t report one), and every binding treats it the same way: it raises the language’s unchecked error rather than a declared one, naming the runtime code and the producer’s message so the bug can be diagnosed from the report.

TargetWhat a failed non-throwing call raises
Cnothing; the caller reads err.code itself
C++{namespace}::InternalError (a std::runtime_error)
SwiftfatalError, since Swift has no unchecked errors (as in UniFFI)
KotlinNativeBugException (an IllegalStateException)
Node.js, WebAssemblythe package’s root error class, {PascalCase(name)}Error (KvstoreError), with the negative code
PythonInternalError (a RuntimeError)
.NETNativeBugException (an InvalidOperationException)
DartNativeError (an Error, not an Exception)
Goa panic whose value is the package’s *Error
RubyNativeBugError (a subclass of the gem’s root Error < StandardError)

Cancellation (-5) isn’t a bug and always surfaces as the language’s cancellation error, as above.

Payloads

An error code may declare fields. When the producer raises it, the fields are serialized into payload_ptr/payload_len in the value-buffer format, and each binding exposes them as properties of the error it raises. A code with no fields leaves the payload null. {prefix}_error_clear and {prefix}_error_free release the payload with the message.

When a callback fails

A consumer’s callback implementation can fail in the consumer’s language. The binding catches the exception in its trampoline and reports it through the vtable entry’s out_err with the runtime surface:

void kvstore_error_set(kvstore_error* err, int32_t code, const char* message);
void kvstore_error_set_payload(kvstore_error* err, const uint8_t* ptr, size_t len);

Both copy their arguments with the producer’s allocator; a consumer never stores its own allocation in message. What the producer receives depends on the method:

  • A method declared throws (it needs an error domain in scope for its module) may report a positive code of that domain, attaching the code’s fields encoded as a value buffer with {prefix}_error_set_payload. The fields may not include objects. The producer receives the code, message, and payload as written.
  • Every other failure, including a code the domain doesn’t declare or a positive code from a method without throws, reaches the producer as -4 with the consumer’s message, so a consumer bug can’t impersonate a domain error.

The producer decides what the failure means for the call in progress. Nothing unwinds and nothing is deferred: in a Rust producer every callback trait method returns Result<T, weaveffi::ForeignError>, and the failure arrives as an Err:

#![allow(unused)]
fn main() {
pub struct ForeignError {
    pub code: i32,         // -4, a declared domain code, or -3 for a bad return
    pub message: String,   // the consumer's message
    pub payload: Vec<u8>,  // a domain code's fields, else empty
}
}

ForeignError::domain::<E>() decodes a declared domain code and its payload into the module’s #[weaveffi::error] enum, returning None for anything else. A producer function that returns Result<T, ForeignError> (and is therefore throws, so its module needs an error domain in scope) can propagate the failure with ?, and the original caller then sees the consumer’s code, message, and payload. A function that handles the failure itself returns whatever it likes. See the producer macro guide for examples.

Panics

A Rust producer’s thunks catch panics and report them as -2. Destructors (_destroy) have no error slot, so a panic in a Drop implementation is swallowed rather than unwinding into C. Async functions report panics through the completion callback.

Leak checks

{prefix}_debug_live(kind) reports how many resources are live, so a test harness can assert that a consumer released everything: 0 objects, 1 foreign callbacks, 2 iterators, 3 cancel tokens, and 4 byte runs (returned runs and {prefix}_alloc runs not yet freed or adopted). Kind -1 returns 1 when the producer counts at all and 0 when it doesn’t, so a harness can tell “nothing is live” from “nothing is counted”. A Rust producer counts only with the weaveffi crate’s leak-check feature; without it every kind returns 0.

Thread safety

Every #[weaveffi::interface] type is Send + Sync (the macro asserts it), so a macro-built producer may be called from any thread, and the bindings add no thread restriction of their own. Callback methods and async completions may arrive on any producer thread; each binding hops to its own scheduler where the language requires it. A hand-written producer with thread-affine state should document it.

Pitfalls

  • Wrong length to free_bytes. Always pass the exact out_len you got.
  • Freeing borrowed inputs. Parameters are the caller’s; never pass them to free_bytes or _destroy.
  • Encoding an object without _clone. The token hands away a reference your wrapper still needs; the later _destroy over-releases.
  • Decoding an object-carrying buffer twice. The second decode adopts references that no longer exist.
  • Not clearing the error. The message and payload leak, and a stale non-zero code confuses the next check. Start every slot at {0}.
  • error_clear on an async error. It leaks the box; use error_free.
  • Freeing a callback’s returned run. A string, bytes, or buffer a callback method returns belongs to the producer once it’s written to the out slots; allocate it with {prefix}_alloc and don’t free it.
  • Holding a lock across a callback call. The consumer’s implementation may call back into the producer, which then waits on the lock you hold; snapshot state, release the lock, then call.

Async and Cancellation

An async function returns immediately and delivers its result later, through the language’s own async idiom: async/await in Swift, Python, JavaScript, C#, and Dart, a suspend fun in Kotlin, a std::future in C++, and a blocking call that honors a context.Context in Go. A cancellable async function is also cancelled through that idiom: cancelling the awaiting task, coroutine, or promise cancels the native call.

Declaring async functions

In Rust, write an async fn (a free function, method, or static). Add #[weaveffi::cancellable] and a final weaveffi::CancelToken parameter to make it cancellable. This is the kvstore sample’s Store::compact:

#![allow(unused)]
fn main() {
#[weaveffi::interface]
pub struct Store { /* ... */ }

impl Store {
    /// Remove every expired entry and complete with how many were removed,
    /// after pausing for `pause_ms` on a thread that polls the token.
    #[weaveffi::cancellable]
    pub async fn compact(&self, pause_ms: u32, cancel: weaveffi::CancelToken) -> u32 {
        Pause::start(pause_ms, &cancel).await;
        self.sweep()
    }
}
}

In an IDL, set async: true and optionally cancellable: true on a function, method, or static:

version: "0.11.0"
modules:
  - name: kv
    errors:
      name: KvError
      codes:
        - { name: InvalidPath, code: 1004, message: "invalid path" }
    interfaces:
      - name: Store
        constructors:
          - name: open
            params:
              - { name: path, type: string }
            throws: true
        methods:
          - name: compact
            params:
              - { name: pause_ms, type: u32 }
            return: u32
            async: true
            cancellable: true

async composes with throws, with object and buffered results, and with every parameter type. Constructors can’t be async (AsyncConstructor): declare an async static that returns the interface instead, which in Rust is an async fn associated function returning Self (the macro reads only a synchronous one as a constructor). Async functions can’t return iterators (AsyncIteratorReturn), and cancellable without async is a validation error (CancellableNotAsync).

The C ABI shape

An async function lowers to a launcher named after the function itself and a completion callback typedef named {symbol}_callback:

typedef void (*kvstore_kv_Store_compact_callback)(
    void* context, kvstore_error* err, uint32_t result);

void kvstore_kv_Store_compact(
    const kvstore_kv_Store* self,
    uint32_t pause_ms,
    kvstore_cancel_token* cancel_token,     /* cancellable functions only */
    kvstore_kv_Store_compact_callback callback,
    void* context);

The result slots are nothing (a void function), one direct value or object pointer named result, or const uint8_t* result_ptr, size_t result_len for strings, bytes, and value buffers. The launcher has no out_err: every failure, including a marshalling failure of an input, arrives through the callback.

The completion contract

The runtime guarantees these for every launch:

  1. Exactly once. The callback fires exactly once, on success, on a domain error, on a marshalling failure (-3), on a panic (-2, including one while the launcher lifts its inputs), on a consumer callback failure (-4), on cancellation (-5), and when the executor can’t take the call (-1).
  2. From any thread. It runs on whatever thread completes the future: an executor thread, a Tokio worker, or (on wasm32) the caller’s own stack. Each binding hops back to its own scheduler before touching consumer state.
  3. Owned results. err is NULL on success; otherwise it’s heap-boxed, owned by the consumer, and released with {prefix}_error_free. A string, bytes, or buffered result is owned by the consumer and released with {prefix}_free_bytes(result_ptr, result_len) after decoding. An object result transfers one strong reference.
  4. Dropped futures complete. If the executor drops a future before it finishes (a runtime shutting down, say), the drop guard completes the call with -5.
  5. Spawn failures complete. If the executor can’t take the future (a custom spawner panics, or the default executor can’t start its threads), the call completes with -1 and a message saying why.

A consumer of the raw C surface follows the same rules in its callback: read or decode everything, free it, and adopt object pointers.

Cancellation

The runtime surface provides reference-counted cancel tokens:

kvstore_cancel_token* token = kvstore_cancel_token_create();   /* refcount 1 */
kvstore_kv_Store_compact(store, 100, token, on_done, ctx);
kvstore_cancel_token_cancel(token);    /* any thread, any time; idempotent */
kvstore_cancel_token_destroy(token);   /* drops the consumer's reference */

The launcher takes its own reference on the token, so the consumer may cancel and destroy its reference at any point after the launch, even before the call completes. Cancelling wakes the producer’s future; the runtime then drops the future and completes the call with code -5 and the message “cancelled”, unless it had already completed. A null token means “never cancelled”.

Producer code doesn’t need to check the token: dropping the future cancels it at its next suspension point, and RAII guards run as usual. Poll cancel.is_cancelled() only for cooperative cleanup of work the future doesn’t own, such as a thread it spawned. Note that a future that never suspends can’t be interrupted; it runs to completion and its result wins.

Per-target surface

TargetAsync surfaceCancellation-5 raises
Clauncher plus callback{prefix}_cancel_token* slotthe code in err
C++std::future<T>RAII CancelToken argumenta Cancelled exception
Swiftasync (async throws)cancelling the TaskCancellationError
Kotlinsuspend funcancelling the coroutineCancellationException
Node.jsPromise<T>AbortSignal ({ signal })CancelledError
WebAssemblyPromise<T>AbortSignal ({ signal })CancelledError
Pythonawaitablecancelling the awaiting taskasyncio.CancelledError
.NETTask<T>CancellationTokenOperationCanceledException
DartFuture<T>a generated cancel-token objectCancelledException
Goblocking call, context.Context firstctx donereturns ctx.Err()
Rubyblocking callcancel: keyword with a CancelTokena Cancelled error

Go and Ruby block the calling goroutine or thread until the completion fires (the native work still runs off-thread), so call them from a goroutine or Thread for concurrency. Exact type names and signatures are on each language page.

Choosing an executor

Something has to drive each future between launch and completion. The runtime routes every future to the process-wide executor:

  • Default. A fixed pool of worker threads, one per available core and at least two, started lazily on the first launch. The workers share one run queue, and a future’s waker puts it back on the queue, so a launch never costs a thread and a future woken from any thread resumes on the pool. The pool has no reactor, so a future that awaits Tokio’s I/O or timers never wakes, and because its workers are few, a future that blocks one for a long time (synchronous I/O, a long computation) holds up every other async call. Run such work on a runtime of its own: enable the tokio feature or install a spawner.

  • The tokio feature. With the weaveffi crate’s tokio feature on, the default executor is Tokio: the current runtime when the launcher is called from inside one, otherwise a multi-thread runtime the library creates on first use (its threads are named weaveffi-async). That runtime enables every Tokio driver the build compiles in, so turn on the Tokio features your futures need (time, net, and so on) in your own dependency on tokio. Use tokio::task::spawn_blocking for synchronous work inside a future.

    [dependencies]
    weaveffi = { version = "0.24", features = ["tokio"] }
    tokio = { version = "1", features = ["time"] }
    
  • Custom. Call weaveffi::set_spawner once at startup, before the first async launch; it overrides both defaults. It accepts any Fn(BoxFuture) + Send + Sync + 'static:

    #![allow(unused)]
    fn main() {
    let handle = tokio::runtime::Handle::current();
    weaveffi::set_spawner(move |fut| {
        handle.spawn(fut);
    })
    .expect("spawner installed once");
    }

    The first call wins; later calls return Err(SpawnerAlreadySet). The spawner must not block, because the launcher calls it on the consumer’s thread. Every future it receives is already wrapped so a panic becomes a -2 completion, a spawner that drops a future still produces a -5 completion, and a spawner that panics completes the call with -1.

  • wasm32. There are no threads, so the default polls the future inline before the launcher returns. A future that finishes after bounded work is fine. A future that’s still pending once nothing can wake it (it awaits a timer or I/O) is dropped, and the call completes with -1 and the message “async function suspended with no executor on wasm32” rather than hanging the module. A call usually completes before an AbortSignal can fire, so on this target cancellation mostly matters for a signal that’s already aborted when the call starts.

Pitfalls

  • Awaiting Tokio under the default executor. The pool has no reactor, so the future never wakes and the callback never fires; enable the tokio feature or install a Tokio spawner.
  • Blocking a pool worker. A future that blocks (synchronous I/O, a long computation) stalls every other call queued behind it on the default pool; move the work to a runtime of its own.
  • Expecting the token to stop CPU-bound work. Cancellation drops the future at a suspension point. Long synchronous loops should yield or check is_cancelled().
  • Clearing an async error. err is boxed; release it with {prefix}_error_free, not error_clear.
  • Leaking a result buffer. A raw C callback that only copies the result must still free it.
  • Async functions with no return value. Valid, but weaveffi validate --warn flags an async free function that returns nothing (AsyncVoidFunction), since it’s usually a missing return type.

Project Configuration

A project’s settings live in one weaveffi.toml at its root. The API definition (a Rust producer crate or an IDL) describes only the API; this file says where it is, what the library is called, how it’s built and packaged, and how each target is generated. Every table is optional, and every unknown table or key is an error, so a typo fails the run instead of being ignored.

[project]
input = "."                 # this crate, or an IDL such as "kvstore.yml"
out = "bindings"
targets = ["c", "swift", "kotlin", "python"]

[package]
name = "kvstore"
version = "1.2.0"
description = "An embedded key-value store"
license = "MIT"
authors = ["Example <hello@example.dev>"]
repository = "https://github.com/example/kvstore"

[build]
platforms = ["darwin-arm64", "ios-arm64", "ios-sim-arm64", "android-arm64"]

[generators.kotlin]
name = "com.example.kvstore"

weaveffi init writes a starter file: in a Rust crate it points input at the crate ("."); elsewhere it also writes a starter IDL.

Discovery

With an explicit input (weaveffi generate api.yml, weaveffi generate path/to/crate), the CLI uses the nearest weaveffi.toml at or above the input (for a crate, at or above its directory). With no input, it looks for weaveffi.toml in the current directory and its parents and reads [project] input, so weaveffi generate works from anywhere inside the project, like cargo build. --config <path> names a file explicitly. Without any file, every setting takes its default.

[project]

KeyDefaultMeaning
inputnoneThe API definition: a Rust producer crate (its directory, usually ".", or its Cargo.toml), whose API is read from its built library (see Library Mode), or a .yml, .yaml, .json, or .toml IDL
outbindingsOutput directory for generate and diff
targetsall elevenTargets to generate when --target isn’t given

Paths are relative to the directory holding weaveffi.toml. Command-line arguments (input, -o, --target) override the table.

[package]

[package] resolves into the library’s identity: the package name every ecosystem publishes under, the C symbol prefix, and the native library base name. Generators derive every other name from these three.

KeyMeaning
nameDistribution name (may contain - or .)
versionVersion stamped into every manifest (default 0.1.0)
description, license, authors, homepage, repositoryManifest metadata
c_prefixC symbol prefix; IDL inputs only
libraryNative library base name (lib{library}.so); IDL inputs only
distWhere weaveffi package writes its artifacts (default dist); not part of the identity

The rules depend on the input:

Rust producer (a crate)IDL
name[package] name, else the crate’s Cargo package name[package] name, else the input file stem
prefixthe crate’s library name ([lib] name, else the package name with - mapped to _)c_prefix, else snake-case name
librarysame as prefix (the cdylib Cargo builds)library, else snake-case name
metadata[package], falling back to cargo metadata (workspace-inherited version.workspace = true included)[package]

A Rust producer’s prefix is what the macro compiled into its symbols, so it can’t be configured: setting c_prefix or library for a Rust producer is an error. Renaming the published package with name is fine.

[build]

How weaveffi build and weaveffi package compile a Rust producer (see Packaging):

KeyDefaultMeaning
platformsthe hostPlatform ids to build when --platforms isn’t given
releasetrueBuild with the release profile (--debug overrides it)
macos_deployment_target"11.0"MACOSX_DEPLOYMENT_TARGET, and the macOS version wheel tags carry
ios_deployment_target"13.0"IPHONEOS_DEPLOYMENT_TARGET
android_api21The minimum Android API level, which picks the NDK compiler
manifestthe project’s crateThe producer’s Cargo.toml, for an IDL project built from a Rust crate

[generators.<target>]

One table per target (c, cpp, swift, kotlin, node, wasm, python, dotnet, dart, go, ruby) holds that generator’s options. Every target that names something after the package spells the override name:

Tablename setsDefault
[generators.cpp]the C++ namespace{prefix}
[generators.swift]the SwiftPM package, product, and module (the C module is C{name})PascalCase(name)
[generators.kotlin]the Kotlin package{prefix}
[generators.node]the npm package{name}
[generators.wasm]the npm package{name}
[generators.python]the PyPI distribution{name}
[generators.dotnet]the namespace, assembly, and NuGet idPascalCase(name)
[generators.dart]the pub package{prefix}
[generators.go]the module path{name}
[generators.ruby]the gem{name}

The C target has no package name. A few targets name a second thing:

TableKeyDefault
[generators.cpp]header_name{library}.hpp
[generators.python]import_name (the import package){prefix}
[generators.ruby]module_name (the top-level Ruby module)PascalCase(name)

The constants that go into package manifests are options too:

TableKeyDefault
[generators.swift]min_macos, min_ios (the platforms: of Package.swift)"11.0", "13.0"
[generators.swift]xcframework_url (where the packaged binary target downloads from; {version} and {file} are substituted)a placeholder
[generators.kotlin]min_sdk, compile_sdk21, 35
[generators.python]requires_python">=3.9"
[generators.node]node_engine (engines.node)">=18"
[generators.dart]sdk (the pubspec SDK constraint)">=3.10.0 <4.0.0"

Keep min_macos, min_ios, and min_sdk at or above the [build] deployment targets and API level, or the toolchains warn that the library is newer than the package claims. The remaining options (C’s buffer_helpers, C++’s standard, Kotlin’s flavor, and Wasm’s emscripten) are documented on the target’s page under Generators. No target has its own C prefix: the prefix belongs to the library.

Generation records

Rendering is pure: each target renders its files in memory, and the orchestrator does every write. Every run renders every selected target, then:

  • Only files whose contents changed are rewritten, so file timestamps (and incremental builds) stay stable. When nothing changed, nothing is written.
  • The orchestrator records the files each target produced in {out}/.weaveffi-cache/{target}.json. Files the previous run wrote that the current run no longer produces (a renamed type, a removed module) are deleted. Files you added under the output directory (a node_modules/, a build directory) are never touched.

--dry-run validates and prints the files that would be written.

CI

weaveffi diff --check regenerates in memory and compares with the output directory without writing anything:

weaveffi diff --check                       # uses [project]
weaveffi diff path/to/crate -o bindings --target c,swift --check

It exits 0 when the directory is up to date, 2 when files would change, and 3 when files would be added or removed (stale files count as removed). weaveffi validate --format json prints one JSON object with ok, counts, and any errors (each with a code matching the error catalog); --warn adds advisory warnings, which never change the exit status.

Pitfalls

  • Config travels with the definition. Two checkouts with different weaveffi.toml files generate different packages from the same source. Commit the file next to the definition and let discovery find it.
  • Name overrides don’t rename the library. A target’s package or module override changes what consumers import, not which native library it loads; that’s always {library}. The targets that load the library at run time (Python, Ruby, .NET, Dart, Kotlin on the JVM, and WebAssembly on Node.js) also accept a full path in the {PREFIX}_LIBRARY environment variable; C, C++, Swift, Go, and the Node.js addon link it when they’re built (see each target’s page).

Packaging

weaveffi generate emits binding source. Shipping it takes two more steps: weaveffi build cross-compiles the Rust producer once per platform, and weaveffi package turns those builds into the artifacts each ecosystem installs: wheels, npm tarballs, gems, a SwiftPM package with its XCFramework, a NuGet package, and so on. Consumers install them with pip, npm, gem, SwiftPM, or dotnet and need no compiler.

weaveffi build --platforms darwin-arm64,ios-arm64,ios-sim-arm64
weaveffi package --target python,node,swift

Both read [project], [package], and [build] from weaveffi.toml (or take an explicit input), and every artifact loads the library named by the identity’s library field.

Build

weaveffi build finds the producer crate with cargo metadata (the project’s crate, [build] manifest, or --manifest-path) and runs cargo rustc --lib once per platform with the artifact kinds that platform needs, so the crate’s own crate-type doesn’t matter. A Rust producer’s API is then read from the first library it built (see Library Mode), so nothing is compiled twice:

PlatformWhat build produces
macOSthe cdylib, with install name @rpath/lib{library}.dylib, plus the staticlib an XCFramework slice is made from
iOSthe staticlib
Linux, Androidthe cdylib, with soname lib{library}.so (Android also 16 KB page-aligned)
Windowsthe DLL and its import library
wasm32the .wasm module, linked with --export-table --growable-table

It sets MACOSX_DEPLOYMENT_TARGET and IPHONEOS_DEPLOYMENT_TARGET from [build], and for Android it finds the NDK (ANDROID_NDK_HOME, ANDROID_NDK_ROOT, or the newest ndk/<version> of the Android SDK) and points Cargo’s linker and the cc crate at the NDK’s clang for the configured API level. Everything lands in the Cargo target directory:

target/weaveffi/
  darwin-arm64/  libkvstore.dylib  libkvstore.a  kvstore_node.node  libkvstore_jni.dylib
  ios-arm64/     libkvstore.a
  android-arm64/ libkvstore.so  libkvstore_jni.so
  wasm32/        kvstore.wasm

The Node.js and Kotlin targets reach the C ABI through C glue of their own (an N-API addon and a JNI shim). When those targets are selected (--target, else [project] targets, else all), build compiles the glue next to the library so packages ship it prebuilt:

  • Node.js addon, against the running Node.js’s headers, for each requested desktop platform the host can compile: both macOS architectures on a macOS host, and the host’s own architecture on a Linux host. Windows isn’t prebuilt (it links node.lib); the package compiles it at install time.
  • JNI shim for every Android ABI (with the NDK) and for the same desktop platforms as the addon (with the JDK headers from JAVA_HOME); never for windows-x64, where the generated CMake project builds it.

Glue a host can’t compile is skipped with a warning, and the package falls back to compiling it. Every platform is checked before anything compiles, so a missing rustup target (rustup target add ...), a missing NDK, or a platform this host can’t build (Apple platforms need macOS, Windows needs Windows) fails at once with every problem listed.

Package

weaveffi package runs build for the requested platforms, then writes each target’s artifacts to the dist directory (-o, else [package] dist, else dist). With --binaries <dir>, it skips the build and reads a directory laid out like target/weaveffi/ instead; that’s how CI hands libraries built on separate runners to one packaging job, and how a producer that isn’t a Rust crate is packaged. A Rust producer’s API is read from the first library in the directory.

TargetArtifactsPublish with
pythonone py3-none-{platform}.whl per desktop platform, the library inside the import packagetwine upload dist/python/*.whl
node{name}-{os}-{cpu}-{version}.tgz per desktop platform (library and prebuilt addon, gated by npm os/cpu) plus {name}-{version}.tgz, which lists them in optionalDependenciesnpm publish <tgz> for each, platform packages first
wasm{name}-{version}.tgz with the .wasm modulenpm publish <tgz>
rubyone {gem}-{version}-{platform}.gem per desktop platformgem push <gem>
swiftC{Module}.xcframework.zip (static libraries for every Apple platform built), its .sha256, and the SwiftPM package swift/{Module}/ whose binary target points at the archive’s URL and checksumupload the archive, then commit and tag the package
dotnetthe project dotnet/{Namespace}/ with runtimes/<rid>/native/ libraries, and the .nupkg that dotnet pack builds from itdotnet nuget push dist/dotnet/*.nupkg
kotlinthe Gradle project kotlin/{name}/ with prebuilt jniLibs/<abi>/ (library and shim) and desktop natives under resources/natives/<platform>/gradle publish
dartthe pub package dart/{package}/ with desktop libraries under native/<platform>/dart pub publish
gothe module go/{package}/ with libraries under lib/<platform>/ and cgo flags that link the right onepush the module to its repository
c, cpp{library}-{version}-c.tar.gz and -cpp.tar.gz: headers, libraries under lib/<platform>/, and a CMakeLists.txt exposing the host’s library as an imported targetattach to a release

Wheel tags carry the build’s macOS deployment target (macosx_11_0_arm64) and, on Linux, the newest glibc the library actually links against (manylinux_2_17_x86_64 or later), so pip only installs a wheel where the library loads. Each archive is deterministic (fixed timestamps and order), so the same builds always produce the same bytes and checksums.

A target skips platforms its ecosystem has no slot for (a wheel has no Android tag, NuGet no wasm32 runtime), and a target with nothing to ship is named in a note.

Platforms

--platforms takes a comma-separated list of platform ids; without it, build and package use [build] platforms, else the host. With --binaries, the default is every platform directory present.

Platform idRust targetShipped by
darwin-arm64aarch64-apple-darwinevery desktop target, and swift
darwin-x64x86_64-apple-darwinevery desktop target, and swift
linux-x64x86_64-unknown-linux-gnuevery desktop target
linux-arm64aarch64-unknown-linux-gnuevery desktop target
windows-x64x86_64-pc-windows-msvcevery desktop target
ios-arm64aarch64-apple-iosswift
ios-sim-arm64aarch64-apple-ios-simswift
ios-sim-x64x86_64-apple-iosswift
android-arm64aarch64-linux-androidkotlin (and c)
android-x64x86_64-linux-androidkotlin (and c)
wasm32wasm32-unknown-unknownwasm (and c)

Swift and the XCFramework

package fuses the Apple static libraries into C{Module}.xcframework (iOS device, iOS simulator, and macOS slices, with a group’s architectures fused by lipo), zips it, and writes its SHA-256 checksum. The packaged Package.swift declares the C module as .binaryTarget(url:checksum:), so apps link the library with no flags. The URL comes from [generators.swift] xcframework_url; until you set it, it’s a placeholder under example.invalid:

[generators.swift]
xcframework_url = "https://github.com/acme/kvstore/releases/download/{version}/{file}"

To publish, upload the archive to that URL and commit swift/{Module}/ to the repository consumers depend on. For local use, unzip the archive next to the packaged Package.swift; the manifest prefers a local C{Module}.xcframework over the URL. This step runs xcodebuild, so it needs macOS.

What still needs the ecosystem’s tools

  • Publishing. There’s no weaveffi publish; run each ecosystem’s own upload command on the artifacts.
  • NuGet. package runs dotnet pack, so the .NET SDK must be installed; without it the run fails after writing everything else.
  • Kotlin. The output is a Gradle project, not an .aar or .jar: building one needs the Kotlin compiler, so run gradle assembleRelease (Android) or gradle jar (JVM) on it. With prebuilt shims present, Gradle skips the CMake build and needs no NDK.
  • Node.js on Windows. The addon isn’t prebuilt for windows-x64, so installing there compiles it with node-gyp.
  • Cross-architecture Linux. Building linux-arm64 on an x64 host needs a cross linker (CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER), and its glue isn’t prebuilt; use an arm64 runner instead.
  • Dart. The pub package’s loader looks for native/<platform>/ inside the package itself (resolved through its package: URI), then relative to the working directory, so a compiled app that no longer has the package sources copies those libraries next to it. Flutter’s native-assets build isn’t generated.

CI recipe

Build each platform on a runner that can, upload target/weaveffi/, then package once:

jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: macos-latest
            platforms: darwin-arm64,darwin-x64,ios-arm64,ios-sim-arm64
            targets: aarch64-apple-darwin,x86_64-apple-darwin,aarch64-apple-ios,aarch64-apple-ios-sim
          - runner: ubuntu-latest
            platforms: linux-x64,android-arm64,android-x64,wasm32
            targets: aarch64-linux-android,x86_64-linux-android,wasm32-unknown-unknown
          - { runner: ubuntu-24.04-arm, platforms: linux-arm64, targets: "" }
          - { runner: windows-latest, platforms: windows-x64, targets: "" }
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.targets }}
      - uses: actions/setup-node@v4   # for the prebuilt Node.js addon
      - run: cargo install weaveffi-cli
      - run: weaveffi build --platforms ${{ matrix.platforms }}
      - uses: actions/upload-artifact@v4
        with:
          name: weaveffi-${{ matrix.runner }}
          path: target/weaveffi/

  package:
    needs: build
    runs-on: macos-latest   # the XCFramework needs xcodebuild
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          pattern: weaveffi-*
          path: prebuilt
          merge-multiple: true
      - run: cargo install weaveffi-cli
      - run: weaveffi package --binaries prebuilt

The Linux runner finds the NDK through ANDROID_NDK_HOME (or the SDK preinstalled on GitHub’s images). A platform you can’t build can be dropped from the matrix; every target packages whatever subset is present.

Library Mode

The CLI never reads Rust source. A Rust producer’s API is read from the library the crate builds: #[weaveffi::module] embeds a description of every declaration it exports in the compiled library, and weaveffi generate, diff, validate, extract, dev, build, and package read it back out. The bindings therefore describe exactly what the library was compiled with, #[cfg] and type aliases resolved, macro expansion and all.

A Rust project’s input is the crate: its directory ([project] input = ".", which weaveffi init writes) or its Cargo.toml.

weaveffi generate                    # build the crate (debug), read it, generate
weaveffi generate --release          # read a release build instead
weaveffi generate --library target/x86_64-unknown-linux-gnu/release/libkvstore.so

Without --library, the CLI resolves the crate with cargo metadata (its name, version, library name, and target directory), runs cargo build --lib --message-format=json-render-diagnostics (Cargo’s errors and warnings print as usual), and reads the crate’s cdylib, or its staticlib when it has no cdylib. --library skips the build and reads the given file: a shared library, a static archive, or a .wasm module, for any platform (an Android .so reads fine on a Mac). With no crate at all (no input argument and no [project] input), --library reads the library on its own: the prefix comes from its metadata and the library name from its file name, and a file holding the metadata of several WeaveFFI crates is an error that asks for the crate.

Extracting an IDL

weaveffi extract prints the API the library embeds as an IDL document, which is useful for reviewing the API surface, committing a canonical IDL next to the crate, or handing the API to a non-Rust implementation.

weaveffi extract                     # the project's crate, YAML to stdout
weaveffi extract path/to/crate -o api.yml
weaveffi extract -f json -o api.json # JSON (or -f toml)
weaveffi extract --library libkvstore.dylib
FlagDefaultMeaning
-o, --outputstdoutWrite to a file
-f, --formatyamlyaml, json, or toml
--librarybuild the crateRead this library instead
--releaseoffBuild the crate in release mode
--configdiscoveredThe weaveffi.toml to use

The document is validated before it’s printed, so what extract prints always generates. An IDL input is an error: there’s nothing to extract. The macro validates each module tree as it compiles; the CLI’s validation adds the rules that span trees (names unique across the whole crate, C symbols that collide between trees).

The extracted IDL carries no package identity. When you generate from it, the identity comes from [package] in weaveffi.toml (IDL rules), not from Cargo.toml, so set name, c_prefix, and library to match the crate if the generated bindings must load the Rust library.

How the API is embedded

For every declaration (a module, its error domain, a function, an interface and each of its members, a record, an enum, a callback interface) the macro emits an exported static:

#[unsafe(no_mangle)]
#[cfg_attr(target_family = "wasm", unsafe(link_section = "weaveffi_meta"))]
#[used]
pub static KVSTORE_META_3A5F0C2E9B1D4467: [u8; 141] = *b"\x89\x00\x00\x00{\"schema\":...}";

The bytes are a frame: a little-endian u32 length, then compact JSON that records the declaration (in the IDL’s own shape), the path of its module, its position among its siblings, the crate’s prefix, and the schema version. The name is the crate’s uppercase prefix, _META_, and 16 uppercase hex digits of the FNV-1a 64 hash of the declaration’s dotted path (followed by a . for a module, so a module and a declaration of the same name differ), so it never collides with another declaration or another crate.

The CLI reads native libraries with the object crate, walking the symbol tables (and a PE export table) for _META_ symbols and reading each one’s bytes from the section that holds it. A wasm32 module keeps its data out of reach of any symbol table, so there the frames go to the weaveffi_meta custom section, which the linker fills with every frame back to back. The CLI keeps the frames carrying the crate’s own prefix (a dependency that is itself a WeaveFFI producer embeds frames too) and assembles them, in declaration order, into the API. Separate top-level modules are separate macro invocations, so they’re ordered by name.

A static carries its declaration’s #[cfg], and sits inside the module it describes, so the module’s #[cfg] applies too. A declaration a build compiles out is missing from that build’s metadata exactly as it’s missing from its symbols and its contract table:

#![allow(unused)]
fn main() {
#[weaveffi::module]
pub mod shop {
    #[cfg(feature = "extra")]
    #[weaveffi::export]
    pub fn discount() -> u32 { 10 }
}
}

weaveffi extract lists discount only for a build with the extra feature (cargo build --features extra, then --library).

A library with no frames for the crate (the API isn’t annotated, or the crate builds no cdylib or staticlib) is an error naming what’s missing: #[weaveffi::module] on the API and one weaveffi::export_runtime!() call.

Iterating: weaveffi dev

weaveffi dev builds the crate’s debug library, generates, and makes the generated packages find that library without any environment variables where a package looks for a bundled copy (when the crate builds a shared library; a static-only build gets the environment variable instead):

  • Python: the library is copied into the package directory, which the loader checks first.
  • Node.js: the library is copied next to binding.gyp, which links the addon against it and records its directory as an rpath, so npm install in the package builds an addon that loads it.
  • Ruby: the library is copied to lib/native/, which the loader checks first.

For every other target, dev prints what to set: {PREFIX}_LIBRARY=<path> for the targets that load the library at run time (.NET, Dart, Kotlin), and the library directory to link with and put on the loader path for the targets that link at build time (C, C++, Swift, Go). WebAssembly needs a wasm32 build, which dev doesn’t make (weaveffi build --platforms wasm32 does). Rerun weaveffi dev after changing the crate: copies don’t follow later builds.

Generating from a build script

The CLI is also a library. weaveffi_cli::project::Project locates a project the way the weaveffi command does and generates it, so a crate that ships bindings can generate them from its build.rs:

// build.rs
fn main() -> miette::Result<()> {
    println!("cargo::rerun-if-changed=weaveffi.toml");
    println!("cargo::rerun-if-changed=api.yml");
    weaveffi_cli::project::Project::discover(env!("CARGO_MANIFEST_DIR"))?.generate()?;
    Ok(())
}
[build-dependencies]
weaveffi-cli = "0.24"
miette = "7"

Project::discover finds the nearest weaveffi.toml at or above the directory, and generate writes the [project] targets into [project] out, rewriting only changed files.

A build script runs before its crate is compiled, so it can’t read its own crate’s library: from a build.rs, the project’s input must be an IDL, or the API must come from a library built earlier, named with Project::library(path). Pointing a crate’s own build.rs at the crate is an error that says so. For a Rust producer, generate after the build instead: weaveffi generate in CI or a cargo xtask.

Type mapping

RustIDL
i8..i64, u8..u64, f32, f64, boolsame
String, &strstring
Vec<u8>, &[u8]bytes
Vec<T>, &[T][T]
Option<T>T?
HashMap<K, V>, BTreeMap<K, V>{K:V}
weaveffi::Iter<T>iter<T>
&T, Arc<T> (interface), Arc<Self>T
Arc<dyn Trait> (callback interface)Trait
weaveffi::CancelTokenremoved (#[weaveffi::cancellable] sets cancellable: true)
Result<T, E>return T, sets throws: true
a type alias declared in the treeits target
any other namethat name, resolved by the validator

Compositions map recursively: Option<Vec<i32>> is [i32]? and Vec<Arc<Gadget>> is [Gadget]. An async fn sets async: true, () and Result<(), E> returns are no return, and a callback method’s Result<T, ForeignError> is a T return that throws only with #[weaveffi::throws]. A type is always referenced by its bare name (Store, wherever it’s declared), because type names are global; a path such as super::kv::Store contributes only its last segment.

Limits

  • Error messages. A #[weaveffi::error] variant’s doc comment becomes the code’s doc:, and its first line the code’s message: (the variant name when it has no doc), so a Rust producer can’t give a code a message that differs from its doc’s first line.
  • Deprecation versions. #[deprecated(since = "...")] keeps only the note; the IDL has no since field.
  • Parameter docs. Rust doesn’t allow doc comments on function parameters, so an extracted API has no Param.doc; describe parameters in the function’s own doc comment.

crates/weaveffi-cli/tests/cli/extract.rs builds the fixture producer in crates/weaveffi-cli/tests/fixtures/producer, checks that weaveffi extract prints exactly its expected.yml, and checks that generating from the library and from that IDL gives identical output.

IDL Schema

An IDL document describes an API: its modules, types, and functions. YAML, JSON, and TOML are all accepted; this page uses YAML. It documents schema version 0.11.0, the only version the current tools accept. A Rust producer never writes one by hand (the macro embeds the API in the built library, and weaveffi extract prints it as an IDL), but the schema is the same either way.

Every YAML block on this page is a complete document that passes weaveffi validate, except blocks whose first line is # rejected:, which show what the validator refuses and the error it reports.

Editor support

The CLI prints a JSON Schema for the document with weaveffi schema --format json-schema, and a copy is checked in as weaveffi.schema.json. Editors using the YAML Language Server pick it up from a header comment:

# yaml-language-server: $schema=./weaveffi.schema.json

Document structure

version: "0.11.0"
modules:
  - name: my_module
    doc: "..."
    structs: [...]
    enums: [...]
    interfaces: [...]
    callback_interfaces: [...]
    functions: [...]
    errors: { ... }
    modules: [...]

The document describes only the API. Package identity and generator options live in weaveffi.toml. Every object rejects unknown keys, so a misspelled field fails to parse instead of being ignored.

FieldTypeRequiredMeaning
versionstringyesMust be "0.11.0"
modulesarray of ModuleyesTop-level modules

Module

FieldTypeMeaning
namestring (required)Namespace segment and C symbol component (kv)
docstringModule documentation
functionsarray of FunctionFree functions
structsarray of StructRecords (value types)
enumsarray of EnumC-style or rich enums
interfacesarray of InterfaceReference-counted object types
callback_interfacesarray of CallbackInterfaceMethod sets the consumer implements
errorsErrorDomainThe module’s error domain
modulesarray of ModuleNested submodules

Function

The same shape describes free functions, interface constructors, methods, and statics, and callback-interface methods, with the restrictions noted in their sections.

FieldTypeMeaning
namestring (required)Function name
paramsarray of ParamOrdered parameters (default none)
returnTypeRefReturn type; omit for none
docstringDocumentation
throwsboolReport typed domain errors (default false)
asyncboolComplete asynchronously (default false)
cancellableboolAccept a cancel token; requires async: true
deprecatedstringDeprecation message propagated to every binding

A Param has a required name and type and an optional doc.

Types

A TypeRef is a string:

SyntaxMeaning
i8 i16 i32 i64 u8 u16 u32 u64Fixed-width integers
f32 f64IEEE 754 floats
boolBoolean
stringUTF-8 text
bytesByte string ([u8] is accepted and becomes bytes)
NameA struct, enum, interface, or callback interface, by its bare name
T?Optional
[T]List
{K:V}Map
iter<T>Lazy iterator (returns only)

A user type is always written as its bare name: type names are global (see Modules and names), so there’s no module-qualified spelling, and a dotted name such as kv.Store is rejected with QualifiedTypeRef. usize, isize, u128, i128, and char aren’t primitives; a reference to one is reported as UnsupportedPrimitive (use u64 or i64 for sizes and wide integers, and string for characters).

The parser reads outside in: [Contact?] is a list of optional contacts and [Contact]? an optional list. Composites nest to any depth. Quote anything YAML would otherwise interpret ("string?", "[i32]", "{string:i32}"). The Node and Wasm targets surface i64 and u64 as BigInt.

Map keys must be an integer, bool, string, or a C-style enum, so every target can use its native dictionary with exact equality. Floats, bytes, composites, and objects are rejected:

# rejected: InvalidMapKey
version: "0.11.0"
modules:
  - name: maps
    functions:
      - name: by_weight
        params:
          - { name: index, type: "{f64:i32}" }

Iterators are lazy: every binding pulls one element per step of its native iteration idiom and destroys the handle exactly once. iter<T> is valid only as the outermost return of a synchronous callable. It’s rejected in a parameter, a field, or nested in another type (IteratorInInvalidPosition), and as the return of an async function (AsyncIteratorReturn):

# rejected: IteratorInInvalidPosition
version: "0.11.0"
modules:
  - name: streaming
    functions:
      - name: consume
        params:
          - { name: items, type: "iter<i32>" }

Type positions

“Buffered field” means a struct field, rich-enum variant field, or error payload field.

TypeParamReturnBuffered fieldInside T? [T] {K:V}Map keyCallback paramCallback return
integers, floats, boolyesyesyesyesintegers, boolyesyes
stringyesyesyesyesyesyesyes
bytesyesyesyesyesnoyesyes
C-style enumyesyesyesyesyesyesyes
struct, rich enumyesyesyesyesnoyesyes
interfaceyesyesyesyesnoyesyes
callback interfaceyesnonoCb? as a top-level parameter onlynonono
T?, [T], {K:V}yesyesyesyesnoyesyes
iter<T>noyes (sync)nonononono

How each family crosses the boundary is in the C ABI contract.

Structs

A struct is a record: a value type with named fields, crossing the ABI by value as a value buffer. Bindings map it to a data class, struct, or record type; no per-struct C functions exist.

FieldTypeMeaning
namestring (required)Type name
docstringDocumentation
deprecatedstringDeprecation message
fieldsarray of Field (required, non-empty)Each with name, type, optional doc
version: "0.11.0"
modules:
  - name: geometry
    structs:
      - name: Point
        doc: A point in the plane.
        fields:
          - { name: x, type: f64 }
          - { name: "y", type: f64 }
      - name: Polygon
        fields:
          - { name: points, type: "[Point]" }
          - { name: label, type: "string?" }
    functions:
      - name: area
        params:
          - { name: shape, type: Polygon }
        return: f64

Enums

An enum is a fixed set of variants, each with an explicit i32 value.

FieldTypeMeaning
namestring (required)Type name
doc, deprecatedstringDocumentation, deprecation message
variantsarray of Variant (required, non-empty)Each with name, value, optional doc and fields

When no variant has fields, the enum is C-style: it crosses as an int32_t and may be a map key. When any variant has fields, the enum is rich (a sum type): it crosses as a value buffer holding the variant’s value as a tag followed by its fields, and bindings map it to a Swift enum with associated values, a sealed class hierarchy, a tagged union, and so on. Unit and data variants may mix.

version: "0.11.0"
modules:
  - name: shapes
    enums:
      - name: Unit
        variants:
          - { name: Metric, value: 0 }
          - { name: Imperial, value: 1 }
      - name: Shape
        variants:
          - { name: Empty, value: 0 }
          - name: Circle
            value: 1
            fields:
              - { name: radius, type: f64 }
          - name: Rect
            value: 2
            fields:
              - { name: width, type: f32 }
              - { name: height, type: f32 }
    functions:
      - name: area
        params:
          - { name: shape, type: Shape }
          - { name: unit, type: Unit }
        return: f64

Variant names and values are unique within an enum, and a variant’s field names are unique within the variant.

Interfaces

An interface is an object type: state with identity and behavior that lives in the producer and crosses the ABI as an opaque, reference-counted pointer. Consumers see a class whose constructors, methods, and statics call into the producer.

FieldTypeMeaning
namestring (required)Type name
doc, deprecatedstringDocumentation, deprecation message
constructorsarray of FunctionReturn a new instance; no return, never async
methodsarray of FunctionTake the instance implicitly
staticsarray of FunctionNo instance

An interface needs at least one member, and constructor, method, and static names share one namespace. A constructor named new becomes the canonical constructor where the target has one (Swift init, Python __init__); others become static factories. For an async factory, declare an async static that returns the interface.

An interface may appear in every position except a map key: parameters, returns, optionals, lists, map values, record and variant fields, error payloads, iterator elements, async results, and callback parameters and returns. A map keyed by an interface reports both InvalidMapKey and InterfaceInInvalidPosition. Store? at the top level of a parameter or return is a nullable pointer. Every position transfers or borrows references by the rules in Errors and Memory.

version: "0.11.0"
modules:
  - name: kv
    errors:
      name: KvError
      codes:
        - { name: KeyNotFound, code: 1001, message: "key not found" }
    structs:
      - name: StoreInfo
        fields:
          - { name: label, type: string }
          - { name: store, type: Store }
    interfaces:
      - name: Store
        constructors:
          - name: open
            params:
              - { name: path, type: string }
            throws: true
        methods:
          - name: get
            params:
              - { name: key, type: string }
            return: bytes
            throws: true
          - name: share
            return: Store
          - name: larger
            params:
              - { name: other, type: "Store?" }
            return: "Store?"
          - name: describe
            params:
              - { name: label, type: string }
            return: StoreInfo
        statics:
          - name: open_many
            params:
              - { name: paths, type: "[string]" }
            return: "[Store]"
            throws: true
# rejected: InvalidMapKey, InterfaceInInvalidPosition
version: "0.11.0"
modules:
  - name: kv
    interfaces:
      - name: Store
        methods:
          - { name: count, return: i64 }
    functions:
      - name: index
        params:
          - { name: stores, type: "{Store:i32}" }

An interface lowers to an opaque type, one symbol per member (methods take a leading self), and implicit _clone and _destroy symbols you never declare. From the kitchen_sink test fixture (prefix kitchen_sink, module kitchen):

typedef struct kitchen_sink_kitchen_Gadget kitchen_sink_kitchen_Gadget;
kitchen_sink_kitchen_Gadget* kitchen_sink_kitchen_Gadget_new(int64_t id, kitchen_sink_error* out_err);
const uint8_t* kitchen_sink_kitchen_Gadget_describe(const kitchen_sink_kitchen_Gadget* self, size_t* out_len, kitchen_sink_error* out_err);
kitchen_sink_kitchen_Gadget* kitchen_sink_kitchen_Gadget_clone(const kitchen_sink_kitchen_Gadget* self);
void kitchen_sink_kitchen_Gadget_destroy(kitchen_sink_kitchen_Gadget* self);

Callback interfaces

A callback interface is the inverse of an interface: methods the consumer implements and the producer calls, any number of times and from any thread, for as long as it holds the implementation.

FieldTypeMeaning
namestring (required)Type name
doc, deprecatedstringDocumentation, deprecation message
methodsarray of Function (required, non-empty)In vtable order

Methods are restricted:

  • synchronous: no async, no cancellable (InvalidCallbackMethod);
  • return nothing or any type except an iterator (InvalidCallbackMethod) or a callback interface (CallbackInterfaceInInvalidPosition); a string, bytes, or buffered return is a run the consumer allocates with {prefix}_alloc and the producer adopts, and an object return transfers one reference to the producer;
  • parameters may be any type except an iterator (IteratorInInvalidPosition) or a callback interface (CallbackInterfaceInInvalidPosition); an object parameter transfers one reference to the consumer.

A method may set throws: true when an error domain is in scope for its module (otherwise it’s ThrowsWithoutErrorDomain). Its consumer may then fail with one of the domain’s codes, with the code’s fields (which can’t include objects), and the producer receives the typed error. Any other consumer failure, and every failure of a method without throws, reaches the producer as the runtime code -4.

A callback interface is valid only as a top-level parameter of a function, constructor, method, or static, passed bare (Listener) or optional (Listener?, where none is a null vtable). Anything else is CallbackInterfaceInInvalidPosition:

# rejected: CallbackInterfaceInInvalidPosition
version: "0.11.0"
modules:
  - name: events
    callback_interfaces:
      - name: Listener
        methods:
          - { name: on_event, params: [{ name: id, type: i64 }] }
    functions:
      - name: current_listener
        return: Listener
version: "0.11.0"
modules:
  - name: events
    enums:
      - name: Delivery
        variants:
          - { name: Accept, value: 0 }
          - { name: Skip, value: 1 }
    structs:
      - name: Message
        fields:
          - { name: topic, type: string }
          - { name: text, type: string }
    callback_interfaces:
      - name: Subscriber
        methods:
          - name: route
            params:
              - { name: topic, type: string }
            return: Delivery
          - name: on_message
            params:
              - { name: message, type: Message }
          - name: name
            return: string
    interfaces:
      - name: EventBus
        constructors:
          - name: new
        methods:
          - name: subscribe
            params:
              - { name: subscriber, type: Subscriber }
            return: i64

A callback interface lowers to a vtable type that starts with a header (the vtable’s size, reserved flags, and the free release hook) followed by one entry per method in declaration order; a parameter lowers to a ctx pointer plus the vtable. From the kitchen_sink fixture, whose label returns a string and throws, latest returns an Item?, and favorite returns a Gadget:

typedef struct kitchen_sink_kitchen_ReadyListener_vtable {
    uint32_t size;
    uint32_t flags;
    void (*free)(void* ctx);
    void (*on_ready)(void* ctx, int32_t code, const uint8_t* msg_ptr, size_t msg_len, kitchen_sink_error* out_err);
    bool (*on_item)(void* ctx, const uint8_t* item_ptr, size_t item_len, kitchen_sink_kitchen_Gadget* gadget, kitchen_sink_error* out_err);
    void (*label)(void* ctx, uint8_t** out_ptr, size_t* out_len, kitchen_sink_error* out_err);
    void (*latest)(void* ctx, uint8_t** out_ptr, size_t* out_len, kitchen_sink_error* out_err);
    kitchen_sink_kitchen_Gadget* (*favorite)(void* ctx, kitchen_sink_error* out_err);
} kitchen_sink_kitchen_ReadyListener_vtable;

int32_t kitchen_sink_kitchen_subscribe(void* listener_ctx, const kitchen_sink_kitchen_ReadyListener_vtable* listener_vtable, kitchen_sink_error* out_err);
bool kitchen_sink_kitchen_maybe_subscribe(void* listener_ctx, const kitchen_sink_kitchen_ReadyListener_vtable* listener_vtable, kitchen_sink_error* out_err);

Error domains

A module may declare one error domain: named, stable codes its throwing callables report. Each binding generates a typed error (an exception hierarchy, a Swift Error enum, a Go error type) so consumers match on the names you declared.

FieldTypeMeaning
namestring (required)Domain type name (KvError)
codesarray of Code (required)The codes

Each code has a required name (PascalCase by convention), positive code, and default message, plus an optional doc and optional fields: a structured payload with the same shape as struct fields, delivered as properties of the raised error.

version: "0.11.0"
modules:
  - name: kv
    errors:
      name: KvError
      codes:
        - name: KeyNotFound
          code: 1001
          message: key not found
          fields:
            - { name: key, type: string }
        - { name: StoreFull, code: 1003, message: store has reached capacity }
    functions:
      - name: lookup
        params:
          - { name: key, type: string }
        return: bytes
        throws: true
      - name: count
        return: i64

Declaring a domain reserves the codes; a function, constructor, method, or static opts in with throws: true. A callable without throws (count above) has a plain signature and can’t report a domain error; a failure there is a producer bug and traps (see Errors and Memory). A domain is in scope for its module and every module nested in it, and throws: true with no domain in scope is rejected:

# rejected: ThrowsWithoutErrorDomain
version: "0.11.0"
modules:
  - name: contacts
    functions:
      - name: get_contact
        params:
          - { name: id, type: i64 }
        return: string
        throws: true

Codes must be positive (0 is success and negative codes belong to the runtime) and unique within their domain. Code names must be unique across every domain in the API, because several targets flatten them into one namespace; qualify one (OrderNotFound) when two domains need the same idea.

Async, cancellable, and deprecated

async: true makes a function, method, or static complete through a callback, surfaced as each language’s async idiom; cancellable: true adds a cancel token. Both are covered in Async and Cancellation. Constructors can’t be async, and cancellable requires async:

# rejected: CancellableNotAsync
version: "0.11.0"
modules:
  - name: net
    functions:
      - { name: ping, return: bool, cancellable: true }

deprecated: on a function, method, static, struct, enum, interface, or callback interface propagates its message to every binding’s native deprecation marker.

Modules and names

Modules nest to any depth. A nested module shares the validation rules of a top-level one, and an error domain declared on a parent serves the subtree.

Names are global. Modules group declarations and namespace their C symbols, but they don’t scope names:

  • Struct, enum, interface, callback interface, and error domain names are unique across the whole API (DuplicateTypeName).
  • Free-function names are unique across the whole API (DuplicateFunctionName), because several targets flatten every module’s functions into one namespace. A free function also can’t share its name with an error domain (NameCollisionWithErrorDomain).
  • Error code names are unique across every domain (DuplicateErrorCodeName).

Members are scoped to their owner: parameters to their callable, fields to their record or variant, variants to their enum, and constructors, methods, and statics to their interface. Sibling modules need distinct names (DuplicateModuleName), but modules in different subtrees may share one.

Because every type name identifies one declaration, a reference is always the bare name, wherever the type is declared. A reference to a name nothing declares is an UnknownTypeRef. C symbols join the declaring module’s path with underscores: a function get_record in module app.data is {prefix}_app_data_get_record, and a record Record declared there is {prefix}_app_data_Record.

version: "0.11.0"
modules:
  - name: kv
    interfaces:
      - name: Store
        constructors:
          - name: open
            params:
              - { name: path, type: string }
    modules:
      - name: stats
        structs:
          - name: Stats
            fields:
              - { name: total_entries, type: i64 }
        functions:
          - name: get_stats
            params:
              - { name: store, type: Store }
            return: Stats
# rejected: DuplicateFunctionName, QualifiedTypeRef
version: "0.11.0"
modules:
  - name: kv
    interfaces:
      - name: Store
        constructors:
          - { name: open, params: [{ name: path, type: string }] }
    functions:
      - { name: stats, params: [{ name: store, type: kv.Store }], return: i64 }
  - name: metrics
    functions:
      - { name: stats, return: i64 }

Documentation comments

doc: is accepted on modules, functions, parameters, structs, fields, enums, variants, interfaces and their members, callback interfaces and their methods, and error codes. Every generator emits it in the target’s native doc-comment syntax (///, /** */, docstrings, XML docs); multi-line YAML block strings (|) are preserved. An absent doc: emits nothing.

Validation

weaveffi validate (and every generating command) runs every rule and reports every violation in one pass, each with a message, a suggestion, and a source span located within the enclosing declaration when one can be found. --format json emits the same diagnostics, each with a code field naming the variant alongside the variant’s own fields (name, module, and so on); --warn adds advisory warnings.

The rules in brief:

  • Names are identifiers (a letter or _, then ASCII letters, digits, and _) and not one of if, else, for, while, loop, match, type, return, async, await, break, continue, fn, struct, enum, mod, use. Generators escape other languages’ keywords themselves.
  • Type names, free-function names, and error code names are unique across the whole API; every other name is unique in its scope (see Modules and names).
  • Structs, enums, interfaces, and callback interfaces aren’t empty.
  • Types are bare names that resolve, and appear only in the positions in the table above.
  • throws needs a domain in scope; cancellable needs async.
  • Codes are positive and their values unique within a domain; no free function shares a domain’s name.
  • No two declarations lower to the same C symbol. Every symbol is the prefix plus the underscore-joined module path plus the declaration name, so a function x_y in module m collides with a function y in module m.x or m_x; so do a free function Store_get and a get method on interface Store, a function named Store_clone and Store’s implicit _clone, and a function named foo_callback and the completion type of an async foo. Symbols that fall in a family the C value-buffer helpers reserve ({prefix}_list_*, {prefix}_map_*, and so on, which covers everything in a top-level module named list) collide too. The full symbol table is in the C ABI contract.

A document with an unsupported version reports only UnsupportedSchemaVersion.

Error catalog

CodeReported when
UnsupportedSchemaVersionversion isn’t 0.11.0
NoModuleNamea module has no name
InvalidModuleNamea module name isn’t an identifier or is reserved
DuplicateModuleNametwo sibling modules share a name
InvalidIdentifiera name isn’t a valid identifier
ReservedKeyworda name is a reserved word
DuplicateFunctionNametwo free functions anywhere in the API share a name
DuplicateParamNametwo parameters of a callable share a name
NameCollisionWithErrorDomaina free function and an error domain share a name
DuplicateStructFieldtwo fields of a struct share a name
EmptyStructa struct has no fields
EmptyEnuman enum has no variants
DuplicateEnumVarianttwo variants of an enum share a name
DuplicateEnumValuetwo variants of an enum share a value
DuplicateEnumVariantFieldtwo fields of a rich-enum variant share a name
DuplicateInterfaceMembertwo members of an interface share a name
EmptyInterfacean interface has no members
ConstructorHasReturna constructor declares return
AsyncConstructora constructor is async
InterfaceInInvalidPositionan interface is used as a map key
EmptyCallbackInterfacea callback interface has no methods
DuplicateCallbackMethodtwo methods of a callback interface share a name
InvalidCallbackMethoda callback method is async or cancellable, or returns an iterator
CallbackInterfaceInInvalidPositiona callback interface appears other than as a top-level parameter (bare or Cb?)
DuplicateTypeNametwo types (including error domains) anywhere in the API share a name
UnknownTypeRefa reference names no declaration
QualifiedTypeRefa reference is a dotted, module-qualified name
UnsupportedPrimitivea reference names usize, isize, u128, i128, or char
InvalidMapKeya map key isn’t an integer, bool, string, or C-style enum
IteratorInInvalidPositioniter<T> appears other than as an outermost return
AsyncIteratorReturnan async function returns iter<T>
CancellableNotAsynca synchronous callable is cancellable
ThrowsWithoutErrorDomainthrows: true on a callable or callback method with no error domain in scope
ErrorDomainMissingNamean error domain has no name
DuplicateErrorCodetwo codes in a domain share a value
DuplicateErrorCodeNametwo codes anywhere in the API share a name
InvalidErrorCodea code is zero or negative
SymbolCollisiontwo declarations lower to the same C identifier, or one falls in a reserved helper family

Warnings

Warnings never fail validation.

CodeFires when
LargeEnumVariantCountan enum has more than 100 variants
DeepNestinga type nests composites more than three levels deep
EmptyModuleDoca module with functions has no doc: on itself or any function
AsyncVoidFunctionan async free function returns nothing
DeprecatedFunctiona free function is deprecated (informational)

Complete example

An address book combining an enum, a record, an error domain, an interface, and a nested module that uses its parent’s types:

version: "0.11.0"
modules:
  - name: contacts
    doc: An in-memory address book.
    errors:
      name: ContactsError
      codes:
        - { name: InvalidName, code: 1, message: name must not be empty }
        - { name: NotFound, code: 2, message: contact not found }
    enums:
      - name: ContactType
        variants:
          - { name: Personal, value: 0 }
          - { name: Work, value: 1 }
    structs:
      - name: Contact
        fields:
          - { name: id, type: i64 }
          - { name: name, type: string }
          - { name: email, type: "string?" }
          - { name: contact_type, type: ContactType }
    interfaces:
      - name: ContactBook
        constructors:
          - name: new
        methods:
          - name: add
            params:
              - { name: name, type: string }
              - { name: email, type: "string?" }
              - { name: contact_type, type: ContactType }
            return: Contact
            throws: true
          - name: get
            params:
              - { name: id, type: i64 }
            return: Contact
            throws: true
          - name: list
            return: "[Contact]"
    modules:
      - name: groups
        functions:
          - name: count_of_type
            params:
              - { name: book, type: ContactBook }
              - { name: contact_type, type: ContactType }
            return: i32

C ABI Contract

This page is the normative description of the WeaveFFI C ABI, revision 4. The #[weaveffi::module] macro implements it on the producer side, the C generator renders it as a header, and every other generator consumes it. Where another page disagrees with this one, this one wins.

Vocabulary

  • Producer: the native library implementing the symbols (a Rust cdylib built with the macro, or a C, C++, or Zig library implementing the header).
  • Consumer: generated bindings, or C code calling the header directly.
  • {p}: the library’s C symbol prefix from its identity, a lower-snake identifier (kvstore). {P} is the same prefix uppercased, used for macros (KVSTORE).
  • {path}: a module path joined with _ (kv, kv_stats).
  • Slot: one C parameter. An IDL parameter lowers to one or more slots.

Every identifier the ABI defines starts with {p}_ (functions, types, constants) or {P}_ (macros). There are no fixed weaveffi_* symbols, so any number of WeaveFFI libraries can link into one process, statically or dynamically.

Load-time checks

A consumer makes two checks once, when it loads the library, before any other call, and refuses to load if either fails (with the language’s load or import error, or a trap where loading can’t throw, as in Swift and Go).

The revision. {P}_ABI_VERSION in the header and {p}_abi_version() in the library must be equal. The revision changes only when the runtime surface, the value-buffer encoding, or the object, callback, async, or iterator conventions change incompatibly; it’s independent of the crate version and the IDL schema version. Revision 4 replaced revision 3’s per-module checksums with the per-declaration contract tables below, gave callback vtables their size-checked header and every return family (with throws and optional callback parameters), exported {p}_alloc on every target (removing {p}_dealloc), and added {p}_error_set_payload.

The contract. Every top-level module m has a contract table: one entry per declaration in m and its submodules.

typedef struct {p}_contract_entry { uint64_t id; uint64_t hash; } {p}_contract_entry;
const {p}_contract_entry* {p}_{m}_contract(size_t* out_len);  /* static, sorted by id */

A declaration is a function, an interface, each interface member (constructor, method, or static), a record, an enum, a callback interface, or an error domain. Its entry is:

  • id: the 64-bit FNV-1a hash of its dotted path, the module path then the name (kv.open_store, kv.Store, kv.Store.get, kv.KvError, kv.stats.Stats);
  • hash: the 64-bit FNV-1a hash of its canonical signature string.

The canonical signature is computed from the resolved model, never from source spelling: type aliases are substituted, [u8] is bytes, and each type is written in its IDL spelling (i32, string, [Contact], {string:i64}, Store?, iter<Item>). Documentation, deprecation text, and error messages are excluded, and so is the order of sibling declarations, because each entry stands alone.

DeclarationCanonical signature
function or interface member{kind} {name}({param}: {type}, ...) -> {type}, where kind is function, constructor, method, or static and a missing return is void, followed by throws, async, and cancellable in that order when set (a constructor’s return is its interface)
recordrecord {name} {{field}: {type}, ...}
enumenum {name} {{Variant} = {value}, ...}, a variant with fields followed by {{field}: {type}, ...}
callback interfacecallback {name} {{method}({param}: {type}, ...) -> {type}; ...}, a method that throws followed by throws
error domainerrors {name} {{Code} = {value}, ...}, a code with fields followed by {{field}: {type}, ...}
interfaceinterface {name} (each member has its own entry)

For example, the kvstore sample’s fn get(&self, key: String) -> Result<Entry, KvError> on interface Store in module kv has the id fnv1a64("kv.Store.get") (0x3bf340d3781ddb6d) and the hash fnv1a64("method get(key: string) -> Entry throws") (0xd86b757fadf495cb). FNV-1a 64 starts from 0xcbf29ce484222325 and, for each byte, XORs the byte in and multiplies by 0x100000001b3, wrapping.

The header defines the entries the bindings were generated with, and a checker:

#define {P}_{M}_CONTRACT_LEN n
#define {P}_{M}_CONTRACT { {0x...ull, 0x...ull}, /* kv.Store.get */ ... }
static inline uint64_t {p}_{m}_contract_check(void);  /* first failing id, or 0 */

A consumer passes when every entry it was generated with is present in the producer’s table with an equal hash. Entries the producer has and the consumer doesn’t are fine, so adding a declaration (a function, a method, a record) never breaks a deployed binding. A missing entry fails with “kv.Store.get is missing from the library”; a different hash fails with “kv.Store.get changed since these bindings were generated”. {M} is the module name uppercased. The macro and the CLI compute the table with the same function (weaveffi_model::contract::entries) over the same model, and a Rust producer’s table lists only the declarations its build compiles in (an item’s #[cfg] applies to its entry).

Runtime surface

Every producer exports these symbols (weaveffi::export_runtime!() emits them for a Rust producer):

#define {P}_ABI_VERSION 4u
uint32_t {p}_abi_version(void);

typedef struct {p}_error {
    int32_t code;               /* 0 ok; >0 domain code; <0 runtime code */
    const char* message;        /* NUL-terminated UTF-8, producer-owned */
    const uint8_t* payload_ptr; /* the code's fields as a value buffer, or NULL */
    size_t payload_len;
} {p}_error;
void {p}_error_set({p}_error* err, int32_t code, const char* message);         /* copies */
void {p}_error_set_payload({p}_error* err, const uint8_t* ptr, size_t len);    /* copies */
void {p}_error_clear({p}_error* err);  /* frees message and payload; code = 0 */
void {p}_error_free({p}_error* err);   /* clear, then free a heap-boxed error */

uint8_t* {p}_alloc(size_t len);                 /* a zero-filled run, NULL for 0 */
void {p}_free_bytes(uint8_t* ptr, size_t len);  /* returned runs and {p}_alloc runs */

typedef struct {p}_cancel_token {p}_cancel_token;
{p}_cancel_token* {p}_cancel_token_create(void);           /* refcount 1 */
void {p}_cancel_token_cancel({p}_cancel_token* token);      /* idempotent */
bool {p}_cancel_token_is_cancelled(const {p}_cancel_token* token);
void {p}_cancel_token_destroy({p}_cancel_token* token);     /* drops one reference */

uint64_t {p}_debug_live(int32_t kind);  /* leak counters */

typedef struct {p}_contract_entry { uint64_t id; uint64_t hash; } {p}_contract_entry;

Every function accepts null where a pointer is optional (error_set, error_set_payload, error_clear, error_free, free_bytes, the token functions, _clone, _destroy) and treats it as a no-op; a null token reads as never cancelled.

{p}_alloc and {p}_free_bytes manage byte runs. Every string, bytes, or value buffer the producer returns is a run the consumer releases with {p}_free_bytes(ptr, len), passing the exact length it received. A consumer allocates a run with {p}_alloc(len) when it hands bytes to the producer (a callback method’s string, bytes, or buffer return, which the producer adopts) or, on wasm32, when it stages an argument in the module’s linear memory (released with {p}_free_bytes). {p}_alloc(0) returns null, and null with length 0 is the empty run everywhere; {p}_free_bytes(ptr, 0) is a no-op.

{p}_debug_live(kind) reports live resources for leak checks: 0 objects, 1 foreign callbacks, 2 iterators, 3 cancel tokens, 4 byte runs (returned and allocated ones not yet freed or adopted). Kind -1 returns 1 when the producer counts at all and 0 when it doesn’t, so a harness can tell “nothing is live” from “nothing is counted”; an unknown kind returns 0. A Rust producer counts only with the leak-check feature; without it every kind but -1 returns 0.

Errors

Every synchronous API symbol (a function or interface member, an iterator launcher, and an iterator’s _next) takes a trailing {p}_error* out_err after all inputs and out-parameters; _clone, _destroy, an iterator’s _destroy, async launchers, and the runtime surface don’t. The caller owns the struct and zero-initializes it. On failure the producer fills it and returns a zero sentinel (0, false, NULL) the caller must not use; on success it clears it (releasing anything an earlier failure left there), so one struct can serve many calls. The caller releases a failure’s message and payload with {p}_error_clear.

CodeMeaning
0success; every pointer is null
> 0a declared code of the callable’s error domain; payload_ptr/payload_len hold the code’s fields, if any
-1generic: an untyped producer error, or an async call the executor couldn’t start
-2the producer panicked
-3marshalling failure: an argument couldn’t be lifted (null with a non-zero length, a null self, object, or vtable where one is required, invalid UTF-8, an out-of-range enum value, a malformed buffer, a map with a repeated key, a callback vtable smaller than the producer’s, an iterator advanced concurrently)
-4a consumer’s callback-interface implementation failed
-5cancelled (async completions only)

A producer lifts every argument of a call before it reports the first one that failed, so the inputs it adopts (a callback context, the object tokens in a value buffer) are adopted and released even when another argument, or a null self, fails.

A non-zero code on a callable without throws is a producer bug or a failed callback; consumers trap on it rather than report a domain error (see Errors and memory).

Families and slots

Every resolved type belongs to one family, which decides how it crosses:

FamilyIDL typesParameter slotsReturn
Directintegers, f32, f64, bool, C-style enumsone by value (bool is C bool; an enum is its header type {p}_{path}_{E}, an int-sized value that crosses as int32_t)by value
Stringstringconst uint8_t* {n}_ptr, size_t {n}_len: UTF-8, not NUL-terminated, borrowedconst uint8_t* plus trailing size_t* out_len
Bytesbytessame as Stringsame as String
Bufferstructs, rich enums, T? (except Interface? and Cb?), [T], {K:V}const uint8_t* {n}_ptr, size_t {n}_len holding a value buffer, borrowedsame as String
Objectinterfaces, Interface?const {p}_{path}_{I}* {n}, borrowed; null means none{p}_{path}_{I}*, one strong reference; null means none
Callbackcallback interfaces, Cb?void* {n}_ctx, const {p}_{path}_{Cb}_vtable* {n}_vtable; for Cb? a null vtable means nonenot allowed
Iteratoriter<T>not allowed{Iter}* (see Iterators)

A parameter ptr may be null when its len is 0. A String, Bytes, or Buffer return is a producer allocation the consumer releases with {p}_free_bytes(ptr, len) using the exact length written to out_len; null with length 0 is the empty value. Out-parameters for a return precede out_err. Methods take the receiver as a leading const {p}_{path}_{I}* self.

Symbol names

All names are computed once by the model and recorded in one symbol table; validation fails with SymbolCollision if two declarations would produce the same identifier.

DeclarationC identifier
function f{p}_{path}_f
interface Itype {p}_{path}_I; members {p}_{path}_I_{member}; {p}_{path}_I_clone, {p}_{path}_I_destroy
async function flauncher {p}_{path}_f (no suffix); completion type {p}_{path}_f_callback
iterator returned by ftype {p}_{path}_{Owner_}{PascalF}Iterator, plus {Iter}_next and {Iter}_destroy
C-style enum E, variant Vtype {p}_{path}_E, constant {p}_{path}_E_V
rich enum E, variant Vtag type {p}_{path}_E_Tag, constant {p}_{path}_E_V; struct {p}_{path}_E and its codecs in the buffer header
record Sstruct {p}_{path}_S and its codecs in the buffer header
error domain D, code Ctype {p}_{path}_D, constant {p}_{path}_D_C; for a code with fields, struct {p}_{path}_D_C_payload and its codecs in the buffer header
callback interface Cbvtable type {p}_{path}_Cb_vtable
top-level module m{p}_{m}_contract, {p}_{m}_contract_check, {P}_{M}_CONTRACT, {P}_{M}_CONTRACT_LEN
runtimethe names in Runtime surface, plus the buffer header’s {p}_str, {p}_bytes, {p}_reader, and {p}_writer (reserved)

{Owner_} is empty for a free function and the interface name plus _ for a member, and the function name is converted to PascalCase: stream_items in kitchen is kitchen_sink_kitchen_StreamItemsIterator, and Store.keys in kv is kvstore_kv_Store_KeysIterator. Records and rich enums export no functions (they cross as buffers), but the C generator’s value-buffer helper header ({library}_buffer.h) declares a struct for each, and for each error code with fields, plus four codecs per struct T (T_write, T_read, T_decode, and T_free), so those names are reserved like the rest. That header also reserves the identifier families {p}_str_*, {p}_bytes_*, {p}_reader_*, {p}_writer_*, {p}_list_*, {p}_map_*, and {p}_opt_*: a declaration whose symbol falls in one (anything in a top-level module named list, say) is a SymbolCollision. IDL spellings are never re-cased in C.

The header’s macros are {P}_ABI_VERSION, the per-module contract macros, {P}_API (symbol visibility; define {P}_BUILD when building the producer on Windows), {P}_DEPRECATED(msg), and the include guard {P}_H.

Objects

Objects are reference counted by the producer. A {p}_{path}_{I}* is one strong reference. _clone returns another strong reference to the same object (the pointer value is unchanged); _destroy releases one, and the object is dropped with the last reference. A consumer may hold any number of references and use them from any thread.

PositionRule
parameterborrowed for the call; the producer takes its own reference if it retains the object
return, async result, iterator elementone strong reference transfers to the consumer
callback method parameterone strong reference transfers to the consumer (the slot is {p}_{path}_{I}*, not const)
callback method returnone strong reference transfers to the producer
object token inside a value buffercarries one strong reference; the reader adopts it

A consumer encoding an object into a buffer writes a fresh reference from _clone. An in-flight async call holds its own reference, so releasing a wrapper while a call is pending is safe.

Callback interfaces

typedef struct {p}_{path}_{Cb}_vtable {
    uint32_t size;            /* sizeof the vtable, as the consumer compiled it */
    uint32_t flags;           /* reserved; 0 */
    void (*free)(void* ctx);  /* always first, at a fixed offset */
    /* one entry per method, in declaration order: */
    <ret> (*{method})(void* ctx, <param slots>, <return out slots>, {p}_error* out_err);
} {p}_{path}_{Cb}_vtable;

A callback interface has no exported symbols; it lowers to a vtable type. The consumer passes a ctx it owns and a pointer to a vtable that outlives the producer’s use of it (in practice, one static vtable per interface), with size set to sizeof the vtable and flags to 0. The producer rejects a vtable whose size is smaller than the vtable it was built with, failing the call with -3 (after calling free(ctx), when the header itself is complete); a larger size is accepted, so a consumer generated from a newer contract may carry methods this producer doesn’t call. For an optional callback parameter (Cb?) a null vtable pointer means none.

The producer may call any entry, any number of times, from any thread, until it calls free(ctx) exactly once, after which it never touches ctx again. free may also run on any producer thread.

Parameters are borrowed for the duration of the call (strings, bytes, and buffers as ptr/len), except objects, which transfer one strong reference the consumer adopts.

Returns of any family but iterators and callback interfaces:

  • A Direct value is the C return (<ret>).
  • An object (I or I?) is the C return, {p}_{path}_{I}*: one strong reference (a fresh _clone) the producer adopts. I? may return null; I must not.
  • A string, bytes, or buffer return makes <ret> void and adds two trailing slots before out_err: uint8_t** out_ptr, size_t* out_len. The consumer allocates the run with {p}_alloc, writes it there, and the producer adopts and frees it. Null with length 0 is the empty value.

The producer adopts whatever the return and out slots hold whether or not the method failed, so a consumer that fails after allocating doesn’t leak. A return it can’t accept (a string that isn’t UTF-8, a null I, an out-of-range enum value, a malformed buffer) fails the method with -3.

Errors. A consumer implementation that fails reports it with {p}_error_set(out_err, code, message), which copies a borrowed message with the producer’s allocator; it never writes message itself. A method declared throws may report a positive code of the error domain in scope for its module, attaching the code’s fields encoded as a value buffer with {p}_error_set_payload (they may not include objects). Any other non-zero code, a code the domain doesn’t declare or whose payload doesn’t decode, or a positive code on a method without throws reaches the producer as -4, so a consumer bug can’t masquerade as a domain error. The producer decides what a failure means for the call in progress: a Rust producer’s method returns Result<T, ForeignError>, and propagating the Err reports its code, message, and payload to the original caller.

Async functions

typedef void (*{p}_{path}_{f}_callback)(void* context, {p}_error* err, <result slots>);
void {p}_{path}_{f}(<self>, <input slots>, [{p}_cancel_token* cancel_token,]
                    {p}_{path}_{f}_callback callback, void* context);

Result slots are: none for a void function; result for a Direct value or an object (adopted); or const uint8_t* result_ptr, size_t result_len for a String, Bytes, or Buffer result, released with {p}_free_bytes. err is NULL on success; otherwise it’s heap-boxed, owned by the consumer, and released with {p}_error_free. The launcher has no out_err; every failure, including an input marshalling failure, is delivered through the callback.

The runtime guarantees:

  • The callback fires exactly once, from any thread, possibly before the launcher returns.
  • The launcher takes everything it needs before returning (it copies strings, bytes, and buffers and retains objects and self), so the consumer may release its arguments as soon as it returns.
  • If the executor can’t take the call (it failed to start, or a custom executor panicked), the completion fires with -1 and a message.
  • If the executor drops the future before it completes, the completion fires with -5 from the drop guard.
  • For a cancellable function, the producer takes its own reference on the token at launch; the consumer may cancel and destroy its reference at any time afterward. Cancelling wakes the future; the runtime then drops it and completes with -5 and the message cancelled, unless it already completed. Producer code may still poll the token for cooperative cleanup.

Which threads drive the futures is the producer’s business; see Async for the Rust runtime’s executors.

Iterators

{Iter}* launcher(<self>, <input slots>, {p}_error* out_err);
int32_t {Iter}_next({Iter}* iter, <item out slots>, {p}_error* out_err);  /* 1 item, 0 done */
void {Iter}_destroy({Iter}* iter);

Item out slots are T* out_item for a Direct or object element and const uint8_t** out_item, size_t* out_len for a String, Bytes, or Buffer element. Each element follows the return rules of its family. _next never blocks on another _next: a call that arrives while the same iterator is being advanced (from another thread, or re-entrantly from inside the producer’s own next) fails with -3. The consumer calls _destroy exactly once, after exhaustion or to abandon the iteration.

Value buffers

Buffers have one encoding, specified in Value Buffers: little-endian, packed, u32 lengths and counts, object tokens as u64. Error payloads use the same encoding.

Producers in other languages

A producer implementing a generated header exports:

  1. every function the header declares for the API;
  2. one {p}_{m}_contract per top-level module, returning a table with (at least) the header’s {P}_{M}_CONTRACT entries, sorted by id;
  3. the runtime surface above, with {p}_abi_version returning {P}_ABI_VERSION.
const calculator_contract_entry* calculator_calculator_contract(size_t* out_len) {
    static const calculator_contract_entry table[] = CALCULATOR_CALCULATOR_CONTRACT;
    if (out_len != NULL) *out_len = CALCULATOR_CALCULATOR_CONTRACT_LEN;
    return table;
}

The producer owns every allocation it hands out and adopts every run a consumer allocates, so error_clear, error_free, free_bytes, and the adoption of a callback’s returned run must all use the allocator alloc uses. When building the library, define {P}_BUILD so the header’s {P}_API macro exports symbols on Windows. conformance/c/producer.c is a complete example.

Value Buffers

Records, rich enums, optionals, lists, maps, and error payloads cross the C ABI by value, serialized in one compact binary format. A buffered value occupies one (const uint8_t*, size_t) slot pair however deeply it nests. This page is the normative wire format every generator and the weaveffi::abi runtime (weaveffi::abi::buffer) implement.

Which types are buffered

  • a struct (record)
  • a rich enum (any variant has fields)
  • [T], {K:V}
  • T?, except Interface?, which stays a nullable object pointer at the top level of a parameter or return, and Cb?, an optional callback parameter whose null vtable means none

Everything else keeps its own slot shape at the top level (see the C ABI contract): direct values by value, strings and bytes as (ptr, len), objects as pointers. Any of them may appear inside a buffer. Iterators and callback interfaces never do; validation rejects them in buffered positions.

Encoding

All multi-byte values are little-endian, packed back to back with no padding or alignment. Lengths and counts are u32, so a single string, byte run, or collection holds at most 232 − 1 entries; an encoder refuses to write more rather than truncate.

IDL typeEncoding
bool1 byte: 0 or 1
i8, u81 byte
i16, u162 bytes
i32, u324 bytes
i64, u648 bytes
f32, f644 or 8 bytes of IEEE 754 bits (NaN payloads, infinities, and -0 round-trip)
C-style enumi32 value
interfaceu64 object token
stringu32 byte length, then UTF-8 bytes (no terminator)
bytesu32 length, then raw bytes
T?1 flag byte (0 absent, 1 present), then the value if present
[T]u32 count, then each element
{K:V}u32 count, then alternating key and value; no key appears twice
structeach field, in declaration order
rich enumi32 tag (the variant’s value), then the variant’s fields in order
error payloadthe code’s fields, in declaration order

The format is compositional, so {string:[T?]}, records inside records, and lists of rich enums need no special cases. A map’s encoding never repeats a key, so no entry can silently disappear between the two sides. [u8] is canonicalized to bytes at parse time; the two encode identically.

Example: a Point { x: f64, y: f64 } with x = 1.5, y = -2.0 is 16 bytes, 00 00 00 00 00 00 F8 3F 00 00 00 00 00 00 00 C0; a [string] of ["hi"] is 01 00 00 00 02 00 00 00 68 69.

Decoding

A decoder rejects a buffer that:

  • ends in the middle of a value;
  • holds a bool or optional flag byte other than 0 or 1;
  • holds invalid UTF-8 in a string;
  • declares a string or byte length larger than the bytes remaining;
  • holds an enum value or rich-enum tag that isn’t declared;
  • repeats a key in a map;
  • has bytes left over after the complete value.

A collection count is not checked against the bytes remaining, because an element’s encoding can be empty; a decoder caps how much it preallocates from a count instead, so a hostile count fails when the elements run out. A malformed buffer is a contract violation between two sides generated from one definition, so it’s reported as the marshalling code -3, never as a domain error.

Objects

An interface value inside a buffer is an object token: the object pointer widened to u64 (zero-extended on 32-bit targets). A token carries exactly one strong reference, in either direction.

  • Writing. The encoder owns the reference it writes. A consumer holding a wrapper calls _clone and writes the returned pointer, keeping its own reference; a Rust producer writes a cloned Arc.
  • Reading. The decoder adopts the reference: a consumer wraps it in a new object wrapper, a Rust producer turns it back into an Arc.
  • Failure. If decoding fails partway, the reader releases every token it already adopted; the writer has no further responsibility once the buffer is handed over.
  • Single use. Because each token is one reference, a buffer holding objects is decoded exactly once. Bindings encode a fresh buffer per call.

A zero token is invalid in a non-optional position; Interface? inside a buffer uses the optional flag byte followed by the token.

A callback method’s error payload can’t carry object tokens: an error code whose fields include an interface can’t be reported from a callback, and a Rust producer decodes a callback’s payload with a reader that refuses every token. That reader (BufferReader::token_free) is also why decoding is otherwise unsafe in the Rust runtime: only a reader that refuses tokens can safely decode arbitrary bytes, because no safe function can turn an arbitrary u64 into an object reference.

Slots and ownership

PositionSlotsOwner
parameter vconst uint8_t* v_ptr, size_t v_lenthe caller; borrowed for the call
returnconst uint8_t* return plus size_t* out_lenthe consumer; free with {p}_free_bytes(ptr, len)
iterator elementconst uint8_t** out_item, size_t* out_lenthe consumer, per element
async resultconst uint8_t* result_ptr, size_t result_lenthe consumer
callback method argumentconst uint8_t* v_ptr, size_t v_lenthe producer; borrowed for the call
error payloadpayload_ptr, payload_len in {p}_errorfreed by {p}_error_clear or {p}_error_free

Object tokens inside any of these transfer their reference to whoever decodes them, including the consumer decoding a callback argument.

What generators emit

Each binding ships a small private codec (a writer and a reader for the table above) plus one encode and one decode routine per record and rich enum, generated from the definition so field order is fixed at generation time. Optionals, lists, and maps are handled generically. Lists of bytes and of fixed-width numbers (every integer and float type, but not bool, whose bytes must be validated) encode and decode with a single copy where the platform is little-endian. The codec sample’s conformance lane checks every binding’s codec against the Rust runtime’s, in both directions.

Naming

Every name WeaveFFI emits derives from two things: the library’s identity (what the library is called) and the IDL names (what the API calls things). This page states both sets of rules.

Identity

The CLI resolves one identity per library from weaveffi.toml and, for a Rust producer, Cargo.toml (the full rules are in Project Configuration):

FieldMeaningRust producerIDL
namepackage name as published[package] name, else the crate’s package name[package] name, else the input file stem
prefixC symbol prefixthe crate’s library name[package] c_prefix, else snake(name)
librarynative library base namesame as prefix[package] library, else snake(name)
version, description, license, authors, homepage, repositorymetadata[package], falling back to Cargo.toml[package]

Two conversions turn an arbitrary name into identifiers:

  • snake: lowercase ASCII letters and digits, with every run of other characters collapsed to one _ and leading and trailing _ dropped; a leading digit gets a lib_ prefix. my-kv.store becomes my_kv_store, 3d-engine becomes lib_3d_engine.
  • PascalCase: each run of letters and digits starts a word whose first letter is uppercased, and existing capitals are kept; a leading digit gets an N prefix. my-kv.store becomes MyKvStore.

{PREFIX} is the prefix uppercased. A c_prefix you set is itself passed through snake. Setting c_prefix or library for a Rust producer is an error, because the macro derives the prefix from the crate. A library read on its own with --library (no crate or IDL) takes its prefix from its embedded metadata, its library from its file name, and its name from [package] name, else the prefix.

Per-target defaults

The names a [generators.<target>] key can override are listed in Project Configuration; the rest follow the identity.

TargetDefault names
Cheader {library}.h; include guard {PREFIX}_H; export macro {PREFIX}_API
C++header {library}.hpp, which includes {library}.h (shipped alongside); namespace {prefix}
SwiftSwiftPM package, product, and module PascalCase(name); C module C{PascalCase(name)}
Kotlinpackage {prefix}; one Kotlin object per IDL module; JNI library {library}_jni
Node.jsnpm package {name}; addon {library}_node.node; one namespace export per module
WebAssemblynpm package {name}; ES module; one namespace export per module
Pythondistribution {name}; import package {prefix}
.NETnamespace, assembly, and NuGet id PascalCase(name)
Dartpackage {prefix}
Gomodule path {name}; package {prefix}
Rubygem {name}; module PascalCase(name); require path {prefix}

For the kvstore sample (crate kvstore) that’s kvstore.h, Swift module Kvstore, import kvstore in Python, and require "kvstore" in Ruby.

Library loading. Every target finds the native library by its library name using the platform’s file naming (lib{library}.dylib, lib{library}.so, {library}.dll). The environment variable {PREFIX}_LIBRARY (for example KVSTORE_LIBRARY) names an explicit path that wins over bundled and system copies where a target resolves the library itself: at run time for Python, Ruby, .NET, Dart, Kotlin (the JVM loads it before the JNI shim), and WebAssembly on Node.js (the .wasm file); at install time for Node.js, whose fallback addon build links it; and at CMake configure time for C++. C, Swift, and Go link the library at build time and don’t read it.

Nothing a generator emits is named after WeaveFFI; the only mentions are comments (the generated-file header and notes on regenerating or packaging). Two libraries built with WeaveFFI therefore never collide in symbols, packages, types, or environment variables.

Global IDL names

Type names (records, enums, interfaces, callback interfaces, and error domains), free-function names, and error-code names are global: each is unique across the whole API, and validation rejects a second declaration anywhere in the module tree. Modules group declarations and namespace their C symbols, but a name never needs its module to be unambiguous, so a type reference in the IDL is always the bare name (Store, never kv.Store). Member names (parameters, fields, variants, and interface members) are scoped to their owner.

C identifiers

C identifiers are the prefix, the underscore-joined path of the declaring module, and the IDL name, verbatim: kvstore_kv_Store_open, kvstore_kv_KvError_KeyNotFound. IDL names are never re-cased in C, except that an iterator type’s function part is PascalCase (kvstore_kv_Store_KeysIterator for Store.keys). The full table is in the C ABI contract; validation rejects any API in which two declarations produce the same identifier.

Identifiers in generated code

IDL names are expected in snake_case for modules, functions, parameters, and fields, and in PascalCase for types, variants, and error codes. The validator only requires identifiers, so other styles are re-cased on a best-effort basis.

Functions, methods, parameters, and fields follow each language’s convention:

TargetStyleget_stats, created_at
C, C++, Python, Rubysnake_caseget_stats, created_at
Swift, Kotlin, Node.js, WebAssembly, DartcamelCasegetStats, createdAt
Go, .NETPascalCaseGetStats, CreatedAt

Types (records, enums, interfaces, callback interfaces, error domains) keep their IDL names in every target, except where a language idiom adds a prefix or suffix (.NET prefixes interfaces it generates for callback interfaces with I, for example). C-style enum variants follow the target’s enum idiom: Volatile in most targets, .volatile in Swift, volatile in Dart, EntryKindVolatile in Go, EntryKind::VOLATILE in Ruby.

Constructors. A constructor named new becomes the language’s constructor (init, __init__, initialize, a C++ or C# constructor); other constructors become static factories (Store.open). Go has no constructors, so they become package functions (NewEventBus, OpenStore).

Modules. How module members are grouped depends on the target’s notion of a namespace. C++ emits a nested namespace per IDL module, Swift a caseless enum per module (nested modules nest), Kotlin an object per module, .NET a static class per module named after its path (Kv, KvStats), and Node.js and WebAssembly a namespace object per module (nested modules nest). Targets that place members in one flat namespace (Python, Ruby, Dart, Go) use the bare function name, which global names keep collision-free; Go falls back to the module-prefixed spelling only for a free function whose name would clash with another generated name, such as a constructor’s (kvstore’s async open_store becomes KvOpenStore because Store.open is OpenStore). Each language page documents its layout.

Errors. Each target has a root error type named after the package and one type (or case) per error domain and code, named from the IDL with at most one idiomatic suffix (Error or Exception). Write code names without a suffix (KeyNotFound, not KeyNotFoundError) and let the generator add it. Code names are global because several targets flatten them into one namespace.

Reserved words. A name that’s a keyword in the target language gains a trailing _ (type becomes type_), a rule that’s stable under repetition. A user type whose name would shadow a standard type in the target (a Kotlin type named Result, say) is referenced in a way that avoids the clash; the language pages describe how.

Reserved member names. An interface member (a constructor that becomes a factory, a method, or a static) whose target spelling matches a member the object wrapper declares or inherits gains a trailing _ as well, so the wrapper’s own release, handle, and identity members keep working. The rule applies after case conversion and keyword escaping, to these names:

TargetReserved member names
C++handle, clone_handle, raw_type
Swiftptr, clonePtr, wvRead, wvWrite
Kotlinclose, handle, cloneHandle, fromHandle, fromHandleOrNull, invoke, equals, hashCode, toString, wait, notify, notifyAll
Node.js, WebAssemblymethods: close, constructor; statics and factories: name, length, prototype, caller
Pythonclose
.NETDispose, Handle, Adopt, CloneHandle, NativeHandle, Equals, GetHashCode, GetType, ToString, ReferenceEquals, MemberwiseClone, Finalize, and the interface’s own name
Dartdispose, hashCode, toString, runtimeType, noSuchMethod
Gomethods: Close (factories and statics are package functions spelled with the type name, so they can’t collide)
Rubymethods: close, handle, initialize, initialize_copy, class, clone, dup, freeze, hash, object_id, send; statics and factories: allocate, name

A method close on Store is therefore close_ in Kotlin, Node.js, WebAssembly, Python, and Ruby and Close_ in Go, and a method dispose is dispose_ in Dart and Dispose_ in .NET; every other target keeps the plain spelling. C needs no rule, because members are prefixed symbols (kvstore_kv_Store_close), but a member named clone or destroy lowers to the same symbol as the interface’s own _clone or _destroy, so validation rejects it (SymbolCollision).

Target names

A target is named after what its consumer identifies with: the language when the output is idiomatic to one language (swift, python, kotlin), and the runtime when several languages consume it equally or two targets share a language (dotnet serves C#, F#, and Visual Basic; node and wasm both emit JavaScript). A deployment platform is never a target name: kotlin covers Android and the desktop JVM, selected by a setting. The same token names the --target value, the [generators.<target>] table, the output directory, and the page under Generators.

Generators

WeaveFFI ships eleven generators, one per target. Every generator implements the whole IDL surface of schema 0.11.0 on ABI revision 4: records, C-style and rich enums, reference-counted objects in every position, callback interfaces, optionals, lists, maps, typed error domains, nested modules, async functions with cancellation, and lazy iterators. Each one names its output from the library’s identity, binds to the native library {library}, checks the ABI revision and every top-level module’s contract table at load time, and produces a tree under {out}/{target}/ that builds with the language’s normal tooling.

The targets that load the library at run time (Python, Ruby, .NET, Dart, Kotlin on the JVM, and WebAssembly on Node.js) accept a full path to it in the {PREFIX}_LIBRARY environment variable (KVSTORE_LIBRARY). The others link it when they’re built: C, Swift, and Go through the platform linker, C++ through its CMake target (which reads {PREFIX}_LIBRARY at configure time), and the Node.js addon when npm install compiles it. Each language page says where its package looks.

Capability matrix

TargetPackage / moduleObject releaseAsyncCancellationCallback interfaceiter<T>Minimum toolchain
Cheader {library}.h_destroylauncher plus callback{prefix}_cancel_token*ctx plus vtable struct_next/_destroyC11
C++header {library}.hpp, namespace {prefix}RAII destructor; copies clonestd::future<T>RAII CancelTokenabstract classinput rangeC++17, CMake 3.14
Swiftmodule PascalCase(name)deinitasync / async throwsTask cancellationprotocolSequenceSwift 5.9
Kotlinpackage {prefix}close(), cleaner backstopsuspend funcoroutine cancellationinterfaceNativeIterator<T> (an Iterator<T>)Kotlin 2.0; Android API 21 or a JVM
Node.jsnpm {name}close(), Symbol.dispose, finalizerPromise<T>AbortSignalobject implementing an interfaceIterableIterator<T>Node.js 18
WebAssemblynpm {name}close(), Symbol.dispose, finalizerPromise<T>AbortSignalobject implementing an interfaceIterableIterator<T>Node.js 18 or a current browser
Pythondist {name}, import {prefix}close(), with, finalizerawaitabletask cancellationabstract base classiteratorPython 3.9
.NETnamespace PascalCase(name)Dispose() (SafeHandle)Task<T>CancellationTokeninterfaceIEnumerable<T>.NET 8
Dartpackage {prefix}dispose(), NativeFinalizerFuture<T>cancel-token objectabstract classIterable<T>Dart 3.10
Gomodule {name}, package {prefix}Close(), finalizerblocking callcontext.Contextinterfaceiter.Seq / iter.Seq2Go 1.23
Rubygem {name}, module PascalCase(name)close, FFI::AutoPointerblocking callcancel: keywordduck-typed objectEnumeratorRuby 2.7 with the ffi gem 1.15

The language pages are authoritative for exact type names, signatures, and toolchain versions. Notes that apply across targets:

  • Objects. Every wrapper owns one strong reference and keeps itself alive for the duration of each native call, so it can’t be released mid-call. Disposing twice is a no-op; using a disposed wrapper raises the target’s usage error.
  • Callbacks. Callback methods may be invoked from any producer thread. An implementation that throws reaches the original caller as runtime code -4; an object passed to a callback is adopted by the implementation.
  • Async. A cancelled call raises the language’s cancellation error. Go and Ruby block the calling goroutine or thread while the native work runs elsewhere. See Async and Cancellation.
  • 64-bit integers. Node.js and WebAssembly use bigint; Kotlin uses ULong for u64; Dart carries u64 in its signed 64-bit int (the bits are preserved, so values above 2^63 - 1 read as negative). NaN, infinities, and -0 cross unchanged everywhere.

Fixture compile checks

Snapshot tests pin the generated text; the fixture check proves it’s valid code. scripts/check-fixtures.sh <target> generates every fixture in crates/weaveffi-cli/tests/fixtures/ (kitchen_sink, edge_cases, nested_modules, docs_everywhere, shapes) together with the C header, then runs scripts/fixtures/<target>.sh, which compiles or type-checks the output with the target’s own toolchain: cc and c++ with -Werror for C, clang++ -fsyntax-only for C++, swiftc -typecheck, kotlinc, tsc --strict and node --check, py_compile plus a type checker on the stubs, dotnet build -warnaserror, dart analyze --fatal-infos, go vet and go build, and ruby -wc. CI runs one job per target. A missing tool is a printed skip locally and a failure when CI=true.

Conformance lanes

conformance/run.sh is the behavioral gate. It builds the three sample producers with leak counting enabled, runs weaveffi generate on each sample crate, reading the API from that same build (--library), and then runs conformance/<lang>/run.sh for every language. Each language declares exactly three lanes, one per sample (python-calculator, python-codec, python-kvstore, and so on); a lane compiles and runs that sample’s consumer under a timeout. The consumer binds only through the generated package, asserts concrete results, and checks that every {prefix}_debug_live counter is zero at exit after forcing its garbage collector:

  • calculator: the getting-started surface (a call, a typed error, a string in and out).
  • codec: the shared-vector loop. Fetch each of the producer’s test vectors, decode it, pass it back to check_vector, and echo each primitive through the direct ABI families; then check a few vectors built from literals.
  • kvstore: every feature, including callbacks implemented in the consumer’s language, cancellation, and iterators.

The wasm lanes reuse the Node.js consumers, since both targets generate the same JavaScript API. The C lanes also implement a producer by hand (c-producer-exports) and check its contract table against the header.

bash conformance/run.sh                        # every lane
ONLY=python bash conformance/run.sh            # one language
ONLY=c,go-kvstore SKIP_GEN=1 bash conformance/run.sh

SKIP excludes lanes, SKIP_GEN=1 reuses previously generated bindings, and LANE_TIMEOUT sets the per-lane limit in seconds (default 300). A missing toolchain skips its language with a note locally and fails it when CI=true. CI runs every language on Linux and macOS.

C

The C target emits the canonical header for a library’s C ABI (revision 4). Every other target binds to the symbols it declares, and a C or C++ producer implementing an IDL by hand implements exactly those symbols. Alongside it, the target emits a helper header with C structs and codecs for the API’s value buffers, so a C consumer never encodes the wire format by hand.

What gets generated

For the kvstore sample (library kvstore, prefix kvstore):

c/
├── kvstore.h          # the ABI: runtime, types, and function prototypes
└── kvstore_buffer.h   # C structs and static inline codecs for value buffers

The ABI header is {library}.h, with include guard {PREFIX}_H and the visibility macro {PREFIX}_API. The helper is {library}_buffer.h; it’s emitted only when some type crosses the ABI as a value buffer, and it can be turned off:

[generators.c]
buffer_helpers = false

weaveffi package writes kvstore-{version}-c.tar.gz: the same headers under include/, the library of every platform built under lib/<platform>/ (with the import library on Windows), a README, and a CMakeLists.txt (CMake 3.15 or later) that exposes the host’s library as the imported target kvstore::kvstore (see Packaging).

Build and load

Both headers are plain C11 that also compiles as C++. Point the compiler at the c/ directory and link the producer’s library:

cc -std=c11 -I bindings/c app.c -L target/release -lkvstore -o app

C links the library at build time, so the platform loader finds it at run time (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, PATH, or an rpath). The {PREFIX}_LIBRARY override (KVSTORE_LIBRARY) applies to targets that load the library dynamically; a C program that wants it calls dlopen itself.

Every C identifier starts with the prefix, so any number of WeaveFFI libraries link into one program. Before its first call, a consumer should check the ABI revision and the contract it was generated against:

if (kvstore_abi_version() != KVSTORE_ABI_VERSION) {
    fprintf(stderr, "kvstore: library does not match kvstore.h's ABI revision\n");
    exit(1);
}
if (kvstore_kv_contract_check() != 0) {
    fprintf(stderr, "kvstore: library does not match kvstore.h (module kv)\n");
    exit(1);
}

Each top-level module has a contract table: the library exports {p}_{m}_contract(size_t* out_len), and the header defines the entries the bindings were generated with as {P}_{M}_CONTRACT (each commented with its declaration’s dotted path), their count as {P}_{M}_CONTRACT_LEN, and a static inline uint64_t {p}_{m}_contract_check(void). The checker returns 0 when every expected entry is in the library’s table with an equal hash, and otherwise the id of the first one that’s missing or changed, which you can look up in the macro to name the declaration. A library with extra entries passes, so adding a declaration doesn’t break existing consumers. See load-time checks.

Type mapping

IDL typeParameterReturnIn {library}_buffer.h
i8 to u64, f32, f64int8_t to uint64_t, float, doublesamesame
boolboolboolbool
C-style enum{p}_{path}_{Enum}samesame
stringconst uint8_t* s_ptr, size_t s_lenconst uint8_t* + size_t* out_len{p}_str
bytesconst uint8_t* b_ptr, size_t b_lenconst uint8_t* + size_t* out_len{p}_bytes
record, rich enumvalue buffer (ptr, len)value buffer + out_lenstruct {p}_{path}_{Name}
[T], {K:V}value buffervalue bufferstructs {p}_list_{T}, {p}_map_{K}_{V}
T?value buffervalue bufferT* (NULL is absent)
interface Iconst {p}_{path}_I* (borrowed){p}_{path}_I* (owned){p}_{path}_I*
I?const {p}_{path}_I* (NULL is absent){p}_{path}_I*{p}_{path}_I*
callback interface, Cb?void* x_ctx, const {p}_{path}_{Cb}_vtable* x_vtable (NULL vtable is absent for Cb?)not allowednot allowed
iter<T>not allowediterator handlenot applicable

Strings are UTF-8 without a NUL terminator and may contain NUL bytes. A parameter’s pointer may be NULL when its length is 0; a NULL pointer with a nonzero length, or invalid UTF-8, fails the call with code -3. Every returned string, byte string, and value buffer is owned by the consumer and released with {p}_free_bytes(ptr, len); NULL with length 0 is the empty value.

Value buffers and {library}_buffer.h

Records, rich enums, lists, maps, and optionals cross as one serialized value buffer. The helper header renders each record as a struct (from the kitchen_sink fixture):

struct kitchen_sink_kitchen_Item {
    /** Stable identifier */
    int64_t id;
    /** Display name */
    kitchen_sink_str name;
    /** Initial count */
    int32_t count;
    /** Whether the item is enabled */
    bool enabled;
    /** Scaling ratio */
    double ratio;
    uint32_t nick;
    kitchen_sink_bytes payload;
    kitchen_sink_list_string tags;
    kitchen_sink_map_string_string attrs;
    int64_t* parent;
    /** Cross-module struct reference */
    kitchen_sink_shared_Token token;
    kitchen_sink_kitchen_Priority priority;
    /** An optional object inside a record */
    kitchen_sink_kitchen_Gadget* gadget;
};

A rich enum is a tag (its {p}_{path}_{Enum}_Tag constant) plus a union as with one struct per variant that has fields. Each of these types, and each optional shape, gets four static inline functions:

static inline void kitchen_sink_kitchen_Item_write(kitchen_sink_writer* w, const kitchen_sink_kitchen_Item* v);
static inline void kitchen_sink_kitchen_Item_read(kitchen_sink_reader* r, kitchen_sink_kitchen_Item* out);
static inline bool kitchen_sink_kitchen_Item_decode(const uint8_t* ptr, size_t len, kitchen_sink_kitchen_Item* out);
static inline void kitchen_sink_kitchen_Item_free(kitchen_sink_kitchen_Item* v);

To pass a value, write it into a zeroed {p}_writer and pass ptr and len. To read a returned buffer, decode it, release the buffer, and free the decoded value when you’re done:

size_t len = 0;
const uint8_t* buf = kitchen_sink_kitchen_maybe_item(7, &len, &err);
kitchen_sink_kitchen_Item* item = NULL;
if (kitchen_sink_opt_kitchen_Item_decode(buf, len, &item)) {
    /* item is NULL when the producer returned none */
}
kitchen_sink_free_bytes((uint8_t*)buf, len);
kitchen_sink_opt_kitchen_Item_free(&item);

An optional’s _write takes the nullable pointer itself. A value you build for writing may borrow your own memory ({p}_str_of("text") borrows a C string); only values produced by _read or _decode are passed to _free. _decode rejects malformed or trailing bytes, frees what it built, and returns false.

Objects and lifetime

An interface is an opaque, reference-counted pointer. Parameters borrow it for the call; a returned pointer carries one strong reference that the consumer releases with {p}_{path}_{I}_destroy. _clone returns a new reference to the same object, and both functions treat NULL as a no-op. Inside a value buffer an object is a token carrying one reference: the helper’s _write stores a fresh _clone, _read adopts the token, and _free destroys it.

Errors

Every synchronous function takes a trailing {p}_error* out_err. On failure code is nonzero and message is NUL-terminated UTF-8; release both with {p}_error_clear. Positive codes are the module’s error domain (kvstore_kv_KvError_KeyNotFound); negative codes are runtime traps: -1 generic (or an async call that couldn’t start), -2 panic, -3 marshalling failure, -4 a callback implementation failed, and -5 cancelled. An error code with fields carries them in payload_ptr/payload_len, which the helper decodes with {code constant}_payload_decode.

Async and cancellation

An async function’s symbol is its launcher. It returns at once and later calls the completion exactly once, from any thread:

typedef void (*kitchen_sink_kitchen_do_cancellable_callback)(void* context, kitchen_sink_error* err, const uint8_t* result_ptr, size_t result_len);
KITCHEN_SINK_API void kitchen_sink_kitchen_do_cancellable(const uint8_t* input_ptr, size_t input_len, kitchen_sink_cancel_token* cancel_token, kitchen_sink_kitchen_do_cancellable_callback callback, void* context);

err is NULL on success; otherwise it’s heap-allocated and the consumer releases it with {p}_error_free. A string, bytes, or buffer result is owned by the consumer (release it with {p}_free_bytes), and an object result carries one reference. Inputs are copied before the launcher returns.

A cancellable function takes a {p}_cancel_token* (or NULL). Create one with {p}_cancel_token_create, cancel it with {p}_cancel_token_cancel, and release your reference with {p}_cancel_token_destroy whenever you like: the call holds its own reference. Cancelling completes a pending call with code -5, even if it was cancelled before launch.

Callback interfaces

A callback interface is a vtable the consumer fills in, passed with a context pointer. The vtable starts with a header (size, flags, and the free hook), followed by one entry per method in declaration order:

typedef struct kitchen_sink_kitchen_ReadyListener_vtable {
    uint32_t size;
    uint32_t flags;
    void (*free)(void* ctx);
    /** Fires when an item is ready */
    void (*on_ready)(void* ctx, int32_t code, const uint8_t* msg_ptr, size_t msg_len, kitchen_sink_error* out_err);
    /** Receives the item itself and says whether to keep listening */
    bool (*on_item)(void* ctx, const uint8_t* item_ptr, size_t item_len, kitchen_sink_kitchen_Gadget* gadget, kitchen_sink_error* out_err);
    /** A display label, failing with a kitchen error */
    void (*label)(void* ctx, uint8_t** out_ptr, size_t* out_len, kitchen_sink_error* out_err);
    /** The most recent item the listener kept, if any */
    void (*latest)(void* ctx, uint8_t** out_ptr, size_t* out_len, kitchen_sink_error* out_err);
    /** The listener's favorite gadget */
    kitchen_sink_kitchen_Gadget* (*favorite)(void* ctx, kitchen_sink_error* out_err);
} kitchen_sink_kitchen_ReadyListener_vtable;

Define one static vtable per interface, with size set to sizeof the vtable and flags to 0. The producer rejects a vtable smaller than its own with -3 (calling free(ctx) first), so a consumer built from an older header can’t make it call a missing entry:

static const kitchen_sink_kitchen_ReadyListener_vtable LISTENER = {
    sizeof(kitchen_sink_kitchen_ReadyListener_vtable),
    0,
    listener_free,
    listener_on_ready,
    listener_on_item,
    listener_label,
    listener_latest,
    listener_favorite,
};

The producer may call any entry from any thread, so the context must be thread-safe, and it calls free(ctx) exactly once, from any thread, when it drops its last reference. For an optional parameter (ReadyListener?), pass a NULL vtable for none.

String and buffer arguments are borrowed for the call; an object argument carries one reference the method adopts. Returns go the other way:

  • A direct value is the C return.
  • An object (favorite) is the C return, one strong reference the producer adopts: return a fresh _clone, never NULL for a non-optional type.
  • A string, bytes, or buffer (label, latest) is written to the trailing out_ptr and out_len slots as a run allocated with {p}_alloc, which the producer adopts and frees. Leave them NULL and 0 for an empty value.

A method reports failure with {p}_error_set(out_err, code, "message"), which copies the message. A method declared throws (label) may use a code of its module’s error domain and attach the code’s fields, encoded as a value buffer, with {p}_error_set_payload(out_err, ptr, len); any other failure reaches the producer as -4:

static void listener_label(void* ctx, uint8_t** out_ptr, size_t* out_len,
                           kitchen_sink_error* out_err) {
    const char* label = ((listener*)ctx)->label;
    if (label == NULL) {
        kitchen_sink_error_set(out_err, kitchen_sink_kitchen_KitchenErrors_NotFound, "no label");
        return;
    }
    size_t n = strlen(label);
    uint8_t* run = kitchen_sink_alloc(n);
    if (n > 0) memcpy(run, label, n);
    *out_ptr = run;
    *out_len = n;
}

The producer adopts whatever the out slots hold even when the method fails, so a method that fails after allocating doesn’t leak.

Iterators

A function returning iter<T> returns an iterator handle. _next writes one item and returns 1, or returns 0 when it’s done; _destroy releases the handle, even mid-stream:

KITCHEN_SINK_API int32_t kitchen_sink_kitchen_StreamItemsIterator_next(kitchen_sink_kitchen_StreamItemsIterator* iter, const uint8_t** out_item, size_t* out_len, kitchen_sink_error* out_err);
KITCHEN_SINK_API void kitchen_sink_kitchen_StreamItemsIterator_destroy(kitchen_sink_kitchen_StreamItemsIterator* iter);

String, bytes, and buffer items are owned (release each with {p}_free_bytes); object items carry one reference.

Implementing an IDL in C

A hand-written producer defines every function the header declares, including the runtime and one contract function per top-level module, which returns the header’s table. For the calculator sample that’s:

uint32_t calculator_abi_version(void) { return CALCULATOR_ABI_VERSION; }

const calculator_contract_entry* calculator_calculator_contract(size_t* out_len) {
    static const calculator_contract_entry table[] = CALCULATOR_CALCULATOR_CONTRACT;
    *out_len = CALCULATOR_CALCULATOR_CONTRACT_LEN;
    return table;
}

void calculator_error_set(calculator_error* err, int32_t code, const char* message);
void calculator_error_set_payload(calculator_error* err, const uint8_t* ptr, size_t len);
void calculator_error_clear(calculator_error* err);
void calculator_error_free(calculator_error* err);
uint8_t* calculator_alloc(size_t len);
void calculator_free_bytes(uint8_t* ptr, size_t len);
calculator_cancel_token* calculator_cancel_token_create(void);
void calculator_cancel_token_cancel(calculator_cancel_token* token);
bool calculator_cancel_token_is_cancelled(const calculator_cancel_token* token);
void calculator_cancel_token_destroy(calculator_cancel_token* token);
uint64_t calculator_debug_live(int32_t kind);  /* may return 0 for every kind */

{p}_alloc returns a zero-filled run (NULL for 0), and {p}_free_bytes must release both the runs the producer returns and the runs {p}_alloc hands out, so both use one allocator. {p}_debug_live(-1) returns 1 when the producer counts live resources and 0 when it doesn’t. Every prototype carries {PREFIX}_API, so the definitions stay exported under -fvisibility=hidden; on Windows, define {PREFIX}_BUILD while building the producer. conformance/c/producer.c is a complete example.

Known limitations

  • The C target emits declarations and helpers only; there’s no runtime loader, so {PREFIX}_LIBRARY isn’t consulted.
  • The helper’s decoders don’t validate UTF-8 in decoded strings; the producer has already done so.

C++

The C++ target emits a header-only C++17 library over the C ABI (revision 4). Records become value structs, rich enums become std::variant-backed sum types, interfaces become copyable RAII classes, callback interfaces become abstract classes you subclass, error domains become exception hierarchies, async functions return std::future, and iter<T> returns a lazy range.

What gets generated

cpp/
├── {library}.h      the C header (identical to the c target's)
├── {library}.hpp    the wrapper; includes {library}.h
├── CMakeLists.txt   INTERFACE target {library}::cpp
└── README.md

For the kvstore sample that’s kvstore.h, kvstore.hpp, CMakeLists.txt, and README.md. Everything in the wrapper lives in namespace {prefix} (kvstore), and each IDL module’s free functions live in a nested namespace named after the module path (kvstore::kv::stats::summarize). Types sit at the namespace root, since type names are unique across an API.

[generators.cpp] keyDefaultMeaning
namethe C prefixNamespace holding every declaration
header_name{library}.hppWrapper header file name
standard17C++ standard the CMake target requires

Add the generated directory to a CMake build (3.14 or later) and link the INTERFACE target:

add_subdirectory(path/to/cpp)
target_link_libraries(app PRIVATE kvstore::cpp)

The target adds the directory to the include path, requires C++17, and links the producer library. Set the KVSTORE_LIBRARY CMake cache variable or environment variable (in general {PREFIX}_LIBRARY) to a full path to link that file; otherwise the target links kvstore by name, which resolves to a CMake target of that name or libkvstore on the linker search path. Without CMake, add cpp/ to the include path and link -lkvstore.

Both headers can be included in one translation unit, in either order.

weaveffi package writes kvstore-{version}-cpp.tar.gz: both headers under include/, each desktop platform’s library under lib/<platform>/, a README, and a CMakeLists.txt that links the host’s library into kvstore::cpp, so the packaged build ignores KVSTORE_LIBRARY (see Packaging).

The library check

check_library() verifies that the linked library implements C ABI revision 4 and every declaration the header was generated with, and throws LoadError naming the first mismatch. The header embeds each top-level module’s contract table as (id, hash, path) entries and compares them with the table the library returns from {prefix}_{module}_contract:

inline void check_library() {
    static const std::string failure = []() -> std::string {
        uint32_t abi = kvstore_abi_version();
        if (abi != KVSTORE_ABI_VERSION) {
            return "kvstore: C ABI revision mismatch (header " + std::to_string(KVSTORE_ABI_VERSION) +
                   ", library " + std::to_string(abi) + ")";
        }
        if (std::string why = detail::contract_mismatch(kvstore_kv_contract, detail::kv_contract); !why.empty()) {
            return "kvstore: " + why;
        }
        ...

A declaration the library lacks fails with kvstore: kv.Store.put is missing from the library, and one whose signature differs with kvstore: kv.Store.put changed since these bindings were generated. Declarations the library has and the header doesn’t are fine, so a library that adds functions keeps working with older bindings.

A C++ binary links its library rather than loading it, and an exception thrown during static initialization can’t be caught, so the check runs on first use instead: every free function, constructor, and static member calls check_library() before its first native call. The result is cached in a function-local static, so the cost after the first call is one guard-variable read. Call it at startup to fail early.

Type mapping

IDL typeC++ typeParameter
i8 … u64, f32, f64, boolint8_t … uint64_t, float, double, boolby value
stringstd::stringstd::string_view
bytesstd::vector<uint8_t>const std::vector<uint8_t>&
C-style enumenum class E : int32_tby value
recordstruct Rconst R&
rich enumstruct E with std::variant<...> valueconst E&
interfaceRAII classconst I& (borrowed)
I?std::optional<I>const std::optional<I>&
callback interface, Cb?abstract classstd::shared_ptr<Cb> (empty for none when optional)
T?, [T], {K: V}std::optional<T>, std::vector<T>, std::unordered_map<K, V>by const reference
iter<T>lazy range class (returns only)n/a

Strings cross the ABI as a UTF-8 pointer and a length, so interior NULs survive in both directions. A returned string is copied into a std::string and the producer’s run is released with {prefix}_free_bytes. Records, rich enums, optionals, lists, and maps cross as one value buffer. The header has one writer and one reader per record, rich enum, and distinct composite type (detail::write_list_Entry, detail::read_map_string_Store), and every call site delegates to them.

Objects and lifetime

An interface class holds one strong reference to a reference-counted producer object:

class Store {
    kvstore_kv_Store* raw_;

public:
    /** The C type this class wraps. */
    using raw_type = kvstore_kv_Store;

    /** Adopts one strong reference to a producer object: `Store(adopt, raw)`. */
    explicit Store(adopt_t, kvstore_kv_Store* h) noexcept : raw_(h) {}
  • The destructor releases the reference with _destroy; copying takes a new one with _clone, so copies share the object; moving transfers it.
  • The constructor named new becomes a C++ constructor (explicit when it takes arguments); any other constructor becomes a static factory (Store::open(path)). Nothing converts implicitly into an object, and adopting a raw pointer requires the adopt tag.
  • A parameter borrows the caller’s reference for the call. A returned object, an async result, an iterator element, and an object argument handed to a callback are adopted.
  • An object inside a record, list, map, or optional crosses as a token minted with _clone, so a record can be copied and destroyed freely.
  • handle() borrows the C pointer (compare two wrappers’ handles to test identity); clone_handle() returns a new reference the caller owns. An interface member named handle, clone_handle, or raw_type gains a trailing _ (reserved member names).

The C++ object model makes lifetimes the caller’s responsibility: a wrapper must outlive the calls made through it, and destroying a wrapper on one thread while another thread calls through it is a data race, as for any C++ object. A moved-from wrapper holds null.

Errors

A call that declares errors (throws) throws an exception derived from {namespace}::Error, which derives from std::runtime_error and carries code():

  • Each module that declares an error domain gets a domain class (KvError : Error) and one subclass per code (KeyNotFoundError). Both names end in Error, which is appended when the IDL name lacks it (the KitchenErrors domain becomes KitchenErrorsError). A code with payload fields exposes them as public members (e.key).
  • A positive code throws its typed subclass. A negative runtime code throws the root Error with that code: -1 generic, -2 producer panic, -3 marshalling failure, and -4 a callback implementation that failed.
  • -5 throws Cancelled, which also derives from Error.

A call that declares no errors can still fail, but only through a producer bug, so it follows the trap policy: it throws InternalError, a std::runtime_error that isn’t an Error, whose what() names the runtime code and the producer’s message and whose code() and message() return them. A value buffer the wrapper can’t decode is also an InternalError with code -3.

LoadError (also a std::runtime_error) reports a library that doesn’t match the headers. Wrappers are never noexcept.

try {
    store.get("missing");
} catch (const kvstore::KeyNotFoundError& e) {
    std::cerr << e.what() << " (" << e.key << ")\n";
} catch (const kvstore::KvError& e) {
    // Any other kv code.
}

Async and cancellation

An async function returns std::future<T>, settled from a producer thread. A cancellable function also takes a trailing const CancelToken&:

std::future<uint32_t> compact(uint32_t pause_ms, const CancelToken& cancel_token = CancelToken::none()) const;

CancelToken is a move-only RAII wrapper over the native token. Pass one token to any number of calls and call cancel() from any thread; every call still in flight completes with Cancelled. Each call takes its own native reference, so the token can be destroyed at any time without affecting the calls. Omitting the argument passes CancelToken::none(), which never cancels.

kvstore::CancelToken token;
std::future<uint32_t> pending = store.compact(60000, token);
token.cancel();
try {
    pending.get();
} catch (const kvstore::Cancelled&) {
    // The runtime dropped the work.
}

std::future has no cancellation of its own, so destroying a future doesn’t cancel the call; cancel its token instead.

Callback interfaces

A callback interface is an abstract class with one pure virtual method per IDL method:

class Policy {
public:
    virtual ~Policy() = default;

    virtual Entry admit(const Entry& entry) = 0;
    virtual Store route(std::string_view key, Store home) = 0;
};

Pass an implementation as std::shared_ptr. The wrapper boxes the pointer as the vtable context and hands the producer a static vtable whose header records its size and whose free deletes the box when the producer releases the implementation, which runs your destructor if nothing else holds it. For an optional callback parameter (Loader?), an empty std::shared_ptr (or nullptr) passes none; a required one throws std::invalid_argument when empty. The producer may call methods, and release the implementation, from any thread, so implementations must be thread-safe.

Arguments follow the parameter rules seen from your side: a string is a std::string_view valid for the call, bytes and buffered values arrive by const reference to a decoded copy, and an object arrives by value as a wrapper you own.

A method can return any family. A direct value is returned as is; an object (Store, std::optional<Store>) hands the producer a fresh reference; a string, bytes, or buffered value is copied into a run allocated with {prefix}_alloc, which the producer adopts. A required object return that’s null (a moved-from wrapper) is refused by the producer with -3, as is a record the producer can’t decode.

A method declared throws may throw its module’s domain exception: the code, the message (what()), and the code’s fields reach the producer, which decodes them into its own error type.

Entry admit(const Entry& entry) override {
    if (entry.key == "secret") throw kvstore::RejectedError("secrets are not stored", entry.key, "no secrets");
    return entry;
}

Any other exception, or a domain exception from a method that doesn’t declare errors, reaches the producer as a callback failure (-4) with the exception’s what(); nothing unwinds through the C frame. Whether the original call then fails is the producer’s decision: a Rust producer that propagates the failure with ? makes a throwing call throw the same RejectedError (or the root Error with code -4).

Iterators

An iter<T> function returns a move-only range class:

for (const std::string& key : store.keys(std::nullopt)) {
    // One producer call per step.
}

Each step pulls one element. The range releases the producer iterator when it’s exhausted or destroyed, so abandoning a loop early leaks nothing. next() returns std::optional<T> for manual iteration.

Known limitations

  • The ranges are single-pass input ranges; copy them into a std::vector for random access.
  • A callback interface must be a std::shared_ptr to a subclass; there’s no lambda overload.
  • Exceptions not derived from std::exception are reported to the producer with a generic message.
  • The library check runs on first use, not at load time (see above).

Swift

The Swift target emits a standalone SwiftPM package that wraps the C ABI in idiomatic Swift: throws for declared errors, plain Hashable structs and enums for values, ARC-managed final class wrappers for objects, class-bound protocols for callback interfaces, async functions with task cancellation for async callables, and lazy Sequences for iterators.

What gets generated

For a package named kvstore:

swift/
  Package.swift
  Sources/
    CKvstore/
      kvstore.h          # copy of the C header
      module.modulemap   # system library: links `kvstore`
    Kvstore/
      Kvstore.swift      # runtime support and the wrapper API

The package, product, and Swift module are the package name in PascalCase (key-store becomes KeyStore); set [generators.swift] name to override it. The C module is always the Swift module’s name with a C prefix. The C symbol prefix and the native library name come from the package identity.

Types (records, enums, error domains, interfaces, callback protocols, and iterator classes) sit at file scope, since type names are unique across an API. Each IDL module’s free functions become static funcs of a caseless namespace enum named after the module in PascalCase, a submodule’s enum nests inside its parent’s, and the module’s doc comment documents its enum:

/// An embedded key-value store with listeners, policies, read-through
/// loaders, iteration, and async compaction.
public enum Kv {
    public static func openStore(path: String) async throws -> Store { ... }

    /// Aggregate statistics, namespaced under `kv.stats`: ...
    public enum Stats {
        public static func summarize(store: Store, prefix: String?) throws -> Kvstore.Stats { ... }
    }
}

Parameters, methods, record fields, and enum cases are lowerCamelCase (expiresAt, .keyNotFound), and every argument is labeled (Kv.Stats.summarize(store: store, prefix: nil)).

[generators.swift] keyDefaultMeaning
namePascalCase(name)Package, product, and module name
min_macos, min_ios"11.0", "13.0"The manifest’s platforms:
xcframework_urla placeholderWhere the packaged binary target downloads C{Module}.xcframework.zip; {version} and {file} are substituted

The package builds as-is with SwiftPM (its manifest declares swift-tools-version:5.9). Depend on it by path or from a repository, and tell the linker where libkvstore lives:

dependencies: [.package(path: "bindings/swift")],
targets: [
    .executableTarget(name: "App", dependencies: [.product(name: "Kvstore", package: "swift")]),
]
swift build -Xlinker -L/path/to/lib

Swift binds the library at link time, so at run time the dynamic loader finds it the usual way (its install name, an rpath, or DYLD_LIBRARY_PATH and LD_LIBRARY_PATH). The {PREFIX}_LIBRARY variable other targets honor doesn’t apply.

When a CKvstore.xcframework sits next to Package.swift, the manifest uses it as a binary target instead of the system library, so apps link without flags.

weaveffi package (on macOS) fuses the static libraries of every Apple platform built (macOS, iOS, the iOS simulator) into CKvstore.xcframework.zip, writes its SHA-256 checksum, and writes the package swift/Kvstore/, whose manifest declares CKvstore as .binaryTarget(url:checksum:) at xcframework_url. Upload the archive there and publish the package; apps on any Apple platform then link with no flags (see Packaging).

Load-time checks

Before the first call (the first constructor, static, or free function), the bindings check, once per process, that the library implements C ABI revision 4 and that every declaration they were generated with is in its top-level module’s contract table with the same signature. The expected entries are embedded in the wrapper:

let wvContract: Void = {
    wvCheckAbiVersion()
    wvCheckContract(kvstore_kv_contract, [
        (0x0969575bfbb012d7, 0xebd38766e3532c4f, "kv.Store.fork"),
        // ...one entry per declaration in `kv` and its submodules
    ])
    wvCheckContract(kvstore_report_contract, [
        // ...
    ])
}()

Declarations the library adds are fine. A missing or changed one stops the process with a message naming it, since a stale binding would otherwise corrupt memory:

Kvstore: kv.Store.put is missing from the library 'kvstore'; regenerate the bindings or rebuild the library
Kvstore: kv.Store.put changed since these bindings were generated; regenerate the bindings or rebuild the library 'kvstore'

Type mapping

IDL typeSwift typeAt the ABI
i8 to i64, u8 to u64Int8 to Int64, UInt8 to UInt64Direct
f32, f64Float, DoubleDirect
boolBoolC bool
stringStringUTF-8 pointer and length
bytesDataPointer and length
C-style enumenum E: Int32, Sendableint32_t
Recordstruct, Sendable (and Hashable when every field is)Value buffer
Rich enumenum with associated valuesValue buffer
T?, [T], {K: V}T?, [T], [K: V]Value buffer
Interfacefinal class, @unchecked SendableObject pointer
Interface?optional wrapperNullable pointer
Callback interfaceany P, where protocol P: AnyObject, SendableContext and vtable
Callback?(any P)?Context and nullable vtable
iter<T>generated Sequence classIterator handle

Strings cross as pointer and length in both directions, so interior NULs survive. Value buffers decode in place from the library’s memory before it’s released. Floats round-trip bit for bit, including NaN and -0.0.

Every type that crosses inside a value buffer conforms to the internal WvCodable protocol: the primitives in the runtime, Optional, Array, and Dictionary generically, and each record, enum, and interface where it’s declared. A composite type such as [String: [Entry?]] therefore has one encoder and one decoder, the generic ones, rather than a loop at each use:

extension Entry: WvCodable {
    static func wvRead(_ r: inout WvReader) -> Entry {
        Entry(key: r.read(), value: r.read(), kind: r.read(), version: r.read(), expiresAt: r.read(), tags: r.read(), metadata: r.read())
    }

    func wvWrite(_ w: inout WvWriter) {
        w.write(self.key)
        w.write(self.value)
        // ...
    }
}

Objects and lifetime

An interface becomes a final class holding one strong reference. deinit releases it, a constructor named new becomes init, and other constructors and statics become static funcs:

public final class Gadget: @unchecked Sendable {
    let ptr: OpaquePointer

    init(ptr: OpaquePointer) {
        self.ptr = ptr
    }

    deinit {
        kitchen_sink_kitchen_Gadget_destroy(ptr)
    }

    /// Returns a new strong reference to the same object, for a position that
    /// takes ownership of it.
    func clonePtr() -> OpaquePointer {
        wvNonNull(kitchen_sink_kitchen_Gadget_clone(ptr))
    }

    /// Create a gadget with the given id
    public init(id: Int64) {
        wvLoad()
        var err = WvError()
        let rv = kitchen_sink_kitchen_Gadget_new(id, &err)
        wvTrap(&err)
        self.ptr = wvNonNull(rv)
    }

    /// Render the gadget as a human-readable string
    public func describe() -> String {
        var err = WvError()
        var outLen = 0
        let rv = kitchen_sink_kitchen_Gadget_describe(ptr, &outLen, &err)
        wvTrap(&err)
        return wvTakeString(rv, outLen)
    }
}

Swift keeps every argument and the receiver alive for the whole call, so a wrapper can’t be freed mid-call. Writing an object into a value buffer or returning it from a callback method clones its reference, and reading one adopts the reference into a new wrapper, so two wrappers can share one native object. There’s no close(): release happens when the last Swift reference goes away. An interface member spelled like one the class declares (ptr, clonePtr, wvRead, wvWrite) gains a trailing _ (reserved member names).

Errors

A module’s error domain becomes an enum conforming to Error, LocalizedError, and Sendable, named after the domain with an Error suffix unless it already has one (KvError stays KvError; KitchenErrors becomes KitchenErrorsError). It has one case per code, carrying the message (the code’s documented message when the library sends none) and any payload fields. errorCode returns the numeric code:

public enum KvError: Error, LocalizedError, Sendable {
    case keyNotFound(message: String, key: String)
    case expired(message: String, key: String, expiredAt: Int64)
    case invalidPath(message: String)
    // ...
}

A function declared throws raises that enum for domain codes and {SwiftModule}RuntimeError (KvstoreRuntimeError, with errorCode and message) for unknown codes and runtime failures: -1 generic, -2 panic, -3 marshalling, and -4 when a callback implementation failed. Swift has no unchecked errors, so a function that doesn’t declare throws follows the trap policy: it stops the process with fatalError when the library reports a failure, naming the function, the code, and the message:

Kvstore.count() failed with code -2: <the producer's panic message>

Async and cancellation

An async function becomes an async function over a checked continuation; the library’s completion callback resumes it exactly once, from any thread. It’s marked throws only when the IDL function declares errors or is cancellable; otherwise a failure follows the trap policy (Kv.Stats.summarizeAll(stores:) is async -> Kvstore.Stats). A cancellable function runs inside withTaskCancellationHandler: it creates a native cancel token, passes it to the launch, and cancels it when the calling task is cancelled. A cancelled call (code -5) throws CancellationError, even when the task was cancelled before the call started:

public static func doCancellable(input: String) async throws -> String {
    wvLoad()
    let token = WvCancelToken()
    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
            let context = Unmanaged.passRetained(WvContinuation(continuation)).toOpaque()
            wvWithUTF8(input) { input_ptr, input_len in
                kitchen_sink_kitchen_do_cancellable(input_ptr, input_len, token.raw, { context, err, resultPtr, resultLen in
                    let cont = Unmanaged<WvContinuation<String, Error>>.fromOpaque(context!).takeRetainedValue().value
                    if let err = err {
                        cont.resume(throwing: wvTakeError(err, wvCancelledOrTrap))
                        return
                    }
                    cont.resume(returning: wvTakeString(resultPtr, resultLen))
                }, context)
            }
        }
    } onCancel: {
        token.cancel()
    }
}

The token is released when the wrapper returns; the library holds its own reference for the call’s duration.

Callback interfaces

A callback interface becomes a class-bound protocol. Every requirement is throws, so any implementation can fail the library’s call in progress:

public protocol Loader: AnyObject, Sendable {
    /// The loader's name, recorded in a loaded entry's metadata under
    /// `source`.
    func name() throws -> String

    /// A store that may already hold `key`, consulted first, if any.
    func fallback(key: String) throws -> Store?

    /// The value for `key`. Fail with `KeyNotFound` naming
    /// `key` when there's none.
    ///
    /// - Throws: ``KvError`` to report a declared failure with its fields, or any other error to fail the call.
    func load(key: String) throws -> Data
}

Implement it with a class and pass an instance (any Loader) where the API expects one. The library may call it from any thread, which is why the protocol requires Sendable; a class guarding its own state can declare @unchecked Sendable. Passing an implementation retains it until the library calls the vtable’s free, which may happen on any library thread, and releases it then. An optional parameter (loader: (any Loader)?) passes a null vtable for nil.

Arguments and returns follow the ABI:

  • Strings, bytes, and value buffers arrive as copies; an object argument arrives as a wrapper that adopted the reference the library passed, which the implementation may keep.
  • A returned number, Bool, or C-style enum crosses by value; a returned object (Store or Store?) as a fresh reference the library adopts; and a returned string, Data, or value buffer (a record, a rich enum, an optional, a list, or a map) as a run allocated with {prefix}_alloc, which the library adopts.
  • A requirement declared throws in the IDL may throw the module’s error enum: the library receives its code, its message, and its fields as the payload, exactly as if the producer had raised it. Any other error, and any error from a requirement not declared throws, reaches the library as a callback failure (-4) with the error’s localizedDescription. Nothing unwinds through C.
final class FileLoader: Loader, @unchecked Sendable {
    func name() throws -> String { "files" }

    func fallback(key: String) throws -> Store? { nil }

    func load(key: String) throws -> Data {
        guard let data = FileManager.default.contents(atPath: "/data/\(key)") else {
            throw KvError.keyNotFound(message: "no file for \(key)", key: key)
        }
        return data
    }
}

let entry = try store.getOrLoad(key: "settings", loader: FileLoader())

Iterators

An iter<T> return becomes a class conforming to Sequence and IteratorProtocol. Each next() pulls exactly one element from the library; the native iterator is destroyed on exhaustion or from deinit when iteration stops early. next() can’t throw, so for a throwing function an error reported mid-stream ends iteration and is stored in the sequence’s error property.

Known limitations

  • The library is linked at build time, so {PREFIX}_LIBRARY isn’t honored and library paths come from the linker and loader.
  • Load-time check failures, malformed value buffers, and failures of non-throwing calls stop the process instead of throwing.
  • A type that shares its name with a module’s namespace enum (the Stats record beside the kv.stats module’s Kv.Stats) is qualified with the Swift module name where the two would clash (Kvstore.Stats).
  • Interface wrappers aren’t Hashable, so records that contain objects are only Sendable.

Kotlin

The Kotlin target emits a Gradle library module: Kotlin sources over a JNI shim that calls the producer’s C ABI (revision 4). The default flavor is an Android library whose shim builds through the NDK’s CMake; the jvm flavor is a plain Kotlin/JVM library for desktop and server JVMs. Records become data classes, interfaces AutoCloseable wrappers, async functions suspend functions, callback interfaces Kotlin interfaces, and each IDL module an object.

What gets generated

For a library whose C prefix and library name are kitchen_sink:

kotlin/
  settings.gradle.kts          repositories and the root project name
  build.gradle.kts             Android library (or Kotlin/JVM) plus maven-publish
  consumer-rules.pro           R8 keep rules (Android flavor only)
  src/main/cpp/
    CMakeLists.txt             builds libkitchen_sink_jni, linked to libkitchen_sink
    kitchen_sink.h             the C header the shim compiles against
    kitchen_sink_jni.c         the JNI shim
  src/main/kotlin/kitchen_sink/
    Kitchen.kt, Shared.kt      one file per top-level module
    JniBridge.kt               internal: one native per C symbol, callback dispatch
    Runtime.kt                 FfiException, NativeBugException, NativeIterator, loader, lifetime
    Buffers.kt                 value-buffer reader and writer (when the API needs them)
    Codecs.kt                  one codec per composite type (when the API has any)
    Async.kt                   coroutine bridge (when the API has async)

The package defaults to the identity’s C prefix (kitchen_sink), the Gradle group to the package, and the root project and artifact ID to the [package] name. Nothing outside the generated-file headers carries WeaveFFI branding.

Configuration

[generators.kotlin]
name = "com.example.kitchen"   # the Kotlin package; default: the C prefix
flavor = "jvm"                 # "android" (default) or "jvm"
min_sdk = 21                   # Android minSdk (default 21)
compile_sdk = 35               # Android compileSdk (default 35)

Building and loading

The tree builds with Gradle as-is (no wrapper is generated), and gradle publishToMavenLocal publishes the module. settings.gradle.kts declares mavenCentral() and the Gradle plugin portal, plus google() for the Android flavor. The Android flavor uses the Android Gradle plugin 8.7.3 and Kotlin 2.0.21 and compiles to Java 8 bytecode; the JVM flavor uses Kotlin 2.0.21. The shim’s CMakeLists.txt needs CMake 3.18 or later, and an API with async functions depends on kotlinx-coroutines-core 1.9.0.

  • Android. Put the producer library for each ABI in src/main/jniLibs/<abi>/libkitchen_sink.so. When libkitchen_sink_jni.so sits next to it (prebuilt), the AAR packages both as they are; otherwise the NDK’s CMake build compiles the shim against the producer.
  • JVM. Put the producer and a prebuilt shim for each platform under src/main/resources/natives/<platform>/ (the bindings extract them from the classpath), or build the shim with CMake, pointing it at the producer library, and put the result on java.library.path:
cmake -S src/main/cpp -B build -DKITCHEN_SINK_LIBRARY_DIR=/path/to/lib/dir
cmake --build build

weaveffi package writes the Gradle project, plus a README, to kotlin/{name}/ with both layouts filled in: weaveffi build prebuilds the shim for every Android ABI (with the NDK) and for each desktop platform the host can compile for (with the JDK headers; not windows-x64), so neither CMake nor the NDK is needed to build the AAR or JAR. Prebuilt Android shims ship only when every Android ABI has one; otherwise Gradle builds them all through the NDK’s CMake. Building the AAR or JAR still takes Gradle (gradle assembleRelease, gradle jar); see Packaging.

At first use the bindings load the producer and then the shim, each from the classpath’s natives/<platform>/ when present, else with System.loadLibrary("kitchen_sink") and System.loadLibrary("kitchen_sink_jni"). Set KITCHEN_SINK_LIBRARY to an absolute path to load the producer from there instead.

JNI_OnLoad then makes the two load-time checks. It compares the producer’s ABI revision with 4, and for every top-level module it looks up each contract entry the bindings were generated with in the producer’s table (kitchen_sink_kitchen_contract). If either check fails, loading fails with an UnsatisfiedLinkError that names the problem:

kitchen_sink: kitchen.Gadget.describe is missing from the library
kitchen_sink: kitchen.Item changed since these bindings were generated

A producer that adds declarations still loads: only the entries the bindings know about are checked.

Type mapping

IDL typeKotlin typeCrosses JNI as
i8, i16, i32, i64Byte, Short, Int, Longjbyte, jshort, jint, jlong
u8, u16, u32, u64UByte, UShort, UInt, ULongthe signed type of the same width
f32, f64Float, Doublejfloat, jdouble
boolBooleanjboolean
stringStringUTF-8 ByteArray (pointer plus length)
bytesByteArrayByteArray
enumenum class with valuejint
recorddata classvalue buffer
rich enumsealed classvalue buffer
T?, [T], {K:V}T?, List<T>, Map<K, V>value buffer
interface, Iface?wrapper class, nullable wrapperaddress (0 is none)
callback interface, Cb?Kotlin interface, nullablethe implementing object (null is none)
iter<T>NativeIterator<T>iterator address

Unsigned integers are Kotlin’s unsigned types everywhere: parameters, returns, record and variant fields, error payload fields, collection elements and map keys, iterator elements, async results, and callback parameters and returns. A JNI native can’t take an unsigned value class, so each one crosses in the signed type of the same width, converted bit for bit (toUInt(), toInt()), and nothing is truncated: u64 max is ULong.MAX_VALUE. Strings cross as UTF-8 bytes with an explicit length, so interior NULs and supplementary characters round-trip exactly.

Value buffers are encoded and decoded by generated functions: one pack and unpack pair per record and rich enum (next to its declaration) and one per distinct composite type in Codecs.kt (packListOfEntry, unpackMapOfStringToStore, packOptionalOfStoreInfo), so every call site calls a function instead of inlining a loop.

Naming

Free functions live in one object per module, with nested modules as nested objects, so two modules can each declare a get. User types live at the top level of the package. Two rules keep the generated names valid:

  • A user type named like a Kotlin keyword, a type Kotlin or Java imports by default (Unit, String, UInt, Result, …), or a runtime declaration (FfiException, NativeIterator, …) gains a trailing underscore: Unit becomes Unit_.
  • A module object whose name equals a type’s, or the name of a generated file (Runtime, Buffers, Codecs, Async, JniBridge), gains a Module suffix: a kv.stats module beside a Stats record is Kv.StatsModule.

Parameters and methods are lowerCamelCase; record fields, variant fields, and error payload fields keep their IDL spelling (expires_at). Keywords gain a trailing underscore (class_), as does a method named like a member every wrapper already declares or inherits (close, handle, toString, wait, …; see reserved member names).

Objects and lifetime

An interface wrapper owns one strong reference:

class Gadget private constructor(address: Long) : AutoCloseable {
    internal val handle: NativeHandle = NativeCleaner.register(this, NativeHandle(address, JniBridge::kitchen_Gadget_destroy))

    /** Render the gadget as a human-readable string */
    fun describe(): String = handle.borrow { _self ->
        decodeUtf8(JniBridge.kitchen_Gadget_describe(_self))
    }

Every call borrows the receiver and every object argument for its whole duration. close() (or use { }) releases the reference; if a call is in flight, the release waits until the call returns, and if the wrapper becomes unreachable mid-call, its handle is still borrowed, so the object is never freed under native code. Using a closed wrapper throws IllegalStateException; closing twice is safe. Wrappers that are never closed are released by a daemon thread draining a PhantomReference queue, which works on every Android API level.

Objects inside records and collections cross as new strong references: the encoder clones each one (and releases the clones again if encoding fails), and the decoder adopts each one into a new wrapper.

Errors

A callable declared throws raises an FfiException. Each error domain becomes a sealed subclass named with an Exception suffix (KvError becomes KvException; KitchenErrors becomes KitchenErrorsException), with one class per code whose payload fields are properties and whose message defaults to the IDL’s:

sealed class KitchenErrorsException(code: Int, message: String) : FfiException(code, message) {
    class NotFound(message: String = "Item not found") : KitchenErrorsException(1, message)

A runtime failure of a throwing call keeps the root FfiException class with its negative code: -1 a generic producer error, -2 a producer panic, -3 a marshalling failure, and -4 a callback implementation that failed (the message is the callback’s).

A callable that isn’t throws can’t report an error, so any failure is a bug in the library, and the binding raises the unchecked NativeBugException (an IllegalStateException) instead, following the trap policy. Its code and message name the runtime code and the producer’s message, as in native call failed with code -2: {the panic message}.

A malformed value buffer or an undeclared enum value read from the producer raises NativeBugException with code -3 as well.

Async and cancellation

Async functions are suspend functions. A cancellable one creates a native cancel token for the call, and cancelling the awaiting coroutine (directly, by its scope, or through withTimeout) cancels the token, so the producer drops the pending work:

suspend fun doCancellable(input: String): String = awaitNative(true, 0, { _raw -> decodeUtf8(_raw as ByteArray) }) { _token, _done ->
    JniBridge.kitchen_do_cancellable(encodeUtf8(input), _token, _done)
}

The coroutine resumes with CancellationException when it’s cancelled or when the producer completes the call with code -5. Completions arrive on producer threads; the token is destroyed once the call completes. The coroutines runtime (kotlinx-coroutines-core) is a dependency only when the API has async functions.

Callbacks

A callback interface is a plain Kotlin interface, and an optional callback parameter (Cb?) takes null for none, which passes a null vtable. Passing an implementation pins it with a JNI global reference until the producer calls the vtable’s free.

interface Policy {
    /**
     * Admit an entry about to be stored, returning it as it should be
     * stored. ...
     *
     * Throw a [KvException] to report a typed error to the library.
     */
    fun admit(entry: Entry): Entry

    fun route(key: String, home: Store): Store
}
  • Parameters. Strings, bytes, and value buffers are decoded before the method runs. An object parameter arrives as a new wrapper that owns the reference the producer handed over; close it, or let the cleaner release it.
  • Returns. A method may return any type but an iterator or a callback interface. A scalar or enum is returned by value. An object is returned as a new strong reference (the wrapper keeps its own). A string, bytes, or value buffer is encoded and copied into a run allocated with {prefix}_alloc, which the producer adopts.
  • Failures. Whatever a method throws is caught in the dispatch shim on JniBridge; nothing unwinds through native frames. A method declared throws may throw its module’s domain exception (KvException.Rejected, say), which reaches the producer with its code, message, and payload fields, so a producer that propagates it hands the original caller the same typed error. Anything else, including a domain exception from a method that doesn’t declare throws, reaches the producer as code -4 with the exception’s message.

Producer threads may call an implementation from anywhere. The shim attaches a thread to the JVM the first time it calls in (as a daemon) and detaches it when the thread exits, through a pthread key destructor or, on Windows, a fiber-local storage callback; if neither can be registered, it detaches the thread again as soon as the call returns.

Iterators

An iter<T> return is a NativeIterator<T>, which is both an Iterator and AutoCloseable. Each hasNext() pulls at most one element. The native iterator is released when the sequence is exhausted, on close(), or by the cleaner.

Known limitations

  • Gradle builds aren’t exercised by the conformance suite, which compiles the same sources with kotlinc and the shim with CMake.
  • Interior NULs in an error message are cut at the first NUL, both in messages the producer reports and in a failing callback’s message.
  • Kotlin’s type system can’t express a callback return the producer would reject (a null object for a non-optional I, or a malformed record), so those -3 paths aren’t reachable from Kotlin.

Node.js

The Node.js target generates an npm package: an ES module with TypeScript declarations, and a small N-API addon that calls the library’s C ABI. The JavaScript API is shared with the WebAssembly target; only the transport underneath differs, so code written against one runs against the other.

What gets generated

For a library named kvstore (the snapshot fixtures use kitchen_sink):

node/
├── package.json      ES module manifest; `npm install` compiles the addon only without a prebuilt one
├── binding.gyp       builds the addon and links the library
├── kvstore_node.c    the N-API addon
├── kvstore.h         a copy of the C header the addon compiles against
├── index.js          the API: one namespace export per top-level module
├── index.d.ts        TypeScript declarations
├── runtime.js        the shared runtime (codec, errors, wrappers, load-time checks)
└── README.md

The package name defaults to the package identity’s name; set name under [generators.node] in weaveffi.toml to override it, and node_engine to change engines.node (default >=18). The addon is named {library}_node.node.

Install, build, and load

The generated directory is a complete package. Its loader uses a prebuilt addon when one matches the platform (from an installed {package}-{os}-{cpu} package, or under prebuilds/<os>-<cpu>/), and its install script compiles the addon with node-gyp only when none does. To compile it, install the package pointing the build at the native library with the {PREFIX}_LIBRARY environment variable (the library file or its directory) or the matching npm option:

KVSTORE_LIBRARY=/opt/kvstore/lib/libkvstore.so npm install ./node
npm install ./node --kvstore-library=/opt/kvstore/lib

Without either, node-gyp looks for the library in the package directory. The addon links {library} and carries an rpath to its own directory and to the directory it was linked against, so a library copied next to the addon also loads. The setting applies when the addon is built; at run time the operating system’s loader finds the library as usual.

import { kv, KvstoreError } from 'kvstore';

const store = kv.Store.open('/tmp/data');
store.put('greeting', new TextEncoder().encode('hi'), kv.EntryKind.Volatile, null);
store.close();

weaveffi package writes npm tarballs in the esbuild-style layout: one {package}-{os}-{cpu} package per desktop platform, gated by npm os and cpu and carrying the library and the addon weaveffi build prebuilt for it, plus the main package, which lists them in optionalDependencies. So npm install needs no compiler on a platform whose addon weaveffi build prebuilt; elsewhere, Windows included (where the addon is never prebuilt), it compiles the addon against the installed platform package’s library. See Packaging for which addons are prebuilt.

Load-time checks

Importing the package loads the addon and, before any other call, checks that the library matches the bindings (see Load-time checks). The library must implement ABI revision 4, and every top-level module’s contract table must hold each declaration the bindings were generated with. index.js embeds those entries:

const $contract = [
  ['kv', [
    [0x0969575bfbb012d7n, 0xebd38766e3532c4fn, 'kv.Store.fork'],
    // ...
  ]],
];
$verify($raw, 'kvstore', 'kvstore', 4, $contract);

If a check fails, the import throws an Error naming the declaration: kvstore: kv.Store.put is missing from the library or kvstore: kv.Store.put changed since these bindings were generated. Declarations the library has and the bindings don’t are fine, so a library that only adds to its API keeps working with older bindings.

Modules and names

Each top-level IDL module is a frozen namespace object, and nested modules are nested namespaces, so equal names in different modules never collide:

export const kitchen = Object.freeze({
  KitchenErrorsError: kitchen$KitchenErrorsError,
  NotFoundError: kitchen$NotFoundError,
  InvalidInputError: kitchen$InvalidInputError,
  Priority: kitchen$Priority,
  Gadget: kitchen$Gadget,
  boolId: kitchen$boolId,

Functions, methods, and parameters are camelCase. A free function, parameter, or module whose name is a JavaScript reserved word gains a trailing underscore (a function delete becomes delete_). Methods keep reserved words, which are legal property names (store.delete(key)), except that an instance method named close or constructor, and a static named name, length, prototype, or caller, gains one too (reserved member names). Record fields keep their IDL spelling.

Type mapping

IDL typeTypeScript typeNotes
i8, i16, i32, u8, u16, u32number
i64, u64bigintIntegral numbers are accepted as arguments; out-of-range values throw a RangeError
f32, f64numberNaN, the infinities, and -0 round-trip
boolboolean
stringstringUTF-8, passed as pointer and length, so interior NULs survive
bytesUint8Array
recordinterfaceA plain object; fields keep their IDL names
C-style enumenumA frozen object with forward and reverse mappings (negative values too)
rich enumtagged union{ tag: 'Circle', radius: 2.5 }
interfaceclassSee Objects
callback interfaceinterfaceAny object with the methods; see Callback interfaces
T?T | nullundefined is accepted as null
[T]T[]
{K: V}Record<string, V>Record<number, V> for integer keys of 32 bits or fewer, Partial<Record<E, V>> for C-style enum keys; keys are property names (strings) at run time, and a Map is accepted as an argument
iter<T>IterableIterator<T>Lazy

Arguments are type-checked: a value of the wrong type throws a TypeError before the native call.

Value buffers are encoded and decoded by one function per type: each record and rich enum ($w$kv$Entry, $r$kv$Entry), each interface carried as an object token, and each distinct optional, list, and map type in the API ($w_list_opt_Entry writes [Entry?]). Call sites name these functions.

Objects and lifetime

An interface is a class whose instances each own one strong reference to the native object. close() releases it, and so does [Symbol.dispose]() (for using declarations); both are idempotent, and a wrapper that is garbage collected unclosed is released by a FinalizationRegistry. Every call lends the wrapper to the native side for its duration, so a close() that races the call (from a callback, or while an async method is pending) waits for it to return. Using a closed wrapper throws a code -3 error.

  poke(times) {
    const $self = $lend(this, kitchen$Gadget);
    try {
      return $raw.kitchen_sink_kitchen_Gadget_poke($self, times);
    } catch ($e) {
      throw $from$kitchen$KitchenErrors($e);
    } finally {
      $unlend(this);
    }
  }

The synchronous constructor named new is the class constructor; other constructors are static factories, and a class without one can’t be constructed directly. Objects returned, awaited, iterated, decoded from a record, or passed to a callback method are new wrappers the receiver owns. Two wrappers may share one native object.

Errors

Every error extends the package’s root class, {Package}Error (here KitchenSinkError), which carries the ABI code:

  • Each error domain is a class (kitchen.KitchenErrorsError), and each of its codes a subclass (kitchen.NotFoundError) with a static CODE and the IDL message as its default message. Payload fields become properties. A code without fields is constructed as new kv.InvalidPathError(message?); a code with fields takes them first: new kv.RejectedError({ key, reason }, message?).
  • A cancelled call rejects with CancelledError (code -5).
  • Runtime failures throw the root class itself: -1 generic, -2 producer panic, -3 marshalling failure, -4 a callback implementation failed.

Only functions declared throws map codes onto their module’s domain; a failure of any other call is a producer bug, and the binding throws the root class with the code and the producer’s message (see the trap policy).

Async and cancellation

An async function returns a Promise. The completion may arrive on any producer thread; the addon settles the promise on the JavaScript thread. A cancellable function takes an optional last argument with an AbortSignal:

  export function doCancellable(input: string, options?: { signal?: AbortSignal }): Promise<string>;

Aborting the signal cancels the native token, and the call rejects with CancelledError. An already-aborted signal cancels the call before it starts.

Callback interfaces

Any object with the interface’s methods implements it; the methods are camelCase. Arguments arrive converted: strings and bytes as string and Uint8Array, records decoded, and objects as new wrappers the implementation owns (close them, or let the garbage collector release them). A parameter of an optional callback interface (Cb?) takes null for none.

A method’s return value is checked and handed to the library in its family’s form:

IDL returnReturn from JavaScriptWhat the library receives
direct (numbers, bool, C-style enums)the valuethe value
string, bytesa string, a Uint8Arraya copy in a run the library adopts
record, rich enum, T?, [T], {K:V}the valueits encoding, in a run the library adopts
Ia wrapper (never null)a new strong reference to its object
I?a wrapper or nulla new strong reference, or null

The implementation keeps its own wrapper; the library gets a reference of its own. A method that throws, or returns a value of the wrong type, fails the native call with code -4 and the exception’s message; nothing unwinds through native code. A method declared throws may instead throw an error of its module’s domain, and the library receives that code, message, and fields:

const policy = {
  admit(entry) {
    if (entry.key.startsWith('secret')) {
      throw new kv.RejectedError({ key: entry.key, reason: 'no secrets' }, 'secrets are not stored');
    }
    return { ...entry, tags: ['admitted'] };
  },
  route(key, home) {
    return home;
  },
};
store.setPolicy(policy);

Threading

The producer may call back from any thread, and every JavaScript call runs on the JavaScript thread (the main thread or the worker that passed the implementation):

  • A call made on the JavaScript thread (because the JavaScript thread is inside a call into the library) runs directly, nested inside that call.
  • A call made on any other thread is queued for the JavaScript thread and runs from its event loop, while the producer thread waits for the result. Nothing runs while the JavaScript thread is busy, so such calls are delivered only once the current JavaScript task returns to the event loop.
  • The library’s release of an implementation (its vtable’s free) may come from any thread; the addon drops the implementation on the JavaScript thread.

The waiting in the second rule could deadlock: if the JavaScript thread is inside a synchronous call into the library that itself waits for the thread making the callback (joins it, or waits on a lock it holds), neither thread can proceed. The addon detects this instead of hanging. It counts the synchronous calls in progress on each JavaScript thread, and a callback from another thread that has waited while the JavaScript thread stayed inside one synchronous call for a second gives up: the callback fails with code -4 and a message saying that it would deadlock, the producer sees that failure, and the synchronous call can finish. A callback that waits through a shorter synchronous call, or for the event loop, simply runs later. So:

  • Make calls that wait for callbacks from other threads async, or have the producer call back on the calling thread.
  • Avoid synchronous calls that block for a second or more while other threads call back; such a callback fails with -4 rather than waiting longer.
  • Return to the event loop regularly while callbacks from other threads are expected: a long-running JavaScript task delays them.

Iterators

An iter<T> result is a lazy iterator: each next() makes one native call. The native iterator is destroyed when it’s exhausted, when it fails, on return() (which for...of calls on break), on close(), or when the iterator is garbage collected.

Known limitations

  • A callback from another thread fails with -4 if it waits for a second on a synchronous call (see Threading).
  • A callback implementation that holds a wrapper of the object retaining it forms a cycle the garbage collector can’t see; close the wrapper or release the callback explicitly.
  • {PREFIX}_LIBRARY selects the library at build time, not at run time.
  • Windows builds link {library}.lib, and the DLL must be on the loader’s search path.
  • The package is ESM only, and its declarations need TypeScript 5.2 or later (for Symbol.dispose).

WebAssembly

The WebAssembly target generates an npm package for a wasm32-unknown-unknown build of the library: an ES module with TypeScript declarations over glue that stages values in the module’s linear memory. The JavaScript API is the Node.js target’s, so the sections there on names, types, objects, errors, callbacks, and iterators apply here unchanged; this page covers what differs.

What gets generated

For a library named kvstore (the snapshot fixtures use kitchen_sink):

wasm/
├── package.json   ES module manifest
├── index.js       `init()`, the raw entry points, and the API
├── index.d.ts     TypeScript declarations
├── runtime.js     the shared runtime (codec, errors, wrappers)
├── linear.js      the transport: loading, staging, table functions
└── README.md

The package name defaults to the package identity’s name; set name under [generators.wasm] to override it. weaveffi package writes the npm tarball wasm/{package}-{version}.tgz with the module weaveffi build linked for wasm32 as {library}.wasm.

Build the module

The glue installs async completions and callback-interface methods in the module’s function table, so link the module with an exported, growable table:

RUSTFLAGS="-C link-arg=--export-table -C link-arg=--growable-table" \
  cargo build --release --target wasm32-unknown-unknown

weaveffi build --platforms wasm32 passes those flags itself and writes the module to target/weaveffi/wasm32/{library}.wasm.

Load

init() loads the module and checks the contract before any other export works: the module must implement ABI revision 4, and each top-level module’s contract table must hold every declaration the bindings were generated with (see the Node.js page). A failed check rejects with an Error naming the declaration (kvstore: kv.Store.put changed since these bindings were generated). Until the promise resolves, every other call throws.

import { init, kv } from 'kvstore';

await init();
const store = kv.Store.open('/tmp/data');

Without an argument, init() reads the file named by the {PREFIX}_LIBRARY environment variable (KVSTORE_LIBRARY) when it’s set on Node.js, and otherwise loads {library}.wasm from next to index.js (by fetch in a browser, from disk on Node.js). It also takes a URL, a path (resolved against index.js), bytes, a Response, or a compiled WebAssembly.Module:

export declare function init(
  source?: string | URL | BufferSource | WebAssembly.Module | Response,
): Promise<void>;

init() may be called more than once; later calls return the same promise, and a failed load can be retried.

Transport

Each C symbol gets one entry point in index.js that checks its arguments, copies strings and byte runs into linear memory for the call, and copies returned strings and buffers out before releasing them:

    kitchen_sink_kitchen_echo_string: (a0) => {
      let s0 = null;
      try {
        s0 = m.str(a0);
        const r = x.kitchen_sink_kitchen_echo_string(s0[0], s0[1], m.len, m.err);
        m.check();
        return m.takeStr(r, m.outLen());
      } finally {
        m.unstage(s0);
      }
    },

Staged arguments are runs from {prefix}_alloc, released with {prefix}_free_bytes once the call returns. Runs have alignment 1, so the glue reads and writes multi-byte values through a DataView. One error slot, one out_len slot, and one iterator item slot are reused by every call; they and the callback vtables live in 8-byte-aligned blocks carved from arenas the glue allocates once and never frees. __debugLive(4) leaves those arenas out of its count of byte runs. Object handles are linear-memory addresses.

Async completions and callback-interface methods are JavaScript functions installed in the module’s function table. The glue uses WebAssembly.Function where the engine provides it and otherwise compiles a tiny module that imports the function with the needed signature and re-exports it, so no experimental flag is needed.

Type mapping

The mapping is the Node.js target’s: 64-bit integers are bigint, bytes is Uint8Array, records are plain objects, rich enums are tagged unions, C-style enums are frozen objects, interfaces are classes with close() and [Symbol.dispose](), optionals are T | null, lists are arrays, maps are Record objects (a Map is accepted as an argument), and iter<T> is a lazy IterableIterator<T>.

Async and cancellation

Async functions return a Promise, and cancellable ones take an optional { signal } last argument, exactly as on Node.js. The module is single-threaded, and on wasm32 the default executor polls a future inline until it completes, inside its launcher, so an async call has completed by the time the Promise is returned; only a signal aborted before the call can cancel it. A future that’s still pending once nothing can wake it (it awaits something only another thread or a reactor would complete) fails with code -1 instead (async function suspended with no executor on wasm32). A cancelled call rejects with CancelledError (code -5).

Callbacks and errors

Callback interfaces work as on Node.js: returns of every family (a string, bytes, or buffer return is copied into a run from {prefix}_alloc that the producer adopts, and an object return is a new reference), domain errors from methods declared throws with their fields, and null for an optional callback (Cb?), which passes a null vtable. The vtable starts with its size, flags, and free entries, like any consumer’s.

There are no threads, so a callback runs only while a call into the module is on the stack, on the calling thread, and the Node.js addon’s threading rules don’t arise. An implementation that throws fails the call with code -4, as on Node.js. Because wasm32-unknown-unknown aborts on a panic, a producer panic traps the module; the trap surfaces as the package’s root error with code -2, and the module may be unusable afterward.

Emscripten

With emscripten = true under [generators.wasm], init instead takes an initialized Emscripten module (or the promise its MODULARIZE factory returns), binds its underscore-prefixed exports, reads memory through HEAPU8, and installs table functions with addFunction. Link the module with -sALLOW_TABLE_GROWTH, -sWASM_BIGINT, and -sEXPORTED_RUNTIME_METHODS=addFunction,HEAPU8, and export the library’s C symbols, including the runtime surface ({prefix}_alloc, {prefix}_free_bytes, {prefix}_error_set_payload, and the rest) and each {prefix}_{module}_contract. The packaged layout then ships glue only.

export declare function init(module: object | Promise<object>): Promise<void>;

Known limitations

  • The module must export its function table and allow it to grow.
  • An async call can’t be cancelled once launched, since it completes before its launcher returns, and a future that waits on another thread or an external event fails with code -1.
  • A producer panic traps rather than unwinding, which can leave the module’s state inconsistent.
  • Emscripten mode isn’t covered by the conformance suite.

Python

The Python target produces a pip-installable package of pure-Python ctypes bindings over the C ABI (revision 4). There’s no compilation step, no native extension, and no third-party runtime dependency; the package works on any Python 3.9+ interpreter that can load the producer library. The implementation module is fully annotated and the package ships py.typed, so type checkers read the bindings themselves (there’s no separate stub to drift out of date) and the generated code passes mypy --strict.

Records and rich enums are dataclasses that cross the boundary as value buffers. Interfaces are reference-counted wrapper classes. Callback interfaces are abstract base classes the consumer subclasses. Async functions are coroutines whose cancellation cancels the native call, and iter<T> returns are lazy Python iterators.

What gets generated

For the kvstore sample (package name kvstore, C prefix kvstore):

python/
├── pyproject.toml          # PEP 621 metadata, built with setuptools
├── README.md
└── kvstore/                # the import package, named after the C prefix
    ├── __init__.py         # re-exports the module's __all__
    ├── kvstore.py          # runtime, prototypes, and wrappers
    └── py.typed            # PEP 561 marker

The distribution name is the package identity’s name and the import package is its C prefix, so a crate named key-store installs as key-store and imports as key_store. Both are configurable:

[generators.python]
name = "kvstore-python"      # distribution name (default: package name)
import_name = "kvstore_py"   # import package (default: C prefix)
requires_python = ">=3.10"   # default ">=3.9"

The module lists its public names in __all__ (the error classes, every type, and the free functions), so from kvstore import * and the package’s __init__.py export the API and none of the runtime’s helpers.

Install and load

weaveffi generate -o bindings --target python   # in the producer crate
pip install bindings/python

At import, the module loads the producer library from the first of:

  1. the path in {PREFIX}_LIBRARY (for example KVSTORE_LIBRARY),
  2. a library bundled inside the package directory (what weaveffi package produces, and where weaveffi dev copies the debug build), and
  3. the system loader’s search path, by platform file name (libkvstore.dylib, libkvstore.so, or kvstore.dll).

It then makes the two load-time checks before binding anything else. kvstore_abi_version() must report revision 4, and every top-level module’s contract table must contain each declaration the bindings were generated with, with an equal hash. The expected entries are embedded in the module, one (id, hash, path) per declaration:

_CONTRACTS: Dict[str, List[Tuple[int, int, str]]] = {
    "kvstore_kv_contract": [
        (0x0969575bfbb012d7, 0xebd38766e3532c4f, "kv.Store.fork"),
        ...
    ],
    "kvstore_report_contract": [...],
}

A failed check raises ImportError naming the declaration (kv.Store.put is missing from the library or kv.Store.put changed since these bindings were generated), so a stale binding never misreads a buffer at runtime. Declarations the library has and the bindings don’t are fine, so a library that grew new functions still loads. Every C prototype is then bound once, in a module-level table:

_c_kv_Store_count = _bind("kvstore_kv_Store_count", ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(_ErrorStruct))

On macOS, the system python3 strips DYLD_LIBRARY_PATH, so prefer {PREFIX}_LIBRARY there.

Type mapping

IDL typePython typeCrosses the ABI as
i8 to u64intc_int8 to c_uint64
f32, f64floatc_float, c_double
boolboolc_bool
stringstrUTF-8 bytes plus length (c_char_p, c_size_t)
bytesbytespointer plus length
C-style enumIntEnum subclassc_int32
record@dataclassvalue buffer
rich enumbase class plus one @dataclass per variantvalue buffer
T?Optional[T]value buffer
[T], {K:V}List[T], Dict[K, V]value buffer
interfacewrapper classc_void_p
interface ?Optional[...]c_void_p, NULL for None
callback interfacesubclass of the generated ABCcontext key plus vtable
callback interface ?Optional[...]context key plus vtable, NULL for None
iter<T>Iterator[T]iterator handle

Strings and bytes pass without per-byte loops: a str is encoded once and its bytes are lent to the call, so interior NUL characters survive. Returned strings, bytes, and buffers are copied with ctypes.string_at and released with {prefix}_free_bytes.

Every record and rich enum has one writer and one reader function (_write_Entry, _read_Entry), and so does every distinct composite type the API uses (_write_list_string, _read_map_string_Store, _read_opt_Entry); a call site makes one call instead of inlining a loop. Lists of a fixed-width number type are packed and unpacked with one struct call. A decoded buffer that’s truncated, has trailing bytes, an invalid flag, invalid UTF-8, an undeclared enum value, or a repeated map key raises InternalError with code -3.

All modules share one Python namespace. Nested modules’ declarations are exported next to their parents’ under their bare names (summarize, not kv_stats_summarize), which global names keep unique. The module starts with from __future__ import annotations, so annotations name types declared later in the file without quotes.

Objects and lifetime

An interface wrapper holds one strong reference and releases it exactly once: from close(), the with statement, or the __del__ backstop. close() is idempotent, and using a closed wrapper raises ValueError. Each call lends the pointer for its duration, so close() from another thread while a call is in flight defers the release until the call returns. Two wrappers compare equal (and hash alike) when they hold the same native object, so store.share() == store and a record holding a Store compares by identity. An interface member named close is close_, so it never replaces the wrapper’s own (reserved member names).

A constructor named new becomes __init__; other constructors are @classmethod factories:

    @classmethod
    def open(cls, path: str) -> Store:
        _path_b = path.encode("utf-8")
        _err = _ErrorStruct()
        _ret = _c_kv_Store_open(_path_b, len(_path_b), ctypes.byref(_err))
        if _err.code:
            raise _kv_error_from(*_read_error(_err))
        return cls._adopt(_required(_ret))

Objects returned by the producer, decoded from a buffer, or passed to a callback are adopted into new wrappers. Writing an object into a value buffer (a record field or a list element) mints a new reference only after the whole argument list has encoded, so a failed encoding leaks nothing.

Errors

A call that declares errors (throws) raises the error domain in scope for its module. Every such exception derives from the package’s root Error (named {PascalName}Error if the API declares its own Error type), which carries code and message. A module’s error domain becomes a subclass (KvError) with one subclass per code (KeyNotFound), each also reachable as KvError.KeyNotFound and carrying its stable CODE. A code’s payload fields are constructor arguments and attributes:

try:
    store.get("nope")
except kvstore.KvError.KeyNotFound as e:
    print(e.code, e.key, e.message)  # 1001 nope key not found: nope

A negative code on a throwing call (a panic, a marshalling failure, or a failed callback) raises the root Error with that code; the constants are GENERIC_ERROR_CODE (-1), PANIC_ERROR_CODE (-2), MARSHAL_ERROR_CODE (-3), and FOREIGN_ERROR_CODE (-4).

A call that declares no errors can only fail through a bug, so it follows the trap policy: it raises InternalError, a RuntimeError subclass outside the Error hierarchy whose message names the code and the producer’s message (InternalError: (-3) ...). A cancelled async call (-5) raises asyncio.CancelledError instead.

Async and cancellation

An async function is an async def that must run on an asyncio event loop. The launcher receives a static completion trampoline and an integer key; the trampoline takes ownership of the result on the producer’s thread and settles the future through call_soon_threadsafe:

async def open_store(path: str) -> Store:
    _path_b = path.encode("utf-8")
    _call, _future = _async_begin()
    try:
        _c_kv_open_store(_path_b, len(_path_b), _kv_open_store_completion, _call)
    except BaseException:
        _async_abandon(_call)
        raise
    _result: Store = await _future
    return _result

For a cancellable function, each call owns a native cancel token. Cancelling the awaiting task (directly, through asyncio.wait_for, or by cancelling a gather) cancels the token, waits for the producer’s cancelled completion, and raises asyncio.CancelledError. Cancelling the task awaiting a function that isn’t cancellable stops the wait only; the native call runs to completion and its result is dropped.

Callbacks

A callback interface is an abc.ABC with one abstract method per IDL method. Passing an instance registers it in a handle table under an integer key, which the producer receives as ctx along with the address of the interface’s one static vtable; None for an optional callback parameter passes a null vtable. The vtable starts with the revision 4 header (its size, flags, and the shared free entry), and the producer’s free(ctx) removes the table entry, so an implementation lives exactly as long as the producer holds it.

Trampolines run on whichever thread the producer calls from; ctypes acquires the GIL first. A trampoline adopts the object arguments first, converts the rest, calls the method, and hands the return back:

  • a number, bool, or enum as the C return value;
  • an object as a fresh strong reference the producer adopts (None is allowed only for I?);
  • a str, bytes, record, or other buffered value through the out_ptr/out_len slots, in a run allocated with {prefix}_alloc that the producer adopts and frees.
def _Loader_load(ctx: Optional[int], key_ptr: Optional[int], key_len: int, out_ptr: Any, out_len: Any, out_err: Any) -> None:
    try:
        _ret = _callback_get(ctx).load(_peek_bytes(key_ptr, key_len).decode("utf-8"))
        _callback_return_bytes(out_ptr, out_len, bytes(_ret))
    except BaseException as exc:
        _callback_fail(out_err, exc, KvError)

Nothing unwinds through C. A method that declares errors may raise its module’s domain codes, which reach the producer as that code with the fields as the error payload ({prefix}_error_set and {prefix}_error_set_payload):

class EmptyLoader(kvstore.Loader):
    def name(self) -> str:
        return "empty"

    def fallback(self, key: str) -> Optional[kvstore.Store]:
        return None

    def load(self, key: str) -> bytes:
        raise kvstore.KeyNotFound(key=key, message="not in the loader")

Any other exception, a return of the wrong type, or a domain error raised by a method that doesn’t declare errors reaches the producer as FOREIGN_ERROR_CODE (-4) with the exception’s message. A Rust producer receives either as the Err of the method’s Result<T, ForeignError>.

Iterators

An iter<T> return is a lazy iterator: each next() makes one native next call through the pre-bound function, and the handle is released on exhaustion, close(), or garbage collection.

for key in store.keys(None):
    print(key)

Packaging

weaveffi package --target python writes one wheel per desktop platform built, python/{name}-{version}-py3-none-{platform}.whl (the distribution name escaped for a wheel file name, so Key-Store becomes key_store), with the producer library inside the import package. The platform tag matches the build: the [build] macOS deployment target (macosx_11_0_arm64), the newest glibc the library links against on Linux (manylinux_2_17_x86_64 or later), or win_amd64. The wheels install with pip and upload with twine; Android, iOS, and wasm32 builds have no wheel platform and are skipped. See Packaging.

Known limitations

  • ctypes dispatch costs more per call than a compiled extension; hot loops that cross the boundary per element will feel it. The bindings keep the per-call work small (prototypes and vtable addresses are resolved at import, and the error check is inline), but each call still allocates its error slot.
  • ctypes truncates an out-of-range integer argument to the parameter’s C width instead of raising; inside a value buffer, struct raises.
  • All modules share one namespace (see “Type mapping”).
  • A long-running callback stalls the producer thread that called it, and a callback that waits for the thread blocked in the calling Python code deadlocks.
  • iter<T> returns are annotated as Iterator[T], so a type checker doesn’t know about their close() method.

.NET

The .NET target emits a standalone C# class library over the C ABI (revision 4). Native calls go through source-generated [LibraryImport] declarations whose slots are all blittable, and callbacks go through [UnmanagedCallersOnly] function pointers, so the bindings need no runtime marshalling and are trim- and AOT-friendly. The project targets net8.0 and builds with any newer SDK.

What gets generated

For a package named kvstore:

dotnet/
  Kvstore.cs        generated API: types, wrappers, codecs, native imports
  Runtime.cs        fixed runtime: exceptions, loader, codec, async and iterator helpers
  Kvstore.csproj    SDK-style project with the NuGet metadata
  README.md

The namespace, assembly name, and NuGet id are the package name in PascalCase (my-kv becomes MyKv). Set name to override all three:

[generators.dotnet]
name = "Acme.Storage"

Each module becomes a static class named by its full path (kv is Kv, kv.stats is KvStats); records, enums, interfaces, callback interfaces, and exceptions sit directly in the namespace, since every type name is unique across the API.

Build and load

Reference the generated project from yours, or pack it:

dotnet add reference bindings/dotnet/Kvstore.csproj
dotnet pack bindings/dotnet/Kvstore.csproj -c Release

The bindings load the native library by its base name (kvstore), so .NET probes libkvstore.dylib, libkvstore.so, or kvstore.dll next to the app and on the platform’s library path. Set KVSTORE_LIBRARY (the {PREFIX}_LIBRARY variable) to a full path to load a specific file. The override is a DllImportResolver on the generated assembly.

weaveffi package writes the project to dotnet/Kvstore/ with each desktop platform’s library under runtimes/<rid>/native/, where NuGet resolves it at restore time, and runs dotnet pack on it to produce dotnet/Kvstore.{version}.nupkg (so it needs the .NET SDK). See Packaging.

Load-time checks

The first native call runs NativeMethods’ static constructor, which installs the resolver, checks {prefix}_abi_version() against 4, and then checks every top-level module’s contract table. The bindings embed the (id, hash, path) entry of every declaration they were generated with:

static partial void VerifyContracts()
{
    VerifyContract("kvstore_kv_contract", &kvstore_kv_contract, new (ulong, ulong, string)[]
    {
        (0xbe2b91d3c1f42f80UL, 0xf47a03bed8a6a39aUL, "kv.Store.put"),
        // ... one entry per declaration in `kv` and its submodules
    });
    VerifyContract("kvstore_report_contract", &kvstore_report_contract, new (ulong, ulong, string)[]
    {
        // ...
    });
}

Each entry must be in the library’s table with an equal hash; entries the library has and the bindings don’t are fine, so a library that only adds declarations keeps working with older bindings. A failure throws InvalidOperationException naming the declaration (kv.Store.put is missing from the library kvstore or kv.Store.put changed since these bindings were generated), surfaced as the InnerException of a TypeInitializationException, so stale bindings fail before any call can misread memory.

Type mapping

IDL typeC# typeCrosses the ABI as
i8 … u64, f32, f64sbyte … ulong, float, doubleby value
boolboolone byte
stringstringUTF-8 (ptr, len); interior NULs survive
bytesbyte[] (parameters also take ReadOnlySpan<byte>)(ptr, len)
C-style enumenum with the IDL discriminantsint32_t
recordsealed class with get-only propertiesvalue buffer
rich enumabstract class with one nested sealed class per variantvalue buffer
T?T?value buffer (Iface? is a nullable pointer, Cb? a null vtable)
[T]T[]value buffer
{K: V}Dictionary<K, V>value buffer
interfacesealed class : IDisposableobject pointer
callback interfaceinterface I{Name}ctx plus a static vtable
iter<T>IEnumerable<T>iterator handle

Strings are encoded to UTF-8 and pinned for the call; returned strings, bytes, and buffers are copied and released with {prefix}_free_bytes. Records and rich enums encode themselves (WriteTo and ReadFrom), and each distinct optional, list, or map type gets one encode and decode pair in the internal FfiCodecs class (WriteListOfEntry, ReadMapOfStringToStore), which every call site and field shares.

Objects and lifetime

An interface wrapper holds one strong reference in a nested SafeHandle subclass whose ReleaseHandle calls _destroy. Every import takes that handle, not a raw pointer, so the interop stub adds a reference for the duration of the call: a wrapper that’s disposed on another thread or collected mid-call can’t free the object under it. Dispose releases the reference (an in-flight call finishes first); the SafeHandle’s finalizer covers wrappers that are never disposed. Calling a disposed wrapper, or passing one as an argument, throws ObjectDisposedException.

Returned objects, object fields of decoded records, and objects handed to a callback each arrive as a new wrapper that owns its reference. Two wrappers are equal (Equals, GetHashCode) when they reference the same native object. Writing an object into a value buffer clones its reference first, so the producer can keep it.

An interface member spelled like a member the wrapper declares or inherits (Dispose, Handle, Equals, ToString, …) or like the interface itself gains a trailing _: an IDL method dispose is Dispose_() (reserved member names).

Errors

What a failed call throws depends on whether it’s declared throws (the trap policy):

  • A throwing call throws its module’s domain exception. Each error domain becomes an abstract class deriving from NativeException, with one nested sealed class per code whose fields are typed properties; catch the nested class for one code or the domain class for any of them. A runtime code (-1 generic, -2 panic, -3 marshalling failure, -4 a failed callback) throws NativeException itself, whose Code property carries it and whose constants name it (GenericErrorCode, PanicErrorCode, MarshalErrorCode, ForeignErrorCode).
  • A call that isn’t throws can’t fail by contract, so a failure is a producer bug: it throws NativeBugException, an InvalidOperationException whose Code is the runtime code and whose message names the code and the producer’s message.
  • Cancellation (-5) always throws OperationCanceledException.

For the kvstore sample’s KvError domain:

public abstract class KvException : NativeException
{
    /// <summary>key not found</summary>
    public sealed class KeyNotFound : KvException
    {
        /// <summary>The domain code, <c>1001</c>.</summary>
        public const int ErrorCode = 1001;

        /// <summary>The key that was looked up.</summary>
        public string Key { get; }

        public KeyNotFound(string key, string? message = null) : base(ErrorCode, message ?? "key not found")
        {
            Key = key;
        }
    }

    // ... Expired, StoreFull, InvalidPath, Rejected
}
try
{
    store.Get("nope");
}
catch (KvException.KeyNotFound e)
{
    Console.WriteLine($"{e.Code} {e.Key}: {e.Message}"); // 1001 nope: key not found: nope
}

The message is the producer’s (a Rust producer’s Display output); a code class constructed in C# defaults to the IDL message. If the API declares a type named NativeException or NativeBugException, that class takes the namespace as a prefix (KvstoreNativeException).

Async and cancellation

An async function returns Task or Task<T>. The launcher receives an [UnmanagedCallersOnly] completion and a GCHandle to the pending call; the completion runs on a producer thread, decodes the result, releases everything the call owned, and completes the task (continuations run asynchronously). A cancellable function also takes a CancellationToken:

public static Task<string> DoCancellable(string input, CancellationToken cancellationToken = default)
{
    if (cancellationToken.IsCancellationRequested)
    {
        return Task.FromCanceled<string>(cancellationToken);
    }
    var inputBytes = Ffi.Utf8(input);
    var ffiCall = new FfiCall<string>(cancellationToken);
    try
    {
        fixed (byte* inputPtr = inputBytes)
        {
            NativeMethods.kitchen_sink_kitchen_do_cancellable(inputPtr, (nuint)inputBytes.Length, ffiCall.CancelToken(), &CompleteDoCancellable, ffiCall.Context);
        }
    }
    catch
    {
        ffiCall.Abandon();
        throw;
    }
    return ffiCall.Task;
}

CancelToken() creates the native token and registers the CancellationToken to cancel it. When the producer completes with -5, the task becomes canceled, so await throws TaskCanceledException (an OperationCanceledException). The consumer’s token reference is destroyed after completion, and a registration racing the completion never touches a destroyed token.

Callbacks

A callback interface becomes a C# interface the consumer implements. Its methods take and return the same C# types as the rest of the API:

public interface IPolicy
{
    /// <exception cref="KvException">Reported to the library with its code and fields.</exception>
    Entry Admit(Entry entry);

    Store Route(string key, Store home);
}

public interface ILoader
{
    string Name();
    Store? Fallback(string key);
    /// <exception cref="KvException">Reported to the library with its code and fields.</exception>
    byte[] Load(string key);
}

Passing an implementation pins it in a GCHandle (the ctx) and hands the producer one process-wide vtable of [UnmanagedCallersOnly] trampolines, which starts with the {size, flags, free} header (size is the vtable’s real size as C# lays it out). The free entry releases the handle when the producer drops its last reference, possibly on another thread. An optional callback parameter (ILoader? loader) passes a null vtable for null.

Trampolines run on whatever thread the producer calls from. String, bytes, and buffer arguments are copied, and object arguments are adopted: each is a new wrapper the implementation owns. Return values go back to the producer by family:

  • a scalar, bool, or C-style enum is the C return;
  • an object is a fresh reference the producer adopts (CloneHandle()); a null for a required object reaches the producer as null, which it rejects as a marshalling failure (-3);
  • a string, bytes, record, rich enum, optional, list, or map is encoded into a run allocated with {prefix}_alloc and written to the method’s out slots, which the producer adopts and frees.

An exception thrown by an implementation never unwinds into native code. A method declared throws that throws its module’s domain exception reports the code, the message, and the code’s fields (encoded as the error payload with {prefix}_error_set_payload), so the producer sees exactly that domain error:

public Entry Admit(Entry entry)
{
    if (entry.Key.StartsWith("secret"))
    {
        throw new KvException.Rejected(entry.Key, "no secrets", "secrets are not stored");
    }
    return entry;
}

Any other exception, or one thrown from a method that isn’t throws, is reported as -4 with the exception’s message. What the failure means for the call in progress is the producer’s decision. When a Rust producer propagates it with ?, the original (throwing) call throws that same domain exception, or NativeException with ForeignErrorCode for a -4.

Iterators

An iter<T> function launches eagerly, so launch errors throw at the call, and returns a single-use IEnumerable<T> that pulls one item per MoveNext. The native iterator is a SafeHandle, destroyed when enumeration ends, when the enumerator is disposed (foreach does this on early exit), or by its finalizer. Enumerating the same sequence twice throws InvalidOperationException.

Known limitations

  • A module class whose name equals the namespace (a calculator module in the Calculator package) must be reached as Calculator.Calculator or through using static.
  • Only one DllImportResolver can exist per assembly. If you compile the generated sources into an assembly that already has one, the {PREFIX}_LIBRARY override is skipped and default probing applies.
  • Async functions that aren’t cancellable take no CancellationToken.
  • Object references cloned into a value buffer leak if encoding a later field throws before the call (for example a disposed wrapper in the same list).
  • C# can name an undeclared enum value ((Color)3); the producer rejects it as a marshalling failure (-3).

Dart

The Dart target generates a standalone Dart package that calls the producer’s C ABI (revision 4) through dart:ffi. It works in Dart command-line apps and in Flutter apps on the desktop, Android, and iOS. Records become value classes, rich enums sealed class hierarchies, interfaces disposable wrapper classes, async functions Futures, and callback interfaces abstract classes you implement.

What gets generated

dart/
  pubspec.yaml         package `{package}`, depends on `ffi`
  README.md
  lib/{package}.dart   the whole binding: runtime, types, and functions

The package name defaults to the library’s C prefix (kvstore for the kvstore sample), so consumers write import 'package:kvstore/kvstore.dart';. Module-level functions use their bare names (openStore, renderReport), which global names keep unique.

Configuration

[generators.dart]
name = "kvstore"          # package name and library file (default: the C prefix)
sdk = ">=3.10.0 <4.0.0"   # the pubspec's SDK constraint (this is the default)

The package needs Dart 3.10 or newer, the first release with NativeCallable.isolateGroupBound, which delivers void callback methods from any thread (see Threading), and the ffi package, which supplies Arena and calloc.

Install, build, and load

Add the generated package as a dependency, for example with a path: dependency, and run dart pub get:

dependencies:
  kvstore:
    path: bindings/dart

The bindings open the native library on first use: libkvstore.dylib on macOS, libkvstore.so on Linux and Android, and kvstore.dll on Windows, found through the platform’s normal search. Set KVSTORE_LIBRARY (the identity’s {PREFIX}_LIBRARY variable) to a full path to load a specific build. On iOS, and on macOS when the file isn’t found, the bindings look the symbols up in the running executable, which is how a statically linked or embedded library is found.

weaveffi package writes the pub package dart/{package}/ with each desktop platform’s library under native/<platform>/. The packaged loader picks the running platform’s library (by Abi.current()) and looks for it in the package itself, resolved with Isolate.resolvePackageUriSync, then under the working directory (for a compiled app that copied native/ next to itself), and only then on the system search path. {PREFIX}_LIBRARY still wins over all of them. See Packaging.

Load-time checks

Before any other call, the bindings check the library and throw a NativeLibraryError (an Error) if it doesn’t match:

  • kvstore_abi_version() must report revision 4.
  • For every top-level module, the bindings embed the contract entries they were generated with (one (id, hash, path) record per declaration), call kvstore_{module}_contract, and require each entry to be present with an equal hash. A missing entry fails with “kvstore: kv.Store.put is missing from the library”; a different hash with “kvstore: kv.Store.put changed since these bindings were generated”. Declarations the library has and the bindings don’t are fine, so adding a function never breaks a deployed binding.

Type mapping

IDL typeDart typeCrosses the C ABI as
i8 to i64, u8 to u64intby value (u64 as its two’s-complement bit pattern)
f32, f64doubleby value
boolboolC bool
stringStringUTF-8 (ptr, len), so interior NULs survive
bytesList<int> (a Uint8List when returned)(ptr, len)
Recorda final class with final fields, named constructor arguments, and value equalityvalue buffer
Plain enuman enhanced enum with value and fromValueint32_t
Rich enuma sealed class with one final subclass per variantvalue buffer
Interfacea final class wrapperobject pointer
T?T?value buffer (Interface? is a nullable pointer, Cb? a null vtable)
[T], {K:V}List<T>, Map<K, V>value buffer
iter<T>Iterable<T>native iterator
Callback interfacean abstract class you implementctx plus vtable

Records and rich-enum variants implement ==, hashCode, and toString: lists, maps, and byte arrays compare element by element, and interface wrappers by identity. Wrapper functions and members use lowerCamelCase; types use UpperCamelCase. A name that collides with a Dart keyword or a type the bindings use gains a trailing _ (class_, String_), as does an interface member named like a member every wrapper declares or inherits (dispose, hashCode, toString, runtimeType, noSuchMethod): a method dispose is dispose_(). See Naming.

Each distinct optional, list, or map type gets one private codec pair (_packListOfEntry, _unpackMapOfStringToString), built on the runtime’s generic writeList/readList helpers, so no call site inlines a loop.

Objects and lifetime

Each wrapper holds one strong reference. Call dispose() when you’re done; an undisposed wrapper is released by a NativeFinalizer when it’s collected. Disposing twice is harmless, and using a disposed wrapper throws a StateError.

final store = Store.open('/tmp/data');      // factory constructor
store.put('key', [1, 2, 3], EntryKind.persistent, null);
final copy = store.share();                 // a second reference
store.dispose();
copy.count();                               // still valid
copy.dispose();

Every call borrows the wrapper’s pointer for its duration. If a callback disposes a wrapper that a call in progress is using, the release waits until that call returns, so the native object never disappears mid-call. Wrappers are Finalizable, so Dart keeps them alive until the end of any call that uses them. An object written into a value buffer (a record field, a list element) is encoded as a fresh reference, so the wrapper stays usable.

Errors

A module’s error domain becomes a sealed exception class, extending NativeException, with one final subclass per code. Payload fields are typed properties, and the code’s documented message is the default for the optional trailing message argument:

try {
  store.get('missing');
} on KeyNotFoundException catch (e) {
  print('${e.code}: ${e.message} (${e.key})');   // 1001: key not found: missing (missing)
} on KvException {
  // any other KvError code
}

Only functions declared throws raise exceptions. A positive code becomes its domain subclass; a negative runtime code becomes a plain NativeException with that code. The runtime codes are constants on NativeException: genericCode (-1), panicCode (-2), marshalCode (-3), foreignCode (-4, a callback implementation failed), and cancelledCode (-5, raised as CancelledException).

A failure of a function that isn’t declared throws is a bug in the producer, so it follows the trap policy: the binding throws a NativeError, an Error (not an Exception) carrying the runtime code and the producer’s message. Cancellation always surfaces as CancelledException.

Async and cancellation

An async function returns a Future. The producer completes it from a worker thread through a NativeCallable.listener, and the result is decoded on the event loop.

A cancellable function takes an optional named CancelToken:

final token = CancelToken();
final pending = store.compact(60000, cancelToken: token);
token.cancel();
try {
  await pending;
} on CancelledException {
  // completed with code -5 without waiting for the work
}

One token can serve any number of calls, a token that’s already cancelled cancels a call at launch, and cancelling after completion does nothing. The bindings create a native token for each call, cancel it when the CancelToken is cancelled, and destroy it when the call completes.

Callbacks

Implement the generated abstract class and pass an instance. The bindings keep it in a table until the producer releases it through the vtable’s free, so you don’t need to hold another reference. An optional callback parameter (Policy?) takes null for none, which passes a null vtable.

class AdmitAll implements Policy {
  @override
  Entry admit(Entry entry) {
    if (entry.key.startsWith('secret')) {
      throw RejectedException(entry.key, 'no secrets', 'secrets are not stored');
    }
    return entry;
  }

  @override
  Store route(String key, Store home) => home;
}

store.setPolicy(AdmitAll());

A method that returns a value hands it to the producer in the shape the ABI expects: a direct value by value; an object as a fresh strong reference the producer adopts; and a string, bytes, or record as a run the bindings allocate with {prefix}_alloc and the producer frees. Object arguments are owned by the implementation; call dispose() on them when you’re done (or let the finalizer release them).

Exceptions never unwind into the producer. In a method declared throws, throwing the module’s domain exception (here a KvException subclass) reports its code and its fields, encoded as a value buffer, so the producer sees exactly that error: put above fails with the same RejectedException. Any other exception, or any exception from a method that isn’t declared throws, reports foreignCode (-4) with the exception’s text.

Threading

How a method runs depends on its return type:

  • A method that returns a value is a NativeCallable.isolateLocal trampoline. It runs synchronously, so the producer must call it on the isolate’s thread while a call from Dart is in progress, as a policy or a filter consulted during the call is.
  • A void method is a NativeCallable.isolateGroupBound forwarder. The producer may call it from any thread; the forwarder copies its arguments and returns at once, and the method runs later on the event loop, in the zone that passed the instance. An exception there is an uncaught error of that zone, and a void method can’t fail the producer’s call even when it’s declared throws. The vtable’s free is forwarded the same way, after every call made before it.

A value-returning method called from any other thread (a producer worker thread, an async executor) aborts the process. That’s a Dart VM rule, not a choice of these bindings. Only an isolateLocal callable can run the isolate’s code synchronously, and the VM aborts before any Dart code runs when one is called from a thread that isn’t running the isolate. The entry points that accept any thread can’t take its place: an isolateGroupBound callable runs outside the isolate, even on the isolate’s own thread, so it can neither run the method nor hand it to the isolate while the isolate is blocked in the very call that invoked it, and a listener returns before the method runs. So don’t give a producer an implementation with value-returning methods that it calls from its own threads. In the kvstore sample, a Listener’s accepts is consulted by compact on a worker thread, so no listener should be subscribed while a compaction runs.

Iterators

An iter<T> return is a lazy Iterable<T>: each step pulls one element from the native iterator, and iterating again starts a new one. The native iterator is destroyed when iteration finishes or fails, or by a finalizer when an iteration is abandoned (first, take, a break) and collected.

for (final key in store.keys('user.')) {
  print(key);
}

Performance notes

Synchronous calls share one error slot and one length slot per isolate, and stage arguments in a single Arena per call. When the API declares no callback interfaces, synchronous calls are bound as leaf calls (isLeaf: true), which skips the VM’s safepoint transition; a leaf call blocks this isolate group’s garbage collector until it returns, so it suits fast functions.

Known limitations

  • A value-returning callback method called from a thread that isn’t the isolate’s (a producer worker thread, an async executor) aborts the process; void methods are safe from any thread (see Threading).
  • Void callback methods are always delivered asynchronously, even when the producer calls them on the isolate’s thread, so their exceptions can’t fail the producer’s call.
  • NativeCallable.isolateGroupBound, which callback interfaces rely on, is marked experimental in the Dart SDK (“may change in the future”), so a future SDK release could change how it’s enabled.
  • Library loading uses DynamicLibrary; Flutter’s native-assets build hooks aren’t generated.

Go

The Go target emits a self-contained cgo module that binds the library’s C ABI. Records and rich enums are plain Go values, interfaces are reference-counted wrappers with Close, throwing calls return error values you match with errors.As, async functions block on a context.Context, and iter<T> returns standard-library iter.Seq sequences, so the module requires Go 1.23 or later.

What’s generated

For a library whose identity is kvstore:

go/
  go.mod        module path (the package name, or `name`)
  README.md
  kvstore.h     a copy of the C header
  bindings.go   the API: types, wrappers, trampolines, load-time checks
  runtime.go    load-time checks, errors, strings, objects, callbacks, async
  codec.go      the value-buffer writer and reader

Every name comes from the package identity:

NameDefaultOverride
Module paththe package name[generators.go] name
Package namethe C prefixnone
Linked library-l{library}none
Header{library}.hnone
[generators.go]
name = "github.com/example/kvstore"

All modules render into one Go package, and Go spells every exported name in PascalCase with Go’s initialisms (user_id is UserID, ttl_seconds is TTLSeconds):

DeclarationGo name
function kv.stats.summarizeSummarize
interface Store*Store
constructor Store.openOpenStore (and new is NewStore)
method Store.get_or_load(*Store).GetOrLoad
static Store.default_capacityStoreDefaultCapacity
C-style enum EntryKind, variant VolatileEntryKind, EntryKindVolatile
rich enum Change, variant PutChange, ChangePut
error domain KvError, code KeyNotFoundKvError, *KeyNotFoundError

A free function whose bare name would clash with a type, an enum constant, a constructor, a static, an error type, the runtime’s Error or DebugLive, or another free function’s bare name is prefixed with its module path: the kvstore sample’s async kv.open_store sits beside the OpenStore constructor, so it’s KvOpenStore (and a function in module kv.stats would get a KvStats prefix). Methods never clash this way, since each lives on its own type.

Build and load

The bindings include the bundled header and link with #cgo LDFLAGS: -l{library}, so a consumer supplies only the library’s directory:

export CGO_LDFLAGS="-L/path/to/lib"
export LD_LIBRARY_PATH="/path/to/lib"   # DYLD_LIBRARY_PATH on macOS
go build ./...

cgo links the library into the program when it’s built, and the platform’s dynamic loader finds it when the program starts. The bindings never open the library themselves, so the {PREFIX}_LIBRARY override the other targets read at load time doesn’t exist for Go; to choose the library at run time, set the loader’s search path (LD_LIBRARY_PATH, DYLD_LIBRARY_PATH, or PATH on Windows). To record the directory in the binary instead, add an rpath when building:

export CGO_LDFLAGS="-L/path/to/lib -Wl,-rpath,/path/to/lib"

weaveffi package writes the module to go/{package}/ with each desktop platform’s library under lib/<platform>/ and replaces the single link line with one per GOOS and GOARCH, so cgo picks the right library with no CGO_LDFLAGS:

/*
#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/lib/darwin-arm64 -Wl,-rpath,${SRCDIR}/lib/darwin-arm64
#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/lib/linux-x64 -Wl,-rpath,${SRCDIR}/lib/linux-x64
#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/lib/windows-x64
#cgo LDFLAGS: -lkvstore
*/

${SRCDIR} expands to the module’s directory (in the module cache, for a dependency), so a program built on a machine runs there without a loader path. A binary you ship elsewhere still needs the library beside it or on the loader’s path: copy it and link with an rpath such as -Wl,-rpath,$ORIGIN (@executable_path on macOS). Windows has no rpath; put the DLL next to the executable or on PATH. Android, iOS, and wasm32 libraries aren’t bundled. Publish the module by pushing the directory to the module path’s repository; see Packaging.

Load-time checks

Importing the package runs init, which checks the library’s ABI revision and then, for every top-level module, the library’s contract table against the entries the bindings were generated with:

func init() {
	wvCheckABI()
	var n C.size_t
	table := C.kvstore_kv_contract(&n)
	wvCheckContract(table, n, []wvContractEntry{
		{0x0969575bfbb012d7, 0xebd38766e3532c4f, "kv.Store.fork"},
		// ...one entry per declaration
	})
	table = C.kvstore_report_contract(&n)
	wvCheckContract(table, n, []wvContractEntry{
		{0xa21a2e7274bf28c6, 0x342e0b83b12d73ec, "report.render_report"},
		{0xd504fae45f64ab45, 0xd430b8de9d574a4c, "report.ReportError"},
	})
}

A mismatch panics with an error naming the declaration: kvstore: kv.Store.put is missing from the library or kvstore: kv.Store.put changed since these bindings were generated. Declarations the library has and the bindings don’t are fine, so a library that only adds functions keeps working with older bindings.

Type mapping

IDL typeGo typeCrosses the ABI as
i8 to i64, u8 to u64int8 to int64, uint8 to uint64the C integer
f32, f64float32, float64float, double
boolboolC bool
stringstringborrowed (ptr, len) UTF-8; returns are copied and freed
bytes[]byteborrowed (ptr, len); returns are copied and freed
C-style enumtype E int32 plus E{Variant} constantsint32_t
rich enumsealed interface plus one struct per variantvalue buffer
recordplain struct with exported fieldsvalue buffer
T?*T; a slice, map, rich enum, []byte, or wrapper stays as it is and nil is nonevalue buffer
[T], {K: V}[]T, map[K]Vvalue buffer
interface*Ifaceobject pointer
Iface?*Iface (nil is none)object pointer or null
callback interfacea Go interface you implementhandle plus a static vtable
Cb?the same interface (nil is none)handle plus a vtable, or a null vtable
iter<T>iter.Seq[T] or iter.Seq2[T, error]iterator handle

Strings pass a view of Go memory with no copy and no NUL terminator, so they may contain \x00. A Go string can hold bytes that aren’t UTF-8; the library rejects them as a marshalling failure (-3).

Each type that crosses inside a value buffer has one codec pair, wvWrite{T} and wvRead{T}, built from the pairs of its parts and shared by every parameter, result, field, and callback argument of that type:

func wvWriteMapStringStore(w *wvWriter, v map[string]*Store) {
	wvWriteMap(w, v, (*wvWriter).writeString, wvWriteStore)
}

Objects and lifetime

Each wrapper owns one strong reference. Close() error releases it (so a wrapper satisfies io.Closer), and a finalizer releases it if you forget. Every call borrows the native pointer for its duration:

func (s *Store) Get(key string) (Entry, error) {
	cSelf := s.native()
	defer s.ref.release()
	cKeyPtr, cKeyLen := wvStr(key)
	var cRetLen C.size_t
	var cErr C.kvstore_error
	cRet := C.kvstore_kv_Store_get(cSelf, cKeyPtr, cKeyLen, &cRetLen, &cErr)
	if cErr.code != 0 {
		return Entry{}, wvKvError(wvTakeError(&cErr))
	}
	return wvDecode(cRet, cRetLen, wvReadEntry), nil
}

Close is idempotent and goroutine-safe. When it races a call in flight (even one that closes the wrapper from inside a callback), the reference is released as soon as that call returns, never mid-call. Using a wrapper after Close, or passing a nil *Iface where an object is required, panics. An object inside a record, list, or map crosses as a token that carries a fresh reference, so the wrapper you passed stays valid. Two wrappers may refer to the same object (a method that returns its receiver hands back a new wrapper); each is closed on its own.

Because every wrapper has Close, an IDL method named close is Close_ (reserved member names).

Errors

A function declared throws returns (T, error). A positive code comes back as its code’s type, a pointer to a struct holding the code’s payload fields and a Message, and every code type of a domain implements the domain’s sealed interface:

_, err := store.Get("missing")
var missing *kvstore.KeyNotFoundError
if errors.As(err, &missing) {
	fmt.Println(missing.Key, missing.Code()) // missing 1001
}
var kvErr kvstore.KvError
if errors.As(err, &kvErr) {
	// any code of the kv domain
}

Error() returns the library’s message, or the code’s default message when Message is empty. A runtime code (-1 generic, -2 a panic in the library, -3 a marshalling failure, -4 a callback failure) or a code outside the domain comes back as the package’s *Error, which keeps the code and message and never matches a domain interface.

A function that isn’t throws can only fail because of a bug, so its wrapper has a plain signature and panics with an *Error instead (see the trap policy). The panic is an ordinary recoverable one, and its text names the code and the library’s message, as in kvstore: <message> (code -3).

Async and cancellation

Async functions take a context.Context first and always return an error. They block the calling goroutine on a channel that a completion trampoline fills from the library’s thread; call them from a goroutine of your own for concurrency. A cancellable function creates a native cancel token, cancels it when the context is done, and returns ctx.Err() (context.Canceled or context.DeadlineExceeded) for the cancelled completion (code -5), or context.Canceled if the library cancelled the call on its own:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
removed, err := store.Compact(ctx, 60000)
if errors.Is(err, context.DeadlineExceeded) {
	// The native call was cancelled.
}

Any other async function returns ctx.Err() as soon as the context is done and discards the native result when it arrives. A context that’s already done never launches the call. A throwing async function returns its domain errors like a sync one; any other panics on failure.

Callbacks

A callback interface is a Go interface. Passing an implementation stores it in a handle table and hands the library the address of one static vtable per interface: the header’s size and flags, the free entry that deletes the handle once the library is done, and one //export trampoline per method. The library may call methods from any thread, and free may run on any thread too.

type Policy interface {
	// Admit an entry about to be stored, returning it as it should be
	// stored. ...
	//
	// Return a KvError to report that code, with its fields, to the native
	// caller.
	Admit(entry Entry) (Entry, error)
	// Route returns the store `key` belongs in: ...
	Route(key string, home *Store) *Store
}
  • Arguments. Strings, bytes, and buffered values are copied or decoded for the call. An object argument (home above) is a new wrapper that owns its own reference: keep it, return it, or Close it (the finalizer releases it otherwise).
  • Returns. A method may return any type but an iterator or a callback interface. A direct value returns by value; a string, []byte, or buffered value is copied into a run allocated with the library’s allocator; an object returns a fresh reference to the wrapper’s object (the wrapper stays yours). Returning nil for a required object, or a value the library can’t read (text that isn’t UTF-8, an undeclared enum value), fails the call in progress with -3.
  • Errors. A method declared throws returns (T, error) or error. Returning one of the domain’s code types (for example &kvstore.RejectedError{Key: k, Reason: "no secrets", Message: "secrets are not stored"}) reports that code, its message, and its payload fields, so the original caller receives exactly that error. Any other error reports -4 with its text. A method that panics, throwing or not, is recovered and reported as -4 with the panic’s text; nothing unwinds into the library.
  • Optional callbacks. A Cb? parameter accepts nil, which passes a null vtable. A required one panics on nil.

Iterators

An iter<T> return becomes a lazy sequence: each range launches the native iterator, pulls one element per step, and destroys the iterator when the loop ends, including on an early break. A throwing function returns an iter.Seq2[T, error] that yields its error as a final (zero, err) pair:

for key, err := range store.Keys(nil) {
	if err != nil {
		return err
	}
	fmt.Println(key)
}

Leak checks

DebugLive(kind) reports the library’s live objects (0), callbacks (1), iterators (2), cancel tokens (3), and byte runs (4), and DebugLive(-1) is 1 when the library counts at all. Every count is 0 unless the library was built with the leak-check feature. Wrappers you don’t close are released by finalizers, so run runtime.GC() before reading the counts.

Limitations

  • All modules share one Go package, which the global naming rules make safe; only a free function whose Go name collides gains its module path (see What’s generated).
  • The library is bound at link time, so there’s no run-time override of which library file loads beyond the platform loader’s search path.
  • A non-cancellable async call abandoned by its context keeps running in the library until it finishes.

Ruby

The Ruby target generates a pure-Ruby gem that binds the C ABI (revision 4) through the ffi gem. There’s no native extension to compile: the gem depends on ffi ~> 1.15 and loads the producer’s shared library at require time. Ruby 2.7 or newer is required.

What gets generated

For a package named kvstore (prefix kvstore, library kvstore):

ruby/
  kvstore.gemspec         gem `kvstore`, depends on ffi ~> 1.15
  README.md
  lib/
    kvstore.rb            require 'kvstore': module Kvstore, the bindings
    kvstore/
      runtime.rb          loader, load-time checks, errors, codec, object base

Names come from the package identity:

NameDefaultOverride ([generators.ruby])
Gemthe package name (key-store)name
Ruby modulethe package name in PascalCase (KeyStore)module_name
require paththe C prefix (key_store)none

Every IDL module, nested ones included, lands in the one Ruby module, and a function keeps its bare name (render_report in module report), which global names keep collision-free.

lib/{prefix}/runtime.rb is fixed code shipped with the generator (the source lives in targets/ruby/runtime/runtime.rb); only the module name, the C prefix, and the library names are substituted into it.

Install, build, and load

weaveffi generate samples/kvstore -o bindings --target ruby
cargo build --release -p kvstore
cd bindings/ruby
gem build kvstore.gemspec
gem install kvstore-1.0.0.gem

The loader opens the library in this order:

  1. the path in {PREFIX}_LIBRARY (KVSTORE_LIBRARY=/path/to/libkvstore.dylib),
  2. a copy bundled at lib/native/ (platform gems from weaveffi package),
  3. libkvstore.dylib, libkvstore.so, or kvstore.dll on the system search path.
require 'kvstore'

store = Kvstore::Store.open('/tmp/data')
store.put('greeting', 'hi'.b, Kvstore::EntryKind::VOLATILE)
store.close

weaveffi package --target ruby writes one precompiled platform gem per desktop platform built, ruby/{gem}-{version}-{platform}.gem (arm64-darwin, x86_64-darwin, x86_64-linux, aarch64-linux, x64-mingw-ucrt), with the library at lib/native/. The gems install with gem install and publish with gem push; Android, iOS, and wasm32 builds are skipped. See Packaging.

Load-time checks

require checks, before anything else is attached, that the library implements C ABI revision 4, and then that every declaration the bindings were generated with is in its top-level module’s contract table with the same signature. The expected entries are embedded in the bindings:

module Kvstore
  _wv_check_contract!(
    kvstore_kv_contract: [
      [0x0969575bfbb012d7, 0xebd38766e3532c4f, 'kv.Store.fork'],
      # ...one entry per declaration in `kv` and its submodules
    ],
    kvstore_report_contract: [
      [0xa21a2e7274bf28c6, 0x342e0b83b12d73ec, 'report.render_report'],
      [0xd504fae45f64ab45, 0xd430b8de9d574a4c, 'report.ReportError'],
    ],
  )

A failed check raises LoadError naming the declaration (kvstore: kv.Store.put is missing from the library or kvstore: kv.Store.put changed since these bindings were generated). Declarations the library has and the bindings don’t are fine, so a library that only adds to its API keeps working with older bindings.

Type mapping

IDL typeRuby typeC slots
i8 to i64, u8 to u64Integer:int8 to :uint64
f32, f64Float:float, :double
booltrue or false:bool
stringString (UTF-8):pointer + :size_t
bytesString (binary):pointer + :size_t
C-style enumInteger constants in a module:int32
recordplain value class with keyword initialize and ==value buffer
rich enumbase class with one nested class per variantvalue buffer
T?T or nilvalue buffer
[T], {K: V}Array, Hashvalue buffer
interfacewrapper class:pointer
Interface?wrapper or nil:pointer (NULL for nil)
callback interfaceany object with the methodscontext + vtable
Cb?such an object or nilcontext + vtable (NULL for nil)
iter<T>lazy Enumeratoriterator handle

Trailing optional parameters (T?, Interface?, Cb?) default to nil, so store.keys is store.keys(nil).

Strings cross as a pointer plus a length, so interior NUL characters survive. A string argument must be a String (or convert with to_str) holding valid UTF-8; anything else raises TypeError or ArgumentError before the call. Returned strings are copied, tagged UTF-8, and released with {prefix}_free_bytes. Integers written into a value buffer are range-checked (RangeError rather than silent wrapping), and an object argument must be a wrapper of the declared interface (TypeError otherwise).

Value buffers use the value-buffer format, encoded and decoded by the runtime’s WvBufferWriter and WvBufferReader and by generated codec methods: one _wv_write_*/_wv_read_* pair per record and rich enum, and one per distinct composite type the API uses ([Entry?] is _wv_write_list_opt_entry, {string:string} is _wv_write_map_string_string), so no call site inlines a loop.

Objects and lifetime

Each interface becomes a subclass of the runtime’s WvObject, holding one strong reference in an FFI::AutoPointer:

  # An embedded key-value store.
  class Store < WvObject
    WV_PTR = StorePtr

    # Create an empty store with the path `memory`.
    def initialize
  • close releases the reference now and is idempotent; otherwise the GC finalizer releases it. The object itself lives until its last reference anywhere (another wrapper, a record field, the library) goes.
  • dup and clone make an independent wrapper with its own reference.
  • A constructor named new becomes initialize; other constructors are class methods (Store.open). An interface without one hides new.
  • Every call pins its receiver and object arguments (_wv_pin), so a close from another thread or from a callback during the call takes effect only when the call returns.
  • An object inside a value buffer carries its own reference, minted just before the call; an object decoded from one is adopted into a new wrapper.
  • Using a closed wrapper raises Error (“Kvstore::Store used after close”).
  • A method spelled like one the wrapper relies on (close, handle, initialize, class, dup, hash, …), or a static or factory named allocate or name, gains a trailing _: an IDL method close is close_ (reserved member names).

Errors

Every error derives from {Module}::Error, which carries code. A module with an error domain gets a domain class (KvError < Error) with one nested class per code (KvError::KeyNotFound, carrying CODE and any payload fields as attributes):

begin
  store.get('missing')
rescue Kvstore::KvError::KeyNotFound => e
  e.code    # => 1001
  e.key     # => "missing"
  e.message # => "key not found: missing"
end

What a failure raises follows the trap policy:

CallDomain codeRuntime code (-1 to -4)-5
declared throwsthe code’s class (KvError::Expired)Error with that code and the library’s messageCancelled
not throwsnot possibleNativeBugErrorCancelled

NativeBugError < Error marks a bug in the library (a panic, an argument it couldn’t accept, a callback failure it let through a call that can’t report one), never an outcome to handle. Its message names the code and the library’s message, as in native call failed with code -2: {the panic message}; a malformed value the library returned raises it with code -3.

Async and cancellation

An async function blocks the calling thread until the library’s completion fires, then returns the result or raises. The wait is a Queue#pop, which releases the GVL, so other threads keep running; run the call in a Thread for concurrency. A cancellable function takes a cancel: keyword:

token = Kvstore::CancelToken.new
Thread.new { sleep 0.5; token.cancel }
begin
  store.compact(60_000, cancel: token)
rescue Kvstore::Cancelled
  # cancelled while the pause was running
ensure
  token.close
end

CancelToken#cancel may be called from any thread; the call then raises Cancelled (code -5) unless it already completed. A token stays cancelled, can be shared by several calls, and is released by close or the GC. Without a token, interrupting the waiting thread (Thread#raise, Timeout) still cancels the library’s work through a private token. Each async function has one completion trampoline constant, so nothing the library may still call is ever garbage-collected.

Callback interfaces

A callback interface becomes a Ruby module whose methods raise NotImplementedError. Any object that responds to the methods works; including the module documents intent and supplies the defaults.

class FileLoader
  include Kvstore::Loader

  def name
    'files'
  end

  def fallback(_key)
    nil
  end

  def load(key)
    path = File.join('/data', key)
    raise Kvstore::KvError::KeyNotFound.new("no file for #{key}", key: key) unless File.exist?(path)

    File.binread(path)
  end
end

entry = store.get_or_load('settings', FileLoader.new)

Passing an implementation stores it in a handle table and hands the library its key, so Ruby object addresses never cross the boundary; the entry is removed when the library calls the vtable’s free, which may happen on any library thread. Each interface has one static vtable (an FFI::Struct whose {size, flags, free} header is followed by one trampoline per method). A required callback parameter rejects nil with TypeError; an optional one (loader = nil) passes a null vtable.

Arguments and returns follow the ABI:

  • Strings, bytes, and value buffers arrive as copies; an object argument arrives as a wrapper that adopted the reference the library passed, which the implementation may keep.
  • A returned number, boolean, or C-style enum crosses by value (checked against its type’s range); a returned object (Store or Store?) as a fresh reference the library adopts (nil is null, which the library rejects with -3 where the return isn’t optional); and a returned string, bytes, or value buffer (a record, a rich enum, an optional, a list, or a map) as a run allocated with {prefix}_alloc, which the library adopts.
  • A method declared throws in the IDL may raise one of its module’s domain error classes: the library receives its code, its message, and its fields as the payload, exactly as if the producer had raised it. Any other exception (including NotImplementedError, and a domain error from a method not declared throws) reaches the library as a callback failure (-4) with the exception’s message. Nothing unwinds through C.

Threads and the GVL

Every call into the library except the trivial runtime helpers (_clone, _destroy, an iterator’s _destroy, {prefix}_free_bytes, {prefix}_alloc, {prefix}_debug_live, the error and cancel-token functions, and the load-time checks) releases the GVL (blocking: true), so a long call doesn’t stall other Ruby threads. That’s also what lets a callback arrive while the call that triggered it is in flight: a callback on the calling thread reacquires the GVL there, and one from a library thread (such as the notifications compact sends from its worker) runs on the ffi gem’s callback thread, which needs the GVL too.

Iterators

An iter<T> return is a lazy Enumerator. The library’s iterator starts on the first pull, each step makes one next call, and the handle is destroyed exactly once, when iteration finishes, raises, or stops early (first(2), break).

Known limitations

  • Async calls block the calling thread; there’s no Fiber-scheduler or promise-based surface.
  • An Enumerator driven externally with next and then abandoned releases its iterator only when the GC collects it.
  • Callback interfaces are duck-typed: a missing method is detected only when the library calls it.
  • A callback that blocks waiting for the thread that made the triggering call deadlocks.
  • A deprecated callable warns on every call (warn ... uplevel: 1); silence it with $VERBOSE = nil like any Ruby warning.

Architecture

This page is for contributors: how the workspace is organized, how a definition becomes bindings, how to add a generator, and how changes are tested. The C ABI contract is the specification everything here implements.

The pipeline

Every generating command (generate, diff, dev, package) runs the same stages:

Rust producer crate                        IDL (.yml / .json / .toml)
  cargo build, then the library's            weaveffi_model::parse
  metadata frames (weaveffi_cli::library,           |
  weaveffi_model::meta::assemble)                   |
            \                                 /
             +-------->  Api (the IR)  <------+
                            |
                            v
   validate        weaveffi_model::validate::validate(&api, &identity): every
                   rule, including the C symbol table under the library's
                   real Identity (from weaveffi.toml / cargo metadata); reports
                   all violations at once and builds the Model exactly once
                            |
                            v
   Model           the identity, the schema version, per-module bindings with
                   every C symbol and lowered ABI signature, and the type
                   index; the plan (ArgPass, RetPass, ErrorStrategy, and the
                   iterator, async, and callback protocols) is computed from it
                            |
                            v
   render          Target::render(&Model): each generator returns its files
                   in memory (pure; no I/O) and never sees the Api
                            |
                            v
   orchestrate     Orchestrator: writes changed files, removes stale ones,
                   keeps generation records

validate stops once the model is built; diff renders and compares without writing; package renders installable artifacts (wheels, npm tarballs, gems, …) around the per-platform libraries build compiled.

The producer macro runs the front half of the same pipeline inside the producer’s build. #[weaveffi::module] lowers its module tree with weaveffi_model::rust::extract_module, validates it into a Model with the same validator the CLI uses (validate_scoped, which accepts names declared in another tree as records or rich enums and leaves the macro to assert that at compile time), with an identity named after CARGO_CRATE_NAME, and reports each validation error on the offending item through the extractor’s SourceMap. It then computes the module’s contract table with weaveffi_model::contract::entries and emits extern "C" thunks instead of rendering a binding, wrapping each item’s output in that item’s #[cfg]. Finally it embeds the tree’s IR in the library: one weaveffi_model::meta::Frame per declaration, in an exported {PREFIX}_META_{HASH16} static (the weaveffi_meta custom section on wasm32) carrying the same #[cfg]. The CLI reads those frames back out of the built library (see Library Mode), so it generates from exactly the declarations the build compiled, with the same validator, model, and contract function, and the symbols and contract entries a producer exports are exactly those its generated bindings expect.

Crates

weaveffi-cli ─────────────────► weaveffi-model
                                     ▲
weaveffi ──► weaveffi-macros ────────┘   (proc macro, build time only)

weaveffi-fuzz ──► weaveffi-model         (unpublished)

The runtime (weaveffi::abi) depends on no other workspace crate at run time, so a producer never links the generators, and the generators never link the runtime. The ABI revision is therefore declared twice, in weaveffi::abi::ABI_VERSION and in the model (re-exported as weaveffi_cli::cabi::ABI_VERSION), and crates/weaveffi-cli/tests/abi_version.rs keeps them equal.

CrateOwns
weaveffi-modelEverything between a definition and code generation: the IR (ir), IDL parsing (parse, behind the default idl feature), Rust extraction for the macro (rust), library metadata frames (meta), validation and diagnostics (validate), resolved types and the type index (ty), package identity (pkg), the model with the symbol table (model), C ABI lowering (abi), the marshalling plan (plan), error naming (errors), and contract tables (contract). The macro uses it without the idl feature.
weaveffi-cliThe library (src/lib.rs): the backend framework and all eleven generators: backend (the LanguageBackend trait), codegen (the object-safe Target, ConfiguredBackend, the Orchestrator, and the CodeWriter toolkit), cabi (the shared C declaration renderer), cache (generation records), lang (keyword tables and escaping), manifest (JSON and XML escaping for package manifests), cargo (resolving a producer crate with cargo metadata), build (cross-compiling a producer per platform and prebuilding the Node.js and JNI glue), package (artifacts and the wheel, npm, gem, tarball, and zip writers) and platform (the platform matrix), config (weaveffi.toml and the target registry), project (locating a project, identity resolution, and loading its API), library (reading a built library’s metadata), utils (generated-file banners), and targets::{c, cpp, swift, kotlin, node, wasm, python, dotnet, dart, go, ruby}. The weaveffi binary (src/main.rs): argument parsing and one module per subcommand under commands/.
weaveffi-macros#[weaveffi::module], the marker attributes, and export_runtime!. Emission is split by concern under src/codegen/ (sync, async_fns, iterators, records, enums, interfaces, callbacks, contract, meta, foreign, diagnostics, helpers, lift).
weaveffiThe producer facade: re-exports the macros and the few runtime types a producer names. Its abi module is the runtime: the error struct and codes, (ptr, len) conversions, reference-counted objects, cancel tokens, callback vtables and foreign errors, iterators, the value-buffer codec, the async spawner (a worker pool by default, Tokio with the tokio feature) and run_async, contract tables, and leak counters (the leak-check feature).
weaveffi-fuzzcargo-fuzz targets for the parsers, parse_type_ref, the validator, and the value-buffer decoder.

The workspace denies unsafe_code; weaveffi::abi opts in, and the thunks the macro emits carry a scoped allowance, so a macro-based producer needs no unsafe of its own.

Key invariants

  • Generators consume the model only. Symbol names, ABI signatures, type families, and wire shapes come from Model, Ty::family(), and Ty::wire(); a generator never re-derives a symbol or reads the raw IR. Names come from the Identity on the Model.
  • Global names. Type names, free-function names, and error-code names are unique across the API, so a Ty carries the bare name (Store) and equality works across modules. Where a generator needs the declaring module (for a C type name or a namespace path), it asks the model’s type index (Model::owner, Model::interface, and so on); nothing splits a dotted string.
  • One symbol table. Model::c_symbols lists every C identifier with the declaration that owns it, and validation rejects duplicates.
  • Fixed runtime code is real source. A target’s codec, error base types, and loader live as source files under targets/<t>/runtime/, included with include_str! and filled in with {{PLACEHOLDER}} substitution, so they read and lint as normal code.
  • Determinism. No hash-map iteration reaches output; output is byte-identical across runs and platforms.
  • No branding. Nothing generated is named after WeaveFFI except the generated-file header and runtime-version comments.

The model and the plan

validate indexes every type declaration, checks every rule, and then walks the document once to produce the Model: the identity, the schema version, the type index (each name’s kind and declaring module), and one ModuleBinding per module (nested modules flattened, each with its segments, its underscore path, its dotted path, its own error domain if it declares one). A top-level module’s contract table is computed from the model on demand by weaveffi_model::contract::entries. A module without a domain inherits the nearest ancestor’s, found with Model::error_domain. Every function, interface member, and callback method carries:

  • its resolved parameter and return Tys, the idiomatic shape a generator renders;
  • its lowered AbiFn, the native shape: the C symbol and the ordered slots from weaveffi_model::abi (lower_param, lower_return, the async and callback signature helpers), whose C rendering is canonical;
  • its CallShape: Sync, Async (launcher, completion type, result slots, cancellability), or Iterator (launcher, iterator type, _next, _destroy).

Two classifications on Ty drive every generator’s dispatch, so no target re-derives them. Ty::family() is the slot family from the ABI contract (Direct, String, Bytes, Buffer, or Object with nullability). Ty::wire() is the shape inside a value buffer (Prim, Object, Enum, User, Optional, List, Map), which codec emitters match on.

weaveffi_model::plan states the calling contracts once, derived from the family: how each argument is passed (ParamBinding::arg_pass), what a wrapper does with a result (RetPass::of; an adopted object owes its interface binding’s destroy_symbol), whether a non-zero error code is a typed domain error or a trap (FnBinding::error_strategy), and the iterator, async, and callback protocols (protocol() on each binding). A generator renders these in its own syntax; it doesn’t decide them.

Backends and the orchestrator

A generator implements weaveffi_cli::backend::LanguageBackend:

pub trait LanguageBackend: Send + Sync {
    type Config: Default + Clone + Send + Sync;
    fn name(&self) -> &'static str;
    fn files(&self, model: &Model, out_dir: &Utf8Path,
             config: &Self::Config) -> Vec<OutputFile>;
    fn package(/* ... */) -> Option<Vec<Artifact>> { None }
}

ConfiguredBackend::new(backend, config) erases the backend to dyn Target, whose render(&Model, out_dir) returns OutputFiles. Each backend walks the model in the order its language needs and builds its own file layout in files.

The Orchestrator runs the selected targets:

  1. Render. Every target renders in parallel (rayon), in memory.
  2. Compare. Each file is compared with the output directory, and each target’s file list with its record from the previous run, {out}/.weaveffi-cache/{target}.json. When nothing differs, nothing is written.
  3. Write. Each file is written only if its contents differ, files recorded by the previous run but no longer produced are deleted, and the record is updated.

weaveffi diff uses render directly and compares in memory.

The CLI

The library’s config module holds ProjectConfig (the [project], [package], [build], and [generators.*] tables, with discovery and path resolution) and the cli_targets! registry: one line per target that expands to the typed [generators.<t>] field, the --target name, and the orchestrator registration. Its project module is the front half every command runs: Project locates the project and its input (an IDL, a crate, or a library alone), builds a crate’s library with cargo and reads its frames with library, resolves the identity, and validates the API into the Model once. A build.rs can use Project too. The binary’s commands/ has one module per subcommand: init, generate, dev, validate, diff, extract, build, package.

Adding a generator

  1. Add crates/weaveffi-cli/src/targets/<lang>/ following an existing target’s layout: mod.rs (config, generator, LanguageBackend impl), types.rs, codec.rs, calls.rs, entities.rs, package.rs, a runtime/ directory of fixed source, and tests.rs.
  2. Implement LanguageBackend. Take every name from the model and the identity; bind to {library} (and, if the language loads it at run time, accept a full path in {PREFIX}_LIBRARY); check the ABI revision and every top-level module’s contract table at load; follow the trap policy; surface cancellation idiomatically; keep objects alive across calls. Implement package if the ecosystem has an installable artifact.
  3. Register it with one line in cli_targets! in crates/weaveffi-cli/src/config.rs.
  4. Add it to snapshot_tests! in crates/weaveffi-cli/tests/snapshots.rs and accept the new snapshots.
  5. Add scripts/fixtures/<lang>.sh, which compiles or type-checks a generated tree with the language’s toolchain, and a CI matrix entry.
  6. Add consumers under conformance/<lang>/ with a run.sh that declares one lane per sample, and add the language to conformance/run.sh.
  7. Write docs/src/generators/<lang>.md, add it to SUMMARY.md, and add a row to the capability matrix.

Testing

Each layer catches a different class of regression; an ABI or IR change usually has to pass all of them.

LayerWhereWhat it pins
Unit tests#[cfg(test)] modules; tests.rs in each targetParsing, validation rules, Ty classification, lowering, naming helpers, metadata frames (meta)
Validation testscrates/weaveffi-model/src/validate/tests.rsEvery ValidationError with its source span
Property testscrates/weaveffi/tests/buffer_proptest.rsCodec laws: round trips, self-delimiting encodings, rejected trailing bytes
Runtime testscrates/weaveffi/tests/Exported runtime symbols, cancellation and dropped futures, foreign-error routing, leak counters
Macro testscrates/weaveffi-macros/tests/ui/ (trybuild)pass_*.rs compile; fail_*.rs fail with the pinned .stderr
Snapshotscrates/weaveffi-cli/tests/snapshots.rs (insta)Byte-exact output of every target: fixture-dependent files for every fixture in tests/fixtures/, fixed runtimes and manifests once, and copies of the C header asserted equal to the C target’s
CLI testscrates/weaveffi-cli/tests/cli/, crates/weaveffi-cli/tests/abi_version.rsEvery subcommand’s behavior and exit codes, library mode (the tests/fixtures/producer crate extracts to its expected.yml, and generating from its library equals generating from that IDL), determinism, no stub markers in output, the ABI revision lockstep
Fixture compile checksscripts/check-fixtures.sh <target>Every fixture’s generated tree compiles or type-checks with the target’s toolchain
Conformanceconformance/run.shReal consumers in every language against every sample, with leak counters at zero
Fuzzingcrates/weaveffi-fuzzParsers and the validator never panic; value-buffer decoders reject malformed bytes cleanly

The fixtures are kitchen_sink (every feature), edge_cases (reserved words, deep nesting, objects in every legal position), nested_modules (cross-module references and name clashes), docs_everywhere (doc-comment emission), and shapes (rich enums). Snapshots redact the CLI version, so a version bump doesn’t touch them.

Day to day:

just check                       # fmt, clippy (with the doc lints), rustdoc
just test                        # every test; fails on snapshot drift
just snapshots                   # accept reviewed snapshot changes
just fixtures swift              # fixture compile check for one target
ONLY=python just conformance     # conformance lanes for one language

See CONTRIBUTING.md for the full workflow.

Samples

The samples/ directory holds three Rust producers written with #[weaveffi::module]: a minimal one, a wire-format oracle, and a feature-complete one. Each is a workspace crate built as a cdylib with the leak-check feature on, has unit tests that call it directly, and is consumed by the conformance harness in all eleven languages, one consumer per sample per language.

weaveffi generate samples/kvstore -o bindings

Each sample’s crate name is its C prefix: kvstore exports kvstore_kv_Store_open, and its bindings load libkvstore (the targets that load it at run time also accept a path in KVSTORE_LIBRARY). weaveffi generate builds the crate and reads its API from the library; the weaveffi.toml beside each crate supplies package metadata.

calculator

The getting-started example, small enough to read in a minute: a calculator module with add (wrapping on overflow), divide, which throws the one-code CalcError domain on a zero divisor, and greet, a string in and a string out. The c-producer-exports lane also implements the calculator’s generated header by hand in C (conformance/c/producer.c) and checks that every required runtime and contract symbol is exported.

codec

The oracle for the value-buffer format, built on shared test vectors. The producer holds a fixed table of values covering every wire shape: each primitive at its extremes (with NaN, the infinities, -0.0, and subnormals for floats), empty and non-empty strings and bytes with non-ASCII text and interior NUL bytes, optionals, lists, maps keyed by strings, integers, and a C-style enum, nested records, C-style and rich enums, and objects inside buffers. Each vector is a variant of the Vector rich enum, so every language’s codec check is one loop:

  1. vector_count() returns how many vectors there are.
  2. vector(i) returns vector i for the consumer to decode. An index past the end fails with CodecError::OutOfRange { index, count }.
  3. check_vector(i, v) takes the decoded value back, re-encoded by the consumer, and returns true only if it’s exactly vector i (floats keep their sign, and any NaN matches NaN).
  4. vector_name(i) and describe_vector(v) label and render a vector, so a failing consumer can say what went wrong.

A round trip can’t catch a decoder and an encoder that make the same mistake, so each consumer also builds a few vectors from literals and checks those, and spot-checks decoded fields. The echo_* functions return their argument through the direct ABI families (scalars by value, strings and bytes as (ptr, len) runs, the C-style enum as an int32_t), and each consumer pushes every primitive vector through the matching echo. A Token interface with sum_holder, primary_of, and same_primary checks object identity and reference counting through buffers.

kvstore

The feature-complete producer: an in-memory key-value store whose API uses every feature WeaveFFI supports, arranged so a consumer can check each with concrete assertions. Time is a logical clock per store (now, advanced by tick), so TTLs are deterministic.

FeatureWhere
Interface with constructors, methods, and staticsStore (open, new; put, get, …; open_many, default_capacity, …)
Typed errors with payload fieldsKvError: KeyNotFound { key }, Expired { key, expired_at }, StoreFull { capacity }, InvalidPath, Rejected { key, reason }
Records, optionals, lists, mapsEntry (bytes, an optional TTL, tags, metadata), StoreInfo, Stats (a map keyed by a C-style enum)
C-style and rich enumsEntryKind; Change (Put, Removed, Cleared)
Objects in every positionparameters, returns, Store? both ways (larger), lists (open_many, total_count), map values (by_label), record fields (StoreInfo), iterator elements (partition), an async result (open_store), and a callback’s parameter and return (Policy::route)
Callback interfacesListener (retained by subscribe; void and direct returns), Policy (a record return, a throwing method whose typed error reaches the put caller, an object parameter and return), Loader (string, bytes, and optional-object returns; passed as an optional callback)
Callbacks from a producer threadcompact notifies listeners from the thread it runs on
Lazy iteratorskeys (strings, throwing), entries (records), partition (objects)
Async and cancellationopen_store (an async free function returning an object), compact (cancellable; its background pause stops cooperatively, shown by Store::active_jobs), get_many (an async list), summarize_all
Nested moduleskv.stats uses the parent’s Store and reports the parent’s KvError
Sibling rootsreport shares the Entry record and has its own ReportError
DeprecationStore::size (use count)

A callback’s failure surfaces according to its role: a Listener that fails is unsubscribed while the store operation succeeds; a Policy or Loader failure fails the call with the callback’s code, message, and payload, so a typed KvError a consumer raised reaches the original caller typed. The store never holds a lock while a callback runs, so callbacks may call back into it.

Comparison

WeaveFFI sits in a crowded ecosystem of FFI tooling. This page is an honest, side-by-side look at how it compares to the projects you are most likely to evaluate against it: UniFFI (Mozilla’s multi-language generator), Diplomat (the Rust-to-many-languages tool behind ICU4X), cbindgen plus hand-written glue, the single-language bridges swift-bridge, napi-rs, and wasm-bindgen, and the C/C++-first generators SWIG and autocxx.

All comparisons reflect the public state of each project at the time of writing (WeaveFFI ABI revision 4, schema 0.11.0). If something here is out of date, please open a PR.

The short version

WeaveFFI’s distinguishing bet is one language-neutral IDL, one C ABI, eleven generators. The producer can be Rust (with #[weaveffi::module] writing the ABI for you) or anything else that can export C symbols, and every generated package is standalone: consumers never install WeaveFFI. The object model is on par with the Rust-first tools: interface objects are reference counted (Arc<T>), can be shared between wrappers and nested inside records, lists, maps, optionals, iterators, and async results, and callback interfaces let the producer call consumer code through a vtable whose methods can return any value and raise typed errors. A Rust producer’s API is read from the library it compiled (library mode, as in UniFFI), and weaveffi package turns the builds into installable artifacts for every ecosystem. Every symbol and package is named after the library, never after WeaveFFI, so several WeaveFFI-built libraries coexist in one process.

The honest flip side is that WeaveFFI is pre-1.0 and its type system is deliberately smaller than UniFFI’s: no user-defined generics or trait objects, no custom types, no async callback methods, and no multi-file IDL imports. The roadmap lists what’s planned.

At a glance

WeaveFFIUniFFIDiplomatcbindgen + glueswift-bridgenapi-rswasm-bindgenSWIGautocxx
Producer languageRust, C, C++, Zig (anything with a C ABI)RustRustRustRustRustRustC / C++C++ (consumed from Rust)
InputYAML / JSON / TOML IDL or annotated Rust (read from the built library)UDL or proc-macros (library mode reads the built library)annotated Rust bridge crateRust sourceannotated Rust bridge moduleannotated Rustannotated RustC/C++ headers + .i fileC++ headers
Consumer languagesC, C++, Swift, Kotlin, Node.js, Wasm/JS, Python, .NET, Dart, Go, RubyKotlin, Swift, Python, Ruby first-party; Go, C#, Dart, Kotlin Multiplatform, React Native (uniffi-bindgen-react-native) as external bindgensC, C++, JS/TS (Wasm), Dart, Kotlin (JNA), Python (nanobind)C (you write the rest)SwiftNode.jsJS/TS (Wasm)many (Python, Java, C#, Ruby, Lua, Perl, PHP, R, …)Rust
Standalone C header✓ (the contract every target shares)✗ (private scaffolding ABI)✓ (C backend)✓ (its purpose)✓ (generated)✗✗✗n/a
Type system
Records / enums / optionals / lists / maps✓ (value buffers)✓✓ (structs, enums, Option, slices; no maps)manual✓ (transparent structs and enums, Option, Vec)✓ (serde objects)✓ (serde-wasm-bindgen or classes)✓✓
Objects with methods✓ reference counted, shareable, nestable✓ (Arc<T>)✓ (opaques, borrowed or owned)manual✓ (opaque types)✓ (#[napi] classes)✓ (classes)✓ (classes)✓ (C++ classes)
Callback interfaces✓ (vtable; synchronous, any return, throws, optional, failures as Result)✓ (foreign traits; any return, throws, async)partial (Kotlin, C, C++; input-only)manual fn pointerspartial (closures Rust to Swift)✓ (ThreadsafeFunction)✓ (closures)partial (directors)partial
Typed error domains✓ (per-module codes, opt-in throws, payload fields)✓ (error enums)✓ (Result)manual✓ (Result)✓ (JS Error)✓ (Result<JsValue>)✗✗
Async functions✓ (callback ABI, pluggable spawner, native cancellation on every target)✓ (poll-based, foreign executors)✗manual✓ (both directions)✓ (Tokio)✓ (Promise)✗✗
Iterators✓ (iter<T>, lazy on every target)✗ (materialize or use a trait)partial (DiplomatWrite)manual✗✗✓ (JS iterators)partial✗
Generics / trait objects✗ (fixed set of built-in shapes)partial (traits, no generics)partial (traits on some backends)✗partial✗✗✓ (templates via %template)✓
Multi-file / multi-crate definitions✗ (one document per API)✓ (external types across crates)✓ (one bridge crate, many modules)n/a✗n/an/a✓ (%include)✓
Workflow
Standalone CLI✓ (cargo install weaveffi-cli)uniffi-bindgen (build.rs or CLI)diplomat-tool✓swift-bridge-clinapi CLI (npm)wasm-bindgen-clisystem packagecargo build
Publishable per-ecosystem packages✓ (weaveffi package: wheels, npm tarballs, gems, an XCFramework SwiftPM package, a .nupkg; Gradle, pub, and Go as ready-to-publish directories)via companion tools (cargo-swift, maturin, uniffi-bindgen-react-native)partialn/a✓ (SwiftPM)✓ (npm)✓ (npm via wasm-pack)✗n/a
Schema-checked IDL with JSON Schema✓✗n/an/an/an/an/a✗n/a
Load-time ABI check✓ (ABI revision plus per-declaration contract tables)✓ (checksums)✗✗✗✓ (N-API version)✗✗n/a
Generated-output drift check in CI✓ (weaveffi diff --check)build-timebuild-time✓build-timebuild-timebuild-time✗build-time
Maturitypre-1.0shipping in Firefox and Mozilla products since 2020shipping in ICU4X1.0+, widely deployed0.1.x, active2.x+, widely deployed0.2.x, ubiquitous30+ yearspre-1.0
LicenseMIT OR Apache-2.0MPL-2.0MIT OR Apache-2.0MPL-2.0MIT OR Apache-2.0MITMIT OR Apache-2.0GPL-3.0 (generated code exempt)MIT OR Apache-2.0

Legend: ✓ = first-class support; partial = supported with caveats, on a subset of backends, or via extensions; ✗ = not supported; manual = you write it by hand; n/a = not applicable to that tool’s scope.

Where competitors are stronger

Pick the right tool for the job. These are the places where another project is ahead of WeaveFFI today.

  • UniFFI has the richer type system and more production mileage. It ships in Firefox, Firefox Sync, Glean, and Nimbus and has years of battle-testing across iOS, Android, and desktop. Its foreign traits can be async; WeaveFFI’s callback-interface methods can return any type and throw, but are synchronous. UniFFI also supports custom types and external types across crates, whereas a WeaveFFI API is one IDL document (or one Rust producer crate). Its ecosystem of companion tools is larger too: cargo-swift builds the XCFramework and SwiftPM package, maturin builds Python wheels, and uniffi-bindgen-react-native targets React Native, where WeaveFFI’s weaveffi package covers the first two itself and has no React Native target. If your matrix is Kotlin, Swift, and Python and you want maximum maturity, UniFFI is the safer pick.
  • Diplomat has the more mature Kotlin and JS story for a Rust library. It powers ICU4X, its bridge-crate model keeps everything in Rust, and its C++ backend is polished for slotting into existing C++ builds. Its opaques support borrowed as well as owned references, which WeaveFFI does not model (every WeaveFFI object crossing is a strong reference). Diplomat has no async support and no maps, and callbacks exist only on some backends.
  • cbindgen is simpler if all you want is a C header. WeaveFFI generates a C header and ten other targets. If you only consume the C surface from C or C++ code, cbindgen has less ceremony, no IDL, and a smaller footprint. You write the object lifecycle, error channel, and any callback plumbing yourself.
  • swift-bridge, napi-rs, and wasm-bindgen are deeper in their one language. Each exposes idioms WeaveFFI’s common denominator can’t: swift-bridge bridges async functions in both directions and transparent Swift structs; napi-rs gives you the full N-API surface (ThreadsafeFunction, typed arrays, AsyncTask, class inheritance) and Tokio integration; wasm-bindgen talks to the whole Web platform through web-sys and js-sys and runs futures on the JS event loop. If you ship to exactly one of those ecosystems, the dedicated bridge is the better fit.
  • SWIG covers languages WeaveFFI doesn’t. Lua, Tcl, R, Octave, Perl, PHP, Java: if your target is exotic, SWIG probably has a generator, and it reads C and C++ headers directly so you author no IDL. It also handles C++ templates.
  • autocxx is unmatched for “wrap an existing C++ library.” It reads your C++ headers and uses bindgen plus cxx under the hood. WeaveFFI does not parse C++; you describe the surface you want to expose and implement the generated header.
  • WeaveFFI’s Wasm target is single-threaded. The default wasm32-unknown-unknown build has no threads, so async functions are polled inline until they finish (a future that waits on something outside the call fails with -1, and there’s little for an AbortSignal to cancel), and callback-interface methods fire only while a call into the module is on the stack. wasm-bindgen’s wasm-bindgen-futures integrates with the JS event loop natively, and Emscripten-based toolchains can use pthreads. In WeaveFFI’s Emscripten compatibility mode, async functions and callback interfaces are not available at all.
  • The single-language tools package better for their one ecosystem. maturin (with PyO3) and napi-rs’s CLI build wheels and npm packages for every platform from one CI matrix with years of edge cases handled; weaveffi package is newer, and it doesn’t prebuild the Node.js addon or the JNI shim for Windows.
  • No formal stability guarantee yet. WeaveFFI is pre-1.0; schema 0.11.0 and ABI revision 4 changed names, the load-time contract check, and the callback vtable layout without compatibility shims (see the migration guide). UniFFI, cbindgen, napi-rs, wasm-bindgen, and SWIG offer stronger compatibility commitments today.

When to choose WeaveFFI

WeaveFFI is the right pick when you want:

  1. One source of truth for many languages. If your library has to land in npm and SwiftPM and PyPI and NuGet and pub.dev and RubyGems and a Go module and a Gradle artifact, that’s the WeaveFFI sweet spot. UniFFI and Diplomat cover a smaller set out of the box; the single-language bridges don’t try.
  2. A native library that isn’t (only) Rust. WeaveFFI works against anything that exposes a C ABI: Rust (with the #[weaveffi::module] macro generating the ABI for you), C, C++, Zig, and so on. UniFFI, Diplomat, swift-bridge, napi-rs, and wasm-bindgen assume Rust; autocxx assumes C++.
  3. Objects that behave the same everywhere. Reference-counted interfaces with deterministic release (close(), Dispose(), deinit, RAII) and a garbage-collector backstop on every managed target, and the ability to put an object inside a record, a list, a map, an optional, an iterator, or an async result on all eleven targets, from one declaration.
  4. Callback interfaces without hand-written trampolines. Declare the methods once and each target gets a protocol, interface, or abstract class to implement; the producer receives an Arc<dyn Trait> it can retain and call from any thread. Methods can return strings, records, and objects, and a consumer-side exception reaches the producer as a weaveffi::ForeignError (carrying a typed domain error when the method is throws) that it can propagate to the original caller with ?.
  5. Standalone, publishable consumer packages. Generated packages are self-contained; weaveffi build cross-compiles the producer per platform and weaveffi package bundles it into wheels, npm tarballs, gems, an XCFramework SwiftPM package, a NuGet package, and Gradle, pub, Go, and C/C++ distributions. There is no “install WeaveFFI” step on the consumer side.
  6. Idiomatic per-target output, not a lowest-common-denominator API. Async functions become async/await in Swift, Promises in Node and Wasm, suspend fun in Kotlin, async def in Python, Task<T> in C#, and Future<T> in Dart, all from the same async: true flag; Rust producers can plug their own executor with weaveffi::set_spawner. Cancelling the awaiting task, coroutine, or promise cancels the native call.
  7. A CI-first CLI. validate, diff --check, extract, schema, build, and package are designed to drop into pipelines, every generator’s output is byte-for-byte deterministic, and generated consumers refuse to load a producer built for a different ABI revision or from a different API definition (per-declaration contract tables, so adding a declaration never breaks a deployed binding).

When to choose something else

  • You only need Kotlin, Swift, and Python and want maximum stability, or you need async callbacks: use UniFFI.
  • You only need a C header for a Rust crate: use cbindgen.
  • You ship to exactly one of Swift, Node.js, or the browser: use swift-bridge, napi-rs, or wasm-bindgen respectively.
  • You’re wrapping a large existing C++ codebase from Rust: use autocxx (or cxx plus bindgen directly).
  • Your target language is Lua, Tcl, R, Octave, Perl, or PHP: use SWIG.
  • You want a Rust-only bridge crate with borrowed-reference semantics and a polished C++ backend: use Diplomat (#[diplomat::bridge], from the rust-diplomat/diplomat repository).

Migrating to or from WeaveFFI

WeaveFFI’s IDL is intentionally close to UniFFI’s UDL surface area (records, enums, interfaces, callback interfaces, error enums, async), which makes hand-porting straightforward in either direction. There is no automatic UDL to WeaveFFI converter today. Going the other way, weaveffi extract prints the IDL a #[weaveffi::module] producer’s built library embeds, which is the starting point for reimplementing a Rust producer in another language. See Library Mode for details, and the C ABI contract if you’re bringing a non-Rust producer to the generated header.

FAQ

Why not UniFFI?

UniFFI is excellent and ships in production at Mozilla; if you need only Swift, Kotlin, and Python and want maximum maturity, it’s the safer pick. WeaveFFI exists for a different set of needs: eleven first-party targets from one definition, a producer that can be written in any language with a C ABI (the generated header is a public contract, not private scaffolding), a standalone CLI built for CI, and a schema-checked YAML, JSON, or TOML IDL. UniFFI is still ahead on async callback methods, custom types, and cross-crate type imports. See the comparison for the full table.

Can I use it with a C++ codebase?

To consume a library from C++, --target cpp emits a header-only RAII API with std::optional, std::vector, std::future, exceptions, and a CMakeLists.txt; see C++. To expose an existing C++ library, describe the surface in an IDL and implement the generated C header in C++; WeaveFFI doesn’t parse C++ headers. For automatic wrapping of C++ headers, look at autocxx or SWIG.

Does it support generics?

Through built-in shapes rather than user-defined generics: T?, [T], {K:V}, and iter<T>, which compose with each other and with every other type, including objects ([Store], {string:Store}, iter<Store>). Arbitrary generics would push type erasure into every generator; trait-object interfaces are on the roadmap.

What’s the runtime overhead?

A call costs the marshalling of its arguments, one extern "C" call, and the marshalling of its result. Scalars pass by value. Strings and bytes pass as borrowed (ptr, len) views, so the producer copies only what it keeps. Objects pass as one pointer. Records and collections are encoded into one value buffer per value, with single-copy fast paths for byte and numeric lists. Async calls add a completion callback and whatever executor drives the future. The runtime itself is the small weaveffi::abi module; the leak-check counters cost an atomic update per counted operation and are off by default.

How are errors propagated?

Every fallible C call takes a {prefix}_error* out-parameter; async calls receive one in their completion. Positive codes are your declared error domain, negative codes are the runtime’s (generic, panic, marshalling, callback failure, cancelled). A function marked throws: true (a Result in Rust) surfaces domain codes as typed errors in every language; a function without it traps on failure, since a failure there is a bug. See Errors and Memory.

Can two WeaveFFI libraries live in one process?

Yes. Every symbol, type, macro, package, and environment variable is named after the library’s own prefix and name (kvstore_error, KVSTORE_LIBRARY), and none after WeaveFFI, so two libraries never collide, whether linked statically or loaded dynamically. Each carries its own copy of the small runtime.

What happens if the bindings and the library drift apart?

They refuse to load. Every generated consumer compares the library’s ABI revision with the one it was generated against, and checks that every declaration it uses is in the library’s contract table with the same signature hash. On a mismatch it raises the language’s load error naming the declaration that’s missing or changed. The hashes cover names, types, and flags, but not documentation or declaration order, so editing doc comments never breaks a deployed binding, and neither does adding a function, method, or type. In CI, weaveffi diff --check catches stale committed bindings before they ship.

Does the CLI parse my Rust source?

No. #[weaveffi::module] embeds a description of the API in the library it compiles, and weaveffi generate builds the crate and reads that description back out of the library, so macros, type aliases, and #[cfg] are already resolved and the bindings match the build exactly. weaveffi extract prints it as an IDL. See Library Mode.

Can I customize the generated code?

Through weaveffi.toml: package metadata, per-target names (the Swift module, the Kotlin package, the Go module path, each set with name), and target-specific options. Run formatters as a separate step after weaveffi generate. See Project Configuration. Changing the C ABI itself is a generator contribution; see the architecture guide.

Does it work with Flutter?

The Dart target emits dart:ffi bindings with a pubspec.yaml usable from Flutter and plain Dart on every platform that supports dart:ffi. weaveffi package --target dart bundles the desktop libraries under native/<platform>/; for iOS and Android you add the library to the Flutter app’s native build yourself, since Flutter’s native-assets build isn’t generated yet. For the web, use the WebAssembly target. See Dart.

Is it Windows-friendly?

The CLI is plain Rust and runs on Windows, and the workspace’s tests run on Windows in CI. Headers carry a {PREFIX}_API macro that resolves to __declspec(dllimport) for consumers and dllexport when a C or C++ producer defines {PREFIX}_BUILD, and every loader knows the {library}.dll naming. The conformance harness runs on Linux and macOS today; Windows lanes are on the roadmap.

How do I distribute the native library?

Build it per platform and let weaveffi package bundle it into each ecosystem’s package layout (npm platform packages, platform wheels, NuGet runtimes/, Android jniLibs/, and so on), then publish with each ecosystem’s own tools. Every generated manifest takes its name and version from [package]. See Packaging.

Who owns an object, and when is it freed?

The producer reference-counts it. Each wrapper holds one strong reference and releases it through the language’s disposal idiom (close(), Dispose(), deinit, a destructor), with a garbage-collector backstop; the object is freed when the last reference anywhere goes. Objects returned, yielded, delivered, or passed to a callback are references the receiver adopts; objects passed to the producer are borrowed. See Errors and Memory.

Which executor runs my async functions, and how does cancellation work?

By default, a small pool of worker threads (one per core, at least two) started on the first call. With the weaveffi crate’s tokio feature it’s Tokio: the current runtime when the call is made from inside one, otherwise one the library creates. weaveffi::set_spawner installs any other executor and overrides both. Consumers see their native async idiom, and cancelling it (a Swift Task, a Kotlin coroutine, an AbortSignal, a CancellationToken, a Go context) cancels the native call: the runtime drops the future and completes with the cancelled code, which surfaces as the language’s cancellation error. See Async and Cancellation.

What’s the license?

MIT OR Apache-2.0, at your option. Generated code carries no license header of its own and is yours to license as you like.

Stability and Versioning

WeaveFFI will follow Semantic Versioning from 1.0.0. Until then it’s in active pre-1.0 development and any surface may change between minor releases. This page says what the version numbers mean, what 1.0 will promise, how to keep a downstream project steady today, and how to migrate to the current release.

Three version numbers

  • The crate version (0.24.x) is shared by every published crate (weaveffi, weaveffi-macros, weaveffi-model, weaveffi-cli) and by the CLI. Releases are cut from Conventional Commits by release-plz.
  • The IDL schema version (0.11.0) is the version: an IDL document declares. Pre-1.0, only the current version is accepted; a document declaring any other is rejected with UnsupportedSchemaVersion.
  • The C ABI revision (4) is what {prefix}_abi_version() returns. It changes only when the runtime surface or a calling convention changes incompatibly, independently of the other two.

What 1.0 will cover

After 1.0.0, these surfaces change incompatibly only in a major release:

  • The CLI: subcommands, flags, exit codes, and documented output formats (validate --format json in particular).
  • The IDL schema: accepted keys, type syntax, and the exported JSON Schema.
  • Generated code: exported symbols, signatures, type names, and package layouts. A patch release won’t change generated bytes; a minor release may add symbols but won’t remove or rename any.
  • The C ABI revision and the contract it names.
  • The public Rust API of the published crates.

Deprecation will precede removal: a deprecated feature warns for at least one minor release (the CLI prints a diagnostic, and generators emit each language’s deprecation marker from the IDL’s deprecated: field) and is removed in the next major release with a migration note. Post-1.0 schema bumps will ship with a migration tool and accept more than one version.

Determinism

Regenerating with the same WeaveFFI version from the same definition and weaveffi.toml produces byte-identical output on every platform. Generators never iterate hash maps into output, and a test regenerates the kitchen-sink fixture twice and compares. Non-determinism is a bug; please report it with the definition that triggers it.

CI workflow

Pin the CLI version and commit the generated output, so an upgrade is an explicit, reviewable change. Then gate CI on weaveffi diff --check, which regenerates in memory and compares with the committed directory without writing anything:

- name: Bindings are up to date
  run: |
    cargo install weaveffi-cli --locked --version =0.24.0
    weaveffi validate --warn
    weaveffi diff --check

diff --check exits 0 when the output matches, 2 when files would change, and 3 when files would be added or removed. For a Rust producer, both commands build the crate first (see Library Mode), so the job needs the Rust toolchain; pass --library <path> to reuse a library an earlier step built.

Migrating to schema 0.11 and ABI 4

This release makes names global, replaces the per-module checksums with contract tables, lets callback interfaces return any value and report typed errors, and reads a Rust producer’s API from its built library instead of its source. Every consumer must be regenerated, and every hand-written producer or consumer updated. There are no compatibility shims.

IDL

Names are global: a declaration is identified by its bare name alone, and modules only group declarations and namespace C symbols.

  • Set version: "0.11.0".
  • Type names (records, enums, interfaces, callback interfaces, and error domains), free-function names, and error-code names must each be unique across the whole API. Two modules can no longer both declare a function open; rename one (DuplicateFunctionName). A free function also can’t share its name with an error domain.
  • Qualified type references are removed: write Contact, not contacts.Contact (QualifiedTypeRef).
  • usize, isize, u128, i128, and char are reported as UnsupportedPrimitive, with a suggested replacement.
  • DuplicateStructName, DuplicateEnumName, DuplicateInterfaceName, DuplicateCallbackInterfaceName, and DuplicateErrorName are gone: DuplicateTypeName and DuplicateErrorCodeName report those once.
  • A callback-interface method may return any type except an iterator or a callback interface, and may set throws: true when an error domain is in scope for its module.
  • A callback-interface parameter may be optional (Cb?), as a top-level parameter of a function or interface member.

C ABI

  • {prefix}_abi_version() returns 4.
  • Contract tables replace checksums. {prefix}_{module}_checksum() and {PREFIX}_{MODULE}_CHECKSUM are gone; each top-level module exports const {prefix}_contract_entry* {prefix}_{module}_contract(size_t* out_len), one {id, hash} entry per declaration, sorted by id. The header defines {PREFIX}_{MODULE}_CONTRACT, {PREFIX}_{MODULE}_CONTRACT_LEN, and {prefix}_{module}_contract_check(), which returns the id of the first entry the library lacks or has with a different hash, or 0. Adding a declaration no longer breaks deployed bindings. A hand-written producer returns the header’s table (see Producers in other languages).
  • Every callback vtable starts with a header, uint32_t size; uint32_t flags; void (*free)(void*);, then the methods in declaration order (free used to come last). Set size to sizeof the vtable and flags to 0. A producer rejects a vtable smaller than its own with -3.
  • Callback methods may return strings, bytes, buffers, and objects. A string, bytes, or buffer return adds trailing uint8_t** out_ptr, size_t* out_len slots holding a run the consumer allocates with {prefix}_alloc; an object return is one strong reference. The producer adopts both.
  • A callback method declared throws may report a positive code of its domain, with the code’s fields set by the new {prefix}_error_set_payload(err, ptr, len) (which copies). Every other callback failure still reaches the producer as -4.
  • An optional callback parameter passes a null vtable for none.
  • {prefix}_alloc(size_t len) is exported on every target (it was wasm32 only) and returns a zero-filled run, or null for 0. {prefix}_dealloc is removed: {prefix}_free_bytes frees both returned runs and alloc runs, and is a no-op for length 0.
  • {prefix}_debug_live(-1) returns 1 when the producer counts and 0 when it doesn’t.
  • An async call the executor can’t start completes with -1 and a message.
  • A value buffer whose map repeats a key, and an iterator _next that arrives while the same iterator is being advanced, fail with -3.

Rust producers

  • The crates are consolidated. weaveffi-abi is now the weaveffi::abi module, and weaveffi-gen moved into weaveffi-cli, which is a library as well as the weaveffi binary. Depend on weaveffi alone.
  • Every callback-interface method must return Result<T, weaveffi::ForeignError>; a plain T is a compile error. ForeignError gains a payload field and domain::<E>(), which decodes a declared domain error. Mark a method #[weaveffi::throws] to let its consumer report the domain in scope.
  • The deferred-error API is removed: ThunkScope, defer_foreign_error, raise_foreign_error, and take_foreign_error no longer exist, and a callback failure never unwinds or waits for the thunk. To propagate one, return Result<T, ForeignError> from the exported function (which then needs an error domain in scope) and use ?.
  • Accept an optional callback as Option<Arc<dyn Trait>>.
  • weaveffi::abi::decode_value and BufferValue::read_value are unsafe fn, because decoding an object token adopts a reference. A reader built with BufferReader::token_free refuses tokens instead.
  • C-style enums implement the runtime trait weaveffi::abi::CEnum, which replaces the generated __weaveffi_from_i32 and __weaveffi_to_i32 inherent methods.
  • The macro validates its module tree with the CLI’s validator and reports each error on the offending item. A type from another module tree that isn’t a record or rich enum fails with “is not a WeaveFFI record or rich enum”.
  • #[cfg] on an item applies to its thunks, metadata, and contract entries, and non-generic type aliases in the tree (pub type Id = u64;) are substituted. A #[cfg] on a member (a field, variant, or method) and an out-of-line submodule (mod x;) are compile errors.
  • The default async executor is a fixed pool of worker threads instead of a thread per call. The weaveffi crate gains a tokio feature that runs futures on the current Tokio runtime, or on one it creates; weaveffi::set_spawner still overrides both.

CLI and configuration

  • Library mode. The CLI no longer reads Rust source. The macro embeds the API in the library (one exported {PREFIX}_META_{HASH} static per declaration, or the weaveffi_meta custom section on wasm32), and generate, validate, diff, and extract build the crate with cargo build --lib and read it from there, so generated bindings follow #[cfg]. --library <path> reads an existing build instead, and --release builds in release mode. See Library Mode.
  • The input is the crate. Set [project] input = "." (instead of "src/lib.rs"), and pass a crate directory or its Cargo.toml on the command line instead of a .rs file.
  • weaveffi extract prints the IDL a built library embeds (--library, or the project’s crate) instead of parsing a source file. Its --lenient flag is gone: what it prints always validates.
  • generate --force is gone, along with the input-hash freshness check it bypassed. Every run renders every selected target and rewrites only the files whose contents changed.
  • [global] is gone, with its pre_generate and post_generate hooks and every strip_module_prefix key (per target, too). Run formatters as a separate step. Targets that flatten module functions into one namespace use the bare function name, which global names keep collision-free.
  • One name key per target. [generators.cpp] namespace, swift.module_name, kotlin.package, node.package_name, wasm.package_name, python.package_name, dotnet.namespace, dart.package_name, go.module_path, and ruby.gem_name all become name. Ruby’s module_name and Python’s import_name stay.
  • [project] out defaults to bindings instead of generated, and generated files’ headers no longer name the input file.
  • weaveffi dev is new: it builds the debug library, generates, copies the library into the generated Python, Node.js, and Ruby packages, and prints how to point the other targets at it.
  • weaveffi build is new, and weaveffi package writes installable artifacts (wheels, npm tarballs, gems, an XCFramework SwiftPM package, a .nupkg) instead of source trees. package --build <crate> is gone: package builds the project’s producer itself, or reads --binaries <dir>. The dist directory is -o, else [package] dist, else dist. See Packaging.
  • The weaveffi-cli library exposes project::Project, which locates a project and generates it, for use from a build.rs (with an IDL input or a previously built library).

Generated bindings

Regenerate every target. Beyond the ABI changes, a few generated APIs changed shape:

  • Every callback-interface method in every language may now return a value and, when it’s declared throws, raise the module’s domain error.
  • Kotlin maps u8, u16, u32, and u64 to UByte, UShort, UInt, and ULong instead of the signed types.
  • Python ships one fully annotated module plus py.typed; the separate .pyi stub is gone.
  • The Node.js package loads a prebuilt addon when one matches the platform and compiles it only as a fallback.
  • Load failures name the declaration that’s missing or changed, instead of reporting a checksum mismatch.

Samples

The six samples are now three: calculator (minimal), codec (the value-buffer oracle, rebuilt around shared test vectors), and kvstore (every feature). contacts, events, and async-demo were folded into kvstore and removed, and every language’s conformance consumers follow suit: three lanes per language. See Samples.

Migrating to schema 0.10 and ABI 3

WeaveFFI 0.24.0 shipped schema 0.10 and ABI revision 3. Coming from an older release, apply these changes first, then the ones above:

  • Every symbol, type, constant, and macro carries the library’s prefix ({prefix}_error, {prefix}_free_bytes, {PREFIX}_API, {prefix}_kv_Store_open), the header is {library}.h, and generated packages and loaders are named after the library’s identity, never after WeaveFFI. [global] c_prefix and every per-target prefix key are gone; an IDL sets [package] c_prefix or library, and a Rust producer’s prefix is its crate’s library name.
  • Strings cross as UTF-8 (ptr, len) runs everywhere, and returned strings are freed with {prefix}_free_bytes.
  • Async launchers drop the _async suffix, cancel tokens are reference counted, and runtime code -5 means cancelled.
  • Map keys can’t be floats, and cancellable: true on a synchronous callable is an error.
  • The runtime is exported with weaveffi::export_runtime!(), and a #[weaveffi::error] enum must implement Display.
  • The [project] table and weaveffi init are new, and generation rewrites only changed files and deletes stale ones.

The changelog lists every release.

Roadmap

WeaveFFI is in active 0.x development. Schema 0.11 and ABI revision 4 made names global, replaced the per-module checksums with per-declaration contract tables, gave callback methods rich returns, typed throws, optional callback parameters, and a size-checked vtable header, made {prefix}_alloc the one allocator contract on every target, moved the codec conformance lanes onto shared test vectors, and taught the CLI to read a Rust producer’s API from its built library. This page lists what comes next. Items are planned (the design is settled) or exploring (wanted, with open design questions). Nothing carries a date; the changelog records what shipped.

Callback interfaces

Async callback methods (planned)

A callback method that returns a future on the consumer side needs a completion flowing the other way and a cancellation story when the producer drops the future. The vtable shape is simple (a completion function and context per async method); the hard part is one producer working the same way whether the consumer runtime is an event loop, a thread pool, or the single-threaded Wasm host.

Off-thread callbacks in Dart (exploring)

Dart can’t run a value-returning callback method synchronously on a thread that isn’t a Dart isolate thread, so a producer that calls one from its own worker thread aborts the process today (void methods are forwarded to the isolate safely). Pure dart:ffi can’t route that return, so the fix is either a small native shim that hops to the isolate and waits, or a per-vtable thread-affinity hint that lets the producer refuse the call with -4 instead. See the Dart page.

Synchronous calls that wait on Node.js callbacks (exploring)

A synchronous Node.js call that blocks the JS thread while a producer thread waits on a callback the JS thread must run would deadlock, so the addon gives such a callback about a second to reach the JS thread and then fails it with -4. A long synchronous call that legitimately overlaps callbacks hits that guard. Making the call itself pump callbacks while it waits would remove the heuristic. See the Node.js page.

Definitions

Multi-file IDL (planned)

An IDL API is one document. Large APIs want to split by module, and a monorepo wants to reference another package’s types. The plan is an imports: list resolved at parse time, with bare type names still unique across the merged API, and diff, validate, and contract tables covering every imported file.

Generic and trait-object interfaces (exploring)

WeaveFFI has a fixed set of generic shapes (T?, [T], {K:V}, iter<T>). Under discussion are trait-object interfaces (one declared method set with several producer implementations behind Arc<dyn Trait>) and, less likely, parameterized interfaces monomorphized per instantiation.

Duration and timestamp primitives (exploring)

Producers pass time as i64 with a documented unit. duration and timestamp primitives mapped to each language’s types would remove the ambiguity; the open question is the representation.

Custom types (exploring)

A producer type that crosses as a builtin (a Uuid as string, a Url as string, a fixed-point amount as i64) has to be converted by hand on both sides today. A declared custom type with per-language conversion hooks would let each binding expose the language’s own type.

Targets and runtime

Kotlin Multiplatform (exploring)

The Kotlin target reaches Android and the JVM through JNI. A Multiplatform flavor using Kotlin/Native cinterop for iOS and desktop is the natural next step.

Wasm threads (exploring)

On wasm32-unknown-unknown futures run inline and callbacks fire only while a call is on the stack. A spawner that schedules on the JS event loop, or shared-memory builds with Web Workers, would lift both limits and let Emscripten mode support async functions and callback interfaces.

Zero-copy blittable records (exploring)

Every record crosses as a serialized value buffer. A record of fixed-size scalars could instead cross as a #[repr(C)] struct passed by pointer, which matters for hot paths that move many small records. The open questions are how a record opts in, and how the contract hash and every target’s layout checks keep the two sides agreeing on the layout.

Poll-based async (exploring)

Async functions complete through a callback the producer fires on its own thread, and each binding hops back to its scheduler. A poll-based protocol, where the consumer’s runtime drives the future and the producer only wakes it, would fit event-loop runtimes better and avoid that hop. It would be an option next to the completion-callback ABI, not a replacement.

Per-language support packages (exploring)

Every package inlines its helpers (codec, error types, object base), which keeps consumers free of dependencies but means a codec fix ships as a regeneration. Opt-in shared support packages per ecosystem are under consideration; inlining would stay the default.

Testing

Windows conformance lanes (planned)

The workspace’s tests run on Windows, Linux, and macOS, but the conformance harness runs only on Linux and macOS. Adding Windows lanes means making conformance/run.sh and the per-language scripts portable (or adding PowerShell equivalents), installing each toolchain on Windows runners, and prebuilding the Node.js addon and JNI shim for windows-x64, which weaveffi build can’t do yet.

Toward 1.0

1.0 means the surfaces in What 1.0 will cover stop changing without a major release. It needs:

  • ABI revision 4 stable for several releases, so 1.0 doesn’t need revision 5;
  • every target passing the full conformance matrix on Linux, macOS, and Windows;
  • multi-file IDL and the deprecation policy in place;
  • a schema migration tool and more than one accepted schema version;
  • the known limitations on each generator page resolved or documented as permanent;
  • a review of every published crate’s public API.

Contributing

Open an issue or discussion on GitHub if an item here matters to you or something you need is missing.

Rust Crates

WeaveFFI is published as four crates that share one version:

CrateUse it when
weaveffiYou’re writing a Rust producer. It’s the only dependency a producer needs, and its abi module is the runtime.
weaveffi-macrosNever directly; weaveffi re-exports its macros.
weaveffi-modelYou’re building a tool on the IR: parsing, validating, or extracting an API, reading library metadata, or reading the validated Model. Its default idl feature adds the YAML, JSON, and TOML formats, the JSON Schema, and rich diagnostics.
weaveffi-cliYou want the weaveffi command (cargo install weaveffi-cli), or you’re driving or extending the generators from Rust (project::Project generates a project, from a build.rs too).
  • Rust API Map lists the items a producer uses from weaveffi and its abi runtime, and which exist only for the macro expansion.
  • Doc Comment Style is the convention for the doc comments themselves.

The full API docs are published at weaveffi.com/api/rust/weaveffi/ and build locally with:

cargo doc --workspace --all-features --no-deps --open

Every public item in the library crates is documented, enforced by #![deny(missing_docs)] and the Clippy doc lints.

Rust API Map

A map of the public API a Rust producer sees: the weaveffi facade and its weaveffi::abi runtime. cargo doc has the full signatures and safety contracts; this page says which items you’ll reach for.

[dependencies]
weaveffi = "0.24"

The weaveffi facade

ItemPurpose
#[weaveffi::module]Marks an exported module, validates its tree, and emits its thunks, contract table, and the library metadata the CLI reads the API from.
#[weaveffi::export]Exports a function (async fn is async; a Result return throws).
#[weaveffi::record]A by-value struct.
#[weaveffi::enumeration]A #[repr(i32)] C-style enum or a rich enum.
#[weaveffi::interface]A reference-counted object type, with its inherent impl.
#[weaveffi::callback_interface]A trait the consumer implements, each method returning Result<T, ForeignError>; accepted as Arc<dyn Trait> or Option<Arc<dyn Trait>>.
#[weaveffi::throws]A callback method whose consumer may fail with the error domain in scope.
#[weaveffi::error]The module’s error domain; requires Display.
#[weaveffi::cancellable]An async function taking a CancelToken.
weaveffi::export_runtime!()Exports the runtime symbols under the crate’s prefix; call once.
Iter<T>The return type for iter<T>; build with Iter::new(iterator).
CancelTokenA cancellable function’s last parameter; is_cancelled() for cooperative cleanup.
ForeignErrorA consumer callback’s failure (code, message, payload), the Err of every callback method; domain::<E>() decodes a declared domain error. Return it from an exported function to propagate a failure with ?.
ErrorDomainImplemented by every #[weaveffi::error] enum, so ForeignError::domain can decode it.
ErrorReportMaps an error type to (code, message, payload); implemented for String, &str, boxed errors, ForeignError, and every #[weaveffi::error] enum.
set_spawner, Spawner, BoxFutureInstall the executor async functions run on, overriding the default worker pool (or Tokio).
abiThe C ABI runtime (below).

The leak-check feature enables the runtime’s leak counters, and the tokio feature runs async functions on Tokio by default. The producer macro guide covers every attribute.

The weaveffi::abi runtime

Items marked (macro) are called by generated thunks; read them to understand the expansion, but you shouldn’t need to call them. Everything but the leak constants is re-exported at the root of weaveffi::abi.

ModuleItems
rootABI_VERSION (4).
errorFfiError, the #[repr(C)] error struct (the C {prefix}_error); the codes GENERIC_ERROR_CODE through CANCELLED_ERROR_CODE (-1 to -5); ErrorReport; ErrorDomain; panic_message. (macro) error_set, error_set_c, error_set_payload_c, error_store, error_clear, error_free, boxed_error.
convert(ptr, len) conversions: lift_str, lift_string, lift_byte_slice, lift_bytes read borrowed parameters; lower_string, lower_bytes, bytes_into_raw hand out allocations; alloc allocates a run for the producer to adopt; adopt_bytes adopts one; free_bytes releases either kind. Useful in tests that call thunks directly.
marshalCEnum, the trait every C-style enum implements (from_i32, to_i32). (macro) the per-family lift_*_param and lower_*_ret functions, lift_self, lift_self_arc, lift_enum, read_enum, write_enum, the *_slots and *_run helpers, call_sync, and Sentinel.
object(macro) lower_object, lower_object_opt, object_ref, object_arc, object_clone, object_destroy, and the buffer token pair object_to_token / object_from_token.
callbackForeignError; VtableHeader, the fixed start of every vtable. (macro) ForeignCallback, Vtable, VtableTooSmall, CallbackInterface, lift_callback, lift_callback_opt, callback_status, callback_status_in, and the callback_ret_* adopters.
cancelCancelToken. (macro) Cancellable, FfiCancelToken, AtomicWaker, and the cancel_token_* bodies.
iterIter. (macro) IterHandle, iter_into_raw, iter_next, iter_destroy.
bufferThe value-buffer codec: BufferValue, BufferWriter, BufferReader, BufferDecodeError, encode_value, decode_value, the ByValue marker that records and rich enums implement, and FixedWidth, the token behind single-copy lists of numbers. decode_value and BufferValue::read_value are unsafe, because decoding an object token adopts a reference; a BufferReader::token_free reader refuses tokens and is safe for any input.
contractContractEntry. (macro) contract_table, contract_len, contract_compact.
spawnset_spawner, Spawner, SpawnerAlreadySet, BoxFuture, SpawnError, default_pool_size, and block_on (a blocking executor for tests). (macro) launch_async, run_async, spawn, CatchUnwind.
leakdebug_live, the ENABLED flag, and the kind constants COUNTING (-1), OBJECTS, CALLBACKS, ITERATORS, TOKENS, and ALLOCATIONS (0 to 4); counters are live only with leak-check.

The C ABI contract is the specification these items implement, and Async and Cancellation covers the spawner in practice.

Doc comment style

This page describes how WeaveFFI’s Rust doc comments are written. Follow it when you add or revise public API so the generated Rust API docs read consistently and the doc lints stay green in CI.

TL;DR

  • Every public item carries a doc comment. This is enforced by #![deny(missing_docs)] on each library crate.
  • Use /// for items, //! for modules and crates.
  • The first line is a short imperative summary ending in a period.
  • Document fallible and panicking behavior with # Errors, # Panics, and # Safety sections. These are the Rust analog of “what can go wrong,” and the matching Clippy lints require them.
  • Link other items with intra-doc links: [`Model`] or [`Api`](weaveffi_model::ir::Api).
  • Wrap code-like identifiers in backticks. Product and tool names (WeaveFFI, SwiftPM, CMake) are allow-listed in clippy.toml instead.
  • Comments explain why, not what.

Grammar and punctuation

Prose in doc comments and Markdown follows the Chicago Manual of Style (17th edition), matching the repository’s AGENTS.md. Highlights:

  • No em dashes (U+2014). Use commas, parentheses, semicolons, colons, or separate sentences instead.
  • Use straight ASCII quotes and apostrophes (" and '), not curly ones, so prose stays copy-pasteable into source and terminals.
  • Use the serial (Oxford) comma in lists of three or more.
  • Use contractions where they read naturally (“doesn’t,” “isn’t”).
  • Use sentence case for headings: capitalize only the first word and proper nouns.

Doc comments

WeaveFFI follows the conventions in RFC 1574 and the rustdoc book. The standard section headings (# Examples, # Errors, # Panics, # Safety) play the role that Args, Returns, and Raises play in a Google-style docstring.

Functions and methods

#![allow(unused)]
fn main() {
/// Generate bindings for every requested target and write them to `out_dir`.
///
/// Targets are rendered from a shared [`Model`] so symbol names and
/// parameter lowering are computed once and reused across languages.
///
/// # Errors
///
/// Returns an error if the IDL fails to validate, a requested target is
/// unknown, or any output file cannot be written.
///
/// # Examples
///
/// ```no_run
/// use my_crate::generate;
/// # use weaveffi_model::ir::Api;
/// # fn demo(api: Api) -> anyhow::Result<()> {
/// generate(&api, "./generated", &["c", "swift"])?;
/// # Ok(())
/// # }
/// ```
pub fn generate(api: &Api, out_dir: &str, targets: &[&str]) -> anyhow::Result<()> {
    // ...
}
}

Notes:

  • Lead with a one-line imperative summary, then a blank line, then any extended description.

  • Refer to parameters by name in backticks (`out_dir`). Don’t restate their types; the rendered signature already shows them.

  • Add a # Errors section to every public function that returns Result, describing the conditions that produce an Err. Clippy’s missing_errors_doc enforces this.

  • Add a # Panics section to any public function that can panic, describing when. Clippy’s missing_panics_doc enforces this. If a panic path is provably unreachable (for example an expect on sanitized input), suppress it locally with a reason instead of documenting a panic that cannot happen:

    #![allow(unused)]
    fn main() {
    // `CString::new` is infallible here: interior NUL bytes are stripped above.
    #[allow(clippy::missing_panics_doc)]
    pub fn string_to_c_ptr(s: impl AsRef<str>) -> *const c_char {
        // ...
    }
    }
  • Prefer ```no_run or ```ignore for examples that need a built cdylib, a file path, or other state the doctest can’t set up. Use a plain ```rust block (which cargo test compiles and runs) when the snippet is self-contained.

unsafe functions

Every public unsafe fn, and any function that dereferences raw pointers across the C ABI, needs a # Safety section spelling out the caller’s obligations. Clippy’s missing_safety_doc enforces this.

#![allow(unused)]
fn main() {
/// Register a handle and its destructor with the given arena.
///
/// # Safety
///
/// `arena` must be a valid pointer returned by `arena_create`. `ptr` and
/// `dtor` must stay valid until `arena_destroy` is called.
pub unsafe fn arena_register(arena: *mut HandleArena, ptr: *mut c_void, dtor: Dtor) {
    // ...
}
}

Structs, enums, and their members

Document the type, then every public field or variant. missing_docs flags undocumented pub fields and variants, not just the type itself.

#![allow(unused)]
fn main() {
/// Error struct passed across the C ABI boundary.
#[repr(C)]
pub struct FfiError {
    /// Status code. `0` means success; a positive value is a domain error
    /// code and a negative value is one of the reserved runtime codes.
    pub code: i32,
    /// Owned, NUL-terminated UTF-8 message, or null when `code` is `0`.
    pub message: *const c_char,
    /// Owned value buffer holding the error code's payload fields, or null
    /// when the code declares no fields.
    pub payload_ptr: *const u8,
    /// Byte length of `payload_ptr`; `0` when null.
    pub payload_len: usize,
}

/// How a value crosses the ABI boundary.
pub enum Ownership {
    /// The callee owns the value; the caller must not free it.
    Borrowed,
    /// Ownership transfers to the caller, who must free it.
    Owned,
}
}

Field and variant docs can be terse. One clause that says what the field means (not what its type is) is usually enough.

Modules and crates

Open every crate’s lib.rs with a //! summary, and every module with a //! header describing its role:

#![allow(unused)]
fn main() {
//! C ABI runtime: error struct, memory helpers, and utility functions.
}

Crate-level docs are enforced separately by RUSTDOCFLAGS="-D rustdoc::missing_crate_level_docs" in CI, so a crate without a //! header fails the rustdoc job.

Private items

missing_docs only requires docs on the public API, so private helpers aren’t strictly required to have them. Still, write a short /// line for non-obvious private items: contributors read them in editors and reviews.

Comments: explain why

Comments are most useful when they explain things the reader can’t learn from the code itself:

  • a non-obvious invariant or ABI constraint,
  • a trade-off between two reasonable approaches,
  • a reference to an external spec, RFC, or upstream bug.

Don’t narrate what the next line does (// increment the counter) or restate a name (// the generator). Delete redundant comments when you find them.

Link to other items so rustdoc can resolve and cross-reference them. This is the Rust analog of the docs site’s autorefs:

#![allow(unused)]
fn main() {
/// Renders from the shared [`Model`], never re-deriving lowering.
///
/// See [`Api`](weaveffi_model::ir::Api) for the input model and
/// [`LanguageBackend`](crate::backend::LanguageBackend) for the trait every
/// generator implements.
}

Use the short [`Type`] form when the item is in scope, and the [`Type`](path::to::Type) form to link across modules or crates.

doc_markdown and backticks

Clippy’s doc_markdown lint flags identifiers that look like code but aren’t wrapped in backticks. Wrap real identifiers, types, paths, and file names in backticks (`Model`, `weaveffi.toml`).

Product names, tool names, and naming-convention terms (WeaveFFI, SwiftPM, CMake, NuGet, snake_case, PascalCase) read as prose, not code. Rather than backticking them, they’re allow-listed in clippy.toml under doc-valid-idents. Add a new entry there when you introduce another such name.

Enforcement

The doc lints are set in each library crate’s lib.rs, and clippy.toml holds the doc_markdown allow list. Every library crate denies missing_docs; weaveffi, weaveffi-model, and weaveffi-cli also turn on the Clippy doc lints below (weaveffi-macros exports only proc macros, which missing_docs covers):

LintWhat it requires
missing_docs (deny)A doc comment on every public item, field, and variant
clippy::missing_errors_docA # Errors section on public fns returning Result
clippy::missing_panics_docA # Panics section on public fns that can panic
clippy::missing_safety_docA # Safety section on public unsafe fns (on by default)
clippy::doc_markdownBackticks around code-like identifiers

CI runs these through the existing gates, so missing or malformed docs fail the build. Check your changes locally before pushing:

# Lint everything, including the doc lints (warnings are denied).
cargo clippy --workspace --all-targets -- -D warnings

# Build the API docs the way the rustdoc job does.
RUSTDOCFLAGS="-D warnings -D rustdoc::all -D rustdoc::missing_crate_level_docs" \
    cargo doc --workspace --no-deps

# Or run formatting, clippy, and rustdoc together.
just check

The generated API reference is published under /api/rust/ on the docs site when it deploys; the Rust API Map is the guided tour.