flan/emacs/flan-repl.el

306 lines
14 KiB
EmacsLisp

;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; A buffer to type expressions at, sent to the program `flan dev' is running
;; and answered with the value they had *there*. It adds no protocol and no
;; compiler support: every line goes through the same `eval-expr' request that
;; C-x C-e uses.
;;
;; Derived from `comint-mode', for the same reason `flan-mode' derives from
;; `lisp-mode': history, the input ring, and kill/yank behaviour already exist
;; and are not worth rewriting. There is no subprocess behind it — the
;; "process" is a stub comint needs in order to have a prompt at all.
;;
;; Two things about it that are decisions, not accidents:
;;
;; - **It is program-scoped.** A name typed here resolves against the running
;; program's top-level namespace, so in sand you write `sim/settle' and not
;; `settle'. A buffer visiting a package's own file gets the alias applied
;; for it, because the file says which package it belongs to; a prompt has no
;; file and nothing to derive it from.
;;
;; - **A value and the program's output are different things** and arrive by
;; different routes. The value of the expression appears at the prompt;
;; anything the program printed while evaluating it goes to *flan-output*,
;; riding along on the same reply. Showing them in one place would be
;; convenient and wrong.
;;; Code:
(require 'comint)
(require 'flan-mode)
(require 'flan)
(defcustom flan-repl-buffer "*flan-repl*"
"Name of the Flan REPL buffer."
:type 'string
:group 'flan)
(defcustom flan-repl-history-file (locate-user-emacs-file "flan-repl-history")
"File the prompt's input history is kept in between sessions.
`locate-user-emacs-file' rather than a path of this file's own choosing: it
follows `user-emacs-directory', which is what `no-littering' and anyone who
has moved their state elsewhere have already redirected. nil keeps history
for the session only."
:type '(choice file (const :tag "Do not keep history between sessions" nil))
:group 'flan)
(defcustom flan-repl-history-size 500
"How many inputs the prompt remembers."
:type 'integer
:group 'flan)
(defconst flan-repl--history-separator "\n\f\n"
"What separates two entries in the history file.
Comint separates them with a newline, which is also what is *inside* a form
typed over several lines: written that way, one such form comes back as
several broken ones. A form feed alone on a line cannot occur inside a form,
so the file survives the round trip.
Let-bound around each call rather than set in the buffer, because
`comint-write-input-ring' reads this variable inside a temporary buffer where
a buffer-local value is not visible.")
(defvar flan-repl-prompt "flan> "
"Prompt shown in `flan-repl-mode'.")
(defvar flan-repl-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-o") #'flan-show-output)
(define-key map (kbd "C-c C-d") #'flan-describe)
(define-key map (kbd "C-c C-q") #'flan-disconnect)
(define-key map (kbd "<up>") #'flan-repl-previous-input)
(define-key map (kbd "<down>") #'flan-repl-next-input)
map)
"Keymap for `flan-repl-mode'.")
(define-derived-mode flan-repl-mode comint-mode "Flan-REPL"
"Type expressions; they are evaluated in the running program.
\\{flan-repl-mode-map}"
:syntax-table flan-mode-syntax-table
(setq-local comint-prompt-regexp (concat "^" (regexp-quote flan-repl-prompt)))
(setq-local comint-prompt-read-only t)
(setq-local comint-input-sender #'flan-repl--send)
;; Nothing is echoed back by a process, because there is no process.
(setq-local comint-process-echoes nil)
;; The prompt gets completion, eldoc and M-. for the same names a buffer
;; does, and against the same program: they read the client's cache, which
;; is program-scoped, which is exactly what a prompt is.
(flan-setup)
(setq-local font-lock-defaults '(flan-font-lock-keywords))
(setq-local comint-input-ring-size flan-repl-history-size)
(setq-local comint-input-ring-file-name
(and flan-repl-history-file
(expand-file-name flan-repl-history-file)))
;; comint made the ring before this body ran, sized from the global value.
(setq-local comint-input-ring (make-ring comint-input-ring-size))
(let ((comint-input-ring-separator flan-repl--history-separator))
;; Silent: a first session has no file yet, and saying so is not news.
(comint-read-input-ring t))
(add-hook 'kill-buffer-hook #'flan-repl--save-history nil t))
;;; History
(defun flan-repl--history-entries (file)
"How many entries FILE holds.
Counted rather than trusted to the ring, because the file may have been
written by another Emacs since this buffer read it."
(if (not (file-readable-p file))
0
(with-temp-buffer
(insert-file-contents file)
(let ((n 0))
(goto-char (point-min))
(while (search-forward flan-repl--history-separator nil t)
(setq n (1+ n)))
n))))
(defun flan-repl--save-history ()
"Write this buffer's input ring to `comint-input-ring-file-name'.
Refused in the two cases where writing loses more than it keeps: a ring with
nothing in it, which would empty the file, and a ring shorter than the file
already is, which is what a second Emacs having saved a longer history since
this buffer started looks like. A write replaces the file whole, so either
would throw away somebody's session."
(when (and comint-input-ring-file-name
(ring-p comint-input-ring)
(not (ring-empty-p comint-input-ring))
(>= (ring-length comint-input-ring)
(flan-repl--history-entries comint-input-ring-file-name)))
(let ((comint-input-ring-separator flan-repl--history-separator))
(comint-write-input-ring))))
(defun flan-repl--save-all-history ()
"Save every REPL buffer's history.
`kill-buffer-hook' does not run for a buffer Emacs is exiting out from under,
so quitting without killing the prompt first would otherwise lose the day."
(dolist (buf (buffer-list))
(with-current-buffer buf
(when (derived-mode-p 'flan-repl-mode)
(flan-repl--save-history)))))
;; Not in batch. A batch Emacs exits through `kill-emacs' like any other, and
;; the prompt a test opened is nobody's session: the sweep would write the
;; person running the test their own history file, out of a ring made of
;; fixtures.
(unless noninteractive
(add-hook 'kill-emacs-hook #'flan-repl--save-all-history))
;;; Recall
;; `<up>' at a prompt means history to nearly everyone who has used a shell,
;; and `M-p' means it to nearly nobody who has not used Emacs. But the text
;; above the prompt is a transcript people scroll back through, and a blunt
;; binding would make that impossible — so the key is history only where there
;; is input for it to replace, and plain line motion everywhere else. Inside a
;; form typed over several lines it still moves between them, and reaches
;; history from the first one.
;;
;; The search is prefix-filtered by what is already typed, which is comint's
;; `...-from-input' pair and the same thing a shell's up-arrow does.
(defun flan-repl--input-start ()
"Where the text being typed begins, or nil if there is no process."
(let ((proc (get-buffer-process (current-buffer))))
(and proc (marker-position (process-mark proc)))))
(defun flan-repl--on-first-input-line-p ()
"Is point on the first line of the input, with history above it?"
(let ((start (flan-repl--input-start)))
;; `forward-line' rather than `line-beginning-position': the prompt is a
;; field, and field-constrained motion answers this question by accident
;; rather than by the rule meant here.
(and start (<= start (point))
(<= (save-excursion (forward-line 0) (point)) start))))
(defun flan-repl--on-last-input-line-p ()
"Is point on the last line of the input?"
(let ((start (flan-repl--input-start)))
(and start (<= start (point))
(save-excursion (forward-line 1) (eobp)))))
(defun flan-repl-previous-input (n)
"Recall the previous matching input, or move up a line above the input."
(interactive "p")
(if (flan-repl--on-first-input-line-p)
(progn
;; comint tells a continued search from a fresh one by looking at
;; `last-command' for its own name, which a wrapper's press never
;; leaves there: without the hand-off the second `<up>' searches again
;; from the entry the first one inserted and never moves.
(setq this-command 'comint-previous-matching-input-from-input)
(comint-previous-matching-input-from-input n))
;; Same reason in the other direction: `line-move' keeps the column it is
;; aiming for only while the line-motion commands follow each other.
(setq this-command 'previous-line)
;; `previous-line' and not `forward-line': the byte compiler's advice
;; against it is for code moving over text, and this is a keypress asking
;; for what the key does everywhere else — visual lines, kept column.
(with-no-warnings (previous-line n))))
(defun flan-repl-next-input (n)
"Recall the next matching input, or move down a line above the input."
(interactive "p")
(if (flan-repl--on-last-input-line-p)
(progn
(setq this-command 'comint-next-matching-input-from-input)
(comint-next-matching-input-from-input n))
(setq this-command 'next-line)
(with-no-warnings (next-line n))))
(defun flan-repl--complete-p (text)
"Is TEXT a whole form?
Parens balanced and not inside a string or comment. RET on a half-typed form
should open a line, not send something the reader will reject."
(let ((state (with-temp-buffer
(set-syntax-table flan-mode-syntax-table)
(insert text)
(parse-partial-sexp (point-min) (point-max)))))
(and (<= (nth 0 state) 0) ; depth
(not (nth 3 state)) ; in a string
(not (nth 4 state))))) ; in a comment
(defun flan-repl--output (text)
"Insert TEXT into the REPL buffer above the next prompt."
(let ((proc (get-buffer-process (current-buffer))))
(comint-output-filter proc (concat text "\n" flan-repl-prompt))))
(defun flan-repl--send (_proc text)
"Evaluate TEXT in the running program and show what it was."
(let ((code (string-trim text)))
(cond
((string-empty-p code) (flan-repl--output ""))
(t
(let ((reply (condition-case err
(flan--request
(list :op "eval-expr" :code code :file "<repl>"))
(error (list :status "error"
:message (error-message-string err))))))
(flan-repl--output
(if (equal (plist-get reply :status) "ok")
(or (plist-get reply :value) "")
(concat "error: " (or (plist-get reply :message) "rejected")
(let ((loc (plist-get reply :loc)))
(if loc (concat " (" loc ")") ""))))))))))
(defun flan-repl-return ()
"Send the input if it is a whole form, otherwise open a line."
(interactive)
(let ((input (buffer-substring-no-properties
(process-mark (get-buffer-process (current-buffer)))
(point-max))))
(if (flan-repl--complete-p input)
(progn
;; `comint-send-input' sends the process mark to *point*, and
;; `comint-eol-on-send' carries it no further than the end of one
;; line. Completeness above was judged over the whole input, so the
;; whole input is what goes: otherwise RET in the middle of a form
;; typed over several lines, or on one just recalled — history leaves
;; point where the typing stopped — sends a fragment the reader will
;; reject.
(goto-char (point-max))
(comint-send-input))
(insert "\n"))))
(define-key flan-repl-mode-map (kbd "RET") #'flan-repl-return)
;;;###autoload
(defun flan-repl ()
"Open a prompt on the program `flan dev' is running.
Connects first if it has to."
(interactive)
(unless (and flan--connection (process-live-p flan--connection))
(call-interactively #'flan-connect))
(let ((buf (get-buffer-create flan-repl-buffer)))
(with-current-buffer buf
(unless (derived-mode-p 'flan-repl-mode)
(flan-repl-mode)
;; comint wants a process to hang a prompt and a process mark off.
;; There is nothing to run, so this one does nothing and is never
;; written to; every request goes over the daemon's socket instead.
(let ((proc (start-process "flan-repl" buf "cat")))
(set-process-query-on-exit-flag proc nil)
(comint-output-filter proc flan-repl-prompt))))
(pop-to-buffer buf)))
(provide 'flan-repl)
;;; flan-repl.el ends here