|
| 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 |
0 commit comments