Introduction
WeaveFFI generates type-safe bindings for 11 languages for any native library that exposes a C ABI, whether it’s written in Rust, C, C++, Zig, or another language: no hand-written JNI, no duplicate implementations, no unsafe boilerplate.
Define your API once as an IDL in YAML, JSON, or TOML, and WeaveFFI generates idiomatic packages for C, C++, Swift, Kotlin/Android, Node.js, WebAssembly, Python, .NET, Dart, Go, and Ruby, all talking to the same stable C ABI. Any backend that implements the symbols declared in the generated C header can be the producer.
Writing your producer in Rust? Annotate a normal module with
#[weaveffi::module] and the macro generates the C ABI and derives the IDL for
you, so you write no unsafe glue and keep no separate IDL in sync. The macro
is one ergonomic path onto the same engine the IDL uses, so whichever you pick,
the producer you build and the bindings you ship cannot drift.
Why WeaveFFI?
- One definition, eleven languages. Write the API once (safe Rust or an IDL) and ship packages to npm, SwiftPM, Maven, PyPI, NuGet, pub.dev, RubyGems, and Go modules.
- Safe Rust in, C ABI out. The
#[weaveffi::module]macro emits theextern "C"thunks, marshalling every argument through an audited runtime, so a Rust producer writes nounsafeglue and the IDL is derived from the code rather than maintained beside it. - Stable C ABI underneath. Every target speaks to the same
extern "C"contract, so adding a new platform later is a code-gen change, not a rewrite. - Idiomatic per-target output. No lowest-common-denominator surface
area. Swift gets
async/awaitandthrows, Kotlin getssuspendand JNI glue, Python gets typed.pyistubs, TypeScript getsPromises, Dart getsdart:ffi, all from the same definition.
Design principle: standalone generated packages
Generated packages are fully self-contained and publishable to their native ecosystem (npm, CocoaPods, Maven Central, PyPI, NuGet, pub.dev, RubyGems, etc.) without requiring consumers to install WeaveFFI tooling or runtime dependencies. WeaveFFI is a build-time tool for library authors; consumers should never need to know it exists. Helper code (error types, memory management utilities) is generated inline into each package rather than pulled from a shared runtime dependency.
Where to next
- Getting Started: install, define an IDL, generate, and call from C.
- The Rust Producer Macro: the
#[weaveffi::module]attribute family, the supported feature set, and the roadmap. - Comparison: feature matrix vs UniFFI, cbindgen, diplomat, SWIG, autocxx, and an honest “when to choose WeaveFFI” guide.
- FAQ: runtime cost, customization, Windows support, distribution, licensing.
- Samples: the kitchen-sink
kvstorereference plus calculator/contacts/inventory walkthroughs. - Generators: per-target reference for each of the eleven languages.
- Guides: memory ownership, error handling, async, configuration.
Getting Started
This guide walks you through installing WeaveFFI, defining an API as a language-neutral IDL, generating multi-language bindings from it, implementing the native library behind the generated C ABI, and calling it from C.
WeaveFFI works with any native library that exposes a C ABI, so the producer
can be written in Rust, C, C++, Zig, or anything else that can speak C. This
guide implements it in Rust because that’s the quickest to set up. If you’re
writing a Rust producer, you can also let the #[weaveffi::module] macro
generate the C ABI and derive the IDL for you, instead of hand-writing YAML
(see step 2).
Prerequisites
You need the Rust toolchain (stable channel) to install the CLI, and for this guide’s Rust producer. Verify with:
rustc --version
cargo --version
The CLI is the only hard requirement. The library you generate bindings for can be written in any language that exposes a C ABI.
1) Install WeaveFFI
Install the CLI from crates.io:
cargo install weaveffi-cli
This puts the weaveffi binary on your PATH.
2) Define your API as an IDL
Describe the API once in a language-neutral IDL. Create math.yml with a
record and a function:
version: "0.5.0"
package:
name: my-math
version: "0.1.0"
modules:
- name: math
structs:
- name: Point
fields:
- { name: x, type: f64 }
- { name: y, type: f64 }
functions:
- name: add
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
The optional package: block sets the name and version stamped into every
generated package manifest (package.json, pyproject.toml, Package.swift,
and so on). The IDL also supports primitives (i32, f64, bool, string,
bytes, handle), optionals (string?), lists ([i32]), interfaces
(objects with constructors, methods, and statics), and typed error domains
(opt in per function with throws: true). See the
IDL Schema reference for the full
specification.
Prefer not to hand-write YAML? Run
weaveffi new my-projectto scaffold a starter project (an example IDL plus aCargo.tomlandsrc/lib.rsstub) you can edit instead.
Writing a Rust producer? You can make annotated Rust the single source of truth instead of a separate IDL: annotate a module with
#[weaveffi::module]and point the generator straight at the source. The macro emits the C ABI and derives the IDL from your code, so you write nounsafeglue. See The Rust Producer Macro. The rest of this guide uses the IDL.
3) Generate bindings
Run the generator to produce bindings for all targets:
weaveffi generate math.yml -o generated --scaffold
The --scaffold flag also emits a scaffold.rs with Rust FFI stubs you can
use as a starting point. The output tree looks like:
generated/
├── c/ # C header + convenience stubs
├── swift/ # SwiftPM package + Swift wrapper
├── android/ # Kotlin JNI wrapper + Gradle skeleton
├── node/ # N-API loader + TypeScript types
├── wasm/ # Wasm loader stub
└── scaffold.rs # Rust FFI function stubs
4) Examine the generated output
C header (generated/c/weaveffi.h)
The C generator produces an opaque struct with lifecycle functions and getters,
plus a module-level function. Functions and constructors take an out_err
parameter for error reporting (destructors and getters don’t):
typedef struct weaveffi_math_Point weaveffi_math_Point;
weaveffi_math_Point* weaveffi_math_Point_create(
double x, double y, weaveffi_error* out_err);
void weaveffi_math_Point_destroy(weaveffi_math_Point* ptr);
double weaveffi_math_Point_get_x(const weaveffi_math_Point* ptr);
double weaveffi_math_Point_get_y(const weaveffi_math_Point* ptr);
int32_t weaveffi_math_add(int32_t a, int32_t b, weaveffi_error* out_err);
Swift wrapper (generated/swift/Sources/MyMath/MyMath.swift)
Structs become classes that own an OpaquePointer and free it on deinit.
Module functions are grouped under a Swift enum namespace. Because add
doesn’t declare throws: true, its Swift wrapper is a plain non-throwing
function:
public class Point {
let ptr: OpaquePointer
deinit { weaveffi_math_Point_destroy(ptr) }
public var x: Double { weaveffi_math_Point_get_x(ptr) }
public var y: Double { weaveffi_math_Point_get_y(ptr) }
}
public enum Math {
public static func add(a: Int32, b: Int32) -> Int32 { ... }
}
TypeScript types (generated/node/types.d.ts)
Structs become interfaces with mapped types. Functions use the IR name directly (no module prefix):
export interface Point {
x: number;
y: number;
}
// module math
export function add(a: number, b: number): number
5) Implement the library behind the C ABI
The generated C header (generated/c/weaveffi.h) is the contract your native
library must satisfy, and it’s the same contract every language binding calls
into. You can implement it in any language that can expose a C ABI; here we use
Rust, starting from the generated scaffold.rs, which already contains a
#[no_mangle] extern "C" stub (with a todo!() body) for every symbol in the
header.
Create a library crate, add the WeaveFFI ABI helpers, and build a cdylib:
cargo new --lib my-math
cd my-math
cargo add weaveffi-abi
In Cargo.toml:
[lib]
crate-type = ["cdylib"]
Copy scaffold.rs into src/lib.rs and fill in the bodies. Implementing add
looks like this (struct lifecycle omitted for brevity):
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_math_add(
a: i32,
b: i32,
out_err: *mut weaveffi_error,
) -> i32 {
abi::error_set_ok(out_err);
a + b
}
// Emit the fixed WeaveFFI C ABI runtime surface (free_string, free_bytes,
// error_clear, cancel_token_*) in one line. Call this exactly once per
// cdylib.
abi::export_runtime!();
}
Key points:
- Every exported function uses
#[no_mangle]andextern "C". out_errmust always be cleared on success withabi::error_set_ok.- On error, call
abi::error_set(out_err, code, message)and return a zero/null value. - The library must export the WeaveFFI runtime symbols: invoke
weaveffi_abi::export_runtime!()to emit all of them in one line instead of writing each#[no_mangle]thunk by hand.
Tip for Rust producers: the
#[weaveffi::module]macro generates these#[no_mangle] extern "C"thunks for you from safe Rust, so you never fill in stubs by hand. See The Rust Producer Macro.
Build with:
cargo build
This produces a shared library (libmy_math.dylib on macOS,
libmy_math.so on Linux, my_math.dll on Windows). The exported symbols match
generated/c/weaveffi.h by construction.
6) Build and test with C
Write a small C program that calls your library:
main.c:
#include <stdio.h>
#include "weaveffi.h"
int main(void) {
struct weaveffi_error err = {0};
int32_t sum = weaveffi_math_add(3, 4, &err);
if (err.code) {
printf("error: %s\n", err.message);
weaveffi_error_clear(&err);
return 1;
}
printf("add(3, 4) = %d\n", sum);
return 0;
}
Compile, link, and run:
# macOS
cc -I generated/c main.c -L target/debug -lmy_math -o my_example
DYLD_LIBRARY_PATH=target/debug ./my_example
# Linux
cc -I generated/c main.c -L target/debug -lmy_math -o my_example
LD_LIBRARY_PATH=target/debug ./my_example
Expected output:
add(3, 4) = 7
Next steps
- Run
weaveffi doctorto check which platform toolchains are available. - Read the IDL Schema reference for all supported types and features.
- Writing a Rust producer? See The Rust Producer Macro to skip the scaffold and generate the C ABI directly from annotated Rust.
- See the Calculator tutorial for a full end-to-end walkthrough including Swift and Node.js.
- Explore the Generators section for target-specific details.
Checking a single target
weaveffi doctor runs every toolchain check it knows about. To narrow it
down to a single target, pass --target {name}:
weaveffi doctor --target dart
weaveffi doctor --target cpp
weaveffi doctor --target go
weaveffi doctor --target ruby
weaveffi doctor --target dotnet
weaveffi doctor --target python
weaveffi doctor --target swift
weaveffi doctor --target android
weaveffi doctor --target node
weaveffi doctor --target wasm
Only checks whose applies_to set contains the chosen target (plus the
required Rust toolchain, which always runs) are executed. When --target
is set the command exits with a non-zero status if any of those checks
failed, making it scriptable in CI:
if ! weaveffi doctor --target dart; then
echo "Dart toolchain not ready" >&2
exit 1
fi
For machine-readable output (handy for piping into jq or aggregating
results across CI matrices), use --format json:
weaveffi doctor --target ruby --format json | jq '.[] | select(.ok == false)'
Each entry has id, name, ok, version, hint, and applies_to fields.
Architecture
This page is the canonical reference for how WeaveFFI works internally. It is the document new generator authors and contributors should read before making non-trivial changes; all other documentation is consumer- or library-author-facing.
High-level pipeline
Every weaveffi generate invocation flows through the same five
stages, in this order:
Input: annotated Rust (.rs) or an IDL (YAML/JSON/TOML)
│
▼
Parse ── weaveffi-ir::parse (IDL) | weaveffi-bridge (.rs): builds an `Api` IR
│
▼
Validate ── weaveffi-core::validate: rejects errors, collects warnings,
│ and rewrites every parsed type reference to its resolved kind
│
▼
Resolve ── weaveffi-cli `CliConfig`: merges --config TOML and the
│ inline generators: section into each target's typed config
▼
Generate ── weaveffi-core::codegen::Orchestrator: dispatches every
│ selected target generator in parallel via rayon
▼
Output ── Each generator writes its files under {out_dir}/{target}/
and updates {out_dir}/.weaveffi-cache/{target}.hash
Subcommands like validate, lint, diff, format, and watch re-use
the parse and validate stages; generate, diff, and watch
additionally exercise resolve and generate.
A .rs input is lowered to the IR by weaveffi-bridge, the same extractor
the #[weaveffi::module] proc-macro uses to build a producer’s C ABI glue.
Because the CLI and the macro share one extraction, the IDL the CLI derives
and the symbols the macro emits are two views of one parse and cannot drift.
See The Rust Producer Macro.
Crate layout
The workspace is structured as a small set of stable, focused crates. The dependency graph is acyclic and shallow:
weaveffi-cli ──► weaveffi-core ──► weaveffi-ir
│ │
│ ├──► weaveffi-gen-c
│ ├──► weaveffi-gen-cpp
│ ├──► weaveffi-gen-swift
│ ├──► weaveffi-gen-android
│ ├──► weaveffi-gen-node
│ ├──► weaveffi-gen-wasm
│ ├──► weaveffi-gen-python
│ ├──► weaveffi-gen-dotnet
│ ├──► weaveffi-gen-dart
│ ├──► weaveffi-gen-go
│ └──► weaveffi-gen-ruby
└──► weaveffi-bridge ──► weaveffi-ir (lowers annotated .rs to IR)
Producer side (a Rust cdylib depends on these, not on the CLI):
weaveffi ──► weaveffi-macros ──► weaveffi-bridge, weaveffi-core, weaveffi-ir
│
└──► weaveffi-abi (the C ABI runtime, re-exported as `weaveffi::abi`)
weaveffi-abi ──► (stand-alone, linked at run time by every cdylib that
exposes the WeaveFFI C ABI)
weaveffi-fuzz ──► weaveffi-ir, weaveffi-core (workspace-private; unpublished)
| Crate | What it owns |
|---|---|
weaveffi-ir | The IR types (Api, Module, Function, TypeRef, …), the parse_api_str parser, the parse_type_ref mini-grammar, and CURRENT_SCHEMA_VERSION. |
weaveffi-abi | Stable C ABI runtime symbols: weaveffi_error, weaveffi_error_clear, weaveffi_free_string, weaveffi_free_bytes, the arena, cancel tokens, the lift_*/lower_* marshalling converters the macro calls, and the export_runtime! macro. |
weaveffi-bridge | The single Rust-to-IR extractor: maps #[weaveffi::module]-annotated source (syn AST) to an Api. Shared by the proc-macro and the CLI’s extract/generate <file.rs>. |
weaveffi-macros | The #[weaveffi::module] proc-macro family. Lowers an annotated module through weaveffi-bridge, builds the BindingModel, and emits the #[no_mangle] extern "C" thunks (marshalling via weaveffi-abi). Emission is decomposed into nine focused submodules under src/codegen/ (sync calls, async launchers, iterators, records, enums, interfaces, callbacks, marshalling, and shared helpers), each stating which clause of the weaveffi_core::plan contracts it implements. |
weaveffi | The producer facade a Rust cdylib depends on: re-exports the weaveffi-macros attributes, export_runtime!, and weaveffi-abi as weaveffi::abi. |
weaveffi-core | The Generator trait, the LanguageBackend framework + driver, the Orchestrator, the abi C-ABI lowering model, the plan marshalling-plan module (the language-neutral calling contracts; see The marshalling plan), the BindingModel, validation rules, generator config resolution, and the per-generator hash cache. |
weaveffi-gen-* | Eleven generator crates. Each implements LanguageBackend (bridged to Generator by impl_generator_via_backend!) and produces target-specific output (header, wrapper, package metadata). |
weaveffi-cli | The weaveffi binary. Parses the IDL, applies validation, instantiates every generator (via the cli_targets! registry in config.rs), and dispatches the Orchestrator. Subcommands live under commands/ (generate, validate, diff, format, package, new, watch); doctor.rs, extract.rs, and scaffold.rs sit beside main.rs; config.rs holds the target registry and config resolution; report.rs formats CLI output. |
weaveffi-fuzz | cargo-fuzz harnesses for the parsers, the validator, and parse_type_ref. Workspace-private (not published to crates.io). |
Crates that contain unsafe code opt in explicitly: weaveffi-abi,
weaveffi-fuzz, the scaffold output emitted by weaveffi generate --scaffold,
and any samples/* producer that dereferences a raw handle pointer in its own
helpers (such as kvstore) add #![allow(unsafe_code)] at the top of
their main source file. The thunks the #[weaveffi::module] macro emits
instead carry a scoped #[allow(unsafe_code)] on each generated function, so
a macro-based producer needs no crate-level opt-in. The workspace-wide
unsafe_code = deny lint forbids it everywhere else.
CLI internals
weaveffi-cli is split so that main.rs holds only argument parsing and
command dispatch; each subcommand and shared concern lives in its own
module:
| Module | Responsibility |
|---|---|
main.rs | clap definitions and top-level dispatch into commands/. |
config.rs | The cli_targets! registry macro, the generated CliConfig, and config resolution (--config TOML + inline generators:). |
report.rs | Human-readable formatting of generate/diff results and summaries. |
commands/ | One module per subcommand: generate, validate, diff, format, package, new, watch (re-exported through commands/mod.rs). |
doctor.rs | weaveffi doctor: probes host toolchains per target. |
extract.rs | weaveffi extract: a thin wrapper over weaveffi-bridge that serializes the derived IDL. |
scaffold.rs | the Rust producer stubs emitted by weaveffi generate --scaffold (for non-macro producers). |
The cli_targets! registry
The 11 language targets used to be spelled out a dozen times (config
struct fields, the --target parser, inline-generator merging, and the
Orchestrator wiring). They now live in one declarative macro,
cli_targets!, defined and invoked in config.rs:
#![allow(unused)]
fn main() {
cli_targets! {
"c" => c: CConfig via CGenerator,
"cpp" => cpp: CppConfig via CppGenerator,
"swift" => swift: SwiftConfig via SwiftGenerator, strip,
// … one line per target …
"ruby" => ruby: RubyConfig via RubyGenerator,
}
}
That single invocation expands to the CliConfig struct (one typed
field per target), build_generators, apply_inline_target, and the
strip_module_prefix/input-stamping fan-out. Adding a language is a
one-line change here; see Adding a new generator.
Format canonicalization
weaveffi format (and format --check) round-trips an IDL through the
IR and re-serializes it, so the on-disk form is canonical. For the
check to be a no-op on an already-formatted file, serialization must omit
every field that is at its default; otherwise serde would inject
null, [], and false noise that the parser then drops on the next
read, making format non-idempotent. The IR types therefore tag their
optional/defaulted fields with #[serde(skip_serializing_if = …)]
(Option::is_none, Vec::is_empty, and a local is_false for booleans
that default to false). This keeps canonical IDLs terse and makes
format idempotent; it also removes the now-meaningless default
annotations from the generated weaveffi.schema.json.
The IR
weaveffi_ir::ir defines a small algebraic type system. The shapes
that matter most:
Api { version, modules, generators }: root node.Module { name, functions, interfaces, structs, enums, callbacks, listeners, errors, modules }: modules can nest.Function { name, params, returns, doc, throws, async, cancellable, deprecated, since }. The same shape describes an interface’s constructors, methods, and statics.InterfaceDef { name, doc, constructors, methods, statics }: an opaque object type; every interface also receives an implicit destroy symbol.TypeRefenumerates every supported type reference: primitives (I32,U32,I64,F64,Bool,StringUtf8,Bytes,Handle,BorrowedStr,BorrowedBytes), user types (Named(String),Record(String),RichEnum(String),Enum(String),Interface(String),TypedHandle(String)), and the four composite shapes (Optional,List,Map,Iterator).
User-type references are two-phase. The parser cannot know whether a
bare identifier names a record, an enum, or an interface, so every
user-type reference starts life as TypeRef::Named. The resolution
pass (weaveffi_core::validate::resolve_type_refs, run by
validate_api after the rule checks pass) rewrites each occurrence
into Record (a struct), RichEnum (an algebraic enum), Enum (a
C-style enum), or Interface, and qualifies cross-module references
with the owning module’s dot-joined path. Generators run strictly
post-resolution: no Named reference remains, and a generator may
treat one as a bug (the core ABI lowering panics on it). Record and
RichEnum share the opaque-object-pointer ABI
(TypeRef::is_object_ref() returns true for both), while Enum
lowers by value as an integer.
Every IR type derives Debug, Clone, PartialEq, Serialize, and
Deserialize. Eq is derived where possible; a few types (Api,
Module, StructDef, StructField) intentionally omit Eq because
they transitively contain f64 (in default values) or
serde_yaml::Value.
TypeRef (de)serializes as a string with custom syntax (i32,
handle<T>, [T], {K:V}, T?, &str, &[u8]). The parser is
weaveffi_ir::ir::parse_type_ref; both human-written IDL and the
JSON Schema export rely on it.
Schema versioning
CURRENT_SCHEMA_VERSION (currently "0.5.0") lives in
crates/weaveffi-ir/src/ir.rs. Pre-1.0, SUPPORTED_VERSIONS
contains exactly the current version; older schema revisions are rejected
by validation with an actionable error. When you change the schema:
- Bump
CURRENT_SCHEMA_VERSION(and theweaveffi-irminor version). - Document the changes in
CHANGELOG.mdunder a “Migration” section. - Update every sample IDL, the
weaveffi newtemplate, the README quickstart, and the Getting Started doc.
The stability page is the external contract; this section is the implementation note.
Validation
weaveffi_core::validate::validate_api is the single entry point.
It returns a Vec<ValidationError> (errors that must be fixed before
generation) and a separate Vec<ValidationWarning> (advisory; the
lint subcommand surfaces these).
Errors enforced today:
- Identifier well-formedness (
is_valid_identifier). - Reserved keyword rejection (
if,else,for,while,loop,match,type,return,async,await,break,continue,fn,struct,enum,mod,use). - Uniqueness of module/function/parameter/struct/enum/field/variant names within their respective scopes, plus API-wide uniqueness of bare type names (structs, enums, interfaces, and error domains share one global namespace).
- Structs must have at least one field; enums at least one variant; interfaces at least one member.
- Enum discriminant uniqueness within an enum.
- Type references resolve by bare name across the whole API and are
qualified to their owning module during resolution, which also
rewrites each parsed
TypeRef::Namedreference into its resolved kind (see The IR and Cross-module references). - Interface members (constructors, methods, statics) share one namespace per interface; constructors declare no return and cannot be async; interface types are valid only as parameters, returns, and optionals of those. Free functions and interface members share the module’s C symbol namespace.
throws: truerequires an error domain in scope (the module or an ancestor).- Iterator return types are valid in return position only.
- Map keys must be a primitive or enum type.
event_callbackon a listener must reference a callback in the same module.- Error domain name must not collide with a function name in the same module; codes must be non-zero and unique within the domain; code names must be unique across every domain in the API.
Warnings emitted today:
LargeEnumVariantCount(>100 variants).DeepNesting(composite types nested deeper than 3 levels).EmptyModuleDoc(nodoc:on any function in the module).AsyncVoidFunction(async without a return type).MutableOnValueType(mutable: trueon a non-pointer parameter).DeprecatedFunction(informational).
Interfaces, typed error domains, async functions, cancellable functions,
listeners, callbacks, iterators (iter<T>), typed handles (handle<T>),
borrowed types (&str, &[u8]), nested modules, and cross-module type
references are all first-class. They pass validation and every
generator handles them. Do not re-add validator rejections for these
features.
Per-target capability gating still exists as a mechanism: each
generator declares a TargetCapabilities (async, callbacks, listeners,
iterators), and the orchestrator fails generation (listing the
offending IDL definitions) when a selected target cannot deliver a
used feature. Today every shipped target declares full support, so the
gate only fires if a future target (or a new gated feature) introduces
a gap; a partial target’s allow_unsupported = true config would opt
into generating the rest of the surface with explicit throwing stubs in
place of the unsupported entry points. The Wasm generator’s Emscripten
mode is the one place stubs still appear: async functions, callbacks,
and listeners become explicit throwing stubs there (and are omitted
from the TypeScript declarations) because Emscripten modules do not
portably expose the trampoline machinery. Capability failures and mode
gaps must stay loud: never skip a definition silently.
Generator configuration resolution
There is no single global config object. Each generator owns its own
typed Generator::Config (CConfig, SwiftConfig, PythonConfig, …),
so adding a knob to one target only touches that target’s crate. The CLI
gathers all of them into one CliConfig struct (generated by the
cli_targets! macro, one field per target) and resolves it from three
sources (later wins):
- Defaults baked into each
Config::default(). - The
--config <file.toml>external file passed togenerate. - The inline
generators:section of the IDL.
The IDL section is the project-local source of truth and overrides any
machine-local TOML; see the
Generator Configuration guide.
Each resolved config is hashed (via serde_json) into the per-generator
cache key, so a config-only change re-runs just that target.
Orchestrator
weaveffi_core::codegen::Orchestrator coordinates the generator stage:
- If
--forceis set, every cache entry under{out_dir}/.weaveffi-cache/{target}.hashis invalidated. - For each registered generator, the orchestrator hashes
(api, generator.name(), config)and compares against the persisted hash, so an IR or config change re-runs just the affected target. - If a
pre_generatehook is configured (OrchestratorHooks), the orchestrator shells out to it (cmd on Windows, sh elsewhere) and aborts on non-zero exit. - The pending generators run in parallel via
rayon::par_iter. Generators must therefore beSend + Sync. post_generateruns once after every generator has succeeded.- Each successful generator’s hash is persisted.
This per-generator caching is what lets weaveffi generate skip every
target whose IR has not changed since the last run; see the
Generator Configuration guide.
The Generator trait and the language-backend framework
The orchestrator consumes the object-safe Generator trait
(weaveffi_core::codegen::Generator). Each generator owns a typed,
serializable Config; the orchestrator stays config-agnostic by working
through the object-safe DynGenerator view:
pub trait Generator: Send + Sync {
/// Per-target options. Must round-trip through `serde_json` so the
/// orchestrator can fold the config into the cache key.
type Config: Serialize + Default + Clone + Send + Sync;
/// Stable short name (`"swift"`, `"c"`, …): the `--target` token and
/// the per-generator cache-file basename.
fn name(&self) -> &'static str;
/// Render the bindings under `out_dir`.
fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
/// Files `generate` would write (used by `--dry-run` and `diff`).
fn output_files(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Vec<String>;
}
To erase the associated Config, a typed generator is paired with a
concrete config value via ConfiguredGenerator::new(gen, config), which
implements the object-safe DynGenerator trait the Orchestrator
stores. The CLI builds one ConfiguredGenerator per selected target
from the resolved CliConfig.
LanguageBackend and the shared driver
Generators are not written against Generator directly. Each target
implements weaveffi_core::backend::LanguageBackend and is bridged to
Generator by the impl_generator_via_backend! macro, so the model
construction, the file I/O, and the output_files derivation live in one
place instead of being re-implemented eleven times:
pub trait LanguageBackend: Send + Sync {
type Config: Serialize + Default + Clone + Send + Sync;
fn name(&self) -> &'static str;
/// C ABI symbol prefix; the driver builds the `BindingModel` with it.
fn prefix<'a>(&self, config: &'a Self::Config) -> &'a str { "weaveffi" }
/// The single required hook: assemble every output file. Rendering is
/// pure; the driver performs the actual writes.
fn files(&self, api: &Api, model: &BindingModel,
out_dir: &Utf8Path, config: &Self::Config) -> Vec<OutputFile>;
/// Canonical per-module walk (error → enums → structs → interfaces →
/// callbacks → listeners → functions) with call-shape dispatch.
/// Single-pass backends override the `render_enum`/`render_struct`/
/// `render_function` hooks and call this; multi-pass backends build
/// their layout in `files` directly.
fn emit_members(&self, out: &mut String, module: &ModuleBinding, config: &Self::Config) { /* … */ }
// render_error / render_enum / render_struct / render_interface /
// render_callback / render_listener / render_function: all default
// to no-op.
}
The free backend::run builds the BindingModel once (with the
backend’s prefix), calls files, and writes each OutputFile
(creating parent directories). backend::output_files calls the same
files and returns the sorted path list, so generate and
output_files are derived from a single source and cannot drift.
Python is the reference single-pass backend (it overrides the per-entity
hooks and composes emit_members); Ruby, .NET, Node, and Android are
multi-pass (their FFI declarations, wrapper classes, and secondary
surfaces such as the JNI C shim are emitted in their own passes inside
files).
Generators emit code into a String; there is no template-engine layer
(an early Tera prototype intended for user template overrides was removed
in 0.4.0 because nothing read from it). Indentation and block nesting are
managed by the CodeWriter toolkit (see below) rather than by hand-rolled
\n/space bookkeeping. Shared rendering infrastructure lives in
weaveffi_core:
backend: theLanguageBackendtrait, therun/output_filesdriver, theOutputFiletype, and theimpl_generator_via_backend!bridge macro.model::BindingModel: the normalized, fully-lowered view every backend renders from (precomputed C symbol names and ABI signatures).codegen::writer::CodeWriter: the structured code-emission toolkit (see TheCodeWriteremission toolkit).codegen::common: module-tree traversal (walk_modules,walk_modules_with_path), theis_c_pointer_typeABI classifier, doc-comment emission (emit_doc), andpascal_casenaming.plan: the marshalling plan, the language-neutral calling contracts every backend renders (see The marshalling plan).
The CodeWriter emission toolkit
weaveffi_core::codegen::CodeWriter is a small, deterministic,
language-agnostic builder that owns indentation and block scoping, so a
generator describes the shape of its output instead of threading
\n and indent strings through every push_str. It is the preferred
way to render any indented, line-oriented body.
let mut w = CodeWriter::four_space(); // or two_space() / tabs()
w.line("class Greeter:");
w.scope(|w| { // one deeper indent level
w.line("def greet(self, name):");
w.scope(|w| {
w.line("return f\"Hello, {name}\"");
});
});
let src = w.finish(); // owns the assembled String
Design points that keep output stable and migrations safe:
- One indent authority.
linewritesindent + text + "\n";scope/blockpush and pop a level around a closure;indent/dedentadjust it manually. Blank lines (blank) never carry trailing whitespace, preserving the determinism contract. with_depth(n)seeds the starting indent so a writer can render a fragment that will be spliced into an already-indented context.rawappends pre-formatted text verbatim (no re-indentation), which is how existing helpers (e.g.emit_doc) and large literal blocks compose into a writer without a rewrite. This makes adoption incremental: a backend can move one function at a time ontoCodeWriterwhile the snapshot suite proves the output is unchanged byte-for-byte.
The Python backend (weaveffi-gen-python) is the reference adopter: its
return marshalling, getters, enums, callbacks, listeners, and the central
function renderer are built with CodeWriter. Remaining generators are
being migrated onto it incrementally, each move guarded by the snapshot
corpus.
The signatures above use Result<T> from anyhow and IR types from
weaveffi_ir; consult those crates for the precise import set.
Implementation notes:
- Implement
name()(the--targetflag value, e.g."swift"), the associatedConfigtype, andfiles(); overrideprefix()when the config carries a configurablec_prefix. - Return every emitted file from
files();--dry-runandweaveffi diffread the derivedoutput_files, so there is no separate list to keep in sync. - All paths are joined under
out_dir; do not write outside the passed directory or you will break the per-generator cache. - Generators run in parallel; share no mutable state across calls.
C ABI naming convention
Every emitted C symbol follows
{c_prefix}_{module}_{function} (default c_prefix = "weaveffi").
The c_prefix configuration is honored end-to-end: when set, the
generated C output uses it consistently, including references to
weaveffi-abi runtime symbols ({c_prefix}_error,
{c_prefix}_error_clear, {c_prefix}_free_string,
{c_prefix}_free_bytes).
Struct lifecycle, enum constants, and getter symbols follow the patterns in the C generator reference.
The ABI lowering model
The C ABI is the foundation every binding sits on: a flat, C-callable
surface where each IDL type lowers to a fixed sequence of C parameters.
A string becomes one const char*; bytes becomes
const uint8_t* {name}_ptr, size_t {name}_len; a map<K,V> becomes
parallel {name}_keys / {name}_values / {name}_len slots;
collection and out-of-band returns append out_* pointers; and every
fallible call ends with a trailing {prefix}_error*.
That calling convention is defined once, in
weaveffi_core::abi, rather than re-derived inside each
generator:
CType: a prefix-agnostic algebra of C types (Int32,Size,Ptr { pointee, const_pos },StructTag { module, name }, …) with a singlerender_c(prefix)method that produces canonical C spelling.element_ctype(ty, module): the C type of a single element.lower_param(name, ty, module, mutable): expands one IDL parameter into its orderedAbiParamslots.lower_return(ty, module): the returnCTypeplus any trailingout_*AbiParams.callback_result_params(ty, module): the trailing slots an async callback receives after(context, err).
The C and C++ generators render these slots straight to C
declarations, so their headers are the model by construction. The
declarative consumer generators (Python, Ruby, .NET) call the same
lower_* functions and map each CType onto their own FFI vocabulary
(ctypes.c_*, Ruby FFI symbols, P/Invoke IntPtr/UIntPtr). This is
what guarantees the producer header and every consumer agree on the
parameter arity and order of a symbol: the class of drift that
previously hid in a dozen hand-written copies of the lowering.
A few conventions are genuinely language-specific and stay local to their generator rather than leaking into the shared model:
- Iterator returns. The C ABI returns an opaque iterator handle
(
{prefix}_{module}_{Iter}*) while other backends model the same slot differently, solower_returnrefuses anIteratorand each caller lowers it explicitly. byrefout-params. ctypes (Python) and P/Invoke (.NET) express a map return’sout_keys/out_valueswith an extra pointer level or the C#outkeyword; those renderings stay in the respective generator.
Imperative generators (Go cgo, Node, Dart, Swift) build their FFI
signatures inline with marshalling code and share the single
is_c_pointer_type classifier in weaveffi_core::codegen::common. The
Android (JNI) and Wasm backends target different ABIs entirely and do
not consume the C lowering.
When you add a parameter shape or change how a type crosses the
boundary, change weaveffi_core::abi and let the consumers inherit it;
the snapshot suite will show every generator the edit touches.
The marshalling plan
The ABI lowering model answers which symbols exist and what their C
signatures are. weaveffi_core::plan is a distinct layer one level
up, sitting between that lowering and the syntax backends: a
language-neutral statement of the calling contracts every backend
renders, the questions the eleven generators used to answer
independently (and inconsistently):
- Errors.
ErrorStrategy(Throws|Trap): when a call reports throughout_err, is the non-zero code a typed domain error the caller can catch (throws: true), or a producer bug the wrapper must trap on?FnBinding::error_strategy()answers it once. See the Error Handling guide. - Ownership.
ReturnFreeandElemFree(via thereturn_freeandelem_freefunctions): after copying a returned value, or one array/map/iterator element, into a native one, exactly which runtime release call does the wrapper owe ({prefix}_free_string,{prefix}_free_bytes, a type’s_destroysymbol), if any? - Iterators.
IteratorProtocol(IteratorBinding::protocol): theiter<T>pull contract, including the requirement that wrappers stay lazy (one producernextcall per consumer step, never a hidden drain into a list), the per-element release plan, and the destroy-exactly-once handle lifecycle. - Async.
AsyncProtocol(AsyncBinding::protocol): the completion-callback contract: the callback fires exactly once from an arbitrary producer thread, borrowed result buffers are valid only for the callback’s duration, and owned-object results are adopted by the consumer.
Generators are thin syntax backends over this shared plan: a backend
that renders these plans in its own syntax cannot drift from the others
on semantics; only the spelling differs. The producer side consumes the
same contracts: each weaveffi-macros codegen submodule states which
plan clause it implements (the generated _async launchers free
borrowed result buffers after the callback returns, iterator thunks
yield one element per _next, and error dispatch follows
ErrorStrategy), so the emitted glue and every consumer wrapper agree
by construction. When a generator needs a free/destroy or throws/trap
decision, it should consume the plan rather than re-derive the fact
with a local match.
Determinism
Regenerating with the same WeaveFFI version on the same IDL produces byte-identical output.
The contract is enforced by determinism tests in the snapshot suite.
Internally, every HashMap iteration that contributes to generated
output has been replaced with BTreeMap or an explicit sort, and the
serde_json-backed cache key uses canonical ordering.
If you need to iterate a map inside a generator, use BTreeMap or
collect to a Vec and sort_by_key. Never rely on HashMap
iteration order for output; CI snapshot tests will fail
non-deterministically on different platforms or insta orderings.
Snapshot tests
crates/weaveffi-cli/tests/snapshots.rs runs every generator across a
ten-fixture corpus (tests/fixtures/01_calculator … 10_shapes:
calculator, contacts, inventory, async-demo, events, kitchen-sink,
docs-everywhere, kvstore, nested-modules, and shapes). Output is diffed via
cargo-insta. When a snapshot diff is intentional:
cargo install cargo-insta --locked
cargo test -p weaveffi-cli --test snapshots
cargo insta review
Press a to accept, r to reject, s to skip. Commit accepted
.snap files in the same commit as the code change that produced
them; never commit .snap.new. CI rejects pending snapshots.
The harness redacts the WeaveFFI version in each file’s generated-by
prelude to [VERSION] before snapshotting (and separately asserts the
real prelude is present), so a routine version bump does not invalidate
every snapshot in the corpus.
Adding a new generator
A condensed checklist (the long version lives in
CONTRIBUTING.md):
- Create
crates/weaveffi-gen-<lang>/mirroring the layout ofweaveffi-gen-c. Add it tomembersin the rootCargo.tomland depend onweaveffi-coreandweaveffi-ir. - Implement
weaveffi_core::backend::LanguageBackend: define the associatedConfigtype, thenname,prefix(if the config carries ac_prefix), andfiles(returning everyOutputFile). For a single-pass layout, override therender_enum/render_struct/render_functionhooks and composeemit_members; otherwise build the layout directly infiles. Then addweaveffi_core::impl_generator_via_backend!(<Generator>);to bridge it toGenerator(this derivesgenerateandoutput_files). ReuseBindingModelandweaveffi_core::codegen::commoninstead of re-deriving traversal or ABI classification. - Wire the generator into the
cli_targets!registry macro incrates/weaveffi-cli/src/config.rs: add one line ("<name>" => <field>: <Config> via <Generator>, plusstripif the generator honorsstrip_module_prefix). That single entry is the source of truth: it expands to theCliConfigfield, the--target <name>parser entry, inline-config merging, and theOrchestratorregistration. No other CLI edits are required. - Add snapshot fixtures in
crates/weaveffi-cli/tests/snapshots.rscovering at minimum the calculator, contacts, inventory, async-demo, and events sample IDLs. - Document the generator under
docs/src/generators/<lang>.mdand link it fromdocs/src/SUMMARY.md. - Add conformance consumers under
conformance/<lang>/and wire them intoconformance/run.sh. - Add
scripts/publish-crates.shto the dependency-ordered publish list (only when the crate is ready to be released).
Where to read next
- IDL Schema: the type system and validation rules from a user’s perspective.
- Generator Configuration: every option a consumer can set.
- Stability and Versioning: what counts as a breaking change once we hit 1.0.
- Memory Ownership: the per-target memory rules every generator must enforce.
- Async Functions: the per-target async invariants every async-capable generator implements.
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, cbindgen, diplomat, SWIG, and autocxx.
All comparisons reflect the public state of each project at the time of writing. If something here is out of date, please open a PR.
At a glance
| WeaveFFI | UniFFI | cbindgen | diplomat | SWIG | autocxx | |
|---|---|---|---|---|---|---|
| Source language | Rust / C / C++ / Zig (anything with a C ABI) | Rust | Rust | Rust | C / C++ | C++ |
| Input format | YAML / JSON / TOML IDL or annotated Rust | UDL or proc-macro on Rust | Rust source (annotated) | Rust source (annotated) | C/C++ headers + .i interface | C++ headers |
| Languages | ||||||
| C | ✓ | ✗ | ✓ | ✓ | ✓ | ✗ |
| C++ | ✓ (RAII, std::optional/vector/unordered_map) | ✗ | ✓ (header) | ✓ | ✓ | ✓ (its purpose) |
| Swift | ✓ (SwiftPM, async/await, throws) | ✓ | ✗ | ✓ | ✗ | ✗ |
| Kotlin / Android (JNI) | ✓ (Kotlin + JNI shim + Gradle) | ✓ | ✗ | ✗ | ✓ (Java via JNI) | ✗ |
| Node.js | ✓ (N-API + .d.ts) | community add-on | ✗ | ✗ | ✓ (JavaScriptCore/V8) | ✗ |
| WebAssembly | ✓ (loader + .d.ts) | ✗ | ✗ | ✓ (JS via Wasm) | ✗ | ✗ |
| Python | ✓ (ctypes + .pyi) | ✓ | ✗ | ✗ | ✓ | ✗ |
| .NET / C# | ✓ (P/Invoke + .csproj) | ✓ (community) | ✗ | ✗ | ✓ | ✗ |
| Dart / Flutter | ✓ (dart:ffi) | community | ✗ | ✓ | ✗ | ✗ |
| Go | ✓ (CGo) | community | ✗ | ✗ | ✓ | ✗ |
| Ruby | ✓ (FFI gem) | ✗ | ✗ | ✗ | ✓ | ✗ |
| Type system | ||||||
Primitives + string | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
bytes / byte slices | ✓ | ✓ | ✓ (raw) | ✓ | partial | ✓ |
| Structs | ✓ (opaque + getters) | ✓ (records & objects) | ✓ (#[repr(C)]) | ✓ (opaque) | ✓ | ✓ |
| Interfaces (objects w/ methods) | ✓ (constructors, methods, statics, implicit destroy) | ✓ (objects) | ✗ | ✓ (opaque types w/ methods) | ✓ (classes) | ✓ (C++ classes) |
| Typed error domains | ✓ (per-module codes, opt-in throws, native error types) | ✓ (error enums) | ✗ | partial (Result) | ✗ | ✗ |
| Enums w/ explicit discriminants | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Optionals | ✓ (T?) | ✓ | partial | ✓ | partial | ✓ |
| Lists | ✓ ([T]) | ✓ | partial | ✓ | ✓ | ✓ |
| Maps | ✓ ({K:V}) | ✓ | ✗ | ✓ | partial | partial |
Typed handles (handle<T>) | ✓ | ✓ (objects) | ✗ | ✓ (opaque) | partial | ✗ |
Borrowed types (&str, &[u8]) | ✓ | partial | ✓ | ✓ | ✗ | ✓ |
Iterators (iter<T>) | ✓ | ✓ (callbacks) | ✗ | partial | partial | ✗ |
| Async functions | ✓ (callback ABI + async/await/Promise/suspend/Task<T>) | ✓ | ✗ | partial | ✗ | ✗ |
| Cancellable futures | ✓ (weaveffi_cancel_token) | partial | ✗ | ✗ | ✗ | ✗ |
| Callbacks / event listeners | ✓ (module-level) | ✓ | ✗ (raw fn ptrs) | partial | partial | partial |
| Cross-module type references | ✓ | ✓ | n/a | ✓ | ✓ | ✓ |
| Nested modules | ✓ | partial | n/a | ✓ | ✓ | ✓ |
| Workflow | ||||||
| Single-binary CLI install | ✓ (cargo install weaveffi-cli) | ✓ | ✓ | ✓ | system package | ✓ |
| Standalone publishable packages | ✓ (npm, SwiftPM, pub.dev, NuGet, gem, etc.) | partial | n/a | partial | partial | n/a |
| JSON Schema for IDL editor support | ✓ | ✗ | n/a | n/a | ✗ | n/a |
extract from annotated source | ✓ (Rust) | ✓ (proc-macro) | ✓ (Rust) | ✓ (Rust) | n/a | ✓ (C++) |
watch mode | ✓ | ✗ | ✓ (--watch) | ✗ | ✗ | partial |
format IDL canonicalizer | ✓ | ✗ | n/a | n/a | ✗ | n/a |
| Custom template overrides | ✗ | partial (Mako) | ✗ | partial | ✓ (%typemap) | partial |
| Snapshot-tested generator output | ✓ | ✓ | ✓ | ✓ | partial | ✓ |
| Maturity | pre-1.0 | 1.0+ in Mozilla shipping products | 1.0+ widely deployed | pre-1.0 | 30+ years, ubiquitous | pre-1.0 |
| License | MIT OR Apache-2.0 | MPL-2.0 | MPL-2.0 | BSD-3-Clause | GPL with FOSS exception | MIT OR Apache-2.0 |
Legend: ✓ = first-class support; partial = supported with caveats or via extensions; ✗ = not supported; n/a = not applicable to that tool’s scope.
Where competitors are stronger
We try hard to be honest about the trade-offs. Pick the right tool for the job:
- UniFFI is more mature. It ships in production at Mozilla (Firefox Sync, Glean, Nimbus) and has years of battle-testing across iOS, Android, and desktop. If you only need Swift, Kotlin, and Python today and you are comfortable with a UDL-or-proc-macro workflow, UniFFI is the safer choice.
- 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/C++ code, cbindgen has less ceremony, no IDL file, and a smaller dependency footprint.
- diplomat has a more polished C++ story. Its C++ output uses richer
templates and integrates more cleanly with existing C++ codebases. WeaveFFI’s
C++ output is RAII-based and includes a
CMakeLists.txt, but it’s optimized for greenfield projects, not for slotting into a 20-year-old C++ build system. - SWIG covers languages WeaveFFI doesn’t. Lua, Tcl, R, Octave, Perl, PHP: if your target is exotic, SWIG probably has a generator. SWIG also natively understands C and C++ headers, so you don’t need to author an IDL at all.
- autocxx is unmatched for “wrap an existing C++ library.” It reads your C++ headers directly and uses bindgen + cxx under the hood. WeaveFFI does not parse C++; you describe the surface area you want to expose, and WeaveFFI generates the contract.
- No IDE plugin yet. The other tools listed have community VSCode/JetBrains
extensions of varying quality. WeaveFFI ships a JSON Schema for editor
autocompletion and a
formatcommand, but no first-party IDE plugin. - No formal stability guarantee yet. WeaveFFI is pre-1.0; the IDL, generated output, and runtime symbol names can shift in minor releases. UniFFI, cbindgen, 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 covers a smaller subset out of the box; cbindgen and autocxx don’t try.
- Standalone, publishable consumer packages. Generated packages are
self-contained: a Swift consumer adds your
.xcframework+ a SwiftPM manifest and is done. No “install WeaveFFI” step on the consumer side. - A native library that isn’t (only) Rust. WeaveFFI works against
anything that exposes a stable C ABI: Rust (with
--scaffoldconvenience), C, C++, Zig, etc. UniFFI and diplomat assume Rust; autocxx assumes C++. - Idiomatic per-target output, not a lowest-common-denominator API.
Async functions become
async/awaitin Swift,Promises in Node,suspend funin Kotlin,async defin Python, andTask<T>in C#, all from the sameasync: trueflag in the IDL. - A CLI workflow with
validate,lint,diff,watch, andformat. WeaveFFI is built for monorepos and CI: every sub-command has a--format jsonoutput mode, anddiff --checkandformat --checkare designed to drop into pre-commit and CI gates. - Honest pre-1.0 churn, documented every release. Every breaking IDL
change is called out in
CHANGELOG.mdwith a migration note, andweaveffi validaterejects out-of-date schema versions with an actionable error instead of silently misreading them.
When to choose something else
- You only need Swift + Kotlin + Python and want maximum stability: use UniFFI.
- You only need a C header for a Rust crate: use cbindgen.
- You’re wrapping a large existing C++ codebase: use autocxx (or cxx + bindgen directly).
- Your target language is Lua, Tcl, R, Octave, Perl, or PHP: use SWIG.
- You need a battle-tested C++ binding generator with rich template support: use diplomat or SWIG.
Migrating to / from WeaveFFI
WeaveFFI’s IDL is intentionally close to UniFFI’s UDL surface area, which
makes hand-porting straightforward in either direction. There is no
automatic UDL → WeaveFFI converter today, but weaveffi extract can read
annotated Rust source and produce a starting IDL, which is often the
fastest path off any Rust-only generator. See the
extract guide for details.
FAQ
The top ten questions we hear about WeaveFFI. For broader context see the introduction, the comparison page, and the per-target generator docs.
1. Why not UniFFI?
UniFFI is excellent, ships in production at Mozilla, and is the right choice if you only need Swift, Kotlin, and Python. We built WeaveFFI because we needed:
- More targets out of the box. WeaveFFI ships first-class generators for C, C++, Swift, Kotlin/Android, Node.js, Wasm, Python, .NET, Dart, Go, and Ruby, eleven in total. UniFFI’s first-party language list is shorter and the rest live as community extensions of varying maturity.
- A standalone CLI workflow. WeaveFFI is a single binary
(
cargo install weaveffi-cli) withvalidate,lint,diff,watch,format, andextractsubcommands designed to drop into CI. UniFFI is a build-script integration first. - A non-Rust-only story. WeaveFFI’s IR is language-agnostic: any backend that can expose a stable C ABI (Rust, C, C++, Zig, …) can be driven from the same IDL. UniFFI is Rust-first.
- A YAML/JSON/TOML IDL with a JSON Schema. WeaveFFI ships
weaveffi.schema.jsonfor editor autocompletion. UniFFI’s UDL is custom-syntax and proc-macro is Rust-only.
If your matrix is only Swift+Kotlin+Python and you want maximum maturity today, UniFFI is the safer pick. See the comparison page for the full table.
2. Can I use it with C++ codebases?
Two distinct cases:
- Generating C++ bindings for consumers. Yes,
--target cppemits a header-only RAII C++ API (weaveffi.hpp) withstd::optional,std::vector,std::unordered_map, exception-based errors, move semantics, and aCMakeLists.txt. See the C++ generator docs. - Wrapping an existing C++ library. WeaveFFI does not parse C++ headers; you describe the surface area you want to expose in the IDL and the C++ implementation provides the stable C ABI symbols. If you want to start from C++ headers and auto-generate, look at autocxx or SWIG.
3. Does it support generics?
Yes, with a curated set of built-in generic shapes rather than open user-defined generics:
handle<T>: typed opaque pointers (compile-time-checked handle types per resource).iter<T>: lazy streaming sequences with_next/_destroyABI.[T]: homogeneous lists.{K:V}: homogeneous maps (passed as parallel key/value arrays at the C ABI).T?: optionals.&str,&[u8]: borrowed views (no copy at the boundary).
We deliberately do not support arbitrary user-defined generics
(e.g. Result<MyType, MyError> parameterized at the IDL level).
Cross-language generic monomorphization is a rabbit hole; the
built-in shapes cover ~95% of real-world FFI surface area without
requiring every target generator to implement type-erasure logic.
4. What’s the runtime overhead?
WeaveFFI itself adds no runtime beyond the small weaveffi-abi
crate (a few hundred lines: error helpers, string/byte-slice
allocators, cancel tokens). Per-call overhead is the cost of:
- Marshalling arguments across the C ABI (string→
const char*, list→*ptr + len, etc.). Borrowed types (&str,&[u8]) avoid copies. - The single
extern "C"function call. - Marshalling the return value back.
For primitive arguments and return types, this is roughly the cost of a normal function call plus an out-pointer write for the error. For larger structs, lists, and maps, it’s dominated by the underlying allocation/copy cost, not by anything WeaveFFI inserts.
Async functions add a callback indirection (the C ABI is callback-based) plus whatever runtime your backend uses. There is no scheduler imposed by WeaveFFI; the implementation chooses how to spawn work.
5. How are errors propagated?
At the C ABI, generated functions take a trailing weaveffi_error* out_err parameter. On success the runtime sets code = 0 and
message = NULL. On failure it sets a non-zero code and a
heap-allocated UTF-8 message that the caller frees via
weaveffi_error_clear.
Above the ABI, error surfacing is opt-in per function: a module
declares an error domain (errors: in the IDL) with named, stable
codes, and functions marked throws: true surface those codes as the
domain’s typed error in each language. Taking a KvError domain as
the example:
- C: direct
weaveffi_errorstruct, plus an enum constant per code (weaveffi_kv_KvError_KeyNotFound). - C++: per-domain exception types (
KvError+ per-code subclasses). - Swift:
throwswith a domain enum conforming toError(catch KvError.keyNotFound). - Kotlin: domain exceptions (
KvExceptionextendsWeaveFFIException, one nested class per code). - Node.js / TypeScript: domain error classes (
KvErrorextendsWeaveFFIError); async functions reject the promise with them. - Wasm/JS: the same domain error classes.
- Python: a domain exception hierarchy (
KvErrorextendsWeaveFFIError,KeyNotFoundextendsKvError). - .NET: thrown
KvException(extendsWeaveFFIException). - Dart: thrown
KvException(extendsWeaveFFIException). - Go: a second
errorreturn carrying a typed error struct matched viaerrors.Asand code constants (KvErrorKeyNotFound). - Ruby: domain exception classes (
KvErrorwith nested per-code classes).
Non-throwing functions (the default) return plain values; a non-zero code on one of them only ever reports a producer bug, so the wrapper panics or traps instead of surfacing an error type. See the Error Handling guide.
6. Can I customize the generated code?
Yes, via two escape hatches in increasing order of power:
- Generator config (
--config cfg.tomlor inlinegenerators:table in the IDL). Controls Swift module names, Android package, C prefix, C++ namespace, Dart/Go/Ruby package names, module-prefix stripping (strip_module_prefix), and other per-target knobs. See the Generator Configuration guide. - Hook commands (
pre_generate/post_generatein the config). Run arbitrary shell commands before and after generation, useful forprettier,swiftformat,gofmt, etc.
If you need to change the C ABI shape itself, that’s a generator
contribution. See CONTRIBUTING.md.
7. Does it work with Flutter?
Yes, --target dart emits dart:ffi bindings plus a pubspec.yaml
that’s drop-in compatible with both Flutter and pure Dart projects.
You ship the generated package alongside the cdylib for each
platform Flutter targets (iOS framework, Android .so per ABI, macOS
.dylib, Linux .so, Windows .dll).
The generated Dart code uses the standard package:ffi helpers, so
it works on every Flutter platform that supports dart:ffi (i.e.
everything except Web today; for the browser, use --target wasm
and load the bindings via JS interop). See the
Dart generator docs.
8. Is it Windows-friendly?
Yes, WeaveFFI itself builds and runs on Windows (the CLI is plain Rust, no platform-specific dependencies). Generated outputs target Windows correctly:
- C / C++: emitted headers are compiler-agnostic (MSVC, clang,
gcc), and every prototype carries a portable
WEAVEFFI_APIvisibility macro. Consumers resolve it to__declspec(dllimport); a C/C++/Zig backend that implements the header builds its library withWEAVEFFI_BUILDdefined to export the symbols via__declspec(dllexport)(see the C generator docs). - .NET: P/Invoke uses
DllImportwith the right calling conventions and looks upweaveffi.dll. - Node.js: the N-API addon builds with
node-gypon Windows. - Python:
ctypesloadsweaveffi.dll. - Dart: looks up
weaveffi.dllviaPlatform.isWindows. - Go / Ruby: load the appropriate Windows shared library.
CI builds and tests the workspace on Windows on every PR, and a dedicated Windows job generates every target’s bindings and verifies the output. The full conformance harness (running the generated bindings in all eleven languages) is exercised on Linux. If you hit a Windows-specific issue, please open an issue.
9. How do I distribute the cdylib?
You build a platform-specific shared library per target triple and ship it alongside the generated package. Three common patterns:
- Per-platform npm/PyPI/gem packages. Publish one package per
(os, arch)and use a small loader in the consumer that picks the right binary at install or runtime. WeaveFFI generates the TypeScript/Python/Ruby loader, you supply the binaries. xcframeworkfor Swift. Bundle iOS device, iOS simulator, and macOS slices into a single.xcframeworkthat SwiftPM can consume. The generatedPackage.swiftreferences it as a.binaryTarget..aarfor Android. Package the JNI shim + per-ABI.sofiles into an Android Archive that Gradle resolves like any other dependency. The generatedbuild.gradleskeleton is compatible with this layout.
The name, version, and metadata stamped into every generated manifest
(package.json, pyproject.toml, *.gemspec, *.csproj, pubspec.yaml,
Package.swift, go.mod, …) come from a single
package: block in your IDL, so you set
your identity once and every ecosystem stays in sync.
There is no opinionated “weaveffi publish” command today; you use each ecosystem’s normal publish flow. The generator-specific docs cover the recommended build matrix per language.
10. What’s the licensing?
WeaveFFI is dual-licensed under MIT OR Apache-2.0 at your option, the same dual-license used by the Rust project itself.
You can use WeaveFFI in commercial, closed-source, or open-source
projects without restriction. Generated code carries no license header
of its own; it’s yours to license however you like. Contributions
to the WeaveFFI repo are accepted under the same MIT-or-Apache-2.0
dual license; see CONTRIBUTING.md.
Stability and Versioning
WeaveFFI follows Semantic Versioning once it reaches 1.0.0. Until then it is in active pre-1.0 development and any surface area may change between minor versions. This page documents exactly what is and isn’t covered, what the deprecation policy will look like post-1.0, and how to bind your CI to a stable WeaveFFI workflow today.
What semver covers (post-1.0)
After the 1.0.0 release, the following surfaces will be governed by SemVer:
- CLI flags and subcommands. Every documented
weaveffi <subcommand>, every flag, every exit code, and every documented stdout/stderr format (--format jsonpayloads in particular). Adding a new optional flag is a minor bump; removing or renaming one is a breaking change. - IDL schema. The set of accepted top-level keys, type-reference syntax
(
handle<T>,iter<T>,[T],{K:V},T?,&str,&[u8], primitives, user-defined struct/enum/interface names),versionsemantics, and the JSON Schema exported byweaveffi schema --format json-schema. - Generated code shape. The exported symbol names, function signatures, type names, package layouts, and ABI conventions of every generator’s output. A patch release will not change the bytes of the generated output; a minor release may add new symbols but will not remove or rename existing ones; a major release may break.
- Public Rust API of every published crate. That is
weaveffi-ir,weaveffi-abi,weaveffi-core,weaveffi-gen-c,weaveffi-gen-cpp,weaveffi-gen-swift,weaveffi-gen-android,weaveffi-gen-node,weaveffi-gen-wasm,weaveffi-gen-python,weaveffi-gen-dotnet,weaveffi-gen-dart,weaveffi-gen-go,weaveffi-gen-ruby, andweaveffi-cli. TheGeneratortrait, theOrchestrator, the IR types, and the C ABI runtime symbols exported fromweaveffi-abiare all public contracts.
What is NOT covered pre-1.0
While the workspace is at 0.x, everything above may change without
warning. In practice we try to keep breaking changes batched (one batch per
minor release, with a schema-version bump), but the contract is “no
contract.” Things that have already changed during 0.x:
- IR type-reference syntax (
callbackwas removed in0.3.0). - The IR
TypeRef::Structvariant was split intoNamed(the unresolved parsed form),Record, andRichEnum, and a shared marshalling-plan module (weaveffi_core::plan) now states the calling contracts every generator renders. Generated wrapper surfaces changed with it:iter<T>returns became each target’s native lazy iteration idiom (Go[]Tbecame aniter.Seq, C++std::vector<T>became a range type), and async result buffers became producer-freed after the completion callback returns. - Schema
0.5.0introduced first-class interfaces (interfaces:, objects with constructors, methods, and statics) and per-function typed errors (throws:), and made bare type names unique across the whole API. The samples’ handle-based resource surfaces were rewritten as interfaces. - The
Generatortrait gainedgenerate_with_configin0.3.0, then was reworked in0.5.0into an associatedConfigtype (with an object-safeDynGeneratorview) that replaced the*_with_configmethod pair. A prototype Tera template hook (generate_with_templates,--templates,template_dir) was added and then removed in0.4.0because no generator ever consumed it. - The C ABI runtime added
weaveffi_arena_*andweaveffi_cancel_token_*families. weaveffi doctorgained--targetand--format json.
Pin the WeaveFFI version in CI (cargo install weaveffi-cli --version =0.3.0) and vendor the generated output in your repository so that
upgrades are an explicit, reviewable event.
Post-1.0 deprecation policy
Once we reach 1.0.0, breaking changes will follow this path:
- The feature is marked deprecated in a minor release. The CLI prints a
--warn-style diagnostic (weaveffi: warning: <name> is deprecated; <suggested replacement>) on every invocation that touches it. The generators emit a native deprecation marker where the target language supports one (#[deprecated]in Rust,@Deprecatedin Kotlin/Java,@available(*, deprecated:)in Swift,[Obsolete]in .NET, JSDoc@deprecatedin TypeScript, and so on, driven by the existing IDLdeprecated:field). - The deprecated feature continues to work for at least one full minor version.
- Removal lands in the next major release with a migration note in
CHANGELOG.md.
In short: nothing disappears in a patch release, nothing disappears without at least one minor release of warnings, and every removal ships with a documented replacement.
IR schema version policy
The IR schema version is independent of the workspace version, but it is
tied to weaveffi-ir’s minor version: each weaveffi-ir minor bump
corresponds to at most one schema version bump.
CURRENT_SCHEMA_VERSION
in crates/weaveffi-ir/src/ir.rs is the source of truth; the current
schema version is 0.5.0.
Pre-1.0, only the current schema version is accepted
(SUPPORTED_VERSIONS contains exactly CURRENT_SCHEMA_VERSION), so a
document declaring any earlier revision is rejected with an actionable
error. When a schema bump lands, update the version field in your IDL and
adjust the document to the new schema by hand; the changes are documented
in CHANGELOG.md with a “Migration” section. Post-1.0, schema bumps will
ship with an automated migration tool and a widened SUPPORTED_VERSIONS
window.
Generated-code stability (determinism)
Regenerating with the same WeaveFFI version on the same IDL produces byte-identical output.
This is enforced by the determinism tests: every generator’s output is
hashed and re-hashed on the kitchen-sink fixture, and any deviation fails
CI. Internally, every
HashMap iteration that contributes to generated output has been replaced
by BTreeMap or an explicit sort. The serde_json-backed cache key uses a
canonical key ordering.
Practical consequences:
- Vendoring the generated
bindings/directory in your repository is safe. A reviewer will only see a diff when the IDL or the generator itself changes. weaveffi diff --check(see below) is a reliable CI gate.- Cross-platform regeneration (Linux vs macOS vs Windows) produces the same bytes for the same WeaveFFI version.
If you ever observe non-determinism, please file an issue with the IDL that triggers it. It’s a bug, not a quirk.
The weaveffi diff --check workflow for downstream CI
The single recommended way to guard a downstream repository against
“forgot to regenerate” mistakes is weaveffi diff --check:
weaveffi diff path/to/api.yml --out generated/ --check
diff --check regenerates into a temporary directory, compares against
--out, and exits:
- 0 when the on-disk output matches what regeneration would produce,
- 2 when at least one file differs (modified content),
- 3 when files are missing or extra (a target was added/removed).
It prints only the summary + N added, - M removed, ~ K modified,
suitable for CI logs without flooding the output.
A typical GitHub Actions step:
- name: Verify generated bindings are up to date
run: |
cargo install weaveffi-cli --locked --version =0.3.0
weaveffi diff idl/api.yml --out generated/ --check
Combine it with weaveffi format --check idl/api.yml (canonical IDL) and
weaveffi validate idl/api.yml (schema correctness) for a complete CI
guard.
See also
- IDL Schema: the type system the schema version governs.
- Getting Started: installation and the basic
workflow
diff --checkplugs into.
Performance
WeaveFFI is designed to disappear in the build. Code generation should finish in under a second on every project from the calculator sample to a fully featured kitchen-sink API, leaving a budget for the surrounding build steps.
This page lists the explicit performance targets the project commits to,
the methodology used to measure them, the latest measurements taken on
commodity hardware, and the locations of the workflow artifacts that the
CI system uploads on every push to main.
Targets
The values below are hard targets enforced via the criterion benchmarks
in crates/weaveffi-core/benches/codegen_bench.rs and
crates/weaveffi-cli/benches/generate_bench.rs. The first
two benchmarks measure single-purpose pipeline stages; the latter two
measure the full code-generation surface (all 11 generators) end-to-end.
| Benchmark | Target | Inputs |
|---|---|---|
validate_kitchen_sink | < 5 ms | crates/weaveffi-cli/tests/fixtures/06_kitchen_sink.yml |
hash_kitchen_sink | < 1 ms | Same fixture, post-validation |
full_codegen_calculator | < 500 ms | samples/calculator/calculator.yml, all 11 generators |
full_codegen_kitchen_sink | < 2000 ms | Kitchen-sink fixture, all 11 generators |
A regression that pushes any of these benchmarks past its target is a
release blocker; the CI workflow uploads benchmark output as an artifact
on every push to main so reviewers can spot drift before it ships.
Methodology
The benchmarks use criterion.rs in its default sampling mode (100 samples, ~3 s measurement, statistical analysis). Each benchmark builds a fresh temporary directory per iteration so I/O is included in the measurement; this matches what users observe at the command line.
cargo bench --workspace -- --noplot
Profile a generator end-to-end with a flame graph:
cargo flamegraph -p weaveffi-cli --bench generate_bench
On macOS, the equivalent invocation uses cargo-instruments:
cargo instruments -t Time -p weaveffi-cli --bench generate_bench
Reference hardware for the numbers below: Apple M-series laptop, release
build (--release, lto = false), no other heavy processes running.
Latest measurements
These numbers were captured on the most recent baseline run after the hot-path optimizations described below. Each row is the criterion median; the parentheses show the headroom relative to the documented target.
| Benchmark | Median | Headroom vs target |
|---|---|---|
validate_kitchen_sink | 7.45 µs | ~670× under |
hash_kitchen_sink | 37.5 µs | ~27× under |
full_codegen_calculator | 6.92 ms | ~72× under |
full_codegen_kitchen_sink | 7.27 ms | ~275× under |
generate_c_large_api | 904 µs | n/a |
generate_swift_large_api | 1.93 ms | n/a |
generate_all_large_api | 24.1 ms | n/a |
generate_all_kitchen_sink | 7.27 ms | n/a |
The *_large_api benchmarks operate on a synthetic 10-module × 50-function
API (500 functions total) that does not have a documented ceiling; they
exist as a regression signal for the per-function cost of each generator.
Optimized hot paths
Profiling revealed three meaningful hot paths in the code-generation pipeline. Each one was tightened in this iteration; the optimizations delivered the cumulative ~7-10 % wall-clock improvement visible in the table above.
-
Pre-allocate output buffers. Both
render_c_headerandrender_swift_wrapperstarted fromString::new()and let the buffer grow by doubling, copying the entire string on each re-allocation. They now estimate the final output size from the number of modules, functions, structs, and callbacks in the API and pre-allocate accordingly viaString::with_capacity. -
write!instead ofpush_str(&format!(...))in the per-function hot loop ofrender_module_header(C generator) and the function wrappers in the Swift generator. Each replacement eliminates the intermediateStringthatformat!allocates before the result is appended to the output buffer. -
Drop the
Vec<String>+join(", ")pattern when emitting parameter signatures. The Swift generator now writes the comma-separated parameter list directly into the output buffer via thewrite_swift_params_sighelper; the C generator routes through awrite_params_intohelper that takes string slices, eliminating the per-parameter allocation loop and the joined intermediate.
These three categories are the ones explicitly called out as candidates in the original performance plan, in order of impact.
Things explicitly not optimized
serde_yamlparsing is the dominant cost of theweaveffi generatehappy path on disk because parsing happens before the benchmarks above run. The kitchen-sink fixture takes ~50 µs to parse on reference hardware, well below the validate/hash targets, andserde_yamldoes not expose a streaming API that is materially faster for our schemas. We accept it as the dominant CLI startup cost and document it here.
CI artifacts
The bench.yml workflow runs cargo bench on every
push to main and uploads the captured criterion output as a
bench-results artifact (retained for 90 days). To inspect the most
recent run:
- Open the bench workflow runs on GitHub.
- Pick the latest run that succeeded.
- Download the
bench-resultsartifact and extractbench.txt; it contains the full criterion output (medians, ranges, outlier counts) for the entire workspace.
The workflow does not gate merges on absolute thresholds today; instead it serves as the authoritative trail when a PR claims to improve or preserve benchmark numbers.
Roadmap
This page is a placeholder. WeaveFFI is in active 0.x development, and we’ll
use this page to share a public roadmap once there’s a concrete plan worth
publishing.
For now, the CHANGELOG is the source of truth for what has shipped, and Stability and Versioning explains how releases and schema versioning work.
Samples
This repo includes sample projects under samples/ that showcase end-to-end
usage of WeaveFFI. Every producer is written as safe Rust and annotated with the
#[weaveffi::module] family of attributes, so the macro generates its C ABI
(see The Rust Producer Macro). The simpler producers
(calculator, contacts, and inventory) generate bindings straight from
their annotated source. The advanced samples (async-demo, events, kvstore,
shapes) are macro-annotated too, and they keep a committed YAML IDL as the
generation source of truth because their surfaces carry metadata the extractor
does not yet recover from source, such as package and per-generator
configuration and standalone since tags.
Kvstore (kitchen-sink reference)
Path: samples/kvstore
A production-quality, in-memory key/value store that exercises every IDL feature WeaveFFI supports in a single sample. Use this as the canonical reference when learning the IDL surface or when you need to copy/paste a real-world pattern for a new generator.
What it demonstrates:
- A first-class interface (
Store) with a throwing constructor (open), instance methods, a static (default_capacity), and implicit destroy - A struct (
Entry) with every primitive:i64,string,bytes, optional field (expires_at: i64?), list field (tags: [string]), and map field (metadata: {string:string}), plus per-field doc strings andbuilder: true - A documented enum (
EntryKindwithVolatile,Persistent,Encrypted) - A documented error domain (
KvErrorwithKeyNotFound,Expired,StoreFull,IoError) and opt-inthrows: trueon the fallible methods - A module-level callback (
OnEvict) and listener (eviction_listener) - A streaming iterator return (
list_keys -> iter<string>) with prefix filter - A cancellable async method (
compact,async: true, cancellable: true) that respects aweaveffi_cancel_tokenwhile reclaiming bytes on a worker thread - A deprecated method (
legacy_put) - A nested sub-module (
kv.stats) with its own struct (Stats) and a function that takes a cross-moduleStoreparameter - Inline
generators:overrides forswift.module_name,cpp.namespace,dotnet.namespace,dart.package_name,go.module_path, andruby.module_name
Build, generate bindings, and run the C ABI tests:
cargo build -p kvstore
cargo test -p kvstore
weaveffi generate samples/kvstore/kvstore.yml -o generated
The conformance/ harness ships a kvstore consumer for every language that
opens a Store, round-trips entries, drives the async compact, and asserts
the typed KvError surface; see conformance/run.sh.
Shapes (rich enums + numerics)
Path: samples/shapes
The reference sample for rich (algebraic) enums (sum types whose variants carry associated data) and the expanded numeric primitives. Use it when learning how a tagged union crosses the C ABI as an opaque object and how each backend wraps it.
What it demonstrates:
- A rich enum (
Shape) with a data-less variant (Empty) and three payload variants (Circle { radius: f64 },Rectangle { width: f32, height: f32 }, andLabeled { label: string, count: u8 }) lowered to an opaque object with per-variant constructors, atagreader, per-variant field getters, and a destructor - A plain C-style enum (
Channel) alongside the rich enum, showing both enum flavors in one module - The new numeric primitives (
f32,u8,u64) as variant fields, parameters, and return types - Functions that take and return a rich enum (
describe,scale) and a list-of-bytes reduction (sum_bytes(values: [u8]) -> u64)
Build, generate bindings, and run the C ABI tests:
cargo build -p shapes
cargo test -p shapes
weaveffi generate samples/shapes/shapes.yml -o generated
The conformance/ harness ships a shapes consumer for every language that
constructs each variant, reads the tag and fields back, and round-trips through
describe/scale; see conformance/run.sh.
Calculator
Path: samples/calculator
The simplest sample: a single #[weaveffi::module] with four functions that
exercise primitive types (i32) and string passing. Good starting point for
understanding the basic C ABI contract and the macro workflow.
What it demonstrates:
- Scalar parameters and return values (
i32) - String parameters and return values (C string ownership)
- The smallest possible typed error surface: a
#[weaveffi::error]enum (CalcError) and one throwing function (divreturnsResult<i32, CalcError>) - A producer written entirely as safe Rust (no hand-written FFI glue)
Build and generate bindings (from the annotated source):
cargo build -p calculator
weaveffi generate samples/calculator/src/lib.rs -o generated
This produces target-specific output under generated/ (C headers, Swift
wrapper, Android skeleton, Node addon sources, Wasm loader). The
Calculator tutorial walks through running C,
Node, and Swift consumers against it.
Contacts
Path: samples/contacts
A CRUD-style sample with a single module, written as safe Rust and annotated
with #[weaveffi::module]. It exercises richer type-system features than the
calculator while writing no unsafe glue.
What it demonstrates:
- A
#[weaveffi::enumeration](ContactTypewithPersonal,Work,Other) - A
#[weaveffi::record](Contact) with generated create/destroy/getters - Optional fields (
Option<String>for the email) - A
#[weaveffi::interface](ContactBook) with anewconstructor, instance methods, and implicit destroy - List return types (
Vec<Contact>fromContactBook::list) - A
#[weaveffi::error]domain (ContactsError) surfaced by the throwing methods viaResult<Contact, ContactsError>
Build and generate bindings (from the annotated source):
cargo build -p contacts
weaveffi generate samples/contacts/src/lib.rs -o generated
Inventory
Path: samples/inventory
A richer, multi-module sample with products and orders modules, written as
safe Rust with two #[weaveffi::module] blocks. It exercises cross-module
references and record lists across the macro.
What it demonstrates:
- Two annotated modules in one crate, each with its own error domain
(
ProductsError,OrdersError) - A
#[weaveffi::interface](Catalog) owning its product list, alongside free functions in theordersmodule - A
#[weaveffi::enumeration](Category) and#[weaveffi::record]s (Product,Order,OrderItem) - Optional and list fields (
Option<String>,Vec<String>tags) - A record-list return (
Catalog::search -> Vec<Product>) and a record-list parameter (create_order(items: Vec<OrderItem>)) - A cross-module record parameter (
orders::add_product_to_ordertakes aproducts::Product)
Build and generate bindings (from the annotated source):
cargo build -p inventory
weaveffi generate samples/inventory/src/lib.rs -o generated
Async Demo
Path: samples/async-demo
Demonstrates the async function pattern using callback-based invocation. Async
functions in the YAML definition get an _async suffix at the C ABI layer and
accept a callback + context pointer instead of returning directly.
What it demonstrates:
- Async function declarations (
async: truein the YAML) - Callback-based C ABI pattern (
weaveffi_tasks_run_task_async) - Callback type definitions (
weaveffi_tasks_run_task_callback) - Batch async operations (
run_batchprocesses a list of names sequentially) - Synchronous fallback functions (
cancel_taskis non-async in the same module) - Struct return types through callbacks (
TaskResultdelivered via callback)
Build and run tests:
cargo build -p async-demo
cargo test -p async-demo
Note: Async functions are fully supported by the validator. This sample focuses on the C ABI callback pattern; see the Async Functions guide for the per-target async/await story.
Events
Path: samples/events
Demonstrates callbacks, event listeners, and iterator-based return types.
What it demonstrates:
- Callback type definitions (
OnMessagecallback) - Listener registration and unregistration (
message_listener) - Event-driven patterns (sending a message triggers the registered callback)
- Iterator return types (
iter<string>in the YAML) - Iterator lifecycle (
get_messagesreturns aGetMessagesIterator, advanced with_next, freed with_destroy)
Build and run tests:
cargo build -p events
cargo test -p events
Node Addon
Path: samples/node-addon
An N-API addon crate that loads the calculator’s C ABI shared library at runtime
via libloading and exposes the functions as JavaScript-friendly #[napi]
exports. It shows the hand-rolled alternative to the generated
weaveffi_addon.c, which the Node generator now emits for you.
What it demonstrates:
- Dynamic loading of a
weaveffi_*shared library from JavaScript - Mapping C ABI error structs to N-API errors
- String ownership across the FFI boundary (CString in, CStr out, free)
Build (requires the calculator library first):
cargo build -p calculator
cargo build -p weaveffi-node-addon
End-to-end testing
The conformance/ directory is the end-to-end regression oracle for
the code generators. Every consumer under conformance/<language>/
binds through the generated wrappers (not the raw C ABI) and asserts
concrete results against the contacts, events, kvstore, and shapes
samples. The conformance/run.sh harness builds each producer cdylib,
runs weaveffi generate for it, then compiles and runs every
per-(language, sample) consumer:
bash conformance/run.sh
It prints [OK] {target} for each consumer that succeeds and reports
a pass/fail summary at the end. Use ONLY=c-contacts,cpp-contacts to
run a subset, or SKIP=go-contacts to omit individual targets.
Missing toolchains cause the affected target to fail; skip those
explicitly. See the comment block at the top of conformance/run.sh
for the per-target prerequisites.
Reference
IDL Type Reference
WeaveFFI consumes a declarative IDL (Interface Definition Language) that describes modules, types, and functions. YAML, JSON, and TOML are all supported; this reference uses YAML throughout.
Editor autocomplete (JSON Schema)
WeaveFFI ships a JSON Schema for the IDL. To get autocomplete and validation in editors that support the YAML Language Server (VS Code, Neovim, Helix, …), add the following header comment to the top of your YAML file:
# yaml-language-server: $schema=./weaveffi.schema.json
The schema is generated by weaveffi schema --format json-schema and a copy
is checked in at weaveffi.schema.json
in the repository root.
Top-level structure
The shape of an IDL document, with placeholder ellipses for nested arrays and objects:
# yaml-language-server: $schema=./weaveffi.schema.json
version: "0.5.0"
package:
name: my_app
version: "1.0.0"
modules:
- name: my_module
structs: [...]
enums: [...]
interfaces: [...]
functions: [...]
callbacks: [...]
listeners: [...]
errors: { ... }
modules: [...]
generators:
swift:
module_name: MyApp
A complete, validating example lives at the bottom of this page in the Complete example section.
| Field | Type | Required | Description |
|---|---|---|---|
version | string | yes | Schema version; only the current version ("0.5.0") is accepted |
package | Package | no | Publishable identity stamped into every generated manifest (see Package metadata) |
modules | array of Module | yes | One or more modules |
generators | map of string to object | no | Per-generator configuration (see generators section) |
Module
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Lowercase identifier (e.g. calculator) |
functions | array of Function | no | Free functions exported by this module |
interfaces | array of Interface | no | Interface (object) type definitions (see Interfaces) |
structs | array of Struct | no | Struct type definitions |
enums | array of Enum | no | Enum type definitions |
callbacks | array of Callback | no | Callback type definitions |
listeners | array of Listener | no | Listener (event subscription) definitions |
errors | ErrorDomain | no | Optional error domain (see Error domain) |
modules | array of Module | no | Nested sub-modules (see nested modules) |
Function
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Function identifier |
params | array of Param | yes | Input parameters (may be empty []) |
return | TypeRef | no | Return type (omit for void functions) |
doc | string | no | Documentation string |
throws | bool | no | Mark as fallible with a typed domain error (default false); requires an error domain in scope, on the same module or an ancestor (see Error domain) |
async | bool | no | Mark as asynchronous (default false) |
cancellable | bool | no | Allow cancellation (only meaningful when async: true) |
deprecated | string | no | Deprecation message shown to consumers |
since | string | no | Version when this function was introduced |
Param
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Parameter name |
type | TypeRef | yes | Parameter type |
mutable | bool | no | Mark as mutable (default false). Indicates the callee may modify the value in-place. |
doc | string | no | Documentation string (see Documentation comments) |
Package metadata
The optional top-level package block is the single source of truth for the
publishable identity stamped into every generated ecosystem manifest:
package.json (Node/Wasm), pyproject.toml/setup.py (Python),
*.gemspec (Ruby), *.csproj/*.nuspec (.NET), pubspec.yaml (Dart),
Package.swift (Swift), go.mod (Go), settings.gradle (Android), and
CMakeLists.txt (C++). Declaring it once keeps the name, version, and
metadata consistent across all eleven targets instead of every generator
hardcoding weaveffi / 0.1.0.
Package schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Distribution name (npm/PyPI/gem/NuGet/pub/…) |
version | string | yes | Semantic version stamped into each manifest |
description | string | no | One-line package description |
license | string | no | SPDX license expression (e.g. MIT, Apache-2.0) |
authors | array of string | no | Author entries (Name <email>) |
homepage | string | no | Project homepage URL |
repository | string | no | Source repository URL |
Name and version resolution
Each target resolves its package name with the following precedence (first non-empty wins):
- an explicit per-target override (e.g.
python.package_name,dart.package_name,ruby.gem_name), package.name,- the IDL file stem (e.g.
kvstore.yml→kvstore), - the built-in default
weaveffi.
The version resolves from package.version, falling back to 0.1.0. Names
are normalized per ecosystem, e.g. a Python import package or Ruby require
path lowercases and replaces non-alphanumerics with _ (my-kv.store →
my_kv_store), while the published distribution name keeps the original
spelling.
Code-level identity that has no manifest of its own still follows the package where it is unambiguous: the Swift module name defaults to the PascalCased
package.name(async-demo→AsyncDemo). The stable C ABI symbol prefix is not affected: it staysweaveffi(or your globalc_prefix) so the generated bindings keep calling the symbols the producer exports.
Package example
version: "0.5.0"
package:
name: kvstore
version: "1.0.0"
description: An embedded key-value store API.
license: MIT
authors:
- WeaveFoundry <hello@weavefoundry.dev>
homepage: https://github.com/weavefoundry/weaveffi
repository: https://github.com/weavefoundry/weaveffi
modules:
- name: kv
functions:
- name: count
params: []
return: i64
Primitive types
The following primitive types are supported. All primitives are valid in both parameters and return types.
| Type | Description | Example value |
|---|---|---|
i8 | Signed 8-bit integer | -12 |
i16 | Signed 16-bit integer | -1000 |
i32 | Signed 32-bit integer | -42 |
i64 | Signed 64-bit integer | 9000000000 |
u8 | Unsigned 8-bit integer | 200 |
u16 | Unsigned 16-bit integer | 60000 |
u32 | Unsigned 32-bit integer | 300 |
u64 | Unsigned 64-bit integer | 18000000000 |
f32 | 32-bit floating point | 1.5 |
f64 | 64-bit floating point | 3.14 |
bool | Boolean | true |
string | UTF-8 string (owned copy) | "hello" |
bytes | Byte buffer (owned copy) | binary data |
handle | Opaque 64-bit identifier | resource id |
handle<T> | Typed handle scoped to type T | resource id |
&str | Borrowed string (zero-copy, param-only) | "hello" |
&[u8] | Borrowed byte slice (zero-copy, param-only) | binary data |
Note on JavaScript/Wasm: 64-bit integers (
i64,u64) surface asBigIntin the Node and WebAssembly backends; all narrower integers and the floats surface asnumber.
Primitive examples
version: "0.5.0"
modules:
- name: primitives
structs:
- name: Session
fields:
- { name: id, type: i64 }
functions:
- name: add
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
- name: scale
params:
- { name: value, type: f64 }
- { name: factor, type: f64 }
return: f64
- name: count
params:
- { name: limit, type: u32 }
return: u32
- name: timestamp
params: []
return: i64
- name: is_valid
params:
- { name: token, type: string }
return: bool
- name: echo
params:
- { name: message, type: string }
return: string
- name: compress
params:
- { name: data, type: bytes }
return: bytes
- name: open_resource
params:
- { name: path, type: string }
return: handle
- name: close_resource
params:
- { name: id, type: handle }
- name: open_session
params:
- { name: config, type: string }
return: "handle<Session>"
doc: "Returns a typed handle scoped to Session"
- name: write_fast
params:
- { name: data, type: "&str" }
doc: "Borrowed string: no copy at the FFI boundary"
- name: send_raw
params:
- { name: payload, type: "&[u8]" }
doc: "Borrowed byte slice: no copy at the FFI boundary"
Typed handles
handle<T> is a typed variant of handle that associates the opaque
identifier with a named type T. This gives generators type-safety
information, for example, generating a distinct wrapper class per handle
type. T must be a struct defined in the same module so the generator
knows how to spell the handle’s type. At the C ABI level, handle<T> is
still a uint64_t.
version: "0.5.0"
modules:
- name: sessions
structs:
- name: Session
fields:
- { name: id, type: i64 }
functions:
- name: create_session
params: []
return: "handle<Session>"
- name: close_session
params:
- { name: session, type: "handle<Session>" }
Borrowed types
&str and &[u8] are zero-copy borrowed variants of string and bytes.
They indicate that the callee only reads the data for the duration of the
call and does not take ownership. This avoids an allocation and copy at
the FFI boundary.
YAML note: Quote borrowed types like
"&str"and"&[u8]"because YAML interprets&as an anchor indicator.
Struct definitions
Structs define composite types with named, typed fields. Define structs under
the structs key of a module, then reference them by name in function
signatures and other type positions.
Struct schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Struct name (e.g. Contact) |
doc | string | no | Documentation string |
fields | array of Field | yes | Must have at least one field |
builder | bool | no | Generate a builder class (default false) |
When builder: true, generators emit a builder class with with_* setter
methods and a build() method, enabling incremental construction of
complex structs.
Each field:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Field name |
type | TypeRef | yes | Field type |
doc | string | no | Documentation string |
default | value | no | Default value for this field |
Struct example
version: "0.5.0"
modules:
- name: geometry
structs:
- name: Point
doc: "A 2D point in space"
fields:
- name: x
type: f64
doc: "X coordinate"
- name: "y"
type: f64
doc: "Y coordinate"
- name: Rect
fields:
- name: origin
type: Point
- name: width
type: f64
- name: height
type: f64
- name: Config
builder: true
fields:
- name: timeout
type: i32
default: 30
- name: retries
type: i32
default: 3
- name: label
type: "string?"
functions:
- name: distance
params:
- { name: a, type: Point }
- { name: b, type: Point }
return: f64
- name: bounding_box
params:
- { name: points, type: "[Point]" }
return: Rect
Struct fields may reference other structs, enums, optionals, lists, or maps. Interface references, borrowed types, and iterators are not valid field types (see Type compatibility).
Enum definitions
Enums define a fixed set of named integer variants. Each variant has an
explicit value (i32). Define enums under the enums key.
Enum schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Enum name (e.g. Color) |
doc | string | no | Documentation string |
variants | array of Variant | yes | Must have at least one variant |
Each variant:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Variant name (e.g. Red) |
value | i32 | yes | Integer discriminant |
doc | string | no | Documentation string |
fields | array of Field | no | Associated data: makes the enum a sum type |
Enum example
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
doc: "Category of contact"
variants:
- name: Personal
value: 0
doc: "Friends and family"
- name: Work
value: 1
doc: "Professional contacts"
- name: Other
value: 2
functions:
- name: count_by_type
params:
- { name: contact_type, type: ContactType }
return: i32
Variant values must be unique within an enum, and variant names must be unique within an enum.
Rich (algebraic) enums: sum types
When one or more variants declare fields, the enum becomes a rich enum: an
algebraic sum type whose variants carry associated data (like a Rust enum or a
Swift enum with associated values). A unit variant (no fields) and a data
variant may coexist in the same enum.
version: "0.5.0"
modules:
- name: shapes
enums:
- name: Shape
doc: "An algebraic shape"
variants:
- name: Empty
value: 0
- name: Circle
value: 1
fields:
- { name: radius, type: f64 }
- name: Rectangle
value: 2
fields:
- { name: width, type: f32 }
- { name: height, type: f32 }
functions:
- name: area
params:
- { name: shape, type: Shape }
return: f64
Unlike a plain C-style enum (which crosses the ABI by value as an integer), a rich enum crosses as an opaque object pointer, exactly like a struct. The C ABI gains a tag reader, a per-variant constructor and field getters, and a destructor; each backend wraps these into an idiomatic owned type:
| Backend | Surface |
|---|---|
| C | *_tag, *_{Variant}_new, *_{Variant}_get_{field}, *_destroy |
| C++ | RAII class with nested Tag, static factories, per-variant getters |
| Python/Ruby | class with a tag, per-variant factory + accessor methods |
| C#/Go | owned class/struct with Tag, per-variant factories + accessors |
A variant fields entry uses the same shape as a struct field (name, type,
doc), but obeys the positional rules of a return-like slot: no borrowed
(&str/&[u8]) types and no iterators. Field names must be unique within a
variant.
Interfaces
An interface is a first-class object type: a stateful resource with identity
and behavior, declared under the interfaces key of a module. Where a struct
models a plain data record with fields, an interface models something you
hold and operate on: a store, a session, a connection. The object lives
behind the FFI boundary and crosses it as an opaque reference; consumers see
a real class (or the target’s closest analogue) whose constructors, methods,
and statics call back into the producer.
Interface schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Interface type name (e.g. Store) |
doc | string | no | Documentation string |
constructors | array of Constructor | no | Static functions returning a new instance |
methods | array of Function | no | Instance methods |
statics | array of Function | no | Static functions namespaced under the interface |
An interface must declare at least one member, and constructor, method, and static names share one namespace per interface (no duplicates across the three lists).
Each constructor:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Constructor identifier (e.g. open) |
params | array of Param | yes | Input parameters (may be empty []) |
doc | string | no | Documentation string |
throws | bool | no | Mark as fallible with a typed domain error (default false) |
A constructor implicitly returns a new instance of its interface, so it
declares no return field, and it may not be async (expose an async
static factory returning the interface instead). A constructor named new
becomes the canonical constructor where the target language has one (Swift
init, Python __init__); every other constructor becomes a static factory
method.
Methods and statics use the full Function schema, including
throws, async, cancellable, deprecated, and since. A method
receives the instance implicitly; it does not declare a self parameter.
A static takes no instance at all.
Lifecycle
Every interface receives an implicit destructor: the C ABI gains a
*_destroy symbol, and each generated wrapper releases the underlying
object through its language’s natural disposal hook (RAII destructors,
deinit, __del__, IDisposable, finalizers, close()). You never
declare destroy in the IDL.
Ownership follows the reference direction: an interface passed as a parameter is borrowed for the duration of the call, while an interface returned from a call is a new owned reference the caller (or its wrapper) must eventually release.
Where interface types may appear
An interface name is a valid TypeRef in function and method parameters,
in return types, and in optionals of those (Store, Store?). It is
not valid as a struct field, a collection element ([Store]), a map
key or value, or a callback parameter; the validator rejects those
positions.
Interface example
A trimmed version of the kvstore sample’s Store interface:
version: "0.5.0"
modules:
- name: kv
errors:
name: KvError
codes:
- { name: KeyNotFound, code: 1001, message: "key not found" }
- { name: IoError, code: 1004, message: "I/O failure" }
interfaces:
- name: Store
doc: An embedded key-value store owning its entries
constructors:
- name: open
doc: Open (or create) a store backed by the given filesystem path
params:
- { name: path, type: string }
throws: true
methods:
- name: get
doc: Look up a value by key
params:
- { name: key, type: string }
return: bytes
throws: true
- name: delete
doc: Remove the entry for the given key, returning true if it existed
params:
- { name: key, type: string }
return: bool
throws: true
- name: count
doc: Return the number of live entries in the store
params: []
return: i64
statics:
- name: default_capacity
doc: The largest number of live entries one store will hold
params: []
return: i64
C ABI lowering
An interface lowers to an opaque struct tag plus one C symbol per member.
Constructors return an owned pointer; methods take a leading
const {tag}* self argument before their declared parameters; statics take
no self; and the implicit destructor releases the object:
typedef struct weaveffi_kv_Store weaveffi_kv_Store;
/* Constructor: returns a new owned instance. */
weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err);
/* Methods: an implicit leading self slot. */
bool weaveffi_kv_Store_delete(const weaveffi_kv_Store* self, const char* key,
weaveffi_error* out_err);
int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err);
/* Statics: no self slot. */
int64_t weaveffi_kv_Store_default_capacity(weaveffi_error* out_err);
/* Implicit destructor: releases the object. */
void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self);
Async and iterator-returning members follow the same shapes as free
functions (the async launcher and the iterator handle simply carry the
self slot). Free functions and interface members share the module’s C
symbol namespace, so a free function named Store_get would collide with a
get method on an interface Store; the validator rejects the collision.
Optional types
Append ? to any type to make it optional (nullable). When a value is absent,
the default is null.
| Syntax | Meaning |
|---|---|
string? | Optional string |
i32? | Optional i32 |
Contact? | Optional struct reference |
Color? | Optional enum reference |
Store? | Optional interface reference (params and returns only) |
Optional example
version: "0.5.0"
modules:
- name: contacts
structs:
- name: Contact
fields:
- { name: id, type: i64 }
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: nickname, type: "string?" }
functions:
- name: find_contact
params:
- { name: id, type: i64 }
return: "Contact?"
doc: "Returns null if no contact exists with the given id"
- name: update_email
params:
- { name: id, type: i64 }
- { name: email, type: "string?" }
YAML note: Quote optional types like
"string?"and"Contact?"to prevent the YAML parser from treating?as special syntax.
List types
Wrap a type in [T] brackets to declare a list (variable-length sequence).
| Syntax | Meaning |
|---|---|
[i32] | List of i32 |
[string] | List of strings |
[Contact] | List of structs |
[Color] | List of enums |
List example
version: "0.5.0"
modules:
- name: lists
structs:
- name: Contact
fields:
- { name: id, type: i64 }
functions:
- name: sum
params:
- { name: values, type: "[i32]" }
return: i32
- name: list_contacts
params: []
return: "[Contact]"
- name: batch_delete
params:
- { name: ids, type: "[i64]" }
return: i32
YAML note: Quote list types like
"[i32]"and"[Contact]"because YAML interprets bare[...]as an inline array.
Map types
Wrap a key-value pair in {K:V} braces to declare a map (dictionary /
associative array). Keys must be primitive types or enums; structs, lists,
and maps are not valid key types. Values may be any valid TypeRef.
| Syntax | Meaning |
|---|---|
{string:i32} | Map from string to i32 |
{string:Contact} | Map from string to struct |
{i32:string} | Map from i32 to string |
{string:[i32]} | Map from string to list of i32 |
Map example
version: "0.5.0"
modules:
- name: maps
structs:
- name: Contact
fields:
- { name: id, type: i64 }
- { name: name, type: string }
- { name: email, type: "string?" }
functions:
- name: update_scores
params:
- { name: scores, type: "{string:i32}" }
return: bool
doc: "Update player scores by name"
- name: get_contacts
params: []
return: "{string:Contact}"
doc: "Returns a map of name to Contact"
- name: merge_tags
params:
- { name: current, type: "{string:string}" }
- { name: additions, type: "{string:string}" }
return: "{string:string}"
YAML note: Quote map types like
"{string:i32}"because YAML interprets bare{...}as an inline mapping.
C ABI convention
Maps are passed across the FFI boundary as parallel arrays of keys and
values, plus a shared length. A map parameter {K:V} named m expands to
three C parameters:
const K* m_keys, const V* m_values, size_t m_len
A map return value expands to out-parameters:
K* out_keys, V* out_values, size_t* out_len
For example, a function update_scores(scores: {string:i32}) generates:
void weaveffi_mymod_update_scores(
const char* const* scores_keys,
const int32_t* scores_values,
size_t scores_len,
weaveffi_error* out_err
);
Key type restrictions
Only primitive types (i32, u32, i64, f64, bool, string, bytes,
handle) and enum types are valid map keys. The validator rejects structs,
lists, and maps as key types.
Nested types
Optional and list modifiers compose freely:
| Syntax | Meaning |
|---|---|
[Contact?] | List of optional contacts (items may be null) |
[i32]? | Optional list of i32 (the entire list may be null) |
[string?] | List of optional strings |
{string:[i32]} | Map from string to list of i32 |
{string:i32}? | Optional map (the entire map may be null) |
Nested type example
version: "0.5.0"
modules:
- name: nested
structs:
- name: Contact
fields:
- { name: id, type: i64 }
functions:
- name: search
params:
- { name: query, type: string }
return: "[Contact?]"
doc: "Returns a list where some entries may be null (redacted)"
- name: get_scores
params:
- { name: user_id, type: i64 }
return: "[i32]?"
doc: "Returns null if user has no scores, otherwise a list"
- name: bulk_update
params:
- { name: emails, type: "[string?]" }
return: i32
The parser evaluates type syntax outside-in: [Contact?] is parsed as
List(Optional(Contact)), while [Contact]? is parsed as
Optional(List(Contact)).
Iterator types
Wrap a type in iter<T> to declare a lazy iterator over values of type T.
Unlike [T] (which materializes the full list), iterators yield elements
one at a time and are suitable for large or streaming result sets.
| Syntax | Meaning |
|---|---|
iter<i32> | Iterator over i32 values |
iter<string> | Iterator over strings |
iter<Contact> | Iterator over structs |
Iterator example
version: "0.5.0"
modules:
- name: streaming
structs:
- name: Contact
fields:
- { name: id, type: i64 }
functions:
- name: scan_entries
params:
- { name: prefix, type: string }
return: "iter<Contact>"
doc: "Lazily iterates over matching contacts"
Laziness is part of the binding contract, not just the C ABI shape.
Every generated wrapper surfaces iter<T> as its language’s native
lazy iteration idiom: an iter.Seq in Go (iter.Seq2 when the
function throws), a Sequence in Swift, an input-iterator range in
C++, an iterator in Python, an Enumerator in Ruby, an
IEnumerable<T> in C#, an Iterable<T> in Dart, an iterable in
JavaScript, and an Iterator<T> with close() in Kotlin. The wrapper
issues one producer next call per consumer step; no target drains
the sequence into a hidden list. The underlying iterator handle is
destroyed exactly once, eagerly on exhaustion or from the wrapper’s
disposal idiom when iteration is abandoned early, and errors from the
launch and from each next follow the function’s error strategy (see
the Error Handling guide).
The full pull contract is stated in
weaveffi_core::plan::IteratorProtocol; element ownership is covered
in the Memory Ownership guide.
Iterators are only valid as return types. The validator rejects iterators in parameter positions.
Callbacks
Callbacks define function signatures that can be passed from the host language into Rust. They enable event-driven patterns where Rust code invokes a caller-provided function.
Callback schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Callback name |
params | array of Param | yes | Parameters passed to the callback |
doc | string | no | Documentation string |
Callback example
version: "0.5.0"
modules:
- name: events
functions: []
callbacks:
- name: on_data
params:
- { name: payload, type: string }
doc: "Fired when data arrives"
- name: on_error
params:
- { name: code, type: i32 }
- { name: message, type: string }
Callback names are not a valid TypeRef. Callbacks are wired up at the
module level: declare them under callbacks:, reference them from a
listeners: entry via event_callback, and emit asynchronous results
from functions marked async: true.
Listeners
Listeners provide a higher-level abstraction over callbacks for event subscription patterns. A listener combines an event callback with subscribe/unsubscribe lifecycle management.
Listener schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Listener name |
event_callback | string | yes | Name of the callback this listener uses |
doc | string | no | Documentation string |
Listener example
version: "0.5.0"
modules:
- name: events
functions: []
callbacks:
- name: on_data
params:
- { name: payload, type: string }
listeners:
- name: data_stream
event_callback: on_data
doc: "Subscribe to data events"
The event_callback must reference a callback defined in the same module.
Nested modules
Modules can contain sub-modules, enabling hierarchical organization of large APIs. Nested modules share the same validation rules as top-level modules.
Nested module example
version: "0.5.0"
modules:
- name: app
functions:
- name: init
params: []
modules:
- name: auth
structs:
- name: Session
fields:
- { name: id, type: i64 }
functions:
- name: login
params:
- { name: username, type: string }
- { name: password, type: string }
return: "handle<Session>"
- name: data
structs:
- name: Record
fields:
- { name: id, type: i64 }
- { name: value, type: string }
functions:
- name: get_record
params:
- { name: id, type: i64 }
return: Record
C ABI symbols for nested modules use underscores to join the path:
weaveffi_app_auth_login, weaveffi_app_data_get_record.
Cross-module type references
Struct, enum, interface, and error domain names are unique across the whole API (the validator rejects two types sharing a bare name, wherever they are declared). A bare type name therefore resolves unambiguously from any module: reference the type by name and the toolchain qualifies the reference to its owning module internally.
For example, a nested stats module can take the parent kv module’s
Store interface as a parameter:
version: "0.5.0"
modules:
- name: kv
errors:
name: KvError
codes:
- { name: IoError, code: 1004, message: "I/O failure" }
interfaces:
- name: Store
constructors:
- name: open
params:
- { name: path, type: string }
throws: true
methods:
- name: count
params: []
return: i64
modules:
- name: stats
structs:
- name: Stats
fields:
- { name: total_entries, type: i64 }
functions:
- name: get_stats
doc: Snapshot the current store statistics
params:
- { name: store, type: Store }
return: Stats
throws: true
The generated bindings spell a cross-module reference with the owning module’s qualification where the target needs one.
Async and lifecycle annotations
Async functions
Functions can be marked as asynchronous. See the Async Functions guide for detailed per-target behaviour.
version: "0.5.0"
modules:
- name: net
errors:
name: NetError
codes:
- { name: Unreachable, code: 1, message: "host unreachable" }
functions:
- name: fetch_data
params:
- { name: url, type: string }
return: string
async: true
throws: true
- name: upload_file
params:
- { name: path, type: string }
return: bool
async: true
cancellable: true
async composes with throws: an async function that also declares
throws: true delivers its failure as the module’s typed domain error
through the target’s async idiom (an async throws function in Swift, a
rejected promise carrying the typed error in JavaScript, and so on). An
async function without throws gets a plain async shape, and a failure can
only be a producer bug.
Async void functions (no return type) emit a validator warning since
they are unusual. An async function cannot return an iterator (iter<T>);
return a list instead or make the function synchronous.
Deprecated functions
Mark a function as deprecated with a migration message:
version: "0.5.0"
modules:
- name: legacy
functions:
- name: add_old
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
deprecated: "Use add_v2 instead"
since: "0.1.0"
Generators propagate the deprecation message to the target language
(e.g. @available(*, deprecated) in Swift, @Deprecated in Kotlin,
warn in Ruby).
Mutable parameters
Mark a parameter as mutable when the callee may modify it in-place:
version: "0.5.0"
modules:
- name: buffers
functions:
- name: fill_buffer
params:
- { name: buf, type: bytes, mutable: true }
This affects the C ABI signature (non-const pointer) and may influence generated wrapper code in target languages.
Generators section
The top-level generators key provides per-generator configuration
directly in the IDL file. This is an alternative to using a separate
TOML configuration file with --config.
version: "0.5.0"
modules:
- name: math
functions:
- name: add
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
generators:
swift:
module_name: MyMathLib
android:
package: com.example.math
ruby:
module_name: MathBindings
gem_name: math_bindings
go:
module_path: github.com/myorg/mathlib
Each key under generators is the target name (matching the --target
flag). The value is a target-specific configuration object. See the
Generator Configuration guide for the full list
of options.
Type compatibility
All types are valid in both parameter and return positions unless noted.
| Type | Params | Returns | Struct fields | Notes |
|---|---|---|---|---|
i8 | yes | yes | yes | |
i16 | yes | yes | yes | |
i32 | yes | yes | yes | |
i64 | yes | yes | yes | |
u8 | yes | yes | yes | |
u16 | yes | yes | yes | |
u32 | yes | yes | yes | |
u64 | yes | yes | yes | BigInt in JS/Wasm |
f32 | yes | yes | yes | |
f64 | yes | yes | yes | |
bool | yes | yes | yes | |
string | yes | yes | yes | |
bytes | yes | yes | yes | |
handle | yes | yes | yes | |
handle<T> | yes | yes | yes | Typed handle |
&str | yes | yes | yes | Borrowed, zero-copy |
&[u8] | yes | yes | yes | Borrowed, zero-copy |
StructName | yes | yes | yes | |
EnumName | yes | yes | yes | |
InterfaceName | yes | yes | no | Also InterfaceName?; not in collections |
T? | yes | yes | yes | |
[T] | yes | yes | yes | |
[T?] | yes | yes | yes | |
[T]? | yes | yes | yes | |
{K:V} | yes | yes | yes | |
{K:V}? | yes | yes | yes | |
iter<T> | no | yes | no | Return-only |
Complete example
A full IDL combining structs, enums, optionals, lists, an interface, and a
typed error domain (a trimmed version of the contacts sample):
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: "A contact record"
fields:
- { name: id, type: i64 }
- { name: first_name, type: string }
- { name: last_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
errors:
name: ContactsError
codes:
- { name: InvalidName, code: 1, message: "name must not be empty" }
- { name: NotFound, code: 2, message: "contact not found" }
interfaces:
- name: ContactBook
doc: "An in-memory address book owning its contacts"
constructors:
- name: new
params: []
methods:
- name: add
doc: "Add a contact, returning the stored record with its assigned id"
params:
- { name: first_name, type: string }
- { name: last_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: Contact
throws: true
- name: get
doc: "Look up a contact by id"
params:
- { name: id, type: i64 }
return: Contact
throws: true
- name: list
params: []
return: "[Contact]"
- name: remove
params:
- { name: id, type: i64 }
return: bool
- name: count
params: []
return: i32
Validation rules
- Module, function, parameter, struct, enum, interface, field, and variant
names must be valid identifiers (start with a letter or
_, contain only alphanumeric characters and_). - Names must be unique within their scope (no duplicate module names, no duplicate function names within a module, etc.).
- Struct, enum, interface, and error domain names must be unique across the whole API, not just their module: generators emit flat per-language type names, and bare-name references resolve API-wide.
- Reserved keywords are rejected:
if,else,for,while,loop,match,type,return,async,await,break,continue,fn,struct,enum,mod,use. - Structs must have at least one field. Enums must have at least one variant. Interfaces must have at least one constructor, method, or static.
- Enum variant values must be unique within their enum.
- Type references to structs, enums, and interfaces must resolve to a definition somewhere in the API (see Cross-module type references).
- Interface members (constructors, methods, and statics) share one namespace
per interface; a constructor may not declare a
returntype and may not beasync. - Interface types are valid only as parameters, return types, and optionals of those; not as struct fields, collection elements, map keys or values, or callback parameters.
- Free functions and interface members share the module’s C symbol namespace; two declarations lowering to the same symbol are rejected.
throws: truerequires an error domain in scope: on the same module or an ancestor module.- Error codes must be non-zero (and not
-2, which is reserved for producer panics) and unique within their domain. Error code names must be unique within their domain and across every domain in the API. - Error domain names must not collide with function names in the same module.
- Async functions are allowed. Async void functions (no return type) emit a warning. An async function cannot return an iterator.
- Listener
event_callbackmust reference a callback in the same module.
ABI mapping
- Parameters map to C ABI types;
stringandbytesare passed as pointer + length. - Return values are direct scalars except:
string: returnsconst char*allocated by Rust; caller must free viaweaveffi_free_string.bytes: returnsconst uint8_t*and requires an extrasize_t* out_lenparam; caller frees withweaveffi_free_bytes.
- Each function takes a trailing
weaveffi_error* out_errfor error reporting.
Error domain
A module can declare one error domain: a named set of symbolic error codes its fallible functions report. The domain generates a typed error construct in every target language (an error enum in Swift, an exception class hierarchy in Python, sealed exception subclasses in Kotlin, and so on), so consumers catch and match on the codes you declared rather than on raw integers.
Error domain schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Domain name, used to name the generated error type (e.g. KvError) |
codes | array of Code | yes | The named codes belonging to this domain |
Each code:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Code name, lowered to a case or subclass on the generated error type (e.g. KeyNotFound) |
code | i32 | yes | Stable numeric value carried across the C ABI |
message | string | yes | Default human-readable message |
doc | string | no | Documentation string |
PascalCase code names (KeyNotFound, StoreFull) are the convention: each
generator re-cases them into its own idiom.
Opting in with throws
Declaring a domain reserves the codes; a function, method, or constructor
joins the typed error path by declaring throws: true:
version: "0.5.0"
modules:
- name: contacts
errors:
name: ContactsError
codes:
- { name: InvalidName, code: 1, message: "name must not be empty" }
- { name: NotFound, code: 2, message: "contact not found" }
functions:
- name: get_contact
params:
- { name: id, type: i64 }
return: string
throws: true
- name: count_contacts
params: []
return: i32
A throwing callable surfaces in each target’s error idiom (throws in
Swift, raise in Python, (T, error) in Go, exceptions elsewhere), and the
error it delivers is the domain type: the numeric code maps to the matching
declared case. A callable without throws (like count_contacts
above) has a plain signature at the idiomatic surface; it cannot report a
domain error, and a failure there can only be a producer bug, which
surfaces as a generic branded error or a trap rather than a typed error.
See the Error Handling guide for the full model,
including panic behavior.
The domain is in scope for its module and every module nested inside
it, so a domain declared on a parent module serves the whole subtree.
Declaring throws: true with no domain in scope is a validation error.
Validation
- Codes must be non-zero (
0means success) and must not be-2(reserved for producer panics). - Numeric codes must be unique within a domain.
- Code names must be unique within a domain and across every domain in
the API: backends with flat namespaces derive one error class or
constant per code, so two domains both declaring
NotFoundwould collide. Qualify one of them (e.g.OrderNotFound). - The domain name must not collide with a function name in the same module, and it shares the API-wide type namespace with struct, enum, and interface names.
Documentation comments
Every IR element accepts an optional doc: field. WeaveFFI propagates that
text into the generated bindings using each language’s native doc-comment
syntax. Multi-line strings (use YAML’s | block form) are preserved across
the boundary; single-line strings collapse to a one-liner where the target
syntax allows.
Supported sites:
Function.doc,Param.docInterfaceDef.doc, plus each of its constructors, methods, and statics (interface members use the function schema, so they carryFunction.docandParam.doc)StructDef.doc,StructField.docEnumDef.doc,EnumVariant.docCallbackDef.doc,ListenerDef.docErrorCode.doc
Per-target syntax:
| Target | Comment syntax | Param docs |
|---|---|---|
| C / C++ | /** ... */ directly above the declaration | not emitted |
| Swift | /// ... per line | /// - Parameter name: ... |
| Kotlin / Android | /** ... */ KDoc block | @param name ... inside the KDoc block |
| TypeScript (Node) | /** ... */ JSDoc | @param name ... |
| TypeScript (Wasm) | /** ... */ JSDoc | @param name ... |
| Python | """...""" first statement; # ... above C ABI binds | NumPy-style Parameters section in the wrapper docstring |
| .NET (C#) | /// <summary>...</summary> XML doc | /// <param name="name">...</param> |
| Dart | /// ... | not emitted |
| Go | // SymbolName ... per Go’s convention | trailing // Parameters: block |
| Ruby | # ... lines above the def | # @param name [Object] ... |
Example IDL:
version: "0.5.0"
modules:
- name: docs
structs:
- name: Document
doc: |
Represents a single document tracked by the system.
Documents are persisted to disk and exposed via the public API.
fields:
- { name: id, type: i64, doc: Stable opaque identifier }
- { name: title, type: string, doc: Human-readable title }
functions:
- name: create_document
doc: Create a brand new document
params:
- { name: title, type: string, doc: Human-readable title }
return: Document
All eleven generators emit emit_doc calls before every documented
declaration; absent or empty doc: fields produce no extra output, so the
feature is fully opt-in.
Memory and Error Model
This section summarizes the C ABI conventions exposed by WeaveFFI and how to manage ownership across the FFI boundary.
Error handling
- Every generated C function ends with an
out_errparameter of typeweaveffi_error*, except_destroysymbols and struct field getters. - On success:
out_err->code == 0andout_err->message == NULL. - On failure:
out_err->code != 0andout_err->messagepoints to a Rust-allocated NUL-terminated UTF-8 string that must be cleared. - On a
throws: truefunction, a non-zero code is one of the module’s declared domain codes (the header emits an enum constant per code, such asweaveffi_kv_KvError_KeyNotFound); on a non-throwing function a non-zero code only ever reports a producer bug such as a panic.
Relevant declarations (from the generated header):
typedef struct weaveffi_error { int32_t code; const char* message; } weaveffi_error;
void weaveffi_error_clear(weaveffi_error* err);
Typical C usage:
struct weaveffi_error err = {0};
int32_t sum = weaveffi_calculator_add(3, 4, &err);
if (err.code) { fprintf(stderr, "%s\n", err.message ? err.message : ""); weaveffi_error_clear(&err); }
Notes:
- The default unspecified error code used by the runtime is
-1. weaveffi_error_clearis idempotent: it frees the message and nulls the pointer, so clearing an already-cleared struct is safe.- Module error domains declare their own codes in the IDL; see the Error Handling Guide for the typed error model, including the Throws versus Trap interpretation of non-zero codes.
Strings and bytes
Returned strings are owned by Rust and must be freed by the caller:
const char* s = weaveffi_calculator_echo(msg, &err);
// ... use s ...
weaveffi_free_string(s);
Returned bytes include a separate out-length parameter and must be freed by the caller:
size_t out_len = 0;
const uint8_t* buf = weaveffi_module_fn(/* params ... */, &out_len, &err);
// ... copy data from buf ...
weaveffi_free_bytes((uint8_t*)buf, out_len);
Relevant declarations:
void weaveffi_free_string(const char* ptr);
void weaveffi_free_bytes(uint8_t* ptr, size_t len);
Iterators
An iter<T> return yields an opaque iterator handle. Each _next call
writes an element the caller now owns: free string elements with
weaveffi_free_string and record or rich-enum elements with their
_destroy symbol after copying; by-value elements need nothing. Call the
iterator’s _destroy exactly once, whether iteration ran to exhaustion or
was abandoned early.
Async completion callbacks
Result buffers passed to an async completion callback (strings, bytes,
arrays, boxed optional scalars) are borrowed: the producer owns them, they
are valid only for the callback’s duration, and the producer frees them
after the callback returns. Copy inside the callback; do not free them.
Owned-object results (records, rich enums, interfaces) are the exception:
the callback receives ownership and must eventually call _destroy. The
err struct is likewise borrowed; copy its code and message inside the
callback (clearing it anyway is safe because the clear is idempotent).
Handles and interfaces
Untyped opaque resources are represented as weaveffi_handle_t (64-bit).
Treat them as tokens; their lifecycle APIs are defined by your module.
Interface objects cross the boundary as typed opaque pointers
(weaveffi_kv_Store*): constructors and methods take out_err, methods take
the receiver as their leading argument, and the _destroy symbol frees the
instance exactly once.
Language wrappers
- Swift: the generated wrapper automatically clears errors and frees returned
strings; a
throws: truefunction throws the module’s typed error enum, and a non-throwing function traps on a poisoned error slot. - Node: the generated
weaveffi_addon.cclears errors and frees returned strings; the JS loader prefers the node-gyp output (build/Release/weaveffi.node), honors aWEAVEFFI_ADDONpath override, and falls back to a prebuiltindex.nodenext to it.
C-string safety
When constructing C strings, interior NUL bytes are sanitized on the Rust side to maintain valid C semantics.
Naming and Package Conventions
Naming and Package Conventions
This guide standardizes how we name the Weave projects, repositories, packages, modules, and identifiers across ecosystems.
Human-facing brand names (prose)
- Use condensed names in sentences and documentation:
- WeaveFFI
- WeaveHeap
Repository and package slugs (URLs and registries)
-
Use condensed lowercase slugs for top-level repositories:
- GitHub:
weaveffi,weaveheap(repos:weavefoundry/weaveffi,weavefoundry/weaveheap)
- GitHub:
-
Use hyphenated slugs for subpackages and components, prefixed with the top-level slug:
- Examples:
weaveffi-core,weaveffi-ir,weaveheap-core
- Examples:
-
Planned package names (not yet published):
- crates.io:
weaveffi,weaveffi-core,weaveffi-ir, etc. - npm:
@weavefoundry/weaveffi - PyPI:
weaveffi - SPM (repo slug):
weaveffi
- crates.io:
Rationale: condensed top-level slugs unify handles across registries and are ergonomic to type; hyphenated subpackages remain idiomatic and map cleanly to ecosystems that normalize to underscores or CamelCase.
Code identifiers by ecosystem
-
Rust
- Crates: hyphenated subcrates on crates.io (e.g.,
weaveffi-core), imported as underscores (e.g.,weaveffi_core). Top-level crate (if any):weaveffi. - Modules/paths: snake_case.
- Types/traits/enums: CamelCase (e.g.,
WeaveFFI).
- Crates: hyphenated subcrates on crates.io (e.g.,
-
Swift / Apple platforms
- Package products and modules: UpperCamelCase (e.g.,
WeaveFFI,WeaveHeap). - Keep repo slug condensed; SPM product name provides the CamelCase surface.
- Package products and modules: UpperCamelCase (e.g.,
-
Java / Kotlin (Android)
- Group ID / package base: reverse-DNS, all lowercase (e.g.,
com.weavefoundry.weaveffi). - Artifact ID: top-level condensed (e.g.,
weaveffi); sub-artifacts hyphenated (e.g.,weaveffi-android). - Class names: UpperCamelCase (e.g.,
WeaveFFI).
- Group ID / package base: reverse-DNS, all lowercase (e.g.,
-
JavaScript / TypeScript (Node, bundlers)
- Package name: scope + condensed for top-level, hyphenated for subpackages (e.g.,
@weavefoundry/weaveffi,@weavefoundry/weaveffi-core). - Import alias: flexible, prefer
WeaveFFIin examples when using default exports or named namespaces.
- Package name: scope + condensed for top-level, hyphenated for subpackages (e.g.,
-
Python
- PyPI name: top-level condensed (e.g.,
weaveffi); subpackages hyphenated (e.g.,weaveffi-core). - Import module: condensed for top-level (e.g.,
import weaveffi); underscores for hyphenated subpackages (e.g.,import weaveffi_core).
- PyPI name: top-level condensed (e.g.,
-
C / CMake
- Target/library names: snake_case (e.g.,
weaveffi,weaveffi_core). - Header guards / include dirs: snake_case or directory-based (e.g.,
#include <weaveffi/weaveffi.h>).
- Target/library names: snake_case (e.g.,
Writing guidelines
- In prose, prefer the condensed brand names: “WeaveFFI”, “WeaveHeap”.
- In code snippets, follow the host language conventions above.
- For cross-language docs, show both the repo/package slug and the language-appropriate identifier on first mention, e.g., “Install
weaveffi(import asweaveffi, Swift moduleWeaveFFI). For subpackages, installweaveffi-core(import asweaveffi_core).”
Migration guidance
- New crates and packages should follow the condensed top-level + hyphenated subpackage pattern:
- Rust crates:
weaveffi-*,weaveheap-*. - npm packages (planned):
@weavefoundry/weaveffi-*,@weavefoundry/weaveheap-*. - Swift products: UpperCamelCase (e.g.,
WeaveFFICore).
- Rust crates:
- Prefer condensed top-level slugs. Avoid hyphenated top-level slugs like
weave-ffi,weave-heapgoing forward.
Examples
-
Rust
- Crate:
weaveffi-core - Import:
use weaveffi_core::{WeaveFFI};
- Crate:
-
Swift (SPM)
- Repo:
weaveffi - Package product:
WeaveFFI - Import:
import WeaveFFI
- Repo:
-
Python (planned)
- Package:
weaveffi - Import:
import weaveffi as ffi
- Package:
-
Node (planned)
- Package:
@weavefoundry/weaveffi - Import:
import { WeaveFFI } from '@weavefoundry/weaveffi'
- Package:
Generators
This section contains language-specific generators and guidance for using the artifacts they produce. Choose a target below to explore the details.
Feature support matrix
Every generator implements the full IDL surface (structs, enums,
interfaces, optionals, lists, maps, typed handles, borrowed parameters,
builders, typed error domains with opt-in throws, and nested modules)
plus the call shapes below. A
generator that cannot support a feature declares it in its
TargetCapabilities, and weaveffi generate fails loudly when an IDL
uses a feature the selected target cannot deliver (no silent skips).
| Target | Async functions | Iterators (iter<T>) | Callbacks | Listeners |
|---|---|---|---|---|
| C | ✓ (raw callback ABI) | ✓ | ✓ | ✓ |
| C++ | ✓ (std::future<T>) | ✓ | ✓ (std::function) | ✓ |
| Swift | ✓ (async throws) | ✓ | ✓ (closures) | ✓ |
| Android (Kotlin) | ✓ (suspend fun) | ✓ | ✓ (lambdas via JNI) | ✓ |
| Node.js | ✓ (Promise<T>) | ✓ | ✓ (thread-safe functions) | ✓ |
| Python | ✓ (async def) | ✓ | ✓ (CFUNCTYPE) | ✓ |
| .NET | ✓ (Task<T>) | ✓ | ✓ (delegates) | ✓ |
| Dart | ✓ (Future<T>) | ✓ | ✓ (NativeCallable) | ✓ |
| Go | ✓ (blocking bridge) | ✓ | ✓ (exported trampolines) | ✓ |
| Ruby | ✓ (blocking bridge) | ✓ | ✓ (FFI::Function) | ✓ |
| Wasm | ✓ (Promise<T>) | ✓ | ✓ (table trampolines) | ✓ |
Notes:
- Iterators are lazy. Every target wraps the C ABI’s
handle/
_next/_destroytriple in its native lazy idiom (Goiter.Seq, SwiftSequence, C++ input-iterator range, KotlinIterator, JS iterables, Python iterators, .NETIEnumerable<T>, DartIterable, RubyEnumerator), pulling one element per consumer step and destroying the handle exactly once. C exposes the raw symbols directly. - Go and Ruby async wrappers block the calling thread until the
producer’s completion callback fires (a channel receive in Go, a
Queue#popin Ruby). Run them from a goroutine or Ruby thread for concurrency; the native producer still runs off-thread. - Wasm callbacks/listeners deliver synchronously. The loader
installs one long-lived JavaScript trampoline per callback typedef in
the module’s function table, so the producer’s
emit_*dispatches straight back into JS. Becausewasm32-unknown-unknownis single-threaded, events fire only while a call into the module is on the stack; a producer that emits from a spawned thread cannot run on this target at all (details). In Emscripten mode callbacks, listeners, and async functions become explicit throwing stubs rather than silent no-ops.
Android
Overview
The Android target produces a Gradle android-library template that
combines a Kotlin wrapper, JNI C shims, and a CMake build for the JNI
shared library. The wrapper exposes idiomatic Kotlin types while the JNI
layer bridges them to the C ABI.
What gets generated
| File | Purpose |
|---|---|
generated/android/settings.gradle | Gradle settings for the library module |
generated/android/build.gradle | android-library plugin, NDK config |
generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.kt | Kotlin wrapper (enums, struct classes, namespaced functions) |
generated/android/src/main/cpp/weaveffi_jni.c | JNI shims that call the C ABI and throw Java exceptions |
generated/android/src/main/cpp/CMakeLists.txt | NDK CMake build for the JNI shared library |
Type mapping
| IDL type | Kotlin type (external) | Kotlin type (wrapper) | JNI C type |
|---|---|---|---|
i32 | Int | Int | jint |
u32 | Long | Long | jlong |
i64 | Long | Long | jlong |
f64 | Double | Double | jdouble |
i8 | Byte | Byte | jbyte |
i16 | Short | Short | jshort |
u8 | Byte | Byte | jbyte |
u16 | Short | Short | jshort |
u64 | Long | Long | jlong |
f32 | Float | Float | jfloat |
bool | Boolean | Boolean | jboolean |
string | String | String | jstring |
bytes | ByteArray | ByteArray | jbyteArray |
handle | Long | Long | jlong |
StructName | Long | StructName | jlong |
EnumName (plain) | Int | EnumName | jint |
EnumName (rich) | Long | EnumName | jlong |
T? | T? | T? | jobject |
[i32] | IntArray | IntArray | jintArray |
[i64] | LongArray | LongArray | jlongArray |
[string] | Array<String> | Array<String> | jobjectArray |
iter<T> | Long (iterator handle) | Iterator<T> (lazy wrapper class) | jlong |
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: age, type: i32 }
functions:
- name: get_contact
params:
- { name: id, type: i32 }
return: Contact
- name: find_by_type
params:
- { name: contact_type, type: ContactType }
return: "[Contact]"
The Kotlin wrapper declares external fun entries inside a companion
object and loads the JNI library on first use. Function names are
lowerCamelCase with the module prefix stripped by default
(strip_module_prefix = false in [android] restores prefixed names).
Where a parameter or return value needs wrapping (enums, structs), the
external entry is a private ...Jni function with lowered types and a
public wrapper converts at the boundary. Struct returns come back as
handles and are wrapped in the struct class; [Contact] stays a
LongArray of handles:
package com.weaveffi
class WeaveFFI {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic private external fun getContactJni(id: Int): Long
@JvmStatic fun getContact(id: Int): Contact = Contact(getContactJni(id))
@JvmStatic private external fun findByTypeJni(contactType: Int): LongArray
@JvmStatic fun findByType(contactType: ContactType): LongArray = findByTypeJni(contactType.value)
}
}
Enums become Kotlin enum class with a fromValue factory:
enum class ContactType(val value: Int) {
Personal(0),
Work(1),
Other(2);
companion object {
fun fromValue(value: Int): ContactType = entries.first { it.value == value }
}
}
Structs are wrapped in a Kotlin class implementing Closeable, with a
finalize() safety net:
class Contact internal constructor(internal var handle: Long) : java.io.Closeable {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic external fun nativeCreate(name: String, age: Int): Long
@JvmStatic external fun nativeDestroy(handle: Long)
@JvmStatic external fun nativeGetName(handle: Long): String
@JvmStatic external fun nativeGetAge(handle: Long): Int
fun create(name: String, age: Int): Contact = Contact(nativeCreate(name, age))
}
val name: String get() = nativeGetName(handle)
val age: Int get() = nativeGetAge(handle)
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
protected fun finalize() {
close()
}
}
The JNI shims (weaveffi_jni.c) bridge each Kotlin external fun into
the C ABI and route errors through a shared throw_weaveffi_error
helper that throws the generic WeaveFFIException:
static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {
const char* msg = err->message ? err->message : "WeaveFFI error";
jclass exClass = (*env)->FindClass(env, "com/weaveffi/WeaveFFIException");
if (exClass != NULL) {
jmethodID ctor = (*env)->GetMethodID(env, exClass, "<init>", "(ILjava/lang/String;)V");
jstring jmsg = (*env)->NewStringUTF(env, msg);
jthrowable ex = (jthrowable)(*env)->NewObject(env, exClass, ctor, (jint)err->code, jmsg);
if (ex != NULL) { (*env)->Throw(env, ex); }
}
weaveffi_error_clear(err);
}
JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_getContactJni(JNIEnv* env, jclass clazz, jint id) {
weaveffi_error err = {0, NULL};
weaveffi_contacts_Contact* rv = weaveffi_contacts_get_contact((int32_t)id, &err);
if (err.code != 0) {
throw_weaveffi_error(env, &err);
return 0;
}
return (jlong)(intptr_t)rv;
}
The CMake file links the JNI shim against the generated C header:
cmake_minimum_required(VERSION 3.22)
project(weaveffi)
add_library(weaveffi SHARED weaveffi_jni.c)
target_include_directories(weaveffi PRIVATE ../../../../c)
Typed errors
Every generated file carries the generic
open class WeaveFFIException(val code: Int, message: String). A module’s
error domain adds a sealed exception hierarchy
named after the domain with the trailing Error stem replaced by
Exception (KvError becomes KvException), one nested class per code,
and a fromCode mapper. From the kvstore sample:
/** Generic WeaveFFI failure: panics, marshalling errors, and unknown codes. */
open class WeaveFFIException(val code: Int, message: String) : Exception(message)
/** Typed error domain `KvError` declared by module `kv`. */
sealed class KvException(code: Int, message: String) : WeaveFFIException(code, message) {
class KeyNotFound(message: String = "key not found") : KvException(1001, message)
class Expired(message: String = "entry expired") : KvException(1002, message)
class StoreFull(message: String = "store has reached capacity") : KvException(1003, message)
class IoError(message: String = "I/O failure") : KvException(1004, message)
companion object {
fun fromCode(code: Int, message: String): WeaveFFIException = when (code) {
1001 -> KeyNotFound(message)
1002 -> Expired(message)
1003 -> StoreFull(message)
1004 -> IoError(message)
else -> WeaveFFIException(code, message)
}
}
}
A callable with throws: true throws the matching subclass from its JNI
shim (a per-domain throw_weaveffi_kv_KvError helper resolves
com/weaveffi/KvException$KeyNotFound and friends by code); catch the
specific class, the sealed domain, or the generic base:
try {
store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null)
} catch (e: KvException.StoreFull) {
// typed case
} catch (e: KvException) {
// any kv domain error
}
A callable without throws keeps a plain signature; its only possible
failures are producer bugs (a panic or a marshalling failure), which
arrive as the generic WeaveFFIException. Unknown codes on the typed
path fall back to WeaveFFIException too.
Interfaces
An interfaces: entry becomes a Kotlin class holding a Long handle and
implementing java.io.Closeable, exactly like a struct wrapper. Its
members live on the class: constructors become companion factories (a
constructor named new becomes operator fun invoke, so ContactBook()
reads like a real constructor), methods are instance functions, statics
are companion functions, and close() calls the implicit destroy symbol.
From the kvstore sample’s Store (trimmed):
/** An embedded key-value store owning its entries */
class Store internal constructor(internal var handle: Long) : java.io.Closeable {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic private external fun nativeOpen(path: String): Long
@JvmStatic private external fun nativeDefaultCapacity(): Long
@JvmStatic private external fun nativeDelete(selfHandle: Long, key: String): Boolean
@JvmStatic private external fun nativeDestroy(handle: Long)
/** Open (or create) a store backed by the given filesystem path */
fun open(path: String): Store = Store(nativeOpen(path))
/** The largest number of live entries one store will hold */
fun defaultCapacity(): Long = nativeDefaultCapacity()
}
/** Remove the entry for the given key, returning true if it existed */
fun delete(key: String): Boolean = nativeDelete(handle, key)
/** Stream every key, optionally filtered by a prefix */
fun listKeys(prefix: String?): Iterator<String> = KvStoreListKeysIterator(nativeListKeys(handle, prefix))
/** Reclaim space asynchronously; returns the number of bytes reclaimed */
suspend fun compact(): Long = suspendCancellableCoroutine { cont ->
nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) })
}
/** Legacy single-shot put kept for compatibility */
@Deprecated("use put() with explicit kind")
fun legacyPut(key: String, value: ByteArray): Boolean = nativeLegacyPut(handle, key, value)
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
}
Store.open("/tmp/cache.kv").use { store ->
store.put("alpha", byteArrayOf(1), EntryKind.Volatile, null)
println(store.count())
}
The JNI externals pass the wrapper’s handle as the leading selfHandle
argument. An interface parameter elsewhere in the API takes the wrapper
class (WeaveFFI.getStats(store: Store) in the nested stats module);
an interface return wraps the new owned handle.
Rich (algebraic) enums
A rich (algebraic) enum, a sum type whose variants carry associated
data, lowers to an opaque object handle at the C ABI, exactly like a
struct, and shares the same ownership model as the struct wrappers above.
The Kotlin wrapper is a Closeable class holding a Long handle, with
one static factory per variant, a nested Tag discriminant enum class,
and per-variant field getters. (A plain C-style enum with no payloads
stays a Kotlin enum class backed by an Int; see above.)
For the shapes module’s Shape enum (Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }), the generator emits (abridged):
/** An algebraic shape (sum type with associated data) */
class Shape internal constructor(internal var handle: Long) : java.io.Closeable {
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic external fun nativeTag(handle: Long): Int
@JvmStatic external fun nativeDestroy(handle: Long)
@JvmStatic external fun nativeNewEmpty(): Long
@JvmStatic external fun nativeNewCircle(radius: Double): Long
@JvmStatic external fun nativeNewRectangle(width: Float, height: Float): Long
@JvmStatic external fun nativeNewLabeled(label: String, count: Byte): Long
@JvmStatic external fun nativeGetCircleRadius(handle: Long): Double
@JvmStatic external fun nativeGetLabeledCount(handle: Long): Byte
/** The empty shape */
fun empty(): Shape = Shape(nativeNewEmpty())
/** A circle with a radius */
fun circle(radius: Double): Shape = Shape(nativeNewCircle(radius))
/** An axis-aligned rectangle */
fun rectangle(width: Float, height: Float): Shape = Shape(nativeNewRectangle(width, height))
/** A labeled shape with a small count */
fun labeled(label: String, count: Byte): Shape = Shape(nativeNewLabeled(label, count))
}
enum class Tag(val value: Int) {
Empty(0),
Circle(1),
Rectangle(2),
Labeled(3);
companion object {
fun fromValue(value: Int): Tag = entries.first { it.value == value }
}
}
val tag: Tag get() = Tag.fromValue(nativeTag(handle))
/** Radius in points */
val circleRadius: Double get() = nativeGetCircleRadius(handle)
val labeledCount: Byte get() = nativeGetLabeledCount(handle)
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
protected fun finalize() {
close()
}
}
Each nativeNew* factory maps to a per-variant constructor
(weaveffi_shapes_Shape_<Variant>_new), nativeTag reads the
discriminant (weaveffi_shapes_Shape_tag), the nativeGet* getters read
one variant field (weaveffi_shapes_Shape_<Variant>_get_<field>), and
nativeDestroy frees the handle (weaveffi_shapes_Shape_destroy). The
JNI shims that back these external methods are named
Java_com_weaveffi_Shape_native*:
JNIEXPORT jlong JNICALL Java_com_weaveffi_Shape_nativeNewCircle(JNIEnv* env, jclass clazz, jdouble radius) {
weaveffi_error err = {0, NULL};
weaveffi_shapes_Shape* rv = weaveffi_shapes_Shape_Circle_new((double)radius, &err);
if (err.code != 0) {
throw_weaveffi_error(env, &err);
return 0;
}
return (jlong)(intptr_t)rv;
}
JNIEXPORT jint JNICALL Java_com_weaveffi_Shape_nativeTag(JNIEnv* env, jclass clazz, jlong handle) {
return (jint)weaveffi_shapes_Shape_tag((const weaveffi_shapes_Shape*)(intptr_t)handle);
}
JNIEXPORT void JNICALL Java_com_weaveffi_Shape_nativeDestroy(JNIEnv* env, jclass clazz, jlong handle) {
weaveffi_shapes_Shape_destroy((weaveffi_shapes_Shape*)(intptr_t)handle);
}
Free functions that take or return the enum pass the handle across the
boundary; on the WeaveFFI companion they are
describe(shape: Shape): String and
scale(shape: Shape, factor: Double): Shape:
Shape.circle(2.0).use { c ->
println(c.tag) // Tag.Circle
println(c.circleRadius) // 2.0
val bigger = WeaveFFI.scale(c, 3.0) // returns a new Shape
try {
println(WeaveFFI.describe(bigger))
} finally {
bigger.close()
}
}
Ownership: a Shape owns its native handle, so call close() (or use
use { ... }) on every Shape you construct or receive, including the
new Shape returned by scale. The finalize() safety net runs
during GC but is not a substitute for deterministic cleanup.
Build instructions
-
Install Android Studio (Giraffe or newer) plus the NDK.
-
Cross-compile the Rust cdylib for every Android ABI you support:
rustup target add aarch64-linux-android armv7-linux-androideabi \ x86_64-linux-android i686-linux-android export ANDROID_NDK_HOME=/path/to/ndk cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 \ build --release -p your_library -
Open
generated/androidin Android Studio, sync Gradle, and build the AAR (./gradlew :weaveffi:assemble). -
Add the resulting AAR as a dependency in your app module and ensure your
jniLibs/directory contains the Rust-built cdylib for each supported ABI.
Memory and ownership
- Struct and interface wrappers implement
Closeable; either call.close()explicitly or useuse { ... }. Thefinalize()safety net runs during GC but is not a substitute for deterministic cleanup. - Strings returned from JNI are fresh Java strings; the JNI shim frees
the underlying Rust pointer with
weaveffi_free_stringbefore returning. - Byte arrays returned from JNI are copied with
SetByteArrayRegion, then the Rust buffer is freed withweaveffi_free_bytes. - Returned string arrays and maps free each element with
weaveffi_free_stringafter copying, then release the array buffer (or both parallel key/value buffers) withweaveffi_free_bytes. - Optional values are passed as boxed wrappers (
Integer,Long,Double,Boolean); the JNI shim unboxes and forwards them to the C ABI. A returned boxed optional scalar is read and its box freed withweaveffi_free_bytes.
Async support
Async IDL functions (async: true) are exposed as Kotlin suspend fun
declarations built on suspendCancellableCoroutine. The public suspend
wrapper passes a WeaveContinuation (a small class with onSuccess /
onError methods) to a private external launcher; struct results
resume as raw handles and are re-wrapped after the await. From the
async-demo sample (WeaveFFI.kt):
@JvmStatic private external fun runTaskAsync(name: String, callback: Any)
@JvmStatic suspend fun runTask(name: String): TaskResult {
val raw: Long = suspendCancellableCoroutine { cont ->
runTaskAsync(name, WeaveContinuation(cont) { code, message -> TaskException.fromCode(code, message) })
}
return TaskResult(raw)
}
internal class WeaveContinuation<T>(
private val cont: kotlinx.coroutines.CancellableContinuation<T>,
private val mapError: (Int, String) -> Throwable
) {
@Suppress("UNCHECKED_CAST")
fun onSuccess(result: Any?) { cont.resume(result as T) }
fun onError(code: Int, message: String) { cont.resumeWithException(mapError(code, message)) }
}
run_task declares throws: true, so a failed suspend call resumes with
the typed TaskException; an async callable without throws maps its
(producer-bug-only) failures to the generic WeaveFFIException.
The JNI launcher allocates a per-call context holding the JavaVM and
a NewGlobalRef to the WeaveContinuation, then hands the C ABI a
completion callback. That callback attaches the producer’s thread to
the JVM if it is not already attached, calls onSuccess/onError,
deletes the global ref, frees the context exactly once, and detaches
the thread if it attached it:
typedef struct {
JavaVM* jvm;
jobject callback;
} weaveffi_jni_async_ctx;
JNIEXPORT void JNICALL Java_com_weaveffi_WeaveFFI_runTaskAsync(JNIEnv* env, jclass clazz, jstring name, jobject callback) {
weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)malloc(sizeof(weaveffi_jni_async_ctx));
(*env)->GetJavaVM(env, &ctx->jvm);
ctx->callback = (*env)->NewGlobalRef(env, callback);
const char* name_chars = (*env)->GetStringUTFChars(env, name, NULL);
weaveffi_tasks_run_task_async(name_chars, weaveffi_tasks_run_task_jni_cb, ctx);
(*env)->ReleaseStringUTFChars(env, name, name_chars);
}
static void weaveffi_tasks_run_task_jni_cb(void* context, weaveffi_error* err, void* result) {
weaveffi_jni_async_ctx* ctx = (weaveffi_jni_async_ctx*)context;
JNIEnv* env = NULL;
int attached = 0;
if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) { free(ctx); return; }
attached = 1;
}
/* ... calls callback.onError(int, String) or callback.onSuccess(Object) ... */
weaveffi_jni_handle_uncaught(env);
(*env)->DeleteGlobalRef(env, ctx->callback);
JavaVM* jvm = ctx->jvm;
free(ctx);
if (attached) (*jvm)->DetachCurrentThread(jvm);
}
The completion callback fires exactly once, on a producer thread.
Result buffers passed to it (strings, byte arrays, arrays) are
borrowed from the producer for the callback’s duration, so the shim
copies them into Java objects (NewStringUTF, SetByteArrayRegion)
inside the callback and never frees them. Owned-object results are the
exception: the callback receives ownership, resumes the continuation
with the raw handle, and the suspend wrapper adopts it into the
wrapper class (TaskResult(raw) above). An exception thrown by the
resumed coroutine goes through the same
weaveffi_jni_handle_uncaught path as listener exceptions (see
Callbacks and listeners).
The generated build.gradle does not declare a coroutines dependency;
add org.jetbrains.kotlinx:kotlinx-coroutines-android (or -core) to
the consuming project.
For callables marked cancellable: true, the C ABI takes an extra
weaveffi_cancel_token* parameter. The private external launcher
carries it as cancelToken: Long and the shim casts it to
weaveffi_cancel_token*, but the public suspend wrapper currently
passes 0L (no token); coroutine cancellation isn’t wired to the
native cancel token. From the kvstore sample’s async method
Store.compact:
@JvmStatic private external fun nativeCompactAsync(selfHandle: Long, cancelToken: Long, callback: Any)
suspend fun compact(): Long = suspendCancellableCoroutine { cont ->
nativeCompactAsync(handle, 0L, WeaveContinuation(cont) { code, message -> KvException.fromCode(code, message) })
}
Callbacks and listeners
IDL callbacks paired with listeners produce a register/unregister
pair. From the events sample:
modules:
- name: events
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
The Kotlin surface takes a lambda and returns a Long subscription id;
pass that id back to unregister:
@JvmStatic external fun registerMessageListener(callback: (String) -> Unit): Long
@JvmStatic external fun unregisterMessageListener(id: Long)
The JNI shim keeps the lambda alive with a NewGlobalRef stored in a
mutex-guarded registry (a linked list of contexts holding the JavaVM,
the global ref, and the subscription id). When the producer fires, a C
trampoline attaches the producer’s thread to the JVM if needed and
invokes the lambda through its kotlin.jvm.functions.Function1
invoke(Object): Object method; unregistering removes the registry
entry, deletes the global ref, and frees the context:
static void weaveffi_events_OnMessage_fn_jni_tramp(const char* message, void* context) {
weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)context;
JNIEnv* env = NULL;
int attached = 0;
if ((*ctx->jvm)->GetEnv(ctx->jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) {
if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, (void**)&env, NULL) != JNI_OK) return;
attached = 1;
}
if ((*env)->PushLocalFrame(env, 32) != 0) {
if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
return;
}
jobject _a0 = message ? (jobject)(*env)->NewStringUTF(env, message) : (jobject)(*env)->NewStringUTF(env, "");
jclass fn_cls = (*env)->GetObjectClass(env, ctx->callback);
jmethodID invoke = (*env)->GetMethodID(env, fn_cls, "invoke", "(Ljava/lang/Object;)Ljava/lang/Object;");
(*env)->CallObjectMethod(env, ctx->callback, invoke, _a0);
weaveffi_jni_handle_uncaught(env);
(*env)->PopLocalFrame(env, NULL);
if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
}
JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_registerMessageListener(JNIEnv* env, jclass clazz, jobject callback) {
weaveffi_jni_listener_ctx* ctx = (weaveffi_jni_listener_ctx*)calloc(1, sizeof(weaveffi_jni_listener_ctx));
(*env)->GetJavaVM(env, &ctx->jvm);
ctx->callback = (*env)->NewGlobalRef(env, callback);
uint64_t id = weaveffi_events_register_message_listener(weaveffi_events_OnMessage_fn_jni_tramp, ctx);
/* ... stores ctx in the registry under id ... */
return (jlong)id;
}
The callback runs on the producer’s thread, whichever thread the
native side fires the event from. For UI work, hop to the main thread
yourself (e.g. withContext(Dispatchers.Main) or Handler.post).
An exception thrown by the Kotlin callback has no caller to propagate
to, since the frame below it is native producer code. The glue routes
it through weaveffi_jni_handle_uncaught, which delivers it to the
handler installed on the module companion:
/**
* Installs a handler for exceptions thrown by listener callbacks and
* async continuations on native producer threads. These exceptions have
* no Kotlin caller to propagate to; when no handler is installed, they
* are logged with their stack trace and dropped. Pass `null` to
* restore the default logging behavior.
*/
@JvmStatic fun setCallbackExceptionHandler(handler: ((Throwable) -> Unit)?) {
callbackExceptionHandler = handler
}
When a module declares listeners or async functions, the JNI glue also
defines JNI_OnLoad, which caches a global reference to the wrapper
class and the dispatchCallbackException method id so the producer
thread can deliver exceptions without an extra class lookup.
Iterators
iter<T> returns surface as Iterator<T> in Kotlin, backed by a
generated per-function wrapper class that is fully lazy: the external
launcher returns the raw iterator handle as a Long, and each
hasNext() lookahead issues exactly one nativeNext call, which maps
to one producer _next call. Nothing is drained into a hidden list.
From the events sample (get_messages returns iter<string>):
@JvmStatic private external fun getMessagesJni(): Long
@JvmStatic fun getMessages(): Iterator<String> = EventsGetMessagesIterator(getMessagesJni())
/**
* A lazy iterator over the `String` elements streamed by [getMessages]. Each step pulls
* exactly one element from the native producer. The native handle is
* released when the producer is exhausted, when [close] is called, or by
* the finalizer if the iterator is abandoned, whichever comes first.
*/
class EventsGetMessagesIterator internal constructor(private var handle: Long) : Iterator<String>, java.io.Closeable {
private var nextSlot: Array<Any?>? = null
override fun hasNext(): Boolean {
if (nextSlot != null) return true
if (handle == 0L) return false
val slot = nativeNext(handle)
if (slot == null) {
close()
return false
}
nextSlot = slot
return true
}
override fun next(): String {
if (!hasNext()) throw NoSuchElementException()
val raw = nextSlot!![0]
nextSlot = null
return raw as String
}
override fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
protected fun finalize() {
close()
}
companion object {
init { System.loadLibrary("weaveffi") }
@JvmStatic private external fun nativeNext(handle: Long): Array<Any?>?
@JvmStatic private external fun nativeDestroy(handle: Long)
}
}
The JNI nativeNext shim pulls one element and returns it in a
one-slot Object[]; null means the stream is exhausted. Each string
element is freed with weaveffi_free_string right after NewStringUTF
copies it; when the element type is a struct, the raw handle is
returned instead and the Kotlin next() adopts it into the owning
wrapper class (Contact(raw as Long)), whose close() eventually
destroys it:
JNIEXPORT jobjectArray JNICALL Java_com_weaveffi_EventsGetMessagesIterator_nativeNext(JNIEnv* env, jclass clazz, jlong handle) {
weaveffi_events_GetMessagesIterator* _iter = (weaveffi_events_GetMessagesIterator*)(intptr_t)handle;
const char* _item = (const char*)0;
weaveffi_error err = {0, NULL};
int32_t _has = weaveffi_events_GetMessagesIterator_next(_iter, &_item, &err);
if (err.code != 0) {
throw_weaveffi_error(env, &err);
return NULL;
}
if (_has == 0) { return NULL; }
jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, "");
weaveffi_free_string(_item);
jclass _obj_cls = (*env)->FindClass(env, "java/lang/Object");
jobjectArray _slot = (*env)->NewObjectArray(env, 1, _obj_cls, NULL);
(*env)->SetObjectArrayElement(env, _slot, 0, _jitem);
return _slot;
}
The native handle is destroyed exactly once: close() is called
eagerly when hasNext() sees exhaustion, callers can call close()
themselves (the class implements Closeable) when abandoning
iteration early, and the finalize() safety net covers abandoned
iterators during GC. Nulling the handle makes a double destroy
impossible.
Errors from the launcher and from each next follow the function’s
error strategy: the throwing kvstore sample’s Store.listKeys shim
throws the typed domain exception (throw_weaveffi_kv_KvError, so
KvException.KeyNotFound and friends) from the step that failed,
while the non-throwing getMessages throws the generic
WeaveFFIException only for producer bugs.
Troubleshooting
UnsatisfiedLinkError: Couldn't find libweaveffi.so: the Rust-built cdylib was not packaged inside the AAR. Place it undersrc/main/jniLibs/<abi>/and rebuild.UnsatisfiedLinkErrorfor the JNI symbol itself: Kotlin external function names must match the JNI signature, including the_1escape for underscores. Re-runweaveffi generateif you hand-edited either side.- Crashes when releasing strings: the JNI shim is responsible for
calling
ReleaseStringUTFCharson everyGetStringUTFChars. If you edit the shim, keep the pairing intact. - R8/ProGuard removes
WeaveFFIsymbols: keep the wrapper class with-keep class com.weaveffi.** { *; }in your ProGuard rules.
C
Overview
The C target emits the canonical C header and a thin reference C file that every other WeaveFFI target ultimately speaks to. All cross-language bindings sit on top of these symbols, so the C output is also the easiest way to inspect what the IDL compiles to.
What gets generated
| File | Purpose |
|---|---|
generated/c/weaveffi.h | Public header: opaque types, enums, interfaces, function prototypes, error/memory helpers |
generated/c/weaveffi.c | Default weaveffi_alloc/weaveffi_dealloc implementations (used by the Wasm JS glue); producers that ship their own allocator can omit it |
Type mapping
| IDL type | C parameter type | C return type |
|---|---|---|
i32 | int32_t | int32_t |
u32 | uint32_t | uint32_t |
i64 | int64_t | int64_t |
u64 | uint64_t | uint64_t |
i8 | int8_t | int8_t |
i16 | int16_t | int16_t |
u8 | uint8_t | uint8_t |
u16 | uint16_t | uint16_t |
f32 | float | float |
f64 | double | double |
bool | bool | bool |
string | const char* (NUL-terminated UTF-8) | const char* |
bytes | const uint8_t* ptr, size_t len | const uint8_t* + size_t* out_len |
handle | weaveffi_handle_t | weaveffi_handle_t |
Struct | const weaveffi_m_S* | weaveffi_m_S* |
Interface | const weaveffi_m_I* (borrowed) | weaveffi_m_I* (owned) |
Enum (plain) | weaveffi_m_E | weaveffi_m_E |
Enum (rich) | const weaveffi_m_E* | weaveffi_m_E* |
T? (value) | const T* (NULL = absent) | T* (NULL = absent) |
[T] | const T* items, size_t items_len | T* + size_t* out_len |
iter<T> | n/a | opaque iterator handle (see Iterators) |
C ABI symbol naming follows a strict convention:
| Kind | Pattern | Example |
|---|---|---|
| Function | weaveffi_{module}_{function} | weaveffi_contacts_create_contact |
| Struct type | weaveffi_{module}_{Struct} | weaveffi_contacts_Contact |
| Struct create | weaveffi_{module}_{Struct}_create | weaveffi_contacts_Contact_create |
| Struct destroy | weaveffi_{module}_{Struct}_destroy | weaveffi_contacts_Contact_destroy |
| Struct getter | weaveffi_{module}_{Struct}_get_{field} | weaveffi_contacts_Contact_get_name |
| Enum type | weaveffi_{module}_{Enum} | weaveffi_contacts_ContactType |
| Enum variant | weaveffi_{module}_{Enum}_{Variant} | weaveffi_contacts_ContactType_Personal |
| Interface type | weaveffi_{module}_{Interface} | weaveffi_kv_Store |
| Interface member | weaveffi_{module}_{Interface}_{member} | weaveffi_kv_Store_open |
| Interface destroy | weaveffi_{module}_{Interface}_destroy | weaveffi_kv_Store_destroy |
| Error enum | weaveffi_{module}_{Domain} | weaveffi_kv_KvError |
| Error constant | weaveffi_{module}_{Domain}_{Code} | weaveffi_kv_KvError_KeyNotFound |
| Callback typedef | weaveffi_{module}_{Callback}_fn | weaveffi_events_OnMessage_fn |
| Listener register | weaveffi_{module}_register_{listener} | weaveffi_events_register_message_listener |
| Listener unregister | weaveffi_{module}_unregister_{listener} | weaveffi_events_unregister_message_listener |
| Async callback | weaveffi_{module}_{function}_callback | weaveffi_tasks_run_task_callback |
| Async launcher | weaveffi_{module}_{function}_async | weaveffi_tasks_run_task_async |
| Iterator type | weaveffi_{module}_{Function}Iterator | weaveffi_events_GetMessagesIterator |
| Iterator next | weaveffi_{module}_{Function}Iterator_next | weaveffi_events_GetMessagesIterator_next |
| Iterator destroy | weaveffi_{module}_{Function}Iterator_destroy | weaveffi_events_GetMessagesIterator_destroy |
{Function} is the function name converted to PascalCase
(get_messages → GetMessages). An iterator returned by an interface
method nests under the interface instead:
weaveffi_kv_Store_ListKeysIterator. Interface members and async
launchers compose the same way (weaveffi_kv_Store_compact_async).
When the IDL sets c_prefix, every symbol, including the runtime
helpers, is rewritten with the new prefix.
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
functions:
- name: create_contact
params:
- { name: first_name, type: string }
- { name: last_name, type: string }
return: Contact
- name: find_contact
params:
- { name: id, type: "i32?" }
return: "Contact?"
- name: list_contacts
params: []
return: "[Contact]"
- name: count_contacts
params: []
return: i32
The header opens with an include guard, standard headers, an
extern "C" block, and the shared error/memory helpers:
#ifndef WEAVEFFI_H
#define WEAVEFFI_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef uint64_t weaveffi_handle_t;
typedef struct weaveffi_error {
int32_t code;
const char* message;
} weaveffi_error;
void weaveffi_error_clear(weaveffi_error* err);
void weaveffi_free_string(const char* ptr);
void weaveffi_free_bytes(uint8_t* ptr, size_t len);
In the real output each prototype is prefixed with a WEAVEFFI_API visibility
macro (and deprecated functions with WEAVEFFI_DEPRECATED), omitted here for
brevity. See Symbol visibility for what it does and when
you need it.
Structs become forward-declared opaque typedefs reached via create/destroy/getter functions:
typedef struct weaveffi_contacts_Contact weaveffi_contacts_Contact;
weaveffi_contacts_Contact* weaveffi_contacts_Contact_create(
const char* name,
const char* email,
int32_t age,
weaveffi_error* out_err);
void weaveffi_contacts_Contact_destroy(weaveffi_contacts_Contact* ptr);
const char* weaveffi_contacts_Contact_get_name(
const weaveffi_contacts_Contact* ptr);
Enums turn into typed enum declarations with prefixed variants:
typedef enum {
weaveffi_contacts_ContactType_Personal = 0,
weaveffi_contacts_ContactType_Work = 1,
weaveffi_contacts_ContactType_Other = 2
} weaveffi_contacts_ContactType;
Optionals and lists use pointer-with-sentinel and pointer+length pairs:
int32_t* weaveffi_store_find(const int32_t* id, weaveffi_error* out_err);
weaveffi_contacts_Contact** weaveffi_contacts_list_contacts(
size_t* out_len,
weaveffi_error* out_err);
Every function takes a trailing weaveffi_error* out_err. On failure
out_err->code is non-zero and out_err->message points at a
Rust-allocated string the consumer must clear:
weaveffi_error err = {0, NULL};
int32_t total = weaveffi_contacts_count_contacts(&err);
if (err.code != 0) {
fprintf(stderr, "Error %d: %s\n", err.code, err.message);
weaveffi_error_clear(&err);
return 1;
}
Interfaces
An interfaces: entry lowers to a forward-declared opaque struct plus one
prototype per member. Constructors return an owned pointer, methods take a
leading const {tag}* self argument before their declared parameters,
statics take no self, and every interface gets an implicit _destroy.
From the kvstore sample’s Store:
typedef struct weaveffi_kv_Store weaveffi_kv_Store;
/* Constructor: returns a new owned instance. */
weaveffi_kv_Store* weaveffi_kv_Store_open(const char* path, weaveffi_error* out_err);
/* Static: no self slot. */
int64_t weaveffi_kv_Store_default_capacity(weaveffi_error* out_err);
/* Methods: an implicit leading self slot. */
bool weaveffi_kv_Store_delete(const weaveffi_kv_Store* self, const char* key,
weaveffi_error* out_err);
int64_t weaveffi_kv_Store_count(const weaveffi_kv_Store* self, weaveffi_error* out_err);
/* Implicit destructor: releases the object. */
void weaveffi_kv_Store_destroy(weaveffi_kv_Store* self);
Ownership follows the reference direction: an interface parameter (such as
const weaveffi_kv_Store* store on weaveffi_kv_stats_get_stats) is
borrowed for the duration of the call, while every pointer returned by a
constructor or function is owned by the consumer, who must eventually pass
it to _destroy. Iterator-returning and async methods follow the same
shapes as free functions with the self slot in front: Store.list_keys
yields a weaveffi_kv_Store_ListKeysIterator handle, and the async
Store.compact appears under Async support.
Typed errors
C is the raw ABI surface, so throwing and non-throwing callables look
identical: every prototype carries the trailing weaveffi_error* out_err,
and the consumer checks err.code after each call. What a module’s error
domain adds is a typed C enum naming the codes its throws: true callables
can report, so consumers match on names instead of magic numbers. From the
kvstore sample’s KvError domain:
/** Error codes reported by throwing functions in the `kv` module tree. */
typedef enum {
weaveffi_kv_KvError_KeyNotFound = 1001,
weaveffi_kv_KvError_Expired = 1002,
weaveffi_kv_KvError_StoreFull = 1003,
weaveffi_kv_KvError_IoError = 1004
} weaveffi_kv_KvError;
A callable declared with throws: true can set any of these codes; a
callable without throws can only fail with the reserved codes (-2 for a
producer panic, 1 for a marshalling failure). See the
Error Handling guide for the full code table.
Symbol visibility
Every function prototype is tagged with a WEAVEFFI_API macro that the header
defines near the top:
#ifndef WEAVEFFI_API
# if defined(_WIN32) || defined(__CYGWIN__)
# ifdef WEAVEFFI_BUILD
# define WEAVEFFI_API __declspec(dllexport)
# else
# define WEAVEFFI_API __declspec(dllimport)
# endif
# elif defined(__GNUC__) && (__GNUC__ >= 4)
# define WEAVEFFI_API __attribute__((visibility("default")))
# else
# define WEAVEFFI_API
# endif
#endif
This covers the two ways the header is used:
- Consuming a prebuilt library (the common case) needs nothing extra. On
Windows the prototypes resolve to
__declspec(dllimport); everywhere else the macro is harmless. - Implementing the header (a C, C++, or Zig backend that supplies the
symbols instead of calling them) relies on the macro to stay exportable. Under
hidden default visibility (
-fvisibility=hidden, the release-build norm and the MSVC default) an untagged definition is local and ships no usable symbol. On GCC and Clang the macro appliesvisibility("default"), so your definitions export with no extra flags.
When you implement the header on Windows, compile your library with
WEAVEFFI_BUILD defined so the macro switches to __declspec(dllexport):
cc -DWEAVEFFI_BUILD -shared mylib.c -o mylib.dll
Deprecated functions carry a companion WEAVEFFI_DEPRECATED("...") macro that
expands to __declspec(deprecated(...)) on MSVC and
__attribute__((deprecated(...))) on GCC and Clang.
When the IDL sets c_prefix, both macros follow it: a c_prefix of acme
yields ACME_API, ACME_BUILD, and ACME_DEPRECATED, so two
WeaveFFI-generated libraries can coexist in one translation unit without
colliding.
Rich (algebraic) enums
An enum whose variants declare fields is a rich (algebraic) enum, a sum
type with associated data. Unlike a plain C-style enum (a bare int32_t
discriminant), a rich enum crosses the ABI as an opaque object pointer,
exactly like a struct: the producer owns the payload and the consumer holds a
handle. A plain _Tag enum names the discriminants, then constructors, a tag
reader, per-variant getters, and a destructor operate on the handle. From the
shapes sample (Shape = Empty | Circle{radius} | Rectangle{width,height} | Labeled{label,count}):
typedef enum {
weaveffi_shapes_Shape_Empty = 0,
weaveffi_shapes_Shape_Circle = 1,
weaveffi_shapes_Shape_Rectangle = 2,
weaveffi_shapes_Shape_Labeled = 3
} weaveffi_shapes_Shape_Tag;
typedef struct weaveffi_shapes_Shape weaveffi_shapes_Shape;
int32_t weaveffi_shapes_Shape_tag(const weaveffi_shapes_Shape* self);
weaveffi_shapes_Shape* weaveffi_shapes_Shape_Empty_new(weaveffi_error* out_err);
weaveffi_shapes_Shape* weaveffi_shapes_Shape_Circle_new(double radius, weaveffi_error* out_err);
weaveffi_shapes_Shape* weaveffi_shapes_Shape_Rectangle_new(float width, float height, weaveffi_error* out_err);
weaveffi_shapes_Shape* weaveffi_shapes_Shape_Labeled_new(const char* label, uint8_t count, weaveffi_error* out_err);
double weaveffi_shapes_Shape_Circle_get_radius(const weaveffi_shapes_Shape* self);
float weaveffi_shapes_Shape_Rectangle_get_width(const weaveffi_shapes_Shape* self);
float weaveffi_shapes_Shape_Rectangle_get_height(const weaveffi_shapes_Shape* self);
const char* weaveffi_shapes_Shape_Labeled_get_label(const weaveffi_shapes_Shape* self);
uint8_t weaveffi_shapes_Shape_Labeled_get_count(const weaveffi_shapes_Shape* self);
void weaveffi_shapes_Shape_destroy(weaveffi_shapes_Shape* self);
Read _tag, then call only the matching variant’s getters. A getter that
returns a const char* hands back Rust-owned memory to free with
weaveffi_free_string:
weaveffi_error err = {0, NULL};
weaveffi_shapes_Shape* shape = weaveffi_shapes_Shape_Circle_new(2.0, &err);
if (weaveffi_shapes_Shape_tag(shape) == weaveffi_shapes_Shape_Circle) {
printf("radius = %f\n", weaveffi_shapes_Shape_Circle_get_radius(shape));
}
const char* text = weaveffi_shapes_describe(shape, &err);
printf("%s\n", text);
weaveffi_free_string(text);
weaveffi_shapes_Shape_destroy(shape);
The consumer owns every weaveffi_shapes_Shape* returned by a constructor or by
a function such as weaveffi_shapes_scale; release each one with
weaveffi_shapes_Shape_destroy.
Build instructions
The runnable consumer uses the contacts sample crate and its
conformance program.
macOS:
cargo build -p contacts
weaveffi generate samples/contacts/contacts.yml -o generated
cc -I generated/c conformance/c/contacts.c -L target/debug -lcontacts -o c_contacts
DYLD_LIBRARY_PATH=target/debug ./c_contacts
Linux:
cargo build -p contacts
weaveffi generate samples/contacts/contacts.yml -o generated
cc -I generated/c conformance/c/contacts.c -L target/debug -lcontacts -o c_contacts
LD_LIBRARY_PATH=target/debug ./c_contacts
Windows:
cargo build -p contacts
weaveffi generate samples\contacts\contacts.yml -o generated
cl /I generated\c conformance\c\contacts.c /link contacts.lib
.\contacts.exe
See conformance/c/ for end-to-end consumers of every sample.
Memory and ownership
Rust always owns memory it allocates. Strings and byte buffers returned across the boundary must be freed by the consumer with the matching helper:
const char* name = weaveffi_contacts_Contact_get_name(contact);
printf("Name: %s\n", name);
weaveffi_free_string(name);
size_t len;
const uint8_t* data = weaveffi_storage_get_data(&len, &err);
weaveffi_free_bytes((uint8_t*)data, len);
For struct handles, call the matching _destroy symbol when the
consumer is done. Borrowed parameters (const T*, string/bytes
inputs) remain owned by the caller for the duration of the call only.
Callbacks and listeners
A callbacks: entry becomes a function-pointer typedef whose
parameters mirror the IDL signature plus a trailing opaque
void* context. A listeners: entry becomes a register/unregister
pair built on that typedef. From the events sample:
typedef void (*weaveffi_events_OnMessage_fn)(const char* message, void* context);
uint64_t weaveffi_events_register_message_listener(
weaveffi_events_OnMessage_fn callback,
void* context);
void weaveffi_events_unregister_message_listener(uint64_t id);
The contract:
register_*stores the(callback, context)pair and returns auint64_tsubscription id. Pass that id tounregister_*to stop delivery.contextis opaque to the producer and is passed back verbatim as the last argument of every invocation. It must stay valid until the listener is unregistered.- The producer invokes the callback on its own thread, whenever the event fires. The callback must be thread-safe and must not assume it runs on the registering thread.
- Pointer arguments (e.g.
const char* message) are only valid for the duration of the invocation; copy anything that must outlive it.
static void on_message(const char* message, void* context) {
int* count = context; /* runs on the producer's thread */
(*count)++;
}
weaveffi_error err = {0, NULL};
int count = 0;
uint64_t id = weaveffi_events_register_message_listener(on_message, &count);
weaveffi_events_send_message("hello", &err); /* fires the listener */
weaveffi_events_unregister_message_listener(id);
Async support
Async functions (async: true) get no synchronous prototype. Each one
emits a per-function callback typedef, (void* context, weaveffi_error* err, <result slots>), and a launcher with the
_async suffix. From the async-demo sample:
typedef void (*weaveffi_tasks_run_task_callback)(
void* context,
weaveffi_error* err,
weaveffi_tasks_TaskResult* result);
void weaveffi_tasks_run_task_async(
const char* name,
weaveffi_tasks_run_task_callback callback,
void* context);
The launcher returns immediately; WeaveFFI invokes the callback exactly once, with either a result or a populated error, from the producer’s worker thread.
Ownership inside the callback follows the async contract. Result
buffers (strings, bytes, arrays, map buffers, boxed optional scalars)
are borrowed: they stay owned by the producer and are valid only for
the callback’s duration, so copy anything you need before returning
and don’t free them. Owned-object results (records, rich enums,
interfaces, including optional ones) are the exception: the callback
receives ownership of the pointer and must eventually pass it to the
matching _destroy. The err struct is likewise borrowed; copy its
code and message inside the callback.
For cancellable: true functions the launcher gains a
weaveffi_cancel_token* slot before the callback, and the runtime
provides the token lifecycle. Async interface methods follow the same
shape with the leading self slot; from the kvstore sample’s async
cancellable Store.compact:
typedef void (*weaveffi_kv_Store_compact_callback)(
void* context,
weaveffi_error* err,
int64_t result);
void weaveffi_kv_Store_compact_async(
const weaveffi_kv_Store* self,
weaveffi_cancel_token* cancel_token,
weaveffi_kv_Store_compact_callback callback,
void* context);
weaveffi_cancel_token* weaveffi_cancel_token_create(void);
void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token);
bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token);
void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token);
See Async functions for the full pattern.
Iterators
Functions returning iter<T> produce an opaque iterator handle plus
_next/_destroy functions instead of a materialized list. From the
events sample (get_messages returns iter<string>):
typedef struct weaveffi_events_GetMessagesIterator weaveffi_events_GetMessagesIterator;
weaveffi_events_GetMessagesIterator* weaveffi_events_get_messages(
weaveffi_error* out_err);
int32_t weaveffi_events_GetMessagesIterator_next(
weaveffi_events_GetMessagesIterator* iter,
const char** out_item,
weaveffi_error* out_err);
void weaveffi_events_GetMessagesIterator_destroy(
weaveffi_events_GetMessagesIterator* iter);
_next writes the next element into the one-slot out-param and
returns 1, or returns 0 when exhausted (leaving *out_item
untouched). Failures are reported through out_err, so check it after
the loop ends. Element ownership follows the usual return rules; each
next hands over an element the consumer now owns, so here each
const char* must be freed with weaveffi_free_string. Call
_destroy exactly once when done, even if iteration stopped early:
weaveffi_error err = {0, NULL};
weaveffi_events_GetMessagesIterator* iter = weaveffi_events_get_messages(&err);
const char* item = NULL;
while (weaveffi_events_GetMessagesIterator_next(iter, &item, &err) == 1) {
printf("%s\n", item);
weaveffi_free_string(item);
}
if (err.code != 0) { /* a failing step ended the loop */ }
weaveffi_events_GetMessagesIterator_destroy(iter);
The higher-level targets wrap exactly these three symbols in their native lazy idioms; only the C surface exposes them raw.
Troubleshooting
undefined reference to weaveffi_*: make sure the linker sees the cdylib (-L target/debug -l<your-crate>). The header alone is not enough.- Crashes inside
weaveffi_free_string: the pointer wasn’t Rust-allocated. Only free pointers returned from a generated getter or function. error: unknown type weaveffi_handle_t: the consumer included the header without<stdint.h>. Include order matters; the generated header pulls in the standard integer typedefs explicitly.weaveffi.clooks nearly empty: that file only carries the defaultweaveffi_alloc/weaveffi_deallocimplementations for Wasm producers. All declarations live inweaveffi.h.
Node.js
Overview
The Node.js target produces a CommonJS loader, TypeScript type
definitions, and the complete N-API addon C source (plus a
binding.gyp) that bridges JS to the C ABI. The loader honors a
WEAVEFFI_ADDON environment override, then prefers the node-gyp build
output (./build/Release/weaveffi.node), and falls back to a prebuilt
binary placed next to it as index.node. On top of the raw native
bindings it layers the idiomatic wrappers: error classes, interface and
rich-enum classes, and camelCased function wrappers.
What gets generated
| File | Purpose |
|---|---|
generated/node/index.js | CommonJS loader: tries ./build/Release/weaveffi.node, falls back to ./index.node |
generated/node/types.d.ts | TypeScript declarations for the public surface |
generated/node/weaveffi_addon.c | N-API addon source: marshaling, promises, threadsafe functions |
generated/node/binding.gyp | node-gyp build file (includes ../c, links -lweaveffi) |
generated/node/package.json | npm package metadata (main, types, gypfile, install script) |
Type mapping
| IDL type | TypeScript type |
|---|---|
i32 | number |
u32 | number |
i8 | number |
i16 | number |
u8 | number |
u16 | number |
i64 | number |
u64 | number |
f64 | number |
f32 | number |
bool | boolean |
string | string |
bytes | Buffer |
handle | bigint |
StructName | StructName |
EnumName (plain, C-style) | enum EnumName |
EnumName (rich / algebraic) | wrapper class (e.g. Shape) |
T? | T | null |
[T] | T[] |
{K: V} | Record<K, V> |
iter<T> | IterableIterator<T> (lazy) |
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: Color
variants:
- { name: Red, value: 0 }
- { name: Green, value: 1 }
- { name: Blue, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: tags, type: "[string]" }
functions:
- name: get_contact
params:
- { name: id, type: i32 }
return: "Contact?"
- name: list_contacts
params: []
return: "[Contact]"
- name: set_favorite_color
params:
- { name: contact_id, type: i32 }
- { name: color, type: "Color?" }
- name: get_tags
params:
- { name: contact_id, type: i32 }
return: "[string]"
Structs become TypeScript interfaces and enums become explicit numeric TypeScript enums:
export interface Contact {
name: string;
email: string | null;
tags: string[];
}
export enum Color {
Red = 0,
Green = 1,
Blue = 2,
}
Functions are exported flat in lowerCamelCase with the module prefix
stripped by default (strip_module_prefix = false in [node] restores
<module>_-prefixed names); parameters are camelCased too. Optional
return and parameter types use | null, arrays use T[]:
export function getContact(id: number): Contact | null
export function listContacts(): Contact[]
export function setFavoriteColor(contactId: number, color: Color | null): void
export function getTags(contactId: number): string[]
Typed errors
Every generated index.js exports WeaveFFIError (extending Error
with a numeric code and the raw errorMessage). A module’s error
domain adds a class named after the domain plus one subclass per code,
each carrying its stable CODE. From the kvstore sample:
class WeaveFFIError extends Error {
constructor(code, message) {
super('(' + code + ') ' + (message || ''));
this.name = 'WeaveFFIError';
this.code = code;
this.errorMessage = message || '';
}
}
class KvError extends WeaveFFIError { /* ... */ }
class KeyNotFoundError extends KvError {
constructor(message) {
super(1001, message || 'key not found');
this.name = 'KeyNotFoundError';
}
}
KeyNotFoundError.CODE = 1001;
// ExpiredError, StoreFullError, IoError follow the same shape.
A callable with throws: true rebrands any native failure through the
domain’s code map, so consumers catch the typed class:
try {
store.put('alpha', Buffer.from('1'), EntryKind.Volatile, null);
} catch (e) {
if (e instanceof StoreFullError) {
// typed case; e.code === 1003
} else if (e instanceof KvError) {
// any kv domain error
}
}
A callable without throws has the same JS signature (JavaScript has no
checked exceptions), but its failures can only be producer bugs, which
surface as the generic WeaveFFIError. Unknown codes on the typed path
fall back to WeaveFFIError as well.
Interfaces
An interfaces: entry becomes a JS class owning the native pointer,
registered with a FinalizationRegistry and freed deterministically via
destroy(). Constructors become static factories, methods are instance
methods, statics are static methods, all camelCased. From the kvstore
sample’s Store (trimmed from index.js):
class Store {
static open(path) {
const _r = __invoke(addon.Store_open, [path], __kvErrorFrom);
return Store._fromHandle(_r);
}
put(key, value, kind, ttlSeconds) {
return __invoke(addon.Store_put, [this._handle, key, value, kind, ttlSeconds], __kvErrorFrom);
}
listKeys(prefix) {
const _it = __invoke(addon.Store_list_keys, [this._handle, prefix], __kvErrorFrom);
return new WeaveFFIIterator(_it, addon.Store_list_keys_iterNext, addon.Store_list_keys_iterDestroy, __kvErrorFrom, null);
}
count() {
return __invoke(addon.Store_count, [this._handle], __generic);
}
compact() {
return __invokeAsync(addon.Store_compact, [this._handle], __kvErrorFrom);
}
static defaultCapacity() {
return __invoke(addon.Store_default_capacity, [], __generic);
}
destroy() {
if (this._handle) {
Store._cleanup.unregister(this);
addon.Store_destroy(this._handle);
this._handle = 0;
}
}
}
Store._cleanup = new FinalizationRegistry((handle) => {
if (handle) { addon.Store_destroy(handle); }
});
The typed declarations mirror the class, with @throws and
@deprecated JSDoc tags:
export class Store {
/** @throws {KvError} */
static open(path: string): Store;
/** @throws {KvError} */
put(key: string, value: Buffer, kind: EntryKind, ttlSeconds: number | null): boolean;
/** @throws {KvError} */
listKeys(prefix: string | null): IterableIterator<string>;
count(): number;
/** @throws {KvError} */
compact(): Promise<number>;
static defaultCapacity(): number;
/** Free the underlying native object. */
destroy(): void;
}
A function elsewhere in the API that takes the interface accepts the
wrapper instance and unwraps its handle (getStats(store) in the nested
stats module); a function returning an interface wraps the new owned
handle in a fresh instance. Call destroy() when you’re done; the
FinalizationRegistry is only a GC-timed safety net.
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style enum stays a numeric TypeScript enum, but a rich
enum lowers to an opaque object handle at the C ABI, exactly like a
struct. The loader layers an idiomatic wrapper class on top of the raw
native bindings, and that class owns the native pointer.
Take a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }. The generated index.js builds
a Shape class with one static factory per variant, a tag()
discriminant reader, a camelCased getter per variant field, and a
destroy() method, backed by a FinalizationRegistry:
class Shape {
static empty() {
return new Shape(__invoke(addon.Shape_empty_new, [], __generic));
}
static circle(radius) {
return new Shape(__invoke(addon.Shape_circle_new, [radius], __generic));
}
static rectangle(width, height) {
return new Shape(__invoke(addon.Shape_rectangle_new, [width, height], __generic));
}
static labeled(label, count) {
return new Shape(__invoke(addon.Shape_labeled_new, [label, count], __generic));
}
tag() {
return addon.Shape_tag(this._handle);
}
get circleRadius() {
return addon.Shape_circle_get_radius(this._handle);
}
get rectangleWidth() {
return addon.Shape_rectangle_get_width(this._handle);
}
get rectangleHeight() {
return addon.Shape_rectangle_get_height(this._handle);
}
get labeledLabel() {
return addon.Shape_labeled_get_label(this._handle);
}
get labeledCount() {
return addon.Shape_labeled_get_count(this._handle);
}
destroy() {
if (this._handle) {
Shape._cleanup.unregister(this);
addon.Shape_destroy(this._handle);
this._handle = 0;
}
}
}
Shape._cleanup = new FinalizationRegistry((handle) => {
if (handle) { addon.Shape_destroy(handle); }
});
Shape.Tag = Object.freeze({ Empty: 0, Circle: 1, Rectangle: 2, Labeled: 3 });
The active variant is read with tag() and compared against the frozen
Shape.Tag map ({ Empty: 0, Circle: 1, Rectangle: 2, Labeled: 3 }).
Each variant field is a getter named <variant><Field>
(circleRadius, rectangleWidth, rectangleHeight, labeledLabel,
labeledCount), delegating to the matching native accessor (e.g.
addon.Shape_circle_get_radius(this._handle)). Free functions
that take or return the enum accept the wrapper directly:
describe(shape) unwraps shape._handle, and
scale(shape, factor) wraps its result back into a new Shape.
The generated types.d.ts types the wrapper as a real export class,
with the Shape.Tag constants in a companion namespace:
export class Shape {
static empty(): Shape;
static circle(radius: number): Shape;
static rectangle(width: number, height: number): Shape;
static labeled(label: string, count: number): Shape;
tag(): number;
get circleRadius(): number;
get rectangleWidth(): number;
get rectangleHeight(): number;
get labeledLabel(): string;
get labeledCount(): number;
destroy(): void;
}
export namespace Shape {
const Tag: Readonly<{
Empty: 0,
Circle: 1,
Rectangle: 2,
Labeled: 3,
}>;
}
A short round-trip that constructs a couple of variants, reads the tag and a
field, calls describe / scale, then releases the handles:
const { Shape, describe, scale } = require('./index.js');
const circle = Shape.circle(2.0);
const label = Shape.labeled('unit', 3);
if (circle.tag() === Shape.Tag.Circle) {
console.log(circle.circleRadius); // 2
}
console.log(describe(circle)); // native-rendered description
const bigger = scale(circle, 3.0); // a fresh Shape
// Done with the handles, release the native objects.
circle.destroy();
label.destroy();
bigger.destroy();
Ownership: each Shape owns its native object. Call destroy() when
you are finished to free it deterministically; if you forget, the
FinalizationRegistry calls the native destroy once the wrapper is
garbage-collected, but GC timing isn’t guaranteed, so prefer an
explicit destroy().
Build instructions
The generated addon is self-contained: run npm install (the install
script runs node-gyp rebuild on the generated binding.gyp) inside
generated/node/ with the generated C headers at ../c and the
producer cdylib on the linker path:
cargo build -p kvstore
weaveffi generate samples/kvstore/kvstore.yml -o generated
cd generated/node
npm install # builds build/Release/weaveffi.node
DYLD_LIBRARY_PATH=../../target/debug node -e "
const kv = require('./index.js');
const store = kv.Store.open('/tmp/cache.kv');
console.log(store.count());
"
(Use LD_LIBRARY_PATH on Linux.) Then publish the generated directory
as a private npm package or ship it inside your app. Copying a prebuilt
platform binary in as index.node also works, and the WEAVEFFI_ADDON
env var can point the loader at any built addon (the
conformance/node/ consumers use it; see conformance/run.sh).
Memory and ownership
- The N-API addon is responsible for all conversions between JS values and C ABI types. Strings and byte buffers are copied into JS-managed storage, so consumers never need to think about freeing memory.
- Struct values are returned as plain JS objects: the addon copies the fields out and destroys the native struct before the call returns, so there is nothing to dispose on the JS side.
- Interface and rich-enum wrappers own their native pointer; release it
with
destroy()(aFinalizationRegistrybackstops forgotten handles at GC time). - Typed handles (
handle<Struct>) pass through as opaque values; release them through the API’s own teardown function. iter<T>returns are lazy JS iterables; see Iterators. The native handle is released on exhaustion, on early exit, or by a finalizer as a backstop.- Errors from the C ABI are converted into JavaScript
Errorinstances by the addon, then rebranded into the typed error classes by the loader before bubbling up to the caller.
Async support
Async IDL functions are exposed as JS functions that return a Promise:
export function runTask(name: string): Promise<TaskResult>
The addon creates the promise with napi_create_promise and calls the
C ABI _async entry point, which runs the work on a native producer
thread. The promise is never settled from that thread: the completion
callback only stashes the result (or error) and posts it through a
napi_threadsafe_function whose settle callback runs on the JS event
loop and calls napi_resolve_deferred / napi_reject_deferred there:
static void weaveffi_tasks_run_task_napi_cb(void* context, weaveffi_error* err, weaveffi_tasks_TaskResult* result) {
weaveffi_tasks_run_task_napi_actx* ctx = (weaveffi_tasks_run_task_napi_actx*)context;
if (err != NULL && err->code != 0) {
ctx->err_code = err->code;
ctx->err_msg = err->message ? strdup(err->message) : strdup("unknown error");
} else {
ctx->result = (void*)result;
}
napi_call_threadsafe_function(ctx->tsfn, ctx, napi_tsfn_blocking);
}
The completion callback fires exactly once, on the producer thread.
Result buffers passed to it (strings, byte buffers, arrays) are
borrowed for the callback’s duration, so the callback deep-copies them
(note the strdup on the error message above) before returning and
never frees them. Owned-object results are the exception: the callback
receives ownership of the pointer (ctx->result = (void*)result
above) and the settle callback wraps it into the JS-side owner.
Rejected promises carry the C error message plus a numeric code
property; the loader rebrands the rejection into the module’s typed
error class when the callable declares throws: true (an async method
like Store.compact() rejects with KvError subclasses), and into the
generic WeaveFFIError otherwise. The settle callback releases the
threadsafe function once the promise is settled, so a pending async
call keeps the event loop alive until it completes.
For functions marked cancellable: true the addon passes NULL for
the C ABI’s cancel-token slot; the token is not surfaced to JS and
there is no AbortSignal parameter. Only the C, C++, and Kotlin
targets expose cancellation tokens.
Iterators
iter<T> returns are lazy: the addon hands back an opaque external
wrapping the native iterator handle, and the loader wraps it in a
shared WeaveFFIIterator class implementing the JS iterator protocol.
Nothing is drained up front; each next() issues exactly one native
_iterNext call. From the events sample’s index.js:
// Lazy iterator over a native producer: one native `next` per step.
// The native handle is released on exhaustion, by `return()` on early
// exit, or by the external's finalizer if the iterator is abandoned.
class WeaveFFIIterator {
next() {
if (this._done) {
return { done: true, value: undefined };
}
const _v = __invoke(this._nextFn, [this._ext], this._map);
if (_v === undefined) {
this._done = true;
return { done: true, value: undefined };
}
return { done: false, value: this._wrapElem ? this._wrapElem(_v) : _v };
}
return(value) {
if (!this._done) {
this._done = true;
this._destroyFn(this._ext);
}
return { done: true, value };
}
[Symbol.iterator]() {
return this;
}
}
wv.getMessages = function () {
const _it = __invoke(addon.getMessages, [], __generic);
return new WeaveFFIIterator(_it, addon.getMessages_iterNext, addon.getMessages_iterDestroy, __generic, null);
};
The TypeScript declaration is IterableIterator<T>, so a plain
for...of loop works and breaking out of it triggers return(),
which destroys the native iterator early. The addon’s _iterNext
binding pulls one element, copies it into a JS value (freeing the
native string), and destroys the iterator eagerly when the producer
reports exhaustion; the external’s N-API finalizer backstops abandoned
iterators at GC time, nulling the stored handle so a double destroy is
impossible. Struct elements are copied into plain JS objects (and the
native struct destroyed) per step; rich-enum elements arrive as owned
raw handles that the loader adopts into their wrapper class via the
wrapElem hook.
Errors from the launcher and each next step follow the function’s
error strategy: Store.listKeys (throws) rebrands a failing step
through __kvErrorFrom into the typed KvError subclasses, while
the non-throwing getMessages throws the generic WeaveFFIError
only for producer bugs.
Callbacks and listeners
An IDL listener becomes a register/unregister pair. Registration
takes a plain JS function and returns a numeric subscription id;
unregistration takes that id back:
export function registerMessageListener(callback: (message: string) => void): number
export function unregisterMessageListener(id: number): void
The id is the uint64 returned by the C ABI’s
weaveffi_events_register_message_listener(callback_fn, context); each
registration gets its own id and threadsafe function.
The native callback fires on the producer’s thread, and the addon never
calls into JS from there. Registration wraps the JS function in a
napi_threadsafe_function, and a C trampoline copies the payload and
queues it onto the JS event loop:
static void weaveffi_events_OnMessage_fn_napi_tramp(const char* message, void* context) {
weaveffi_napi_listener_ctx* ctx = (weaveffi_napi_listener_ctx*)context;
weaveffi_events_OnMessage_fn_payload* p = (weaveffi_events_OnMessage_fn_payload*)calloc(1, sizeof(weaveffi_events_OnMessage_fn_payload));
p->message = message ? strdup(message) : NULL;
napi_call_threadsafe_function(ctx->tsfn, p, napi_tsfn_nonblocking);
}
The threadsafe function is unref’d immediately after registration:
napi_create_threadsafe_function(env, args[0], NULL, resource_name, 0, 1, NULL, NULL, NULL, weaveffi_events_OnMessage_fn_napi_calljs, &ctx->tsfn);
napi_unref_threadsafe_function(env, ctx->tsfn);
uint64_t id = weaveffi_events_register_message_listener(weaveffi_events_OnMessage_fn_napi_tramp, ctx);
Threading caveats:
- The JS callback always runs on the JS thread; delivery is
asynchronous and the producer does not wait for it
(
napi_tsfn_nonblocking). - Because the threadsafe function is unref’d, a registered listener does not keep the process alive; the loop may exit with listeners still registered.
- Unregistering calls the C ABI unregister, releases the threadsafe function, and frees the listener context.
Troubleshooting
Error: Cannot find module './index.node': no addon binary was found at either loader path. Runnpm installingenerated/node/to build the generated addon with node-gyp, or copy a prebuilt binary in asindex.node.dlopen: ... image not found: the addon links against the Rust cdylib at runtime; setDYLD_LIBRARY_PATH/LD_LIBRARY_PATHor copy the cdylib next toindex.node.BigInterrors withhandle: handles are 64-bit; pass them asbigint, notnumber.- TypeScript complains about missing types: point
tsconfig’spathsatgenerated/node/types.d.tsor include the generated package incompilerOptions.types.
Swift
Overview
The Swift target emits a SwiftPM System Library (CWeaveFFI) that
references the generated C header via a module.modulemap, plus a thin
Swift module (WeaveFFI) that wraps the C ABI in idiomatic Swift with
throws-based error handling and Swift-native types.
What gets generated
| File | Purpose |
|---|---|
generated/swift/Package.swift | SwiftPM manifest declaring CWeaveFFI (system library) and WeaveFFI (Swift wrapper) |
generated/swift/Sources/CWeaveFFI/module.modulemap | C module map pointing at the generated header |
generated/swift/Sources/WeaveFFI/WeaveFFI.swift | Swift wrapper: enums, struct classes, namespaced module functions |
The module name shown above (WeaveFFI) is the default. It is overridden by
[swift] module_name or, failing that, by the IDL
package: name PascalCased
(async-demo → AsyncDemo). The Swift wrapper, its Sources/<Module>/
directory, the system-library target, and its Sources/C<Module>/ module map
all move together (e.g. AsyncDemo + CAsyncDemo), so the generated package
stays buildable under any name.
Type mapping
| IDL type | Swift type | Notes |
|---|---|---|
i32 | Int32 | Direct value |
u32 | UInt32 | Direct value |
i64 | Int64 | Direct value |
u64 | UInt64 | Direct value |
i8 | Int8 | Direct value |
i16 | Int16 | Direct value |
u8 | UInt8 | Direct value |
u16 | UInt16 | Direct value |
f32 | Float | Direct value |
f64 | Double | Direct value |
bool | Bool | C bool at the ABI |
string | String | NUL-terminated UTF-8 (withCString) |
bytes | Data / [UInt8] | Pointer + length |
handle | UInt64 | Direct value |
StructName | StructName (class) | Wraps OpaquePointer |
InterfaceName | InterfaceName (final class) | Wraps OpaquePointer; see Interfaces |
EnumName (plain) | EnumName (enum) | Backed by UInt32 |
EnumName (rich) | EnumName (class) | Wraps OpaquePointer, like a struct |
T? | T? | Optional pointer / sentinel |
[T] | [T] | Pointer + length |
iter<T> | generated Sequence class | Lazy; one _next call per step |
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
errors:
name: ContactsError
codes:
- { name: InvalidName, code: 1, message: "name must not be empty" }
- { name: NotFound, code: 2, message: "contact not found" }
functions:
- name: create_contact
params:
- { name: name, type: string }
- { name: age, type: i32 }
return: Contact
throws: true
- name: find_contact
params:
- { name: id, type: i32 }
return: "Contact?"
throws: true
- name: list_contacts
params: []
return: "[Contact]"
- name: set_type
params:
- { name: id, type: i32 }
- { name: contact_type, type: ContactType }
Enums become Swift enums with lowerCamelCase cases backed by UInt32:
public enum ContactType: UInt32 {
case personal = 0
case work = 1
case other = 2
}
Structs are wrapper classes around an OpaquePointer. The deinit calls
the C destructor; computed properties call the C getters:
public class Contact {
let ptr: OpaquePointer
init(ptr: OpaquePointer) { self.ptr = ptr }
deinit { weaveffi_contacts_Contact_destroy(ptr) }
public var name: String {
let raw = weaveffi_contacts_Contact_get_name(ptr)
guard let raw = raw else { return "" }
defer { weaveffi_free_string(raw) }
return String(cString: raw)
}
}
Module functions live as static methods on a namespace enum in
lowerCamelCase with real argument labels; the module prefix is stripped by
default (strip_module_prefix = false in [swift] restores it), since the
namespace enum already scopes the name. A function with throws: true
becomes a Swift throws function delivering the typed domain error. String
parameters are passed as NUL-terminated C strings via withCString:
public enum Contacts {
public static func createContact(name: String, age: Int32) throws -> Contact {
var err = weaveffi_error(code: 0, message: nil)
let result: OpaquePointer? = name.withCString { name_ptr in
return weaveffi_contacts_create_contact(name_ptr, age, &err)
}
try checkContacts(&err)
guard let result = result else { throw WeaveFFIError.error(code: -1, message: "null pointer") }
return Contact(ptr: result)
}
}
Call it as try Contacts.createContact(name: "Grace", age: 46). A
function without throws keeps a plain non-throwing signature; its only
possible failures are producer bugs, which trap via fatalError. Nested
IDL modules become nested namespace enums (Kv.Stats.getStats(store:) in
the kvstore sample).
Optionals and lists use withOptionalPointer, withOptionalCString,
and withUnsafeBufferPointer helpers:
@inline(__always)
func withOptionalPointer<T, R>(to value: T?, _ body: (UnsafePointer<T>?) throws -> R) rethrows -> R {
guard let value = value else { return try body(nil) }
return try withUnsafePointer(to: value) { try body($0) }
}
ids.withUnsafeBufferPointer { buf in
let ids_ptr = buf.baseAddress
let ids_len = buf.count
}
Typed errors
A module’s error domain becomes a Swift error enum conforming to Error
and LocalizedError, with one lowerCamelCase case per declared code, each
carrying its message. From the kvstore sample’s KvError:
/// Typed errors reported by the `kv` module.
public enum KvError: Error, LocalizedError {
case keyNotFound(message: String)
case expired(message: String)
case storeFull(message: String)
case ioError(message: String)
/// The numeric ABI code carried by this error.
public var errorCode: Int32 {
switch self {
case .keyNotFound: return 1001
case .expired: return 1002
case .storeFull: return 1003
case .ioError: return 1004
}
}
}
Callables with throws: true route failures through a per-domain checker
(checkKv) that maps the ABI code to the matching case, falling back to
the generic WeaveFFIError.error(code:message:) for codes the domain
doesn’t declare (a producer panic, for example):
do {
let entry = try store.get(key: "alpha")
} catch KvError.keyNotFound {
print("no such key")
} catch let e as KvError {
print("kv failure \(e.errorCode)")
}
Callables without throws have plain, non-throwing signatures. They still
check the error slot after the call, but a non-zero code there can only be
a producer bug, so it traps with fatalError("\(code): \(message)")
instead of throwing.
Interfaces
An interfaces: entry becomes a final class owning an OpaquePointer,
with deinit calling the implicit C destructor. A constructor named new
becomes init; any other constructor becomes a throwing static factory.
Methods are instance methods and statics are static methods, all in
lowerCamelCase with argument labels. From the kvstore sample’s Store
(trimmed):
/// An embedded key-value store owning its entries
public final class Store {
let ptr: OpaquePointer
deinit {
weaveffi_kv_Store_destroy(ptr)
}
/// Open (or create) a store backed by the given filesystem path
public static func open(path: String) throws -> Store {
var err = weaveffi_error(code: 0, message: nil)
let result: OpaquePointer? = path.withCString { path_ptr in
return weaveffi_kv_Store_open(path_ptr, &err)
}
try checkKv(&err)
guard let result = result else { throw WeaveFFIError.error(code: -1, message: "null pointer") }
return Store(ptr: result)
}
/// Remove the entry for the given key, returning true if it existed
public func delete(key: String) throws -> Bool
/// Return the number of live entries in the store
public func count() -> Int64 // no throws: traps on producer bugs
/// Stream every key, optionally filtered by a prefix
public func listKeys(prefix: String?) throws -> KvStoreListKeysIterator
/// Reclaim space asynchronously; returns the number of bytes reclaimed
public func compact() async throws -> Int64
/// Legacy single-shot put kept for compatibility
@available(*, deprecated, message: "use put() with explicit kind")
public func legacyPut(key: String, value: Data) throws -> Bool
/// The largest number of live entries one store will hold
public static func defaultCapacity() -> Int64
}
let store = try Store.open(path: "/tmp/cache.kv")
_ = try store.put(key: "alpha", value: Data("1".utf8), kind: .volatile, ttlSeconds: nil)
print(store.count())
let reclaimed = try await store.compact()
The contacts sample’s ContactBook declares a constructor named new,
which surfaces as a real initializer: let book = ContactBook(). ARC
releases the underlying object when the last reference goes away; there’s
no manual close. An interface parameter is borrowed for the call (the
wrapper passes its pointer); an interface return wraps the owned pointer
in a new instance.
Rich (algebraic) enums
An enum whose variants declare fields is a rich (algebraic) enum, a sum
type with associated data. Plain C-style enums stay Swift enums backed by
UInt32; a rich enum instead becomes a wrapper class around an
OpaquePointer (same ownership model as a struct class) with a nested Tag,
throwing static factories, and per-variant computed properties. From the
shapes sample:
public class Shape {
let ptr: OpaquePointer
deinit { weaveffi_shapes_Shape_destroy(ptr) }
public enum Tag: Int32 {
case empty = 0
case circle = 1
case rectangle = 2
case labeled = 3
}
public var tag: Tag { Tag(rawValue: weaveffi_shapes_Shape_tag(ptr))! }
public static func empty() throws -> Shape
public static func circle(radius: Double) throws -> Shape
public static func rectangle(width: Float, height: Float) throws -> Shape
public static func labeled(label: String, count: UInt8) throws -> Shape
public var circleRadius: Double { get }
public var rectangleWidth: Float { get }
public var rectangleHeight: Float { get }
public var labeledLabel: String { get }
public var labeledCount: UInt8 { get }
}
Build a variant with its throwing factory, switch on tag, and read only the
matching property. Module functions live on the Shapes namespace enum and
take/return the wrapper:
let shape = try Shape.circle(radius: 2.0)
if shape.tag == .circle {
print("radius = \(shape.circleRadius)")
}
print(Shapes.describe(shape: shape))
let bigger = Shapes.scale(shape: shape, factor: 3.0)
Ownership matches struct classes: the Shape deinit calls
weaveffi_shapes_Shape_destroy, so ARC frees the handle when the last
reference goes away, no manual free required.
Build instructions
Build the producer cdylib, generate the bindings, and compile your
program against the generated Swift module (the contacts sample shown;
its module resolves to Contacts + CContacts from the package name):
cargo build -p contacts
weaveffi generate samples/contacts/contacts.yml -o generated
swiftc \
-I generated/swift/Sources/CContacts \
-L target/debug -lcontacts \
-Xlinker -rpath -Xlinker target/debug \
generated/swift/Sources/Contacts/Contacts.swift main.swift -o app
DYLD_LIBRARY_PATH=target/debug ./app # LD_LIBRARY_PATH on Linux
In a real SwiftPM application, add the generated package as a path
dependency, link the system-library and wrapper targets, and ship the
cdylib as part of an XCFramework or bundled .dylib/.so. The
conformance/swift/ consumers show a complete SwiftPM assembly for every
sample (see conformance/run.sh).
Memory and ownership
- Struct and interface classes own an
OpaquePointer. The classdeinitcalls the matching C destructor. - Returned strings are copied into Swift
Stringand the raw pointer is freed viaweaveffi_free_stringimmediately. withUnsafeBufferPointerandwithOptionalPointerkeep input buffers alive only for the duration of the C call; there’s no copy.- For
bytesparameters, the wrapper copies theDatainto a[UInt8]array and passes it viawithUnsafeBufferPointer; returnedbytesare copied intoDataand the Rust buffer is freed withweaveffi_free_bytes. - Returned optional scalars arrive boxed behind a pointer; the wrapper
dereferences the value and frees the box with the
wvFreeBoxhelper. Returned arrays and maps free each string element individually, then release the buffer(s) withwvFreeArray; both helpers callweaveffi_free_bytes.
Async support
Async IDL functions (async: true) are exposed as async throws
methods that bridge the C ABI completion callback into Swift structured
concurrency via withCheckedThrowingContinuation. The continuation is
boxed in a ContinuationRef, retained with Unmanaged.passRetained,
and released exactly once, by takeRetainedValue() inside the C
completion callback. From the async-demo sample:
private final class ContinuationRef<T, E: Error> {
let value: CheckedContinuation<T, E>
init(_ value: CheckedContinuation<T, E>) { self.value = value }
}
public static func runTask(name: String) async throws -> TaskResult {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<TaskResult, Error>) in
let ctx = Unmanaged.passRetained(ContinuationRef(continuation)).toOpaque()
name.withCString { name_ptr in
weaveffi_tasks_run_task_async(name_ptr, { context, err, result in
let contRef = Unmanaged<ContinuationRef<TaskResult, Error>>.fromOpaque(context!).takeRetainedValue()
if let err = err, err.pointee.code != 0 {
let code = err.pointee.code
let msg = err.pointee.message.flatMap { String(cString: $0) } ?? ""
contRef.value.resume(throwing: mapTasks(code: code, message: msg))
} else {
guard let result = result else {
contRef.value.resume(throwing: WeaveFFIError.error(code: -1, message: "null pointer"))
return
}
contRef.value.resume(returning: TaskResult(ptr: result))
}
}, ctx)
}
}
}
The completion callback fires exactly once, on an arbitrary producer
thread, and the continuation is resumed exactly once from inside it.
Result buffers passed to the callback (strings, bytes, arrays) are
borrowed from the producer for the callback’s duration: the wrapper
copies them (for example String(cString:) on the error message)
before the callback returns and never frees them. Owned-object results
are the exception: run_task returns a struct, so the callback adopts
the pointer into a new TaskResult, whose deinit eventually frees
it.
run_task declares throws: true, so the continuation rejects with the
typed TaskError (via mapTasks). An async callable without throws is
async but not throws: it uses a plain withCheckedContinuation whose
failure type is Never, and a producer bug traps instead.
For callables marked cancellable: true, the C ABI takes an extra
weaveffi_cancel_token* parameter. The Swift wrapper passes nil for
that slot; cancellation isn’t surfaced in Swift, and Swift Task
cancellation doesn’t propagate to the native operation (from the
kvstore sample’s Store.compact):
weaveffi_kv_Store_compact_async(ptr, nil, { context, err, result in
Callbacks and listeners
IDL callbacks paired with listeners produce a register/unregister
pair. From the events sample:
modules:
- name: events
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
Registration is a static method on the module’s namespace enum: it
takes a plain Swift closure and returns a UInt64 subscription id;
pass that id back to unregister. The closure is boxed
(WvCallbackBox), retained with Unmanaged.passRetained, and handed
to the C ABI as the void* context of a C trampoline. The context
pointer is kept in a global wvListenerContexts dictionary keyed by
subscription id and guarded by an NSLock (wvListenerLock);
unregistering removes the entry and releases the box:
public static func registerMessageListener(_ callback: @escaping (String) -> Void) -> UInt64 {
let box = WvCallbackBox(callback)
let ctx = Unmanaged.passRetained(box).toOpaque()
let id = weaveffi_events_register_message_listener({ message, context in
let cb = Unmanaged<WvCallbackBox<(String) -> Void>>.fromOpaque(context!).takeUnretainedValue().value
cb(String(cString: message!))
}, ctx)
wvListenerLock.lock()
wvListenerContexts[id] = ctx
wvListenerLock.unlock()
return id
}
public static func unregisterMessageListener(_ id: UInt64) {
weaveffi_events_unregister_message_listener(id)
wvListenerLock.lock()
let ctx = wvListenerContexts.removeValue(forKey: id)
wvListenerLock.unlock()
if let ctx = ctx {
Unmanaged<WvCallbackBox<(String) -> Void>>.fromOpaque(ctx).release()
}
}
The callback runs on the producer’s thread, whichever thread the
native side fires the event from. For UI work, hop to the main thread
yourself (e.g. DispatchQueue.main.async or await MainActor.run).
Iterators
iter<T> returns are lazy: the wrapper returns a generated
final class conforming to Sequence and IteratorProtocol that
wraps the opaque C iterator handle. Nothing is drained up front; each
next() call pulls exactly one element from the producer, copies it
into Swift memory, and frees the element’s native allocation (strings
via weaveffi_free_string). From the events sample (get_messages
returns iter<string> and doesn’t declare throws, so the wrapper is
non-throwing and traps on producer bugs):
/// A lazy sequence over the `String` elements streamed by `weaveffi_events_get_messages`.
///
/// Each `next()` call pulls exactly one element from the producer. The
/// underlying C iterator is destroyed eagerly on exhaustion and from
/// `deinit` when iteration is abandoned early.
public final class EventsGetMessagesIterator: Sequence, IteratorProtocol {
private var handle: OpaquePointer?
deinit {
destroyHandle()
}
/// Pulls the next element from the producer, or returns `nil` once the
/// stream is exhausted (destroying the underlying iterator).
public func next() -> String? {
guard let handle = handle else { return nil }
var item: UnsafePointer<CChar>? = nil
var err = weaveffi_error(code: 0, message: nil)
if weaveffi_events_GetMessagesIterator_next(handle, &item, &err) == 0 {
// ... a non-zero code is a producer bug: fatalError ...
destroyHandle()
return nil
}
let element = String(cString: item!)
weaveffi_free_string(item)
return element
}
}
public static func getMessages() -> EventsGetMessagesIterator {
var err = weaveffi_error(code: 0, message: nil)
let iter = weaveffi_events_get_messages(&err)
trap(&err)
guard let iter = iter else { fatalError("-1: null iterator") }
return EventsGetMessagesIterator(handle: iter)
}
The handle is destroyed exactly once: eagerly when next() reports
exhaustion, or from deinit when the sequence is abandoned early
(destroyHandle nulls the stored handle, so a double destroy is
impossible). Since the class conforms to Sequence, callers just
write for message in Events.getMessages() { ... }; the sequence is
single-pass.
A throwing iterator function like the kvstore sample’s
Store.listKeys(prefix:) declares throws and routes a launch
failure through the typed checker (checkKv). Swift’s
IteratorProtocol.next() can’t throw, so a mid-stream error can’t
surface on the step that failed; instead it ends iteration and is
stored in the sequence’s public error property for the caller to
inspect after the loop:
/// If the producer reports an error mid-stream, iteration ends and the
/// error is stored in ``error`` for the caller to inspect after the loop.
public final class KvStoreListKeysIterator: Sequence, IteratorProtocol {
/// The error that ended iteration early, if any.
public private(set) var error: Error?
// ... same handle lifecycle as above; a failing next() maps the
// code through mapKv, stores it in `error`, and returns nil ...
}
let keys = try store.listKeys(prefix: nil)
for key in keys { print(key) }
if let error = keys.error { throw error }
Troubleshooting
module 'CWeaveFFI' not found: Xcode/SwiftPM didn’t pick up the generatedmodule.modulemap. Make sureSources/CWeaveFFI/module.modulemapis on disk and the package declaressystemLibrary(name: "CWeaveFFI").Library not loaded: libweaveffi.dylib: setDYLD_LIBRARY_PATHfor development or embed the dylib in your application bundle for distribution.- Crashes after
deinit: never reuse anOpaquePointerafter the owning Swift wrapper goes out of scope. The C side has already freed it. - Optional struct ends up
nileven when present: the C function is allowed to return a null pointer to indicate absence; double-check the Rust implementation actually returnsSome(_)for the case you expect.
Wasm
Overview
The Wasm target produces a typed ES module loader for
wasm32-unknown-unknown builds of WeaveFFI cdylibs. The loader wraps
the raw exports in idiomatic JavaScript: per-module namespaces, struct
wrapper classes with getters, thrown Errors instead of error slots,
Promise-based async functions, and automatic string/bytes staging in
linear memory. TypeScript declarations describe the whole surface.
C and C++ producers compiled with Emscripten are supported through a dedicated loader variant; see Emscripten mode.
Callbacks and listeners are supported with synchronous, same-thread
delivery: a wasm32-unknown-unknown module is single-threaded, so
events fire only while a call into the module is on the stack; see
Callbacks and listeners.
What gets generated
| File | Purpose |
|---|---|
generated/wasm/weaveffi_wasm.js | ES module: memory helpers, struct wrapper classes, and the async loadWeaveffiWasm(url) loader returning typed bindings |
generated/wasm/weaveffi_wasm.d.ts | TypeScript declarations for the loader and every module namespace |
generated/wasm/package.json | npm package manifest (type: "module") |
generated/wasm/README.md | Quickstart and boundary conventions |
Type mapping
| IDL type | Wasm boundary | JavaScript surface |
|---|---|---|
i32 / u32 | i32 | number |
i8 / i16 | i32 | number |
u8 / u16 | i32 | number |
i64 | i64 | BigInt |
u64 | i64 | BigInt |
f64 | f64 | number |
f32 | f32 | number |
bool | i32 | boolean (0/1 at the boundary) |
string | i32 pointer (NUL-terminated UTF-8) | string, staged via weaveffi_alloc |
bytes | i32 pointer + i32 length | Uint8Array copy |
handle / StructName | i32 pointer into linear memory (0 = null) | struct wrapper class with getters |
EnumName (plain, C-style) | i32 discriminant | number |
EnumName (rich / algebraic) | i32 pointer into linear memory (0 = null) | wrapper class (e.g. Shape) |
T? | 0 / null pointer; scalars boxed by pointer | T | null |
[T] | i32 pointer + i32 length | Array copy |
iter<T> | iterator handle + next out-param | lazy IterableIterator<T> |
Example IDL → generated code
The loader exports a single async entry point that fetches,
instantiates, and wraps a .wasm module:
import { loadWeaveffiWasm } from './weaveffi_wasm.js';
const api = await loadWeaveffiWasm('/your_library.wasm');
Functions are grouped by IDL module in lowerCamelCase (nested IDL
modules nest namespaces, e.g. api.kv.stats) and have idiomatic
signatures; strings, arrays, and error handling are taken care of
inside the wrapper:
api.events.sendMessage('hello'); // throws WeaveFFIError on failure
for (const m of api.events.getMessages()) { // iter<string> -> lazy iterable
console.log(m);
}
Structs come back as wrapper classes holding the native handle, with a
getter per field and a static create when the struct has a
constructor:
const result = await api.tasks.runTask('build');
console.log(result.id, result.value, result.success);
The raw exports stay reachable for anything not covered by the typed surface:
api._raw.weaveffi_alloc(16);
The generated weaveffi_wasm.d.ts mirrors all of this for TypeScript
consumers:
export interface WeaveffiWasmModule {
_raw: WebAssembly.Exports;
events: {
sendMessage(text: string): void;
getMessages(): IterableIterator<string>;
};
}
export function loadWeaveffiWasm(url: string): Promise<WeaveffiWasmModule>;
Typed errors
The module exports WeaveFFIError (extending Error with a numeric
code). A module’s error domain adds an exported base class named after
the domain plus one exported class per code, each carrying its stable
CODE and reachable both flat and via the domain class. From the
kvstore sample:
export class WeaveFFIError extends Error {
constructor(code, message) {
super(message ? `WeaveFFI error ${code}: ${message}` : `WeaveFFI error ${code}`);
this.name = new.target.name;
this.code = code;
}
}
/** Base error for the `kv` module's error domain. */
export class KvError extends WeaveFFIError {}
// key not found
export class KeyNotFound extends KvError {
constructor(message = "key not found") {
super(1001, message);
}
}
KeyNotFound.CODE = 1001;
KvError.KeyNotFound = KeyNotFound;
// Expired, StoreFull, IoError follow the same shape.
A callable with throws: true checks the error slot through the
domain’s mapper (_kvErrorFrom), so a failure arrives as the matching
subclass (KeyNotFound), the domain (KvError), or, for codes outside
the domain, the generic WeaveFFIError. A callable without throws
uses the generic checker only; a failure there can only be a producer
bug and throws WeaveFFIError.
Interfaces
An interfaces: entry becomes a class exposed on its module’s namespace
(api.kv.Store). Constructors are static factories, methods are
camelCased instance methods, statics are static methods, and free()
releases the native object (there’s no FinalizationRegistry on this
target). From the kvstore sample (trimmed):
// An embedded key-value store owning its entries
class Store {
free() {
if (this._handle !== 0) {
wasm.weaveffi_kv_Store_destroy(this._handle);
this._handle = 0;
}
}
static open(path) {
const [a0_p, a0_s] = _cstr(wasm, path);
const _err = _allocErr(wasm);
const _r = wasm.weaveffi_kv_Store_open(a0_p, _err);
wasm.weaveffi_dealloc(a0_p, a0_s);
_checkKvError(wasm, _err);
_freeErr(wasm, _err);
return Store._wrap(_r);
}
delete(key) { /* throws typed KvError subclasses */ }
count() { /* generic check only (no throws) */ }
compact() {
return new Promise((resolve, reject) => {
const ctxId = _nextCtxId++;
_asyncContexts.set(ctxId, { resolve, reject, mkErr: _kvErrorFrom });
wasm.weaveffi_kv_Store_compact_async(this._handle, 0, _cbPtr_i32_i32_i64, ctxId);
});
}
/** @deprecated use put() with explicit kind */
legacyPut(key, value) { /* ... */ }
static defaultCapacity() { /* ... */ }
}
const store = api.kv.Store.open('/tmp/cache.kv');
store.put('alpha', new Uint8Array([1]), api.kv.EntryKind.Volatile, null);
console.log(store.count());
store.free();
An interface parameter accepts the wrapper and reads _handle
(api.kv.stats.getStats(store)); an interface return wraps the owned
pointer in a fresh instance. Call free() when done; otherwise the
allocation lives until the module instance is dropped.
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style enum stays an i32 discriminant (surfaced as a
number plus a frozen constants object), but a rich enum lowers to an
opaque object handle, an i32 pointer into linear memory, exactly
like a struct wrapper. The loader wraps it in a Shape class that owns
that handle for the lifetime of the module instance.
For a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }, the generated Shape class has
one static factory per variant, a tag getter, a getter per variant
field, and an explicit free() (there is no FinalizationRegistry on
this target):
class Shape {
constructor(wasm, handle) {
this._wasm = wasm;
this._handle = handle;
}
get tag() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_tag(this._handle);
return _r;
}
static empty(wasm) {
const _err = _allocErr(wasm);
const _r = wasm.weaveffi_shapes_Shape_Empty_new(_err);
_checkErr(wasm, _err);
_freeErr(wasm, _err);
return new Shape(wasm, _r);
}
static circle(wasm, radius) {
const _err = _allocErr(wasm);
const _r = wasm.weaveffi_shapes_Shape_Circle_new(radius, _err);
_checkErr(wasm, _err);
_freeErr(wasm, _err);
return new Shape(wasm, _r);
}
// ... rectangle(wasm, width, height), labeled(wasm, label, count) ...
get circleRadius() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_Circle_get_radius(this._handle);
return _r;
}
get labeledLabel() {
const wasm = this._wasm;
const _r = wasm.weaveffi_shapes_Shape_Labeled_get_label(this._handle);
return _takeCStr(wasm, _r);
}
// ... rectangleWidth, rectangleHeight, labeledCount ...
free() {
if (this._handle !== 0) {
this._wasm.weaveffi_shapes_Shape_destroy(this._handle);
this._handle = 0;
}
}
}
Shape.Tag = Object.freeze({
Empty: 0,
Circle: 1,
Rectangle: 2,
Labeled: 3,
});
The wasm instance is bound for you by the loader, so on the returned
API the factories take only their declared arguments. Under
api.shapes.Shape you get empty(), circle(radius),
rectangle(width, height), labeled(label, count), plus the frozen
Tag map:
shapes: {
// ...
Shape: {
empty: (...args) => Shape.empty(wasm, ...args),
circle: (...args) => Shape.circle(wasm, ...args),
rectangle: (...args) => Shape.rectangle(wasm, ...args),
labeled: (...args) => Shape.labeled(wasm, ...args),
Tag: Shape.Tag,
},
},
The active variant is read through the tag getter (no call
parentheses) and compared against api.shapes.Shape.Tag. Each variant
field is a camelCased getter: circleRadius, rectangleWidth,
rectangleHeight, labeledLabel, labeledCount. Functions that take
or return the enum pass the wrapper directly: describe(shape) reads
shape._handle, and scale(shape, factor) returns a fresh Shape.
The generated weaveffi_wasm.d.ts types the wrapper as an
export declare class:
export declare class Shape {
get tag(): number;
static readonly Tag: Readonly<{
Empty: 0;
Circle: 1;
Rectangle: 2;
Labeled: 3;
}>;
static empty(): Shape;
static circle(radius: number): Shape;
static rectangle(width: number, height: number): Shape;
static labeled(label: string, count: number): Shape;
get circleRadius(): number;
get rectangleWidth(): number;
get rectangleHeight(): number;
get labeledLabel(): string;
get labeledCount(): number;
free(): void;
}
A short round-trip that constructs a couple of variants, reads the tag and a
field, calls describe / scale, then frees the handles:
const api = await loadWeaveffiWasm('/shapes.wasm');
const circle = api.shapes.Shape.circle(2.0);
const label = api.shapes.Shape.labeled('unit', 3);
if (circle.tag === api.shapes.Shape.Tag.Circle) {
console.log(circle.circleRadius); // 2
}
console.log(api.shapes.describe(circle)); // native-rendered description
const bigger = api.shapes.scale(circle, 3.0); // a fresh Shape
// No FinalizationRegistry on this target. Free handles yourself.
circle.free();
label.free();
bigger.free();
Ownership: a Shape owns its native object. JavaScript has no
deterministic destructors here, so call free() when you are done;
otherwise the allocation lives until the module instance is dropped.
Async support
Async IDL functions return real Promises. The loader grows the
module’s __indirect_function_table and registers one JavaScript
trampoline per completion-callback signature using the
JS Type Reflection API
(new WebAssembly.Function(...)); each call stores its
resolve/reject pair in a context map keyed by an integer id:
runTask(name) {
return new Promise((resolve, reject) => {
const ctxId = _nextCtxId++;
_asyncContexts.set(ctxId, { resolve, reject, mkErr: _taskErrorFrom, unwrap: (w, h) => new TaskResult(w, h) });
const [a0_p, a0_s] = _cstr(wasm, name);
wasm.weaveffi_tasks_run_task_async(a0_p, _cbPtr_i32_i32_i32, ctxId);
wasm.weaveffi_dealloc(a0_p, a0_s);
});
}
When the producer invokes the completion callback, the trampoline looks
up the context, settles the promise, and removes the entry. A callable
with throws: true stores the module’s typed error mapper in the
context (mkErr), so the rejection carries the domain error; a
non-throwing async callable rejects with the generic WeaveFFIError
only when the producer has a bug.
Two caveats apply:
WebAssembly.Functionrequires a runtime with JS Type Reflection (recent V8/SpiderMonkey; Chrome, Firefox, Node 16+, Deno).- The module is single-threaded: the producer must complete the
callback on the calling thread (e.g. an executor polled by the same
thread). A producer that spawns OS threads will not work on
wasm32-unknown-unknown.
A cancellable function’s ABI symbol takes a weaveffi_cancel_token*
parameter; the loader passes a null token, so cancellation isn’t
surfaced on this target (Store.compact() runs to completion). An IDL
function that models cancellation itself is exposed as a plain function
in the same namespace (e.g. api.tasks.cancelTask(id)).
Iterators
iter<T> returns are lazy: the wrapper launches the producer iterator
and hands back a shared _WeaveFFIIterator implementing the JS
iterator protocol over the iterator handle. Nothing is drained; each
next() issues exactly one producer next call through a per-element
slot staged in linear memory. From the events sample:
getMessages() {
const _err = _allocErr(wasm);
const _it = wasm.weaveffi_events_get_messages(_err);
_checkErr(wasm, _err);
_freeErr(wasm, _err);
return new _WeaveFFIIterator(wasm, _it, 4,
(it, slot, ep) => wasm.weaveffi_events_GetMessagesIterator_next(it, slot, ep),
(it) => wasm.weaveffi_events_GetMessagesIterator_destroy(it),
_checkErr, (w, p) => _takeCStr(w, new DataView(w.memory.buffer).getUint32(p, true)));
}
The class settles the handle’s lifecycle exactly once: _close()
destroys the producer iterator, frees the element slot, and nulls the
handle. It runs eagerly on exhaustion, on a next error, or from
return() when iteration stops early; a for...of loop calls
return() automatically on break or throw. There is no reliable
finalization hook across the runtimes this loader supports, so
abandoning an iterator without exhausting or closing it leaks the
producer handle.
Each decoded element is copied out of linear memory and its producer
allocation released (_takeCStr frees strings via
weaveffi_free_string). Errors from the launcher and from each next
follow the function’s error strategy: a throwing function such as the
kvstore sample’s Store.listKeys checks each step with the domain
checker and throws the typed KvError subclasses; a non-throwing one
like getMessages throws the generic WeaveFFIError only for
producer bugs. The TypeScript declaration is IterableIterator<T>.
Callbacks and listeners
Each listener surfaces on its module namespace as a
register.../unregister... pair. register takes a plain JavaScript
function and returns a numeric subscription id; unregister takes that
id and stops delivery. From the events sample:
const received = [];
const sub = api.events.registerMessageListener((message) => received.push(message));
api.events.sendMessage('alpha'); // emit_message_listener fires synchronously
console.log(received); // ['alpha']
api.events.unregisterMessageListener(sub);
Under the hood the loader reuses the async machinery: it installs one
long-lived trampoline per callback typedef in the module’s
__indirect_function_table (via WebAssembly.Function, the same
JS Type Reflection API
dependency async functions have) and hands the trampoline’s table index
plus a per-subscription context id to the producer’s register_*
symbol. When the producer’s emit_* helper fires, the trampoline looks
up the subscription by context id, decodes each argument, and invokes
the JavaScript callback:
let _nextLsnId = 1;
const _listeners = new Map();
const _lsnPtr_weaveffi_events_OnMessage_fn = _registerTrampoline(_table, ['i32', 'i32'], (a0, _ctx) => {
const _l = _listeners.get(_ctx);
if (_l === undefined) return;
const _p0 = _readCStr(wasm, a0);
_l.callback(_p0);
});
// On the module object:
registerMessageListener(callback) {
const _id = _nextLsnId++;
const _rid = wasm.weaveffi_events_register_message_listener(_lsnPtr_weaveffi_events_OnMessage_fn, _id);
_listeners.set(_id, { callback, rid: _rid });
return _id;
},
unregisterMessageListener(id) {
const _l = _listeners.get(id);
if (_l === undefined) return;
_listeners.delete(id);
wasm.weaveffi_events_unregister_message_listener(_l.rid);
},
One trampoline serves every subscription to callbacks of the same
typedef (the context id disambiguates), so register/unregister churn
never grows the function table. The producer’s uint64_t subscription
id stays internal to the loader; the public surface deals only in plain
numbers.
Two semantic points to keep in mind:
- Delivery is synchronous and same-thread. The target is
single-threaded, so
emit_*can only run while a call into the module is on the stack, and your callback runs before that call returns. This is not a limitation of the bindings: a producer that emits from a spawned thread cannot run onwasm32-unknown-unknownat all (std::thread::spawnfails there). - Callback arguments are borrowed. The producer owns every argument
for the duration of the dispatch. Strings and byte buffers are copied
into JavaScript values before your callback runs, but struct,
rich-enum, and interface arguments wrap producer-owned memory: read
what you need inside the callback and do not retain the wrapper or
call
free()on it.
In Emscripten mode callbacks and listeners are not supported; each register/unregister entry point becomes an explicit throwing stub and is omitted from the TypeScript declarations, exactly like async functions in that mode.
Emscripten mode
The default loader fetches a bare .wasm and calls
WebAssembly.instantiate with an empty import object, which only works
for wasm32-unknown-unknown builds. A C or C++ library compiled with
Emscripten needs its own JS runtime, its own import object, and exposes
exports as Module['_name'] rather than instance.exports.name. Set
emscripten to generate a loader for that layout:
# weaveffi.toml
[wasm]
emscripten = true
or inline in the IDL:
generators:
wasm:
emscripten: true
Instead of a URL, the loader accepts the initialized Emscripten module,
or the promise returned by its MODULARIZE factory. You construct the
module yourself, so options like locateFile stay under your control:
import Module from './your_library.js';
import { loadWeaveffiWasm } from './weaveffi_wasm.js';
const api = await loadWeaveffiWasm(Module({ locateFile: (p) => 'build/' + p }));
Internally the loader binds the module’s underscore-prefixed exports to the symbol names the glue calls, once, up front:
const wasm = {
// Emscripten replaces HEAPU8 when linear memory grows, so the
// buffer is re-read on every access instead of captured once.
get memory() { return { buffer: m['HEAPU8'].buffer }; },
weaveffi_alloc: m['_weaveffi_alloc'],
weaveffi_dealloc: m['_weaveffi_dealloc'],
weaveffi_math_add: m['_weaveffi_math_add'],
// ...
};
Everything after that prologue is identical to the standard loader. The quoted bracket access on the Emscripten module is deliberate: it survives Closure Compiler’s advanced property renaming, while the rest of the glue keeps consistent dot access on this locally constructed object, which Closure can rename safely.
Building the producer
The generated header tags every export with {PREFIX}_API, which
expands to __attribute__((used, visibility("default"))) under
Emscripten (the same expansion as EMSCRIPTEN_KEEPALIVE), so the
symbols survive dead-code elimination without an -sEXPORTED_FUNCTIONS
list. The glue stages arguments through weaveffi_alloc /
weaveffi_dealloc; the generated weaveffi.c provides malloc/free-
backed defaults, so compile it into your library or export your own
implementations. A typical build:
emcc your_library.c generated/c/weaveffi.c -Igenerated/c \
-o your_library.js \
-sMODULARIZE=1 -sEXPORT_ES6=1 \
-sEXPORTED_RUNTIME_METHODS=HEAPU8 \
-sALLOW_MEMORY_GROWTH=1
-sEXPORTED_RUNTIME_METHODS=HEAPU8 is required: the glue reads and
writes linear memory through Module['HEAPU8'].
Limitations
Async functions, callbacks, and listeners are not supported in
Emscripten mode. The trampoline registration in the standard loader
relies on WebAssembly.Function and a growable
__indirect_function_table, neither of which an Emscripten module
exposes portably. Each async, register, and unregister entry point
becomes an explicit stub that throws at call time and is omitted from
the TypeScript declarations.
Build instructions
macOS / Linux / Windows (cross-compilation, all hosts):
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release -p your_library
The resulting .wasm is in target/wasm32-unknown-unknown/release/.
Serve it over HTTP and load it with the generated helper:
<script type="module">
import { loadWeaveffiWasm } from './weaveffi_wasm.js';
const api = await loadWeaveffiWasm('/your_library.wasm');
</script>
Memory and ownership
- The wrapper stages strings, bytes, and arrays into linear memory with
the exported
weaveffi_alloc/weaveffi_deallocand releases them after the call; you don’t manage buffers for typed calls. - Producer-owned returns (strings, arrays, struct fields) are copied to
JavaScript values and freed via
weaveffi_free_string/weaveffi_deallocinside the wrapper. - Struct wrapper objects hold a native handle. JavaScript has no deterministic destructors; the underlying allocation lives until the module is dropped. Treat handles as owned by the module instance.
- Error slots are allocated, checked, and cleared internally; failures
surface as thrown
Errors with the producer’s code and message. - When you bypass the typed surface via
_raw, the conventions at the top ofweaveffi_wasm.jsapply and every alloc must be paired with a dealloc.
Troubleshooting
WebAssembly.Function is not a constructor: the runtime lacks JS Type Reflection. Use a current Chrome/Firefox/Node/Deno, or avoid async functions, callbacks, and listeners for this target.LinkError: import object field 'env' is not a Function: the loader instantiates with an empty imports object. If your Rust crate imports host functions, extendloadWeaveffiWasmto pass them in. If the module was built with Emscripten, use Emscripten mode instead.- An async call never settles: the producer must invoke the
completion callback on the same thread;
std::thread::spawndoes not exist onwasm32-unknown-unknown. - A registered listener never fires: delivery is synchronous, so
events arrive only while a call into the module is on the stack. The
producer must
emit_*during one of your calls; there is no background delivery on this target. - Out-of-memory after many
_rawcalls: every pointer returned from the module must be deallocated; the typed wrappers do this for you, raw calls do not. - The
.wasmfile fails to instantiate: the build artifact must bewasm32-unknown-unknown.wasm32-wasimodules require WASI imports and cannot run in the browser without a polyfill.
Python
Overview
The Python target produces pure-Python ctypes bindings, type stubs, and
packaging files. Calls go through Python’s built-in ctypes module so
there is no compilation step, no native extension, and no third-party
runtime dependency. The generated package works on any Python 3.7+
interpreter that can dlopen the shared library.
The trade-off is that ctypes calls are slower than compiled extensions
(cffi, pybind11, PyO3). For typical FFI workloads the overhead is
negligible compared to the work done inside the Rust library.
What gets generated
| File | Purpose |
|---|---|
python/weaveffi/__init__.py | Re-exports the public API from weaveffi.py |
python/weaveffi/weaveffi.py | ctypes bindings: library loader, wrappers, classes |
python/weaveffi/weaveffi.pyi | Type stub for IDE autocompletion and mypy |
python/pyproject.toml | PEP 621 project metadata |
python/setup.py | Fallback setuptools script |
python/README.md | Basic usage instructions |
The package directory follows the IDL package.name (a package named
events produces python/events/...); weaveffi is the default.
Type mapping
| IDL type | Python type hint | ctypes type |
|---|---|---|
i32 | int | ctypes.c_int32 |
u32 | int | ctypes.c_uint32 |
i64 | int | ctypes.c_int64 |
f64 | float | ctypes.c_double |
i8 | int | ctypes.c_int8 |
i16 | int | ctypes.c_int16 |
u8 | int | ctypes.c_uint8 |
u16 | int | ctypes.c_uint16 |
u64 | int | ctypes.c_uint64 |
f32 | float | ctypes.c_float |
bool | bool | ctypes.c_int32 |
string | str | ctypes.c_char_p |
bytes | bytes | ctypes.POINTER(ctypes.c_uint8) + ctypes.c_size_t |
handle | int | ctypes.c_uint64 |
Struct | "StructName" | ctypes.c_void_p |
Interface | "InterfaceName" | ctypes.c_void_p |
Enum (plain) | "EnumName" | ctypes.c_int32 |
Enum (rich) | "EnumName" | ctypes.c_void_p |
T? | Optional[T] | ctypes.POINTER(scalar) for values; same pointer for strings/structs |
[T] | List[T] | ctypes.POINTER(scalar) + ctypes.c_size_t |
{K: V} | Dict[K, V] | key/value pointer arrays + ctypes.c_size_t |
iter<T> | Iterator[T] (lazy) | opaque ctypes.c_void_p iterator handle |
Booleans cross the boundary as c_int32 (0/1) because C has no
standard fixed-width boolean type across ABIs.
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
doc: "Type of contact"
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: "A contact record"
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
functions:
- name: create_contact
params:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: count_contacts
params: []
return: i32
The generated module loads the platform-specific shared library:
def _load_library() -> ctypes.CDLL:
# An explicit path in WEAVEFFI_LIBRARY wins, so callers can point at a
# specific build artifact regardless of its file name or location.
override = os.environ.get("WEAVEFFI_LIBRARY")
if override:
return ctypes.CDLL(override)
system = platform.system()
if system == "Darwin":
name = "libweaveffi.dylib"
elif system == "Windows":
name = "weaveffi.dll"
else:
name = "libweaveffi.so"
return ctypes.CDLL(name)
_lib = _load_library()
Functions become snake_case Python functions with full type hints;
ctypes argtypes/restype are set up at the call site:
def create_contact(name: str, email: Optional[str], contact_type: "ContactType") -> int:
_fn = _lib.weaveffi_contacts_create_contact
_fn.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int32,
ctypes.POINTER(_WeaveFFIErrorStruct)]
_fn.restype = ctypes.c_uint64
_err = _WeaveFFIErrorStruct()
_result = _fn(_string_to_bytes(name), _string_to_bytes(email),
contact_type.value, ctypes.byref(_err))
_check_error(_err)
return _result
Enums become IntEnum subclasses:
class ContactType(IntEnum):
"""Type of contact"""
Personal = 0
Work = 1
Other = 2
Structs become Python classes that wrap a void pointer and expose
@property getters; __del__ calls the C destructor:
class Contact:
"""A contact record"""
def __init__(self, _ptr: int) -> None:
self._ptr = _ptr
def __del__(self) -> None:
if self._ptr is not None:
_lib.weaveffi_contacts_Contact_destroy.argtypes = [ctypes.c_void_p]
_lib.weaveffi_contacts_Contact_destroy.restype = None
_lib.weaveffi_contacts_Contact_destroy(self._ptr)
self._ptr = None
@property
def name(self) -> str:
_fn = _lib.weaveffi_contacts_Contact_get_name
_fn.argtypes = [ctypes.c_void_p]
_fn.restype = ctypes.c_void_p
return _take_string(_fn(self._ptr)) or ""
String getters return the C string as a raw address; _take_string
copies it into a Python str and frees the producer’s buffer with
weaveffi_free_string, so the getter doesn’t leak.
The accompanying .pyi stub mirrors the public surface for IDE/mypy:
class ContactType(IntEnum):
Personal: int
Work: int
Other: int
class Contact:
@property
def name(self) -> str: ...
@property
def email(self) -> Optional[str]: ...
@property
def age(self) -> int: ...
def create_contact(name: str, email: Optional[str], contact_type: "ContactType") -> int: ...
Wrapper names drop the IDL module prefix by default and stay
snake_case, so create_contact in module contacts is exported as
plain create_contact (the C symbol keeps its full
weaveffi_contacts_create_contact name). Set
strip_module_prefix: false in the Python generator config (or under
[global]) to restore module-prefixed wrapper names like
contacts_create_contact.
Typed errors
Every generated module defines WeaveFFIError(Exception) with code
and message attributes. A module that declares an error domain also
gets a domain base class and one subclass per code, each pinning its
stable CODE; from the contacts sample:
class ContactsError(WeaveFFIError):
"""Base exception for the `contacts` module's error domain."""
class InvalidName(ContactsError):
"""name must not be empty"""
CODE = 1
def __init__(self, message: str = "name must not be empty") -> None:
super().__init__(1, message)
class NotFound(ContactsError):
"""contact not found"""
CODE = 2
def __init__(self, message: str = "contact not found") -> None:
super().__init__(2, message)
ContactsError.InvalidName = InvalidName
ContactsError.NotFound = NotFound
Only callables marked throws: true in the IDL raise these typed
errors: their wrappers check the error slot with
_check_contacts_error, which maps the code through
_contacts_error_from and raises NotFound, InvalidName, or (for
codes outside the domain, such as producer panics) a plain
WeaveFFIError. Their docstrings carry a Raises section naming the
domain. A callable without throws uses the generic _check_error,
which raises WeaveFFIError only if the producer misbehaves:
try:
contact = book.get(999)
except NotFound:
... # specific code
except ContactsError as e:
print(e.code, e.message) # any domain error
Interfaces
An interfaces: entry becomes a Python class wrapping the opaque
pointer. A constructor named new renders as __init__; any other
constructor becomes a @classmethod factory. Methods are instance
methods, statics are @staticmethods, and __del__ calls the C
destructor; _from_ptr builds an instance around a pointer the C side
already owns. From the kvstore sample (trimmed):
class Store:
"""An embedded key-value store owning its entries"""
@classmethod
def _from_ptr(cls, ptr) -> "Store":
_obj = cls.__new__(cls)
_obj._ptr = ptr
return _obj
def __del__(self) -> None:
if self._ptr is not None:
_lib.weaveffi_kv_Store_destroy.argtypes = [ctypes.c_void_p]
_lib.weaveffi_kv_Store_destroy.restype = None
_lib.weaveffi_kv_Store_destroy(self._ptr)
self._ptr = None
@classmethod
def open(cls, path: str) -> "Store":
"""Open (or create) a store backed by the given filesystem path
Raises
------
KvError
If the call reports one of the domain's error codes.
"""
_fn = _lib.weaveffi_kv_Store_open
_fn.argtypes = [ctypes.c_char_p, ctypes.POINTER(_WeaveFFIErrorStruct)]
_fn.restype = ctypes.c_void_p
_err = _WeaveFFIErrorStruct()
_result = _fn(_string_to_bytes(path), ctypes.byref(_err))
_check_kv_error(_err)
if _result is None:
raise WeaveFFIError(-1, "null pointer")
return cls._from_ptr(_result)
def get(self, key: str) -> Optional["Entry"]: ...
def delete(self, key: str) -> bool: ...
async def compact(self) -> int:
_fn = _lib.weaveffi_kv_Store_compact_async
_loop = asyncio.get_running_loop()
_fut = _loop.create_future()
# ... completion callback resolves _fut via call_soon_threadsafe ...
return await _fut
def legacy_put(self, key: str, value: bytes) -> bool:
import warnings
warnings.warn("use put() with explicit kind", DeprecationWarning, stacklevel=2)
...
@staticmethod
def default_capacity() -> int: ...
A constructor named new (as on the contacts sample’s ContactBook)
lets you write book = ContactBook(); named constructors read as
store = Store.open("/tmp/cache.kv"). Methods on the C ABI take the
receiver as the leading argument (weaveffi_kv_Store_put(self._ptr, ...)), and functions elsewhere in the IDL accept or return the wrapper
directly (get_stats(store) passes store._ptr). Deprecated members
emit DeprecationWarning at call time.
import asyncio
from kvstore import EntryKind, Store
store = Store.open("/tmp/cache.kv")
store.put("alpha", b"\x01", EntryKind.Persistent, None)
print(store.count(), Store.default_capacity())
reclaimed = asyncio.run(store.compact())
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. Unlike a plain C-style Enum, which crosses the boundary as a
bare ctypes.c_int32 discriminant, a rich enum lowers to an opaque
object handle, so the generator emits a wrapper class with exactly the
same ownership model as a struct wrapper: a ctypes.c_void_p held
behind @property accessors and freed by __del__.
Given a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and Labeled { label: string, count: u8 }, the generated class exposes a nested Tag IntEnum, one
@classmethod constructor per variant, a tag property, and a
per-variant field getter for each payload:
class Shape:
"""An algebraic shape (sum type with associated data)"""
class Tag(IntEnum):
Empty = 0
Circle = 1
Rectangle = 2
Labeled = 3
def __del__(self) -> None:
if self._ptr is not None:
_lib.weaveffi_shapes_Shape_destroy.argtypes = [ctypes.c_void_p]
_lib.weaveffi_shapes_Shape_destroy.restype = None
_lib.weaveffi_shapes_Shape_destroy(self._ptr)
self._ptr = None
@property
def tag(self) -> int:
_fn = _lib.weaveffi_shapes_Shape_tag
_fn.argtypes = [ctypes.c_void_p]
_fn.restype = ctypes.c_int32
return _fn(self._ptr)
@classmethod
def circle(cls, radius: float) -> "Shape":
"""A circle with a radius"""
_fn = _lib.weaveffi_shapes_Shape_Circle_new
_fn.argtypes = [ctypes.c_double, ctypes.POINTER(_WeaveFFIErrorStruct)]
_fn.restype = ctypes.c_void_p
_err = _WeaveFFIErrorStruct()
_result = _fn(radius, ctypes.byref(_err))
_check_error(_err)
if _result is None:
raise WeaveFFIError(-1, "null pointer")
return cls(_result)
@property
def circle_radius(self) -> float:
"""Radius in points"""
_fn = _lib.weaveffi_shapes_Shape_Circle_get_radius
_fn.argtypes = [ctypes.c_void_p]
_fn.restype = ctypes.c_double
return _fn(self._ptr)
The full surface mirrors the variants: constructors Shape.empty(),
Shape.circle(radius), Shape.rectangle(width, height), and
Shape.labeled(label, count) (the last takes ctypes.c_char_p +
ctypes.c_uint8); field getters circle_radius, rectangle_width,
rectangle_height, labeled_label, and labeled_count. Each C symbol
follows the weaveffi_shapes_Shape_<Variant>_new /
weaveffi_shapes_Shape_<Variant>_get_<field> pattern, with
weaveffi_shapes_Shape_tag reading the discriminant.
Construct a couple of variants, read the tag and a field, then hand the wrapper to a free function:
from weaveffi import Shape, describe, scale
circle = Shape.circle(2.0)
labeled = Shape.labeled("unit", 3)
if circle.tag == Shape.Tag.Circle:
print(circle.circle_radius) # 2.0
print(labeled.labeled_count) # 3
print(describe(circle)) # render via the C ABI
bigger = scale(circle, 3.0) # returns a brand-new Shape
Ownership: each Shape owns its ctypes.c_void_p; __del__ calls
weaveffi_shapes_Shape_destroy once the last Python reference is
dropped, and the Shape returned by scale is owned the same
way. The .pyi stub mirrors the class (nested Tag, @classmethod
constructors, and @property getters) for IDE and mypy support.
Build instructions
-
Generate the bindings:
weaveffi generate weaveffi.yaml -o generated --target python -
Build the Rust shared library:
cargo build --release -p your_library -
Install the package (editable install for development):
cd generated/python pip install -e . -
Make the shared library findable at runtime:
- macOS:
export DYLD_LIBRARY_PATH=$PWD/../../target/release - Linux:
export LD_LIBRARY_PATH=$PWD/../../target/release - Windows: place
weaveffi.dllnext to your script or add its directory toPATH.
- macOS:
-
Use the bindings:
from weaveffi import ( ContactType, count_contacts, create_contact, get_contact, ) handle = create_contact("Alice", "alice@example.com", ContactType.Work) contact = get_contact(handle) print(f"{contact.name} ({contact.email})") print(f"Total: {count_contacts()}")
Memory and ownership
-
Strings in: Python
stris encoded to UTF-8 by_string_to_bytesbefore crossing the boundary. ctypes manages the lifetime of the temporary buffer. -
Strings out: owned
const char*returns come back as raw addresses;_take_stringcopies the text and immediately callsweaveffi_free_stringon the producer’s buffer. (_bytes_to_stringis reserved for borrowed strings, such as listener callback parameters, which the wrapper must not free.) -
Bytes: copied in via a ctypes array, copied out via slicing (
_result[:_out_len.value]); the wrapper then releases the producer’s buffer withweaveffi_free_bytes. -
Optional scalars out: the producer boxes the value behind a pointer (null means
None); the wrapper dereferences it and frees the box withweaveffi_free_bytes. -
Lists and maps out: each element is copied (string elements through
_take_string, which frees them individually), then the array buffer itself, or both parallel key/value buffers for a map, is released withweaveffi_free_bytes. -
Structs: wrappers hold an opaque
c_void_p.__del__calls the matching_destroyC function. For deterministic cleanup, use the_PointerGuardcontext manager:with _PointerGuard(handle, _lib.weaveffi_contacts_Contact_destroy): ...
Async support
Async IDL functions (async: true) are exposed as async def
wrappers that integrate directly with asyncio; no worker thread blocks
waiting for the result. The wrapper creates a future on the running
loop, builds a ctypes.CFUNCTYPE completion callback, calls the
_async-suffixed C launcher (which returns immediately), and awaits
the future. From the kvstore sample’s Store.compact:
async def compact(self) -> int:
"""Reclaim space asynchronously; returns the number of bytes reclaimed
Raises
------
KvError
If the call reports one of the domain's error codes.
"""
_fn = _lib.weaveffi_kv_Store_compact_async
_loop = asyncio.get_running_loop()
_fut = _loop.create_future()
def _cb_impl(context, err, result):
# Fires exactly once, on a producer thread: convert (copying
# borrowed buffers) here, then hop back to the event loop.
_state = {"err": None, "val": None}
if err and err.contents.code != 0:
_code = err.contents.code
_msg = err.contents.message.decode("utf-8") if err.contents.message else ""
_lib.weaveffi_error_clear(ctypes.byref(err.contents))
_state["err"] = _kv_error_from(_code, _msg)
else:
_state["val"] = result
def _resolve():
_async_pending.pop(_token, None)
# A cancelled future must not be resolved.
if _fut.cancelled():
return
if _state["err"] is not None:
_fut.set_exception(_state["err"])
else:
_fut.set_result(_state["val"])
_loop.call_soon_threadsafe(_resolve)
_cb_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(_WeaveFFIErrorStruct), ctypes.c_int64)
_cb = _cb_type(_cb_impl)
_token = _async_register(_cb) # pinned until completion
_fn.argtypes = [ctypes.c_void_p, ctypes.c_void_p, _cb_type, ctypes.c_void_p]
_fn.restype = None
_fn(self._ptr, None, _cb, None)
return await _fut
The completion callback fires exactly once, on an arbitrary producer
thread. Result buffers passed to it (strings, bytes, arrays) are owned
by the producer and valid only for the callback’s duration, so the
wrapper deep-copies them inside the callback and never frees them.
Owned-object results (structs, rich enums, interfaces, including
optional ones) are the exception: the callback receives ownership and
adopts the pointer into a wrapper class. Conversion happens on the
producer thread; the wrapper then hops back to the event loop with
loop.call_soon_threadsafe to resolve the future, since asyncio
futures must not be touched from foreign threads.
When the callable is marked throws: true, an error reported through
the callback is mapped through the domain mapper (here
_kv_error_from) and set as the future’s exception, so await raises
the typed error. For a non-throwing callable a non-zero code can only
be a producer bug; the wrapper raises the generic WeaveFFIError
rather than swallowing it.
Each callback trampoline is pinned in the module-level _async_pending
dict until completion, so the GC cannot collect an object the producer
still holds, even if the awaiting coroutine is cancelled. A cancelled
future is never resolved, but the native operation itself keeps
running.
Async interface methods work the same way as bound methods: the receiver pointer is passed as the launcher’s leading argument.
For functions marked cancellable: true the C launcher takes an extra
cancel-token parameter; the Python wrapper always passes None (NULL)
for it, as in the compact example above. The token is not exposed,
so cancelling the awaiting asyncio task does not stop the native
operation. Cancellation tokens are currently surfaced only by the C
and C++ targets.
Callbacks and listeners
IDL callbacks declare a C function-pointer type; a listener pairs
one with register/unregister entry points:
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
Each listener becomes a register/unregister pair of module functions.
Registering wraps the Python callable in a ctypes.CFUNCTYPE
trampoline that decodes each C slot, and returns a uint64
subscription id:
_CFUNC_weaveffi_events_OnMessage_fn = ctypes.CFUNCTYPE(
None, ctypes.c_char_p, ctypes.c_void_p)
def register_message_listener(callback: Callable[[str], None]) -> int:
def _trampoline(message, _context):
callback(_bytes_to_string(message))
_cfunc = _CFUNC_weaveffi_events_OnMessage_fn(_trampoline)
_fn = _lib.weaveffi_events_register_message_listener
_fn.argtypes = [_CFUNC_weaveffi_events_OnMessage_fn, ctypes.c_void_p]
_fn.restype = ctypes.c_uint64
_listener_id = int(_fn(_cfunc, None))
_listener_refs[_listener_id] = _cfunc
return _listener_id
def unregister_message_listener(listener_id: int) -> None:
_fn = _lib.weaveffi_events_unregister_message_listener
# ...
_fn(ctypes.c_uint64(listener_id))
_listener_refs.pop(listener_id, None)
- GC safety: the ctypes function object is pinned in the
module-level
_listener_refsdict, keyed by subscription id, so the garbage collector cannot reclaim a trampoline the producer may still call. Unregistering drops the reference. - Subscription ids: registration returns the
uint64id produced byweaveffi_events_register_message_listener(fn, context); pass it tounregister_message_listenerto stop delivery and release the trampoline. - Threading: the callback fires on the producer’s thread, not the
thread that registered it. Do not block inside it; if results must
reach an asyncio loop or UI thread, marshal them yourself (e.g. with
loop.call_soon_threadsafe).
Typical round trip:
listener_id = register_message_listener(lambda m: print(m))
send_message("hello")
unregister_message_listener(listener_id)
Iterators
Functions returning iter<T> receive an opaque iterator handle from
the C ABI (weaveffi_events_get_messages) and wrap it in a generated
lazy iterator class. The wrapper returns immediately; nothing is
drained, and each consumer step issues exactly one producer next
call (weaveffi_events_GetMessagesIterator_next). The signature is
annotated Iterator[str]:
def get_messages() -> Iterator[str]:
"""
Return an iterator over all sent messages
Returns a lazy iterator: each step pulls one element from the producer. Exhaust or close() the iterator to release its native handle (garbage collection also releases it).
"""
_fn = _lib.weaveffi_events_get_messages
_fn.argtypes = [ctypes.POINTER(_WeaveFFIErrorStruct)]
_fn.restype = ctypes.c_void_p
_err = _WeaveFFIErrorStruct()
_result = _fn(ctypes.byref(_err))
_check_error(_err)
return _GetMessagesIterator(_result)
The per-function iterator class implements the Python iterator
protocol. Each __next__ pulls one element, checks the step’s error
slot, and copies the yielded string with _take_string (which also
frees the producer’s buffer per element):
class _GetMessagesIterator:
"""Lazy iterator over a producer stream: each step pulls one element
across the C boundary. The native handle is released exactly once, on
exhaustion, on close(), or when the iterator is garbage collected."""
def __next__(self):
if self._done:
raise StopIteration
# ... argtypes/restype for _next_fn ...
_out_item = ctypes.c_void_p()
_err = _WeaveFFIErrorStruct()
_has = _next_fn(self._ptr, ctypes.byref(_out_item), ctypes.byref(_err))
_check_error(_err)
if not _has:
self._done = True
self._destroy()
raise StopIteration
return _take_string(_out_item.value)
def close(self):
"""Release the native iterator without draining it."""
self._done = True
self._destroy()
The native handle is destroyed exactly once: eagerly on exhaustion,
via close() when iteration is abandoned early, or from __del__ as
a garbage-collection backstop. _destroy nulls the stored pointer, so
a double destroy is impossible.
Errors from the launcher and from each next follow the function’s
error strategy. A throwing iterator such as the kvstore sample’s
Store.list_keys checks each step with _check_kv_error and raises
the typed domain error (KeyNotFound, IoError, …) from the step
that failed; a non-throwing iterator like get_messages raises the
generic WeaveFFIError only for producer bugs.
Troubleshooting
OSError: cannot find ...: the loader could not locate the shared library. SetDYLD_LIBRARY_PATH/LD_LIBRARY_PATHor copy the library next to your script.WeaveFFIError: ...: the Rust side returned a non-zero error code. CatchWeaveFFIErrorand inspect.code/.message.AttributeError: ... has no attribute 'argtypes': the wrapper setsargtypes/restypeat the call site; ensure you’re calling the generated function, not reaching into_libdirectly.- Garbage-collected struct still referenced from Rust: keep a
Python reference until you’re done; Python will call
__del__only after the last reference is dropped.
.NET
Overview
The .NET target emits a C# class library that wraps the C ABI through
P/Invoke.
Structs and interfaces are exposed as IDisposable classes with
PascalCase members, error domains become managed exception types, and
the project targets net8.0.
What gets generated
| File | Purpose |
|---|---|
generated/dotnet/WeaveFFI.cs | C# bindings: P/Invoke declarations, wrapper classes, enums, exceptions |
generated/dotnet/WeaveFFI.csproj | SDK-style project (net8.0, AllowUnsafeBlocks) |
generated/dotnet/WeaveFFI.nuspec | NuGet package metadata |
generated/dotnet/README.md | Build and pack instructions |
File names and the C# namespace follow the IDL package.name (a
package named kvstore produces Kvstore.cs inside
namespace Kvstore); WeaveFFI is the default.
Type mapping
| IDL type | C# type | P/Invoke type |
|---|---|---|
i32 | int | int |
u32 | uint | uint |
i64 | long | long |
f64 | double | double |
i8 | sbyte | sbyte |
i16 | short | short |
u8 | byte | byte |
u16 | ushort | ushort |
u64 | ulong | ulong |
f32 | float | float |
bool | bool | int |
string | string | IntPtr |
handle | ulong | ulong |
bytes | byte[] | IntPtr |
StructName | StructName | IntPtr |
InterfaceName | InterfaceName | IntPtr |
EnumName (plain) | EnumName | int |
EnumName (rich) | EnumName | IntPtr |
T? | T? (nullable) | IntPtr |
[T] | T[] | IntPtr |
{K: V} | Dictionary<K, V> | IntPtr |
iter<T> | IEnumerable<T> (lazy) | IntPtr |
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
doc: Type of contact
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: A contact record
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
- { name: contact_type, type: ContactType }
functions:
- name: create_contact
params:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: list_contacts
params: []
return: "[Contact]"
Enums become C# enums with explicit values:
/// <summary>Type of contact</summary>
public enum ContactType
{
Personal = 0,
Work = 1,
Other = 2,
}
Structs are wrapped in IDisposable classes with a finalizer safety
net:
public class Contact : IDisposable
{
private IntPtr _handle;
private bool _disposed;
internal Contact(IntPtr handle)
{
_handle = handle;
}
internal IntPtr Handle => _handle;
public string Name
{
get
{
var ptr = NativeMethods.weaveffi_contacts_Contact_get_name(_handle);
var str = WeaveFFIHelpers.PtrToString(ptr);
NativeMethods.weaveffi_free_string(ptr);
return str ?? "";
}
}
public void Dispose()
{
if (!_disposed)
{
NativeMethods.weaveffi_contacts_Contact_destroy(_handle);
_disposed = true;
}
GC.SuppressFinalize(this);
}
~Contact()
{
Dispose();
}
}
Functions live as static methods on a class named after the module.
Method names are PascalCase with the module prefix stripped
(Contacts.CreateContact, not ContactsCreateContact); set
strip_module_prefix: false in the .NET generator config (or under
[global]) to keep prefixed names. Nested IDL modules flatten into a
single class with a concatenated name (a stats module nested under
kv becomes KvStats with KvStats.GetStats):
public static class Contacts
{
public static ulong CreateContact(string name, string? email, int age)
{
var err = new WeaveFFIError();
var namePtr = Marshal.StringToCoTaskMemUTF8(name);
var emailPtr = email != null ? Marshal.StringToCoTaskMemUTF8(email) : IntPtr.Zero;
try
{
var result = NativeMethods.weaveffi_contacts_create_contact(namePtr, emailPtr, age, ref err);
WeaveFFIError.Check(err);
return result;
}
finally
{
Marshal.FreeCoTaskMem(namePtr);
if (emailPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(emailPtr);
}
}
}
P/Invoke entries live in an internal NativeMethods class:
internal static class NativeMethods
{
private const string LibName = "weaveffi";
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
internal static extern void weaveffi_free_string(IntPtr ptr);
[DllImport(LibName, EntryPoint = "weaveffi_contacts_create_contact", CallingConvention = CallingConvention.Cdecl)]
internal static extern ulong weaveffi_contacts_create_contact(IntPtr name, IntPtr email, int age, ref WeaveFFIError err);
}
Typed errors
The library defines WeaveFFIException with a Code property. A
module’s error domain adds a derived exception named by replacing the
trailing Error stem with Exception (KvError becomes
KvException), carrying one const int per code and a FromCode
factory. From the kvstore sample:
/// <summary>Typed exception for the KvError error domain (module kv).</summary>
public class KvException : WeaveFFIException
{
/// <summary>key not found</summary>
public const int KeyNotFound = 1001;
/// <summary>entry expired</summary>
public const int Expired = 1002;
/// <summary>store has reached capacity</summary>
public const int StoreFull = 1003;
/// <summary>I/O failure</summary>
public const int IoError = 1004;
public KvException(int code, string message) : base(code, message)
{
}
/// <summary>Wraps a raw error slot in the typed exception, falling
/// back to <see cref="WeaveFFIException"/> for unknown codes.</summary>
internal static WeaveFFIException FromCode(int code, string message)
{
switch (code)
{
case KeyNotFound:
return new KvException(code, string.IsNullOrEmpty(message) ? "key not found" : message);
// ... Expired, StoreFull, IoError ...
default:
return new WeaveFFIException(code, message);
}
}
}
Only callables marked throws: true in the IDL surface the typed
exception: their wrappers check the error slot with
WeaveFFIError.CheckKv, which throws KvException for domain codes
and plain WeaveFFIException for anything else (producer panics,
marshalling failures), and their doc comments carry an
<exception cref="KvException"> tag. A callable without throws uses
the generic WeaveFFIError.Check, which only throws
WeaveFFIException if the producer misbehaves.
try
{
store.Delete("missing");
}
catch (KvException e) when (e.Code == KvException.KeyNotFound)
{
// specific code
}
Interfaces
An interfaces: entry becomes a class implementing IDisposable.
Constructors are static factories (a constructor named new becomes a
public C# constructor), methods are PascalCase instance methods,
statics are static methods, and Dispose() calls the C destructor with
a finalizer as a safety net. From the kvstore sample (trimmed):
public class Store : IDisposable
{
private IntPtr _handle;
private bool _disposed;
internal Store(IntPtr handle)
{
_handle = handle;
}
/// <summary>Open (or create) a store backed by the given filesystem path</summary>
/// <exception cref="KvException">Thrown when the call reports a KvError code.</exception>
public static Store Open(string path)
{
var err = new WeaveFFIError();
var pathPtr = Marshal.StringToCoTaskMemUTF8(path);
try
{
var result = NativeMethods.weaveffi_kv_Store_open(pathPtr, ref err);
WeaveFFIError.CheckKv(err);
return new Store(result);
}
finally
{
Marshal.FreeCoTaskMem(pathPtr);
}
}
public bool Put(string key, byte[] value, EntryKind kind, long? ttlSeconds) { /* throws KvException */ }
public Entry? Get(string key) { /* throws KvException */ }
public IEnumerable<string> ListKeys(string? prefix) { /* lazy; see Memory and ownership */ }
public long Count() { /* generic check only (no throws) */ }
/// <exception cref="KvException">Thrown when the call reports a KvError code.</exception>
public async Task<long> Compact() { /* see Async support */ }
[Obsolete("use put() with explicit kind")]
public bool LegacyPut(string key, byte[] value) { /* ... */ }
/// <summary>The largest number of live entries one store will hold</summary>
public static long DefaultCapacity()
{
var err = new WeaveFFIError();
var result = NativeMethods.weaveffi_kv_Store_default_capacity(ref err);
WeaveFFIError.Check(err);
return result;
}
public void Dispose()
{
if (!_disposed)
{
NativeMethods.weaveffi_kv_Store_destroy(_handle);
_disposed = true;
}
GC.SuppressFinalize(this);
}
~Store()
{
Dispose();
}
}
Functions elsewhere in the IDL pass the wrapper’s handle across the
boundary (KvStats.GetStats(store) reads store.Handle and returns a
new Stats). Deprecated members carry [Obsolete]:
using var store = Store.Open("/tmp/cache.kv");
store.Put("alpha", new byte[] { 1 }, EntryKind.Persistent, null);
Console.WriteLine($"{store.Count()} / {Store.DefaultCapacity()}");
long reclaimed = await store.Compact();
Rich (algebraic) enums
A rich (algebraic) enum, a sum type whose variants carry associated
data, lowers to an opaque handle at the C ABI, just like a struct,
and uses the same IDisposable ownership model as the struct wrappers
above. The generated C# type is a class wrapping an IntPtr, with one
static factory per variant, a nested Tag enum for the discriminant, and
per-variant property getters. (A plain C-style enum with no payloads
stays a normal C# enum backed by int; see above.)
For the shapes module’s Shape enum (Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }), the generator emits (abridged):
/// <summary>An algebraic shape (sum type with associated data)</summary>
public class Shape : IDisposable
{
private IntPtr _handle;
private bool _disposed;
internal Shape(IntPtr handle)
{
_handle = handle;
}
internal IntPtr Handle => _handle;
public enum Tag
{
Empty = 0,
Circle = 1,
Rectangle = 2,
Labeled = 3,
}
public Tag GetTag()
{
return (Tag)NativeMethods.weaveffi_shapes_Shape_tag(_handle);
}
/// <summary>A circle with a radius</summary>
public static Shape Circle(double radius)
{
var err = new WeaveFFIError();
var result = NativeMethods.weaveffi_shapes_Shape_Circle_new(radius, ref err);
WeaveFFIError.Check(err);
return new Shape(result);
}
/// <summary>A labeled shape with a small count</summary>
public static Shape Labeled(string label, byte count)
{
var err = new WeaveFFIError();
var labelPtr = Marshal.StringToCoTaskMemUTF8(label);
try
{
var result = NativeMethods.weaveffi_shapes_Shape_Labeled_new(labelPtr, count, ref err);
WeaveFFIError.Check(err);
return new Shape(result);
}
finally
{
Marshal.FreeCoTaskMem(labelPtr);
}
}
/// <summary>Radius in points</summary>
public double CircleRadius
{
get
{
return NativeMethods.weaveffi_shapes_Shape_Circle_get_radius(_handle);
}
}
public byte LabeledCount
{
get
{
return NativeMethods.weaveffi_shapes_Shape_Labeled_get_count(_handle);
}
}
public void Dispose()
{
if (!_disposed)
{
NativeMethods.weaveffi_shapes_Shape_destroy(_handle);
_disposed = true;
}
GC.SuppressFinalize(this);
}
~Shape()
{
Dispose();
}
}
The static factories (Shape.Empty(), Shape.Circle(double),
Shape.Rectangle(float, float), Shape.Labeled(string, byte)) call the
per-variant constructors weaveffi_shapes_Shape_<Variant>_new; GetTag()
reads the discriminant via weaveffi_shapes_Shape_tag; each getter reads
one variant field via weaveffi_shapes_Shape_<Variant>_get_<field>; and
Dispose() frees the handle via weaveffi_shapes_Shape_destroy. The
P/Invoke entries live in NativeMethods:
[DllImport(LibName, EntryPoint = "weaveffi_shapes_Shape_tag", CallingConvention = CallingConvention.Cdecl)]
internal static extern int weaveffi_shapes_Shape_tag(IntPtr ptr);
[DllImport(LibName, EntryPoint = "weaveffi_shapes_Shape_Circle_new", CallingConvention = CallingConvention.Cdecl)]
internal static extern IntPtr weaveffi_shapes_Shape_Circle_new(double radius, ref WeaveFFIError err);
[DllImport(LibName, EntryPoint = "weaveffi_shapes_Shape_destroy", CallingConvention = CallingConvention.Cdecl)]
internal static extern void weaveffi_shapes_Shape_destroy(IntPtr ptr);
Free functions that take or return the enum live on the module class
Shapes and pass the wrapper’s handle across the boundary
(Shapes.Describe(Shape), Shapes.Scale(Shape, double)):
using var c = Shape.Circle(2.0);
Console.WriteLine(c.GetTag()); // Tag.Circle
Console.WriteLine(c.CircleRadius); // 2
using var bigger = Shapes.Scale(c, 3.0); // returns a new Shape
Console.WriteLine(Shapes.Describe(bigger));
Ownership: a Shape owns its native handle, so dispose every Shape
you create or receive, including the one returned by Shapes.Scale, with
using or an explicit Dispose(). The finalizer is a safety net that
runs on a non-deterministic schedule.
Build instructions
-
Generate the bindings:
weaveffi generate api.yaml -o generated/ --target dotnet -
Build:
cd generated/dotnet dotnet build -
Pack as NuGet:
dotnet pack -c ReleaseThe resulting
.nupkglives inbin/Release/. For production packages, bundle the native cdylib inside the package underruntimes/{rid}/native/. -
Make the cdylib findable at runtime: place it next to the built DLL, set
LD_LIBRARY_PATH/DYLD_LIBRARY_PATH, or include it in the NuGet package as above.
Memory and ownership
- Each struct and interface class implements
IDisposable; useusingfor deterministic cleanup. The finalizer is a safety net only and runs on a non-deterministic schedule. - Strings returned from getters are copied into managed memory and the
raw pointer is freed via
weaveffi_free_stringimmediately, so string properties do not require any disposal. - Strings passed as parameters are marshalled with
Marshal.StringToCoTaskMemUTF8and freed in afinallyblock. - Returned
byte[], array, and dictionary buffers are copied into managed memory and released withweaveffi_free_bytes; string elements are freed individually withweaveffi_free_stringfirst. - Optional struct returns surface as
IntPtr.Zerofrom the C ABI and becomenullin C#. A boxed optional scalar is dereferenced and its box freed withweaveffi_free_bytes. iter<T>functions return a lazy, single-useIEnumerable<T>(WeaveFFIOnceEnumerable<T>) that pulls one item through the C_nextfunction per enumeration step; each string element is copied and freed withweaveffi_free_string, and the native iterator handle is destroyed in afinallyblock when enumeration completes, a step fails, or the enumerator is disposed early (aforeachdisposes it automatically, including on early exit). A throwing function checks the launch and each step with the domain checker (Store.ListKeysthrowsKvExceptionfrom the failing step).
Async support
Async IDL functions are exposed as async Task<T> methods (named like
every other wrapper: no extra Async suffix is appended). The wrapper
wires the C ABI completion callback into a TaskCompletionSource<T>
and keeps the callback delegate alive with a GCHandle while the call
is in flight:
/// <exception cref="TaskException">Thrown when the call reports a TaskError code.</exception>
public static async Task<TaskResult> RunTask(string name)
{
var tcs = new TaskCompletionSource<TaskResult>(TaskCreationOptions.RunContinuationsAsynchronously);
NativeMethods.AsyncCb_weaveffi_tasks_run_task callback = (context, err, result) =>
{
try
{
// ... tcs.SetException(TaskException.FromCode(...)) on error ...
tcs.SetResult(new TaskResult(result));
}
finally
{
if (context != IntPtr.Zero)
{
GCHandle.FromIntPtr(context).Free();
}
}
};
var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal);
var ctx = GCHandle.ToIntPtr(gcHandle);
// ... marshal parameters, gcHandle.Free() in a catch if the native call throws ...
NativeMethods.weaveffi_tasks_run_task_async(namePtr, callback, ctx);
return await tcs.Task;
}
- The
GCHandleprevents the GC from collecting the delegate (and the native thunk the producer will call) before completion. It is freed exactly once: in the callback’sfinally, or on thecatchpath if the native call itself throws synchronously. - The completion callback runs on the producer’s native thread;
RunContinuationsAsynchronouslykeeps awaiting code from running inline on that thread. - For a callable marked
throws: true, an error faults the task with the domain exception via itsFromCodefactory (KvException.FromCodeonStore.Compact()); otherwise a failure can only be a producer bug and faults the task withWeaveFFIException. - Result ownership follows the async contract: string, bytes, array,
map, and boxed optional scalar results are borrowed for the
callback’s duration, so the callback deep-copies them into managed
values and never frees them (the producer does after the callback
returns). Object results (records, rich enums, interfaces, including
optional ones) are the exception: the callback receives ownership,
and the wrapper adopts the pointer, as
new TaskResult(result)does above.
Async interface methods follow the same pattern as instance methods:
await store.Compact() returns Task<long>.
For functions marked cancellable: true the wrapper passes
IntPtr.Zero for the C ABI’s cancel-token slot; no
CancellationToken parameter is exposed. Only the C and C++
targets expose cancellation tokens.
Callbacks and listeners
An IDL listener becomes a register/unregister pair on the module
class. Registration takes an Action<...> and returns a ulong
subscription id; unregistration takes that id back:
public static ulong RegisterMessageListener(Action<string> callback)
public static void UnregisterMessageListener(ulong id)
The id is the uint64 returned by the C ABI’s
weaveffi_events_register_message_listener(callback_fn, context).
Registration wraps the Action in a Cdecl delegate trampoline and
stores it in a registry keyed by the subscription id so the GC cannot
collect it while the native side may still call it:
private static readonly object _listenerLock = new object();
private static readonly Dictionary<ulong, Delegate> _listenerRefs = new Dictionary<ulong, Delegate>();
public static ulong RegisterMessageListener(Action<string> callback)
{
NativeMethods.Cb_weaveffi_events_OnMessage_fn trampoline = (message, context) =>
{
callback(Marshal.PtrToStringUTF8(message) ?? "");
};
ulong id;
lock (_listenerLock)
{
id = NativeMethods.weaveffi_events_register_message_listener(trampoline, IntPtr.Zero);
_listenerRefs[id] = trampoline;
}
return id;
}
The trampoline’s delegate type is declared with
[UnmanagedFunctionPointer(CallingConvention.Cdecl)].
Events.UnregisterMessageListener(id) calls the C ABI unregister first
and then drops the registry entry, releasing the delegate for
collection.
Threading caveats:
- The callback runs on the producer’s native thread, not on any
captured
SynchronizationContext. Post to your UI thread or dispatcher yourself if needed. - Keep callbacks fast and non-throwing; they execute while the native producer is delivering the event.
Troubleshooting
DllNotFoundException: Unable to load DLL 'weaveffi': the runtime cannot find the shared library. Place it in the application directory or setLD_LIBRARY_PATH/DYLD_LIBRARY_PATH.AccessViolationExceptionon dispose: the struct has been disposed twice. Wrap usage inusingand avoid passing handles around once disposed.- Strings returned with garbage characters: make sure your
binding is targeting
UTF8(Marshal.PtrToStringUTF8,StringToCoTaskMemUTF8); the generated helpers do this for you. - NuGet consumers cannot find the cdylib: ship it inside the
package under
runtimes/{rid}/native/so the .NET runtime resolves it automatically.
C++
Overview
The C++ target emits a header-only library weaveffi.hpp that wraps the
C ABI in idiomatic C++17. Structs and interfaces become RAII classes with
deleted copies and movable handles, error domains map to typed exception
hierarchies, async functions return std::future, and listeners accept
std::function callbacks. A CMakeLists.txt is included so the generated
directory can be dropped into any CMake build.
What gets generated
| File | Purpose |
|---|---|
generated/cpp/weaveffi.hpp | Header-only bindings: extern “C” declarations, RAII wrappers, enum classes, inline function wrappers |
generated/cpp/CMakeLists.txt | INTERFACE library target (weaveffi_cpp) |
generated/cpp/README.md | Build instructions |
Type mapping
| IDL type | C++ type | Passed as parameter |
|---|---|---|
i32 | int32_t | int32_t |
u32 | uint32_t | uint32_t |
i64 | int64_t | int64_t |
u64 | uint64_t | uint64_t |
i8 | int8_t | int8_t |
i16 | int16_t | int16_t |
u8 | uint8_t | uint8_t |
u16 | uint16_t | uint16_t |
f32 | float | float |
f64 | double | double |
bool | bool | bool |
string | std::string | const std::string& |
bytes | std::vector<uint8_t> | const std::vector<uint8_t>& |
handle | void* | void* |
StructName | StructName | const StructName& |
InterfaceName | InterfaceName (RAII class) | const InterfaceName& |
EnumName (plain) | EnumName (enum class) | EnumName |
EnumName (rich) | EnumName (RAII class) | const EnumName& |
T? | std::optional<T> | const std::optional<T>& |
[T] | std::vector<T> | const std::vector<T>& |
{K: V} | std::unordered_map<K, V> | const std::unordered_map<K, V>& |
iter<T> | generated lazy range class (return only; see Iterators) | n/a |
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
- { name: contact_type, type: ContactType }
functions:
- name: create_contact
params:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
return: Contact
- name: find_contact
params:
- { name: id, type: i32 }
return: "Contact?"
- name: list_contacts
params: []
return: "[Contact]"
- name: count_contacts
params: []
return: i32
- name: fetch_contact
async: true
params:
- { name: id, type: i32 }
return: Contact
Enums become enum class:
enum class ContactType : int32_t {
Personal = 0,
Work = 1,
Other = 2
};
Structs become RAII handle wrappers with deleted copy and noexcept move:
class Contact {
void* handle_;
public:
explicit Contact(void* h) : handle_(h) {}
~Contact() {
if (handle_) weaveffi_contacts_Contact_destroy(
static_cast<weaveffi_contacts_Contact*>(handle_));
}
Contact(const Contact&) = delete;
Contact& operator=(const Contact&) = delete;
Contact(Contact&& o) noexcept : handle_(o.handle_) { o.handle_ = nullptr; }
std::string name() const {
const char* raw = weaveffi_contacts_Contact_get_name(
static_cast<const weaveffi_contacts_Contact*>(handle_));
std::string ret(raw);
weaveffi_free_string(raw);
return ret;
}
};
Free functions live in a nested namespace per module inside the outer
weaveffi namespace (configurable via namespace), keeping their
snake_case IDL names with no module prefix, and throw on failure:
namespace weaveffi {
namespace contacts {
inline Contact create_contact(
const std::string& name,
const std::optional<std::string>& email,
int32_t age)
{
weaveffi_error err{};
auto result = weaveffi_contacts_create_contact(
name.c_str(),
email.has_value() ? email.value().c_str() : nullptr,
age, &err);
detail::check(err);
return Contact(result);
}
} // namespace contacts
} // namespace weaveffi
The module namespace replaces the old flat contacts_create_contact
spelling; call it as weaveffi::contacts::create_contact(...). Nested IDL
modules nest namespaces the same way (weaveffi::kv::stats::get_stats).
Typed errors
WeaveFFIError extends std::runtime_error and carries the raw code().
A module’s error domain generates a typed hierarchy: one class named after
the domain, plus one subclass per declared code, each named in PascalCase
with exactly one Error suffix. From the contacts sample’s
ContactsError domain:
namespace weaveffi {
class ContactsError : public WeaveFFIError {
public:
ContactsError(int32_t code, const std::string& msg) : WeaveFFIError(code, msg) {}
};
/** name must not be empty */
class InvalidNameError : public ContactsError { /* ... */ };
/** contact not found */
class NotFoundError : public ContactsError { /* ... */ };
} // namespace weaveffi
A callable declared with throws: true routes its failure through a
per-domain checker (detail::check_contacts) that throws the most specific
subclass, so you can catch a single code, the domain, or the generic base:
try {
auto contact = book.get(42);
} catch (const weaveffi::NotFoundError& e) {
std::cerr << "Not found: " << e.what() << '\n';
} catch (const weaveffi::ContactsError& e) {
std::cerr << "Contacts error " << e.code() << ": " << e.what() << '\n';
}
A callable without throws has the same C++ signature (C++ has no checked
exceptions), but its failures can only be producer bugs (a panic or a
marshalling failure), which arrive as the generic weaveffi::WeaveFFIError
rather than a domain type. An unknown code on the typed path falls back to
the domain class itself (ContactsError).
Interfaces
An interfaces: entry becomes a move-only RAII class following the same
ownership model as struct wrappers. Constructors become static factories,
methods are instance members, statics are static members, and the
destructor calls the implicit C _destroy symbol. From the kvstore
sample’s Store (trimmed):
/** An embedded key-value store owning its entries */
class Store {
void* handle_;
public:
~Store() {
if (handle_) weaveffi_kv_Store_destroy(static_cast<weaveffi_kv_Store*>(handle_));
}
Store(const Store&) = delete;
Store(Store&& other) noexcept;
/** Open (or create) a store backed by the given filesystem path */
static Store open(const std::string& path) {
weaveffi_error err{};
auto result = weaveffi_kv_Store_open(path.c_str(), &err);
detail::check_kv(err); // throws: true -> typed KvError path
return Store(result);
}
/** Remove the entry for the given key, returning true if it existed */
bool delete_(const std::string& key) const;
/** Return the number of live entries in the store */
int64_t count() const; // no throws: generic check only
/** Stream every key, optionally filtered by a prefix */
ListKeysIterator list_keys(const std::optional<std::string>& prefix) const;
/** Reclaim space asynchronously; returns the number of bytes reclaimed */
std::future<int64_t> compact(weaveffi_cancel_token* cancel_token = nullptr) const;
/** The largest number of live entries one store will hold */
static int64_t default_capacity();
};
Method names keep their snake_case IDL spelling; a name that collides with
a C++ keyword gains a trailing underscore (delete → delete_).
Deprecated members carry [[deprecated("...")]]. An interface parameter is
passed as const Store& (borrowed); an interface return wraps the owned
pointer in a new instance.
Rich (algebraic) enums
An enum whose variants declare fields is a rich (algebraic) enum, a sum
type with associated data. Plain C-style enums stay enum class; a rich enum
instead becomes an opaque RAII wrapper class with the same ownership model as a
struct wrapper, plus a nested Tag, static factory methods, and per-variant
getters. From the shapes sample:
namespace weaveffi {
class Shape {
void* handle_;
public:
enum class Tag : int32_t { Empty = 0, Circle = 1, Rectangle = 2, Labeled = 3 };
Tag tag() const;
static Shape Empty();
static Shape Circle(double radius);
static Shape Rectangle(float width, float height);
static Shape Labeled(const std::string& label, uint8_t count);
double circle_radius() const;
float rectangle_width() const;
float rectangle_height() const;
std::string labeled_label() const;
uint8_t labeled_count() const;
~Shape(); // calls weaveffi_shapes_Shape_destroy
Shape(const Shape&) = delete; // move-only, like struct wrappers
Shape(Shape&&) noexcept;
};
} // namespace weaveffi
Build a variant with its factory, switch on tag(), and read only the
matching getters. Free functions take and return the wrapper by const& /
by value:
weaveffi::Shape shape = weaveffi::Shape::Circle(2.0);
if (shape.tag() == weaveffi::Shape::Tag::Circle) {
std::cout << "radius = " << shape.circle_radius() << '\n';
}
std::cout << weaveffi::shapes_describe(shape) << '\n';
weaveffi::Shape bigger = weaveffi::shapes_scale(shape, 3.0);
Ownership follows the struct-wrapper rules: the destructor calls
weaveffi_shapes_Shape_destroy, copies are deleted, and moves transfer the
handle, no manual free required.
Build instructions
The generated CMakeLists.txt defines an INTERFACE library (the
project version mirrors package.version from the IDL):
cmake_minimum_required(VERSION 3.14)
project(weaveffi_cpp VERSION 1.0.0)
add_library(weaveffi_cpp INTERFACE)
target_include_directories(weaveffi_cpp INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(weaveffi_cpp INTERFACE weaveffi)
target_compile_features(weaveffi_cpp INTERFACE cxx_std_17)
Consume it from your project:
add_subdirectory(path/to/generated/cpp)
add_executable(myapp main.cpp)
target_link_libraries(myapp weaveffi_cpp)
Then #include "weaveffi.hpp" and link against the Rust shared library
(libweaveffi.dylib, libweaveffi.so, or weaveffi.dll).
Memory and ownership
- Struct and interface wrappers own a single
void*handle. The destructor calls the C_destroyfunction. Copies are deleted; moves transfer ownership by nulling the source handle. - Strings returned from getters are copied into
std::stringand the raw pointer is freed viaweaveffi_free_stringbefore returning. - Optional fields use
std::optional<T>; anullptrfrom the C layer becomesstd::nullopt. A returned optional scalar arrives boxed behind a pointer; the wrapper dereferences it and frees the box withweaveffi_free_bytes. std::vector<T>returns own their contents: the wrapper copies each element (freeing string elements individually withweaveffi_free_string), then releases the producer’s buffer withweaveffi_free_bytes; map returns release both parallel key/value buffers the same way. List parameters borrow the underlying buffer for the duration of the call.
Callbacks and listeners
Listeners surface as free functions in the module’s namespace taking
std::function. The register wrapper boxes the callable in a
std::shared_ptr, hands the C ABI a capture-less trampoline plus the raw
pointer as context, and pins the box in a global registry so it stays
alive until unregister. From the events sample (trimmed):
namespace detail {
inline std::mutex& wv_listener_mutex() {
static std::mutex m;
return m;
}
inline std::unordered_map<uint64_t, std::shared_ptr<void>>& wv_listener_registry() {
static std::unordered_map<uint64_t, std::shared_ptr<void>> registry;
return registry;
}
} // namespace detail
namespace events {
inline uint64_t register_message_listener(std::function<void(std::string)> callback) {
auto fn = std::make_shared<std::function<void(std::string)>>(std::move(callback));
uint64_t id = weaveffi_events_register_message_listener(
[](const char* message, void* context) {
auto& cb = *static_cast<std::function<void(std::string)>*>(context);
cb(std::string(message ? message : ""));
},
fn.get());
std::lock_guard<std::mutex> lock(detail::wv_listener_mutex());
detail::wv_listener_registry()[id] = fn;
return id;
}
inline void unregister_message_listener(uint64_t id) {
weaveffi_events_unregister_message_listener(id);
std::lock_guard<std::mutex> lock(detail::wv_listener_mutex());
detail::wv_listener_registry().erase(id);
}
} // namespace events
register_*returns theuint64_tsubscription id from the C layer. The registry (detail::wv_listener_registry(), astd::unordered_map<uint64_t, std::shared_ptr<void>>guarded bydetail::wv_listener_mutex()) maps that id to the boxedstd::function, keeping it alive while events can still fire.unregister_*first unregisters at the C layer, then erases the registry entry, releasing the callable.- The static trampoline converts the C arguments to C++ types
(
const char*→std::string) before invoking the stored function. - The callback runs on the producer’s thread, not the thread that registered it; capture and synchronize accordingly.
uint64_t id = weaveffi::events::register_message_listener(
[](std::string message) { std::cout << message << '\n'; });
weaveffi::events::send_message("hello");
weaveffi::events::unregister_message_listener(id);
Async support
Async IDL functions return std::future<T>. The wrapper allocates a
heap-owned std::promise, hands the C ABI a callback that resolves
(or rejects) the promise, and returns the corresponding future:
inline std::future<Contact> fetch_contact(int32_t id) {
auto* promise_ptr = new std::promise<Contact>();
auto future = promise_ptr->get_future();
weaveffi_contacts_fetch_contact_async(id,
[](void* context, weaveffi_error* err,
weaveffi_contacts_Contact* result) {
auto* p = static_cast<std::promise<Contact>*>(context);
if (err && err->code != 0) {
std::string msg(err->message ? err->message : "unknown error");
p->set_exception(detail::make_error(err->code, msg));
} else {
p->set_value(Contact(result));
}
delete p;
}, static_cast<void*>(promise_ptr));
return future;
}
Use it with .get() (blocking) or compose with your event loop. The
completion lambda runs exactly once, on an arbitrary producer thread; it
completes (or rejects) the promise and then deletes it. Result buffers
passed to the callback (strings, bytes, arrays, and the error message)
are borrowed from the producer for the callback’s duration, so the
lambda copies them into C++ values before returning and never frees
them. Owned-object results are the exception: the callback receives
ownership, so Contact(result) above adopts the pointer into a RAII
wrapper. An async callable with throws: true rejects with the
module’s typed domain exception (detail::make_kv_error and friends);
one without throws rejects with the generic WeaveFFIError only when
the producer has a bug.
When the IDL marks the callable cancellable: true, the wrapper gains
a trailing weaveffi_cancel_token* parameter defaulting to nullptr.
From the kvstore sample’s async method Store.compact:
/** Reclaim space asynchronously; returns the number of bytes reclaimed */
std::future<int64_t> compact(weaveffi_cancel_token* cancel_token = nullptr) const;
weaveffi_cancel_token* token = weaveffi_cancel_token_create();
auto fut = store.compact(token);
weaveffi_cancel_token_cancel(token); // from any thread
// fut.get() throws (typed KvError) if the operation was cancelled
weaveffi_cancel_token_destroy(token);
C++ is one of only three targets (C, C++, Kotlin) that expose the cancel token; see Async functions.
Iterators
iter<T> return values surface as a generated move-only RAII range
class with begin()/end(), so results stream in constant memory:
nothing is drained up front, and each iteration step pulls exactly one
element from the producer through _next. From the events sample
(get_messages returns iter<string>, trimmed):
/**
* A lazy, move-only range over the `std::string` elements produced by `get_messages()`.
*/
class GetMessagesIterator {
weaveffi_events_GetMessagesIterator* handle_;
public:
~GetMessagesIterator() {
if (handle_) weaveffi_events_GetMessagesIterator_destroy(handle_);
}
GetMessagesIterator(const GetMessagesIterator&) = delete;
GetMessagesIterator(GetMessagesIterator&&) noexcept;
/** Pulls the next element, or `std::nullopt` once exhausted. */
std::optional<std::string> next() {
if (!handle_) return std::nullopt;
weaveffi_error err{};
const char* item{};
int32_t has_item = weaveffi_events_GetMessagesIterator_next(handle_, &item, &err);
if (err.code != 0) {
weaveffi_events_GetMessagesIterator_destroy(handle_);
handle_ = nullptr;
detail::check(err);
}
if (has_item == 0) {
weaveffi_events_GetMessagesIterator_destroy(handle_);
handle_ = nullptr;
return std::nullopt;
}
std::string value(item);
weaveffi_free_string(item);
return value;
}
struct sentinel {};
/** Single-pass input iterator; each increment pulls one element. */
class iterator { /* input_iterator_tag; compares against sentinel */ };
iterator begin() { return iterator(this); }
sentinel end() const { return sentinel{}; }
};
inline GetMessagesIterator get_messages() {
weaveffi_error err{};
weaveffi_events_GetMessagesIterator* iter = weaveffi_events_get_messages(&err);
detail::check(err);
return GetMessagesIterator(iter);
}
The range is single-pass: begin() returns an input iterator that
compares against a sentinel, so a plain range-for works:
for (const std::string& message : weaveffi::events::get_messages()) {
std::cout << message << '\n';
}
Each pulled string is copied into std::string and its native
allocation freed with weaveffi_free_string; record elements are
adopted by RAII wrappers. The producer iterator is destroyed exactly
once: eagerly when next() reports exhaustion (or an error), or from
the range’s destructor when iteration is abandoned early (the handle
is nulled, so a double destroy is impossible).
Errors from the launcher and from each next follow the function’s
error strategy. A throwing function like the kvstore sample’s
Store::list_keys checks both through detail::check_kv, so the step
that failed throws the typed KvError subclass (after releasing the
iterator); a non-throwing function like get_messages throws the
generic WeaveFFIError only for producer bugs.
Troubleshooting
undefined reference to weaveffi_*: link against the Rust cdylib. The header alone is not enough.- Double-free crashes: RAII wrappers delete copy operators on
purpose. If you see double-frees, somewhere you have a manual copy or
a raw
void*shared between wrappers. - Exceptions not caught across DLL boundaries on MSVC: build the
consumer and the dynamically loaded library with the same
_HAS_EXCEPTIONSsetting and CRT. std::optionalis missing: the header requires C++17. Addtarget_compile_features(... cxx_std_17)to your CMake target.
Dart
Overview
The Dart target produces a pure-Dart FFI package that wraps the C ABI
using dart:ffi. It opens the
shared library with DynamicLibrary.open and resolves each symbol via
lookupFunction. There’s no native compilation step or ffigen run
required; the generated .dart file is ready to import.
What gets generated
| File | Purpose |
|---|---|
dart/lib/weaveffi.dart | dart:ffi bindings: loader, typedefs, lookups, wrappers, struct/enum classes |
dart/pubspec.yaml | Package metadata and package:ffi dependency |
dart/README.md | Basic usage instructions |
Type mapping
| IDL type | Dart type | Native FFI type | Dart FFI type |
|---|---|---|---|
i32 | int | Int32 | int |
u32 | int | Uint32 | int |
i64 | int | Int64 | int |
f64 | double | Double | double |
i8 | int | Int8 | int |
i16 | int | Int16 | int |
u8 | int | Uint8 | int |
u16 | int | Uint16 | int |
u64 | int | Uint64 | int |
f32 | double | Float | double |
bool | bool | Int32 | int |
string | String | Pointer<Utf8> | Pointer<Utf8> |
bytes | List<int> | Pointer<Uint8> | Pointer<Uint8> |
handle | int | Int64 | int |
StructName | StructName | Pointer<Void> | Pointer<Void> |
InterfaceName | InterfaceName | Pointer<Void> | Pointer<Void> |
EnumName (plain) | EnumName | Int32 | int |
EnumName (rich) | EnumName | Pointer<Void> | Pointer<Void> |
T? | T? | same as inner type | same as inner type |
[T] | List<T> | Pointer<Void> | Pointer<Void> |
{K: V} | Map<K, V> | Pointer<Void> | Pointer<Void> |
iter<T> | Iterable<T> (lazy) | Pointer<Void> | Pointer<Void> |
Booleans cross as Int32 (0/1) and the wrapper converts both ways.
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
doc: Type of contact
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: A contact record
fields:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: age, type: i32 }
functions:
- name: create_contact
params:
- { name: name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: find_contact
params:
- { name: id, type: i32 }
return: "Contact?"
The loader auto-detects the platform:
DynamicLibrary _openLibrary() {
// An explicit path in WEAVEFFI_LIBRARY wins, so callers can point at a
// specific build artifact regardless of its file name or location.
final override = Platform.environment['WEAVEFFI_LIBRARY'];
if (override != null && override.isNotEmpty) return DynamicLibrary.open(override);
if (Platform.isMacOS) return DynamicLibrary.open('libweaveffi.dylib');
if (Platform.isLinux) return DynamicLibrary.open('libweaveffi.so');
if (Platform.isWindows) return DynamicLibrary.open('weaveffi.dll');
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
}
final DynamicLibrary _lib = _openLibrary();
Enums become Dart enhanced enums:
/// Type of contact
enum ContactType {
personal(0),
work(1),
other(2),
;
const ContactType(this.value);
final int value;
static ContactType fromValue(int value) =>
ContactType.values.firstWhere((e) => e.value == value);
}
Structs are wrapped in classes with a dispose() method and getter
methods that call the C accessors:
/// A contact record
class Contact {
final Pointer<Void> _handle;
Contact._(this._handle);
void dispose() {
_weaveffiContactsContactDestroy(_handle);
}
String get name {
final result = _weaveffiContactsContactGetName(_handle);
final value = result.toDartString();
_weaveffiFreeString(result);
return value;
}
}
String getters copy the returned pointer with toDartString() and
release the producer’s allocation with weaveffi_free_string.
Each function emits a native typedef, Dart typedef, lookup, and top-level wrapper:
typedef _NativeWeaveffiContactsCreateContact =
Int64 Function(Pointer<Utf8>, Pointer<Utf8>, Int32, Pointer<_WeaveFFIError>);
typedef _DartWeaveffiContactsCreateContact =
int Function(Pointer<Utf8>, Pointer<Utf8>, int, Pointer<_WeaveFFIError>);
final _weaveffiContactsCreateContact = _lib.lookupFunction<
_NativeWeaveffiContactsCreateContact,
_DartWeaveffiContactsCreateContact>('weaveffi_contacts_create_contact');
int createContact(String name, String? email, ContactType contactType) {
final err = calloc<_WeaveFFIError>();
final namePtr = name.toNativeUtf8();
try {
final result = _weaveffiContactsCreateContact(
namePtr, email, contactType.value, err);
_checkError(err);
return result;
} finally {
calloc.free(namePtr);
calloc.free(err);
}
}
Wrapper names are lowerCamelCase with the IDL module prefix stripped
by default (a kv.open_store function would surface as openStore,
not kvOpenStore); the C symbols keep their full names. Set
strip_module_prefix: false in the Dart generator config (or under
[global]) to keep module-prefixed wrapper names.
Typed errors
The package defines WeaveFFIException with code and message
fields. A module’s error domain adds an exception subclass named by
replacing the trailing Error stem with Exception (KvError
becomes KvException) plus one subclass per code, and a mapper that
falls back to WeaveFFIException for codes outside the domain. From
the kvstore sample:
/// Typed error domain `KvError` declared by module `kv`.
class KvException extends WeaveFFIException {
KvException(super.code, super.message);
}
/// key not found
class KeyNotFoundException extends KvException {
KeyNotFoundException([String message = 'key not found']) : super(1001, message);
}
// ExpiredException, StoreFullException, IoException follow the same shape.
WeaveFFIException _mapKvException(int code, String message) {
switch (code) {
case 1001:
return KeyNotFoundException(message);
// ... 1002, 1003, 1004 ...
default:
return WeaveFFIException(code, message);
}
}
Only callables marked throws: true in the IDL check their error slot
with _checkKvException (their doc comments read
Throws [KvException] on domain errors.); catching
KeyNotFoundException or KvException works as usual. A callable
without throws uses the generic _checkError, which throws
WeaveFFIException only if the producer misbehaves.
Interfaces
An interfaces: entry becomes a class holding the opaque pointer. A
constructor named new renders as an unnamed factory (so
ContactBook() just works); other constructors become named factories
(Store.open(path)). Methods are lowerCamelCase instance methods,
statics are static methods, and dispose() releases the native
object. From the kvstore sample (trimmed):
/// An embedded key-value store owning its entries
class Store {
final Pointer<Void> _handle;
Store._(this._handle);
/// Releases the native object reference.
void dispose() {
_weaveffiKvStoreDestroy(_handle);
}
/// Open (or create) a store backed by the given filesystem path
///
/// Throws [KvException] on domain errors.
factory Store.open(String path) {
final pathPtr = path.toNativeUtf8();
final err = calloc<_WeaveFFIError>();
try {
final result = _weaveffiKvStoreOpen(pathPtr, err);
_checkKvException(err);
return Store._(result);
} finally {
calloc.free(pathPtr);
calloc.free(err);
}
}
bool put(String key, List<int> value, EntryKind kind, int? ttlSeconds) { /* throws KvException */ }
Entry? get(String key) { /* throws KvException */ }
Iterable<String> listKeys(String? prefix) sync* { /* see Iterators */ }
int count() { /* generic check only (no throws) */ }
/// Throws [KvException] on domain errors.
Future<int> compact() { /* see Async support */ }
@Deprecated('use put() with explicit kind')
bool legacyPut(String key, List<int> value) { /* ... */ }
/// The largest number of live entries one store will hold
static int defaultCapacity() { /* ... */ }
}
Functions elsewhere in the IDL pass the wrapper’s handle across the
boundary (getStats(store) returns a new Stats). There’s no
finalizer; call dispose() when done, ideally in try/finally:
final store = Store.open('/tmp/cache.kv');
try {
store.put('alpha', [1], EntryKind.persistent, null);
print('${store.count()} / ${Store.defaultCapacity()}');
final reclaimed = await store.compact();
} finally {
store.dispose();
}
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style enum surfaces as a Dart enum and crosses as an
Int32; a rich enum instead lowers to an opaque object handle, so
the generator emits a wrapper class with the same ownership model as a
struct wrapper, a Pointer<Void> freed by an explicit dispose().
For a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and Labeled { label: string, count: u8 }, the generator emits a companion ShapeTag enum, one
factory per variant, a tag getter that maps the discriminant back to
ShapeTag, and a getter per payload field:
/// An algebraic shape (sum type with associated data)
enum ShapeTag {
empty(0),
circle(1),
rectangle(2),
labeled(3),
;
const ShapeTag(this.value);
final int value;
static ShapeTag fromValue(int value) =>
ShapeTag.values.firstWhere((e) => e.value == value);
}
/// An algebraic shape (sum type with associated data)
class Shape {
final Pointer<Void> _handle;
Shape._(this._handle);
void dispose() {
_weaveffiShapesShapeDestroy(_handle);
}
ShapeTag get tag =>
ShapeTag.fromValue(_weaveffiShapesShapeTag(_handle));
/// A circle with a radius
factory Shape.circle(double radius) {
final err = calloc<_WeaveFFIError>();
try {
final result = _weaveffiShapesShapeCircleNew(radius, err);
_checkError(err);
return Shape._(result);
} finally {
calloc.free(err);
}
}
/// Radius in points
double get circleRadius {
final result = _weaveffiShapesShapeCircleGetRadius(_handle);
return result;
}
int get labeledCount {
final result = _weaveffiShapesShapeLabeledGetCount(_handle);
return result;
}
}
The rest of the surface follows the same shape: factories
Shape.empty(), Shape.circle(radius), Shape.rectangle(width, height), and Shape.labeled(label, count); getters circleRadius,
rectangleWidth, rectangleHeight, labeledLabel, and labeledCount.
Each resolves a weaveffi_shapes_Shape_<Variant>_new /
weaveffi_shapes_Shape_<Variant>_get_<field> symbol, and
weaveffi_shapes_Shape_tag backs the tag getter.
Construct a couple of variants, read the tag and a field, then pass the wrapper to a top-level function:
final circle = Shape.circle(2.0);
final labeled = Shape.labeled('unit', 3);
try {
if (circle.tag == ShapeTag.circle) {
print(circle.circleRadius); // 2.0
}
print(labeled.labeledCount); // 3
print(describe(circle)); // render via the C ABI
final bigger = scale(circle, 3.0); // returns a new Shape
bigger.dispose();
} finally {
circle.dispose();
labeled.dispose();
}
Ownership: a Shape wraps a Pointer<Void> that you own; call
dispose() (which invokes weaveffi_shapes_Shape_destroy) exactly as
with struct wrappers. The Shape returned by scale is a separate
handle you also dispose.
Build instructions
Standalone Dart:
-
Generate the bindings:
weaveffi generate api.yaml -o generated --target dart -
Build the Rust shared library:
cargo build --release -p your_library -
Make the cdylib findable at runtime:
- macOS:
DYLD_LIBRARY_PATH=$PWD/../../target/release dart run example/main.dart - Linux:
LD_LIBRARY_PATH=$PWD/../../target/release dart run example/main.dart - Windows: place
weaveffi.dllnext to the script or add its directory toPATH.
- macOS:
Flutter:
-
Generate the bindings as above.
-
Cross-compile the Rust cdylib for every Flutter target you support (
aarch64-apple-ios,aarch64-linux-android,x86_64-apple-darwin, etc.). -
Reference the generated package from your app’s
pubspec.yaml:dependencies: weaveffi: path: ../generated/dart -
Bundle the cdylib per platform:
- iOS / macOS: ship a Framework or use a
podspec. - Android: place
.sofiles underandroid/src/main/jniLibs/{abi}/. - Linux / Windows: place next to the executable or on the library search path.
- iOS / macOS: ship a Framework or use a
Memory and ownership
-
Strings: Dart
Stringvalues are converted withtoNativeUtf8(). The wrapper frees the resulting pointer in afinallyblock. Returned UTF-8 pointers are copied withtoDartString()and then released withweaveffi_free_string. -
Bytes, lists, and maps: returned buffers are copied into Dart collections, then the producer’s allocation is released. String elements are freed individually with
weaveffi_free_stringbefore the backing buffer is freed withweaveffi_free_bytes. -
Structs and interfaces: wrappers hold a
Pointer<Void>. Thedispose()method calls the corresponding_destroyC function. Always wrap usage intry/finally:final contact = getContact(id); try { print(contact.name); } finally { contact.dispose(); } -
Optionals:
T?returns check the native pointer againstnullptrbefore wrapping; absent optionals becomenull. A boxed optional scalar is dereferenced, then the box is freed withweaveffi_free_bytes. -
Iterators: each yielded element is copied (or, for records, adopted by its wrapper class), and the iterator handle is destroyed exactly once; see Iterators.
Callbacks and listeners
A callbacks: entry in the IDL defines the native function-pointer
type; a listeners: entry generates a register/unregister pair around
it. Registration wraps the Dart closure in a NativeCallable, hands
its nativeFunction pointer to the C ABI, and returns the int
subscription id the native side minted:
// Live listener trampolines by subscription id. Holding the
// NativeCallable here keeps its native thunk alive until unregistered.
final Map<int, NativeCallable> _listenerCallables = {};
/// Registers a OnMessage listener. Returns a subscription id for
/// unregisterMessageListener().
int registerMessageListener(void Function(String message) callback) {
final callable =
NativeCallable<_NativeCb_weaveffi_events_OnMessage_fn>.isolateLocal(
(Pointer<Utf8> message, Pointer<Void> context) {
callback(message == nullptr ? '' : message.toDartString());
});
final id = _weaveffiEventsRegisterMessageListener(callable.nativeFunction, nullptr);
_listenerCallables[id] = callable;
return id;
}
/// Unregisters a listener previously registered with registerMessageListener().
void unregisterMessageListener(int id) {
_weaveffiEventsUnregisterMessageListener(id);
_listenerCallables.remove(id)?.close();
}
- Lifetime. The live
NativeCallableis stored in_listenerCallableskeyed by subscription id; that reference keeps the native thunk and the captured closure alive. Unregistering removes the entry andclose()s the callable. The Cvoid* contextslot is unused (nullptr); the closure travels inside the callable, so no registry id needs to cross the boundary. - Threading. Listener trampolines are
NativeCallable.isolateLocal, not.listener: WeaveFFI listeners fire synchronously on the thread calling the producer API (here, whilesendMessageruns), and the argument pointers are only valid for that borrow window, so they are converted to Dart values inside the callback before the producer frees them. AnisolateLocalcallable may only be invoked on the owning isolate’s thread, so events are delivered during the isolate’s own calls into the library rather than queued to the event loop. - Isolate lifetime. The generated code never sets
keepIsolateAlive = false, so thedart:ffidefault applies: a registered listener keeps its isolate alive until it is unregistered.
Async support
Functions marked async: true return a Future<T> backed by the
_async-suffixed C launcher. The completion callback is a
NativeCallable.listener, which may be invoked from any native
thread: the event is posted to the owning isolate’s event loop, where
it completes the Completer:
/// Throws [TaskException] on domain errors.
Future<TaskResult> runTask(String name) {
final completer = Completer<TaskResult>();
final namePtr = name.toNativeUtf8();
late NativeCallable<_NativeAsyncCb_weaveffi_tasks_run_task> callable;
callable = NativeCallable<_NativeAsyncCb_weaveffi_tasks_run_task>.listener(
(Pointer<Void> context, Pointer<_WeaveFFIError> err, Pointer<Void> result) {
try {
if (err.address != 0 && err.ref.code != 0) {
final code = err.ref.code;
final msg = err.ref.message.toDartString();
_weaveffiErrorClear(err);
completer.completeError(_mapTaskException(code, msg));
return;
}
completer.complete(TaskResult._(result));
} catch (e) {
completer.completeError(e);
} finally {
callable.close();
}
});
try {
_weaveffiTasksRunTaskAsync(namePtr, callable.nativeFunction, nullptr);
} catch (e) {
callable.close();
calloc.free(namePtr);
rethrow;
}
return completer.future.whenComplete(() {
calloc.free(namePtr);
});
}
The callable is closed in the callback’s finally (or immediately if
the launch itself throws), so each native trampoline is freed exactly
once; input buffers are released in whenComplete once the future
settles. The dart:async import is only emitted when the IDL contains
at least one async function.
Result ownership follows the async contract: the callback borrows
string, bytes, list, map, and boxed optional scalar results, so the
callback body deep-copies them into Dart values before it returns and
never frees them (the producer does, after the callback returns).
Object results (records, rich enums, interfaces, including optional
ones) are the exception: the callback receives ownership, and the
wrapper adopts the pointer, as TaskResult._(result) does above; its
dispose() owns the eventual destroy.
For a callable marked throws: true, the completion callback maps an
error through the domain mapper (_mapTaskException above,
_mapKvException on Store.compact()), so the future fails with the
typed exception; a non-throwing async callable can only fail with
WeaveFFIException on a producer bug. Async interface methods follow
the same pattern as instance methods returning Future<T>.
For functions marked cancellable: true the C launcher gains a
weaveffi_cancel_token* parameter. The Dart wrapper passes nullptr
for it and doesn’t expose the token; only the C and C++
targets surface cancellation tokens.
Iterators
iter<T> returns surface as Iterable<T> backed by a sync*
generator, so they are fully lazy: nothing runs until the consumer
starts iterating, and each element pulls exactly one native next
call. Iterating the returned Iterable again launches a fresh native
iterator. From the events sample:
/// Return an iterator over all sent messages
///
/// Returns a lazy [Iterable]: elements are pulled from the native
/// iterator one at a time (one native `next` call per element), and
/// iterating the result again launches a fresh native iterator.
///
/// The native iterator handle is destroyed exactly once: eagerly when
/// the iteration completes or fails, or by a GC finalizer if the
/// iteration is abandoned before it is exhausted.
Iterable<String> getMessages() sync* {
final err = calloc<_WeaveFFIError>();
final outItem = calloc<Pointer<Utf8>>();
Pointer<Void> iter = nullptr;
final anchor = _IteratorLifetime();
try {
iter = _weaveffiEventsGetMessages(err);
_checkError(err);
_weaveffiEventsGetMessagesIteratorDestroyFinalizer.attach(anchor, iter, detach: anchor);
while (_weaveffiEventsGetMessagesIteratorNext(iter, outItem, err) != 0) {
_checkError(err);
final itemPtr = outItem.value;
final item = itemPtr.toDartString();
_weaveffiFreeString(itemPtr);
yield item;
}
_checkError(err);
} finally {
if (iter != nullptr) {
_weaveffiEventsGetMessagesIteratorDestroyFinalizer.detach(anchor);
_weaveffiEventsGetMessagesIteratorDestroy(iter);
iter = nullptr;
}
calloc.free(outItem);
calloc.free(err);
}
}
Each yielded string is copied with toDartString() and its producer
allocation released with weaveffi_free_string; record elements are
adopted by their wrapper class instead. The handle lifecycle covers
early abandonment: the finally block runs when the loop exhausts,
when a step fails, or when the consumer stops iterating (Dart closes
the suspended sync* frame on break). If an iteration is abandoned
without ever resuming the frame, the _IteratorLifetime anchor’s
NativeFinalizer destroys the handle at GC time; an eagerly destroyed
handle detaches first, so the destroy runs exactly once either way.
Errors from the launcher and from each next follow the function’s
error strategy: the throwing kvstore sample’s Store.listKeys
checks each step with _checkKvException and throws the typed
KvException subclasses from the step that failed; the non-throwing
getMessages throws WeaveFFIException only for producer bugs.
Troubleshooting
Invalid argument(s): Failed to load dynamic library: the cdylib is not on the search path. SetDYLD_LIBRARY_PATH/LD_LIBRARY_PATHor copy the library next to your executable.UnsupportedError: Unsupported platform: the loader maps todarwin,linux, andwindows. Other platforms (Android, iOS) use the Flutter integration where the framework opens the library.MissingPluginExceptionin Flutter: that error is unrelated to WeaveFFI; double-check that you depend on the generated package and haven’t shadowed it with a differentweaveffidependency.- Strings appear truncated: Rust strings aren’t nul-terminated;
make sure
toDartString()is reading the pointer returned from a generated getter, not a raw pointer.
Go
Overview
The Go target produces idiomatic Go bindings that use CGo to call the C
ABI. The generator emits one Go source file (weaveffi.go) plus a
go.mod so the result can be imported by any Go module. Functions
marked throws: true return (value, error) to match Go conventions;
all other wrappers return plain values. Struct and interface wrappers
expose methods plus an explicit Close(). Functions returning
iter<T> produce standard-library iter.Seq/iter.Seq2 sequences,
so the generated module requires Go 1.23 or later (the emitted
go.mod declares go 1.23).
What gets generated
| File | Purpose |
|---|---|
go/weaveffi.go | CGo bindings: preamble, type wrappers, function wrappers |
go/go.mod | Go module descriptor (configurable module path) |
go/README.md | Prerequisites and build instructions |
Type mapping
| IDL type | Go type | C type (CGo) |
|---|---|---|
i32 | int32 | C.int32_t |
u32 | uint32 | C.uint32_t |
i64 | int64 | C.int64_t |
f64 | float64 | C.double |
i8 | int8 | C.int8_t |
i16 | int16 | C.int16_t |
u8 | uint8 | C.uint8_t |
u16 | uint16 | C.uint16_t |
u64 | uint64 | C.uint64_t |
f32 | float32 | C.float |
bool | bool | C._Bool |
string | string | *C.char (via C.CString/C.GoString) |
bytes | []byte | *C.uint8_t + C.size_t |
handle | int64 | C.weaveffi_handle_t |
Struct | *StructName | *C.weaveffi_mod_Struct |
Interface | *InterfaceName | *C.weaveffi_mod_Interface |
Enum (plain) | EnumName | C.weaveffi_mod_Enum |
Enum (rich) | *EnumName | *C.weaveffi_mod_Enum |
T? | *T | pointer to scalar; nil-able pointer for strings/structs |
[T] | []T | pointer + C.size_t |
{K: V} | map[K]V | key/value arrays + C.size_t |
iter<T> | iter.Seq[T], or iter.Seq2[T, error] when the function throws | opaque iterator pointer + _next/_destroy |
Booleans map to C._Bool, matching CGo’s representation of _Bool.
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
fields:
- { name: id, type: i64 }
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
functions:
- name: create_contact
params:
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: list_contacts
params: []
return: "[Contact]"
- name: count_contacts
params: []
return: i32
The generated weaveffi.go opens with the CGo preamble:
package weaveffi
/*
#cgo LDFLAGS: -lweaveffi
#include "weaveffi.h"
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
Enums become typed integer aliases:
type ContactType int32
const (
ContactTypePersonal ContactType = 0
ContactTypeWork ContactType = 1
ContactTypeOther ContactType = 2
)
Structs hold a typed C pointer and expose getters plus Close():
type Contact struct {
ptr *C.weaveffi_contacts_Contact
}
func (s *Contact) FirstName() string {
cStr := C.weaveffi_contacts_Contact_get_first_name(s.ptr)
goResult := C.GoString(cStr)
C.weaveffi_free_string(cStr)
return goResult
}
func (s *Contact) Email() *string {
cStr := C.weaveffi_contacts_Contact_get_email(s.ptr)
if cStr == nil { return nil }
v := C.GoString(cStr)
C.weaveffi_free_string(cStr)
return &v
}
func (s *Contact) Close() {
if s.ptr != nil {
C.weaveffi_contacts_Contact_destroy(s.ptr)
s.ptr = nil
}
}
Function wrappers are PascalCase with the IDL module prefix stripped
(CreateContact, not ContactsCreateContact); set
strip_module_prefix: false in the Go generator config (or under
[global]) to keep prefixed names. A function without throws returns
a plain value; its error slot is checked by wvTrap, which panics,
because a non-zero code there can only be a producer panic or a
marshalling failure:
func CreateContact(firstName string, email *string, contactType ContactType) int64 {
cFirstName := C.CString(firstName)
defer C.free(unsafe.Pointer(cFirstName))
var cEmail *C.char
if email != nil {
cEmail = C.CString(*email)
defer C.free(unsafe.Pointer(cEmail))
}
var cErr C.weaveffi_error
result := C.weaveffi_contacts_create_contact(
cFirstName, cEmail, C.weaveffi_contacts_ContactType(contactType), &cErr)
wvTrap(&cErr)
return int64(result)
}
A function marked throws: true returns (value, error) instead; see
Typed errors.
Lists round-trip through unsafe.Slice; after the copy, the wrapper
releases the producer’s buffer with weaveffi_free_bytes (and frees
string elements individually with weaveffi_free_string first):
var cOutLen C.size_t
result := C.weaveffi_store_list_ids(&cOutLen, &cErr)
count := int(cOutLen)
if count == 0 || result == nil { return nil, nil }
goResult := make([]int32, count)
cSlice := unsafe.Slice((*C.int32_t)(unsafe.Pointer(result)), count)
for i, v := range cSlice { goResult[i] = int32(v) }
C.weaveffi_free_bytes((*C.uint8_t)(unsafe.Pointer(result)), C.size_t(count)*C.size_t(unsafe.Sizeof(*result)))
The Go module path defaults to weaveffi; override it via the
generator config:
version: "0.5.0"
modules:
- name: math
functions:
- name: add
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
generators:
go:
module_path: "github.com/myorg/mylib"
Typed errors
The package defines a generic WeaveFFIError struct with Code and
Message fields. A module’s error domain adds a typed error struct
named after the domain, package-level code constants, and a mapper
that falls back to *WeaveFFIError for codes outside the domain. From
the kvstore sample:
// KvError is a typed error reported by the `kv` module.
type KvError struct {
// Code is the numeric ABI error code (one of the KvError constants).
Code int32
// Message is the human-readable error message.
Message string
}
func (e *KvError) Error() string {
return fmt.Sprintf("kv: %s (code %d)", e.Message, e.Code)
}
// KvError codes.
const (
// KvErrorKeyNotFound key not found
KvErrorKeyNotFound int32 = 1001
// KvErrorExpired entry expired
KvErrorExpired int32 = 1002
// KvErrorStoreFull store has reached capacity
KvErrorStoreFull int32 = 1003
// KvErrorIoError: I/O failure
KvErrorIoError int32 = 1004
)
A callable marked throws: true returns (value, error) and maps a
non-zero error slot through the domain mapper (wvMapKv); match it
with errors.As and compare the code constants:
_, err := store.Delete("missing")
var kvErr *KvError
if errors.As(err, &kvErr) && kvErr.Code == KvErrorKeyNotFound {
// specific code
}
A callable without throws returns a plain value and checks its slot
with wvTrap, which panics on the codes that can only mean a producer
bug.
Interfaces
An interfaces: entry becomes a struct holding the typed C pointer.
Constructors become package-level factory functions combining the
constructor and type names (open becomes OpenStore, new becomes
NewContactBook), methods hang off the wrapper, statics become
package-level functions prefixed by the type name
(StoreDefaultCapacity), and Close() frees the native object. From
the kvstore sample (trimmed):
type Store struct {
ptr *C.weaveffi_kv_Store
}
// OpenStore: Open (or create) a store backed by the given filesystem path
func OpenStore(path string) (*Store, error) {
cPath := C.CString(path)
defer C.free(unsafe.Pointer(cPath))
var cErr C.weaveffi_error
result := C.weaveffi_kv_Store_open(cPath, &cErr)
if cErr.code != 0 {
return nil, wvMapKv(wvTakeError(&cErr))
}
return &Store{ptr: result}, nil
}
// Put: Insert or replace a value, returning true on success
func (s *Store) Put(key string, value []byte, kind EntryKind, ttlSeconds *int64) (bool, error) { /* ... */ }
// Count: Return the number of live entries in the store
func (s *Store) Count() int64 {
var cErr C.weaveffi_error
result := C.weaveffi_kv_Store_count(s.ptr, &cErr)
wvTrap(&cErr)
return int64(result)
}
// Compact: Reclaim space asynchronously; returns the number of bytes reclaimed
// Blocks the calling goroutine until the async producer completes.
func (s *Store) Compact() (int64, error) { /* see Async support */ }
// ListKeys: Stream every key, optionally filtered by a prefix
func (s *Store) ListKeys(prefix *string) iter.Seq2[string, error] { /* see Iterators */ }
// LegacyPut: Legacy single-shot put kept for compatibility
// Deprecated: use put() with explicit kind
func (s *Store) LegacyPut(key string, value []byte) (bool, error) { /* ... */ }
// StoreDefaultCapacity: The largest number of live entries one store will hold
func StoreDefaultCapacity() int64 { /* ... */ }
func (s *Store) Close() {
if s.ptr != nil {
C.weaveffi_kv_Store_destroy(s.ptr)
s.ptr = nil
}
}
Functions elsewhere in the IDL pass the wrapper’s pointer across the
boundary (GetStats(store) returns a new *Stats). Deprecated
members carry a standard // Deprecated: comment that go vet and
editors understand. As with structs, pair every wrapper with
defer store.Close():
store, err := OpenStore("/tmp/cache.kv")
if err != nil {
return err
}
defer store.Close()
ok, err := store.Put("alpha", []byte{1}, EntryKindPersistent, nil)
fmt.Println(store.Count(), StoreDefaultCapacity())
Rich (algebraic) enums
A rich (algebraic) enum, a sum type whose variants carry associated
data, lowers to an opaque object pointer at the C ABI, exactly like a
struct, and shares the same ownership model as the struct wrappers above.
The Go wrapper is a struct holding a typed C pointer, with one
New<Enum><Variant> constructor per variant, a Tag() method returning
the int32 discriminant, per-variant field getter methods, and an
explicit Close(). (A plain C-style enum with no payloads stays a typed
int32 alias with const values; see above.)
For the shapes module’s Shape enum (Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and
Labeled { label: string, count: u8 }), the generator emits (abridged):
// Shape: An algebraic shape (sum type with associated data)
type Shape struct {
ptr *C.weaveffi_shapes_Shape
}
const (
// ShapeEmpty: The empty shape
ShapeEmpty int32 = 0
// ShapeCircle: A circle with a radius
ShapeCircle int32 = 1
// ShapeRectangle: An axis-aligned rectangle
ShapeRectangle int32 = 2
// ShapeLabeled: A labeled shape with a small count
ShapeLabeled int32 = 3
)
func (s *Shape) Tag() int32 {
return int32(C.weaveffi_shapes_Shape_tag(s.ptr))
}
// NewShapeCircle: A circle with a radius
func NewShapeCircle(radius float64) (*Shape, error) {
var cErr C.weaveffi_error
result := C.weaveffi_shapes_Shape_Circle_new(C.double(radius), &cErr)
if cErr.code != 0 {
return nil, wvBrandError(wvTakeError(&cErr))
}
return &Shape{ptr: result}, nil
}
// NewShapeLabeled: A labeled shape with a small count
func NewShapeLabeled(label string, count uint8) (*Shape, error) {
cLabel := C.CString(label)
defer C.free(unsafe.Pointer(cLabel))
var cErr C.weaveffi_error
result := C.weaveffi_shapes_Shape_Labeled_new(cLabel, C.uint8_t(count), &cErr)
if cErr.code != 0 {
return nil, wvBrandError(wvTakeError(&cErr))
}
return &Shape{ptr: result}, nil
}
// CircleRadius: Radius in points
func (s *Shape) CircleRadius() float64 {
return float64(C.weaveffi_shapes_Shape_Circle_get_radius(s.ptr))
}
func (s *Shape) LabeledCount() uint8 {
return uint8(C.weaveffi_shapes_Shape_Labeled_get_count(s.ptr))
}
func (s *Shape) Close() {
if s.ptr != nil {
C.weaveffi_shapes_Shape_destroy(s.ptr)
s.ptr = nil
}
}
Each NewShape<Variant> calls a per-variant constructor
(weaveffi_shapes_Shape_<Variant>_new); Tag() reads the discriminant
(weaveffi_shapes_Shape_tag) and can be compared against the package
constants ShapeEmpty/ShapeCircle/ShapeRectangle/ShapeLabeled; the
getter methods read one variant field
(weaveffi_shapes_Shape_<Variant>_get_<field>); and Close() frees the
pointer (weaveffi_shapes_Shape_destroy). Free functions that take or
return the enum pass the wrapper’s pointer across the boundary
(Describe(*Shape), Scale(*Shape, float64); both are non-throwing
here, so they return plain values):
c, err := NewShapeCircle(2.0)
if err != nil {
return err
}
defer c.Close()
fmt.Println(c.Tag() == ShapeCircle) // true
fmt.Println(c.CircleRadius()) // 2
bigger := Scale(c, 3.0) // returns a new *Shape
defer bigger.Close()
fmt.Println(Describe(bigger))
Ownership: a *Shape owns its native pointer. Go has no deterministic
destructors, so pair every constructor (and every *Shape returned by
Scale) with defer s.Close().
Build instructions
-
Generate the bindings:
weaveffi generate api.yaml -o generated --target go -
Build the Rust shared library:
cargo build --release -p your_library -
Point CGo at the header and library:
export CGO_CFLAGS="-I$PWD/generated/c" export CGO_LDFLAGS="-L$PWD/target/release -lweaveffi" -
Build and run a Go consumer:
cd generated/go go build ./...
CGo requires a C compiler (gcc or clang) on the host; on Windows
use a MinGW-w64 toolchain or the MSVC build provided by go env.
Memory and ownership
- Strings in:
C.CStringallocates a copy in C memory; the generated wrapper pairs everyCStringwith adefer C.free(...). - Strings out:
C.GoStringcopies the C string into Go-owned memory, then the wrapper callsweaveffi_free_stringto release the Rust allocation. - Bytes: input slices are passed by pointer for the duration of
the call (no copy); returned bytes are copied with
C.GoBytesand thenweaveffi_free_bytesis called. - Lists and maps out: each element is copied (string elements are
freed individually with
weaveffi_free_string), then the array buffer, or both parallel key/value buffers for a map, is released withweaveffi_free_bytes. - Structs and interfaces: wrappers hold a typed C pointer. Always
pair with
defer s.Close()because Go has no deterministic destructors. - Optionals: scalar optionals are
*T; struct/string optionals rely on a nil pointer to indicate absence. A returned boxed scalar is dereferenced and its box freed withweaveffi_free_bytes.
Callbacks and listeners
A callbacks: entry in the IDL defines a C function-pointer type; a
listeners: entry generates a register/unregister pair around it:
modules:
- name: events
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
The C ABI is weaveffi_events_register_message_listener(callback, void* context), which returns a uint64_t subscription id, plus
weaveffi_events_unregister_message_listener(id). The Go surface
takes a closure and returns that id:
// Returns a subscription id for UnregisterMessageListener.
func RegisterMessageListener(callback func(message string)) uint64 {
ctxID := wvCallbackStore(callback)
id := uint64(C.weaveffi_events_register_message_listener(
C.weaveffi_events_OnMessage_fn(unsafe.Pointer(C.goWv_weaveffi_events_OnMessage_fn)),
unsafe.Pointer(uintptr(ctxID))))
wvCallbackMu.Lock()
wvListenerCtx[id] = ctxID
wvCallbackMu.Unlock()
return id
}
func UnregisterMessageListener(id uint64) {
C.weaveffi_events_unregister_message_listener(C.uint64_t(id))
wvCallbackMu.Lock()
ctxID, ok := wvListenerCtx[id]
delete(wvListenerCtx, id)
wvCallbackMu.Unlock()
if ok {
wvCallbackDelete(ctxID)
}
}
CGo forbids passing Go pointers to C, so the closure itself never
crosses the boundary. The bindings keep a mutex-guarded registry
(wvCallbacks, written through wvCallbackStore) and hand C two
things: a //exported trampoline (goWv_weaveffi_events_OnMessage_fn,
declared extern in the CGo preamble) as the function pointer, and
the registry key as the void* context, an integer id cast via
unsafe.Pointer(uintptr(ctxID)) that the C side never dereferences.
When the event fires, the trampoline looks the closure up and calls it:
//export goWv_weaveffi_events_OnMessage_fn
func goWv_weaveffi_events_OnMessage_fn(message *C.char, context unsafe.Pointer) {
v := wvCallbackLoad(uint64(uintptr(context)))
if v == nil {
return
}
cb := v.(func(message string))
arg0 := ""
if message != nil {
arg0 = C.GoString(message)
}
cb(arg0)
}
- Subscription ids: the native library mints the
uint64id; pair every register with exactly one unregister. Unregistering tears down the native subscription, then useswvListenerCtx(subscription id → registry key) to delete the stored closure so it can be collected. A leaked subscription pins the closure forever. - Threading: the callback runs as a CGo callback on whatever thread
the producer fires it from (in the events sample, synchronously
inside
SendMessage). Don’t block in it; forward to a channel or goroutine if handling is slow.
Async support
Functions marked async: true are exposed through _async-suffixed C
launchers that take a completion callback plus void* context. Go has
no ambient async runtime, so the generated wrapper turns that into a
blocking call built on a channel: it makes a buffered channel, stores
it in the same callback registry the listener bindings use, launches
the C call with an exported trampoline and the integer context id,
then receives from the channel. The generated doc comment states that
the call blocks. From the kvstore sample:
// Compact: Reclaim space asynchronously; returns the number of bytes reclaimed
// Blocks the calling goroutine until the async producer completes.
func (s *Store) Compact() (int64, error) {
ch := make(chan wvOutcomeKvStoreCompact, 1)
ctxID := wvCallbackStore(ch)
C.weaveffi_kv_Store_compact_async(s.ptr, nil, C.weaveffi_kv_Store_compact_callback(unsafe.Pointer(C.goWv_weaveffi_kv_Store_compact_callback)), unsafe.Pointer(uintptr(ctxID)))
outcome := <-ch
if outcome.err != nil {
return 0, outcome.err
}
return outcome.val, nil
}
The completion callback fires exactly once, on a producer thread. The
trampoline removes the channel from the registry with wvCallbackTake
(one-shot), converts the C error or result inside the callback (result
buffers such as strings and arrays are borrowed from the producer for
the callback’s duration, so the trampoline copies them into Go memory
and never frees them; owned-object results are adopted into a wrapper
instead), and sends a single wvOutcome… value:
//export goWv_weaveffi_kv_Store_compact_callback
func goWv_weaveffi_kv_Store_compact_callback(context unsafe.Pointer, err *C.weaveffi_error, result C.int64_t) {
v := wvCallbackTake(uint64(uintptr(context)))
if v == nil {
return
}
ch := v.(chan wvOutcomeKvStoreCompact)
if err != nil && err.code != 0 {
ch <- wvOutcomeKvStoreCompact{err: wvMapKv(wvTakeError(err))}
return
}
ch <- wvOutcomeKvStoreCompact{val: int64(result)}
}
For a callable marked throws: true, the trampoline maps the error
through the domain mapper, so the returned error is the typed one
(*KvError from store.Compact()). The native producer already runs
on its own thread, so the wrapper simply blocks the calling goroutine;
callers that want concurrency run the call from a goroutine of their
own.
For functions marked cancellable: true the C launcher gains a
weaveffi_cancel_token* parameter. The Go wrapper passes nil for it
and doesn’t expose the token; only the C and C++ targets
surface cancellation tokens.
Iterators
iter<T> returns map to the standard library’s range-over-function
sequences (Go 1.23+): a non-throwing function returns iter.Seq[T]
and a throwing one returns iter.Seq2[T, error]. Nothing is drained:
the producer iterator is launched when the consumer starts ranging,
and each consumer step issues exactly one producer next call. From
the events sample:
// GetMessages: Return an iterator over all sent messages
// Returns a lazy sequence: the producer iterator is launched on first
// iteration and one producer next call runs per element. The iterator is
// destroyed exactly once, whether the sequence is exhausted or abandoned
// early; each range over the sequence launches a fresh producer iterator.
// A reported error can only be a producer bug and panics with the
// weaveffi message.
func GetMessages() iter.Seq[string] {
return func(yield func(string) bool) {
var cErr C.weaveffi_error
it := C.weaveffi_events_get_messages(&cErr)
wvTrap(&cErr)
defer C.weaveffi_events_GetMessagesIterator_destroy(it)
for {
var outItem *C.char
var iterErr C.weaveffi_error
ok := C.weaveffi_events_GetMessagesIterator_next(it, &outItem, &iterErr) != 0
wvTrap(&iterErr)
if !ok {
return
}
item := C.GoString(outItem)
C.weaveffi_free_string(outItem)
if !yield(item) {
return
}
}
}
}
Each yielded element is copied into Go memory and its Rust allocation
released per element (strings via weaveffi_free_string; record
elements are adopted by owning wrappers). The deferred _destroy call
runs exactly once, whether the consumer exhausts the sequence or
breaks out of the for range loop early. Ranging over the same
returned sequence again launches a fresh producer iterator.
A throwing function yields errors in-band as the second value of the
iter.Seq2 pair: a launch or per-element failure is mapped through
the domain mapper, yielded as the final (zero value, error) pair,
and iteration stops. From the kvstore sample:
// ListKeys: Stream every key, optionally filtered by a prefix
// ...
// A launch or per-element error is yielded as the final (zero value,
// error) pair, and iteration stops.
func (s *Store) ListKeys(prefix *string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
// ... launch weaveffi_kv_Store_list_keys ...
if cErr.code != 0 {
yield("", wvMapKv(wvTakeError(&cErr)))
return
}
defer C.weaveffi_kv_Store_ListKeysIterator_destroy(it)
for {
// ... one _next call per step; errors yield ("", err) and stop ...
}
}
}
Consume it with for key, err := range store.ListKeys(nil), checking
err on each step. In a non-throwing sequence such as GetMessages,
a reported error can only be a producer bug, so wvTrap panics
instead of yielding it.
Troubleshooting
undefined reference to weaveffi_*:CGO_LDFLAGSis missing the-lflag or-Ldirectory. Recheck the environment exports.could not determine kind of namein CGo: ensureCGO_CFLAGSpoints at the directory containingweaveffi.h.- Crashes after struct goes out of scope: Go doesn’t call
Close()for you. Eitherdefer s.Close()or wrap usage in a helper that takes a closure. go: cannot find module providing package weaveffi: change the generator config sogo.moddeclares the module path you actually import, e.g.github.com/myorg/mylib.
Ruby
Overview
The Ruby target produces pure-Ruby FFI bindings using the
ffi gem to call the C ABI directly. There’s
no native extension to compile; gem install ffi is the only
prerequisite. The generator emits a single .rb file plus a gemspec
ready for gem build and gem install.
The trade-off is that FFI gem calls are slower than a hand-written C extension. For typical FFI workloads the overhead is negligible compared to the work done inside the Rust library.
What gets generated
| File | Purpose |
|---|---|
ruby/lib/weaveffi.rb | FFI bindings: library loader, attach_function declarations, wrapper classes |
ruby/weaveffi.gemspec | Gem specification with ffi ~> 1.15 dependency |
ruby/README.md | Prerequisites and usage instructions |
The file names follow the gem name (IDL package.name): a package
named events produces lib/events.rb and events.gemspec;
weaveffi is the default.
Type mapping
| IDL type | Ruby type | FFI type |
|---|---|---|
i32 | Integer | :int32 |
u32 | Integer | :uint32 |
i64 | Integer | :int64 |
f64 | Float | :double |
i8 | Integer | :int8 |
i16 | Integer | :int16 |
u8 | Integer | :uint8 |
u16 | Integer | :uint16 |
u64 | Integer | :uint64 |
f32 | Float | :float |
bool | true/false | :int32 (0/1 conversion) |
string | String | :string (param) / :pointer (return) |
bytes | String (binary) | :pointer + :size_t |
handle | Integer | :uint64 |
Struct | StructName | :pointer |
Interface | InterfaceName | :pointer |
Enum (plain) | Integer | :int32 |
Enum (rich) | EnumName | :pointer |
T? | T or nil | :pointer for scalars; same pointer for strings/structs |
[T] | Array | :pointer + :size_t |
{K: V} | Hash | key/value pointer arrays + :size_t |
iter<T> | Enumerator (lazy) | :pointer iterator handle |
Booleans cross as :int32 (0/1); the wrapper converts both
directions.
Example IDL → generated code
version: "0.5.0"
modules:
- name: contacts
enums:
- name: ContactType
variants:
- { name: Personal, value: 0 }
- { name: Work, value: 1 }
- { name: Other, value: 2 }
structs:
- name: Contact
doc: "A contact record"
fields:
- { name: id, type: i64 }
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
functions:
- name: create_contact
params:
- { name: first_name, type: string }
- { name: email, type: "string?" }
- { name: contact_type, type: ContactType }
return: handle
- name: get_contact
params:
- { name: id, type: handle }
return: Contact
- name: list_contacts
params: []
return: "[Contact]"
The generated module extends FFI::Library and selects the right
shared library at load time:
require 'ffi'
module WeaveFFI
extend FFI::Library
# An explicit path in WEAVEFFI_LIBRARY wins, so callers can point at a
# specific build artifact regardless of its file name or location.
_wv_override = ENV['WEAVEFFI_LIBRARY']
if _wv_override && !_wv_override.empty?
ffi_lib _wv_override
else
case FFI::Platform::OS
when /darwin/ then ffi_lib 'libweaveffi.dylib'
when /mswin|mingw/ then ffi_lib 'weaveffi.dll'
else ffi_lib 'libweaveffi.so'
end
end
end
Enums become Ruby modules with constants:
module ContactType
PERSONAL = 0
WORK = 1
OTHER = 2
end
Structs become classes wrapping an FFI::AutoPointer so the C
destructor is called when Ruby garbage-collects the wrapper:
class ContactPtr < FFI::AutoPointer
def self.release(ptr)
WeaveFFI.weaveffi_contacts_Contact_destroy(ptr)
end
end
class Contact
attr_reader :handle
def initialize(handle)
@handle = ContactPtr.new(handle)
end
def first_name
result = WeaveFFI.weaveffi_contacts_Contact_get_first_name(@handle)
return '' if result.null?
str = result.read_string
WeaveFFI.weaveffi_free_string(result)
str
end
def email
result = WeaveFFI.weaveffi_contacts_Contact_get_email(@handle)
return nil if result.null?
str = result.read_string
WeaveFFI.weaveffi_free_string(result)
str
end
end
Functions are snake_case class methods on the module, with the IDL
module prefix stripped by default (a kv.open_store function surfaces
as open_store, not kv_open_store; the attach_function bindings
keep the full C symbol names). Set strip_module_prefix: false in the
Ruby generator config (or under [global]) to keep prefixed names:
def self.create_contact(first_name, email, contact_type)
err = ErrorStruct.new
result = weaveffi_contacts_create_contact(
first_name, email, contact_type, err)
check_error!(err)
result
end
def self.get_contact(id)
err = ErrorStruct.new
result = weaveffi_contacts_get_contact(id, err)
check_error!(err)
raise Error.new(-1, 'null pointer') if result.null?
Contact.new(result)
end
The shared error machinery:
class ErrorStruct < FFI::Struct
layout :code, :int32, :message, :pointer
end
class Error < StandardError
attr_reader :code
def initialize(code, message)
@code = code
super(message)
end
end
def self.check_error!(err)
return if err[:code].zero?
code = err[:code]
msg_ptr = err[:message]
msg = msg_ptr.null? ? '' : msg_ptr.read_string
weaveffi_error_clear(err.to_ptr)
raise Error.new(code, msg)
end
Catch errors with standard begin/rescue:
require 'weaveffi'
begin
handle = WeaveFFI.create_contact("Alice", nil, WeaveFFI::ContactType::WORK)
rescue WeaveFFI::Error => e
puts "Error #{e.code}: #{e.message}"
end
Typed errors
A module’s error domain adds a base class extending Error with one
nested class per code, each pinning its stable CODE, plus a mapper
that falls back to the generic Error for codes outside the domain.
From the kvstore sample:
# Base error for the `kv` module's error domain.
class KvError < Error
# key not found
class KeyNotFound < KvError
CODE = 1001
def initialize(message = 'key not found')
super(1001, message)
end
end
# Expired, StoreFull, IoError follow the same shape.
end
# Builds the KvError subclass matching `code`, or a generic Error
# for codes outside the domain (panics, marshalling).
def self.kv_error_from(code, message)
cls = KV_ERROR_CODES[code]
return Error.new(code, message) if cls.nil?
message.empty? ? cls.new : cls.new(message)
end
Only callables marked throws: true in the IDL raise the typed
classes: their wrappers call check_kv_error!, so you can rescue
Kvstore::KvError::KeyNotFound for one code or Kvstore::KvError for
the whole domain. A callable without throws uses the generic
check_error!, which raises Error only if the producer misbehaves.
Interfaces
An interfaces: entry becomes a class wrapping an FFI::AutoPointer
subclass, so the C destructor runs when Ruby garbage-collects the
wrapper. Constructors become class methods (Store.open; a
constructor named new maps to the ordinary Store.new), methods are
snake_case instance methods, statics are class methods, and destroy
frees the native object deterministically. From the kvstore sample
(trimmed):
class StorePtr < FFI::AutoPointer
def self.release(ptr)
Kvstore.weaveffi_kv_Store_destroy(ptr)
end
end
# An embedded key-value store owning its entries
class Store
attr_reader :handle
# Wraps an owned pointer the producer handed over, without
# re-running initialize.
def self._from_ptr(ptr)
obj = allocate
obj.instance_variable_set(:@handle, StorePtr.new(ptr))
obj
end
def destroy
return if @handle.nil?
@handle.free
@handle = nil
end
# Open (or create) a store backed by the given filesystem path
def self.open(path)
err = ErrorStruct.new
result = Kvstore.weaveffi_kv_Store_open(path, err)
Kvstore.check_kv_error!(err)
raise Error.new(-1, 'null pointer') if result.null?
_from_ptr(result)
end
def put(key, value, kind, ttl_seconds) # raises typed KvError subclasses
# ...
end
def list_keys(prefix) # lazy Enumerator; see Iterators
# ...
end
def count() # generic check only (no throws)
# ...
end
def compact() # blocking async; see Async support
# ...
end
# Legacy single-shot put kept for compatibility
def legacy_put(key, value)
warn "[DEPRECATED] use put() with explicit kind"
# ...
end
# The largest number of live entries one store will hold
def self.default_capacity()
# ...
end
end
Functions elsewhere in the IDL pass the wrapper’s handle across the
boundary (Kvstore.get_stats(store) returns a new Stats).
Deprecated members print a [DEPRECATED] warning at call time:
store = Kvstore::Store.open('/tmp/cache.kv')
store.put('alpha', "\x01".b, Kvstore::EntryKind::PERSISTENT, nil)
puts "#{store.count} / #{Kvstore::Store.default_capacity}"
reclaimed = store.compact
store.destroy
Rich (algebraic) enums
A rich (algebraic) enum is a sum type whose variants carry associated
data. A plain C-style Enum crosses as a bare :int32 discriminant; a
rich enum instead lowers to an opaque object handle, so the
generator emits a wrapper class with the same ownership model as a
struct wrapper, an FFI::AutoPointer (ShapePtr) that calls the C
_destroy on garbage collection.
For a Shape enum with variants Empty, Circle { radius: f64 },
Rectangle { width: f32, height: f32 }, and Labeled { label: string, count: u8 }, the generated class carries one discriminant constant per
variant, a tag reader, a self.<variant> factory per variant, and a
field reader per payload:
class ShapePtr < FFI::AutoPointer
def self.release(ptr)
WeaveFFI.weaveffi_shapes_Shape_destroy(ptr)
end
end
# An algebraic shape (sum type with associated data)
class Shape
attr_reader :handle
def initialize(handle)
@handle = ShapePtr.new(handle)
end
# Variant discriminants returned by #tag
EMPTY = 0
CIRCLE = 1
RECTANGLE = 2
LABELED = 3
def tag
WeaveFFI.weaveffi_shapes_Shape_tag(@handle)
end
# A circle with a radius
def self.circle(radius)
err = WeaveFFI::ErrorStruct.new
result = WeaveFFI.weaveffi_shapes_Shape_Circle_new(radius, err)
WeaveFFI.check_error!(err)
new(result)
end
# A labeled shape with a small count
def self.labeled(label, count)
err = WeaveFFI::ErrorStruct.new
result = WeaveFFI.weaveffi_shapes_Shape_Labeled_new(label, count, err)
WeaveFFI.check_error!(err)
new(result)
end
# Radius in points
def circle_radius
WeaveFFI.weaveffi_shapes_Shape_Circle_get_radius(@handle)
end
def labeled_count
WeaveFFI.weaveffi_shapes_Shape_Labeled_get_count(@handle)
end
end
The remaining surface follows the same pattern: factories
Shape.empty, Shape.circle, Shape.rectangle, and Shape.labeled;
readers circle_radius, rectangle_width, rectangle_height,
labeled_label, and labeled_count. Each maps to a
weaveffi_shapes_Shape_<Variant>_new /
weaveffi_shapes_Shape_<Variant>_get_<field> symbol, and
weaveffi_shapes_Shape_tag returns the discriminant.
Construct a couple of variants, read the tag and a field, then pass the wrapper to a module function:
require 'weaveffi'
circle = WeaveFFI::Shape.circle(2.0)
labeled = WeaveFFI::Shape.labeled('unit', 3)
if circle.tag == WeaveFFI::Shape::CIRCLE
puts circle.circle_radius # 2.0
end
puts labeled.labeled_count # 3
puts WeaveFFI.describe(circle) # render via the C ABI
bigger = WeaveFFI.scale(circle, 3.0) # returns a new Shape
Ownership: the ShapePtr FFI::AutoPointer calls
weaveffi_shapes_Shape_destroy when Ruby garbage-collects the wrapper;
call #destroy for deterministic cleanup. The Shape returned by
WeaveFFI.scale is managed the same way.
Build instructions
-
Generate the bindings:
weaveffi generate api.yaml -o generated --target ruby -
Build the Rust shared library:
cargo build --release -p your_library -
Build and install the gem:
cd generated/ruby gem build weaveffi.gemspec gem install weaveffi-0.1.0.gem -
Make the cdylib findable at runtime:
- macOS:
DYLD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb - Linux:
LD_LIBRARY_PATH=$PWD/../../target/release ruby your_script.rb - Windows: place
weaveffi.dllnext to the script or add its directory toPATH.
- macOS:
The Ruby module name and gem name can be customised via generator configuration:
[ruby]
module_name = "MyBindings"
gem_name = "my_bindings"
Memory and ownership
- Strings in: Ruby strings are passed as
:stringparameters and the FFI gem encodes them to null-terminated C strings. - Strings out: the wrapper reads the returned
:pointerwithread_string, then callsweaveffi_free_stringto release the Rust-owned buffer. - Bytes: an
FFI::MemoryPointeris allocated for inputs; outputs are copied withread_string(len)and the returned buffer is released withweaveffi_free_bytes. - Structs and interfaces: wrappers hold an
FFI::AutoPointerwhosereleasecallback invokes the C_destroyfunction on GC. Use the explicitdestroymethod for deterministic cleanup. - Lists and maps: elements are copied into a Ruby
ArrayorHash; string elements are freed individually withweaveffi_free_string, then the backing pointer buffers are freed withweaveffi_free_bytes. - Boxed optional scalars: an absent value is
nil; a present one is dereferenced and the box is freed withweaveffi_free_bytes.
Async support
Async IDL functions (async: true) are exposed as blocking wrapper
methods. The wrapper creates a Queue, builds an FFI::Function
completion callback that pushes either the converted result or an
error onto it, calls the _async-suffixed C launcher, then pops the
queue and raises if the producer reported an error. For a callable
marked throws: true, the error goes through the domain mapper
(task_error_from here, kv_error_from on Store#compact), so the
raised object is the typed class:
# Blocks until the async producer completes.
def self.run_task(name)
queue = Queue.new
callback = FFI::Function.new(
:void, [:pointer, :pointer, :pointer]
) do |_context, err_ptr, result|
err = err_ptr.null? ? nil : ErrorStruct.new(err_ptr)
if err && err[:code] != 0
# ... read code/message, weaveffi_error_clear ...
queue << task_error_from(code, msg)
else
# ... null-pointer guard ...
queue << TaskResult.new(result)
end
end
weaveffi_tasks_run_task_async(name, callback, FFI::Pointer::NULL)
value = queue.pop
raise value if value.is_a?(Error)
value
end
There is no promise/future type and no concurrent-ruby dependency:
the calling thread blocks until the completion callback fires. Wrap
the call in a Thread when you need concurrency:
t = Thread.new { WeaveFFI.run_task('demo') }
result = t.value # joins; re-raises a WeaveFFI::Error from the call
The local callback reference keeps the FFI::Function alive until
queue.pop returns, so the completion callback cannot be collected
mid-flight.
Result ownership follows the async contract: string, bytes, array,
map, and boxed optional scalar results are borrowed for the callback’s
duration, so the callback copies them into Ruby values (read_string,
element reads) before it returns and never frees them; the producer
does after the callback returns. Object results (records, rich enums,
interfaces, including optional ones) are the exception: the callback
receives ownership, and the wrapper adopts the pointer into its
FFI::AutoPointer (as TaskResult.new(result) does above), so the
destructor runs on GC or an explicit destroy.
For functions marked cancellable: true the C launcher takes an extra
cancel-token parameter. The wrapper always passes FFI::Pointer::NULL
for it. The token isn’t exposed (the generated comment reads
“cancellation token not exposed; pass-through is NULL”). Cancellation
tokens are currently surfaced only by the C and C++ targets.
Callbacks and listeners
IDL callbacks declare a C function-pointer type; a listener pairs
one with register/unregister entry points:
callbacks:
- name: OnMessage
params:
- { name: message, type: string }
listeners:
- name: message_listener
event_callback: OnMessage
The generated module declares the FFI callback type and exposes a
register/unregister pair. Registering takes a block, wraps it in an
FFI::Function trampoline, and returns a uint64 subscription id:
callback :weaveffi_events_OnMessage_fn, [:string, :pointer], :void
attach_function :weaveffi_events_register_message_listener,
[:weaveffi_events_OnMessage_fn, :pointer], :uint64
attach_function :weaveffi_events_unregister_message_listener, [:uint64], :void
# Registers a OnMessage listener block. Returns a subscription id for
# unregister_message_listener.
def self.register_message_listener(&block)
trampoline = FFI::Function.new(:void, [:string, :pointer]) do |message, _context|
block.call(message)
end
listener_id = weaveffi_events_register_message_listener(trampoline, FFI::Pointer::NULL)
@listener_refs[listener_id] = trampoline
listener_id
end
def self.unregister_message_listener(listener_id)
weaveffi_events_unregister_message_listener(listener_id)
@listener_refs.delete(listener_id)
nil
end
- GC safety: the
FFI::Functiontrampoline is pinned in a module-level registry (@listener_refs), keyed by subscription id, so it cannot be garbage-collected while the producer may still call it. Unregistering deletes the registry entry. - Subscription ids: registration returns the
uint64id produced byweaveffi_events_register_message_listener(fn, context); pass it tounregister_message_listenerto stop delivery and release the trampoline. - Threading: the callback fires on the producer’s thread, not the
thread that registered it. Do not block inside it; marshal results
to your own thread or event loop (a
Queueworks well).
Typical round trip:
id = WeaveFFI.register_message_listener { |message| puts message }
WeaveFFI.send_message('hello')
WeaveFFI.unregister_message_listener(id)
Iterators
Functions returning iter<T> return a lazy Enumerator that streams
one element per pull: each consumer step issues exactly one call to
the generated _next binding, so nothing is drained up front. Call
.to_a if you want an eager Array:
attach_function :weaveffi_events_get_messages, [:pointer], :pointer
attach_function :weaveffi_events_GetMessagesIterator_next,
[:pointer, :pointer, :pointer], :int32
attach_function :weaveffi_events_GetMessagesIterator_destroy,
[:pointer], :void
# Return an iterator over all sent messages
# Returns a lazy Enumerator that streams one element per pull; call
# `.to_a` to collect eagerly. The underlying producer iterator is
# launched on the first pull, so launch errors raise at that point
# rather than when this method returns. The iterator handle is
# released exactly once, when iteration finishes or is abandoned
# early (for example by `break`).
def self.get_messages()
Enumerator.new do |y|
err = ErrorStruct.new
iter = weaveffi_events_get_messages(err)
begin
check_error!(err)
unless iter.null?
loop do
out_item = FFI::MemoryPointer.new(:pointer)
item_err = ErrorStruct.new
has_item = weaveffi_events_GetMessagesIterator_next(iter, out_item, item_err)
check_error!(item_err)
break if has_item.zero?
item_ptr = out_item.read_pointer
if item_ptr.null?
y << ''
else
item = item_ptr.read_string
weaveffi_free_string(item_ptr)
y << item
end
end
end
ensure
weaveffi_events_GetMessagesIterator_destroy(iter) unless iter.null?
end
end
end
The producer iterator launches on the first pull, so a launch error
raises then, not when the method returns. Each string element is
copied with read_string and freed with weaveffi_free_string;
record elements are adopted by their FFI::AutoPointer-backed
wrapper. The ensure block destroys the handle exactly once, whether
iteration exhausts, raises, or is abandoned early (Ruby runs ensure
when the enumerator’s fiber is torn down, for example after break).
The per-step error check follows the function’s error strategy: the
throwing kvstore sample’s Store#list_keys checks the launcher and
each next with check_kv_error!, so a failing step raises the typed
KvError subclass; the non-throwing get_messages uses the generic
check_error!, which raises only on a producer bug.
Troubleshooting
LoadError: Could not open library 'libweaveffi.dylib': the cdylib is not on the loader path. SetDYLD_LIBRARY_PATH/LD_LIBRARY_PATHor copy the library next to your script.FFI::NotFoundError: Function 'weaveffi_*' not found: the cdylib does not export the symbol. Rebuild the Rust crate after regenerating the IDL.- Segmentation faults on Ruby exit: the generated wrappers pin
listener trampolines in
@listener_refsand keep async completion callbacks referenced until they fire. If you call theattach_functionbindings directly, keep your ownFFI::Functionobjects alive for the lifetime of the call; letting them be garbage-collected mid-call corrupts the C side. - Strings come back as binary garbage: UTF-8 strings should round
trip through
read_string; for binary data useread_bytes(length)with theout_lenreturned by the C ABI.
API
Reference documentation for the WeaveFFI Rust crates.
API docs are generated from source via cargo doc:
cargo doc --workspace --all-features --no-deps --open
When the documentation site is deployed, API docs are available under the API section.
Every public item in the library crates is documented; this is enforced in CI. See Doc Comment Style for the conventions and the lints that back them.
Rust API (cargo doc)
The weaveffi producer crate
A Rust producer depends on a single crate, weaveffi. It
re-exports the #[weaveffi::module] family of attributes and the
export_runtime! macro, plus the C ABI runtime as weaveffi::abi. Annotate a
normal module, tag the items to export, and the macro emits the
#[no_mangle] extern "C" thunks for you. See
The Rust Producer Macro for the full guide.
[dependencies]
weaveffi = "0.12"
The supporting crates are published separately and are useful when you need the lower layers directly:
| Crate | What it is |
|---|---|
weaveffi | The producer facade: the attribute macros, export_runtime!, and abi. Depend on this. |
weaveffi-abi | The stable C ABI runtime: weaveffi_error, memory helpers, cancel tokens, the arena, and the lift_*/lower_* marshalling converters the macro calls. The macro generates code against it; a producer reaches the same helpers through weaveffi::abi when it needs one directly (for example to dereference a raw handle). |
weaveffi-macros | The proc-macro implementation behind weaveffi’s attributes. You rarely depend on it directly. |
weaveffi-ir | The IR types (Api, Module, TypeRef, …) and the IDL parser. |
Browsing the docs
Generate and view the Rust API docs locally:
cargo doc --workspace --all-features --no-deps --open
When the documentation site is deployed, API docs are available at weavefoundry.github.io/weaveffi/api/rust/weaveffi_core/.
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:
[`BindingModel`]or[`Api`](weaveffi_ir::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 [`BindingModel`] 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 weaveffi_core::codegen::generate;
/// # use weaveffi_ir::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 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 weaveffi_error {
/// Status code. `0` means success; any non-zero value indicates failure.
pub code: i32,
/// Owned, NUL-terminated UTF-8 message, or null when `code` is `0`.
pub message: *const c_char,
}
/// 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 [`BindingModel`], never re-deriving lowering.
///
/// See [`Api`](weaveffi_ir::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 (`BindingModel`, `weaveffi.yml`).
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 configured per library crate (in each crate’s lib.rs)
and centrally in clippy.toml:
| 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 rustdoc::all -D rustdoc::missing_crate_level_docs" \
cargo doc --workspace --no-deps
# Or run both through the shared recipe.
just doc
The generated API reference is published under
/api/rust/ when the docs site deploys.
Guides
Practical guides for working with WeaveFFI bindings across targets.
- The Rust Producer Macro: write safe Rust, annotate it with
#[weaveffi::module], and let theweavefficrate generate the C ABI glue. - Memory Ownership: allocation rules; freeing strings, bytes, structs, and errors across the FFI boundary.
- Error Handling: the uniform error model and how each target surfaces failures.
- Async Functions: IDL declaration, the C ABI callback contract, and per-target async surfaces.
- Annotated Rust Extraction: extract an IDL from annotated Rust source instead of writing YAML by hand.
- Generator Configuration: customize per-target names, module-prefix stripping, and the C ABI prefix via
weaveffi.tomlor inlinegenerators:blocks. - Packaging and Distribution: assemble ready-to-publish packages that bundle prebuilt native libraries per platform.
The Rust Producer Macro
If your producer is written in Rust, the most ergonomic workflow is to write a
normal, safe Rust library, annotate it with the #[weaveffi::module] family of
attributes, and let the weaveffi crate generate the #[no_mangle] extern "C"
thunks that back the
stable C ABI. The same annotated source is what weaveffi generate src/lib.rs
reads to emit the IDL, the C header, and every language binding, so the
producer you compile and the bindings you ship cannot drift: they are two
views of one parse.
This is the “Rust as the source of truth” model. You never hand-write
unsafe FFI glue, and there is no separate IDL file to keep in sync.
Setup
Add the single weaveffi facade crate and build a cdylib (plus an rlib
if you also want to unit-test the safe functions in-crate):
[package]
name = "my-lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
weaveffi = "0.14"
A complete example
#![allow(unused)]
fn main() {
//! src/lib.rs
/// Arithmetic over 32-bit integers.
#[weaveffi::module]
pub mod calculator {
/// The calculator's error domain: the codes its throwing functions report.
#[weaveffi::error]
#[derive(Debug)]
pub enum CalcError {
/// division by zero
DivisionByZero = 1,
}
/// Add two integers.
#[weaveffi::export]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Divide two integers, failing on a zero divisor.
#[weaveffi::export]
pub fn div(a: i32, b: i32) -> Result<i32, CalcError> {
if b == 0 {
return Err(CalcError::DivisionByZero);
}
Ok(a / b)
}
}
// Emit the fixed runtime surface (memory, error, and cancel-token helpers)
// exactly once per cdylib.
weaveffi::export_runtime!();
}
That is the whole producer. Building it yields a shared library exporting
weaveffi_calculator_add and weaveffi_calculator_div with the exact
signatures the generated C header declares. A Result<T, E> return marks the
function throws: true in the IDL: the return type is T, and Err is
reported through the trailing out_err parameter with the code the
#[weaveffi::error] enum declares, so every binding surfaces it as a typed
domain error (see Error Handling). A throwing function needs an
error domain in scope on its module or an ancestor.
Generate the bindings straight from the same file:
weaveffi generate src/lib.rs -o generated --target c,swift,python
The attributes
| Attribute | Where it goes | Effect |
|---|---|---|
#[weaveffi::module] | inline mod foo { ... } | Marks an exported namespace and drives the codegen. Modules may nest. |
#[weaveffi::export] | fn | Exports a function. A Result<T, E> return is throws: true; () (and Result<(), E>) is a void return. |
#[weaveffi::record] | named-field struct | A by-value record. Generates create, destroy, and a getter per field. |
#[weaveffi::interface] | struct with an impl block | An opaque object type. The impl block’s pub fns become constructors (those returning Self), methods (&self), and statics; a destroy symbol is implicit. |
#[weaveffi::error] | unit-variant enum | Declares the module’s error domain. Every variant needs an explicit = N discriminant; the doc comment is the code’s default message. |
#[weaveffi::enumeration] | #[repr(i32)] enum | A C-style enum. Every variant needs an explicit = N discriminant. |
#[weaveffi::callback] | fn | Declares a callback signature (see roadmap). |
#[weaveffi::listener(event = "Name")] | fn | Declares an event listener bound to a callback (see roadmap). |
#[weaveffi::cancellable] | async fn | Marks an async function as accepting a cancel token (see roadmap). |
#[weaveffi::builder] | #[weaveffi::record] struct | Opts the record into a fluent builder (see roadmap). |
Only items carrying a marker are exported. Private helpers, use items, the
module’s in-memory state, and free functions without #[weaveffi::export] are
left untouched, so a module can freely mix its exported surface with its
implementation. Doc comments (///) on items, fields, and variants flow into
the generated IDL and every binding.
Call weaveffi::export_runtime!() exactly once in the crate (not per module).
It emits the fixed C ABI runtime symbols (weaveffi_free_string,
weaveffi_free_bytes, weaveffi_error_clear, the cancel-token helpers, and
the arena) that every binding links against.
How values cross the boundary
The macro marshals each argument and result through the audited
weaveffi::abi runtime, so every unsafe
pointer operation lives in one reviewed place rather than in generated glue.
You write ordinary Rust types; the macro picks the matching ABI shape:
| Rust type | IDL type | C ABI shape |
|---|---|---|
i8..i64, u8..u32, f32, f64, bool | same | the scalar |
String, &str | string | const char* |
Vec<u8>, &[u8] | bytes | const uint8_t* ptr, size_t len |
u64 | handle | weaveffi_handle_t |
*mut T, *const T | handle<T> | opaque T* |
a #[weaveffi::record] struct | the record | opaque object pointer |
a #[weaveffi::enumeration] enum | the enum | int-sized discriminant |
Option<T> | T? | nullable pointer or value slot |
Vec<T> | [T] | ptr + len (object pointers for record lists) |
A u64 parameter or return is an opaque handle. Reach for the IDL directly
if you need a real 64-bit scalar. See
Annotated Rust Extraction for the exhaustive table.
Records
A #[weaveffi::record] struct that crosses the boundary by value must derive
Clone (the macro clones it out of the caller’s heap). The generated surface
matches the canonical C ABI for a record: a create constructor over the
fields, a destroy, and a getter per field.
#![allow(unused)]
fn main() {
#[weaveffi::record]
#[derive(Clone, Debug)]
pub struct Contact {
pub id: i64,
pub first_name: String,
pub email: Option<String>,
pub kind: ContactType,
}
}
Interfaces
A #[weaveffi::interface] struct is a first-class object type: the consumer
holds an opaque pointer to a live instance rather than a copied value. The
impl block defines the surface; the struct’s own fields (its state) never
cross the boundary, so they need no annotations.
#![allow(unused)]
fn main() {
#[weaveffi::interface]
pub struct ContactBook {
contacts: Mutex<Vec<Contact>>,
next_id: AtomicI64,
}
impl ContactBook {
/// Create an empty book. // -> constructor
pub fn new() -> Self { /* ... */ }
/// Add a contact to the book. // -> throwing method
pub fn add(&self, first_name: String, /* ... */) -> Result<Contact, ContactsError> { /* ... */ }
/// Number of contacts in the book. // -> method
pub fn count(&self) -> i32 { /* ... */ }
}
}
Every binding wraps the pointer in an idiomatic class (Swift class with
deinit, Kotlin Closeable, Python __del__, Go Close(), and so on) and
the implicit destroy symbol frees the instance exactly once. See
samples/contacts and samples/inventory for complete producers.
Cross-module references
Modules can reference each other’s records and enums. Import the type with a
normal use and pass it by value or by reference:
#![allow(unused)]
fn main() {
#[weaveffi::module]
pub mod products {
#[weaveffi::record]
#[derive(Clone)]
pub struct Product { pub id: i64, pub price: f64 }
// ...
}
#[weaveffi::module]
pub mod orders {
use super::products::Product;
/// Takes a `products::Product` across the module boundary.
#[weaveffi::export]
pub fn add_product(order_id: u64, product: Product) -> bool {
// ... look up the order and append the product ...
true
}
}
}
Each module is expanded on its own, so the macro emits a pointer-passing thunk
named for its own module while the CLI (which sees the whole crate) resolves
the reference to products.Product in the IDL and header. Both spellings are
the same opaque pointer at the ABI level, so the producer and the generated
bindings agree. See samples/inventory for a complete two-module example.
Feature support
The proc-macro generates cdylib glue for the full IDL feature set. Every
feature below is understood by the IDL, the validator, and every generator, and
the macro emits the matching producer glue, so an annotated module compiles
straight to a weaveffi_* cdylib with no hand-written extern "C" layer.
| Feature | Macro codegen | Reference sample |
|---|---|---|
| Modules, nested modules | Supported | inventory, kvstore |
Sync functions, Result errors | Supported | calculator, contacts |
Error domains (#[weaveffi::error]) | Supported | calculator, contacts |
| Interfaces (constructors / methods / statics) | Supported | contacts, inventory |
| Records (create / destroy / getters) | Supported | contacts |
| C-style enums | Supported | contacts, shapes |
Scalars, string, bytes, handles, typed handles | Supported | kvstore |
| Optionals, lists (scalar / string / record), maps | Supported | inventory, kvstore |
| Async (and cancellable) functions | Supported | async-demo, kvstore |
| Callbacks and event listeners | Supported | events, kvstore |
| Iterator returns | Supported | events, kvstore |
| Rich (data-carrying) enums | Supported | shapes |
| Builder records | Supported | kvstore |
A few narrow shapes are still rejected at compile time with a clear message rather than emitting glue that disagrees with the header, notably iterator parameters (as opposed to iterator returns) and tuple-style rich-enum variants (use named fields instead). When the macro can’t express a producer it fails loudly, so it never drifts silently from the IDL. The generators deliver the full feature set on the consumer side regardless; the samples in the right-hand column are working references for each pattern.
See also
- Getting Started: the end-to-end IDL-first walkthrough; this guide is the Rust-macro alternative to its step 2.
- Annotated Rust Extraction: the
weaveffi extract/generate <file.rs>CLI and the full attribute and type reference. - Memory Ownership and Error Handling: the ABI contracts the macro upholds for you.
Memory Ownership
Overview
WeaveFFI exposes Rust functionality through a stable C ABI. Because Rust and the consumer languages (C, Swift, Kotlin, Python, …) have different memory models, every allocation that crosses the boundary follows strict ownership rules.
Golden rule: whoever allocates owns it, and ownership must be
explicitly transferred back for deallocation. Rust allocates; the
consumer frees through the designated weaveffi_free_* functions or
the matching _destroy symbol.
The full release contract, exactly which call a wrapper owes after
copying a returned value or a collection element, is stated once, in
weaveffi_core::plan (ReturnFree for returns, ElemFree for
array, map, and iterator elements). Every generated wrapper renders
that plan, and this guide describes the same rules in prose.
When to use
Read this guide when:
- You are writing a consumer in C/C++ where the compiler will not free anything for you.
- You are debugging a leak, double-free, or use-after-free in a generated binding.
- You are extending a generator and need to verify the ownership contract for a new type.
- You are reviewing PRs that add new IDL types that involve heap-allocated data.
For higher-level languages (Swift, Kotlin, Python, .NET, Dart, Ruby, Go) the generated wrappers handle most of this automatically; the rules below explain what those wrappers are doing under the hood.
Step-by-step
Strings
Rust returns NUL-terminated, UTF-8, heap-allocated C strings created
via CString::into_raw. The consumer must free them with
weaveffi_free_string.
weaveffi_error err = {0, NULL};
const char* echoed = weaveffi_calculator_echo("hello", &err);
if (err.code) {
fprintf(stderr, "%s\n", err.message);
weaveffi_error_clear(&err);
return 1;
}
printf("result: %s\n", echoed);
weaveffi_free_string(echoed);
Generated wrappers do the same with defer:
let raw = weaveffi_calculator_echo(...)
defer { weaveffi_free_string(raw) }
return String(cString: raw!)
Byte buffers
Byte buffers are returned as const uint8_t* plus an out_len. Free
them with weaveffi_free_bytes(ptr, len); the length must match what
the C ABI returned.
size_t out_len = 0;
const uint8_t* buf = weaveffi_module_get_data(&out_len, &err);
if (err.code) {
weaveffi_error_clear(&err);
return 1;
}
process_data(buf, out_len);
weaveffi_free_bytes((uint8_t*)buf, out_len);
Lists, maps, and boxed optionals
Composite returns owe two levels of release: one per element, then one for the buffer itself.
- Lists (
[T]) returnT* + out_len. Free each element per its element plan (below), then release the array buffer withweaveffi_free_bytes(ptr, len * sizeof(T)). - Maps (
{K:V}) return parallelout_keys/out_values/out_lenbuffers. Free each key and each value per its element plan, then release both parallel arrays withweaveffi_free_bytes. - Optional scalars (
i32?,f64?, …) return a boxed pointer (T*, null meaning none). Dereference the value, then release the box withweaveffi_free_bytes(ptr, sizeof(T)). Optional pointer returns (string?,Contact?) reuse the inner type’s plan; a null return simply means there is nothing to free.
The per-element plan is:
| Element type | Release owed per element |
|---|---|
Scalar, bool, C-style enum, handle | nothing (by value) |
string | weaveffi_free_string |
| Record or rich enum | the type’s _destroy symbol (the consumer owns each element) |
| Optional of the above | the inner plan; skip null slots |
Iterator elements
An iter<T> return hands the consumer an opaque iterator handle, not a
buffer, so there is nothing to free on launch. Ownership flows per
step:
- Each
_nextcall writes an element the consumer now owns. After copying it, free it per the element plan above (weaveffi_free_stringfor strings,_destroyfor record or rich-enum elements, nothing for by-value elements). - The handle is released with the iterator’s own
_destroysymbol, exactly once: eagerly on exhaustion, and from the wrapper’s disposal idiom (RAII destructor, finalizer,close(), generator cleanup) when iteration is abandoned early.
Generated wrappers do both for you; they surface iter<T> as the
target’s native lazy iteration idiom and pull one element per consumer
step. See the IDL reference.
Sync versus async returns
Everything above describes synchronous returns: the consumer receives an owned value and owes the matching release call after copying it.
Async results invert the buffer rule. The buffers passed to an
async completion callback (strings, bytes, arrays, boxed optional
scalars) are borrowed: they stay owned by the producer, are valid only
for the callback’s duration, and are freed by the producer after the
callback returns. The consumer copies inside the callback and must not
free them. Owned-object results (records, rich enums, and interfaces,
including optionals of them) are the exception in both directions: the
callback receives ownership, adopts the pointer, and eventually calls
_destroy, exactly as a synchronous object return would. See
Result ownership and threading.
Struct and interface lifecycle
Structs and interface objects are opaque on the consumer side. The lifecycle is:
*_create(structs) or a declared constructor such as*_open(interfaces) allocates and returns a pointer; the consumer owns it.*_destroyfrees the object. Call exactly once.*_get_<field>getters read struct fields, and interface methods take the receiver as their leading argument. Primitive getters (i32,f64,bool) return values directly. String/bytes getters return new owned copies that must be freed.
Functions that take an interface or handle<T> parameter always
borrow it: the producer must never free a receiver it is passed,
even for close-style functions. The only function that frees an
object is its *_destroy symbol. Generated wrappers call *_destroy
automatically (Swift deinit, Python __del__, Ruby
FFI::AutoPointer, …), so a producer that frees a receiver inside
an ordinary function causes a double-free as soon as the wrapper is
garbage collected.
weaveffi_error err = {0, NULL};
weaveffi_contacts_Contact* contact = weaveffi_contacts_Contact_create(
1, "Alice", "Smith", "alice@example.com",
weaveffi_contacts_ContactType_Personal,
&err);
if (err.code) {
weaveffi_error_clear(&err);
return 1;
}
int64_t id = weaveffi_contacts_Contact_get_id(contact);
const char* name = weaveffi_contacts_Contact_get_first_name(contact);
weaveffi_free_string(name);
weaveffi_contacts_Contact_destroy(contact);
The generated Swift wrapper invokes _destroy from deinit and frees
returned strings with defer:
public class Contact {
let ptr: OpaquePointer
init(ptr: OpaquePointer) { self.ptr = ptr }
deinit { weaveffi_contacts_Contact_destroy(ptr) }
public var first_name: String {
let raw = weaveffi_contacts_Contact_get_first_name(ptr)
guard let raw = raw else { return "" }
defer { weaveffi_free_string(raw) }
return String(cString: raw)
}
}
Error struct lifecycle
Every C ABI function takes a trailing weaveffi_error* out_err. On
failure Rust writes a non-zero code and a Rust-allocated message.
Clearing the error frees the message:
weaveffi_error err = {0, NULL};
int32_t result = weaveffi_calculator_div(10, 0, &err);
if (err.code) {
fprintf(stderr, "error %d: %s\n", err.code, err.message);
weaveffi_error_clear(&err);
}
result = weaveffi_calculator_add(1, 2, &err);
Generated wrappers clear the slot for you. On a throws: true
function they convert non-zero codes into the module’s typed domain
error (throw, raise, (T, error)); on a non-throwing function a
non-zero code only ever reports a producer bug, so the wrapper panics
or traps instead. See the Error Handling Guide.
weaveffi_error_clear is idempotent: it frees the message and nulls
the pointer, so clearing an already-cleared slot is safe. That matters
for async completion callbacks, where the error struct is borrowed
from the producer (which releases the message itself after the
callback returns); a consumer that clears it anyway causes no
double-free.
Thread safety
Generated FFI functions are expected to be called from a single thread unless the module’s documentation says otherwise. Concurrent calls from multiple threads can cause data races and undefined behaviour. Synchronise externally, for example with a mutex or a serial dispatch queue:
let queue = DispatchQueue(label: "com.app.weaveffi")
queue.sync {
let result = Calculator.add(a: 1, b: 2)
}
Reference
| Resource | Allocator | Free function | Notes |
|---|---|---|---|
| Returned string | Rust | weaveffi_free_string | Every const char* return |
| Returned bytes | Rust | weaveffi_free_bytes | Pass both pointer and length |
| Returned list | Rust | element plan, then weaveffi_free_bytes | Free each element first, then the buffer (len * sizeof(T)) |
| Returned map | Rust | element plans, then weaveffi_free_bytes twice | Keys and values first, then both parallel arrays |
| Boxed optional scalar | Rust | weaveffi_free_bytes | sizeof(T); null means none, nothing to free |
| Struct instance | Rust | *_destroy | Call exactly once |
| Interface instance | Rust | *_destroy | Call exactly once; methods borrow |
| String from getter | Rust | weaveffi_free_string | Getter returns an owned copy |
| Iterator handle | Rust | the iterator’s _destroy | Exactly once: on exhaustion or abandonment |
| Iterator element | Rust | element plan | Each _next yields a consumer-owned element |
| Async result buffer | Rust | none (borrowed) | Producer frees after the callback returns; copy inside it |
| Async object result | Rust | *_destroy | Callback adopts ownership |
| Error message | Rust | weaveffi_error_clear | Clears code and frees message; idempotent |
Pitfalls
- Use-after-free: reading a string after freeing it, or accessing
a struct after
_destroy. Once the consumer frees something, the pointer is invalid. - Double-free: freeing the same pointer twice (e.g. calling
weaveffi_free_stringtwice or invoking_destroyafter the wrapper has already done so). - Wrong length to
weaveffi_free_bytes: always free with the exact length the C ABI returned inout_len. - Forgetting to clear error structs:
err.messageis Rust-allocated; failing to callweaveffi_error_clearafter a non-zero code leaks that string. - Calling FFI from multiple threads without synchronisation: the default contract is single-threaded; synchronise externally if you need parallelism.
- Manually freeing pointers passed in as borrowed parameters:
borrowed inputs (
&str,&[u8],const T*) are owned by the caller and must not be passed toweaveffi_free_*. - Freeing only the buffer of a list of strings or objects: a
returned
[string]or[Contact]owes one release per element before the buffer release; skipping the element pass leaks every entry. - Freeing an async result buffer: buffers passed to a completion callback are producer-owned and freed by the producer after the callback returns. Copy inside the callback; freeing there double-frees.
- Destroying an iterator handle twice: destroy it once, on exhaustion or when abandoning iteration early. Generated wrappers null the handle so their disposal idiom cannot double-destroy; hand-written C consumers must do the same.
Error Handling
Overview
WeaveFFI’s error model is typed and opt-in. A module declares an error
domain: a named set of symbolic codes. A function, method, or constructor
opts into that domain by declaring throws: true, and every generator then
surfaces its failures through the target’s idiomatic error mechanism
(throws in Swift, raise in Python, (T, error) in Go, exceptions
elsewhere) carrying a typed error derived from the domain, so consumers
catch and match on the codes you declared.
A callable without throws has a plain signature: no throws clause,
no error return. It cannot report a domain error; the only failures it can
experience are producer bugs (a panic, a marshalling failure), and those
trap loudly through the target’s programming-error idiom rather than
surfacing as a typed error. The two interpretations are named once, in
weaveffi_core::plan::ErrorStrategy, and every generator renders the
same pair; see Throws versus Trap.
Underneath, every generated symbol still reports through the C-level
out-error parameter (weaveffi_error*) with an integer code and an
optional message string; the typed surface is built on top of it.
When to use
Reach for this guide when:
- You are designing an IDL and want to surface stable, named error codes to consumers as typed errors.
- You are writing the Rust implementation of a module and need to return errors over the C ABI.
- You are debugging an “unknown error” surface in a generated binding.
- You are reviewing or extending a generator and need to know what the error contract guarantees.
Step-by-step
Declare a domain and opt in with throws
version: "0.5.0"
modules:
- name: contacts
errors:
name: ContactsError
codes:
- name: InvalidName
code: 1
message: "name must not be empty"
- name: NotFound
code: 2
message: "contact not found"
functions:
- name: get_contact
params:
- { name: id, type: i64 }
return: string
throws: true
- name: count_contacts
params: []
return: i32
get_contact is fallible and delivers ContactsError values;
count_contacts has a plain signature in every target. Code names are
PascalCase by convention (NotFound, not not_found); each generator
re-cases them into its own idiom.
The domain is in scope for its module and every module nested inside it,
so one domain on a parent module can serve a whole subtree. Interface
constructors and methods opt in with the same throws: true flag.
The validator enforces:
code = 0is reserved for success and-2for producer panics; any other non-zero value is allowed.- Numeric codes are unique within a domain.
- Code names are unique within a domain and across every domain in the
API. Backends with flat namespaces derive one error class or constant
per code, so two domains both declaring
NotFoundwould collide; qualify one of them (e.g.OrderNotFound). - The domain
namemust not be empty, must not collide with any function name in the module, and shares the API-wide type namespace with struct, enum, and interface names. throws: truewith no domain in scope (on the module or an ancestor) is an error.
Report errors from the producer
With the Rust macro, declare the domain as a #[weaveffi::error] enum
whose discriminants are the ABI codes (doc comments become the default
messages), and return Result<T, YourError> from fallible functions:
#![allow(unused)]
fn main() {
#[weaveffi::module]
pub mod contacts {
#[weaveffi::error]
#[derive(Debug)]
pub enum ContactsError {
/// name must not be empty
InvalidName = 1,
/// contact not found
NotFound = 2,
}
#[weaveffi::export]
pub fn get_contact(id: i64) -> Result<String, ContactsError> {
Err(ContactsError::NotFound)
}
}
}
The macro generates the ErrorReport implementation and the C ABI thunks
that write the matching code and message into out_err.
If you hand-implement the C ABI (a non-Rust producer, or Rust without
the macro), report through the weaveffi-abi helpers, preferring the
codes you declared in the IDL:
#![allow(unused)]
fn main() {
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_contacts_get_contact(
id: i64,
out_err: *mut weaveffi_error,
) -> *const std::ffi::c_char {
abi::error_set(out_err, 2, "contact not found");
std::ptr::null()
}
}
| Helper | Effect |
|---|---|
error_set_ok(out_err) | Sets code = 0, frees any prior message |
error_set(out_err, code, msg) | Sets a non-zero code and allocates a message |
result_to_out_err(result, out_err) | Maps Result<T, E> through ErrorReport (domain code for implementors, generic -1 for plain Display errors) |
error_set_panic(out_err, payload) | Reports a caught panic with the reserved code -2 |
Handle errors in C
The C surface is the raw out-error struct:
weaveffi_error err = {0, NULL};
const char* contact = weaveffi_contacts_get_contact(id, &err);
if (err.code) {
fprintf(stderr, "error %d: %s\n", err.code,
err.message ? err.message : "unknown");
weaveffi_error_clear(&err);
return 1;
}
printf("contact: %s\n", contact);
weaveffi_free_string(contact);
The pattern is always:
- Zero-initialise:
weaveffi_error err = {0, NULL};. - Call the function with
&erras the last argument. - Check
err.code; if non-zero, readerr.messageand callweaveffi_error_clear(&err). - Reuse the struct for subsequent calls.
The domain’s codes are also emitted as a C enum, so a consumer can match on names instead of magic numbers:
typedef enum {
weaveffi_contacts_ContactsError_InvalidName = 1,
weaveffi_contacts_ContactsError_NotFound = 2
} weaveffi_contacts_ContactsError;
What consumers see
Every other target wraps that struct into a typed error construct named
after the domain. In Swift, the domain becomes an error enum with one
case per code (named in lowerCamelCase), and throwing wrappers throw
it:
public enum ContactsError: Error, LocalizedError {
case invalidName(message: String)
case notFound(message: String)
}
do {
let contact = try Contacts.getContact(id: 42)
print(contact)
} catch ContactsError.notFound {
print("no such contact")
}
In Python, the domain becomes an exception class (subclassing the generic
WeaveFFIError) with one subclass per code carrying its stable CODE:
try:
contact = contacts_get_contact(42)
except ContactsError.NotFound:
print("no such contact")
The remaining targets follow the same conceptual shape in their own
idiom: one typed error construct per domain, one case or subclass per
code, delivered through the language’s native error channel. Ecosystems
that suffix exceptions rename the domain accordingly (ContactsError
becomes ContactsException in Kotlin, .NET, and Dart). A code the
consumer doesn’t recognize (from a newer producer, for example) falls
back to the generic branded error rather than being dropped.
Producer panics
Generated Rust thunks wrap the producer call in catch_unwind. A panic is
reported through out_err with the reserved code -2
(weaveffi_abi::PANIC_ERROR_CODE) and the panic message, so a consumer
can always distinguish “the producer has a bug” from any declared domain
error. Panics never surface as typed domain errors: on a throwing
callable they arrive as the generic branded error, and on a non-throwing
callable they surface as the target’s unrecoverable idiom (a Swift
fatalError, a Go panic, a generic exception elsewhere).
Reference
Throws versus Trap
Every synchronous C ABI entry point carries a trailing out_err, and
every async completion callback carries an err slot, regardless of
throws. What differs is the meaning of a non-zero code, and every
backend agrees on it because the two interpretations are stated once as
weaveffi_core::plan::ErrorStrategy:
- Throws (
throws: true): a non-zero code is a typed domain error. The wrapper maps the code onto the module’s error domain (an exception subclass, a SwiftErrorenum case, a Goerrorvalue) and surfaces it through the target’s normal error channel so callers can catch and match on it. - Trap (no
throws): the only wayout_errreports failure is a producer bug (most commonly a caught panic, code-2). The wrapper surfaces it through the target’s programming-error idiom (a PythonWeaveFFIError, a Gopanic, a SwiftfatalError, a C# exception). A trapped failure is never silently ignored, and it is never dressed up as a typed domain error.
The per-target rendering of both strategies is tabulated below.
At the ABI level, weaveffi_error.code means:
| Code | Meaning |
|---|---|
0 | Success |
| a declared code | A typed producer error from the module’s domain |
-1 | Generic error (null self, bad input, string errors) |
-2 | Producer panic (PANIC_ERROR_CODE) |
1 | Invalid argument from marshalling |
On the typed path, a wrapper maps a non-zero code to the matching declared case of the domain type and falls back to the generic branded error for any code the domain doesn’t declare.
Per-target surface
Per target, the two strategies surface as:
| Target | Throws (throws: true) | Trap (producer bug) |
|---|---|---|
| C | weaveffi_error { code, message } struct | same struct (code -2 or 1) |
| Swift | throws, typed domain enum | fatalError |
| Python | raise, domain exception subclass | raise WeaveFFIError |
| Kotlin | throw, typed domain exception | throw WeaveFFIException |
| C# | throw, typed domain exception | throw WeaveFFIException |
| Dart | throw, typed domain exception | throw WeaveFFIException |
| JS/TS | throw, typed domain error | throw WeaveFFIError |
| Ruby | raise, typed domain error | raise WeaveFFI::Error |
| Go | (T, error) return, typed domain error | panic |
| C++ | throw, typed domain error | throw weaveffi::Error |
All targets share the canonical WeaveFFI brand (never the heck-derived
Weaveffi) for the generic fallback type. Error type names are derived
from a single naming policy: ecosystems that suffix with Error (Swift,
C++, Python, Node, Ruby, Go) use WeaveFFIError; ecosystems that suffix
with Exception (Kotlin, .NET, Dart) use WeaveFFIException. Per-code
names are PascalCased from the IDL, and domain type names keep exactly one
Error (or Exception) suffix.
| Field | Type | Description |
|---|---|---|
code | int32_t | 0 = success, non-zero = error |
message | const char* | NULL on success; Rust-allocated string on error |
See the Memory Ownership Guide for the freeing contract
on err.message.
Pitfalls
- Forgetting to call
weaveffi_error_clear: the message is Rust-allocated. Skipping the clear leaks the string. - Reading
err.messageafter clearing: the pointer is invalid as soon asweaveffi_error_clearreturns. - Using
code = 0orcode = -2as a domain value: the validator rejects both;0always means success and-2is reserved for producer panics. - Reusing a code name in two domains: code names are unique across
the whole API, so the validator rejects a second
NotFound. Qualify one of them (OrderNotFound). - Declaring
throws: truewithout a domain in scope: a throwing callable needs anerrors:block on its module or an ancestor. - Expecting a typed error from a non-throwing function: a callable
without
throwscannot deliver a domain error; a failure there is a producer bug and traps through the target’s programming-error idiom (see Throws versus Trap). - Not initialising the struct: always start with
{0, NULL}(or the language equivalent). Stalecodevalues from earlier calls produce confusing failures. - Ignoring the return value when
code != 0: Rust does not promise the return value is meaningful on failure. For pointer returns it is typicallyNULL; do not free it.
Async Functions
Overview
WeaveFFI exposes asynchronous Rust operations through a single
callback-based C ABI and language-native async wrappers in every
target. Mark a function with async: true (and optionally
cancellable: true) in the IDL and the generators emit the right
shape per target: async in Swift (async throws when the function
also declares throws: true), suspend fun in Kotlin, Promise<T>
in JS, async def in Python, Task<T> in .NET, and so on. When an
async function declares throws: true, the failure that settles the
future is the module’s typed domain error (see the
Error Handling Guide).
The completion contract every wrapper implements is stated once, in
weaveffi_core::plan::AsyncProtocol: the callback fires exactly once
per launch, from an arbitrary producer thread, and the result it
receives is either borrowed (copy it inside the callback) or adopted
(own it and destroy it later). See
Result ownership and threading
below.
When to use
Use async functions for:
- I/O-bound work (network, disk, database).
- Long-running operations that should not block the consumer’s event loop (UI threads, JS event loop, asyncio loop).
- Operations the consumer should be able to cancel (combine with
cancellable: true).
Avoid async for:
- Short CPU-bound work (math, parsing, validation). The callback overhead is more expensive than the call itself.
- Functions whose Rust implementation is purely synchronous and finishes in microseconds.
Step-by-step
1. Declare the function in the IDL
version: "0.5.0"
modules:
- name: net
errors:
name: NetError
codes:
- { name: Unreachable, code: 1, message: "host unreachable" }
functions:
- name: fetch_data
params:
- { name: url, type: string }
return: string
async: true
throws: true
doc: "Fetches data from the given URL"
- name: upload_file
params:
- { name: path, type: string }
- { name: data, type: bytes }
return: bool
async: true
cancellable: true
doc: "Uploads a file, can be cancelled"
| Field | Type | Default | Description |
|---|---|---|---|
async | bool | false | Mark the function as asynchronous |
cancellable | bool | false | Allow the async operation to be cancelled |
throws | bool | false | Deliver failures as the module’s typed domain error |
Here fetch_data fails with a typed NetError, while upload_file
is non-throwing: apart from cancellation, the only failures it can
surface are producer bugs.
2. Implement it in Rust
The generated C ABI symbol takes a callback pointer and an opaque
void* context. The Rust worker invokes the callback exactly once
when it is done. With the #[weaveffi::module] macro you write a
plain async fn (see samples/async-demo/src/lib.rs) and the
launcher below is generated for you; a hand-written producer
implements the same pattern:
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(non_camel_case_types)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use std::ffi::c_void;
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};
pub type weaveffi_net_fetch_data_callback =
extern "C" fn(context: *mut c_void, err: *mut weaveffi_error, result: *const c_char);
#[no_mangle]
pub extern "C" fn weaveffi_net_fetch_data_async(
url: *const c_char,
callback: weaveffi_net_fetch_data_callback,
context: *mut c_void,
) {
let url_str = abi::c_ptr_to_string(url).unwrap_or_default();
let ctx = context as usize;
std::thread::spawn(move || {
let payload = abi::string_to_c_ptr(&format!("payload from {url_str}"));
callback(ctx as *mut c_void, std::ptr::null_mut(), payload);
// The string is borrowed by the callback: the producer frees it
// after the callback returns, so the consumer must have copied.
abi::free_string(payload);
});
}
}
The async launcher symbol always carries the _async suffix
(weaveffi_net_fetch_data_async), keeping the name free for a possible
synchronous variant. Note who frees the result: buffer results
(strings, byte arrays, lists, boxed optional scalars) are owned by the
producer, which releases them after the callback returns; the
macro-generated launchers do exactly this. Owned-object results
(records, rich enums, interfaces) are the exception: the callback
receives ownership of the pointer. See
Result ownership and threading.
3. Call it from each target
Swift:
let payload = try await Net.fetchData("https://example.com/data")
Kotlin/Android:
val payload = Net.fetchData("https://example.com/data")
Node.js:
const payload = await fetchData("https://example.com/data");
Python:
payload = await fetch_data("https://example.com/data")
.NET:
var payload = await Net.FetchDataAsync("https://example.com/data");
Dart:
final payload = await fetchData('https://example.com/data');
Because fetch_data declares throws: true, the error that rejects
the promise (or resumes the continuation, or fails the task) is the
typed NetError, so a Swift consumer writes catch NetError.unreachable
and a Python consumer writes except NetError.Unreachable.
4. Cancel a running operation
For cancellable: true functions the C launcher gains a
weaveffi_cancel_token* slot (before callback and context), and
the weaveffi-abi runtime provides the token lifecycle:
weaveffi_cancel_token* token = weaveffi_cancel_token_create();
weaveffi_net_upload_file_async(path, data, data_len, token, on_done, ctx);
/* later, from any thread: */
weaveffi_cancel_token_cancel(token);
The Rust worker polls weaveffi_cancel_token_is_cancelled(token) and
stops early, but the callback is always invoked exactly once:
either with the result or with a Cancelled error. The pin/unpin pair
(see Reference) runs on the cancellation path identically to the
success path.
Today the C and C++ surfaces expose the token (C++ as a trailing
cancel_token = nullptr parameter); the other wrappers pass NULL.
The operation runs to completion even if the consumer-side future is
abandoned.
Reference
C ABI shape
Each async function gets its own callback typedef of the form (context, err, <result slots>), and a launcher with the _async suffix:
typedef void (*weaveffi_net_fetch_data_callback)(
void* context,
weaveffi_error* err,
const char* result);
void weaveffi_net_fetch_data_async(
const char* url,
weaveffi_net_fetch_data_callback callback,
void* context);
The err argument of the callback carries the domain code for a
throws: true function; on a non-throwing function a non-zero code
only ever reports a producer bug (see
Throws versus Trap).
For cancellable: true the launcher takes a token slot before the
callback, and the runtime provides the token lifecycle:
void weaveffi_net_upload_file_async(
const char* path,
const uint8_t* data, size_t data_len,
weaveffi_cancel_token* cancel_token,
weaveffi_net_upload_file_callback callback,
void* context);
weaveffi_cancel_token* weaveffi_cancel_token_create(void);
void weaveffi_cancel_token_cancel(weaveffi_cancel_token* token);
bool weaveffi_cancel_token_is_cancelled(const weaveffi_cancel_token* token);
void weaveffi_cancel_token_destroy(weaveffi_cancel_token* token);
Result ownership and threading
The completion contract has three clauses, stated once in
weaveffi_core::plan::AsyncProtocol and rendered by every wrapper:
- Single completion. The callback fires exactly once per launch.
The wrapper resolves its native future idiom (a Python
asynciofuture, a JSPromise, a Swift continuation, a C#TaskCompletionSource, a Go channel) exactly once and then releases the registration. - Borrowed results. Result buffers passed to the callback
(strings, bytes, arrays, boxed optional scalars) are owned by the
producer and valid only for the callback’s duration: the
wrapper deep-copies them before the callback returns and must not
free them. The producer releases them after the callback returns;
the macro-generated launchers do this for you. Owned-object results
(records, rich enums, and interfaces, including optionals of them)
are the exception: the callback receives ownership and adopts the
pointer into the wrapper’s disposal idiom, which eventually calls
the type’s
_destroysymbol. - Foreign-thread delivery. The callback runs on an arbitrary
producer thread, so the wrapper hops back to its native scheduler
before touching consumer state (Python’s
call_soon_threadsafe, Node’s thread-safe function, a dispatched Swift continuation) rather than resolving inline where the target’s runtime forbids it.
The error struct passed to the callback is also producer-owned and
borrowed for the callback’s duration: the wrapper copies the code and
message inside the callback, and the producer releases the message
afterward. A wrapper may also call weaveffi_error_clear itself; the
clear is idempotent (it nulls the message pointer), so the producer’s
own release stays safe.
If you consume the raw C surface directly, the same rules apply to your callback: copy every buffer before returning, adopt object pointers, and never free a borrowed result.
Per-target async surface
| Target | Async surface | Cancel token exposure (cancellable: true) |
|---|---|---|
| C | Raw callback + _async launcher | weaveffi_cancel_token* slot before the callback |
| C++ | std::future<T> | trailing cancel_token = nullptr parameter |
| Swift | async (async throws with throws: true) | not exposed; wrapper passes nil |
| Kotlin | suspend fun | not exposed; wrapper passes 0L |
| Node.js | Promise<T> (thread-safe function settling) | not exposed; wrapper passes NULL |
| Python | async def (asyncio future settled via call_soon_threadsafe) | not exposed; wrapper passes None |
| .NET | Task<T> | not exposed; wrapper passes IntPtr.Zero |
| Dart | Future<T> (NativeCallable.listener) | not exposed; wrapper passes nullptr |
| Wasm | Promise<T> (table trampolines) | not exposed; wrapper passes 0 |
| Go | blocking bridge (chan receive); call from a goroutine | not exposed; wrapper passes nil |
| Ruby | blocking bridge (Queue#pop); call from a Thread | not exposed; wrapper passes NULL |
A wrapper that does not expose the token still launches and completes the call correctly; the operation simply runs to completion even if the consumer abandons the future. Drop to the C surface when you need cooperative cancellation from one of those targets.
Pin / unpin matrix
Every binding pins the user-supplied void* context and the callback
closure for the lifetime of the operation, then releases them exactly
once on the callback path. The matrix below is the contract every
generator implements; each row is asserted by that generator’s unit
tests.
| Target | Pin (allocate / retain) | Unpin (free / release) on callback | Notes |
|---|---|---|---|
| Swift | Unmanaged.passRetained(ContinuationRef(...)) | Unmanaged.fromOpaque(ctx).takeRetainedValue() | The retained +1 is dropped exactly once when the continuation resumes. |
| .NET | GCHandle.Alloc(callback, GCHandleType.Normal) | GCHandle.FromIntPtr(context).Free() | The catch path also frees the handle on synchronous failure. |
| Kotlin | JNI (*env)->NewGlobalRef(env, callback) | (*env)->DeleteGlobalRef(env, ctx->callback) | The JNI shim mallocs and frees the per-call context exactly once. |
| Node.js | napi_create_promise(env, &deferred, &promise) | napi_resolve_deferred or napi_reject_deferred | The N-API runtime owns the deferred; the per-call context is malloc-ed and freed exactly once. |
| Python | _token = _async_register(_cb) stores the ctypes.CFUNCTYPE trampoline in the module-level _async_pending dict | _async_pending.pop(_token, None) when the callback fires | The callback settles the asyncio future via loop.call_soon_threadsafe; no thread blocks waiting. |
| C++ | new std::promise<T>() plus the lambda capture | delete p; once at the end of the lambda | The lambda owns the heap promise on every exit branch. |
| Dart | NativeCallable<...>.listener(...) | callable.close() in finally and on the catch path | Pointer-typed parameters are kept alive in whenComplete. |
| Wasm | _registerTrampoline per signature plus _asyncContexts.set(ctxId, ...) per call | _asyncContexts.delete(ctxId) in the trampoline | Per-call resolver closures are removed after resolve/reject. |
| Go | wvCallbackStore(ch) registers the channel in a global registry keyed by an integer id | wvCallbackTake(id) removes it when the exported trampoline fires | The context crossing C is an integer id, never a Go pointer (cgo rule); the channel is buffered so the producer thread never blocks. |
| Ruby | the FFI::Function trampoline is a local kept alive by the enclosing method scope | the blocking queue.pop returns only after the callback ran | The wrapper blocks the calling Ruby thread, so the trampoline cannot be collected while the producer can still call it. |
Audit invariants
For every async-capable target:
- The
void* contexthas exactly one owner at any moment. - The callback closure is pinned by an explicit “+1” allocation
(
GCHandle.Alloc,Unmanaged.passRetained,NewGlobalRef,NativeCallable.listener, …) before the C worker can see it, and released by the matching “-1” exactly once on the callback path. - Synchronous failure of the C call (the callback never fires) is
handled in a
catch/trythat frees the pin so it does not leak. - The async-demo sample exports
weaveffi_tasks_active_callbacks()so a harness can assert the count returns to zero after a burst of concurrent calls.
Pitfalls
- Async void functions: the validator emits a warning. They are valid but almost always indicate a missing return type.
- Forgetting
cancellable: true: without it, the launcher has no cancel-token slot and the operation cannot be cancelled at all. - Using async for CPU-bound work: the callback overhead exceeds the work being done; keep it synchronous.
- Calling Go/Ruby async functions on a latency-sensitive thread:
both wrappers block the calling thread until the producer completes.
Wrap the call in a goroutine / Ruby
Threadwhen you need concurrency; the native work already runs off-thread. - Letting the callback closure get garbage-collected: every generator pins it explicitly. Do not strip those pins when editing generated code by hand.
- Returning
nullinstead of invoking the callback: the contract is that the callback fires exactly once for every async call, including on cancellation. - Holding a result pointer past the callback: buffer results are producer-owned and freed as soon as the callback returns. Copy the data inside the callback; a stashed pointer dangles.
- Freeing a borrowed result inside the callback: strings, bytes,
and array buffers belong to the producer, which frees them itself.
Freeing them in the callback double-frees. The only pointers the
callback owns are object results (records, rich enums, interfaces),
which it must eventually
_destroyexactly once.
Annotated Rust Extraction
Overview
One way to drive WeaveFFI is to make annotated Rust your source of truth. The
#[weaveffi::module] proc-macro reads that annotated source to generate the
producer’s C ABI glue (see The Rust Producer Macro), and
the CLI reads the
same annotations to derive the IDL and bindings. Both paths call into one
shared extractor (weaveffi-bridge), so the IDL the CLI emits and the symbols
the macro produces cannot drift.
You can point weaveffi generate and weaveffi extract straight at a .rs
file. generate lowers the source to the IR in memory and runs the generators;
extract writes the derived IDL to disk (handy for review, for committing a
canonical IDL alongside the source, or for piping into another command).
When to use
Reach for a .rs input when:
- You want the Rust implementation to be the single source of truth, with no separate IDL to maintain.
- You want the IDL to track signature changes automatically: edit the Rust, re-run.
Author an IDL document (YAML/JSON/TOML) directly when:
- You want to design the API before any Rust exists.
- You need a feature the extractor cannot infer from Rust syntax, such as
struct field defaults, package and per-generator configuration, or
since:without an accompanying#[deprecated]. See Pitfalls.
Step-by-step
1. Annotate the Rust source
Mark an inline module with #[weaveffi::module] and tag the items you want to
export. The attributes come from the weaveffi crate; the same crate’s macro
generates the producer glue when you compile the library.
#![allow(unused)]
fn main() {
/// Catalog operations.
#[weaveffi::module]
pub mod inventory {
/// A product in the catalog.
#[weaveffi::record]
#[derive(Clone)]
pub struct Product {
/// Stable identifier.
pub id: i32,
pub name: String,
pub price: f64,
pub tags: Vec<String>,
}
/// Product availability.
#[weaveffi::enumeration]
#[repr(i32)]
#[derive(Clone, Copy)]
pub enum Availability {
InStock = 0,
OutOfStock = 1,
Preorder = 2,
}
/// Look up a product by ID.
#[weaveffi::export]
pub fn get_product(id: i32) -> Option<Product> {
todo!()
}
/// Replaced by `search_v2` in 0.3.0.
#[weaveffi::export]
#[deprecated(since = "0.2.0", note = "use search_v2 instead")]
pub fn search(query: String, limit: i32) -> Vec<Product> {
todo!()
}
/// A nested namespace.
#[weaveffi::module]
pub mod nested {
/// Lives inside `inventory::nested`.
#[weaveffi::export]
pub fn helper() -> i32 {
0
}
}
}
}
2. Run weaveffi extract
weaveffi extract src/lib.rs # YAML to stdout
weaveffi extract src/lib.rs -o api.yml # YAML to file
weaveffi extract src/lib.rs -f json -o api.json # JSON to file
weaveffi extract src/lib.rs | weaveffi generate -o generated
The extracted IDL is validated automatically and extraction fails loudly
if the result would not generate, for example a handle<T> whose target type
the source never declares, a duplicate name, or a listener pointing at a
missing callback. Pass --warn to downgrade those errors to a warning: line
on stderr and emit the IDL anyway, which is useful when bootstrapping from
source that references types you have not declared yet:
weaveffi extract src/lib.rs --warn # best-effort, errors as warnings
3. Generate directly, or validate and generate the IDL
Skip the intermediate file and generate from the source:
weaveffi generate src/lib.rs -o generated/
Or commit the derived IDL and feed that to the rest of the toolchain:
weaveffi extract src/lib.rs -o api.yml
weaveffi validate api.yml
weaveffi generate api.yml -o generated/
Reference
CLI command
weaveffi extract <INPUT> [--output <PATH>] [--format <FORMAT>] [--warn]
| Flag | Default | Description |
|---|---|---|
<INPUT> | required | Path to a .rs source file |
-o, --output | stdout | Write to a file instead of stdout |
-f, --format | yaml | Output format: yaml, json, or toml |
--warn | off | Downgrade validation errors to warnings and emit the IDL anyway |
Attribute reference
The extractor matches a marker by its final path segment, so both the
namespaced form (#[weaveffi::record]) and a bare form (#[record]) resolve
identically. Prefer the namespaced form: it is what the #[weaveffi::module]
macro consumes, and it reads unambiguously.
| Attribute | Where it goes | Effect |
|---|---|---|
#[weaveffi::module] | inline mod | Marks an exported namespace. Required: only modules carrying it are extracted. Modules may nest. |
#[weaveffi::export] | free fn | Emits a Function in the enclosing module. async fn sets async: true; a Result<T, E> return sets throws: true (the IDL return type is T). |
#[weaveffi::record] | named-field struct | Emits a StructDef. |
#[weaveffi::interface] | struct with an impl block | Emits an InterfaceDef. The impl block’s pub fns become constructors (those returning Self), methods (&self receivers), and statics. |
#[weaveffi::error] | unit-variant enum | Emits the module’s error domain. Every variant needs an explicit = N discriminant; the first doc line is the code’s message. |
#[weaveffi::builder] | struct (with #[weaveffi::record]) | Sets builder: true on the emitted struct. |
#[weaveffi::enumeration] + #[repr(i32)] | enum | Emits an EnumDef. Every variant must have an explicit = N discriminant. |
#[weaveffi::cancellable] | exported async fn | Sets cancellable: true. |
#[weaveffi::callback] | free fn | Emits a module-level CallbackDef using the function’s name and parameters. |
#[weaveffi::listener(event = "Name")] | free fn | Emits a ListenerDef referencing the named callback (the legacy event_callback key is also accepted). |
#[deprecated(since = "...", note = "...")] | exported fn | Populates since and deprecated. Bare #[deprecated] sets deprecated = "deprecated". |
Doc comments (///) on items, fields, and enum variants become the doc
field in the IR.
Macro versus extraction. Both the CLI extractor and the
#[weaveffi::module]proc-macro understand the full annotation surface above, including interfaces, error domains, async, callbacks, listeners, iterators, rich enums, maps, and builders. A hand-authored IDL can additionally carry metadata that source can’t yet express (package and per-generator configuration, struct field defaults, and standalonesincetags), which is why the advanced samples keep a committed YAML IDL for generation. See Feature support for the macro’s current matrix.
Type mapping
| Rust type | WeaveFFI TypeRef | IDL string |
|---|---|---|
i8 | I8 | i8 |
i16 | I16 | i16 |
i32 | I32 | i32 |
i64 | I64 | i64 |
u8 | U8 | u8 |
u16 | U16 | u16 |
u32 | U32 | u32 |
f32 | F32 | f32 |
f64 | F64 | f64 |
bool | Bool | bool |
String | StringUtf8 | string |
Vec<u8> | Bytes | bytes |
u64 | Handle | handle |
&str | BorrowedStr | &str |
&[u8] | BorrowedBytes | &[u8] |
*mut T / *const T | TypedHandle("T") | handle<T> |
Vec<T> | List(T) | [T] |
Option<T> | Optional(T) | T? |
weaveffi::Iter<T> | Iterator(T) | iter<T> |
HashMap<K, V> | Map(K, V) | {K:V} |
BTreeMap<K, V> | Map(K, V) | {K:V} |
&T (other) | inner type | T |
&mut T (other) | inner type, mutable | T |
| Any other identifier | Struct(name) | name |
Compositions work recursively: Option<Vec<i32>> becomes [i32]? and
Vec<Option<String>> becomes [string?].
&mut T parameters are reduced to T and the surrounding Param record
gets mutable: true. &T for any non-str/[u8] type is also reduced to
T with mutable: false.
Round-trip integrity
The roundtrip_kitchen_sink integration test in
crates/weaveffi-cli/tests/extract_roundtrip.rs proves that the
hand-annotated form of the kitchen-sink IDL round-trips through weaveffi extract and matches the original IR for every supported feature: modules,
nested modules, structs (including builders), enums, interfaces, error
domains, per-function throws, callbacks, listeners, every primitive type,
borrowed types, typed handles, optional/list/map composites, async,
cancellable, and deprecated/since.
Pitfalls
The extractor parses syntax, not semantics. The items below cannot be inferred from Rust source alone and either must be added to the generated IDL by hand or are documented as round-trip gaps.
- Package and per-generator configuration. The
package:andgenerators:blocks have no source-level spelling; add them to the extracted IDL by hand (this is why the advanced samples commit a YAML). - Struct field default values. The IDL’s
default:field cannot be derived from Rust syntax (Rust struct fields have no default expressions). - Standalone
since:without#[deprecated].sinceis only recovered when paired with#[deprecated(since = "...")]. To setsinceon a non-deprecated function, edit the YAML manually. - An error code’s
doc:separate from itsmessage:. In Rust the first doc line on a#[weaveffi::error]variant is the code’s message; the IDL can carry both, so a distinctdoc:is dropped on round-trip. - Doc comments on parameters. Rust accepts
///onfnparameters but most formatters strip them; when present, the extractor preserves them, but plan forParam.docto be lossy. - Generics, trait
implblocks, and macros. The extractor never resolves generics or expands macros, and it only reads the inherentimplblock of a#[weaveffi::interface]type. Items produced by proc-macros and declarative macros are invisible. - External
mod foo;declarations. Only inline modules (mod foo { ... }) are processed; declarations that point to other files are skipped. - Tuple and unit structs. Only structs with named fields work with
#[weaveffi::record]. - Enum discriminants are mandatory. C-style enums need
#[repr(i32)]with explicit= Ndiscriminants, and rich (payload-carrying) enum variants must use named fields; tuple-style variants are rejected.
Generator Configuration
Overview
WeaveFFI ships with sensible defaults so weaveffi generate api.yml
just works. When you need to override package names, namespaces, or
the C ABI prefix, you have two options that compose with each other:
- A TOML file (
weaveffi.toml) passed via--config. Per-environment values that vary by machine or CI runner. - An inline
generators:block inside the IDL. Project-wide values every contributor inherits without remembering a flag.
When the same option appears in both, the inline IDL value wins.
When to use
- Use the TOML config when one developer or one pipeline needs to swap a value without changing the IDL.
- Use the inline
generators:block when the value is part of the project contract (Swift module name, Go module path, custom C ABI prefix). Checking it into the IDL guarantees consistency. - Use both when there is a project-wide default that an environment occasionally needs to override.
Step-by-step
1. Pass a TOML config file
weaveffi generate api.yml -o generated --config weaveffi.toml
[swift]
module_name = "MyApp"
[android]
package = "com.example.myapp"
[node]
package_name = "@myorg/myapp"
[wasm]
module_name = "myapp_wasm"
[c]
prefix = "myapp"
[global]
strip_module_prefix = false
Every section and key is optional; omit anything you want defaulted.
The [global] table accepts the alias [weaveffi]. Module-prefix
stripping is on by default, so the useful direction for
strip_module_prefix is false: one [global] line restores
module-prefixed wrapper names across every supporting target.
2. Embed generators: in the IDL
version: "0.5.0"
modules:
- name: math
functions:
- name: add
params:
- { name: a, type: i32 }
- { name: b, type: i32 }
return: i32
generators:
swift:
module_name: MyAppFFI
android:
package: com.example.myapp
c:
prefix: myapp
cpp:
namespace: myapp
header_name: myapp.hpp
standard: "20"
dart:
package_name: my_dart_pkg
go:
module_path: github.com/example/myapp
ruby:
module_name: MyApp
gem_name: myapp
weaveffi:
strip_module_prefix: false
pre_generate: "cargo build --release"
Unknown target keys are silently ignored, so an older weaveffi CLI
can still read an IDL written for a newer one.
3. Verify the result
weaveffi generate api.yml -o generated --config weaveffi.toml
ls generated/
For day-to-day project recipes:
# iOS / macOS
[swift]
module_name = "MyAppFFI"
[c]
prefix = "myapp"
# Android
[android]
package = "com.example.myapp.ffi"
[c]
prefix = "myapp"
# Node
[node]
package_name = "@myorg/myapp-native"
The C ABI symbol prefix is global by nature: every consumer must call
the identical exported symbols. The CLI resolves it once
([global] c_prefix wins, then [c] prefix) and fans it out to every
per-target config that hasn’t set its own prefix, so a custom prefix
is honored across all eleven languages, not just C and C++.
4. Wire it into CI
weaveffi diff --check enforces that the committed bindings still
match the IDL. A typical guard job:
# .github/workflows/ci.yml
- name: Verify generated bindings are up to date
run: weaveffi diff api.yml --out generated --check
weaveffi validate --format json and weaveffi lint --format json
are designed to be parsed by quality dashboards:
weaveffi --quiet validate api.yml --format json | jq '.ok'
weaveffi --quiet lint api.yml --format json > lint-report.json || \
(cat lint-report.json && exit 1)
Reference
TOML config files and inline IDL generators: blocks share the same
section names and key names. Pick the location that fits your workflow;
the keys are identical.
Per-target sections
| Section | Key | Type | Default | Description |
|---|---|---|---|---|
[swift] | module_name | string | "WeaveFFI" | Swift module name in Package.swift and the Sources/ directory |
[swift] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Swift symbols |
[android] | package | string | "com.weaveffi" | Java/Kotlin package declaration in the JNI wrapper |
[android] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Java/Kotlin symbols |
[node] | package_name | string | "weaveffi" | npm package name in the Node.js loader |
[node] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted JS/TS symbols |
[wasm] | module_name | string | "weaveffi_wasm" | Module name in the Wasm JS loader |
[wasm] | emscripten | bool | false | Target an Emscripten build: the loader accepts a pre-initialized Emscripten Module (or its MODULARIZE factory promise) instead of a .wasm URL; async functions, callbacks, and listeners become throwing stubs |
[c] | prefix | string | "weaveffi" | Prefix prepended to every C ABI symbol ({prefix}_{module}_{function}) |
[cpp] | namespace | string | "weaveffi" | C++ namespace for the wrapper |
[cpp] | header_name | string | "weaveffi.hpp" | Header file name for the C++ output |
[cpp] | standard | string | "17" | C++ standard for the generated CMakeLists.txt |
[python] | package_name | string | "weaveffi" | Python package name |
[python] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Python symbols |
[dotnet] | namespace | string | "WeaveFFI" | .NET namespace |
[dotnet] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted C# symbols |
[dart] | package_name | string | "weaveffi" | Dart package name in pubspec.yaml |
[dart] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Dart symbols |
[go] | module_path | string | "weaveffi" | Go module path in go.mod |
[go] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Go symbols |
[ruby] | module_name | string | "WeaveFFI" | Ruby module that wraps the bindings |
[ruby] | gem_name | string | "weaveffi" | Ruby gem name |
[ruby] | strip_module_prefix | bool | true | Strip the IR module prefix from emitted Ruby symbols |
Every per-target section also accepts a prefix key naming the C ABI
symbol prefix its wrappers call. You rarely set it per target: the CLI
fans the resolved global prefix ([global] c_prefix, or [c] prefix)
out to every section that leaves it unset, so all eleven targets call
the same exported symbols.
Package identity. The name, version, and metadata stamped into every generated manifest are resolved from the IDL
package:block by one shared policy. For an identity value an explicit key below wins; otherwise it falls back to thepackage:name (normalized per ecosystem), then the IDL file stem, then the"weaveffi"/"WeaveFFI"default shown above. The keys that participate are[swift] module_name,[node] package_name,[python] package_name,[dart] package_name,[go] module_path,[ruby] gem_name, and[dotnet] namespace(which also sets the NuGet package id). Manifests with no dedicated key (AndroidrootProject.name, the Wasmpackage.json, and the C++CMakeLists.txtversion) follow the same identity, and the published version comes frompackage.version(default0.1.0). All other keys (e.g.[c] prefix,[cpp] namespace,[android] package,[ruby] module_name,[wasm] module_name) keep the fixed defaults above.
[global] section
| Key | Type | Default | Description |
|---|---|---|---|
strip_module_prefix | bool | unset | Shorthand: sets strip_module_prefix on every target that supports it, overriding their sections. Stripping is on by default, so false restores module-prefixed names everywhere at once |
c_prefix | string | unset | Global C ABI symbol prefix, fanned out to every per-target prefix that is unset; wins over [c] prefix as the resolution source |
pre_generate | string | none | Shell command run before any generator starts |
post_generate | string | none | Shell command run after every generator finishes |
The alias [weaveffi] is accepted for the [global] section.
Performance and CI flags
-
The orchestrator dispatches every selected generator in parallel using rayon. The pre- and post-generate hooks still run serially around the whole batch.
-
Each generator persists a hash under
{out_dir}/.weaveffi-cache/{target}.hash. Only generators whose hash changed are re-run; pass--forceto invalidate every entry. -
weaveffi diff --checkexit codes:Code Meaning 0The committed output matches the IDL exactly. 2One or more files would change in place. 3One or more files would be added or removed. -
weaveffi validate --format jsonemits structured success/failure:{ "ok": true, "modules": 2, "functions": 8, "structs": 3, "enums": 1 }{ "ok": false, "errors": [ { "code": "DuplicateFunctionName", "module": "math", "function": "add", "message": "duplicate function name in module 'math': add", "suggestion": "function names must be unique within a module; rename the duplicate" } ] } -
weaveffi lint --format jsonreturns the warning list with stablecode/location/messagefields:{ "ok": false, "warnings": [ { "code": "DeepNesting", "location": "math::compute::matrix", "message": "deep type nesting at math::compute::matrix (depth 4, max recommended 3)" } ] }
Pitfalls
- Inline value overrides TOML silently: there is no warning when both are set. If a TOML override “doesn’t take”, check for an inline block in the IDL.
- The C prefix rewrites every generator: picking a custom prefix
also rewrites the runtime symbols (
{prefix}_free_string, …). The Rust cdylib must be built with the same prefix. Every wrapper picks it up automatically from the resolved global value; if you also set a per-targetprefix, make sure they agree. - Module-prefix stripping flattens names: it’s on by default, so
two modules that each declare an
openfunction collide in targets with a flat namespace. Rename one, or setstrip_module_prefix = false(globally or per target) to restore prefixed names. - Hooks run shell commands as-is:
pre_generateandpost_generateare passed straight tosh -c. Quote them carefully and never include untrusted input. - Cache covers IR, generator name, generator config, and CLI version: changing the IR, any generator config field, or upgrading the CLI invalidates the per-generator cache and triggers re-emission.
- Older CLIs ignore unknown keys: adding a new generator key with a project-wide implication does not error out on older toolchains. Pin the CLI version in CI when you need that guarantee.
Packaging and Distribution
Overview
weaveffi generate emits binding source: the consumer still has to compile it
or point it at a native library. weaveffi package goes one step further and
assembles ready-to-publish packages that bundle a prebuilt native library for
each target platform, laid out the idiomatic way each ecosystem expects. The
goal is that dotnet add package, pip install, gem install, npm install,
and friends “just work” with no local toolchain on a supported platform.
weaveffi package api.yml --binaries ./prebuilt --target dotnet,python,ruby -o dist
Choosing where the native libraries come from
A package can only bundle libraries you have already built. weaveffi package
gets them one of two ways:
--binaries <dir>: a directory of prebuilt libraries laid out as<dir>/<platform>/<library>. This is the path CI uses, building each platform on its own runner and collecting the results.--build <crate>: cross-compile the given Cargo package as acdylibfor each platform’s Rust target triple. Convenient locally, but every target needs its rustup target and a working cross-linker installed (rustup target add aarch64-unknown-linux-gnu, and so on).
The two are mutually exclusive. Before a --build run, weaveffi doctor --target package reports which producer cross-targets are installed (and the
rustup target add command for any that are missing), and exits non-zero if any
are absent so it can gate the build in CI.
The --binaries layout
Each platform gets a subdirectory named for its platform id, holding that platform’s shared library:
prebuilt/
darwin-arm64/libcontacts.dylib
darwin-x64/libcontacts.dylib
linux-x64/libcontacts.so
linux-arm64/libcontacts.so
windows-x64/contacts.dll
A platform with no subdirectory is skipped with a warning, so a partial matrix
still produces artifacts for what is available. When a platform directory holds
more than one library, name the one to bundle after the resolved package
identity (for example libcontacts.dylib) to disambiguate.
The v1 platform matrix
| Platform id | OS / arch | Rust target | NuGet RID | Node os/cpu | Python tag | Ruby platform |
|---|---|---|---|---|---|---|
darwin-arm64 | macOS arm64 | aarch64-apple-darwin | osx-arm64 | darwin/arm64 | macosx_11_0_arm64 | arm64-darwin |
darwin-x64 | macOS x64 | x86_64-apple-darwin | osx-x64 | darwin/x64 | macosx_10_12_x86_64 | x86_64-darwin |
linux-x64 | Linux x64 glibc | x86_64-unknown-linux-gnu | linux-x64 | linux/x64 | manylinux2014_x86_64 | x86_64-linux |
linux-arm64 | Linux arm64 glibc | aarch64-unknown-linux-gnu | linux-arm64 | linux/arm64 | manylinux2014_aarch64 | aarch64-linux |
windows-x64 | Windows x64 | x86_64-pc-windows-msvc | win-x64 | win32/x64 | win_amd64 | x64-mingw-ucrt |
Restrict the build with --platforms (a comma-separated list of platform ids);
the default is the full matrix. Restrict the languages with --target exactly
as in weaveffi generate.
Per-ecosystem layout
Each target lays the bundled libraries out where its ecosystem resolves native code automatically.
.NET (dotnet)
A single NuGet-ready project with libraries under runtimes/<rid>/native/, the
layout NuGet selects at restore time. The [DllImport] library name is rebound
from the WeaveFFI brand to the bundled library’s base name, and the .csproj
packs the runtimes/ tree. Just dotnet add package.
Python (python)
One wheel-ready tree per platform under python/<platform>/, with the library
bundled inside the import package. The loader prefers the bundled library, so no
WEAVEFFI_LIBRARY or system install is needed. The generated setup.py forces a
non-pure (platform-tagged) wheel; build it with python -m build --wheel and tag
it for the target platform before publishing.
Ruby (ruby)
One precompiled platform gem per platform under ruby/<platform>/, with
s.platform set and the library bundled under lib/native/. The ffi loader
prefers the bundled library.
Node.js (node)
The idiomatic optionalDependencies layout: a main package that depends on one
per-platform package per target (each gated by npm os/cpu, so only the
matching one installs) under node/npm/<name>-<os>-<cpu>/, each bundling its
prebuilt library. Because the Node binding is an N-API addon, the thin addon is
still compiled at install (node-gyp rebuild) and links the prebuilt library
from the selected platform package, so no Rust toolchain is needed; a C compiler
and the generated C header (package the c target alongside) are.
Swift (swift)
A SwiftPM package that consumes its C ABI through a binaryTarget xcframework.
The prebuilt libraries are bundled under lib/<platform>/; assembling them into
the xcframework is the one step that needs Apple tooling (lipo plus
xcodebuild -create-xcframework, run on macOS). The generated README.md
includes the exact recipe.
C and C++ (c, cpp)
The header (include/) plus a prebuilt library for every platform under
lib/<platform>/, with a CMakeLists.txt that selects the right library for the
host and exposes it as an imported target. add_subdirectory and link.
Go (go)
A Go module that bundles a library per platform under lib/<platform>/. The cgo
preamble adds the matching ${SRCDIR}-relative library search path and rpath per
GOOS/GOARCH, so go build links the right library with no manual CGO_LDFLAGS.
The C ABI header is expected at ../c/include/, so package the c target
alongside Go (weaveffi package --target c,go).
Continuous integration recipe
In CI the cleanest approach is to build each platform’s library on a runner of
that platform (native builds avoid cross-linker setup), collect the results into
the --binaries layout, then run weaveffi package once. The matrix below
builds a Cargo producer crate (my-producer, declaring
crate-type = ["cdylib"]) and uploads each library under its platform id, then a
final job assembles the packages.
name: package
on:
push:
tags: ["v*"]
jobs:
build:
strategy:
matrix:
include:
- { platform: darwin-arm64, runner: macos-14, target: aarch64-apple-darwin, lib: libmy_producer.dylib }
- { platform: darwin-x64, runner: macos-13, target: x86_64-apple-darwin, lib: libmy_producer.dylib }
- { platform: linux-x64, runner: ubuntu-latest, target: x86_64-unknown-linux-gnu, lib: libmy_producer.so }
- { platform: linux-arm64, runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, lib: libmy_producer.so }
- { platform: windows-x64, runner: windows-latest, target: x86_64-pc-windows-msvc, lib: my_producer.dll }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- run: cargo build --release -p my-producer --target ${{ matrix.target }}
- run: |
mkdir -p "prebuilt/${{ matrix.platform }}"
cp "target/${{ matrix.target }}/release/${{ matrix.lib }}" "prebuilt/${{ matrix.platform }}/"
shell: bash
- uses: actions/upload-artifact@v4
with:
name: prebuilt-${{ matrix.platform }}
path: prebuilt/
package:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: prebuilt-*
path: prebuilt
merge-multiple: true
- run: cargo install weaveffi-cli
- run: weaveffi package api.yml --binaries prebuilt --target dotnet,python,node,ruby -o dist
# ... then publish each package with `dotnet nuget push`, `npm publish`, etc.
A platform you can’t build (no runner, missing target) can simply be dropped
from the matrix; weaveffi package warns and produces artifacts for whatever is
present.
Targets that do not bundle binaries yet
wasm and android are skipped (with a note) because they need a different
artifact model than the native matrix: a WebAssembly module is a single portable
binary, and Android ships per-ABI libraries (arm64-v8a, and so on) that map to
Android-specific targets rather than the desktop/server platforms above. Use
weaveffi generate for their source bindings.
One Android packaging caveat: the generated JNI glue defines a JNI_OnLoad
whenever the module declares async functions or listeners (it caches the
class references the exception-handler hook needs). Each generated module
must therefore link into its own shared library; compiling the glue for two
modules into one .so would collide on the duplicate JNI_OnLoad symbol.
Tutorials
Each tutorial follows the same shape: Goal, Prerequisites, Step-by-step, Verification, Cleanup, Next steps. Pick the target you’re shipping to and follow it end-to-end.
- Calculator: fastest path to generate every target, build the cdylib from the in-tree sample, and run small C/Node/Swift consumers against it.
- Swift iOS: Rust → SwiftPM → Xcode iOS app.
- Android: Rust → AAR → Android Studio app on emulator/device.
- Python: Rust → ctypes package →
pip installandpython demo.py. - Node.js: Rust → N-API addon →
npm publishshape.
Calculator end-to-end
Goal
Take the in-tree samples/calculator producer (safe Rust annotated with
#[weaveffi::module]), generate bindings for every target, build the cdylib,
and run the calculator from a real consumer (C, Node.js, Swift, then optionally
Android and Wasm). By the end you will have produced bindings, executed them on
at least one host, and seen the same Rust add(a, b) answer come back through
three different runtimes, plus the typed CalcError surface when you divide
by zero.
Prerequisites
- Rust toolchain (stable channel) with
cargoonPATH. - The WeaveFFI CLI (
cargo install weaveffi-cliorcargo run -p weaveffi-cli --if you are working in the repo). - macOS or Linux for the C/Node/Swift steps; Windows works for C and Node but the Swift step requires macOS.
- For the optional Android and Wasm steps:
- Android Studio with the NDK installed.
rustup target add wasm32-unknown-unknown.
Step-by-step
1. Generate every target
Point the generator at the annotated source (the calculator.yml IDL still
works too, and produces the same bindings):
weaveffi generate samples/calculator/src/lib.rs -o generated
The output appears under generated/, one directory per target. The three
this tutorial exercises:
generated/c: C header (weaveffi.h) and convenience C filegenerated/swift: SwiftPM System Library (CWeaveFFI) and Swift wrapper (WeaveFFI)generated/node: N-API addon source, JS loader, and.d.ts
The rest (cpp, android, wasm, python, dotnet, dart, go, ruby)
follow the same pattern; the generator pages cover
each one.
2. Build the Rust sample
cargo build -p calculator
The cdylib lands in target/debug/:
- macOS:
libcalculator.dylib - Linux:
libcalculator.so - Windows:
calculator.dll
3. Run a C consumer
Write a minimal main.c that calls through the generated header. add is
non-throwing, so its error slot only trips on a poisoned call; div is
declared throws, so a zero divisor fills out_err with the typed
CalcError code:
#include <stdio.h>
#include "weaveffi.h"
int main(void) {
weaveffi_error err = {0};
printf("2 + 3 = %d\n", weaveffi_calculator_add(2, 3, &err));
weaveffi_calculator_div(1, 0, &err);
if (err.code == weaveffi_calculator_CalcError_DivisionByZero) {
printf("div(1, 0) failed: %s\n", err.message);
weaveffi_error_clear(&err);
}
return 0;
}
Compile and run it from the repo root (on Linux, replace
DYLD_LIBRARY_PATH with LD_LIBRARY_PATH):
cc -I generated/c main.c -L target/debug -lcalculator -o calc_c
DYLD_LIBRARY_PATH=target/debug ./calc_c
You should see 2 + 3 = 5 followed by div(1, 0) failed: division by zero.
4. Run a Node consumer
The generated binding.gyp links against libweaveffi, so give the sample
cdylib that name with a symlink, then build the addon in place (npm install
runs node-gyp rebuild; LIBRARY_PATH tells the linker where to find the
alias):
ln -sf libcalculator.dylib target/debug/libweaveffi.dylib # .so on Linux
cd generated/node
LIBRARY_PATH="$PWD/../../target/debug" npm install
Then call the wrapper. Names are camelCase with the module prefix stripped,
and the throwing div raises a typed error class:
DYLD_LIBRARY_PATH=../../target/debug node -e "
const calc = require('./index.js');
console.log('2 + 3 =', calc.add(2, 3));
try { calc.div(1, 0); } catch (e) { console.log(e.name + ':', e.message); }
"
This prints 2 + 3 = 5 and DivisionByZeroError: (1) division by zero.
5. Run a Swift consumer (macOS / Linux)
Write a main.swift at the repo root. The wrapper exposes the module as
a Calculator enum namespace, and div is throws:
print("2 + 3 = \(Calculator.add(a: 2, b: 3))")
do {
_ = try Calculator.div(a: 1, b: 0)
} catch {
print("div(1, 0) failed: \(error.localizedDescription)")
}
Compile the generated wrapper together with your main.swift (the module map
also links libweaveffi, so this reuses the symlink from step 4):
swiftc \
-I generated/swift/Sources/CWeaveFFI \
-L target/debug \
-Xlinker -rpath -Xlinker target/debug \
generated/swift/Sources/WeaveFFI/WeaveFFI.swift main.swift -o calc_swift
./calc_swift
On Linux, export LD_LIBRARY_PATH=target/debug before running so the loader
resolves the libweaveffi.so alias.
6. Optional: Android and Wasm
- Open
generated/androidin Android Studio and build the:weaveffiAAR. Combine with the steps in the Android tutorial. - For Wasm, run
cargo build --target wasm32-unknown-unknown --releaseand load the.wasmfile withgenerated/wasm/weaveffi_wasm.js.
Verification
You should see the same calculator output from each consumer. Concretely:
- The C consumer prints
2 + 3 = 5and the typed division error. - The Node one-liner prints the sum, then
DivisionByZeroErrorfrom the thrown error class. - The Swift binary prints the same arithmetic, catches the thrown
CalcError, and exits cleanly.
For fuller consumers that exercise interfaces, callbacks, and async
functions, see the conformance/ directory: each conformance/<target>/
file is a runnable program against the richer samples (contacts, events,
kvstore, shapes), and conformance/run.sh builds and runs them all.
If the host cannot find the cdylib, you will see
dyld: Library not loaded (macOS) or error while loading shared libraries (Linux). Re-export DYLD_LIBRARY_PATH /
LD_LIBRARY_PATH and rerun.
Cleanup
rm -rf generated/ main.c main.swift calc_c calc_swift
rm -f target/debug/libweaveffi.dylib # the link alias from step 4
cargo clean -p calculator
The generated/ directory is safe to delete and recreate; nothing
else in the repository depends on its contents.
Next steps
- Walk through the per-target details in Generators.
- Read the Memory Ownership and Error Handling guides for the contracts every consumer must follow.
- Try a target-specific tutorial: Swift iOS, Android, Python, or Node.js.
Swift iOS App
Goal
Build a small Rust greeter library, generate Swift bindings with WeaveFFI, and call them from a SwiftUI iOS app running in the simulator.
Prerequisites
-
Rust toolchain (stable channel).
-
Xcode 15 or later with the iOS SDK installed.
-
WeaveFFI CLI (
cargo install weaveffi-cli). -
iOS Rust targets:
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
Step-by-step
1. Author the IDL
Save as greeter.yml:
version: "0.5.0"
modules:
- name: greeter
errors:
name: GreeterError
codes:
- { name: UnknownLang, code: 1, message: "unknown language" }
structs:
- name: Greeting
fields:
- { name: message, type: string }
- { name: lang, type: string }
functions:
- name: hello
params:
- { name: name, type: string }
return: string
- name: greeting
throws: true
params:
- { name: name, type: string }
- { name: lang, type: string }
return: Greeting
hello can’t fail, so it stays non-throwing. greeting declares
throws: true and reports codes from the module’s GreeterError
domain when the language is unknown.
2. Generate bindings
weaveffi generate greeter.yml -o generated --scaffold
You should see, among other targets:
generated/
├── c/
│ └── weaveffi.h
├── swift/
│ ├── Package.swift
│ └── Sources/
│ ├── CWeaveFFI/
│ │ └── module.modulemap
│ └── WeaveFFI/
│ └── WeaveFFI.swift
└── scaffold.rs
3. Implement the Rust library
cargo init --lib mygreeter
mygreeter/Cargo.toml:
[package]
name = "mygreeter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["staticlib", "cdylib"]
[dependencies]
weaveffi-abi = { version = "0.14" }
mygreeter/src/lib.rs:
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_greeter_hello(
name: *const c_char,
out_err: *mut weaveffi_error,
) -> *const c_char {
abi::error_set_ok(out_err);
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world");
let msg = format!("Hello, {name}!");
CString::new(msg).unwrap().into_raw() as *const c_char
}
// Emit the WeaveFFI C ABI runtime symbols (free_string, free_bytes,
// error_clear, cancel_token_*), one line per cdylib.
abi::export_runtime!();
}
Use scaffold.rs as the template for the rest of the API
(weaveffi_greeter_greeting, the Greeting lifecycle, getters, …).
4. Build for iOS targets
cargo build -p mygreeter --target aarch64-apple-ios --release
cargo build -p mygreeter --target aarch64-apple-ios-sim --release
cargo build -p mygreeter --target x86_64-apple-ios --release
Combine the simulator architectures with lipo and bundle everything
in an XCFramework so Xcode can pick the right slice automatically:
mkdir -p target/universal-ios-sim/release
lipo -create \
target/aarch64-apple-ios-sim/release/libmygreeter.a \
target/x86_64-apple-ios/release/libmygreeter.a \
-output target/universal-ios-sim/release/libmygreeter.a
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libmygreeter.a \
-headers generated/c/ \
-library target/universal-ios-sim/release/libmygreeter.a \
-headers generated/c/ \
-output MyGreeter.xcframework
5. Wire it into Xcode
- Create a new iOS App in Xcode (SwiftUI or UIKit).
- Drag
MyGreeter.xcframeworkinto the project navigator. Confirm it appears under Build Phases > Link Binary With Libraries. - File > Add Package Dependencies > Add Local… and pick
generated/swift/. The package contributes theCWeaveFFIandWeaveFFItargets. - Build Settings > Header Search Paths: add the path to
generated/c/(e.g.$(SRCROOT)/../generated/c). - Build Settings > Library Search Paths: add the path to the
matching Rust static library
(
$(SRCROOT)/../target/aarch64-apple-ios/releasefor device builds). - Build Phases > Dependencies: ensure
WeaveFFIis listed.
6. Call from Swift
import SwiftUI
import WeaveFFI
struct ContentView: View {
@State private var greeting = ""
var body: some View {
VStack {
Text(greeting)
Button("Greet") {
greeting = Greeter.hello(name: "Swift")
}
}
.padding()
}
}
The generated WeaveFFI module exposes:
Greeter.hello(name:): non-throwing, returnsString.Greeter.greeting(name:lang:): declaredthrowsin the IDL, so the Swift wrapper isthrowsand surfacesGreeterError; returns aGreetinginstance with.messageand.langproperties, anddeinitcalls the Rust destructor automatically.GreeterError: the module’s error domain as a Swiftenumconforming toErrorandLocalizedError.Greeting: the wrapper class around the opaque Rust pointer.
Verification
-
Select an iOS Simulator target and press Cmd+R.
-
Tap Greet in the running app; the label changes to
Hello, Swift!. -
Re-run on a physical device after building for
aarch64-apple-iosto confirm the device path also works. -
Common error mappings:
Symptom Likely cause Undefined symbols for architecture arm64Static library not linked or the search path is wrong. Module 'CWeaveFFI' not foundHeader search path does not point at generated/c/.No such module 'WeaveFFI'Local Swift package not added under Add Package Dependencies > Add Local…. Crash when running on Intel simulator Build for x86_64-apple-iosand combine withlipo.
Cleanup
rm -rf generated/ MyGreeter.xcframework
cargo clean -p mygreeter
Remove the MyGreeter.xcframework reference from the Xcode project
and undo the Header Search Paths / Library Search Paths
edits.
Next steps
- See the Swift generator reference for the full type mapping.
- Read the Memory Ownership guide to understand
struct lifecycle and
deinitrules. - Try the Calculator tutorial for a simpler end-to-end walkthrough or Android for a JVM target.
Android App
Goal
Build a small Rust greeter library, generate Kotlin/JNI bindings with WeaveFFI, and call them from an Android Studio app running on an emulator or a physical device.
Prerequisites
-
Rust toolchain (stable channel).
-
Android Studio with the NDK installed (via SDK Manager).
-
WeaveFFI CLI (
cargo install weaveffi-cli). -
Android Rust targets:
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
Step-by-step
1. Author the IDL
Save as greeter.yml:
version: "0.5.0"
modules:
- name: greeter
errors:
name: GreeterError
codes:
- { name: UnknownLang, code: 1, message: "unknown language" }
structs:
- name: Greeting
fields:
- { name: message, type: string }
- { name: lang, type: string }
functions:
- name: hello
params:
- { name: name, type: string }
return: string
- name: greeting
throws: true
params:
- { name: name, type: string }
- { name: lang, type: string }
return: Greeting
hello can’t fail, so it stays non-throwing. greeting declares
throws: true and reports codes from the module’s GreeterError
domain when the language is unknown.
2. Generate bindings
weaveffi generate greeter.yml -o generated --scaffold
You should see, among other targets:
generated/
├── c/
│ └── weaveffi.h
├── android/
│ ├── settings.gradle
│ ├── build.gradle
│ └── src/main/
│ ├── kotlin/com/weaveffi/WeaveFFI.kt
│ └── cpp/
│ ├── weaveffi_jni.c
│ └── CMakeLists.txt
└── scaffold.rs
3. Implement the Rust library
cargo init --lib mygreeter
mygreeter/Cargo.toml:
[package]
name = "mygreeter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
weaveffi-abi = { version = "0.14" }
mygreeter/src/lib.rs:
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_greeter_hello(
name: *const c_char,
out_err: *mut weaveffi_error,
) -> *const c_char {
abi::error_set_ok(out_err);
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world");
let msg = format!("Hello, {name}!");
CString::new(msg).unwrap().into_raw() as *const c_char
}
// Emit the WeaveFFI C ABI runtime symbols (free_string, free_bytes,
// error_clear, cancel_token_*), one line per cdylib.
abi::export_runtime!();
}
Use scaffold.rs for the rest of the API (weaveffi_greeter_greeting,
the Greeting lifecycle, getters, …).
4. Configure the NDK toolchain
export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/$(ls $HOME/Library/Android/sdk/ndk | sort -V | tail -1)"
export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH"
Replace darwin-x86_64 with linux-x86_64 on Linux. Add the matching
linker = ... entries in .cargo/config.toml:
[target.aarch64-linux-android]
linker = "aarch64-linux-android21-clang"
[target.armv7-linux-androideabi]
linker = "armv7a-linux-androideabi21-clang"
[target.x86_64-linux-android]
linker = "x86_64-linux-android21-clang"
5. Cross-compile for every ABI
cargo build -p mygreeter --target aarch64-linux-android --release
cargo build -p mygreeter --target armv7-linux-androideabi --release
cargo build -p mygreeter --target x86_64-linux-android --release
You should now have:
target/aarch64-linux-android/release/libmygreeter.so
target/armv7-linux-androideabi/release/libmygreeter.so
target/x86_64-linux-android/release/libmygreeter.so
6. Wire it into Android Studio
-
Create a new Android project (Empty Activity, Kotlin,
minSdk21+). -
Include the generated module in the root
settings.gradle:include ':weaveffi' project(':weaveffi').projectDir = new File('generated/android') -
Add it as a dependency in your app’s
build.gradle:dependencies { implementation project(':weaveffi') } -
Copy the cdylib into
jniLibsper ABI:mkdir -p app/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64} cp target/aarch64-linux-android/release/libmygreeter.so \ app/src/main/jniLibs/arm64-v8a/libmygreeter.so cp target/armv7-linux-androideabi/release/libmygreeter.so \ app/src/main/jniLibs/armeabi-v7a/libmygreeter.so cp target/x86_64-linux-android/release/libmygreeter.so \ app/src/main/jniLibs/x86_64/libmygreeter.so -
Confirm the JNI
CMakeLists.txtingenerated/android/src/main/cpp/includestarget_include_directories(... PRIVATE ../../../../c)so it can findweaveffi.h.
7. Call from Kotlin
import com.weaveffi.WeaveFFI
import com.weaveffi.GreeterException
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<TextView>(R.id.textView).text = WeaveFFI.hello("Android")
try {
WeaveFFI.greeting("Hi", "en").use { g ->
println("${g.message} (${g.lang})")
}
} catch (e: GreeterException) {
println("greeting failed: ${e.message}")
}
}
}
The generated WeaveFFI companion object loads the JNI library
lazily and exposes:
WeaveFFI.hello(name: String): String: non-throwing.WeaveFFI.greeting(name: String, lang: String): Greeting: declaredthrowsin the IDL, so the JNI shim raisesGreeterExceptionsubclasses (a sealed class extendingWeaveFFIException, one nested class per error code) on failure.
Greeting implements Closeable; either call .close() or use
use { ... } for deterministic cleanup.
Verification
-
Sync Gradle in Android Studio.
-
Pick an emulator or a connected device and press Run (Shift+F10).
-
The text view should display
Hello, Android!and Logcat should showHi (en)from theGreetingblock. -
Common error mappings:
Symptom Likely cause UnsatisfiedLinkError: dlopen failedThe cdylib is missing from jniLibs/or built for the wrong ABI.WeaveFFIExceptionfrom JNIA WeaveFFI error was raised; inspect the code and message. Linker errors during cargo buildANDROID_NDK_HOMEis not set or the NDK toolchain is missing fromPATH.No implementation found for native methodJNI symbol names do not match the Kotlin package; re-run weaveffi generate.
Cleanup
rm -rf generated/ app/src/main/jniLibs
cargo clean -p mygreeter
Drop the include ':weaveffi' line from settings.gradle and remove
the dependency from your app module if you do not want to keep the
generated bindings around.
Next steps
- See the Android generator reference for the full type mapping and JNI conventions.
- Read Error Handling: JNI shims convert C
errors to
WeaveFFIException(or a typed domain exception) automatically. - Try the Calculator tutorial for a simpler end-to-end walkthrough or Swift iOS for a sibling mobile target.
Python Package
Goal
Build a small Rust greeter library, generate Python ctypes bindings with WeaveFFI, install the package locally, and call it from a Python script.
Prerequisites
- Rust toolchain (stable channel).
- Python 3.8 or later (
python3 --version). - WeaveFFI CLI (
cargo install weaveffi-cli). pip(ships with Python).
Step-by-step
1. Author the IDL
Save as greeter.yml:
version: "0.5.0"
modules:
- name: greeter
errors:
name: GreeterError
codes:
- { name: UnknownLang, code: 1, message: "unknown language" }
structs:
- name: Greeting
fields:
- { name: message, type: string }
- { name: lang, type: string }
functions:
- name: hello
params:
- { name: name, type: string }
return: string
- name: greeting
throws: true
params:
- { name: name, type: string }
- { name: lang, type: string }
return: Greeting
hello can’t fail, so it stays non-throwing. greeting declares
throws: true and reports codes from the module’s GreeterError
domain when the language is unknown.
2. Generate bindings
weaveffi generate greeter.yml -o generated --scaffold
Among other targets, you should see:
generated/
├── c/
│ └── weaveffi.h
├── python/
│ ├── pyproject.toml
│ ├── setup.py
│ ├── README.md
│ └── greeter/
│ ├── __init__.py
│ ├── weaveffi.py
│ └── weaveffi.pyi
└── scaffold.rs
The package directory and distribution name follow the IDL package name
(here greeter). The Python target uses ctypes: no native extension to
compile on the Python side.
3. Implement the Rust library
cargo init --lib mygreeter
mygreeter/Cargo.toml:
[package]
name = "mygreeter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
weaveffi-abi = { version = "0.14" }
mygreeter/src/lib.rs:
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_greeter_hello(
name: *const c_char,
out_err: *mut weaveffi_error,
) -> *const c_char {
abi::error_set_ok(out_err);
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world");
let msg = format!("Hello, {name}!");
CString::new(msg).unwrap().into_raw() as *const c_char
}
// Emit the WeaveFFI C ABI runtime symbols (free_string, free_bytes,
// error_clear, cancel_token_*), one line per cdylib.
abi::export_runtime!();
}
Use scaffold.rs for the rest of the API; it lists every symbol the
bindings expect, with exact signatures.
4. Build the cdylib
cargo build -p mygreeter --release
Produces:
| Platform | Output |
|---|---|
| macOS | target/release/libmygreeter.dylib |
| Linux | target/release/libmygreeter.so |
| Windows | target/release/mygreeter.dll |
5. Install the Python package
cd generated/python
pip install .
Use pip install -e . for an editable install during development.
6. Make the cdylib findable
The simplest option on any platform is the WEAVEFFI_LIBRARY
environment variable, which the generated loader checks first and
treats as an explicit path:
WEAVEFFI_LIBRARY=target/release/libmygreeter.dylib python demo.py
Without the override, the loader looks for libweaveffi.dylib (macOS),
libweaveffi.so (Linux), or weaveffi.dll (Windows) on the system
loader path. Symlink or copy your cdylib to the expected name and set
the loader path.
macOS:
cp target/release/libmygreeter.dylib target/release/libweaveffi.dylib
DYLD_LIBRARY_PATH=target/release python demo.py
Linux:
cp target/release/libmygreeter.so target/release/libweaveffi.so
LD_LIBRARY_PATH=target/release python demo.py
Windows: place weaveffi.dll next to your script or add its
directory to PATH.
7. Use the bindings
Save as demo.py. Function names are snake_case with the module
prefix stripped, and the throwing greeting raises the typed
exception hierarchy (GreeterError extends WeaveFFIError, with an
UnknownLang subclass per code):
from greeter import hello, greeting, GreeterError
print(hello("Python"))
try:
g = greeting("Python", "en")
print(f"{g.message} ({g.lang})")
except GreeterError as e:
print(f"Error {e.code}: {e.message}")
Struct wrappers free the Rust allocation when garbage-collected; for
deterministic cleanup, del g after you are done with the object.
Verification
-
pip show greeterlists the package. -
Running
demo.pyprintsHello, Python!andHi (en)(or whateverGreetingyou constructed). -
mypy demo.pyreports no errors thanks to the generatedweaveffi.pyistub. -
Common error mappings:
Symptom Likely cause OSError: dlopen ... not foundCdylib not on the loader path; set WEAVEFFI_LIBRARYor the loader path.GreeterError: ...at runtimeRust reported a domain error code; inspect e.codeande.message.ModuleNotFoundError: No module named 'greeter'Package not installed; rerun pip install .fromgenerated/python/.mypy complains about greeterMake sure weaveffi.pyiships next toweaveffi.pyin the package.
Cleanup
pip uninstall greeter
rm -rf generated/
cargo clean -p mygreeter
Next steps
- See the Python generator reference for the full type mapping and memory contract.
- Read Error Handling for the cross-target error model.
- Try the Calculator tutorial for a simpler end-to-end walkthrough or Node.js for a sibling scripting target.
Node.js npm Package
Goal
Build a small Rust greeter library, generate Node.js bindings with
WeaveFFI, build the N-API addon, and call the bindings from a
JavaScript script. By the end you will have an npm-installable
package shape ready to publish.
Prerequisites
- Rust toolchain (stable channel).
- Node.js 16 or later and
npm. - WeaveFFI CLI (
cargo install weaveffi-cli). - A C compiler in the
PATH(Xcode CLT on macOS,build-essentialon Linux, MSVC build tools on Windows) for the N-API addon build.
Step-by-step
1. Author the IDL
Save as greeter.yml:
version: "0.5.0"
modules:
- name: greeter
errors:
name: GreeterError
codes:
- { name: UnknownLang, code: 1, message: "unknown language" }
structs:
- name: Greeting
fields:
- { name: message, type: string }
- { name: lang, type: string }
functions:
- name: hello
params:
- { name: name, type: string }
return: string
- name: greeting
throws: true
params:
- { name: name, type: string }
- { name: lang, type: string }
return: Greeting
hello can’t fail, so it stays non-throwing. greeting declares
throws: true and reports codes from the module’s GreeterError
domain when the language is unknown.
2. Generate bindings
weaveffi generate greeter.yml -o generated --scaffold
Among other targets you should see:
generated/
├── c/
│ └── weaveffi.h
├── node/
│ ├── binding.gyp
│ ├── index.js
│ ├── package.json
│ ├── types.d.ts
│ └── weaveffi_addon.c
└── scaffold.rs
weaveffi_addon.c is a complete N-API addon that bridges Node’s
runtime to the C ABI, and binding.gyp builds it with node-gyp; you
don’t write any addon code yourself.
3. Implement the Rust library
cargo init --lib mygreeter
mygreeter/Cargo.toml:
[package]
name = "mygreeter"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
weaveffi-abi = { version = "0.14" }
mygreeter/src/lib.rs:
#![allow(unused)]
#![allow(unsafe_code)]
#![allow(clippy::not_unsafe_ptr_arg_deref)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use weaveffi_abi::{self as abi, weaveffi_error};
#[no_mangle]
pub extern "C" fn weaveffi_greeter_hello(
name: *const c_char,
out_err: *mut weaveffi_error,
) -> *const c_char {
abi::error_set_ok(out_err);
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("world");
let msg = format!("Hello, {name}!");
CString::new(msg).unwrap().into_raw() as *const c_char
}
// Emit the WeaveFFI C ABI runtime symbols (free_string, free_bytes,
// error_clear, cancel_token_*), one line per cdylib.
abi::export_runtime!();
}
Use scaffold.rs for the rest of the API; it lists every symbol the
addon expects, with exact signatures.
4. Build the cdylib and the N-API addon
cargo build -p mygreeter --release
The generated binding.gyp links against libweaveffi, so give the
cdylib that name with a symlink, then build the addon in place
(npm install runs node-gyp rebuild; LIBRARY_PATH tells the
linker where to find the alias):
ln -sf libmygreeter.dylib target/release/libweaveffi.dylib # .so on Linux
cd generated/node
LIBRARY_PATH="$PWD/../../target/release" npm install
The compiled addon lands at build/Release/weaveffi.node, which is
the first place the generated index.js looks. You can also point
the loader at any built addon with the WEAVEFFI_ADDON environment
variable, or ship a prebuilt binary as index.node next to
index.js (the fallback location).
5. Run the bindings locally
Save as generated/node/demo.js. Function names are camelCase with
the module prefix stripped, and the throwing greeting raises typed
error classes (GreeterError extends WeaveFFIError, with an
UnknownLangError subclass per code):
const greeter = require("./index");
console.log(greeter.hello("Node"));
try {
const g = greeter.greeting("Node", "en");
console.log(`${g.message} (${g.lang})`);
} catch (e) {
if (e instanceof greeter.GreeterError) {
console.log(`${e.name}: ${e.errorMessage}`);
} else {
throw e;
}
}
Run it (the cdylib must be on the loader path so the addon’s
libweaveffi reference resolves):
macOS:
cd generated/node
DYLD_LIBRARY_PATH=../../target/release node demo.js
Linux:
cd generated/node
LD_LIBRARY_PATH=../../target/release node demo.js
For TypeScript consumers, the generated types.d.ts is enough:
import * as greeter from "./index";
const msg: string = greeter.hello("TypeScript");
const g: greeter.Greeting = greeter.greeting("TS", "en");
console.log(`${g.message} (${g.lang})`);
6. Prepare for publishing
Copy the built addon to the fallback location the loader checks, so consumers don’t need node-gyp:
cp build/Release/weaveffi.node index.node
Then edit generated/node/package.json:
{
"name": "@myorg/greeter",
"version": "0.1.0",
"main": "index.js",
"types": "types.d.ts",
"files": [
"index.js",
"index.node",
"types.d.ts"
],
"os": ["darwin", "linux"],
"cpu": ["x64", "arm64"]
}
files must include index.node. For multi-platform packages,
publish per-platform optional dependencies (e.g.
@myorg/greeter-darwin-arm64) and use an install script to pick the
right binary.
7. Publish
cd generated/node
npm pack
npm publish
For scoped packages, append --access public. Consumers then run:
npm install @myorg/greeter
const { hello } = require("@myorg/greeter");
console.log(hello("npm"));
Verification
-
node demo.jsprintsHello, Node!and exits with code0. -
npm packproduces a.tgzcontainingindex.node,types.d.ts, andindex.js. -
TypeScript consumers see the
Greetinginterface andhellosignature without manual type declarations. -
Common error mappings:
Symptom Likely cause Error: Cannot find module './index.node'The addon isn’t built; run npm installor setWEAVEFFI_ADDON.Error: dlopen ... not foundCdylib not on the loader path; set DYLD_LIBRARY_PATH/LD_LIBRARY_PATH.TypeError: greeter.hello is not a functionThe addon is stale; rerun npm installafter IDL edits.Crashes on require()Addon built for the wrong Node.js version or architecture; rebuild.
Cleanup
rm -rf generated/
cargo clean -p mygreeter
If you published a test version, mark it as deprecated with
npm deprecate @myorg/greeter@0.1.0 "test publish".
Next steps
- See the Node generator reference for the
full type mapping and
types.d.tslayout. - Read Memory Ownership for struct lifecycle semantics.
- Try the Calculator tutorial for a simpler end-to-end walkthrough or Python for a sibling scripting target.