Skip to content

jolt-lang/jolt

Repository files navigation

Jolt

tests

A Clojure implementation on top of Janet. Jolt reads Clojure source and, by default, compiles each form to native Janet bytecode — falling back to a tree-walking interpreter for forms the compiler doesn't handle, so results always match the interpreter. It ships a Clojure-compatible standard library. The goal is a Janet-hosted SCI-style runtime with a minimal bootstrap.

Build

git clone https://github.com/jolt-lang/jolt.git
cd jolt
git submodule update --init   # pulls vendor/sci and vendor/clojure-test-suite
jpm build                     # builds build/jolt and build/jolt-deps

Requires jpm and a recent Janet (CI-tested against 1.41). See docs/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.

Run

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 as a library

(use jolt/api)

(def ctx (init))
(eval-string ctx "(+ 1 2)")            # → 3
(eval-string ctx "(map inc [1 2 3])")  # → (2 3 4)   ; a lazy seq, like Clojure

(init) returns a context with clojure.core loaded. Each context is isolated; use separate contexts for separate environments.

Evaluation pipeline: interpreted and compiled

Every form passes through one router (loader/eval-toplevel) that decides per form whether to tree-walk it or compile it to Janet bytecode. The shipped runtime compiles by default; set JOLT_INTERPRET=1 to force the interpreter.

Hybrid, always correct. The compiler is incomplete by design: a form it can't compile correctly throws jolt/uncompilable, and the router falls back to the tree-walking interpreter (eval-form) for that form. So the result always matches the interpreter — compilation is a transparent speedup, never a semantic change. Only the compile step is guarded; runtime errors in compiled code propagate normally (no double-evaluation, no hidden errors).

What compiles: def/defn, multi-arity / named / variadic fns, recur (in loop and directly in fn), let/if/do/try/throw/quote, map and vector literals, and calls. What falls back to the interpreter: context-modifying and definitional forms (ns, defmacro, deftype, defprotocol, defmulti/defmethod, reify, require, binding, …), destructuring, regex literals, and the handful of interpreter-only special forms.

Live redefinition. Compiled global references deref through Jolt var cells (Janet early-binds plain symbols, which would freeze redefinition), so redefining a def/defn at the REPL is visible to already-compiled callers — Clojure's var model. Hot numeric primitives (+ - * < > <= >=) emit native Janet ops, and calls compile to direct Janet calls.

(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, native Janet bytecode

For compute-heavy code the compiled path is dramatically faster than tree-walking, at native Janet speed.

Validated at parity. The conformance suite passes 258/258 under all three execution paths — interpreter, compiler, and the self-hosted compiler (conformance-test.janet runs all three in CI) — and the full clojure-test-suite matches its baseline across ~4.6k assertions — evidence the hybrid path doesn't diverge.

AOT. aot.janet marshals a compiled namespace to a Janet bytecode image (save-ns) and loads it back into a fresh context (load-ns-image), skipping parse/analyze/emit/compile on reload. Core fns are referenced by name against the baked-in runtime; only user bytecode and var cells are serialized.

Host interop

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 janet interop bridge

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])                ; → :table

The 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/*. It also reaches any jpm-installed module: the first reference to janet.<module>/<name> requires the module from the janet module path and caches its bindings — so after jpm install spork, janet.spork.http/* just works. The jolt.http client and the Ring adapter in examples/ring-app are built on spork/http this way, and a project can declare the requirement in deps.edn with a :jpm/module coordinate (see docs/tools-deps.md).

(require '[jolt.interop :as j])
(j/janet-type [1 2])              ; → :tuple
(j/janet-table-keys {:a 1 :b 2})  ; → [:b :a]

nREPL

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)

Differences from Clojure

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, proxy of Java classes, and java.* are unavailable. instance? recognizes a small set of built-in types (clojure.lang.Atom, Number, String, …).
  • Numbers. Janet integers and doubles. (/ 1 3) is 0.3333… and large products lose precision. No ratios or BigDecimal (ratio? is always false, bigdec falls back to a double); bigint/biginteger use Janet's 64-bit int/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/mod follow Clojure's sign rules. The symbolic values ##Inf/##-Inf/##NaN read, and infinite?/NaN? work. Janet represents an integer and an integer-valued double identically, so 1 and 1.0 are indistinguishable: (float?/double? 1.0) is false and (int? 1.0) is truefloat?/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/cons prepend 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 — use sorted-map/sorted-set when 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 (default jpm build is immutable). In mutable mode vectors and lists share one mutable array representation (so conj mutates in place and appends, and vector?/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, or send; locking evaluates its body without real locking. Atoms, volatiles, promises, and delays are supported.
  • Futures. future runs its body on a real OS thread (Janet's ev/thread), so it can use a second core for CPU-bound work — unlike the cooperatively-scheduled go blocks. deref/@ parks until the result is ready (with the optional (deref f timeout-ms timeout-val) arity); future?, future-done?, realized?, future-cancel, and future-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, so future-cancel marks 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.async runs 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, a go block is just its body run in a fiber — no CPS/state-machine rewrite — so <!/>! work anywhere, including inside try, nested fns, and loops (positions Clojure's go macro forbids). Go blocks are cooperatively scheduled on one OS thread, so parking (<!) and blocking (<!!) coincide; thread runs cooperatively too. Dynamic-var bindings are conveyed into go blocks (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-array is a Janet buffer (contiguous, C-backed); object-array/int-array/double-array/etc. are Janet arrays. aget/aset/alength/aclone work 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 the clojure.repl/clojure.template namespaces.

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, #"…").

Test

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 (run in all three execution modes), SCI bootstrap/runtime loading, jank conformance, the cross-dialect clojure-test-suite (a git submodule at vendor/clojure-test-suite, run via a minimal clojure.test shim 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 docs/grammar.ebnf, with Jolt-vs-Clojure deviations noted inline. test/spec/reader-syntax-spec.janet exercises it.

clojure-test-suite conformance

The clojure-test-suite battery (vendored as a git submodule) runs ~3980 assertions green. Jolt validates its arguments like Clojure — arithmetic on non-numbers, comparisons against nil, out-of-range indices, malformed conj!/assoc!/merge, non-seqable first/seq/vec, and lazy transformers (map/filter/…) realized over a non-seqable all throw. The lazy seq fns return seqs (not vectors), so seq?/vector?/sequential? of their results match Clojure. The assertions that remain failing are accounted for by the platform/design differences above, not by missing behavior:

  • No bignum/ratio/BigDecimalbigint/numerator/denominator/bigdec, the big-int?/auto-promotion checks, and the 2N/1/2/1.0M literals read but don't carry those exact types.
  • Integer/float identity — Janet represents 1 and 1.0 identically, so quot/rem/mod's double?/int? result-type assertions and many float?/double? cases can't distinguish them ((str 0.0) is "0").
  • 64-bit integers / Unicodebit-and etc. on full-width 64-bit constants lose precision (doubles), and subs/count work on bytes, not code points.

License

Eclipse Public License 1.0

Releases

No releases published

Packages

 
 
 

Contributors