feat(nrepl): nREPL server + client in Clojure on a Janet interop bridge

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.<module>` resolves
  against Janet's environment: `janet/<name>` -> root binding (janet/slurp),
  `janet.<module>/<name>` -> 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).
This commit is contained in:
Yogthos 2026-06-05 21:25:23 -04:00
parent 0f12598b06
commit 8cbc695f99
6 changed files with 457 additions and 7 deletions

View file

@ -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/<name> -> Janet root binding (janet/slurp, janet/type)
# janet.<module>/<name> -> 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)]

239
src/jolt/jolt/nrepl.clj Normal file
View file

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

View file

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