diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el new file mode 100644 index 0000000..525aaa8 --- /dev/null +++ b/emacs/flan-cnr.el @@ -0,0 +1,418 @@ +;;; flan-cnr.el --- What a stopped program is offering -*- lexical-binding: t; -*- + +;; `C-c C-b' is a `completing-read' over restart names. That is the whole UI +;; for the one moment the dev loop exists to make survivable, and it is thin in +;; a way that is worth being specific about: it shows the names and nothing +;; else — not the condition's fields, not where the error was, not what the +;; frame between here and the restart was doing — and it will happily let you +;; choose a name that cannot be invoked. This is the buffer that replaces it. +;; +;; Two sources, and they answer different questions. +;; +;; **SBCL** answers *what to put first*. `invoke-debugger' prints the +;; condition, then `show-restarts', and stops. The backtrace is a command you +;; type, not something it shows you. That ordering is a claim: the restarts +;; are the decision in front of you and the stack is the explanation for it, +;; and a debugger that opens with forty frames has buried the decision under +;; the explanation. So: condition, restarts, then the stack, and the stack's +;; locals folded away until asked for. +;; +;; `show-restarts' also numbers them and brackets the name — and omits the +;; bracket on a name already used further in. That device is not decoration. +;; A restart is invoked by name; the name resolves to the innermost frame +;; offering it; so a second frame offering `retry' is real, is on the list, and +;; **cannot be chosen by name**. spec-conditions.md §4 says exactly this about +;; Flan ("takes the first frame offering the name"), and `flan_find_restart' +;; does exactly this in the runtime. Today's `completing-read' hides it: the +;; list has `retry' in it twice, picking either sends the string "retry", and +;; the inner one runs. Here the shadowed entry is drawn and refused, by name, +;; with the reason. +;; +;; **CIDER's stacktrace buffer** answers *how it should behave*: frames that +;; expand in place, TAB to fold, everything reachable from the keyboard, +;; `q' to go. What was not taken from it is the cause chain — CIDER walks +;; `ex-cause' because a JVM exception wraps another one, and a Flan condition +;; wraps nothing — and its filters, which exist because a JVM backtrace is +;; mostly frames nobody wrote. +;; +;; Everything this buffer cannot fill in is drawn as a section that says so, by +;; name, with what it would take. A missing section is indistinguishable from +;; a section that happened to be empty, and only one of those is a fact about +;; the program. + +;;; Code: + +(require 'seq) +(require 'subr-x) + +(declare-function flan-dev--request "flan-dev" (form)) +(declare-function flan-inspect "flan-inspect" (expr)) + +(defgroup flan-cnr nil + "The conditions and restarts buffer." + :prefix "flan-cnr-" + :group 'flan) + +(defcustom flan-cnr-buffer "*flan-conditions*" + "Where the break buffer draws." + :type 'string) + +(defvar flan-cnr-request-function #'flan-dev--request + "How this buffer reaches the daemon. +One plist in, the reply plist out. A variable so the renderers can be driven +from fixtures, and so `flan-dev.el' is named in one place.") + +;;; What is not available, and why + +;; Said in one table rather than at each use, because these are claims about +;; the protocol and they should be revisable in one place when a verb lands. +;; Each carries its status in the same three words the design uses. + +(defconst flan-cnr-unavailable + '((layout + . "which struct the class name refers to [needs a daemon op: `layout'. The field names and types are in `Tast.structs', which the daemon holds because it owns the build — no running program is involved. It is the one piece of this buffer that is a day's work on one side of the socket]") + (value + . "the break loop is handed the condition as an opaque pointer, and `break_loop' currently discards it; nothing at run time can render a value whose type it does not know [needs an agent verb: `condition', to stash and hand back the pointer, and a daemon-built render thunk aimed at it]") + (site + . "a restart frame carries its name and the hash matching uses, and no location [needs a location in the restart frame, which the compiler must emit]") + (params + . "restart arguments are checked at run time against a frame that does not record its arity [needs a field in the restart frame]") + (stack + . "a Flan build carries no frame metadata, so there is nothing to walk the stopped stack with [blocked: needs DWARF]") + (locals + . "reading a stopped frame's locals needs both the frame layout and a renderer aimed at an address rather than at an expression [blocked: needs DWARF, and the same thunk the condition's fields need]")) + "Why a section of this buffer is empty, by name.") + +(defun flan-cnr--why (key) + (or (cdr (assq key flan-cnr-unavailable)) "not implemented")) + +;;; Restarts, and which of them can actually be chosen + +(defun flan-cnr-annotate-restarts (names) + "Turn NAMES — innermost first — into what the buffer draws. +Each entry is (INDEX NAME SHADOWED-BY), where SHADOWED-BY is the index of the +earlier entry that owns the name, or nil. + +This is §4's walk, computed here because it can be: `restart NAME' resolves +through `flan_find_restart', which returns the first frame whose hash matches, +so a name's second appearance is unreachable through the only verb there is. +Nothing new has to be asked of the program to know that — the order of the +list already says it." + (let ((seen nil) (i -1)) + (mapcar (lambda (name) + (setq i (1+ i)) + (let ((owner (cdr (assoc name seen)))) + (unless owner (push (cons name i) seen)) + (list i name owner))) + names))) + +;;; Drawing + +(defvar-local flan-cnr--state nil + "The plist this buffer was last drawn from.") +(defvar-local flan-cnr--open nil + "Indices of the frames whose locals are showing.") + +(defun flan-cnr--unavailable (key) + (propertize (concat " not available — " (flan-cnr--why key) "\n") + 'face 'font-lock-comment-face)) + +(defun flan-cnr--section (title) + (insert (propertize (format "--- %s\n" title) 'face 'font-lock-comment-face))) + +(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))) + (insert "\n") + (flan-cnr--section "Condition fields:") + ;; Two refusals, not one, and keeping them apart is the point. The *shape* of + ;; a condition — its field names and their types — is in `Tast.structs', + ;; which the daemon holds because it owns the build; no running program is + ;; involved in answering it. Only the *values* need the pointer the break + ;; loop was handed. So a field can be named and typed while its value is + ;; refused, which is strictly more than saying nothing, and it is what tells + ;; you whether the :path you were about to blame is even a field of this + ;; condition. + (let ((fields (plist-get state :fields))) + (if (null fields) + (insert (flan-cnr--unavailable 'layout)) + (let ((w (apply #'max 4 (mapcar (lambda (f) (length (nth 0 f))) fields))) + (tw (apply #'max 4 (mapcar (lambda (f) (length (or (nth 1 f) ""))) fields)))) + (dolist (f fields) + (let ((start (point)) + (name (nth 0 f)) (type (or (nth 1 f) "?")) (value (nth 2 f))) + (insert (format " :%s%s %s%s " name + (make-string (- w (length name)) ?\s) + (propertize type 'face 'font-lock-type-face) + (make-string (- tw (length type)) ?\s))) + (insert (if value value + (propertize (concat "value not available — " + (flan-cnr--why 'value)) + 'face 'font-lock-comment-face)) + "\n") + (add-text-properties start (point) + (list 'flan-cnr-inspect nil + 'mouse-face 'highlight))))))) + (insert "\n")) + +(defun flan-cnr--insert-restarts (state) + (flan-cnr--section "Restarts (innermost first) — RET or a digit takes one:") + (let* ((names (plist-get state :restarts)) + (rows (flan-cnr-annotate-restarts names))) + (if (null rows) + (insert (propertize + " none are active. Nothing between the error and the top offered one; abort, or fix a body and reload\n" + 'face 'font-lock-comment-face)) + (let ((w (apply #'max 4 (mapcar (lambda (r) (length (nth 1 r))) rows)))) + (dolist (r rows) + (let* ((i (nth 0 r)) (name (nth 1 r)) (owner (nth 2 r)) + (start (point))) + ;; SBCL's bracket: it is there when the name reaches this frame and + ;; gone when it does not. + (insert (format " %2d: %s%s%s " i + (if owner " " "[") + (propertize name 'face + (if owner 'shadow 'font-lock-keyword-face)) + (if owner " " "]"))) + (insert (make-string (- w (length name)) ?\s)) + (if owner + (insert (propertize + (format "shadowed by %d — `restart' resolves a name to the innermost frame offering it, so this one cannot be taken by name [needs an agent verb: `restart-at INDEX']" owner) + 'face 'font-lock-comment-face)) + (insert (propertize (flan-cnr--why 'site) 'face 'shadow))) + (insert "\n") + (add-text-properties start (point) + (list 'flan-cnr-restart (if owner nil name) + 'flan-cnr-shadowed owner + 'flan-cnr-index i + 'mouse-face 'highlight)))))) + ;; Last, and on the same list, because it is the same decision: what you + ;; pick when none of the restarts is the answer. + (let ((start (point))) + (insert (format " %2d: [%s] " (length rows) + (propertize "abort" 'face 'error))) + (insert (propertize "let the program die where it stopped; this ends `flan dev' too\n" + 'face 'shadow)) + (add-text-properties start (point) + (list 'flan-cnr-abort t 'mouse-face 'highlight)))) + (insert "\n")) + +(defun flan-cnr--insert-stack (state) + (flan-cnr--section "Stack (innermost first) — TAB folds a frame's locals:") + (let ((frames (plist-get state :stack))) + (if (null frames) + (insert (flan-cnr--unavailable 'stack)) + (let ((i -1)) + (dolist (fr frames) + (setq i (1+ i)) + (let ((start (point)) + (open (memq i flan-cnr--open))) + (insert (format " %2d: %s %s%s\n" i + (if open "v" ">") + (propertize (or (plist-get fr :fn) "?") + 'face 'font-lock-function-name-face) + (if (plist-get fr :loc) + (propertize (format " %s" (plist-get fr :loc)) + 'face 'shadow) + ""))) + (add-text-properties start (point) + (list 'flan-cnr-frame i 'mouse-face 'highlight))) + ;; Collapsed by default. A stopped program has as many locals as it + ;; has frames, and all of them at once is the backtrace problem again + ;; one level down. + (when (memq i flan-cnr--open) + (let ((locals (plist-get fr :locals))) + (if (null locals) + (insert (flan-cnr--unavailable 'locals)) + (dolist (l locals) + (let ((start (point))) + (insert (format " %s %s = %s\n" + (propertize (nth 1 l) 'face 'font-lock-type-face) + (nth 0 l) (nth 2 l))) + (add-text-properties start (point) + (list 'flan-cnr-inspect (nth 0 l) + 'mouse-face 'highlight))))))))))) + (insert "\n")) + +(defun flan-cnr--render (state) + "Draw STATE, a plist, into the current buffer." + (let ((inhibit-read-only t)) + (erase-buffer) + (setq flan-cnr--state state) + ;; SBCL's order: what happened, what you can do about it, then why. + (flan-cnr--insert-condition state) + (flan-cnr--insert-restarts state) + (flan-cnr--insert-stack state) + (insert (propertize + "RET/0-9 take a abort TAB fold a frame i inspect g refresh q quit\n" + 'face 'shadow)) + (goto-char (point-min)))) + +;;; Commands + +(defun flan-cnr--refuse-shadowed () + (user-error + "flan: restart %d is shadowed by %d; `restart' takes a name, and this name reaches the inner frame. Taking this one needs an index verb the daemon does not have" + (get-text-property (point) 'flan-cnr-index) + (get-text-property (point) 'flan-cnr-shadowed))) + +(defun flan-cnr-take () + "Take the restart on this line." + (interactive) + (cond + ((get-text-property (point) 'flan-cnr-abort) (flan-cnr-abort)) + ((get-text-property (point) 'flan-cnr-shadowed) (flan-cnr--refuse-shadowed)) + ((get-text-property (point) 'flan-cnr-restart) + (flan-cnr--invoke (get-text-property (point) 'flan-cnr-restart))) + ((get-text-property (point) 'flan-cnr-frame) (flan-cnr-toggle-frame)) + (t (user-error "flan: nothing to take on this line")))) + +(defun flan-cnr--invoke (name) + (let ((r (funcall flan-cnr-request-function (list :op "restart" :name name)))) + (if (equal (plist-get r :status) "ok") + ;; Accepted, not resumed — the choice is validated against the stopped + ;; stack and taken when that thread next comes round its loop. So the + ;; buffer says so and goes, rather than redrawing a state that is about + ;; to stop being true. + (progn (message "flan: %s — %s" name (or (plist-get r :note) "accepted")) + (quit-window)) + (user-error "flan: %s" (or (plist-get r :message) "refused"))))) + +(defun flan-cnr-take-number (n) + "Take restart number N, as SBCL's debugger does." + (interactive (list (- last-command-event ?0))) + (save-excursion + (goto-char (point-min)) + (let ((found nil)) + (while (and (not found) (not (eobp))) + (when (and (equal n (get-text-property (point) 'flan-cnr-index)) + (or (get-text-property (point) 'flan-cnr-restart) + (get-text-property (point) 'flan-cnr-shadowed))) + (setq found t)) + (unless found (forward-line 1))) + (if found (flan-cnr-take) + ;; The abort line's number is one past the last restart. + (let ((names (plist-get flan-cnr--state :restarts))) + (if (= n (length names)) (flan-cnr-abort) + (user-error "flan: there is no restart %d" n))))))) + +(defun flan-cnr-abort () + "Let the stopped program die where it stopped." + (interactive) + (let ((r (funcall flan-cnr-request-function '(:op "abort")))) + (if (equal (plist-get r :status) "ok") + (progn (message "flan: %s" (or (plist-get r :note) "aborted")) (quit-window)) + (user-error "flan: %s" (or (plist-get r :message) "refused"))))) + +(defun flan-cnr-toggle-frame () + "Show or hide this frame's locals." + (interactive) + (let ((i (get-text-property (point) 'flan-cnr-frame))) + (unless i (user-error "flan: point is not on a frame")) + (setq flan-cnr--open + (if (memq i flan-cnr--open) (delq i flan-cnr--open) + (cons i flan-cnr--open))) + (let ((line (line-number-at-pos))) + (flan-cnr--render flan-cnr--state) + (goto-char (point-min)) + (forward-line (1- line))))) + +(defun flan-cnr-inspect () + "Open the inspector on the thing at point." + (interactive) + (let ((expr (get-text-property (point) 'flan-cnr-inspect))) + (unless expr + (user-error "flan: %s" (flan-cnr--why 'locals))) + (require 'flan-inspect) + (flan-inspect expr))) + +(defun flan-cnr-refresh () + "Ask the program again what it is offering." + (interactive) + (flan-cnr-show)) + +(defun flan-cnr-next () + "Move to the next line that does something." + (interactive) + (let ((p (point))) + (forward-line 1) + (while (and (not (eobp)) (not (flan-cnr--actionable-p (point)))) + (forward-line 1)) + (when (eobp) (goto-char p)))) + +(defun flan-cnr-previous () + "Move to the previous line that does something." + (interactive) + (let ((p (point))) + (forward-line -1) + (while (and (not (bobp)) (not (flan-cnr--actionable-p (point)))) + (forward-line -1)) + (unless (flan-cnr--actionable-p (point)) (goto-char p)))) + +(defun flan-cnr--actionable-p (pos) + (or (get-text-property pos 'flan-cnr-restart) + (get-text-property pos 'flan-cnr-shadowed) + (get-text-property pos 'flan-cnr-abort) + (get-text-property pos 'flan-cnr-frame) + (get-text-property pos 'flan-cnr-inspect))) + +(defvar flan-cnr-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "RET") #'flan-cnr-take) + (define-key map [mouse-1] #'flan-cnr-take) + (define-key map (kbd "TAB") #'flan-cnr-next) + (define-key map [backtab] #'flan-cnr-previous) + (define-key map "n" #'flan-cnr-next) + (define-key map "p" #'flan-cnr-previous) + (define-key map "f" #'flan-cnr-toggle-frame) + (define-key map "i" #'flan-cnr-inspect) + (define-key map "a" #'flan-cnr-abort) + (define-key map "g" #'flan-cnr-refresh) + (define-key map "q" #'quit-window) + ;; Numbered, as SBCL's are, and for SBCL's reason: the names are not + ;; unique, so the number is the only unambiguous handle a person has. + (dotimes (i 10) + (define-key map (kbd (number-to-string i)) #'flan-cnr-take-number)) + map) + "Keys in `flan-cnr-mode'.") + +(define-derived-mode flan-cnr-mode special-mode "flan-break" + "What a stopped Flan program is offering." + (setq buffer-read-only t)) + +(defun flan-cnr-state-from-reply (reply) + "The buffer's state, out of a `break' REPLY. +Everything this cannot fill in is left nil deliberately: the renderer draws a +section saying why rather than leaving one out, and a section that is absent +cannot be told from one that is empty." + (list :condition (plist-get reply :condition) + :restarts (plist-get reply :restarts) + ;; Both nil deliberately: `break' answers a class name and a list of + ;; restart names, and nothing else exists to put here yet. The renderer + ;; draws a section saying why rather than leaving one out. + :fields nil + :stack nil + :locals nil)) + +;;;###autoload +(defun flan-cnr-show () + "Show what the stopped program is offering, in a buffer. +Refuses while the program is running, by name: there is no restart stack to +walk from a running program." + (interactive) + (let ((r (funcall flan-cnr-request-function '(:op "break")))) + (unless (equal (plist-get r :status) "ok") + (user-error "flan: %s" (or (plist-get r :message) "refused"))) + (unless (plist-get r :stopped) + (user-error "flan: the program is running; there is no restart stack to walk")) + (let ((buf (get-buffer-create flan-cnr-buffer))) + (with-current-buffer buf + (unless (derived-mode-p 'flan-cnr-mode) (flan-cnr-mode)) + (flan-cnr--render (flan-cnr-state-from-reply r))) + (pop-to-buffer buf) + buf))) + +(provide 'flan-cnr) +;;; flan-cnr.el ends here diff --git a/emacs/flan-inspect.el b/emacs/flan-inspect.el new file mode 100644 index 0000000..74d1e48 --- /dev/null +++ b/emacs/flan-inspect.el @@ -0,0 +1,452 @@ +;;; flan-inspect.el --- Navigate a running program's values -*- lexical-binding: t; -*- + +;; `C-x C-e' renders a value once and puts it in the echo area. This is the +;; interactive version of the same walk: the fields laid out one per line, RET +;; to go into one, `l' to come back, `g' to read it again. CIDER's inspector, +;; adapted — and the adaptation is the whole design, so it is worth stating +;; what changed and why. +;; +;; CIDER's inspector keeps its stack **on the server**. `inspect-push' hands +;; the middleware an index and the middleware walks into the object it is +;; already holding; the client's own stack is only remembered point positions. +;; That is available to it because a JVM value can be retained: the middleware +;; keeps a reference and the collector leaves it alone. +;; +;; Nothing here can do that. A Flan value has no header, the thunk that +;; rendered it is `dlclose'd the moment it returns, and there is no heap to +;; retain anything in. So the stack is a stack of **expressions**, on this +;; side, and going into a field means sending a *different expression* — +;; `(.pos b)' where the last one was `b'. Two consequences, one good and one +;; that has to be said out loud: +;; +;; the view is never stale. Every step and every `g' reads the program as +;; it is now, at a frame boundary it agreed to stop on. CIDER's inspector +;; shows you the object as it was when you pushed; +;; +;; and the root expression runs again on every step. Appending a field +;; accessor to it is pure, but the root need not be — `(spawn-enemy)' as a +;; root spawns one per keystroke. Which is why there is no auto-refresh and +;; why `g' is a key someone presses. +;; +;; The other thing this buys, and the reason it is worth having at all next to +;; `C-x C-e': the renderer bounds its walk at depth 4 and span 8 +;; (lib/session.ml). A field past either bound comes back as `...' and no +;; amount of squinting at the echo area recovers it. Re-rooting the walk at +;; that field renders it from depth 0 — the bound moves with you. + +;;; Code: + +(require 'seq) +(require 'subr-x) + +(declare-function flan-dev--request "flan-dev" (form)) + +(defgroup flan-inspect nil + "Navigating values in a running Flan program." + :prefix "flan-inspect-" + :group 'flan) + +(defcustom flan-inspect-buffer "*flan-inspect*" + "Where the inspector draws." + :type 'string) + +(defvar flan-inspect-request-function #'flan-dev--request + "How the inspector reaches the program. +Called with one plist — a request — and returning the reply plist. It is a +variable rather than a direct call so that a test can hand the renderers a +reply without a daemon behind them, and so that this file names +`flan-dev.el' in exactly one place.") + +;;; Reading what the renderer wrote + +;; The value comes back as a string, and that string is very nearly an +;; s-expression — `(Blob {:id 7 :pos (V {:x 1.5 :y 0})})'. Nearly, because +;; Emacs' `read' has no `{', so it is parsed here instead. The grammar is +;; small and fixed by `Session.render' (lib/session.ml), and every case below +;; names the line of it that produces it. +;; +;; (Name {:f V :f V}) a struct, with ` ...' before the `}' if the walk hit +;; its span bound of 8 fields +;; [ V V V] an array or a slice, ` ...' likewise +;; (some V) / none an option +;; a pointer, never followed +;; a named type the walk had no structure for +;; ... the depth bound, 4, reached at this position +;; :name an enum member, or its number if it matched none +;; "…" a string, escaped in C +;; true / false / () bool, and the value of a Unit expression +;; 1.5 / 7 / 18446… a number +;; +;; A node is a plist: :kind, :text (what the renderer wrote for it), :type +;; where there is one, and :children as a list of (LABEL . NODE). + +(defun flan-inspect--skip-space (s i) + (while (and (< i (length s)) (memq (aref s i) '(?\s ?\n ?\t))) (setq i (1+ i))) + i) + +(defun flan-inspect--read-string (s i) + "Read a quoted string starting at I (which is the opening quote)." + (let ((out (list ?\")) (i (1+ i)) (done nil)) + (while (and (not done) (< i (length s))) + (let ((c (aref s i))) + (cond ((eq c ?\\) + (setq i (1+ i)) + (when (< i (length s)) (push (aref s i) out) (setq i (1+ i)))) + ((eq c ?\") (push ?\" out) (setq i (1+ i)) (setq done t)) + (t (push c out) (setq i (1+ i)))))) + (cons (list :kind 'atom :text (concat (nreverse out))) i))) + +(defun flan-inspect--read-atom (s i) + "Read a bare token at I: a number, a keyword, `true', `none', `...'." + (let ((start i)) + (while (and (< i (length s)) + (not (memq (aref s i) '(?\s ?\n ?\t ?\) ?\] ?\})))) + (setq i (1+ i))) + (let ((text (substring s start i))) + (cons (list :kind (if (equal text "...") 'trunc 'atom) :text text) i)))) + +(defun flan-inspect--read-angle (s i) + "Read `' or `' at I." + (let ((end (or (string-match ">" s i) (1- (length s))))) + (let ((text (substring s i (1+ end)))) + (cons (list :kind (if (equal text "") 'ptr 'opaque) :text text) + (1+ end))))) + +(defun flan-inspect--read-seq (s i) + "Read `[ V V]' at I, which is `[' — an array or a slice." + (let ((i (1+ i)) (kids nil) (n 0) (more nil) (done nil)) + (while (not done) + (setq i (flan-inspect--skip-space s i)) + (cond + ((>= i (length s)) (setq done t)) + ((eq (aref s i) ?\]) (setq i (1+ i)) (setq done t)) + (t (let ((r (flan-inspect--read s i))) + (setq i (cdr r)) + ;; A bare `...' inside a sequence is the renderer saying it stopped, + ;; not an element. It is the last thing it writes either way. + (if (eq (plist-get (car r) :kind) 'trunc) + (setq more t) + (push (cons n (car r)) kids) + (setq n (1+ n))))))) + (cons (list :kind 'seq + :text (format "%d element%s%s" n (if (= n 1) "" "s") + (if more ", and more the renderer did not write" "")) + :truncated more + :children (nreverse kids)) + i))) + +(defun flan-inspect--read-struct (s i) + "Read `(Name {…})' or `(some V)' at I, which is `('." + (let ((j (1+ i))) + (let ((start j)) + (while (and (< j (length s)) (not (memq (aref s j) '(?\s ?\))))) (setq j (1+ j))) + (let ((head (substring s start j))) + (cond + ;; (some V). There is no accessor form in Flan that reaches an + ;; option's payload — the compiler gets at it as field 1 and nothing + ;; in the surface language does — so this parses, prints, and refuses + ;; to be entered. + ((equal head "some") + (let* ((r (flan-inspect--read s (flan-inspect--skip-space s j))) + (k (flan-inspect--skip-space s (cdr r)))) + (cons (list :kind 'option :text "some" :type "Option" + :children (list (cons "some" (car r)))) + (if (and (< k (length s)) (eq (aref s k) ?\))) (1+ k) k)))) + (t + ;; `(Name {' then `:field VALUE' pairs, then `})'. + (setq j (flan-inspect--skip-space s j)) + (when (and (< j (length s)) (eq (aref s j) ?\{)) (setq j (1+ j))) + (let ((kids nil) (more nil) (done nil)) + (while (not done) + (setq j (flan-inspect--skip-space s j)) + (cond + ((>= j (length s)) (setq done t)) + ((eq (aref s j) ?\}) (setq j (1+ j)) (setq done t)) + ((eq (aref s j) ?:) + (let ((start j)) + (while (and (< j (length s)) (not (memq (aref s j) '(?\s ?\})))) + (setq j (1+ j))) + (let ((name (substring s (1+ start) j)) + (r (flan-inspect--read s (flan-inspect--skip-space s j)))) + (setq j (cdr r)) + (push (cons name (car r)) kids)))) + (t (let ((r (flan-inspect--read s j))) + (setq j (cdr r)) + (if (eq (plist-get (car r) :kind) 'trunc) + (setq more t) + ;; Not a `:field' and not `...'. Rather than guess, keep + ;; it where it was written. + (push (cons "?" (car r)) kids)))))) + (setq j (flan-inspect--skip-space s j)) + (when (and (< j (length s)) (eq (aref s j) ?\))) (setq j (1+ j))) + (cons (list :kind 'struct :text head :type head + :truncated more + :children (nreverse kids)) + j)))))))) + +(defun flan-inspect--read (s i) + "Read one rendered value out of S at I. Returns (NODE . NEXT-INDEX)." + (let ((i (flan-inspect--skip-space s i))) + (if (>= i (length s)) + (cons (list :kind 'atom :text "") i) + (pcase (aref s i) + (?\( (flan-inspect--read-struct s i)) + (?\[ (flan-inspect--read-seq s i)) + (?\" (flan-inspect--read-string s i)) + (?< (flan-inspect--read-angle s i)) + (_ (flan-inspect--read-atom s i)))))) + +(defun flan-inspect-parse (rendered) + "Parse RENDERED — what the daemon put in a reply's :value — into a node." + (car (flan-inspect--read (or rendered "") 0))) + +;;; Where a field is, said in Flan + +;; A step is not an index into something remembered; it is a piece of source. +;; `(.pos b)' and `(at (.tags b) 2)' are expressions the program can be handed +;; exactly as a person would type them, which is what makes the whole thing +;; work without a handle to retain. + +(defun flan-inspect-step-expr (expr step) + "The Flan expression reaching STEP inside EXPR." + (pcase step + (`(:field ,name) (format "(.%s %s)" name expr)) + (`(:index ,i) (format "(at %s %d)" expr i)) + (_ expr))) + +;;; Why a thing cannot be entered + +;; Every refusal is by name and carries its reason, because the alternative — +;; RET doing nothing on some lines and something on others — is a UI that +;; teaches you nothing about the language. + +(defun flan-inspect-refusal (node) + "Why NODE cannot be inspected, or nil if it can." + (pcase (plist-get node :kind) + ('struct (and (null (plist-get node :children)) + "a struct with no fields the renderer could reach")) + ('seq (and (null (plist-get node :children)) + "an empty sequence: there is no element to go into")) + ('option + "an option's payload: Flan has no accessor form that reaches it, so there is no expression to send") + ('ptr + "a pointer: the renderer never follows one, and dereferencing a pointer on your behalf is not safe") + ('trunc + "truncated: the walk stopped at its depth bound of 4. Inspect the field that holds it, which re-roots the walk") + ('opaque + (format "%s: the walk had no structure for this type, so there are no fields to show" + (plist-get node :text))) + ('atom (format "%s is an atom; it has no fields" (plist-get node :text))) + (_ "not something this inspector knows how to enter"))) + +;;; Drawing it + +(defvar-local flan-inspect--stack nil + "Where we have been: a list of (EXPR . POINT), innermost last-pushed first.") +(defvar-local flan-inspect--expr nil "The expression this buffer is showing.") +(defvar-local flan-inspect--node nil "Its parsed value.") + +(defun flan-inspect--label (child) + (let ((k (car child))) + (if (integerp k) (format "%d." k) (format ":%s" k)))) + +(defun flan-inspect--summary (node) + "One line for NODE, as it appears beside its label." + (pcase (plist-get node :kind) + ('struct (format "(%s …%s)" (plist-get node :type) + (let ((n (length (plist-get node :children)))) + (format " %d field%s" n (if (= n 1) "" "s"))))) + ('seq (plist-get node :text)) + ('option (format "(some …)")) + (_ (plist-get node :text)))) + +(defun flan-inspect--render (expr node stack) + "Draw NODE, reached by EXPR, with STACK behind it." + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (propertize expr 'face 'font-lock-function-name-face) "\n") + (insert (propertize + (pcase (plist-get node :kind) + ('struct (format "a %s\n" (plist-get node :type))) + ('seq (format "%s\n" (plist-get node :text))) + ('option "an option\n") + ('ptr "a pointer — never followed\n") + (_ (format "%s\n" (plist-get node :text)))) + 'face 'font-lock-type-face)) + ;; The stack made visible. CIDER keeps it and does not show it; here it is + ;; the difference between a value and *which* value, and the thing that was + ;; typed at the root is often several steps back by now. + (when stack + (insert (propertize + (concat " via " + (string-join (reverse (mapcar #'car stack)) " > ") + " > here\n") + 'face 'shadow))) + (insert "\n") + (let ((kids (plist-get node :children))) + (cond + (kids + (insert (propertize (if (eq (plist-get node :kind) 'seq) + "--- Elements:\n" "--- Fields:\n") + 'face 'font-lock-comment-face)) + (let ((w (apply #'max 4 (mapcar (lambda (c) (length (flan-inspect--label c))) kids)))) + (dolist (c kids) + (let* ((label (flan-inspect--label c)) + (child (cdr c)) + (step (if (integerp (car c)) (list :index (car c)) + (list :field (car c)))) + (start (point))) + (insert (format " %s%s " label + (make-string (- w (length label)) ?\s))) + (insert (flan-inspect--summary child) "\n") + (add-text-properties + start (point) + (list 'flan-inspect-step step + 'flan-inspect-node child + 'mouse-face 'highlight))))) + (when (plist-get node :truncated) + (insert (propertize + " ... the renderer stopped at its span bound of 8; the rest was not written\n" + 'face 'font-lock-warning-face)))) + (t + (insert (propertize + (format "Nothing to go into: %s\n" (flan-inspect-refusal node)) + 'face 'font-lock-comment-face))))) + (insert "\n") + (insert (propertize + "RET inspect l back g refresh TAB/n next p previous q quit\n" + 'face 'shadow)) + (goto-char (point-min)))) + +;;; The commands + +(defun flan-inspect--value (expr) + "Ask the program for EXPR's value, rendered. Signals if it refuses." + (let ((r (funcall flan-inspect-request-function + (list :op "eval-expr" :code expr :file "")))) + (unless (equal (plist-get r :status) "ok") + (user-error "flan: %s" (or (plist-get r :message) "refused"))) + (or (plist-get r :value) + (user-error "flan: the program answered without a value for %s" expr)))) + +(defun flan-inspect--show (expr &optional stack) + "Render EXPR in the inspector buffer, with STACK behind it." + (let ((value (flan-inspect--value expr)) + (buf (get-buffer-create flan-inspect-buffer))) + (with-current-buffer buf + (unless (derived-mode-p 'flan-inspect-mode) (flan-inspect-mode)) + (setq flan-inspect--expr expr) + (setq flan-inspect--node (flan-inspect-parse value)) + (setq flan-inspect--stack stack) + (flan-inspect--render expr flan-inspect--node stack)) + (display-buffer buf) + buf)) + +;;;###autoload +(defun flan-inspect (expr) + "Inspect the value of EXPR in the running program. +Interactively, the expression before point, or one you type." + (interactive + (list (read-string "Inspect: " + (ignore-errors + (buffer-substring-no-properties + (save-excursion (backward-sexp) (point)) (point)))))) + (flan-inspect--show expr nil)) + +(defun flan-inspect-into () + "Go into the field or element at point." + (interactive) + (let ((step (get-text-property (point) 'flan-inspect-step)) + (node (get-text-property (point) 'flan-inspect-node))) + (unless step (user-error "flan: nothing to inspect on this line")) + (let ((why (flan-inspect-refusal node))) + (when why (user-error "flan: %s" why))) + (let ((expr (flan-inspect-step-expr flan-inspect--expr step)) + (stack (cons (cons flan-inspect--expr (point)) flan-inspect--stack))) + (flan-inspect--show expr stack)))) + +(defun flan-inspect-pop () + "Back to the value you came from, at the line you left." + (interactive) + (unless flan-inspect--stack + (user-error "flan: this is the root; there is nothing behind it")) + (let* ((top (car flan-inspect--stack)) + (rest (cdr flan-inspect--stack))) + (flan-inspect--show (car top) rest) + (with-current-buffer flan-inspect-buffer + (goto-char (min (cdr top) (point-max)))))) + +(defun flan-inspect-refresh () + "Read the same expression again. +Deliberately a key rather than a timer: the expression runs in the program, +and a root with an effect in it would fire once a second forever." + (interactive) + (unless flan-inspect--expr (user-error "flan: nothing is being inspected")) + (let ((p (point))) + (flan-inspect--show flan-inspect--expr flan-inspect--stack) + (with-current-buffer flan-inspect-buffer (goto-char (min p (point-max)))))) + +(defun flan-inspect--fields () + "The start of every inspectable line, in order. +Both movement commands go through this rather than walking property changes +by hand: a field line has the property on all of it, so `next-single-...' +from the middle of one finds the *end* of the line you are already on, and +forward and backward then disagree about where a field begins." + (let ((out nil) (p (point-min))) + (while (< p (point-max)) + ;; A new field begins where the property's *value* changes, not where the + ;; property appears: field lines are contiguous, so the last character of + ;; one carries a step just as the first character of the next does. + (when (and (get-text-property p 'flan-inspect-step) + (or (= p (point-min)) + (not (equal (get-text-property p 'flan-inspect-step) + (get-text-property (1- p) 'flan-inspect-step))))) + (push p out)) + (setq p (1+ p))) + (nreverse out))) + +(defun flan-inspect-next (&optional n) + "Move to the next inspectable line. With N, that many. +Wraps, as CIDER's does: a list you have walked off the end of should come +back round rather than stop dead." + (interactive "p") + (let ((fields (flan-inspect--fields))) + (unless fields (user-error "flan: there is nothing to move between")) + (dotimes (_ (or n 1)) + (goto-char (or (seq-find (lambda (p) (> p (point))) fields) + (car fields)))))) + +(defun flan-inspect-previous (&optional n) + "Move to the previous inspectable line. With N, that many. +Wraps too — the same list, walked the other way, and an asymmetry here is +the kind of thing nobody reports and everybody notices." + (interactive "p") + (let ((fields (flan-inspect--fields))) + (unless fields (user-error "flan: there is nothing to move between")) + (dotimes (_ (or n 1)) + (goto-char (or (seq-find (lambda (p) (< p (point))) (reverse fields)) + (car (last fields))))))) + +(defvar flan-inspect-mode-map + (let ((map (make-sparse-keymap))) + ;; CIDER's, and the same letters mean the same things: someone who has used + ;; one should not have to learn the other. + (define-key map (kbd "RET") #'flan-inspect-into) + (define-key map [mouse-1] #'flan-inspect-into) + (define-key map "l" #'flan-inspect-pop) + (define-key map "g" #'flan-inspect-refresh) + (define-key map (kbd "TAB") #'flan-inspect-next) + (define-key map "n" #'flan-inspect-next) + (define-key map [backtab] #'flan-inspect-previous) + (define-key map "p" #'flan-inspect-previous) + (define-key map "q" #'quit-window) + map) + "Keys in `flan-inspect-mode'.") + +(define-derived-mode flan-inspect-mode special-mode "flan-inspect" + "Look at a value in the running Flan program." + (setq buffer-read-only t) + (setq-local truncate-lines t)) + +(provide 'flan-inspect) +;;; flan-inspect.el ends here diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el new file mode 100644 index 0000000..7dfbfac --- /dev/null +++ b/emacs/test-flan-cider.el @@ -0,0 +1,484 @@ +;;; test-flan-cider.el --- The inspector and break buffers, from fixtures -*- lexical-binding: t; -*- + +;; Run as: emacs -Q --batch -L emacs -l emacs/test-flan-cider.el +;; +;; Nothing here needs a daemon or a running program, and that is deliberate +;; rather than a shortcut. Both buffers are functions from *a reply's data* to +;; *text with properties on it*, and the interesting failures are all on that +;; side: a struct the reader mis-parses, a shadowed restart drawn as though it +;; could be chosen, a section quietly omitted instead of refused. Driving it +;; from fixtures tests exactly that, and it tests the cases a live program +;; cannot easily be made to produce — a value past the renderer's depth bound, +;; two restarts with one name, a frame with locals in it at all. +;; +;; The fixtures are not invented. Every rendered string below is the shape +;; `Session.render' writes, case by case, and the comment on each says which. + +;;; Code: + +(require 'flan-inspect) +(require 'flan-cnr) + +(defvar test-flan--failures 0) +(defvar test-flan--ran 0) + +(defun test-flan--check (name ok) + (setq test-flan--ran (1+ test-flan--ran)) + (if ok (message " ok %s" name) + (setq test-flan--failures (1+ test-flan--failures)) + (message " FAIL %s" name))) + +(defun test-flan--text (thunk) + "Run THUNK in a scratch buffer and return what it drew." + (with-temp-buffer + (funcall thunk) + (buffer-substring-no-properties (point-min) (point-max)))) + +(defun test-flan--caught (thunk) + "The message of the error THUNK signals, or nil if it does not." + (condition-case e (progn (funcall thunk) nil) + (error (error-message-string e)))) + + +;;; The reader + +(message "\nreading what the renderer wrote") + +;; (V {:x 1.5 :y 0}) — Types.Named, lib/session.ml. +(let ((n (flan-inspect-parse "(V {:x 1.5 :y 0})"))) + (test-flan--check "a struct is a struct" (eq (plist-get n :kind) 'struct)) + (test-flan--check "with its type name" (equal (plist-get n :type) "V")) + (test-flan--check "and its fields in order" + (equal (mapcar #'car (plist-get n :children)) '("x" "y"))) + (test-flan--check "carrying their values" + (equal (plist-get (cdr (assoc "x" (plist-get n :children))) :text) + "1.5"))) + +;; The whole of NEXT.md's worked example, nested two deep with a string that +;; has escaped quotes in it and a slice at the end. +(let* ((src "(Blob {:id 7 :name \"sandy \\\"quoted\\\"\" :pos (V {:x 1.5 :y 0}) :tags [ 0 42 0]})") + (n (flan-inspect-parse src)) + (kids (plist-get n :children))) + (test-flan--check "every field of a nested struct" + (equal (mapcar #'car kids) '("id" "name" "pos" "tags"))) + (test-flan--check "an escaped quote does not end the string early" + (equal (plist-get (cdr (assoc "name" kids)) :text) + "\"sandy \"quoted\"\"")) + (test-flan--check "a struct inside a struct" + (equal (plist-get (cdr (assoc "pos" kids)) :type) "V")) + (test-flan--check "a slice inside a struct, with its elements" + (equal (mapcar #'car (plist-get (cdr (assoc "tags" kids)) :children)) + '(0 1 2)))) + +;; [ 0 42 0] — Types.Slice and Types.Array both write this. +(let ((n (flan-inspect-parse "[ 0 42 0]"))) + (test-flan--check "a sequence is a sequence" (eq (plist-get n :kind) 'seq)) + (test-flan--check "indexed from zero" + (equal (mapcar #'car (plist-get n :children)) '(0 1 2)))) + +;; [ [ 0 0] [ 1 ...] ...] — span truncation at both levels, which is what +;; sand's [100 [100 u32]] actually produces. +(let* ((n (flan-inspect-parse "[ [ 0 0] [ 1 ...] ...]")) + (kids (plist-get n :children))) + (test-flan--check "a trailing ... is truncation, not an element" + (and (= (length kids) 2) (plist-get n :truncated))) + (test-flan--check "and it is noticed on the inner sequence too" + (plist-get (cdr (nth 1 kids)) :truncated))) + +;; The renderer's refusals, each of which becomes a leaf here. +(test-flan--check "a pointer is a pointer" + (eq (plist-get (flan-inspect-parse "") :kind) 'ptr)) +(test-flan--check "a type the walk had no structure for" + (eq (plist-get (flan-inspect-parse "") :kind) 'opaque)) +(test-flan--check "the depth bound" + (eq (plist-get (flan-inspect-parse "...") :kind) 'trunc)) +(test-flan--check "an option" + (eq (plist-get (flan-inspect-parse "(some 3)") :kind) 'option)) +(test-flan--check "an enum member is an atom, not a field" + (eq (plist-get (flan-inspect-parse ":blue") :kind) 'atom)) +(test-flan--check "and so is a u64 that fills the range" + (equal (plist-get (flan-inspect-parse "18446744073709551615") :text) + "18446744073709551615")) + +;; A struct with a `...' where a field would be: span truncation *inside* a +;; struct, which is a different position in the grammar from a sequence's. +(let ((n (flan-inspect-parse "(Wide {:a 1 :b 2 ...})"))) + (test-flan--check "a struct's span bound is truncation, not a field" + (and (equal (mapcar #'car (plist-get n :children)) '("a" "b")) + (plist-get n :truncated)))) + + +;;; Where a field is, said in Flan + +(message "\nthe path is an expression") + +(test-flan--check "a field is a field accessor" + (equal (flan-inspect-step-expr "b" '(:field "pos")) "(.pos b)")) +(test-flan--check "an element is `at'" + (equal (flan-inspect-step-expr "(.tags b)" '(:index 2)) + "(at (.tags b) 2)")) +(test-flan--check "and they compose, which is the whole trick" + (equal (flan-inspect-step-expr + (flan-inspect-step-expr "b" '(:field "pos")) '(:field "x")) + "(.x (.pos b))")) + + +;;; Refusals, by name, with the reason + +(message "\nwhat cannot be entered says so") + +(dolist (case '(("" "pointer") ("..." "depth bound") ("7" "atom") + ("(some 3)" "accessor form") ("" "no structure"))) + (let ((why (flan-inspect-refusal (flan-inspect-parse (car case))))) + (test-flan--check (format "%s refuses, naming %s" (car case) (cadr case)) + (and why (string-match-p (regexp-quote (cadr case)) why))))) + +(test-flan--check "a struct with fields does not refuse" + (null (flan-inspect-refusal (flan-inspect-parse "(V {:x 1 :y 2})")))) + + +;;; The inspector buffer + +(message "\nthe inspector buffer") + +(defun test-flan--inspect (expr rendered) + "Draw EXPR's RENDERED value in a temp buffer and return it, live." + (let ((flan-inspect-request-function + (lambda (_) (list :status "ok" :value rendered))) + (flan-inspect-buffer " *test-inspect*")) + (when (get-buffer " *test-inspect*") (kill-buffer " *test-inspect*")) + (save-window-excursion (flan-inspect--show expr nil)))) + +(let* ((buf (test-flan--inspect + "b" "(Blob {:id 7 :name \"sandy\" :pos (V {:x 1.5 :y 0})})")) + (text (with-current-buffer buf (buffer-string)))) + (test-flan--check "the expression is at the top" (string-match-p "\\`b\n" text)) + (test-flan--check "and the type under it" (string-match-p "a Blob" text)) + (test-flan--check "the fields are listed" (string-match-p ":id.*7" text)) + (test-flan--check "a nested struct is summarised, not expanded" + (string-match-p ":pos +(V … 2 fields)" text)) + (test-flan--check "and the keys are shown" (string-match-p "RET inspect" text)) + ;; Every field line carries the step that reaches it. + (with-current-buffer buf + (goto-char (point-min)) + (flan-inspect-next) + (test-flan--check "TAB lands on the first field" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "id"))) + (flan-inspect-next) + (flan-inspect-next) + (test-flan--check "and walks to the third" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "pos"))) + ;; Backwards is the same list walked the other way. It is tested because + ;; an asymmetry here is the kind of thing nobody reports and everybody + ;; notices — and because point lands mid-line after a search, where a + ;; property-change walk and a field walk disagree. + (flan-inspect-previous) + (test-flan--check "p comes back to the second" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "name"))) + ;; From the middle of a line, `p' goes to the start of the field point is + ;; *in*, which is CIDER's behaviour and the reason both commands go + ;; through one list of field starts: walking property changes from + ;; mid-line finds the end of the current field instead, and forward and + ;; backward then disagree about where a field begins. + (end-of-line) + (flan-inspect-previous) + (test-flan--check "from mid-line, p reaches this field's start" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "name"))) + (flan-inspect-previous) + (test-flan--check "and then the one before it" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "id"))) + (flan-inspect-previous) + (test-flan--check "p wraps to the last, as n wraps to the first" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "pos"))) + (flan-inspect-next) + (test-flan--check "and n wraps round from it" + (equal (get-text-property (point) 'flan-inspect-step) + '(:field "id"))))) + +;; Going in sends a *different expression*, which is the entire adaptation. +(let ((asked nil)) + (let ((flan-inspect-request-function + (lambda (form) + (push (plist-get form :code) asked) + (list :status "ok" + :value (if (equal (plist-get form :code) "(.pos b)") + "(V {:x 1.5 :y 0})" + "(Blob {:id 7 :pos (V {:x 1.5 :y 0})})")))) + (flan-inspect-buffer " *test-inspect*")) + (when (get-buffer " *test-inspect*") (kill-buffer " *test-inspect*")) + (save-window-excursion + (flan-inspect--show "b" nil) + (with-current-buffer " *test-inspect*" + (goto-char (point-min)) + (flan-inspect-next) (flan-inspect-next) ; :pos + (flan-inspect-into) + (test-flan--check "going in asks for the accessor expression" + (equal (car asked) "(.pos b)")) + (test-flan--check "and the buffer is now showing that" + (string-match-p "\\`(\\.pos b)\n" (buffer-string))) + (test-flan--check "with the stack behind it" + (string-match-p "via b > here" (buffer-string))) + (flan-inspect-pop) + (test-flan--check "coming back asks for the one we came from" + (equal (car asked) "b")) + (test-flan--check "and there is no stack left" + (not (string-match-p "via" (buffer-string)))) + (test-flan--check "popping at the root refuses" + (string-match-p + "nothing behind it" + (or (test-flan--caught #'flan-inspect-pop) ""))))))) + +;; RET on something that cannot be entered refuses there, rather than sending +;; an expression the program would reject. +(let* ((buf (test-flan--inspect "p" "(Node {:next :n 1})"))) + (with-current-buffer buf + (goto-char (point-min)) + (flan-inspect-next) + (test-flan--check "RET on a pointer field refuses, naming it" + (string-match-p "pointer" + (or (test-flan--caught #'flan-inspect-into) ""))))) + +;; An atom root has nothing to go into, and the buffer says so rather than +;; drawing an empty field list. +(let ((text (with-current-buffer (test-flan--inspect "(.x p)" "1.5") (buffer-string)))) + (test-flan--check "an atom root explains itself" + (string-match-p "Nothing to go into:.*atom" text))) + +;; The span bound is drawn, because a field list that silently stops is a lie +;; about the value. +(let ((text (with-current-buffer + (test-flan--inspect "w" "(Wide {:a 1 :b 2 ...})") (buffer-string)))) + (test-flan--check "span truncation is drawn, not dropped" + (string-match-p "span bound of 8" text))) + + + + +;;; Restarts: which of them can be taken + +(message "\nrestarts, and §4's shadowing") + +(let ((rows (flan-cnr-annotate-restarts '("retry" "use-placeholder" "retry" "skip")))) + (test-flan--check "numbered from zero, innermost first" + (equal (mapcar (lambda (r) (nth 0 r)) rows) '(0 1 2 3))) + (test-flan--check "the first of a name owns it" + (null (nth 2 (nth 0 rows)))) + (test-flan--check "a repeat is shadowed by it" + (equal (nth 2 (nth 2 rows)) 0)) + (test-flan--check "and a different name is not" + (null (nth 2 (nth 3 rows))))) + +(test-flan--check "no restarts is a list of no rows" + (null (flan-cnr-annotate-restarts nil))) + + +;;; The break buffer + +(message "\nthe break buffer") + +(defun test-flan--cnr (state) + "Draw STATE and return the buffer." + (let ((buf (get-buffer-create " *test-cnr*"))) + (with-current-buffer buf + (let ((inhibit-read-only t)) (erase-buffer)) + (flan-cnr-mode) + (setq flan-cnr--open nil) + (flan-cnr--render state)) + buf)) + +;; What the daemon can answer today, and nothing more: a class name and a list +;; of names. +(let* ((buf (test-flan--cnr + (list :condition "Missing" + :restarts '("retry" "use-placeholder" "retry")))) + (text (with-current-buffer buf (buffer-string)))) + ;; SBCL's order, which is the claim this buffer is making. + (test-flan--check "the condition is first" + (string-match-p "\\`Missing" text)) + (test-flan--check "the restarts are before the stack" + (< (string-match "Restarts" text) (string-match "Stack" text))) + (test-flan--check "restarts are numbered" + (string-match-p " 0: \\[retry\\]" text)) + (test-flan--check "a shadowed one has no bracket, as SBCL's has none" + (string-match-p " 2: retry " text)) + (test-flan--check "and says why, and what it would take" + (string-match-p "shadowed by 0.*restart-at" text)) + (test-flan--check "abort is the last entry on the same list" + (string-match-p " 3: \\[abort\\]" text)) + ;; The rule: nothing implemented is refused by name, with the reason. These + ;; three sections exist and are empty, and each says what would fill it. + ;; The shape of a condition and the values in it are two different + ;; refusals, and with nothing at all the outer one is what shows. + (test-flan--check "the condition's fields are refused, not omitted" + (string-match-p "Condition fields:\n not available.*Tast.structs" text)) + (test-flan--check "the stack is refused, naming DWARF" + (string-match-p "Stack.*\n not available.*DWARF" + (substring text (string-match "--- Stack" text)))) + (test-flan--check "and the keys are shown" (string-match-p "TAB fold a frame" text))) + +;; A stopped program with nothing on offer between the error and the top. It +;; is a real state — spec-conditions §2's `error' with no `restart-case' above +;; it — and it must not look like a bug in the buffer. +(let ((text (with-current-buffer + (test-flan--cnr (list :condition "Missing" :restarts nil)) + (buffer-string)))) + (test-flan--check "no restarts is said, not drawn blank" + (string-match-p "none are active" text)) + (test-flan--check "and abort is still offered" + (string-match-p " 0: \\[abort\\]" text))) + +;; Taking one sends `restart' with the name. +(let ((sent nil)) + (let ((flan-cnr-request-function + (lambda (form) (setq sent form) (list :status "ok" :note "accepted")))) + (with-current-buffer (test-flan--cnr + (list :condition "Missing" :restarts '("retry" "skip"))) + (goto-char (point-min)) + (search-forward " 1: ") + (save-window-excursion (flan-cnr-take)) + (test-flan--check "RET on a restart sends its name" + (equal sent '(:op "restart" :name "skip")))))) + +;; And a shadowed one is refused here rather than sent, which is the bug in +;; today's `completing-read': it would send "retry" and the *inner* frame would +;; take it, silently. +(let ((sent nil)) + (let ((flan-cnr-request-function (lambda (form) (setq sent form) '(:status "ok")))) + (with-current-buffer (test-flan--cnr + (list :condition "Missing" :restarts '("retry" "a" "retry"))) + (goto-char (point-min)) + (search-forward " 2: ") + (let ((err (test-flan--caught #'flan-cnr-take))) + (test-flan--check "a shadowed restart refuses" + (and err (string-match-p "shadowed by 0" err))) + (test-flan--check "and nothing was sent" (null sent)))))) + +;; A digit takes one, as it does in SBCL. +(let ((sent nil)) + (let ((flan-cnr-request-function + (lambda (form) (setq sent form) (list :status "ok" :note "accepted")))) + (with-current-buffer (test-flan--cnr + (list :condition "Missing" :restarts '("retry" "skip"))) + (goto-char (point-max)) + (let ((last-command-event ?0)) + (save-window-excursion (call-interactively #'flan-cnr-take-number))) + (test-flan--check "0 takes the innermost" + (equal sent '(:op "restart" :name "retry"))) + (let ((last-command-event ?2)) + (save-window-excursion (call-interactively #'flan-cnr-take-number))) + (test-flan--check "and the number past the last is abort" + (equal sent '(:op "abort")))))) + +;; The stack and its locals. Nothing produces this data today; the fixture is +;; the shape a `backtrace' verb would have to answer with, and building the +;; buffer against it is how it is ready when one exists. +(let* ((state (list :condition "Missing" + :restarts '("retry") + :stack (list (list :fn "sim/settle" :loc "sand.flan:42:3" + :locals '(("i" "i32" "7") + ("b" "Blob" "(Blob {:id 7})"))) + (list :fn "sim/step" :loc "sand.flan:60:1" + :locals nil)))) + (buf (test-flan--cnr state)) + (text (with-current-buffer buf (buffer-string)))) + (test-flan--check "frames are numbered, innermost first" + (string-match-p " 0: > sim/settle" text)) + (test-flan--check "with where they are" (string-match-p "sand.flan:42:3" text)) + (test-flan--check "locals are collapsed to begin with" + (not (string-match-p "i32 i = 7" text))) + (with-current-buffer buf + (goto-char (point-min)) + (search-forward " 0: > sim/settle") + (flan-cnr-toggle-frame) + (test-flan--check "TAB on a frame opens its locals" + (string-match-p "i32 i = 7" (buffer-string))) + (test-flan--check "and the marker turns" + (string-match-p " 0: v sim/settle" (buffer-string))) + ;; A frame whose locals nobody could read says so, in the same place the + ;; locals would have been. + (goto-char (point-min)) + (search-forward " 1: > sim/step") + (flan-cnr-toggle-frame) + (test-flan--check "a frame with no locals refuses, naming DWARF" + (string-match-p "not available.*DWARF" (buffer-string))) + (goto-char (point-min)) + (search-forward " 0: v sim/settle") + (flan-cnr-toggle-frame) + (test-flan--check "and TAB again closes it" + (not (string-match-p "i32 i = 7" (buffer-string)))))) + +;; The two buffers meet: `i' on a local opens the inspector on its name, which +;; is an expression the program can be handed. +(let ((asked nil)) + (let ((flan-inspect-request-function + (lambda (form) (push (plist-get form :code) asked) + (list :status "ok" :value "(Blob {:id 7})"))) + (flan-inspect-buffer " *test-inspect*")) + (with-current-buffer (test-flan--cnr + (list :condition "Missing" :restarts '("retry") + :stack (list (list :fn "f" :locals '(("b" "Blob" "…")))))) + (goto-char (point-min)) + (search-forward " 0: > f") + (flan-cnr-toggle-frame) + (goto-char (point-min)) + (search-forward "Blob b") + (save-window-excursion (flan-cnr-inspect)) + (test-flan--check "`i' on a local inspects it by name" + (equal (car asked) "b"))))) + +;; `flan-cnr-show' refuses a running program by name rather than opening an +;; empty buffer. +;; The layout without the values: what a `layout' op alone would buy. The +;; names and the types come out of Tast.structs, which the daemon holds +;; whether or not a program is running; only the values need the pointer the +;; break loop was handed. Drawing the two apart is strictly more than saying +;; nothing, and it is what tells you whether the field you were about to +;; blame is even a field of this condition. +(let ((text (with-current-buffer + (test-flan--cnr (list :condition "Missing" :restarts '("retry") + :fields '(("path" "string" nil) + ("tried" "i32" "3")))) + (buffer-string)))) + (test-flan--check "a field is named and typed even with no value" + (string-match-p ":path *string *value not available" text)) + (test-flan--check "and the missing value names the verb it needs" + (string-match-p "value not available.*agent verb: `condition'" text)) + (test-flan--check "a value that is there is simply shown" + (string-match-p ":tried *i32 *3" text))) + +;; Backwards through the buffer, for the same reason as in the inspector. +(with-current-buffer (test-flan--cnr + (list :condition "Missing" :restarts '("retry" "skip"))) + (goto-char (point-min)) + (search-forward "[abort]") + (beginning-of-line) + (flan-cnr-previous) + (test-flan--check "p from abort lands on the last restart" + (equal (get-text-property (point) 'flan-cnr-index) 1)) + (flan-cnr-next) + (test-flan--check "and n goes back to abort" + (get-text-property (point) 'flan-cnr-abort))) + +(let ((flan-cnr-request-function + (lambda (_) '(:status "ok" :restarts nil :stopped nil)))) + (test-flan--check "a running program is refused, by name" + (string-match-p "no restart stack" + (or (test-flan--caught #'flan-cnr-show) "")))) + +(let ((flan-cnr-request-function + (lambda (_) '(:status "error" :message "the program exited")))) + (test-flan--check "and the daemon's own refusal is passed through" + (string-match-p "the program exited" + (or (test-flan--caught #'flan-cnr-show) "")))) + + +(message "\n%d checks, %d failures" test-flan--ran test-flan--failures) +(kill-emacs (if (> test-flan--failures 0) 1 0)) + +;;; test-flan-cider.el ends here diff --git a/test/dune b/test/dune index 9fbe3b8..914ab8b 100644 --- a/test/dune +++ b/test/dune @@ -1,5 +1,5 @@ (tests - (names test_flan test_acceptance test_reload test_agent test_session test_dev test_emacs test_repl) + (names test_flan test_acceptance test_reload test_agent test_session test_dev test_emacs test_repl test_cider) (libraries flan unix) ; The acceptance programs are part of the test corpus: if the reader, the ; parser or the checker regresses on them we want to know here, not at the CLI. diff --git a/test/test_cider.ml b/test/test_cider.ml new file mode 100644 index 0000000..7fc6ad5 --- /dev/null +++ b/test/test_cider.ml @@ -0,0 +1,27 @@ +(* The inspector and the break buffer, driven from fixtures. + + Unlike test_emacs.ml there is no daemon and no running program behind this, + and that is the point rather than a shortcut. Both buffers are functions + from a reply's data to text with properties on it, and every interesting way + they can be wrong is on that side: a struct the reader mis-parses, a + shadowed restart drawn as though it could be taken, a section quietly + omitted where it should have been refused. Fixtures also reach the states a + live program is hard to hold still in — a value past the renderer's depth + bound, two restarts sharing a name, a frame with locals in it at all. + + Skipped, not failed, where there is no emacs: the compiler does not depend + on one. *) + +let () = + if Sys.command "command -v emacs > /dev/null 2>&1" <> 0 then + print_endline "cider: skipped (no emacs on PATH)" + else + let code = + Sys.command + "emacs -Q --batch -L ../../../emacs -l ../../../emacs/test-flan-cider.el 2>&1" + in + if code = 0 then print_endline "cider: all tests passed" + else begin + Printf.printf "\nthe inspector/break buffer tests exited %d\n" code; + exit 1 + end