Merge pull request #1 from jolt-lang/nrepl
nREPL server + client on a general Janet interop bridge
This commit is contained in:
commit
065f5d28c6
9 changed files with 512 additions and 9 deletions
62
README.md
62
README.md
|
|
@ -1,13 +1,13 @@
|
|||
# Jolt
|
||||
|
||||
[](https://github.com/yogthos/jolt/actions/workflows/tests.yml)
|
||||
[](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
|
||||
|
|
@ -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/<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/*`.
|
||||
|
||||
```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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
236
src/jolt/jolt/nrepl.clj
Normal file
236
src/jolt/jolt/nrepl.clj
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
; 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))
|
||||
; 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))
|
||||
|
||||
(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" (pr-str val)})
|
||||
(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)))
|
||||
|
|
@ -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))))
|
||||
|
|
|
|||
99
test/integration/nrepl-test.janet
Normal file
99
test/integration/nrepl-test.janet
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 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")
|
||||
|
||||
# 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}))
|
||||
|
||||
# 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")
|
||||
|
||||
# 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
|
||||
(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")
|
||||
|
||||
# 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)))"
|
||||
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)
|
||||
(do (eprint "nrepl-test: " fails " failing check(s)") (os/exit 1))
|
||||
(do (print "\nAll nREPL tests passed!") (os/exit 0)))
|
||||
|
|
@ -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/<name>` resolves a Janet
|
||||
# root binding; `janet.<module>/<name>` resolves a module binding. The boundary
|
||||
# is explicit so it's visible where host semantics take over.
|
||||
(defspec "interop / janet bridge"
|
||||
["root builtin janet/<name>" "\"123\"" "(janet/string 1 2 3)"]
|
||||
["root builtin janet/type" ":string" "(janet/type \"x\")"]
|
||||
["module fn janet.<mod>/<name>" "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)))"]
|
||||
|
|
|
|||
27
test/spec/nrepl-spec.janet
Normal file
27
test/spec/nrepl-spec.janet
Normal file
|
|
@ -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})")])
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue