cljrs (Clojurust CLI)
The cljrs binary — command-line interface for running, compiling, and
interactively exploring clojurust programs.
File layout
src/
main.rs — the binary: a one-line shim over `cli::main`
lib.rs — module index; the CLI lives in a library so its own
integration tests can reach it (not an embedding API)
cli.rs — global flags, the miette error hook, the tracing
subscriber, the large-stack worker thread, and the
subcommand dispatcher
session.rs — everything more than one subcommand needs: `setup_globals`
(runtime + stdlib + `cljrs.edn` wiring + JIT policy),
source-path helpers, `eval_in` / `eval_form`, the async
driver, and error formatting
native/ — loading native (Rust) code into a running environment
mod.rs — the project's own `:rust` cdylib and the cargo/path helpers
pinned.rs — pinned native packages (`:rust/load :dylib`): wrapper
generation, cargo build + cache, dlopen + ABI handshake
extensions.rs — `default_set()`: the runtime extensions this build ships,
handed to the compiler for `cljrs compile` (the compiler
backend does not choose them)
build.rs — captures `rustc -V` for the pinned-package ABI fingerprint
commands/ — one module per subcommand: its clap `Args` and its `run`
mod.rs — module index
run.rs — `run`: interpret a file, then call `-main`
repl.rs — `repl`: the interactive loop
compile.rs — `compile`: `CompileTarget`, entry-namespace resolution,
opacity policy, native and wasm AOT
eval.rs — `eval`: one expression
ir/ — `ir`: `IrCommands` enum, dispatch, bundle pre-lowering
mod.rs — (`ir build`) and bundle dump
viz/ — `ir viz`: the self-contained HTML IR visualizer
mod.rs — `render_html` / `RenderOptions`
render.rs — HTML assembly, region colouring, source pane
region.rs — `RegionStart`/`RegionEnd` pairing and membership
blame.rs — escape-verdict badges and the blamed use
test.rs — `test`: namespace discovery, the runner, the summary
deps.rs — `deps fetch` / `deps status`
build_native.rs — `build-native`: cargo-build the project's `:rust` crate
lsp.rs — `lsp`: run the language server over stdio
nrepl.rs — `nrepl`: serve an nREPL session
Subcommands
| Subcommand | Purpose |
|---|---|
run |
Interpret a .cljrs / .cljc source file |
repl |
Start an interactive REPL |
compile |
AOT-compile a source file or project (via cljrs.edn) to a native binary, or a .wasm module with --target wasm |
eval |
Evaluate a single Clojure expression and print the result |
ir build |
Pre-lower namespaces to IR and write a serialized bundle |
ir dump |
Print a human-readable dump of a serialized IR bundle |
ir viz |
Render the optimized IR + source as a self-contained HTML visualizer |
test |
Run clojure.test namespaces (named on the CLI or auto-discovered) |
deps fetch |
Clone / update git dependencies declared in cljrs.edn |
deps status |
Show which dependencies are cached and which are missing |
-main entry point
After all top-level forms in the source file are evaluated, cljrs run looks
up -main in the current namespace. If the var exists it is called with the
arguments that follow -- on the command line, each as an individual string:
The same convention applies to AOT binaries produced by cljrs compile: the
compiled binary calls -main after __cljrs_main finishes, passing all
argv entries (skipping the program name) as individual string arguments.
If -main is not defined the program exits normally without error.
An ^:async -main is supported: calling it returns a Future immediately,
so cljrs run awaits that future on the shared async LocalSet (see
implementation notes) before exiting, ensuring the body and anything it spawns
run to completion. A synchronous -main is awaited as a no-op pass-through.
Per-subcommand flags
run, repl, compile, test accept:
--src-path <DIR>— repeatable; directories searched byrequire--gc-soft-limit-mb <MB>— soft GC threshold--gc-hard-limit-mb <MB>— hard GC threshold
run additionally accepts:
[-- ARGS…]— positional arguments forwarded verbatim to-main
compile additionally accepts:
-o, --out <PATH>— output path (required): a native binary, or a.wasmmodule with--target wasm--target <native|wasm>- code-generation target (defaultnative), a closed set validated by clap.wasmemits a WebAssembly module via the AOT wasm backend (the entry namespace's functions; the"rt"imports are satisfied by the runtime built forwasm32-unknown-unknown).--testis not yet supported withwasm.--main <NS>— namespace containing-main; overrides:mainincljrs.ednand auto-detection--test— compile a test harness that runs every test in the given file/directory--require-fully-compiled- fail the build if the artifact would not fully represent the program. On--target nativethat means embedded readable Clojure source (interpreted preambles, bundled namespaces); on--target wasm, which embeds no source, it means a namespace or entry form the backend dropped.--testcannot satisfy it (the harness bundles every test namespace as source) and is refused.
ir build accepts:
-n, --ns <NS>— repeatable; namespaces to lower (defaultclojure.core)-o, --output <PATH>— output bundle path (defaultir_bundle.bin)--src-path <DIR>— repeatable; source directories forrequire-ing non-clojure.corenamespaces-v, --verbose— print per-arity lowering progress
ir dump takes a single positional bundle path and prints the IR of every function it contains.
ir viz accepts:
-o, --out <PATH>— output HTML path (defaults to<file>.ir.html)--src-path <DIR>— repeatable--quiet— suppress the[aot] …progress output
test additionally accepts:
[namespaces…]— positional list; if empty, namespaces are auto-discovered under--src-path-v, --verbose— print each passing assertion (helps isolate hangs)
eval takes a single positional expression string.
deps fetch accepts an optional positional dependency name; without it all git
deps are fetched. deps status takes no arguments.
cljrs.edn auto-discovery
When any command that runs code (run, repl, eval, test, compile)
starts, it walks up the directory tree from the current working directory
looking for a cljrs.edn file. If found, its :paths entries are appended
to the source search path (after any --src-path CLI flags), and the parsed
DepsConfig is stored in GlobalEnv.deps_config so that versioned symbol
resolution can use it without a second parse.
compile and cljrs.edn
cljrs compile reads cljrs.edn to determine:
- Source paths —
:pathsentries are added to--src-path(CLI flags come first). - Dependency source roots — each dep's source directories are resolved and appended so
requireresolves correctly during compilation. - Entry-point namespace — determined by the following priority:
--main <NS>CLI flag (highest priority):mainkey incljrs.edn(e.g.:main my.app.core)- Auto-detection: scans
:pathsfor a unique-mainfunction; errors if zero or multiple are found
When cljrs.edn is present and the entry-point namespace is known, the
file positional argument may be omitted; the compiler finds the source file
for the main namespace automatically.
Each declared dependency's own source roots are also appended to the search
path, so a plain (require '[dep.ns :as …]) resolves namespaces provided by a
dependency:
- Local deps (
:local/root) contribute theircljrs.edn:paths(orsrc/) from the directory on disk. - Git deps are materialized from the local bare cache at their pinned
:git/sha(no network — runcljrs deps fetchfirst; a missing cache warns and is skipped), and contribute the checkout's:paths(orsrc/). - Native deps (
:rust/load :dylib) carry no Clojure source; they are built and registered on demand by the native-requireloader (native::pinned) when their namespace is firstrequired.
Global flags
These appear before the subcommand and apply to every command:
-
--stack-size-mb <MB>— thread stack size (default 64). Raise if you hit stack overflows in deeply recursive code. -
--debug— enable debug logging -
--trace— enable trace logging (implies--debug)Codegen crates (
cranelift_*,regalloc2) are pinned towarnat all verbosity levels —cranelift-jit/cranelift-objectlog every compiled function's whole CLIF body atinfo, which otherwise buries real output. SetRUST_LOG(tracingtarget=level syntax) to replace the defaults and get them back, e.g.RUST_LOG=info,cranelift_jit=info cljrs run app.cljrs. -
-X <LEVEL:FEATURES>— feature-level logging, repeatable. Format:<level>:<feat1>,<feat2>,…. Levels:debug,trace. Features:gc,env,ir,jit. Example:-X debug:gc,jit. These aretracingtargets:RUST_LOG=gc=debugdoes the same thing, and-Xis layered on top ofRUST_LOGso both can be used together. A blanket--debug/--tracedeliberately leaves them off — they are firehoses. A malformed-Xis a hard error; a malformedRUST_LOGis reported on stderr and ignored, leaving the--debug/--tracedefault in place. An AOT binary reads the same two variables (CLJRS_X_FLAGin place of-X) and treats a bad value in each exactly the same way. -
--gc-stats [FILE]— print acljrs_gc::GC_STATSsnapshot at program exit (allocations, region/bump usage, GC pause count + total duration, freed objects/bytes). No value → stdout; with a path → that file. Honoured byrun,eval, andtest. -
--jit-stats [FILE]— print a JIT specialization / inline-cache counter snapshot at program exit (boxed arithmetic bridge calls, entry-guard deopts, keyword IC fills, protocol IC hits/misses; Phase 10.6,cljrs_compiler::rt_abi::jit_stats). No value → stdout; with a path → that file. Honoured byrun,eval, andtest.
Examples
# Interpret a file
# REPL
# AOT compile to a native binary
# One-shot expression
# Render IR visualizer (writes samples/graph.cljrs.ir.html, open in any browser)
# Pre-lower namespaces to an IR bundle (replayed by cljrs_eval::load_prebuilt_ir;
# no cljrs runtime path loads one today - these are lowerer diagnostics)
# Tests
# GC stats
# Bigger stack + tracing for one feature
# Dependency management (reads cljrs.edn from the current directory tree)
Build features
| Feature | Effect |
|---|---|
async (default on) |
Pulls in cljrs-async and cljrs-io and builds the Tokio runtime that drives top-level async evaluation (see implementation notes). Without it, ^:async/core.async/clojure.rust.io.async are unavailable and evaluation is purely synchronous. |
net, charset, base64 (default on) |
Network transports and protocols, charset codecs, Base64. Each feature adds its package to both the interpreted runtime (setup_globals) and the compile-time extension set (extensions::default_set), so cljrs run and cljrs compile of the same program see the same namespaces. |
no-gc (default off) |
Propagated to cljrs-gc/cljrs-value/cljrs-eval/cljrs-compiler/cljrs-stdlib. Disables the tracing GC; only region-allocated and stack values are permitted. Compiles fail (AotError::NoGcBlacklist) if the program contains allocations the optimizer can't lift onto regions. |
enable-rustyline |
Pulls in rustyline for a line-editing REPL. Without it, cljrs repl falls back to a plain BufRead loop. |
Build with e.g. cargo build --release --features enable-rustyline,no-gc.
Implementation notes
- Argument parsing uses Clap derive macros (
Parser,Subcommand). - The miette error hook is installed at startup so
CljxErrorpropagated tomainrenders with terminal-linked source snippets. - A worker thread is spawned with the configured stack size to run the actual command; the main thread only handles signal/exit setup.
- The REPL prints results, paginates errors via
miette, and persists multi-line input across blank prompts. - Top-level async (with the
asyncfeature).session::with_async_driverbuilds a single-threaded Tokio runtime +LocalSetand stashes it in a thread-localAsyncDriverrather than wrapping the whole session in oneblock_on. Each top-level form is then evaluated throughcljrs_async::eval_asyncviaLocalSet::block_onineval_form, so spawned tasks (core.async producers,^:asynccalls,clojure.rust.io.asyncreaders/writers) make progress and a top-levelawaitresolves. Tasks that outlive a form — e.g. a channeldefd at one REPL prompt and consumed at the next — stay queued on the sharedLocalSetand continue on the next form's drive. Note: blocking ops (<!!/>!!) still park the single executor thread and so are not usable at the top level; use(await (take! ch))/goinstead. ir vizruns the AOT pipeline through region optimization (viacljrs_compiler::aot::lower_file_to_ir) and hands the resultingIrFunctiontocommands::ir::viz::render_html.ir buildboots a standard environment, walks every var in the requested namespaces, and lowers each function arity withcljrs_eval::lower::lower_arityinto anIrBundle. It lives incommands/ir/mod.rs; there is no separate pre-build crate or binary. Nocljrsruntime path loads a bundle —cljrs_eval::load_prebuilt_iris the public API an embedder would call to replay one.
Dependencies
| Crate | Role |
|---|---|
cljrs-types (workspace) |
CljxError for miette::Result propagation; Span |
cljrs-gc (workspace) |
GC root, configuration, GC_STATS snapshot |
cljrs-reader (workspace) |
Lexer + parser |
cljrs-value (workspace) |
Value and persistent collections |
cljrs-eval (workspace) |
Tree-walking interpreter, Env |
cljrs-runtime (workspace) |
Runtime construction (Runtime::builder) and evaluation |
cljrs-stdlib (workspace) |
Standard library installed into the runtime (install) |
cljrs-compiler (workspace) |
AOT pipeline (compile_file, compile_test_harness, lower_file_to_ir) |
cljrs-ir (workspace) |
IrBundle, serialize_bundle, deserialize_bundle — used by ir build / ir dump |
cljrs-interop (workspace) |
Rust ↔ Clojure FFI |
cljrs-async (workspace, optional) |
clojure.core.async runtime + eval_async; enabled by async |
cljrs-io (workspace, optional) |
clojure.rust.io.async async file I/O; enabled by async |
tokio (workspace, optional) |
Single-threaded runtime + LocalSet driving async; enabled by async |
tracing (workspace) |
Level for the --debug / --trace default; --debug / --trace / -X all build one Targets filter and install the stderr subscriber through cljrs_runtime::logging, which owns the tracing-subscriber dependency |
cljrs-project (workspace) |
config — cljrs.edn parser, DepsConfig / Dependency types; vcs — pure-Rust (gitoxide) git helpers: fetch_remote, cache_path_for_url, native signature verification |
clap (workspace) |
CLI argument parsing |
miette (workspace) |
Rich terminal error rendering |
rustyline (workspace, optional) |
Line-editing REPL when enable-rustyline is on |
libloading (workspace) |
dlopen for the project :rust cdylib and pinned native packages |
serde_json |
Reading target_directory out of cargo metadata output |
Pinned native packages (:rust/load :dylib)
Purpose
Pinned native packages: build a dependency's Rust crate at a pinned git
commit as a cdylib and load it, so versioned symbols (my.lib/f@<sha>) can
resolve to truly pinned native code instead of the default verified HEAD
binding (:rust/load :dylib in cljrs.edn). The same machinery also makes a
:rust/load :dylib dependency loadable by a plain require of its
namespace, registering the package's exports into the live (unversioned)
namespace.
Status
Versioned-namespaces plan, Phase 5 (see docs/versioned-namespaces-plan.md).
Implemented and tested end-to-end, but experimental: the init call
crosses a Rust-ABI boundary guarded only by the fingerprint handshake
(feature-flag skew between host and wrapper is not detected), and a Rust
toolchain is required at runtime. Statically linking pinned native crates
into AOT harnesses is deferred (open problem: #[export] inventory
collisions between two versions of one crate).
File layout
src/native/pinned.rs — install (both loader hooks), wrapper crate generation,
cargo build + cache, dlopen + ABI handshake, versioned/unversioned
Registry init
build.rs — captures `rustc -V` for the host side of the ABI fingerprint
tests/
pinned_dylib_e2e.rs — gated end-to-end test (CLJRS_DYLIB_E2E=1): two-commit
native crate fixture; pinned (versioned-symbol) resolution loads
the v1 dylib while HEAD stays untouched, and a plain `require`
loads the v1 dylib into the unversioned namespace
Public API
/// Install both native loader hooks on the environment (idempotent): the
/// pinned-native loader (versioned-symbol resolution) and the native-require
/// loader (plain `require` of a `:rust/load :dylib` dep). Called by the
/// cljrs CLI during setup_globals.
; // cljrs::native::pinned
/// The host's ABI fingerprint: "cljrs <version>; <rustc -V>; <debug|release>".
/// A wrapper dylib is loaded only when its baked fingerprint equals this.
;
pub const ABI_SYMBOL: &; // b"cljrs_dylib_abi\0"
pub const INIT_SYMBOL: &; // b"cljrs_dylib_init\0"
How it works
- The versioned resolver (
cljrs_runtime::env::versioned) calls the installedPinnedNativeLoaderwhen a pinned lookup is about to fall back to a native function. - The loader finds a
:rust/load :dylibgit dep covering the namespace (exact or dotted-prefix match) with a:rust/initfunction. cljrs_project::vcs::fetch_remote+ a gitoxide worktree checkout of the pinned commit's tree (~/.cljrs/cache/dylibs/checkouts/<crate>@<commit>, no.git; a.cljrs-checkout-completesentinel marks a finished checkout).- A wrapper cdylib crate is generated
(
~/.cljrs/cache/dylibs/<crate>@<commit>/fp-<hash>/), pinning the samecljrs-interopas the host (local checkout path when found —CLJRS_WORKSPACE_ROOToverride honored — else the published=version), and built with cargo in the host's profile (debug/release —cljrs-gcobject headers differ between profiles). - dlopen →
cljrs_dylib_abi()fingerprint must equalabi_fingerprint()exactly, else refuse →cljrs_dylib_init(*mut Registry)registers the package's exports throughRegistry::versioned(commit), landing every definition in the immutable"<ns>@<commit>"namespace. - The namespace is marked loaded; subsequent pinned lookups are plain namespace hits.
Plain require of a native dep
When (require '[my.native.lib :as l]) finds no Clojure source for the
namespace, cljrs-runtime's unversioned loader consults the installed
NativeRequireLoader. It runs the same fetch/checkout/wrapper-build pipeline
(steps 2–4 above), keyed on the dep's pinned :git/sha, then runs
cljrs_dylib_init through Registry::for_require(...) — an unversioned
view — so the exports land in the live my.native.lib namespace. The loader
returns and the unversioned loader marks the namespace loaded, so l/encode
resolves like any other namespace.
The IR visualizer (cljrs ir viz)
Purpose: debug the bump-allocation optimizer. When a value escapes or otherwise misses region promotion, the visualizer flags it with the escape-analysis verdict and the use that "blamed" it — making it obvious why the optimizer left it on the GC heap.
Status: implemented and tested against hand-written snippets; not
integrated with the AOT compiler's --emit-ir-html flag — cljrs ir viz
is the interface. This was the cljrs-ir-viz package until consolidation
stage 5; the CLI was its only consumer.
File layout
src/commands/ir/viz/
mod.rs — public entry point: `render_html` and `RenderOptions`
render.rs — top-level HTML assembly, function/block/inst rendering,
source-pane rendering, region color assignment
region.rs — collect `RegionStart`/`RegionEnd` pairs, compute the set
of `(block, inst_index)` positions covered by each region
blame.rs — pick a representative "blame" use for a non-promoted
allocation; format use-kind labels and escape-state badges
tests/
ir_viz.rs — lower a small snippet, render to HTML, and assert the
output is well-formed and contains expected markers
examples/
ir_viz_dump.rs — `cargo run -p cljrs --example ir_viz_dump > /tmp/ir.html`
renders a hand-written demo to stdout
Usage
CLI
From Rust
use ;
use ;
let ir = optimize;
let html = render_html;
write?;
Public API
;
render_html walks ir plus all subfunctions, runs escape analysis with
an inter-procedural context, and produces a complete HTML document. The
return value is a self-contained string suitable for writing to disk and
opening in any browser.
What the visualizer shows
For each function:
- Header — function name (with parent path for subfunctions), parameter list, and source span when known.
- Allocation summary — count of region-allocated, heap, and closure allocations.
- Per-block IR — every instruction with its index, with kinds
color-coded:
alloc(heap) — orangeralloc(region) — green, with strong tint matching the region's colorrstart/rend— italic graycall,store,loc, etc.
- Region coloring — every
RegionStart/RegionEndpair gets a deterministic hue (golden-angle spacing). Instructions inside the region get a pale tint of that hue; the actualRegionAlloc/RegionStart/RegionEndmarkers get a stronger tint plus an accent border. Source lines that produced any of the region'sRegionAllocs get the same accent border in the gutter. - Escape badges — every
Alloc*instruction (i.e. one that did not get promoted) shows its escape verdict (no-escape,arg-escape,returns,escapes) and the blamed use (e.g. "return value", "stored into heap object in bb1", "arg 0 of known call Map"). Pureno-escapeallocations are unusual after optimization and indicate a missed promotion opportunity. - Hover linking — hovering an IR instruction highlights its source
line; hovering a source line highlights all IR insts derived from it.
Lookup is by line number via
data-lineattributes.
Notes on source mapping
ANF lowering emits Inst::SourceLoc(span) markers at the head of each
form's lowering, deduped per (file, line) within a block. These are
pure no-op instructions (Effect::Pure, no dst) so all existing
analysis and code-generation passes ignore them — they exist only for
this visualizer and other downstream tooling.
The IrFunction.span field is currently populated only for
hand-constructed IR; the ANF lowering path does not yet set it for
top-level functions. Subfunction headers therefore show only their
first SourceLoc marker rather than a span range.