Skip to content

milyin/prebindgen

Repository files navigation

prebindgen

A tool for separating the implementation of FFI interfaces from language-specific binding generation, allowing each to reside in different crates.

Problem

Making FFI (Foreign Function Interface) for a Rust library is not an easy task. This involves a large amount of boilerplate code that wraps the Rust API in extern "C" functions and #[repr(C)] structures. It's already hard for a single language, but when you need to add more languages, the situation becomes more complex.

The root cause of this complexity in a multi-language scenario is that you must either:

  • Make separate crates for each language's FFI. For example, you have a Rust library "foo" and cdylib/staticlib crates "foo-c", "foo-csharp", "foo-java", etc. Each crate contains its own independent wrappers for "foo". Code duplication is huge.
  • Generate all language-specific libraries from the same source. In this case, you just replace code duplication with code complexity: the single FFI library must conform to all language targets.

A small example: cbindgen supports wrapper types (Option, MaybeUninit) in extern "C" functions, but csbindgen (a binding generator for C#) doesn't understand them. The following FFI function works for C but can't be used for C#:

#[no_mangle]
pub extern "C" fn foo(dst: &mut MaybeUninit<Foo>, src: &Foo) { ...  }

And there are more such quirks, which make it hard to support a common source for multiple languages.

Solution

The proposed solution is to create a common Rust library (e.g., "foo-ffi") that wraps the original "foo" library in FFI-compatible functions, but does not add extern "C" and #[no_mangle] modifiers. Instead, it marks these functions with the #[prebindgen] macro.

#[prebindgen]
pub fn foo(dst: &mut MaybeUninit<Foo>, src: &Foo) { ...  }

The dependent language-specific crates ("foo-c", "foo-cs", etc.) in this case contain only autogenerated code based on these marked functions, with the necessary extern "C" and #[no_mangle] added, stripped-out wrapper types, etc.

Comparison to other solutions

Several tools solve adjacent parts of the Rust FFI problem:

  • Language-specific generators such as cbindgen and csbindgen are mature and integrate well with their target ecosystems. Their drawback in a multi-language project is that each generator expects a slightly different Rust FFI surface, so a single hand-written extern "C" crate either becomes a compromise or has to be duplicated per language.
  • UniFFI is production-ready and widely used, especially for Kotlin, Swift, and Python bindings. It supports both UDL files and proc-macros, generates Rust scaffolding plus foreign bindings, and has a strong object model with records, interfaces, errors, callbacks/foreign traits, and async functions. Its trade-off is that it owns the binding model: APIs must fit UniFFI's supported type/object model, proc-macro binding generation discovers metadata from a compiled library, complex values go through UniFFI's lifting/lowering layer, and it generates UniFFI bindings rather than source tailored for existing tools or C/C++ header generators.
  • Diplomat provides a mature bridge-crate model and generates idiomatic bindings for several languages from one Rust-defined API. Its trade-off is the shared generated C ABI hub: all targets are shaped by the same bridge surface, which is not always ideal when different languages need different ownership, performance, or API projections.
  • BoltFFI is an end-to-end SDK/package generator with strong built-in support for Swift, Kotlin, Java, C#, TypeScript/WASM, async functions, streams, and platform packaging. Its trade-off is that it is a complete toolchain: it owns build, generation, and packaging, while some projects need a smaller layer that composes with existing generators and custom build logic.

What makes prebindgen different:

  • No separate tooling step: the destination crate generates bindings from its build.rs by pulling the marked pieces of the source crate, then includes the generated Rust with include!().
  • Works with existing binding generators: prebindgen is not intended to replace tools such as cbindgen or csbindgen; it prepares Rust source adapted and tweaked for the chosen generator when needed.
  • Per-language ABI freedom: destination crates choose their own adapter and wire representation instead of sharing one generated ABI for every language.
  • Opt-in consumers: each language-specific crate declares exactly which annotated functions and types it exports, so C, JNI, and other targets can expose different subsets of the same source crate.
  • Cross-compilation-aware generation: prebindgen has been proven in cross-compilation scenarios where the generated source depends on the destination platform.
  • Performance-oriented projections: adapters can specialize ownership and value passing for a target language, such as inline C value types or JNI handle/value-class projections, without constraining other backends.

Architecture

Each element to be exported is marked in the source crate with the #[prebindgen] macro. When the source crate is compiled, these elements are written to an output directory. The destination crate's build.rs reads these elements and creates FFI-compatible functions and proxy structures for them. The generated source file is included with the include!() macro in the dependent crate and parsed by the language binding generator (e.g., cbindgen).

It's important to keep in mind that [build-dependencies] and [dependencies] are different. The #[prebindgen] macro collects sources when compiling the [build-dependencies] instance of the source crate. Later, these sources are used to generate proxy calls to the [dependencies] instance, which may be built with a different feature set and for a different architecture. A set of assertions is added to the generated code to catch possible divergences, but it's the developer's job to manually resolve these errors.

Usage

1. In the Common FFI Library Crate (e.g., example-flat)

Mark the types and functions that form your FFI surface with the prebindgen macro and export the prebindgen output directory path. The source crate stays plain idiomatic Rust — opaque handles are ordinary types returned by value, fallible calls return Result<T, E>; the language adapter does the C-ABI lowering, so there is no #[repr(C)] here.

// example-flat/src/lib.rs
use prebindgen_proc_macro::{features, prebindgen, prebindgen_out_dir};

// Path to the prebindgen output directory; the destination crate's `build.rs`
// reads the collected code from this path.
pub const PREBINDGEN_OUT_DIR: &str = prebindgen_out_dir!();

// Features with which the crate is compiled. This constant is used
// in the generated code to validate that it's compatible with the actual crate.
pub const FEATURES: &str = features!();

// An opaque handle — a plain Rust type, returned by value.
pub struct Calculator {
    value: f64,
}

#[prebindgen]
pub fn calculator_new() -> Calculator {
    Calculator { value: 0.0 }
}

#[prebindgen]
pub fn calculator_get_value(c: &Calculator) -> f64 {
    c.value
}

Call init_prebindgen_out_dir() in the source crate's build.rs:

// example-flat/build.rs
fn main() {
    prebindgen::init_prebindgen_out_dir();
}

2. In the Language-Specific FFI Binding Crate (e.g., example-cbindgen)

Add the source FFI library to both dependencies and build-dependencies, and drive the lang::Cbindgen adapter from build.rs:

# example-cbindgen/Cargo.toml
[dependencies]
example-flat = { path = "../example-flat" }
prebindgen = "0.5"
konst = "0.3"      # the generated file emits a konst feature guard

[build-dependencies]
example-flat = { path = "../example-flat" }
prebindgen = "0.5"
cbindgen = "0.29"
syn = { version = "2", features = ["full"] }

Declare which #[prebindgen]-marked items to export and how to name them, then let the adapter resolve types and emit the Rust file of extern "C" wrappers. Items are opt-in — only what you declare is generated.

// example-cbindgen/build.rs
use syn::parse_quote as pq;

fn main() {
    // Read the items captured from the common FFI crate.
    let source = prebindgen::Source::new(example_flat::PREBINDGEN_OUT_DIR);

    // Configure the C adapter: declare the items to export and how to name them.
    let cbindgen = prebindgen::lang::Cbindgen::new()
        .source_module(pq!(example_flat))
        .free_memory_function("example_free")
        .mangle_type_name(|base| format!("{base}_t"))
        .mangle_destructor(|base| format!("{base}_drop"))
        .mangle_function(|n| n.to_string())
        .opaque_ptr(pq!(Calculator))
        .function(pq!(calculator_new))
        .function(pq!(calculator_get_value)).panic();

    // Resolve types and write the Rust file of `extern "C"` wrappers.
    let mut registry =
        prebindgen::core::Registry::from_items(source.items_all()).unwrap();
    let bindings_file = registry.write_rust(&cbindgen, "example_flat.rs").unwrap();

    // Pass the generated file to cbindgen for C header generation.
    generate_c_headers(&bindings_file);
}

Include the generated Rust file in your project to build the static or dynamic FFI-compatible library:

// lib.rs
include!(concat!(env!("OUT_DIR"), "/example_flat.rs"));

Examples

See example projects in the examples directory:

  • example-flat: Common FFI library (plain Rust, #[prebindgen]-annotated) demonstrating prebindgen usage
  • example-cbindgen: Language-specific binding using lang::Cbindgen + cbindgen for C headers
  • perftest-flat / perftest-c / perftest-kotlin: A shared flat library and its performance-oriented C and Kotlin/JNI bindings
  • covertest-kotlin: A Kotlin/JNI binding that exercises every lang::JniGen feature and verifies behavior with check(...) asserts (see its README)

Documentation

About

Separating FFI implementation and language-specific binding to different crates in Rust

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages