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.
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.
| 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 + BindConfig → Native.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. |
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 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.
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)
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.
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.
- Ratatui.cs — refactored to consume rustbind (proves generality on the origin crate; its
just gencalls rustbind with a ratatuiBindConfig). RegeneratedNative.csmust be byte-identical to today's. - orgmap —
orgmap-ffi(C-ABI overidentity_for_path/color_text_to_ansi256/WorkgroupIdentity) →Orgmap.cs. Replaces three hand-rolled orgmap slices in babel (hud/world/project-metrics). - future — any org Rust crate that wants a C# face.
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-orgThe 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.