Skip to content

Commit 092c9e9

Browse files
committed
Add M-x port (SLIME-style jack-in)
Detects deps.edn / project.clj projects, picks a free port in 5555-5574, spawns a JVM that runs (clojure.core.server/start-server ... :accept io-prepl) and blocks on @(promise) so the JVM stays up, polls until the port is reachable, and connects via port-connect. The server's stdout lands in a *port-server* buffer below the REPL. If a session is already active, M-x port just pops to the REPL instead of starting a new one. With C-u prefix arg the auto-detected command is offered as the editable default. The session struct now holds the spawned process so port-session-shutdown can kill it, and a sentinel tears down the session if the JVM dies unexpectedly. Babashka, shadow-cljs and per-project alias selection are not yet covered; users with those setups can still launch their own server and M-x port-connect.
1 parent c95bffe commit 092c9e9

7 files changed

Lines changed: 301 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Initial prototype.
1717
- `port-find-definition` (bound to `M-.`) jumps to the source of the
1818
symbol at point using `:file` / `:line` from var metadata. Files
1919
inside jars are not yet handled.
20+
- `M-x port` jacks in: detects `deps.edn` / `project.clj`, picks a free
21+
port, spawns a JVM running a prepl server, polls until reachable, and
22+
connects. If a session is already active it just pops to the REPL,
23+
SLIME-style.
2024
- Single-buffer REPL that renders `:ret`, `:out`, `:err`, and `:tap`
2125
messages.
2226
- Interactive eval commands: last-sexp, defun-at-point, region, buffer.

README.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,23 @@ so direct evals from a Clojure buffer behave like typing into the REPL.
9595

9696
## Starting a prepl
9797

98-
From your project, run:
98+
The simplest path is to let Port spawn one for you: visit a file in your
99+
project and run `M-x port`. It auto-detects the project layout (`deps.edn`
100+
or `project.clj`), picks a free port in `5555-5574`, starts a JVM with a
101+
prepl server on that port, and connects when it's ready. The server's
102+
stdout/stderr lands in a `*port-server*` buffer below the REPL.
103+
104+
If you'd rather run the prepl yourself (handy for embedding it in a
105+
long-running application or pre-warming the JVM), start it like this:
99106

100107
```
101-
clj -X clojure.core.server/start-server \
102-
:name '"port"' :port 5555 \
103-
:accept clojure.core.server/io-prepl
108+
clojure -e '(do (clojure.core.server/start-server
109+
{:name "port" :port 5555
110+
:accept (quote clojure.core.server/io-prepl)})
111+
@(promise))'
104112
```
105113

106-
You can also embed an equivalent `start-server` call into your application's
107-
`-main`, or wire it up via a `deps.edn` alias.
114+
and then `M-x port-connect` to attach.
108115

109116
## Installation
110117

@@ -137,8 +144,9 @@ For a manual checkout (e.g. while contributing):
137144

138145
## Connecting from Emacs
139146

140-
`M-x port-connect`, accept the default `localhost:5555`, and a REPL buffer
141-
pops up.
147+
For most projects `M-x port` is all you need — it starts a prepl and
148+
connects. Use `M-x port-connect` (default `localhost:5555`) when you've
149+
started a prepl yourself or want to attach to one running elsewhere.
142150

143151
## Key bindings (in `port-mode`)
144152

doc/design.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,10 @@ architecture.
402402

403403
## Known limitations
404404

405-
- No `port-jack-in`; the user starts the prepl manually.
405+
- Jack-in covers `deps.edn` and `project.clj` only. Babashka,
406+
shadow-cljs, and per-project alias selection (`-A:dev` etc.) are not
407+
yet supported; users with those setups can still launch the prepl
408+
manually and `M-x port-connect`.
406409
- No structured stacktrace buffer. Exceptions print as the prepl
407410
emits them. The infrastructure is there — `:exception true` is
408411
detected on `:ret` messages — it just isn't yet rendered into a
@@ -421,23 +424,25 @@ architecture.
421424

422425
In rough priority order:
423426

424-
1. `port-jack-in`: detect `deps.edn`, `project.clj`, `bb.edn` etc.,
425-
spawn a prepl, discover its port.
426-
2. Structured stacktrace buffer: detect `:exception true` on the user
427+
1. Structured stacktrace buffer: detect `:exception true` on the user
427428
socket, parse the printed `Throwable->map`, render in a dedicated
428429
buffer with navigation.
429-
3. Jar source resolution: when `:file` resolves under a jar URL, ask
430+
2. Jar source resolution: when `:file` resolves under a jar URL, ask
430431
the prepl to extract the file's contents (or use Emacs's
431432
`archive-mode`/tramp-archive support).
432-
4. xref backend: replace the standalone `port-find-definition`
433+
3. Test runner integration (`clojure.test`).
434+
4. Pretty-printed and length-capped result rendering in the REPL.
435+
5. xref backend: replace the standalone `port-find-definition`
433436
command with an `xref-backend-functions` implementation, which
434437
gets us references and apropos UI for free.
435-
5. Reader extension to support vectors and lists, removing the need
438+
6. Jack-in for babashka and shadow-cljs, plus per-project alias
439+
selection.
440+
7. Reader extension to support vectors and lists, removing the need
436441
for `pr-str` on the Clojure side of helper commands.
437-
6. Multi-session support keyed per clojure-mode buffer.
438-
7. Persistent input history (per-project history file).
439-
8. CIDER-style result overlays (deliberately listed last; the
440-
"all output goes to the REPL" UX is a deliberate choice).
442+
8. Multi-session support keyed per clojure-mode buffer.
443+
9. Persistent input history (per-project history file).
444+
10. CIDER-style result overlays (deliberately listed last; the
445+
"all output goes to the REPL" UX is a deliberate choice).
441446

442447
## Versioning
443448

lisp/port-jack-in.el

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
;;; port-jack-in.el --- Start a prepl in the current project and connect to it -*- lexical-binding: t -*-
2+
3+
;; Copyright © 2026 Bozhidar Batsov and Port contributors
4+
5+
;; This program is free software: you can redistribute it and/or modify
6+
;; it under the terms of the GNU General Public License as published by
7+
;; the Free Software Foundation, either version 3 of the License, or
8+
;; (at your option) any later version.
9+
10+
;;; Commentary:
11+
12+
;; SLIME-style jack-in: `M-x port' detects the project type, picks a
13+
;; free port, spawns a JVM that runs a prepl server alongside an
14+
;; ever-blocking main thread, polls until the port is reachable, and
15+
;; then connects to it via `port-connect'.
16+
17+
;;; Code:
18+
19+
(require 'cl-lib)
20+
(require 'subr-x)
21+
(require 'port-client)
22+
(require 'port-session)
23+
24+
(defcustom port-jack-in-clojure-program "clojure"
25+
"Command used to launch the Clojure CLI."
26+
:type 'string :group 'port)
27+
28+
(defcustom port-jack-in-leiningen-program "lein"
29+
"Command used to launch Leiningen."
30+
:type 'string :group 'port)
31+
32+
(defcustom port-jack-in-startup-timeout 30
33+
"Seconds to wait for the prepl server to start accepting connections."
34+
:type 'number :group 'port)
35+
36+
(defcustom port-jack-in-port-range '(5555 . 5574)
37+
"Inclusive cons (LOW . HIGH) of ports to scan for a free one."
38+
:type '(cons integer integer) :group 'port)
39+
40+
(defun port-jack-in--detect-project-root ()
41+
"Walk up from `default-directory' looking for a Clojure project marker.
42+
Return the project root or `default-directory' if no marker is found."
43+
(or (locate-dominating-file default-directory "deps.edn")
44+
(locate-dominating-file default-directory "project.clj")
45+
(locate-dominating-file default-directory "bb.edn")
46+
default-directory))
47+
48+
(defun port-jack-in--detect-project-type (root)
49+
"Return one of `tools-deps', `leiningen', `babashka', or `bare' for ROOT."
50+
(cond
51+
((file-exists-p (expand-file-name "deps.edn" root)) 'tools-deps)
52+
((file-exists-p (expand-file-name "project.clj" root)) 'leiningen)
53+
((file-exists-p (expand-file-name "bb.edn" root)) 'babashka)
54+
(t 'bare)))
55+
56+
(defun port-jack-in--port-free-p (port)
57+
"Return non-nil if nothing is currently listening on 127.0.0.1:PORT."
58+
(let ((proc (condition-case _
59+
(make-network-process
60+
:name "port-jack-in-probe"
61+
:host "127.0.0.1" :service port
62+
:nowait nil :noquery t)
63+
(error nil))))
64+
(if proc (progn (delete-process proc) nil) t)))
65+
66+
(defun port-jack-in--free-port ()
67+
"Return the lowest free port in `port-jack-in-port-range'."
68+
(let* ((lo (car port-jack-in-port-range))
69+
(hi (cdr port-jack-in-port-range))
70+
(p lo))
71+
(while (and (<= p hi) (not (port-jack-in--port-free-p p)))
72+
(setq p (1+ p)))
73+
(if (<= p hi) p
74+
(user-error "Port: no free ports in %d-%d" lo hi))))
75+
76+
(defun port-jack-in--server-form (port)
77+
"Return the Clojure -e form that starts a prepl on PORT and blocks."
78+
(format
79+
(concat "(do (clojure.core.server/start-server"
80+
" {:name \"port\" :port %d"
81+
" :accept (quote clojure.core.server/io-prepl)})"
82+
" @(promise))")
83+
port))
84+
85+
(defun port-jack-in--build-command (project-type port)
86+
"Return a list (PROGRAM ARG ...) to spawn the JVM for PROJECT-TYPE on PORT."
87+
(let ((form (port-jack-in--server-form port)))
88+
(pcase project-type
89+
((or 'tools-deps 'bare)
90+
(list port-jack-in-clojure-program "-e" form))
91+
('leiningen
92+
(list port-jack-in-leiningen-program
93+
"trampoline" "run" "-m" "clojure.main" "-e" form))
94+
('babashka
95+
(user-error "Port: babashka jack-in is not yet supported"))
96+
(_ (error "Port: unknown project type %S" project-type)))))
97+
98+
(defun port-jack-in--wait-for-port (port timeout)
99+
"Block until 127.0.0.1:PORT accepts connections, or TIMEOUT seconds elapse.
100+
Return non-nil on success."
101+
(let ((deadline (+ (float-time) timeout)))
102+
(catch 'reachable
103+
(while (< (float-time) deadline)
104+
(when (not (port-jack-in--port-free-p port))
105+
;; The probe inside port-free-p connected successfully, so the
106+
;; server is up.
107+
(throw 'reachable t))
108+
(sleep-for 0.2))
109+
nil)))
110+
111+
(defun port-jack-in--sentinel (proc event)
112+
"Tear down the session if its JVM PROC dies unexpectedly."
113+
(when (memq (process-status proc) '(closed exit failed signal))
114+
(when (and port-default-session
115+
(eq proc (port-session-jvm-process port-default-session)))
116+
(message "Port: server process %s; disconnecting" (string-trim event))
117+
(port-session-shutdown port-default-session))))
118+
119+
(defun port-jack-in--prep-buffer (cmd root)
120+
"Create and return the *port-server* buffer with CMD and ROOT recorded."
121+
(let ((buf (get-buffer-create "*port-server*")))
122+
(with-current-buffer buf
123+
(let ((inhibit-read-only t))
124+
(erase-buffer)
125+
(insert (format "; cwd: %s\n; cmd: %s\n\n"
126+
root (mapconcat #'identity cmd " "))))
127+
(special-mode))
128+
buf))
129+
130+
;;;###autoload
131+
(defun port (&optional edit-command)
132+
"Start a prepl in the current project and connect to it.
133+
With a prefix arg (EDIT-COMMAND), prompt for the startup command;
134+
the auto-detected one is offered as the default. If a session is
135+
already active, just pop to the REPL buffer."
136+
(interactive "P")
137+
(cond
138+
(port-default-session
139+
(pop-to-buffer (port-session-repl-buffer port-default-session)))
140+
(t
141+
(let* ((root (port-jack-in--detect-project-root))
142+
(default-directory root)
143+
(type (port-jack-in--detect-project-type root))
144+
(port-num (port-jack-in--free-port))
145+
(auto-cmd (port-jack-in--build-command type port-num))
146+
(cmd (if edit-command
147+
(split-string-shell-command
148+
(read-string "Startup command: "
149+
(mapconcat #'shell-quote-argument
150+
auto-cmd " ")))
151+
auto-cmd))
152+
(buf (port-jack-in--prep-buffer cmd root))
153+
(proc (apply #'make-process
154+
:name "port-server"
155+
:buffer buf
156+
:command cmd
157+
:sentinel #'port-jack-in--sentinel
158+
:noquery nil
159+
nil)))
160+
(display-buffer buf '(display-buffer-below-selected
161+
(window-height . 8)))
162+
(message "Port: starting %s server on port %d ..." type port-num)
163+
(cond
164+
((port-jack-in--wait-for-port port-num
165+
port-jack-in-startup-timeout)
166+
(let ((session (port-connect "127.0.0.1" port-num)))
167+
(setf (port-session-jvm-process session) proc)
168+
(message "Port: %s session ready on 127.0.0.1:%d" type port-num)))
169+
(t
170+
(when (process-live-p proc) (delete-process proc))
171+
(user-error "Port: server didn't come up within %d seconds"
172+
port-jack-in-startup-timeout)))))))
173+
174+
(provide 'port-jack-in)
175+
176+
;;; port-jack-in.el ends here

lisp/port-session.el

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
user-conn
2929
tool-conn
3030
repl-buffer
31+
jvm-process ; non-nil when this session was started via `M-x port'
3132
(next-id 0)
3233
(pending '()))
3334

@@ -56,11 +57,14 @@
5657
(cdr entry))))
5758

5859
(defun port-session-shutdown (session)
59-
"Tear down both connections of SESSION."
60+
"Tear down both connections of SESSION, and the JVM if we spawned it."
6061
(when (port-session-user-conn session)
6162
(port-client-disconnect (port-session-user-conn session)))
6263
(when (port-session-tool-conn session)
6364
(port-client-disconnect (port-session-tool-conn session)))
65+
(when-let ((proc (port-session-jvm-process session)))
66+
(when (process-live-p proc)
67+
(delete-process proc)))
6468
(setf (port-session-pending session) nil)
6569
(when (eq port-default-session session)
6670
(setq port-default-session nil)))

lisp/port.el

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
(require 'port-eldoc)
4848
(require 'port-completion)
4949
(require 'port-xref)
50+
(require 'port-jack-in)
5051
(require 'port-mode)
5152

5253
(defgroup port nil
@@ -87,7 +88,8 @@ correlated helper-command requests, then pops to the REPL buffer."
8788
(setq port-default-session session)
8889
(port-tooling-install session)
8990
(pop-to-buffer buf)
90-
(message "Port connected to %s:%d (user + tool sockets)" host port)))
91+
(message "Port connected to %s:%d (user + tool sockets)" host port)
92+
session))
9193

9294
;;;###autoload
9395
(defun port-disconnect ()

0 commit comments

Comments
 (0)