Skip to content

CIDER-style nREPL: jack-in, per-project connections, completion, eldoc, doc, M-. - #23

Open
nooga wants to merge 5 commits into
mainfrom
nrepl-client
Open

nooga wants to merge 5 commits into
mainfrom
nrepl-client

Conversation

@nooga

@nooga nooga commented Sep 24, 2026

Copy link
Copy Markdown
Owner

CIDER-style Clojure/let-go tooling for let-go-mode: an nREPL client, one-command jack-in, completion, eldoc, doc lookup and goto-definition. Each buffer routes all of it to either the editor's own VM or its project's nREPL server.

What you get

Key What
C-c M-j Jack in: find the project root, start its nREPL server on a free port, connect. Stopped again by C-c C-q or editor exit
C-c M-c / C-c C-q connect to a running server (pre-filled from .nrepl-port) / disconnect
C-x C-e C-j C-c C-v C-c C-e unchanged keys; they evaluate on the server when the buffer's project is connected (C-c C-e = load-file)
TAB / M-TAB completion. TAB completes only when indenting would change nothing and point ends a symbol
(automatic) eldoc: ns/name: [args] in the echo area while typing a call
C-c C-d d arglists, doc and source location in *Help*
M-. / M-, goto definition (files, open buffers, or source inside a Clojure jar, read-only) and back

Jack-in launch configs are data (register-launch-config!):

Project Marker Server
lgx lgx.edn lgx nrepl --port N
Clojure CLI deps.edn clojure -Sdeps '{nrepl + cider-nrepl}' -M -m nrepl.cmdline …
Babashka bb.edn bb nrepl-server N
let-go deps.edn, or none lg -n -p N

When the markers are ambiguous (deps.edn for both CLI and lg, bb.edn next to deps.edn), the buffer's extension and a priority decide, and a real tie prompts. M-x nrepl-jack-in-command edits the command first, and M-x nrepl-server-log shows the server output.

Connections are per project root. File buffers under a connected root use that server. Fileless buffers (*scratch*, *repl*) always stay in-process, so editor scripting keeps working next to two projects on two servers. The lighter shows nrepl:clj / :bb / :lg / :lgx.

How it's built

  • legmacs.modes.letgo: every eval command builds one backend request. eval-backend-for picks the backend per buffer through registered resolvers. With :in-process, C-x C-e/C-j/C-c C-e stay synchronous, exactly as before; any other backend runs them through spawn-task. Remote results come back as printed-result. C-j and C-c C-v check that the form is still in the same buffer before writing. New after-command-hooks let eldoc run after paren-matching.
  • legmacs.nrepl: bencode over net, one blocking round trip per request, serialized by a channel-token lock inside the job's future. There's no long-lived reader goroutine for loop-wait to keep polling. Connections are keyed by project root; ops are discovered with describe, preferring cider-nrepl's complete/info/eldoc when present. Server errors are cleaned to one line (message plus caused by: chain), and an eval in a namespace the server hasn't loaded gets a note, because let-go silently falls back to user.
  • legmacs.jack-in: let-go interop can't reach cmd.Process, so servers run under sh -c. The script writes a pidfile, logs to a file (an unread pipe would block the server) and execs the command. The editor holds its stdin open (lg -n exits on EOF), and stopping it is SIGTERM plus closing stdin. Liveness uses ps -o stat=, because kill -0 reports a zombie as alive. The editor picks the port, since bb doesn't write .nrepl-port for port 0. New bufs/add-exit-hook! stops everything after the terminal is restored.
  • legmacs.modes.letgo-complete / letgo-lookup: completers and lookups registries per backend. In-process answers come from ns-map/ns-publics/aliases (including the ns form's :requires) and var metadata. eldoc scans only back to the top-level form and caches remote answers for 15s. A new :visit buffer-command and a shared :xref-stack implement M-./M-,.

Also in here

  • test/run.lg ran 0 tests. let-go's run-tests now defaults to *ns*, so the suite had been reporting success without running anything, in CI too. It now uses run-all-tests and runs 401 tests. CLAUDE.md's single-namespace command is fixed the same way.
  • The let-go pin moves to main (36d57e3), because the old pin predates the net/bencode namespaces. build/go.mod/go.sum are tidied against it. CI's test step puts lg on PATH, since the jack-in tests start a real lg -n server.

What was tested

  • The suite: 401 tests, 0 failures, on let-go 36d57e3 (the new pin). The new tests use a fake bencode server on a loopback socket, a real lg -n jack-in, and a server that dies at startup.
  • Headless end-to-end against real servers:
    • lg, Babashka and Clojure CLI (+cider-nrepl): jack-in, load, eval, completion, disconnect (server gone afterwards).
    • Clojure only: eldoc, doc, and M-. into clojure/core.clj inside the Clojure jar.
  • The real TUI under a pty, from source and as the native binary: jack-in, eval, M-TAB, M-./M-,, doc, and no server left running after C-x C-c.
  • build.sh plus the plan9/amd64 and plan9/arm cross-builds against the new pin. The generated Go has no closure-hoisting pattern (gogen: a capturing closure inside a conditional is emitted as the function's return value, discarding the branch let-go#766).
  • lgx is untested: it isn't installed here.

Known gaps, from let-go

Filed together as nooga/let-go#961:

  • AOT builds drop def metadata (plus an option to elide it on purpose), so the native binary's in-process eldoc/doc/M-. know names only.
  • Core vars have no arglists or docs.
  • let-go's nREPL info returns name/ns only, and it has no eldoc, streamed output, headless mode, plain-text errors, or ns in completions.
  • No subprocess spawn with real pipes, pid and kill.
  • No symbol/end positions or structured error positions from the reader and compiler.

Not done yet from CIDER: test runner, stacktrace view, interrupt (nooga/let-go#589), macroexpand, and a REPL buffer tied to the connection.

🤖 Generated with Claude Code

nooga and others added 5 commits September 24, 2026 00:42
let-go's run-tests now defaults to *ns* like Clojure's, so the bare
(run-tests) in test.run ran 0 tests and reported success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C-c M-c connects (pre-filled from the nearest .nrepl-port that lg -n
writes), C-c C-q disconnects. While connected, C-x C-e, C-j, C-c C-v and
C-c C-e send their source to the server; the let-go-mode lighter reads
nrepl.

- letgo: all eval commands now build one backend request (:op
  :eval/:load). In-process stays synchronous; any other backend runs the
  command through spawn-task. Remote results are printed-result (server's
  printed text plus captured output). C-j and C-c C-v verify the form is
  still in the same buffer before writing.
- nrepl: blocking bencode round trips over net, serialized by a
  channel-token lock inside the job future, so no persistent reader
  goroutine for the main loop to poll. Errors are cleaned to the message
  plus caused-by chain; an eval in a namespace the server hasn't loaded
  (let-go falls back to user silently) gets a note.
- tests against a fake bencode server on a loopback socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C-c M-j starts the project's nREPL server, waits for it, connects, and
stops it on C-c C-q or editor exit. Launch configs are data
(register-launch-config!) for lgx (lgx.edn), Clojure CLI with
cider-nrepl (deps.edn), Babashka (bb.edn) and plain lg (deps.edn or no
project); the buffer's extension and :priority settle deps.edn/bb.edn
ambiguity, a real tie prompts. nrepl-jack-in-command edits the command
first; nrepl-server-log shows the server output.

Processes: let-go interop can't reach cmd.Process, so servers run under
sh -c writing a pid file and logging to a file, exec'ing the command,
with stdin held open (lg -n exits on EOF). Stop = SIGTERM + close stdin;
liveness via ps stat since kill -0 counts zombies. The editor picks the
port (bb doesn't write .nrepl-port for port 0). New bufs exit hooks run
after the terminal is restored.

Connections are now per project root: buffers visiting files under a
connected root evaluate there, via a backend resolver in letgo
(eval-backend-for); fileless buffers like *scratch* stay in-process.

Completion (legmacs.modes.letgo-complete): TAB completes when indenting
changes nothing and point ends a symbol, M-TAB always. In-process from
the VM (buffer ns, aliases incl. the ns form's requires, alias/ publics,
special forms); on nREPL via cider's complete or completions. One
candidate inserts, a longer common prefix extends, else the minibuffer
picker with type/ns/arglists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
legmacs.modes.letgo-lookup, routed per buffer like eval and completion
through a lookups registry:

- eldoc: an after-command hook shows the enclosing call's arglists in
  the echo area when nothing else claimed it. It scans back only to the
  top-level form and reads the ns form from the buffer's head, so it's
  cheap per key; remote answers come from a short-TTL cache filled by a
  quiet spawn-task.
- C-c C-d d / C-c C-d C-d: arglists, doc and source location in *Help*.
- M-.: jump to the definition -- the current buffer, an open one, a file
  opened fresh, or for Clojure libraries the source read out of the jar
  into a read-only buffer. M-, pops back across buffers.

In-process answers come from var metadata, resolving relative :file paths
from the buffer's directory upward, LG_SOURCE_PATHS and the cwd. nREPL
answers use cider-nrepl's eldoc/info, or nREPL's built-in lookup.

Plumbing: letgo after-command-hooks (run after paren-matching, each in a
try); a :visit buffer-command (show a buffer by id/filename/name or open
:state, at row/col, clamped); a shared :xref-stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…PATH

The nREPL client needs let-go's net and bencode namespaces, which the
old pin (a665761) predates. build/go.mod/go.sum are go mod tidy against
the new pin. The suite now runs for real (it ran 0 tests before), and the
jack-in tests start an actual lg -n server, so CI's test step puts the
freshly built lg on PATH. Tests clean up with rm -rf rather than
os/delete-tree for the same reason: not every lg has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant