A Clojure interpreter running on Janet. Jolt reads Clojure source, evaluates it with an interpreter written in pure Janet, and ships a Clojure-compatible standard library. The goal is a Janet-hosted SCI runtime — a minimal bootstrap that loads SCI's Clojure source as its standard library.
git clone https://github.com/jolt-lang/jolt.git
cd jolt
git submodule update --init # pulls vendor/sci
jpm build # builds build/jolt and build/jolt-depsRequires jpm and a recent Janet (CI-tested against 1.41). See
doc/building-and-deps.md for build details, the
jpm clean caveat, how namespaces are resolved (JOLT_PATH), and pulling
Clojure libraries from a deps.edn with the jolt-deps tool.
build/jolt # start a REPL
build/jolt file.clj [args] # run a file (binds *command-line-args* and *file*)
build/jolt -e EXPR [args] # evaluate EXPR and print the result
build/jolt -m NS [args] # require NS and call its -main
build/jolt nrepl-server [addr] # start an nREPL server ([host:]port, default 7888)
build/jolt --version # print the version
build/jolt -h | --help # help
The REPL accumulates multi-line forms until they balance:
user=> (defn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))
#'user/fib
user=> (map fib (range 10))
(0 1 1 2 3 5 8 13 21 34)
Running a file evaluates its top-level forms:
$ echo '(println "hello" (* 6 7))' > hello.clj
$ build/jolt hello.clj
hello 42
(use jolt/api)
(def ctx (init))
(eval-string ctx "(+ 1 2)") # → 3
(eval-string ctx "(map inc [1 2 3])") # → [2 3 4](init) returns a context with clojure.core loaded. Each context is isolated; use separate contexts for separate environments.
Every form Jolt evaluates passes through one router (eval-one), which decides
per form whether to tree-walk it or compile it to Janet. There are two modes:
Interpreted (default). Without :compile?, every form is evaluated by the
tree-walking interpreter (eval-form). This is the live, fully-featured path:
all of Clojure's semantics — macros, multimethods, protocols, dynamic vars,
lazy seqs, destructuring — go through here.
Compiled (:compile? true). With compilation enabled, the router splits each
top-level form two ways:
- Context-modifying forms always interpret.
ns,defmacro,deftype,defmulti/defmethod,require,in-ns,set!,var,.,new,eval, and syntax-quote mutate the evaluation context (namespaces, the macro table, type/method registries, dynamic vars), so they are routed to the interpreter unchanged. - Everything else compiles to Janet. The form is macro-expanded, lowered to
a Janet AST, and
eval'd in a per-context Janet environment.def/defnbindings live in that environment so they persist and resolve across forms (and self-recurse via a named-fn rewrite); hot numeric primitives (+ - * < > <= >=) emit native Janet ops so the JIT-free Janet VM runs them at full speed; and function calls compile to direct Janet calls (keyword/map/set in call position still dispatch through the IFn runtime).
The two paths share one context. Compiled def/defn results are both
evaluated into the Janet environment and interned into the Jolt namespace, so
an interpreted form can call a compiled function and vice-versa within the same
context — which is what makes the always-interpret carve-out above safe.
(def ctx (init {:compile? true}))
(eval-string ctx "(defn fib [n] (if (< n 2) n (+ (fib (- n 1)) (fib (- n 2)))))")
(eval-string ctx "(fib 30)") ; → 832040, fastFor compute-heavy code the compiled path is dramatically faster — recursive
fib(30) runs in ~0.08 s compiled vs ~50 s interpreted (≈600×), at native Janet
speed.
Compile mode is opt-in and still maturing. The numeric-op inlining relaxes the
strict non-number checks (e.g. (< nil 1) doesn't throw), and constructs the
compiler doesn't yet handle currently error rather than transparently
falling back to the interpreter — a per-form hybrid fallback (compile what we
can, interpret the rest) is the next step toward making compilation safe to
turn on by default.
Jolt exposes CLJS-style host interop through . on any Janet table or struct — a field holding a function is called with the receiver as the first argument:
(def obj {:greet (fn [self name] (str "Hello " name))})
(. obj greet "Alice") ; → "Hello Alice"
(.-greet obj) ; field access (reader sugar for (. obj :greet))The whole Janet standard library is reachable from Clojure through an explicit
janet namespace segment, which marks every crossing into host code (where
Clojure semantics no longer hold):
(janet.os/clock) ; → a Janet module fn: os/clock
(janet.string/join ["a" "b"] ",") ; → janet `string/join` (NB: takes a Janet
; tuple, not a Jolt vector — convert first)
(janet/slurp "deps.edn") ; → a Janet root builtin: slurp
(janet/type [1 2]) ; → :tableThe rule is janet/<name> for a Janet root binding and janet.<module>/<name>
for a module binding. Because the boundary is explicit, you can tell at the call
site that a form drops into the host — and that values cross the boundary as
their Janet representations (a Jolt vector is a Janet table, etc.), so a Janet
function expecting a tuple needs an explicit conversion. The jolt.interop,
jolt.shell, and jolt.http namespaces are thin Clojure wrappers built on this.
This bridge is what makes networking (and everything else in Janet's stdlib)
available to ordinary Clojure — for example, jolt.nrepl (below) is plain
Clojure over janet.net/*.
(require '[jolt.interop :as j])
(j/janet-type [1 2]) ; → :tuple
(j/janet-table-keys {:a 1 :b 2}) ; → [:b :a]Jolt ships an nREPL server and client (jolt.nrepl),
written in Clojure on top of the janet.net/* bridge. Start a server from the
CLI — it writes .nrepl-port so editors (CIDER, Calva, …) auto-connect:
jolt nrepl-server # listen on 127.0.0.1:7888, write .nrepl-port
jolt nrepl-server 12345 # choose a port
jolt nrepl-server 0.0.0.0:12345 # choose host and port (alias: nrepl)Supported ops: clone, describe, eval, load-file, close, ls-sessions,
interrupt (acknowledged; an in-flight eval can't actually be interrupted), and
eldoc. eval streams out, reports the current ns, evaluates each form in
the message, and returns an eval-error status (the session stays usable) on
failure. One Jolt runtime backs the server and sessions share it, so defs
persist across a connection like a normal dev REPL.
It's also usable as a library — embed a server, or drive another nREPL as a client:
(require '[jolt.nrepl :as nrepl])
(def server (nrepl/start-server! {:port 7888}))
;; ... later ...
(nrepl/stop-server! server)
(def c (nrepl/connect {:port 7888}))
(def session (nrepl/client-clone c))
(nrepl/client-eval c "(+ 1 2)" session) ; → responses incl. {"value" "3"}
(nrepl/client-close c)Jolt targets Clojure semantics but runs on Janet, not the JVM. The notable divergences:
- Host platform. No JVM and no Java interop —
import,gen-class,proxyof Java classes, andjava.*are unavailable.instance?recognizes a small set of built-in types (clojure.lang.Atom,Number,String, …). - Numbers. Janet integers and doubles.
(/ 1 3)is0.3333…and large products lose precision. No ratios orBigDecimal(ratio?is always false,bigdecfalls back to a double);bigint/bigintegeruse Janet's 64-bitint/s64, not arbitrary precision. The reader still accepts Clojure's numeric literal syntaxes — the BigInt/BigDecimal suffixes (42N,1.5M), ratios (1/2), radixed integers (2r1010,16rFF), and exponents (1e3) — but reads them as plain Janet numbers (a ratio becomes its double quotient). The auto-promoting+'/-'/*'/inc'/dec'are aliases for the plain ops, since Janet numbers don't overflow.quot/rem/modfollow Clojure's sign rules. The symbolic values##Inf/##-Inf/##NaNread, andinfinite?/NaN?work. Janet represents an integer and an integer-valued double identically, so1and1.0are indistinguishable:(float?/double? 1.0)isfalseand(int? 1.0)istrue—float?/double?are true only for values with a fractional part or##Inf/##NaN. - Collections. By default Jolt uses immutable persistent data structures: vectors are 32-way branching tries (structural-sharing persistent vectors with O(log₃₂ n)
conj/assoc/nth), lists are persistent singly-linked cons cells (O(1)conj/consprepend with structural sharing), and maps/sets are persistent hash structures. Value equality and sequence operations are Clojure-compatible, but hash-map/hash-set iteration order is unspecified and differs from Clojure — usesorted-map/sorted-setwhen order matters. - Mutable build mode. Jolt can be compiled to use fast Janet-native mutable collections instead, via a build-time flag:
JOLT_MUTABLE=1 jpm build(defaultjpm buildis immutable). In mutable mode vectors and lists share one mutable array representation (soconjmutates in place and appends, andvector?/list?no longer distinguish them) — a performance/looseness trade-off. The default immutable build has full Clojure value semantics. - Concurrency / STM. No refs,
dosync, agents, orsend;lockingevaluates its body without real locking. Atoms, volatiles, promises, and delays are supported. - Futures.
futureruns its body on a real OS thread (Janet'sev/thread), so it can use a second core for CPU-bound work — unlike the cooperatively-scheduledgoblocks.deref/@parks until the result is ready (with the optional(deref f timeout-ms timeout-val)arity);future?,future-done?,realized?,future-cancel, andfuture-cancelled?are supported. Two important divergences from the JVM: (1) snapshot semantics — Janet threads have separate heaps, so the body and the state it closes over are copied to the worker thread and only the return value is copied back; mutating a captured atom does not propagate to the parent (communicate via the return value). (2) no thread interruption — Janet OS threads can't be cancelled mid-run, sofuture-cancelmarks the future cancelled (deref then throws and the predicates flip) but the underlying computation still runs to completion in the background. As on the JVM, a live future thread keeps the process alive until it finishes (the JVM's non-daemon future pool behaves the same). - core.async.
clojure.core.asyncruns on Janet fibers and channels (chan,go,go-loop,<!/>!/<!!/>!!,close!,alts!,timeout,put!/take!,buffer/dropping-buffer/sliding-buffer, and channel transducers via(chan n xform)). Because Janet fibers are stackful coroutines, agoblock is just its body run in a fiber — no CPS/state-machine rewrite — so<!/>!work anywhere, including insidetry, nestedfns, and loops (positions Clojure'sgomacro forbids). Go blocks are cooperatively scheduled on one OS thread, so parking (<!) and blocking (<!!) coincide;threadruns cooperatively too. Dynamic-var bindings are conveyed intogoblocks (each go block sees the bindings in effect when it was spawned). - Regex. Compiled to Janet's PEG engine (Janet has no regex). Supported: capturing groups (
[whole g1 …]), greedy and lazy quantifiers with backtracking,(?:…), lookahead(?=…)/(?!…), alternation, anchors^ $ \b \B, character classes, and the(?i)flag. Not supported: lookbehind, backreferences (\1), named groups ((?<name>…)), and Unicode property classes (\p{Lu}). - Arrays. Java-style arrays map onto Janet's native types:
byte-arrayis a Janet buffer (contiguous, C-backed);object-array/int-array/double-array/etc. are Janet arrays.aget/aset/alength/aclonework over both. - Transients.
transient/conj!/assoc!/dissoc!/disj!/pop!/persistent!are real mutable scratch collections backed by Janet's native arrays and tables (vectors → arrays, maps/sets → tables), so building a collection with them avoids the per-step copying of the persistent path (notably for maps/sets).persistent!freezes back to a persistent value. - Not implemented. JVM reflection,
proxy, and theclojure.repl/clojure.templatenamespaces.
Supported and Clojure-compatible: chars as a distinct type, lazy/infinite sequences, transducers, destructuring, multimethods with hierarchies, protocols/records (deftype/defrecord/reify/extend-protocol), metadata, namespaces, and the reader (#(), #_, #?, tagged literals, #"…").
jpm test # full suite (recurses test/)
janet test/spec/sequences-spec.janet # a single spec
janet test/integration/conformance-test.janet
Tests are organized in three layers:
test/spec/— the contract. Black-box, behavior-defining tables (one file per public API area) that collectively pin down Jolt's defined behavior. This is the authoritative description of what Jolt promises.test/integration/— cross-cutting and regression batteries: the Clojure conformance suite, SCI bootstrap/runtime loading, jank conformance, the cross-dialect clojure-test-suite (run via a minimalclojure.testshim against~/src/clojure-test-suite, if present, and baseline-guarded), compile-mode tests, the library API, and a broad systematic-coverage net.test/unit/— white-box tests for individual components (reader, evaluator, types, persistent collections, regex, compiler).
test/support/harness.janet provides the shared defspec table runner (cases
are ["label" expected actual], compared with Jolt's own =) plus
expect=/expect-throws for unit tests.
The syntactic half of the contract — the surface syntax the reader accepts — is
specified as an EBNF grammar in doc/grammar.ebnf, with
Jolt-vs-Clojure deviations noted inline. test/spec/reader-syntax-spec.janet
exercises it.
The clojure-test-suite battery
runs ~3900 assertions green. Jolt validates its arguments like Clojure —
arithmetic on non-numbers, comparisons against nil, out-of-range indices,
malformed conj!/assoc!/merge, and non-seqable first/seq/vec all
throw. The assertions that remain failing are accounted for by the
platform/design differences above, not by missing behavior:
- No bignum/ratio/BigDecimal —
bigint/numerator/denominator/bigdec, thebig-int?/auto-promotion checks, and the2N/1/2/1.0Mliterals read but don't carry those exact types. - Integer/float identity — Janet represents
1and1.0identically, soquot/rem/mod'sdouble?/int?result-type assertions and manyfloat?/double?cases can't distinguish them ((str 0.0)is"0"). - 64-bit integers / Unicode —
bit-andetc. on full-width 64-bit constants lose precision (doubles), andsubs/countwork on bytes, not code points. - Eager seqs —
map/filter/rangereturn vectors, soseq?/vector?/sequential?of their results differ, and sorts aren't guaranteed stable.