Skip to content

nrepl/neat

Repository files navigation

neat

CI

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.

Status

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.

Project context

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:

  1. An official nREPL specification. Today the nREPL project is the de facto spec; a formal version is being drafted at nrepl/spec.nrepl.org. neat aims to keep pressure on the spec to stay genuinely language-agnostic by being a client that refuses to silently assume Clojure.
  2. Reference clients. A spec without independent client implementations is wishful thinking. neat is 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.
  3. A compatibility test suite. The parameterised integration suite under test/neat-integration-test.el already 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.

Installation

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.

Modules

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-mode minor mode for source buffers.

Library users typically only need neat-bencode and neat-client.

Quick start

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.

Troubleshooting

Eldoc, completion, or M-. quietly do nothing

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.

Evaluation just sits there

Three usual suspects:

  • The eval is reading from stdin. Look at the minibuffer for a stdin: prompt and answer it. C-g interrupts the read.
  • The connection died. The mode-line shows [closed] and a ;; connection closed line appears in the REPL buffer.
  • The eval is actually running, just slowly. C-c C-c in the REPL (or C-c C-k in a source buffer) sends an interrupt op.

Evaluation lands in the wrong namespace

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.el for a project-wide default.
  • Or swap neat-buffer-ns-function for one that derives the ns from the buffer (parsing a (ns ...) form, reading file metadata, etc.).

M-x neat doesn't autofill the port

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-directory is used, which may not be where you expect.

M-: (neat-discover-port-file) RET shows what neat is finding.

M-. doesn't jump anywhere

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.

Something is happening on the wire but you can't tell what

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-log pops the buffer without toggling.
  • neat-clear-message-log wipes 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).

Generic Emacs debugging tools

A few Emacs builtins that pair well with neat:

  • M-x toggle-debug-on-quit. With it on, hitting C-g during 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 uses condition-case-unless-debug, so turning this on means buggy callbacks land in the debugger rather than being swallowed by a one-line message. See the Emacs Lisp Debugger manual.
  • M-x profiler-start, exercise the slow thing, then M-x profiler-report. Helpful when CAPF feels laggy or an eldoc lookup blocks longer than expected. See the Profiling manual.

Design

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.

Development

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.

License

Distributed under the GNU General Public License, version 3 or later. See LICENSE.

About

A small, language-agnostic nREPL client for Emacs

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors