Introduction
clojurust is a Rust-hosted dialect of the Clojure programming language. It reads
and executes .cljrs and .cljc source files, provides an interactive REPL, and
can AOT-compile programs to standalone native binaries.
Goals
- Interpreter — read and execute
.cljrs(native) and.cljc(cross-platform) source files. - Reader conditionals —
.cljcfiles use#?(:rust ... :clj ... :default ...)to branch on platform; the platform key for clojurust is:rust. - Rust interop — Clojure code can call Rust functions through a defined set of conventions and type-marshalling primitives.
- Garbage collector — a tracing GC manages all Clojure values; an optional region-based allocator is available for allocation-heavy code paths.
- AOT compilation —
cljrs compileproduces a standalone native binary via Cranelift, or a WebAssembly module with--target wasm. See the WebAssembly chapter. - Async & I/O —
clojure.core.asyncchannels and non-blocking file I/O ship as optional crates layered over the interpreter. See the Async & I/O chapter.
Source file extensions
| Extension | Meaning |
|---|---|
.cljrs | Native clojurust source. Always evaluated under the :rust platform. |
.cljc | Cross-platform source. Reader conditionals select the active branch; clojurust evaluates :rust branches. |
Quick start
cljrs run hello.cljrs
cljrs repl
cljrs eval '(+ 1 2)'
cljrs compile app.cljrs -o app
cljrs test --src-path test
Detailed documentation for each subcommand is in the CLI chapter.
Command-line tool
cljrs is the command-line entry point for clojurust. It provides subcommands
for running files, starting a REPL, evaluating expressions, compiling to native
binaries, running tests, and managing dependencies.
cljrs [GLOBAL OPTIONS] <SUBCOMMAND> [SUBCOMMAND OPTIONS]
Subcommands
| Subcommand | Description |
|---|---|
run | Interpret a .cljrs or .cljc source file |
repl | Start an interactive REPL |
eval | Evaluate a single expression and print the result |
compile | AOT-compile a source file to a native binary |
test | Run clojure.test namespaces |
deps | Manage project dependencies declared in cljrs.edn |
ir-viz | Render the optimised IR for a source file to HTML |
Global options
These options are accepted by every subcommand.
--stack-size-mb <MB>
Set the main thread’s stack size in megabytes. Defaults to 64 MB. Increase this value if you encounter stack overflows in deeply recursive code.
cljrs --stack-size-mb 128 run my-program.cljrs
--debug
Enable debug-level logging. Prints internal diagnostics to stderr.
--trace
Enable trace-level logging (implies --debug). Much more verbose than --debug.
-X <LEVEL:FEATURES>
Feature-scoped logging. Enables logging at LEVEL for the named comma-separated
FEATURES only.
cljrs -X debug:gc,reader run my-program.cljrs
cljrs -X trace:jit run my-program.cljrs
Available levels: debug, trace.
Available features: gc, reader, jit, and others.
--gc-stats [FILE]
Print garbage-collector statistics on exit. Pass a file path to write the report there; omit the path to write to stdout.
Only honoured by run, eval, and test.
cljrs --gc-stats run my-program.cljrs # stats to stdout
cljrs --gc-stats gc.log run my-program.cljrs # stats to file
--verify-commit-signatures
Require valid PGP or SSH signatures on every versioned commit before executing
historical code. Verification is native (no git/gpg subprocess) and checks
the signature against the keys listed in :trusted-signers. Off by default. Can
also be enabled per-project in cljrs.edn via :verify-commit-signatures true.
Project configuration: cljrs.edn
When any of run, repl, compile, or test starts, clojurust walks up the
directory tree from the current working directory and loads the nearest
cljrs.edn it finds. The :paths declared in that file are appended to the
source-path list (CLI --src-path values come first).
See deps for the full format of cljrs.edn.
cljrs run
Interpret a .cljrs or .cljc source file.
cljrs run [OPTIONS] <FILE>
All top-level forms in FILE are evaluated in order. The return value of the
last form is discarded; side effects (output, file writes, etc.) are the
intended mechanism for a run program to produce observable results.
Arguments
| Argument | Description |
|---|---|
<FILE> | Path to the source file (.cljrs or .cljc) |
Options
--src-path <DIR>
Add DIR to the list of directories searched when resolving require. May be
repeated to add multiple directories.
cljrs run --src-path src --src-path lib my-program.cljrs
Paths declared in :paths of the nearest cljrs.edn are appended automatically
after CLI paths.
--gc-soft-limit-mb <MB>
Soft memory limit for the GC in megabytes. When live heap exceeds this value, a collection is triggered at the next safepoint.
--gc-hard-limit-mb <MB>
Hard memory limit for the GC in megabytes. When live heap exceeds this value, a collection is forced immediately.
Examples
# Run a file in the current directory
cljrs run hello.cljrs
# Run with a source path for namespace resolution
cljrs run --src-path src src/myapp/core.cljrs
# Run and write GC stats to stderr on exit
cljrs --gc-stats run my-program.cljrs
cljrs repl
Start an interactive read–eval–print loop.
cljrs repl [OPTIONS]
The REPL reads one expression at a time, evaluates it, and prints the result. Multi-line input is supported: the REPL continues reading until all open brackets are closed before evaluating.
Type :quit or press Ctrl-D to exit.
Options
--src-path <DIR>
Add DIR to the source-path list for require. May be repeated.
--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>
GC memory limits. See run for details.
REPL behaviour
- The initial namespace is
user. - The standard library (
clojure.coreand friends) is pre-loaded. *1,*2,*3hold the last three non-nil results.*eholds the last exception.nilresults are printed asnil.
Line editing
When clojurust is built with the enable-rustyline feature, the REPL uses
rustyline for line editing, including
history and readline-style key bindings.
Without that feature, a simpler line reader is used that still supports multi-line input but has no history or key bindings.
Example session
$ cljrs repl
clojurust REPL (type :quit to exit)
=> (+ 1 2)
3
=> (def x 42)
=> x
42
=> (map inc [1 2 3])
(2 3 4)
=> :quit
Bye.
cljrs eval
Evaluate a single Clojure expression and print the result.
cljrs eval '<EXPR>'
The expression is evaluated in a fresh environment with the standard library
loaded. If the result is non-nil, it is printed to stdout. A nil result
produces no output.
Arguments
| Argument | Description |
|---|---|
<EXPR> | The expression to evaluate, as a string |
Examples
cljrs eval '(+ 1 2)'
# → 3
cljrs eval '(map str (range 5))'
# → ("0" "1" "2" "3" "4")
cljrs eval '(println "hello")'
# prints: hello
# (println returns nil so no value line is printed)
Notes
eval does not accept --src-path. If you need to require namespaces
from a source tree, use run with a small script file instead.
cljrs compile
AOT-compile a source file to a standalone native binary.
cljrs compile [OPTIONS] <FILE> --out <OUT>
The compiler lowers the source file through the IR pipeline and emits a native
binary via Cranelift. The resulting binary statically links the clojurust
runtime, GC, and standard library; it has no runtime dependency on the cljrs
tool.
Arguments
| Argument | Description |
|---|---|
<FILE> | Source file to compile (or a directory when --test is used) |
Required options
-o, --out <OUT>
Output path for the compiled binary.
Optional options
--src-path <DIR>
Add DIR to the source path for require resolution. May be repeated.
--test
Compile a test harness instead of a regular program. When this flag is set,
FILE should be a directory; the compiler discovers all .cljrs/.cljc files
in that directory and builds a binary that runs all clojure.test tests found
in them.
--target <TARGET>
Select the code-generation backend. Defaults to native (a Cranelift native
binary). wasm emits a WebAssembly module instead — see
Targeting WebAssembly.
--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>
GC memory limits baked into the compiled binary, not the compilation process itself.
Examples
# Compile a single file
cljrs compile src/myapp/core.cljrs --out myapp
# Compile and run
cljrs compile src/myapp/core.cljrs --out myapp && ./myapp
# Compile a test binary
cljrs compile --test test/ --out run-tests && ./run-tests
Targeting WebAssembly
cljrs compile src/myapp/core.cljrs --target wasm -o myapp.wasm
With --target wasm the compiler runs the same IR pipeline but emits a
.wasm module — the entry namespace and every lowerable required namespace
bundled together — instead of a native binary. The emitted module is validated
with wasmparser. --target wasm cannot be combined with --test (there is no
wasm test harness yet).
Status: code generation complete; runtime linking in progress. The emitted module imports its runtime bridge, linear memory, and function table from a
"rt"module that the wasm runtime must satisfy — that linking step, and wiring the IR interpreter in as the dynamic-code tier, is not yet done. See the WebAssembly chapter.
Notes
AOT compilation is based on Cranelift (native) or wasm-encoder (wasm). Not all
language features are yet supported in AOT mode; in particular, features that rely
on dynamic dispatch or late binding may fall back to interpreted execution within
the compiled program.
Native Rust code
If cljrs.edn contains a :rust key, cljrs compile links the declared Rust
crate into the binary and calls its cljrs_init function before any Clojure
code runs. See AOT mode for details.
cljrs test
Run clojure.test test namespaces.
cljrs test [OPTIONS] [NAMESPACES...]
Loads each namespace, calls clojure.test/run-tests on it, and prints a
summary of passes, failures, and errors. Exits with code 0 if all tests
pass, 1 if any fail or error, and 2 if no test namespaces are found.
Arguments
| Argument | Description |
|---|---|
[NAMESPACES...] | Namespace names to test (e.g. myapp.core-test). If omitted, all namespaces in --src-path directories are discovered automatically. |
Options
--src-path <DIR>
Source directory to search for test namespaces. May be repeated. Namespace
discovery translates file paths to namespace names by replacing path separators
with . and underscores with -.
cljrs test --src-path test
-v, --verbose
Print each passing assertion as well as failures. Useful for identifying which test is hanging.
--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>
GC memory limits. See run for details.
Namespace discovery
When no explicit namespaces are given, cljrs test walks all --src-path
directories and converts every .cljrs and .cljc file to a namespace name:
test/myapp/core_test.cljrs → myapp.core-test
test/myapp/util_test.cljc → myapp.util-test
Output format
Ran 12 tests containing 48 assertions across 2 namespace(s) in 0.3s.
48 passed, 0 failed, 0 errors.
All tests passed.
══════════════════════════════════════════════════════════════
On failure, a breakdown by namespace is printed before the summary line.
Examples
# Run all tests discovered under test/
cljrs test --src-path test
# Run specific namespaces
cljrs test --src-path test myapp.core-test myapp.util-test
# Verbose output
cljrs test --src-path test --verbose
cljrs deps
Manage project dependencies declared in cljrs.edn.
cljrs deps <SUBCOMMAND>
Subcommands
| Subcommand | Description |
|---|---|
fetch | Clone or update git dependencies |
status | Show which dependencies are cached and which are missing |
fetch
cljrs deps fetch [NAME]
Clone or update git dependencies from cljrs.edn. Without a NAME, fetches
every git dependency declared in the nearest cljrs.edn. With a NAME, fetches
only that one dependency.
Git repositories are cached in ~/.cljrs/cache/git/. Network access only
occurs when this command is run explicitly — the runtime never fetches
dependencies automatically.
If a versioned symbol or namespace requires a git dependency that is not in the local cache, the runtime raises a clear error:
error: dependency 'my.lib' is not cached locally.
run `cljrs deps fetch` to download it.
Examples
cljrs deps fetch # fetch all git deps
cljrs deps fetch my.lib # fetch only 'my.lib'
status
cljrs deps status
Print the cache status of every dependency declared in the nearest cljrs.edn.
my.lib: cached (sha: abc1234ef, url: https://github.com/user/my-lib)
dev-tools: NOT cached — run `cljrs deps fetch` (sha: 9f3a112b, url: ...)
vendor: local dep at ../vendor/utils — ok
Exits with code 0 if all dependencies are satisfied, 1 otherwise.
cljrs.edn format
clojurust discovers project configuration by walking up the directory tree from
the current working directory until it finds a cljrs.edn file. The file is
valid clojurust EDN:
{:paths ["src" "resources"]
:deps
{my.lib {:git/url "https://github.com/user/my-lib"
:git/sha "abc1234ef"}
dev-tools {:git/url "https://github.com/user/dev-tools"
:git/sha "9f3a112b"}
vendor {:local/root "../vendor/utils"}}
:aliases
{:dev {:extra-paths ["dev"]}
:test {:extra-paths ["test"]
:extra-deps {test-tools {:git/url "..."
:git/sha "..."}}}}
:verify-commit-signatures true
; Keys allowed to sign versioned commits (inline key or path to a key file).
:trusted-signers ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... maintainer@example.com"
"keys/release-signing.asc"]
; Optional: embed a Rust crate for native interop
:rust {:crate "."
:init "my_project::cljrs_init"}}
Keys
| Key | Type | Description |
|---|---|---|
:paths | vector of strings | Directories to add to the source path. Equivalent to --src-path on the CLI. |
:deps | map | Map from dependency name (symbol) to dependency descriptor. |
:aliases | map | Named alias maps with :extra-paths and :extra-deps. |
:verify-commit-signatures | boolean | If true, require valid PGP/SSH signatures (verified natively) on all versioned commits. |
:trusted-signers | vector of strings | Public keys allowed to sign versioned commits. Each entry is an inline key (armored PGP or OpenSSH) or a path to a key file relative to cljrs.edn. |
:rust | map | Embedded Rust crate for native interop. See Rust Interop. |
Git dependency URLs
:git/url accepts https:// URLs and local paths (and file://), all fetched
in-process with pure-Rust gitoxide — no git binary required. The cljrs
binary additionally fetches ssh:// and scp-like git@host:path URLs natively
over SSH: host keys are verified against ~/.ssh/known_hosts, and authentication
uses a running ssh-agent ($SSH_AUTH_SOCK). Other schemes (git://, http://)
are rejected.
:rust key
:rust {:crate "." ; path to Cargo.toml directory
:init "my_project::cljrs_init"} ; Rust path to the init function
| Sub-key | Description |
|---|---|
:crate | Directory containing the user’s Cargo.toml, relative to cljrs.edn. |
:init | Fully-qualified Rust path to the init function. The first :: segment is treated as the crate name. |
When :rust is present, cljrs run and cljrs repl automatically load the
compiled shared library from <crate>/target/debug/lib<name>.so (or
equivalent). Build it first with cljrs build-native.
Dependency descriptors
Git dependency:
my.lib {:git/url "https://github.com/user/my-lib"
:git/sha "abc1234ef"}
:git/sha must be at least a 7-character commit prefix. The full commit hash
is recommended for reproducibility.
Local dependency:
vendor {:local/root "../vendor/utils"}
:local/root is a path relative to the cljrs.edn file’s directory.
cljrs build-native
Compile the project’s embedded Rust crate to a shared library so that
cljrs run and cljrs repl can load native functions at startup.
cljrs build-native [--release]
This command reads the :rust key from the nearest cljrs.edn, runs
cargo build inside the declared crate directory, and prints the path of the
resulting library to stdout.
Options
--release
Build in release mode (cargo build --release) instead of the default debug
mode. Use this when profiling or shipping.
What it does
- Locates
cljrs.ednby walking up the directory tree. - Reads
:rust :crate(the directory containing the user’sCargo.toml) and:rust :init(the fully-qualified Rust path to the init function). - Derives the crate name from the first
::segment of the init path — e.g."my_project::cljrs_init"→my_project. - Runs
cargo build [--release]in the crate directory. - Prints the output library path on success:
- Linux:
target/debug/libmy_project.so - macOS:
target/debug/libmy_project.dylib - Windows:
target/debug/my_project.dll
- Linux:
Auto-loading
Once the library exists, cljrs run and cljrs repl load it automatically on
startup — no flags required. If the library is absent (not yet built or
deleted), a warning is printed but the interpreter continues; calls to
unregistered native functions will produce a runtime error.
Example
# Build the native library
cljrs build-native
# Now run Clojure code that calls native functions
cljrs run src/main.cljrs
See Rust Interop for a complete walkthrough.
cljrs ir-viz
Render the optimised intermediate representation (IR) for a source file to a self-contained HTML page.
cljrs ir-viz [OPTIONS] <FILE>
The HTML output shows the source side-by-side with the IR, with regions colour-coded by the bump-allocation optimiser’s results. Allocations that did not make it into a region are annotated with their escape verdict and the blamed use site.
This subcommand is primarily a debugging aid for the IR optimisation pipeline.
Arguments
| Argument | Description |
|---|---|
<FILE> | Source file to lower to IR |
Options
-o, --out <FILE>
Output path for the HTML file. If omitted, the output is written alongside
the source file with an .ir.html extension:
src/myapp/core.cljrs → src/myapp/core.cljrs.ir.html
--src-path <DIR>
Add DIR to the source path for require resolution. May be repeated.
--quiet
Suppress the [ir-viz] wrote … progress line on stderr.
Example
cljrs ir-viz src/myapp/core.cljrs
# writes: src/myapp/core.cljrs.ir.html
cljrs ir-viz src/myapp/core.cljrs --out /tmp/core.html --quiet
Open the resulting HTML file in a browser to explore the IR.
Interpreting the output
- Green regions — allocations placed in a bump-allocation region; they do not incur GC heap pressure.
- Red / yellow annotations — allocations that escaped the region, labelled with the reason (returned, captured by closure, stored in heap object, etc.).
- Clicking a source line highlights the corresponding IR instructions and vice versa.
Language overview
clojurust is a dialect of Clojure. Its syntax, data model, and core library are designed to be as compatible with Clojure as possible, with a small number of deliberate extensions and a small number of features that are not yet implemented.
What is the same
- All Clojure literal syntax: symbols, keywords, numbers (long, double, ratio, BigInt, BigDecimal), strings, characters, booleans, nil.
- All collection literals: list
(...), vector[...], map{...}, set#{...}. - Reader dispatch macros:
',`,~,~@,^,@,#',#(...),#"...",##Inf,##-Inf,##NaN, tagged literals. - The full set of special forms:
def,fn*,if,do,let*,loop*,recur,quote,var,set!,throw,try/catch/finally,letfn,binding. - Persistent collections (HAMT-backed maps and sets, RRB vectors, linked lists, queues) with Clojure-compatible equality and hashing.
clojure.core— arithmetic, comparison, collection operations, lazy sequences, transducers, I/O, concurrency primitives.- Standard library namespaces:
clojure.string,clojure.set,clojure.test,clojure.walk,clojure.edn,clojure.zip,clojure.data. - Protocols (
defprotocol,extend-type,extend-protocol), multimethods (defmulti,defmethod), records (defrecord), andreify. - Concurrency:
atom,future,promise,delay,volatile!,agent. - Dynamic variables (
binding,with-bindings,*ns*,*out*, etc.). - Metadata on vars and some values (
with-meta,meta,^:dynamic, etc.).
Extensions
- Reader conditionals — the
:rustplatform key. - Versioned symbols —
my-fn@abc1234syntax. - A small set of new built-in functions with no Clojure equivalent.
Known differences and missing features
See Differences from Clojure for the full list.
Reader conditionals
Reader conditionals allow a single source file to contain code for multiple
Clojure platforms. clojurust evaluates the :rust branch.
Syntax
Non-splicing form
#?(:rust expr-rust
:clj expr-jvm
:cljs expr-clojurescript
:default expr-fallback)
Exactly one branch is selected at read time based on the current platform. For
clojurust, :rust is matched first. If no :rust key is present, :default
is used. If neither is present, the entire form is skipped (reads as nothing).
The selected branch is a single expression; the whole #?(...) form evaluates
to that expression.
(def platform #?(:rust "clojurust"
:clj "JVM Clojure"
:cljs "ClojureScript"
:default "unknown"))
Splicing form
#?@(:rust [a b c]
:clj [x y z]
:default [])
The splicing form #?@(...) selects a vector from the active platform and
splices its elements into the surrounding form. It is only valid inside a list,
vector, map, or set literal.
;; Adds platform-specific items to a vector
(def features [#?@(:rust [:gc :cranelift]
:clj [:jvm :hotspot]
:default [])])
; => [:gc :cranelift] (on clojurust)
;; Platform-specific require in an ns form
(ns myapp.core
(:require [clojure.string :as str]
#?@(:rust [[:clojurust.system :as sys]]
:clj [[:java.lang.System :as sys]])))
File-extension behaviour
| Extension | Platform dispatch |
|---|---|
.cljrs | Always :rust. Reader conditionals are still supported but :rust is always the active branch. |
.cljc | Cross-platform. Reader conditional branches are stored as-is; the evaluator selects :rust. |
Notes
- The reader stores all branches of a
#?(...)form; only the evaluator discards non-matching branches. This means reader-conditional forms can be inspected programmatically without losing the other branches. - Order within a reader conditional matters: keys are checked left-to-right.
:defaultshould come last. - Unlike Clojure, there is no
:cljr(ClojureCLR) platform; the clojurust key is:rust.
Versioned symbols
clojurust lets you pin a symbol or namespace to a specific git commit by
appending @<commit> to its name. This lets callers use a historical
implementation without requiring the defining library to keep the old code
alongside the new.
Syntax
my-fn@abc1234 ; unqualified versioned symbol
my.ns/my-fn@abc1234 ; namespace-qualified versioned symbol
The commit suffix must be a valid hex prefix of at least 7 characters (the full 40-character hash is recommended for reproducibility).
Versioned require
Whole namespaces can be loaded at a specific commit:
(require '[my.lib@abc1234 :as lib-v1])
(require '[my.lib :as lib]) ; HEAD
(lib/some-fn x) ; current version
(lib-v1/some-fn x) ; pinned to abc1234
Both aliases coexist in the same namespace; calls through lib-v1/ always
resolve against commit abc1234.
Propagation semantics
When a function body is evaluated in a versioned context — because it was
loaded via a versioned require or called through a versioned symbol — the
following resolution rules apply:
| Symbol form | Resolved at |
|---|---|
Unqualified or same-namespace, no @ | The inherited commit (propagated from the caller) |
Qualified self-reference (my.ns/x written inside my.ns) | The inherited commit |
Explicitly versioned foo@D | Commit D |
External / cross-namespace, no @ | HEAD (current) |
This means a versioned call behaves like a logical snapshot: internal helpers in the same namespace are drawn from the same commit automatically, but cross-namespace dependencies and the standard library use their current values unless explicitly pinned.
Resolving any pinned symbol loads the whole namespace at that commit into
an immutable namespace named my.ns@<commit>: top-level side effects of the
pinned file run once (when the snapshot is first loaded), historical
definitions never overwrite the live (HEAD) bindings, and the snapshot is
cached for the rest of the session.
Execution tiers
Versioned symbols behave identically everywhere code runs:
- Interpreter — symbols resolve through the shared versioned resolver.
- JIT — hot functions keep their pins: compiled code resolves each pinned reference once through a per-call-site inline cache (versioned bindings are immutable, so the cache never needs invalidation).
- AOT (
cljrs compile) — pins are resolved at compile time: every versioned require and bare versioned symbol is fetched from git during compilation and embedded in the binary. The produced binary is self-contained — no git repository, source tree, or~/.cljrscache is needed where it runs. A pin pointing at a missing commit fails the compile, and a versioned namespace that was not embedded fails at runtime with a clear “was not embedded at compile time” error.
Native (Rust) functions
Native functions live in the running binary, so there is no historical
Clojure source to re-evaluate. By default a pinned lookup of a native
function resolves to the current binary’s implementation — a verified HEAD
binding: the runtime compares the pin against the package’s recorded
provenance (the commit it was built from, declared with
cljrs_interop::register_provenance!). A match is silent; a mismatch or
missing provenance warns once per pin, or errors when
--enforce-native-versions (or :enforce-native-versions true in
cljrs.edn) is set.
For true pinned native code, opt in per dependency (experimental; requires a Rust toolchain at runtime):
{:deps
{my.native.lib {:git/url "https://github.com/user/my-native-lib"
:git/sha "abc1234ef"
:rust/init "my_native_lib::cljrs_init"
:rust/load :dylib}}}
The runtime then builds the dependency’s crate at the pinned commit as a
shared library (cached under ~/.cljrs/cache/dylibs/), verifies an ABI
fingerprint (cljrs version, rustc version, build profile must match the host
exactly), and registers the pinned implementations into the immutable
my.native.lib@<commit> namespace.
Dependency setup
Versioned symbols require the referenced git repository to be cached locally.
Declare the dependency in cljrs.edn and run cljrs deps fetch before using
versioned symbols:
; cljrs.edn
{:deps
{my.lib {:git/url "https://github.com/user/my-lib"
:git/sha "abc1234ef"}}}
cljrs deps fetch my.lib
If the required commit is not cached, clojurust raises a descriptive error rather than attempting a network fetch:
error: dependency 'my.lib' is not cached locally.
run `cljrs deps fetch` to download it.
Signature verification
When --verify-commit-signatures is passed on the CLI (or
:verify-commit-signatures true is set in cljrs.edn), clojurust verifies
that every accessed versioned commit carries a valid PGP or SSH signature
before executing its code. Verification is native (no git/gpg/ssh-keygen
subprocess): the signature must be made by a key listed in the project’s
:trusted-signers. With verification on but no trusted signers configured, no
commit can be verified and resolution fails closed.
Notes
- Versioned symbols are resolved lazily at call time, not at load time, so the dependency only needs to be cached the first time the code path is actually executed.
- The version cache is per-
GlobalEnvand is keyed on"<ns>/<name>@<commit>", so the same commit of the same namespace is loaded at most once per interpreter session.
Differences from Clojure
This page documents intentional differences, missing features, and behavioural
variations between clojurust and Clojure (JVM). The goal is to be a useful
reference for porting .cljc code and for understanding what to expect when
running Clojure code under clojurust.
No JVM, no Java interop
clojurust runs on Rust, not the JVM. There is no Java class hierarchy, no
java.lang.*, and no Java method calls. The . (dot) special form and new
have limited implementations:
(new Exception "msg")and(Exception. "msg")produce clojurust exception values, not Java objects.(.method obj args...)is not yet implemented. Use protocols or the built-in equivalents instead.
Code that uses (System/nanoTime), (Thread/sleep n), or any other Java
static method must use reader conditionals to supply
a :rust alternative, or use the clojurust built-ins nanotime and sleep
respectively.
Platform key
The reader-conditional platform key is :rust, not :clj. See
Reader conditionals.
Missing concurrency features
| Feature | Status |
|---|---|
ref / STM (dosync, alter, ref-set, commute, ensure) | Not implemented |
locking macro | Not implemented |
monitor-enter / monitor-exit | Not implemented |
atom, agent, future, promise, delay, and volatile! are fully
implemented.
deftype
deftype is not implemented. Use defrecord (which is fully supported) or
reify for most cases.
Metadata on collections
Metadata is supported on vars and some values, but not yet propagated through
collection operations such as assoc. The with-meta and meta functions
work on any value that carries metadata.
Sorted collections
sorted-map and sorted-set are implemented. sorted-map-by and
sorted-set-by (custom comparators) are not yet implemented.
Hierarchies
make-hierarchy, ancestors, descendants, and parents have stub
implementations. derive, underive, and a full isa? hierarchy are not yet
implemented. defmulti / defmethod dispatch works but does not consult a
custom hierarchy.
amap and areduce
These macros are registered as stubs but not fully implemented.
clojure.pprint
Not implemented.
clojure.zip
Stub — the namespace exists but most functions are not yet implemented.
Numeric tower
The numeric tower (Long → BigInt → Ratio → BigDecimal → Double) follows Clojure conventions. A few differences to be aware of:
- Integer overflow in
+,-,*automatically promotes toBigInt(same as Clojure’scheckedarithmetic). The promoting variants+',-',*'are also available. (/ 1 3)returns aRatio(1/3), not aDouble, same as Clojure.- BigDecimal precision is controlled by
with-precision(the Clojure macro) or the lower-levelpush-precision!/pop-precision!built-ins.
*clojure-version* / *cljrs-version*
These vars are not yet defined. clojure-version as a function returns a
map describing the current runtime.
with-open and close
clojurust provides RAII-style resource management via with-open (a macro)
and close (a built-in function). These follow the same protocol as Clojure’s
with-open; any value that implements the Resource protocol can be used.
Source file namespace mapping
The namespace→file mapping converts . to / and - to _, the same as
Clojure:
myapp.core → myapp/core.cljrs (or .cljc)
my-app.utils → my_app/utils.cljrs
Standard library
The following clojure.* namespaces are available:
clojure.string, clojure.set, clojure.test, clojure.walk,
clojure.edn, clojure.data.
clojure.zip and clojure.pprint exist as stubs. clojure.spec.alpha,
clojure.core.async, and clojure.core.match are not available.
New built-in functions
clojurust adds a small number of built-in functions that have no direct equivalent in standard Clojure. Most exist because Clojure code would normally reach these capabilities through Java interop, which is not available in clojurust.
System / time
(sleep ms)
Pause the current thread for ms milliseconds.
(sleep 100) ; sleep 100 ms
Clojure equivalent: (Thread/sleep ms).
(nanotime)
Return the number of nanoseconds since the Unix epoch as a Long.
(let [start (nanotime)
_ (do-work)
end (nanotime)]
(println "elapsed ns:" (- end start)))
Clojure equivalent: (System/nanoTime) (note: Clojure’s version is relative to
an arbitrary origin; clojurust’s is Unix epoch-relative).
String utilities
(char-code c)
Return the Unicode code point of character c as a Long.
(char-code \A) ; => 65
(char-code \λ) ; => 955
Clojure equivalent: (int c).
(char-at s i)
Return the character at index i in string s.
(char-at "hello" 1) ; => \e
Clojure equivalent: (.charAt s i).
(string->list s)
Convert string s to a list of its characters.
(string->list "abc") ; => (\a \b \c)
Clojure equivalent: (seq s) (returns a seq, not a list, but behaves the same
in most contexts).
(number->string n)
Convert number n to its string representation.
(number->string 42) ; => "42"
(number->string 3.14) ; => "3.14"
Clojure equivalent: (str n).
(string->number s) / (string->number s base)
Parse string s as a number, returning nil if the string is not a valid
number. The optional base argument specifies the radix (default 10).
(string->number "42") ; => 42
(string->number "3.14") ; => 3.14
(string->number "ff" 16) ; => 255
(string->number "nope") ; => nil
Clojure equivalent: no single equivalent; typically (Integer/parseInt s),
(Double/parseDouble s), or a try/catch around those.
BigDecimal precision
(push-precision! n) / (push-precision! n mode)
Push a BigDecimal precision context onto the thread-local precision stack.
Subsequent BigDecimal operations are rounded to n significant digits using
mode (default HALF_UP).
Available rounding modes: CEILING, FLOOR, HALF_UP, HALF_DOWN,
HALF_EVEN, UP, DOWN, UNNECESSARY.
(push-precision! 4)
(/ 1M 3M) ; => 0.3333 (4 significant digits)
(pop-precision!)
This is the lower-level mechanism underlying the with-precision macro (which
is preferred in normal code). Use push-precision! / pop-precision! only
when you need to manage the precision stack manually across multiple calls.
Clojure equivalent: with-precision (macro).
(pop-precision!)
Pop the most recently pushed BigDecimal precision context.
Persistent queue
(queue) / (queue capacity)
Create a new empty persistent queue. The optional capacity argument is a size
hint for the initial allocation.
(def q (queue))
(def q2 (conj q :a :b :c))
(peek q2) ; => :a
(pop q2) ; => queue with [:b :c]
Clojure equivalent: clojure.lang.PersistentQueue/EMPTY (a Java static field).
Mutable ArrayList
These functions provide a mutable, GC-managed resizable array backed by a Rust
Vec. They are intended for performance-sensitive code that builds up a
collection before converting it to an immutable value, or for interoperating
with Rust code that expects a mutable sequence.
An ArrayList value is a NativeObject; it is not a Clojure collection and
cannot be used with seq, conj, map, etc. directly. Convert it with
array-list-to-array first.
(array-list) / (array-list capacity)
Create a new empty ArrayList. With a Long argument, pre-allocates storage
for capacity elements.
(def al (array-list 16))
(array-list-push al v)
Append value v to the end of al. Returns al (mutates in place).
(array-list-push al :x)
(array-list-push al :y)
(array-list-remove al i)
Remove and return the element at index i. Later elements shift left.
(array-list-remove al 0) ; removes and returns first element
(array-list-length al)
Return the number of elements in al as a Long.
(array-list-length al) ; => 2
(array-list-to-array al)
Convert al to an immutable object array (Value::ObjectArray). The
ArrayList is unaffected.
(def arr (array-list-to-array al))
(alength arr) ; => 2
(array-list-clear al)
Remove all elements from al. Returns al.
(array-list-clear al)
Rust interop
(native-object? x)
Return true if x is a NativeObject (a Rust value wrapped for use in
clojurust), false otherwise.
(native-object? (array-list)) ; => true
(native-object? [1 2 3]) ; => false
(native-type x)
Return the type-tag string of NativeObject x, or nil if x is not a
NativeObject.
(native-type (array-list)) ; => "ArrayList"
(native-type 42) ; => nil
The type tag is set by the Rust code that implements the NativeObject trait
and is the primary mechanism for dispatching on native types from clojurust
code.
Async & I/O
clojurust ships asynchronous concurrency and non-blocking file I/O as two optional crates that layer on top of the interpreter:
| Crate | Namespace | Provides |
|---|---|---|
cljrs-async | clojure.core.async | CSP channels, go blocks, ^:async/await, timeout, alts/alt, pipelines |
cljrs-io | clojure.rust.io.async | non-blocking file reads and writes delivered over core.async channels |
Both are wired into the cljrs CLI by default (via its async feature) and
can be embedded by Rust programs that link the crates directly.
Design at a glance
Async support is delivered as a separate library, mirroring how
clojure.core.async ships as its own JAR on the JVM. The core interpreter
crates contain no Tokio dependency and no #[cfg(feature = "async")] guards;
they expose a single hook trait (AsyncRuntime) that cljrs-async registers
into the environment at startup. The only conditional compilation lives in the
CLI binary.
The whole async tier runs on a Tokio current_thread runtime driving a
LocalSet. Every Clojure value stays on one thread, which keeps the
garbage collector’s !Send pointers (GcPtr<T>) sound — no value ever crosses
a thread boundary inside async code. CPU-bound parallelism continues to use the
existing thread-based future, pmap, and agent primitives, which are
unchanged.
Two ways to wait
clojurust distinguishes async waiting from blocking waiting:
awaitis a special form. Inside an^:asyncfunction (or agoblock), it yields the single executor thread to other tasks until the value it is given — aFuture, apromise, or a channel operation — resolves.deref/@blocks the calling OS thread. Usingderefon a future from within an^:asyncbody is a runtime error that steers you toawait.
Without cljrs-async loaded, (await x) falls back to a blocking deref, so the
form is still meaningful in purely synchronous code.
To scale Clojure work across CPU cores, that single-threaded executor is instantiated multiple times as independent isolates, each with its own heap and collector. Values move between isolates by an explicit copy rather than a shared pointer. See the Worker isolation chapter for the model and its rationale.
See the core.async chapter for the concurrency model, the Worker isolation chapter for scaling across cores, and the Asynchronous I/O chapter for the channel-oriented filesystem API.
core.async
The clojure.core.async namespace provides CSP-style concurrency — channels,
go blocks, and the ^:async/await function model — implemented on a Tokio
current_thread runtime and LocalSet. It is provided by the cljrs-async
crate and loaded automatically by the cljrs CLI.
(require '[clojure.core.async :refer [chan go take! put! close! timeout alts]])
The execution model
All async tasks cooperate on a single executor thread. This is a deliberate
choice: Clojure values are managed by a tracing GC whose pointers are !Send,
so they cannot be moved between threads. Running every async task on one thread
via LocalSet keeps those pointers sound and keeps garbage collection simple —
“stop the world” just means “finish the current poll, collect, resume.”
Two tiers coexist:
| Tier | Runs on | Used by |
|---|---|---|
| Async (single thread) | Tokio LocalSet | ^:async fns, go, channels, timeout, alts |
| Parallel (thread pool) | OS threads | future, pmap, agent (unchanged core primitives) |
Both tiers produce Value::Future, so a caller can await or deref either.
To run Clojure work on more than one core, the single-threaded executor is instantiated multiple times as independent isolates; see the Worker isolation chapter.
^:async functions and await
A function tagged ^:async runs its body as an async task. Calling it returns
a Future immediately; the body runs cooperatively, yielding the executor at
every await.
(defn ^:async fetch [url]
(let [resp (await (http-get url))] ; yields until the request resolves
(:body resp)))
(fetch "https://example.com") ; returns a Future right away
(await (fetch "https://example.com")) ; yields until the body is ready
await is a special form, not a function — it is syntactically detectable
so the compiler can recognise yield points. Its behaviour depends on context:
- Inside an
^:asyncbody (driven by the async evaluator),(await x)yields the executor thread untilx— aFuture, apromise, or a channel op — resolves, then returns the resolved value. - Outside any async context,
(await x)falls back to a blocking deref of the value, so the form still works in synchronous code.
^:async is viral: a function that uses await should itself be ^:async.
await vs. deref
deref / @ always blocks the calling OS thread. Calling deref on a future
inside an ^:async body is a runtime error:
(defn ^:async bad [f]
@f) ; error: use (await ...) instead of deref inside an ^:async function
This enforcement (via the interpreter’s async-context flag) prevents the classic deadlock where a task blocks the only executor thread waiting on a future that can only be resolved by that same thread.
Channels
Channels are CSP conduits between tasks. They are implemented as NativeObjects
(CljChannel) rather than a dedicated Value variant, keeping the core value
model free of async concerns.
(chan) ; unbuffered — a rendezvous channel
(chan 0) ; same as (chan)
(chan 10) ; buffered, capacity 10
- An unbuffered (rendezvous) channel hands a value directly from a
put!to atake!: theput!resolvestrueonly once a taker consumes the value. - A buffered channel accepts
put!s while it has room and servestake!s while it is non-empty. - A closed channel drains any buffered values, then
take!yieldsnilandput!resolvesfalse.
Channel operations
Operations that can block return a Value::Future, so they are used with
await inside an async context:
| Operation | Meaning |
|---|---|
(take! ch) | await the next value (or nil once closed and drained) |
(put! ch v) | await acceptance of v; resolves true, or false if closed |
(close! ch) | close the channel (idempotent) |
Non-blocking variants act synchronously and return immediately:
| Operation | Meaning |
|---|---|
(poll! ch) | take a buffered value now, or nil if none is ready |
(offer! ch v) | put v now if there is buffer room → true/false |
Blocking variants park the OS thread and are meant for the REPL, tests, and
other synchronous contexts — not for use inside an ^:async body or from
the single-threaded executor thread (they deadlock there):
| Operation | Meaning |
|---|---|
(<!! ch) | blocking take |
(>!! ch v) | blocking put |
go blocks
go spawns its body as an anonymous async task and returns a Future:
(def in (chan 1))
(def out (chan 1))
(go (let [v (await (take! in))]
(await (put! out (* v 2)))))
(await (put! in 21))
(await (take! out)) ; => 42
Selection: timeout, alts, alt
timeout returns a future that delivers nil after a delay:
(timeout 5000) ; => Future resolving to nil after 5 s
alts waits on a vector of futures (or channel ops) and returns
[value index] for whichever resolves first:
(let [[v i] (await (alts [(take! ch) (timeout 1000)]))]
(if (= i 1) (println "timed out") (println "got" v)))
alt is a macro that pairs each future with a handler, awaits alts, and
dispatches to the matching handler:
(alt
(take! ch1) (fn [v] (println "ch1:" v))
(take! ch2) (fn [v] (println "ch2:" v))
(timeout 500) (fn [_] (println "timed out")))
Higher-level utilities
| Function | Description |
|---|---|
(join-all futs) | await a seq of futures, returning a vector of results (like Promise.all) |
(async-pmap f coll) | spawn f over coll concurrently and await all results |
(thread f) / (thread-call f) | run f on a real OS thread (spawn_blocking); deliver its result over a channel |
(onto-chan! ch coll) | put every element of coll onto ch, then close it |
(to-chan! coll) | return a channel and seed it from coll in the background |
(merge chs) | fan several channels into one |
(mult ch) + (tap! m ch) / (untap! m ch) / (untap-all! m) | broadcast one source channel to many taps |
(reduce f init ch) | fold over a channel until it closes |
(into coll ch) | drain a channel into a collection |
Garbage collection
Because the async tier is single-threaded, GC safepoints are cooperative: the
runtime collects between poll cycles, and await invokes a safepoint before
each yield. A background GC-service task (spawned by the crate’s init)
services collection requests, and explicit root guards keep spawned task
futures and their captured environments reachable while they are in flight.
Embedding from Rust
cljrs-async is a standalone crate. Call init from inside a LocalSet
context, then evaluate code as usual:
#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = tokio::task::LocalSet::new();
rt.block_on(local.run_until(async {
let globals = cljrs_stdlib::standard_env();
cljrs_async::init(&globals);
// ... evaluate code ...
}));
}
The CLI links this crate when built with its default async feature; a minimal
binary can be produced with cargo build -p cljrs --no-default-features.
WASM note. In the browser REPL,
initmay run before aLocalSetexists; the GC-service spawn no-ops in that case and should be re-invoked from inside aLocalSet::run_untilblock.timeoutuses the browser’ssetTimeout(viagloo-timers) onwasm32instead oftokio::time::sleep.
Worker isolation
The async tier described in core.async runs on a single executor
thread. That keeps the garbage collector simple, but one interpreter thread can
only use one CPU core. To scale Clojure work across cores, clojurust uses an
isolate model: each worker is an independent execution context with its own
heap, its own collector, and its own current_thread + LocalSet executor.
Isolates share no GC pointers. A value moves between them by being copied,
never by being aliased.
This chapter explains how that differs from Clojure, why the boundary is an explicit copy instead of transparent sharing, and how to work with it.
What an isolate is
An isolate is just “the single-threaded async runtime from the previous chapter, instantiated N times.” Each one:
- owns a private GC heap (a thread-local) and collects it independently — no global stop-the-world, no cross-isolate coordination;
- runs its own Tokio
current_threadruntime andLocalSet, sogoblocks, channels, and^:asyncfunctions behave exactly as they do in a single-isolate program; - references the shared static arena (compiled code, interned keywords/symbols) with no copy, so isolates run the same program without duplicating it.
Because nothing is shared between heaps, two isolates collect garbage in parallel and throughput scales with the number of isolates rather than bottlenecking on one collector.
Differences from Clojure
JVM Clojure assumes one shared, globally-visible heap. Several of its concurrency features lean on that assumption; the isolate model deliberately does not reproduce them bit-for-bit.
| Clojure (JVM) | clojurust |
|---|---|
future runs the body on another OS thread, sharing the heap | future is loop-async on the same isolate — concurrent, not parallel. Cross-core parallelism is a separate, explicit step. |
atom, ref, and var roots are globally shared mutable cells any thread can see | atom is isolate-local and fast. Genuinely shared mutable state is a distinct primitive, shared-atom. |
| Passing a value to another thread shares a pointer — zero copy, but also zero isolation | Passing a value to another isolate deep-copies it. The two sides are fully independent afterward. |
| Any value (closures, refs, mutable objects) can be handed to another thread | Only plain, immutable, acyclic data can cross an isolate boundary; everything else is rejected at the send site. |
The guiding stance (from docs/async-worker-pool-plan.md) is that pure-Clojure
compatibility is not a hard constraint: where an honest separation of concerns
conflicts with reproducing JVM semantics exactly, separation wins, and clojurust
adds new primitives on top where they make sense. shared-atom and the isolate
channels below are those primitives.
Why explicit copying instead of transparent sharing
The decision to copy at the boundary — rather than share pointers like the JVM — falls out of one technical fact and one design value.
The technical fact: GcPtr is !Send. Every Clojure value lives behind a
GcPtr, which is a raw heap pointer into one isolate’s heap. It is honestly
not safe to send across threads, and the Rust type system enforces that. You
cannot accidentally leak a pointer from one isolate into another — it is a
compile error, not a runtime hazard. This is the same property that lets
transients and the single-threaded collector stay unsynchronized and fast.
So if a value is going to cross from one heap to another, it has to be
reconstructed on the far side. That reconstruction is the copy. There is no
“share it instead” option to fall back to — the alternative was an unsafe
shared-heap discipline, which was rejected because it forced serialized global
GC pauses and a single allocation bottleneck.
The design value: no invisible surprises. Given that crossings cost a copy,
the remaining question is whether the programmer can see the cost. The plan
(docs/isolate-boundary-plan.md) commits to four guarantees so the boundary is
honest:
- Crossing only happens through an operation you typed. You send through a
distinct construct (an isolate channel), so a copy never hides inside an
ordinary
(chan)or function call. You know a copy is coming because of the target you are holding, not because you annotated the message — the same way Erlang’sPid ! Msgtells you a copy happens because you are sending to a process. - The parallel primitive is distinct from
future. The same source must not be a cheap loop-async task in one place and a silent deep copy onto another isolate in another. Parallel-across-isolates is its own primitive, never a re-interpretation offuture. - The copy is metered. Every accepted crossing records bytes copied and time
into
GC_STATS, visible via--gc-stats. A fan-out that deep-copies a 2 MB map to eight workers shows up as a number, not as mystery latency. - Can’t-cross failures are located. A value that cannot cross raises an error at the send site, naming the offending type — not deep inside the scheduler.
The trap this avoids is the one where identical-looking code is sometimes free and sometimes an expensive deep copy, decided by scheduling you cannot see. The explicit boundary trades a little ceremony for the property that cost and failure are always attached to something you wrote.
Isolate channels — the copy boundary
An isolate channel is the sanctioned way to move a value between isolates. It
is a distinct constructor from (chan), precisely so the crossing is visible
in source.
(require '[clojure.core.async :refer [isolate-chan isolate-put! isolate-poll! isolate-take!]])
(let [[tx rx] (isolate-chan)]
(isolate-put! tx {:a 1 :b [2 3]}) ; deep-copies the map across the boundary → true
(isolate-poll! rx)) ; => {:a 1 :b [2 3]} (an independent copy)
| Operation | Meaning |
|---|---|
(isolate-chan) | create a channel, returning [tx rx]. tx is multi-producer (cloneable); rx is single-consumer. |
(isolate-put! tx v) | deep-copy v across the boundary and enqueue it. true on success, false if the receiver is gone, throws if v can’t cross. |
(isolate-poll! rx) | non-blocking take: the next value (copied into this isolate’s heap), or nil if empty/closed. |
(isolate-take! rx) | a Future resolving to the next value, or nil once closed and drained. Use with await in a go/^:async body. |
;; park until a value arrives, inside an async body:
(go (let [msg (await (isolate-take! rx))]
(handle msg)))
The receiver deserializes into the heap of whichever isolate holds it, so keep
rx on the isolate that will consume from it. The sender can be cloned and used
from anywhere.
Current scope. The Clojure-level primitive that spawns a worker isolate (
pfuture/spawn) is deferred — it needs the shared code arena so a worker can see the running program without copying it. Today, isolate spawning is a Rust-level facility (cljrs_async::isolate::Isolate), and from Clojure both ends of anisolate-chanusually live on one isolate. The channel still pays — and still meters — the honest deep copy, so the boundary is observable now and your code is already written against the API it will keep.
What can and cannot cross
Only plain, immutable, acyclic data can be copied across the boundary.
isolate-put! (and shared-atom) accept:
- all scalars:
nil, booleans, longs, doubles, chars, bigints, ratios, UUIDs; - strings, symbols, keywords, and compiled regex patterns (by source);
- all persistent collections — lists, vectors, maps, sets, queues, cons cells — and records, recursively;
- primitive and object arrays (snapshotted), and error values (message + data + cause chain);
- realized lazy sequences (they are forced first, then the result is copied).
The following hold isolate-local state and are rejected at the send site:
- mutable cells —
atom,volatile,var,promise,future,agent; - functions and macros of every kind — a closure captures
GcPtrs from its home isolate, so it cannot be reconstructed elsewhere; - native
Resources andNativeObjects (OS handles, channels — bound to one isolate); - transients and an unforced
delay(deliberately thread-confined).
The error is located and names the type, e.g.:
(isolate-put! tx (fn [] 1))
;; => throws: isolate-put!: value of type `fn` cannot cross an isolate boundary;
;; the value holds isolate-local state and cannot cross an isolate boundary
If you need to hand work to another isolate, send it data describing the work (a keyword tag, a vector of arguments) and have the receiving isolate dispatch to code it already has — not a closure.
shared-atom — cross-isolate mutable state
For genuinely shared mutable state, use shared-atom. It is the second tier of a
deliberate two-tier design: atom stays local and fast; shared-atom is the
explicit, opt-in tool for sharing.
(def counter (shared-atom 0))
(swap! counter inc)
(swap! counter + 10)
@counter ; => 11
(compare-and-set! counter 11 0) ; lock-free CAS, like a normal atom
(shared-atom? counter) ; => true
(atom? counter) ; => false — it is a distinct type
It supports the full atom surface — deref/@, reset!, swap!,
compare-and-set! — with lock-free atomic updates that are safe across
isolates. Under the hood the cell is an Arc<ArcSwap<…>> and its contents are a
Send + Sync value representation, so the same reference can be handed to another
isolate (it crosses an isolate channel by a refcount bump, not a deep copy) and
mutated concurrently from both.
The cost is paid on write: each value stored is promoted into the shared
representation. The same shareability rule applies — you can only publish plain,
immutable, acyclic data. Storing a closure or other isolate-local value into a
shared-atom fails, and a failed swap! leaves the atom unchanged:
(def a (shared-atom 0))
(swap! a (fn [_] (fn [] 1))) ; throws — the new value is a closure
@a ; => 0 (swap did not take effect)
Use shared-atom only where you actually need cross-isolate sharing; keep the
common case on a local atom, which avoids all promotion and refcount traffic.
The Send worker pool
Not every parallel task needs a whole isolate. Byte-level work that touches no
Clojure values — socket reads and writes, TLS handshakes and bulk crypto,
compression, hashing — runs on a multi-threaded Send-only worker pool
instead. The heap thread offloads such work and awaits the result, which comes
back as plain Send data (Vec<u8>, a string) that the heap thread turns into a
Clojure value.
This is the seam cljrs-net uses: a socket lives in the pool as byte traffic,
and the isolate’s interpreter only ever sees byte-arrays it constructed itself.
The pool is a Rust-level facility (WorkerPool), used by native crates rather
than called directly from Clojure; it lets I/O-bound servers scale across cores
while Clojure logic stays on its isolate.
Using isolation effectively
- Default to local. Plain
atom,future, channels, and collections are bump-allocated and fast. Reach for isolates andshared-atomonly when you need real multicore execution or cross-isolate sharing. - Send data, not behavior. A closure cannot cross. Pass a tag plus arguments and let the receiving isolate dispatch to code it already holds.
- Keep the receiver pinned. An isolate-channel
rxdeserializes into its own heap; consume from it on the isolate that owns it. - Make crossings coarse. Each crossing is a deep copy. Prefer sending one larger message over chattering many small ones, and don’t fan a large value out to many workers without expecting the copy cost.
- Watch the meter. Run with
--gc-statsto see bytes copied and time spent at the boundary. If one value dominates, that is the value to restructure (or, later, to make zero-copy). - Let failures guide you. A located “cannot cross” error usually means a closure or a stateful object slipped into your message. Replace it with plain data.
Looking ahead
The boundary that ships today is deep-copy-on-send with the four visibility
guarantees above. A future phase adds a zero-copy fast path — explicitly
constructed shared-vec/shared-map values that are born in the Arc-backed
shared representation and cross by refcount instead of by copy, demoting back to
ordinary GC-backed collections the moment they hold something non-shareable. The
boundary itself does not move; the telemetry from the metered seam is what will
tell you which values are worth promoting to that form. See
docs/isolate-boundary-plan.md for the full design.
Asynchronous I/O
The clojure.rust.io.async namespace exposes the host filesystem to Clojure as
a non-blocking, channel-oriented API. Every operation runs on the same Tokio
LocalSet executor that drives core.async and returns a
clojure.core.async channel, so file I/O composes with go, take!, alts,
and the rest of the async toolkit instead of blocking the interpreter thread.
It is provided by the cljrs-io crate and loaded automatically by the cljrs
CLI (with its default-on async feature).
(require '[clojure.rust.io.async :as aio]
'[clojure.core.async :refer [take!]])
The channels it returns are ordinary core.async channels — cljrs-io reuses
cljrs-async’s CljChannel rather than defining its own type.
Two channel shapes
The API deliberately uses two channel shapes, chosen per operation:
-
Streaming reads return a raw channel. The call returns immediately and a background producer task reads the file and puts a sequence of values onto the channel, closing it at EOF. A small buffer (
cap, default 8) bounds how far the producer reads ahead of the consumer, so even a multi-gigabyte file is streamed with backpressure rather than slurped into memory. -
Discrete request/response ops return a promise channel. The call returns a capacity-1 channel onto which the producer delivers exactly one result before closing it. A single
take!yields the value; a second yieldsnil. This signals “resolves exactly once” while staying uniformly takeable alongside everything else.
Streaming reads
Each returns a channel immediately and closes it at EOF:
| Function | Yields |
|---|---|
(chunk-chan path [buf-size [cap]]) | byte-array chunks of up to buf-size bytes (default 8192) |
(byte-chan path [cap]) | individual bytes as signed longs (-128..127) |
(char-chan path [charset [cap]]) | characters decoded with charset (default :utf-8) |
(line-chan path [charset [cap]]) | lines, without the trailing \n / \r\n |
(go (loop []
(when-let [line (await (take! (line-chan "big.log")))]
(println line)
(recur))))
Discrete operations
Each returns a one-shot promise channel carrying a single result:
| Function | Delivers |
|---|---|
(slurp path [charset]) | the whole file as a decoded string |
(slurp-bytes path) | the whole file as a byte-array |
(read-bytes path n) | a byte-array of up to the first n bytes |
(spit path data [charset]) | the number of bytes written (data is a string or byte-array) |
(go (let [text (await (take! (slurp "config.edn")))]
(println text)))
Error handling
Failures are delivered in band: the producer puts an error value (an
exception) onto the channel and then closes it. Consumers distinguish results
from failures with the error? / ok? helpers:
(require '[clojure.core.async :refer [<!!]]
'[clojure.rust.io.async :as aio])
(let [result (<!! (aio/slurp "config.edn"))]
(if (aio/error? result)
(println "read failed:" (ex-message result))
(println result)))
Top-level consumption. From
cljrs repl,cljrs run, orcljrs eval, consume results with(await (take! ...)). The blocking<!!/>!!ops deadlock the single-threaded executor at the top level — they are for use off the executor thread (separate test threads, embedders), not for top-level CLI forms.
Charsets
The charset argument is a keyword or string label resolved by encoding_rs,
defaulting to UTF-8:
(aio/slurp "data.txt" :utf-8)
(aio/char-chan "legacy.txt" :windows-1252)
(aio/line-chan "jp.txt" :shift_jis)
Supported labels include :utf-8, :utf-16le, :iso-8859-1, :windows-1252,
:shift_jis, and the rest of the encoding_rs set.
Status and scope
The eight builtins above plus the error? / ok? helpers are implemented.
Candidate follow-ups not yet covered: a stateful AsyncReader handle with a
cursor (open / read-chunk! / seek), append/options maps for spit,
directory streaming, and transducer-equipped channels.
Embedding from Rust
cljrs-io::init registers the namespace; it is idempotent and requires
cljrs_async::init and a running LocalSet:
#![allow(unused)]
fn main() {
rt.block_on(local.run_until(async {
let globals = cljrs_stdlib::standard_env();
cljrs_async::init(&globals); // required first
cljrs_io::init(&globals);
// ... evaluate code ...
}));
}
Charset encoding and decoding
The clojure.rust.charset namespace provides streaming charset encoding and
decoding backed by encoding_rs,
which implements the WHATWG Encoding Standard. The companion namespace
clojure.rust.charset.async wraps the same codecs as channel-to-channel
transformers that compose naturally with the rest of the async I/O stack.
Both are provided by the cljrs-charset crate and loaded automatically by the
cljrs CLI.
(require '[clojure.rust.charset :as charset])
(require '[clojure.rust.charset.async :as ca])
Charset labels
Any label recognized by encoding_rs is accepted as a keyword or string:
:utf-8 :utf-16le :shift-jis :windows-1252 :iso-8859-1 ...
nil or an omitted argument defaults to UTF-8. The full list of accepted
labels is defined by the WHATWG Encoding Standard.
Streaming codecs
Streaming codecs handle input that arrives in pieces — TCP segments, buffered reads — without buffering the whole input first.
Creating a codec
(charset/decoder) ; => CljDecoder — UTF-8
(charset/decoder :shift-jis) ; => CljDecoder — Shift-JIS
(charset/encoder) ; => CljEncoder — UTF-8
(charset/encoder :windows-1252) ; => CljEncoder — Windows-1252
Feeding chunks
(charset/update! dec chunk) ; => string — decoded output so far
(charset/update! enc string) ; => byte-blob — encoded bytes so far
update! may return an empty string or empty byte-blob if the chunk ended on a
multibyte boundary; the partial state is held inside the codec.
Flushing
(charset/finish! dec) ; => string — any remaining decoded characters
(charset/finish! enc) ; => byte-blob — any remaining encoded bytes
After finish!, the codec is closed and further calls return an error.
Full example
;; Decode a Shift-JIS file streamed in two chunks
(let [dec (charset/decoder :shift-jis)]
(println (charset/update! dec chunk1))
(println (charset/update! dec chunk2))
(println (charset/finish! dec)))
One-shot helpers
When the entire input is available at once, the one-shot helpers are more convenient:
| Function | Signature | Returns |
|---|---|---|
decode | (decode bytes) / (decode bytes charset) | decoded string |
encode | (encode string) / (encode string charset) | byte-blob |
(charset/decode my-bytes :windows-1252) ; => "Hello World"
(charset/encode "こんにちは" :shift-jis) ; => #bytes[...]
Unmappable characters
When encoding to a non-Unicode charset, characters that have no representation are replaced with HTML numeric character references:
😀 → 😀
Async channel transformers
clojure.rust.charset.async wraps the streaming codecs as channel-to-channel
transformers. Each function returns an output channel immediately; a background
producer task drives the conversion.
| Function | Signature | Input | Output |
|---|---|---|---|
decode-chan | (decode-chan bytes-chan [charset [buf]]) | ByteBlob values | string values |
encode-chan | (encode-chan strings-chan [charset [buf]]) | string values | ByteBlob values |
The third argument sets the output channel buffer depth (default 8). The producer yields whenever the consumer has not drained the buffer, applying natural backpressure back to the upstream source.
Closure and errors
- Closing the input channel (or putting
nil) signals the end of the stream. The producer flushes any partial codec state — for example a split multibyte sequence — and then closes the output channel. - Non-blob / non-string values, including
Value::Error, are forwarded to the output channel unchanged so consumers can detect upstream failures.
Example: network decode pipeline
(require '[clojure.rust.net :as net])
(require '[clojure.rust.charset.async :as ca])
(require '[clojure.core.async :refer [go <! close!]])
;; byte-chan from a TCP connection → decode UTF-8 → process strings
(let [conn (await (take! (net/connect {:host "example.com" :port 80})))
strings-ch (ca/decode-chan (:in conn) :utf-8)]
(go (loop []
(when-let [s (<! strings-ch)]
(process! s)
(recur)))
(close! (:out conn))))
Example: encode and send
;; Encode strings as Shift-JIS and write to a connection
(let [in-ch (async/chan 8)
out-ch (ca/encode-chan in-ch :shift-jis)]
(async/onto-chan! in-ch ["Hello" " " "世界"])
;; out-ch delivers ByteBlob values ready to put on (:out conn)
)
Embedding from Rust
cljrs_charset::init registers the sync namespace; init_async registers the
channel-based namespace. Both are idempotent. init_async requires
cljrs_async::init and a running Tokio LocalSet.
#![allow(unused)]
fn main() {
cljrs_async::init(&globals); // required before init_async
cljrs_charset::init(&globals);
cljrs_charset::init_async(&globals);
}
The namespace name constants are also exported for use in attribute maps or
require calls from Rust:
#![allow(unused)]
fn main() {
cljrs_charset::NS // "clojure.rust.charset"
cljrs_charset::NS_ASYNC // "clojure.rust.charset.async"
}
Networking
The clojure.rust.net family of namespaces provides channel-oriented TCP, TLS,
Unix-domain, and UDP sockets. Every socket is modelled as a pair of
clojure.core.async channels — bytes arrive on :in, bytes leave through
:out. This is the same duplex-channel model used by libraries such as aleph on
the JVM, so protocols can be written as ordinary channel operations: go loops,
take!, put!, alts, and the framing helpers.
The stack is provided by the cljrs-net crate and loaded automatically by the
cljrs CLI.
(require '[clojure.rust.net :as net])
| Namespace | Contents |
|---|---|
clojure.rust.net.tcp | Plain TCP client and server |
clojure.rust.net.tls | TLS client and server (rustls) |
clojure.rust.net.unix | Unix-domain stream sockets (#[cfg(unix)]) |
clojure.rust.net.udp | UDP datagrams |
clojure.rust.net.frame | Stateful framers and encode helpers |
clojure.rust.net | Umbrella namespace — dispatches on :transport |
Connection model
connect returns a capacity-1 promise channel (the discrete-op shape used
throughout clojurust I/O). Taking from it yields either a connection map or an
error:
{:in <chan> ; byte-array chunks, closed at EOF/error
:out <chan> ; put byte-arrays or strings here; close to half-close
:remote-addr "ip:port"
:local-addr "ip:port"
:resource <handle>} ; call (net/close conn) to release the FD
Half-close: (close! (:out conn)) sends a TCP FIN while leaving :in
open, so you can finish reading any in-flight data before the peer closes its
side. (net/close conn) tears down both halves immediately.
Server model
listen binds synchronously and returns a server map immediately:
{:conns <chan> ; yields a connection map per accepted socket; closed at shutdown
:local-addr "ip:port"
:resource <handle>} ; call (net/close server) to stop accepting
Taking from :conns blocks until the next accepted connection or the listener
closes. Backpressure is propagated: when :conns is full the accept loop parks
until the application drains the channel.
start-server is sugar that spawns a go-loop accepting from :conns and
calling a handler for each connection:
(net/start-server
(fn [conn]
(go (loop []
(when-let [chunk (<! (:in conn))]
(>! (:out conn) chunk)
(recur)))
(close! (:out conn))))
{:port 8080})
Umbrella dispatch
clojure.rust.net delegates every call to the right namespace based on the
:transport key (defaulting to :tcp):
(net/connect {:host "example.com" :port 443 :transport :tls})
(net/connect {:path "/tmp/app.sock" :transport :unix})
(net/listen {:port 8080}) ; :tcp by default
The umbrella close inspects the map shape to decide whether the argument is a
server, a stream connection, or a UDP socket:
(net/close conn) ; :remote-addr present → stream connection
(net/close server) ; :conns present → listener
(net/close udp) ; everything else → UDP socket
Lifecycle helpers
These are provided by clojure.rust.net and work with any connection or server:
;; Ensure a connection is closed even if the body throws
(with-open [c (await (take! (net/connect opts)))]
body...)
;; Separate a stream of values from the first error
(let [{:keys [out err]} (net/split-err (:in conn))]
;; out — channel of non-error values
;; err — promise of the first error, or nil at clean EOF
)
;; Consume an entire channel, collecting results
(let [{:keys [values error]} (await (net/drain-to (:in conn)))]
...)
Pool-based I/O (Phase A2)
TCP and TLS byte-level I/O runs on a WorkerPool multi-thread runtime. The
LocalSet executor — which owns all GcPtr<Value> — interacts with it through
lightweight bridge tasks that convert between Rust bytes and Clojure values.
This keeps the heap thread responsive under sustained byte traffic while
preserving the single-thread invariant that the garbage collector requires.
UDP and Unix sockets use a simpler single-task model; their I/O volume does not justify the bridge overhead.
TCP
(require '[clojure.rust.net.tcp :as tcp])
;; Client
(let [conn (await (take! (tcp/connect {:host "example.com" :port 80})))]
...)
;; Server — manual accept loop
(let [server (tcp/listen {:port 8080})]
(go (loop []
(when-let [conn (<! (:conns server))]
(handle conn)
(recur)))))
;; Server — with sugar
(tcp/start-server handle-fn {:port 8080})
connect accepts :in-buf and :out-buf keyword options to set channel buffer
depths (default 8 each). listen additionally accepts :host (default
"0.0.0.0") and :conns-buf.
TLS
(require '[clojure.rust.net.tls :as tls])
;; Client — uses WebPKI roots by default
(tls/connect {:host "example.com" :port 443})
;; Client — system roots or custom CA bundle
(tls/connect {:host "internal.example.com" :port 8443 :roots :system})
(tls/connect {:host "dev.local" :port 8443 :roots "ca.pem"})
;; Client — disable cert verification (testing only)
(tls/connect {:host "localhost" :port 8443 :insecure-skip-verify true})
;; Client — ALPN negotiation
(tls/connect {:host "example.com" :port 443 :alpn ["h2" "http/1.1"]})
;; Server — PEM cert and key required
(tls/listen {:port 8443 :cert "cert.pem" :key "key.pem"})
(tls/start-server handle-fn {:port 8443 :cert "cert.pem" :key "key.pem"})
The returned connection and server maps have the same shape as TCP.
Unix-domain sockets
Unix-domain sockets are only available on Unix targets. On other platforms the
functions are registered but throw "not supported on this platform".
(require '[clojure.rust.net.unix :as unix])
;; Client
(unix/connect {:path "/tmp/app.sock"})
;; Server — automatically unlinks the path on close
(unix/listen {:path "/tmp/app.sock"})
(unix/start-server handle-fn {:path "/tmp/app.sock"})
listen pre-unlinks any stale socket file at the path before binding, so
restarting a server after a crash does not require manual cleanup. close also
unlinks the path.
UDP
UDP sockets use a datagram map on both :in and :out:
(require '[clojure.rust.net.udp :as udp])
(let [sock (udp/socket {:port 9000})]
;; Receive: {:data <byte-array> :addr "ip:port"}
(go (loop []
(when-let [pkt (<! (:in sock))]
(println (:addr pkt) "->" (count (:data pkt)) "bytes")
(recur))))
;; Send
(>! (:out sock) {:data my-bytes :addr "10.0.0.1:9000"})
(udp/close sock))
socket accepts :host (default "0.0.0.0") and :in-buf / :out-buf channel buffer options.
Framing
Raw TCP connections deliver bytes in arbitrary-sized chunks; protocols typically
need message boundaries. clojure.rust.net.frame/frame pipes a raw :in
channel through a stateful framer and returns a new channel of complete
messages:
(require '[clojure.rust.net.frame :as frame])
;; Line-delimited protocol
(let [lines (frame/frame (:in conn) (frame/lines))]
(go (loop []
(when-let [line (<! lines)]
(println line)
(>! (:out conn) (frame/lines-encode line))
(recur)))))
;; Length-prefixed protocol (4-byte big-endian header)
(let [msgs (frame/frame (:in conn) (frame/length-prefixed {:bytes 4}))]
(go (loop []
(when-let [msg (<! msgs)]
(>! (:out conn) (frame/length-prefixed-encode msg {:bytes 4}))
(recur)))))
Framer specs
| Constructor | Output type | Notes |
|---|---|---|
(frame/lines) | string per line | strips \r; emits partial final line at EOF |
(frame/by-delimiter b) | byte-array per frame | delimiter byte excluded from output |
(frame/length-prefixed {:bytes n}) | byte-array per frame | N-byte header (big-endian by default); partial frames at EOF are discarded |
Pass :endian :little to length-prefixed for little-endian headers.
Encode helpers
(frame/lines-encode str) ; => byte-array (UTF-8 + \n)
(frame/length-prefixed-encode ba {:bytes 4}) ; => byte-array (4-byte header prepended)
Async map over a framed channel
pipe-map covers the common case of applying a function to every message on a
channel:
(let [msgs (frame/frame (:in conn) (frame/lines))
parsed (frame/pipe-map msgs parse-json)]
;; parsed is a channel of parse-json results
)
Embedding from Rust
cljrs_net::init registers all namespaces. It is idempotent and calls
cljrs_async::init internally, so you do not need to call it separately:
#![allow(unused)]
fn main() {
rt.block_on(local.run_until(async {
let globals = cljrs_stdlib::standard_env();
cljrs_net::init(&globals);
// ... evaluate code ...
}));
}
Lower-level functions are also callable directly from Rust if you need to open sockets outside Clojure code:
#![allow(unused)]
fn main() {
use cljrs_net::{tcp, tls, udp, frame};
tcp::connect_to("example.com", 80, 8, 8);
tcp::listen_on("0.0.0.0", 8080, 16, 8, 8)?;
udp::socket_on("0.0.0.0", 9000, 8, 8)?;
tls::tls_connect_to("example.com", 443, client_cfg, 8, 8);
tls::tls_listen_on("0.0.0.0", 8443, server_cfg, 16, 8, 8)?;
frame::frame_channel(in_chan, FramerSpec::Lines, 8);
}
Rust Interop
clojurust lets Clojure code call Rust functions with full type safety and GC integration. The interop layer has two modes that work together:
- Interpreter mode — the Rust crate is compiled to a shared library
(
.so/.dylib/.dll) and loaded bycljrs run/cljrs replat startup viacljrs build-native. - AOT mode —
cljrs compilestatically links the Rust crate into the generated binary; the native init function is called before any Clojure code runs.
Both modes use the same API: a Registry object that maps Clojure-visible
names to Rust functions.
When to use Rust interop
- Wrapping an existing Rust library (e.g. a database driver, image codec, or systems API) for use from Clojure.
- Hot paths where Clojure performance is insufficient.
- Exposing mutable or OS-level state (file descriptors, sockets, GPU buffers)
as opaque
NativeObjectvalues that participate in protocol dispatch.
Chapter overview
| Page | Contents |
|---|---|
| Project setup | cljrs.edn config, Cargo setup, crate layout |
| Registry API | Registry, wrap_fn*, type marshalling, NativeObject |
The #[export] macro | Zero-boilerplate function registration |
| Interpreter mode | cljrs build-native, auto-loading, hot-reload workflow |
| AOT mode | How cljrs compile wires native init into the binary |
Quick example
cljrs.edn:
{:paths ["src"]
:rust {:crate "."
:init "my_project::cljrs_init"}}
Cargo.toml (user crate):
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cljrs-interop = { path = "/path/to/cljrs/crates/cljrs-interop" }
src/lib.rs:
#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn2};
#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
let r = unsafe { &mut *registry };
r.define("my.project/add",
wrap_fn2("add", |a: i64, b: i64| Ok::<i64, String>(a + b)));
}
}
src/main.cljrs:
(ns my.project.core
(:require [my.project :as native]))
(println (native/add 3 4)) ; => 7
Workflow:
cljrs build-native # compile lib → target/debug/libmy_project.so
cljrs run src/main.cljrs # auto-loads the .so, then runs Clojure
Project Setup
A mixed Rust/Clojure project needs three things: a cljrs.edn that points to
the Rust crate, a Cargo.toml with the right crate type, and a cljrs_init
entry point.
Directory layout
my-project/
├── cljrs.edn # Clojure project config (source paths, :rust key)
├── Cargo.toml # Rust crate manifest
├── src/
│ ├── lib.rs # Rust source — defines cljrs_init and native fns
│ └── main.cljrs # Clojure entry point
The Rust crate and the cljrs.edn file can live in the same directory (:crate ".") or in a subdirectory (:crate "native").
cljrs.edn
Add a :rust map to the top-level config:
{:paths ["src"]
:rust {:crate "." ; path to Cargo.toml directory
:init "my_project::cljrs_init"} ; Rust path to the init function
}
| Key | Required | Description |
|---|---|---|
:crate | yes | Path to the directory containing the user’s Cargo.toml. Relative to cljrs.edn. |
:init | yes | Fully-qualified Rust path to the init function, e.g. "my_crate::cljrs_init". The first :: segment is used as the crate name. |
Cargo.toml
The user crate must be a library with cdylib output (for interpreter-mode
dynamic loading) and, optionally, rlib output (for AOT static linking):
[package]
name = "my_project"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cljrs-interop = { path = "/path/to/cljrs/crates/cljrs-interop" }
Note:
cdylibproduces the.so/.dylib/.dllloaded bycljrs run.rliballowscljrs compileto link the crate statically into the AOT binary. Both can coexist incrate-type.
The cljrs_init entry point
The init function receives a *mut Registry pointer and registers all native
functions. It must have C linkage so the dynamic linker can find it by name:
#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn1, wrap_fn2};
#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
let r = unsafe { &mut *registry };
r.define("my.project/greet",
wrap_fn1("greet", |name: String| {
Ok::<String, String>(format!("Hello, {name}!"))
}));
r.define("my.project/add",
wrap_fn2("add", |a: i64, b: i64| Ok::<i64, String>(a + b)));
}
}
The function name in :rust :init ("my_project::cljrs_init") must match the
Rust function name used in #[no_mangle] (cljrs_init). The crate prefix
(my_project) is used when generating the AOT harness; it must match the
[package] name in Cargo.toml with hyphens replaced by underscores.
Calling native functions from Clojure
Native functions registered under "my.project/greet" are visible in Clojure
as my.project/greet. No require is needed unless you want a namespace alias:
; Direct qualified call
(my.project/greet "world") ; => "Hello, world!"
; With a require alias
(ns my.app
(:require [my.project :as native]))
(native/add 3 4) ; => 7
The namespace my.project is created automatically when cljrs_init is called;
you do not need to create or load a Clojure file for it.
Registry API
The Registry type (from cljrs_interop) is the handle passed to cljrs_init
for registering Rust functions as Clojure-visible values.
Registry methods
#![allow(unused)]
fn main() {
// Register f under "my.ns/my-fn".
// Panics if `qualified` contains no '/'.
pub fn define(&self, qualified: &str, f: NativeFn);
// Register f into an explicit namespace with a plain name.
// Equivalent to define("ns/name", f).
pub fn define_in(&self, ns: &str, name: &str, f: NativeFn);
// Access the underlying GlobalEnv for advanced operations
// (registering builtin sources, setting namespace aliases, etc.).
pub fn env(&self) -> &Arc<GlobalEnv>;
}
Wrapping Rust functions
The wrap_fn* family converts idiomatic Rust signatures into NativeFn values.
Arguments and return values are marshalled automatically via the FromValue and
IntoValue traits.
#![allow(unused)]
fn main() {
use cljrs_interop::{wrap_fn0, wrap_fn1, wrap_fn2, wrap_fn3, wrap_fn_variadic};
// Zero arguments
wrap_fn0("my.ns/timestamp", || Ok::<i64, String>(epoch_millis()))
// One argument
wrap_fn1("my.ns/double", |x: i64| Ok::<i64, String>(x * 2))
// Two arguments
wrap_fn2("my.ns/add", |a: i64, b: i64| Ok::<i64, String>(a + b))
// Three arguments
wrap_fn3("my.ns/clamp", |lo: i64, hi: i64, x: i64| Ok::<i64, String>(x.clamp(lo, hi)))
// Variadic — receives &[Value] directly; minimum argument count enforced
wrap_fn_variadic("my.ns/sum", 0, |args: &[Value]| {
let total: i64 = args.iter().filter_map(|v| i64::from_value(v).ok()).sum();
Ok::<i64, String>(total)
})
}
All wrappers accept closures (not just bare fn pointers), so they can capture
Rust state:
#![allow(unused)]
fn main() {
let multiplier = Arc::new(AtomicI64::new(3));
let m = multiplier.clone();
r.define("my.ns/scale",
wrap_fn1("scale", move |x: i64| {
Ok::<i64, String>(x * m.load(Ordering::Relaxed))
}));
}
Type marshalling
Built-in conversions
| Clojure type | Rust type |
|---|---|
nil | () or Option<T> (None) |
true / false | bool |
| Long | i64 |
| Double | f64 (also accepts Long) |
| String | String |
| Any value | Value (pass-through) |
| Nil or any | Option<T> |
| BigInt | num_bigint::BigInt |
Implement FromValue and/or IntoValue on your own types to add new
conversions:
#![allow(unused)]
fn main() {
use cljrs_interop::{FromValue, IntoValue};
use cljrs_value::{Value, ValueResult};
struct Point { x: f64, y: f64 }
impl IntoValue for Point {
fn into_value(self) -> Value {
// encode as a two-element vector
Value::vector(vec![self.x.into_value(), self.y.into_value()])
}
}
}
Error bridging
All wrap_fn* closures return Result<R, E> where E: Display. Any Err
value is converted to a Clojure exception and re-thrown; Ok values are
marshalled via IntoValue.
For explicit control, use wrap_result:
#![allow(unused)]
fn main() {
use cljrs_interop::wrap_result;
fn my_fn(args: &[Value]) -> ValueResult<Value> {
let n = i64::from_value(&args[0])?;
wrap_result(std::fs::read_to_string(format!("/tmp/{n}.txt")))
}
}
Opaque Rust objects (NativeObject)
Arbitrary Rust structs can be wrapped as Clojure values using the NativeObject
trait. The value appears in Clojure as an opaque object that can be passed
around, stored in collections, and dispatched on via protocols.
#![allow(unused)]
fn main() {
use cljrs_interop::{NativeObject, gc_native_object};
use cljrs_gc::{MarkVisitor, Trace};
use cljrs_value::Value;
#[derive(Debug)]
struct Connection { /* ... */ }
impl NativeObject for Connection {
fn type_tag(&self) -> &str { "Connection" }
fn as_any(&self) -> &dyn std::any::Any { self }
}
// Connection holds no GcPtr fields, so Trace is a no-op.
impl Trace for Connection {
fn trace(&self, _: &mut MarkVisitor) {}
}
// Create a Value::NativeObject wrapping a Connection.
fn make_conn(_args: &[Value]) -> ValueResult<Value> {
let conn = Connection { /* ... */ };
Ok(Value::NativeObject(gc_native_object(conn)))
}
}
To downcast back to the concrete type in a Rust function:
#![allow(unused)]
fn main() {
fn use_conn(args: &[Value]) -> ValueResult<Value> {
let Value::NativeObject(obj) = &args[0] else {
return Err(ValueError::WrongType { expected: "Connection", got: "…".into() });
};
let conn = obj.get().downcast_ref::<Connection>()
.ok_or_else(|| ValueError::WrongType { expected: "Connection", got: obj.get().type_tag().into() })?;
// use conn…
Ok(Value::Nil)
}
}
Protocol dispatch on native objects
extend-type can be used in Clojure to implement protocols for native objects.
The type tag (the string returned by type_tag()) is used for dispatch:
(defprotocol IConn
(query [conn sql])
(close! [conn]))
(extend-type Connection IConn
(query [conn sql] (native/db-query conn sql))
(close! [conn] (native/db-close conn)))
GC integration
If your NativeObject contains GcPtr<T> fields, implement Trace properly
so the GC can follow references:
#![allow(unused)]
fn main() {
use cljrs_gc::{GcPtr, MarkVisitor, Trace};
use cljrs_value::Value;
struct Cache { entries: Vec<GcPtr<Value>> }
impl Trace for Cache {
fn trace(&self, visitor: &mut MarkVisitor) {
for entry in &self.entries {
entry.trace(visitor);
}
}
}
}
If your struct holds no GcPtr fields (only plain Rust data), a no-op Trace
impl is sufficient.
For simpler cases that don’t need closures or custom NativeObject wiring,
the #[export] macro provides a zero-boilerplate alternative
to manual define calls.
The #[export] Macro
The #[export] attribute (from cljrs_interop) is the zero-boilerplate way to
expose Rust functions to Clojure. Annotating a function with it causes the
function to be registered automatically when a Registry is created — no
explicit call required.
Basic usage
#![allow(unused)]
fn main() {
use cljrs_interop::export;
#[export(ns = "math")]
pub fn add(a: i64, b: i64) -> Result<i64, String> {
Ok(a + b)
}
}
add is now visible in Clojure as math/add as soon as the shared library is
loaded or the AOT binary starts. No cljrs_init is required unless you have
other setup to perform (see When cljrs_init is still needed).
Attribute options
| Key | Required | Description |
|---|---|---|
ns | yes | Clojure namespace, e.g. "math" or "my.project". |
name | no | Override the Clojure symbol name. Default: Rust name with _ replaced by -. |
variadic_min | no | Minimum arity for variadic functions (default 0). Only valid when the function takes a single &[Value] parameter. |
Name mapping
Rust function names are converted to Clojure-style kebab-case: every _ becomes
-. Use name = "..." to override:
#![allow(unused)]
fn main() {
#[export(ns = "str.util")]
fn to_upper_case(s: String) -> String { // → str.util/to-upper-case
s.to_uppercase()
}
#[export(ns = "str.util", name = "upper")] // → str.util/upper
fn to_upper_case_v2(s: String) -> String {
s.to_uppercase()
}
}
Supported signatures
Fixed arity
Each parameter must implement FromValue. The parameter count sets the arity
enforced by the runtime. There is no upper limit.
Plain return value — any type implementing IntoValue; wrapped in Ok
automatically:
#![allow(unused)]
fn main() {
#[export(ns = "math")]
pub fn pi() -> f64 {
std::f64::consts::PI
}
}
Result<T, E> return — Err becomes a Clojure exception via E::to_string():
#![allow(unused)]
fn main() {
#[export(ns = "math")]
pub fn safe_sqrt(x: f64) -> Result<f64, String> {
if x < 0.0 {
Err(format!("cannot take sqrt of {x}"))
} else {
Ok(x.sqrt())
}
}
}
No return value — maps to nil:
#![allow(unused)]
fn main() {
#[export(ns = "log")]
pub fn log_info(msg: String) {
eprintln!("[info] {msg}");
}
}
Four or more parameters work identically to two or three:
#![allow(unused)]
fn main() {
#[export(ns = "geom")]
pub fn rect_contains(rx: f64, ry: f64, rw: f64, rh: f64, px: f64, py: f64) -> bool {
px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
}
}
Variadic
For functions that take a variable number of arguments, use a single &[Value]
parameter. Set variadic_min to enforce a minimum argument count:
#![allow(unused)]
fn main() {
use cljrs_interop::{FromValue, IntoValue, export};
use cljrs_value::Value;
#[export(ns = "math", variadic_min = 1)]
pub fn sum(args: &[Value]) -> Result<Value, String> {
let total: i64 = args
.iter()
.map(|v| i64::from_value(v).map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.sum();
Ok(total.into_value())
}
}
(math/sum 1 2 3 4 5) ; => 15
When cljrs_init is still needed
#[export] handles function registration. A cljrs_init is still required when
you need to:
- Call
mark_loadedsorequiretreats a namespace as built-in rather than looking for a file on the source path. - Set namespace aliases or refer bindings via
Registry::env(). - Perform any other startup work beyond defining Clojure-visible functions.
#![allow(unused)]
fn main() {
use cljrs_interop::Registry;
#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
let r = unsafe { &mut *registry };
// #[export] functions are already registered — Registry::new ran first.
r.env().mark_loaded("math");
r.env().mark_loaded("log");
}
}
Note: The
*mut Registrypassed tocljrs_initis the sameRegistrycreated by the runtime before calling your function. All#[export]entries are already interned whencljrs_initis invoked.
Mixing #[export] with manual define
Both styles can coexist freely. Use #[export] for straightforward functions
and r.define / wrap_fn* for cases that capture runtime state or need custom
arity logic:
#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn1, export};
use std::sync::{Arc, Mutex};
// Simple stateless function — use #[export].
#[export(ns = "counter")]
pub fn increment(n: i64) -> i64 {
n + 1
}
#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
let r = unsafe { &mut *registry };
// Stateful closure that captures a value created at init time.
let total: Arc<Mutex<i64>> = Arc::new(Mutex::new(0));
let t = total.clone();
r.define(
"counter/running-total",
wrap_fn1("running-total", move |n: i64| {
let mut guard = t.lock().unwrap();
*guard += n;
Ok::<i64, String>(*guard)
}),
);
}
}
How it works
#[export] is a proc-macro (in cljrs-export-macro) that leaves the original
function intact and emits an inventory submission:
#![allow(unused)]
fn main() {
// What #[export(ns = "math")] generates alongside your function:
::cljrs_interop::inventory::submit!(::cljrs_interop::ExportEntry {
qualified: "math/add",
make_fn: || {
::cljrs_interop::NativeFn::with_closure(
"math/add",
::cljrs_interop::Arity::Fixed(2),
move |args| {
let __a0 = <i64 as ::cljrs_interop::FromValue>::from_value(&args[0])?;
let __a1 = <i64 as ::cljrs_interop::FromValue>::from_value(&args[1])?;
add(__a0, __a1)
.map(<i64 as ::cljrs_interop::IntoValue>::into_value)
.map_err(|e| ::cljrs_interop::ValueError::Other(e.to_string()))
},
)
},
});
}
inventory uses linker constructors to collect all submissions across the
binary at link time. Registry::new then iterates them and calls
Registry::define for each one.
If you need to register exports into a Registry you constructed outside the
normal runtime flow (e.g. in tests), call register_exports directly:
#![allow(unused)]
fn main() {
use cljrs_interop::{register_exports, Registry};
let r = Registry::new(env.clone()); // already auto-registered
// …or, if you have a registry from elsewhere:
register_exports(&r);
}
Interpreter Mode
In interpreter mode (cljrs run / cljrs repl), the Rust crate is compiled to
a shared library and loaded at startup via dlopen. No recompilation of
cljrs itself is required.
Workflow
# 1. Build the shared library (once, or after Rust changes)
cljrs build-native
# 2. Run Clojure code — the .so is loaded automatically
cljrs run src/main.cljrs
# 3. Start the REPL — also auto-loads
cljrs repl
cljrs build-native is just cargo build run inside the crate directory
declared by :rust :crate in cljrs.edn. It inherits your normal Rust
toolchain, RUSTFLAGS, and cargo configuration.
How auto-loading works
When cljrs run (or repl) starts:
- It locates
cljrs.ednby walking up the directory tree. - If
:rustis present, it derives the library path from the crate name and profile (debugby default):- Linux:
<crate_dir>/target/debug/lib<crate_name>.so - macOS:
<crate_dir>/target/debug/lib<crate_name>.dylib - Windows:
<crate_dir>/target/debug/<crate_name>.dll
- Linux:
- It opens the library with
dlopenand looks up the symbol named after the last::segment of:rust :init(e.g."cljrs_init"). - It calls the symbol with a
*mut Registry, which registers all native functions into the global namespace table. - The library is kept loaded for the lifetime of the process.
All of this happens before any Clojure source is evaluated, so native functions
are visible to ns/require forms and to top-level code.
Missing library
If the shared library does not exist yet, cljrs prints a warning and
continues:
cljrs: native library not found at target/debug/libmy_project.so
— run `cljrs build-native` first
Clojure code that calls unregistered native functions will get a runtime error;
code that doesn’t call them runs normally. This is intentional: you can develop
pure-Clojure parts of a mixed project without running cljrs build-native on
every edit.
Release builds
Pass --release to build an optimised library:
cljrs build-native --release
The release library is placed at target/release/libmy_project.so (and so on).
Note that cljrs run always looks for the debug build; to use the release
library you currently need to copy it to the debug path or symlink it. (A future
--release flag on run/repl will automate this.)
Development tips
- Fast iteration:
cljrs build-nativecompiles only the Rust crate, not the whole clojurust workspace. On a warm build cache it typically takes a few seconds. - Separate processes: Because the library is opened once at startup,
changes to Rust code require restarting
cljrs run. There is no hot-reload of native code within a single process. - Cargo features: Pass
CARGO_FLAGSor set[profile.*]in yourCargo.tomlas usual;cljrs build-nativeinherits the environment. - Debugging: Use
RUST_LOG,RUST_BACKTRACE=1, and your normal Rust debugging tools. The library is a standard shared object;gdb/lldbcan attach to thecljrsprocess and set breakpoints in native code.
AOT Mode
When cljrs compile detects a :rust key in cljrs.edn, it statically links
the user’s Rust crate into the generated binary. The compiled binary is fully
self-contained: no shared library or cljrs installation is needed at runtime.
How it works
cljrs compile generates a temporary Cargo harness project that:
- Depends on the clojurust runtime crates (
cljrs-stdlib,cljrs-eval, etc.) - Also depends on the user’s Rust crate (via
path = "<crate_dir>") - Also depends on
cljrs-interop(forRegistry) - Contains a generated
main.rsthat: a. Initialises the standard environment b. Creates aRegistryand calls the user’s init function c. Evaluates the Clojure preamble (interpreted forms:ns,require,defmacro, …) d. Calls the AOT-compiled__cljrs_mainfunction
The harness is compiled with cargo build --release and the resulting binary
is copied to the output path.
Generated main.rs (simplified)
fn main() {
cljrs_compiler::rt_abi::anchor_rt_symbols();
let globals = cljrs_stdlib::standard_env();
// Native init — registered before any Clojure code runs
let mut registry = cljrs_interop::Registry::new(globals.clone());
my_project::cljrs_init(&mut registry);
let mut env = cljrs_eval::Env::new(globals, "user");
cljrs_env::callback::push_eval_context(&env);
// Interpreted preamble (ns, require, defmacro, …)
// …
// AOT-compiled body
unsafe { __cljrs_main() };
}
Crate type requirement
For AOT static linking the user crate must produce an rlib (the default for
library crates). If you also need interpreter-mode dynamic loading, declare
both:
[lib]
crate-type = ["cdylib", "rlib"]
If you only need AOT (no cljrs run native loading), crate-type = ["rlib"]
is sufficient.
Building an AOT binary
# Build once (debug or release)
cljrs build-native # optional — only needed for cljrs run
# Compile to a native binary
cljrs compile src/main.cljrs --out myapp
# Run anywhere — no cljrs required
./myapp
The cljrs compile step does not require a pre-built .so; it statically
links the Rust crate directly. You do not need to run cljrs build-native
before cljrs compile.
Native functions in the preamble
Because the native init call happens before the interpreted preamble is
evaluated, native functions are visible to ns, require, macros, and
defprotocol/extend-type forms that run at startup:
(ns my.app
(:require [my.project])) ; my.project namespace already populated
(defprotocol IWidget
(render [w]))
(extend-type my_project.Widget IWidget ; native type tag
(render [w] (my.project/widget-render w)))
Offline builds
The AOT harness uses cargo build --release --offline by default. All
dependencies (the clojurust crates and the user crate) must be resolvable
without network access. Point to local paths in Cargo.toml as shown in
Project setup.
Memory management
AOT-compiled binaries use the bump allocator fast path: escape analysis
promotes short-lived, non-escaping objects into bump-allocated regions,
leaving the tracing GC to handle everything else. (Since JIT phase 10.5 the
same machinery also runs under cljrs run/repl/eval; AOT still sees the
most opportunities because the whole program is analyzed as one unit.) See
Memory Management and
The bump allocator for details.
Memory Management
Every Clojure value in clojurust lives behind a GcPtr<T> — a raw pointer
into managed memory. clojurust uses two allocators that cooperate behind
that pointer:
| Allocator | When it runs | Reclaims memory |
|---|---|---|
| Tracing GC (mark-and-sweep) | always, in the default build | during collect (stop-the-world) |
| Bump allocator (regions) | AOT-compiled code only | in bulk, when a region’s scope ends |
The garbage collector is the backstop: it manages the full object graph and is the only allocator the interpreter uses. The bump allocator is a compile-time optimization layered on top — when the AOT compiler can prove that an object does not outlive the function (or loop) that created it, it allocates that object in a region that is freed all at once, with no tracing and no GC pause.
You don’t manage either allocator by hand. You write ordinary Clojure; the compiler decides what is region-eligible and the GC handles everything else.
Tracing GC
The default collector is a non-moving, stop-the-world mark-and-sweep GC. Key properties:
GcPtr<T>stores a stable address — objects never move, so a pointer stays valid for the lifetime of the object.cloneis an O(1) pointer copy;dropis a no-op. Reference cycles are collected, because liveness is determined by reachability from roots, not reference counts.- Collection is triggered by a memory threshold. The default hard limit is
1/4 of system RAM (a fixed 64 MB soft limit on
wasm32, which cannot query system memory). - Each OS thread (isolate) owns an independent heap and collects on its own, with no cross-thread coordination.
Bump allocator (regions)
The bump allocator is a region-based fast path for short-lived, non-escaping allocations. It is selected automatically by the AOT compiler’s escape analysis and is described in detail in the next chapter.
AOT only. The bump allocator currently runs only in AOT-compiled binaries (
cljrs compile). The interpreter (cljrs run,cljrs repl,cljrs eval) allocates everything on the GC heap. See The bump allocator for why.
Inspecting allocation behaviour
Both allocators feed a single set of process-global counters. Set the
CLJRS_GC_STATS environment variable to dump them at program exit:
CLJRS_GC_STATS=- ./myapp # write a summary to stdout
CLJRS_GC_STATS=stats.txt ./myapp # write the summary to a file
The summary reports GC allocations and bytes, region (bump) allocations and
bytes, GC collection count, total pause time, and objects/bytes freed — so
you can see how much work the bump allocator is taking off the GC. The
interpreter exposes the same counters through the cljrs --gc-stats [FILE]
flag.
The Bump Allocator
The bump allocator — called a region in the code — is the fastest way clojurust can hand out memory. Instead of allocating each object individually on the GC heap and later tracing it, a region carves objects out of a contiguous block by advancing (“bumping”) a single pointer, and frees them all at once when the region’s scope ends.
It is roughly 2.6× faster than a GC-heap allocation: there is no mutex, no
per-object Box::new, and no collection pause.
The bump allocator runs in both AOT and JIT/interpreted modes. Binaries produced by
cljrs compilehave always used it; since JIT phase 10.5,cljrs run,cljrs repl, andcljrs evaluse it too: eager IR lowering runs the same escape-optimization pass perdefn(consulting previously-lowered defns through a cross-defn registry), the Tier-1 IR interpreter executes the region instructions, and JIT-compiled code threads the active region into callees as a hidden argument. See Which tiers use regions?.
How it works
A region owns one or more chunks of raw memory (the default chunk is 4 KiB). It tracks a single bump pointer into the active chunk:
chunk: [ obj A ][ obj B ][ obj C ][ free ............ ]
^
bump pointer
Allocating an object is:
- Align the bump pointer up to the object’s alignment.
- If the object fits in the current chunk, write it there and advance the pointer past it. This is the common, near-instant path.
- If it doesn’t fit, allocate a fresh chunk (sized
max(4 KiB, 2 × object size)), chain it on, and allocate from it.
There is no per-object bookkeeping for freeing — a region does not free objects one at a time. When the region’s scope ends, it:
- Runs any registered destructors in reverse (LIFO) order, so objects that may reference earlier ones are torn down first.
- Releases its chunks back to the system allocator (keeping the first chunk to reuse) and rewinds the bump pointer.
That bulk reset is what makes the allocator cheap: the cost of freeing a thousand short-lived objects is one chunk free, not a thousand.
Scopes and the region stack
Regions live on a thread-local region stack. AOT-compiled code brackets a region-eligible scope with two runtime calls:
rt_region_startpushes a fresh region onto the stack at scope entry.rt_region_endpops it at scope exit, running destructors and freeing chunks.
Only the top region receives allocations. While a region is active, the runtime’s allocation helpers route region-eligible collections into it and fall back to the GC heap when no region is active:
#![allow(unused)]
fn main() {
fn box_coll_val(v: Value) -> *const Value {
if region_is_active() {
// bump-allocate into the active region
try_alloc_in_region(v).unwrap().get() as *const Value
} else {
box_val(v) // fall back to the GC heap
}
}
}
This fallback is why region promotion is always safe: if escape analysis is conservative, or no region happens to be active, the object simply lands on the GC heap with identical semantics — just a little slower.
How the compiler decides what to bump-allocate
You never mark an allocation as region-eligible yourself. During AOT compilation, an escape analysis pass classifies every allocation on a four-level lattice:
| State | Meaning | Allocator |
|---|---|---|
NoEscape | never leaves the function | region |
ArgEscape | stored into an argument that escapes | GC heap |
Returns | returned to the caller | region if the caller doesn’t let it escape |
Escapes | stored in the heap, captured by a closure, returned to the world | GC heap |
An allocation is promoted to a region only when it provably does not escape
the scope that created it — it is not returned, not stored in a longer-lived
container, not captured by a closure, and not passed to a call that could
retain it. The analysis understands many built-ins precisely (for example
(first coll) and (count coll) don’t cause their argument to escape, while
(conj coll x) lets coll escape but not x), and it follows recur into
loop headers so loop-local intermediates can be region-allocated too.
Escape analysis also reaches across function boundaries: small non-capturing callees are inlined so their allocations become local again, and larger callees can be specialized to inherit the caller’s region, so a helper that builds and returns a short-lived vector can still be bump-allocated at a call site that immediately discards it.
Which tiers use regions?
The bump allocator depends on compile-time escape analysis. The decision
of what may be region-allocated, and where the rt_region_start /
rt_region_end brackets go, is decided when code is lowered and optimized:
cljrs compile(AOT): the whole program is one IR tree; escape analysis and region promotion see every callee.cljrs run/repl/evalwith eager lowering (the JIT default): each top-leveldefnis lowered and optimized at definition time. A cross-defn registry makes previously-lowered defns visible, so calls into other defns can be region-promoted too (the callee variant receives the caller’s region as a hidden trailing argument once JIT-compiled). The Tier-1 IR interpreter executes the same region instructions before native code is published.- Pure tree-walking (no IR): no escape information, everything on the GC heap — the always-correct default.
Because the analysis can be wrong in principle, the GC build carries a runtime
safety net: storing a value into a program-lifetime cell (def, atoms,
volatiles, promises, channel puts) passes a publish barrier that promotes
any region-allocated parts to the GC heap with a deep copy — and when a value
is opaque to that scan (a closure, an unrealized lazy seq), the active regions
are retired (kept alive forever and traced as GC roots) instead of being
reset. Correctness never depends on the analysis being perfect.
Relationship to the GC
In the default build the two allocators run side by side, and the bump allocator never hides memory from the collector:
- Region-allocated pointers carry a low-bit tag. The GC’s mark phase checks the tag without dereferencing the pointer and skips region objects — their chunk memory may already have been freed and reused once the region’s scope ended, so following them would be unsafe.
- Instead, every live region on the thread’s region stack is treated as a GC root. The collector walks the objects inside active regions, so any GC-heap object reachable only through a region stays alive during collection.
So the bump allocator is a fast path for provably short-lived objects, and the tracing GC remains the backstop for everything with a longer or unknown lifetime.
The no-gc build
clojurust can also be built with the no-gc Cargo feature, which removes the
tracing GC entirely and makes regions the only allocator. In that mode every
function call and every loop iteration pushes a scratch region that is freed
when the scope exits, return values are evaluated in the caller’s region, and
program-lifetime values (from def, defn, atom, and friends) live in a
global static arena. This trades the GC’s generality for zero collection pauses
and is documented separately; the default distribution ships with the GC
enabled.
See also
- Memory management overview — how the GC and bump allocator fit
together, and the
CLJRS_GC_STATScounters. - AOT mode — how
cljrs compilebuilds the native binary the bump allocator runs in.
JIT & tiered execution
Status: planned (Phase 10). This page describes work that is designed but not yet implemented. The full architecture and roadmap live in
docs/jit-plan.md.
clojurust runs code through a series of tiers, each faster than the last and selected automatically based on how hot the code is:
| Tier | Engine | When it runs |
|---|---|---|
| 0 | Tree-walking interpreter (cljrs-interp) | Always available; the universal fallback |
| 1 | IR register interpreter (cljrs-eval) | When a function’s ANF/SSA IR is cached |
| 2 | AOT native code (cljrs-compiler) | After an explicit cljrs compile |
| JIT | In-process native code (cljrs-jit) | Planned — when a function or loop gets hot at runtime |
All tiers meet at one seam — the call_cljrs_fn dispatch hook — and a function
transparently moves up the tiers as it warms up, falling back when a form isn’t
yet supported.
The JIT tier brings native speed to ad-hoc code — scripts run with
cljrs run and expressions typed at the REPL — without any explicit compile
step. Its design covers:
- Hot-path detection via per-arity invocation counters and loop back-edge counters, with on-stack replacement (OSR) so a single long-running loop promotes to native mid-execution.
- Background compilation on a worker thread, with the finished code swapped in atomically so a hot call never stalls.
- Code unloading that reclaims native code when the REPL redefines a function, tied to the garbage collector’s stop-the-world safepoints so there is no unload-vs-execute race.
- Context-driven bump allocation: the JIT specializes a function’s allocation strategy to the context it is called from, threading the caller’s region through proven-non-escaping calls — extending the bump allocator beyond AOT code into the default GC build.
See docs/jit-plan.md
for the complete design and the phased Phase 10 roadmap.
WebAssembly
clojurust can compile Clojure to WebAssembly for native-fast, sandbox-safe
deployment in the browser. cljrs compile --target wasm runs the same IR
pipeline as the native backend, but emits a .wasm module instead of a native
binary.
Status: code generation complete; runtime linking in progress. The compiler emits validated wasm modules for most of the language; making a module runnable in the browser (linking it against the wasm runtime) is the remaining step. See Status below and the full design in
docs/wasm-aot-plan.md.
Why a separate backend
clojurust’s native execution tiers up at runtime, and its top tiers generate
machine code while the program runs (the JIT). A WebAssembly
sandbox forbids exactly that: there is no mmap(PROT_EXEC) inside a module, so it
cannot generate and then execute fresh machine code.
The browser story is therefore ahead-of-time: compile each Clojure function to wasm bytecode at build time and ship it; the browser’s own engine JITs that to native. The execution tiers invert relative to native:
| Bottom (dynamic) | Top (peak) | |
|---|---|---|
| native | tree-walk → IR-interp | JIT/OSR, reached at runtime |
| browser | tree-walk → IR-interp | AOT-wasm, frozen at build time |
The IR interpreter stays on board the wasm bundle as the dynamic-code tier — for
eval, the REPL, freshly-required namespaces, and macros — while AOT-wasm is
the frozen top tier. No in-sandbox JIT or on-stack-replacement hooks are
installed.
What is shared
Everything upstream of code generation is backend-agnostic and reused unchanged
from the native path: ANF/SSA lowering, escape analysis + region inference,
scalar representation inference, and the runtime-bridge contract. The only
genuinely wasm-specific work is relooping (recovering structured control flow,
since wasm has no goto) and the bytecode emitter. How those work is covered
in The AOT backend.
Because regions are a property of the IR, bump allocation comes along for free
in wasm: a region is a linear-memory arena, a region handle is an i32 offset.
See Memory Management.
Status
Working (every emitted module is validated with wasmparser):
- Scalar, string, keyword, and symbol constants; all control flow
(
if/cond/loop/recurvia the relooper). - Boxed and unboxed arithmetic and comparison; collection and region allocation.
- Calls (direct, region-threaded, and dynamic), closures via a shared function table, and cross-function tail calls.
- Globals/vars and exceptions (
throw/try/catch). - The typed parameter ABI —
^long/^doubleparams passed unboxed, with a boxed-entry trampoline for dynamic callers. - Whole-program bundling — the entry namespace and every lowerable required namespace compile into one module.
Remaining — linking the module against the wasm runtime (so its imported
rt_* bridges, memory, and function table are satisfied) and wiring the IR
interpreter in as the dynamic-code tier. Until then the module is the AOT
artifact, not yet a running program.
Not yet supported — the async poll-function ABI; the per-call-site inline cache. Deferred indefinitely — WasmGC (the linear-memory GC stays) and an in-browser JIT.
See also
- Compiling to WebAssembly — the CLI options.
- The AOT backend — how the value model, relooper, emitter, and typed-parameter ABI work.
docs/wasm-aot-plan.md— the complete design and the open-task list.
The AOT backend
This page explains how the WebAssembly code generator works. It lives entirely in
crates/cljrs-compiler/src/wasm/ and is a second consumer of the same cljrs-ir
IR that the native (Cranelift) backend consumes.
crates/cljrs-compiler/src/wasm/
mod.rs — public API: compile_function, compile_bundle, WasmBackend, WasmError
abi.rs — the ABI contract: Value→i32, the rt_abi import table, regions, WasmLayout
reloop.rs — the relooper: IR CFG → structured control flow (wasm-private)
emit.rs — the emitter: IrFunction(s) → a validated wasm module
The pipeline is always reloop → emit.
The value model
Under wasm32, every pointer is an i32 — a linear-memory offset. The GC
heap and all regions live in the module’s single linear memory.
| IR representation | wasm type | Meaning |
|---|---|---|
boxed *const Value | i32 | offset of the boxed value |
| region handle | i32 | offset of the region arena |
unboxed Long | i64 | raw long payload |
unboxed Double | f64 | raw double payload |
Bool / tags | i32 | small integers |
The default model is boxed-only: every IR value is, by default, a wasm i32
holding a boxed pointer. This is always correct; unboxing is layered on top as an
optimization. Because every pointer is an i32, the entire runtime-bridge surface
(rt_abi, ~165 extern "C" functions) is expressible as wasm imports from the
module named "rt", with no marshalling beyond width changes.
Relooping
wasm has only block/loop/if + labelled br — no goto. The relooper
recovers structured control flow from the IR’s CFG using dominator-tree
structuring (Ramsey’s “Beyond Relooper”), specialized to two facts true of
every CFG this backend sees:
- Back edges are exactly
recur. Clojure has nogoto; the only cyclic control flow isloop/recur, so loop headers are precisely therecurtargets and every other edge is forward. - The CFG is reducible. Structured source can’t produce irreducible control flow, so the relooper needs no node-splitting or dispatch variable.
The output is a Structured tree (Simple/Labeled/Loop/If/Br/Return)
that the emitter walks directly: Labeled→block, Loop→loop, If→if/else,
Br→br N. Each block is emitted exactly once. This pass is wasm-private —
Cranelift wants the raw CFG and would be pessimized by re-structuring.
The emitter
The emitter lowers the structured tree and each Inst to bytecode. Highlights:
- SSA φ resolution — no
phiinstruction is emitted. On each edge, each φ’s incoming value is copied into its local using the operand stack for parallel-move semantics (all reads before any writes), so a swapping(recur b a)cannot clobber. - Allocation — element pointers are marshalled into a runtime scratch buffer
(
rt_scratch_ptr), then a slice-takingrt_alloc_*bridge is called. Regions reuse the same machinery with the handle threaded as a leading argument. - Calls — direct calls resolve to a wasm function index; region-threaded
calls pass the handle as a hidden trailing argument; dynamic calls dispatch
through
rt_call. Because imported functions occupy the low index space, the emitter runs two passes — discover imports, then encode with the import count settled. - Closures use a shared imported function table (a closure’s function pointer
is a table index); tail calls become
return_callwhen the tail-call proposal is enabled. - Constants/globals intern their bytes into a deduplicated read-only data
pool emitted as one data segment; exceptions use the boxed thread-local
error path (
rt_throw/rt_try). - Unboxed scalars — representation inference assigns
i64/f64/i32to intermediates wherever the boxed bridge’s semantics survive on the raw value, so hot arithmetic compiles to native wasm ops; values box on demand only at boxed-context boundaries.
A GC rt_safepoint is emitted at function entry and at each loop header.
The native (Cranelift) backend is the semantic reference: the wasm emitter mirrors
codegen.rsarm for arm. The one structural difference is control flow — Cranelift consumes the raw CFG; the wasm backend reloops it first.
The typed parameter ABI
By default, parameters stay boxed (the signature is all-i32), because the
always-boxed dispatchers — dynamic rt_call, the indirect function table,
cross-function direct calls — cannot supply unboxed arguments.
A function with static ^long/^double parameter hints compiles to two wasm
functions:
- a typed body whose hinted params arrive unboxed (
i64/f64), so the body reads them with no per-use unbox; - a boxed-entry trampoline with the all-
i32signature every dispatcher expects. The trampoline is the function’s primary entry — exported, installed in the table, and the target of every direct call — so all the boxed dispatch paths reach a typed function unchanged. It coerces each boxed argument (rt_coerce_long/rt_coerce_double) and tail-calls the typed body.
The native backend’s specialized prologue deoptimizes on a tag mismatch; the
wasm sandbox has no deopt seam, so a violated static hint coerces or throws
instead (Clojure’s longCast/doubleCast semantics).
Whole-program bundling
cljrs compile --target wasm lowers the entry namespace and every
transitively-required user namespace the backend can lower into one module
(each as a __cljrs_ns_init_N initializer, mirroring the native path’s
per-namespace discovery). A namespace the backend can’t lower is skipped, left
for the runtime’s IR-interpreter tier — the same graceful degradation native AOT
uses. The module’s read-only data and function-table base addresses are
configurable (WasmLayout) so the linking step can place them at the addresses
the runtime reserves.
Testing
Every new shape is validated with wasmparser in a unit test (cargo test -p cljrs-compiler wasm::) — a module that validates is structurally correct wasm
even with no JS runtime to execute it. End-to-end tests drive real .cljrs source
through compile_file_to_wasm, including a cross-namespace require.
For the complete design, the increment-by-increment build log, and the open-task
list, see
docs/wasm-aot-plan.md.