Load foreign code with one call
eq.tsc.hk — project site, built with crepuscularity + moonshine and hosted on Cloudflare Pages.
Equilibrium auto-detects source files in various programming languages, compiles them to C intermediate representation, and loads the result into a Rust-friendly module handle. Binding generation is available when you need it, but load() is the primary path. Generated consumer wrappers can target the same C ABI surface for other supported languages.
The eq CLI manages compilers and builds polyglot projects.
# Build
cargo install --path . --features cli
# Check which compilers are installed
eq check
# Install missing compilers (interactive multi-select, parallel)
eq install
# Install specific compilers
eq install zig nim d odin
# Build a project with all compilers on PATH
eq build --release --bin my-app
# Generate Rust FFI bindings from a C header (optional)
eq generate mylib.h -o src/mylib_ffi.rs
# Generate imports for another language
eq generate mylib.h --consumer zig -o src/mylib.zig
eq generate mylib.h --consumer scriptc --out-dir generated # bindings.ts + the --ffi manifest
eq generate mylib.h --consumer all --out-dir generated-importsInstall order per platform:
- Linux: wax → brew/linuxbrew → apt / dnf / pacman → npm
- macOS: wax → brew → npm
- Windows: winget → scoop → npm
npm is a fallback for tools the JS ecosystem ships (scriptc).
Multiple compilers install in parallel.
Equilibrium runs your compilers on your source paths (load, eq generate, compile_to_c). Treat paths like a build script: only trusted trees; shared CI should not point at arbitrary uploads. Compiler binaries come from PATH (and eq’s extra search dirs)—use a known-good toolchain. eq install may invoke sudo with apt/dnf/pacman; set EQ_INSTALL_NO_SUDO=1 to skip those managers. Header/source reads are capped (10 MB headers, 64 MB discovery sources) to limit accidental DoS.
use equilibrium_ffi::load;
let lib = load("examples/c-ffi/mathlib.c")?;
println!("{}", lib.output_path.display());load() compiles the source when needed, then gives you a loaded module wrapper you can inspect, reuse, or turn into generated bindings.
use equilibrium_ffi::{Language, LoadOptions, load_with_options};
let lib = load_with_options(
"examples/c-ffi/mathlib.c",
LoadOptions::default().consumer_languages([Language::Zig, Language::Nim]),
)?;
for generated in lib.imports {
println!("{:?}: {}", generated.language, generated.code);
}use equilibrium_ffi::load;
let lib = load("math.v")?;
println!("loaded: {}", lib.output_path.display());[dependencies]
equilibrium-ffi = "0.3"// build.rs
use equilibrium_ffi::load;
fn main() {
let _lib = load("src/native/math.v").unwrap();
println!("cargo:rerun-if-changed=src/native/*");
}fn main() {
let lib = equilibrium_ffi::load("src/native/math.v").unwrap();
println!("{}", lib.output_path.display());
}Use load() for the smallest path. Reach for generate_bindings() only when you already have a C header and want explicit Rust extern declarations:
let lib = equilibrium_ffi::load("native/math.c")?;
println!("{}", lib.output_path.display());eq generate mylib.h -o src/mylib_ffi.rs
eq generate mylib.h --consumer csharp -o src/mylib.cs| Language | Compiler | Notes |
|---|---|---|
| V (Vlang) | v |
-backend c outputs C |
| Zig | zig |
build-obj -OReleaseFast -fPIC |
| C | clang/gcc |
Already C (preprocessed) |
| C++ | clang++/g++ |
Compiled to object files |
| C# | dotnet |
Native AOT |
| Rust | rustc |
cbindgen for header generation |
| D | ldc2/dmd/gdc |
-HC flag for C headers |
| Nim | nim |
Compiles to C by default, --mm:none --app:staticlib |
| Odin | odin |
-build-mode:obj -reloc-mode:pic |
| Hare | hare |
QBE backend (Linux only) |
| TypeScript/JavaScript | scriptc |
Library mode: scriptc build --lib --profile → self-contained static archive |
scriptc compiles ordinary TypeScript/JavaScript to native code, and its only host-callable C ABI is library mode. Equilibrium detects .ts .mts .cts .js .mjs .cjs, derives a library profile and a matching C header from the module's export function declarations, then runs scriptc build --lib --profile … and returns the self-contained archive (lib<stem>.a) for linking.
// native/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function greet(who: string, loud: boolean): string {
return loud ? who.toUpperCase() : who;
}let lib = equilibrium_ffi::load("native/math.ts")?;
// lib.output_path == target/native/scriptc/libmath.a
// lib.header_path == target/native/scriptc/math.h (generated: scriptc emits none)A complete runnable version — build.rs, class overrides, string/bytes calls — lives in examples/scriptc-app.
Requirements and behavior:
scriptc(Node.js 24+) plus a C compiler for its library lane. Equilibrium passesSCRIPTC_CC=zigccwhen you havezigand did not setSCRIPTC_CCyourself, because scriptc's defaultclangdriver is missing or too old on many Linux hosts.- Signatures must be C ABI safe:
number→f64,boolean→bool,string→string,Uint8Array→bytes, andvoidreturns. Anything else (optional/rest/any-typed parameters, unions, generics) is skipped with a warning. - Marshalling classes and the profile emission are configurable through the
[target.<name>]table inequilibrium.toml(beside the source, or at your crate root):
[target.math]
language = "scriptc"
sources = ["native/math.ts"]
emission = "c" # "llvm" (default) or "c"
[target.math.signatures]
mix = { params = ["u32", "u32"], returns = "f64" }
scale = { params = ["u64", "f64"], returns = "f64" }
truncate = { returns = "i64" }Overrides are validated against the TypeScript annotations, so a class that cannot describe the annotated value — or the wrong parameter count, an unknown class name, an override for a function that is not part of the ABI — is refused with the reason instead of silently changing the ABI. u8/u32/i32 are inbound-only in scriptc's ABI and are rejected as return classes.
- scriptc proves every
i64/u64return value whole and in range, so such a function must bound its value with ordered comparisons; otherwise the build fails with scriptc'sSC4022/SC4023diagnostic:
export function truncate(value: number): number {
if (value > -9007199254740991 && value < 9007199254740991) {
return Math.trunc(value);
}
return 0;
}- Every exported symbol is prefixed with the module stem (
math_add), and each module gets a private copy of the scriptc runtime, so several compiled modules can coexist in one process. - Call
<stem>_init()once before the exports. Register<stem>_set_panic_sink()first if you want trap messages, since an unregistered trap aborts the process, and call<stem>_collect()to release bufferedstring/bytesresults. - The generated profile/header are build inputs in the output directory. The default
llvmemission is scriptc's production lane; switch toemission = "c"for scriptc's readable C backend (which accepts the same library-mode profile).
The reverse direction works too: a TypeScript host can call any library equilibrium has a header for. Language::ScriptC generates a declaration module plus the scriptc --ffi manifest that binds it, and load() fills the manifest in with the artifact it just compiled:
let module = load_with_options(
"native/math.c",
LoadOptions::default().consumer_languages([Language::ScriptC]),
)?;
// module.imports[0].code -> bindings.ts (`export declare function …`)
// module.imports[0].companions[0] -> math.ffi.json naming the compiled archive// host.ts
import { c_add } from "./bindings";
console.log(c_add(20, 22));scriptc build host.ts --ffi math.ffi.json -o app && ./appeq generate math.h --consumer scriptc --out-dir generated writes the same two files, and --consumer all now covers every language including TypeScript. Outbound classes stop at f64, bool, u8, u32, i32, string, bytes and void, so 64-bit integers, char * parameters (scriptc's cstring is callback-only), float, and pointer or struct returns are skipped with the reason. A const uint8_t * + size_t pair crosses as one parameter, typed Uint8Array by default — name it in ImportOptions::scriptc_string_spans(["c_greet:name"]) to declare it as UTF-8 string instead. Host builds need only scriptc and a platform linker: scriptc's executable lane lowers through its bundled helper, so no C compiler is required for this direction.
[dependencies]
equilibrium-ffi = { git = "https://github.com/tschk/equilibrium" }For the eq CLI:
[dependencies]
equilibrium-ffi = { git = "https://github.com/tschk/equilibrium", features = ["cli"] }Or install globally:
cargo install --git https://github.com/tschk/equilibrium --features cli┌─────────────────┐
│ Source Files │
│ (.v, .zig, .ts) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Detector │ ◄─── Auto-detect language + compiler
│ detector.rs │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Compiler │ ◄─── Invoke with language-specific flags
│ compiler.rs │
└────────┬────────┘
│
▼
┌─────────────────┐
│ C Output │
│ (.c, .h, .o) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Bindings │ ◄─── Parse C headers → Rust FFI
│ bindings.rs │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Rust Code │
│ (ready to use) │
└─────────────────┘
| Language | Library | Description |
|---|---|---|
| Rust | equilibrium-rust |
#[ffi] proc macro for automatic extern "C" |
| Nim | equilibrium.nim |
Type conversion helpers and export utilities |
| D | equilibrium.d |
@ffi UDA and extern(C) helpers |
| Zig | equilibrium.zig |
Comptime FFI helpers and type conversions |
examples/polyglot-gui/ is the live demo. It loads C via load() and shows the rest of the compilers it can find, including TypeScript through scriptc.
cd examples/polyglot-gui
# TUI (works everywhere including WSL2)
cargo build --bin polyglot-tui
./target/debug/polyglot-tui
# GUI
cargo build --bin polyglot-gui
./target/debug/polyglot-guiOr use eq build to ensure all compilers are on PATH:
cd examples/polyglot-gui
eq build --bin polyglot-tuicargo test.github/workflows/ci.yml— tests on Linux/macOS/Windows.github/actions/setup-equilibrium/— reusable action for your projects
- uses: tschk/equilibrium/.github/actions/setup-equilibrium@main
with:
install-zig: true
install-nim: trueISC