I think I'll take my REPL neat
My parens black and my bed at three
CIDER's too sweet for me...-- Bozier
A small, language-agnostic nREPL client for Emacs.
Where other nREPL clients (e.g. monroe and CIDER) target Clojure specifically,
neat aims to be the purest language-agnostic nREPL client in the Emacs world:
no Clojure-flavored helpers, no hardcoded ops, no assumptions about your
project's build tool. It's useful directly (a REPL buffer plus a source-buffer
minor mode) and also as a library that other Emacs packages can build on.
This is an early, experimental project. Expect rough edges.
0.1.0 is the first tagged release. The codec, async dispatch, comint
REPL, and source-buffer minor mode are all in place; eldoc, CAPF,
xref find-definition, doc-lookup, namespace-aware eval, and stdin
handling work against any conformant server. See
CHANGELOG.md for the full inventory. Expect rough
edges - this is the start of the road, not the end.
neat is part of a broader push to make nREPL a healthy multi-language
ecosystem rather than a Clojure-only protocol. That effort has three
strands:
- An official nREPL specification. Today the nREPL project
is the de facto spec; a formal version is being drafted at
nrepl/spec.nrepl.org.
neataims to keep pressure on the spec to stay genuinely language-agnostic by being a client that refuses to silently assume Clojure. - Reference clients. A spec without independent client
implementations is wishful thinking.
neatis one such reference client, intentionally built on Emacs builtins and free of Clojure-specific helpers, so it can act as a baseline for what "compliant" should mean on the client side. - A compatibility test suite. The parameterised integration suite
under
test/neat-integration-test.elalready runs the same assertions against multiple servers (Clojure, Babashka, Basilisp), and divergences between them get surfaced as real findings rather than mysterious bugs. The long-term goal is to grow this into a portable suite any nREPL server can self-check against.
neat isn't on MELPA yet. In the meantime the snippets below track
the main branch; to pin to a tagged release, swap :branch "main"
for :rev "v0.1.0" (with package-vc-install) or :branch "v0.1.0"
(with straight.el). Latest tag: v0.1.0.
Easiest path - package-vc-install on Emacs 29+:
;; latest from main
(package-vc-install
'(neat :url "https://github.com/nrepl/neat" :branch "main"))
;; pinned to a release
(package-vc-install
'(neat :url "https://github.com/nrepl/neat" :rev "v0.1.0"))On Emacs 30+ with use-package:
;; latest from main
(use-package neat
:vc (:url "https://github.com/nrepl/neat" :branch "main")
:commands (neat neat-mode))
;; pinned to a release
(use-package neat
:vc (:url "https://github.com/nrepl/neat" :rev "v0.1.0")
:commands (neat neat-mode))With straight.el:
;; latest from main
(straight-use-package
'(neat :type git :host github :repo "nrepl/neat"))
;; pinned to a release
(straight-use-package
'(neat :type git :host github :repo "nrepl/neat" :branch "v0.1.0"))Or combined with use-package:
(use-package neat
:straight (neat :type git :host github :repo "nrepl/neat")
:commands (neat neat-mode))For a manual checkout (e.g. while contributing):
(add-to-list 'load-path "/path/to/neat")
(require 'neat)neat-mode is a minor mode you turn on per source buffer; hook it onto
whichever languages you actually drive (clojure-mode, fennel-mode,
hy-mode, ...). The mode itself doesn't assume any specific language.
neat is a few small files instead of one big one, so other packages can
pick and choose:
neat-bencode.el- bencode encode/decode, no other dependencies.neat-client.el- connection management, request dispatch, nREPL ops.neat-repl.el- comint-derived REPL buffer.neat.el- entry point, customization group,neat-modeminor mode for source buffers.
Library users typically only need neat-bencode and neat-client.
Start an nREPL server. Anything that speaks the protocol will do; for a Clojure server the easy options are:
bb nrepl-server :port 7888
# or
lein repl :headless :port 7888
# or
clj -M:nrepl
Then in Emacs:
M-x neat RET localhost RET 7888 RET
A *neat: localhost:7888* buffer pops up with a prompt. Type an
expression, hit RET, see the result. Multi-line forms work too:
RET only submits when the input parses as balanced; otherwise it
inserts a newline so you can finish the form. Input history is
persisted between sessions in neat-repl-history-file and the prompt
follows the server's reported namespace (user> , myapp.core> , ...).
To evaluate from a source buffer:
M-x neat-mode
Bindings:
| Key | Command |
|---|---|
C-c C-e |
neat-eval-last-sexp |
C-c C-c |
neat-eval-defun |
C-c C-r |
neat-eval-region |
C-c C-b |
neat-eval-buffer |
C-c C-l |
neat-load-buffer-file |
C-c C-z |
neat-switch-to-repl |
C-c C-k |
neat-interrupt-eval |
C-c M-n |
neat-set-ns |
C-c C-d C-d |
neat-show-doc-at-point |
M-. |
xref-find-definitions |
M-, |
xref-go-back |
neat-eval-buffer ships the buffer contents as an eval op (each form
evaluated in turn, every value streamed back). neat-load-buffer-file
sends a load-file op instead, carrying the buffer's path and filename
so the server can attribute file and line numbers to errors. Use it
when you're loading an actual file from disk and care about good
diagnostics; use neat-eval-buffer when you're scratching around in
a buffer that may not even be on disk.
M-. is plain xref-find-definitions. neat-mode registers an xref
backend that asks the server's lookup op where the symbol at point
is defined and jumps there. M-, pops back through the standard
xref stack. Sources behind URLs we can't resolve locally (jar:...,
http:..., ...) yield no result; we don't try to extract files from
jars.
neat-set-ns (C-c M-n) sets the buffer-local namespace neat-ns,
which is sent as the ns field on every eval op from this buffer.
Set it explicitly per buffer, in a major-mode hook, or via
.dir-locals.el. For languages where the namespace is declared in
the source (Clojure's (ns foo.bar), etc.), swap in a parser via
neat-buffer-ns-function -- the default just returns neat-ns.
The CAPF, eldoc backend, and xref backend all rely on the standard
completions and lookup nREPL ops. If your server doesn't implement
them, our sync helpers return nil and we defer silently - that's by
design, a language-agnostic client can't assume anything.
Diagnosis: turn on the message log (next-to-last entry below) and
look for unknown-op in the status.
Three usual suspects:
- The eval is reading from stdin. Look at the minibuffer for a
stdin:prompt and answer it.C-ginterrupts the read. - The connection died. The mode-line shows
[closed]and a;; connection closedline appears in the REPL buffer. - The eval is actually running, just slowly.
C-c C-cin the REPL (orC-c C-kin a source buffer) sends aninterruptop.
By default neat sends no ns on eval, so the server uses whatever
its current namespace is. To pin a source buffer:
C-c M-n(neat-set-ns) interactively.((eval . (setq neat-ns "myapp.core")))in.dir-locals.elfor a project-wide default.- Or swap
neat-buffer-ns-functionfor one that derives the ns from the buffer (parsing a(ns ...)form, reading file metadata, etc.).
The port comes from the nearest .nrepl-port file walking up from
the current buffer. Common reasons it doesn't fire:
- Your server writes a different filename: set
neat-port-file-name. - The file isn't on the path from the buffer to the project root.
- You're in a buffer not visiting a file:
default-directoryis used, which may not be where you expect.
M-: (neat-discover-port-file) RET shows what neat is finding.
The lookup op may have returned a URL we can't resolve locally -
jar: paths (Clojure stdlib source inside jars), http: URLs
(remote sources), and similar are skipped by design. Or the server
simply doesn't know where the symbol lives. The message log shows
the actual response.
M-x neat-toggle-message-log flips a global mirror. Both directions
land pretty-printed in *neat-messages*, prefixed with -->
(request), <-- (response), or !!! (internal note, e.g. a dropped
malformed message). The buffer is in neat-message-log-mode - q
buries, c clears. Windows scrolled to the end auto-follow new
entries; scroll up and the log stops chasing you.
Two more knobs worth knowing:
neat-show-message-logpops the buffer without toggling.neat-clear-message-logwipes it.neat-message-log-max-message-length(default 2000) truncates each message to keep huge values from making the buffer unusable.neat-message-log-max-buffer-lines(default 5000) trims the oldest entries on the fly so long sessions don't grow without bound.
Customize the buffer name via neat-message-log-buffer-name;
permanently enable logging with (setq neat-log-messages t).
A few Emacs builtins that pair well with neat:
M-x toggle-debug-on-quit. With it on, hittingC-gduring a hang drops into the debugger at exactly the stuck point - very useful when an eval, lookup, or completion call is wedged.M-x toggle-debug-on-error. Surfaces signaled errors instead of routing them to*Messages*. neat's callback dispatch usescondition-case-unless-debug, so turning this on means buggy callbacks land in the debugger rather than being swallowed by a one-linemessage. See the Emacs Lisp Debugger manual.M-x profiler-start, exercise the slow thing, thenM-x profiler-report. Helpful when CAPF feels laggy or an eldoc lookup blocks longer than expected. See the Profiling manual.
For the rationale behind the architecture (the module split, the
async dispatch model, the comint pipe-process trick, why we target
Emacs 28+, and so on) see doc/design.md.
The project uses Eldev and Buttercup.
eldev compile # byte-compile
eldev lint # lint
eldev test # run Buttercup suites
The default suite is the fast one. There's also an integration suite that boots real nREPL servers as subprocesses and exercises the full client. It's gated behind an env var since starting a server adds a few seconds:
NEAT_INTEGRATION=1 eldev test
The integration suite walks neat-it--server-impls in
test/neat-integration-test.el and registers a block per implementation
that's installed on PATH. Currently:
| Implementation | Executable | How to install |
|---|---|---|
Clojure (nrepl/nrepl) |
clojure |
clojure.org/guides/install_clojure |
| Babashka | bb |
brew install borkdude/brew/babashka |
| Basilisp (Python) | basilisp |
pipx install basilisp |
| let-go (Go) | let-go |
go install github.com/nooga/let-go@latest |
Add more entries to the list to teach the suite about your favorite
nREPL implementation. Most servers just need an executable that prints
a port banner on stdout; for ones that can't or won't announce an
OS-assigned port (let-go, currently), the entry can supply a
:port-fn that pre-allocates a free port for the framework to pass
in.
Distributed under the GNU General Public License, version 3 or later. See
LICENSE.