diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 0dfae9b..8272755 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -120,11 +120,28 @@ list already says it." (defun flan-cnr--section (title) (insert (propertize (format "--- %s\n" title) 'face 'font-lock-comment-face))) +(defconst flan-cnr-breakpoint "Pause" + "The condition `(pause)' signals. +`lib/prelude.ml' writes it as an ordinary struct under a `restart-case', so +nothing in the compiler knows a breakpoint from an error and this buffer is the +first place that can tell the difference. Named here rather than spelled at +its use, because it is a fact about the prelude.") + (defun flan-cnr--insert-condition (state) - (let ((name (or (plist-get state :condition) "a condition"))) - (insert (propertize name 'face 'error)) - (insert (propertize " — unhandled; stopped on the frame that erred, nothing unwound\n" - 'face 'shadow))) + (let* ((name (or (plist-get state :condition) "a condition")) + ;; A breakpoint is a stop, not a failure. Everything underneath is + ;; identical — `(pause)' *is* `error' under a `restart-case', which is + ;; what a breakpoint is in a language that has conditions — so the + ;; only thing that can be wrong here is the word for it. Calling a + ;; breakpoint unhandled would be a small lie told at the top of the + ;; one buffer that exists to say what happened. + (paused (equal name flan-cnr-breakpoint))) + (insert (propertize name 'face (if paused 'warning 'error))) + (insert (propertize + (if paused + " — a breakpoint; stopped where (pause) was called, nothing unwound\n" + " — unhandled; stopped on the frame that erred, nothing unwound\n") + 'face 'shadow))) (insert "\n") (flan-cnr--section "Condition fields:") ;; Two refusals, not one, and keeping them apart is the point. The *shape* of diff --git a/emacs/flan-dev.el b/emacs/flan-dev.el index a1e8c03..232f6df 100644 --- a/emacs/flan-dev.el +++ b/emacs/flan-dev.el @@ -135,6 +135,90 @@ reply that request was waiting for.") ;; scrolled back is reading something. (when at-end (goto-char (point-max))))))) +;; The break buffer, which this file shows but does not draw. An autoload +;; rather than a `require': flan-cnr.el reaches the daemon through this file, +;; so requiring it here would be a cycle, and it is wanted only at the moment +;; a program stops. +(autoload 'flan-cnr-show "flan-cnr" nil t) + +;;; Opening the break buffer when the program stops + +;; The mode line and one echo-area line were the whole of it: the buffer that +;; says what happened and what can be done about it appeared only when `C-c +;; C-b' was typed. `flan-dev--absorb' below already knows the moment — it +;; reads `:stopped' off every reply, and the poll covers the case where no +;; reply is coming — so this is a hook at a point that exists rather than new +;; plumbing. +;; +;; Three things had to be settled to build it, and they are settled here. +;; +;; **It displays, it does not select.** A program stops on its own clock, not +;; the editor's: the likeliest moment is in a frame of its own game loop while +;; someone is typing in another buffer. Taking the window would send the next +;; keystrokes somewhere they were not aimed, and `q' in a break buffer is not +;; what a half-typed word wanted to be. So `display-buffer': the buffer +;; appears, point does not move, and the window that had focus keeps it. +;; `flan-dev-break-on-stop' can be set to `focus' by anyone who disagrees, and +;; to nil to go back to the mode line alone. +;; +;; **`(pause)' is not a special case.** It was worth asking — a breakpoint is +;; deliberate where an error is not, so it could be argued it has earned the +;; window. It has not, and for the reason above: `(pause)' is deliberate at +;; the moment it was *written*, and the frame it fires on still arrives +;; whenever the program gets there. Nothing about that is more expected than +;; an error, from the point of view of the hands on the keyboard. What it does +;; get is an honest headline — `Pause' is a stop and not a failure, and the +;; break buffer says so rather than calling it unhandled. +;; +;; **A stop that arrives mid-edit disturbs nothing**, which falls out of +;; displaying rather than selecting. Two guards go beyond that. Nothing +;; happens while the minibuffer is active, because a prompt is a modal thing +;; someone is in the middle of and rearranging windows under it is hostile; +;; and nothing happens while a keyboard macro is running, because a macro that +;; behaves differently depending on whether the program happened to stop is a +;; macro that cannot be trusted. In both cases the mode line still says +;; stopped and `C-c C-b' still works, so nothing is lost but the automatic +;; part. + +(defcustom flan-dev-break-on-stop 'display + "What to do when the program stops. +`display' shows the break buffer without taking focus, `focus' shows it and +selects its window, and nil leaves it to the mode line and `C-c C-b'." + :type '(choice (const :tag "Show it" display) + (const :tag "Show it and go there" focus) + (const :tag "Only the mode line" nil))) + +(defun flan-dev--auto-break () + "Show the break buffer, if the program is still stopped and it is safe to. +Runs from a timer, deliberately: `flan-dev--absorb' notices the stop in the +middle of reading a reply on the socket, with `flan-dev--busy' bound, and +`flan-cnr-show' asks the daemon three more questions. Issuing those from +inside the read they were triggered by would interleave two conversations on +one connection. + +The check is on the state rather than on the edge that scheduled this. By the +time this runs the edge has been consumed, `flan-cnr-show''s own `break' has +been through `flan-dev--absorb' again, and the program may have been resumed in +between — so what matters is whether it is stopped *now*." + (when (and flan-dev--stopped + flan-dev-break-on-stop + (not flan-dev--busy) + (process-live-p flan-dev--connection) + ;; Someone is in the middle of answering a prompt. + (not (active-minibuffer-window)) + ;; A macro must do the same thing every time it is run. + (not (or executing-kbd-macro defining-kbd-macro))) + ;; Errors are swallowed on purpose. This is a timer nobody asked to run, + ;; and a daemon that refuses it has already said so through the mode line; + ;; signalling here would put an error in the echo area in place of the + ;; message naming the condition, which is the more useful of the two. + (ignore-errors + (save-selected-window + (let ((buf (flan-cnr-show))) + (when (and (eq flan-dev-break-on-stop 'focus) (buffer-live-p buf)) + (let ((win (get-buffer-window buf))) + (when win (select-window win))))))))) + (defun flan-dev--absorb (reply) "Take from REPLY the two things every reply carries, and return it. The program's output, and whether it is stopped. Both are read here rather @@ -152,7 +236,11 @@ with it, and a rejected evaluation is a likely moment to *become* stopped." ;; echo area was saying, every second, for as long as the program sat ;; there. (when now - (message "flan: stopped on %s — C-c C-b to choose a restart" now)))) + (message "flan: stopped on %s — C-c C-b to choose a restart" now) + ;; On the edge, and out of band. See `flan-dev--auto-break' for why + ;; it cannot happen here: this runs inside the read of a reply on the + ;; socket, and showing the buffer asks three more questions down it. + (run-at-time 0 nil #'flan-dev--auto-break)))) reply) (defun flan-dev--request (form) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index 1506760..bb1bdfd 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -18,6 +18,9 @@ (require 'flan-inspect) (require 'flan-cnr) +;; For `flan-dev--auto-break', which is a decision about globals and windows +;; and needs neither a daemon nor a socket to be asked. +(require 'flan-dev) (defvar test-flan--failures 0) (defvar test-flan--ran 0) @@ -613,6 +616,79 @@ (or (test-flan--caught #'flan-cnr-show) "")))) +;;; Opening the break buffer by itself + +;; `flan-dev--auto-break' is a decision — given the state the client is in, +;; does the break buffer appear — so it is asked here with the state bound +;; rather than by stopping a real program. Every guard in it exists because +;; the answer is no in some state a person is actually in. + +(message "\nthe break buffer opening itself") + +(defun test-flan--auto-break (&rest bindings) + "Non-nil if `flan-dev--auto-break' would show the buffer. +BINDINGS is a plist of extra state; the defaults are a live connection and a +stopped program, which is the case where it should fire." + (let ((shown nil)) + (cl-letf (((symbol-function 'flan-cnr-show) (lambda () (setq shown t) nil)) + ;; A process object that is not live, and `process-live-p' of a + ;; non-process is nil — so the default has to be overridden by + ;; the caller, which is what `:live' does. + ((symbol-function 'process-live-p) + (lambda (_) (if (plist-member bindings :live) + (plist-get bindings :live) t)))) + (let ((flan-dev--stopped + (if (plist-member bindings :stopped) + (plist-get bindings :stopped) "Missing")) + (flan-dev-break-on-stop + (if (plist-member bindings :setting) + (plist-get bindings :setting) 'display)) + (flan-dev--busy (plist-get bindings :busy)) + (executing-kbd-macro (plist-get bindings :macro)) + (flan-dev--connection 'stub)) + (flan-dev--auto-break))) + shown)) + +(test-flan--check "a stopped program opens it" + (test-flan--auto-break)) +(test-flan--check "a running one does not" + (not (test-flan--auto-break :stopped nil))) +(test-flan--check "nor does a connection that has gone" + (not (test-flan--auto-break :live nil))) +;; The reason it is deferred at all: `flan-dev--absorb' notices the stop in the +;; middle of reading a reply, and three more requests down the same socket +;; would interleave two conversations. +(test-flan--check "nor while a request is still in flight" + (not (test-flan--auto-break :busy t))) +;; A prompt is modal and someone is in the middle of it. +(test-flan--check "nor under an active minibuffer" + (let ((probe (lambda (&rest _) t))) + (advice-add 'active-minibuffer-window :override probe) + (unwind-protect (not (test-flan--auto-break)) + (advice-remove 'active-minibuffer-window probe)))) +;; A macro must do the same thing every time it is run, and whether the program +;; happened to stop during it is not something a macro can depend on. +(test-flan--check "nor inside a keyboard macro" + (not (test-flan--auto-break :macro t))) +(test-flan--check "and not at all when it is turned off" + (not (test-flan--auto-break :setting nil))) + +;; `(pause)' is a stop and not a failure, and it is the one thing about it that +;; the buffer gets to say differently. Everything underneath is identical. +(let ((text (test-flan--text + (lambda () (flan-cnr--insert-condition + (list :condition flan-cnr-breakpoint)))))) + (test-flan--check "a breakpoint is called a breakpoint" + (string-match-p "a breakpoint; stopped where (pause)" text)) + (test-flan--check "and is not called unhandled" + (not (string-match-p "unhandled" text)))) + +(let ((text (test-flan--text + (lambda () (flan-cnr--insert-condition '(:condition "Missing")))))) + (test-flan--check "while an error still is" + (string-match-p "unhandled" text))) + + ;; `flan-mode' itself — indentation, which is a function from text to text and ;; so belongs with the other fixture-driven checks rather than with anything ;; that needs a daemon. Loaded rather than run separately because