clinj is not java.
clinj runs JVM-Clojure libraries unchanged on non-JVM Clojure runtimes (ClojureScript, jank, jolt, let-go, or a custom host) by shimming the JDK API surface those libraries call — not by emulating the JVM.
The model is WINE, not an emulator: WINE reimplements the Win32 API that apps call, not the CPU or the kernel. clinj reimplements the JDK API that Clojure programs call, not the bytecode VM, the numeric tower, or reflection. "Just enough Java to run Clojure programs."
Reimplementing a dependency resolver is tempting and almost always a mistake. Basis calculation, deps expansion, and Maven's POM and version semantics are large, subtle, and alive — they keep evolving. A paraphrase, however careful, becomes a second thing that has to be kept faithful forever and drifts the moment it isn't watched. clinj takes the opposite stance: it runs the actual, unmodified source. Fidelity is by construction, and following a new upstream release is just re-running the port — there is no parallel implementation to maintain.
That stance carries into how the code is licensed. clinj's own work is given away freely; code that clinj merely ports keeps its original copyright and license, carried through untouched, with a provenance banner noting the mechanical transform. A portability layer should add reach without taking ownership of anything it didn't write.
And the one piece clinj does write from scratch — Maven resolution, the part that genuinely can't be shimmed — is deliberately plugged in through tools.deps' own extension points rather than woven into its internals. It sits alongside the real resolver as a swappable extension, not as a fork of it.
Concretely:
- clinj's own code (shims, codemod,
clinj.mvn,subst/adapters) → 0BSD, no copyright claimed (LICENSE). - Vendored
clojure.tools.deps/clojure.tools.gitlibs(© Rich Hickey and contributors; authored and maintained by Alex Miller) → EPL-1.0, unchanged; clinj's 0BSD never touches it. clinj.mvn→ original pure-Clojure POM/version resolver, registered through the tools.deps extension multimethods.
Full provenance in NOTICE.md. The long-term aim is to be
complementary — upstreaming small portability fixes so the real code runs
directly on more hosts and the subst/ adapters shrink over time.
1. The codemod (scripts/port.clj) — a build-time .clj → .cljc transform.
It reader-conditionalizes the ns form so that on :clj the file stays
byte-identical (real :import, real clojure.java.io), and on every non-JVM
host (:default) the Java imports become :requires on clinj shims/stubs. The
body is preserved verbatim except a few mechanical normalizations (see below).
An author can instead do this one edit by hand — reader-conditionalize the
:import clause — and leave the whole body untouched. That's the entire
"author contract."
2. The shims (src/clinj/java/*, src/clinj/clojure/*) — portable .cljc
reimplementations of just the JDK API surface Clojure code actually calls.
Host-agnostic. Every shim type is a deftype whose instance methods are
declared under Object with their literal Java names (getPath,
isAbsolute, …). That's the load-bearing trick: on ClojureScript, Object
methods become real prototype methods, so unchanged interop — (.getPath f),
(instance? File f), File/separator — dispatches natively. (Protocol methods
get munged and would not be reachable via raw .method.)
3. The host backend (src/clinj/host/fs.cljc, process, clinj.runtime) — the
per-runtime effect layer. This is the only part that differs between cljs,
jolt, let-go, and a custom host. host.fs has :clj / :cljs / :default
branches; adding a runtime means adding its branch (its native file IO, process
spawn, etc.).
Source library code (unchanged):
(ns my.lib
(:import [java.io File])) ; <- only this line is reader-conditionalized
(defn parent-path [p]
(.getPath (.getParentFile (File. p))))On ClojureScript, File is clinj.java.io/File (a deftype); (File. p)
constructs it; (.getParentFile …) / (.getPath …) hit its Object methods,
which delegate to clinj.host.fs, which calls Node's path/fs. On jolt, the
same shim calls jolt's native IO. The library body never changes.
These are the same machine — (A) is just (B) applied to one specific library, pre-packaged.
clinj ships the real clojure.tools.deps already ported, so a non-JVM
Clojure gets dependency resolution — read a deps.edn, resolve :local/:git/
:mvn coordinates, build a classpath — with no JVM:
(require '[clojure.tools.deps :as deps]) ; the REAL tools.deps, unmodified
(deps/calc-basis
{:deps {'my/lib {:local/root "../lib"}}
:paths ["src"]})
;; => {:libs {my/lib {...}}
;; :classpath-roots ["src" "../lib/src"] ...} — on Node, no JVMThis is load-bearing infrastructure: a non-JVM Clojure needs dep resolution to
bootstrap its own ecosystem. clinj also exposes the portable clojurestar.deps
facade (add-deps / add-lib / add-libs / sync-deps / current-basis) — the
same shape Grenadine uses — backed by
the real tools.deps, so clinj is a drop-in for it (see below).
(require '[clojurestar.deps :as deps])
(deps/add-deps '{:deps {org.clojure/data.json {:mvn/version "2.5.1"}}}) ; => nil
(deps/current-basis) ; the resolved basisTo use some existing JVM-Clojure library (e.g. a clj-commons lib) on cljs/jolt:
- Author opt-in — rename
.clj → .cljc, reader-conditionalize the imports, depend on clinj. Ships once, runs everywhere. - Consumer / automatic — run the library's source through clinj's codemod at
build time (this is exactly what
prepare-source-roots!does after the resolver extracts a dependency), then(:require [the.lib])and use it.
Whether a given library can be ported depends on what JDK surface it touches (next section).
Like WINE's app-compatibility tiers, coverage is a spectrum, not all-or-nothing.
| Tier | What it means | Examples | Works? |
|---|---|---|---|
| T1 — already portable | pure Clojure / already .cljc |
camel-snake-kebab, rewrite-clj, tools.cli | free |
| T2 — JDK surface only | touches only JDK APIs clinj shims over host effects | tools.deps, fs (java.io/nio), digest (→crypto), conch (→child_process), clj-http-lite (→http) |
yes — clinj's job |
| T3 — wraps a Java library | thin Clojure over a whole Java lib (not JDK) | clj-yaml→SnakeYAML, hickory→jsoup, aleph→Netty, pomegranate→Aether | needs a JS-native backend per lib (opt-in seam) |
| T4 — JVM-only semantics | needs things a JS host fundamentally lacks | seesaw (Swing), primitive-math (JVM longs), manifold (real threads), AOT compile-clj/javac |
no (WINE + kernel driver) |
clinj targets all of T1 + T2 out of the box. T3 gets a backend seam (same
pattern as host.fs) so a JS impl drops in. T4 is out of scope by construction.
The tools.deps ecosystem specifically: tools.deps (core, T2),
tools.gitlibs (:git, shells to the git binary, T2), data.xml (has a cljs
port), tools.cli (T1), tools.build (jar/uber tasks T2, but AOT
compile-clj/javac are the T4 wall).
The codemod, shims, and reader-conditional scheme are already host-neutral —
#?(:clj … :default …) and :default fires for jolt/let-go/jank/cljs alike.
Adding a runtime is:
- Host backend — add the runtime's branch to
clinj.host.fs, the process shim, andclinj.runtime(its native file IO, spawn, runtime types). The shim internals currently branch on:cljs; generalize the ones a host needs to:jolt/:lg/ etc. - Per-host de-risk — re-run the make-or-break validation: does raw
.methodinterop hitdeftypeObjectmethods on that host? does cross-namespace macro inference work? (These are proven for cljs; each host confirms its own.)
The bar per host is Grenadine's conformance suite (make test-all runs it on
jolt and let-go).
Grenadine is the opposite bet: a
from-scratch pure-Clojure rewrite of the dependency resolver (zero Java
interop, host effects injected as a :host map), targeting JVM/bb/glojure/jolt/
let-go. clinj instead runs the real tools.deps unmodified on JDK shims,
targeting cljs/jank/jolt/let-go/custom.
clinj aims to be a drop-in replacement for Grenadine — same
clojurestar.deps facade, and it adopts Grenadine's conformance suite (the
portable unit tests + the calc-basis differential oracle over
test/fixtures/grenadine/differential-corpus.edn).
The two converge on Maven: tools.deps' :mvn extension is the aether/Maven
Java stack, which can't be JDK-shimmed. But tools.deps dispatches extension
behavior through multimethods keyed on coordinate type, so clinj overrides
just the :mvn methods (coord-deps/coord-paths/find-versions/
compare-versions) with a pure-Clojure resolver — adapting Grenadine's core.
:git/:local/:deps/:pom + the whole framework stay real unmodified
tools.deps. (This is the "hybrid Maven" plan; not yet built — see Status.)
| Capability | State |
|---|---|
| Interop de-risk (cljs Object / let-go protocol / jolt native dispatch) | ✅ validated on all three |
calc-basis :local+:git+:mvn (transitive) on cljs/Node |
✅ green |
calc-basis :local+:git+:mvn (transitive) on let-go |
✅ green — identical basis to cljs |
jolt: ported .cljc runs on native java.* (+ :jolt host backends) |
✅ dir proven (native java.*); full resolver blocked by jolt v0.6.5 runtime gaps in the expander (no bundled clojure.spec; deref semantics for Future/delay) — jolt-side, and jolt ships its own resolver |
clojurestar.deps facade (Grenadine drop-in shape) |
✅ done |
| Differential oracle vs real tools.deps (Grenadine corpus) | ✅ 6/6 exact match on Node |
Proven end-to-end: the real, unmodified clojure.tools.deps resolves :local,
:git, and (transitive) :mvn dependencies in one calc-basis and builds a
classpath on Node — no JVM. And it's held to the real thing: clinj's shimmed
resolver is diffed against JVM-aether tools.deps ([:libs :classpath :classpath-roots]) over Grenadine's differential corpus, 6/6 exact match
(scripts/oracle_expected.clj → proof/run_oracle.cljc). The core resolver is
green up to AOT (which needs a real JVM and is out of scope).
src/clinj/
host/fs.cljc host FS backend (:clj java.io / :cljs Node path+fs / :default)
java/io.cljc File, reader/stream chain, StringWriter
java/lang.cljc System, Class, Runtime, Thread, ProcessBuilder/Process,
Exception family, format, promise/deliver, Boolean
java/nio/file.cljc Path, Files
java/util*.cljc List, Properties, concurrent (ConcurrentHashMap), function, jar
java/net.cljc URL, URI
clojure/java_io.cljc clojure.java.io front-end (file, resource, copy, reader)
clojure/lang.cljc PersistentQueue, EdnReader$ReaderException, ...
runtime.cljc runtime compat (patches cljs MultiFn with JVM interop methods)
stub/… Tier-2 load-only stubs (aether/maven/plexus) — see scripts/gen_stubs.clj
subst/… host-effect SUBSTITUTES for tools.deps leaves that can't port
transparently: util/{concurrent,session,io}, specs, s3_transporter
(same public API, clinj-native impl)
scripts/
port.clj the codemod (.clj -> .cljc)
port_td.clj batch-port the vendored tools.deps + tools.gitlibs tree
gen_stubs.clj generate the Tier-2 stub namespaces
proof/ end-to-end runners (run_basis.cljs, run_dir.cljs, run_stubs.cljs)
test/fixtures/ local-lib fixture + vendored Grenadine differential-corpus
ported/ (gitignored) codemod output for the vendored tools.deps tree
Run: clojure -M scripts/port.clj in.clj out.cljc
Batch (vendored tools.deps + gitlibs): clojure -M scripts/port_td.clj
What it rewrites — the ns form:
(:import [pkg Class…])→#?@(:clj [] :default [[clinj.<pkg> :refer [Class…]]]); JDK packages map toclinj.java.*, the aether/maven stack toclinj.stub.*.clojure.java.io/clojure.java.shellrequires → clinj equivalents on:default.- Auto-imported
java.langusage (System/,Class/,format,promise,ProcessBuilder,(catch Exception …), …) is detected in the body and injected asclinj.java.langrefers — becausejava.langhas no:import. - A ns that defines macros gets
#?(:cljs (:require-macros [self :refer [macros…]]))so cljs treats them as macros (and same-ns unqualified uses expand).
And a few body normalizations (semantics-preserving):
(load "…")→#?(:clj (load "…"))(extensions get:required on non-JVM instead).(set! *warn-on-reflection* …)→ gated to:clj.- Clojure regex
\/→/(cljs emits\/as an invalid JS regex-literal escape). (.length x)→(.-length x)(JVM String/array.length()is a JS property).
Most of tools.deps ports transparently (logic unchanged). A few thin
host-idiom leaves are substituted — a clinj-native .cljc with the same
public API replaces the original, because they're pure host effect or use
JVM-only mechanics (ConcurrentHashMap, promise/Thread, PushbackReader
streams, alter-var-root, clojure.spec). These live in subst/ (ahead of
ported/ on the classpath) and are the non-JVM analog of Grenadine's :host
map. gitlibs' concurrency leaf is the next one to substitute.
Compile + run the deps proof on Node:
clojure -M scripts/port_td.clj # port the vendored tree -> ported/
clojure -Sdeps '{:deps {org.clojure/clojurescript {:mvn/version "1.12.145"}} \
:paths ["subst" "src" "ported" "proof"]}' \
-M -m cljs.main \
-co '{:main run-basis :output-to "/tmp/clinj/main.js" :output-dir "/tmp/clinj/out" \
:target :nodejs :optimizations :simple}' -c
node /tmp/clinj/main.jsThe same .cljc proofs also run on the JVM (real java.io) for parity, e.g.
proof/clojure/tools/deps/util/dir.cljc via clojure -M ....
Note: the vendored tools.deps / tools.gitlibs sources are ported into
ported/ (gitignored) from a local checkout; the codemod + subst/ + shims are
the actual clinj source.