A self-contained native Clojure runtime, REPL, and LLVM AOT compiler
with no JDK required, tracking upstream Clojure master
(clojure/clojure). (clojure-version still reports 1.13.0-alpha11
for now; tagged v* releases ship per-platform binaries and are cut
only once the port's divergences and out-of-scope items are fully
handled.) This repository is the port: the original
Java implementation (sources, pom.xml, Maven workflows) was removed
in PR #11 once the port completed its specification; the Rust
workspace below is the sole implementation.
- Binary:
clojure-rs(eval,run,repl,build,jit). - Language: a documented Clojure subset, differentially verified against JVM Clojure (same input, same printed output).
- Codegen: LLVM only, via inkwell 0.10.0 (
llvm22-1, LLVM 22.1.8). - License: EPL-1.0 (
epl-v10.html). - Attribution: the Clojure language is designed by Rich Hickey;
vendored upstream sources keep their
Copyright (c) Rich Hickeynotices, adapted files carry dual notices, original port code isCopyright (c) Hao Hu, all under EPL-1.0.
Normative details live with the code: the
core coverage list (machine-checked by CI),
the design spec and its contracts
(docs/t1174/*-contract.md), and the workspace
README (LLVM setup, build commands).
Prerequisites: stable Rust ≥ 1.85 (MSRV). The default aot feature
needs LLVM 22 — see LLVM setup;
without LLVM, build the interpreter with --no-default-features.
cd rust
cargo test --workspace --locked # full suite
cargo build --release --locked # ./target/release/clojure-rs$ clojure-rs eval '(+ 1 2 3)'
6
$ clojure-rs eval '(map inc [1 2 3])'
(2 3 4)
$ clojure-rs run hello.clj # prints hello
$ clojure-rs repl # interactive, history at ~/.clojure-rs/history
$ clojure-rs build hello.clj -o hello && ./hello
42
$ clojure-rs jit hello.clj # JIT-run pure subset, no binary emittedeval takes --edn (EDN-only reader); run binds extra args to
*command-line-args*; repl takes --history-file / --no-history;
build takes --emit-ir / --emit-obj (skip linking) and
--runtime-archive (default: next to the binary — release archives
ship both); jit takes a file plus args passed to -main as argv.
Exit codes: 0 success, 1 evaluation/compile error
(file:line:col: message), 2 usage error, 3 I/O error.
Prefer a download? Every CI run keeps per-platform
clojure-rs-<target>.tar.gz artifacts (binary + runtime archive),
and each v* tag attaches the same three archives to its GitHub
release.
CLI. All five entry points: eval (one form), run (script
file), repl (rustyline: history, clojure.core completion,
multi-line input, *1/*2/*3/*e, Ctrl-C cancels, Ctrl-D exits;
non-tty stdin degrades to streaming eval), build (LLVM AOT,
below), jit (LLVM JIT over the same pure subset, below).
Reader. Symbols, keywords (incl. auto-resolve ::), integers,
doubles, strings (escapes + unicode), chars, booleans, nil, lists,
vectors, maps, sets, quoting (', ` with unquote/splicing),
deref @, meta ^, % arg sugar, comments (;, #_), dispatch
macros (#{, #", #(, #', #_, #? reader conditionals with
:cljr features — .cljc never loads JVM branches), #= eval
reader (gated by *read-eval*), validated #inst/#uuid
reads into native Instant/Uuid values, and
data_readers.clj/data_readers.cljc
autoload from each *load-path* dir (the port's classpath
mapping). Every form carries a span for diagnostics.
Types (no bytecode). deftype defrecord reify proxy definterface gen-interface gen-class over a namespace registry
(Type. construction, .-field / .method interop, ->Type /
map->Type factories); records are maps with the type name on
them (map ops, hash formula, metadata); methods dispatch
through per-type tables with Object fallbacks (toString,
record hashCode).
Protocols. defprotocol (map descriptors with :on / :sigs
/ :impls, variadic dispatch fns), extend / extend-type /
extend-protocol (named types, host classes, nil, and the
IPersistentMap fallback for maps/records, merged under the
var-alter lock), satisfies? / extends? (registry plus inline
claims; descriptors never satisfy), inline bodies on
deftype / defrecord / reify / proxy, the Closeable
contract, and with-open (reverse-order finally closes).
String Long Double Boolean Object resolve as class-name
strings (the port has no class literals).
Multimethods. defmulti (dispatch fn, docstring/attr head,
:default and :hierarchy options — absent :hierarchy
means the global hierarchy, explicit nil NPEs at the call),
defmethod (single- and multi-arity tails), prefer-method
(edges break isa? ties), get-method / methods /
remove-method / remove-all-methods (best-match entry else
default entry, whole-table view, missing-entry no-op).
Dispatch consults the resolved hierarchy: exact entry, then
the best isa? match with preference edges breaking ties,
then the default entry; unrelated competing methods raise
the JVM ambiguity error, and total misses fail with
No method in multimethod 'name' for dispatch value: <pr>;
table updates install a new var root under the var-alter
lock, so values captured before an update keep their old table
(the JVM mutates the one object). Hierarchies (derive /
isa?, global plus per-multifn) match the JVM.
Language core. Special forms def if do let letfn loop recur fn fn* quote var throw try/catch/finally set! (set! on dynamic vars
only); destructuring; defn defmacro defonce comment assert;
control flow (cond condp case and or when when-not when-let if-let if-some when-some doseq dotimes dorun doall for lazy-seq); full
seq/collection/string I/O surface (map filter remove mapv filterv reduce take drop range repeat repeatedly iterate cycle interleave concat apply comp partial juxt constantly identity complement ...,
slurp spit format, read-string); transients (transient
/ persistent!, single-threaded by &mut); eval load-file load-string macroexpand gensym.
Namespaces, vars, macros. ns in-ns create-ns remove-ns find-ns all-ns the-ns ns-name ns-aliases ns-resolve resolve alias refer use require load loaded-libs, *ns* *file* *load-path* (port-only
dirs vector), intern declare ns-interns ns-publics ns-refers ns-map ns-unmap ns-unalias, roots + thread-local bindings
(push-thread-bindings, with-bindings*, conveyance to futures
and agent actions), alter-var-root, with-redefs, metadata
(meta with-meta vary-meta alter-meta! reset-meta!). Macros are
real: expansion evaluates the macro fn on unevaluated forms.
Concurrency. atom (validators, watches, swap!
/ reset! / compare-and-set!), delay / force, future
(binding conveyance, cached failures) / promise / deliver,
pcalls / pvalues, agents (send on a fixed pool, send-off
unbounded, :fail / :continue error modes, await / await-for,
restart-agent, shutdown-agents), custom executors
(port-added (executor) fresh single-thread pools;
set-agent-send-executor! / set-agent-send-off-executor!
route sends through them), STM (dosync over refs —
alter / commute / ensure / ref-set, io!, validators,
watches, history vars), locking, add-watch /
remove-watch, realized?.
Core vars: 690 implemented, 0 divergent, 0 out of scope
(rust/core-coverage.md is normative and
CI-enforced: listed vars must resolve, excluded ones must not).
AOT (build). Compiles the pure subset — top-level fixed-arity
defn over Int/Double/Bool/Str/Keyword/Vector value flow, if/do/let
- direct self-recursion, calls to a closed 19-primitive set
(
+ - * quot < <= > >= = count nth get conj long int transient conj! assoc! persistent!),print— to a native binary linked against the shippedclj-rtruntime archive. Anything else fails the compile withpath:line:col: unsupported form in AOT subset: form (hint); runtime failures printpath:line:col: Class: messagewith the true call-site span and exit 1. Host-only, no debug info, no tail-call optimization (deep self-recursion overflows like the JVM);maindrops every slot and takes no arguments (*command-line-args*is unconveyed). Primitives dispatch through the interpreter's own natives, so AOT andruncannot drift.jitruns the same pure subset in-process through the LLVM JIT (no binary emitted); its file args reach-mainas argv, unlikebuild's unconveyed arguments.
Deliberate, documented, owner-accepted, and pinned by the differential corpus — each entry asserts its exact behavior, so drift fails CI. There are no open divergences: everything below differs from the JVM only where matching it would require a JVM, plus one architectural pin:
- Printing.
prround-trips EDN values through the reader;*print-length*/*print-level*truncate with...;*print-dup*routesprthrough the dup printer (undupable kinds throw). Errors render asfile:line:col:diagnostic chains — JVM stack dumps are impossible without a JVM. - Analyzer builtins. The nine FX4 heads (
let loop fn binding letfn with-redefs with-bindings bound-fn locking) expand undermacroexpand-1on both runtimes. The seven fence macros (defmacro declare ns assert time with-out-str with-in-str) expand on the JVM but stay identity here — the analyzer consumes them directly, so expansion would destroy the forms it matches on. - Safety pins. Re-entrant
forceon adelayraises the JVM's empty-messageStackOverflowErrordirectly (#200 — raised, never recursed into, since a Rust stack overflow would abort the process). - Namespaces.
ns-importskeys are byte-equal to the oracle (sorted; fresh namespaces seed the 106 JVM default imports flat per namespace, #74), but the values are emulated classes — realClassobjects need a JVM.
Absent from rust/core-coverage.md means
out of scope, not silently missing:
- Binary compatibility with JVM-compiled artifacts — the only
non-goal: the port never loads
.classfiles or links JVM bytecode.
The former N1/N4 exclusions — host interop (#120), LLVM JIT
(#121), clojure.spec (#122), core.async (#123), tools.deps
(#124) — have all landed; see their issues for slice details.
There is no tracing garbage collector. Values are immutable and
Send + Sync, shared by Arc; persistence plus reference
counting reclaims storage on drop:
Valueis one enum (Nil Bool Int(i64) Double(f64) BigInt BigInteger BigDec Ratio Str Char Symbol Keyword List Vector MapEntry Map Set Regex Uuid Instant Uri TaggedLiteral ReaderConditional Var Unbound Throwable Transient Atom Delay Future Promise Agent Volatile Ref Extension HostType MultiFn Class Array PrimitiveVector Executor). Heap payloads — strings, collections, metadata, extension values — sit behindArc.- Collections are thread-safe
rpdspersistent types with anArcmetadata slot plus insertion-order sidecars (small JVM maps/sets seq in insertion order, which is observable). - Seqs are lazy and chunked (32, like the JVM); lazy thunks
memoize through
OnceLock, so they run once even under contention, and chunked side-effect order matches the JVM observably. Concurrency cells (atoms, futures, agents, …) areArc-shared identity cells holding state, validators, and watches. - The price of
Arcwithout a collector: self-referential cells leak —(reset! a a)pins a cycle nothing collects (the JVM GCs it). Never store a ref in its own state. - AOT binaries use the same representation across the FFI
boundary. Generated LLVM code owns opaque
*mut Valueboxes and drives them through auditedrt_*shims (clj-rt): arguments are consumed, results are owned (null on error),rt_clone/rt_dropmanage sharing explicitly, andmaindrops every slot. Panics are caught inside the runtime and reported asjava.lang.Error— unwinding never crosses FFI. Primitives run on a process-global booted evaluator behind a single non-nesting mutex (built binaries are single-threaded), which cannot deadlock by construction.
rust/ Cargo workspace (binary + 7 crates, edition 2024)
src/bin/clojure-rs/ CLI shell: eval/run/repl/build (clap)
crates/clj-reader/ reader + printer, spans on every form
crates/clj-value/ Value model, persistent collections, seqs, refs
crates/clj-eval/ analyzer + tree-walking interpreter
crates/clj-core/ namespaces/vars/macros + Rust-backed core subset
crates/clj-repl/ rustyline REPL loop
crates/clj-aot/ LLVM backend via inkwell (optional `aot` feature)
crates/clj-rt/ AOT runtime archive (safe prim dispatch + rt_* shims)
core-coverage.md normative core var coverage (CI-enforced)
tests/ integration suites + fixtures + vendored corpus inputs
docs/t1174/ design spec, per-area contracts, oracle pins,
differential corpora, TLA+ models
scripts/ci/ Go CI tooling + quality-gate container image
.github/workflows/ rust.yml (CI) + rust-release.yml (v* releases)
+ storyboard.yml (board sync)
epl-v10.html license text
rust.ymlruns per PR and master push: 11 jobs on Linux/Windows (fmt+clippy, test, MSRV 1.85, LLVM-free unlinked tests, loom models, TLA+ model check, quality gates, Go CI-tools tests, release builds). Ordinary CI skips macOS for host capacity;rust-release.ymlruns the full test suite plus builds on all three OSes for everyv*tag (and on manual dispatch as the on-demand macOS check), attaching the threeclojure-rs-<target>.tar.gzarchives to the GitHub release.- Quality gates:
cargo-deny(licenses/bans/sources),cargo-geiger(unsafe audit),cargo-udeps,cargo-audit, complexity caps, and 100% line + patch coverage floors (llvm-cov over nextest, JUnit test-results — both uploaded to Codecov). - Concurrency is model-checked, not just tested: 42 loom schedule-exploration tests plus TLA+ agent-queue, channel-mailbox, and executor models discharged by TLC in CI.
- Merging to master requires one approving review
(
CODEOWNERSauto-requests it) and verified signed commits (SSH:gpg.format=ssh,commit.gpgsign=true, key registered under GitHub Settings → SSH and GPG keys → Signing keys).
- Roadmap — what needs to be done (goals, queue, backlog).
- Design spec — goals, non-goals, CLI/language contracts, slice plan (S1–S9, all landed).
- Core coverage — every var's implemented/divergent/out-of-scope state.
- Per-area contracts — reader, evaluator, values, namespaces, vars, macros, concurrency (pinned divergences).
- Workspace README — LLVM setup per platform, build/test commands, crate layout.