Skip to content

Commit da67cbe

Browse files
committed
REPL v2: multi-line input, history persistence, ns-aware prompt
Three improvements that bring the REPL up to what a daily user expects: 1. Multi-line input. RET now goes through neat-repl-return, which checks the pending input's balance via parse-partial-sexp under neat-repl-input-syntax-table (Emacs Lisp by default). Balanced -> submit; not balanced or in-string -> newline. The user can finish a sexp across as many lines as needed and only the last RET submits. 2. History persistence. neat-repl-history-file defaults to ~/.emacs.d/neat-repl-history; comint-input-ring-file-name and comint-input-ring-size are wired in on mode setup, and the kill-buffer hook now writes the ring out before the buffer dies. 3. Namespace-aware prompt. neat-repl-prompt-format (default '%s> ') plus a buffer-local neat-repl--current-ns. Every response carrying an 'ns' field bumps the slot, and the next prompt picks it up. So 'user> (in-ns 'myapp.core)' yields a 'myapp.core> ' prompt next. neat-repl-default-ns is what shows up before the server has had a chance to tell us anything. Replaces the old neat-repl-prompt defcustom -- no released versions to keep compatible with.
1 parent 7a27fea commit da67cbe

4 files changed

Lines changed: 175 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2020
- `.nrepl-port` discovery: `M-x neat` defaults the port to whatever the nearest port file contains, so in a project with a running server `M-x neat RET RET` is enough. Customize via `neat-port-file-name`; library entry points are `neat-discover-port` and `neat-discover-port-file`.
2121
- Multi-connection support: `neat-connections` tracks every live `neat-connection`; `neat-set-default-connection` is an interactive picker that switches which one source buffers (running `neat-mode`) talk to. Connections drop out of the registry automatically on disconnect or server death, and the default demotes to the next-most-recent live connection if it goes away.
2222
- Integration test suite now parameterised over nREPL implementations: `neat-it--server-impls` describes each, and any one whose executable is on PATH gets its own `describe` block. Ships with entries for Clojure and Babashka.
23+
- REPL: multi-line input. `RET` only submits when the form parses as balanced; otherwise it inserts a newline. Balance check uses `neat-repl-input-syntax-table` (Emacs Lisp by default; override for languages with very different bracketing rules).
24+
- REPL: input history persistence. New `neat-repl-history-file` defaults to `~/.emacs.d/neat-repl-history`; history is loaded on REPL start and saved on buffer kill. Set to nil to disable.
25+
- REPL: namespace-aware prompt. The prompt is now derived from `neat-repl-prompt-format` (default `"%s> "`) and updates in response to the server's `ns` field, so `user> ` becomes `myapp.core> ` after `(in-ns 'myapp.core)`. `neat-repl-default-ns` controls what appears before the server has reported one.
26+
27+
### Removed
28+
29+
- `neat-repl-prompt` (defcustom). Replaced by `neat-repl-prompt-format` and `neat-repl-default-ns`. There were no released versions, so this is just churn within the unreleased changelog window.

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ M-x neat RET localhost RET 7888 RET
5151
```
5252

5353
A `*neat: localhost:7888*` buffer pops up with a prompt. Type an
54-
expression, hit `RET`, see the result.
54+
expression, hit `RET`, see the result. Multi-line forms work too --
55+
`RET` only submits when the input parses as balanced; otherwise it
56+
inserts a newline so you can finish the form. Input history is
57+
persisted between sessions in `neat-repl-history-file` and the prompt
58+
follows the server's reported namespace (`user> `, `myapp.core> `, ...).
5559

5660
To evaluate from a source buffer:
5761

neat-repl.el

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@
2424
(require 'neat-bencode)
2525
(require 'neat-client)
2626

27-
(defcustom neat-repl-prompt "neat> "
28-
"Prompt string displayed in the REPL buffer."
27+
(defcustom neat-repl-prompt-format "%s> "
28+
"Format string used to build the REPL prompt.
29+
The single %s is replaced with the current namespace (see
30+
`neat-repl--current-ns'), or `neat-repl-default-ns' before the
31+
server has reported one."
32+
:type 'string
33+
:group 'neat)
34+
35+
(defcustom neat-repl-default-ns "neat"
36+
"Namespace shown in the REPL prompt before the server reports one."
2937
:type 'string
3038
:group 'neat)
3139

@@ -39,6 +47,24 @@
3947
:type 'integer
4048
:group 'neat)
4149

50+
(defcustom neat-repl-history-file
51+
(expand-file-name "neat-repl-history" user-emacs-directory)
52+
"File where REPL input history is persisted between sessions.
53+
Set to nil to disable persistence."
54+
:type '(choice file (const :tag "Disabled" nil))
55+
:group 'neat)
56+
57+
(defcustom neat-repl-history-size 1000
58+
"Maximum number of input entries to keep in the REPL history ring."
59+
:type 'integer
60+
:group 'neat)
61+
62+
(defvar neat-repl-input-syntax-table emacs-lisp-mode-syntax-table
63+
"Syntax table used when checking REPL input balance before submit.
64+
Defaults to Emacs Lisp syntax, which is close enough for the Clojure
65+
family. Set to a different syntax table if you're talking to a server
66+
in a language with very different bracketing rules.")
67+
4268
(defface neat-repl-output
4369
'((t :inherit shadow))
4470
"Face for `out' (stdout) chunks streamed back from the server."
@@ -57,21 +83,31 @@
5783
(defvar-local neat-repl-connection nil
5884
"The `neat-connection' associated with this REPL buffer.")
5985

86+
(defvar-local neat-repl--current-ns nil
87+
"Most-recent namespace reported by the server for this buffer.")
88+
6089
(defvar neat-repl-mode-map
6190
(let ((map (make-sparse-keymap)))
6291
(set-keymap-parent map comint-mode-map)
92+
(define-key map (kbd "RET") #'neat-repl-return)
6393
(define-key map (kbd "C-c C-c") #'neat-repl-interrupt)
6494
(define-key map (kbd "C-c C-q") #'neat-repl-quit)
6595
map)
6696
"Keymap for `neat-repl-mode'.")
6797

6898
(define-derived-mode neat-repl-mode comint-mode "neat-repl"
6999
"Major mode for an nREPL REPL buffer."
70-
(setq-local comint-prompt-regexp (concat "^" (regexp-quote neat-repl-prompt)))
100+
;; A permissive prompt regex so the prompt format can vary with the
101+
;; current namespace. Matches `<anything-but-newline>> ' at line start.
102+
(setq-local comint-prompt-regexp "^[^\n]*?> ")
71103
(setq-local comint-prompt-read-only t)
72104
(setq-local comint-input-sender #'neat-repl--input-sender)
73105
(setq-local comint-use-prompt-regexp nil)
74106
(setq-local comint-scroll-show-maximum-output t)
107+
(when neat-repl-history-file
108+
(setq-local comint-input-ring-file-name neat-repl-history-file)
109+
(setq-local comint-input-ring-size neat-repl-history-size)
110+
(ignore-errors (comint-read-input-ring t)))
75111
(add-hook 'kill-buffer-hook #'neat-repl--kill-buffer-cleanup nil t))
76112

77113
(defun neat-repl-buffer-name (conn)
@@ -105,11 +141,41 @@ purpose is to satisfy `comint-output-filter' and friends."
105141
:coding 'utf-8)))
106142
(set-marker (process-mark proc) (point-max)))))
107143

144+
(defun neat-repl--prompt ()
145+
"Compute the prompt string for the current buffer."
146+
(format neat-repl-prompt-format
147+
(or neat-repl--current-ns neat-repl-default-ns)))
148+
108149
(defun neat-repl--insert-prompt ()
109150
"Insert a fresh prompt at the end of the buffer."
110151
(let ((proc (get-buffer-process (current-buffer))))
111152
(when proc
112-
(comint-output-filter proc neat-repl-prompt))))
153+
(comint-output-filter proc (neat-repl--prompt)))))
154+
155+
(defun neat-repl--input-complete-p (input)
156+
"Return non-nil if INPUT is a balanced, complete form.
157+
158+
Empty input counts as complete. Otherwise the string is parsed under
159+
`neat-repl-input-syntax-table' and we require zero open parens, no
160+
in-string state, and no in-comment state at end of input."
161+
(or (string-empty-p (string-trim input))
162+
(with-temp-buffer
163+
(set-syntax-table neat-repl-input-syntax-table)
164+
(insert input)
165+
(let ((state (parse-partial-sexp (point-min) (point-max))))
166+
(and (zerop (car state)) ; depth in parens
167+
(null (nth 3 state)) ; inside a string
168+
(null (nth 4 state)))))))
169+
170+
(defun neat-repl-return ()
171+
"Submit the pending REPL input when it is balanced.
172+
Otherwise insert a newline so the user can keep typing the form."
173+
(interactive)
174+
(let* ((start (comint-line-beginning-position))
175+
(input (buffer-substring-no-properties start (point-max))))
176+
(if (neat-repl--input-complete-p input)
177+
(comint-send-input)
178+
(newline))))
113179

114180
(defun neat-repl--input-sender (_proc input)
115181
"Eval INPUT on the current REPL buffer's connection."
@@ -136,7 +202,12 @@ purpose is to satisfy `comint-output-filter' and friends."
136202
(out (neat-bencode-get resp "out"))
137203
(err (neat-bencode-get resp "err"))
138204
(ex (neat-bencode-get resp "ex"))
205+
(ns (neat-bencode-get resp "ns"))
139206
(status (neat-bencode-get resp "status")))
207+
;; Track the namespace as soon as we see one so the next prompt
208+
;; reflects any `(in-ns ...)' or namespace-switching form.
209+
(when ns
210+
(setq neat-repl--current-ns ns))
140211
(when proc
141212
(when out
142213
(comint-output-filter
@@ -151,7 +222,7 @@ purpose is to satisfy `comint-output-filter' and friends."
151222
(comint-output-filter
152223
proc (propertize (format "%s\n" ex) 'face 'neat-repl-error)))
153224
(when (member "done" status)
154-
(comint-output-filter proc neat-repl-prompt)))))
225+
(comint-output-filter proc (neat-repl--prompt))))))
155226

156227
(defun neat-repl-interrupt ()
157228
"Send an `interrupt' op to the REPL's connection."
@@ -172,7 +243,9 @@ purpose is to satisfy `comint-output-filter' and friends."
172243
(bury-buffer))
173244

174245
(defun neat-repl--kill-buffer-cleanup ()
175-
"Tear down the connection and pipe process when the REPL buffer dies."
246+
"Tear down the connection, persist history, and stop the pipe process."
247+
(when comint-input-ring-file-name
248+
(ignore-errors (comint-write-input-ring)))
176249
(when (and neat-repl-connection
177250
(neat-connection-live-p neat-repl-connection))
178251
(ignore-errors (neat-disconnect neat-repl-connection)))

test/neat-repl-test.el

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
;;; neat-repl-test.el --- Tests for the REPL buffer helpers -*- lexical-binding: t; -*-
2+
3+
;;; Commentary:
4+
5+
;; Unit tests for the pure helpers in `neat-repl' -- balance-aware
6+
;; input checks, prompt formatting, and namespace tracking via the
7+
;; rendered response. The full comint UI is not driven here; that's
8+
;; out of scope for the fast suite.
9+
10+
;;; Code:
11+
12+
(require 'buttercup)
13+
(require 'neat-bencode)
14+
(require 'neat-repl)
15+
16+
(describe "neat-repl--input-complete-p"
17+
(it "accepts an empty string as complete"
18+
(expect (neat-repl--input-complete-p "") :to-be-truthy))
19+
20+
(it "accepts whitespace-only input as complete"
21+
(expect (neat-repl--input-complete-p " \n ") :to-be-truthy))
22+
23+
(it "accepts a balanced form as complete"
24+
(expect (neat-repl--input-complete-p "(+ 1 2)") :to-be-truthy))
25+
26+
(it "accepts a balanced multi-line form as complete"
27+
(expect (neat-repl--input-complete-p "(let [x 1\n y 2]\n (+ x y))")
28+
:to-be-truthy))
29+
30+
(it "rejects an unclosed open paren"
31+
(expect (neat-repl--input-complete-p "(+ 1 2") :to-be nil))
32+
33+
(it "rejects mismatched bracket types as unbalanced"
34+
;; Emacs Lisp syntax doesn't recognise [ ] as paren-like, so this
35+
;; spec keeps to plain (), which is the common Clojure/Lisp case.
36+
(expect (neat-repl--input-complete-p "(foo (bar 1)") :to-be nil))
37+
38+
(it "rejects input that ends inside a string"
39+
(expect (neat-repl--input-complete-p "(println \"hello") :to-be nil))
40+
41+
(it "accepts input with a closed string"
42+
(expect (neat-repl--input-complete-p "(println \"hi\")") :to-be-truthy)))
43+
44+
(describe "neat-repl--prompt"
45+
(it "uses `neat-repl-default-ns' when no namespace is known"
46+
(let ((neat-repl-prompt-format "%s> ")
47+
(neat-repl-default-ns "neat")
48+
(neat-repl--current-ns nil))
49+
(expect (neat-repl--prompt) :to-equal "neat> ")))
50+
51+
(it "uses the tracked namespace when one is set"
52+
(let ((neat-repl-prompt-format "%s> ")
53+
(neat-repl-default-ns "neat")
54+
(neat-repl--current-ns "myapp.core"))
55+
(expect (neat-repl--prompt) :to-equal "myapp.core> ")))
56+
57+
(it "honours a custom prompt format"
58+
(let ((neat-repl-prompt-format "[%s] => ")
59+
(neat-repl-default-ns "neat")
60+
(neat-repl--current-ns nil))
61+
(expect (neat-repl--prompt) :to-equal "[neat] => "))))
62+
63+
(describe "neat-repl--render-response (namespace tracking)"
64+
(it "updates `neat-repl--current-ns' when the server reports `ns'"
65+
(with-temp-buffer
66+
(setq-local neat-repl--current-ns nil)
67+
;; Render a response with an `ns' field. We don't have a comint
68+
;; process attached, so the user-visible writes are no-ops, but
69+
;; the buffer-local ns slot should still get updated.
70+
(neat-repl--render-response
71+
'(("id" . "1")
72+
("ns" . "myapp.core")
73+
("value" . "nil")
74+
("status" "done")))
75+
(expect neat-repl--current-ns :to-equal "myapp.core")))
76+
77+
(it "leaves `neat-repl--current-ns' alone when the response has no `ns'"
78+
(with-temp-buffer
79+
(setq-local neat-repl--current-ns "stays")
80+
(neat-repl--render-response
81+
'(("id" . "1") ("value" . "nil") ("status" "done")))
82+
(expect neat-repl--current-ns :to-equal "stays"))))
83+
84+
;;; neat-repl-test.el ends here

0 commit comments

Comments
 (0)