Skip to content

Commit 42b67bd

Browse files
committed
Auto bracket paris
1 parent 577edd1 commit 42b67bd

6 files changed

Lines changed: 149 additions & 74 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Use aliases `:clj-line/client` or `:clj-line/server` for more precise targeting.
5858
| `:editing-mode` | client | `:vi` | Vi keybindings (emacs). |
5959
| `:history` | client | `:project` or `:user` | History scope (`:user`). |
6060
| `:keybindings` | client | qualified symbol | Fn `(f reader)` binding keys. |
61+
| `:bracket-pairs` | client | boolean | Auto close `[({` characters (`false`). |
6162
| `:auto-require` | client | vector of libspecs | Required into each namespace. |
6263
| `:middleware` | server | vector of qualified symbols | nREPL middleware. |
6364
| `:eval-hook` | server | qualified symbol | Wraps eval: `(f eval)` returns the eval fn. |

‎client/src/clj_line/api.clj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
(defn widget
6262
"Reify a zero-arg fn as a JLine widget to use with `bind-key`.
6363
Any return value from the function is ignored."
64-
^Widget [f]
64+
[f]
6565
(reify Widget (apply [_] (f) true)))
6666

6767
(defn bind-key

‎client/src/clj_line/client/main.clj‎

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,90 @@
1010
[clj-line.color :as color])
1111
(:import
1212
[org.jline.terminal Terminal$Signal Terminal$SignalHandler TerminalBuilder]
13-
[org.jline.reader LineReader LineReaderBuilder Reference Widget EndOfFileException UserInterruptException]
13+
[org.jline.reader Buffer LineReader LineReaderBuilder Reference Widget EndOfFileException UserInterruptException]
1414
[org.jline.keymap KeyMap]))
1515

1616
(set! *warn-on-reflection* true)
1717

18+
(defn cursor-at-end?
19+
"True when nothing other than possible whitespace follows the cursor in the buffer."
20+
[^Buffer buf]
21+
(str/blank? (subs (.toString buf) (.cursor buf))))
22+
1823
(defn indent-or-accept
19-
"Returns a JLine widget for the Enter key. A complete buffer gets submitted.
20-
Otherwise it inserts a newline with enough spaces to align
21-
the cursor one column past the last unclosed opener."
22-
^Widget [^LineReader rdr]
24+
"Returns a JLine widget for the Enter key. A complete buffer gets submitted
25+
otherwise write a new indented line. Paired braces always make a complete form
26+
so if active submitting requires the cursor be at the end."
27+
[^LineReader rdr paired?]
2328
(reify Widget
2429
(apply [_]
2530
(let [buf (.getBuffer rdr)
26-
line (.toString buf)]
27-
(if (reader/complete? line)
31+
line (.toString buf)
32+
ready-to-submit? (and (reader/complete? line)
33+
(if paired? (cursor-at-end? buf) true))]
34+
(if ready-to-submit?
2835
(.callWidget rdr "accept-line")
29-
(.write buf (str "\n" (apply str (repeat (reader/indent-column line) \space))))))
36+
(.write buf (str "\n" (apply str (repeat (reader/indent-column line (.cursor buf)) \space))))))
3037
true)))
3138

3239
(defn eof-or-delete
3340
"Returns a JLine widget for ctrl-d that ends input on an empty line.
3441
JLine's default binding leaves no way to exit the repl."
35-
^Widget [^LineReader rdr]
42+
[^LineReader rdr]
3643
(reify Widget
3744
(apply [_]
3845
(if (zero? (.length (.getBuffer rdr)))
3946
(throw (EndOfFileException.))
4047
(.callWidget rdr "delete-char"))
4148
true)))
4249

50+
(defn insert-pair [^LineReader rdr open close]
51+
(reify Widget
52+
(apply [_]
53+
(doto (.getBuffer rdr)
54+
(.write (str open close))
55+
(.move -1))
56+
true)))
57+
58+
(defn skip-or-insert
59+
"Returns a JLine widget for a closing brace that advances the cursor if
60+
the brace typed is the same."
61+
[^LineReader rdr close]
62+
(reify Widget
63+
(apply [_]
64+
(let [buf (.getBuffer rdr)]
65+
(if (= (.currChar buf) (int close))
66+
(.move buf 1)
67+
(.write buf (str close))))
68+
true)))
69+
70+
(defn delete-pair
71+
"Returns a JLine widget for backspace that removes both halves of an
72+
empty pair of braces."
73+
[^LineReader rdr]
74+
(reify Widget
75+
(apply [_]
76+
(let [buf (.getBuffer rdr)
77+
closer (reader/closer-of (char (.prevChar buf)))]
78+
(when (and closer (= (int closer) (.currChar buf)))
79+
(.delete buf))
80+
(.backspace buf))
81+
true)))
82+
83+
(defn bind-pairs
84+
"Bind each brace to its pairing widget in keymap."
85+
[^LineReader rdr ^KeyMap keymap]
86+
(let [widgets (.getWidgets rdr)]
87+
(doseq [[open close] reader/closer-of
88+
:let [open-name (str "insert-pair-" open)
89+
close-name (str "skip-or-insert-" close)]]
90+
(.put widgets open-name (insert-pair rdr open close))
91+
(.put widgets close-name (skip-or-insert rdr close))
92+
(.bind keymap (Reference. open-name) (str open))
93+
(.bind keymap (Reference. close-name) (str close)))
94+
(.put widgets "delete-pair" (delete-pair rdr))
95+
(.bind keymap (Reference. "delete-pair") (KeyMap/del))))
96+
4397
(defn unbind-control-self-inserts
4498
"Unbind control keys that insert a literal control char.
4599
JLine seems to miscount these and it breaks redrawing."
@@ -50,7 +104,7 @@
50104
:when (= self-insert (.getBound keymap k))]
51105
(.unbind keymap k))))
52106

53-
(defn build-reader ^LineReader [^java.io.File history-file editing-mode]
107+
(defn build-reader ^LineReader [{:keys [editing-mode bracket-pairs]} history-file]
54108
(let [terminal (-> (TerminalBuilder/builder)
55109
(.system true)
56110
(.ffm false)
@@ -60,17 +114,18 @@
60114
(.terminal terminal)
61115
(.parser (reader/clojure-parser))
62116
(.highlighter (highlight/clojure-highlighter))
63-
(.variable LineReader/SECONDARY_PROMPT_PATTERN "%N%P > ")
117+
(.variable LineReader/SECONDARY_PROMPT_PATTERN "%P ")
64118
(.variable LineReader/HISTORY_FILE history-file)
65119
.build)
66120
vi? (= :vi editing-mode)
67121
keymaps (.getKeyMaps rdr)]
68-
(.put (.getWidgets rdr) "indent-or-accept" (indent-or-accept rdr))
122+
(.put (.getWidgets rdr) "indent-or-accept" (indent-or-accept rdr bracket-pairs))
69123
(.put (.getWidgets rdr) "eof-or-delete" (eof-or-delete rdr))
70124
(doseq [keymap-name (if vi? [LineReader/VIINS LineReader/VICMD] [LineReader/MAIN])]
71125
(let [^KeyMap keymap (.get keymaps keymap-name)]
72126
(.bind keymap (Reference. "indent-or-accept") "\r")
73-
(.bind keymap (Reference. "eof-or-delete") (KeyMap/ctrl \D))))
127+
(.bind keymap (Reference. "eof-or-delete") (KeyMap/ctrl \D))
128+
(when bracket-pairs (bind-pairs rdr keymap))))
74129
(when vi?
75130
(.put keymaps LineReader/MAIN (.get keymaps LineReader/VIINS)) ;; `main` is each line's starting keymap defaulted to emacs
76131
(unbind-control-self-inserts (.get keymaps LineReader/VIINS)))
@@ -161,7 +216,7 @@
161216
port (Integer/parseInt (str/trim (or (first args) (slurp ".nrepl-port"))))
162217
{:keys [session]} (nrepl/connect port)
163218
libspecs (:auto-require config)
164-
rdr (build-reader (history-file 'org.clojure/clj-line config) (:editing-mode config))]
219+
rdr (build-reader config (history-file 'org.clojure/clj-line config))]
165220
(reset! api/reader rdr)
166221
(reset! api/session session)
167222
(apply-keybindings config rdr)

‎client/src/clj_line/highlight.clj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
(set! *warn-on-reflection* true)
1010

1111
(defn highlight-clj [^String buffer]
12-
(let [bad (reader/unmatched-brackets buffer)
12+
(let [bad (:unmatched (reader/bracket-scan buffer))
1313
^AttributedStyle bad-style (color/build-style {:fg :red :bold true})
1414
asb (AttributedStringBuilder.)]
1515
(dotimes [i (.length buffer)]

‎client/src/clj_line/reader.clj‎

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
(defn complete?
1212
"True unless src needs more input to finish.
1313
Finished but invalid input returns true."
14-
[^String src]
14+
[src]
1515
(try
1616
(let [rdr (rt/indexing-push-back-reader src)]
1717
(loop []
@@ -24,7 +24,7 @@
2424
(defn read-all
2525
"Reads all top level forms from src into a vector. Read errors propagate
2626
to surface invalid input to the repl loop."
27-
[^String src]
27+
[src]
2828
(let [rdr (rt/indexing-push-back-reader src)]
2929
(loop [acc []]
3030
(let [form (r/read {:eof ::eof} rdr)]
@@ -35,7 +35,7 @@
3535
read multiple lines. Every other case, complete input, or any other context
3636
such as tab completion, falls through to DefaultParser."
3737
[]
38-
(let [^DefaultParser default (DefaultParser.)]
38+
(let [default (DefaultParser.)]
3939
(proxy [DefaultParser] []
4040
(parse [line cursor context]
4141
(when (and (= context Parser$ParseContext/ACCEPT_LINE) (not (complete? line)))
@@ -62,34 +62,37 @@
6262
(let [nl (.indexOf src (int \newline) (int start))]
6363
(if (neg? nl) (.length src) nl)))
6464

65-
(defn unmatched-brackets
66-
"Returns the set of positions in src where a bracket ([{}]) has no partner.
67-
Brackets inside strings, char literals, and comments are skipped."
65+
(def closer-of {\( \), \[ \], \{ \}})
66+
67+
(defn bracket-scan
68+
"Returns the open/close index of each matched bracket in :spans, innermost
69+
first, and the index of each bracket with no partner under :unmatched.
70+
Brackets inside strings and comments are not accounted for."
6871
[^String src]
69-
(let [len (.length src)
70-
closer-of {\( \), \[ \], \{ \}}]
71-
(loop [pos 0, stack [], bad #{}]
72+
(let [len (.length src)]
73+
(loop [pos 0, stack [], spans [], bad #{}]
7274
(if (>= pos len)
73-
(into bad (map first) stack)
75+
{:spans spans :unmatched (into bad (map first) stack)}
7476
(let [c (.charAt src pos)]
7577
(cond
76-
(= c \;) (recur (skip-comment src pos) stack bad)
77-
(= c \") (recur (skip-string src pos) stack bad)
78-
(= c \\) (recur (long (min len (+ pos 2))) stack bad)
79-
(#{\( \[ \{} c) (recur (inc pos) (conj stack [pos (closer-of c)]) bad)
80-
(#{\) \] \}} c) (if (= c ^Character (second (peek stack)))
81-
(recur (inc pos) (pop stack) bad)
82-
(recur (inc pos) stack (conj bad pos)))
83-
:else (recur (inc pos) stack bad)))))))
78+
(= c \;) (recur (skip-comment src pos) stack spans bad)
79+
(= c \") (recur (skip-string src pos) stack spans bad)
80+
(= c \\) (recur (min len (+ pos 2)) stack spans bad)
81+
(#{\( \[ \{} c) (recur (inc pos) (conj stack [pos (closer-of c)]) spans bad)
82+
(#{\) \] \}} c) (let [[open closer] (peek stack)]
83+
(if (and closer (= c ^Character closer))
84+
(recur (inc pos) (pop stack) (conj spans [open (inc pos)]) bad)
85+
(recur (inc pos) stack spans (conj bad pos))))
86+
:else (recur (inc pos) stack spans bad)))))))
8487

8588
(defn indent-column
86-
"Number of spaces a continuation line should be indented so the cursor
87-
sits one column past the last unclosed opener. Zero if the buffer
88-
has no unclosed openers."
89-
^long [^String src]
90-
(let [openers (filter #(#{\( \[ \{} (.charAt src ^long %)) (unmatched-brackets src))]
91-
(if (empty? openers)
92-
0
93-
(let [innermost (long (apply max openers))
94-
last-nl (.lastIndexOf src (int \newline) innermost)]
95-
(- innermost last-nl)))))
89+
"Spaces to indent a continuation line"
90+
[^String src cursor]
91+
(let [{:keys [spans unmatched]} (bracket-scan src)
92+
enclosing (filter (fn [[open close]] (< open cursor close)) spans)
93+
openers (filter #(#{\( \[ \{} (.charAt src %)) unmatched)
94+
open (or (ffirst enclosing)
95+
(when (seq openers) (apply max openers)))]
96+
(if open
97+
(- open (.lastIndexOf src (int \newline) (int open)))
98+
0)))

‎client/test/clj_line/reader_test.clj‎

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,44 +43,60 @@
4343
(testing "returns len for an unterminated string"
4444
(is (= 4 (sut/skip-string "\"abc" 0)))))
4545

46-
(deftest unmatched-brackets-test
46+
(deftest bracket-scan-unmatched-test
4747
(testing "balanced inputs have no bad brackets"
48-
(is (= #{} (sut/unmatched-brackets "")))
49-
(is (= #{} (sut/unmatched-brackets "(foo)")))
50-
(is (= #{} (sut/unmatched-brackets "[1 2 3]")))
51-
(is (= #{} (sut/unmatched-brackets "{:a 1}")))
52-
(is (= #{} (sut/unmatched-brackets "(let [x 1] (* x 2))"))))
48+
(is (= #{} (:unmatched (sut/bracket-scan ""))))
49+
(is (= #{} (:unmatched (sut/bracket-scan "(foo)"))))
50+
(is (= #{} (:unmatched (sut/bracket-scan "[1 2 3]"))))
51+
(is (= #{} (:unmatched (sut/bracket-scan "{:a 1}"))))
52+
(is (= #{} (:unmatched (sut/bracket-scan "(let [x 1] (* x 2))")))))
5353
(testing "unclosed openers are flagged at their position"
54-
(is (= #{0} (sut/unmatched-brackets "(")))
55-
(is (= #{0} (sut/unmatched-brackets "(foo")))
56-
(is (= #{0 11} (sut/unmatched-brackets "(let [x 1] (foo"))))
54+
(is (= #{0} (:unmatched (sut/bracket-scan "("))))
55+
(is (= #{0} (:unmatched (sut/bracket-scan "(foo"))))
56+
(is (= #{0 11} (:unmatched (sut/bracket-scan "(let [x 1] (foo")))))
5757
(testing "stray closers are flagged"
58-
(is (= #{0} (sut/unmatched-brackets ")")))
59-
(is (= #{4} (sut/unmatched-brackets "(a) ]"))))
58+
(is (= #{0} (:unmatched (sut/bracket-scan ")"))))
59+
(is (= #{4} (:unmatched (sut/bracket-scan "(a) ]")))))
6060
(testing "mismatched type flags both open and close"
61-
(is (= #{0 2} (sut/unmatched-brackets "(a]"))))
61+
(is (= #{0 2} (:unmatched (sut/bracket-scan "(a]")))))
6262
(testing "brackets inside strings are ignored"
63-
(is (= #{} (sut/unmatched-brackets "\"(\""))))
63+
(is (= #{} (:unmatched (sut/bracket-scan "\"(\"")))))
6464
(testing "char literal consumes its char"
65-
(is (= #{} (sut/unmatched-brackets "\\(")))
66-
(is (= #{} (sut/unmatched-brackets "\\)")))
67-
(is (= #{2} (sut/unmatched-brackets "\\;)"))))
65+
(is (= #{} (:unmatched (sut/bracket-scan "\\("))))
66+
(is (= #{} (:unmatched (sut/bracket-scan "\\)"))))
67+
(is (= #{2} (:unmatched (sut/bracket-scan "\\;)")))))
6868
(testing "brackets after comments are ignored"
69-
(is (= #{} (sut/unmatched-brackets ";("))))
69+
(is (= #{} (:unmatched (sut/bracket-scan ";(")))))
7070
(testing "brackets in regex are ignored"
71-
(is (= #{} (sut/unmatched-brackets "#\"(\"")))))
71+
(is (= #{} (:unmatched (sut/bracket-scan "#\"(\""))))))
72+
73+
(deftest bracket-scan-spans-test
74+
(testing "spans for each matched pair"
75+
(is (= [] (:spans (sut/bracket-scan ""))))
76+
(is (= [[0 5]] (:spans (sut/bracket-scan "(foo)"))))
77+
(is (= [[5 10] [11 18] [0 19]] (:spans (sut/bracket-scan "(let [x 1] (+ x 2))")))))
78+
(testing "unmatched brackets have no span"
79+
(is (= [] (:spans (sut/bracket-scan "(foo"))))
80+
(is (= [] (:spans (sut/bracket-scan "(a]")))))
81+
(testing " strings and comments do not gen create spans"
82+
(is (= [] (:spans (sut/bracket-scan "\"(\""))))
83+
(is (= [] (:spans (sut/bracket-scan ";("))))))
7284

7385
(deftest indent-column-test
74-
(testing "balanced inputs return zero"
75-
(is (= 0 (sut/indent-column "")))
76-
(is (= 0 (sut/indent-column "(foo)")))
77-
(is (= 0 (sut/indent-column "[1 2 3]"))))
78-
(testing "single unclosed opener at start of buffer"
79-
(is (= 1 (sut/indent-column "(foo")))
80-
(is (= 1 (sut/indent-column "[1 2 3"))))
81-
(testing "unclosed opener with leading content"
82-
(is (= 4 (sut/indent-column "abc(def"))))
83-
(testing "nested openers indent to the innermost"
84-
(is (= 12 (sut/indent-column "(let [x 1] (foo"))))
86+
(testing "cursor not in form"
87+
(is (= 0 (sut/indent-column "" 0)))
88+
(is (= 0 (sut/indent-column "(foo)" 5)))
89+
(is (= 0 (sut/indent-column "[1 2 3]" 7))))
90+
(testing "cursor at the end of unclosed line"
91+
(is (= 1 (sut/indent-column "(foo" 4)))
92+
(is (= 1 (sut/indent-column "[1 2 3" 6)))
93+
(is (= 4 (sut/indent-column "abc(def" 7)))
94+
(is (= 12 (sut/indent-column "(let [x 1] (foo" 15))))
95+
(testing "paired mode curosr inside a balanced form"
96+
(is (= 1 (sut/indent-column "(+ 5 6)" 6)))
97+
(is (= 6 (sut/indent-column "(let [y 7])" 9)))
98+
(is (= 1 (sut/indent-column "(let [y 7])" 10))))
99+
(testing "cursor beyond balanced for does not indent"
100+
(is (= 1 (sut/indent-column "(foo (bar)" 10))))
85101
(testing "indent is relative to the current line"
86-
(is (= 2 (sut/indent-column "(let\n (foo")))))
102+
(is (= 2 (sut/indent-column "(let\n (foo" 10)))))

0 commit comments

Comments
 (0)