From 829a42c8432ecf14dac1b85cea659e4c878ef5a1 Mon Sep 17 00:00:00 2001 From: curist Date: Wed, 22 Jan 2025 13:54:24 +0800 Subject: [PATCH 1/3] fix nrepl evaluating multi forms --- src/squint/repl/nrepl_server.cljs | 87 ++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/src/squint/repl/nrepl_server.cljs b/src/squint/repl/nrepl_server.cljs index 3e47a2b6..a1c1bfae 100644 --- a/src/squint/repl/nrepl_server.cljs +++ b/src/squint/repl/nrepl_server.cljs @@ -6,7 +6,8 @@ ["net" :as node-net] [squint.compiler-common :as cc :refer [*cljs-ns*]] [squint.compiler :as compiler] - [squint.repl.nrepl.bencode :refer [decode-all encode]])) + [squint.repl.nrepl.bencode :refer [decode-all encode]] + [edamame.core :as e])) (defn debug [& strs] (.debug js/console (str/join " " strs))) @@ -97,39 +98,63 @@ (def in-progress (atom false)) (def state (atom nil)) -(defn compile [the-val] +(defn read-forms [code] + (let [rdr (e/reader code)] + (loop [forms []] + (let [form (try + (e/parse-next rdr compiler/squint-parse-opts) + (catch :default e + (if (str/includes? (ex-message e) "EOF while reading") + ::eof + (throw e))))] + (if (or (= form ::eof) + (= form :edamame.core/eof)) + forms + (recur (conj forms form))))))) + +(defn compile-form [form] (let [{js-str :javascript cljs-ns :ns - :as new-state} (compiler/compile-string* the-val {:context :return - :elide-exports true - :repl true - :async true} - @state) - _ (reset! state new-state) - js-str (str/replace "(async function () {\n%s\n}) ()" "%s" js-str)] + :as new-state} (compiler/compile-string* + (binding [*print-meta* true] + (pr-str form)) + {:context :return + :elide-exports true + :repl true + :async true} + @state)] + (reset! state new-state) (reset! last-ns cljs-ns) - js-str)) - -(defn do-handle-eval [{:keys [ns code file - _load-file? _line] :as request} send-fn] - (-> - (js/Promise.resolve code) - (.then compile) - (.then (fn [v] - (println "About to eval:") - (println v) - (js/eval v))) - (.then (fn [val] - (send-fn request {"ns" (str @last-ns) - "value" (format-value (:nrepl.middleware.print/print request) - (:nrepl.middleware.print/options request) - val)}))) - (.catch (fn [e] - (js/console.error e) - (handle-error send-fn request e))) - (.finally (fn [] - (send-fn request {"ns" (str @last-ns) - "status" ["done"]}))))) + (str/replace "(async function () {\n%s\n}) ()" "%s" js-str))) + +(defn eval-form [form send-fn request] + (-> (js/Promise.resolve form) + (.then compile-form) + (.then (fn [js-code] + (println "Evaluating:" js-code) + (js/eval js-code))) + (.then (fn [val] + (send-fn request + {"ns" (str @last-ns) + "value" (format-value + (:nrepl.middleware.print/print request) + (:nrepl.middleware.print/options request) + val)}) + val)) + (.catch (fn [e] + (js/console.error e) + (handle-error send-fn request e))))) + +(defn do-handle-eval [{:keys [code] :as request} send-fn] + (let [forms (read-forms code)] + (-> (reduce (fn [promise form] + (.then promise #(eval-form form send-fn request))) + (js/Promise.resolve nil) + forms) + (.finally (fn [] + (send-fn request + {"ns" (str @last-ns) + "status" ["done"]})))))) (defn handle-eval [{:keys [ns] :as request} send-fn] (prn :ns ns) From b69744d7db344b73d5a124f8243e4a5c23f112da Mon Sep 17 00:00:00 2001 From: curist Date: Wed, 22 Jan 2025 12:57:08 +0800 Subject: [PATCH 2/3] Add nrepl test --- shadow-cljs.edn | 7 +- test/squint/compiler_test.cljs | 3 +- test/squint/nrepl_test.cljs | 149 +++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 test/squint/nrepl_test.cljs diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 89952bd5..6b0f8708 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -22,9 +22,12 @@ compileString squint.compiler.node/compile-string-js}} :cljs.pprint {:entries [cljs.pprint] :depends-on #{:compiler}} - :node.nrepl_server {:depends-on #{:compiler.node :cljs.pprint :node} + :node.nrepl_server {:entries [squint.repl.nrepl-server] + :depends-on #{:compiler.node :cljs.pprint :node} :exports {startServer squint.repl.nrepl-server/start-server}} :cli {:depends-on #{:compiler :compiler.node :node} - :init-fn squint.internal.cli/init}} + :init-fn squint.internal.cli/init} + :squint_tests {:entries [squint.nrepl-test] + :depends-on #{:node.nrepl_server :cljs.pprint}}} :build-hooks [(shadow.cljs.build-report/hook {:output-to "report.html"})]}}} diff --git a/test/squint/compiler_test.cljs b/test/squint/compiler_test.cljs index f6149869..cfad01dd 100644 --- a/test/squint/compiler_test.cljs +++ b/test/squint/compiler_test.cljs @@ -6,6 +6,7 @@ [squint.jsx-test] [squint.html-test] [squint.string-test] + [squint.nrepl-test] [squint.test-utils :refer [eq js! jss! jsv!]] ["fs" :as fs] ["child_process" :as process] @@ -2353,4 +2354,4 @@ new Foo();") (is (eq [1 2 3] (jsv! "(def f #(apply vector %&)) (f 1 2 3)")))) (defn init [] - (t/run-tests 'squint.compiler-test 'squint.jsx-test 'squint.string-test 'squint.html-test)) + (t/run-tests 'squint.compiler-test 'squint.jsx-test 'squint.string-test 'squint.html-test 'squint.nrepl-test)) diff --git a/test/squint/nrepl_test.cljs b/test/squint/nrepl_test.cljs new file mode 100644 index 00000000..ac896bc4 --- /dev/null +++ b/test/squint/nrepl_test.cljs @@ -0,0 +1,149 @@ +(ns squint.nrepl-test + (:require [clojure.test :refer [deftest testing is async]] + [squint.repl.nrepl-server :as nrepl] + [squint.repl.nrepl.bencode :as bencode] + ["net" :as net])) + +(defn connect-client [port] + (let [socket (new net/Socket)] + (.connect socket port "localhost") + socket)) + +(defn send-message [socket msg] + (.write socket (bencode/encode msg))) + +(defn read-response [^js socket cb] + (let [responses (atom [])] + (.on socket "data" + (fn [data] + (let [[decoded _rest] (bencode/decode-all data :keywordize-keys true)] + (swap! responses concat decoded) + (when (some #(contains? (set (:status %)) "done") @responses) + (cb @responses))))))) + +(deftest test-multiple-forms + (testing "evaluating multiple forms in one message" + (async done + (let [port 0 ; random available port + server (nrepl/start-server {:port port}) + session-id (str (random-uuid))] + (.on + server "listening" + (fn [] + (let [address (-> server .address) + port (.-port address) + socket (connect-client port)] + + ;; Test case 1: Multiple def forms + (testing "multiple def forms" + (send-message + socket + {:op "eval" + :code "(def x 1)\n(def y 2)\n(+ x y)" + :session session-id + :id "1"}) + (read-response + socket + (fn [responses] + (is (= 3 (count (filter :value responses)))) + (is (= ["#'user/x" "#'user/y" "3"] + (->> responses + (filter :value) + (mapv :value)))) + + ;; Test case 2: Nested forms + (send-message + socket + {:op "eval" + :code "(let [a 10\n b 20]\n (+ a b))" + :session session-id + :id "2"}) + (read-response + socket + (fn [responses] + (is (= "30" (:value (last responses)))) + + ;; Test case 3: Forms with side effects + (send-message + socket + {:op "eval" + :code "(def nums (atom []))\n(swap! nums conj 1)\n(swap! nums conj 2)\n@nums" + :session session-id + :id "3"}) + (read-response + socket + (fn [responses] + (is (= "[1 2]" (:value (last responses)))) + + ;; Cleanup + (.close server) + (done)))))))))))))) + + (deftest test-error-handling + (testing "handling errors in multiple forms" + (async done + (let [port 0 + server (nrepl/start-server {:port port}) + session-id (str (random-uuid))] + (.on server "listening" + (fn [] + (let [address (-> server .address) + port (.-port address) + socket (connect-client port)] + + ;; Test case: Error in middle form + (send-message + socket + {:op "eval" + :code "(def a 1)\n(+ b 2)\n(def c 3)" + :session session-id + :id "1"}) + (read-response + socket + (fn [responses] + (is (= "#'user/a" (:value (first responses)))) + (is (some :ex responses) "Should contain error response") + (is (not (some #(= "#'user/c" (:value %)) responses)) + "Should not evaluate forms after error") + + ;; Cleanup + (.close server) + (done)))))))))) + + (deftest test-state-preservation + (testing "preserving state between multiple form evaluations" + (async done + (let [port 0 + server (nrepl/start-server {:port port}) + session-id (str (random-uuid))] + (.on server "listening" + (fn [] + (let [address (-> server .address) + port (.-port address) + socket (connect-client port)] + + ;; Test case: State preservation + (send-message + socket + {:op "eval" + :code "(def state (atom {}))\n(swap! state assoc :a 1)\n(swap! state assoc :b 2)\n@state" + :session session-id + :id "1"}) + (read-response + socket + (fn [responses] + (is (= "{:a 1, :b 2}" (:value (last responses)))) + + ;; Verify state in new evaluation + (send-message socket + {:op "eval" + :code "(get @state :a)" + :session session-id + :id "2"}) + (read-response socket + (fn [responses] + (is (= "1" (:value (last responses)))) + + ;; Cleanup + (.close server) + (done))))))))))))) From 41ca0a0331492da0d2011e5f886ad4e84e24dad0 Mon Sep 17 00:00:00 2001 From: curist Date: Wed, 22 Jan 2025 14:18:39 +0800 Subject: [PATCH 3/3] pass all the nrepl tests --- test/squint/nrepl_test.cljs | 311 +++++++++++++++++++++--------------- 1 file changed, 179 insertions(+), 132 deletions(-) diff --git a/test/squint/nrepl_test.cljs b/test/squint/nrepl_test.cljs index ac896bc4..7c782dfe 100644 --- a/test/squint/nrepl_test.cljs +++ b/test/squint/nrepl_test.cljs @@ -2,148 +2,195 @@ (:require [clojure.test :refer [deftest testing is async]] [squint.repl.nrepl-server :as nrepl] [squint.repl.nrepl.bencode :as bencode] - ["net" :as net])) + ["net" :as net] + ["fs" :as fs])) + +(defn wait-for-port [interval-ms timeout-ms callback] + (let [start-time (js/Date.now)] + (letfn [(check [] + (if (> (- (js/Date.now) start-time) timeout-ms) + (callback (js/Error. "Timeout waiting for port")) + (let [port-file (try + (str (fs/readFileSync ".nrepl-port")) + (catch :default _e nil))] + (if port-file + (callback nil (js/parseInt port-file)) + (js/setTimeout check interval-ms)))))] + (check)))) + +(defn start-server [] + (js/Promise. + (fn [resolve reject] + ;; Use port 0 to let OS assign a port + (-> (nrepl/start-server {:port 0}) + (.then (fn [_server] + (wait-for-port 100 5000 + (fn [err port] + (if err + (reject err) + (resolve port)))))))))) (defn connect-client [port] - (let [socket (new net/Socket)] - (.connect socket port "localhost") - socket)) + (js/Promise. + (fn [resolve reject] + (let [socket (new net/Socket)] + (.on socket "error" reject) + (.on socket "connect" #(resolve socket)) + (.connect socket #js {:port port + :host "127.0.0.1" + :timeout 1000}))))) -(defn send-message [socket msg] +(defn send-message [^js socket msg] (.write socket (bencode/encode msg))) -(defn read-response [^js socket cb] - (let [responses (atom [])] - (.on socket "data" - (fn [data] - (let [[decoded _rest] (bencode/decode-all data :keywordize-keys true)] - (swap! responses concat decoded) - (when (some #(contains? (set (:status %)) "done") @responses) - (cb @responses))))))) +(defn read-response [^js socket] + (js/Promise. + (fn [resolve reject] + (let [responses (atom [])] + (.on socket "error" reject) + (.on socket "data" + (fn [data] + (let [[decoded _rest] (bencode/decode-all data :keywordize-keys true)] + (swap! responses concat decoded) + (when (some #(contains? (set (:status %)) "done") @responses) + (resolve @responses))))))))) (deftest test-multiple-forms (testing "evaluating multiple forms in one message" (async done - (let [port 0 ; random available port - server (nrepl/start-server {:port port}) - session-id (str (random-uuid))] - (.on - server "listening" - (fn [] - (let [address (-> server .address) - port (.-port address) - socket (connect-client port)] - - ;; Test case 1: Multiple def forms - (testing "multiple def forms" - (send-message - socket - {:op "eval" - :code "(def x 1)\n(def y 2)\n(+ x y)" - :session session-id - :id "1"}) - (read-response - socket - (fn [responses] - (is (= 3 (count (filter :value responses)))) - (is (= ["#'user/x" "#'user/y" "3"] - (->> responses - (filter :value) - (mapv :value)))) - - ;; Test case 2: Nested forms - (send-message - socket - {:op "eval" - :code "(let [a 10\n b 20]\n (+ a b))" - :session session-id - :id "2"}) - (read-response - socket - (fn [responses] - (is (= "30" (:value (last responses)))) - - ;; Test case 3: Forms with side effects - (send-message - socket - {:op "eval" - :code "(def nums (atom []))\n(swap! nums conj 1)\n(swap! nums conj 2)\n@nums" - :session session-id - :id "3"}) - (read-response - socket - (fn [responses] - (is (= "[1 2]" (:value (last responses)))) - - ;; Cleanup - (.close server) - (done)))))))))))))) + (let [session-id (str (random-uuid)) + socket-ref (atom nil)] + (-> (start-server) + (.then (fn [port] + (connect-client port))) + (.then (fn [socket] + (reset! socket-ref socket) + ;; Test case 1: Multiple def forms + (send-message socket + {:op "eval" + :code "(def x 1)\n(def y 2)\n(+ x y)" + :session session-id + :id "1"}) + (read-response socket))) + (.then (fn [responses] + (try + (is (= 3 (count (filter :value responses))) + "Should receive three values") + (is (= ["nil" "nil" "3"] + (->> responses + (filter :value) + (mapv :value))) + "Values should match expected sequence") + (catch :default e + (js/console.error "Assertion error:" e) + (throw e))) - (deftest test-error-handling - (testing "handling errors in multiple forms" - (async done - (let [port 0 - server (nrepl/start-server {:port port}) - session-id (str (random-uuid))] - (.on server "listening" - (fn [] - (let [address (-> server .address) - port (.-port address) - socket (connect-client port)] + ;; Test case 2: Using previous state + (send-message @socket-ref + {:op "eval" + :code "(+ x y 10)" + :session session-id + :id "2"}) + (read-response @socket-ref))) + (.then (fn [responses] + (try + (is (= "13" (:value (last (butlast responses)))) + "Second evaluation should use previous state") + (catch :default e + (js/console.error "Assertion error:" e) + (throw e))))) + (.catch (fn [err] + (js/console.error "Test error:" (.-message err)) + (is false (str "Test failed with error: " (.-message err))))) + (.finally (fn [] + (try + (when @socket-ref + (.end @socket-ref)) + (catch :default e + (js/console.error "Cleanup error:" e))) + (done)))))))) - ;; Test case: Error in middle form - (send-message - socket - {:op "eval" - :code "(def a 1)\n(+ b 2)\n(def c 3)" - :session session-id - :id "1"}) - (read-response - socket - (fn [responses] - (is (= "#'user/a" (:value (first responses)))) - (is (some :ex responses) "Should contain error response") - (is (not (some #(= "#'user/c" (:value %)) responses)) - "Should not evaluate forms after error") - - ;; Cleanup - (.close server) - (done)))))))))) - - (deftest test-state-preservation - (testing "preserving state between multiple form evaluations" - (async done - (let [port 0 - server (nrepl/start-server {:port port}) - session-id (str (random-uuid))] - (.on server "listening" - (fn [] - (let [address (-> server .address) - port (.-port address) - socket (connect-client port)] - - ;; Test case: State preservation - (send-message - socket - {:op "eval" - :code "(def state (atom {}))\n(swap! state assoc :a 1)\n(swap! state assoc :b 2)\n@state" - :session session-id - :id "1"}) - (read-response - socket - (fn [responses] - (is (= "{:a 1, :b 2}" (:value (last responses)))) +(deftest test-error-handling + (testing "handling errors in multiple forms" + (async done + (let [session-id (str (random-uuid)) + socket-ref (atom nil)] + (-> (start-server) + (.then (fn [port] + (connect-client port))) + (.then (fn [socket] + (reset! socket-ref socket) + (send-message socket + {:op "eval" + :code "(def a 1)\n(+ b 2)\n(def c 3)" + :session session-id + :id "1"}) + (read-response socket))) + (.then (fn [responses] + (try + (is (some :ex responses) "Should contain error") + (is (= 2 (count (filter :value responses))) + "Should only evaluate first form") + (catch :default e + (js/console.error "Assertion error:" e) + (throw e))))) + (.catch (fn [err] + (js/console.error "Test error:" (.-message err)) + (is false (str "Test failed with error: " (.-message err))))) + (.finally (fn [] + (try + (when @socket-ref + (.end @socket-ref)) + (catch :default e + (js/console.error "Cleanup error:" e))) + (done)))))))) - ;; Verify state in new evaluation - (send-message socket - {:op "eval" - :code "(get @state :a)" - :session session-id - :id "2"}) - (read-response socket - (fn [responses] - (is (= "1" (:value (last responses)))) +(deftest test-state-preservation + (testing "preserving state between multiple form evaluations" + (async done + (let [session-id (str (random-uuid)) + socket-ref (atom nil)] + (-> (start-server) + (.then (fn [port] + (connect-client port))) + (.then (fn [socket] + (reset! socket-ref socket) + ;; Define state and modify it + (send-message socket + {:op "eval" + :code "(def state (atom {}))\n(swap! state assoc :a 1)\n(swap! state assoc :b 2)\n@state" + :session session-id + :id "1"}) + (read-response socket))) + (.then (fn [responses] + (try + (is (= "#js {:a 1, :b 2}" (:value (last (butlast responses)))) + "State should be properly maintained") + (catch :default e + (js/console.error "Assertion error:" e) + (throw e))) - ;; Cleanup - (.close server) - (done))))))))))))) + ;; Verify state in new evaluation + (send-message @socket-ref + {:op "eval" + :code "(get @state :a)" + :session session-id + :id "2"}) + (read-response @socket-ref))) + (.then (fn [responses] + (try + (is (= "1" (:value (last (butlast responses)))) + "Should be able to access previous state") + (catch :default e + (js/console.error "Assertion error:" e) + (throw e))))) + (.catch (fn [err] + (js/console.error "Test error:" (.-message err)) + (is false (str "Test failed with error: " (.-message err))))) + (.finally (fn [] + (try + (when @socket-ref + (.end @socket-ref)) + (catch :default e + (js/console.error "Cleanup error:" e))) + (done))))))))