Write Rust, call it from Common Lisp. rulisp is the missing PyO3/rustler-style bridge for CL: annotate plain Rust with a couple of macros, build a cdylib, and load it from the REPL as an idiomatic Lisp package — CLOS handles, typed conditions, GC finalizers, restarts, and live reload included.
use rulisp::prelude::*;
use std::sync::Mutex;
#[rulisp::handle]
pub struct WordBag { words: Mutex<Vec<String>> }
#[rulisp::export]
impl WordBag {
#[rulisp(constructor)]
pub fn new() -> WordBag { WordBag { words: Mutex::new(Vec::new()) } }
pub fn add(&self, word: &str) -> Result<(), Error> {
if word.is_empty() { return Err(Error::msg("empty word not allowed")); }
self.words.lock().unwrap().push(word.to_owned());
Ok(())
}
pub fn len(&self) -> u64 { self.words.lock().unwrap().len() as u64 }
}
#[rulisp::export]
pub fn greet(name: &str) -> String { format!("Hello, {name}!") }
rulisp::module! {
name: "wordbag",
handles: [WordBag],
fns: [greet, WordBag::new, WordBag::add, WordBag::len],
}CL-USER> (asdf:load-system :rulisp)
CL-USER> (rulisp:use-crate #p"~/src/wordbag/") ; cargo build + load
#<RULISP:CRATE "wordbag" gen 1 abi 1 :: 4 fns, 1 handle, package WORDBAG>
CL-USER> (wordbag:greet "리스퍼")
"Hello, 리스퍼!"
CL-USER> (defvar *bag* (wordbag:make-word-bag))
#<WORDBAG:WORD-BAG live gen 1 {100A3C2E13}>
CL-USER> (wordbag:word-bag-add *bag* "hello")
CL-USER> (wordbag:word-bag-len *bag*)
1
;; edit Rust, rebuild, reload — without restarting the image:
CL-USER> (rulisp:use-crate #p"~/src/wordbag/")
#<RULISP:CRATE "wordbag" gen 2 ...>The macros generate extern "C" shims (panic-catching, status codes,
(ptr,len) UTF-8 strings, opaque handles) plus an s-expression manifest
embedded in the cdylib. The CL side dlopens a unique copy of the library,
reads the manifest, and generates wrappers at load time — defuns, CLOS
handle classes, typed conditions from your Rust error types, and
trivial-garbage finalizers. No C headers, no hand-written FFI on either
side, and the bindings are derived by construction from the exact library
that was just loaded.
The hard problems are handled structurally, not by convention:
- Panics become
rulisp:rust-panicconditions;panic = "abort"builds fail to compile. - Handles are gated by a state machine (in-flight counting + deferred free): a second free is refused (idempotent, returns NIL), use-after-free and stale handles after reload or image restore signal named conditions, and a free racing an in-flight call is deferred until that call returns — memory is never corrupted.
- Callbacks are borrowed, same-thread, lifetime-pinned (
!Send): storing one is a compile error. A Lisp condition tunnels through Rust (destructors run) and re-signals as the same object. - Live reload never calls
dlcloseand always dlopens a fresh copy — the reload cannot lie. - Image dump/restore (
save-lisp-and-die) invalidates every pre-dump handle via a session counter and regenerates all bindings on startup.
Start here: docs/installation.md
(Linux/macOS/Windows setup, dependencies), then
docs/quickstart.md — wrapping the real regex
crate (the finished example is examples/rx/). docs/usage.md explains
the two ways to consume rulisp — running a prebuilt glue library (no Rust
toolchain needed) vs building your own. For something bigger,
examples/wasm/ gives Common Lisp a WebAssembly runtime
in under 250 lines of glue: load .wat/.wasm modules from the REPL, call
their exports under a fuel-metered CPU budget (runaway guest code traps as
a condition instead of hanging the image), move byte buffers in and out of
the guest's linear memory (bounds-checked; out-of-bounds is a condition),
wire host functions so GUEST code calls straight into your Lisp closures
(stored callbacks; a condition in the closure becomes a guest trap), and
watch wasm traps arrive as Lisp conditions (built on the
signal-handler-free wasmi interpreter — see BOUNDARY.md §7 for why that
matters). examples/fetch/ is an async HTTPS client
on tokio and rustls: the pattern for a crate that owns threads (capped
waits, no signal handlers, a declared dump hook), with its own suite in
CI. The full contract is in BOUNDARY.md;
architecture and rationale in DESIGN.md (Korean). Measured boundary costs, with the method:
docs/benchmarks.md. What is stable, what 1.0 will
promise, and how hosts are supported: docs/stability.md. Release
history: CHANGELOG.md. Reporting a vulnerability and the
threat model: SECURITY.md.
0.5.0. A host is supported exactly when it is a required CI job (docs/stability.md §5); this table is that matrix, and nothing else is claimed:
| Host | Linux x86-64 | macOS arm64 | Windows x86-64 | Linux aarch64 |
|---|---|---|---|---|
| SBCL 2.1.11+ | required | required | required | best-effort |
| Clozure CL 1.13 | required | — | — | — |
| ECL 21.2.1 | required | — | — | — |
Every required job runs the full gate (races, nested callbacks, reload,
the 10k-op fuzzers, and dump/restore where the host dumps images); SBCL
and CCL on Linux also run the async HTTPS example, and the ECL job
builds and runs a program-op executable. ECL notes: a C toolchain is required for callbacks (rulisp
natively compiles trampolines to dodge an upstream GC bug we root-caused
— docs/upstream/ecl-dynamic-callback-gc.md), and foreign-thread stored
callbacks are unsupported there.
Versions up to 0.2.1 have a soundness hole (issue #1: an explicit
'static in an export signature could retain a Lisp-owned buffer past
the call) and are yanked — depend on 0.3.0 or later.
Requirements: a Rust toolchain (cargo), CFFI-capable Lisp, Quicklisp
(deps: cffi, babel, trivial-garbage, bordeaux-threads).
make test-m4 # full gate: cargo tests + every suite but fetch, on SBCL
make test-fetch # the async HTTPS example (hermetic)
make test-ccl # the m4 suites on Clozure CLScalars, bool, &str/String, &[u8]/Vec<u8> byte buffers,
Option<T> (NIL ↔ None; Option<bool> is refused), &[scalar]/
Vec<scalar> vectors, opaque handles (&self methods, constructors),
borrowed same-thread callbacks and stored any-thread callbacks
(StoredCallback + rulisp:callback tokens — fail-safe lifetime,
verified cross-thread on SBCL and CCL), Result errors → typed
conditions, prebuilt-blob loading (load-blob-crate, no Rust toolchain
needed), live reload, image dump/restore on SBCL and CCL (ECL has no
image dump — ship a program-op executable, docs/distribution.md),
use-value/retry-build restarts. The closed type vocabulary, token by
token, is BOUNDARY.md §11; every claim on this page has a citation in
docs/claims.md, the claims register.
Out — see ROADMAP.md: Vec<String>/nested containers,
multiple return values.
rulisp is for writing glue crates — it does not auto-bind arbitrary existing crates, by design (neither does PyO3).
MIT