;;; test-flan-dev.el --- Drive the client against a running program -*- lexical-binding: t; -*- ;; Run as: emacs -Q --batch -L emacs -l emacs/test-flan-dev.el -- ;; ;; This is the client half of test_dev.ml. The OCaml test proves the daemon ;; answers correctly; this proves the elisp actually talks to it — the framing, ;; the reply reader, and C-c C-c picking the right form out of a buffer. A ;; protocol bug that only shows up under Emacs' coding systems would pass the ;; OCaml test and fail here, which is the whole reason it exists. ;;; Code: (require 'flan-mode) (require 'flan-dev) (require 'flan-repl) (require 'flan-watch) (defvar test-flan--failures 0) (defmacro test-flan--said (&rest body) "Run BODY and return the last thing it put in the echo area. `current-message' is nil under --batch, so the echo area is watched where it is written instead — the real `message' call the real command makes." `(let* ((said nil) (probe (lambda (fmt &rest args) (when fmt (setq said (apply #'format fmt args)))))) (advice-add 'message :before probe) (unwind-protect (progn ,@body) (advice-remove 'message probe)) said)) (defun test-flan--check (name ok) (if ok (message " ok %s" name) (setq test-flan--failures (1+ test-flan--failures)) (message " FAIL %s" name))) (let* ((args (cdr (member "--" command-line-args))) (socket (nth 0 args)) (file (nth 1 args)) (flan (nth 2 args)) ;; The buffer above is a copy in a temporary directory; this is the ;; program where it actually lives, which is the one a second daemon ;; can be started on — an `import' is resolved from the importing ;; file's own directory, and a copy in /tmp has no packages above it. (program (nth 3 args))) (find-file file) ;; The copy comes out of a build directory, so it may arrive read-only. ;; Set the flag directly: `read-only-mode' asks about the file on disk, and ;; a question in a batch run is a hang waiting to happen. (setq buffer-read-only nil) (test-flan--check "flan-mode is on for a .flan file" (eq major-mode 'flan-mode)) (test-flan--check "the modeline says so before connecting" (and (eq (flan-dev-state) 'off) (string-match-p "off" (flan-dev-mode-line)))) (flan-connect socket) (test-flan--check "connected" (process-live-p flan-dev--connection)) (test-flan--check "the modeline says a program is there" (and (eq (flan-dev-state) 'live) (string-match-p "live" (flan-dev-mode-line)))) (test-flan--check "and says nothing in a buffer that is not Flan's" (with-temp-buffer (null (flan-dev-mode-line)))) ;; Buffer-locally, so that someone who loads this and never opens a .flan ;; file is not evaluating it on every redisplay of every buffer they have. (test-flan--check "the indicator is in this buffer's modeline" (member '(:eval (flan-dev-mode-line)) mode-line-misc-info)) (test-flan--check "and not in everyone else's" (with-temp-buffer (not (member '(:eval (flan-dev-mode-line)) mode-line-misc-info)))) ;; A daemon restarted while Emacs was not looking is the ordinary case. The ;; socket outlives this connection, so dropping the process and asking again ;; is the same situation the client meets after a restart, and it must come ;; back rather than fail. (delete-process flan-dev--connection) (test-flan--check "a dead connection reads as lost, not as never-connected" (and (eq (flan-dev-state) 'lost) (string-match-p "lost" (flan-dev-mode-line)))) (let ((r (flan-dev--request '(:op "describe")))) (test-flan--check "the next request reconnects on its own" (and (process-live-p flan-dev--connection) (member "step" (plist-get r :fns))))) ;; ...and knows the program's names again. An empty cache after a reconnect ;; is honest but silent: eldoc goes quiet and M-. falls through to another ;; backend, with nothing said about why. (test-flan--check "and knows the program's names again" (assoc "step" flan-dev--defs)) ;; But a socket nobody is listening on is refused by name, rather than ;; retried forever or reported as some other failure. (let ((flan-dev--connection nil) (flan-dev--socket "/nonexistent/flan-dev-not-here.sock") (raised nil)) (condition-case err (flan-dev--request '(:op "describe")) (error (setq raised (error-message-string err)))) (test-flan--check "a socket that is gone is refused by name" (and raised (string-match-p "flan-dev-not-here.sock" raised) (string-match-p "nothing is listening" raised)))) (let ((r (flan-dev--request '(:op "describe")))) (test-flan--check "describe lists the program's functions" (member "step" (plist-get r :fns))) (test-flan--check "describe lists the program's globals" (member "ticks" (plist-get r :globals)))) ;; `layout' against the real daemon, through `flan-cnr-layout', which is how ;; the conditions buffer gets it. The reply is the first one with a list of ;; lists in it, so `read' on this side is doing something it does nowhere ;; else — and the daemon answers it without asking the program anything. (require 'flan-cnr) (let ((flan-cnr-request-function #'flan-dev--request)) (test-flan--check "a struct's fields come back named and typed" (equal (flan-cnr-layout "Missing") '(("id" "i32" nil)))) (test-flan--check "and a type the daemon cannot place is nil, not an error" (null (flan-cnr-layout "Nonesuch")))) ;; C-c C-c on the form at point: put point inside `step' and send it. The ;; text comes from the buffer, so this exercises `beginning-of-defun' against ;; Flan's own syntax table as much as it does the wire. (goto-char (point-min)) (search-forward "(defn step") (goto-char (match-beginning 0)) (save-excursion (search-forward "(+ ticks 1)") (replace-match "(+ ticks 41)")) (let ((form (flan-dev--defun-at-point))) (test-flan--check "the form at point is the defn" (and (string-prefix-p "(defn step" (string-trim form)) (string-match-p "41" form)))) (flan-eval-defun) ;; And an error: the daemon answers with a location, the client raises. (let ((raised nil)) (condition-case err (flan-dev--eval "(defn step [] i64 nonsense)" "form") (user-error (setq raised (error-message-string err)))) (test-flan--check "a form that does not check is reported" (and raised (string-match-p "unknown name" raised)))) ;; The :loc column is a *byte* offset — lib/reader.ml walks the source a byte ;; at a time — and Emacs counts characters. Same rule as the framing, a ;; different place to get it wrong, and it shows up only for someone whose ;; comments or identifiers are not ASCII. (with-temp-buffer (insert ";; héllo\n(defn wörld [] i64 nonsense)\n") (let ((want (save-excursion (goto-char (point-min)) (search-forward "nonsense") (match-beginning 0))) (eol (save-excursion (goto-char (point-min)) (line-end-position)))) (test-flan--check "a byte column lands on the right character" (= (flan-dev--position 2 (1+ (string-bytes "(defn wörld [] i64 "))) want)) (test-flan--check "a column past the end of a line is clamped to it" (= (flan-dev--position 1 500) eol)))) ;; A rejected form is marked where it is, not only in the echo area. The ;; daemon numbers lines from the start of what it was sent, so a form taken ;; from the middle of a buffer only lands on the right line because the ;; client pads it back into place before sending. (goto-char (point-min)) (search-forward "(defn step") (goto-char (match-beginning 0)) (let ((defn-line (line-number-at-pos))) (save-excursion (search-forward "(+ ticks 41)") (replace-match "(+ ticks nonsense)")) (ignore-errors (flan-eval-defun)) (let ((ovs (seq-filter (lambda (o) (overlay-get o 'flan-dev-error)) (overlays-in (point-min) (point-max))))) (test-flan--check "a rejected form gets exactly one error overlay" (= 1 (length ovs))) (test-flan--check "the overlay is at the form, not at line 1" (and ovs (>= (line-number-at-pos (overlay-start (car ovs))) defn-line))) (test-flan--check "the overlay carries the daemon's reason" (and ovs (string-match-p "unknown name" (or (overlay-get (car ovs) 'help-echo) "")))) (test-flan--check "and shows it beside the code" (and ovs (string-match-p "unknown name" (or (overlay-get (car ovs) 'after-string) ""))))) ;; ...and it goes away when the next evaluation is accepted. A marker left ;; behind after a fix is a lie about the running program. Point moved to ;; the error, which is the point of all this, so start the search over. (goto-char (point-min)) (search-forward "(+ ticks nonsense)") (replace-match "(+ ticks 41)") (search-backward "(defn step") ;; A silent success is indistinguishable from a silent failure, so an ;; accepted evaluation says what landed in the running program and what it ;; cost. The name comes from the *reply*: the daemon is the one that knows ;; which names it installed. (let ((said (test-flan--said (flan-eval-defun)))) (test-flan--check "an accepted evaluation clears it" (null (seq-filter (lambda (o) (overlay-get o 'flan-dev-error)) (overlays-in (point-min) (point-max))))) (test-flan--check "and says which name landed" (and said (string-match-p "\\_" said))) (test-flan--check "and how long it took" (and said (string-match-p "[0-9]+ ms" said))))) ;; A declaration the program already has installs nothing, and must say so ;; rather than reporting a time for a build that did not happen. (let ((said (test-flan--said (flan-dev--eval "(defvar ticks i64)" "form")))) (test-flan--check "an evaluation with nothing to install says so" (and said (string-match-p "nothing to install" said) (not (string-match-p "installed" said))))) ;; Many names are counted and sampled. An echo area truncated in the middle ;; of the tenth name says neither how many there were nor which. (test-flan--check "a long list of names is counted, not cut off" (equal (flan-dev--names-phrase '("a" "b" "c" "d" "e" "f") "fallback") "6 names (a, b, c, d, …)")) (test-flan--check "a short one is just named" (equal (flan-dev--names-phrase '("a" "b") "fallback") "a, b")) ;; `defs' is what eldoc, completion and M-. all read. One op answering all ;; three, cached, because eldoc fires on an idle timer and completion inside ;; redisplay, and neither may block on a socket. (test-flan--check "the program's names are known" (assoc "step" flan-dev--defs)) (test-flan--check "with a signature" (equal (nth 2 (assoc "step" flan-dev--defs)) "step [] i64")) (test-flan--check "a global is known, and says it is one" (equal (nth 1 (assoc "ticks" flan-dev--defs)) "var")) (test-flan--check "so is an imported package's extern" (let ((d (assoc "agent/wait-raw" flan-dev--defs))) (and d (equal (nth 1 d) "extern")))) ;; `step' was last installed from this buffer, so that is where the daemon ;; says it is — which is also the check that the client's line padding put it ;; on the line it is really on rather than on line 1. (test-flan--check "a fn carries where it is written" (equal (nth 3 (assoc "step" flan-dev--defs)) (format "%s:%d:7" buffer-file-name (save-excursion (goto-char (point-min)) (search-forward "(defn step") (line-number-at-pos))))) ;; eldoc: the signature of the name at point, and of the form point is ;; inside, which is what you want while typing arguments. (goto-char (point-min)) (search-forward "(set ticks (step") (let ((said nil)) (test-flan--check "eldoc answers for the name at point" (and (flan-dev-eldoc-function (lambda (s &rest _) (setq said s))) said (string-match-p "step \\[\\] i64" said)))) (let ((said nil)) (save-excursion (goto-char (point-min)) (search-forward "(set ticks (step)") (backward-char 1) ; inside (step ...), not on the name (flan-dev-eldoc-function (lambda (s &rest _) (setq said s)))) (test-flan--check "and for the form point is inside" (and said (string-match-p "step" said)))) (let ((said nil)) (with-temp-buffer (insert "not-a-flan-name") (flan-dev-eldoc-function (lambda (s &rest _) (setq said s)))) (test-flan--check "and says nothing about a name the program has not got" (null said))) ;; Completion: the running program's names, through `completion-at-point'. (goto-char (point-min)) (search-forward "(defn step") (let* ((capf (flan-dev-completion-at-point)) (table (nth 2 capf))) (test-flan--check "completion offers the program's own names" (member "step" (all-completions "ste" table))) (test-flan--check "and the names an import brought in" (member "agent/wait" (all-completions "agent/" table))) (test-flan--check "and annotates each with what it is" (equal (funcall (plist-get (nthcdr 3 capf) :annotation-function) "ticks") " var"))) ;; M-. through xref, so it is the key it always is. (let ((xs (xref-backend-definitions 'flan "step")) (line (save-excursion (goto-char (point-min)) (search-forward "(defn step") (line-number-at-pos)))) (test-flan--check "M-. finds where a function is written" (and (= 1 (length xs)) (let ((l (xref-item-location (car xs)))) (and (file-equal-p (xref-location-group l) buffer-file-name) (= (xref-location-line l) line)))))) ;; And the two things it cannot do, refused by name with the reason rather ;; than by opening an empty buffer. (let ((raised nil)) (condition-case err (xref-backend-definitions 'flan "ticks") (user-error (setq raised (error-message-string err)))) (test-flan--check "M-. on a global refuses, saying why" (and raised (string-match-p "ticks" raised) (string-match-p "no location" raised)))) (let ((raised nil)) (condition-case err (xref-backend-definitions 'flan "rand-seed") (user-error (setq raised (error-message-string err)))) (test-flan--check "M-. into the prelude refuses, saying why" (and raised (string-match-p "prelude" raised) (string-match-p "not a file on disk" raised)))) (let ((raised nil)) (condition-case err (xref-backend-definitions 'flan "no-such-name") (user-error (setq raised (error-message-string err)))) (test-flan--check "and so does a name the program does not have" (and raised (string-match-p "no no-such-name" raised)))) ;; A name installed now must complete now, not after the next connect. (flan-dev--eval "(defn freshly-added [] i64 7)" "form") (test-flan--check "a name just installed is known immediately" (equal (nth 2 (assoc "freshly-added" flan-dev--defs)) "freshly-added [] i64")) ;; The session is not poisoned by that: a good form still lands. (flan-dev--eval "(defn step [] i64 (set ticks (+ ticks 100)) ticks)" "form") ;; The program's own output arrives on replies and lands in its buffer, so ;; a long-running program is not writing into a terminal nobody is watching. (flan-dev--eval "(defn step [] i64 (do (println \"HELLO\") ticks))" "form") (let ((seen nil) (deadline (+ (float-time) 10))) (while (and (not seen) (< (float-time) deadline)) (ignore-errors (flan-dev--request '(:op "describe"))) (setq seen (with-current-buffer (get-buffer-create flan-dev-output-buffer) (string-match-p "HELLO" (buffer-string))))) (test-flan--check "the program's output reaches its buffer" seen)) ;; The REPL buffer: typed input goes through the same eval-expr request, and ;; the value lands at the prompt while the program's own output goes to ;; *flan-output*. Conflating those two is the bug worth testing for. (test-flan--check "an incomplete form is not sent" (not (flan-repl--complete-p "(+ 1"))) (test-flan--check "a whole form is sent" (flan-repl--complete-p "(+ 1 2)")) (test-flan--check "a paren in a string does not count" (not (flan-repl--complete-p "(f \"(\""))) (flan-repl) (with-current-buffer flan-repl-buffer (goto-char (point-max)) (insert "(+ 20 3)") (flan-repl-return) (let ((deadline (+ (float-time) 15))) (while (and (not (string-match-p "23" (buffer-string))) (< (float-time) deadline)) (accept-process-output nil 0.05))) (test-flan--check "the REPL shows a value" (string-match-p "23" (buffer-string))) (goto-char (point-max)) (insert "no-such-thing") (flan-repl-return) (let ((deadline (+ (float-time) 15))) (while (and (not (string-match-p "unknown name" (buffer-string))) (< (float-time) deadline)) (accept-process-output nil 0.05))) (test-flan--check "the REPL shows an error" (string-match-p "unknown name" (buffer-string))) ;; A value and the program's output travel by different routes: the value ;; is the result of the request, the output rides along with the reply. ;; Showing them in one place would be convenient and wrong. (goto-char (point-max)) (insert "(println \"PRINTED\")") (flan-repl-return) (let ((deadline (+ (float-time) 15))) (while (and (not (with-current-buffer flan-dev-output-buffer (string-match-p "PRINTED" (buffer-string)))) (< (float-time) deadline)) (ignore-errors (flan-dev--request '(:op "describe"))) (accept-process-output nil 0.05))) (test-flan--check "printed text goes to the output buffer" (with-current-buffer flan-dev-output-buffer (string-match-p "PRINTED" (buffer-string)))) ;; ...and the prompt got the *value*, which for a call made for its effect ;; is Unit. The text it printed is not the value and does not belong here. (test-flan--check "and the prompt got the value, not the text" (string-match-p "()" (buffer-string)))) ;; ── The break loop ──────────────────────────────────────────────────── ;; ;; An unhandled `error' stops the program on the frame that erred instead of ;; killing it, and this is the half of that an editor sees: it has to notice ;; without being told, say what stopped it, offer the restarts, and keep ;; working while the program sits there. Last in this file because the ;; program is left running afterwards but its `step' has been through a ;; break, and nothing above should have to reason about that. ;; Refused while it is running, by name. There is no restart stack to walk ;; from a running program, and an empty prompt would be worse than a refusal. (let ((raised nil)) (condition-case err (flan-break) (user-error (setq raised (error-message-string err)))) (test-flan--check "the prompt refuses while the program is running" (and raised (string-match-p "running" raised)))) ;; The poll is a real timer, registered on connect. What it *does* is ;; checked below by calling it; that it is scheduled at all is checked here, ;; because a background discovery that nothing ever runs discovers nothing. (test-flan--check "a poll timer is running" (and (timerp flan-dev--timer) (eq (timer--function flan-dev--timer) #'flan-dev--poll))) ;; Break it: `step' is called every time round the program's loop, so a body ;; that errors stops it on its own game thread, in a frame of its own — not ;; inside anything this client asked for. Nothing tells Emacs. (flan-dev--eval "(defn step [] i64 (restart-case (do (error (Missing {.id 7})) 0) (use-placeholder [] -1)))" "form") (let ((deadline (+ (float-time) 20))) (while (and (not flan-dev--stopped) (< (float-time) deadline)) (flan-dev--poll) (accept-process-output nil 0.05))) (test-flan--check "the client notices a stop nobody asked about" (equal flan-dev--stopped "Missing")) (test-flan--check "and the modeline says so, with the condition" (and (eq (flan-dev-state) 'stopped) (string-match-p "stopped" (flan-dev-mode-line)) (string-match-p "Missing" (flan-dev-mode-line)))) ;; What the prompt would offer. `completing-read' is not driven here — a ;; minibuffer in a batch run is a hang waiting to happen — so the list it ;; reads and the two commands it dispatches to are exercised instead. (test-flan--check "the restarts on offer are the ones the frame declared" (equal (flan-dev-restarts) '("use-placeholder"))) ;; And what it would put in front of someone. The labels carry the position, ;; because the position is what gets chosen: two frames may offer the same ;; name and only a number can say which one. A pure function over a reply, ;; so it is checked against the shapes a real daemon cannot easily be made to ;; produce as well as against the one it just did. (test-flan--check "the prompt numbers what it offers" (equal (flan-dev--restart-candidates '("use-placeholder") nil) '(("0. use-placeholder" . 0)))) (test-flan--check "a shadowed name is two distinguishable choices" (equal (mapcar #'cdr (flan-dev--restart-candidates '("retry" "use-placeholder" "retry") nil)) '(0 1 2))) (test-flan--check "a restart below the break is shown, and shown as such" (let ((table (flan-dev--restart-candidates '("retry" "use-placeholder") '(1)))) (and (not (string-match-p "cannot be taken" (caar table))) (string-match-p "cannot be taken" (car (nth 1 table))) (equal (cdr (nth 1 table)) 1)))) ;; The payoff. The break loop *is* the poll loop, so an expression sent now ;; runs on the stopped thread and comes back — which is the one moment ;; anybody actually wants C-x C-e to work. (goto-char (point-max)) (let ((beg (point))) (insert "\n(+ 20 3)") (let ((said (test-flan--said (flan-eval-last-sexp)))) (test-flan--check "C-x C-e works while the program is stopped" (and said (string-match-p "23" said)))) (delete-region beg (point-max))) ;; And installing, which the break loop allows on purpose: there is no frame ;; in progress, so the rule against swapping a body that is on the stack does ;; not apply. This is the fix-it-and-retry loop — the broken `step' is ;; replaced here, and the resume below returns into the old one for the last ;; time before every later call reaches the new body through its cell. (let ((said (test-flan--said (flan-dev--eval "(defn step [] i64 (set ticks (+ ticks 1)) ticks)" "form")))) (test-flan--check "a fix installs while the program is stopped" (and said (string-match-p "\\_" said)))) ;; Choosing one. "ok" from the daemon means accepted — the stopped thread ;; takes it on its next pass — so the client stops claiming a break and lets ;; the next poll settle it. ;; By position, which is the path `C-c C-b' takes: the name goes with it as ;; the receipt the program checks, not as the lookup. (flan-dev-restart-at 0 "use-placeholder") (let ((deadline (+ (float-time) 20))) (while (and (not (eq (flan-dev-state) 'live)) (< (float-time) deadline)) (flan-dev--poll) (accept-process-output nil 0.05))) (test-flan--check "choosing a restart resumes the program" (and (null flan-dev--stopped) (eq (flan-dev-state) 'live))) ;; ...and the client is an ordinary client again on the far side of it. (goto-char (point-max)) (let ((beg (point))) (insert "\n(+ 1 1)") (let ((said (test-flan--said (flan-eval-last-sexp)))) (test-flan--check "and everything works again afterwards" (and said (string-match-p "2" said)))) (delete-region beg (point-max))) ;; ── The documentation buffer ────────────────────────────────────────── ;; ;; The same four facts `defs' carries, in a buffer: eldoc answers while you ;; are typing, and a signature in the echo area is gone the moment you do ;; anything else. Run before the disconnect below, because it reads the ;; running program. (flan-doc "step") (with-current-buffer flan-doc-buffer (let ((text (buffer-string))) (test-flan--check "the doc buffer names the thing and its signature" (and (string-match-p "\\`step" text) (string-match-p "step \\[\\] i64" text))) (test-flan--check "and says what kind of thing it is" (string-match-p "Kind +fn" text)) )) ;; Where it is written, for a name that has not been re-installed from a ;; buffer since the daemon built it: `main' is still at the location the ;; daemon read it from, which is the ordinary case and the one with a ;; button on it. (flan-doc "main") (with-current-buffer flan-doc-buffer (test-flan--check "and where a definition is written" (string-match-p (regexp-quote (file-name-nondirectory program)) (buffer-string)))) ;; A global has no Loc in the Tast, so the buffer says that in the same words ;; M-. refuses in — rather than leaving the line out, which reads as though ;; the name had no home at all. (flan-doc "ticks") (with-current-buffer flan-doc-buffer (test-flan--check "a global says why there is no location" (string-match-p "no location for a var" (buffer-string)))) (let ((raised nil)) (condition-case err (flan-doc "no-such-name") (user-error (setq raised (error-message-string err)))) (test-flan--check "and a name the program has not got is refused" (and raised (string-match-p "no no-such-name" raised)))) ;; ── The watch buffer ────────────────────────────────────────────────── ;; ;; test_dev.ml proves the table itself: a program pushes and the daemon reads ;; it back without compiling anything. What is left to prove here is the ;; part that is only true in Emacs, and it is not the painting — it is that ;; an *asynchronous* sender and the ordinary synchronous request can share one ;; connection. ;; ;; The protocol is one reply per request on one socket. The watch timer ;; sends and does not wait, deliberately, because waiting on a 0.2s timer ;; stalls the UI. That leaves a reply in flight that the next C-c C-c would ;; read as its own — an evaluation reporting the watch table's answer, which ;; is the exact bug `flan-dev-settle-hook' exists to make impossible. This ;; program writes nothing into the table, which does not matter: the ;; interleaving is the claim. (flan-watch) (test-flan--check "the watch buffer opens" (get-buffer flan-watch-buffer)) (test-flan--check "and the timer is running" flan-watch--timer) (test-flan--check "a program that watches nothing says so, rather than looking broken" (with-current-buffer flan-watch-buffer (string-match-p "nothing is being watched" (buffer-string)))) ;; The tick by hand, so this does not depend on a timer firing inside a batch ;; run. Two of them: the first sends, the second collects and sends again. (flan-watch--tick) (test-flan--check "a tick leaves a request in flight rather than waiting for it" flan-watch--pending) ;; And now the interleaving, with a reply outstanding on purpose. If the ;; settle hook were not there this would return the watch table's plist and ;; `flan-dev--report' would take its missing :status for a rejection. ;; ;; Back in the source buffer first: `flan-doc' and `flan-watch' above both ;; display buffers of their own, and C-c C-c reads the buffer it is run in. (pop-to-buffer (flan-dev--buffer-visiting file)) (goto-char (point-min)) (search-forward "(defn step") (goto-char (match-beginning 0)) (let ((said (test-flan--said (flan-eval-defun)))) (test-flan--check "an eval with a watch reply in flight still gets its own answer" (and said (string-match-p "step" said))) (test-flan--check "and the watch request was settled, not abandoned" (null flan-watch--pending))) (flan-watch--tick) (flan-watch--tick) (test-flan--check "and the buffer keeps painting afterwards" (with-current-buffer flan-watch-buffer (> (buffer-size) 0))) ;; Point survives a repaint. This is why `replace-buffer-contents' is used ;; rather than erase-and-insert: the latter would put the cursor back at the ;; top of the buffer on every tick, which makes the one thing you want to do ;; in a watch buffer — look at a line while the program runs — impossible. (with-current-buffer flan-watch-buffer (goto-char (point-max)) (let ((where (point))) (flan-watch--tick) (flan-watch--tick) (test-flan--check "and point does not jump to the top on a repaint" (= (point) where)))) (flan-watch-stop) (test-flan--check "stopping cancels the timer" (null flan-watch--timer)) (test-flan--check "and takes the settle hook off with it" (not (memq #'flan-watch--settle flan-dev-settle-hook))) (kill-buffer flan-watch-buffer) (flan-disconnect) (test-flan--check "disconnected" (not (process-live-p flan-dev--connection))) (test-flan--check "and the poll timer is cancelled with it" (null flan-dev--timer)) ;; ── Starting the daemon from Emacs ──────────────────────────────────── ;; ;; Last, and after the disconnect above, because it runs a *second* daemon: ;; nothing before this should have to reason about which of two programs a ;; request went to. Its own socket for the same reason — and because ;; `flan dev' with no -s puts one beside the program, which for a test ;; program in /tmp is a path shared with every other thing running there. (setq flan-dev-command flan) (let ((socket2 (concat socket "-started-from-emacs"))) (ignore-errors (delete-file socket2)) (test-flan--check "nothing to quit before anything was started" (let ((raised nil)) (condition-case err (flan-dev-quit) (user-error (setq raised (error-message-string err)))) (and raised (string-match-p "no daemon started" raised)))) (flan-dev program socket2) (test-flan--check "M-x flan-dev builds, launches and connects" (and (process-live-p flan-dev--daemon) (eq (flan-dev-state) 'live))) (test-flan--check "and it is the program that was asked for" (member "step" (plist-get (flan-dev--request '(:op "describe")) :fns))) ;; Refused rather than silently restarted: a second daemon would take the ;; first one's program and everything in its memory with it. (test-flan--check "a second one is refused while the first is alive" (let ((raised nil)) (condition-case err (flan-dev program socket2) (user-error (setq raised (error-message-string err)))) (and raised (string-match-p "already running" raised)))) ;; The daemon owns the program's lifetime, so quitting has to actually end ;; the process — not just drop the socket and leave it running. ;; Restarting the program: for a change the running one cannot take — a ;; struct whose layout moved — where the answer is a new build, a new ;; process and the session that compiled it. Proved by what it throws ;; away: a name installed into the old program is not in the new one. (flan-dev--eval "(defn only-in-the-old-program [] i64 1)" "form") (test-flan--check "a name installed into the running program is there" (assoc "only-in-the-old-program" flan-dev--defs)) (let ((old flan-dev--daemon)) (flan-dev-restart-program) (test-flan--check "restarting gives a different daemon, connected" (and (not (process-live-p old)) (process-live-p flan-dev--daemon) (not (eq old flan-dev--daemon)) (eq (flan-dev-state) 'live)))) (test-flan--check "and a program built from source, without the addition" (and (assoc "step" flan-dev--defs) (null (assoc "only-in-the-old-program" flan-dev--defs)))) (let ((proc flan-dev--daemon)) (flan-dev-quit) ;; The process, not the variable: forgetting a daemon is not stopping ;; one, and a program left running with nothing attached to it is ;; exactly what the terminal loop used to leave behind. (test-flan--check "quitting ends the daemon" (and (not (process-live-p proc)) (null flan-dev--daemon) (not (process-live-p flan-dev--connection)) (eq (flan-dev-state) 'off))) ;; The daemon unlinks its socket on the way out, so this is the same ;; claim seen from the other side. (test-flan--check "and takes its socket with it" (not (file-exists-p socket2)))) (ignore-errors (delete-file socket2))) ;; A program that does not exist is refused here rather than by a daemon ;; that starts, fails to build and exits — which looks the same from a ;; distance and takes a compile to find out. (test-flan--check "a file that is not there is refused before anything starts" (let ((raised nil)) (condition-case err (flan-dev "/nonexistent/nope.flan") (user-error (setq raised (error-message-string err)))) (and raised (string-match-p "no such file" raised)))) ;; ── Navigating a file ───────────────────────────────────────────────── ;; ;; No daemon in any of this: imenu and which-function read the buffer, which ;; is the point — they work on a file nobody has run yet, and they keep ;; working when the program is stopped or gone. (with-temp-buffer (insert ";;;; A file with one of everything.\n" "(defstruct Missing [id i32])\n" "(defvar ticks i64)\n" "(defconst limit i64 10)\n" "(declare later [] i64)\n" "(defn step [] i64\n" " (let [x 1]\n" " (defn not-top-level [] i64 2)\n" " (+ ticks x)))\n") (flan-mode) (let* ((index (imenu--make-index-alist)) (group (lambda (name) (cdr (assoc name index))))) (test-flan--check "imenu finds a function, where it is written" (equal (marker-position (cdr (assoc "step" (funcall group "Functions")))) (save-excursion (goto-char (point-min)) (search-forward "(defn step") (match-beginning 0)))) (test-flan--check "and a struct, under its own heading" (assoc "Missing" (funcall group "Types"))) (test-flan--check "and both kinds of global" (and (assoc "ticks" (funcall group "Variables")) (assoc "limit" (funcall group "Variables")))) ;; A forward declaration is not a definition; listing it beside one ;; would show the same name twice with nothing to tell them apart. (test-flan--check "and a declaration, said to be one" (and (assoc "later" (funcall group "Declared")) (null (assoc "later" (funcall group "Functions"))))) ;; A `defn' inside a `let' defines nothing at the top level, and the ;; index is anchored at column 0 so that it cannot offer one. (test-flan--check "and nothing that is not a top-level form" (null (assoc "not-top-level" (funcall group "Functions"))))) ;; which-function: the case is a long body scrolled past its own header. (goto-char (point-min)) (search-forward "(+ ticks x)") (test-flan--check "which-function names the definition point is in" (equal (flan-current-defun-name) "step")) (goto-char (point-min)) (test-flan--check "and says nothing above the first one" (null (flan-current-defun-name)))) ;; ── A rejection lasts as long as the action it was about ────────────── ;; ;; The overlay is feedback on the evaluation that just failed, so the next ;; command in that buffer takes it down. What is checked here is the ;; mechanism and not Emacs' command loop: `execute-kbd-macro' under --batch ;; runs no `pre-command-hook' at all (and does not even move point), so there ;; is no way from here to make the real loop run one. `run-hooks' is what ;; the loop calls, and calling it is the closest honest thing — it proves the ;; hook is installed, in the right buffer and nowhere else, and that running ;; it clears the overlay and uninstalls itself. It does not prove Emacs runs ;; it, which is Emacs' own contract. (let ((buf (flan-dev--buffer-visiting file))) (with-current-buffer buf (flan-dev-clear-errors) (let ((marked (flan-dev--show-error (format "%s:2:1" file) "no such name"))) (test-flan--check "a rejection is marked in the buffer it came from" (and marked (flan-dev--error-overlays))) (test-flan--check "and the buffer is armed to take it down again" (memq #'flan-dev--clear-errors-on-command pre-command-hook)) ;; Buffer-local, or every buffer in the session runs this on every ;; keystroke for the sake of a buffer that had one bad evaluation. (test-flan--check "and nobody else is" (not (memq #'flan-dev--clear-errors-on-command (default-value 'pre-command-hook)))) (run-hooks 'pre-command-hook) (test-flan--check "the next command in that buffer clears it" (null (flan-dev--error-overlays))) (test-flan--check "and the hook goes with the last overlay" (not (memq #'flan-dev--clear-errors-on-command pre-command-hook)))))) ;; ── Disassembly ─────────────────────────────────────────────────────── ;; ;; Its own daemon, because the first one was disconnected above and a ;; disassembly is a question only a live session can answer: the daemon is ;; the thing that built the module and still has the .ll and the .so. (let ((socket3 (concat socket "-disasm"))) (ignore-errors (delete-file socket3)) (flan-dev program socket3) (test-flan--check "a daemon to disassemble against" (process-live-p flan-dev--connection)) (when (executable-find "objdump") (flan-disassemble "step") (with-current-buffer flan-disassembly-buffer (let ((text (buffer-string))) (test-flan--check "C-c C-a writes a disassembly of the name" (string-match-p "\\`; disassembly for step" text)) ;; The header is the half a listing cannot carry: what the answer ;; claims, which for generated code is never "this is running". (test-flan--check "with the daemon's own account of what it shows" (string-match-p "showing" text)) (test-flan--check "and instructions under it, numbered from zero" (string-match-p "^ 0000 " text))))) ;; The other half of the same question, on the same body. (flan-disassemble "step" t) (with-current-buffer flan-disassembly-buffer (let ((text (buffer-string))) (test-flan--check "C-u C-c C-a writes the IR it was built from" (and (string-match-p "\\`; LLVM IR for step" text) (string-match-p "^define .*flan\\.step" text))) ;; Nothing has been evaluated into this daemon, so the body in the ;; cell is still the one the process was launched with — the one case ;; where what is installed *now* is knowable, and it says so. (test-flan--check "and says the program is still running the host's copy" (string-match-p "host executable" text)))) ;; Refused by name rather than shown as an empty buffer. (test-flan--check "a name the program does not define is refused by name" (let ((raised nil)) (condition-case err (flan-disassemble "no-such-thing") (user-error (setq raised (error-message-string err)))) (and raised (string-match-p "no function named" raised)))) (test-flan--check "and so is a global, which has no code to show" (let ((raised nil)) (condition-case err (flan-disassemble "ticks") (user-error (setq raised (error-message-string err)))) (and raised (string-match-p "not a function" raised)))) (flan-dev-quit) (ignore-errors (delete-file socket3))) ;; ── Marking a form with (pause) ─────────────────────────────────────── ;; ;; DISCUSS.md §9: `C-u' before an evaluation marks a form so the program ;; stops when it runs, and the buffer is never edited — the position goes on ;; the wire beside the code and the daemon splices the call in after parsing. ;; ;; Last in this file on purpose: the one live check here *stops the program*, ;; and everything above it needs one that is running. ;; ;; Three things are the client's own and need no daemon at all: which form a ;; prefix argument picks, the byte column that names it, and the fact that a ;; mark outlives the next command where a rejection does not. (with-temp-buffer (flan-mode) (insert "(defvar ticks i64)\n\n(defn step [] i64\n (set ticks (+ ticks 1))\n ticks)\n") (goto-char (point-min)) (search-forward "(+ ticks 1)") (goto-char (1- (match-end 0))) ; inside the (+ ...), before its ")" (let* ((b (flan-dev--defun-bounds)) (inner (flan-dev--pause-bounds b '(4))) (whole (flan-dev--pause-bounds b '(16)))) (test-flan--check "no prefix marks nothing" (null (flan-dev--pause-bounds b nil))) (test-flan--check "C-u marks the form point is inside" (equal (buffer-substring-no-properties (car inner) (cdr inner)) "(+ ticks 1)")) ;; A `defn' is a declaration and cannot be wrapped in a `do', so the ;; daemon reads the top-level form's own position as "stop on entry". (test-flan--check "C-u C-u marks the top-level form itself" (equal whole b))) ;; Point at the very start of the defn is not nested inside anything, and ;; `backward-up-list' would either fail or walk somewhere surprising. It ;; falls back to the defun, which is the only honest answer. (goto-char (point-min)) (search-forward "(defn step") (goto-char (match-beginning 0)) (let ((b (flan-dev--defun-bounds))) (test-flan--check "and a prefix with point not nested falls back to it" (equal (flan-dev--pause-bounds b '(4)) b))) ;; The column is a byte offset, because the reader walks the source a byte ;; at a time. Same rule as the `:loc' column, the other way round — and ;; `flan-dev--position' is the inverse, so a round trip is the check. (goto-char (point-min)) (search-forward "(+ ticks 1)") (let* ((pos (match-beginning 0)) (lc (flan-dev--wire-position pos))) (test-flan--check "a marked position round-trips through the wire" (= (flan-dev--position (nth 0 lc) (nth 1 lc)) pos))) (with-temp-buffer (insert ";; héllo\n(defn wörld [] i64 (+ 1 1))\n") (goto-char (point-min)) (search-forward "(+ 1 1)") (test-flan--check "and counts bytes, not characters, past a non-ASCII one" (equal (flan-dev--wire-position (match-beginning 0)) (list 2 (1+ (string-bytes "(defn wörld [] i64 "))))))) ;; A mark is an annotation on the running program and not feedback about one ;; command, so unlike a rejection it has to survive the next keystroke. That ;; difference is the whole of its lifetime, and it is checked here the same ;; way the rejection's is: by calling what the command loop calls. (with-temp-buffer (flan-mode) (insert "(defn step [] i64 (+ 1 1))\n") (goto-char (point-min)) (search-forward "(+ 1 1)") (flan-dev--show-pause (match-beginning 0) (match-end 0)) (test-flan--check "a mark is drawn over the form" (= 1 (length (flan-dev--pause-overlays)))) (run-hooks 'pre-command-hook) (test-flan--check "and survives the next command, where a rejection would not" (= 1 (length (flan-dev--pause-overlays)))) ;; Re-marking the same form leaves one, not two stacked overlays whose ;; faces compound into something that is not the face. (flan-dev--show-pause (match-beginning 0) (match-end 0)) (test-flan--check "and marking it again leaves one mark, not two" (= 1 (length (flan-dev--pause-overlays)))) (flan-dev-clear-pause) (test-flan--check "and clearing takes it down" (null (flan-dev--pause-overlays)))) ;; And once against a real daemon: the round trip, the overlay drawn off the ;; reply's `:pause' rather than off what was asked for, and the mark coming ;; down again when the same form is evaluated plainly. (let ((socket4 (concat socket "-pause"))) (ignore-errors (delete-file socket4)) (flan-dev program socket4) (test-flan--check "a daemon to mark a form in" (process-live-p flan-dev--connection)) (with-temp-buffer (flan-mode) (insert "(defn step [] i64\n (set ticks (+ ticks 1))\n ticks)\n") (goto-char (point-min)) (search-forward "(+ ticks 1)") (goto-char (1- (match-end 0))) (flan-eval-defun '(4)) (let ((ovs (flan-dev--pause-overlays))) (test-flan--check "C-u C-c C-c marks the form point is inside" (and (= 1 (length ovs)) (equal (buffer-substring-no-properties (overlay-start (car ovs)) (overlay-end (car ovs))) "(+ ticks 1)"))) (test-flan--check "and marks it as a pause, not as an error" (and ovs (eq (overlay-get (car ovs) 'face) 'flan-dev-pause-face)))) ;; The program calls `step' every few milliseconds, so it stops almost at ;; once — but "almost" is not "before this line", so wait for it. (let ((tries 400)) (while (and (> tries 0) (not (eq (flan-dev-state) 'stopped))) (setq tries (1- tries)) (flan-dev--request (list :op "describe")) (sleep-for 0.005))) (test-flan--check "and the program stops there" (eq (flan-dev-state) 'stopped)) ;; A plain `C-c C-c' over the same form replaces the stored declaration ;; with an unmarked one, which is what makes the mark stop sticking — and ;; the overlay has to go with it or the buffer is claiming a breakpoint ;; the daemon no longer has. Allowed while stopped: there is no frame in ;; progress for `step'. (flan-eval-defun) (test-flan--check "and an ordinary C-c C-c takes the mark down again" (null (flan-dev--pause-overlays))) ;; Left running, because the next thing this file does is finish and the ;; daemon's program is killed with it — but a test that ends with the ;; program parked is one nobody can add anything after. (flan-dev-restart "continue") (let ((tries 400)) (while (and (> tries 0) (eq (flan-dev-state) 'stopped)) (setq tries (1- tries)) (flan-dev--request (list :op "describe")) (sleep-for 0.005))) (test-flan--check "and it resumes when continue is taken" (not (eq (flan-dev-state) 'stopped)))) (flan-dev-quit) (ignore-errors (delete-file socket4))) ;; ── C-c C-m: what a macro call expands to ───────────────────────────── ;; ;; test_session.ml drives the expansion itself and is where the language ;; cases live. What this adds is the half that is only true in an editor: ;; which region the command picks, that the expansion arrives as readable ;; source in a buffer, that expanding again in place works, and — the one ;; worth a real daemon — that a macro which never settles comes back as a ;; refusal drawn on the call, at its own line *and its own column*. ;; ;; That last one is what `flan-dev--text-at' exists for. `C-x C-e' sends a ;; raw substring and `flan-dev--text' pads lines only, because a top-level ;; form starts at column 1; a macro call is written well inside a line, and ;; a refusal against it would otherwise be drawn at the start of that line. (let ((socket5 (concat socket "-macro"))) (ignore-errors (delete-file socket5)) (flan-dev program socket5) (test-flan--check "a daemon to expand macros against" (process-live-p flan-dev--connection)) (with-current-buffer (get-file-buffer file) (goto-char (point-max)) (insert "\n(defn expandable [] i32\n (unless false 1 2)\n (clamp 9 0 3))\n") ;; One step, on a prelude macro. Point on the opening paren, which is ;; where a reader puts it and which `C-x C-e' could not use. (goto-char (point-max)) (search-backward "(unless false 1 2)") (flan-macroexpand) (test-flan--check "C-c C-m expands the form point is on" (with-current-buffer flan-macroexpansion-buffer (string-match-p "(if (not false)" (buffer-string)))) (test-flan--check "and says which macro ran" (with-current-buffer flan-macroexpansion-buffer (string-match-p "macro +unless" (buffer-string)))) (test-flan--check "and the buffer is Flan source, not a dump" (with-current-buffer flan-macroexpansion-buffer (and (derived-mode-p 'flan-mode) buffer-read-only))) ;; The three keys that would send an expansion back as though it were a ;; file refuse by name rather than doing nothing. (test-flan--check "C-c C-c in the expansion buffer refuses, and says why" (with-current-buffer flan-macroexpansion-buffer (condition-case e (progn (call-interactively (key-binding (kbd "C-c C-c"))) nil) (user-error (string-match-p "not source" (error-message-string e)))))) ;; All the way, off the same key with a prefix. (goto-char (point-max)) (search-backward "(clamp 9 0 3)") (flan-macroexpand t) (test-flan--check "C-u C-c C-m expands all the way" (with-current-buffer flan-macroexpansion-buffer (and (string-match-p "all the way" (buffer-string)) (string-match-p (regexp-quote "(min 3 (max 0 9))") (buffer-string))))) ;; A macro that never settles, evaluated into the session and then asked ;; about. One step answers — it makes one call and does not look at what ;; comes back — and all the way is refused at the bound rather than ;; hanging the daemon, which is the failure that would wedge the editor ;; with the program still on screen. (flan-dev--request (list :op "eval" :file file :code "(defmacro spinner [args] `(spinner ~@args))")) (goto-char (point-max)) (insert "\n(defn spun [] i32\n (spinner 1))\n") (goto-char (point-max)) (search-backward "(spinner 1)") (let ((call (point)) (r (flan-macroexpand))) (test-flan--check "one step of a macro that does not settle answers" (and (null (plist-get r :expanded)) (equal (plist-get r :macro) "spinner"))) (flan-dev-clear-errors) (goto-char call) (let ((said (condition-case e (progn (flan-macroexpand t) nil) (user-error (error-message-string e))))) (test-flan--check "and all the way is refused rather than hanging" (and said (string-match-p "did not settle" said))) ;; The whole of `flan-dev--text-at': the refusal's location is the ;; call's own line and column, so the overlay lands on the call and ;; not at the start of the line it is written on. (let ((ovs (flan-dev--error-overlays))) (test-flan--check "and the refusal is drawn on the call, at its own column" (and (= 1 (length ovs)) (= (overlay-start (car ovs)) call))))) (flan-dev-clear-errors)) ;; Expanding again in place, which is what makes one-step-by-default ;; usable rather than a thing you press once and lose. Two macros into ;; the session, the outer one quasiquoting a call to the inner: that is ;; the shape where one step leaves a macro call standing, and it is the ;; only shape where expanding in place has anything to do. (flan-dev--request (list :op "eval" :file file :code "(defmacro m-inner [args] `(+ ~(at args 0) 1))")) (flan-dev--request (list :op "eval" :file file :code "(defmacro m-outer [args] `(m-inner ~(at args 0)))")) (goto-char (point-max)) (insert "\n(defn outered [] i32 (m-outer 5))\n") (goto-char (point-max)) (search-backward "(m-outer 5)") (flan-macroexpand) (test-flan--check "one step leaves the call to the macro it quasiquoted" (with-current-buffer flan-macroexpansion-buffer (string-match-p (regexp-quote "(m-inner 5)") (buffer-string)))) (with-current-buffer flan-macroexpansion-buffer (goto-char (point-min)) (search-forward "(m-inner 5)") (goto-char (match-beginning 0)) (flan-macroexpand-again) (test-flan--check "m in the expansion buffer expands the form at point in place" (and (string-match-p (regexp-quote "(+ 5 1)") (buffer-string)) (not (string-match-p (regexp-quote "(m-inner 5)") (buffer-string)))))) ;; And the same call taken all the way in one go, which is the half the ;; prefix argument is for: no intermediate at all. (goto-char (point-max)) (search-backward "(m-outer 5)") (flan-macroexpand t) (test-flan--check "C-u goes straight to the fixpoint" (with-current-buffer flan-macroexpansion-buffer (and (string-match-p (regexp-quote "(+ 5 1)") (buffer-string)) (not (string-match-p (regexp-quote "(m-inner 5)") (buffer-string))))))) (flan-dev-quit) (ignore-errors (delete-file socket5))) (if (zerop test-flan--failures) (message "flan-dev.el: all tests passed") (message "\n%d failure(s)" test-flan--failures) (kill-emacs 1))) ;;; test-flan-dev.el ends here