diff --git a/.github/workflows/tests-run.yml b/.github/workflows/tests-run.yml index 51cb992..80a3d0e 100644 --- a/.github/workflows/tests-run.yml +++ b/.github/workflows/tests-run.yml @@ -46,5 +46,8 @@ jobs: run: poetry install - name: Run tests - run: poetry run basilisp test + # workaround for https://github.com/basilisp-lang/basilisp/issues/1119 + env: + PYTHONPATH: ${{ github.workspace }} + run: poetry run pytest -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 14caa02..c3e5bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +## 0.1.0b2 + +- Upgraded Basilisp to 0.2.4. +- Improved on the nREPL server exception messages by matching that of the REPL user friendly format, backported from [basilisp-blender](https://github.com/ikappaki/basilisp-blender). +- Fix incorrect line numbers for compiler exceptions in nREPL when evaluating forms in loaded files, backported from [basilisp-blender](https://github.com/ikappaki/basilisp-blender) +- nREPL server no longer sends ANSI color escape sequences in exception messages to clients, backported from [basilisp-blender](https://github.com/ikappaki/basilisp-blender). +- Conform to the `cider-nrepl` `info` ops spec by ensuring result's `:file` is URI, also added missing :column number, backported from [basilisp-blender](https://github.com/ikappaki/basilisp-blender). + + ## 0.1.0b1 - Initial version based on basilisp-blender nREPL server with improved error reporting. diff --git a/README.md b/README.md index 93c0e8f..ca09b4f 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ # A Basilisp async nREPL server for cooperative multitasking ## Overview -`basilisp-nrepl-async` is an nREPL server implementation for [Basilisp](https://basilisp.readthedocs.io/en/latest/) that allows client requests to be evaluated asynchronously by the main program at a chosen time and place. This enables cooperative multitasking on a single thread between the program and the nREPL server on the Python VM. +`basilisp-nrepl-async` is an nREPL server implementation for [Basilisp](https://basilisp.readthedocs.io/en/latest/) that allows client requests to be evaluated asynchronously by the main program at a chosen time and place. This enables cooperative multitasking on a single thread between the program and the nREPL server on the Python VM, while avoiding conflicts with the Global Interpreter Lock (GIL) and single threaded libraries. ## Installation To install `basilisp-nrepl-async`, run: ```shell -pip install https://github.com/ikappaki/basilisp-nrepl-async/releases/download/v0.1.0b1/basilisp_nrepl_async-0.1.0b1-py3-none-any.whl +pip install https://github.com/ikappaki/basilisp-nrepl-async/releases/download/v0.1.0b2/basilisp_nrepl_async-0.1.0b2-py3-none-any.whl ``` ## Usage @@ -26,23 +26,60 @@ To start the nREPL server on a random port bound to the local interface, call th (def server-async (nr/start-server! {:async? true})) ; nREPL server started on port 55144 on host 127.0.0.1 - nrepl://127.0.0.1:55144 -;; Process client requests on this thread +;; Process client requests on this thread (let [{:keys [host port shutdown-fn work-fn]} server-async] - (try + (loop [] ;; suppose this is the main event loop - (loop [] ;; suppose this is the main loop + (work-fn) ;; Execute any pending nREPL client work - (work-fn) ;; Execute any pending nREPL client work + ;; simulate some work + (time/sleep 0.5) - ;; simulate some work - (time/sleep 0.5) - - (recur)))) + (recur))) ``` The server will create an `.nrepl-port` file in the current working directory with the port number, which nREPL-enabled Clojure editors can use to connect. -You can also pass additional options to the `server-start!` function, such as `:address` and `:port`, to explicitly set the server's listening interface and port, respectively. See the function documentation for more details. +You can also pass additional options to the `server-start!` function, such as `:host`, `:port` and `:nrepl-port-file`, to explicitly configure the server's listening interface, port, and the file where the port number is written (typically `/.nrepl-port` for integration with your editor). See the function documentation for more details in [src/basilisp_nrepl_async/nrepl_server.lpy](src/basilisp_nrepl_async/nrepl_server.lpy) + +```clojure +"Create an nREPL server with `server-make` (of which see) according to + ``opts`` if given, and blocks for serving clients. + + It prints out the `nrepl-server-signature` message at startup for + IDEs to pickup the host number to connect to. + + ``opts`` is a map of options with the following optional keys + + :async? If truthy, runs the server non-blocking in a separate thread + where requests are queued instead of executed immediately. Returns a + map with + + :error :error Contains details of any error during server + creation. + + :host The host interface the server is bound to. + + :port The local port the server is listening on. + + :shutdown-fn The function to shutdown the server. + + :work-fn The function to process any pending client + requests. + + :nrepl-port-file An optional filepath to write the port number + to. Typically set to .nrepl-port for the editors to detect. + + :server* An optional promise of a map delivered on success with + + :host The host interface the server is bound to. + + :port The local port the server is listening on. + + :shutdown-fn The function to shutdown the server. + + also see `server-make` for additionally supported ``opts`` keys." +``` ## Development and Testing @@ -58,4 +95,4 @@ This project is licensed under the Eclipse Public License 2.0. See the [LICENSE] ## Acknowledgments -This library is a spin-off of the [basilisp-blender](https://github.com/ikappaki/basilisp-blender) nrepl-server namespace. +This library is a spin-off of [basilisp-blender](https://github.com/ikappaki/basilisp-blender)'s `basilisp-blender.nrepl-server` namespace. diff --git a/pyproject.toml b/pyproject.toml index c07d2e5..386e9e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,14 @@ [tool.poetry] name = "basilisp-nrepl-async" -version = "0.1.0b1" +version = "0.1.0b2" description = "" authors = ["ikappaki "] readme = "README.md" packages = [{include = "basilisp_nrepl_async", from = "src"}] +[project.urls] +Homepage = "https://github.com/ikappaki/basilisp-nrepl-async" + [tool.poetry.dependencies] python = "^3.8" basilisp = "^0.2.4" diff --git a/src/basilisp_nrepl_async/nrepl_server.lpy b/src/basilisp_nrepl_async/nrepl_server.lpy index 2f10cc2..8fbc4bd 100644 --- a/src/basilisp_nrepl_async/nrepl_server.lpy +++ b/src/basilisp_nrepl_async/nrepl_server.lpy @@ -1,30 +1,28 @@ ;; adapted from ;; https://github.com/basilisp-lang/basilisp/blob/b4d9c2d6ed1aaa9ba2f4b1dc0e8073813aab1315/src/basilisp/contrib/nrepl_server.lpy (ns basilisp-nrepl-async.nrepl-server - "A port of `nbb `_'s nREPL server implementation to basilisp. - - Additions: - - - Client requests can be collected into a map for asynchronous - processing outside the current execution thread." (:require [basilisp.contrib.bencode :as bc] - [basilisp.stacktrace :as strace] [basilisp.string :as str] [basilisp-nrepl-async.utils :as u]) - (:import contextlib + (:import basilisp.logconfig + contextlib logging + os queue socket socketserver sys threading - traceback - uuid)) + urllib + traceback)) (def logger "The logger for this namespace." (logging/getLogger (namespace ::))) +(defmacro ^:private trace [& values] + `(when (.isEnabledFor logger basilisp.logconfig/TRACE) + (.log logger basilisp.logconfig/TRACE (str/join " " [~@values])))) (defmacro ^:private debug [& values] `(when (.isEnabledFor logger logging/DEBUG) (.debug logger (str/join " " [~@values])))) @@ -65,43 +63,41 @@ (defn- log-request-mw [handler] (fn [request send-fn] - (info :request (dissoc request :client*)) + (debug :request (dissoc request :client*)) (handler request send-fn))) (defn- log-response-mw [handler] (fn [request response] - (info :response response) + (debug :response response) (handler request response))) (declare ops) (defn- handle-describe [request send-fn] - (send-fn request - {"versions" {"basilisp" (let [version basilisp.lang.runtime/BASILISP-VERSION-STRING] - (assoc (zipmap ["major" "minor" "incremental"] - version) - "version-string" (str/join "." version))) - "python" (let [version (get (.split sys/version " ") 0)] - (assoc (zipmap ["major" "minor" "incremental"] - (py->lisp (.split version "."))) - "version-string" (py->lisp sys/version)))} - "ops" (zipmap (map name (keys ops)) (repeat {})) - "status" ["done"]})) + (let [basilisp-version (-> (zipmap ["major" "minor" "incremental"] *basilisp-version*) + (assoc "version-string" (str/join "." *basilisp-version*))) + python-version (-> (zipmap ["major" "minor" "incremental"] *python-version*) + (assoc "version-string" (str/join "." *python-version*)))] + (send-fn request + {"versions" {"basilisp" basilisp-version + "python" python-version} + "ops" (zipmap (map name (keys ops)) (repeat {})) + "status" ["done"]}))) (defn- format-value [_nrepl-pprint _pprint-options value] (pr-str value)) (defn- send-value [request send-fn v] - (let [{:keys [client*]} request - {:keys [*1 *2]} @client* - [v opts] v - ns (:ns opts)] + (let [{:keys [client*]} request + {:keys [*1 *2]} @client* + [v opts] v + ns (:ns opts)] (swap! client* assoc :*1 v :*2 *1 :*3 *2) (let [v (format-value (:nrepl.middleware.print/print request) (:nrepl.middleware.print/options request) v)] (send-fn request {"value" (str v) - "ns" (str ns)})))) + "ns" (str ns)})))) (defn- handle-error [send-fn request e] (let [{:keys [client* ns]} request @@ -109,9 +105,9 @@ message (or (:message data) (str e))] (swap! client* assoc :*e e) (send-fn request {"err" (str message)}) - (send-fn request {"ex" (traceback/format-exc) + (send-fn request {"ex" (traceback/format-exc) "status" ["eval-error"] - "ns" ns}))) + "ns" ns}))) (defn- do-handle-eval "Evaluate the ``request`` ``code`` of ``file`` in the ``ns`` namespace @@ -128,32 +124,38 @@ It binds the `*1`, `*2`, `*3` and `*e` variables for evaluation from the corresponding ones found in ``client*``, and updates the latter according to the result." - [{:keys [client* code ns file _column _line] :as request} send-fn] + [{:keys [client* code ns file column line] :as request} send-fn] (let [{:keys [*1 *2 *3 *e eval-ns]} @client* out-stream (StreamOutFn #(send-fn request {"out" %})) - reader (io/StringIO code) ctx (basilisp.lang.compiler.CompilerContext. (or file "")) eval-ns (if ns (create-ns (symbol ns)) eval-ns)] - (binding [*ns* eval-ns + (binding [*ns* eval-ns *out* out-stream - *1 *1 *2 *2 *3 *3 *e *e] + *1 *1 + *2 *2 + *3 *3 + *e *e] (try - (let [results (for [form (seq (basilisp.lang.reader/read reader - *resolver* - *data-readers*))] + (let [result (last + (for [form (read-seq (cond-> {} + line (assoc :init-line line) + column (assoc :init-column column)) + (io/StringIO code))] (basilisp.lang.compiler/compile-and-exec-form form ctx - *ns*)) - result (last results)] + *ns*)))] (send-value request send-fn [result {:ns (str *ns*)}])) (catch python/Exception e - (info :eval-exception e) - (handle-error send-fn (assoc request :ns (str *ns*)) e)) + (debug :eval-exception e) + (let [msg (->> (basilisp.lang.exception/format_exception e + ** :disable-color true) + (str/join ""))] + (handle-error send-fn (assoc request :ns (str *ns*)) msg))) (finally (swap! client* assoc :eval-ns *ns*) - (send-fn request {"ns" (str *ns*) + (send-fn request {"ns" (str *ns*) "status" ["done"]})))))) (defn- handle-eval [request send-fn] @@ -192,12 +194,10 @@ [resolve-ns symbol-str] (let [reader (io/StringIO symbol-str) {:keys [form error]} (try {:form (binding [*ns* resolve-ns] - (first (seq (basilisp.lang.reader/read reader - *resolver* - *data-readers*))))} - (catch python/Exception e - (info :symbol-identify-reader-error :input symbol-str :exception e) - {:error (str e)}))] + (read reader))} + (catch python/Exception e + (debug :symbol-identify-reader-error :input symbol-str :exception e) + {:error (str e)}))] (cond error @@ -217,12 +217,9 @@ (catch python/Exception e {:error (str e)}))] (cond - var - [:var var] - error - [:error error] - :else - [:other form]))))) + var [:var var] + error [:error error] + :else [:other form]))))) (defn- forms-join [forms] (->> (map pr-str forms) @@ -238,37 +235,45 @@ (let [mapping-type (-> request :op) {:keys [eval-ns]} @client*] (try - (let [lookup-ns (if ns - (create-ns (symbol ns)) - eval-ns) - sym-str (or (:sym request) ;; cider - (:symbol request) ;; calva - ) + (let [lookup-ns (if ns + (create-ns (symbol ns)) + eval-ns) + sym-str (or (:sym request) ;; cider + (:symbol request) ;; calva + ) + [tp var-maybe] (symbol-identify lookup-ns sym-str) var-meta (when (= tp :var) (meta var-maybe)) - {:keys [arglists doc file ns line] symname :name} var-meta - ref (when (= tp :var) (var-get var-maybe)) - response (when symname (case mapping-type - :eldoc (cond-> - {"eldoc" (mapv #(mapv str %) arglists) - "ns" (str ns) - "type" (if (fn? ref) - "function" - "variable") - "name" (str symname) - "status" ["done"]} - doc (assoc "docstring" doc)) - :info {"doc" doc - "ns" (str ns) - "name" (str symname) - "file" file - "line" line - "arglists-str" (forms-join arglists) - "status" ["done"]})) - status (if (and (nil? symname) (= mapping-type :eldoc) ) - ["done" "no-eldoc"] - ["done"])] - (debug :lookup :sym sym-str :doc doc :args arglists) + + {:keys [arglists doc file ns line col] symname :name} var-meta + + ref (when (= tp :var) (var-get var-maybe)) + response (when symname + (case mapping-type + :eldoc (cond-> + {"eldoc" (mapv #(mapv str %) arglists) + "ns" (str ns) + "type" (if (fn? ref) + "function" + "variable") + "name" (str symname) + "status" ["done"]} + doc (assoc "docstring" doc)) + :info {"doc" doc + "ns" (str ns) + "name" (str symname) + "file" (if (os.path/isabs file) + (->> (urllib.parse/quote file) + (urllib.parse/urljoin "file:")) + file) + "line" line + "column" col + "arglists-str" (forms-join arglists) + "status" ["done"]})) + status (if (and (nil? symname) (= mapping-type :eldoc) ) + ["done" "no-eldoc"] + ["done"])] + (trace :lookup :sym sym-str :doc doc :args arglists) (send-fn request (assoc response :status status))) (catch python/Exception e (let [status (cond-> @@ -327,17 +332,18 @@ "status" ["done"]}))) (def ops - "A list of operations supported by the nrepl server." - {:eval handle-eval - :describe handle-describe - :info handle-lookup - :eldoc handle-lookup - :clone handle-clone - :close handle-close + "A map of operations supported by the nREPL server (as keywords) to function + handlers for those operations." + {:eval handle-eval + :describe handle-describe + :info handle-lookup ;; cider-nrepl middleware + :eldoc handle-lookup ;; cider-nrepl middleware + :clone handle-clone + :close handle-close + :load-file handle-load-file + :complete handle-complete ;; :macroexpand handle-macroexpand ;; :classpath handle-classpath - :load-file handle-load-file - :complete handle-complete }) (defn- handle-request [{:keys [op] :as request} send-fn] @@ -354,7 +360,7 @@ (defn- make-send-fn [socket] (fn [_request response] - (debug :sending (:id _request) :response-keys (keys response)) + (trace :sending (:id _request) :response-keys (keys response)) (try (.sendall socket (bc/encode response)) (catch python/TypeError e @@ -365,14 +371,16 @@ log-response-mw response-for-mw)) -(defn- work*-make [] +(defn- work*-make "Create and return an atom for a work* registry map to store the connected clients and their requests." + [] (atom {})) -(defn- work*-client-add! [work* socket] +(defn- work*-client-add! "Add a new client ``socket`` connection to the ``work*`` map and return the client's requests `queue/Queue` assigned to it." + [work* socket] (let [q (queue/Queue)] (swap! work* assoc socket q) q)) @@ -411,7 +419,7 @@ (info "Connection accepted" :info client-info) ;; Need to load the `clojure.core` alias because cider uses it ;; to test for availability of features. - (eval (read-string "(ns user (:require clojure.core))")) + (eval '(ns user (:require clojure.core))) (swap! client* assoc :eval-ns *ns*) (loop [data (.recv socket recv-buffer-size)] (if (= data zero-bytes) @@ -423,8 +431,8 @@ b) data) [requests unprocessed] (bc/decode-all data {:keywordize-keys true - :string-fn #(.decode % "utf-8")})] - (debug :requests requests) + :string-fn #(.decode % "utf-8")})] + (trace :requests requests) (when (not (str/blank? unprocessed)) (reset! pending unprocessed)) (doseq [request requests] @@ -511,7 +519,7 @@ :work-fn If :async? is truthy, this is the function to process pending client `work*`. - See :lpy:fn:`on-connect!` for additionally supported ``opts`` keys. + See `on-connect!` for additionally supported ``opts`` keys. Known limitations: @@ -543,7 +551,7 @@ "nREPL server started on port %s on host %s - nrepl://%s:%s") (defn- server-serve! - "Blocking call to serve `server` requests, with graceful erorr + "Blocking call to serve `server` requests, with graceful error handling. It returns a map with the following key on error @@ -560,8 +568,8 @@ {:error error})))) (defn start-server! - "Create an nREPL server with :lpy:fn:`server-make` (of which see) - according to ``opts`` if given, and blocks for serving clients. + "Create an nREPL server with `server-make` (of which see) according to + ``opts`` if given, and blocks for serving clients. It prints out the `nrepl-server-signature` message at startup for IDEs to pickup the host number to connect to. @@ -587,7 +595,7 @@ :nrepl-port-file An optional filepath to write the port number to. Typically set to .nrepl-port for the editors to detect. - :server* An optional promise of a map delivered on sucess with + :server* An optional promise of a map delivered on success with :host The host interface the server is bound to. @@ -595,30 +603,36 @@ :shutdown-fn The function to shutdown the server. - also see :lpy:fn:`server-make` for additionally supported - ``opts`` keys." + also see `server-make` for additionally supported ``opts`` keys." ([] (start-server! {})) ([opts] (let [{:keys [nrepl-port-file async? server*] :as k - :or {nrepl-port-file ".nrepl-port"}} opts] + :or {nrepl-port-file ".nrepl-port"}} opts + port-file-path (when nrepl-port-file + (try (os.path/abspath nrepl-port-file) + (catch python/Exception _ + nil)))] (u/with-eprotect :start-server-error :on-err-str #(err %) - (with [port-file (if nrepl-port-file - (python/open nrepl-port-file "w") + (with [port-file (if port-file-path + (python/open port-file-path "w") (contextlib/nullcontext))] (let [{:keys [error server shutdown-fn work-fn]} (server-make opts)] (if error {:error error} - (let [[host port] (py->lisp (.-server-address server))] + (let [[host port] (py->lisp (.-server-address server)) + port-file-path (when port-file-path + (try + (.write port-file (str port)) + (.flush port-file) + port-file-path + (catch Exception e + (warn :nrepl-start-error :can-t-write-to-port-file-at nrepl-port-file e) + nil)))] (binding [*out* sys/stdout] (println (format nrepl-server-signature port host host port))) - (when port-file - (try - (.write port-file (str port)) - (.flush port-file) - (catch Exception e - (warn :nrepl-start-error :can-t-write-to-port-file-at nrepl-port-file e)))) + (if async? ;; the server thread has to be a ;; `threading/Thread`, not a `future`, for the @@ -629,11 +643,13 @@ :target #(u/with-eprotect :nrepl-server-async-fut (server-serve! server)))] (.start server-thread) - {:host host - :port port - :server-thread_ server-thread - :shutdown-fn shutdown-fn - :work-fn work-fn}) + (cond-> {:host host + :port port + :server-thread_ server-thread + :shutdown-fn shutdown-fn + :work-fn work-fn} + nrepl-port-file + (assoc :nrepl-port-file port-file-path))) (do (when server* (deliver server* {:host host diff --git a/tests/basilisp_nrepl_async/nrepl_server_test.lpy b/tests/basilisp_nrepl_async/nrepl_server_test.lpy index 40af6b3..44c3aa5 100644 --- a/tests/basilisp_nrepl_async/nrepl_server_test.lpy +++ b/tests/basilisp_nrepl_async/nrepl_server_test.lpy @@ -8,28 +8,33 @@ [basilisp.io :as bio] [basilisp.set :as set] [basilisp.string :as str :refer [starts-with?]] - [basilisp.test :refer [deftest are is testing]]) + [basilisp.test :refer [deftest are is testing use-fixtures]] + [basilisp.test.fixtures :as fixtures :refer [*tempdir*]]) (:import os socket tempfile - time)) + threading + time + urllib)) + +(use-fixtures :each fixtures/tempdir) (def ^:dynamic *nrepl-port* - "The port the :lpy:py:`with-server` is bound to." + "The port the `with-server` is bound to." nil) (def ^:dynamic *nrepl-work-fn* - "The work-fn the :lpy:py:`with-server` and its variance fn are using, + "The work-fn the `with-server` and its variance fn are using, if any." nil) (defmacro with-server-make [opts & body] "Create an nREPL server on a thread with - :lpy:fn:`basilisp-nrepl-async.nrepl-server/server-make` passing in - ``opts``, bind its port to `*nrepl-port*`, run ``body`` on the main - thread, and then shutdown server." + `basilisp-blender.nrepl-server/server-make` passing in ``opts``, + bind its port to `*nrepl-port*`, run ``body`` on the main thread, + and then shutdown server." `(let [{error# :error server# :server shutdown-fn# :shutdown-fn work-fn# :work-fn} (nr/server-make ~opts)] @@ -55,8 +60,8 @@ ``client`` is a map with the following keys: - :backlog* A helper atom for the :lpy:fn:`client-rev!` to keep track - of the arriving responses and any yet incomplete bencoded messages. + :backlog* A helper atom for the `client-rev!` to keep track of the + arriving responses and any yet incomplete bencoded messages. :sock The socket connection to the server." `(with [sock# (socket/socket socket/AF_INET socket/SOCK_STREAM)] @@ -229,17 +234,21 @@ (.sendall sock encoded) (.recv sock 1024))))))) {:keys [error]} (deref client-thread 1000 nil)] - (is (str/starts-with? (u/error->str error) ":server-make-async-test-error TimeoutError('timed out')") + (is (str/starts-with? (u/error->str error) #?(:lpy39- + ":server-make-async-test-error timeout('timed out')" + :lpy310+ + ":server-make-async-test-error TimeoutError('timed out')")) (u/error->str error))) (let [work-fut (future - (u/with-eprotect {:id :serve-make-async-work-fn-test-error - :on-err-str (fn [err] - (throw (Exception err)))} - (dotimes [_ 20] - (work-fn) - (time/sleep 0.2)) - :done)) + (binding [*out* *err*] + (u/with-eprotect {:id :serve-make-async-work-fn-test-error + :on-err-str (fn [err] + (throw (Exception err)))} + (dotimes [it 20] + (work-fn) + (time/sleep 0.2)) + :done))) client-fut (future (u/with-eprotect :server-make-async-test-error (let [[address port] (.-server-address server)] (with [sock (socket/socket socket/AF_INET socket/SOCK_STREAM)] @@ -261,7 +270,7 @@ (bc/decode {:keywordize-keys true :string-fn #(.decode % "utf-8")})))))) :done)))))] - (is (= :done (deref work-fut 5000 :work-time-out))) + (is (= :done (deref work-fut 10000 :work-time-out))) (is (= :done (deref client-fut 1000 :client-time-out)))) (finally (is (nil? (shutdown-fn))) @@ -519,7 +528,9 @@ {:id @id* :ns "xyz" :status ["done"]}) (client-send! client {:id (id-inc!) :op "eval" :code "(/ 3 0)"}) - (is (= {:id @id* :err "ZeroDivisionError('Fraction(3, 0)')"} (client-recv! client))) + (let [{:keys [id err]} (client-recv! client)] + (is (= id @id* )) + (is (str/includes? err "ZeroDivisionError: Fraction(3, 0)"))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "xyz" ns)) @@ -531,8 +542,10 @@ (client-send! client {:id (id-inc!) :op "eval" :code "(println :hey)\n(/ 4 0)"}) (are [response] (= response (client-recv! client)) {:id @id* :out ":hey"} - {:id @id* :out os/linesep} - {:id @id* :err "ZeroDivisionError('Fraction(4, 0)')"}) + {:id @id* :out os/linesep}) + (let [{:keys [id err]} (client-recv! client)] + (is (= @id* id)) + (is (str/includes? err "ZeroDivisionError: Fraction(4, 0)"))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "xyz" ns)) @@ -541,15 +554,23 @@ (are [response] (= response (client-recv! client)) {:id @id* :ns "xyz" :status ["done"]}) - (client-send! client {:id (id-inc!) :op "eval" :code "[*1 *2 *3 *e]"}) + (client-send! client {:id (id-inc!) :op "eval" :code "[*1 *2 *3]"}) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "xyz" :value "[6 nil 4]"} + {:id @id* :ns "xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "eval" :code "*e"}) + (let [{:keys [id value status]} (client-recv! client)] + (is (= @id* id)) + (is (str/includes? value "ZeroDivisionError: Fraction(4, 0)") value)) (are [response] (= response (client-recv! client)) - {:id @id* :ns "xyz" :value "[6 nil 4 ZeroDivisionError('Fraction(4, 0)')]"} {:id @id* :ns "xyz" :status ["done"]}) ;; error with :file (client-send! client {:id (id-inc!) :op "eval" :file "/hey/you.lpy" :code "1\n2\n(/ 5 0)"}) - (are [response] (= response (client-recv! client)) - {:id @id* :err "ZeroDivisionError('Fraction(5, 0)')"}) + (let [{:keys [id err]} (client-recv! client)] + (is (= @id* id)) + (is (str/includes? err "ZeroDivisionError: Fraction(5, 0)"))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "xyz" ns)) @@ -562,7 +583,11 @@ (client-send! client {:id (id-inc!) :op "eval" :code "(xyz"}) (let [{:keys [id err]} (client-recv! client)] (is (= @id* id)) - (is (str/starts-with? err "basilisp.lang.reader.SyntaxError"))) + (is (= ["" + " exception: " + " message: Unexpected EOF in list" + " line: 1:4"] + (str/split-lines err)))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "xyz" ns)) @@ -572,7 +597,15 @@ {:id @id* :ns "xyz" :status ["done"]}) (client-send! client {:id (id-inc!) :op "eval" :code "(+ 3 5)" :ns "not-there"}) - (is (= {:id @id* :err "CompilerException(msg=\"unable to resolve symbol '+' in this context\", phase=, filename='', form=+, lisp_ast=None, py_ast=None)"} (client-recv! client))) + (let [{:keys [id err]} (client-recv! client)] + (is (= id @id*)) + (is (= ["" + " exception: " + " phase: :analyzing" + " message: unable to resolve symbol '+' in this context" + " form: +" + " location: :1"] + (str/split-lines err)))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "not-there" ns)) @@ -596,7 +629,13 @@ ;; bad namespace (client-send! client {:id 3 :op "eval" :code "(+ 3 5)" :ns "#,,"}) - (is (= {:id 3 :err "CompilerException(msg=\"unable to resolve symbol '+' in this context\", phase=, filename='', form=+, lisp_ast=None, py_ast=None)"} (client-recv! client))) + (let [{:keys [id err]} (client-recv! client)] + (is (= 3 id)) + (is (= ["" + " exception: " + " phase: :analyzing" " message: unable to resolve symbol '+' in this context" + " form: +" " location: :1"] + (str/split-lines err)))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= 3 id)) (is (= "#,," ns)) @@ -617,13 +656,17 @@ (let [{:keys [status]} (client-recv! client)] (is (= ["done"] status))) (client-send! client {:id (id-inc!) :op "info" :ns "user" :sym "sort-by"}) - (let [{:keys [file line] :as response} (client-recv! client) - {:keys [doc] - meta-file :file} (meta (resolve 'sort-by))] + (let [{:keys [file line column] :as response} (client-recv! client) + {:keys [doc col] + meta-file :file + meta-line :line} (meta (resolve 'sort-by)) + meta-file-uri (->> (urllib.parse/quote meta-file) + (urllib.parse/urljoin "file:"))] (is (= {:ns "basilisp.core" :status ["done"] :id @id* :arglists-str "[keyfn coll]\n[keyfn cmp coll]" + :line line :column col :doc doc :name "sort-by"} - (select-keys response [:ns :status :id :arglists-str :doc :name]))) - (is (= meta-file file))) + (select-keys response [:ns :status :id :arglists-str :doc :name :line :column]))) + (is (= meta-file-uri file))) ;; test fqdn, aliases and refers (client-send! client {:id (id-inc!) :op "eval" @@ -687,13 +730,25 @@ (with-server-make {} (with-connect client (let [id* (atom 0) - id-inc! #(swap! id* inc)] + id-inc! #(swap! id* inc) + filename (str (bio/path *tempdir* "load-file-test.lpy"))] (client-send! client {:id (id-inc!) :op "clone"}) (let [{:keys [status]} (client-recv! client)] (is (= ["done"] status))) + + (spit filename "(ns abc.xyz (:require [clojure.string :as str]) +(:import [sys :as s])) +(defn afn [] + (str/lower-case \"ABC\")) +(comment + (abc) + (xyz)) + +(afn)") + (client-send! client {:id (id-inc!) :op "load-file" - :ns "user" :file "(ns abc.xyz (:require [clojure.string :as str]) (:import [sys :as s])) (defn afn [] (str/lower-case \"ABC\")) (afn)" - :file-name "xyz.lpy" :file-path "/abc/xyz.lpy"}) + :ns "user" :file (slurp filename) + :file-name "xyz.lpy" :file-path filename}) (are [response] (= response (client-recv! client)) {:id @id* :ns "abc.xyz" :value "\"abc\""} {:id @id* :ns "abc.xyz" :status ["done"]}) @@ -725,9 +780,42 @@ {:id @id* :ns "abc.other" :value "\"abc\""} {:id @id* :ns "abc.other" :status ["done"]}) + (client-send! client {:id (id-inc!) :ns "abc.xyz" :op "eval" :code "(abc)" + :file filename + :line 6 :column 2}) + (let [{:keys [id err]} (client-recv! client)] + (is (= @id* id)) + (is (= ["" + " exception: " + " phase: :analyzing" + " message: unable to resolve symbol 'abc' in this context" + " form: abc" + (str " location: " filename ":6") + " context:" + "" + " 2 | (:import [sys :as s]))" + " 3 | (defn afn []" + " 4 | (str/lower-case \"ABC\"))" + " 5 | (comment" + " 6 > | (abc)" + " 7 | (xyz))" + " 8 | " + " 9 | (afn)"] + (str/split-lines err)))) + (let [{:keys [id ex status ns]} (client-recv! client)] + (is (= @id* id)) + (is (= "abc.xyz" ns)) + (is (= ["eval-error"] status)) + (is (str/starts-with? ex "Traceback (most recent call last):"))) + (are [response] (= response (client-recv! client)) + {:id @id* :ns "abc.xyz" :status ["done"]}) + + (client-send! client {:id (id-inc!) :op "load-file" :ns "user" :file "(ns abc.third)\n\n(/ 3 0)" :file-name "third.lpy" :file-path "/abc/third.lpy"}) - (is (= {:id @id* :err "ZeroDivisionError('Fraction(3, 0)')"} (client-recv! client))) + (let [{:keys [id err]} (client-recv! client)] + (is (= @id* id)) + (is (str/includes? err "ZeroDivisionError: Fraction(3, 0)") (str/split-lines err))) (let [{:keys [id ex status ns]} (client-recv! client)] (is (= @id* id)) (is (= "abc.third" ns)) @@ -735,25 +823,25 @@ (is (not= -1 (.find ex "File \"/abc/third.lpy\", line 3")) ex) (is (str/starts-with? ex "Traceback (most recent call last):"))) (are [response] (= response (client-recv! client)) - {:id @id* :ns "abc.third" :status ["done"]})))) + {:id @id* :ns "abc.third" :status ["done"]}))))) - (testing "no file" - (with-server-make {} - (with-connect client - (client-send! client {:id 1 :op "clone"}) - (let [{:keys [status]} (client-recv! client)] - (is (= ["done"] status))) - (client-send! client {:id 2 :op "load-file" :ns "user"}) - (are [response] (= response (client-recv! client)) - {:id 2 :ns "user" :value "nil"} - {:id 2 :ns "user" :status ["done"]})))))) + (testing "no file" + (with-server-make {} + (with-connect client + (client-send! client {:id 1 :op "clone"}) + (let [{:keys [status]} (client-recv! client)] + (is (= ["done"] status))) + (client-send! client {:id 2 :op "load-file" :ns "user"}) + (are [response] (= response (client-recv! client)) + {:id 2 :ns "user" :value "nil"} + {:id 2 :ns "user" :status ["done"]}))))) (defmacro with-start-server [opts & body] "Create an nREPL server on a thread with - :lpy:fn:`basilisp-nrepl-async.nrepl-server/start-server!` passing in - ``opts``, bind its port to `*nrepl-port*`, run ``body`` on the main - thread, and then shutdown server." + `basilisp-blender.nrepl-server/start-server!` passing in ``opts``, + bind its port to `*nrepl-port*`, run ``body`` on the main thread, + and then shutdown server." `(let [opts# (cond-> ~opts (not (contains? ~opts :server*)) (assoc :server* (promise)))