;;; 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 ""))) 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 ""))) "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