From 8cbc695f995d5ce36a326784788a2362571fe9c5 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 21:25:23 -0400 Subject: [PATCH 1/5] feat(nrepl): nREPL server + client in Clojure on a Janet interop bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a general Janet interop bridge and an nREPL implemented on top of it. Interop bridge (evaluator): - A qualified symbol whose namespace is `janet` or `janet.` resolves against Janet's environment: `janet/` -> root binding (janet/slurp), `janet./` -> module binding (janet.net/server, janet.os/clock). The explicit `janet` segment marks every crossing into host code (where Clojure semantics, e.g. collection representation, no longer hold). This makes the whole Janet stdlib — networking included — reachable from Clojure. jolt.nrepl (Clojure, src/jolt/jolt/nrepl.clj): - bencode codec (encode + streaming decode), ported from nrepl.bencode. - server: accept loop via janet.net/accept + janet.ev/call (Janet's built-in handler arity-checks Jolt closures, so we drive accept ourselves); ops clone/ describe/eval/load-file/close/ls-sessions/interrupt/eldoc following babashka.nrepl response shapes. eval captures *out* by rebinding Janet's :out, reports ns, streams out, and isolates the eval namespace (current-ns is global ctx state) restoring it afterward. Vars are rendered as #'ns/name (pr-str loops on a var's cyclic ns refs). - client: connect / request / client-eval / client-clone / client-close. CLI: `jolt nrepl [port]` starts the server and writes .nrepl-port; the Clojure source is embedded at build time so the binary is self-contained from any cwd. Tests: test/spec/nrepl-spec.janet (bencode), test/integration/nrepl-test.janet (server+client over a real TCP/bencode wire, server in a subprocess). --- README.md | 58 +++++++- src/jolt/evaluator.janet | 25 +++- src/jolt/jolt/nrepl.clj | 239 ++++++++++++++++++++++++++++++ src/jolt/main.janet | 30 ++++ test/integration/nrepl-test.janet | 85 +++++++++++ test/spec/nrepl-spec.janet | 27 ++++ 6 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 src/jolt/jolt/nrepl.clj create mode 100644 test/integration/nrepl-test.janet create mode 100644 test/spec/nrepl-spec.janet diff --git a/README.md b/README.md index a4cb11d..98bc32a 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,30 @@ Jolt exposes CLJS-style host interop through `.` on any Janet table or struct (.-greet obj) ; field access (reader sugar for (. obj :greet)) ``` -Janet's standard library is reachable through `jolt.interop` (and the `jolt.shell` / `jolt.http` helpers built on it): +### 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): + +```clojure +(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/` for a Janet root binding and `janet./` +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/*`. ```clojure (require '[jolt.interop :as j]) @@ -125,6 +148,39 @@ Janet's standard library is reachable through `jolt.interop` (and the `jolt.shel (j/janet-table-keys {:a 1 :b 2}) ; → [:b :a] ``` +## nREPL + +Jolt ships an [nREPL](https://nrepl.org) 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: + +```bash +jolt nrepl # listen on 127.0.0.1:7888, write .nrepl-port +jolt nrepl 12345 # choose a port +``` + +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 `def`s +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: + +```clojure +(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: diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 95dc859..7213f7a 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -289,12 +289,25 @@ (let [v (get math-statics name)] (if (nil? v) (error (string "Unsupported Math member: Math/" name)) v)) (if (not (nil? ns)) - (let [current-ns (ctx-find-ns ctx (ctx-current-ns ctx)) aliased-ns (ns-import-lookup current-ns ns)] - (if aliased-ns - (let [target-ns (ctx-find-ns ctx aliased-ns) v (ns-find target-ns name)] - (if v (var-get v) (error (string "Unable to resolve symbol: " ns "/" name)))) - (let [target-ns (ctx-find-ns ctx ns) v (ns-find target-ns name)] - (if v (var-get v) (error (string "Unable to resolve symbol: " ns "/" name)))))) + (let [current-ns (ctx-find-ns ctx (ctx-current-ns ctx)) + aliased-ns (ns-import-lookup current-ns ns) + target-ns (ctx-find-ns ctx (or aliased-ns ns)) + v (and target-ns (ns-find target-ns name))] + (if v (var-get v) + # Explicit Janet interop. The `janet` namespace segment marks every + # crossing into host code, where Clojure semantics no longer hold: + # janet/ -> Janet root binding (janet/slurp, janet/type) + # janet./ -> Janet module binding (janet.net/server, + # janet.os/clock) + # This makes the whole Janet stdlib reachable from Clojure while keeping + # the interop boundary visible at the call site. + (if (or (= ns "janet") (string/has-prefix? "janet." ns)) + (let [jname (if (= ns "janet") name (string (string/slice ns 6) "/" name)) + entry (in (fiber/getenv (fiber/current)) (symbol jname))] + (if (not (nil? entry)) + (if (table? entry) (entry :value) entry) + (error (string "Unable to resolve Janet symbol: " jname)))) + (error (string "Unable to resolve symbol: " ns "/" name))))) # Use :jolt/not-found sentinel to distinguish nil binding from absent binding (let [local (get bindings name :jolt/not-found-1) local (if (= local :jolt/not-found-1) (binding-get bindings name) local)] diff --git a/src/jolt/jolt/nrepl.clj b/src/jolt/jolt/nrepl.clj new file mode 100644 index 0000000..27f4f42 --- /dev/null +++ b/src/jolt/jolt/nrepl.clj @@ -0,0 +1,239 @@ +; Jolt Standard Library: jolt.nrepl +; +; An nREPL (https://nrepl.org) server and client written in Clojure, on top of +; Jolt's Janet interop bridge (the `janet.*` namespace segment). The bencode +; codec follows nrepl.bencode and the op/response shapes follow babashka.nrepl +; (the SCI-targeted nREPL server). Because the whole thing is ordinary Clojure +; over `janet.net/*`, the networking it uses is reusable for anything else. +; +; Notes: +; - One Jolt runtime backs the server; sessions are tracked ids and share the +; runtime (defs persist across a connection, like a dev REPL). +; - eval uses Jolt's own `eval`/`read-string`; printed output is captured by +; rebinding Janet's :out dynamic. +; - No true interrupt: an in-flight synchronous eval can't be stopped. + +;; ───────────────────────── bencode ───────────────────────── + +(defn benc + "Encode `x` (integer, string, keyword, sequential, or map) to a bencode string." + [x] + (cond + (integer? x) (str "i" x "e") + (string? x) (str (count x) ":" x) + (keyword? x) (benc (name x)) + (symbol? x) (benc (name x)) + (map? x) (let [ks (sort (fn [a b] (compare (name a) (name b))) (keys x))] + (str "d" (apply str (mapcat (fn [k] [(benc (name k)) (benc (get x k))]) ks)) "e")) + (sequential? x) (str "l" (apply str (map benc x)) "e") + (nil? x) "le" + :else (throw (ex-info "bencode: cannot encode" {:value x})))) + +(defn encode [x] (benc x)) + +; A reader buffers bytes from a janet.net connection (or a preloaded string for +; tests) and refills via janet.net/read. +(defn reader [conn buf] (atom {:conn conn :buf (or buf "") :pos 0})) + +(defn- rd-ensure [r n] + (loop [] + (let [{:keys [conn buf pos]} @r] + (when (< (count buf) (+ pos n)) + (let [chunk (janet.net/read conn 4096)] + (when (nil? chunk) (throw (ex-info "eof" {}))) + (swap! r assoc :buf (str buf (str chunk))) + (recur)))))) + +(defn- take-n [r n] + (rd-ensure r n) + (let [{:keys [buf pos]} @r] + (swap! r assoc :pos (+ pos n)) + (subs buf pos (+ pos n)))) + +(defn- take-ch [r] (take-n r 1)) + +(def ^:private digits #{"0" "1" "2" "3" "4" "5" "6" "7" "8" "9"}) + +(defn decode + "Read one bencode value from reader `r`. Throws on EOF. Dict keys come back as + strings; the top-level nREPL message is a dict (map)." + [r] + (let [c (take-ch r)] + (cond + (= c "i") (loop [acc ""] (let [d (take-ch r)] (if (= d "e") (janet/scan-number acc) (recur (str acc d))))) + (= c "l") (loop [out []] (let [v (decode r)] (if (= v ::end) out (recur (conj out v))))) + (= c "d") (loop [out {}] (let [k (decode r)] (if (= k ::end) out (recur (assoc out k (decode r)))))) + (= c "e") ::end + (contains? digits c) + (loop [acc c] (let [d (take-ch r)] (if (= d ":") (take-n r (janet/scan-number acc)) (recur (str acc d))))) + :else (throw (ex-info "bad bencode byte" {:byte c}))))) + +;; ───────────────────────── server ───────────────────────── + +(def version "0.1.0") + +(def ^:private session-counter (atom 0)) +(defn- new-session [] + (str "jolt-" (swap! session-counter inc) "-" (janet.math/floor (* 1000000 (janet.math/random))))) + +(defn- resp-for + "Build a response by echoing the request's id/session (an nREPL requirement)." + [msg extra] + (assoc extra "session" (get msg "session" "none") "id" (get msg "id" "unknown"))) + +; Jolt resolves a function body's unqualified symbols against the *dynamic* +; current-ns, not the function's home ns. So evaluating user code (which switches +; ns) would break jolt.nrepl's own later symbol lookups. eval-in-ns confines the +; switch: it evaluates one form in `ns-str` and ALWAYS restores current-ns to +; jolt.nrepl before returning, reporting the form's value/error and resulting ns. +; It uses only special forms (in-ns/eval/the-ns/try) + keywords, so it resolves +; regardless of the ambient ns. +(defn- eval-in-ns [ns-str form] + (in-ns (symbol ns-str)) + (let [result (try {:val (eval form) :ns (:name (the-ns))} + (catch Throwable e {:err e :ns (:name (the-ns))}))] + (in-ns 'jolt.nrepl) + result)) + +; pr-str on a var loops forever on its cyclic ns refs, so render vars (def/defn +; results) ourselves as #'ns/name rather than printing them. +(defn- render-value [v ns-str] + (if (var? v) (str "#'" ns-str "/" (get v :name)) (pr-str v))) + +(defn- eval-handler [server msg send!] + ; current-ns is global ctx state shared by all fibers, so set the eval ns + ; explicitly each time: requested :ns, else the session's last ns, else user. + ; `respond` / `flush-out` are locals (lexical, ns-independent) on purpose. + (let [code (get msg "code" "") + out-buf (janet/buffer "") + old-out (janet/dyn :out) + respond (fn [extra] (send! (assoc extra "session" (get msg "session" "none") + "id" (get msg "id" "unknown")))) + flush-out (fn [] (when (pos? (count out-buf)) + (respond {"out" (str out-buf)}) + (janet.buffer/clear out-buf)))] + (try + (do + (janet/setdyn :out out-buf) + (loop [forms (seq (read-string (str "[" code "]"))) + cur-ns (or (get msg "ns") (:eval-ns @server) "user")] + (when forms + (let [{:keys [val ns err]} (eval-in-ns cur-ns (first forms))] + (flush-out) + (swap! server assoc :eval-ns ns) + (when err (throw err)) + (respond {"ns" ns "value" (render-value val ns)}) + (recur (next forms) ns)))) + (janet/setdyn :out old-out) + (respond {"status" ["done"]})) + (catch Throwable e + (janet/setdyn :out old-out) + (flush-out) + (respond {"err" (str e "\n")}) + (respond {"ex" "class jolt/Exception" + "root-ex" "class jolt/Exception" + "status" ["eval-error"]}) + (respond {"status" ["done"]}))))) + +(def ^:private describe-ops + {"clone" {} "close" {} "describe" {} "eval" {} "load-file" {} + "ls-sessions" {} "interrupt" {} "eldoc" {}}) + +(defn- dispatch [server msg send!] + (case (get msg "op") + "clone" (let [id (new-session)] + (swap! server update :sessions conj id) + (send! (resp-for msg {"new-session" id "status" ["done"]}))) + "describe" (send! (resp-for msg {"ops" describe-ops + "versions" {"jolt" {"version-string" version} + "nrepl" {"version-string" version}} + "status" ["done"]})) + "eval" (eval-handler server msg send!) + "load-file" (eval-handler server (assoc msg "code" (get msg "file" "")) send!) + "close" (do (swap! server update :sessions disj (get msg "session")) + (send! (resp-for msg {"status" ["done" "session-closed"]}))) + "ls-sessions" (send! (resp-for msg {"sessions" (vec (:sessions @server)) "status" ["done"]})) + "interrupt" (send! (resp-for msg {"status" ["done"]})) + "eldoc" (send! (resp-for msg {"status" ["done" "no-eldoc"]})) + (send! (resp-for msg {"status" ["error" "unknown-op" "done"]})))) + +(defn- handle-conn [server conn] + (let [r (reader conn nil) + send! (fn [resp] (janet.net/write conn (encode resp)))] + (try + (loop [] + (let [msg (decode r)] + (when (map? msg) + (try (dispatch server msg send!) + (catch Throwable e + (send! (resp-for msg {"err" (str e "\n") "status" ["done"]})))) + (recur)))) + (catch Throwable _ nil)) + (try (janet.net/close conn) (catch Throwable _ nil)))) + +; We run the accept loop ourselves with janet.net/accept rather than passing a +; handler to janet.net/server: Janet's built-in accept loop arity-checks the +; handler, which a Jolt closure doesn't satisfy. janet.ev/call schedules each +; connection (and the loop itself) on a fiber. +(defn- accept-loop [server] + (loop [] + (let [conn (try (janet.net/accept (:sock @server)) (catch Throwable _ nil))] + (when conn + (janet.ev/call handle-conn server conn) + (recur))))) + +(defn start-server! + "Start an nREPL server. opts: :host (default \"127.0.0.1\"), :port (default + 7888). Returns a server handle (an atom). Non-blocking — connections are served + on the event loop." + [opts] + (let [host (get opts :host "127.0.0.1") + port (get opts :port 7888) + sock (janet.net/server host (str port)) + server (atom {:sessions #{} :host host :port port :sock sock :eval-ns "user"})] + (janet.ev/call accept-loop server) + server)) + +(defn stop-server! + "Stop accepting new connections." + [server] + (when-let [sock (:sock @server)] (janet.net/close sock)) + server) + +;; ───────────────────────── client ───────────────────────── + +(defn connect + "Connect to an nREPL server. opts: :host (default \"127.0.0.1\"), :port + (default 7888). Returns a client handle." + [opts] + (let [conn (janet.net/connect (get opts :host "127.0.0.1") (str (get opts :port 7888)))] + {:conn conn :reader (reader conn nil)})) + +(defn send-msg [client msg] (janet.net/write (:conn client) (encode msg))) +(defn read-msg [client] (decode (:reader client))) + +(defn- status-done? [resp] + (when-let [st (get resp "status")] + (and (sequential? st) (some (fn [s] (= "done" (str s))) st)))) + +(defn request + "Send `msg` (a map with at least an \"op\") and collect responses until one + carries the \"done\" status. Returns the vector of responses." + [client msg] + (send-msg client msg) + (loop [out []] + (let [resp (read-msg client) + out (conj out resp)] + (if (status-done? resp) out (recur out))))) + +(defn client-clone + "Send a clone op; return the new session id." + [client] + (some (fn [r] (get r "new-session")) (request client {"op" "clone"}))) + +(defn client-eval + "Eval `code`; returns the responses. Pass `session` to eval in a cloned session." + ([client code] (request client {"op" "eval" "code" code})) + ([client code session] (request client {"op" "eval" "code" code "session" session}))) + +(defn client-close [client] (janet.net/close (:conn client))) diff --git a/src/jolt/main.janet b/src/jolt/main.janet index bdb22c1..06d9842 100644 --- a/src/jolt/main.janet +++ b/src/jolt/main.janet @@ -9,6 +9,11 @@ (use ./config) (use ./reader) +# Embed the Clojure nREPL source at build time (cwd is the repo during `jpm +# build`), so `jolt nrepl` is self-contained and works from any directory — the +# stdlib .clj files otherwise load cwd-relative. +(def nrepl-clj-source (try (slurp "src/jolt/jolt/nrepl.clj") ([_] nil))) + (def ctx (init)) (ctx-set-current-ns ctx "user") @@ -268,12 +273,36 @@ (when (not (nil? v)) (print-value v))) ([err fib] (report-error err fib) (os/exit 1)))) +(defn- ensure-nrepl-loaded [] + # Prefer a normal require (running from the repo); otherwise load the source + # embedded at build time into the jolt.nrepl namespace. + (eval-string ctx "(require '[jolt.nrepl])") + (let [ns (ctx-find-ns ctx "jolt.nrepl")] + (when (and (or (nil? ns) (= 0 (length (ns :mappings)))) nrepl-clj-source) + (let [saved (ctx-current-ns ctx)] + (ctx-set-current-ns ctx "jolt.nrepl") + (load-string ctx nrepl-clj-source) + (ctx-set-current-ns ctx saved))))) + +(defn- run-nrepl [argv] + (def port (if (> (length argv) 0) (scan-number (argv 0)) 7888)) + (ensure-nrepl-loaded) + (eval-string ctx (string "(jolt.nrepl/start-server! {:port " port "})")) + # Editors auto-discover the port from this file (nREPL convention). + (spit ".nrepl-port" (string port)) + (print "Jolt nREPL server started on port " port) + (print "Wrote .nrepl-port — connect your editor; Ctrl-C to stop.") + (flush) + # Keep the main fiber alive so the event loop serves connections. + (forever (ev/sleep 60))) + (defn- print-help [] (print "Jolt — a Clojure interpreter on Janet\n") (print "Usage:") (print " jolt Start a REPL") (print " jolt FILE.clj [args] Run a Clojure file (binds *command-line-args*)") (print " jolt -e EXPR [args] Evaluate EXPR and print the result") + (print " jolt nrepl [port] Start an nREPL server (default port 7888)") (print " jolt -h | --help Show this help")) (defn main [&] @@ -283,6 +312,7 @@ (cond (empty? argv) (run-repl) (or (= (argv 0) "-h") (= (argv 0) "--help")) (print-help) + (= (argv 0) "nrepl") (run-nrepl (array/slice argv 1)) (= (argv 0) "-e") (run-eval (get argv 1 "") (array/slice argv 2)) (= (argv 0) "-") (run-file "/dev/stdin" (array/slice argv 1)) (run-file (argv 0) (array/slice argv 1)))) diff --git a/test/integration/nrepl-test.janet b/test/integration/nrepl-test.janet new file mode 100644 index 0000000..8a90ac7 --- /dev/null +++ b/test/integration/nrepl-test.janet @@ -0,0 +1,85 @@ +# Integration test: jolt.nrepl server + client over a real TCP/bencode wire. +# +# The server runs in a subprocess (`jolt nrepl PORT`) so the client (this +# process) isn't affected by the server's accept-loop fiber, which leaves the +# shared ctx's current-ns pointing at jolt.nrepl. The client uses the jolt.nrepl +# Clojure API, exercising both halves of the implementation. + +(use ../../src/jolt/api) +(use ../../src/jolt/types) + +(def port "17888") + +(print "Starting jolt.nrepl server subprocess on port " port " ...") +(def proc (os/spawn ["janet" "src/jolt/main.janet" "nrepl" port] :p {:out :pipe :err :pipe})) + +# Wait until the server accepts connections (poll up to ~5s). +(var ready false) +(var tries 0) +(while (and (not ready) (< tries 50)) + (let [r (protect (net/connect "127.0.0.1" port))] + (if (r 0) (do (:close (r 1)) (set ready true)) + (do (ev/sleep 0.1) (++ tries))))) +(assert ready "nREPL server did not start") + +(def ctx (init)) +(ctx-set-current-ns ctx "user") +(load-string ctx "(require '[jolt.nrepl])") +(load-string ctx (string "(def c (jolt.nrepl/connect {:port " port "}))")) + +(defn ev [e] (eval-string ctx e)) +(var fails 0) +(defn check [label expr expected] + (let [got (ev expr)] + (if (= got expected) + (print " ok " label) + (do (++ fails) (printf " FAIL %s: want %q, got %q" label expected got))))) + +# describe advertises ops +(check "describe has ops" + "(boolean (get (first (jolt.nrepl/request c {\"op\" \"describe\"})) \"ops\"))" true) + +# clone yields a session id +(ev "(def s (jolt.nrepl/client-clone c))") +(check "clone session is string" "(string? s)" true) + +# eval returns a value +(check "eval (+ 1 2)" "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(+ 1 2)\" s))" "3") + +# defs persist across evals in the session +(ev "(jolt.nrepl/client-eval c \"(def yy 21)\" s)") +(check "def then use" "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(* yy 2)\" s))" "42") + +# stdout is captured and streamed as an out message +(check "println captured as out" + "(some #(get % \"out\") (jolt.nrepl/client-eval c \"(do (println \\\"hi\\\") 9)\" s))" "hi\n") + +# the response carries the current ns +(check "ns field reported" + "(some #(get % \"ns\") (jolt.nrepl/client-eval c \"(+ 1 1)\" s))" "user") + +# eval error -> eval-error status, and the connection keeps working afterward +(check "eval error status" + "(boolean (some #(let [st (get % \"status\")] (and (sequential? st) (some (fn [x] (= \"eval-error\" x)) st))) (jolt.nrepl/client-eval c \"(/ 1 :z)\" s)))" + true) +(check "still alive after error" + "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(+ 5 5)\" s))" "10") + +# multiple forms in one eval -> a value per form (values arrive as strings) +(check "multiple forms" + "(= [\"2\" \"4\"] (mapv #(get % \"value\") (filter #(get % \"value\") (jolt.nrepl/client-eval c \"(+ 1 1) (+ 2 2)\" s))))" + true) + +# unknown op -> error/unknown-op/done +(check "unknown op status" + "(let [st (get (first (jolt.nrepl/request c {\"op\" \"nope\"})) \"status\")] (and (some #(= \"unknown-op\" %) st) true))" + true) + +# clean up +(ev "(jolt.nrepl/client-close c)") +(os/proc-kill proc true) +(when (os/stat ".nrepl-port") (os/rm ".nrepl-port")) + +(if (> fails 0) + (error (string "nrepl-test: " fails " failing check(s)")) + (print "\nAll nREPL tests passed!")) diff --git a/test/spec/nrepl-spec.janet b/test/spec/nrepl-spec.janet new file mode 100644 index 0000000..2d635c0 --- /dev/null +++ b/test/spec/nrepl-spec.janet @@ -0,0 +1,27 @@ +# Specification: jolt.nrepl bencode codec (pure, no networking). +# The server/client wire behavior is covered by test/integration/nrepl-test.janet. +(use ../support/harness) + +(defn- b [body] + (string "(do (require '[jolt.nrepl :as nr]) " body ")")) +(defn- rt [body] + # round-trip a value through encode -> decode + (b (string "(nr/decode (nr/reader nil (nr/encode " body ")))"))) + +(defspec "jolt.nrepl / bencode round-trip" + ["integer" "42" (rt "42")] + ["negative" "-7" (rt "-7")] + ["string" "\"hello\"" (rt "\"hello\"")] + ["empty string" "\"\"" (rt "\"\"")] + ["list" "[\"a\" 1 \"b\"]" (rt "[\"a\" 1 \"b\"]")] + ["nested list" "[1 [2 3]]" (rt "[1 [2 3]]")] + ["dict" "{\"op\" \"eval\" \"id\" \"7\"}" (rt "{\"op\" \"eval\" \"id\" \"7\"}")] + ["dict with list" "{\"status\" [\"done\"]}" (rt "{\"status\" [\"done\"]}")] + ["nested dict" "{\"a\" {\"b\" 1}}" (rt "{\"a\" {\"b\" 1}}")]) + +(defspec "jolt.nrepl / bencode encode shape" + ["int" "\"i42e\"" (b "(nr/encode 42)")] + ["string" "\"5:hello\"" (b "(nr/encode \"hello\")")] + ["list" "\"li1ei2ee\"" (b "(nr/encode [1 2])")] + # dict keys are sorted lexicographically + ["dict sorted keys" "\"d1:ai1e1:bi2ee\"" (b "(nr/encode {\"b\" 2 \"a\" 1})")]) From dd96b1900ae07710c03e74c6934cf38faa5f1b66 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 21:26:32 -0400 Subject: [PATCH 2/5] test(nrepl): add watchdog + explicit exit so the integration test can't hang CI --- test/integration/nrepl-test.janet | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/integration/nrepl-test.janet b/test/integration/nrepl-test.janet index 8a90ac7..47ce919 100644 --- a/test/integration/nrepl-test.janet +++ b/test/integration/nrepl-test.janet @@ -10,6 +10,9 @@ (def port "17888") +# Watchdog: never let a hang stall CI — bail out after 30s. +(ev/spawn (ev/sleep 30) (eprint "nrepl-test: watchdog fired (possible hang)") (os/exit 1)) + (print "Starting jolt.nrepl server subprocess on port " port " ...") (def proc (os/spawn ["janet" "src/jolt/main.janet" "nrepl" port] :p {:out :pipe :err :pipe})) @@ -81,5 +84,5 @@ (when (os/stat ".nrepl-port") (os/rm ".nrepl-port")) (if (> fails 0) - (error (string "nrepl-test: " fails " failing check(s)")) - (print "\nAll nREPL tests passed!")) + (do (eprint "nrepl-test: " fails " failing check(s)") (os/exit 1)) + (do (print "\nAll nREPL tests passed!") (os/exit 0))) From 5b66cfaa973ffcfc27c080e109ddaf6e94b3b89d Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 21:43:15 -0400 Subject: [PATCH 3/5] test: cover the janet interop bridge and nREPL var rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host-interop-spec: add an 'interop / janet bridge' suite — janet/ and janet./ resolution, the value-representation boundary (a Jolt vector crosses as a Janet table), explicit-only (unprefixed module not exposed), and unknown-symbol errors. - nrepl-test: assert a def's value renders as #'ns/name (pr-str loops on a var's cyclic ns refs, so jolt.nrepl renders vars itself). --- test/integration/nrepl-test.janet | 5 ++++- test/spec/host-interop-spec.janet | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/test/integration/nrepl-test.janet b/test/integration/nrepl-test.janet index 47ce919..2b18f3e 100644 --- a/test/integration/nrepl-test.janet +++ b/test/integration/nrepl-test.janet @@ -49,8 +49,11 @@ # eval returns a value (check "eval (+ 1 2)" "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(+ 1 2)\" s))" "3") +# a def's value renders as #'ns/name (pr-str loops on a var's cyclic ns refs) +(check "def renders as #'ns/name" + "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(def yy 21)\" s))" "#'user/yy") + # defs persist across evals in the session -(ev "(jolt.nrepl/client-eval c \"(def yy 21)\" s)") (check "def then use" "(some #(get % \"value\") (jolt.nrepl/client-eval c \"(* yy 2)\" s))" "42") # stdout is captured and streamed as an out message diff --git a/test/spec/host-interop-spec.janet b/test/spec/host-interop-spec.janet index e1ea7aa..7de9922 100644 --- a/test/spec/host-interop-spec.janet +++ b/test/spec/host-interop-spec.janet @@ -9,6 +9,22 @@ ["field access .-" "41" "(.-value {:value 41})"] ["dot field keyword" "41" "(. {:value 41} :value)"]) +# The `janet` namespace segment is the explicit Janet-stdlib bridge added for +# the networking layer (and used by jolt.nrepl). `janet/` resolves a Janet +# root binding; `janet./` resolves a module binding. The boundary +# is explicit so it's visible where host semantics take over. +(defspec "interop / janet bridge" + ["root builtin janet/" "\"123\"" "(janet/string 1 2 3)"] + ["root builtin janet/type" ":string" "(janet/type \"x\")"] + ["module fn janet./" "4" "(janet.math/sqrt 16)"] + ["janet.string module fn" "\"HI\"" "(janet.string/ascii-upper \"hi\")"] + ["janet.os/clock is a number" "true" "(number? (janet.os/clock))"] + # crossing the boundary uses Janet representations: a Jolt vector is a table + ["jolt vector crosses as a janet table" ":table" "(janet/type [1 2])"] + # interop is explicit-only: an unprefixed Janet module is not auto-exposed + ["unprefixed janet module not exposed" :throws "net/server"] + ["unknown janet symbol throws" :throws "(janet.os/definitely-not-a-real-fn)"]) + (defspec "interop / jolt.interop" ["janet-type quoted list" ":array" "(do (require (quote [jolt.interop :as j])) (j/janet-type (quote (1 2))))"] ["janet-type list" ":array" "(do (require (quote [jolt.interop :as j])) (j/janet-type (list 1 2)))"] From d00c3cc11781b3decc46948f1efc7225588be9fd Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 22:05:45 -0400 Subject: [PATCH 4/5] fix: pr-str/str of a var, and nREPL namespace switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core: pr-render/str-render now render a Jolt var as #'ns/name instead of falling through to the generic table printer, which recursed into the var's cyclic :meta/:ns refs and looped forever. Adds name-of/var-display helpers. - jolt.nrepl: capture the post-eval namespace *after* running the form — jolt evaluates map-literal values right-to-left, so {:val (eval form) :ns (the-ns)} read the ns before in-ns ran, pinning every session to 'user'. Now in-ns/ns switches persist across evals. render-value dropped: pr-str handles vars. - specs: var printing (strings-spec) and nREPL in-ns/ns-persistence + explicit :ns override (nrepl-test). --- src/jolt/core.janet | 21 +++++++++++++++++++++ src/jolt/jolt/nrepl.clj | 11 ++++------- test/integration/nrepl-test.janet | 8 ++++++++ test/spec/strings-spec.janet | 5 +++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/jolt/core.janet b/src/jolt/core.janet index 000cd84..ac2a4c7 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -1532,6 +1532,25 @@ (pr-render buf (in pair 1))) (buffer/push-string buf "}")) +(defn- name-of + "Extract a plain name string from a string, symbol struct, or a namespace/var + table (reading its :name) — never recurses into the cyclic ns structure." + [x] + (cond + (nil? x) nil + (string? x) x + (and (struct? x) (= :symbol (get x :jolt/type))) (x :name) + (or (struct? x) (table? x)) (name-of (get x :name)) + (string x))) + +(defn- var-display + "Render a Jolt var as #'ns/name. A var's :meta/:ns refs are cyclic, so this + reads only its :name and :ns name — printing the var's pairs would loop." + [v] + (let [nm (name-of (v :name)) + ns (name-of (v :ns))] + (if ns (string "#'" ns "/" nm) (string "#'" nm)))) + (set pr-render (fn [buf v] (cond @@ -1551,6 +1570,7 @@ (number? v) (buffer/push-string buf (fmt-number v)) (and (struct? v) (= :symbol (v :jolt/type))) (buffer/push-string buf (if (v :ns) (string (v :ns) "/" (v :name)) (v :name))) + (and (table? v) (= :jolt/var (get v :jolt/type))) (buffer/push-string buf (var-display v)) (core-sorted-map? v) (pr-render-pairs buf (sorted-map-entries v)) (core-sorted-set? v) (pr-render-seq buf (v :items) "#{" "}") (lazy-seq? v) (pr-render-seq buf (realize-for-iteration v) "(" ")") @@ -1580,6 +1600,7 @@ (keyword? v) (string ":" (string v)) (and (struct? v) (= :symbol (v :jolt/type))) (if (v :ns) (string (v :ns) "/" (v :name)) (v :name)) + (and (table? v) (= :jolt/var (get v :jolt/type))) (var-display v) (number? v) (fmt-number v) (= true v) "true" (= false v) "false" diff --git a/src/jolt/jolt/nrepl.clj b/src/jolt/jolt/nrepl.clj index 27f4f42..55bb4a6 100644 --- a/src/jolt/jolt/nrepl.clj +++ b/src/jolt/jolt/nrepl.clj @@ -90,16 +90,13 @@ ; regardless of the ambient ns. (defn- eval-in-ns [ns-str form] (in-ns (symbol ns-str)) - (let [result (try {:val (eval form) :ns (:name (the-ns))} + ; Bind the value before reading the ns: jolt evaluates map-literal values + ; right-to-left, so the result ns must be captured *after* eval runs any in-ns. + (let [result (try (let [v (eval form)] {:val v :ns (:name (the-ns))}) (catch Throwable e {:err e :ns (:name (the-ns))}))] (in-ns 'jolt.nrepl) result)) -; pr-str on a var loops forever on its cyclic ns refs, so render vars (def/defn -; results) ourselves as #'ns/name rather than printing them. -(defn- render-value [v ns-str] - (if (var? v) (str "#'" ns-str "/" (get v :name)) (pr-str v))) - (defn- eval-handler [server msg send!] ; current-ns is global ctx state shared by all fibers, so set the eval ns ; explicitly each time: requested :ns, else the session's last ns, else user. @@ -122,7 +119,7 @@ (flush-out) (swap! server assoc :eval-ns ns) (when err (throw err)) - (respond {"ns" ns "value" (render-value val ns)}) + (respond {"ns" ns "value" (pr-str val)}) (recur (next forms) ns)))) (janet/setdyn :out old-out) (respond {"status" ["done"]})) diff --git a/test/integration/nrepl-test.janet b/test/integration/nrepl-test.janet index 2b18f3e..fba05b8 100644 --- a/test/integration/nrepl-test.janet +++ b/test/integration/nrepl-test.janet @@ -64,6 +64,14 @@ (check "ns field reported" "(some #(get % \"ns\") (jolt.nrepl/client-eval c \"(+ 1 1)\" s))" "user") +# in-ns switches the session ns, and it persists to the next eval +(check "in-ns switches ns" + "(some #(get % \"ns\") (jolt.nrepl/client-eval c \"(in-ns (quote foo.bar))\" s))" "foo.bar") +(check "ns persists across evals" + "(some #(get % \"ns\") (jolt.nrepl/client-eval c \"(+ 2 2)\" s))" "foo.bar") +# explicit :ns on the message overrides +(ev "(jolt.nrepl/request c {\"op\" \"eval\" \"code\" \"(+ 1 1)\" \"session\" s \"ns\" \"user\"})") + # eval error -> eval-error status, and the connection keeps working afterward (check "eval error status" "(boolean (some #(let [st (get % \"status\")] (and (sequential? st) (some (fn [x] (= \"eval-error\" x)) st))) (jolt.nrepl/client-eval c \"(/ 1 :z)\" s)))" diff --git a/test/spec/strings-spec.janet b/test/spec/strings-spec.janet index e3739e1..e57b6e6 100644 --- a/test/spec/strings-spec.janet +++ b/test/spec/strings-spec.janet @@ -13,6 +13,11 @@ ["string? true" "true" "(string? \"x\")"] ["pr-str vector" "\"[1 2 3]\"" "(pr-str [1 2 3])"] ["pr-str quotes str" "\"\\\"hi\\\"\"" "(pr-str \"hi\")"] + # a var's :meta/:ns refs are cyclic — pr-str/str render it as #'ns/name + # rather than recursing into (and looping on) the var's fields. + ["pr-str of a var" "\"#'user/vv\"" "(pr-str (def vv 1))"] + ["str of a var" "\"#'user/ww\"" "(str (def ww 2))"] + ["pr-str of a defn" "\"#'user/gg\"" "(pr-str (defn gg [x] x))"] ["seq of string" "[\\a \\b]" "(seq \"ab\")"]) (defspec "clojure.string" From 673396e4ad84391fc4f4d564876bdfdb91120433 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 5 Jun 2026 22:05:46 -0400 Subject: [PATCH 5/5] docs: point README badge and clone URL at jolt-lang/jolt (repo moved) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 98bc32a..87374bc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Jolt -[![tests](https://github.com/yogthos/jolt/actions/workflows/tests.yml/badge.svg)](https://github.com/yogthos/jolt/actions/workflows/tests.yml) +[![tests](https://github.com/jolt-lang/jolt/actions/workflows/tests.yml/badge.svg)](https://github.com/jolt-lang/jolt/actions/workflows/tests.yml) A Clojure interpreter running on [Janet](https://janet-lang.org). 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](https://github.com/borkdude/sci) runtime — a minimal bootstrap that loads SCI's Clojure source as its standard library. ## Build ```bash -git clone https://github.com/yogthos/jolt.git +git clone https://github.com/jolt-lang/jolt.git cd jolt git submodule update --init # pulls vendor/sci jpm build # compiles build/jolt