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, andmathlib_math_greet, plus the runtime (mathlib_error_clear,mathlib_free_bytes, and so on). Result<i32, MathError>makesdiva throwing function. The enum’s discriminants are the stable error codes, and itsDisplayoutput 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
calculatorto the feature-completekvstore. - Generators: what each language gets.
- Gate CI on
weaveffi diff --checkso 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
| Attribute | On | Effect |
|---|---|---|
#[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 fn | Exports a free function. Result<T, E> makes it throwing; async fn makes it async. |
#[weaveffi::record] | struct with named fields | A by-value record, serialized as a value buffer. |
#[weaveffi::enumeration] | #[repr(i32)] enum, or enum with named-field variants | A C-style enum (explicit = N on every variant) or a rich enum. |
#[weaveffi::interface] | struct plus its inherent impl | A reference-counted object type; the impl’s pub fns become constructors, methods, and statics. |
#[weaveffi::callback_interface] | trait Name: Send + Sync | Methods the consumer implements, each returning Result<T, weaveffi::ForeignError>; accepted as Arc<dyn Name> or Option<Arc<dyn Name>>. |
#[weaveffi::throws] | callback-interface method | The consumer may fail with a code of the error domain in scope. |
#[weaveffi::error] | enum with explicit discriminants | The module’s error domain. Must implement Display. |
#[weaveffi::cancellable] | exported async fn or async method | Takes 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
| Rust | IDL | Crosses the ABI as |
|---|---|---|
i8..i64, u8..u64, f32, f64, bool | same | one value |
usize, isize, u128, i128, char | none (rejected) | use u64, i64, or String |
String, &str | string | UTF-8 (ptr, len) |
Vec<u8>, &[u8] | bytes | (ptr, len) |
#[weaveffi::record] struct, rich enum | the type | value buffer |
#[repr(i32)] enum | the enum | int32_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 return | T, 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::CancelToken | none | the 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 aResult. - An
async fnassociated 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
&selforself: Arc<Self>.&mut selfandselfby value are rejected. - An object parameter
&Tis borrowed for the call;Arc<T>(orOption<Arc<T>>) is a new strong reference the thunk took for you, so you may store it. A by-valueTparameter 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>(orOption<Arc<T>>), never&T. - The return is
Result<T, ForeignError>, whereTis()or any IDL type except an iterator or a callback interface: a direct value, aString, aVec<u8>, a record, rich enum,Option,Vec, or map, or an object (Arc<T>, orOption<Arc<T>>when the consumer may return none). A plainTis 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>>, whereNoneis 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/:
| Source | Why |
|---|---|
*const T, *mut T | Declare the pointee as an interface and pass &T or Arc<T>. |
usize, isize, u128, i128, char | Use 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 parameter | Take &T or a value and return the result. |
interface method on self or &mut self | Use &self or self: Arc<Self> with interior mutability. |
interface that isn’t Send + Sync | The compiler names the offending field. |
callback method on &mut self | Take &self; the producer may call it from any thread. |
callback method returning a plain T | Return Result<T, weaveffi::ForeignError>. |
Result with no error domain in scope | Declare a #[weaveffi::error] enum in the module or an ancestor. |
#[weaveffi::error] enum without Display | Display supplies the runtime message. |
C-style enum without #[repr(i32)] | The discriminant crosses as int32_t. |
Iter<T> parameter or nested Iter | Iterators are outermost returns only. |
| non-value type from another root | See cross-module references. |
#[cfg] on a field, variant, error code, method, or callback method | Put 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
- Samples:
kvstoreuses every feature on this page. - C ABI Contract: what the thunks implement.
- Rust API Map: the
weaveffiandweaveffi::abiitems.
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
| Family | Parameter | Return, async result, iterator element | Release |
|---|---|---|---|
direct (scalars, bool, C-style enums) | copied | copied | nothing |
string, bytes | borrowed (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 pointer | one strong reference | {Type}_destroy |
| callback interfaces | ctx plus vtable, owned by the consumer | never returned | the 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.
| Position | Rule |
|---|---|
parameter (Store, Store?) | borrowed for the call; the producer takes its own reference if it keeps the object |
| return, async result, iterator element | one strong reference transfers; the consumer adopts it |
| callback-interface method parameter | one 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:
| Return | Slot | Ownership |
|---|---|---|
| direct value | the C return | copied |
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 buffer | trailing uint8_t** out_ptr, size_t* out_len | a 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
| Code | Meaning |
|---|---|
0 | success |
> 0 | a declared code of the module’s error domain |
-1 | generic: an untyped producer error (Result<T, String>, an error type without a domain code), or an async call the executor couldn’t start |
-2 | the producer panicked; the message carries the panic text |
-3 | marshalling 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 |
-4 | a consumer’s callback-interface implementation failed; the message is the consumer’s |
-5 | cancelled: 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 SwiftErrorcase, a Goerrorvalue, 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. -5on an async call always surfaces as the language’s own cancellation error (Swift’sCancellationError, Kotlin’sCancellationException, .NET’sOperationCanceledException, Python’sasyncio.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.
| Target | What a failed non-throwing call raises |
|---|---|
| C | nothing; the caller reads err.code itself |
| C++ | {namespace}::InternalError (a std::runtime_error) |
| Swift | fatalError, since Swift has no unchecked errors (as in UniFFI) |
| Kotlin | NativeBugException (an IllegalStateException) |
| Node.js, WebAssembly | the package’s root error class, {PascalCase(name)}Error (KvstoreError), with the negative code |
| Python | InternalError (a RuntimeError) |
| .NET | NativeBugException (an InvalidOperationException) |
| Dart | NativeError (an Error, not an Exception) |
| Go | a panic whose value is the package’s *Error |
| Ruby | NativeBugError (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-4with 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 exactout_lenyou got. - Freeing borrowed inputs. Parameters are the caller’s; never pass them
to
free_bytesor_destroy. - Encoding an object without
_clone. The token hands away a reference your wrapper still needs; the later_destroyover-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
codeconfuses the next check. Start every slot at{0}. error_clearon an async error. It leaks the box; useerror_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}_allocand 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:
- 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). - 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. - Owned results.
errisNULLon 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. - 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. - 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
-1and 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
| Target | Async surface | Cancellation | -5 raises |
|---|---|---|---|
| C | launcher plus callback | {prefix}_cancel_token* slot | the code in err |
| C++ | std::future<T> | RAII CancelToken argument | a Cancelled exception |
| Swift | async (async throws) | cancelling the Task | CancellationError |
| Kotlin | suspend fun | cancelling the coroutine | CancellationException |
| Node.js | Promise<T> | AbortSignal ({ signal }) | CancelledError |
| WebAssembly | Promise<T> | AbortSignal ({ signal }) | CancelledError |
| Python | awaitable | cancelling the awaiting task | asyncio.CancelledError |
| .NET | Task<T> | CancellationToken | OperationCanceledException |
| Dart | Future<T> | a generated cancel-token object | CancelledException |
| Go | blocking call, context.Context first | ctx done | returns ctx.Err() |
| Ruby | blocking call | cancel: keyword with a CancelToken | a 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
tokiofeature or install a spawner. -
The
tokiofeature. With theweavefficrate’stokiofeature 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 namedweaveffi-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 ontokio. Usetokio::task::spawn_blockingfor synchronous work inside a future.[dependencies] weaveffi = { version = "0.24", features = ["tokio"] } tokio = { version = "1", features = ["time"] } -
Custom. Call
weaveffi::set_spawneronce at startup, before the first async launch; it overrides both defaults. It accepts anyFn(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-2completion, a spawner that drops a future still produces a-5completion, 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-1and the message “async function suspended with no executor on wasm32” rather than hanging the module. A call usually completes before anAbortSignalcan 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
tokiofeature 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.
erris boxed; release it with{prefix}_error_free, noterror_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 --warnflags 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]
| Key | Default | Meaning |
|---|---|---|
input | none | The 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 |
out | bindings | Output directory for generate and diff |
targets | all eleven | Targets 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.
| Key | Meaning |
|---|---|
name | Distribution name (may contain - or .) |
version | Version stamped into every manifest (default 0.1.0) |
description, license, authors, homepage, repository | Manifest metadata |
c_prefix | C symbol prefix; IDL inputs only |
library | Native library base name (lib{library}.so); IDL inputs only |
dist | Where 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 |
prefix | the crate’s library name ([lib] name, else the package name with - mapped to _) | c_prefix, else snake-case name |
library | same 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):
| Key | Default | Meaning |
|---|---|---|
platforms | the host | Platform ids to build when --platforms isn’t given |
release | true | Build 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_api | 21 | The minimum Android API level, which picks the NDK compiler |
manifest | the project’s crate | The 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:
| Table | name sets | Default |
|---|---|---|
[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 id | PascalCase(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:
| Table | Key | Default |
|---|---|---|
[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:
| Table | Key | Default |
|---|---|---|
[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_sdk | 21, 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 (anode_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.tomlfiles 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}_LIBRARYenvironment 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:
| Platform | What build produces |
|---|---|
| macOS | the cdylib, with install name @rpath/lib{library}.dylib, plus the staticlib an XCFramework slice is made from |
| iOS | the staticlib |
| Linux, Android | the cdylib, with soname lib{library}.so (Android also 16 KB page-aligned) |
| Windows | the DLL and its import library |
wasm32 | the .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 forwindows-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.
| Target | Artifacts | Publish with |
|---|---|---|
python | one py3-none-{platform}.whl per desktop platform, the library inside the import package | twine 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 optionalDependencies | npm publish <tgz> for each, platform packages first |
wasm | {name}-{version}.tgz with the .wasm module | npm publish <tgz> |
ruby | one {gem}-{version}-{platform}.gem per desktop platform | gem push <gem> |
swift | C{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 checksum | upload the archive, then commit and tag the package |
dotnet | the project dotnet/{Namespace}/ with runtimes/<rid>/native/ libraries, and the .nupkg that dotnet pack builds from it | dotnet nuget push dist/dotnet/*.nupkg |
kotlin | the Gradle project kotlin/{name}/ with prebuilt jniLibs/<abi>/ (library and shim) and desktop natives under resources/natives/<platform>/ | gradle publish |
dart | the pub package dart/{package}/ with desktop libraries under native/<platform>/ | dart pub publish |
go | the module go/{package}/ with libraries under lib/<platform>/ and cgo flags that link the right one | push 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 target | attach 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 id | Rust target | Shipped by |
|---|---|---|
darwin-arm64 | aarch64-apple-darwin | every desktop target, and swift |
darwin-x64 | x86_64-apple-darwin | every desktop target, and swift |
linux-x64 | x86_64-unknown-linux-gnu | every desktop target |
linux-arm64 | aarch64-unknown-linux-gnu | every desktop target |
windows-x64 | x86_64-pc-windows-msvc | every desktop target |
ios-arm64 | aarch64-apple-ios | swift |
ios-sim-arm64 | aarch64-apple-ios-sim | swift |
ios-sim-x64 | x86_64-apple-ios | swift |
android-arm64 | aarch64-linux-android | kotlin (and c) |
android-x64 | x86_64-linux-android | kotlin (and c) |
wasm32 | wasm32-unknown-unknown | wasm (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.
packagerunsdotnet 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
.aaror.jar: building one needs the Kotlin compiler, so rungradle assembleRelease(Android) orgradle 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-arm64on 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 itspackage: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
| Flag | Default | Meaning |
|---|---|---|
-o, --output | stdout | Write to a file |
-f, --format | yaml | yaml, json, or toml |
--library | build the crate | Read this library instead |
--release | off | Build the crate in release mode |
--config | discovered | The 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, sonpm installin 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
| Rust | IDL |
|---|---|
i8..i64, u8..u64, f32, f64, bool | same |
String, &str | string |
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::CancelToken | removed (#[weaveffi::cancellable] sets cancellable: true) |
Result<T, E> | return T, sets throws: true |
| a type alias declared in the tree | its target |
| any other name | that 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’sdoc:, and its first line the code’smessage:(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 nosincefield. - 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
version | string | yes | Must be "0.11.0" |
modules | array of Module | yes | Top-level modules |
Module
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Namespace segment and C symbol component (kv) |
doc | string | Module documentation |
functions | array of Function | Free functions |
structs | array of Struct | Records (value types) |
enums | array of Enum | C-style or rich enums |
interfaces | array of Interface | Reference-counted object types |
callback_interfaces | array of CallbackInterface | Method sets the consumer implements |
errors | ErrorDomain | The module’s error domain |
modules | array of Module | Nested submodules |
Function
The same shape describes free functions, interface constructors, methods, and statics, and callback-interface methods, with the restrictions noted in their sections.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Function name |
params | array of Param | Ordered parameters (default none) |
return | TypeRef | Return type; omit for none |
doc | string | Documentation |
throws | bool | Report typed domain errors (default false) |
async | bool | Complete asynchronously (default false) |
cancellable | bool | Accept a cancel token; requires async: true |
deprecated | string | Deprecation message propagated to every binding |
A Param has a required name and type and an optional doc.
Types
A TypeRef is a string:
| Syntax | Meaning |
|---|---|
i8 i16 i32 i64 u8 u16 u32 u64 | Fixed-width integers |
f32 f64 | IEEE 754 floats |
bool | Boolean |
string | UTF-8 text |
bytes | Byte string ([u8] is accepted and becomes bytes) |
Name | A 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.
| Type | Param | Return | Buffered field | Inside T? [T] {K:V} | Map key | Callback param | Callback return |
|---|---|---|---|---|---|---|---|
integers, floats, bool | yes | yes | yes | yes | integers, bool | yes | yes |
string | yes | yes | yes | yes | yes | yes | yes |
bytes | yes | yes | yes | yes | no | yes | yes |
| C-style enum | yes | yes | yes | yes | yes | yes | yes |
| struct, rich enum | yes | yes | yes | yes | no | yes | yes |
| interface | yes | yes | yes | yes | no | yes | yes |
| callback interface | yes | no | no | Cb? as a top-level parameter only | no | no | no |
T?, [T], {K:V} | yes | yes | yes | yes | no | yes | yes |
iter<T> | no | yes (sync) | no | no | no | no | no |
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.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Type name |
doc | string | Documentation |
deprecated | string | Deprecation message |
fields | array 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.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Type name |
doc, deprecated | string | Documentation, deprecation message |
variants | array 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.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Type name |
doc, deprecated | string | Documentation, deprecation message |
constructors | array of Function | Return a new instance; no return, never async |
methods | array of Function | Take the instance implicitly |
statics | array of Function | No 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.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Type name |
doc, deprecated | string | Documentation, deprecation message |
methods | array of Function (required, non-empty) | In vtable order |
Methods are restricted:
- synchronous: no
async, nocancellable(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}_allocand 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.
| Field | Type | Meaning |
|---|---|---|
name | string (required) | Domain type name (KvError) |
codes | array 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 ofif,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.
throwsneeds a domain in scope;cancellableneedsasync.- 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_yin modulemcollides with a functionyin modulem.xorm_x; so do a free functionStore_getand agetmethod on interfaceStore, a function namedStore_cloneandStore’s implicit_clone, and a function namedfoo_callbackand the completion type of an asyncfoo. 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 namedlist) collide too. The full symbol table is in the C ABI contract.
A document with an unsupported version reports only
UnsupportedSchemaVersion.
Error catalog
| Code | Reported when |
|---|---|
UnsupportedSchemaVersion | version isn’t 0.11.0 |
NoModuleName | a module has no name |
InvalidModuleName | a module name isn’t an identifier or is reserved |
DuplicateModuleName | two sibling modules share a name |
InvalidIdentifier | a name isn’t a valid identifier |
ReservedKeyword | a name is a reserved word |
DuplicateFunctionName | two free functions anywhere in the API share a name |
DuplicateParamName | two parameters of a callable share a name |
NameCollisionWithErrorDomain | a free function and an error domain share a name |
DuplicateStructField | two fields of a struct share a name |
EmptyStruct | a struct has no fields |
EmptyEnum | an enum has no variants |
DuplicateEnumVariant | two variants of an enum share a name |
DuplicateEnumValue | two variants of an enum share a value |
DuplicateEnumVariantField | two fields of a rich-enum variant share a name |
DuplicateInterfaceMember | two members of an interface share a name |
EmptyInterface | an interface has no members |
ConstructorHasReturn | a constructor declares return |
AsyncConstructor | a constructor is async |
InterfaceInInvalidPosition | an interface is used as a map key |
EmptyCallbackInterface | a callback interface has no methods |
DuplicateCallbackMethod | two methods of a callback interface share a name |
InvalidCallbackMethod | a callback method is async or cancellable, or returns an iterator |
CallbackInterfaceInInvalidPosition | a callback interface appears other than as a top-level parameter (bare or Cb?) |
DuplicateTypeName | two types (including error domains) anywhere in the API share a name |
UnknownTypeRef | a reference names no declaration |
QualifiedTypeRef | a reference is a dotted, module-qualified name |
UnsupportedPrimitive | a reference names usize, isize, u128, i128, or char |
InvalidMapKey | a map key isn’t an integer, bool, string, or C-style enum |
IteratorInInvalidPosition | iter<T> appears other than as an outermost return |
AsyncIteratorReturn | an async function returns iter<T> |
CancellableNotAsync | a synchronous callable is cancellable |
ThrowsWithoutErrorDomain | throws: true on a callable or callback method with no error domain in scope |
ErrorDomainMissingName | an error domain has no name |
DuplicateErrorCode | two codes in a domain share a value |
DuplicateErrorCodeName | two codes anywhere in the API share a name |
InvalidErrorCode | a code is zero or negative |
SymbolCollision | two declarations lower to the same C identifier, or one falls in a reserved helper family |
Warnings
Warnings never fail validation.
| Code | Fires when |
|---|---|
LargeEnumVariantCount | an enum has more than 100 variants |
DeepNesting | a type nests composites more than three levels deep |
EmptyModuleDoc | a module with functions has no doc: on itself or any function |
AsyncVoidFunction | an async free function returns nothing |
DeprecatedFunction | a 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.
| Declaration | Canonical 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) |
| record | record {name} {{field}: {type}, ...} |
| enum | enum {name} {{Variant} = {value}, ...}, a variant with fields followed by {{field}: {type}, ...} |
| callback interface | callback {name} {{method}({param}: {type}, ...) -> {type}; ...}, a method that throws followed by throws |
| error domain | errors {name} {{Code} = {value}, ...}, a code with fields followed by {{field}: {type}, ...} |
| interface | interface {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.
| Code | Meaning |
|---|---|
0 | success; every pointer is null |
> 0 | a declared code of the callable’s error domain; payload_ptr/payload_len hold the code’s fields, if any |
-1 | generic: an untyped producer error, or an async call the executor couldn’t start |
-2 | the producer panicked |
-3 | marshalling 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) |
-4 | a consumer’s callback-interface implementation failed |
-5 | cancelled (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:
| Family | IDL types | Parameter slots | Return |
|---|---|---|---|
| Direct | integers, f32, f64, bool, C-style enums | one 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 |
| String | string | const uint8_t* {n}_ptr, size_t {n}_len: UTF-8, not NUL-terminated, borrowed | const uint8_t* plus trailing size_t* out_len |
| Bytes | bytes | same as String | same as String |
| Buffer | structs, rich enums, T? (except Interface? and Cb?), [T], {K:V} | const uint8_t* {n}_ptr, size_t {n}_len holding a value buffer, borrowed | same as String |
| Object | interfaces, Interface? | const {p}_{path}_{I}* {n}, borrowed; null means none | {p}_{path}_{I}*, one strong reference; null means none |
| Callback | callback interfaces, Cb? | void* {n}_ctx, const {p}_{path}_{Cb}_vtable* {n}_vtable; for Cb? a null vtable means none | not allowed |
| Iterator | iter<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.
| Declaration | C identifier |
|---|---|
function f | {p}_{path}_f |
interface I | type {p}_{path}_I; members {p}_{path}_I_{member}; {p}_{path}_I_clone, {p}_{path}_I_destroy |
async function f | launcher {p}_{path}_f (no suffix); completion type {p}_{path}_f_callback |
iterator returned by f | type {p}_{path}_{Owner_}{PascalF}Iterator, plus {Iter}_next and {Iter}_destroy |
C-style enum E, variant V | type {p}_{path}_E, constant {p}_{path}_E_V |
rich enum E, variant V | tag type {p}_{path}_E_Tag, constant {p}_{path}_E_V; struct {p}_{path}_E and its codecs in the buffer header |
record S | struct {p}_{path}_S and its codecs in the buffer header |
error domain D, code C | type {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 Cb | vtable type {p}_{path}_Cb_vtable |
top-level module m | {p}_{m}_contract, {p}_{m}_contract_check, {P}_{M}_CONTRACT, {P}_{M}_CONTRACT_LEN |
| runtime | the 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.
| Position | Rule |
|---|---|
| parameter | borrowed for the call; the producer takes its own reference if it retains the object |
| return, async result, iterator element | one strong reference transfers to the consumer |
| callback method parameter | one strong reference transfers to the consumer (the slot is {p}_{path}_{I}*, not const) |
| callback method return | one strong reference transfers to the producer |
| object token inside a value buffer | carries 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 (
IorI?) is the C return,{p}_{path}_{I}*: one strong reference (a fresh_clone) the producer adopts.I?may return null;Imust not. - A string, bytes, or buffer return makes
<ret>voidand adds two trailing slots beforeout_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 length0is 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
-1and a message. - If the executor drops the future before it completes, the completion
fires with
-5from the drop guard. - For a
cancellablefunction, 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-5and the messagecancelled, 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:
- every function the header declares for the API;
- one
{p}_{m}_contractper top-level module, returning a table with (at least) the header’s{P}_{M}_CONTRACTentries, sorted by id; - the runtime surface above, with
{p}_abi_versionreturning{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?, exceptInterface?, which stays a nullable object pointer at the top level of a parameter or return, andCb?, 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 type | Encoding |
|---|---|
bool | 1 byte: 0 or 1 |
i8, u8 | 1 byte |
i16, u16 | 2 bytes |
i32, u32 | 4 bytes |
i64, u64 | 8 bytes |
f32, f64 | 4 or 8 bytes of IEEE 754 bits (NaN payloads, infinities, and -0 round-trip) |
| C-style enum | i32 value |
| interface | u64 object token |
string | u32 byte length, then UTF-8 bytes (no terminator) |
bytes | u32 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 |
| struct | each field, in declaration order |
| rich enum | i32 tag (the variant’s value), then the variant’s fields in order |
| error payload | the 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
boolor optional flag byte other than0or1; - 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
_cloneand writes the returned pointer, keeping its own reference; a Rust producer writes a clonedArc. - 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
| Position | Slots | Owner |
|---|---|---|
parameter v | const uint8_t* v_ptr, size_t v_len | the caller; borrowed for the call |
| return | const uint8_t* return plus size_t* out_len | the consumer; free with {p}_free_bytes(ptr, len) |
| iterator element | const uint8_t** out_item, size_t* out_len | the consumer, per element |
| async result | const uint8_t* result_ptr, size_t result_len | the consumer |
| callback method argument | const uint8_t* v_ptr, size_t v_len | the producer; borrowed for the call |
| error payload | payload_ptr, payload_len in {p}_error | freed 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):
| Field | Meaning | Rust producer | IDL |
|---|---|---|---|
name | package name as published | [package] name, else the crate’s package name | [package] name, else the input file stem |
prefix | C symbol prefix | the crate’s library name | [package] c_prefix, else snake(name) |
library | native library base name | same as prefix | [package] library, else snake(name) |
version, description, license, authors, homepage, repository | metadata | [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 alib_prefix.my-kv.storebecomesmy_kv_store,3d-enginebecomeslib_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
Nprefix.my-kv.storebecomesMyKvStore.
{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.
| Target | Default names |
|---|---|
| C | header {library}.h; include guard {PREFIX}_H; export macro {PREFIX}_API |
| C++ | header {library}.hpp, which includes {library}.h (shipped alongside); namespace {prefix} |
| Swift | SwiftPM package, product, and module PascalCase(name); C module C{PascalCase(name)} |
| Kotlin | package {prefix}; one Kotlin object per IDL module; JNI library {library}_jni |
| Node.js | npm package {name}; addon {library}_node.node; one namespace export per module |
| WebAssembly | npm package {name}; ES module; one namespace export per module |
| Python | distribution {name}; import package {prefix} |
| .NET | namespace, assembly, and NuGet id PascalCase(name) |
| Dart | package {prefix} |
| Go | module path {name}; package {prefix} |
| Ruby | gem {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:
| Target | Style | get_stats, created_at |
|---|---|---|
| C, C++, Python, Ruby | snake_case | get_stats, created_at |
| Swift, Kotlin, Node.js, WebAssembly, Dart | camelCase | getStats, createdAt |
| Go, .NET | PascalCase | GetStats, 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:
| Target | Reserved member names |
|---|---|
| C++ | handle, clone_handle, raw_type |
| Swift | ptr, clonePtr, wvRead, wvWrite |
| Kotlin | close, handle, cloneHandle, fromHandle, fromHandleOrNull, invoke, equals, hashCode, toString, wait, notify, notifyAll |
| Node.js, WebAssembly | methods: close, constructor; statics and factories: name, length, prototype, caller |
| Python | close |
| .NET | Dispose, Handle, Adopt, CloneHandle, NativeHandle, Equals, GetHashCode, GetType, ToString, ReferenceEquals, MemberwiseClone, Finalize, and the interface’s own name |
| Dart | dispose, hashCode, toString, runtimeType, noSuchMethod |
| Go | methods: Close (factories and statics are package functions spelled with the type name, so they can’t collide) |
| Ruby | methods: 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
| Target | Package / module | Object release | Async | Cancellation | Callback interface | iter<T> | Minimum toolchain |
|---|---|---|---|---|---|---|---|
| C | header {library}.h | _destroy | launcher plus callback | {prefix}_cancel_token* | ctx plus vtable struct | _next/_destroy | C11 |
| C++ | header {library}.hpp, namespace {prefix} | RAII destructor; copies clone | std::future<T> | RAII CancelToken | abstract class | input range | C++17, CMake 3.14 |
| Swift | module PascalCase(name) | deinit | async / async throws | Task cancellation | protocol | Sequence | Swift 5.9 |
| Kotlin | package {prefix} | close(), cleaner backstop | suspend fun | coroutine cancellation | interface | NativeIterator<T> (an Iterator<T>) | Kotlin 2.0; Android API 21 or a JVM |
| Node.js | npm {name} | close(), Symbol.dispose, finalizer | Promise<T> | AbortSignal | object implementing an interface | IterableIterator<T> | Node.js 18 |
| WebAssembly | npm {name} | close(), Symbol.dispose, finalizer | Promise<T> | AbortSignal | object implementing an interface | IterableIterator<T> | Node.js 18 or a current browser |
| Python | dist {name}, import {prefix} | close(), with, finalizer | awaitable | task cancellation | abstract base class | iterator | Python 3.9 |
| .NET | namespace PascalCase(name) | Dispose() (SafeHandle) | Task<T> | CancellationToken | interface | IEnumerable<T> | .NET 8 |
| Dart | package {prefix} | dispose(), NativeFinalizer | Future<T> | cancel-token object | abstract class | Iterable<T> | Dart 3.10 |
| Go | module {name}, package {prefix} | Close(), finalizer | blocking call | context.Context | interface | iter.Seq / iter.Seq2 | Go 1.23 |
| Ruby | gem {name}, module PascalCase(name) | close, FFI::AutoPointer | blocking call | cancel: keyword | duck-typed object | Enumerator | Ruby 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 usesULongforu64; Dart carriesu64in its signed 64-bitint(the bits are preserved, so values above2^63 - 1read as negative).NaN, infinities, and-0cross 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 type | Parameter | Return | In {library}_buffer.h |
|---|---|---|---|
i8 to u64, f32, f64 | int8_t to uint64_t, float, double | same | same |
bool | bool | bool | bool |
| C-style enum | {p}_{path}_{Enum} | same | same |
string | const uint8_t* s_ptr, size_t s_len | const uint8_t* + size_t* out_len | {p}_str |
bytes | const uint8_t* b_ptr, size_t b_len | const uint8_t* + size_t* out_len | {p}_bytes |
| record, rich enum | value buffer (ptr, len) | value buffer + out_len | struct {p}_{path}_{Name} |
[T], {K:V} | value buffer | value buffer | structs {p}_list_{T}, {p}_map_{K}_{V} |
T? | value buffer | value buffer | T* (NULL is absent) |
interface I | const {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 allowed | not allowed |
iter<T> | not allowed | iterator handle | not 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, neverNULLfor a non-optional type. - A string, bytes, or buffer (
label,latest) is written to the trailingout_ptrandout_lenslots as a run allocated with{p}_alloc, which the producer adopts and frees. Leave themNULLand0for 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}_LIBRARYisn’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] key | Default | Meaning |
|---|---|---|
name | the C prefix | Namespace holding every declaration |
header_name | {library}.hpp | Wrapper header file name |
standard | 17 | C++ standard the CMake target requires |
Build and link
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 type | C++ type | Parameter |
|---|---|---|
i8 … u64, f32, f64, bool | int8_t … uint64_t, float, double, bool | by value |
string | std::string | std::string_view |
bytes | std::vector<uint8_t> | const std::vector<uint8_t>& |
| C-style enum | enum class E : int32_t | by value |
| record | struct R | const R& |
| rich enum | struct E with std::variant<...> value | const E& |
| interface | RAII class | const I& (borrowed) |
I? | std::optional<I> | const std::optional<I>& |
callback interface, Cb? | abstract class | std::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
newbecomes a C++ constructor (explicitwhen 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 theadopttag. - 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 namedhandle,clone_handle, orraw_typegains 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 inError, which is appended when the IDL name lacks it (theKitchenErrorsdomain becomesKitchenErrorsError). 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
Errorwith that code: -1 generic, -2 producer panic, -3 marshalling failure, and -4 a callback implementation that failed. - -5 throws
Cancelled, which also derives fromError.
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::vectorfor random access. - A callback interface must be a
std::shared_ptrto a subclass; there’s no lambda overload. - Exceptions not derived from
std::exceptionare 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] key | Default | Meaning |
|---|---|---|
name | PascalCase(name) | Package, product, and module name |
min_macos, min_ios | "11.0", "13.0" | The manifest’s platforms: |
xcframework_url | a placeholder | Where the packaged binary target downloads C{Module}.xcframework.zip; {version} and {file} are substituted |
Build and link
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 type | Swift type | At the ABI |
|---|---|---|
i8 to i64, u8 to u64 | Int8 to Int64, UInt8 to UInt64 | Direct |
f32, f64 | Float, Double | Direct |
bool | Bool | C bool |
string | String | UTF-8 pointer and length |
bytes | Data | Pointer and length |
| C-style enum | enum E: Int32, Sendable | int32_t |
| Record | struct, Sendable (and Hashable when every field is) | Value buffer |
| Rich enum | enum with associated values | Value buffer |
T?, [T], {K: V} | T?, [T], [K: V] | Value buffer |
| Interface | final class, @unchecked Sendable | Object pointer |
Interface? | optional wrapper | Nullable pointer |
| Callback interface | any P, where protocol P: AnyObject, Sendable | Context and vtable |
Callback? | (any P)? | Context and nullable vtable |
iter<T> | generated Sequence class | Iterator 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 (StoreorStore?) 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
throwsin 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 declaredthrows, reaches the library as a callback failure (-4) with the error’slocalizedDescription. 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}_LIBRARYisn’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(theStatsrecord beside thekv.statsmodule’sKv.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 onlySendable.
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. Whenlibkitchen_sink_jni.sosits 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 onjava.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 type | Kotlin type | Crosses JNI as |
|---|---|---|
i8, i16, i32, i64 | Byte, Short, Int, Long | jbyte, jshort, jint, jlong |
u8, u16, u32, u64 | UByte, UShort, UInt, ULong | the signed type of the same width |
f32, f64 | Float, Double | jfloat, jdouble |
bool | Boolean | jboolean |
string | String | UTF-8 ByteArray (pointer plus length) |
bytes | ByteArray | ByteArray |
| enum | enum class with value | jint |
| record | data class | value buffer |
| rich enum | sealed class | value buffer |
T?, [T], {K:V} | T?, List<T>, Map<K, V> | value buffer |
interface, Iface? | wrapper class, nullable wrapper | address (0 is none) |
callback interface, Cb? | Kotlin interface, nullable | the 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:UnitbecomesUnit_. - A module object whose name equals a type’s, or the name of a generated
file (
Runtime,Buffers,Codecs,Async,JniBridge), gains aModulesuffix: akv.statsmodule beside aStatsrecord isKv.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 declaredthrowsmay 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 declarethrows, reaches the producer as code-4with 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
kotlincand 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-3paths 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 type | TypeScript type | Notes |
|---|---|---|
i8, i16, i32, u8, u16, u32 | number | |
i64, u64 | bigint | Integral numbers are accepted as arguments; out-of-range values throw a RangeError |
f32, f64 | number | NaN, the infinities, and -0 round-trip |
bool | boolean | |
string | string | UTF-8, passed as pointer and length, so interior NULs survive |
bytes | Uint8Array | |
| record | interface | A plain object; fields keep their IDL names |
| C-style enum | enum | A frozen object with forward and reverse mappings (negative values too) |
| rich enum | tagged union | { tag: 'Circle', radius: 2.5 } |
| interface | class | See Objects |
| callback interface | interface | Any object with the methods; see Callback interfaces |
T? | T | null | undefined 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 staticCODEand the IDL message as its default message. Payload fields become properties. A code without fields is constructed asnew 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 return | Return from JavaScript | What the library receives |
|---|---|---|
direct (numbers, bool, C-style enums) | the value | the value |
string, bytes | a string, a Uint8Array | a copy in a run the library adopts |
record, rich enum, T?, [T], {K:V} | the value | its encoding, in a run the library adopts |
I | a wrapper (never null) | a new strong reference to its object |
I? | a wrapper or null | a 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}_LIBRARYselects 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:
- the path in
{PREFIX}_LIBRARY(for exampleKVSTORE_LIBRARY), - a library bundled inside the package directory (what
weaveffi packageproduces, and whereweaveffi devcopies the debug build), and - the system loader’s search path, by platform file name
(
libkvstore.dylib,libkvstore.so, orkvstore.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 type | Python type | Crosses the ABI as |
|---|---|---|
i8 to u64 | int | c_int8 to c_uint64 |
f32, f64 | float | c_float, c_double |
bool | bool | c_bool |
string | str | UTF-8 bytes plus length (c_char_p, c_size_t) |
bytes | bytes | pointer plus length |
| C-style enum | IntEnum subclass | c_int32 |
| record | @dataclass | value buffer |
| rich enum | base class plus one @dataclass per variant | value buffer |
T? | Optional[T] | value buffer |
[T], {K:V} | List[T], Dict[K, V] | value buffer |
| interface | wrapper class | c_void_p |
interface ? | Optional[...] | c_void_p, NULL for None |
| callback interface | subclass of the generated ABC | context 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 (
Noneis allowed only forI?); - a
str,bytes, record, or other buffered value through theout_ptr/out_lenslots, in a run allocated with{prefix}_allocthat 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
ctypesdispatch 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.ctypestruncates an out-of-range integer argument to the parameter’s C width instead of raising; inside a value buffer,structraises.- 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 asIterator[T], so a type checker doesn’t know about theirclose()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 type | C# type | Crosses the ABI as |
|---|---|---|
i8 … u64, f32, f64 | sbyte … ulong, float, double | by value |
bool | bool | one byte |
string | string | UTF-8 (ptr, len); interior NULs survive |
bytes | byte[] (parameters also take ReadOnlySpan<byte>) | (ptr, len) |
| C-style enum | enum with the IDL discriminants | int32_t |
| record | sealed class with get-only properties | value buffer |
| rich enum | abstract class with one nested sealed class per variant | value 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 |
| interface | sealed class : IDisposable | object pointer |
| callback interface | interface 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) throwsNativeExceptionitself, whoseCodeproperty carries it and whose constants name it (GenericErrorCode,PanicErrorCode,MarshalErrorCode,ForeignErrorCode). - A call that isn’t
throwscan’t fail by contract, so a failure is a producer bug: it throwsNativeBugException, anInvalidOperationExceptionwhoseCodeis 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()); anullfor 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}_allocand 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
calculatormodule in theCalculatorpackage) must be reached asCalculator.Calculatoror throughusing static. - Only one
DllImportResolvercan exist per assembly. If you compile the generated sources into an assembly that already has one, the{PREFIX}_LIBRARYoverride is skipped and default probing applies. - Async functions that aren’t
cancellabletake noCancellationToken. - 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), callkvstore_{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 type | Dart type | Crosses the C ABI as |
|---|---|---|
i8 to i64, u8 to u64 | int | by value (u64 as its two’s-complement bit pattern) |
f32, f64 | double | by value |
bool | bool | C bool |
string | String | UTF-8 (ptr, len), so interior NULs survive |
bytes | List<int> (a Uint8List when returned) | (ptr, len) |
| Record | a final class with final fields, named constructor arguments, and value equality | value buffer |
| Plain enum | an enhanced enum with value and fromValue | int32_t |
| Rich enum | a sealed class with one final subclass per variant | value buffer |
| Interface | a final class wrapper | object 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 interface | an abstract class you implement | ctx 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.isolateLocaltrampoline. 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.isolateGroupBoundforwarder. 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 declaredthrows. The vtable’sfreeis 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:
| Name | Default | Override |
|---|---|---|
| Module path | the package name | [generators.go] name |
| Package name | the C prefix | none |
| Linked library | -l{library} | none |
| Header | {library}.h | none |
[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):
| Declaration | Go name |
|---|---|
function kv.stats.summarize | Summarize |
interface Store | *Store |
constructor Store.open | OpenStore (and new is NewStore) |
method Store.get_or_load | (*Store).GetOrLoad |
static Store.default_capacity | StoreDefaultCapacity |
C-style enum EntryKind, variant Volatile | EntryKind, EntryKindVolatile |
rich enum Change, variant Put | Change, ChangePut |
error domain KvError, code KeyNotFound | KvError, *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 type | Go type | Crosses the ABI as |
|---|---|---|
i8 to i64, u8 to u64 | int8 to int64, uint8 to uint64 | the C integer |
f32, f64 | float32, float64 | float, double |
bool | bool | C bool |
string | string | borrowed (ptr, len) UTF-8; returns are copied and freed |
bytes | []byte | borrowed (ptr, len); returns are copied and freed |
| C-style enum | type E int32 plus E{Variant} constants | int32_t |
| rich enum | sealed interface plus one struct per variant | value buffer |
| record | plain struct with exported fields | value buffer |
T? | *T; a slice, map, rich enum, []byte, or wrapper stays as it is and nil is none | value buffer |
[T], {K: V} | []T, map[K]V | value buffer |
| interface | *Iface | object pointer |
Iface? | *Iface (nil is none) | object pointer or null |
| callback interface | a Go interface you implement | handle 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 (
homeabove) is a new wrapper that owns its own reference: keep it, return it, orCloseit (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
throwsreturns(T, error)orerror. 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-4with its text. A method that panics, throwing or not, is recovered and reported as-4with 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:
| Name | Default | Override ([generators.ruby]) |
|---|---|---|
| Gem | the package name (key-store) | name |
| Ruby module | the package name in PascalCase (KeyStore) | module_name |
require path | the 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:
- the path in
{PREFIX}_LIBRARY(KVSTORE_LIBRARY=/path/to/libkvstore.dylib), - a copy bundled at
lib/native/(platform gems fromweaveffi package), libkvstore.dylib,libkvstore.so, orkvstore.dllon 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 type | Ruby type | C slots |
|---|---|---|
i8 to i64, u8 to u64 | Integer | :int8 to :uint64 |
f32, f64 | Float | :float, :double |
bool | true or false | :bool |
string | String (UTF-8) | :pointer + :size_t |
bytes | String (binary) | :pointer + :size_t |
| C-style enum | Integer constants in a module | :int32 |
| record | plain value class with keyword initialize and == | value buffer |
| rich enum | base class with one nested class per variant | value buffer |
T? | T or nil | value buffer |
[T], {K: V} | Array, Hash | value buffer |
| interface | wrapper class | :pointer |
Interface? | wrapper or nil | :pointer (NULL for nil) |
| callback interface | any object with the methods | context + vtable |
Cb? | such an object or nil | context + vtable (NULL for nil) |
iter<T> | lazy Enumerator | iterator 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
closereleases 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.dupandclonemake an independent wrapper with its own reference.- A constructor named
newbecomesinitialize; other constructors are class methods (Store.open). An interface without one hidesnew. - Every call pins its receiver and object arguments (
_wv_pin), so aclosefrom 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 namedallocateorname, gains a trailing_: an IDL methodcloseisclose_(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:
| Call | Domain code | Runtime code (-1 to -4) | -5 |
|---|---|---|---|
declared throws | the code’s class (KvError::Expired) | Error with that code and the library’s message | Cancelled |
not throws | not possible | NativeBugError | Cancelled |
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 (
StoreorStore?) as a fresh reference the library adopts (nilis null, which the library rejects with-3where 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
throwsin 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 (includingNotImplementedError, and a domain error from a method not declaredthrows) 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
Enumeratordriven externally withnextand 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 = nillike 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.
| Crate | Owns |
|---|---|
weaveffi-model | Everything 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-cli | The 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). |
weaveffi | The 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-fuzz | cargo-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(), andTy::wire(); a generator never re-derives a symbol or reads the raw IR. Names come from theIdentityon theModel. - Global names. Type names, free-function names, and error-code names
are unique across the API, so a
Tycarries 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_symbolslists 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 withinclude_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 fromweaveffi_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), orIterator(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:
- Render. Every target renders in parallel (rayon), in memory.
- 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. - 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
- Add
crates/weaveffi-cli/src/targets/<lang>/following an existing target’s layout:mod.rs(config, generator,LanguageBackendimpl),types.rs,codec.rs,calls.rs,entities.rs,package.rs, aruntime/directory of fixed source, andtests.rs. - 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. Implementpackageif the ecosystem has an installable artifact. - Register it with one line in
cli_targets!incrates/weaveffi-cli/src/config.rs. - Add it to
snapshot_tests!incrates/weaveffi-cli/tests/snapshots.rsand accept the new snapshots. - Add
scripts/fixtures/<lang>.sh, which compiles or type-checks a generated tree with the language’s toolchain, and a CI matrix entry. - Add consumers under
conformance/<lang>/with arun.shthat declares one lane per sample, and add the language toconformance/run.sh. - Write
docs/src/generators/<lang>.md, add it toSUMMARY.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.
| Layer | Where | What it pins |
|---|---|---|
| Unit tests | #[cfg(test)] modules; tests.rs in each target | Parsing, validation rules, Ty classification, lowering, naming helpers, metadata frames (meta) |
| Validation tests | crates/weaveffi-model/src/validate/tests.rs | Every ValidationError with its source span |
| Property tests | crates/weaveffi/tests/buffer_proptest.rs | Codec laws: round trips, self-delimiting encodings, rejected trailing bytes |
| Runtime tests | crates/weaveffi/tests/ | Exported runtime symbols, cancellation and dropped futures, foreign-error routing, leak counters |
| Macro tests | crates/weaveffi-macros/tests/ui/ (trybuild) | pass_*.rs compile; fail_*.rs fail with the pinned .stderr |
| Snapshots | crates/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 tests | crates/weaveffi-cli/tests/cli/, crates/weaveffi-cli/tests/abi_version.rs | Every 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 checks | scripts/check-fixtures.sh <target> | Every fixture’s generated tree compiles or type-checks with the target’s toolchain |
| Conformance | conformance/run.sh | Real consumers in every language against every sample, with leak counters at zero |
| Fuzzing | crates/weaveffi-fuzz | Parsers 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:
vector_count()returns how many vectors there are.vector(i)returns vectorifor the consumer to decode. An index past the end fails withCodecError::OutOfRange { index, count }.check_vector(i, v)takes the decoded value back, re-encoded by the consumer, and returnstrueonly if it’s exactly vectori(floats keep their sign, and any NaN matches NaN).vector_name(i)anddescribe_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.
| Feature | Where |
|---|---|
| Interface with constructors, methods, and statics | Store (open, new; put, get, …; open_many, default_capacity, …) |
| Typed errors with payload fields | KvError: KeyNotFound { key }, Expired { key, expired_at }, StoreFull { capacity }, InvalidPath, Rejected { key, reason } |
| Records, optionals, lists, maps | Entry (bytes, an optional TTL, tags, metadata), StoreInfo, Stats (a map keyed by a C-style enum) |
| C-style and rich enums | EntryKind; Change (Put, Removed, Cleared) |
| Objects in every position | parameters, 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 interfaces | Listener (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 thread | compact notifies listeners from the thread it runs on |
| Lazy iterators | keys (strings, throwing), entries (records), partition (objects) |
| Async and cancellation | open_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 modules | kv.stats uses the parent’s Store and reports the parent’s KvError |
| Sibling roots | report shares the Entry record and has its own ReportError |
| Deprecation | Store::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
| WeaveFFI | UniFFI | Diplomat | cbindgen + glue | swift-bridge | napi-rs | wasm-bindgen | SWIG | autocxx | |
|---|---|---|---|---|---|---|---|---|---|
| Producer language | Rust, C, C++, Zig (anything with a C ABI) | Rust | Rust | Rust | Rust | Rust | Rust | C / C++ | C++ (consumed from Rust) |
| Input | YAML / JSON / TOML IDL or annotated Rust (read from the built library) | UDL or proc-macros (library mode reads the built library) | annotated Rust bridge crate | Rust source | annotated Rust bridge module | annotated Rust | annotated Rust | C/C++ headers + .i file | C++ headers |
| Consumer languages | C, C++, Swift, Kotlin, Node.js, Wasm/JS, Python, .NET, Dart, Go, Ruby | Kotlin, Swift, Python, Ruby first-party; Go, C#, Dart, Kotlin Multiplatform, React Native (uniffi-bindgen-react-native) as external bindgens | C, C++, JS/TS (Wasm), Dart, Kotlin (JNA), Python (nanobind) | C (you write the rest) | Swift | Node.js | JS/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 pointers | partial (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/a | n/a | ✓ (%include) | ✓ |
| Workflow | |||||||||
| Standalone CLI | ✓ (cargo install weaveffi-cli) | uniffi-bindgen (build.rs or CLI) | diplomat-tool | ✓ | swift-bridge-cli | napi CLI (npm) | wasm-bindgen-cli | system package | cargo 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) | partial | n/a | ✓ (SwiftPM) | ✓ (npm) | ✓ (npm via wasm-pack) | ✗ | n/a |
| Schema-checked IDL with JSON Schema | ✓ | ✗ | n/a | n/a | n/a | n/a | n/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-time | build-time | ✓ | build-time | build-time | build-time | ✗ | build-time |
| Maturity | pre-1.0 | shipping in Firefox and Mozilla products since 2020 | shipping in ICU4X | 1.0+, widely deployed | 0.1.x, active | 2.x+, widely deployed | 0.2.x, ubiquitous | 30+ years | pre-1.0 |
| License | MIT OR Apache-2.0 | MPL-2.0 | MIT OR Apache-2.0 | MPL-2.0 | MIT OR Apache-2.0 | MIT | MIT OR Apache-2.0 | GPL-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 andthrow, 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-swiftbuilds the XCFramework and SwiftPM package,maturinbuilds Python wheels, anduniffi-bindgen-react-nativetargets React Native, where WeaveFFI’sweaveffi packagecovers 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 throughweb-sysandjs-sysand 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-unknownbuild 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 anAbortSignalto cancel), and callback-interface methods fire only while a call into the module is on the stack. wasm-bindgen’swasm-bindgen-futuresintegrates with the JS event loop natively, and Emscripten-based toolchains can usepthreads. 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 packageis 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.0and 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:
- 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.
- 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++. - 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. - 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 aweaveffi::ForeignError(carrying a typed domain error when the method isthrows) that it can propagate to the original caller with?. - Standalone, publishable consumer packages. Generated packages are
self-contained;
weaveffi buildcross-compiles the producer per platform andweaveffi packagebundles 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. - Idiomatic per-target output, not a lowest-common-denominator API.
Async functions become
async/awaitin Swift,Promises in Node and Wasm,suspend funin Kotlin,async defin Python,Task<T>in C#, andFuture<T>in Dart, all from the sameasync: trueflag; Rust producers can plug their own executor withweaveffi::set_spawner. Cancelling the awaiting task, coroutine, or promise cancels the native call. - A CI-first CLI.
validate,diff --check,extract,schema,build, andpackageare 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 therust-diplomat/diplomatrepository).
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 theversion:an IDL document declares. Pre-1.0, only the current version is accepted; a document declaring any other is rejected withUnsupportedSchemaVersion. - 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 jsonin 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, notcontacts.Contact(QualifiedTypeRef). usize,isize,u128,i128, andcharare reported asUnsupportedPrimitive, with a suggested replacement.DuplicateStructName,DuplicateEnumName,DuplicateInterfaceName,DuplicateCallbackInterfaceName, andDuplicateErrorNameare gone:DuplicateTypeNameandDuplicateErrorCodeNamereport those once.- A callback-interface method may return any type except an iterator or a
callback interface, and may set
throws: truewhen 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()returns4.- Contract tables replace checksums.
{prefix}_{module}_checksum()and{PREFIX}_{MODULE}_CHECKSUMare gone; each top-level module exportsconst {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, or0. 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 (freeused to come last). Setsizetosizeofthe vtable andflagsto0. 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_lenslots holding a run the consumer allocates with{prefix}_alloc; an object return is one strong reference. The producer adopts both. - A callback method declared
throwsmay 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 waswasm32only) and returns a zero-filled run, or null for0.{prefix}_deallocis removed:{prefix}_free_bytesfrees both returned runs andallocruns, and is a no-op for length0.{prefix}_debug_live(-1)returns1when the producer counts and0when it doesn’t.- An async call the executor can’t start completes with
-1and a message. - A value buffer whose map repeats a key, and an iterator
_nextthat arrives while the same iterator is being advanced, fail with-3.
Rust producers
- The crates are consolidated.
weaveffi-abiis now theweaveffi::abimodule, andweaveffi-genmoved intoweaveffi-cli, which is a library as well as theweaveffibinary. Depend onweaveffialone. - Every callback-interface method must return
Result<T, weaveffi::ForeignError>; a plainTis a compile error.ForeignErrorgains apayloadfield anddomain::<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, andtake_foreign_errorno longer exist, and a callback failure never unwinds or waits for the thunk. To propagate one, returnResult<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_valueandBufferValue::read_valueareunsafe fn, because decoding an object token adopts a reference. A reader built withBufferReader::token_freerefuses tokens instead.- C-style enums implement the runtime trait
weaveffi::abi::CEnum, which replaces the generated__weaveffi_from_i32and__weaveffi_to_i32inherent 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
weavefficrate gains atokiofeature that runs futures on the current Tokio runtime, or on one it creates;weaveffi::set_spawnerstill 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 theweaveffi_metacustom section onwasm32), andgenerate,validate,diff, andextractbuild the crate withcargo build --liband read it from there, so generated bindings follow#[cfg].--library <path>reads an existing build instead, and--releasebuilds 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 itsCargo.tomlon the command line instead of a.rsfile. weaveffi extractprints the IDL a built library embeds (--library, or the project’s crate) instead of parsing a source file. Its--lenientflag is gone: what it prints always validates.generate --forceis 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 itspre_generateandpost_generatehooks and everystrip_module_prefixkey (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
namekey 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, andruby.gem_nameall becomename. Ruby’smodule_nameand Python’simport_namestay. [project] outdefaults tobindingsinstead ofgenerated, and generated files’ headers no longer name the input file.weaveffi devis 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 buildis new, andweaveffi packagewrites installable artifacts (wheels, npm tarballs, gems, anXCFrameworkSwiftPM package, a.nupkg) instead of source trees.package --build <crate>is gone:packagebuilds the project’s producer itself, or reads--binaries <dir>. The dist directory is-o, else[package] dist, elsedist. See Packaging.- The
weaveffi-clilibrary exposesproject::Project, which locates a project and generates it, for use from abuild.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, andu64toUByte,UShort,UInt, andULonginstead of the signed types. - Python ships one fully annotated module plus
py.typed; the separate.pyistub 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_prefixand every per-target prefix key are gone; an IDL sets[package] c_prefixorlibrary, 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
_asyncsuffix, cancel tokens are reference counted, and runtime code-5means cancelled. - Map keys can’t be floats, and
cancellable: trueon a synchronous callable is an error. - The runtime is exported with
weaveffi::export_runtime!(), and a#[weaveffi::error]enum must implementDisplay. - The
[project]table andweaveffi initare 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:
| Crate | Use it when |
|---|---|
weaveffi | You’re writing a Rust producer. It’s the only dependency a producer needs, and its abi module is the runtime. |
weaveffi-macros | Never directly; weaveffi re-exports its macros. |
weaveffi-model | You’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-cli | You 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
weaveffiand itsabiruntime, 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
| Item | Purpose |
|---|---|
#[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). |
CancelToken | A cancellable function’s last parameter; is_cancelled() for cooperative cleanup. |
ForeignError | A 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 ?. |
ErrorDomain | Implemented by every #[weaveffi::error] enum, so ForeignError::domain can decode it. |
ErrorReport | Maps an error type to (code, message, payload); implemented for String, &str, boxed errors, ForeignError, and every #[weaveffi::error] enum. |
set_spawner, Spawner, BoxFuture | Install the executor async functions run on, overriding the default worker pool (or Tokio). |
abi | The 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.
| Module | Items |
|---|---|
| root | ABI_VERSION (4). |
error | FfiError, 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. |
marshal | CEnum, 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. |
callback | ForeignError; 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. |
cancel | CancelToken. (macro) Cancellable, FfiCancelToken, AtomicWaker, and the cancel_token_* bodies. |
iter | Iter. (macro) IterHandle, iter_into_raw, iter_next, iter_destroy. |
buffer | The 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. |
contract | ContractEntry. (macro) contract_table, contract_len, contract_compact. |
spawn | set_spawner, Spawner, SpawnerAlreadySet, BoxFuture, SpawnError, default_pool_size, and block_on (a blocking executor for tests). (macro) launch_async, run_async, spawn, CatchUnwind. |
leak | debug_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# Safetysections. 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.tomlinstead. - 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
# Errorssection to every public function that returnsResult, describing the conditions that produce anErr. Clippy’smissing_errors_docenforces this. -
Add a
# Panicssection to any public function that can panic, describing when. Clippy’smissing_panics_docenforces this. If a panic path is provably unreachable (for example anexpecton 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_runor```ignorefor examples that need a builtcdylib, a file path, or other state the doctest can’t set up. Use a plain```rustblock (whichcargo testcompiles 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.
Intra-doc links
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):
| Lint | What it requires |
|---|---|
missing_docs (deny) | A doc comment on every public item, field, and variant |
clippy::missing_errors_doc | A # Errors section on public fns returning Result |
clippy::missing_panics_doc | A # Panics section on public fns that can panic |
clippy::missing_safety_doc | A # Safety section on public unsafe fns (on by default) |
clippy::doc_markdown | Backticks 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.