Skip to content

holo-q/rustbind

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rustbind

One Rust FFI crate is the ABI truth; bindings are generated, never hand-maintained.

rustbind is a reusable Rust→C# (and, later, Py/TS) FFI binding generator, extracted from the Ratatui.cs codegen pipeline and generalized to bind any Rust crate. Point it at a crate's extern "C" surface and it emits the interop layer (P/Invoke) plus ergonomic wrappers — hand-drift becomes structurally impossible.

Two stages, one IR in the middle:

<any rust ffi crate>  ──manifest-gen (nightly-expand + syn)──► bindings.json (typed IR)
                                                                    │
                                                emitter: gen-cs --config <BindConfig>
                                                                    │
                                                                    ▼
                                            <Consumer>/Interop/Native.cs + wrappers
                                            + parity-report.txt + residue.txt

The IR (bindings.json) is the contract. manifest-gen produces it; the C# emitter consumes it. New language emitters are just new consumers of the same IR — today they live in their own binding repos (e.g. ratatui-py, ratatui-ts ship their own Python/TS emitters); the C# emitter here is the first in-tree one.

Origin

Lifted from repo-com/ratatui where the pipeline was proven: one syn-parsed IR (ratatui-ffi/bindings.json) drove the C#/Py/TS bindings, killing ~220 hand-maintained interop decls per language that drifted independently. The generator turned out to be ~90% generic — the only ratatui-specific surface was config (output namespace, native lib name, env-var names, version-probe fn, search paths) plus a per-binding ergonomic-overrides data table. rustbind lifts that config into a BindConfig and keeps the engine generic.

Components

Component Role
manifest-gen/ (Rust crate, syn) Extract a crate's C-ABI surface → bindings.json (typed IR). Crate-agnostic; the crate root is --ffi-crate <dir>. Standalone cargo crate (publish = false) so syn & friends never enter a shipped cdylib's dep tree.
src/Rustbind/ (C# emitter) bindings.json + BindConfigNative.cs P/Invoke + ergonomic wrappers + parity/residue reports. Emitter/Ir/Naming/TypeMap/Parity/WrapperEmitter are generic; Overrides is a per-binding data table referenced by the config.
BindConfig (BindConfig.cs) The entire per-crate coupling, lifted out of the engine: { namespace, libName, ffiDirEnvVar, ffiPathEnvVar, versionProbeFn, nativeSearchPaths, overridesPath, outPath } + naming lexicons.

Why nightly expansion, not raw lib.rs parsing

A mature FFI crate generates a large fraction of its surface via macro_rules! and splits the rest across a module tree (src/ffi/widgets/*.rs, …) plus include!d files. syn expands none of that — mod foo;, include!(…), and macro invocations are opaque to a raw-source parse, which sees only the literal #[no_mangle] fns (≈half the real ABI). So manifest-gen expands the whole crate with nightly rustc (cargo rustc --lib --profile check -- -Zunpretty=expanded, nightly selected via RUSTUP_TOOLCHAIN): every module inlined, every macro expanded, one parseable dump carrying the complete typed surface. The one thing expansion destroys is bitflags! (it lowers to a plain struct + impl-const block), so bitflags are harvested separately from the raw source tree where they remain literal macros. A nightly toolchain must be on PATH.

The IR — bindings.json

The contract between the two stages. A typed, language-neutral, deterministic (sorted) snapshot of a crate's C-ABI surface:

{
  "schema": 1,
  "ffi_version": "0.2.6",          // crate version from its Cargo.toml
  "ratatui_version": "0.30",       // optional upstream pin, only with --upstream-dep
  "functions":      [ /* name, params:[{name,type}], ret, cfg_feature?, doc? */ ],
  "value_structs":  [ /* repr(C) structs — field ORDER is the C layout */ ],
  "opaque_structs": [ "FfiTerminal", ... ],   // handle types, only ever crossed as pointers
  "enums":          [ /* #[repr(uN)] enums w/ computed discriminants */ ],
  "bitflags":       [ /* bitflags! structs, same shape as enums */ ]
}

Types are a tagged union on kind (prim / char / void / ptr / struct), so *const c_char is ptr(const, char) and *mut FfiTerminal is ptr(mut, struct FfiTerminal opaque=true). Each emitter maps these to its own language's interop primitives.

The BindConfig surface (the entire ratatui-coupling, lifted)

namespace        → was "Ratatui.Interop"            (output ns of Native.cs)
libName          → was "ratatui_ffi"                (DllImport LibraryName)
ffiDirEnvVar     → was "RATATUI_FFI_DIR"            (runtime lib-dir override)
ffiPathEnvVar    → was "RATATUI_FFI_PATH"           (runtime lib-path override)
versionProbeFn   → was "RatatuiFfiVersion"          (load-time version self-check; optional)
nativeSearchPaths→ was native/ratatui_ffi/target/{debug,release}
overrides        → was Overrides.Table (ABI-preserving ergonomic marshal sugar; empty for fresh crates)

Verbs

Two separate binaries — the Rust manifest extractor (syn, standalone cargo crate) and the C# emitter (rk-registered so rk run rustbind resolves).

# 1) lib.rs → bindings.json  (Rust; run via cargo, syn stays out of any cdylib)
cargo run --manifest-path manifest-gen/Cargo.toml -- \
    --ffi-crate <crate-dir> [--upstream-dep <dep>] [--out <bindings.json>]

# 2) bindings.json + BindConfig → Native.cs (+ parity-report.txt + residue.txt)
#    dotnet is hook-blocked → always via rk:
rk run rustbind -- gen-cs --config <bindconfig.toml>

--upstream-dep <dep> (manifest) is the parametric pin that emits a <dep>_version key into the IR (e.g. --upstream-dep ratatui"ratatui_version": "0.30"). It is the entire lift of the origin's hardcoded ratatui-version scrape — omit it for a crate with no meaningful upstream pin and the field simply doesn't appear.

All I/O paths inside the BindConfig (manifest_path, out_path, src_dir, overrides_path) resolve relative to the config file's directory, so a config lives beside its consumer repo and uses repo-relative paths regardless of cwd.

What lifted to config vs stayed generic

Lifted to BindConfig (per-crate) Stayed generic (the engine)
namespace, lib_name, banner the syn parse + total/loud type mapper (manifest-gen)
ffi_dir_env_var, ffi_path_env_var value/opaque struct fixpoint, enum/bitflag discriminant eval
version_probe_fn (optional), native_search_subdir TypeMap (IrType → C#), Naming algorithm, Parity/Residue
compound_words, name_aliases, compound_members the whole Emitter emit shape (resolver, structs, DllImports)
overrides_path → ergonomic marshal sugar (string/array/out) the override application (ABI-preserving by construction)

A fresh crate supplies empty naming lexicons + no overrides and gets naive PascalCasing with a neutral default banner.

Consumers (dogfood + roll-out)

  1. Ratatui.cs — refactored to consume rustbind (proves generality on the origin crate; its just gen calls rustbind with a ratatui BindConfig). Regenerated Native.cs must be byte-identical to today's.
  2. orgmaporgmap-ffi (C-ABI over identity_for_path/color_text_to_ansi256/WorkgroupIdentity) → Orgmap.cs. Replaces three hand-rolled orgmap slices in babel (hud/world/project-metrics).
  3. future — any org Rust crate that wants a C# face.

Build

manifest-gen is a standalone cargo crate; the C# emitter is a net10.0 console app. Verification is build-only (generate → build), never run:

cargo build --manifest-path manifest-gen/Cargo.toml   # the Rust extractor
# C# emitter:
dotnet build src/Rustbind/Rustbind.csproj             # or `rk build` in-org

Sibling path-deps (org clone-the-tree convention)

The C# emitter binds its BindConfig/overrides TOML through Tomatonl, the org's reflection-free TOML library, via a ProjectReference:

repo-lib/Tomatonl/src/Tomatonl            — the TOML lib
repo-lib/Tomatonl/src/Tomatonl.Generator  — its source-gen binder (referenced as an analyzer)

This is the standard org sibling-clone pattern, not a bug: clone the org tree so ../../../../repo-lib/Tomatonl resolves. To build the C# emitter standalone, clone Tomatonl alongside this repo under a sibling repo-lib/. The Rust manifest-gen crate has no path-deps — it uses only crates.io (syn/serde/quote/proc-macro2) and builds anywhere.

About

Rust FFI → language bindings generator: parse any Rust crate's C-ABI surface into a typed IR (bindings.json), then emit idiomatic bindings (C# first; Py/TS in their own repos).

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors