Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

WeaveFFI generates type-safe bindings for 11 languages from a single IDL: no hand-written JNI, no duplicate implementations, no unsafe boilerplate.

Define your API once in YAML, JSON, or TOML; ship idiomatic packages for C, C++, Swift, Kotlin/Android, Node.js, WebAssembly, Python, .NET, Dart, Go, and Ruby that all talk to the same stable C ABI.

WeaveFFI works with any native library that exposes a stable C ABI, whether it’s written in Rust, C, C++, Zig, or another language. Rust gets first-class scaffolding via weaveffi generate --scaffold; other backends implement the symbols declared in the generated C header directly.

Why WeaveFFI?

  • One IDL, eleven languages. Describe your API once and ship packages to npm, SwiftPM, Maven, PyPI, NuGet, pub.dev, RubyGems, and Go modules.
  • 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/await and throws, Kotlin gets suspend and JNI glue, Python gets typed .pyi stubs, TypeScript gets Promises, Dart gets dart: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 → IDL → generate → call from C.
  • 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 kvstore reference 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, generating multi-language bindings, implementing the Rust library, and calling it from C.

Prerequisites

You need the Rust toolchain (stable channel) installed. Verify with:

rustc --version
cargo --version

1) Install WeaveFFI

Install the CLI from crates.io:

cargo install weaveffi-cli

This puts the weaveffi binary on your PATH.

2) Create a new project

Scaffold a starter project:

weaveffi new my-project
cd my-project

This creates a my-project/ directory containing:

  • weaveffi.yml: an example IDL with add, mul, and echo functions
  • README.md: quick-start notes

3) Define your IDL

Open weaveffi.yml and replace its contents with an IDL that has a struct and a function:

version: "0.4.0"
package:
  name: my-project
  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); weaveffi new scaffolds one for you. The IDL also supports primitives (i32, f64, bool, string, bytes, handle), optionals (string?), and lists ([i32]). See the IDL Schema reference for the full specification.

4) Generate bindings

Run the generator to produce bindings for all targets:

weaveffi generate weaveffi.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

5) 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. Every exported function takes an out_err parameter for error reporting:

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/WeaveFFI/WeaveFFI.swift)

Structs become classes that own an OpaquePointer and free it on deinit. Module functions are grouped under a Swift enum namespace:

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) throws -> 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

6) Implement the Rust library

The generated scaffold.rs contains todo!() stubs for every function. Create a Rust library crate and fill in the implementations.

Cargo.toml:

[package]
name = "my-math"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
weaveffi-abi = { version = "0.1" }

src/lib.rs, implementing the add function (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] and extern "C".
  • out_err must always be cleared on success with abi::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.

Build with:

cargo build

This produces a shared library (libmy_math.dylib on macOS, libmy_math.so on Linux).

7) 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 doctor to check which platform toolchains are available.
  • See the Calculator tutorial for a full end-to-end walkthrough including Swift and Node.js.
  • Read the IDL Schema reference for all supported types and features.
  • 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:

IDL file (YAML/JSON/TOML)
   │
   ▼
Parse        ── weaveffi-ir::parse: produces an `Api` IR
   │
   ▼
Validate     ── weaveffi-core::validate: rejects errors, collects warnings
   │
   ▼
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.

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-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)
CrateWhat it owns
weaveffi-irThe IR types (Api, Module, Function, TypeRef, …), the parse_api_str parser, the parse_type_ref mini-grammar, and CURRENT_SCHEMA_VERSION.
weaveffi-abiStable C ABI runtime symbols: weaveffi_error, weaveffi_error_clear, weaveffi_free_string, weaveffi_free_bytes, the arena, cancel tokens.
weaveffi-coreThe Generator trait, the LanguageBackend framework + driver, the Orchestrator, the abi C-ABI lowering model, 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-cliThe weaveffi binary. Parses the IDL, applies validation, instantiates every generator (via the cli_targets! registry), and dispatches the Orchestrator. Self-contained subcommands live in their own modules (doctor.rs, extract.rs, scaffold.rs).
weaveffi-fuzzcargo-fuzz harnesses for the parsers, the validator, and parse_type_ref. Workspace-private (not published to crates.io).

Crates that contain unsafe code (weaveffi-abi, every samples/* cdylib, weaveffi-fuzz, and the scaffold output emitted by weaveffi generate --scaffold) opt in with #![allow(unsafe_code)] at the top of their main source file. 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 self-contained subcommand group lives in its own module:

ModuleResponsibility
main.rsclap definitions, the cli_targets! registry, and dispatch.
doctor.rsweaveffi doctor: probes host toolchains per target.
extract.rsweaveffi extract: derives an IDL from annotated Rust source.
scaffold.rsthe Rust producer stubs emitted by weaveffi generate --scaffold.

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!, invoked once near the top of main.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, structs, enums, callbacks, listeners, errors, modules }: modules can nest.
  • Function { name, params, returns, doc, async, cancellable, deprecated, since }.
  • TypeRef enumerates every supported type reference: primitives (I32, U32, I64, F64, Bool, StringUtf8, Bytes, Handle, BorrowedStr, BorrowedBytes), user types (Struct(String), Enum(String), TypedHandle(String)), and the four composite shapes (Optional, List, Map, Iterator).

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.4.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:

  1. Bump CURRENT_SCHEMA_VERSION (and the weaveffi-ir minor version).
  2. Document the changes in CHANGELOG.md under a “Migration” section.
  3. Update every sample IDL, the weaveffi new template, 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.
  • Structs must have at least one field; enums at least one variant.
  • Enum discriminant uniqueness within an enum.
  • Type references resolve within the enclosing module chain (cross-sibling references are rejected; see Cross-module references).
  • Iterator return types are valid in return position only.
  • Map keys must be a primitive or enum type.
  • event_callback on 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.

Warnings emitted today:

  • LargeEnumVariantCount (>100 variants).
  • DeepNesting (composite types nested deeper than 3 levels).
  • EmptyModuleDoc (no doc: on any function in the module).
  • AsyncVoidFunction (async without a return type).
  • MutableOnValueType (mutable: true on a non-pointer parameter).
  • DeprecatedFunction (informational).

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.

The one exception is per-target capability gating: 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 only WASM declares gaps (callbacks and listeners); its allow_unsupported = true config opts into generating the rest of the surface with explicit throwing stubs in place of the unsupported entry points. Capability failures 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):

  1. Defaults baked into each Config::default().
  2. The --config <file.toml> external file passed to generate.
  3. 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:

  1. If --force is set, every cache entry under {out_dir}/.weaveffi-cache/{target}.hash is invalidated.
  2. 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.
  3. If a pre_generate hook is configured (OrchestratorHooks), the orchestrator shells out to it (cmd on Windows, sh elsewhere) and aborts on non-zero exit.
  4. The pending generators run in parallel via rayon::par_iter. Generators must therefore be Send + Sync.
  5. post_generate runs once after every generator has succeeded.
  6. 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 (enums → structs → 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_enum / render_struct / 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 by direct string construction; 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). Shared rendering infrastructure lives in weaveffi_core:

  • backend: the LanguageBackend trait, the run/output_files driver, the OutputFile type, and the impl_generator_via_backend! bridge macro.
  • model::BindingModel: the normalized, fully-lowered view every backend renders from (precomputed C symbol names and ABI signatures).
  • codegen::common: module-tree traversal (walk_modules, walk_modules_with_path), the is_c_pointer_type ABI classifier, doc-comment emission (emit_doc), and pascal_case naming.

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 --target flag value, e.g. "swift"), the associated Config type, and files(); override prefix() when the config carries a configurable c_prefix.
  • Return every emitted file from files(); --dry-run and weaveffi diff read the derived output_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 single render_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 ordered AbiParam slots.
  • lower_return(ty, module): the return CType plus any trailing out_* 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, so lower_return refuses an Iterator and each caller lowers it explicitly.
  • byref out-params. ctypes (Python) and P/Invoke (.NET) express a map return’s out_keys / out_values with an extra pointer level or the C# out keyword; 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.

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 nine-fixture corpus (tests/fixtures/01_calculator09_nested_modules: calculator, contacts, inventory, async-demo, events, kitchen-sink, docs-everywhere, kvstore, and nested-modules). 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):

  1. Create crates/weaveffi-gen-<lang>/ mirroring the layout of weaveffi-gen-c. Add it to members in the root Cargo.toml and depend on weaveffi-core and weaveffi-ir.
  2. Implement weaveffi_core::backend::LanguageBackend: define the associated Config type, then name, prefix (if the config carries a c_prefix), and files (returning every OutputFile). For a single-pass layout, override the render_enum/render_struct/ render_function hooks and compose emit_members; otherwise build the layout directly in files. Then add weaveffi_core::impl_generator_via_backend!(<Generator>); to bridge it to Generator (this derives generate and output_files). Reuse BindingModel and weaveffi_core::codegen::common instead of re-deriving traversal or ABI classification.
  3. Wire the generator into the cli_targets! registry macro in crates/weaveffi-cli/src/main.rs: add one line ("<name>" => <field>: <Config> via <Generator>, plus strip if the generator honors strip_module_prefix). That single entry is the source of truth: it expands to the CliConfig field, the --target <name> parser entry, inline-config merging, and the Orchestrator registration. No other CLI edits are required.
  4. Add snapshot fixtures in crates/weaveffi-cli/tests/snapshots.rs covering at minimum the calculator, contacts, inventory, async-demo, and events sample IDLs.
  5. Document the generator under docs/src/generators/<lang>.md and link it from docs/src/SUMMARY.md.
  6. Add a consumer example under examples/<lang>/ and wire it into examples/run_all.sh.
  7. Add scripts/publish-crates.sh to the dependency-ordered publish list (only when the crate is ready to be released).

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

WeaveFFIUniFFIcbindgendiplomatSWIGautocxx
Source languageRust / C / C++ / Zig (anything with a C ABI)RustRustRustC / C++C++
Input formatYAML / JSON / TOML IDLUDL or proc-macro on RustRust source (annotated)Rust source (annotated)C/C++ headers + .i interfaceC++ 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)
Enums w/ explicit discriminants
Optionals✓ (T?)partialpartial
Lists✓ ([T])partial
Maps✓ ({K:V})partialpartial
Typed handles (handle<T>)✓ (objects)✓ (opaque)partial
Borrowed types (&str, &[u8])partial
Iterators (iter<T>)✓ (callbacks)partialpartial
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)partialpartialpartial
Cross-module type referencesn/a
Nested modulespartialn/a
Workflow
Single-binary CLI install✓ (cargo install weaveffi-cli)system package
Standalone publishable packages✓ (npm, SwiftPM, pub.dev, NuGet, gem, etc.)partialn/apartialpartialn/a
JSON Schema for IDL editor supportn/an/an/a
extract from annotated source✓ (Rust)✓ (proc-macro)✓ (Rust)✓ (Rust)n/a✓ (C++)
watch mode✓ (--watch)partial
format IDL canonicalizern/an/an/a
Custom template overridespartial (Mako)partial✓ (%typemap)partial
Snapshot-tested generator outputpartial
Maturitypre-1.01.0+ in Mozilla shipping products1.0+ widely deployedpre-1.030+ years, ubiquitouspre-1.0
LicenseMIT OR Apache-2.0MPL-2.0MPL-2.0BSD-3-ClauseGPL with FOSS exceptionMIT 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 format command, 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:

  1. One source of truth for many languages. If your library has to land in npm and SwiftPM and PyPI and NuGet and pub.dev and RubyGems and a Go module and a Gradle artifact, that’s the WeaveFFI sweet spot. UniFFI covers a smaller subset out of the box; cbindgen and autocxx don’t try.
  2. 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.
  3. A native library that isn’t (only) Rust. WeaveFFI works against anything that exposes a stable C ABI: Rust (with --scaffold convenience), C, C++, Zig, etc. UniFFI and diplomat assume Rust; autocxx assumes C++.
  4. Idiomatic per-target output, not a lowest-common-denominator API. Async functions become async/await in Swift, Promises in Node, suspend fun in Kotlin, async def in Python, and Task<T> in C#, all from the same async: true flag in the IDL.
  5. A CLI workflow with validate, lint, diff, watch, and format. WeaveFFI is built for monorepos and CI: every sub-command has a --format json output mode, and diff --check and format --check are designed to drop into pre-commit and CI gates.
  6. Honest pre-1.0 churn, documented every release. Every breaking IDL change is called out in CHANGELOG.md with a migration note, and weaveffi validate rejects 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) with validate, lint, diff, watch, format, and extract subcommands 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.json for 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 cpp emits a header-only RAII C++ API (weaveffi.hpp) with std::optional, std::vector, std::unordered_map, exception-based errors, move semantics, and a CMakeLists.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 / _destroy ABI.
  • [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:

  1. Marshalling arguments across the C ABI (string→const char*, list→*ptr + len, etc.). Borrowed types (&str, &[u8]) avoid copies.
  2. The single extern "C" function call.
  3. 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?

Every generated function takes 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.

Each target language maps this to its native error story:

  • C: direct weaveffi_error struct.
  • C++: exceptions (WeaveFFIError + per-code subclasses).
  • Swift: throws + WeaveFFIError.
  • Kotlin: checked exceptions (WeaveFFIException).
  • Node.js / TypeScript: thrown Error objects (or Promise.reject for async).
  • WASM/JS: thrown Error.
  • Python: raised WeaveFFIError.
  • .NET: thrown WeaveFFIException.
  • Dart: thrown WeaveFFIException.
  • Go: second error return value.
  • Ruby: raised WeaveFFIError.

You can also declare named error domains in the IDL (per module) to assign stable numeric codes to expected failures. See the Error Handling guide.

6. Can I customize the generated code?

Yes, via two escape hatches in increasing order of power:

  1. Generator config (--config cfg.toml or inline generators: table in the IDL). Controls Swift module names, Android package, C prefix, C++ namespace, Dart/Go/Ruby package names, and other per-target knobs. See the Generator Configuration guide.
  2. Hook commands (pre_generate / post_generate in the config). Run arbitrary shell commands before and after generation, useful for prettier, 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).
  • .NET: P/Invoke uses DllImport with the right calling conventions and looks up weaveffi.dll.
  • Node.js: the N-API addon builds with node-gyp on Windows.
  • Python: ctypes loads weaveffi.dll.
  • Dart: looks up weaveffi.dll via Platform.isWindows.
  • Go / Ruby: load the appropriate Windows shared library.

CI runs the Python end-to-end consumer test on Windows on every PR to keep the platform honest. The other targets are exercised on macOS and Linux only. 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.
  • xcframework for Swift. Bundle iOS device, iOS simulator, and macOS slices into a single .xcframework that SwiftPM can consume. The generated Package.swift references it as a .binaryTarget.
  • .aar for Android. Package the JNI shim + per-ABI .so files into an Android Archive that Gradle resolves like any other dependency. The generated build.gradle skeleton 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 json payloads 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 names), version semantics, and the JSON Schema exported by weaveffi 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, and weaveffi-cli. The Generator trait, the Orchestrator, the IR types, and the C ABI runtime symbols exported from weaveffi-abi are 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 (callback was removed in 0.3.0).
  • The Generator trait gained generate_with_config in 0.3.0, then was reworked in 0.5.0 into an associated Config type (with an object-safe DynGenerator view) that replaced the *_with_config method pair. A prototype Tera template hook (generate_with_templates, --templates, template_dir) was added and then removed in 0.4.0 because no generator ever consumed it.
  • The C ABI runtime added weaveffi_arena_* and weaveffi_cancel_token_* families.
  • weaveffi doctor gained --target and --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:

  1. 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, @Deprecated in Kotlin/Java, @available(*, deprecated:) in Swift, [Obsolete] in .NET, JSDoc @deprecated in TypeScript, and so on, driven by the existing IDL deprecated: field).
  2. The deprecated feature continues to work for at least one full minor version.
  3. 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.

Pre-1.0, only the current schema version is accepted (SUPPORTED_VERSIONS contains exactly CURRENT_SCHEMA_VERSION). When a schema bump lands, update the version field in your IDL and adjust the document to the new schema; 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 --check plugs 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.

BenchmarkTargetInputs
validate_kitchen_sink< 5 mscrates/weaveffi-cli/tests/fixtures/06_kitchen_sink.yml
hash_kitchen_sink< 1 msSame fixture, post-validation
full_codegen_calculator< 500 mssamples/calculator/calculator.yml, all 11 generators
full_codegen_kitchen_sink< 2000 msKitchen-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.

BenchmarkMedianHeadroom vs target
validate_kitchen_sink7.45 µs~670× under
hash_kitchen_sink37.5 µs~27× under
full_codegen_calculator6.92 ms~72× under
full_codegen_kitchen_sink7.27 ms~275× under
generate_c_large_api904 µsn/a
generate_swift_large_api1.93 msn/a
generate_all_large_api24.1 msn/a
generate_all_kitchen_sink7.27 msn/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.

  1. Pre-allocate output buffers. Both render_c_header and render_swift_wrapper started from String::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 via String::with_capacity.

  2. write! instead of push_str(&format!(...)) in the per-function hot loop of render_module_header (C generator) and the function wrappers in the Swift generator. Each replacement eliminates the intermediate String that format! allocates before the result is appended to the output buffer.

  3. 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 the write_swift_params_sig helper; the C generator routes through a write_params_into helper 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_yaml parsing is the dominant cost of the weaveffi generate happy 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, and serde_yaml does 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:

  1. Open the bench workflow runs on GitHub.
  2. Pick the latest run that succeeded.
  3. Download the bench-results artifact and extract bench.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 the C ABI layer. Each sample contains a YAML IDL and a Rust crate that implements the corresponding weaveffi_* C ABI functions.

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:

  • Typed handles (handle<Store>) for opaque resource lifecycle
  • 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 and builder: true
  • A documented enum (EntryKind with Volatile, Persistent, Encrypted)
  • A documented error domain (KvError with KEY_NOT_FOUND, EXPIRED, STORE_FULL, IO_ERROR)
  • A module-level callback (OnEvict) and listener (eviction_listener)
  • A streaming iterator return (list_keys -> iter<string>) with prefix filter
  • A cancellable async function (compact_async, async: true, cancellable: true) that respects a weaveffi_cancel_token while reclaiming bytes on a worker thread
  • A deprecated function (legacy_put) and since: "0.3.0" on every other function
  • A nested sub-module (kv.stats) with its own struct (Stats) and a function that takes a cross-module handle<Store>
  • Inline generators: overrides for swift.module_name, cpp.namespace, dotnet.namespace, dart.package_name, go.module_path, and ruby.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

Every consumer language under examples/ ships with a kvstore smoke test (open -> put -> get -> delete -> close) that runs against the generated bindings and the produced libkvstore cdylib; see examples/run_all.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 }, and Labeled { label: string, count: u8 }) lowered to an opaque object with per-variant constructors, a tag reader, 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 module with four functions that exercise primitive types (i32) and string passing. Good starting point for understanding the basic C ABI contract.

What it demonstrates:

  • Scalar parameters and return values (i32)
  • String parameters and return values (C string ownership)
  • Error propagation via weaveffi_error (e.g. division by zero)
  • The weaveffi_free_string / weaveffi_error_clear lifecycle helpers

Build and generate bindings:

cargo build -p calculator
weaveffi generate samples/calculator/calculator.yml -o generated

This produces target-specific output under generated/ (C headers, Swift wrapper, Android skeleton, Node addon loader, WASM stub). Runnable examples that consume the generated output are in examples/.

Contacts

Path: samples/contacts

A CRUD-style sample with a single module that exercises richer type-system features than the calculator.

What it demonstrates:

  • Enum definitions (ContactType with Personal, Work, Other)
  • Struct definitions (Contact with typed fields)
  • Optional fields (string? for the email)
  • List return types ([Contact])
  • Handle-based resource management (create_contact returns a handle)
  • Struct getter and setter functions
  • Enum conversion functions (from_i32 / to_i32)
  • Struct destroy and list-free lifecycle functions

Build and generate bindings:

cargo build -p contacts
weaveffi generate samples/contacts/contacts.yml -o generated

Inventory

Path: samples/inventory

A richer, multi-module sample with products and orders modules that exercises cross-module struct references and nested list types.

What it demonstrates:

  • Multiple modules in a single IDL
  • Enums (Category with Electronics, Clothing, Food, Books)
  • Structs with optional fields, list fields ([string] tags), and float types
  • List-returning search functions (search_products filtered by category)
  • Cross-module struct passing (add_product_to_order takes a Product)
  • Nested struct lists (Order.items is [OrderItem])
  • Full CRUD operations across both modules

Build and generate bindings:

cargo build -p inventory
weaveffi generate samples/inventory/inventory.yml -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: true in the YAML)
  • Callback-based C ABI pattern (weaveffi_tasks_run_task_async)
  • Callback type definitions (weaveffi_tasks_run_task_callback)
  • Batch async operations (run_batch processes a list of names sequentially)
  • Synchronous fallback functions (cancel_task is non-async in the same module)
  • Struct return types through callbacks (TaskResult delivered 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 (OnMessage callback)
  • 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_messages returns a MessageIterator, 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. Used by the Node.js example in examples/.

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

Every consumer language under examples/ ships with an executable test that loads the calculator and contacts cdylibs at runtime and asserts a representative slice of the C ABI (basic add, contact create/list/cleanup). The examples/run_all.sh orchestrator builds and runs each one in turn:

cargo build -p calculator -p contacts

WEAVEFFI_LIB=target/debug/libcalculator.dylib \
  bash examples/run_all.sh

It prints [OK] {target} for each example that succeeds and exits non-zero on the first failure. Use ONLY=python,ruby to run a subset, or SKIP=android,go to omit individual targets. CI runs the full matrix on Linux, most targets on macOS, and the Python path on Windows. See the comment block at the top of examples/run_all.sh for the full list of env vars and 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.4.0"
package:
  name: my_app
  version: "1.0.0"
modules:
  - name: my_module
    structs: [...]
    enums: [...]
    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.

FieldTypeRequiredDescription
versionstringyesSchema version; only the current version ("0.4.0") is accepted
packagePackagenoPublishable identity stamped into every generated manifest (see Package metadata)
modulesarray of ModuleyesOne or more modules
generatorsmap of string to objectnoPer-generator configuration (see generators section)

Module

FieldTypeRequiredDescription
namestringyesLowercase identifier (e.g. calculator)
functionsarray of FunctionyesFunctions exported by this module
structsarray of StructnoStruct type definitions
enumsarray of EnumnoEnum type definitions
callbacksarray of CallbacknoCallback type definitions
listenersarray of ListenernoListener (event subscription) definitions
errorsErrorDomainnoOptional error domain
modulesarray of ModulenoNested sub-modules (see nested modules)

Function

FieldTypeRequiredDescription
namestringyesFunction identifier
paramsarray of ParamyesInput parameters (may be empty [])
returnTypeRefnoReturn type (omit for void functions)
docstringnoDocumentation string
asyncboolnoMark as asynchronous (default false)
cancellableboolnoAllow cancellation (only meaningful when async: true)
deprecatedstringnoDeprecation message shown to consumers
sincestringnoVersion when this function was introduced

Param

FieldTypeRequiredDescription
namestringyesParameter name
typeTypeRefyesParameter type
mutableboolnoMark as mutable (default false). Indicates the callee may modify the value in-place.
docstringnoDocumentation 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

FieldTypeRequiredDescription
namestringyesDistribution name (npm/PyPI/gem/NuGet/pub/…)
versionstringyesSemantic version stamped into each manifest
descriptionstringnoOne-line package description
licensestringnoSPDX license expression (e.g. MIT, Apache-2.0)
authorsarray of stringnoAuthor entries (Name <email>)
homepagestringnoProject homepage URL
repositorystringnoSource repository URL

Name and version resolution

Each target resolves its package name with the following precedence (first non-empty wins):

  1. an explicit per-target override (e.g. python.package_name, dart.package_name, ruby.gem_name),
  2. package.name,
  3. the IDL file stem (e.g. kvstore.ymlkvstore),
  4. 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.storemy_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-demoAsyncDemo). The stable C ABI symbol prefix is not affected: it stays weaveffi (or your global c_prefix) so the generated bindings keep calling the symbols the producer exports.

Package example

version: "0.4.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.

TypeDescriptionExample value
i8Signed 8-bit integer-12
i16Signed 16-bit integer-1000
i32Signed 32-bit integer-42
i64Signed 64-bit integer9000000000
u8Unsigned 8-bit integer200
u16Unsigned 16-bit integer60000
u32Unsigned 32-bit integer300
u64Unsigned 64-bit integer18000000000
f3232-bit floating point1.5
f6464-bit floating point3.14
boolBooleantrue
stringUTF-8 string (owned copy)"hello"
bytesByte buffer (owned copy)binary data
handleOpaque 64-bit identifierresource id
handle<T>Typed handle scoped to type Tresource id
&strBorrowed 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 as BigInt in the Node and WebAssembly backends; all narrower integers and the floats surface as number.

Primitive examples

version: "0.4.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.4.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

FieldTypeRequiredDescription
namestringyesStruct name (e.g. Contact)
docstringnoDocumentation string
fieldsarray of FieldyesMust have at least one field
builderboolnoGenerate 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:

FieldTypeRequiredDescription
namestringyesField name
typeTypeRefyesField type
docstringnoDocumentation string
defaultvaluenoDefault value for this field

Struct example

version: "0.4.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, or lists: any valid TypeRef.


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

FieldTypeRequiredDescription
namestringyesEnum name (e.g. Color)
docstringnoDocumentation string
variantsarray of VariantyesMust have at least one variant

Each variant:

FieldTypeRequiredDescription
namestringyesVariant name (e.g. Red)
valuei32yesInteger discriminant
docstringnoDocumentation string
fieldsarray of FieldnoAssociated data: makes the enum a sum type

Enum example

version: "0.4.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.4.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:

BackendSurface
C*_tag, *_{Variant}_new, *_{Variant}_get_{field}, *_destroy
C++RAII class with nested Tag, static factories, per-variant getters
Python/Rubyclass with a tag, per-variant factory + accessor methods
C#/Goowned 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.


Optional types

Append ? to any type to make it optional (nullable). When a value is absent, the default is null.

SyntaxMeaning
string?Optional string
i32?Optional i32
Contact?Optional struct reference
Color?Optional enum reference

Optional example

version: "0.4.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).

SyntaxMeaning
[i32]List of i32
[string]List of strings
[Contact]List of structs
[Color]List of enums

List example

version: "0.4.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.

SyntaxMeaning
{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.4.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:

SyntaxMeaning
[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.4.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.

SyntaxMeaning
iter<i32>Iterator over i32 values
iter<string>Iterator over strings
iter<Contact>Iterator over structs

Iterator example

version: "0.4.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"

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

FieldTypeRequiredDescription
namestringyesCallback name
paramsarray of ParamyesParameters passed to the callback
docstringnoDocumentation string

Callback example

version: "0.4.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

FieldTypeRequiredDescription
namestringyesListener name
event_callbackstringyesName of the callback this listener uses
docstringnoDocumentation string

Listener example

version: "0.4.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.4.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

Type references to structs and enums must resolve within the same module (including its parent chain). Cross-module references between sibling modules are not currently supported. Define shared types in a common parent module or duplicate the definition.


Async and lifecycle annotations

Async functions

Functions can be marked as asynchronous. See the Async Functions guide for detailed per-target behaviour.

version: "0.4.0"
modules:
  - name: net
    functions:
      - name: fetch_data
        params:
          - { name: url, type: string }
        return: string
        async: true

      - name: upload_file
        params:
          - { name: path, type: string }
        return: bool
        async: true
        cancellable: true

Async void functions (no return type) emit a validator warning since they are unusual.

Deprecated functions

Mark a function as deprecated with a migration message:

version: "0.4.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.4.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.4.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.

TypeParamsReturnsStruct fieldsNotes
i8yesyesyes
i16yesyesyes
i32yesyesyes
i64yesyesyes
u8yesyesyes
u16yesyesyes
u32yesyesyes
u64yesyesyesBigInt in JS/WASM
f32yesyesyes
f64yesyesyes
boolyesyesyes
stringyesyesyes
bytesyesyesyes
handleyesyesyes
handle<T>yesyesyesTyped handle
&stryesyesyesBorrowed, zero-copy
&[u8]yesyesyesBorrowed, zero-copy
StructNameyesyesyes
EnumNameyesyesyes
T?yesyesyes
[T]yesyesyes
[T?]yesyesyes
[T]?yesyesyes
{K:V}yesyesyes
{K:V}?yesyesyes
iter<T>noyesnoReturn-only

Complete example

A full IDL combining structs, enums, optionals, lists, and nested types:

version: "0.4.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 }

    functions:
      - name: create_contact
        params:
          - { name: first_name, type: string }
          - { name: last_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: delete_contact
        params:
          - { name: id, type: handle }
        return: bool

      - name: count_contacts
        params: []
        return: i32

Validation rules

  • Module, function, parameter, struct, enum, 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.).
  • 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.
  • Enum variant values must be unique within their enum.
  • Type references to structs/enums must resolve to a definition in the same module.
  • Async functions are allowed. Async void functions (no return type) emit a warning.
  • Listener event_callback must reference a callback in the same module.
  • Error domain names must not collide with function names.

ABI mapping

  • Parameters map to C ABI types; string and bytes are passed as pointer + length.
  • Return values are direct scalars except:
    • string: returns const char* allocated by Rust; caller must free via weaveffi_free_string.
    • bytes: returns const uint8_t* and requires an extra size_t* out_len param; caller frees with weaveffi_free_bytes.
  • Each function takes a trailing weaveffi_error* out_err for error reporting.

Error domain

You can declare an optional error domain on a module to reserve symbolic names and numeric codes:

version: "0.4.0"
modules:
  - name: contacts
    errors:
      name: ContactErrors
      codes:
        - { name: not_found, code: 1, message: "Contact not found" }
        - { name: duplicate, code: 2, message: "Contact already exists" }
    functions:
      - name: get_contact
        params:
          - { name: id, type: handle }
        return: handle

Error codes must be non-zero and unique. Error domain names must not collide with function names in the same module.

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.doc
  • StructDef.doc, StructField.doc
  • EnumDef.doc, EnumVariant.doc
  • CallbackDef.doc, ListenerDef.doc
  • ErrorCode.doc

Per-target syntax:

TargetComment syntaxParam docs
C / C++/** ... */ directly above the declarationnot 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 bindsNumPy-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 conventiontrailing // Parameters: block
Ruby# ... lines above the def# @param name [Object] ...

Example IDL:

version: "0.4.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_err parameter of type weaveffi_error*.
  • On success: out_err->code == 0 and out_err->message == NULL.
  • On failure: out_err->code != 0 and out_err->message points to a Rust-allocated NUL-terminated UTF-8 string that must be cleared.

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.
  • Future versions may map module error domains to well-known 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);

Handles

Opaque resources are represented as weaveffi_handle_t (64-bit). Treat them as tokens; their lifecycle APIs are defined by your module.

Language wrappers

  • Swift: the generated wrapper throws WeaveFFIError and automatically clears errors and frees returned strings.
  • Node: the provided N-API addon clears errors and frees returned strings; the generated JS loader expects a compiled addon index.node placed next 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)
  • Use hyphenated slugs for subpackages and components, prefixed with the top-level slug:

    • Examples: weaveffi-core, weaveffi-ir, weaveheap-core
  • Planned package names (not yet published):

    • crates.io: weaveffi, weaveffi-core, weaveffi-ir, etc.
    • npm: @weavefoundry/weaveffi
    • PyPI: weaveffi
    • SPM (repo slug): weaveffi

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).
  • Swift / Apple platforms

    • Package products and modules: UpperCamelCase (e.g., WeaveFFI, WeaveHeap).
    • Keep repo slug condensed; SPM product name provides the CamelCase surface.
  • 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).
  • 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 WeaveFFI in examples when using default exports or named namespaces.
  • 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).
  • 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>).

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 as weaveffi, Swift module WeaveFFI). For subpackages, install weaveffi-core (import as weaveffi_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).
  • Prefer condensed top-level slugs. Avoid hyphenated top-level slugs like weave-ffi, weave-heap going forward.

Examples

  • Rust

    • Crate: weaveffi-core
    • Import: use weaveffi_core::{WeaveFFI};
  • Swift (SPM)

    • Repo: weaveffi
    • Package product: WeaveFFI
    • Import: import WeaveFFI
  • Python (planned)

    • Package: weaveffi
    • Import: import weaveffi as ffi
  • Node (planned)

    • Package: @weavefoundry/weaveffi
    • Import: import { WeaveFFI } from '@weavefoundry/weaveffi'

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, optionals, lists, maps, typed handles, borrowed parameters, builders, error domains, 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).

TargetAsync functionsIterators (iter<T>)CallbacksListeners
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>)

Notes:

  • Go and Ruby async wrappers block the calling thread until the producer’s completion callback fires (a channel receive in Go, a Queue#pop in Ruby). Run them from a goroutine or Ruby thread for concurrency; the native producer still runs off-thread.
  • WASM callbacks/listeners are unsupported: a wasm32-unknown-unknown module is single-threaded and has no producer thread to deliver events. Generation fails unless you opt in with allow_unsupported = true (details), in which case the unsupported entry points 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

FilePurpose
generated/android/settings.gradleGradle settings for the library module
generated/android/build.gradleandroid-library plugin, NDK config
generated/android/src/main/kotlin/com/weaveffi/WeaveFFI.ktKotlin wrapper (enums, struct classes, namespaced functions)
generated/android/src/main/cpp/weaveffi_jni.cJNI shims that call the C ABI and throw Java exceptions
generated/android/src/main/cpp/CMakeLists.txtNDK CMake build for the JNI shared library

Type mapping

IDL typeKotlin type (external)Kotlin type (wrapper)JNI C type
i32IntIntjint
u32LongLongjlong
i64LongLongjlong
f64DoubleDoublejdouble
i8ByteBytejbyte
i16ShortShortjshort
u8ByteBytejbyte
u16ShortShortjshort
u64LongLongjlong
f32FloatFloatjfloat
boolBooleanBooleanjboolean
stringStringStringjstring
bytesByteArrayByteArrayjbyteArray
handleLongLongjlong
StructNameLongStructNamejlong
EnumName (plain)IntEnumNamejint
EnumName (rich)LongEnumNamejlong
T?T?T?jobject
[i32]IntArrayIntArrayjintArray
[i64]LongArrayLongArrayjlongArray
[string]Array<String>Array<String>jobjectArray
iter<T>Iterator<T>Iterator<T>jobject

Example IDL → generated code

version: "0.4.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 prefixed with the module name. 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 contacts_get_contactJni(id: Int): Long
        @JvmStatic fun contacts_get_contact(id: Int): Contact = Contact(contacts_get_contactJni(id))
        @JvmStatic private external fun contacts_find_by_typeJni(contact_type: Int): LongArray
        @JvmStatic fun contacts_find_by_type(contact_type: ContactType): LongArray = contacts_find_by_typeJni(contact_type.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:

static void throw_weaveffi_error(JNIEnv* env, weaveffi_error* err) {
    const char* msg = err->message ? err->message : "WeaveFFI error";
    jclass exClass = (*env)->FindClass(env, "java/lang/RuntimeException");
    (*env)->ThrowNew(env, exClass, msg);
    weaveffi_error_clear(err);
}

JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_contacts_1get_1contactJni(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;
}

When the module declares an error domain, the generator emits a sealed class WeaveFFIException with one PascalCased subclass per code (KEY_NOT_FOUNDWeaveFFIException.KeyNotFound), and the shim resolves the matching subclass by code (FindClass(env, "com/weaveffi/WeaveFFIException$KeyNotFound")). Modules without a declared domain get an open class WeaveFFIException, and unknown codes fall back to java.lang.RuntimeException.

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)

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 shapes_describe(shape: Shape): String and shapes_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.shapes_scale(c, 3.0)   // returns a new Shape
    try {
        println(WeaveFFI.shapes_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 shapes_scale. The finalize() safety net runs during GC but is not a substitute for deterministic cleanup.

Build instructions

  1. Install Android Studio (Giraffe or newer) plus the NDK.

  2. 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
    
  3. Open generated/android in Android Studio, sync Gradle, and build the AAR (./gradlew :weaveffi:assemble).

  4. 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 wrappers implement Closeable; either call .close() explicitly or use use { ... }. The finalize() 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_string before returning.
  • Byte arrays returned from JNI are copied with SetByteArrayRegion before the Rust buffer is freed.
  • Optional values are passed as boxed wrappers (Integer, Long, Double, Boolean); the JNI shim unboxes and forwards them to the C ABI.

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 tasks_run_taskAsync(name: String, callback: Any)
@JvmStatic suspend fun tasks_run_task(name: String): TaskResult {
    val raw: Long = suspendCancellableCoroutine { cont ->
        tasks_run_taskAsync(name, WeaveContinuation(cont))
    }
    return TaskResult(raw)
}

internal class WeaveContinuation<T>(private val cont: kotlinx.coroutines.CancellableContinuation<T>) {
    @Suppress("UNCHECKED_CAST")
    fun onSuccess(result: Any?) { cont.resume(result as T) }
    fun onError(message: String) { cont.resumeWithException(RuntimeException(message)) }
}

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_tasks_1run_1taskAsync(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(String) or callback.onSuccess(Object) ... */
    if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
    (*env)->DeleteGlobalRef(env, ctx->callback);
    JavaVM* jvm = ctx->jvm;
    free(ctx);
    if (attached) (*jvm)->DetachCurrentThread(jvm);
}

The generated build.gradle does not declare a coroutines dependency; add org.jetbrains.kotlinx:kotlinx-coroutines-android (or -core) to the consuming project.

For functions 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:

@JvmStatic private external fun kv_compact_asyncAsync(store: Long, cancelToken: Long, callback: Any)
@JvmStatic suspend fun kv_compact_async(store: Store): Long = suspendCancellableCoroutine { cont ->
    kv_compact_asyncAsync(store.handle, 0L, WeaveContinuation(cont))
}

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 events_register_message_listener(callback: (String) -> Unit): Long
@JvmStatic external fun events_unregister_message_listener(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);
    if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
    (*env)->PopLocalFrame(env, NULL);
    if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm);
}

JNIEXPORT jlong JNICALL Java_com_weaveffi_WeaveFFI_events_1register_1message_1listener(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).

Iterators

iter<T> returns surface as Iterator<T> in Kotlin, but the shim drains the native iterator eagerly: it calls the generated _next C function until exhaustion, copies each element into a java.util.ArrayList (freeing the Rust string as it goes), destroys the iterator handle, and returns the list’s iterator(). From the events sample (get_messages returns iter<string>):

@JvmStatic external fun events_get_messages(): Iterator<String>
weaveffi_events_GetMessagesIterator* _iter = weaveffi_events_get_messages(&err);
/* ... */
while (weaveffi_events_GetMessagesIterator_next(_iter, &_item, &_iter_err) != 0) {
    jstring _jitem = _item ? (*env)->NewStringUTF(env, _item) : (*env)->NewStringUTF(env, "");
    (*env)->CallBooleanMethod(env, _list, _al_add, _jitem);
    (*env)->DeleteLocalRef(env, _jitem);
    weaveffi_free_string(_item);
}
weaveffi_events_GetMessagesIterator_destroy(_iter);

Troubleshooting

  • UnsatisfiedLinkError: Couldn't find libweaveffi.so: the Rust-built cdylib was not packaged inside the AAR. Place it under src/main/jniLibs/<abi>/ and rebuild.
  • UnsatisfiedLinkError for the JNI symbol itself: Kotlin external function names must match the JNI signature, including the _1 escape for underscores. Re-run weaveffi generate if you hand-edited either side.
  • Crashes when releasing strings: the JNI shim is responsible for calling ReleaseStringUTFChars on every GetStringUTFChars. If you edit the shim, keep the pairing intact.
  • R8/ProGuard removes WeaveFFI symbols: 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

FilePurpose
generated/c/weaveffi.hPublic header: opaque types, enums, function prototypes, error/memory helpers
generated/c/weaveffi.cEmpty placeholder for future convenience wrappers (kept so projects can link a single TU if desired)

Type mapping

IDL typeC parameter typeC return type
i32int32_tint32_t
u32uint32_tuint32_t
i64int64_tint64_t
u64uint64_tuint64_t
i8int8_tint8_t
i16int16_tint16_t
u8uint8_tuint8_t
u16uint16_tuint16_t
f32floatfloat
f64doubledouble
boolboolbool
stringconst char* (NUL-terminated UTF-8)const char*
bytesconst uint8_t* ptr, size_t lenconst uint8_t* + size_t* out_len
handleweaveffi_handle_tweaveffi_handle_t
Structconst weaveffi_m_S*weaveffi_m_S*
Enum (plain)weaveffi_m_Eweaveffi_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_lenT* + size_t* out_len
iter<T>n/aopaque iterator handle (see Iterators)

C ABI symbol naming follows a strict convention:

KindPatternExample
Functionweaveffi_{module}_{function}weaveffi_contacts_create_contact
Struct typeweaveffi_{module}_{Struct}weaveffi_contacts_Contact
Struct createweaveffi_{module}_{Struct}_createweaveffi_contacts_Contact_create
Struct destroyweaveffi_{module}_{Struct}_destroyweaveffi_contacts_Contact_destroy
Struct getterweaveffi_{module}_{Struct}_get_{field}weaveffi_contacts_Contact_get_name
Enum typeweaveffi_{module}_{Enum}weaveffi_contacts_ContactType
Enum variantweaveffi_{module}_{Enum}_{Variant}weaveffi_contacts_ContactType_Personal
Callback typedefweaveffi_{module}_{Callback}_fnweaveffi_events_OnMessage_fn
Listener registerweaveffi_{module}_register_{listener}weaveffi_events_register_message_listener
Listener unregisterweaveffi_{module}_unregister_{listener}weaveffi_events_unregister_message_listener
Async callbackweaveffi_{module}_{function}_callbackweaveffi_tasks_run_task_callback
Async launcherweaveffi_{module}_{function}_asyncweaveffi_tasks_run_task_async
Iterator typeweaveffi_{module}_{Function}Iteratorweaveffi_events_GetMessagesIterator
Iterator nextweaveffi_{module}_{Function}Iterator_nextweaveffi_events_GetMessagesIterator_next
Iterator destroyweaveffi_{module}_{Function}Iterator_destroyweaveffi_events_GetMessagesIterator_destroy

{Function} is the function name converted to PascalCase (get_messagesGetMessages).

When the IDL sets c_prefix, every symbol, including the runtime helpers, is rewritten with the new prefix.

Example IDL → generated code

version: "0.4.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);

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;
}

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 example uses the calculator sample crate.

macOS:

cargo build -p calculator

cd examples/c
cc -I ../../generated/c main.c -L ../../target/debug -lcalculator -o c_example
DYLD_LIBRARY_PATH=../../target/debug ./c_example

Linux:

cargo build -p calculator

cd examples/c
cc -I ../../generated/c main.c -L ../../target/debug -lcalculator -o c_example
LD_LIBRARY_PATH=../../target/debug ./c_example

Windows:

cargo build -p calculator
cd examples\c
cl /I ..\..\generated\c main.c /link calculator.lib
.\main.exe

See examples/c/main.c for end-to-end usage.

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 a uint64_t subscription id. Pass that id to unregister_* to stop delivery.
  • context is 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.

For cancellable: true functions the launcher gains a weaveffi_cancel_token* slot before the callback, and the runtime provides the token lifecycle (from the kvstore sample, whose function is named compact_async, hence the doubled suffix):

void weaveffi_kv_compact_async_async(
    weaveffi_kv_Store* store,
    weaveffi_cancel_token* cancel_token,
    weaveffi_kv_compact_async_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. Element ownership follows the usual return rules; here each const char* must be freed with weaveffi_free_string. Always call _destroy 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);
}
weaveffi_events_GetMessagesIterator_destroy(iter);

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.c is empty: that file is intentionally a placeholder. All declarations live in weaveffi.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 prefers the node-gyp build output (./build/Release/weaveffi.node) and falls back to a prebuilt binary placed next to it as index.node (samples/node-addon provides one for the in-tree examples).

What gets generated

FilePurpose
generated/node/index.jsCommonJS loader: tries ./build/Release/weaveffi.node, falls back to ./index.node
generated/node/types.d.tsTypeScript declarations for the public surface
generated/node/weaveffi_addon.cN-API addon source: marshaling, promises, threadsafe functions
generated/node/binding.gypnode-gyp build file (includes ../c, links -lweaveffi)
generated/node/package.jsonnpm package metadata (main, types, gypfile, install script)

Type mapping

IDL typeTypeScript type
i32number
u32number
i8number
i16number
u8number
u16number
i64number
u64number
f64number
f32number
boolboolean
stringstring
bytesBuffer
handlebigint
StructNameStructName
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>T[] (drained)

Example IDL → generated code

version: "0.4.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 with a <module>_ prefix; optional return and parameter types use | null, arrays use T[]:

export function contacts_get_contact(id: number): Contact | null
export function contacts_list_contacts(): Contact[]
export function contacts_set_favorite_color(contact_id: number, color: Color | null): void
export function contacts_get_tags(contact_id: number): string[]

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 {
  constructor(handle) {
    this._handle = handle;
    Shape._cleanup.register(this, handle, this);
  }
  static empty() {
    return new Shape(addon.shapes_Shape_empty_new());
  }
  static circle(radius) {
    return new Shape(addon.shapes_Shape_circle_new(radius));
  }
  static rectangle(width, height) {
    return new Shape(addon.shapes_Shape_rectangle_new(width, height));
  }
  static labeled(label, count) {
    return new Shape(addon.shapes_Shape_labeled_new(label, count));
  }
  tag() {
    return addon.shapes_Shape_tag(this._handle);
  }
  get circleRadius() {
    return addon.shapes_Shape_circle_get_radius(this._handle);
  }
  get rectangleWidth() {
    return addon.shapes_Shape_rectangle_get_width(this._handle);
  }
  get rectangleHeight() {
    return addon.shapes_Shape_rectangle_get_height(this._handle);
  }
  get labeledLabel() {
    return addon.shapes_Shape_labeled_get_label(this._handle);
  }
  get labeledCount() {
    return addon.shapes_Shape_labeled_get_count(this._handle);
  }
  destroy() {
    if (this._handle) {
      Shape._cleanup.unregister(this);
      addon.shapes_Shape_destroy(this._handle);
      this._handle = 0;
    }
  }
}
Shape._cleanup = new FinalizationRegistry((handle) => {
  if (handle) { addon.shapes_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.shapes_Shape_circle_get_radius(this._handle)). Free functions that take or return the enum accept the wrapper directly: shapes_describe(shape) unwraps shape._handle, and shapes_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, shapes_describe, shapes_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(shapes_describe(circle)); // native-rendered description
const bigger = shapes_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 shapes_Shape_destroy once the wrapper is garbage-collected, but GC timing isn’t guaranteed, so prefer an explicit destroy().

Build instructions

The runnable example uses the calculator sample.

macOS:

cargo build -p calculator
cp target/debug/libindex.dylib generated/node/index.node

cd examples/node
DYLD_LIBRARY_PATH=../../target/debug npm start

Linux:

cargo build -p calculator
cp target/debug/libindex.so generated/node/index.node

cd examples/node
LD_LIBRARY_PATH=../../target/debug npm start

Windows: copy target\debug\index.dll to generated\node\index.node and run npm start from examples\node.

For your own project 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 weaveffi cdylib on the linker path. 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 (as above) also works.

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.
  • Typed handles (handle<Struct>) pass through as opaque values; release them through the API’s own teardown function (e.g. kv_close_store).
  • iter<T> returns are drained eagerly inside the addon: it loops the C _next function into a JS array, frees each native item, and destroys the iterator handle before returning.
  • Errors from the C ABI are converted into JavaScript Error instances by the addon before bubbling up to the caller.

Async support

Async IDL functions are exposed as JS functions that return a Promise:

export function tasks_run_task(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);
}

Rejected promises carry the C error message plus a numeric code property. 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.

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 events_register_message_listener(callback: (message: string) => void): number
export function events_unregister_message_listener(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. Run npm install in generated/node/ to build the generated addon with node-gyp, or copy a prebuilt binary in as index.node.
  • dlopen: ... image not found: the addon links against the Rust cdylib at runtime; set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH or copy the cdylib next to index.node.
  • BigInt errors with handle: handles are 64-bit; pass them as bigint, not number.
  • TypeScript complains about missing types: point tsconfig’s paths at generated/node/types.d.ts or include the generated package in compilerOptions.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

FilePurpose
generated/swift/Package.swiftSwiftPM manifest declaring CWeaveFFI (system library) and WeaveFFI (Swift wrapper)
generated/swift/Sources/CWeaveFFI/module.modulemapC module map pointing at the generated header
generated/swift/Sources/WeaveFFI/WeaveFFI.swiftSwift 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-demoAsyncDemo). 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 typeSwift typeNotes
i32Int32Direct value
u32UInt32Direct value
i64Int64Direct value
u64UInt64Direct value
i8Int8Direct value
i16Int16Direct value
u8UInt8Direct value
u16UInt16Direct value
f32FloatDirect value
f64DoubleDirect value
boolBoolC bool at the ABI
stringStringNUL-terminated UTF-8 (withCString)
bytesData / [UInt8]Pointer + length
handleUInt64Direct value
StructNameStructName (class)Wraps OpaquePointer
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>[T]Drained eagerly via _next

Example IDL → generated code

version: "0.4.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: name, 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: 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, are prefixed with the module name, and try into Swift errors. String parameters are passed as NUL-terminated C strings via withCString:

public enum Contacts {
    public static func contacts_create_contact(_ 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 check(&err)
        guard let result = result else { throw WeaveFFIError.error(code: -1, message: "null pointer") }
        return Contact(ptr: result)
    }
}

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
}

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(2.0)

if shape.tag == .circle {
    print("radius = \(shape.circleRadius)")
}

print(try Shapes.shapes_describe(shape))
let bigger = try Shapes.shapes_scale(shape, 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

The runnable example uses the calculator sample.

macOS:

cargo build -p calculator

cd examples/swift
swiftc \
  -I ../../generated/swift/Sources/CWeaveFFI \
  -L ../../target/debug -lcalculator \
  -Xlinker -rpath -Xlinker ../../target/debug \
  Sources/App/main.swift -o .build/debug/App

DYLD_LIBRARY_PATH=../../target/debug .build/debug/App

Linux:

cargo build -p calculator

cd examples/swift
swiftc \
  -I ../../generated/swift/Sources/CWeaveFFI \
  -L ../../target/debug -lcalculator \
  -Xlinker -rpath -Xlinker ../../target/debug \
  Sources/App/main.swift -o .build/debug/App

LD_LIBRARY_PATH=../../target/debug .build/debug/App

In a real SwiftPM application, add the generated package as a path dependency, link CWeaveFFI and WeaveFFI, and ship the cdylib as part of an XCFramework or bundled .dylib/.so.

Memory and ownership

  • Struct classes own an OpaquePointer. The class deinit calls the matching C destructor.
  • Returned strings are copied into Swift String and the raw pointer is freed via weaveffi_free_string immediately.
  • withUnsafeBufferPointer and withOptionalPointer keep input buffers alive only for the duration of the C call; there’s no copy.
  • For bytes parameters, the wrapper copies the Data into a [UInt8] array and passes it via withUnsafeBufferPointer; returned bytes are copied into Data and the Rust buffer is freed with weaveffi_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> {
    let value: CheckedContinuation<T, Error>
    init(_ value: CheckedContinuation<T, Error>) { self.value = value }
}

public static func tasks_run_task(_ 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>>.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: WeaveFFIError.error(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)
        }
    }
}

For functions 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:

weaveffi_kv_compact_async_async(store.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 events_register_message_listener(_ 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 events_unregister_message_listener(_ 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 drained eagerly: the wrapper calls the generated _next C function until it reports exhaustion, frees each element, destroys the iterator handle, and returns a Swift array. From the events sample (get_messages returns iter<string>):

public static func events_get_messages() throws -> [String] {
    var err = weaveffi_error(code: 0, message: nil)
    let iter = weaveffi_events_get_messages(&err)
    try check(&err)
    guard let iter = iter else { return [] }
    var items: [String] = []
    var iterItem: UnsafePointer<CChar>? = nil
    var iterErr = weaveffi_error(code: 0, message: nil)
    while weaveffi_events_GetMessagesIterator_next(iter, &iterItem, &iterErr) != 0 {
        items.append(String(cString: iterItem!))
        weaveffi_free_string(UnsafeMutablePointer(mutating: iterItem))
    }
    weaveffi_events_GetMessagesIterator_destroy(iter)
    try check(&iterErr)
    return items
}

Troubleshooting

  • module 'CWeaveFFI' not found: Xcode/SwiftPM didn’t pick up the generated module.modulemap. Make sure Sources/CWeaveFFI/module.modulemap is on disk and the package declares systemLibrary(name: "CWeaveFFI").
  • Library not loaded: libweaveffi.dylib: set DYLD_LIBRARY_PATH for development or embed the dylib in your application bundle for distribution.
  • Crashes after deinit: never reuse an OpaquePointer after the owning Swift wrapper goes out of scope. The C side has already freed it.
  • Optional struct ends up nil even when present: the C function is allowed to return a null pointer to indicate absence; double-check the Rust implementation actually returns Some(_) 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.

Because a wasm32-unknown-unknown module is single-threaded and has no producer thread, callbacks and listeners are not supported; see Capabilities and allow_unsupported.

What gets generated

FilePurpose
generated/wasm/weaveffi_wasm.jsES module: memory helpers, struct wrapper classes, and the async loadWeaveffiWasm(url) loader returning typed bindings
generated/wasm/weaveffi_wasm.d.tsTypeScript declarations for the loader and every module namespace
generated/wasm/package.jsonnpm package manifest (type: "module")
generated/wasm/README.mdQuickstart and boundary conventions

Type mapping

IDL typeWASM boundaryJavaScript surface
i32 / u32i32number
i8 / i16i32number
u8 / u16i32number
i64i64BigInt
u64i64BigInt
f64f64number
f32f32number
booli32boolean (0/1 at the boundary)
stringi32 pointer (NUL-terminated UTF-8)string, staged via weaveffi_alloc
bytesi32 pointer + i32 lengthUint8Array copy
handle / StructNamei32 pointer into linear memory (0 = null)struct wrapper class with getters
EnumName (plain, C-style)i32 discriminantnumber
EnumName (rich / algebraic)i32 pointer into linear memory (0 = null)wrapper class (e.g. Shape)
T?0 / null pointer; scalars boxed by pointerT | null
[T]i32 pointer + i32 lengthArray copy
iter<T>iterator handle + next out-paramdrained into an Array

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 and have idiomatic signatures; strings, arrays, and error handling are taken care of inside the wrapper:

api.events.send_message('hello');        // throws Error on failure
const all = api.events.get_messages();   // iter<string> -> string[]

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.run_task('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: {
    send_message(text: string): void;
    get_messages(): string[];
  };
}

export function loadWeaveffiWasm(url: string): Promise<WeaveffiWasmModule>;

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:

run_task(name) {
  return new Promise((resolve, reject) => {
    const ctxId = _nextCtxId++;
    _asyncContexts.set(ctxId, { resolve, reject, 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.

Two caveats apply:

  • WebAssembly.Function requires 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.

Cancellable functions expose their cancel entry point as a plain function in the same namespace (e.g. api.tasks.cancel_task(id)).

Capabilities and allow_unsupported

The WASM generator declares callbacks and listeners as unsupported in its TargetCapabilities. If your IDL uses them, weaveffi generate fails with an error listing the offending definitions rather than silently skipping them.

To generate the rest of the surface anyway, opt in explicitly:

# weaveffi.toml
[wasm]
allow_unsupported = true

or inline in the IDL:

generators:
  wasm:
    allow_unsupported: true

With the opt-in, unsupported entry points are generated as explicit throwing stubs (calling register_message_listener throws an Error explaining that listeners need a native target), so the gap is visible at the call site instead of failing silently.

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_dealloc and 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_dealloc inside 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 of weaveffi_wasm.js apply 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 IDL functions 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, extend loadWeaveffiWasm to pass them in.
  • An async call never settles: the producer must invoke the completion callback on the same thread; std::thread::spawn does not exist on wasm32-unknown-unknown.
  • Out-of-memory after many _raw calls: every pointer returned from the module must be deallocated; the typed wrappers do this for you, raw calls do not.
  • The .wasm file fails to instantiate: the build artifact must be wasm32-unknown-unknown. wasm32-wasi modules 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

FilePurpose
python/weaveffi/__init__.pyRe-exports the public API from weaveffi.py
python/weaveffi/weaveffi.pyctypes bindings: library loader, wrappers, classes
python/weaveffi/weaveffi.pyiType stub for IDE autocompletion and mypy
python/pyproject.tomlPEP 621 project metadata
python/setup.pyFallback setuptools script
python/README.mdBasic usage instructions

The package directory follows the IDL package.name (a package named events produces python/events/...); weaveffi is the default.

Type mapping

IDL typePython type hintctypes type
i32intctypes.c_int32
u32intctypes.c_uint32
i64intctypes.c_int64
f64floatctypes.c_double
i8intctypes.c_int8
i16intctypes.c_int16
u8intctypes.c_uint8
u16intctypes.c_uint16
u64intctypes.c_uint64
f32floatctypes.c_float
boolboolctypes.c_int32
stringstrctypes.c_char_p
bytesbytesctypes.POINTER(ctypes.c_uint8) + ctypes.c_size_t
handleintctypes.c_uint64
Struct"StructName"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]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.4.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:
    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 Python functions with full type hints; ctypes argtypes/restype are set up at the call site:

def contacts_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_char_p
        return _bytes_to_string(_fn(self._ptr)) or ""

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 contacts_create_contact(name: str, email: Optional[str], contact_type: "ContactType") -> int: ...

Wrapper names carry the IDL module prefix by default (contacts_create_contact); set strip_module_prefix: true in the Python generator config to drop it.

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, shapes_describe, shapes_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(shapes_describe(circle))       # render via the C ABI
bigger = shapes_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 shapes_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

  1. Generate the bindings:

    weaveffi generate weaveffi.yaml -o generated --target python
    
  2. Build the Rust shared library:

    cargo build --release -p your_library
    
  3. Install the package (editable install for development):

    cd generated/python
    pip install -e .
    
  4. 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.dll next to your script or add its directory to PATH.
  5. Use the bindings:

    from weaveffi import (
        ContactType,
        contacts_create_contact,
        contacts_get_contact,
        contacts_count_contacts,
    )
    
    handle = contacts_create_contact("Alice", "alice@example.com", ContactType.Work)
    contact = contacts_get_contact(handle)
    print(f"{contact.name} ({contact.email})")
    print(f"Total: {contacts_count_contacts()}")
    

Memory and ownership

  • Strings in: Python str is encoded to UTF-8 by _string_to_bytes before crossing the boundary. ctypes manages the lifetime of the temporary buffer.

  • Strings out: Returned c_char_p is decoded via _bytes_to_string. The Rust runtime owns the original pointer; the preamble registers weaveffi_free_string for cleanup.

  • Bytes: copied in via a ctypes array, copied out via slicing (_result[:_out_len.value]). Rust frees the original buffer.

  • Structs: wrappers hold an opaque c_void_p. __del__ calls the matching _destroy C function. For deterministic cleanup, use the _PointerGuard context manager:

    with _PointerGuard(handle, _lib.weaveffi_contacts_Contact_destroy):
        ...
    

Async support

Async IDL functions (async: true) are exposed as async def wrappers. Each wrapper delegates to a generated blocking _<module>_<name>_sync helper via run_in_executor, so the asyncio event loop stays free while a worker thread waits for the native completion callback:

async def tasks_run_task(name: str) -> "TaskResult":
    _loop = asyncio.get_event_loop()
    return await _loop.run_in_executor(None, _tasks_run_task_sync, name)

The _sync helper builds a ctypes.CFUNCTYPE completion callback, calls the _async-suffixed C launcher, and blocks on a threading.Event until the C side fires the callback. An error reported through the callback is re-raised as WeaveFFIError:

def _tasks_run_task_sync(name: str) -> "TaskResult":
    _fn = _lib.weaveffi_tasks_run_task_async
    _ev = threading.Event()
    _state = {"err": None, "val": None}
    def _cb_impl(context, err, result):
        try:
            if err and err.contents.code != 0:
                # ... decode the error, weaveffi_error_clear ...
                _state["err"] = WeaveFFIError(_code, _msg)
            else:
                # ... null-pointer guard ...
                _state["val"] = TaskResult(result)
        finally:
            _ev.set()
    _cb_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p,
                                ctypes.POINTER(_WeaveFFIErrorStruct),
                                ctypes.c_void_p)
    _cb = _cb_type(_cb_impl)
    _fn.argtypes = [ctypes.c_char_p, _cb_type, ctypes.c_void_p]
    _fn.restype = None
    _fn(_string_to_bytes(name), _cb, None)
    _ev.wait()
    if _state["err"] is not None:
        raise _state["err"]
    return _state["val"]

For functions marked cancellable: true the C launcher takes an extra cancel-token parameter; the Python wrapper always passes None (NULL) for it. 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, C++, and Kotlin 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 events_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 events_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_refs dict, 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 uint64 id produced by weaveffi_events_register_message_listener(fn, context); pass it to events_unregister_message_listener to 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 = events_register_message_listener(lambda m: print(m))
events_send_message("hello")
events_unregister_message_listener(listener_id)

Iterators

Functions returning iter<T> receive an opaque iterator handle from the C ABI (weaveffi_events_get_messages). The wrapper drains it eagerly with the generated _next binding (weaveffi_events_GetMessagesIterator_next), destroys the handle, and returns the collected items; the signature is annotated Iterator[str]:

def events_get_messages() -> Iterator[str]:
    _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)
    # ... argtypes/restype for _next_fn and _destroy_fn ...
    _items = []
    while True:
        _out_item = ctypes.c_char_p()
        _item_err = _WeaveFFIErrorStruct()
        _has = _next_fn(_result, ctypes.byref(_out_item),
                        ctypes.byref(_item_err))
        _check_error(_item_err)
        if not _has:
            break
        _items.append(_bytes_to_string(_out_item.value))
    _destroy_fn(_result)
    return _items

An error from _next raises WeaveFFIError; on success the iterator handle is destroyed before the wrapper returns, so no native state outlives the call.

Troubleshooting

  • OSError: cannot find ...: the loader could not locate the shared library. Set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH or copy the library next to your script.
  • WeaveFFIError: ...: the Rust side returned a non-zero error code. Catch WeaveFFIError and inspect .code / .message.
  • AttributeError: ... has no attribute 'argtypes': the wrapper sets argtypes/restype at the call site; ensure you’re calling the generated function, not reaching into _lib directly.
  • 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 are exposed as IDisposable classes with property getters, errors become managed exceptions, and the project targets net8.0.

What gets generated

FilePurpose
generated/dotnet/WeaveFFI.csC# bindings: P/Invoke declarations, wrapper classes, enums, exceptions
generated/dotnet/WeaveFFI.csprojSDK-style project (net8.0, AllowUnsafeBlocks)
generated/dotnet/WeaveFFI.nuspecNuGet package metadata
generated/dotnet/README.mdBuild and pack instructions

Type mapping

IDL typeC# typeP/Invoke type
i32intint
u32uintuint
i64longlong
f64doubledouble
i8sbytesbyte
i16shortshort
u8bytebyte
u16ushortushort
u64ulongulong
f32floatfloat
boolboolint
stringstringIntPtr
handleulongulong
bytesbyte[]IntPtr
StructNameStructNameIntPtr
EnumName (plain)EnumNameint
EnumName (rich)EnumNameIntPtr
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.4.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 carry the module prefix (ContactsCreateContact), and nested IDL modules flatten into a single class with a concatenated name (a stats module nested under kv becomes KvStats with KvStatsGetStats). All wrappers throw WeaveFFIException on failure:

public static class Contacts
{
    public static ulong ContactsCreateContact(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);
}

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.ShapesDescribe(Shape), Shapes.ShapesScale(Shape, double)):

using var c = Shape.Circle(2.0);
Console.WriteLine(c.GetTag());                  // Tag.Circle
Console.WriteLine(c.CircleRadius);              // 2
using var bigger = Shapes.ShapesScale(c, 3.0);  // returns a new Shape
Console.WriteLine(Shapes.ShapesDescribe(bigger));

Ownership: a Shape owns its native handle, so dispose every Shape you create or receive, including the one returned by ShapesScale, with using or an explicit Dispose(). The finalizer is a safety net that runs on a non-deterministic schedule.

Build instructions

  1. Generate the bindings:

    weaveffi generate api.yaml -o generated/ --target dotnet
    
  2. Build:

    cd generated/dotnet
    dotnet build
    
  3. Pack as NuGet:

    dotnet pack -c Release
    

    The resulting .nupkg lives in bin/Release/. For production packages, bundle the native cdylib inside the package under runtimes/{rid}/native/.

  4. 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 class implements IDisposable; use using for 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_string immediately, so string properties do not require any disposal.
  • Strings passed as parameters are marshalled with Marshal.StringToCoTaskMemUTF8 and freed in a finally block.
  • Optional struct returns surface as IntPtr.Zero from the C ABI and become null in C#.
  • iter<T> functions return a lazy IEnumerable<T> that pulls items through the C _next function as you enumerate; the native iterator handle is destroyed in a finally block when enumeration completes or the enumerator is disposed early.

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:

public static async Task<TaskResult> TasksRunTask(string name)
{
    var tcs = new TaskCompletionSource<TaskResult>(TaskCreationOptions.RunContinuationsAsynchronously);
    NativeMethods.AsyncCb_weaveffi_tasks_run_task callback = (context, err, result) =>
    {
        try
        {
            // ... tcs.SetException(new WeaveFFIException(...)) 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 GCHandle prevents the GC from collecting the delegate (and the native thunk the producer will call) before completion. It is freed exactly once: in the callback’s finally, or on the catch path if the native call itself throws synchronously.
  • The completion callback runs on the producer’s native thread; RunContinuationsAsynchronously keeps awaiting code from running inline on that thread.
  • Errors fault the task with a WeaveFFIException carrying the C error code and message.

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, C++, and Kotlin 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 EventsRegisterMessageListener(Action<string> callback)
public static void EventsUnregisterMessageListener(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 EventsRegisterMessageListener(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)]. EventsUnregisterMessageListener(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 set LD_LIBRARY_PATH / DYLD_LIBRARY_PATH.
  • AccessViolationException on dispose: the struct has been disposed twice. Wrap usage in using and 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 become RAII classes with deleted copies and movable handles, errors map to exceptions, 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

FilePurpose
generated/cpp/weaveffi.hppHeader-only bindings: extern “C” declarations, RAII wrappers, enum classes, inline function wrappers
generated/cpp/CMakeLists.txtINTERFACE library target (weaveffi_cpp)
generated/cpp/README.mdBuild instructions

Type mapping

IDL typeC++ typePassed as parameter
i32int32_tint32_t
u32uint32_tuint32_t
i64int64_tint64_t
u64uint64_tuint64_t
i8int8_tint8_t
i16int16_tint16_t
u8uint8_tuint8_t
u16uint16_tuint16_t
f32floatfloat
f64doubledouble
boolboolbool
stringstd::stringconst std::string&
bytesstd::vector<uint8_t>const std::vector<uint8_t>&
handlevoid*void*
StructNameStructNameconst StructName&
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>std::vector<T> (return only; see Iterators)n/a

Example IDL → generated code

version: "0.4.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 the weaveffi namespace and throw on failure:

namespace weaveffi {
inline Contact contacts_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);
    if (err.code != 0) {
        std::string msg(err.message ? err.message : "unknown error");
        int32_t code = err.code;
        weaveffi_error_clear(&err);
        throw WeaveFFIError(code, msg);
    }
    return Contact(result);
}
} // namespace weaveffi

WeaveFFIError extends std::runtime_error. When the IDL declares custom error codes the generator also emits typed subclasses, each named in PascalCase with an Error suffix (not_foundNotFoundError, KEY_NOT_FOUNDKeyNotFoundError):

namespace weaveffi {
class NotFoundError : public WeaveFFIError { /* ... */ };
} // namespace weaveffi

The exception dispatcher throws the most specific subclass, so you can catch a single code or fall back to the base WeaveFFIError:

try {
    auto contact = weaveffi::contacts_find_contact(42);
} catch (const weaveffi::NotFoundError& e) {
    std::cerr << "Not found: " << e.what() << '\n';
} catch (const weaveffi::WeaveFFIError& e) {
    std::cerr << "Error " << e.code() << ": " << e.what() << '\n';
}

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 wrappers own a single void* handle. The destructor calls the C _destroy function. Copies are deleted; moves transfer ownership by nulling the source handle.
  • Strings returned from getters are copied into std::string and the raw pointer is freed via weaveffi_free_string before returning.
  • Optional fields use std::optional<T>; a nullptr from the C layer becomes std::nullopt.
  • std::vector<T> returns own their contents. List parameters borrow the underlying buffer for the duration of the call.

Callbacks and listeners

Listeners surface as free functions 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

inline uint64_t events_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 events_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);
}
  • register_* returns the uint64_t subscription id from the C layer. The registry (detail::wv_listener_registry(), a std::unordered_map<uint64_t, std::shared_ptr<void>> guarded by detail::wv_listener_mutex()) maps that id to the boxed std::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> contacts_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(std::make_exception_ptr(
                    WeaveFFIError(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 promise is completed (or rejected with a WeaveFFIError exception) exactly once in the completion lambda, which then deletes it.

When the IDL marks the function cancellable: true, the wrapper gains a trailing weaveffi_cancel_token* parameter defaulting to nullptr (from the kvstore sample):

inline std::future<int64_t> kv_compact_async(Store& store,
    weaveffi_cancel_token* cancel_token = nullptr) { /* ... */ }
weaveffi_cancel_token* token = weaveffi_cancel_token_create();
auto fut = weaveffi::kv_compact_async(store, token);
weaveffi_cancel_token_cancel(token);   // from any thread
// fut.get() throws WeaveFFIError 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 plain std::vector<T>: the wrapper drains the C iterator with _next, frees each element, destroys the iterator handle, and returns the collected values. From the events sample (get_messages returns iter<string>, trimmed):

inline std::vector<std::string> events_get_messages() {
    weaveffi_error err{};
    weaveffi_events_GetMessagesIterator* iter = weaveffi_events_get_messages(&err);
    // ... throw WeaveFFIError on error ...
    std::vector<std::string> ret;
    while (true) {
        const char* item{};
        int32_t has_item = weaveffi_events_GetMessagesIterator_next(iter, &item, &err);
        // ... destroy iterator + throw on error ...
        if (has_item == 0) break;
        ret.emplace_back(item);
        weaveffi_free_string(item);
    }
    weaveffi_events_GetMessagesIterator_destroy(iter);
    return ret;
}

Iteration is eager; the full sequence is materialized before the wrapper returns. Drop to the C ABI (_next/_destroy) if you need streaming consumption.

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_EXCEPTIONS setting and CRT.
  • std::optional is missing: the header requires C++17. Add target_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

FilePurpose
dart/lib/weaveffi.dartdart:ffi bindings: loader, typedefs, lookups, wrappers, struct/enum classes
dart/pubspec.yamlPackage metadata and package:ffi dependency
dart/README.mdBasic usage instructions

Type mapping

IDL typeDart typeNative FFI typeDart FFI type
i32intInt32int
u32intUint32int
i64intInt64int
f64doubleDoubledouble
i8intInt8int
i16intInt16int
u8intUint8int
u16intUint16int
u64intUint64int
f32doubleFloatdouble
boolboolInt32int
stringStringPointer<Utf8>Pointer<Utf8>
bytesList<int>Pointer<Uint8>Pointer<Uint8>
handleintInt64int
StructNameStructNamePointer<Void>Pointer<Void>
EnumName (plain)EnumNameInt32int
EnumName (rich)EnumNamePointer<Void>Pointer<Void>
T?T?same as inner typesame as inner type
[T]List<T>Pointer<Void>Pointer<Void>
{K: V}Map<K, V>Pointer<Void>Pointer<Void>
iter<T>Iterable<T>Pointer<Void>Pointer<Void>

Booleans cross as Int32 (0/1) and the wrapper converts both ways.

Example IDL → generated code

version: "0.4.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 err = calloc<_WeaveFFIError>();
    try {
      final result = _weaveffiContactsContactGetName(_handle, err);
      _checkError(err);
      return result.toDartString();
    } finally {
      calloc.free(err);
    }
  }
}

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);
  }
}

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:

  1. Generate the bindings:

    weaveffi generate api.yaml -o generated --target dart
    
  2. Build the Rust shared library:

    cargo build --release -p your_library
    
  3. 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.dll next to the script or add its directory to PATH.

Flutter:

  1. Generate the bindings as above.

  2. Cross-compile the Rust cdylib for every Flutter target you support (aarch64-apple-ios, aarch64-linux-android, x86_64-apple-darwin, etc.).

  3. Reference the generated package from your app’s pubspec.yaml:

    dependencies:
      weaveffi:
        path: ../generated/dart
    
  4. Bundle the cdylib per platform:

    • iOS / macOS: ship a Framework or use a podspec.
    • Android: place .so files under android/src/main/jniLibs/{abi}/.
    • Linux / Windows: place next to the executable or on the library search path.

Memory and ownership

  • Strings: Dart String values are converted with toNativeUtf8(). The wrapper frees the resulting pointer in a finally block. Returned UTF-8 pointers are decoded with toDartString().

  • Structs: wrappers hold a Pointer<Void>. The dispose() method calls the corresponding _destroy C function. Always wrap usage in try/finally:

    final contact = getContact(id);
    try {
      print(contact.name);
    } finally {
      contact.dispose();
    }
    
  • Optionals: T? returns check the native pointer against nullptr before wrapping; absent struct optionals become null.

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 NativeCallable is stored in _listenerCallables keyed by subscription id; that reference keeps the native thunk and the captured closure alive. Unregistering removes the entry and close()s the callable. The C void* context slot 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, while sendMessage runs), 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. An isolateLocal callable 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 the dart:ffi default 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:

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(WeaveFFIException(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.

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, C++, and Kotlin targets surface cancellation tokens.

Iterators

iter<T> returns surface as Iterable<T>. The wrapper launches the iterator, drains it eagerly into a List<T> through the generated _next binding, then destroys the iterator handle:

/// Return an iterator over all sent messages
Iterable<String> getMessages() {
  final err = calloc<_WeaveFFIError>();
  try {
    final iter = _weaveffiEventsGetMessages(err);
    _checkError(err);
    final items = <String>[];
    final outItem = calloc<Pointer<Utf8>>();
    while (_weaveffiEventsGetMessagesIteratorNext(iter, outItem, err) != 0) {
      _checkError(err);
      items.add(outItem.value.toDartString());
    }
    _checkError(err);
    calloc.free(outItem);
    _weaveffiEventsGetMessagesIteratorDestroy(iter);
    return items;
  } finally {
    calloc.free(err);
  }
}

Troubleshooting

  • Invalid argument(s): Failed to load dynamic library: the cdylib is not on the search path. Set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH or copy the library next to your executable.
  • UnsupportedError: Unsupported platform: the loader maps to darwin, linux, and windows. Other platforms (Android, iOS) use the Flutter integration where the framework opens the library.
  • MissingPluginException in Flutter: that error is unrelated to WeaveFFI; double-check that you depend on the generated package and haven’t shadowed it with a different weaveffi dependency.
  • 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 return (value, error) to match Go conventions; struct wrappers expose methods plus an explicit Close().

What gets generated

FilePurpose
go/weaveffi.goCGo bindings: preamble, type wrappers, function wrappers
go/go.modGo module descriptor (configurable module path)
go/README.mdPrerequisites and build instructions

Type mapping

IDL typeGo typeC type (CGo)
i32int32C.int32_t
u32uint32C.uint32_t
i64int64C.int64_t
f64float64C.double
i8int8C.int8_t
i16int16C.int16_t
u8uint8C.uint8_t
u16uint16C.uint16_t
u64uint64C.uint64_t
f32float32C.float
boolboolC._Bool
stringstring*C.char (via C.CString/C.GoString)
bytes[]byte*C.uint8_t + C.size_t
handleint64C.weaveffi_handle_t
Struct*StructName*C.weaveffi_mod_Struct
Enum (plain)EnumNameC.weaveffi_mod_Enum
Enum (rich)*EnumName*C.weaveffi_mod_Enum
T?*Tpointer to scalar; nil-able pointer for strings/structs
[T][]Tpointer + C.size_t
{K: V}map[K]Vkey/value arrays + C.size_t
iter<T>[]T (drained eagerly)opaque iterator pointer + _next/_destroy

Booleans map to C._Bool, matching CGo’s representation of _Bool.

Example IDL → generated code

version: "0.4.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 {
	return C.GoString(C.weaveffi_contacts_Contact_get_first_name(s.ptr))
}

func (s *Contact) Email() *string {
	cStr := C.weaveffi_contacts_Contact_get_email(s.ptr)
	if cStr == nil { return nil }
	v := C.GoString(cStr)
	return &v
}

func (s *Contact) Close() {
	if s.ptr != nil {
		C.weaveffi_contacts_Contact_destroy(s.ptr)
		s.ptr = nil
	}
}

Functions return (value, error):

func ContactsCreateContact(firstName string, email *string, contactType ContactType) (int64, error) {
	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)
	if cErr.code != 0 {
		goErr := fmt.Errorf("weaveffi: %s (code %d)",
			C.GoString(cErr.message), int(cErr.code))
		C.weaveffi_error_clear(&cErr)
		return 0, goErr
	}
	return int64(result), nil
}

Lists round-trip through unsafe.Slice:

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) }

The Go module path defaults to weaveffi; override it via the generator config:

version: "0.4.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"

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 {
		goErr := fmt.Errorf("weaveffi: %s (code %d)", C.GoString(cErr.message), int(cErr.code))
		C.weaveffi_error_clear(&cErr)
		return nil, goErr
	}
	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 {
		goErr := fmt.Errorf("weaveffi: %s (code %d)", C.GoString(cErr.message), int(cErr.code))
		C.weaveffi_error_clear(&cErr)
		return nil, goErr
	}
	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 (ShapesDescribe(*Shape), ShapesScale(*Shape, float64)):

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, err := ShapesScale(c, 3.0) // returns a new *Shape
if err != nil {
	return err
}
defer bigger.Close()
desc, err := ShapesDescribe(bigger)
if err != nil {
	return err
}
fmt.Println(desc)

Ownership: a *Shape owns its native pointer. Go has no deterministic destructors, so pair every constructor (and every *Shape returned by ShapesScale) with defer s.Close().

Build instructions

  1. Generate the bindings:

    weaveffi generate api.yaml -o generated --target go
    
  2. Build the Rust shared library:

    cargo build --release -p your_library
    
  3. Point CGo at the header and library:

    export CGO_CFLAGS="-I$PWD/generated/c"
    export CGO_LDFLAGS="-L$PWD/target/release -lweaveffi"
    
  4. 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.CString allocates a copy in C memory; the generated wrapper pairs every CString with a defer C.free(...).
  • Strings out: C.GoString copies the C string into Go-owned memory, then the wrapper calls weaveffi_free_string to 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.GoBytes and then weaveffi_free_bytes is called.
  • Structs: 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.

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 EventsUnregisterMessageListener.
func EventsRegisterMessageListener(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 EventsUnregisterMessageListener(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 uint64 id; pair every register with exactly one unregister. Unregistering tears down the native subscription, then uses wvListenerCtx (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 EventsSendMessage). 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. The generated Go wrapper turns that into a plain blocking call: 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:

// Blocks until the async producer completes.
func TasksRunTask(name string) (*TaskResult, error) {
	ch := make(chan wvOutcomeTasksRunTask, 1)
	ctxID := wvCallbackStore(ch)
	cName := C.CString(name)
	defer C.free(unsafe.Pointer(cName))
	C.weaveffi_tasks_run_task_async(cName,
		C.weaveffi_tasks_run_task_callback(unsafe.Pointer(C.goWv_weaveffi_tasks_run_task_callback)),
		unsafe.Pointer(uintptr(ctxID)))
	outcome := <-ch
	if outcome.err != nil {
		return nil, outcome.err
	}
	return outcome.val, nil
}

The completion trampoline removes the channel from the registry with wvCallbackTake (one-shot), converts the C error or result, and sends a single wvOutcome… value. 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, C++, and Kotlin targets surface cancellation tokens.

Iterators

iter<T> returns map to plain []T. The wrapper obtains the opaque iterator pointer, drains it eagerly through the generated _next symbol, and destroys it before returning:

func EventsGetMessages() ([]string, error) {
	var cErr C.weaveffi_error
	it := C.weaveffi_events_get_messages(&cErr)
	// ... error check ...
	defer C.weaveffi_events_GetMessagesIterator_destroy(it)
	goResult := []string{}
	for {
		var outItem *C.char
		var iterErr C.weaveffi_error
		if C.weaveffi_events_GetMessagesIterator_next(it, &outItem, &iterErr) == 0 {
			break
		}
		// ... error check ...
		goResult = append(goResult, C.GoString(outItem))
		C.weaveffi_free_string(outItem)
	}
	return goResult, nil
}

Each yielded element is copied into Go memory and its Rust allocation released (strings via weaveffi_free_string); the iterator handle is destroyed by the deferred _destroy call.

Troubleshooting

  • undefined reference to weaveffi_*: CGO_LDFLAGS is missing the -l flag or -L directory. Recheck the environment exports.
  • could not determine kind of name in CGo: ensure CGO_CFLAGS points at the directory containing weaveffi.h.
  • Crashes after struct goes out of scope: Go doesn’t call Close() for you. Either defer s.Close() or wrap usage in a helper that takes a closure.
  • go: cannot find module providing package weaveffi: change the generator config so go.mod declares 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

FilePurpose
ruby/lib/weaveffi.rbFFI bindings: library loader, attach_function declarations, wrapper classes
ruby/weaveffi.gemspecGem specification with ffi ~> 1.15 dependency
ruby/README.mdPrerequisites 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 typeRuby typeFFI type
i32Integer:int32
u32Integer:uint32
i64Integer:int64
f64Float:double
i8Integer:int8
i16Integer:int16
u8Integer:uint8
u16Integer:uint16
u64Integer:uint64
f32Float:float
booltrue/false:int32 (0/1 conversion)
stringString:string (param) / :pointer (return)
bytesString (binary):pointer + :size_t
handleInteger:uint64
StructStructName: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}Hashkey/value pointer arrays + :size_t
iter<T>Array:pointer iterator handle

Booleans cross as :int32 (0/1); the wrapper converts both directions.

Example IDL → generated code

version: "0.4.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

  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

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 class methods on the module and raise on failure:

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

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

  1. Generate the bindings:

    weaveffi generate api.yaml -o generated --target ruby
    
  2. Build the Rust shared library:

    cargo build --release -p your_library
    
  3. Build and install the gem:

    cd generated/ruby
    gem build weaveffi.gemspec
    gem install weaveffi-0.1.0.gem
    
  4. 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.dll next to the script or add its directory to PATH.

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 :string parameters and the FFI gem encodes them to null-terminated C strings.
  • Strings out: the wrapper reads the returned :pointer with read_string, then calls weaveffi_free_string to release the Rust-owned buffer.
  • Bytes: an FFI::MemoryPointer is allocated for inputs; outputs are read with read_string(len) and the Rust side is responsible for the buffer it returned.
  • Structs: wrappers hold an FFI::AutoPointer whose release callback invokes the C _destroy function on GC. Use the explicit destroy method for deterministic cleanup.
  • Maps: keys and values are marshalled into parallel FFI::MemoryPointer buffers; the wrapper rebuilds a Ruby Hash from the returned arrays.

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:

# 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 << Error.new(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.

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, C++, and Kotlin 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::Function trampoline 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 uint64 id produced by weaveffi_events_register_message_listener(fn, context); pass it to unregister_message_listener to 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 Queue works 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> receive an opaque iterator handle from the C ABI. The wrapper drains it eagerly with the generated _next binding, frees each returned string, destroys the handle, and returns a fully materialised Array; there’s no lazy Enumerator:

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

def self.get_messages()
  err = ErrorStruct.new
  iter = weaveffi_events_get_messages(err)
  check_error!(err)
  items = []
  return items if 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)
    # ... destroy the iterator and check_error! if item_err is set ...
    break if has_item.zero?
    item_ptr = out_item.read_pointer
    # ... empty string for NULL ...
    items << item_ptr.read_string
    weaveffi_free_string(item_ptr)
  end
  weaveffi_events_GetMessagesIterator_destroy(iter)
  items
end

If _next reports an error the wrapper destroys the handle first and then raises WeaveFFI::Error via check_error!; on success the handle is destroyed before the array is returned.

Troubleshooting

  • LoadError: Could not open library 'libweaveffi.dylib': the cdylib is not on the loader path. Set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH or 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_refs and keep async completion callbacks referenced until they fire. If you call the attach_function bindings directly, keep your own FFI::Function objects 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 use read_bytes(length) with the out_len returned 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)

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 # Safety sections. These are the Rust analog of “what can go wrong,” and the matching Clippy lints require them.
  • Link other items with intra-doc links: [`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.toml instead.
  • Comments explain why, not what.

Grammar and punctuation

Prose in doc comments and Markdown follows the Chicago Manual of Style (17th edition), matching the repository’s AGENTS.md. Highlights:

  • No em dashes (U+2014). Use commas, parentheses, semicolons, colons, or separate sentences instead.
  • Use straight ASCII quotes and apostrophes (" and '), not curly ones, so prose stays copy-pasteable into source and terminals.
  • Use the serial (Oxford) comma in lists of three or more.
  • Use contractions where they read naturally (“doesn’t,” “isn’t”).
  • Use sentence case for headings: capitalize only the first word and proper nouns.

Doc comments

WeaveFFI follows the conventions in RFC 1574 and the rustdoc book. The standard section headings (# Examples, # Errors, # Panics, # Safety) play the role that Args, Returns, and Raises play in a Google-style docstring.

Functions and methods

#![allow(unused)]
fn main() {
/// Generate bindings for every requested target and write them to `out_dir`.
///
/// Targets are rendered from a shared [`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 # Errors section to every public function that returns Result, describing the conditions that produce an Err. Clippy’s missing_errors_doc enforces this.

  • Add a # Panics section to any public function that can panic, describing when. Clippy’s missing_panics_doc enforces this. If a panic path is provably unreachable (for example an expect on sanitized input), suppress it locally with a reason instead of documenting a panic that cannot happen:

    #![allow(unused)]
    fn main() {
    // `CString::new` is infallible here: interior NUL bytes are stripped above.
    #[allow(clippy::missing_panics_doc)]
    pub fn string_to_c_ptr(s: impl AsRef<str>) -> *const c_char {
        // ...
    }
    }
  • Prefer ```no_run or ```ignore for examples that need a built cdylib, a file path, or other state the doctest can’t set up. Use a plain ```rust block (which cargo test compiles and runs) when the snippet is self-contained.

unsafe functions

Every public unsafe fn, and any function that dereferences raw pointers across the C ABI, needs a # Safety section spelling out the caller’s obligations. Clippy’s missing_safety_doc enforces this.

#![allow(unused)]
fn main() {
/// Register a handle and its destructor with the given arena.
///
/// # Safety
///
/// `arena` must be a valid pointer returned by `arena_create`. `ptr` and
/// `dtor` must stay valid until `arena_destroy` is called.
pub 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.

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:

LintWhat it requires
missing_docs (deny)A doc comment on every public item, field, and variant
clippy::missing_errors_docA # Errors section on public fns returning Result
clippy::missing_panics_docA # Panics section on public fns that can panic
clippy::missing_safety_docA # Safety section on public unsafe fns (on by default)
clippy::doc_markdownBackticks around code-like identifiers

CI runs these through the existing gates, so missing or malformed docs fail the build. Check your changes locally before pushing:

# Lint everything, including the doc lints (warnings are denied).
cargo clippy --workspace --all-targets -- -D warnings

# Build the API docs the way the rustdoc job does.
RUSTDOCFLAGS="-D 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.

  • 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: customise per-target names and the C ABI prefix via weaveffi.toml or inline generators: blocks.

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.

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(
    (const uint8_t*)"hello", 5, &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);

Struct lifecycle

Structs are opaque on the consumer side. The lifecycle is:

  1. *_create allocates and returns a pointer; the consumer owns it.
  2. *_destroy frees the struct. Call exactly once.
  3. *_get_<field> getters read fields. Primitive getters (i32, f64, bool) return values directly. String/bytes getters return new owned copies that must be freed.

Functions that take a handle<T> parameter always borrow it: the producer must never free a handle it receives, even for close-style functions. The only function that frees a handle is its *_destroy symbol. Generated wrappers call *_destroy automatically (Swift deinit, Python __del__, Ruby FFI::AutoPointer, …), so a producer that frees a handle 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(
    (const uint8_t*)"Alice", 5,
    (const uint8_t*)"alice@example.com", 17,
    30,
    &err);
if (err.code) {
    weaveffi_error_clear(&err);
    return 1;
}

int32_t age = weaveffi_contacts_Contact_get_age(contact);
const char* name = weaveffi_contacts_Contact_get_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 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)
    }
}

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 convert non-zero codes into language-native exceptions (throw, raise, Result::Err).

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 = try? Calculator.add(a: 1, b: 2)
}

Reference

ResourceAllocatorFree functionNotes
Returned stringRustweaveffi_free_stringEvery const char* return
Returned bytesRustweaveffi_free_bytesPass both pointer and length
Struct instanceRust*_destroyCall exactly once
String from getterRustweaveffi_free_stringGetter returns an owned copy
Error messageRustweaveffi_error_clearClears code and frees message

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_string twice or invoking _destroy after the wrapper has already done so).
  • Wrong length to weaveffi_free_bytes: always free with the exact length the C ABI returned in out_len.
  • Forgetting to clear error structs: err.message is Rust-allocated; failing to call weaveffi_error_clear after 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 to weaveffi_free_*.

Error Handling

Overview

WeaveFFI uses a uniform error model across the FFI boundary. Every generated function carries an out-error parameter (weaveffi_error*) that reports success or failure through an integer code and an optional message string. Each generator maps that to its target’s idiomatic error mechanism (exceptions, throws, Result, etc.) so consumers rarely touch the C-level struct directly.

When to use

Reach for this guide when:

  • You are designing an IDL and want to surface stable, named error codes to consumers.
  • 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

Define an error domain in the IDL

version: "0.4.0"
modules:
  - name: contacts
    errors:
      name: ContactErrors
      codes:
        - name: not_found
          code: 1
          message: "Contact not found"
        - name: duplicate
          code: 2
          message: "Contact already exists"
        - name: invalid_email
          code: 3
          message: "Email address is invalid"

    functions:
      - name: get_contact
        params:
          - { name: id, type: handle }
        return: string

The validator enforces:

  • code = 0 is reserved for success; non-zero is required.
  • All names within a domain are unique.
  • All numeric codes within a domain are unique.
  • The domain name must not collide with any function name in the module.
  • The domain name must not be empty.

Set errors from the Rust implementation

#![allow(unused)]
fn main() {
use weaveffi_abi::{self as abi, weaveffi_error};

#[no_mangle]
pub extern "C" fn weaveffi_contacts_get_contact(
    id: u64,
    out_err: *mut weaveffi_error,
) -> *const std::ffi::c_char {
    abi::error_set_ok(out_err);
    abi::error_set(out_err, 1, "Contact not found");
    std::ptr::null()
}
}
HelperEffect
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> (Ok clears, Err sets -1)

Prefer the codes you defined in the IDL (e.g. not_found = 1) so consumers can react meaningfully.

Handle errors in C

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:

  1. Zero-initialise: weaveffi_error err = {0, NULL};.
  2. Call the function with &err as the last argument.
  3. Check err.code; if non-zero, read err.message and call weaveffi_error_clear(&err).
  4. Reuse the struct for subsequent calls.

Handle errors in Swift

do {
    let contact = try Contacts.getContact(id: handle)
    print(contact)
} catch let e as WeaveFFIError {
    print("Failed: \(e)")
}

The generated wrapper calls try check(&err) after every C call, which throws WeaveFFIError and clears the C-side struct.

Handle errors in Kotlin / Android

try {
    val contact = Contacts.getContact(id)
    println(contact)
} catch (e: WeaveFFIException.NotFound) {
    println("No such contact")
} catch (e: WeaveFFIException) {
    println("Failed: ${e.message}")
}

The JNI shim maps each declared error code to a WeaveFFIException subclass (e.g. WeaveFFIException.NotFound), throws it with the message, and clears the C-side struct before returning. Codes outside the declared domain fall back to the base WeaveFFIException.

Handle errors in Node.js

import { Contacts } from "weaveffi";

try {
    const contact = Contacts.getContact(id);
    console.log(contact);
} catch (e) {
    console.error("Failed:", (e as Error).message);
}

The N-API addon throws a JavaScript Error carrying the message.

Handle errors in WASM

The minimal WASM target uses numeric return codes. Inspect the return value after each call:

const result = instance.exports.weaveffi_contacts_get_contact(id);
if (result === 0) {
    console.error("call failed: inspect log");
}

The WASM error surface is still evolving. Future versions will surface richer error information.

Reference

LayerError mechanismHow a non-zero code surfaces
C ABIweaveffi_error { code, message }Consumer inspects struct after every call
SwiftWeaveFFIError enum (throws)try raises a Swift Error; per-code cases
C++WeaveFFIError + per-code subclassestry/catch (const WeaveFFIError&)
KotlinWeaveFFIException + per-code subclassestry/catch (rethrown by the JNI shim)
Node.jsJavaScript ErrorN-API addon throws
PythonWeaveFFIError exceptiontry/except
RubyWeaveFFI::Error (StandardError)begin/rescue
DartWeaveFFIExceptiontry/on WeaveFFIException catch
.NETWeaveFFIExceptiontry/catch
Goerror return valueStandard if err != nil { ... }
WASMJavaScript ErrorCaller wraps calls in try/catch

All targets share the canonical WeaveFFI brand (never the heck-derived Weaveffi). 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 (KEY_NOT_FOUNDKeyNotFound/KeyNotFoundError), never raw SCREAMING_SNAKE.

FieldTypeDescription
codeint32_t0 = success, non-zero = error
messageconst 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.message after clearing: the pointer is invalid as soon as weaveffi_error_clear returns.
  • Using code = 0 as a domain value: the validator rejects this because 0 always means success.
  • Reusing custom codes across modules and assuming they are unique: error domains are scoped to a single module. Document cross-module conventions if you need them.
  • Not initialising the struct: always start with {0, NULL} (or the language equivalent). Stale code values 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 typically NULL; 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 throws in Swift, suspend fun in Kotlin, Promise<T> in JS, async def in Python, Task<T> in .NET, and so on.

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.4.0"
modules:
  - name: net
    functions:
      - name: fetch_data
        params:
          - { name: url, type: string }
        return: string
        async: 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"
FieldTypeDefaultDescription
asyncboolfalseMark the function as asynchronous
cancellableboolfalseAllow the async operation to be cancelled

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. The pattern from samples/async-demo/src/lib.rs:

#![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 = std::ffi::CString::new(format!("payload from {url_str}"))
            .unwrap()
            .into_raw();
        callback(ctx as *mut c_void, std::ptr::null_mut(), payload);
    });
}
}

The async launcher symbol always carries the _async suffix (weaveffi_net_fetch_data_async), keeping the name free for a possible synchronous variant.

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');

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, C++, and Kotlin surfaces expose the token (C++ as a trailing cancel_token = nullptr parameter, Kotlin as a cancelToken: Long); 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);

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);

Per-target async surface

TargetAsync surfaceCancel token exposure (cancellable: true)
CRaw callback + _async launcherweaveffi_cancel_token* slot before the callback
C++std::future<T>trailing cancel_token = nullptr parameter
Swiftasync throwsnot exposed; wrapper passes nil
Kotlinsuspend funcancelToken: Long parameter (raw token pointer)
Node.jsPromise<T> (thread-safe function settling)not exposed; wrapper passes NULL
Pythonasync def (executor thread + event)not exposed; wrapper passes None
.NETTask<T>not exposed; wrapper passes IntPtr.Zero
DartFuture<T> (NativeCallable.listener)not exposed; wrapper passes nullptr
WASMPromise<T> (table trampolines)not exposed; wrapper passes 0
Goblocking bridge (chan receive); call from a goroutinenot exposed; wrapper passes nil
Rubyblocking bridge (Queue#pop); call from a Threadnot 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 verified by a {generator}_async_pins_callback_for_lifetime unit test plus the 1000-call stress test under examples/{target}/async_stress.{ext}.

TargetPin (allocate / retain)Unpin (free / release) on callbackNotes
SwiftUnmanaged.passRetained(ContinuationRef(...))Unmanaged.fromOpaque(ctx).takeRetainedValue()The retained +1 is dropped exactly once when the continuation resumes.
.NETGCHandle.Alloc(callback, GCHandleType.Normal)GCHandle.FromIntPtr(context).Free()The catch path also frees the handle on synchronous failure.
KotlinJNI (*env)->NewGlobalRef(env, callback)(*env)->DeleteGlobalRef(env, ctx->callback)The JNI shim mallocs and frees the per-call context exactly once.
Node.jsnapi_create_promise(env, &deferred, &promise)napi_resolve_deferred or napi_reject_deferredThe N-API runtime owns the deferred; the per-call context is malloc-ed and freed exactly once.
Python_cb = ctypes.CFUNCTYPE(...)(impl) (kept by helper)_ev.set() in the callback’s finally releases the helper’s _ev.wait()The helper blocks on the event so _cb (and its trampoline) outlive the callback.
C++new std::promise<T>() plus the lambda capturedelete p; once at the end of the lambdaThe lambda owns the heap promise on every exit branch.
DartNativeCallable<...>.listener(...)callable.close() in finally and on the catch pathPointer-typed parameters are kept alive in whenComplete.
WASM_registerTrampoline per signature plus _asyncContexts.set(ctxId, ...) per call_asyncContexts.delete(ctxId) in the trampolinePer-call resolver closures are removed after resolve/reject.
GowvCallbackStore(ch) registers the channel in a global registry keyed by an integer idwvCallbackTake(id) removes it when the exported trampoline firesThe context crossing C is an integer id, never a Go pointer (cgo rule); the channel is buffered so the producer thread never blocks.
Rubythe FFI::Function trampoline is a local kept alive by the enclosing method scopethe blocking queue.pop returns only after the callback ranThe 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:

  1. The void* context has exactly one owner at any moment.
  2. 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.
  3. Synchronous failure of the C call (the callback never fires) is handled in a catch / try that frees the pin so it does not leak.
  4. The stress test asserts weaveffi_tasks_active_callbacks() returns to zero after 1000 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 Thread when 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 null instead of invoking the callback: the contract is that the callback fires exactly once for every async call, including on cancellation.

Annotated Rust Extraction

Overview

Instead of hand-writing an IDL, you can annotate your Rust source with WeaveFFI marker attributes and let weaveffi extract produce the IDL for you. The result keeps the IDL co-located with the implementation and eliminates drift between the two: change the Rust signatures and re-run extract.

When to use

Reach for weaveffi extract when:

  • The Rust implementation already exists and you want a starting IDL.
  • The IDL changes whenever signatures change, and you want a single source of truth.
  • You are scaffolding a new module and would rather decorate Rust than write YAML by hand.

Skip extraction when:

  • You want to design the API before any Rust exists: author the IDL directly.
  • You need iterator return types (iter<T>), error domains, struct field defaults, or since: without an accompanying #[deprecated] attribute. See Pitfalls.

Step-by-step

1. Annotate the Rust source

WeaveFFI recognises a small family of marker attributes by name only; there is no proc-macro crate. Define them as no-op attribute macros, or add #![allow(unused_attributes)] and ignore the warning.

#![allow(unused)]
#![allow(unused_attributes)]

fn main() {
mod inventory {
    /// A product in the catalog.
    #[weaveffi_struct]
    #[weaveffi_builder]
    struct Product {
        /// Stable identifier.
        id: i32,
        name: String,
        price: f64,
        tags: Vec<String>,
    }

    /// Product availability.
    #[weaveffi_enum]
    #[repr(i32)]
    enum Availability {
        InStock = 0,
        OutOfStock = 1,
        Preorder = 2,
    }

    /// Fired when a product is ready for pickup.
    #[weaveffi_callback]
    fn OnReady(product_id: i32) {}

    /// Subscribe to OnReady events.
    #[weaveffi_listener(event_callback = "OnReady")]
    fn ready_listener() {}

    /// Look up a product by ID.
    #[weaveffi_export]
    fn get_product(id: i32) -> Option<Product> {
        todo!()
    }

    /// Append to a search index.
    #[weaveffi_export]
    fn index(buf: &mut SearchIndex, query: &str) {
        todo!()
    }

    /// Open a long-lived session handle.
    #[weaveffi_export]
    fn open_session() -> *mut Session {
        todo!()
    }

    /// Replaced by `search_v2` in 0.3.0.
    #[weaveffi_export]
    #[deprecated(since = "0.2.0", note = "use search_v2 instead")]
    fn search(query: String, limit: i32) -> Vec<Product> {
        todo!()
    }

    /// Long-running fetch.
    #[weaveffi_export]
    #[weaveffi_async]
    #[weaveffi_cancellable]
    fn refresh_catalog() -> i32 {
        todo!()
    }

    mod nested {
        /// Lives inside `inventory::nested`.
        #[weaveffi_export]
        fn helper() -> i32 {
            0
        }
    }
}
}

2. Run weaveffi extract

weaveffi extract src/api.rs                   # YAML to stdout
weaveffi extract src/api.rs -o api.yml         # YAML to file
weaveffi extract src/api.rs -f json -o api.json  # JSON to file
weaveffi extract src/api.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. This prevents extract from emitting a silently-broken IDL. 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/api.rs --warn          # best-effort, errors as warnings

3. Validate and generate

weaveffi validate api.yml
weaveffi generate api.yml -o generated/

Reference

CLI command

weaveffi extract <INPUT> [--output <PATH>] [--format <FORMAT>] [--warn]
FlagDefaultDescription
<INPUT>requiredPath to a .rs source file
-o, --outputstdoutWrite to a file instead of stdout
-f, --formatyamlOutput format: yaml, json, or toml
--warnoffDowngrade validation errors to warnings and emit the IDL anyway

Attribute reference

The extractor matches attributes by their final ident. Path-style attributes are not currently recognised; use the underscore form (e.g. #[weaveffi_export], not #[weaveffi::export]).

AttributeWhere it goesEffect
#[weaveffi_export]free fnEmits a Function in the enclosing module.
#[weaveffi_struct]named-field structEmits a StructDef.
#[weaveffi_builder]struct (with weaveffi_struct)Sets builder: true on the emitted struct.
#[weaveffi_enum] + #[repr(i32)]enumEmits an EnumDef. Every variant must have an explicit = N discriminant.
#[weaveffi_async]exported fnSets async: true. The Rust async fn keyword has the same effect.
#[weaveffi_cancellable]exported fnSets cancellable: true (typically combined with #[weaveffi_async]).
#[weaveffi_callback]free fnEmits a module-level CallbackDef using the function’s name and parameters.
#[weaveffi_listener(event_callback = "Name")]free fnEmits a ListenerDef referencing the named callback.
#[deprecated(since = "...", note = "...")]exported fnPopulates since and deprecated. Bare #[deprecated] sets deprecated = "deprecated".

Doc comments (///) on items, fields, and enum variants become the doc field in the IR.

Type mapping

Rust typeWeaveFFI TypeRefIDL string
i8I8i8
i16I16i16
i32I32i32
i64I64i64
u8U8u8
u16U16u16
u32U32u32
f32F32f32
f64F64f64
boolBoolbool
StringStringUtf8string
Vec<u8>Bytesbytes
u64Handlehandle
&strBorrowedStr&str
&[u8]BorrowedBytes&[u8]
*mut T / *const TTypedHandle("T")handle<T>
Vec<T>List(T)[T]
Option<T>Optional(T)T?
HashMap<K, V>Map(K, V){K:V}
BTreeMap<K, V>Map(K, V){K:V}
&T (other)inner typeT
&mut T (other)inner type, mutableT
Any other identifierStruct(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, 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.

  • Iterator return types (iter<T>). No equivalent Rust syntax; add the iter<T> return manually after extraction.
  • Error domains (module.errors). The extractor never emits errors: blocks; add them by hand.
  • 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]. since is only recovered when paired with #[deprecated(since = "...")]. To set since on a non-deprecated function, edit the YAML manually.
  • Doc comments on parameters. Rust accepts /// on fn parameters but most formatters strip them; when present, the extractor preserves them, but plan for Param.doc to be lossy.
  • Generics, trait impl blocks, and macros. The extractor never resolves generics, walks impl blocks, or expands macros. 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_struct].
  • Enums must use #[repr(i32)] with explicit discriminants. Rust-style enums with payloads cannot be extracted.

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 = true

Every section and key is optional; omit anything you want defaulted. The [global] table accepts the alias [weaveffi].

2. Embed generators: in the IDL

version: "0.4.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: true
    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"

When you set [c] prefix = ... and do not explicitly set [cpp] c_prefix = ..., the CLI copies the C prefix into the C++ wrapper config automatically so the C++ header keeps calling the same symbols the C ABI exports.

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

SectionKeyTypeDefaultDescription
[swift]module_namestring"WeaveFFI"Swift module name in Package.swift and the Sources/ directory
[swift]strip_module_prefixboolfalseStrip the IR module prefix from emitted Swift symbols
[android]packagestring"com.weaveffi"Java/Kotlin package declaration in the JNI wrapper
[android]strip_module_prefixboolfalseStrip the IR module prefix from emitted Java/Kotlin symbols
[node]package_namestring"weaveffi"npm package name in the Node.js loader
[node]strip_module_prefixboolfalseStrip the IR module prefix from emitted JS/TS symbols
[wasm]module_namestring"weaveffi_wasm"Module name in the WASM JS loader
[wasm]allow_unsupportedboolfalseGenerate anyway when the IDL uses features WASM cannot deliver (callbacks, listeners); unsupported entry points become explicit throwing stubs
[c]prefixstring"weaveffi"Prefix prepended to every C ABI symbol ({prefix}_{module}_{function})
[cpp]namespacestring"weaveffi"C++ namespace for the wrapper
[cpp]header_namestring"weaveffi.hpp"Header file name for the C++ output
[cpp]standardstring"17"C++ standard for the generated CMakeLists.txt
[cpp]c_prefixstringinherits [c]C ABI prefix that the C++ wrappers call into
[python]package_namestring"weaveffi"Python package name
[python]strip_module_prefixboolfalseStrip the IR module prefix from emitted Python symbols
[dotnet]namespacestring"WeaveFFI".NET namespace
[dotnet]strip_module_prefixboolfalseStrip the IR module prefix from emitted C# symbols
[dart]package_namestring"weaveffi"Dart package name in pubspec.yaml
[go]module_pathstring"weaveffi"Go module path in go.mod
[ruby]module_namestring"WeaveFFI"Ruby module that wraps the bindings
[ruby]gem_namestring"weaveffi"Ruby gem name

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 the package: 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 (Android rootProject.name, the WASM package.json, and the C++ CMakeLists.txt version) follow the same identity, and the published version comes from package.version (default 0.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

KeyTypeDefaultDescription
strip_module_prefixboolfalseShorthand: enable strip_module_prefix on every target that supports it
pre_generatestringnoneShell command run before any generator starts
post_generatestringnoneShell 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 --force to invalidate every entry.

  • weaveffi diff --check exit codes:

    CodeMeaning
    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 json emits 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 json returns the warning list with stable code / location / message fields:

    {
      "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.
  • [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. The C++ wrapper picks it up automatically; if you set both [c] prefix and [cpp] c_prefix make sure they agree.
  • strip_module_prefix = true flattens names: collisions across modules become possible. Pick one or the other consistently.
  • Hooks run shell commands as-is: pre_generate and post_generate are passed straight to sh -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.

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, and run the C/Node/Swift consumers from the in-tree sample.
  • Swift iOS: Rust → SwiftPM → Xcode iOS app.
  • Android: Rust → AAR → Android Studio app on emulator/device.
  • Python: Rust → ctypes package → pip install and python demo.py.
  • Node.js: Rust → N-API addon → npm publish shape.

Calculator end-to-end

Goal

Take the in-tree samples/calculator IDL, 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.

Prerequisites

  • Rust toolchain (stable channel) with cargo on PATH.
  • The WeaveFFI CLI (cargo install weaveffi-cli or cargo 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

weaveffi generate samples/calculator/calculator.yml -o generated

The output appears under generated/:

  • generated/c: C header and convenience C file
  • generated/swift: SwiftPM System Library (CWeaveFFI) and Swift wrapper (WeaveFFI)
  • generated/android: Kotlin wrapper, JNI shims, and Gradle skeleton
  • generated/node: N-API loader and .d.ts
  • generated/wasm: minimal WASM loader

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 the C example

macOS:

cd examples/c
cc -I ../../generated/c main.c -L ../../target/debug -lcalculator -o c_example
DYLD_LIBRARY_PATH=../../target/debug ./c_example

Linux:

cd examples/c
cc -I ../../generated/c main.c -L ../../target/debug -lcalculator -o c_example
LD_LIBRARY_PATH=../../target/debug ./c_example

4. Run the Node example

macOS:

cp target/debug/libindex.dylib generated/node/index.node
cd examples/node
DYLD_LIBRARY_PATH=../../target/debug npm start

Linux:

cp target/debug/libindex.so generated/node/index.node
cd examples/node
LD_LIBRARY_PATH=../../target/debug npm start

5. Run the Swift example (macOS / Linux)

cargo build -p calculator
cd examples/swift
swiftc \
  -I ../../generated/swift/Sources/CWeaveFFI \
  -L ../../target/debug -lcalculator \
  -Xlinker -rpath -Xlinker ../../target/debug \
  Sources/App/main.swift -o .build/debug/App
DYLD_LIBRARY_PATH=../../target/debug .build/debug/App

On Linux replace DYLD_LIBRARY_PATH with LD_LIBRARY_PATH.

6. Optional: Android and WASM

  • Open generated/android in Android Studio and build the :weaveffi AAR. Combine with the steps in the Android tutorial.
  • For WASM, run cargo build --target wasm32-unknown-unknown --release and load the .wasm file with generated/wasm/weaveffi_wasm.js.

Verification

You should see the same calculator output from each consumer, e.g. 2 + 3 = 5. Concretely:

  • The C example prints 2 + 3 = 5 (or whatever expression examples/c/main.c exercises) without any weaveffi: error messages.
  • npm start exits with code 0 and prints the calculator results followed by the Done. banner.
  • The Swift binary launches, prints the same arithmetic, and exits cleanly.

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/
cargo clean -p calculator
rm -rf examples/c/c_example examples/swift/.build

The generated/ directory is safe to delete and recreate; nothing else in the repository depends on its contents.

Next steps

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.4.0"
modules:
  - name: greeter
    structs:
      - name: Greeting
        fields:
          - { name: message, type: string }
          - { name: lang, type: string }
    functions:
      - name: hello
        params:
          - { name: name, type: string }
        return: string
      - name: greeting
        params:
          - { name: name, type: string }
          - { name: lang, type: string }
        return: Greeting

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.1" }

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_ptr: *const c_char,
    _name_len: usize,
    out_err: *mut weaveffi_error,
) -> *const c_char {
    abi::error_set_ok(out_err);
    let name = unsafe { CStr::from_ptr(name_ptr) }.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

  1. Create a new iOS App in Xcode (SwiftUI or UIKit).
  2. Drag MyGreeter.xcframework into the project navigator. Confirm it appears under Build Phases > Link Binary With Libraries.
  3. File > Add Package Dependencies > Add Local… and pick generated/swift/. The package contributes the CWeaveFFI and WeaveFFI targets.
  4. Build Settings > Header Search Paths: add the path to generated/c/ (e.g. $(SRCROOT)/../generated/c).
  5. Build Settings > Library Search Paths: add the path to the matching Rust static library ($(SRCROOT)/../target/aarch64-apple-ios/release for device builds).
  6. Build Phases > Dependencies: ensure WeaveFFI is 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") {
                do {
                    greeting = try Greeter.hello("Swift")
                } catch {
                    greeting = "Error: \(error)"
                }
            }
        }
        .padding()
    }
}

The generated WeaveFFI module exposes:

  • Greeter.hello(_:): returns String.
  • Greeter.greeting(_:_:): returns a Greeting instance with .message and .lang properties; deinit calls the Rust destructor automatically.
  • 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-ios to confirm the device path also works.

  • Common error mappings:

    SymptomLikely 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 simulatorBuild for x86_64-apple-ios and combine with lipo.

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

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.4.0"
modules:
  - name: greeter
    structs:
      - name: Greeting
        fields:
          - { name: message, type: string }
          - { name: lang, type: string }
    functions:
      - name: hello
        params:
          - { name: name, type: string }
        return: string
      - name: greeting
        params:
          - { name: name, type: string }
          - { name: lang, type: string }
        return: Greeting

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.1" }

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_ptr: *const c_char,
    _name_len: usize,
    out_err: *mut weaveffi_error,
) -> *const c_char {
    abi::error_set_ok(out_err);
    let name = unsafe { CStr::from_ptr(name_ptr) }.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

  1. Create a new Android project (Empty Activity, Kotlin, minSdk 21+).

  2. Include the generated module in the root settings.gradle:

    include ':weaveffi'
    project(':weaveffi').projectDir = new File('generated/android')
    
  3. Add it as a dependency in your app’s build.gradle:

    dependencies {
        implementation project(':weaveffi')
    }
    
  4. Copy the cdylib into jniLibs per 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
    
  5. Confirm the JNI CMakeLists.txt in generated/android/src/main/cpp/ includes target_include_directories(... PRIVATE ../../../../c) so it can find weaveffi.h.

7. Call from Kotlin

import com.weaveffi.WeaveFFI
import com.weaveffi.Greeting

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")

        Greeting.create("Hi", "en").use { g ->
            println("${g.message} (${g.lang})")
        }
    }
}

The generated WeaveFFI companion object loads the cdylib lazily and exposes:

  • WeaveFFI.hello(name: String): String
  • WeaveFFI.greeting(name: String, lang: String): Long: opaque handle that the Greeting wrapper consumes.

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 show Hi (en) from the Greeting block.

  • Common error mappings:

    SymptomLikely cause
    UnsatisfiedLinkError: dlopen failedThe cdylib is missing from jniLibs/ or built for the wrong ABI.
    RuntimeException from JNIA WeaveFFI error was raised; inspect the message.
    Linker errors during cargo buildANDROID_NDK_HOME is not set or the NDK toolchain is missing from PATH.
    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

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.7 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.4.0"
modules:
  - name: greeter
    structs:
      - name: Greeting
        fields:
          - { name: message, type: string }
          - { name: lang, type: string }
    functions:
      - name: hello
        params:
          - { name: name, type: string }
        return: string
      - name: greeting
        params:
          - { name: name, type: string }
          - { name: lang, type: string }
        return: Greeting

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
│   └── weaveffi/
│       ├── __init__.py
│       ├── weaveffi.py
│       └── weaveffi.pyi
└── scaffold.rs

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.1" }

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_ptr: *const c_char,
    _name_len: usize,
    out_err: *mut weaveffi_error,
) -> *const c_char {
    abi::error_set_ok(out_err);
    let name = unsafe { CStr::from_ptr(name_ptr) }.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.

4. Build the cdylib

cargo build -p mygreeter --release

Produces:

PlatformOutput
macOStarget/release/libmygreeter.dylib
Linuxtarget/release/libmygreeter.so
Windowstarget/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 generated loader looks for libweaveffi.dylib (macOS), libweaveffi.so (Linux), or weaveffi.dll (Windows). 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. For production, copy the cdylib into the package directory and update weaveffi.py’s loader path.

7. Use the bindings

Save as demo.py:

from weaveffi import hello, greeting, WeaveFFIError

print(hello("Python"))

try:
    g = greeting("Python", "en")
    print(f"{g.message} ({g.lang})")
except WeaveFFIError 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 weaveffi lists the package.

  • Running demo.py prints Hello, Python! and Hi (en) (or whatever Greeting you constructed).

  • mypy demo.py reports no errors thanks to the generated weaveffi.pyi stub.

  • Common error mappings:

    SymptomLikely cause
    OSError: dlopen ... not foundCdylib not on the loader path; set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH.
    WeaveFFIError: ... at runtimeRust returned a non-zero error code; inspect e.code and e.message.
    ModuleNotFoundError: No module named 'weaveffi'Package not installed; rerun pip install . from generated/python/.
    mypy complains about weaveffiMake sure weaveffi.pyi ships next to weaveffi.py in the package.

Cleanup

pip uninstall weaveffi
rm -rf generated/
cargo clean -p mygreeter

Next steps

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-essential on 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.4.0"
modules:
  - name: greeter
    structs:
      - name: Greeting
        fields:
          - { name: message, type: string }
          - { name: lang, type: string }
    functions:
      - name: hello
        params:
          - { name: name, type: string }
        return: string
      - name: greeting
        params:
          - { name: name, type: string }
          - { name: lang, type: string }
        return: Greeting

2. Generate bindings

weaveffi generate greeter.yml -o generated --scaffold

Among other targets you should see:

generated/
├── c/
│   └── weaveffi.h
├── node/
│   ├── index.js
│   ├── types.d.ts
│   └── package.json
└── 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.1" }

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_ptr: *const c_char,
    _name_len: usize,
    out_err: *mut weaveffi_error,
) -> *const c_char {
    abi::error_set_ok(out_err);
    let name = unsafe { CStr::from_ptr(name_ptr) }.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. You also need an N-API addon crate that bridges Node’s runtime to the C ABI. See samples/node-addon in the WeaveFFI repository for a working example to copy.

4. Build the cdylib and the N-API addon

cargo build -p mygreeter --release
cargo build -p node-addon --release

Copy the addon into the generated package as index.node:

macOS:

cp target/release/libindex.dylib generated/node/index.node

Linux:

cp target/release/libindex.so generated/node/index.node

Windows:

copy target\release\index.dll generated\node\index.node

5. Run the bindings locally

Save as generated/node/demo.js:

const weaveffi = require("./index");

const msg = weaveffi.hello("Node");
console.log(msg);

Run it (the cdylib must be on the loader path):

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 weaveffi from "./index";

const msg: string = weaveffi.hello("TypeScript");
const g: weaveffi.Greeting = weaveffi.greeting("TS", "en");
console.log(`${g.message} (${g.lang})`);

6. Prepare for publishing

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.js prints Hello, Node! and exits with code 0.

  • npm pack produces a .tgz containing index.node, types.d.ts, and index.js.

  • TypeScript consumers see the Greeting interface and hello signature without manual type declarations.

  • Common error mappings:

    SymptomLikely cause
    Error: Cannot find module './index.node'The compiled addon is missing; copy the platform-specific binary in.
    Error: dlopen ... not foundCdylib not on the loader path; set DYLD_LIBRARY_PATH / LD_LIBRARY_PATH.
    TypeError: weaveffi.hello is not a functionThe N-API addon did not export the expected symbols; rebuild after IDL edits.
    Crashes on require()Addon built for the wrong Node.js version or architecture; rebuild.

Cleanup

rm -rf generated/
cargo clean -p mygreeter
cargo clean -p node-addon

If you published a test version, mark it as deprecated with npm deprecate @myorg/greeter@0.1.0 "test publish".

Next steps