Call Rust libraries from Jolt using Diplomat as the FFI bridge.
One script (bind.clj) takes any Diplomat-annotated Rust crate and produces ready-to-use Jolt bindings. A small hand-written runtime library handles the lifetime and marshaling conventions that Diplomat's C ABI requires.
flowchart TD
subgraph YOUR_CRATE["Your Rust crate"]
RS["src/lib.rs\n#[diplomat::bridge]"]
end
subgraph BIND_SH["bind.clj (one-time per crate)"]
direction TB
S1["① cargo build\n→ libfoo.dylib"]
S2["② diplomat-tool c\n→ C headers"]
S3["③ jolt-diplomat-backend\n→ diplomat/*.clj\n→ generated_shim.c"]
S4["④ cc\n→ libfoo_shim.dylib"]
S1 --> S2 --> S3 --> S4
end
subgraph SHIM["generated_shim.c 〔why it exists〕"]
SH1["Result<T,E> returns\nstruct-by-value → out-pointer"]
SH2["DiplomatWrite\nwrap diplomat_simple_write"]
SH3["Struct returns\nmemcpy into caller buffer\n+ sizeof helper"]
SH4["Option<Prim> returns\ndecompose to (T*, bool*)"]
end
subgraph GENERATED["generated/diplomat/*.clj 〔auto-generated〕"]
G1["defopaque + destroy binding"]
G2["defcfn per method\n(direct or via shim)"]
G3["field offset reads\nfor struct returns"]
G4["unwrap-result! calls\nfor fallible methods"]
end
subgraph RUNTIME["runtime/ 〔hand-written, ship once〕"]
R1["defopaque macro\nopaque lifetime protocol"]
R2["with-opaque / when-opaque\nscoped resource management"]
R3["unwrap-result!\nResult → ex-info"]
R4["DiplomatWrite helpers\nsimple-write! / writeable-capture"]
R5["load! macro\nloads cdylib + shim dylib"]
R6["read-u16\n(Jolt ffi has no 16-bit read)"]
R7["with-primitive-buffer\n&[T] slice marshaling"]
end
subgraph JOLT["Your Jolt program"]
J["(dr/with-opaque [u (url/parse s)]\n (url/host u))"]
end
RS --> BIND_SH
S3 --> SHIM
S3 --> GENERATED
GENERATED --> JOLT
RUNTIME --> JOLT
SHIM --> JOLT
Diplomat's C backend emits several ABI shapes that Jolt's ffi/defcfn cannot express directly:
| Shape | Problem | Shim solution |
|---|---|---|
Result<T,E> |
Returned as a named struct by value; no struct-by-value return in Jolt ffi | Shim takes an out-pointer, writes the struct through it |
DiplomatWrite |
diplomat_simple_write returns DiplomatWrite by value (56 bytes) |
Shim wraps it with an out-pointer |
| Struct returns | Any -> MyStruct crosses as struct-by-value |
Shim memcpys into caller buffer; sizeof helper lets Jolt allocate the right size |
Option<Prim> |
C ABI is a per-function {T ok; bool is_ok} result struct |
Shim decomposes to (T* out_val, bool* out_is_ok) |
| Concern | Why it can't be generated |
|---|---|
Opaque lifetime (with-opaque, when-opaque) |
Scoping convention, not derivable from a single type's API |
unwrap-result! |
Common across all fallible methods; one copy is better than N |
read-u16 |
Jolt ffi has no 16-bit foreign-ref type; two uint8 reads + bit-or |
with-primitive-buffer |
&[T] marshaling is identical for every slice param regardless of type |
load! |
Loads both the cdylib and shim dylib in one call |
jolt-diplomat/
├── runtime/ — Jolt library; add as :local/root or :git/url dep
├── backend/ — Rust generator (jolt-diplomat-backend)
├── macros/ — proc-macro attributes (#[jolt_diplomat::blocking], etc.)
├── bind.clj — full pipeline: cargo → diplomat-tool → generator → cc
└── examples/
├── url/ — url crate: nullable prim, struct return, fallible
│ └── leak-check/ — AllocStats-based leak check (see Memory-leak checking below)
├── regex/ — regex crate: nullable write, opaque error
│ └── leak-check/
├── semver/ — semver crate: cross-opaque method params
│ └── leak-check/
├── base64/ — base64 + hex: &[u8] slice params
│ └── leak-check/
├── json/ — serde_json: nullable opaque, enum return
│ └── leak-check/
├── chrono/ — chrono: struct return with mixed field types
│ └── leak-check/
├── markdown/ — pulldown-cmark: struct-by-value param, plain scalar returns
│ └── leak-check/
├── callback/ — impl Fn(...) params: Jolt closures called from Rust
│ └── leak-check/
├── sdl3/ — SDL3 window/renderer: bouncing-box GUI with mouse interaction
│ └── leak-check/
└── tantivy/ — tantivy full-text search: blocking commit, opaque chain, ResultSet accessors
└── leak-check/
# my_capi/Cargo.toml
[package]
name = "my_capi"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_capi"
crate-type = ["cdylib"]
[dependencies]
diplomat = ">=0.10,<0.16"
diplomat-runtime = ">=0.10,<0.16"// my_capi/src/lib.rs
#[diplomat::bridge]
mod ffi {
use diplomat_runtime::DiplomatWrite;
use std::fmt::Write as _;
#[diplomat::opaque]
pub struct MyType(inner::MyType);
#[diplomat::opaque]
pub struct MyError(String);
impl MyError {
// Required whenever an opaque is used as a Result's error type —
// the generator always emits a call to this (see Known limitations
// below), so a fallible method whose error type lacks it produces
// a binding that fails at first call, not at generation time.
pub fn message(&self, write: &mut DiplomatWrite) {
let _ = write.write_str(&self.0);
}
}
impl MyType {
pub fn parse(s: &str) -> Result<Box<MyType>, Box<MyError>> { ... }
pub fn value(&self) -> u32 { ... }
}
}cargo install jolt-diplomat-backendbind.clj isn't published anywhere — it's a script, not a crate — so fetch it directly from this repo (curl, or clone if you'd rather have the whole thing):
curl -O https://raw.githubusercontent.com/jolt-lang/jolt-diplomat/main/bind.clj
chmod +x bind.clj./bind.clj path/to/my_capi --releasebind.clj looks for jolt-diplomat-backend on PATH first (from step 2 above) and only falls back to building it from a local backend/ checkout if nothing is installed — so this works with no rust-jolt clone in sight.
Outputs: generated/diplomat/*.clj, generated/generated_shim.c, libmy_capi_shim.dylib.
Pin a commit SHA from jolt-lang/jolt-diplomat — :deps/root scopes the git dep down to the runtime/ subdirectory:
; deps.edn
{:paths ["src" "../generated"]
:deps {jolt-diplomat-runtime/jolt-diplomat-runtime
{:git/url "https://github.com/jolt-lang/jolt-diplomat"
:git/sha "<full commit SHA>"
:deps/root "runtime"}}}(Working inside this repo already, e.g. one of the examples? Use :local/root "../../../runtime" instead — no need to fetch over git.)
Releases are tagged (v0.1.0, ...) at the commit where jolt-diplomat-macros and jolt-diplomat-backend were published to crates.io, so runtime/ at that same commit is guaranteed compatible with whatever crates.io versions you installed in steps 1-2. Resolve a tag to a SHA with git ls-remote --tags https://github.com/jolt-lang/jolt-diplomat (or git rev-parse v0.1.0 in a local clone) and pin that SHA above — :git/sha needs the full commit hash, not the tag name itself.
(require '[diplomat.runtime :as dr])
(dr/load! demo-dir "my_capi")
(require '[diplomat.my-type :as mt])
(dr/with-opaque [x (mt/parse "hello")]
(println (mt/value x)))Add jolt-diplomat-macros to your crate and annotate the method — the generated binding gets jolt.ffi's :blocking flag, so the call doesn't pin the garbage collector for every other thread while it waits:
# my_capi/Cargo.toml
[dependencies]
jolt-diplomat-macros = "0.1"use jolt_diplomat::blocking;
impl MyType {
#[jolt_diplomat::blocking]
pub fn save(&self, path: &str) -> Result<(), Box<MyError>> { ... }
}Skip this for everything else — most methods (parsing, math, data transforms) are fast enough that it isn't worth the overhead. A &str/String param works fine on a blocking method (the generator routes it through a foreign-allocated buffer automatically) — see Known limitations below for why that's necessary.
Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shdiplomat-tool (must be 0.14–0.15; 0.16+ changes the HIR and is not yet supported)
cargo install diplomat-tool --version "^0.15"Jolt v0.8.4+
Follow the Jolt install guide. Verify with:
jolt --version # should print v0.8.4 or laterC compiler — on macOS install Xcode Command Line Tools if not already present:
xcode-select --installEach example ships with pre-generated bindings and a pre-compiled shim dylib, so for a quick run you only need Jolt:
cd examples/chrono/jolt-project
jolt run -m demoTo rebuild from source (e.g. after modifying the Rust crate):
cd examples/chrono
../../bind.clj chrono_capi # runs the full pipeline: cargo → diplomat-tool → generator → cc
cd jolt-project
jolt run -m demofor demo in url regex semver base64 json chrono markdown callback tantivy; do
echo "=== $demo ==="
(cd examples/$demo/jolt-project && jolt run -m demo)
donesdl3 opens a window and runs its own event loop — run it on its own, not in the batch loop above:
(cd examples/sdl3/jolt-project && jolt run -m demo)| Example | Rust crate | Key ABI shapes |
|---|---|---|
url |
url |
fallible ctor, Option<u16> return, struct return |
regex |
regex |
nullable write, opaque error type |
semver |
semver |
cross-opaque method params |
base64 |
base64 + hex |
&[u8] slice params |
json |
serde_json |
nullable opaque return, enum return |
chrono |
chrono |
struct return with mixed field types, nullable opaque |
markdown |
pulldown-cmark |
struct-by-value param with real behavioral effect, plain scalar returns |
callback |
(synthetic Reducer) |
impl Fn(...) params — Jolt closures called back into from Rust |
sdl3 |
sdl3 |
windowed GUI with mouse interaction driven entirely from Jolt |
tantivy |
tantivy |
blocking commit, opaque SearchIndex→ResultSet chain, per-hit accessor pattern |
Every example under examples/ ships a leak-check/ subproject that exercises the crate's bound API at volume and reports whether every byte the crate allocates gets deallocated — an exact yes/no answer per run, not an inferred one.
The first version of this tooling sampled process RSS (ps -o rss=) and Chez's own (current-memory-bytes) before and after a loop. Two problems killed that approach:
- Chez's GC-heap stats never see Rust-side allocations. An undestroyed
Box<Codec>from a forgottenclose!lives in native/malloc memory Chez's collector doesn't track at all —current-memory-bytesstayed perfectly flat across a run that was leaking hundreds of KB. - RSS does see it, but noisily. RSS growth is a real signal for a native leak, but distinguishing "20MB of steady leak" from "20MB of one-time startup/JIT noise" needs threshold-tuning, warm-up windows, and multiple samples — and the right threshold for a small
Codecstruct (base64) turned out ten times too coarse forJsonValue's smaller per-leak footprint (json), so it wasn't even a threshold you could set once and reuse.
Each example's *_capi crate gets a mem-trace Cargo feature. Behind that feature, stats_alloc replaces the crate's global allocator, and an AllocStats opaque exposes two counters back to Jolt:
#[cfg(feature = "mem-trace")]
#[global_allocator]
static GLOBAL: &StatsAlloc<System> = &INSTRUMENTED_SYSTEM;
#[cfg(feature = "mem-trace")]
#[diplomat::opaque]
pub struct AllocStats;
#[cfg(feature = "mem-trace")]
impl AllocStats {
pub fn bytes_allocated() -> u64 { super::GLOBAL.stats().bytes_allocated as u64 }
pub fn bytes_deallocated() -> u64 { super::GLOBAL.stats().bytes_deallocated as u64 }
}(AllocStats is an opaque with static methods, not a bare function, because this repo's jolt-diplomat backend doesn't lower Diplomat free functions yet.)
A leak-check script does a warm-up, snapshots bytes_allocated - bytes_deallocated, runs N iterations of the real API, and snapshots again. Any nonzero delta is bytes that were allocated and never freed — exact, deterministic, no averaging, no noise floor:
(defn live-bytes [] (- (as/bytes-allocated) (as/bytes-deallocated)))
;; ... warm up, snapshot baseline, run N iterations, snapshot again ...
;; leaked = after - baselinediplomat-tool and this repo's Jolt-side generator both parse src/lib.rs textually — neither runs cargo's feature/cfg resolution. That means a #[cfg(feature = "mem-trace")]-gated AllocStats is always visible to codegen, regardless of which Cargo features the .dylib was actually compiled with. Generating bindings once against a default (non-traced) build would silently produce Clojure code that calls a symbol the dylib doesn't export.
So bind.clj gained two flags to keep the two builds fully separate:
# consumable build: generated/, target/, no AllocStats surface at all
./bind.clj my_capi --release
# traced build: generated-traced/, target-traced/ (own CARGO_TARGET_DIR), AllocStats present
./bind.clj my_capi --release --features mem-trace --out-suffix -traced--out-suffix isolates everything per build — c-headers<suffix>/, generated<suffix>/, the shim dylib name, and (critically) CARGO_TARGET_DIR, so the traced and untraced .dylibs coexist on disk instead of one build overwriting the other's artifact at the same path.
The result: real consumers get a cdylib with zero tracing overhead and no AllocStats symbol at all; leak-check/ loads the traced build directly via jolt.ffi/load-library (not the dr/load! macro, which assumes the untraced path layout).
cd examples/chrono/leak-check
jolt run -m leak-check # exercises the real API; expect "leaked: 0 bytes"
jolt run -m leak-check-neg # deliberately skips close! on an opaque; expect a nonzero, deterministic leakEvery example's leak-check/ has both files: leak_check.clj proves the crate's real usage pattern doesn't leak, and leak_check_neg.clj proves the check isn't just silent — it deliberately breaks with-opaque/when-opaque discipline (the specific mistake shape that crate's opaque graph actually invites — a flat forgotten close! for a single-opaque crate like base64/chrono, a forgotten nested owned-opaque for json's array_get-shaped return, a leaked borrowed-looking-but-actually-owned argument for semver's two-opaque matches call, and so on) and confirms a real, nonzero, repeatable byte count comes back.
Most examples wrap a pure data-transform crate (a single opaque, cheap to construct, no real OS resources), so the same 500-round-warmup / 20000-iteration shape works everywhere. Two examples didn't fit that mold:
tantivy— building aSearchIndexis expensive (a realtantivy::Index/IndexWriter/IndexReader, aMutex, a 50MB writer buffer).leak_check.cljbuilds one index up front and loops the actual leak risk —search()'s ownedResultSetreturn and its accessors — 20000 times against it, rather than rebuilding the index every iteration.sdl3—SdlApp::createopens a real OS window andAudioStream::opengrabs the real default audio device on every call, not a cheap in-process value. Itsleak-check/uses 30 iterations instead of 20000, run once and meant to be observed directly (brief window flashing is expected), and its negative control can only leak a single instance —sdl3::EventPumpis a process-wide singleton the crate itself enforces, so a second unclosedcreatethrows instead of accumulating. That failure became a second, independent confirmation of the leak alongside the byte count.callback— has no opaque that needs closing at all (Reducer::reduce/apply_twicerun their closure inline and return nothing owned). The real leak risk lives in Diplomat's callback marshaling: a boxedimpl Fntrait object on the Rust side (whichAllocStatscan see), and a Chez-sidejolt.ffi/foreign-callabletrampoline pair on the Jolt side (which it cannot — that's Chez-owned foreign memory, andjolt.ffiexposes no live-callable counter).callback_capihas aapply_twice_leakymethod that exists solely as a test fixture (Box::leaks the closure) so the negative control has something real to detect; the Chez-side half of this crate's leak risk remains unverified by any tool in this repo.
callback's Chez-side trampoline leak is undetectable with current tooling — nojolt.ffiintrospection exists for live foreign-callables.sdl3'sSdlApphas noDropimpl, so a loaded TTF font (and theTTF_Initcall) is never released.leak_check.cljdeliberately avoidsload_fontso the check measures what's currently correct rather than a known-broken path.
- Rust + Cargo
diplomat-tool0.14–0.15 (cargo install diplomat-tool --version "^0.15")- Jolt v0.8.4+
cc(Xcode CLT on macOS)
- Struct-by-value params support primitive, enum,
Option<primitive/enum>, and nested-struct fields (flattened recursively to scalars at the FFI boundary). Struct-by-value returns support the same exceptOption<...>fields — the generator rejects those loudly rather than silently dropping them from the returned Clojure map. impl Fn(...)callback params support primitive-in/primitive-out signatures only, invoked synchronously during the call and freed right after — seeexamples/callback. This matches Diplomat's own callback design; it isn't a shape for handing Jolt a long-lived handle into live Rust state (e.g. it can't express something likeegui's closure-based, mutably-borrowed UI builder API).- Owned slice params (
Box<[T]>,Vec<String>) aren't supported — Diplomat's own C backend doesn't support owned primitive slices either, and this generator doesn't support owned string slices. - An opaque used as a fallible method's error type must implement
message(&self, write: &mut DiplomatWrite). The generator always emits a call to{error-type}/messagewhen unwrapping aResultwhose error is an opaque — it doesn't check whether that method actually exists on the Rust side. An error type missing it compiles and generates fine, then fails the first time that fallible method is actually called (No such var: ...error/message), not at generation time. Every error type inexamples/defines this method; follow that pattern for your own. - Marking a method
#[jolt_diplomat::blocking](see Usage above) is safe with any param shape, including&str/String— the generator automatically routes a blocking method's string params through a foreign-allocated buffer instead of a bare:stringarg, sincejolt.ffi's:blockingcalling convention (Chez's__collect_safe) rejects:stringoutright for GC-safety reasons. If you ever see a generation-time panic mentioning:string argument, it means some other param shape reached:blockingwithout going through that routing — file it as a bug against this generator. - Tested against
diplomat-tool/diplomat_core0.10–0.15; 0.16 changes the HIR shape in ways not yet accounted for.
MIT — see LICENSE.