From a1c803024333e34f2f32a637966d934602151619 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:13 +0300 Subject: [PATCH 1/6] Take symbols like -> for arguments, not options Anything starting with a dash was an option, so doc ->, where -main and complete -ma failed with an unknown option. An argument is now an option only when it looks like one: --name, a short -x, or a known short option with more after it, which stays the unknown option it was. --- CHANGELOG.md | 1 + src/neorepl/cli.clj | 13 +++++++++++-- test/neorepl/cli_test.clj | 5 +++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24df9f7..f054744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - [#29](https://github.com/nrepl/neorepl/pull/29): A test error's location is its test's, rather than the file of the frame that threw with the test's line, and on babashka every failure's is. - [#29](https://github.com/nrepl/neorepl/pull/29): `neorepl test --timeout` covers loading the tests too, and running out of time there exits 124, rather than 3. - [#29](https://github.com/nrepl/neorepl/pull/29): `neorepl test` of a namespace without tests says so and exits 1, rather than passing. +- [#30](https://github.com/nrepl/neorepl/pull/30): Symbols that start with a dash, like `->` and `-main`, are arguments to `doc`, `where` and the other lookups, rather than unknown options. ## 0.3.0 (2026-10-17) diff --git a/src/neorepl/cli.clj b/src/neorepl/cli.clj index 57cadd3..ea9501c 100644 --- a/src/neorepl/cli.clj +++ b/src/neorepl/cli.clj @@ -12,10 +12,19 @@ [{:keys [long]}] (keyword (subs long 2))) +(defn- option? + "Whether arg looks like an option: --name, a short -x, or one of the + short options in by-name with more after it, so that symbols like -> and + -main, the arguments of lookups, don't." + [by-name arg] + (boolean (or (re-matches #"--[^-=].*|-[a-zA-Z](=.*)?" arg) + (and (< 2 (count arg)) (contains? by-name (subs arg 0 2)))))) + (defn parse "Parses args against specs into {:options {...} :args [...]}, or {:error msg}. Options and arguments can come in any order, values either as the - next argument or after =. -- ends the options; - alone is an argument." + next argument or after =. -- ends the options; - alone is an argument, as + is anything else that doesn't look like an option." [specs args] (let [by-name (into {} (for [spec specs name ((juxt :short :long) spec) @@ -29,7 +38,7 @@ (cond (empty? args) {:options options :args positional} (= "--" a) {:options options :args (into positional more)} - (or (= "-" a) (not (str/starts-with? a "-"))) (recur more options (conj positional a)) + (not (option? by-name a)) (recur more options (conj positional a)) :else (let [[name inline] (str/split a #"=" 2) {:keys [arg parse] :or {parse identity} :as spec} (by-name name)] diff --git a/test/neorepl/cli_test.clj b/test/neorepl/cli_test.clj index 94adf37..401510d 100644 --- a/test/neorepl/cli_test.clj +++ b/test/neorepl/cli_test.clj @@ -14,6 +14,9 @@ (testing "-- ends the options and - is an argument" (is (= {:options {:port 1 :format "text"} :args ["-" "-1" "--help"]} (cli/parse specs ["--port=1" "-" "--" "-1" "--help"])))) + (testing "arguments that don't look like options, as symbols do" + (is (= {:options {:format "text"} :args ["->" "-main" "-1" "-"]} + (cli/parse specs ["->" "-main" "-1" "-"])))) (testing "values can look like options" (is (= {:options {:port -1 :format "text"} :args []} (cli/parse specs ["-p" "-1"])))) @@ -24,6 +27,8 @@ (testing "errors" (are [args error] (= {:error error} (cli/parse specs args)) ["--nope"] "Unknown option: --nope" + ;; A value right after a short option isn't one. + ["-p7888"] "Unknown option: -p7888" ["-x" "--port" "1"] "Unknown option: -x" ["--port"] "--port needs a value" ["--port" "x"] "Invalid value for --port: x" From 10618287dbabd835d7995e5843b43ac15dd5d886 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:14 +0300 Subject: [PATCH 2/6] Report failures in edn and json too Most failures, like no server to talk to, a usage mistake or the server failing an op, only warned on stderr, so --format edn and json left stdout empty for scripts to choke on. They now come out in the format asked for, as their status and message. run parses the arguments once for the command and the failure both. --- CHANGELOG.md | 1 + src/neorepl/main.clj | 35 +++++++++++++++++++++-------------- test/neorepl/main_test.clj | 10 +++++++++- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f054744..500a2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - [#29](https://github.com/nrepl/neorepl/pull/29): `neorepl test --timeout` covers loading the tests too, and running out of time there exits 124, rather than 3. - [#29](https://github.com/nrepl/neorepl/pull/29): `neorepl test` of a namespace without tests says so and exits 1, rather than passing. - [#30](https://github.com/nrepl/neorepl/pull/30): Symbols that start with a dash, like `->` and `-main`, are arguments to `doc`, `where` and the other lookups, rather than unknown options. +- [#30](https://github.com/nrepl/neorepl/pull/30): `--format edn` and `json` have failures too, like no server to talk to or a usage mistake, as their status and message, where stdout used to be empty. ## 0.3.0 (2026-10-17) diff --git a/src/neorepl/main.clj b/src/neorepl/main.clj index a459849..ee7f9cf 100644 --- a/src/neorepl/main.clj +++ b/src/neorepl/main.clj @@ -1115,12 +1115,13 @@ Commands: Run neorepl COMMAND --help for its options. neorepl --version prints the version.")) -(defn- run-command [{:keys [options help main]} args ctx] - (let [{:keys [error] :as parsed} (cli/parse options args)] - (cond - error (throw (usage-error error)) - (:help (:options parsed)) (do (println help) 0) - :else (main (:options parsed) (:args parsed) ctx)))) +(defn- run-command + "Runs the command spec with args, as cli/parse made them." + [{:keys [help main]} {:keys [error options args]} ctx] + (cond + error (throw (usage-error error)) + (:help options) (do (println help) 0) + :else (main options args ctx))) (defn run "Runs the neorepl command line args and returns the exit status. ctx has @@ -1134,20 +1135,26 @@ version.")) (and (str/starts-with? command "-") (not (#{"-h" "--help" "--version"} command)))) ["repl" args] [command more]) - spec (some #(when (= command (:name %)) %) commands)] + spec (some #(when (= command (:name %)) %) commands) + parsed (some-> spec :options (cli/parse args))] (try (cond (contains? #{"help" "-h" "--help"} command) (do (println help) 0) (= "--version" command) (do (println "neorepl" version) 0) - spec (run-command spec args ctx) + spec (run-command spec parsed ctx) :else (throw (usage-error (str "Unknown command: " command)))) (catch clojure.lang.ExceptionInfo e - (case (:type (ex-data e)) - ::usage (do (warn (str (ex-message e) "\nRun neorepl " (when spec (str command " ")) "--help for usage.")) - 2) - (::nrepl/no-server ::nrepl/disconnected ::nrepl/timeout) (do (warn (ex-message e)) 3) - (::features/failed ::jack-in/failed ::jack-in/no-project ::server-running) (do (warn (ex-message e)) 1) - (throw e)))))) + (let [;; The format failures come out in too, where scripts look for them. + format (get-in parsed [:options :format] "text") + fail #(do (report format {:status %1 :message %3}) %2) + message (ex-message e) + type (:type (ex-data e))] + (case type + ::usage (fail "usage" 2 (cond-> message + (= "text" format) (str "\nRun neorepl " (when spec (str command " ")) "--help for usage."))) + (::nrepl/no-server ::nrepl/disconnected ::nrepl/timeout) (fail (name type) 3 message) + (::features/failed ::jack-in/failed ::jack-in/no-project ::server-running) (fail "error" 1 message) + (throw e))))))) (defn -main [& args] (let [status (run args {:dir (System/getProperty "user.dir") diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index f153835..fbb4515 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -88,7 +88,15 @@ (let [{:keys [status out]} (run "--format" "json" "(+ 1 2) \"s\"")] (is (zero? status)) (is (str/starts-with? out "{\"status\":\"ok\",\"values\":[\"3\",\"\\\"s\\\"\"],")) - (is (str/ends-with? out "}\n"))))) + (is (str/ends-with? out "}\n")))) + (testing "failures too, where scripts look for them" + (let [port (test-server/free-port)] + (is (= {:status 3 :out (str "{\"status\":\"no-server\",\"message\":\"No nREPL server answering at 127.0.0.1:" port "\"}\n") + :err ""} + (run-in *dir* "" "eval" "-p" (str port) "--format" "json" "1"))))) + (testing "a usage error, in json" + (is (= {:status 2 :out "{\"status\":\"usage\",\"message\":\"Give a SYM\"}\n" :err ""} + (run-in *dir* "" "doc" "-p" "1" "--format" "json"))))) (deftest session-test (testing "a session keeps its namespace between calls" From 064a500ea1202778058de12bb6edd5373f166fbb Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:14 +0300 Subject: [PATCH 3/6] Say when a named session is gone, and when it's replaced A server that restarts loses its sessions, and neorepl quietly gave the next call a fresh one, in user, without the *1 or definitions the session had, while pst and interrupt said there was no session by that name. sessions/ensure! now calls back when it replaces one, which the CLI warns about on stderr and the MCP tools put in their result, and the commands that don't make one, pst, interrupt and sessions close among them, say the session is gone. MCP's pst no longer makes a fresh one either. --- CHANGELOG.md | 1 + src/neorepl/main.clj | 19 +++++++----- src/neorepl/mcp.clj | 20 +++++++----- src/neorepl/repl.clj | 2 +- src/neorepl/sessions.clj | 56 +++++++++++++++++++++++++--------- test/neorepl/main_test.clj | 11 ++++++- test/neorepl/mcp_test.clj | 18 +++++++++-- test/neorepl/sessions_test.clj | 13 ++++++++ 8 files changed, 108 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500a2e8..a3c58f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - [#29](https://github.com/nrepl/neorepl/pull/29): `neorepl test` of a namespace without tests says so and exits 1, rather than passing. - [#30](https://github.com/nrepl/neorepl/pull/30): Symbols that start with a dash, like `->` and `-main`, are arguments to `doc`, `where` and the other lookups, rather than unknown options. - [#30](https://github.com/nrepl/neorepl/pull/30): `--format edn` and `json` have failures too, like no server to talk to or a usage mistake, as their status and message, where stdout used to be empty. +- [#30](https://github.com/nrepl/neorepl/pull/30): A named session the server lost, as when it restarts, says so: where a new one takes its place, on stderr or in the MCP result, and where it doesn't, like `pst` and `sessions close`. ## 0.3.0 (2026-10-17) diff --git a/src/neorepl/main.clj b/src/neorepl/main.clj index ee7f9cf..465fe3c 100644 --- a/src/neorepl/main.clj +++ b/src/neorepl/main.clj @@ -266,8 +266,11 @@ when it times out.") (some-> message warn) (emit format result))) -(defn- no-session [name] - {:status "no-session" :message (str "No session called " name)}) +(defn- replaced-session + "Warns that the session called name was gone, so it's a new one, for + sessions/ensure!." + [name] + #(warn (sessions/replaced-message name))) (defn- with-named-session "Calls (f client id) with a client connected the way options say and the @@ -278,7 +281,7 @@ when it times out.") (fn [client] (if-let [id (get (sessions/named sessions-file client) session)] (f client id) - (do (report format (no-session session)) + (do (report format (sessions/missing sessions-file client session)) 1))))) (defn- report-run @@ -292,7 +295,7 @@ when it times out.") (let [printer (if (= "text" format) (evaluation/text-printer) (fn [_])) {:keys [status message truncated out] :as result} (run client - (sessions/ensure! sessions-file client session) + (sessions/ensure! sessions-file client session (replaced-session session)) {:timeout timeout :max-output max-output :on-response printer})] @@ -446,10 +449,12 @@ errors and 3 when there's no server to talk to." (with-client options dir (fn [client] (if name - (let [{:keys [status] :as result} + (let [;; Before it's gone from the cache too. + missing (sessions/missing sessions-file client name) + {:keys [status] :as result} (if (sessions/close! sessions-file client name) {:status "closed"} - (no-session name))] + missing)] (report format result) (if (= "closed" status) 0 1)) (do (print-rows format (juxt :name :id) (for [[name id] (sessions/named sessions-file client)] @@ -958,7 +963,7 @@ drops." (with-repl-client options dir (fn [client] (let [id (if session - (sessions/ensure! sessions-file client session) + (sessions/ensure! sessions-file client session (replaced-session session)) (nrepl/clone-session client)) ;; Loaded only when needed, along with JLine. start (requiring-resolve 'neorepl.repl/start)] diff --git a/src/neorepl/mcp.clj b/src/neorepl/mcp.clj index ae1f171..d326db5 100644 --- a/src/neorepl/mcp.clj +++ b/src/neorepl/mcp.clj @@ -85,9 +85,10 @@ ([{:keys [sessions-file timeout max-output] :as server} {:keys [session port] :as args} on-send run text] (with-client server port (fn [client] - (let [[output {:keys [status message truncated] :as r}] + (let [replaced? (volatile! false) + [output {:keys [status message truncated] :as r}] (output (run client - (sessions/ensure! sessions-file client session) + (sessions/ensure! sessions-file client session #(vreset! replaced? true)) {:timeout (or (:timeout args) timeout) :max-output max-output :on-send on-send @@ -95,7 +96,9 @@ ;; none for the code. :read-line (constantly nil)}) max-output)] - (result (str/join "\n" (remove str/blank? [output + (result (str/join "\n" (remove str/blank? [(when @replaced? + (str "(" (sessions/replaced-message session) ".)")) + output (when truncated (str "(Cut at " max-output " characters)")) message (text r)])) @@ -247,10 +250,13 @@ {:name "pst" :description "Show the stack trace of the session's last error, minus the frames of the tooling." :inputSchema (schema (merge session-arg port-arg)) - :run (fn [{:keys [sessions-file] :as server} {:keys [session] :as args} _] - (wrap (lookup server args "pst" nil - (fn [client] {:session (sessions/ensure! sessions-file client session) :session-name session})) - :causes))} + :run (fn [{:keys [sessions-file] :as server} {:keys [session port] :as args} _] + (with-client server port + (fn [client] + ;; One that's gone, or was never there, has no error. + (if-let [id (get (sessions/named sessions-file client) session)] + (wrap (lookup server args "pst" nil (constantly {:session id :session-name session})) :causes) + (error (sessions/missing-message sessions-file client session))))))} {:name "interrupt" :description "Interrupt what's running in a session, like an evaluation that's been left behind." :inputSchema (schema (merge session-arg port-arg)) diff --git a/src/neorepl/repl.clj b/src/neorepl/repl.clj index 58e6ff6..5962466 100644 --- a/src/neorepl/repl.clj +++ b/src/neorepl/repl.clj @@ -272,7 +272,7 @@ {:session id :session-name (if own? (:session-name own) name) :ns (nrepl/evaluation-ns client id)}) - (out (str "No session called " name ", neorepl eval -s " name " CODE makes one"))) + (out (str (sessions/missing-message sessions-file client name) ", neorepl eval -s " name " CODE makes one"))) (out (str/join "\n" (cli/columns (for [[name id] (cons [(or (:session-name own) "-") (:session own)] (dissoc (sessions/named sessions-file client) (:session-name own)))] [(str (if (= id session) "* " " ") name) diff --git a/src/neorepl/sessions.clj b/src/neorepl/sessions.clj index cd20e8e..f408990 100644 --- a/src/neorepl/sessions.clj +++ b/src/neorepl/sessions.clj @@ -84,20 +84,48 @@ (defn ensure! "The id of the session called name on client's server, as cached in file. Clones and caches a new one when there's none yet or the server doesn't - know the cached one anymore." + know the cached one anymore, typically because it restarted, calling + (replaced) then, if given, since what the session had is gone." + ([file client name] (ensure! file client name (fn []))) + ([file client name replaced] + (let [before (cached file client name)] + (if (and before ((live-sessions client) before)) + before + (call-with-lock file + #(let [cache (read-cache file) + now (get-in cache [(:address client) name])] + ;; Another call may have cached a new one in the meantime, for + ;; the one that was gone too. + (if (and now (not= now before)) + (do (when before + (replaced)) + now) + (let [session (nrepl/clone-session client)] + (write-cache! file (assoc-in cache [(:address client) name] session)) + (when before + (replaced)) + session)))))))) + +(defn replaced-message + "What to say when the session called name was gone, so there's a new one, + as ensure! has it." + [name] + (str "Session " name " was gone, the server having restarted, so it's a new one")) + +(defn missing-message + "What to say of the session called name, which client's server doesn't + have: that it's gone, when file still has it, as after the server + restarted, or that there's none." + [file client name] + (if (cached file client name) + (str "Session " name " is gone: the server has restarted since, or closed it") + (str "No session called " name))) + +(defn missing + "The result saying client's server has no session called name, as + missing-message says it." [file client name] - (let [before (cached file client name)] - (if (and before ((live-sessions client) before)) - before - (call-with-lock file - #(let [cache (read-cache file) - now (get-in cache [(:address client) name])] - ;; Another call may have cached a new one in the meantime. - (if (and now (not= now before)) - now - (let [session (nrepl/clone-session client)] - (write-cache! file (assoc-in cache [(:address client) name] session)) - session))))))) + {:status "no-session" :message (missing-message file client name)}) (defn interrupt "Interrupts whatever runs in the session called name, returning how that @@ -116,7 +144,7 @@ (case (interrupt file client name) :interrupted {:status "interrupted"} :idle {:status "idle" :message (str "Nothing is running in session " name)} - :unknown-session {:status "no-session" :message (str "No session called " name)} + :unknown-session (missing file client name) {:status "error" :message "The server couldn't interrupt the evaluation"})) (defn close! diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index fbb4515..6745f38 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -112,7 +112,16 @@ (with-client [client {:port *port*}] (let [lost (sessions/ensure! (sessions-file) client "lost")] (nrepl/close-session client lost) - (is (= {:status 0 :out "2\n" :err ""} (run "-s" "lost" "2"))) + (testing "which the commands that don't make one say" + (is (= {:status 1 :out "" :err "neorepl: Session lost is gone: the server has restarted since, or closed it\n"} + (run-command "pst" "-s" "lost"))) + (run "-s" "lost-too" "1") + (nrepl/close-session client (sessions/ensure! (sessions-file) client "lost-too")) + (is (= {:status 1 :out "" :err "neorepl: Session lost-too is gone: the server has restarted since, or closed it\n"} + (run-command "sessions" "close" "lost-too")))) + (testing "saying so" + (is (= {:status 0 :out "2\n" :err "neorepl: Session lost was gone, the server having restarted, so it's a new one\n"} + (run "-s" "lost" "2")))) (is (not= lost (sessions/ensure! (sessions-file) client "lost"))))))) (deftest sessions-command-test diff --git a/test/neorepl/mcp_test.clj b/test/neorepl/mcp_test.clj index 06331fa..35fc2d2 100644 --- a/test/neorepl/mcp_test.clj +++ b/test/neorepl/mcp_test.clj @@ -7,7 +7,8 @@ [neorepl.main :as main] [neorepl.mcp :as mcp] [neorepl.nrepl :as nrepl] - [neorepl.test-server :as test-server :refer [*port* call-with-temp-dir spit-file]]) + [neorepl.sessions :as sessions] + [neorepl.test-server :as test-server :refer [*port* call-with-temp-dir spit-file with-client]]) (:import (clojure.lang LineNumberingPushbackReader) (java.io BufferedReader InputStreamReader OutputStreamWriter PipedInputStream PipedOutputStream PrintWriter StringReader StringWriter) @@ -227,7 +228,20 @@ (send (tool-call 1 "eval" {:code "*1" :session "shared"})) (is (= "#'user/shared-x" (text (receive)))) (send (tool-call 2 "eval" {:code "*1"})) - (is (not= "#'user/shared-x" (text (receive)))))))) + (is (not= "#'user/shared-x" (text (receive)))) + (testing "and saying when the server lost one" + (with-client [client {:port *port*}] + (nrepl/close-session client (get (sessions/named (sessions-file) client) "shared"))) + (send (tool-call 3 "eval" {:code "3" :session "shared"})) + (is (= "(Session shared was gone, the server having restarted, so it's a new one.)\n3" + (text (receive)))) + (testing "or that it's gone, for pst, which doesn't make one" + (with-client [client {:port *port*}] + (nrepl/close-session client (get (sessions/named (sessions-file) client) "shared"))) + (send (tool-call 4 "pst" {:session "shared"})) + (let [{:keys [result]} (receive)] + (is (:isError result)) + (is (= "Session shared is gone: the server has restarted since, or closed it" (text {:result result})))))))))) (def started "Delivered by code that neorepl evaluates on the test server, to say it's diff --git a/test/neorepl/sessions_test.clj b/test/neorepl/sessions_test.clj index 0abfed5..772f9d3 100644 --- a/test/neorepl/sessions_test.clj +++ b/test/neorepl/sessions_test.clj @@ -54,6 +54,19 @@ (let [fresh (sessions/ensure! file client "agent")] (is (not= session fresh)) (is (= fresh (sessions/ensure! file client "agent"))))) + (testing "saying a session the server lost is gone, and replaced" + (let [lost (sessions/ensure! file client "lost") + replaced (atom 0)] + (nrepl/close-session client lost) + (is (= "Session lost is gone: the server has restarted since, or closed it" + (sessions/missing-message file client "lost"))) + (is (= "No session called nope" (sessions/missing-message file client "nope"))) + (is (not= lost (sessions/ensure! file client "lost" #(swap! replaced inc)))) + (is (= 1 @replaced)) + (testing "only then, not for one that's there or a new name" + (sessions/ensure! file client "lost" #(swap! replaced inc)) + (sessions/ensure! file client "brand-new" #(swap! replaced inc)) + (is (= 1 @replaced))))) (testing "a broken cache file is as good as none" (spit file "{:oops") (let [fresh (sessions/ensure! file client "agent")] From fc92782c8a66c996884efbd82ba34fc4e50c9da0 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:14 +0300 Subject: [PATCH 4/6] Say when a timeout waits behind another evaluation An evaluation left running in a session, by a client that went away, say, keeps the next one waiting, and a timeout then said interrupting the evaluation failed, since the server answers that another one is running. It now says the evaluation waits behind one from before, which interrupting the session stops. --- CHANGELOG.md | 1 + src/neorepl/evaluation.clj | 13 +++++++++---- test/neorepl/evaluation_test.clj | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c58f2..7f24f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - [#30](https://github.com/nrepl/neorepl/pull/30): Symbols that start with a dash, like `->` and `-main`, are arguments to `doc`, `where` and the other lookups, rather than unknown options. - [#30](https://github.com/nrepl/neorepl/pull/30): `--format edn` and `json` have failures too, like no server to talk to or a usage mistake, as their status and message, where stdout used to be empty. - [#30](https://github.com/nrepl/neorepl/pull/30): A named session the server lost, as when it restarts, says so: where a new one takes its place, on stderr or in the MCP result, and where it doesn't, like `pst` and `sessions close`. +- [#30](https://github.com/nrepl/neorepl/pull/30): A timeout while an evaluation from before still runs in the session says so, rather than that interrupting failed. ## 0.3.0 (2026-10-17) diff --git a/src/neorepl/evaluation.clj b/src/neorepl/evaluation.clj index 2a3eb44..2f00b15 100644 --- a/src/neorepl/evaluation.clj +++ b/src/neorepl/evaluation.clj @@ -55,8 +55,10 @@ :else (or (some statuses failure-statuses) "ok")) message (case status "timeout" (str "Timed out after " timeout "s" - (if interrupted? - " and interrupted the evaluation" + (case interrupted? + true " and interrupted the evaluation" + :busy (str " waiting behind an evaluation from before, still running in the" + " session, which interrupting the session stops, and this one runs after") ", and interrupting the evaluation failed")) "stopped" "Stopped waiting for the evaluation, which may still be running" "interrupted" "The evaluation was interrupted" @@ -77,7 +79,9 @@ (defn- interrupt! "Interrupts the evaluation with message id, sent at the time sent-at, whose responses go to the queue responses, giving the server a second to - confirm. Returns whether it did, or found it done already. A server can + confirm. Returns whether it did, or found it done already, or :busy when + the session's running another evaluation, which this one waits behind, + from a client that went away, say. A server can find the session idle while the evaluation runs elsewhere, as shadow-cljs's ClojureScript ones do, but one that's done has sent done before saying so. It can also find it idle right after the message went @@ -94,6 +98,7 @@ (< (- (System/currentTimeMillis) sent-at) 500) (do (Thread/sleep 50) (recur)) :else false) + :mismatch :busy false))) (defn- after @@ -201,7 +206,7 @@ (let [try-interrupt (halt!)] ;; Not on the thread that delivers signals and keystrokes. (future - (when-not (try-interrupt) + (when-not (true? (try-interrupt)) (.put responses ::interrupt-failed))))) ;; Sends msg, unless halted, and says so. send-msg (fn [msg] diff --git a/test/neorepl/evaluation_test.clj b/test/neorepl/evaluation_test.clj index a2f91a2..3fb597a 100644 --- a/test/neorepl/evaluation_test.clj +++ b/test/neorepl/evaluation_test.clj @@ -56,6 +56,23 @@ (when @#'nrepl/quickack (is (< elapsed 2000))))))) +(deftest busy-test + (with-client [client {:port *port*}] + (let [session (nrepl/clone-session client) + ;; As from a client that went away. + before (future (nrepl/request client {:op "eval" :session session :code "(Thread/sleep 5000)"} + {:timeout-ms nil}))] + (try + (Thread/sleep 300) + (testing "a timeout waiting behind another evaluation says so" + (let [{:keys [status message]} (evaluation/evaluate client {:op "eval" :code ":after" :session session} + {:timeout 0.3})] + (is (= "timeout" status)) + (is (str/includes? message "waiting behind an evaluation from before")))) + (finally + (nrepl/interrupt client session nil) + (deref before 5000 nil)))))) + (deftest read-line-test (with-client [client {:port *port*}] (testing "a read from *in* that blocks doesn't outlast the timeout" From 1dd77f63dfc9f4ab06bdf70301dbd612b52c0951 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:15 +0300 Subject: [PATCH 5/6] Check a file's delimiters before loading it The server's reader places a missing paren as a line of the REPL, so loading a file that doesn't balance said REPL:9:1, and a file that can't be read got a raw exception. eval -f, load and the MCP load tool now check the file the way neorepl check does, saying where it doesn't balance and that check --fix repairs it, and that a file can't be read. macroexpand says a form doesn't balance, rather than asking for one form. --- CHANGELOG.md | 1 + src/neorepl/features.clj | 20 +++++++++++++++++++- src/neorepl/main.clj | 24 ++++++++++++++++-------- src/neorepl/mcp.clj | 15 +++++++++------ test/neorepl/main_test.clj | 19 +++++++++++++++++-- test/neorepl/mcp_test.clj | 11 +++++++++++ 6 files changed, 73 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f24f0c..e798d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ - [#30](https://github.com/nrepl/neorepl/pull/30): `--format edn` and `json` have failures too, like no server to talk to or a usage mistake, as their status and message, where stdout used to be empty. - [#30](https://github.com/nrepl/neorepl/pull/30): A named session the server lost, as when it restarts, says so: where a new one takes its place, on stderr or in the MCP result, and where it doesn't, like `pst` and `sessions close`. - [#30](https://github.com/nrepl/neorepl/pull/30): A timeout while an evaluation from before still runs in the session says so, rather than that interrupting failed. +- [#30](https://github.com/nrepl/neorepl/pull/30): `eval -f`, `load` and the MCP `load` tool check a file's delimiters first, saying where they don't balance rather than the reader's `REPL:9:1`, and say when a file can't be read. ## 0.3.0 (2026-10-17) diff --git a/src/neorepl/features.clj b/src/neorepl/features.clj index 384951f..350ad4f 100644 --- a/src/neorepl/features.clj +++ b/src/neorepl/features.clj @@ -16,7 +16,8 @@ [clojure.string :as str] [neorepl.evaluation :as evaluation] [neorepl.lexer :as lexer] - [neorepl.nrepl :as nrepl])) + [neorepl.nrepl :as nrepl] + [neorepl.render :as render])) (defn- supports? [client op] (contains? (:ops client) op)) @@ -244,6 +245,8 @@ evaluating anything, the way #= would. cider-nrepl evaluates what it expands with *read-eval* on." [text] + (when-let [{:keys [message]} (lexer/delimiter-problem text)] + (throw (ex-info (str "The form doesn't balance: " message) {:type ::failed}))) (when-not (and (= 1 (count (lexer/forms text))) (not (str/includes? text "#="))) (throw (ex-info "Give one form to expand, without #=" {:type ::failed})))) @@ -831,6 +834,21 @@ (when-not (supports? client "inspect-push") (throw (ex-info "Inspecting values takes cider-nrepl or babashka on the server" {:type ::failed})))) +(defn checked-text + "The text of file, which is there, after checking its delimiters balance, + as neorepl check does, since the server's reader would place where they + don't as a line of the REPL. Throws ::failed saying what's wrong, about + path, and that fix repairs delimiters." + [file path fix] + (let [text (try + (slurp file) + (catch java.io.IOException _ + (throw (ex-info (str "Can't read the file " path) {:type ::failed}))))] + (when-let [problem (lexer/delimiter-problem text)] + (throw (ex-info (str (render/problem-text (assoc problem :file path)) ", which " fix " can repair") + {:type ::failed}))) + text)) + (defn inspect "Evaluates code in session and starts inspecting its value, or the last form's when there are several, the way evaluation/evaluate evaluates, whose diff --git a/src/neorepl/main.clj b/src/neorepl/main.clj index 465fe3c..aa212ea 100644 --- a/src/neorepl/main.clj +++ b/src/neorepl/main.clj @@ -108,9 +108,16 @@ when it times out.") (defn- existing-file ^File [path] (let [file (io/file path)] - (if (.isFile file) - file - (throw (usage-error (str "No such file: " path)))))) + (cond + (not (.isFile file)) (throw (usage-error (str "No such file: " path))) + (not (.canRead file)) (throw (usage-error (str "Can't read the file " path))) + :else file))) + +(defn- file-text + "The text of the file at path, an existing-file, checked as + features/checked-text checks it." + [path] + (features/checked-text (existing-file path) path "neorepl check --fix")) (defn- connect [{:keys [url host port]} dir] (cond @@ -347,7 +354,7 @@ printed as it arrives and each value on a line of its own." (defn- read-code [file args] (cond (and file (seq args)) (throw (usage-error "Give either CODE or --file, not both")) - file (slurp (existing-file file)) + file (file-text file) (= ["-"] args) (slurp *in*) (= 1 (count args)) (first args) (empty? args) (throw (usage-error "Nothing to evaluate")) @@ -383,12 +390,13 @@ an ns form switches the session to that namespace." neorepl load --timeout 60 --format json src/my/app.clj")) (defn- load-command [options args ctx] - (let [file (case (count args) + (let [path (case (count args) 0 (throw (usage-error "Nothing to load")) - 1 (existing-file (first args)) - (throw (usage-error "Load one file at a time")))] + 1 (first args) + (throw (usage-error "Load one file at a time"))) + file (io/file path)] (report-evaluation {:op "load-file" - :file (slurp file) + :file (file-text path) :file-path (.getAbsolutePath file) :file-name (.getName file)} options diff --git a/src/neorepl/mcp.clj b/src/neorepl/mcp.clj index d326db5..7f14b55 100644 --- a/src/neorepl/mcp.clj +++ b/src/neorepl/mcp.clj @@ -209,12 +209,15 @@ "file") :run (fn [server {:keys [file] :as args} on-send] (let [f (io/file file)] - (if (.isFile f) - (evaluate server args on-send - #(evaluation/evaluate %1 {:op "load-file" :session %2 :file (slurp f) - :file-path (.getAbsolutePath f) :file-name (.getName f)} - %3)) - (error (str "No such file: " file)))))} + (cond + (not (.isFile f)) (error (str "No such file: " file)) + (not (.canRead f)) (error (str "Can't read the file " file)) + :else + (let [text (features/checked-text f file "check with fix")] + (evaluate server args on-send + #(evaluation/evaluate %1 {:op "load-file" :session %2 :file text + :file-path (.getAbsolutePath f) :file-name (.getName f)} + %3))))))} (symbol-tool "doc" "Show a var's arglists and documentation.") (symbol-tool "source" "Show a var's source.") (symbol-tool "where" "Show the file and line a var is defined at.") diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index 6745f38..49ebd1e 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -279,7 +279,20 @@ (let [file (spit-file *dir* "src/demo/value.clj" "(ns demo.value)\n:a\n(range 10)")] ;; load-file doesn't say which namespace the session's in. (is (= {:status "ok" :values ["(0 1 2"] :out "" :err "" :ns nil :truncated true} - (edn/read-string (:out (run-command "load" "--format" "edn" "--max-output" "6" (str file))))))))) + (edn/read-string (:out (run-command "load" "--format" "edn" "--max-output" "6" (str file)))))))) + (testing "a file that doesn't balance, said as neorepl check would, rather than the reader's REPL:3:1" + (let [file (str (spit-file *dir* "src/demo/unbalanced.clj" "(ns demo.unbalanced)\n(defn f []\n"))] + (doseq [args [["load" file] ["eval" "-f" file]]] + (is (= {:status 1 :out "" + :err (str "neorepl: " file ":2:1: Unclosed (, which neorepl check --fix can repair\n")} + (apply run-command args)) + (pr-str args))))) + (testing "a file that can't be read" + (let [file (spit-file *dir* "src/demo/unreadable.clj" "(ns demo.unreadable)")] + ;; Where that can be had: not on Windows, nor as root. + (when (and (.setReadable file false) (not (.canRead file))) + (is (= 2 (:status (run-command "load" (str file))))) + (is (str/includes? (:err (run-command "load" (str file))) "Can't read the file")))))) (deftest repl-test (let [repl #(apply run-in *dir* %1 "repl" "-p" (str *port*) %&)] @@ -468,7 +481,9 @@ (is (= {:status 0 :out "(if x (do y))\n" :err ""} (run-command "macroexpand" "(when x y)"))) (is (str/includes? (:out (run-command "macroexpand" "--all" "(when-let [a 1] a)")) "let*")) (is (= {:expansion "(if x (do y))"} - (edn/read-string (:out (run-command "macroexpand" "--format" "edn" "(when x y)")))))) + (edn/read-string (:out (run-command "macroexpand" "--format" "edn" "(when x y)"))))) + (is (= {:status 1 :out "" :err "neorepl: The form doesn't balance: Unclosed (\n"} + (run-command "macroexpand" "(when x")))) (deftest where-test (let [{:keys [status out]} (run-command "where" "map")] diff --git a/test/neorepl/mcp_test.clj b/test/neorepl/mcp_test.clj index 35fc2d2..5b94931 100644 --- a/test/neorepl/mcp_test.clj +++ b/test/neorepl/mcp_test.clj @@ -294,6 +294,17 @@ {:file (str *dir* "/missing.clj") :message "No such file"}] (:problems structuredContent))) (is (str/starts-with? (text {:result result}) (str bad ":2:1: Unclosed (")))) + (testing "and loading only what can be read" + (let [unreadable (spit-file *dir* "unreadable.clj" "(ns unreadable)")] + ;; Where that can be had: not on Windows, nor as root. + (when (and (.setReadable unreadable false) (not (.canRead unreadable))) + (send (tool-call 5 "load" {:file (str unreadable)})) + (is (= (str "Can't read the file " unreadable) (text (receive))))))) + (testing "and loading only what balances" + (send (tool-call 4 "load" {:file bad})) + (let [{:keys [result]} (receive)] + (is (:isError result)) + (is (= (str bad ":2:1: Unclosed (, which check with fix can repair") (text {:result result}))))) (send (tool-call 2 "check" {:files [good]})) (is (= "Every delimiter balances" (text (receive)))) (testing "and repairing them" From c765736a0d82f701da66a78122ceaf27c15ef275 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Tue, 20 Oct 2026 08:30:15 +0300 Subject: [PATCH 6/6] Say what's wrong with a namespace, a regex or inspected code The server's exception came through as "The server couldn't do eval" or lookup and the whole of it. A namespace that isn't there now says so, from nREPL's namespace-not-found, from the fallbacks that evaluate code, and from nREPL's lookup, which only says lookup-error and gets the fallback then. apropos checks the regex before it goes out. cider-nrepl answers inspected code that throws with the whole stack trace, which comes down to its first line and its root cause, as eval would say it. --- CHANGELOG.md | 1 + PLAN.md | 2 +- src/neorepl/features.clj | 94 +++++++++++++++++++++++++--------- test/neorepl/features_test.clj | 16 ++++++ test/neorepl/main_test.clj | 7 ++- 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e798d81..74a3d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - [#30](https://github.com/nrepl/neorepl/pull/30): A named session the server lost, as when it restarts, says so: where a new one takes its place, on stderr or in the MCP result, and where it doesn't, like `pst` and `sessions close`. - [#30](https://github.com/nrepl/neorepl/pull/30): A timeout while an evaluation from before still runs in the session says so, rather than that interrupting failed. - [#30](https://github.com/nrepl/neorepl/pull/30): `eval -f`, `load` and the MCP `load` tool check a file's delimiters first, saying where they don't balance rather than the reader's `REPL:9:1`, and say when a file can't be read. +- [#30](https://github.com/nrepl/neorepl/pull/30): A namespace that isn't there, a regex that doesn't parse and inspected code that throws get a message saying so, rather than the server's exception. ## 0.3.0 (2026-10-17) diff --git a/PLAN.md b/PLAN.md index 8745b59..8026929 100644 --- a/PLAN.md +++ b/PLAN.md @@ -272,7 +272,7 @@ Each one is a release people can use. The first three stages below all went into - MCP, done: `pst` and `apropos` put their lists under `causes` and `vars`, since structured content is an object, which the official SDK checks. Code gets end of input from `*in*`, which carries the protocol. `reload` gives up at the call's timeout. Arguments get checked against the tools' schemas, which say what ports, timeouts and sessions can be, and a line the reader can't take gets a parse error, ids past a long being bigints now. A connection whose server went away gets replaced, and closed, before a call uses it. The default session is `agent`, the skill's, and `--max-output` cuts the whole result. - Lookups, done: `source` reads the definition from the file and line `info` finds, as CIDER does, which works after `load` and in ClojureScript, and takes it only if it's a definition, since cider-nrepl gives the macros ClojureScript takes from Clojure lines from somewhere else; it falls back to `clojure.repl/source-fn` for files this machine can't read. The eval fallbacks compile in `user` with `*ns*` bound, so they work in a namespace made with `in-ns`. Java members and classes get their doc, from cider-nrepl's Javadoc fragments, and arglists, fields none, and a namespace is named once. `,macroexpand` with cider-nrepl expands in a copy of the session, keeping its `*1`. `--session`, and the MCP tools' `session`, look things up as a session sees them, in its language and namespace; in ClojureScript that's the Clojure namespace under it, which is all `describe` has, so `--ns` names the other. - Tests, done: a whole-project run with cider-nrepl loads the project's namespaces first, those orchard finds, in a copy of the session, and says which don't load, then runs the tests without `:load-project-ns?`, which never answers when a namespace doesn't load; a server without orchard, like bb's, still gets the flag. A namespace that threw while loading is gone, but counts as loaded to `require` after, its `ns` form having said so, so a reload gets its error again, for every run, named namespaces' too. A test error's location is its test's file, with the line cider-nrepl gives, which is the test's, rather than the file of the frame that threw, and on bb every failure's is, bb giving the file the session loaded last. One deadline covers the run, loading the tests included, and running out while loading them exits 124. A namespace named without tests says so and exits 1, a test namespace that doesn't load says that, and failure headings show files relative to the directory neorepl runs in. - - Errors and output: `--format edn` and `json` print nothing on stdout for most failures, like no server, a server error or no port files. "The server couldn't do eval" and friends carry the exception's whole text, for a namespace that isn't there, a bad regex, or a test namespace that doesn't compile, and `,inspect` of failing code prints the Java trace on cider-nrepl. A server restart gives a named session a fresh start without a word. `doc ->` and `where -main` are taken for options. A timeout waiting on a busy session says interrupting failed. Unbalanced code in `eval -f` and `load` gets the reader's `REPL:9:1`, and unreadable files a raw exception. `pst` shows the REPL's own frames without cider-nrepl, and hides real ones as duplicates with it and with bb. `reload` stays broken once a namespace's file is gone, until cider-nrepl's `refresh-clear`. + - Errors and output, done: failures come out in `--format edn` and `json` too, with their status and message. "The server couldn't do eval" and friends make way for what's wrong: a namespace that isn't there, which nREPL's `lookup` says only with an error, a regex that doesn't parse, checked before it goes out, and inspected code that throws, which cider-nrepl answers with the whole stack trace, cut to its first line and its root cause. A server restart that loses a named session says so where a new one takes its place, on stderr or in the MCP result, and where it doesn't, as for `pst`. `doc ->` and `where -main` take their symbols for arguments, an argument being an option only when it looks like one. A timeout waiting behind an evaluation left in the session says that, and that interrupting the session stops it. `eval -f` and `load` check a file's delimiters first, as `neorepl check` would, and say a file can't be read. Left for the next round: `pst` showing the REPL's own frames without cider-nrepl and hiding real ones as duplicates with it and bb, and `reload` staying broken once a namespace's file is gone, until cider-nrepl's `refresh-clear`. - Tracing: `,trace map` takes a cider-nrepl server down, orchard tracing its own calls. - The REPL: output from another thread that doesn't end its line stays hidden, then sticks to the next value. Ctrl-R's search prompt doesn't show, the autosuggestion's tip taking its place. Enter on a frame without source in `,stacktrace` leaves the view, and `,edit` can't open a definition in a jar. `,macroexpand` replaces `*1` with cider-nrepl. Text typed while an evaluation runs shows up in its output and again at the prompt. The plain REPL shows the inspector's `[N]` marks, Ctrl-C's message starts after the `^C`, long eldoc tips wrap, and `NO_COLOR` leaves the completion menu's color. C-x C-s and C-x C-a, the continuation lines' indentation, the vi keymap and the completion menu's namespaces are in this plan but not in the REPL. - Jack-in: a build tool that isn't installed gets a raw `exec: lein: not found`. `neorepl server --tool shadow-cljs` won't start next to a Clojure server, and the REPL's `--tool` goes to whatever server is up. A port file pointing at something that isn't nREPL takes 10s to give up on. Done: a host that doesn't answer gives up after 10s, rather than 75s. A server that dies leaves its port file and no word why, and Ctrl-C while one starts says it failed. A nested project connects to its parent's server. diff --git a/src/neorepl/features.clj b/src/neorepl/features.clj index 350ad4f..7927b6b 100644 --- a/src/neorepl/features.clj +++ b/src/neorepl/features.clj @@ -22,6 +22,11 @@ (defn- supports? [client op] (contains? (:ops client) op)) +(defn- no-namespace + "The exception saying there's no namespace called ns." + [ns] + (ex-info (str "No namespace called " ns) {:type ::failed})) + (defn- ask "Sends msg and returns its responses, throwing ::failed when the server reports an error: a status ending in error, as eval-error and @@ -29,6 +34,8 @@ [client msg opts] (let [responses (nrepl/request client msg opts) statuses (into #{} (mapcat :status) responses)] + (when (statuses "namespace-not-found") + (throw (no-namespace (:ns msg)))) (when (some #(and (str/ends-with? % "error") (not= "no-error" %)) statuses) (throw (ex-info (str "The server couldn't do " (:op msg) (when-let [err (some-> (keep :err responses) seq str/join str/trim)] @@ -52,13 +59,17 @@ "eval-data with the code evaluated as if in the namespace ns names, which needn't refer clojure.core, made by in-ns, say: it's compiled in user, after requiring the namespaces requires names, a top-level form of its - own so that the code compiles after, with *ns* bound to that one." + own so that the code compiles after, with *ns* bound to that one. Throws + ::failed when there's no such namespace." [client ns requires template args opts] - (eval-data client {:ns "user"} - (str "(do " (when (seq requires) (str "(require " (str/join " " (map #(str "'" %) requires)) ") ")) - "(binding [*ns* (the-ns (symbol %s))] " template "))") - (cons ns args) - opts)) + (let [value (eval-data client {:ns "user"} + (str "(do " (when (seq requires) (str "(require " (str/join " " (map #(str "'" %) requires)) ") ")) + "(if-let [n (find-ns (symbol %s))] (binding [*ns* n] " template ") :neorepl.features/no-namespace))") + (cons ns args) + opts)] + (if (= ::no-namespace value) + (throw (no-namespace ns)) + value))) (defn- lookup-msg "msg, looking something up where ctx says." @@ -142,6 +153,21 @@ (seq forms) (assoc :forms (mapv #(if (string? %) % (str "(" (str/join " " (map form-text %)) ")")) forms)) forms-str (assoc :forms (str/split-lines forms-str))))) +(defn- evaluated-info + "info, found by evaluating code, for servers without info or lookup." + [client sym ctx opts] + (some-> (eval-in client (:ns ctx) [] "(when-let [m (meta (resolve (symbol %s)))] + {:ns (str (:ns m)) + :name (str (:name m)) + :arglists-str (some->> (:arglists m) (map pr-str) (interpose \"\\n\") (apply str)) + :doc (:doc m) + :file (:file m) + :line (:line m) + :column (:column m) + :macro (:macro m)})" + [sym] opts) + normalize-info)) + (defn info "What the server knows about sym, a symbol's text, as seen from where ctx says: a map with its :ns and :name, :arglists, :doc and where it @@ -157,23 +183,20 @@ (normalize-info info))) (supports? client "lookup") - (let [info (some :info (ask client (lookup-msg {:op "lookup" :sym sym} ctx) opts))] - ;; Nothing found comes back as an empty list. - (when (map? info) - (normalize-info info))) + (try + (let [info (some :info (ask client (lookup-msg {:op "lookup" :sym sym} ctx) opts))] + ;; Nothing found comes back as an empty list. + (when (map? info) + (normalize-info info))) + (catch clojure.lang.ExceptionInfo e + ;; As it says for a namespace that isn't there, which evaluating + ;; says. + (if (contains? (:statuses (ex-data e)) "lookup-error") + (evaluated-info client sym ctx opts) + (throw e)))) :else - (some-> (eval-in client (:ns ctx) [] "(when-let [m (meta (resolve (symbol %s)))] - {:ns (str (:ns m)) - :name (str (:name m)) - :arglists-str (some->> (:arglists m) (map pr-str) (interpose \"\\n\") (apply str)) - :doc (:doc m) - :file (:file m) - :line (:line m) - :column (:column m) - :macro (:macro m)})" - [sym] opts) - normalize-info)))) + (evaluated-info client sym ctx opts)))) (defn complete "The completions for prefix where ctx says, as maps with the :candidate @@ -232,6 +255,12 @@ the server says, the :type and :doc." ([client regex] (apropos client regex {})) ([client regex opts] + ;; Rather than the server's stack trace for one that doesn't parse. + (try + (re-pattern regex) + (catch java.util.regex.PatternSyntaxException e + (throw (ex-info (str "Not a regex: " (.getDescription e) ", at index " (.getIndex e) " of " regex) + {:type ::failed})))) (if (supports? client "apropos") (->> (ask client {:op "apropos" :query regex} opts) (some :apropos-matches) @@ -834,6 +863,15 @@ (when-not (supports? client "inspect-push") (throw (ex-info "Inspecting values takes cider-nrepl or babashka on the server" {:type ::failed})))) +(defn- trace-summary + "err, an exception's stack trace as cider-nrepl's inspector prints it for + code that throws, cut down to what eval would say: its first line, and + the line of its root cause." + [err] + (let [lines (some-> err str/split-lines) + cause (last (filter #(str/starts-with? % "Caused by: ") lines))] + (str (str/join "\n" (remove nil? [(first lines) cause])) "\n"))) + (defn checked-text "The text of file, which is there, after checking its delimiters balance, as neorepl check does, since the server's reader would place where they @@ -861,19 +899,27 @@ (check-inspector client) (let [;; The last value's view, or ::failed. view (atom nil) + ;; cider-nrepl's stack trace for code that throws, which comes as + ;; :err with the :ex, and how much less to say. + trace (atom nil) + summary #(cond-> % (and (:ex %) (:err %)) (update :err trace-summary)) {:keys [status] :as result} (evaluate-for client (cond-> {:op "eval" :session session :code code :inspect "true"} page-size (assoc :page-size page-size)) ;; It would cut the view short, which is the value. - (dissoc opts :max-output :page-size) + (cond-> (dissoc opts :max-output :page-size) + (:on-response opts) (update :on-response comp summary)) #(cond (:value %) (reset! view (:value %)) ;; cider-nrepl's way of saying eval-error. - (nrepl/status? % "inspect-eval-error") (reset! view ::failed)))] + (nrepl/status? % "inspect-eval-error") (do (reset! view ::failed) + (when (and (:ex %) (:err %)) + (reset! trace (:err %))))))] (cond (not= "ok" status) result - (= ::failed @view) (assoc result :status "eval-error") + (= ::failed @view) (cond-> (assoc result :status "eval-error") + @trace (update :err str/replace @trace (trace-summary @trace))) @view (assoc result :lines (inspector-lines @view)) :else (throw (ex-info "The server didn't inspect the value" {:type ::failed}))))) diff --git a/test/neorepl/features_test.clj b/test/neorepl/features_test.clj index e6c4066..90c2e71 100644 --- a/test/neorepl/features_test.clj +++ b/test/neorepl/features_test.clj @@ -230,6 +230,11 @@ (testing "from a namespace that doesn't refer clojure.core" (nrepl/request client {:op "eval" :ns "user" :code "(in-ns 'neorepl.bare)"}) (is (= "(if x (do y))" (features/expand client "(clojure.core/when x y)" {:ns "neorepl.bare"} false)))) + (testing "or one that isn't there, which says so" + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"^No namespace called no.such.ns$" + (features/expand client "(when x y)" {:ns "no.such.ns"} false))) + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"^No namespace called no.such.ns$" + (features/info client "map" {:ns "no.such.ns"})))) (testing "one form, only ever read, not evaluated" (are [text] (= ::features/failed (:type (ex-data (is (thrown? clojure.lang.ExceptionInfo (features/expand client text user false)))))) @@ -541,6 +546,17 @@ (finally (server/stop-server srv))))) +(deftest trace-summary-test + (testing "cider-nrepl's stack trace for inspected code that throws, as eval would say it" + (is (= (str "clojure.lang.Compiler$CompilerException: Syntax error compiling at (1:1).\n" + "Caused by: java.lang.RuntimeException: Unable to resolve symbol: nosuch in this context\n") + (#'features/trace-summary + (str "clojure.lang.Compiler$CompilerException: Syntax error compiling at (1:1).\n" + "#:clojure.error{:phase :compile-syntax-check, :line 1, :column 1}\n" + " at clojure.lang.Compiler.analyze (Compiler.java:7397)\n" + "Caused by: java.lang.RuntimeException: Unable to resolve symbol: nosuch in this context\n" + " at clojure.lang.Util.runtimeException (Util.java:221)\n")))))) + (deftest inspect-test (with-client [client {:port *port*}] (let [session (nrepl/clone-session client) diff --git a/test/neorepl/main_test.clj b/test/neorepl/main_test.clj index 49ebd1e..e4c8f31 100644 --- a/test/neorepl/main_test.clj +++ b/test/neorepl/main_test.clj @@ -472,10 +472,9 @@ (testing "nothing matching" (is (= {:status 1 :out "" :err "neorepl: Nothing matches ^nope-nope$\n"} (run-command "apropos" "^nope-nope$")))) - (testing "a regex the server can't make sense of" - (let [{:keys [status err]} (run-command "apropos" "(")] - (is (= 1 status)) - (is (str/includes? err "The server couldn't do eval"))))) + (testing "a regex that doesn't parse, said before it goes out" + (is (= {:status 1 :out "" :err "neorepl: Not a regex: Unclosed group, at index 1 of (\n"} + (run-command "apropos" "("))))) (deftest macroexpand-test (is (= {:status 0 :out "(if x (do y))\n" :err ""} (run-command "macroexpand" "(when x y)")))