diff --git a/README.md b/README.md index cf891f2..d9c0b48 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ issue](https://github.com/trptcolin/reply/issues), but the following may help. For keybinding issues, check out `~/.inputrc` - you can mostly use the same specifications there as you can with normal readline applications like bash, but from time to time we do come across missing features that we then add to -[jline](https://github.com/jline/jline2). +[jline](https://github.com/jline/jline3). To get a very detailed look at what jline is doing under the hood, you can `export JLINE_LOGGING=trace` (or `debug`) before starting REPLy. There may be @@ -93,7 +93,7 @@ me know if you are! ## Thanks Thanks to the developers of [Clojure](https://github.com/clojure/clojure), -[JLine](https://github.com/jline/jline2), [nREPL](https://github.com/nrepl/nrepl), +[JLine](https://github.com/jline/jline3), [nREPL](https://github.com/nrepl/nrepl), [incomplete](https://github.com/nrepl/incomplete), for their work on the excellent projects that this project depends upon. diff --git a/project.clj b/project.clj index 6215a10..bf3b355 100644 --- a/project.clj +++ b/project.clj @@ -1,7 +1,7 @@ (defproject reply "0.6.0-SNAPSHOT" :description "REPL-y: A fitter, happier, more productive REPL for Clojure." :dependencies [[org.clojure/clojure "1.8.0"] - [jline "2.14.6"] + [org.jline/jline "3.28.0"] [clj-stacktrace "0.2.8"] [nrepl "1.5.2"] [org.clojure/tools.cli "1.3.250"] @@ -25,7 +25,6 @@ :1.10 {:dependencies [[org.clojure/clojure "1.10.3"]]} :1.11 {:dependencies [[org.clojure/clojure "1.11.4"]]} :1.12 {:dependencies [[org.clojure/clojure "1.12.4"]]}} - ;; :jvm-opts ["-Djline.internal.Log.trace=true"] :aot [reply.reader.jline.JlineInputReader] :main ^{:skip-aot true} reply.main :deploy-repositories [["clojars" {:url "https://clojars.org/repo" diff --git a/spec/reply/reader/jline/completion_spec.clj b/spec/reply/reader/jline/completion_spec.clj deleted file mode 100644 index c39df89..0000000 --- a/spec/reply/reader/jline/completion_spec.clj +++ /dev/null @@ -1,53 +0,0 @@ -(ns reply.reader.jline.completion-spec - (:use [reply.reader.jline.completion] - [speclj.core])) - -;; TODO: don't bother to port these to clojure.test until jline3 is working - -(describe "construct-possible-completions-form" - (it "does correct quoting in completions form request" - (should= '(incomplete.core/completions (str "clojure.core/map-") (symbol "user")) - (construct-possible-completions-form "clojure.core/map-" "user")))) - -(describe "using the completer" - - (with redraw-count (atom 0)) - (with completer (make-completer eval - #(swap! @redraw-count inc) - "clojure.core")) - - (it "populates the list with possible completions" - (let [candidates (java.util.LinkedList.) - word-start (.complete @completer " (map" 5 candidates)] - (should= ["map" "map-indexed" "map?" "mapcat" "mapv"] - candidates) - (should= 1 @@redraw-count) - (should= 2 word-start))) - - (it "gets empty completions when none exist" - (let [candidates (java.util.LinkedList.) - word-start (.complete @completer "foobar" 6 candidates)] - (should= [] candidates) - (should= 0 @@redraw-count) - (should= -1 word-start))) - - (it "handles a single slash" - (let [candidates (java.util.LinkedList.) - word-start (.complete @completer "/" 1 candidates)] - (should= [] candidates) - (should= 0 @@redraw-count) - (should= -1 word-start))) - - (it "handles null buffer" - (let [candidates (java.util.LinkedList.) - word-start (.complete @completer nil 6 candidates)] - (should= [] candidates) - (should= 0 @@redraw-count) - (should= -1 word-start))) - - (it "handles no word at the end" - (let [candidates (java.util.LinkedList.) - word-start (.complete @completer "map " 4 candidates)] - (should= [] candidates) - (should= 0 @@redraw-count) - (should= -1 word-start)))) diff --git a/spec/reply/reader/jline/jline_input_reader_spec.clj b/spec/reply/reader/jline/jline_input_reader_spec.clj deleted file mode 100644 index b3e6afc..0000000 --- a/spec/reply/reader/jline/jline_input_reader_spec.clj +++ /dev/null @@ -1,47 +0,0 @@ -(ns reply.reader.jline.jline-input-reader-spec - (:use [speclj.core]) - (:import [reply.reader.jline JlineInputReader])) - -;; TODO: don't bother to port these to clojure.test until jline3 is working - -(defprotocol LineReader - (readLine [_])) - -(defrecord FakeJlineReader [f] - LineReader - (readLine [this] (f))) - -(defn fake-reader - ([input-fn] (fake-reader input-fn #())) - ([input-fn set-empty-prompt] - (let [jline-reader (FakeJlineReader. input-fn)] - (JlineInputReader. {:jline-reader jline-reader - :set-empty-prompt set-empty-prompt})))) - -(describe "JLineInputReader" - (it "reads a -1 when nothing is available" - (should= -1 (.read (fake-reader (constantly nil))))) - - (it "reads a newline" - (should= 10 (.read (fake-reader (constantly ""))))) - - (it "reads a few characters" - (let [reader (fake-reader (constantly "abcd"))] - (should= [97 98 99 100 10] - (for [i (range 5)] (.read reader))))) - - (it "calls for more input on a newline" - (let [times-readLine-called (atom 0) - reader (fake-reader (constantly "") - #(swap! times-readLine-called inc))] - (should= [10 10 10] - (for [i (range 3)] (.read reader))) - (should= 3 @times-readLine-called))) - - (it "makes no more calls to readLine than are necessary" - (let [times-readLine-called (atom 0) - reader (fake-reader (constantly "ab") - #(swap! times-readLine-called inc))] - (should= [97 98 10 97 98 10 97 98 10 97] - (for [i (range 10)] (.read reader))) - (should= 4 @times-readLine-called)))) diff --git a/src/reply/reader/jline/completion.clj b/src/reply/reader/jline/completion.clj index 8e4c891..995f1e5 100644 --- a/src/reply/reader/jline/completion.clj +++ b/src/reply/reader/jline/completion.clj @@ -1,28 +1,24 @@ (ns reply.reader.jline.completion (:require [reply.completion :as completion] [incomplete.core]) - (:import [jline.console.completer Completer])) + (:import [org.jline.reader Completer Candidate ParsedLine LineReader])) (defn construct-possible-completions-form [prefix ns] `(~'incomplete.core/completions (~'str ~prefix) (~'symbol ~ns))) -(defn get-prefix [buffer cursor] - (let [buffer (or buffer "")] - (or (completion/get-word-ending-at buffer cursor) ""))) +(defn get-prefix [^ParsedLine line] + (let [word (.word line)] + (or word ""))) -(defn make-completer [eval-fn redraw-line-fn ns] +(defn make-completer [eval-fn ns] (proxy [Completer] [] - (complete [^String buffer cursor ^java.util.List candidates] - (let [prefix ^String (get-prefix buffer cursor) + (complete [^LineReader reader ^ParsedLine line ^java.util.List candidates] + (let [prefix ^String (get-prefix line) prefix-length (.length prefix)] - (if (zero? prefix-length) - -1 + (when-not (zero? prefix-length) (let [possible-completions-form (construct-possible-completions-form prefix ns) possible-completions (eval-fn possible-completions-form)] - (if (empty? possible-completions) - -1 - (do - (.addAll candidates (map :candidate possible-completions)) - (redraw-line-fn) - (- cursor prefix-length))))))))) + (when-not (empty? possible-completions) + (doseq [c possible-completions] + (.add candidates (Candidate. (:candidate c))))))))))) diff --git a/src/reply/reader/simple_jline.clj b/src/reply/reader/simple_jline.clj index 4d192d5..bff709d 100644 --- a/src/reply/reader/simple_jline.clj +++ b/src/reply/reader/simple_jline.clj @@ -1,12 +1,19 @@ (ns reply.reader.simple-jline (:require [reply.reader.jline.completion :as jline.completion]) - (:import [java.io File FileInputStream FileDescriptor - PrintStream ByteArrayOutputStream IOException] - [jline.console ConsoleReader ConsoleKeys] - [jline.console.history FileHistory MemoryHistory] - [jline.internal Configuration Log])) + (:import [java.io BufferedReader File FileInputStream FileDescriptor + InputStreamReader IOException OutputStream] + [java.util.logging Logger Level] + [org.jline.reader LineReader LineReader$Option LineReaderBuilder + UserInterruptException EndOfFileException] + [org.jline.reader.impl.history DefaultHistory] + [org.jline.terminal Terminal TerminalBuilder])) (def ^:private current-console-reader (atom nil)) +(def ^:private current-terminal (atom nil)) +(def ^:private current-piped-reader (atom nil)) +(def ^:private current-piped-stream (atom nil)) + +(def ^:private default-history-size-value 500) (defn- make-history-file [^String history-path] (if history-path @@ -16,144 +23,144 @@ (File. "." history-path))) (File. (System/getProperty "user.home") ".jline-reply.history"))) -(defn reset-reader [^ConsoleReader reader] - (when reader - (.clear (.getCursorBuffer reader)))) - (defn shutdown ([] (shutdown {:reader @current-console-reader})) - ([{:keys [^ConsoleReader reader] :as state}] - (when reader - (reset-reader reader) - (.restore (.getTerminal reader)) - (.shutdown reader)) - (reset! current-console-reader nil))) - -(defn null-output-stream [] - (proxy [java.io.OutputStream] [] - (write [& args]))) - -(defn set-jline-output! [] - (when (and (not (Boolean/getBoolean "jline.internal.Log.trace")) - (not (Boolean/getBoolean "jline.internal.Log.debug"))) - (Log/setOutput (PrintStream. ^java.io.OutputStream (null-output-stream))))) + ([{:keys [^LineReader reader] :as state}] + (when-let [^Terminal terminal @current-terminal] + (.close terminal)) + (reset! current-console-reader nil) + (reset! current-terminal nil))) + +(defn- set-jline-log-level! [] + (let [logger (Logger/getLogger "org.jline")] + (.setLevel logger Level/SEVERE))) + +(defn- migrate-history-file + "Rename old jline2-format history file so jline3 can start fresh." + [^File history-file] + (when (.exists history-file) + (try + (let [first-line (with-open [rdr (clojure.java.io/reader history-file)] + (.readLine rdr))] + (when (and first-line + (not (re-find #"^\d+:" first-line))) + (.renameTo history-file (File. (str (.getPath history-file) ".old"))))) + (catch Exception _)))) (defn- initialize-jline [] (.addShutdownHook (Runtime/getRuntime) (Thread. #(when-let [reader @current-console-reader] (shutdown {:reader reader})))) - (when (= "dumb" (System/getenv "TERM")) - (.setProperty (Configuration/getProperties) "jline.terminal" "none")) - (set-jline-output!)) - -(defmulti flush-history type) -(defmethod flush-history FileHistory - [^FileHistory history] - (try (.flush history) + (set-jline-log-level!)) + +(defn flush-history [^DefaultHistory history] + (try (.save history) (catch IOException e))) -(defmethod flush-history MemoryHistory - [history]) (defprotocol InteractiveLineReader (interactive-read-line [this]) (prepare-for-next-read [this])) (extend-protocol InteractiveLineReader - ConsoleReader + LineReader (interactive-read-line [reader] (try (.readLine reader) (catch UnsupportedOperationException e ""))) (prepare-for-next-read [reader] - (flush-history (.getHistory reader)) - (when-let [completer (first (.getCompleters reader))] - (.removeCompleter reader completer)))) - -(defn ^Integer default-history-size - "Get default history size from system settings, or nil if not set. -Set `app-name` for jline's reading of inputrc, or null for default." - [^String app-name] - (try - (some-> (ConsoleKeys. app-name (ConsoleReader/getInputRc)) - (.getVariable "history-size") - (Integer/parseInt)) - (catch IOException ioe - nil) - (catch NumberFormatException nfe - nil))) - -(defn configure-history - "Configure a MemoryHistory or FileHistory's max-size, if size -non-nil. Return nil." - [hist ^Integer size] - (when size - (.setMaxSize hist size))) + (flush-history (.getHistory reader)))) (defn setup-console-reader [{:keys [prompt-string reader input-stream output-stream history-file completer-factory blink-parens] - :or {input-stream (FileInputStream. FileDescriptor/in) - output-stream System/out - prompt-string "=> " + :or {prompt-string "=> " blink-parens true} :as state}] - (let [reader (ConsoleReader. input-stream output-stream) - hist-size (default-history-size nil) ;; currently null appName - file-history (doto (FileHistory. (make-history-file history-file) false) - (configure-history hist-size) - (.load)) - history (try - (flush-history file-history) - file-history - (catch IOException e - (doto (MemoryHistory.) - (configure-history hist-size)))) - completer (if completer-factory - (completer-factory reader) - nil)] - (.setBlinkMatchingParen (.getKeys reader) blink-parens) - (when completer (.addCompleter reader completer)) + (let [tb (TerminalBuilder/builder) + _ (when (= "dumb" (System/getenv "TERM")) + (.dumb tb true)) + terminal (.build tb) + hist-file (make-history-file history-file) + _ (migrate-history-file hist-file) + history-path (.getAbsolutePath hist-file) + completer (when completer-factory + (completer-factory nil)) + builder (-> (LineReaderBuilder/builder) + (.terminal terminal) + (.variable LineReader/HISTORY_FILE (java.nio.file.Paths/get + history-path + (into-array String []))) + (.variable LineReader/HISTORY_SIZE (int default-history-size-value))) + builder (if completer + (.completer builder completer) + builder) + ^LineReader reader (.build builder)] + (.setOpt reader LineReader$Option/DISABLE_EVENT_EXPANSION) (reset! current-console-reader reader) - (doto reader - (.setHistory history) - (.setHandleUserInterrupt true) - (.setExpandEvents false) - (.setPaginationEnabled true) - (.setPrompt prompt-string)))) + (reset! current-terminal terminal) + reader)) (def jline-state (atom {})) +(defn- piped-read-line + "Read a line from a custom input stream, printing the prompt to output-stream. + Used when input is piped (non-interactive), since jline3's DumbTerminal + does not support reading from ByteArrayInputStream." + [{:keys [input-stream output-stream prompt-string] + :or {prompt-string "=> "}}] + (let [^BufferedReader br (if (and @current-piped-reader + (identical? @current-piped-stream input-stream)) + @current-piped-reader + (let [r (BufferedReader. (InputStreamReader. input-stream))] + (reset! current-piped-reader r) + (reset! current-piped-stream input-stream) + r)) + ^OutputStream os (or output-stream System/out)] + (.write os (.getBytes ^String prompt-string)) + (.flush os) + (.readLine br))) + (defn get-input-line [state] (when-not (:reader state) (initialize-jline)) - (shutdown state) - (if (:no-jline state) + (cond + (:no-jline state) (assoc (dissoc state :no-jline) :reader nil :input (read-line)) - (let [reader (setup-console-reader state) - input (try (interactive-read-line reader) - (catch jline.console.UserInterruptException e - :interrupted))] - (prepare-for-next-read reader) - (if (= :interrupted input) - (assoc state - :reader reader - :input "" - :interrupted true) - (assoc state - :reader reader - :input input - :interrupted nil))))) + + (:input-stream state) + (let [input (piped-read-line state)] + (assoc state + :reader nil + :input input + :interrupted nil)) + + :else + (do + (shutdown state) + (let [reader (setup-console-reader state) + prompt (or (:prompt-string state) "=> ") + input (try (.readLine reader prompt) + (catch UserInterruptException e + :interrupted) + (catch EndOfFileException e + nil))] + (prepare-for-next-read reader) + (if (= :interrupted input) + (assoc state + :reader reader + :input "" + :interrupted true) + (assoc state + :reader reader + :input input + :interrupted nil)))))) (defn make-completer [ns eval-fn] - (fn [^ConsoleReader reader] - (let [redraw-line-fn (fn [] - (.redrawLine reader) - (.flush reader))] - (if ns - (jline.completion/make-completer - eval-fn redraw-line-fn ns) - nil)))) + (fn [_reader] + (if ns + (jline.completion/make-completer eval-fn ns) + nil))) (defn safe-read-line ([{:keys [prompt-string completer-factory no-jline input-stream output-stream @@ -177,4 +184,3 @@ non-nil. Return nil." {:keys [ns] :as state}] (safe-read-line (assoc state :completer-factory (make-completer ns completion-eval-fn))))) - diff --git a/test/reply/integration_test.clj b/test/reply/integration_test.clj index bc859f1..9a002c2 100644 --- a/test/reply/integration_test.clj +++ b/test/reply/integration_test.clj @@ -14,16 +14,17 @@ ;; TODO: this is easy but seems like wasted effort ;; probably better to use pomegranate (def nrepl - {:local-path "spec/nrepl-1.5.2.jar" + {:local-path "target/test-jars/nrepl-1.5.2.jar" :remote-url "https://clojars.org/repo/nrepl/nrepl/1.5.2/nrepl-1.5.2.jar"}) (def clojure - {:local-path "spec/clojure-1.8.0.jar" + {:local-path "target/test-jars/clojure-1.8.0.jar" :remote-url "https://repo1.maven.org/maven2/org/clojure/clojure/1.8.0/clojure-1.8.0.jar"}) (defn ensure-test-jar [{:keys [local-path remote-url]}] (let [file (java.io.File. local-path)] (when-not (.exists file) + (.mkdirs (.getParentFile file)) (.createNewFile file) (let [out (java.io.FileOutputStream. file) in (io/input-stream remote-url)] diff --git a/test/reply/reader/jline/completion_test.clj b/test/reply/reader/jline/completion_test.clj new file mode 100644 index 0000000..baaf14c --- /dev/null +++ b/test/reply/reader/jline/completion_test.clj @@ -0,0 +1,50 @@ +(ns reply.reader.jline.completion-test + (:require [reply.reader.jline.completion :as completion] + [clojure.test :as t]) + (:import [org.jline.reader Candidate ParsedLine])) + +(defn- fake-parsed-line [word] + (reify ParsedLine + (word [_] word) + (wordIndex [_] 0) + (wordCursor [_] 0) + (cursor [_] 0) + (line [_] word) + (words [_] []))) + +(defn- candidate-values [^java.util.List candidates] + (mapv #(.value ^Candidate %) candidates)) + +(t/deftest construct-possible-completions-form + (t/testing "does correct quoting in completions form request" + (t/is (= '(incomplete.core/completions (str "clojure.core/map-") (symbol "user")) + (completion/construct-possible-completions-form "clojure.core/map-" "user"))))) + +(t/deftest completer-integration + (let [completer (completion/make-completer eval "clojure.core")] + + (t/testing "populates the list with possible completions" + (let [candidates (java.util.LinkedList.)] + (.complete completer nil (fake-parsed-line "map") candidates) + (t/is (= ["map" "map-entry?" "map-indexed" "map?" "mapcat" "mapv"] + (candidate-values candidates))))) + + (t/testing "gets empty completions when none exist" + (let [candidates (java.util.LinkedList.)] + (.complete completer nil (fake-parsed-line "foobar") candidates) + (t/is (empty? candidates)))) + + (t/testing "handles a single slash" + (let [candidates (java.util.LinkedList.)] + (.complete completer nil (fake-parsed-line "/") candidates) + (t/is (empty? candidates)))) + + (t/testing "handles null buffer" + (let [candidates (java.util.LinkedList.)] + (.complete completer nil (fake-parsed-line "") candidates) + (t/is (empty? candidates)))) + + (t/testing "handles no word at the end" + (let [candidates (java.util.LinkedList.)] + (.complete completer nil (fake-parsed-line "") candidates) + (t/is (empty? candidates)))))) diff --git a/test/reply/reader/jline/jline_input_reader_test.clj b/test/reply/reader/jline/jline_input_reader_test.clj new file mode 100644 index 0000000..be59146 --- /dev/null +++ b/test/reply/reader/jline/jline_input_reader_test.clj @@ -0,0 +1,45 @@ +(ns reply.reader.jline.jline-input-reader-test + (:require [clojure.test :as t]) + (:import [reply.reader.jline JlineInputReader])) + +(defprotocol LineReader + (readLine [_])) + +(defrecord FakeJlineReader [f] + LineReader + (readLine [_] (f))) + +(defn- fake-reader + ([input-fn] (fake-reader input-fn #())) + ([input-fn set-empty-prompt] + (let [jline-reader (FakeJlineReader. input-fn)] + (JlineInputReader. {:jline-reader jline-reader + :set-empty-prompt set-empty-prompt})))) + +(t/deftest jline-input-reader + (t/testing "reads a -1 when nothing is available" + (t/is (= -1 (.read (fake-reader (constantly nil)))))) + + (t/testing "reads a newline" + (t/is (= 10 (.read (fake-reader (constantly "")))))) + + (t/testing "reads a few characters" + (let [reader (fake-reader (constantly "abcd"))] + (t/is (= [97 98 99 100 10] + (for [_ (range 5)] (.read reader)))))) + + (t/testing "calls for more input on a newline" + (let [times-called (atom 0) + reader (fake-reader (constantly "") + #(swap! times-called inc))] + (t/is (= [10 10 10] + (for [_ (range 3)] (.read reader)))) + (t/is (= 3 @times-called)))) + + (t/testing "makes no more calls to readLine than are necessary" + (let [times-called (atom 0) + reader (fake-reader (constantly "ab") + #(swap! times-called inc))] + (t/is (= [97 98 10 97 98 10 97 98 10 97] + (for [_ (range 10)] (.read reader)))) + (t/is (= 4 @times-called)))))