Skip to content

Repository files navigation

clinj

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


Why it runs the real thing

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.

How it works — three layers

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

Runtime example

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.


Two ways to use clinj

These are the same machine — (A) is just (B) applied to one specific library, pre-packaged.

A. Dependency resolution (batteries-included)

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 JVM

This 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 basis

B. Run a JVM-Clojure library on your host

To 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).


What ports, and what doesn't (compatibility tiers)

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


Targeting a new host (jolt, let-go, custom)

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:

  1. Host backend — add the runtime's branch to clinj.host.fs, the process shim, and clinj.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.
  2. Per-host de-risk — re-run the make-or-break validation: does raw .method interop hit deftype Object methods 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).


Relationship to Grenadine

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


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.cljproof/run_oracle.cljc). The core resolver is green up to AOT (which needs a real JVM and is out of scope).


Repo layout

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

The codemod (scripts/port.clj)

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 to clinj.java.*, the aether/maven stack to clinj.stub.*.
  • clojure.java.io / clojure.java.shell requires → clinj equivalents on :default.
  • Auto-imported java.lang usage (System/, Class/, format, promise, ProcessBuilder, (catch Exception …), …) is detected in the body and injected as clinj.java.lang refers — because java.lang has 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).

Substitutes vs shims

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.


Development

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

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

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages