diff --git a/.gitignore b/.gitignore index 18814153..2a556140 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ pom.xml.asc .hg/ /bb .clj-kondo/.cache +!java/src/babashka/impl/LockFix.class diff --git a/README.md b/README.md index cacfc838..8a39d9b1 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,32 @@ export BABASHKA_PRELOADS='(load-file "my_awesome_prelude.clj")' Note that `*in*` is not available in preloads. +## Socket REPL + +Start the socket REPL like this: + +``` shellsession +$ bb --socket-repl 1666 +Babashka socket REPL started at localhost:1666 +``` + +Now you can connect with your favorite socket REPL client: + +``` shellsession +$ rlwrap nc 127.0.0.1 1666 +Babashka v0.0.14-SNAPSHOT REPL. +Use :repl/quit or :repl/exit to quit the REPL. +Clojure rocks, Bash reaches. + +bb=> (+ 1 2 3) +6 +bb=> :repl/quit +$ +``` + +A socket REPL client for Emacs is +[inf-clojure](https://github.com/clojure-emacs/inf-clojure). + ## Enabling SSL If you want to be able to use SSL to e.g. run `(slurp @@ -360,4 +386,5 @@ beverage](https://ko-fi.com/borkdude). Copyright © 2019 Michiel Borkent -Distributed under the EPL License, same as Clojure. See LICENSE. +Distributed under the EPL License. This project contains modified Clojure code +which is licensed under the same EPL License. See LICENSE. diff --git a/project.clj b/project.clj index 60e171d2..e51e7753 100644 --- a/project.clj +++ b/project.clj @@ -9,10 +9,8 @@ :url "http://opensource.org/licenses/eclipse-1.0.php"} :source-paths ["src" "sci/src" "sci/inlined"] :resource-paths ["resources" "sci/resources"] - :dependencies [[org.clojure/clojure "1.9.0"]] - :profiles {:clojure-1.9.0 {:dependencies [[org.clojure/clojure "1.9.0"]]} - :clojure-1.10.1 {:dependencies [[org.clojure/clojure "1.10.1"]]} - :test {:dependencies [[clj-commons/conch "0.9.2"]]} + :dependencies [[org.clojure/clojure "1.10.1"]] + :profiles {:test {:dependencies [[clj-commons/conch "0.9.2"]]} :uberjar {:global-vars {*assert* false} :jvm-opts ["-Dclojure.compiler.direct-linking=true" "-Dclojure.spec.skip-macros=true"] diff --git a/script/compile b/script/compile index e6049a12..94cb69fa 100755 --- a/script/compile +++ b/script/compile @@ -23,7 +23,7 @@ BABASHKA_VERSION=$(cat resources/BABASHKA_VERSION) # mkdir -p src/sci # cp -R /tmp/sci/src/* src -lein with-profiles +clojure-1.10.1 do clean, uberjar +lein do clean, uberjar $NATIVE_IMAGE \ -jar target/babashka-$BABASHKA_VERSION-standalone.jar \ -H:Name=bb \ diff --git a/script/test b/script/test index fba04448..e631756a 100755 --- a/script/test +++ b/script/test @@ -3,12 +3,4 @@ set -eo pipefail export BABASHKA_PRELOADS='(defn __bb__foo [] "foo") (defn __bb__bar [] "bar")' -if [ "$BABASHKA_TEST_ENV" = "native" ]; then - lein test -else - echo "Testing with Clojure 1.9.0" - lein with-profiles +clojure-1.9.0 test - - echo "Testing with Clojure 1.10.1" - lein with-profiles +clojure-1.10.1 test -fi +lein test diff --git a/src/babashka/impl/clojure/core/server.clj b/src/babashka/impl/clojure/core/server.clj new file mode 100644 index 00000000..ea6f14c8 --- /dev/null +++ b/src/babashka/impl/clojure/core/server.clj @@ -0,0 +1,100 @@ +;; Modified / stripped version of clojure.core.server for use with babashka on +;; GraalVM. +;; +;; Copyright (c) Rich Hickey. All rights reserved. +;; The use and distribution terms for this software are covered by the +;; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +;; which can be found in the file epl-v10.html at the root of this distribution. +;; By using this software in any fashion, you are agreeing to be bound by +;; the terms of this license. +;; You must not remove this notice, or any other, from this software. + +(ns ^{:doc "Socket server support" + :author "Alex Miller" + :no-doc true} + babashka.impl.clojure.core.server + (:refer-clojure :exclude [locking]) + (:import + [clojure.lang LineNumberingPushbackReader] + [java.net InetAddress Socket ServerSocket SocketException] + [java.io Reader Writer PrintWriter BufferedWriter BufferedReader InputStreamReader OutputStreamWriter])) + +(set! *warn-on-reflection* true) + +(def server (atom nil)) + +(defmacro ^:private thread + [^String name daemon & body] + `(doto (Thread. (fn [] ~@body) ~name) + (.setDaemon ~daemon) + (.start))) + +(defn- accept-connection + "Start accept function, to be invoked on a client thread, given: + conn - client socket + name - server name + client-id - client identifier + in - in stream + out - out stream + err - err stream + accept - accept fn symbol to invoke + args - to pass to accept-fn" + [^Socket conn client-id in out err accept args] + (try + (binding [*in* in + *out* out + *err* err] + (swap! server assoc-in [:sessions client-id] {}) + (apply accept args)) + (catch SocketException _disconnect) + (finally + (swap! server update-in [:sessions] dissoc client-id) + (.close conn)))) + +(defn start-server + "Start a socket server given the specified opts: + :address Host or address, string, defaults to loopback address + :port Port, integer, required + :name Name, required + :accept Namespaced symbol of the accept function to invoke, required + :args Vector of args to pass to accept function + :bind-err Bind *err* to socket out stream?, defaults to true + :server-daemon Is server thread a daemon?, defaults to true + :client-daemon Are client threads daemons?, defaults to true + Returns server socket." + [opts] + (let [{:keys [address port name accept args bind-err server-daemon client-daemon] + :or {bind-err true + server-daemon true + client-daemon true}} opts + address (InetAddress/getByName address) ;; nil returns loopback + socket (ServerSocket. port 0 address)] + (reset! server {:name name, :socket socket, :sessions {}}) + (thread + (str "Clojure Server " name) server-daemon + (try + (loop [client-counter 1] + (when (not (.isClosed socket)) + (try + (let [conn (.accept socket) + in (LineNumberingPushbackReader. (InputStreamReader. (.getInputStream conn))) + out (BufferedWriter. (OutputStreamWriter. (.getOutputStream conn))) + client-id (str client-counter)] + (thread + (str "Clojure Connection " name " " client-id) client-daemon + (accept-connection conn client-id in out (if bind-err out *err*) accept args))) + (catch SocketException _disconnect)) + (recur (inc client-counter)))) + (finally + (reset! server nil)))) + socket)) + +(defn stop-server + "Stop server with name or use the server-name from *session* if none supplied. + Returns true if server stopped successfully, nil if not found, or throws if + there is an error closing the socket." + [] + (when-let [s @server] + (when-let [server-socket ^ServerSocket (get s :socket)] + (.close server-socket))) + (reset! server nil)) diff --git a/src/babashka/impl/clojure/main.clj b/src/babashka/impl/clojure/main.clj new file mode 100644 index 00000000..e48aeb07 --- /dev/null +++ b/src/babashka/impl/clojure/main.clj @@ -0,0 +1,143 @@ +;; Modified / stripped version of clojure.main for use with babashka on +;; GraalVM. +;; +;; Copyright (c) Rich Hickey All rights reserved. The use and +;; distribution terms for this software are covered by the Eclipse Public +;; License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) which can be found +;; in the file epl-v10.html at the root of this distribution. By using this +;; software in any fashion, you are agreeing to be bound by the terms of +;; this license. You must not remove this notice, or any other, from this +;; software. + +;; Originally contributed by Stephen C. Gilardi + +(ns ^{:doc "Top-level main function for Clojure REPL and scripts." + :author "Stephen C. Gilardi and Rich Hickey" + :no-doc true} + babashka.impl.clojure.main + (:refer-clojure :exclude [with-bindings]) + (:import (java.io StringReader) + (clojure.lang LineNumberingPushbackReader LispReader$ReaderException))) + +(defmacro with-bindings + "Executes body in the context of thread-local bindings for several vars + that often need to be set!: *ns* *warn-on-reflection* *math-context* + *print-meta* *print-length* *print-level* *compile-path* + *command-line-args* *1 *2 *3 *e" + [& body] + `(binding [*ns* *ns* + *warn-on-reflection* *warn-on-reflection* + *math-context* *math-context* + *print-meta* *print-meta* + *print-length* *print-length* + *print-level* *print-level* + *print-namespace-maps* true + *data-readers* *data-readers* + *default-data-reader-fn* *default-data-reader-fn* + *command-line-args* *command-line-args* + *unchecked-math* *unchecked-math* + *assert* *assert* + ;; clojure.spec.alpha/*explain-out* clojure.spec.alpha/*explain-out* + *1 nil + *2 nil + *3 nil + *e nil] + ~@body)) + +(defn repl-prompt + "Default :prompt hook for repl" + [] + (print "bb=> " )) + +(defn repl-caught + "Default :caught hook for repl" + [e] + (binding [*out* *err*] + (println (.getMessage ^Exception e)) + (flush))) + +(defn repl + "Generic, reusable, read-eval-print loop. By default, reads from *in*, + writes to *out*, and prints exception summaries to *err*. If you use the + default :read hook, *in* must either be an instance of + LineNumberingPushbackReader or duplicate its behavior of both supporting + .unread and collapsing CR, LF, and CRLF into a single \\newline. Options + are sequential keyword-value pairs. Available options and their defaults: + - :init, function of no arguments, initialization hook called with + bindings for set!-able vars in place. + default: #() + - :need-prompt, function of no arguments, called before each + read-eval-print except the first, the user will be prompted if it + returns true. + default: (if (instance? LineNumberingPushbackReader *in*) + #(.atLineStart *in*) + #(identity true)) + - :prompt, function of no arguments, prompts for more input. + default: repl-prompt + - :flush, function of no arguments, flushes output + default: flush + - :read, function of two arguments, reads from *in*: + - returns its first argument to request a fresh prompt + - depending on need-prompt, this may cause the repl to prompt + before reading again + - returns its second argument to request an exit from the repl + - else returns the next object read from the input stream + default: repl-read + - :eval, function of one argument, returns the evaluation of its + argument + default: eval + - :print, function of one argument, prints its argument to the output + default: prn + - :caught, function of one argument, a throwable, called when + read, eval, or print throws an exception or error + default: repl-caught" + [& options] + (let [{:keys [init need-prompt prompt flush read eval print caught] + :or {need-prompt (if (instance? LineNumberingPushbackReader *in*) + #(.atLineStart ^LineNumberingPushbackReader *in*) + #(identity true)) + prompt repl-prompt + flush flush + print prn + caught repl-caught}} + (apply hash-map options) + request-prompt (Object.) + request-exit (Object.) + read-eval-print + (fn [] + (try + (let [input (try + (read request-prompt request-exit) + (catch LispReader$ReaderException e + (throw (ex-info nil {:clojure.error/phase :read-source} e))))] + (or (#{request-prompt request-exit} input) + (let [value (eval input)] + (set! *3 *2) + (set! *2 *1) + (set! *1 value) + (try + (print value) + (catch Throwable e + (throw (ex-info nil {:clojure.error/phase :print-eval-result} e))))))) + (catch Throwable e + (caught e) + (set! *e e))))] + (with-bindings + (try + (init) + (catch Throwable e + (caught e) + (set! *e e))) + (prompt) + (flush) + (loop [] + (when-not + (try (identical? (read-eval-print) request-exit) + (catch Throwable e + (caught e) + (set! *e e) + nil)) + (when (need-prompt) + (prompt) + (flush)) + (recur)))))) diff --git a/src/babashka/impl/socket_repl.clj b/src/babashka/impl/socket_repl.clj new file mode 100644 index 00000000..d767ea59 --- /dev/null +++ b/src/babashka/impl/socket_repl.clj @@ -0,0 +1,66 @@ +(ns babashka.impl.socket-repl + {:no-doc true} + (:require [babashka.impl.clojure.core.server :as server] + [babashka.impl.clojure.main :as m] + [sci.core :refer [eval-string]] + [sci.impl.parser :as parser] + [sci.impl.toolsreader.v1v3v2.clojure.tools.reader.reader-types :as r] + [clojure.string :as str] + [clojure.java.io :as io])) + +(set! *warn-on-reflection* true) + +(defn repl + "REPL with predefined hooks for attachable socket server." + [sci-opts] + (m/repl + :init #(do (println "Babashka" + (str "v" (str/trim (slurp (io/resource "BABASHKA_VERSION")))) + "REPL.") + (println "Use :repl/quit or :repl/exit to quit the REPL.") + (println "Clojure rocks, Bash reaches.") + (println)) + :read (fn [request-prompt request-exit] + (let [in (r/indexing-push-back-reader (r/push-back-reader *in*)) + p (r/peek-char in)] + (if (= \newline p) + (do (r/read-char in) request-prompt) + (let [v (parser/parse-next {} in)] + (if (or (identical? :repl/quit v) + (identical? :repl/exit v)) + request-exit + v))))) + :eval (fn [expr] + (eval-string (str expr) + (update sci-opts + :bindings + merge {'*1 *1 + '*2 *2 + '*3 *3 + '*e *e}))))) + +(defn start-repl! [host+port sci-opts] + (let [parts (str/split host+port #":") + [host port] (if (= 1 (count parts)) + [nil (Integer. ^String (first parts))] + [(first parts) (Integer. ^String (second parts))]) + host+port (if-not host (str "localhost:" port) + host+port) + socket (server/start-server + {:address host + :port port + :name "bb" + :accept babashka.impl.socket-repl/repl + :args [sci-opts]})] + (println "Babashka socket REPL started at" host+port) + socket)) + +(defn stop-repl! [] + (server/stop-server)) + +(comment + (def sock (start-repl! "0.0.0.0:1666" {:env (atom {})})) + (.accept sock) + @#'server/servers + (stop-repl!) + ) diff --git a/src/babashka/main.clj b/src/babashka/main.clj index cffb60ac..733765f6 100644 --- a/src/babashka/main.clj +++ b/src/babashka/main.clj @@ -7,6 +7,7 @@ [clojure.java.io :as io] [clojure.java.shell :as shell] [clojure.string :as str] + [babashka.impl.socket-repl :as socket-repl] [sci.core :as sci]) (:import [sun.misc Signal] [sun.misc SignalHandler]) @@ -55,6 +56,11 @@ (recur (rest options) (assoc opts-map :file (first options)))) + ("--socket-repl") + (let [options (rest options)] + (recur (rest options) + (assoc opts-map + :socket-repl (first options)))) (if (not (:file opts-map)) (assoc opts-map :expression opt @@ -80,7 +86,7 @@ (defn print-version [] (println (str "babashka v"(str/trim (slurp (io/resource "BABASHKA_VERSION")))))) -(def usage-string "Usage: bb [ --help ] | [ --version ] | [ -i | -I ] [ -o | -O ] [ --stream ] ( expression | -f )") +(def usage-string "Usage: bb [ --help ] | [ --version ] | [ -i | -I ] [ -o | -O ] [ --stream ] ( expression | -f | --socket-repl [host:]port )") (defn print-usage [] (println usage-string)) @@ -101,6 +107,7 @@ -O: write EDN values to stdout. --stream: stream over lines or EDN values from stdin. Combined with -i or -I *in* becomes a single value per iteration. --file or -f: read expressions from file instead of argument wrapped in an implicit do. + --socket-repl: start socket REPL. Specify port (e.g. 1666) or host and port separated by colon (e.g. 127.0.0.1:1666). --time: print execution time before exiting. ")) @@ -178,7 +185,7 @@ (let [t0 (System/currentTimeMillis) {:keys [:version :shell-in :edn-in :shell-out :edn-out :help? :file :command-line-args - :expression :stream? :time?] :as _opts} + :expression :stream? :time? :socket-repl] :as _opts} (parse-opts args) read-next #(if (pipe-signal-received?) ::EOF @@ -205,12 +212,15 @@ [(print-version) 0] help? [(print-help) 0] + socket-repl [(do (socket-repl/start-repl! socket-repl ctx) + @(promise)) 0] :else (try (let [expr (if file (read-file file) expression)] (loop [in (read-next)] (let [ctx (update ctx :bindings assoc (with-meta '*in* - (when-not stream? {:sci/deref! true})) in)] + (when-not stream? + {:sci/deref! true})) in)] (if (identical? ::EOF in) [nil 0] ;; done streaming (let [res [(do (when-not (or expression file) diff --git a/test/babashka/impl/socket_repl_test.clj b/test/babashka/impl/socket_repl_test.clj new file mode 100644 index 00000000..0a43c4ff --- /dev/null +++ b/test/babashka/impl/socket_repl_test.clj @@ -0,0 +1,20 @@ +(ns babashka.impl.socket-repl-test + (:require + [babashka.impl.socket-repl :refer [start-repl! stop-repl!]] + [babashka.test-utils :as tu] + [clojure.java.shell :refer [sh]] + [clojure.string :as str] + [clojure.test :as t :refer [deftest is]])) + +(deftest socket-repl-test + (when tu/jvm? + (start-repl! "0.0.0.0:1666" {:env (atom {})}) + (is (str/includes? (:out (sh "bash" "-c" + "echo \"(+ 1 2 3)\n:repl/exit\" | nc 127.0.0.1 1666")) + "bb=> 6")) + (stop-repl!))) + +;;;; Scratch + +(comment + )