flan/emacs/flan-dev.el
Joseph Ferano 12f99702b4 Say in the modeline whether there is a program, and reconnect to one
Whether a program is on the other end is the one fact worth a permanent place
on screen, because every command in the client is a lie without it. Until now
it was discovered by something failing, which is the worst moment to learn it.

Three states, not two. `off' is never connected; `lost' is a daemon that has
gone away, which is the ordinary case rather than an error — `flan dev' ends
when its program does, and a program under development exits all the time. So
`lost' is reconnected from, on the socket it was on, the next time anything is
sent.

The reconnect is strictly *before* a send and never after one. A connection
that dies mid-request might have died after the daemon took the request and
ran it; resending would install a definition twice, or evaluate a
side-effecting expression twice. That case now reports what happened and says
it was not resent, rather than silently doing it again.

A socket that is not there is refused by name with the path, and a deliberate
`flan-disconnect' forgets the socket, so the next command says "not connected"
instead of quietly reopening what was just closed.
2026-09-11 17:48:06 +07:00

454 lines
20 KiB
EmacsLisp

;;; flan-dev.el --- Talk to a running Flan program -*- lexical-binding: t; -*-
;; The editor half of Flan's dev loop. `flan dev program.flan' compiles the
;; program, launches it, and listens on .flan-dev.sock beside the source; this
;; connects to that socket and sends it forms.
;;
;; C-c C-c recompiles the top-level form at point and installs it in the
;; running program, at that program's next frame boundary. Call sites compiled
;; before the new body existed follow it, and the program's state — its globals
;; — is untouched. C-c C-k does the same for a whole buffer.
;;
;; The protocol is one s-expression per message, length framed. That is why
;; there is no parser here: `prin1' writes a request and `read' reads a reply.
;;
;; C-x C-e evaluates the expression before point *in the running program* and
;; shows its value. That is a different primitive from redefining a name:
;; there is nothing to install a body into, so the expression is wrapped in a
;; thunk the program runs at its next frame boundary. Only scalars, bool and
;; strings render so far — a Flan value carries no header, so a printer has to
;; be derived per type at compile time, and the ones that are not derived yet
;; say so rather than guessing.
;;; Code:
(require 'subr-x)
(require 'seq)
(require 'pcase)
(defgroup flan-dev nil
"Talking to a running Flan program."
:group 'flan
:prefix "flan-dev-")
(defcustom flan-dev-socket-name ".flan-dev.sock"
"Name of the socket `flan dev' listens on, looked for up from the buffer."
:type 'string)
(defcustom flan-dev-echo-result t
"Whether a successful evaluation reports in the echo area."
:type 'boolean)
(defcustom flan-dev-output-buffer "*flan-output*"
"Buffer the running program's own output is appended to."
:type 'string)
(defvar flan-dev--connection nil
"The open connection, or nil.")
(defvar flan-dev--socket nil
"Path of the socket `flan-dev--connection' is connected to.")
;;; Wire
;; Framing is a decimal byte count, a newline, then that many bytes. A message
;; carries Flan source, which contains newlines, so a line-oriented protocol
;; would need an escape layer that this does not. Lengths are in *bytes*, so
;; every measurement goes through `string-bytes' and the process is raw-text —
;; a multibyte identifier would otherwise put the reply stream out of step by
;; exactly as many bytes as the payload has non-ASCII characters.
(defun flan-dev--send (proc form)
"Send FORM to PROC as one framed message."
(let* ((payload (encode-coding-string (prin1-to-string form) 'utf-8 t)))
(process-send-string proc (format "%d\n%s" (length payload) payload))))
(defun flan-dev--read-reply (proc)
"Block until PROC sends one complete framed message, and read it."
(with-current-buffer (process-buffer proc)
(let ((deadline (+ (float-time) 30)))
;; The header first: digits up to a newline.
(while (and (not (save-excursion (goto-char (point-min))
(re-search-forward "\\`\\([0-9]+\\)\n" nil t)))
(< (float-time) deadline))
(accept-process-output proc 0.05))
(goto-char (point-min))
(unless (re-search-forward "\\`\\([0-9]+\\)\n" nil t)
;; Deliberately not retried. If the daemon took the request and died
;; before replying, the evaluation may well have happened — sending it
;; again would install it twice, or run a side-effecting expression
;; twice. Reconnecting happens before a send, never after one.
(if (process-live-p proc)
(error "flan dev: no reply in 30s from %s"
(abbreviate-file-name (or flan-dev--socket "the daemon")))
(error
"flan dev: the daemon on %s closed the connection; not resent, because it may already have run"
(abbreviate-file-name (or flan-dev--socket "?")))))
(let* ((n (string-to-number (match-string 1)))
(body-start (point)))
(while (and (< (- (position-bytes (point-max)) (position-bytes body-start)) n)
(< (float-time) deadline))
(accept-process-output proc 0.05))
(let* ((end (byte-to-position (+ (position-bytes body-start) n)))
(text (decode-coding-string
(encode-coding-string (buffer-substring-no-properties
body-start end)
'utf-8 t)
'utf-8))
(form (car (read-from-string text))))
(delete-region (point-min) end)
form)))))
(defun flan-dev--append-output (text)
"Append TEXT, the running program's own output, to its buffer."
(when (and text (> (length text) 0))
(with-current-buffer (get-buffer-create flan-dev-output-buffer)
(let ((at-end (= (point) (point-max))))
(save-excursion
(goto-char (point-max))
(insert text))
;; Follow the tail only for someone who was already at it; a reader
;; scrolled back is reading something.
(when at-end (goto-char (point-max)))))))
(defun flan-dev--request (form)
"Send FORM to the connected program and return its reply."
(let* ((proc (flan-dev--live-connection))
(reply (progn (flan-dev--send proc form)
(flan-dev--read-reply proc))))
;; Whatever the program printed since the last reply rides along with this
;; one, so the output an evaluation itself caused arrives with its result.
(flan-dev--append-output (plist-get reply :output))
reply))
;;; Connection
(defun flan-dev--find-socket ()
"Find the daemon's socket by walking up from the current buffer."
(let ((dir (locate-dominating-file
(or buffer-file-name default-directory)
flan-dev-socket-name)))
(and dir (expand-file-name flan-dev-socket-name dir))))
(defun flan-dev--open (socket)
"Open a connection to SOCKET and make it the current one."
(when (process-live-p flan-dev--connection)
(delete-process flan-dev--connection))
(let ((buf (get-buffer-create " *flan-dev*")))
;; Unibyte, because the framing counts bytes and this buffer is where they
;; are counted.
(with-current-buffer buf (erase-buffer) (set-buffer-multibyte nil))
(setq flan-dev--connection
(make-network-process
:name "flan-dev" :buffer buf :family 'local :service socket
:coding 'binary :noquery t))
(setq flan-dev--socket socket))
(force-mode-line-update t)
flan-dev--connection)
;; A daemon restarted while Emacs was not looking is the ordinary case, not an
;; exceptional one: `flan dev' ends when its program does, and a program under
;; development exits all the time. So a dead connection is reopened on the
;; socket it was on rather than reported — but only *before* a request goes
;; out. Reconnecting after one has been sent and lost would be a retry, and a
;; retry of `eval-expr' runs the expression a second time.
(defun flan-dev--live-connection ()
"The open connection, reconnecting if the daemon has been restarted."
(unless (process-live-p flan-dev--connection)
(cond
((null flan-dev--socket)
(error "Not connected: M-x flan-connect, or start `flan dev program.flan'"))
((not (file-exists-p flan-dev--socket))
(setq flan-dev--connection nil)
(force-mode-line-update t)
(error "flan dev: nothing is listening on %s; start `flan dev program.flan' again"
(abbreviate-file-name flan-dev--socket)))
(t
(condition-case err
(progn (flan-dev--open flan-dev--socket)
(message "flan dev: reconnected to %s"
(abbreviate-file-name flan-dev--socket)))
(error
(setq flan-dev--connection nil)
(force-mode-line-update t)
(error "flan dev: cannot reconnect to %s: %s"
(abbreviate-file-name flan-dev--socket)
(error-message-string err)))))))
flan-dev--connection)
;;;###autoload
(defun flan-connect (&optional socket)
"Connect to a `flan dev' daemon listening on SOCKET.
With no argument, look for `flan-dev-socket-name' up from this buffer."
(interactive
(list (or (flan-dev--find-socket)
(read-file-name "flan dev socket: "))))
(unless socket (user-error "No %s found above this buffer" flan-dev-socket-name))
(flan-dev--open socket)
(let ((r (flan-dev--request '(:op "describe"))))
(message "flan dev: connected to %s (%d functions, %d globals)"
(abbreviate-file-name socket)
(length (plist-get r :fns)) (length (plist-get r :globals))))
flan-dev--connection)
(defun flan-disconnect ()
"Close the connection, which also ends the daemon and its program."
(interactive)
(when (process-live-p flan-dev--connection)
(ignore-errors (flan-dev--request '(:op "close")))
(delete-process flan-dev--connection))
(setq flan-dev--connection nil)
;; Forgotten, not kept: this was a deliberate disconnect, so the next
;; request should say so rather than quietly reopening what was just closed.
(setq flan-dev--socket nil)
(force-mode-line-update t)
(message "flan dev: disconnected"))
;;; The modeline
;; Whether there is a program on the other end is the one thing worth a
;; permanent place on screen, because every other command in here is a lie
;; without it. Before this it was discovered by a command failing.
(defface flan-dev-live-face '((t :inherit success))
"Face for the modeline indicator when a program is connected."
:group 'flan-dev)
(defface flan-dev-lost-face '((t :inherit warning))
"Face for the modeline indicator when the daemon has gone away."
:group 'flan-dev)
(defun flan-dev-state ()
"Whether a program is connected: `live', `lost', or `off'.
`lost' means there was one and the daemon is gone — a restart away, not a
mistake, so it is distinguished from never having connected."
(cond ((process-live-p flan-dev--connection) 'live)
(flan-dev--socket 'lost)
(t 'off)))
(defun flan-dev-mode-line ()
"The Flan connection indicator, for `mode-line-misc-info'."
(when (derived-mode-p 'flan-mode 'flan-repl-mode)
(pcase (flan-dev-state)
('live (propertize " flan:live" 'face 'flan-dev-live-face
'help-echo (format "Connected to %s" flan-dev--socket)))
('lost (propertize " flan:lost" 'face 'flan-dev-lost-face
'help-echo
(format "%s has gone away; the next command reconnects"
flan-dev--socket)))
(_ (propertize " flan:off" 'face 'shadow
'help-echo "Not connected (C-c C-z)")))))
;; Appended rather than prepended: this is the least urgent thing in the line.
(add-to-list 'mode-line-misc-info '(:eval (flan-dev-mode-line)) t)
;;;###autoload
(defun flan-show-output ()
"Show the running program's output, after collecting anything pending."
(interactive)
(ignore-errors (flan-dev--request '(:op "describe")))
(display-buffer (get-buffer-create flan-dev-output-buffer)))
(defun flan-describe ()
"Report what the running program currently defines."
(interactive)
(let ((r (flan-dev--request '(:op "describe"))))
(message "flan dev: %s, %d functions, %d globals"
(if (plist-get r :alive) "running" "exited")
(length (plist-get r :fns)) (length (plist-get r :globals)))))
;;; Where an error is
;; A reply's :loc is "file:line:col", and the column is a *byte* offset into
;; the line: the reader walks the source a byte at a time (lib/reader.ml), and
;; OCaml strings are bytes. Emacs counts characters, so the same rule the
;; framing has applies here — one non-ASCII character earlier on the line puts
;; the marker as many columns to the right as that character has bytes. Going
;; through `byte-to-position' from the line's start is the whole fix, and
;; `forward-char' would also have walked into the next line on a column past
;; the end of a short one.
(defun flan-dev--parse-loc (loc)
"Split LOC, a \"file:line:col\" string, into (FILE LINE COL), or nil."
(when (and (stringp loc)
(string-match "\\`\\(.*\\):\\([0-9]+\\):\\([0-9]+\\)\\'" loc))
(list (match-string 1 loc)
(string-to-number (match-string 2 loc))
(string-to-number (match-string 3 loc)))))
(defun flan-dev--position (line col)
"Position of LINE and byte-column COL in the current buffer."
(save-excursion
(goto-char (point-min))
(forward-line (1- line))
(let* ((bol (point))
(eol (line-end-position))
(want (+ (position-bytes bol) (max 0 (1- col))))
(p (and (<= want (position-bytes eol)) (byte-to-position want))))
;; Clamped rather than trusted: a column past the end of the line is a
;; location for something the reader wanted and did not find, and
;; overshooting into the next line would point at innocent code.
(min (or p eol) eol))))
(defun flan-dev--buffer-visiting (file)
"The live buffer visiting FILE, or nil.
Compared with `file-equal-p', so a symlinked or relative path still matches."
(seq-find (lambda (b)
(let ((n (buffer-local-value 'buffer-file-name b)))
(and n (file-exists-p file) (file-equal-p n file))))
(buffer-list)))
;;; Error overlays
;; An error is shown where it is rather than only in the echo area, because the
;; echo area is gone the moment you type and the location is the useful half of
;; the message. It is cleared when the next evaluation of that buffer is
;; accepted: an overlay left behind after a fix is a lie about the program, and
;; a stale one is worse than none.
(defface flan-dev-error-face
'((t :inherit error :underline (:style wave)))
"Face for the text an evaluation was rejected at."
:group 'flan-dev)
(defface flan-dev-error-message-face
'((t :inherit error :height 0.9))
"Face for the message shown beside a rejected form."
:group 'flan-dev)
(defun flan-dev-clear-errors (&optional buffer)
"Remove Flan error overlays from BUFFER, or from the current buffer."
(interactive)
(with-current-buffer (or buffer (current-buffer))
(remove-overlays (point-min) (point-max) 'flan-dev-error t)))
(defun flan-dev--show-error (loc msg)
"Mark MSG at LOC, if LOC names a file some buffer is visiting.
Returns non-nil when it put an overlay somewhere."
(let ((parts (flan-dev--parse-loc loc)))
(when parts
(let ((buf (flan-dev--buffer-visiting (nth 0 parts))))
(when buf
(with-current-buffer buf
(flan-dev-clear-errors buf)
(let* ((beg (flan-dev--position (nth 1 parts) (nth 2 parts)))
(end (save-excursion (goto-char beg) (line-end-position)))
(ov (make-overlay beg end buf t nil)))
(overlay-put ov 'flan-dev-error t)
(overlay-put ov 'face 'flan-dev-error-face)
(overlay-put ov 'help-echo msg)
(overlay-put ov 'evaporate nil)
(overlay-put ov 'priority 100)
(overlay-put ov 'after-string
(propertize (concat " " msg)
'face 'flan-dev-error-message-face))
;; Point goes there too, but only in the buffer being looked at:
;; moving point in a buffer nobody is showing is a surprise the
;; next time it is visited.
(when (eq buf (current-buffer)) (goto-char beg))
t)))))))
;;; Evaluating
(defun flan-dev--report (reply what)
"Report REPLY, describing WHAT was sent."
(if (equal (plist-get reply :status) "ok")
(let ((fns (plist-get reply :fns))
(names (plist-get reply :names))
(note (plist-get reply :note))
(value (plist-get reply :value)))
;; Accepted, so whatever the last rejection marked is no longer true.
(flan-dev-clear-errors)
(when flan-dev-echo-result
(if value
;; An expression's value, rendered inside the running program —
;; nothing was marshalled back, because nothing could be.
(message "=> %s" value)
(if note
;; The daemon accepted it and had nothing to send. Say so rather
;; than claiming an install that did not happen.
(message "%s: %s" (if names (string-join names ", ") what) note)
(message "%s installed in %.0fms"
(if fns (string-join fns ", ")
(if names (string-join names ", ") what))
(or (plist-get reply :ms) 0))))))
;; The daemon reports where, so mark it there. This must not itself
;; signal: the error the caller is owed is the daemon's, and losing it to a
;; bad location would report the wrong thing entirely.
(let ((loc (plist-get reply :loc))
(msg (plist-get reply :message)))
(ignore-errors (flan-dev--show-error loc (or msg "rejected")))
(user-error "flan: %s%s" (or msg "rejected")
(if loc (format " (%s)" loc) "")))))
(defun flan-dev--eval (code what)
"Send CODE to the running program. WHAT names it for the echo area."
(flan-dev--report
(flan-dev--request
;; buffer-file-name so an error points at the file being edited rather than
;; at the daemon's placeholder.
(list :op "eval" :code code :file (or buffer-file-name "<buffer>")))
what))
(defun flan-dev--text (start end)
"The buffer text from START to END, on the line it is actually written on.
The daemon reads what it is sent starting at line 1, so a form taken from the
middle of a buffer comes back with a location relative to the *snippet* — and
an error overlay drawn from that sits on line 1 of the file, pointing at
whatever happens to be there. Leading newlines are the whole fix: the reader
skips them, and the line numbers in the reply are then the buffer's own. The
columns already were, because a top-level form starts at column 1."
(concat (make-string (1- (line-number-at-pos start)) ?\n)
(buffer-substring-no-properties start end)))
(defun flan-dev--defun-bounds ()
"Bounds of the top-level form containing or preceding point, as (START . END)."
(save-excursion
(end-of-defun)
(let ((end (point)))
(beginning-of-defun)
(cons (point) end))))
(defun flan-dev--defun-at-point ()
"The text of the top-level form containing or preceding point."
(let ((b (flan-dev--defun-bounds)))
(buffer-substring-no-properties (car b) (cdr b))))
;;;###autoload
(defun flan-eval-defun ()
"Recompile the top-level form at point and install it in the running program."
(interactive)
(let ((b (flan-dev--defun-bounds)))
(flan-dev--eval (flan-dev--text (car b) (cdr b)) "form")))
;;;###autoload
(defun flan-eval-buffer ()
"Recompile every top-level form in this buffer and install them together.
One module, not one per form: a var and the function that uses it have to
arrive in the same load or the first refers to storage that does not exist."
(interactive)
(flan-dev--eval (buffer-substring-no-properties (point-min) (point-max))
(buffer-name)))
;;;###autoload
(defun flan-eval-last-sexp ()
"Evaluate the expression before point in the running program and show it."
(interactive)
(let ((code (buffer-substring-no-properties
(save-excursion (backward-sexp) (point))
(point))))
(flan-dev--report
(flan-dev--request
(list :op "eval-expr" :code code :file (or buffer-file-name "<buffer>")))
"expression")))
;;;###autoload
(defun flan-eval-region (start end)
"Recompile the top-level forms between START and END."
(interactive "r")
(flan-dev--eval (flan-dev--text start end) "region"))
(provide 'flan-dev)
;;; flan-dev.el ends here