Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.cljc files 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 compilationcljrs compile produces a standalone native binary via Cranelift, or a WebAssembly module with --target wasm. See the WebAssembly chapter.
  • Async & I/Oclojure.core.async channels and non-blocking file I/O ship as optional crates layered over the interpreter. See the Async & I/O chapter.

Source file extensions

ExtensionMeaning
.cljrsNative clojurust source. Always evaluated under the :rust platform.
.cljcCross-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

SubcommandDescription
runInterpret a .cljrs or .cljc source file
replStart an interactive REPL
evalEvaluate a single expression and print the result
compileAOT-compile a source file to a native binary
testRun clojure.test namespaces
depsManage project dependencies declared in cljrs.edn
ir-vizRender 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

ArgumentDescription
<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.core and friends) is pre-loaded.
  • *1, *2, *3 hold the last three non-nil results.
  • *e holds the last exception.
  • nil results are printed as nil.

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

ArgumentDescription
<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

ArgumentDescription
<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

ArgumentDescription
[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

SubcommandDescription
fetchClone or update git dependencies
statusShow 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

KeyTypeDescription
:pathsvector of stringsDirectories to add to the source path. Equivalent to --src-path on the CLI.
:depsmapMap from dependency name (symbol) to dependency descriptor.
:aliasesmapNamed alias maps with :extra-paths and :extra-deps.
:verify-commit-signaturesbooleanIf true, require valid PGP/SSH signatures (verified natively) on all versioned commits.
:trusted-signersvector of stringsPublic 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.
:rustmapEmbedded 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-keyDescription
:crateDirectory containing the user’s Cargo.toml, relative to cljrs.edn.
:initFully-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

  1. Locates cljrs.edn by walking up the directory tree.
  2. Reads :rust :crate (the directory containing the user’s Cargo.toml) and :rust :init (the fully-qualified Rust path to the init function).
  3. Derives the crate name from the first :: segment of the init path — e.g. "my_project::cljrs_init"my_project.
  4. Runs cargo build [--release] in the crate directory.
  5. 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

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

ArgumentDescription
<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), and reify.
  • 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

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

ExtensionPlatform dispatch
.cljrsAlways :rust. Reader conditionals are still supported but :rust is always the active branch.
.cljcCross-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. :default should 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 formResolved 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@DCommit 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 ~/.cljrs cache 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-GlobalEnv and 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

FeatureStatus
ref / STM (dosync, alter, ref-set, commute, ensure)Not implemented
locking macroNot implemented
monitor-enter / monitor-exitNot 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 to BigInt (same as Clojure’s checked arithmetic). The promoting variants +', -', *' are also available.
  • (/ 1 3) returns a Ratio (1/3), not a Double, same as Clojure.
  • BigDecimal precision is controlled by with-precision (the Clojure macro) or the lower-level push-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:

CrateNamespaceProvides
cljrs-asyncclojure.core.asyncCSP channels, go blocks, ^:async/await, timeout, alts/alt, pipelines
cljrs-ioclojure.rust.io.asyncnon-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:

  • await is a special form. Inside an ^:async function (or a go block), it yields the single executor thread to other tasks until the value it is given — a Future, a promise, or a channel operation — resolves.
  • deref / @ blocks the calling OS thread. Using deref on a future from within an ^:async body is a runtime error that steers you to await.

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:

TierRuns onUsed by
Async (single thread)Tokio LocalSet^:async fns, go, channels, timeout, alts
Parallel (thread pool)OS threadsfuture, 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 ^:async body (driven by the async evaluator), (await x) yields the executor thread until x — a Future, a promise, 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 a take!: the put! resolves true only once a taker consumes the value.
  • A buffered channel accepts put!s while it has room and serves take!s while it is non-empty.
  • A closed channel drains any buffered values, then take! yields nil and put! resolves false.

Channel operations

Operations that can block return a Value::Future, so they are used with await inside an async context:

OperationMeaning
(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:

OperationMeaning
(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):

OperationMeaning
(<!! 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

FunctionDescription
(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, init may run before a LocalSet exists; the GC-service spawn no-ops in that case and should be re-invoked from inside a LocalSet::run_until block. timeout uses the browser’s setTimeout (via gloo-timers) on wasm32 instead of tokio::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_thread runtime and LocalSet, so go blocks, channels, and ^:async functions 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 heapfuture 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 seeatom 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 isolationPassing 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 threadOnly 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:

  1. 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’s Pid ! Msg tells you a copy happens because you are sending to a process.
  2. 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 of future.
  3. 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.
  4. 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)
OperationMeaning
(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 an isolate-chan usually 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 and NativeObjects (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 and shared-atom only 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 rx deserializes 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-stats to 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 yields nil. This signals “resolves exactly once” while staying uniformly takeable alongside everything else.

Streaming reads

Each returns a channel immediately and closes it at EOF:

FunctionYields
(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:

FunctionDelivers
(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, or cljrs 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:

FunctionSignatureReturns
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:

😀  →  &#128512;

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.

FunctionSignatureInputOutput
decode-chan(decode-chan bytes-chan [charset [buf]])ByteBlob valuesstring values
encode-chan(encode-chan strings-chan [charset [buf]])string valuesByteBlob 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])
NamespaceContents
clojure.rust.net.tcpPlain TCP client and server
clojure.rust.net.tlsTLS client and server (rustls)
clojure.rust.net.unixUnix-domain stream sockets (#[cfg(unix)])
clojure.rust.net.udpUDP datagrams
clojure.rust.net.frameStateful framers and encode helpers
clojure.rust.netUmbrella 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

ConstructorOutput typeNotes
(frame/lines)string per linestrips \r; emits partial final line at EOF
(frame/by-delimiter b)byte-array per framedelimiter byte excluded from output
(frame/length-prefixed {:bytes n})byte-array per frameN-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 by cljrs run/cljrs repl at startup via cljrs build-native.
  • AOT modecljrs compile statically 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 NativeObject values that participate in protocol dispatch.

Chapter overview

PageContents
Project setupcljrs.edn config, Cargo setup, crate layout
Registry APIRegistry, wrap_fn*, type marshalling, NativeObject
The #[export] macroZero-boilerplate function registration
Interpreter modecljrs build-native, auto-loading, hot-reload workflow
AOT modeHow 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
}
KeyRequiredDescription
:crateyesPath to the directory containing the user’s Cargo.toml. Relative to cljrs.edn.
:inityesFully-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: cdylib produces the .so/.dylib/.dll loaded by cljrs run. rlib allows cljrs compile to link the crate statically into the AOT binary. Both can coexist in crate-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 typeRust type
nil() or Option<T> (None)
true / falsebool
Longi64
Doublef64 (also accepts Long)
StringString
Any valueValue (pass-through)
Nil or anyOption<T>
BigIntnum_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

KeyRequiredDescription
nsyesClojure namespace, e.g. "math" or "my.project".
namenoOverride the Clojure symbol name. Default: Rust name with _ replaced by -.
variadic_minnoMinimum 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> returnErr 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_loaded so require treats 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 Registry passed to cljrs_init is the same Registry created by the runtime before calling your function. All #[export] entries are already interned when cljrs_init is 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:

  1. It locates cljrs.edn by walking up the directory tree.
  2. If :rust is present, it derives the library path from the crate name and profile (debug by 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
  3. It opens the library with dlopen and looks up the symbol named after the last :: segment of :rust :init (e.g. "cljrs_init").
  4. It calls the symbol with a *mut Registry, which registers all native functions into the global namespace table.
  5. 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-native compiles 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_FLAGS or set [profile.*] in your Cargo.toml as usual; cljrs build-native inherits the environment.
  • Debugging: Use RUST_LOG, RUST_BACKTRACE=1, and your normal Rust debugging tools. The library is a standard shared object; gdb/lldb can attach to the cljrs process 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:

  1. Depends on the clojurust runtime crates (cljrs-stdlib, cljrs-eval, etc.)
  2. Also depends on the user’s Rust crate (via path = "<crate_dir>")
  3. Also depends on cljrs-interop (for Registry)
  4. Contains a generated main.rs that: a. Initialises the standard environment b. Creates a Registry and calls the user’s init function c. Evaluates the Clojure preamble (interpreted forms: ns, require, defmacro, …) d. Calls the AOT-compiled __cljrs_main function

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:

AllocatorWhen it runsReclaims memory
Tracing GC (mark-and-sweep)always, in the default buildduring collect (stop-the-world)
Bump allocator (regions)AOT-compiled code onlyin 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.
  • clone is an O(1) pointer copy; drop is 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 compile have always used it; since JIT phase 10.5, cljrs run, cljrs repl, and cljrs eval use it too: eager IR lowering runs the same escape-optimization pass per defn (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:

  1. Align the bump pointer up to the object’s alignment.
  2. If the object fits in the current chunk, write it there and advance the pointer past it. This is the common, near-instant path.
  3. 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:

  1. Runs any registered destructors in reverse (LIFO) order, so objects that may reference earlier ones are torn down first.
  2. 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_start pushes a fresh region onto the stack at scope entry.
  • rt_region_end pops 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:

StateMeaningAllocator
NoEscapenever leaves the functionregion
ArgEscapestored into an argument that escapesGC heap
Returnsreturned to the callerregion if the caller doesn’t let it escape
Escapesstored in the heap, captured by a closure, returned to the worldGC 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 / eval with eager lowering (the JIT default): each top-level defn is 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_STATS counters.
  • AOT mode — how cljrs compile builds 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:

TierEngineWhen it runs
0Tree-walking interpreter (cljrs-interp)Always available; the universal fallback
1IR register interpreter (cljrs-eval)When a function’s ANF/SSA IR is cached
2AOT native code (cljrs-compiler)After an explicit cljrs compile
JITIn-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)
nativetree-walk → IR-interpJIT/OSR, reached at runtime
browsertree-walk → IR-interpAOT-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/recur via 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/^double params 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

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 reloopemit.

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 representationwasm typeMeaning
boxed *const Valuei32offset of the boxed value
region handlei32offset of the region arena
unboxed Longi64raw long payload
unboxed Doublef64raw double payload
Bool / tagsi32small 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 no goto; the only cyclic control flow is loop/recur, so loop headers are precisely the recur targets 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: Labeledblock, Looploop, Ifif/else, Brbr 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 phi instruction 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-taking rt_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_call when 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/i32 to 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.rs arm 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-i32 signature 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.