lisp.el finishes end-of-defun by skipping blanks and stepping over the newline after the closing paren, so the bounds C-c C-c works from end at the start of the next line. The declaration path never cared -- a newline more or less in the text sent changes nothing -- but a value drawn at that position sits in column 0 below the form, in the gap before the next one. The fixture that missed it inserted the expression at point-max with nothing after it, which is the one shape where end-of-defun has no newline to step over. It has one now, and the check is the closing paren rather than a line number. The error-overlay check was the same kind of weak: it asserted the line was not 1, which flan--text alone would have given. It asserts the token now, which is what flan--text-at is for -- and eval-expr's replies do respect the column padding, so the switch stands.
1925 lines
104 KiB
EmacsLisp
1925 lines
104 KiB
EmacsLisp
;;; test-flan.el --- Drive the client against a running program -*- lexical-binding: t; -*-
|
|
|
|
;; Run as: emacs -Q --batch -L emacs -l emacs/test-flan.el -- <socket> <flan-file>
|
|
;;
|
|
;; 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)
|
|
(require 'flan-repl)
|
|
(require 'flan-watch)
|
|
(require 'flan-lower)
|
|
|
|
(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--result ()
|
|
"The text of the inline result overlay in this buffer, or nil.
|
|
An expression's value now goes beside the form rather than into the echo
|
|
area, so this is where the assertions that used to read `test-flan--said'
|
|
read instead. Overlays exist under --batch — the error-overlay checks below
|
|
already rely on it — so nothing here is a stand-in for the real thing."
|
|
(let ((ovs (flan--result-overlays)))
|
|
(and (= 1 (length ovs))
|
|
(substring-no-properties
|
|
(or (overlay-get (car ovs) 'after-string) "")))))
|
|
|
|
(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-state) 'off)
|
|
(string-match-p "off" (flan-mode-line))))
|
|
|
|
(flan-connect socket)
|
|
(test-flan--check "connected" (process-live-p flan--connection))
|
|
(test-flan--check "the modeline says a program is there"
|
|
(and (eq (flan-state) 'live)
|
|
(string-match-p "live" (flan-mode-line))))
|
|
(test-flan--check "and says nothing in a buffer that is not Flan's"
|
|
(with-temp-buffer (null (flan-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-mode-line)) mode-line-misc-info))
|
|
(test-flan--check "and not in everyone else's"
|
|
(with-temp-buffer
|
|
(not (member '(:eval (flan-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--connection)
|
|
(test-flan--check "a dead connection reads as lost, not as never-connected"
|
|
(and (eq (flan-state) 'lost)
|
|
(string-match-p "lost" (flan-mode-line))))
|
|
(let ((r (flan--request '(:op "describe"))))
|
|
(test-flan--check "the next request reconnects on its own"
|
|
(and (process-live-p flan--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--defs))
|
|
|
|
;; But a socket nobody is listening on is refused by name, rather than
|
|
;; retried forever or reported as some other failure.
|
|
(let ((flan--connection nil)
|
|
(flan--socket "/nonexistent/flan-not-here.sock")
|
|
(raised nil))
|
|
(condition-case err (flan--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-not-here.sock" raised)
|
|
(string-match-p "nothing is listening" raised))))
|
|
|
|
;; ── The two frames a real daemon does not send ────────────────────────
|
|
;;
|
|
;; Both of these are what the client does when the other end is wrong, and
|
|
;; neither can be asked of a daemon that is working: a stand-in process
|
|
;; stands where one would be, with its frames written into its buffer by
|
|
;; hand. A live process rather than a dead one on purpose — which of the
|
|
;; two silences the reader reports depends on whether the far end is still
|
|
;; there, and this is the branch a person actually meets.
|
|
;;
|
|
;; What is under test is the state the connection is left in. A truncated
|
|
;; frame used to raise `wrong-type-argument' out of `byte-to-position' and
|
|
;; leave its header in the buffer, so every later request read the frame
|
|
;; before its own, for ever; an unreadable payload used to be read before it
|
|
;; was deleted, so it was never consumed and the *same* bytes signalled
|
|
;; again on every request after it. Both were permanent, and both are meant
|
|
;; to cost one request now.
|
|
(let* ((buf (generate-new-buffer " *flan-stand-in*"))
|
|
;; `cat' with nothing on its input: a process that is alive, has a
|
|
;; buffer, and will never say anything of its own.
|
|
(stand-in (start-process "flan-stand-in" buf "cat")))
|
|
(set-process-query-on-exit-flag stand-in nil)
|
|
;; Unibyte, as `flan--open' makes it: the framing counts bytes, and
|
|
;; `position-bytes' on a multibyte buffer counts something else.
|
|
(with-current-buffer buf (set-buffer-multibyte nil))
|
|
(unwind-protect
|
|
(let ((flan-reply-timeout 0.3))
|
|
;; Announced 40 bytes, wrote 12, went quiet.
|
|
(with-current-buffer buf (insert "40\n(:status \"o"))
|
|
(let ((raised nil))
|
|
(condition-case err (flan--read-reply stand-in)
|
|
(error (setq raised (error-message-string err))))
|
|
(test-flan--check "a body that stalls is a timeout, said in words"
|
|
(and raised (string-match-p "no reply in" raised)))
|
|
(test-flan--check "and the half-frame is dropped rather than left to be read as the next reply"
|
|
(zerop (buffer-size buf))))
|
|
;; A payload that will not read, with a good frame behind it: the
|
|
;; claim is that the second one arrives, which is the whole of
|
|
;; self-healing and stronger than an empty buffer.
|
|
;; Two frames, written one after the other because that is what
|
|
;; they are: three bytes of `(:a', which will not read, and then a
|
|
;; whole reply behind it.
|
|
(with-current-buffer buf
|
|
(insert "3\n(:a")
|
|
(insert "14\n(:status \"ok\")"))
|
|
(let ((raised nil))
|
|
(condition-case err (flan--take-reply stand-in)
|
|
(error (setq raised (error-message-string err))))
|
|
(test-flan--check "a payload that will not read signals"
|
|
raised)
|
|
(test-flan--check "and is consumed, so the reply behind it arrives"
|
|
(equal (flan--take-reply stand-in)
|
|
'(:status "ok")))))
|
|
(delete-process stand-in)
|
|
(kill-buffer buf)))
|
|
|
|
(let ((r (flan--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))))
|
|
|
|
;; The third state. A program that finishes no longer ends its process: the
|
|
;; main thread parks holding every global, and `flan-rerun' sends it round
|
|
;; `main' again. The client has to be able to *say* that, because from
|
|
;; anywhere else in Emacs a finished program and a running one look the same.
|
|
;;
|
|
;; Driven through `flan--absorb' with a made-up reply rather than by
|
|
;; waiting for this program to finish. What is under test is the client's
|
|
;; reading of `:parked', which is a decision it makes about a plist; making
|
|
;; the real program park first would put the daemon, the agent and a
|
|
;; condition variable between the question and the answer, and test_dev.ml
|
|
;; already does that end to end.
|
|
(let ((said (test-flan--said
|
|
(flan--absorb '(:status "ok" :stopped nil :parked t)))))
|
|
(test-flan--check "a reply that says parked makes the client say so"
|
|
(and (eq (flan-state) 'parked)
|
|
(string-match-p "parked" (flan-mode-line))))
|
|
;; Once, on the edge, and naming the way out: the poll runs every second
|
|
;; and the whole point of the message is that it is read.
|
|
(test-flan--check "and says how to get the program back, once"
|
|
(and said (string-match-p "runs it again" said)
|
|
(null (test-flan--said
|
|
(flan--absorb
|
|
'(:status "ok" :stopped nil :parked t)))))))
|
|
;; Stopped is not parked and must win where both arrive: a break has
|
|
;; restarts to choose and is the state with something to answer in it.
|
|
(flan--absorb '(:status "ok" :stopped t :condition "Missing" :parked t))
|
|
(test-flan--check "a stopped program reads as stopped even while parked is set"
|
|
(eq (flan-state) 'stopped))
|
|
(flan--absorb '(:status "ok" :stopped nil :parked nil))
|
|
(test-flan--check "and both clear again"
|
|
(eq (flan-state) 'live))
|
|
|
|
;; `flan-describe' had two words for three states, and "exited" was the
|
|
;; wrong one of them: nothing exited, and being told so is what sent people
|
|
;; to `flan-restart-program' for a thing `flan-rerun' does without
|
|
;; losing the build.
|
|
(test-flan--check
|
|
"flan-describe names the parked state rather than calling it exited"
|
|
(let ((said (test-flan--said
|
|
(cl-letf (((symbol-function 'flan--request)
|
|
(lambda (&rest _)
|
|
'(:status "ok" :fns ("step") :globals ("ticks")
|
|
:alive t :parked t))))
|
|
(flan-describe)))))
|
|
(and said (string-match-p "parked" said))))
|
|
|
|
;; And the refusal a parked program gives an op it cannot answer, which is
|
|
;; the daemon's words reaching a person: it has to name the state and the
|
|
;; command, not say the program exited.
|
|
(test-flan--check
|
|
"a parked refusal reaches the user with the way out in it"
|
|
(let ((raised nil))
|
|
(cl-letf (((symbol-function 'flan--request)
|
|
(lambda (&rest _)
|
|
'(:status "error"
|
|
:message "a backtrace is the frames of a stopped program, and a parked one has no frames at all; the program has finished and its process is parked — M-x flan-rerun starts it again"))))
|
|
(condition-case err (flan-rerun)
|
|
(error (setq raised (error-message-string err)))))
|
|
(and raised (string-match-p "parked" raised)
|
|
(string-match-p "flan-rerun" raised))))
|
|
|
|
;; `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--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--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--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--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--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-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-error))
|
|
(overlays-in (point-min) (point-max)))))
|
|
(test-flan--check "and says which name landed"
|
|
(and said (string-match-p "\\_<step\\_>" 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--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--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--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--defs))
|
|
(test-flan--check "with a signature"
|
|
(equal (nth 2 (assoc "step" flan--defs)) "step [] i64"))
|
|
(test-flan--check "a global is known, and says it is one"
|
|
(equal (nth 1 (assoc "ticks" flan--defs)) "var"))
|
|
(test-flan--check "so is an imported package's extern"
|
|
(let ((d (assoc "agent/wait-raw" flan--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--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-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-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-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-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--eval "(defn freshly-added [] i64 7)" "form")
|
|
(test-flan--check "a name just installed is known immediately"
|
|
(equal (nth 2 (assoc "freshly-added" flan--defs))
|
|
"freshly-added [] i64"))
|
|
|
|
;; The session is not poisoned by that: a good form still lands.
|
|
(flan--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--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--request '(:op "describe")))
|
|
(setq seen (with-current-buffer (get-buffer-create flan-output-buffer)
|
|
(string-match-p "HELLO" (buffer-string)))))
|
|
(test-flan--check "the program's output reaches its buffer" seen))
|
|
|
|
;; ── Which evaluator C-x C-e reaches ──────────────────────────────────
|
|
;;
|
|
;; The dispatch is off the form that would be *sent*, so these four are
|
|
;; checked with point where the key would be pressed rather than by handing
|
|
;; the predicate a string: a form's depth is a fact about the buffer, and an
|
|
;; off-by-one at the open delimiter would make every form read as nested,
|
|
;; leave the feature switched permanently off, and still pass a test that
|
|
;; only looked at the head. No daemon round trip here — nothing is sent —
|
|
;; which is why all four are affordable.
|
|
(goto-char (point-min))
|
|
(search-forward "(defvar ticks i64)")
|
|
(test-flan--check "a top-level defvar takes the declaration path"
|
|
(equal (car (flan--declaration-before-point)) "defvar"))
|
|
(goto-char (point-min))
|
|
(search-forward " ticks)")
|
|
(test-flan--check "and so does a top-level defn"
|
|
(equal (car (flan--declaration-before-point)) "defn"))
|
|
;; The case the whole "form, not cursor" rule exists for: this line is
|
|
;; inside `step', so the enclosing declaration is a `defn' — and evaluating
|
|
;; here must still mean this expression, not a reinstall of the function.
|
|
(goto-char (point-min))
|
|
(search-forward "(+ ticks 41)")
|
|
(test-flan--check "an expression inside a defn body does not"
|
|
(null (flan--declaration-before-point)))
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
(insert "\n(+ 2 3)")
|
|
(test-flan--check "nor does a bare expression at top level"
|
|
(null (flan--declaration-before-point)))
|
|
;; ...and it still evaluates as one, which is the half of this key that
|
|
;; was already working and must not have moved. The value is now drawn
|
|
;; beside the form and no longer echoed, so both halves are asserted: an
|
|
;; overlay that says it, and an echo area that does not say it twice.
|
|
(let ((said (test-flan--said (flan-eval-last-sexp))))
|
|
(test-flan--check "which C-x C-e evaluates and shows beside the form"
|
|
(let ((r (test-flan--result)))
|
|
(and r (string-match-p "5" r)
|
|
(string-match-p "=>" r))))
|
|
(test-flan--check "and does not also say it in the echo area"
|
|
(not (and said (string-match-p "=>" said))))
|
|
;; The overlay is anchored to the form that produced it, not to point
|
|
;; and not to the start of the line: two expressions one under the
|
|
;; other both returning 2 is the case the echo area cannot answer.
|
|
(test-flan--check "the value is drawn at the end of the form"
|
|
(let ((ovs (flan--result-overlays)))
|
|
(and (= 1 (length ovs))
|
|
(= (overlay-start (car ovs)) (point-max)))))
|
|
;; It lasts exactly as long as the evaluation it is about. The hook is
|
|
;; buffer-local and installed only while there is something to remove,
|
|
;; the way the error overlays' is.
|
|
(test-flan--check "and a command in the buffer takes it away"
|
|
(progn
|
|
(test-flan--check
|
|
"with a hook installed while it is up"
|
|
(memq #'flan--clear-result-on-command
|
|
(buffer-local-value 'pre-command-hook
|
|
(current-buffer))))
|
|
(flan--clear-result-on-command)
|
|
(and (null (flan--result-overlays))
|
|
(not (memq #'flan--clear-result-on-command
|
|
(buffer-local-value
|
|
'pre-command-hook
|
|
(current-buffer))))))))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; Switched off, the echo area is what is left: the two settings are not two
|
|
;; spellings of one thing, and a value has to come out somewhere.
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
(insert "\n(+ 7 8)")
|
|
(let* ((flan-inline-result nil)
|
|
(said (test-flan--said (flan-eval-last-sexp))))
|
|
(test-flan--check "with the overlay off the value goes back to the echo area"
|
|
(and said (string-match-p "15" said)
|
|
(string-match-p "=>" said)
|
|
(null (flan--result-overlays)))))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; ── C-c C-c on what is not a declaration ─────────────────────────────
|
|
;;
|
|
;; The complaint this routing came from: `(+ 1 1)' written at the top level
|
|
;; of a buffer, `C-M-x' pressed, and the parser's "is a top-level
|
|
;; declaration, not an expression" coming back — because the key only ever
|
|
;; had the one path. `C-M-x' and `C-c C-c' are one command and both route.
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
;; The trailing newline is the point, not tidiness: `end-of-defun' steps
|
|
;; over it, so the bounds it hands back end at the start of the next line
|
|
;; and a value drawn at that position appears in column 0 below the form.
|
|
;; Without the newline here the bug is invisible.
|
|
(insert "\n(+ 1 1)\n")
|
|
;; Point *inside* the form, which is where `C-c C-c' is pressed from and
|
|
;; the difference between this and `C-x C-e': the form is the one point is
|
|
;; in, not the one behind it.
|
|
(search-backward "1 1")
|
|
(test-flan--check "C-M-x on a bare top-level expression evaluates it"
|
|
(progn (flan-eval-defun)
|
|
(let ((r (test-flan--result)))
|
|
(and r (string-match-p "=> 2" r)))))
|
|
(test-flan--check "and draws the value after the closing paren"
|
|
(let ((ovs (flan--result-overlays)))
|
|
(and (= 1 (length ovs))
|
|
(= (char-before (overlay-start (car ovs))) ?\))
|
|
(= (line-number-at-pos (overlay-start (car ovs)))
|
|
(line-number-at-pos
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(search-forward "(+ 1 1)")))))))
|
|
;; What a keystroke would do. There are no commands under --batch, so the
|
|
;; `pre-command-hook' that takes a value down never runs and the next
|
|
;; check below would be reading this one's overlay.
|
|
(flan-clear-result)
|
|
(goto-char (point-max))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; And the other half, which is the one that must not have moved: point in
|
|
;; the middle of a `defn' body still reinstalls the enclosing declaration.
|
|
;; `flan--defun-bounds' takes the form point is *inside* and
|
|
;; `flan--declaration-before-point' the one *before* point, and routing
|
|
;; C-c C-c off the second would have turned this into C-x C-e.
|
|
(goto-char (point-min))
|
|
(search-forward "(+ ticks 41)")
|
|
(test-flan--check "the head is asked of the form point is inside"
|
|
(equal (flan--declaration-head-at
|
|
(car (flan--defun-bounds)))
|
|
"defn"))
|
|
(let ((said (test-flan--said (flan-eval-defun))))
|
|
(test-flan--check "C-c C-c from inside a defn body still installs it"
|
|
(and said (string-match-p "\\_<step\\_>" said)
|
|
(string-match-p "installed" said)))
|
|
(test-flan--check "and draws no value beside it"
|
|
(null (flan--result-overlays))))
|
|
|
|
;; `package' is the one head the two keys disagree about, and it is a real
|
|
;; disagreement rather than an oversight: `Parse.expr' has no arm refusing
|
|
;; it, so C-x C-e's "would this fail as an expression" says no, while
|
|
;; `Parse.decl' has one and C-c C-c has always installed it. Routing both
|
|
;; off one list would have quietly taken that away. Nothing is sent here —
|
|
;; the claim is only about which path each key picks.
|
|
(with-temp-buffer
|
|
(flan-mode)
|
|
(insert "(package demo)\n")
|
|
(goto-char (point-min))
|
|
(search-forward "demo")
|
|
(test-flan--check "C-c C-c treats a package form as a declaration"
|
|
(equal (flan--declaration-head-at
|
|
(car (flan--defun-bounds)) flan--defun-heads)
|
|
"package"))
|
|
(goto-char (point-max))
|
|
(test-flan--check "and C-x C-e does not, for the reason it never has"
|
|
(null (flan--declaration-before-point))))
|
|
|
|
;; A prefix on the expression path is a flag and not a position — there is
|
|
;; no inside for one to point at — so both `C-u' and `C-u C-u' mean the one
|
|
;; thing, which is `C-u C-x C-e's behaviour reached through the other key.
|
|
;;
|
|
;; Checked by reading the request rather than by sending it: `:pause t' on a
|
|
;; thunk parks the program in the break loop, and doing that here would hand
|
|
;; every test below this line a stopped daemon. What the daemon then does
|
|
;; with the flag is test_dev.ml's question anyway; this one is only whether
|
|
;; the client sends a flag where it cannot send a position.
|
|
(goto-char (point-max))
|
|
(let ((beg (point))
|
|
(sent nil))
|
|
(insert "\n(+ 3 4)")
|
|
(search-backward "3 4")
|
|
(let ((probe (lambda (req) (setq sent req) '(:status "ok" :value "7"))))
|
|
(advice-add 'flan--request :override probe)
|
|
(unwind-protect
|
|
(progn
|
|
(flan-eval-defun '(4))
|
|
(test-flan--check "C-u C-M-x on an expression sends the flag"
|
|
(and (equal (plist-get sent :op) "eval-expr")
|
|
(eq (plist-get sent :pause) t)))
|
|
(flan-eval-defun '(16))
|
|
(test-flan--check "and C-u C-u means the same, having nowhere else to point"
|
|
(eq (plist-get sent :pause) t))
|
|
(flan-eval-defun)
|
|
(test-flan--check "and no prefix sends no flag at all"
|
|
(null (plist-member sent :pause))))
|
|
(advice-remove 'flan--request probe)))
|
|
(test-flan--check "none of which leaves a pause mark behind"
|
|
(null (flan--pause-overlays)))
|
|
(flan-clear-result)
|
|
(goto-char (point-max))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; A refusal must never be drawn as a value. This is the collision the two
|
|
;; overlays could have had: one command, one form, and both paths wanting
|
|
;; the end of the same line.
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
(insert "\n(+ 1 nonsense)")
|
|
(search-backward "nonsense")
|
|
(ignore-errors (flan-eval-defun))
|
|
(test-flan--check "a refused expression gets no value overlay"
|
|
(null (flan--result-overlays)))
|
|
(test-flan--check "and is marked as an error instead"
|
|
(let ((ovs (flan--error-overlays)))
|
|
(and (= 1 (length ovs))
|
|
(string-match-p
|
|
"unknown name"
|
|
(or (overlay-get (car ovs) 'help-echo) "")))))
|
|
;; The padding argument, which is why the expression path sends
|
|
;; `flan--text-at': the daemon numbers from the start of what it was sent,
|
|
;; so an unpadded snippet puts this overlay on line 1 of the file. The
|
|
;; column is the half `flan--text' alone would not have fixed, so the
|
|
;; check is the token and not the line — a client that padded lines only
|
|
;; would pass "not line 1" and still point at the open paren.
|
|
(test-flan--check "and marked at the word it is about, not at line 1"
|
|
(let ((ovs (flan--error-overlays)))
|
|
(and ovs (save-excursion
|
|
(goto-char (overlay-start (car ovs)))
|
|
(looking-at-p "nonsense")))))
|
|
(flan-clear-errors)
|
|
(goto-char (point-max))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; A declaration can be written where it is prose and not a declaration, and
|
|
;; the depth at its open delimiter says nothing about that: a form at column
|
|
;; 1 inside a comment or a string is at depth 0 like any other, so the head
|
|
;; was the only thing being asked and a sentence would have been compiled.
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
(insert "\n;; (defvar commented i64 1)\n\"(defvar inside i64 1)\"")
|
|
;; Point after the form's own closing paren rather than at the end of the
|
|
;; line: `backward-sexp' walks over a whole comment, so from the end of
|
|
;; one it never reaches the paren inside it and the guard is never asked.
|
|
(goto-char beg)
|
|
(search-forward "commented i64 1)")
|
|
(test-flan--check "a declaration written in a comment is not one"
|
|
(null (flan--declaration-before-point)))
|
|
(goto-char beg)
|
|
(search-forward "inside i64 1)")
|
|
(test-flan--check "and neither is one written in a string"
|
|
(null (flan--declaration-before-point)))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; And the top of the buffer, where there is nothing behind point at all.
|
|
;; `backward-sexp' does not signal there — it stays where it is — so the
|
|
;; form the predicate looked at was the one *after* point and the region it
|
|
;; measured out was empty: C-x C-e at point-min sent the first declaration
|
|
;; in the file by name with no body at all, and the daemon installed it.
|
|
;; Nothing is sent now, from either path, and the refusal says why.
|
|
(goto-char (point-min))
|
|
(test-flan--check "the form after point is not the form before it"
|
|
(null (flan--declaration-before-point)))
|
|
(test-flan--check "and C-x C-e at the top of a buffer refuses rather than sending nothing"
|
|
(let ((raised nil))
|
|
(condition-case err (flan-eval-last-sexp)
|
|
(user-error (setq raised (error-message-string err))))
|
|
(and raised (string-match-p "no form before point" raised))))
|
|
|
|
;; The bug this key had: a `defvar' typed at the top of a file could only be
|
|
;; evaluated with C-c C-c, because C-x C-e sent it to the expression
|
|
;; evaluator and the parser refused it as a declaration. One round trip, on
|
|
;; the form the report has to be able to name.
|
|
(goto-char (point-max))
|
|
(let ((beg (point)))
|
|
(insert "\n(defvar spark i64 9)")
|
|
(let ((said (test-flan--said (flan-eval-last-sexp))))
|
|
(test-flan--check "C-x C-e on a top-level defvar installs it"
|
|
(and said (string-match-p "\\_<spark\\_>" said)
|
|
(string-match-p "installed" said)))
|
|
;; The two paths share a key now, so the echo area is the only thing
|
|
;; left that says which of them ran. An installed declaration reports
|
|
;; what changed; an expression reports `=>' and a value.
|
|
(test-flan--check "and says so rather than printing a value"
|
|
(and said (not (string-match-p "=>" said)))))
|
|
(delete-region beg (point-max)))
|
|
|
|
;; And the literal complaint, on the `defvar' actually written in the file
|
|
;; rather than on one typed in for the occasion. What is asserted is only
|
|
;; what the client decides: the var is named and no value is printed.
|
|
;; Whether the daemon answers "installed" or "nothing to install" is its
|
|
;; call and depends on what this session has been through by now, and a test
|
|
;; that pinned one of them here would be testing the order of the checks
|
|
;; above it. A defvar declares no functions either way, which is what makes
|
|
;; the name — and not the kind of form — the subject of the sentence.
|
|
(goto-char (point-min))
|
|
(search-forward "(defvar ticks i64)")
|
|
(let ((said (test-flan--said (flan-eval-last-sexp))))
|
|
(test-flan--check "C-x C-e on the file's own defvar reports the var"
|
|
(and said (string-match-p "\\_<ticks\\_>" said)
|
|
(not (string-match-p "=>" said)))))
|
|
|
|
;; 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))
|
|
;; Propertized, as interactive input always is — font-lock marks what a
|
|
;; batch `insert' would leave bare. comint hands the sender the text
|
|
;; properties and all, and a propertized string prints as #(...), which
|
|
;; the daemon's reader takes as a symbol and a stray list rather than a
|
|
;; string. So this line is the regression test for `flan--bare'.
|
|
(insert (propertize "(+ 20 3)" 'fontified t 'face 'default))
|
|
(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-output-buffer
|
|
(string-match-p "PRINTED" (buffer-string))))
|
|
(< (float-time) deadline))
|
|
(ignore-errors (flan--request '(:op "describe")))
|
|
(accept-process-output nil 0.05)))
|
|
(test-flan--check "printed text goes to the output buffer"
|
|
(with-current-buffer flan-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--timer)
|
|
(eq (timer--function flan--timer) #'flan--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--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--stopped) (< (float-time) deadline))
|
|
(flan--poll)
|
|
(accept-process-output nil 0.05)))
|
|
(test-flan--check "the client notices a stop nobody asked about"
|
|
(equal flan--stopped "Missing"))
|
|
(test-flan--check "and the modeline says so, with the condition"
|
|
(and (eq (flan-state) 'stopped)
|
|
(string-match-p "stopped" (flan-mode-line))
|
|
(string-match-p "Missing" (flan-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-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--restart-candidates '("use-placeholder") nil)
|
|
'(("0. use-placeholder" . 0))))
|
|
(test-flan--check "a shadowed name is two distinguishable choices"
|
|
(equal (mapcar #'cdr
|
|
(flan--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--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)")
|
|
(flan-eval-last-sexp)
|
|
(test-flan--check "C-x C-e works while the program is stopped"
|
|
(let ((r (test-flan--result)))
|
|
(and r (string-match-p "23" r))))
|
|
(flan-clear-result)
|
|
(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--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 "\\_<step\\_>" 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-restart-at 0 "use-placeholder")
|
|
(let ((deadline (+ (float-time) 20)))
|
|
(while (and (not (eq (flan-state) 'live)) (< (float-time) deadline))
|
|
(flan--poll)
|
|
(accept-process-output nil 0.05)))
|
|
(test-flan--check "choosing a restart resumes the program"
|
|
(and (null flan--stopped)
|
|
(eq (flan-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)")
|
|
(flan-eval-last-sexp)
|
|
(test-flan--check "and everything works again afterwards"
|
|
(let ((r (test-flan--result)))
|
|
(and r (string-match-p "2" r))))
|
|
(flan-clear-result)
|
|
(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))))
|
|
|
|
;; A builtin, which is the case this buffer used to refuse outright: the name
|
|
;; is fine and the program's symbol table has never heard of it, so C-c C-v
|
|
;; answered "the running program defines no arena-new" about a name that
|
|
;; works. `arena-new' by name because it is the one that was reported.
|
|
(flan-doc "arena-new")
|
|
(with-current-buffer flan-doc-buffer
|
|
(let ((text (buffer-string)))
|
|
(test-flan--check "C-c C-v on a builtin answers"
|
|
(string-match-p "\\`arena-new" text))
|
|
(test-flan--check "with its signature"
|
|
(string-match-p "arena-new \\[i64\\] Allocator" text))
|
|
(test-flan--check "and says it is a builtin"
|
|
(string-match-p "Kind +builtin" text))
|
|
;; The honest answer to "where", and not the one an empty location would
|
|
;; otherwise have produced: a global's location is missing, a builtin's
|
|
;; never existed.
|
|
(test-flan--check "and that it lives in the compiler, not in a file"
|
|
(string-match-p "Defined +in the compiler" text))
|
|
(test-flan--check "and carries a line about what it does"
|
|
(string-match-p "capacity is explicit" text))))
|
|
;; The other half of the table: a name written as a name rather than as a
|
|
;; call, which `var' answers and which reads here as a builtin too.
|
|
(flan-doc "context/allocator")
|
|
(with-current-buffer flan-doc-buffer
|
|
(test-flan--check "and so does a builtin that is a name, not a call"
|
|
(string-match-p "Kind +builtin" (buffer-string))))
|
|
;; M-. has nowhere to jump either way, but the reason is the point: the
|
|
;; refusal names the compiler instead of reporting a gap in the daemon.
|
|
(let ((raised nil))
|
|
(condition-case err (xref-backend-definitions 'flan "arena-new")
|
|
(user-error (setq raised (error-message-string err))))
|
|
(test-flan--check "M-. on a builtin says it is in the compiler"
|
|
(and raised (string-match-p "arena-new" raised)
|
|
(string-match-p "compiler" raised))))
|
|
(test-flan--check "eldoc has a builtin's signature too"
|
|
(let ((said nil))
|
|
(with-temp-buffer
|
|
(insert "arena-new")
|
|
(flan-eldoc-function
|
|
(lambda (s &rest _) (setq said s))))
|
|
(and said (string-match-p "arena-new \\[i64\\]" said)
|
|
;; The kind rides along, as it does for a global,
|
|
;; so the echo area says which of the two this is.
|
|
(string-match-p "builtin" said))))
|
|
(let* ((capf (with-temp-buffer
|
|
(insert "arena")
|
|
(flan-completion-at-point)))
|
|
(table (nth 2 capf)))
|
|
(test-flan--check "completion offers builtins"
|
|
(member "arena-new" (all-completions "arena-" table)))
|
|
;; They are appended after the program's own names rather than merged in,
|
|
;; so a table built from this order does not bury what is being worked on.
|
|
(test-flan--check "and still offers the program's names first"
|
|
(< (seq-position table "step")
|
|
(seq-position table "arena-new"))))
|
|
|
|
;; ── 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-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)
|
|
;; The background poll is a sender too, and it was the one sender that did
|
|
;; not settle: it guarded on `flan--busy' alone, which the watch timer
|
|
;; deliberately does not bind — it never waits, so it has nothing to hold —
|
|
;; and sent `describe' straight into a connection that already owed a reply.
|
|
;; It then read the watch's answer as its own, and the two stayed swapped
|
|
;; for the rest of the session. The second check is where that would show:
|
|
;; a `describe' answered by the watch table has no `:fns' in it at all.
|
|
(flan--poll)
|
|
(test-flan--check "a poll settles the watch's reply rather than reading it as its own"
|
|
(null flan-watch--pending))
|
|
(test-flan--check "and the request after it is still answered by its own reply"
|
|
(member "step" (plist-get (flan--request '(:op "describe"))
|
|
:fns)))
|
|
;; And the same hook against a daemon restarted under an armed watch. The
|
|
;; reply the watch is owed was asked for on the connection that has gone, so
|
|
;; there is nothing to wait for — running the hook before the connection is
|
|
;; checked is what lets it see that. Asking after the reconnect meant a
|
|
;; whole `flan-reply-timeout' of frozen Emacs on the first thing anybody
|
|
;; typed after a restart, which is why the wait itself is what is measured.
|
|
(flan-watch--tick)
|
|
(delete-process flan--connection)
|
|
;; The timeout and the assertion are deliberately different numbers: what is
|
|
;; being told apart is a request that waited one out from a request that did
|
|
;; not, and the wider the gap the less this depends on how loaded the machine
|
|
;; running the suite happens to be. A reconnect and a `describe' are
|
|
;; milliseconds of work.
|
|
(let ((flan-reply-timeout 10)
|
|
(started (float-time)))
|
|
(let ((r (flan--request '(:op "describe"))))
|
|
(test-flan--check "a request after a restart does not wait out a reply the old connection owed"
|
|
(and (member "step" (plist-get r :fns))
|
|
(< (- (float-time) started) 3)))
|
|
(test-flan--check "and the watch is not left waiting for one either"
|
|
(null flan-watch--pending))))
|
|
;; A request in flight again, for the interleaving below.
|
|
(flan-watch--tick)
|
|
;; 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--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--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-settle-hook)))
|
|
(kill-buffer flan-watch-buffer)
|
|
|
|
(flan-disconnect)
|
|
(test-flan--check "disconnected" (not (process-live-p flan--connection)))
|
|
(test-flan--check "and the poll timer is cancelled with it"
|
|
(null flan--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-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-quit)
|
|
(user-error (setq raised (error-message-string err))))
|
|
(and raised (string-match-p "no daemon started" raised))))
|
|
(flan program socket2)
|
|
(test-flan--check "M-x flan builds, launches and connects"
|
|
(and (process-live-p flan--daemon)
|
|
(eq (flan-state) 'live)))
|
|
(test-flan--check "and it is the program that was asked for"
|
|
(member "step" (plist-get (flan--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 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--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--defs))
|
|
(let ((old flan--daemon))
|
|
(flan-restart-program)
|
|
(test-flan--check "restarting gives a different daemon, connected"
|
|
(and (not (process-live-p old))
|
|
(process-live-p flan--daemon)
|
|
(not (eq old flan--daemon))
|
|
(eq (flan-state) 'live))))
|
|
(test-flan--check "and a program built from source, without the addition"
|
|
(and (assoc "step" flan--defs)
|
|
(null (assoc "only-in-the-old-program"
|
|
flan--defs))))
|
|
|
|
(let ((proc flan--daemon))
|
|
(flan-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--daemon)
|
|
(not (process-live-p flan--connection))
|
|
(eq (flan-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)))
|
|
|
|
;; ── One command, one session ──────────────────────────────────────────
|
|
;;
|
|
;; Attaching to a second program while a daemon of this Emacs' own is
|
|
;; running is an ordinary thing to do — the other one is in a terminal, and
|
|
;; `flan-connect' is the command for it — and it used to cost the first
|
|
;; program its life: `flan-quit' sent `close' down whichever connection
|
|
;; was current and then killed the daemon, which by then were two different
|
|
;; programs. Both halves are checked here.
|
|
;;
|
|
;; Stand-ins rather than two real daemons. Nothing about this is a claim
|
|
;; about the wire: what is under test is which process each command reaches
|
|
;; for, and a `cat' is a live process with a buffer, which is all either of
|
|
;; them looks at. `cat' also parrots whatever is written to it, so the
|
|
;; `close' a disconnect sends comes straight back as its own reply and
|
|
;; nothing waits. Every variable touched is bound, so the suite carries on
|
|
;; with the state it had.
|
|
(let* ((mine-buf (generate-new-buffer " *flan-mine*"))
|
|
(theirs-buf (generate-new-buffer " *flan-theirs*"))
|
|
(mine (start-process "flan-mine" mine-buf "cat"))
|
|
(theirs (start-process "flan-theirs" theirs-buf "cat")))
|
|
(set-process-query-on-exit-flag mine nil)
|
|
(set-process-query-on-exit-flag theirs nil)
|
|
(with-current-buffer theirs-buf (set-buffer-multibyte nil))
|
|
(unwind-protect
|
|
(let ((flan--daemon mine)
|
|
(flan--daemon-socket "/tmp/flan-emacs-mine.sock")
|
|
(flan--file "/tmp/flan-emacs-mine.flan")
|
|
(flan--connection theirs)
|
|
(flan--socket "/tmp/flan-emacs-theirs.sock")
|
|
(flan--defs nil))
|
|
(test-flan--check
|
|
"flan-connect elsewhere names both programs rather than dropping one"
|
|
(let ((raised nil))
|
|
(condition-case err (flan-connect "/tmp/flan-emacs-elsewhere.sock")
|
|
(user-error (setq raised (error-message-string err))))
|
|
(and raised
|
|
(string-match-p "flan-emacs-mine" raised)
|
|
(string-match-p "flan-emacs-elsewhere" raised))))
|
|
(test-flan--check "and the connection it refused to replace is untouched"
|
|
(eq flan--connection theirs))
|
|
;; And the command whose name promises one program. It closes the
|
|
;; connection it is on and leaves the daemon alone, saying so —
|
|
;; where it used to end both.
|
|
(let ((said (test-flan--said (flan-quit))))
|
|
(test-flan--check "quit ends the session it is connected to"
|
|
(not (process-live-p theirs)))
|
|
(test-flan--check "and leaves the daemon this Emacs started running"
|
|
(process-live-p mine))
|
|
(test-flan--check "and says which one it left"
|
|
(and said (string-match-p "still running" said)
|
|
(string-match-p "flan-emacs-mine" said)))))
|
|
(ignore-errors (delete-process mine))
|
|
(ignore-errors (delete-process theirs))
|
|
(kill-buffer mine-buf)
|
|
(kill-buffer theirs-buf)))
|
|
|
|
;; 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 "/nonexistent/nope.flan")
|
|
(user-error (setq raised (error-message-string err))))
|
|
(and raised (string-match-p "no such file" raised))))
|
|
|
|
;; ── Which file M-x flan starts ────────────────────────────────────────
|
|
;;
|
|
;; The `interactive' form by itself, evaluated the way the command loop
|
|
;; evaluates it. Calling the command would build and launch a program, and
|
|
;; none of that is in question here: the question is only which file the
|
|
;; form arrives at, and whether it had to ask to get there.
|
|
(let* ((asked nil)
|
|
(answer "/nonexistent/answered.flan")
|
|
(probe (lambda (&rest _) (setq asked t) answer))
|
|
(spec (cadr (interactive-form 'flan))))
|
|
(advice-add 'read-file-name :override probe)
|
|
(unwind-protect
|
|
(progn
|
|
(with-temp-buffer
|
|
(setq buffer-file-name "/nonexistent/here.flan")
|
|
(setq asked nil)
|
|
(test-flan--check "a .flan buffer is started without a prompt"
|
|
(and (equal (eval spec t) '("/nonexistent/here.flan"))
|
|
(not asked))))
|
|
(with-temp-buffer
|
|
(setq buffer-file-name "/nonexistent/notes.org")
|
|
(setq asked nil)
|
|
(test-flan--check "a buffer that is not Flan's is asked about"
|
|
(and (equal (eval spec t) (list answer)) asked)))
|
|
(with-temp-buffer
|
|
(setq asked nil)
|
|
(test-flan--check "and so is one visiting no file at all"
|
|
(and (equal (eval spec t) (list answer)) asked)))
|
|
;; The escape hatch, for starting some other program from a .flan
|
|
;; buffer — the one case the silent path would otherwise close off.
|
|
(with-temp-buffer
|
|
(setq buffer-file-name "/nonexistent/here.flan")
|
|
(setq asked nil)
|
|
(let ((current-prefix-arg '(4)))
|
|
(test-flan--check "a prefix argument asks from a .flan buffer too"
|
|
(and (equal (eval spec t) (list answer)) asked)))))
|
|
(advice-remove 'read-file-name probe)))
|
|
|
|
;; The old name is gone rather than deprecated, so nothing should still
|
|
;; answer to it — an alias left behind is what keeps a rename from finishing.
|
|
(test-flan--check "the -dev- names are not defined any more"
|
|
(not (or (fboundp 'flan-dev) (fboundp 'flan-dev-quit)
|
|
(boundp 'flan-dev-socket-name))))
|
|
|
|
;; ── 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--buffer-visiting file)))
|
|
(with-current-buffer buf
|
|
(flan-clear-errors)
|
|
(let ((marked (flan--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--error-overlays)))
|
|
(test-flan--check "and the buffer is armed to take it down again"
|
|
(memq #'flan--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--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--error-overlays)))
|
|
(test-flan--check "and the hook goes with the last overlay"
|
|
(not (memq #'flan--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 program socket3)
|
|
(test-flan--check "a daemon to disassemble against"
|
|
(process-live-p flan--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-quit)
|
|
(ignore-errors (delete-file socket3)))
|
|
|
|
;; ── Every lowering of one function, in one buffer ─────────────────────
|
|
;;
|
|
;; No daemon: this command compiles the file rather than asking the running
|
|
;; program, which is the whole distinction between it and `C-c C-a' above.
|
|
;;
|
|
;; Most of what is checked here is the renderer -- folding, and the memory
|
|
;; of what was folded -- so the fetch is replaced with canned text. Driving
|
|
;; it through the real one would put four compilers and an `llc -O2' behind
|
|
;; every redraw, and prove nothing about folding that the canned text does
|
|
;; not. The real fetch is exercised once, at the end, where it belongs.
|
|
;;
|
|
;; `buffer-string' is no use for any of it: it returns hidden text too, so
|
|
;; "all four are still named when everything is shut" would pass without
|
|
;; anything being shut at all. `invisible-p' is the predicate that means
|
|
;; what it says, and it means it whether outline folded with an overlay or
|
|
;; with a text property.
|
|
(let* ((flan-lower--state (copy-alist flan-lower--state))
|
|
(flan-lower-fetch-function
|
|
(lambda (section _file name _flags)
|
|
(format "%s-first-line of %s\n%s-second-line\n"
|
|
section name section)))
|
|
(visible
|
|
(lambda ()
|
|
(let ((out nil) (p (point-min)))
|
|
(while (< p (point-max))
|
|
(if (invisible-p p)
|
|
(setq p (next-single-char-property-change p 'invisible))
|
|
(push (buffer-substring-no-properties p (1+ p)) out)
|
|
(setq p (1+ p))))
|
|
(apply #'concat (nreverse out)))))
|
|
;; Somewhere inside a section's body, which is where invisibility is
|
|
;; the thing to ask about.
|
|
(body-of
|
|
(lambda (id)
|
|
(with-current-buffer flan-lower-buffer
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(search-forward (format "%s-first-line" id))
|
|
(point))))))
|
|
(flan-lowering "step" file)
|
|
(with-current-buffer flan-lower-buffer
|
|
(test-flan--check "C-c C-l opens a lowering buffer in its own mode"
|
|
(derived-mode-p 'flan-lower-mode))
|
|
(test-flan--check "TAB is the lowering buffer's toggle, not Outline's"
|
|
(eq (key-binding (kbd "TAB")) #'flan-lower-toggle))
|
|
;; The claim the header makes, which is the opposite of the one
|
|
;; `flan-disassemble' makes, and the reason both commands exist.
|
|
(test-flan--check "whose header says it is the file and not the program"
|
|
(string-match-p "what this file compiles to"
|
|
(buffer-string)))
|
|
(test-flan--check "and names all four lowerings"
|
|
(let ((v (funcall visible)))
|
|
(and (string-match-p "LLVM IR" v)
|
|
(string-match-p "LLVM -O0" v)
|
|
(string-match-p "LLVM -O2" v)
|
|
(string-match-p "x86 backend" v))))
|
|
(test-flan--check "with a line count beside each, so a shut one still says something"
|
|
(= 4 (length (seq-filter
|
|
(lambda (l) (string-match-p " 2 lines\\'" l))
|
|
(split-string (funcall visible) "\n")))))
|
|
;; Out of the box the IR is the open one, so it is the one whose body
|
|
;; can be read and the other three are the ones that cannot.
|
|
(test-flan--check "the open section's body is showing"
|
|
(not (invisible-p (funcall body-of 'ir))))
|
|
(test-flan--check "and a shut section's body is not"
|
|
(and (invisible-p (funcall body-of 'O0))
|
|
(invisible-p (funcall body-of 'O2))
|
|
(invisible-p (funcall body-of 'x86))))
|
|
|
|
;; TAB, on the heading and from inside the body, which are the two
|
|
;; places a reader's point actually is.
|
|
(flan-lower--goto-section 'x86)
|
|
(flan-lower-toggle)
|
|
(test-flan--check "TAB on a heading opens that section"
|
|
(not (invisible-p (funcall body-of 'x86))))
|
|
(test-flan--check "and leaves the others alone"
|
|
(and (not (invisible-p (funcall body-of 'ir)))
|
|
(invisible-p (funcall body-of 'O0))))
|
|
(goto-char (funcall body-of 'x86))
|
|
(flan-lower-toggle)
|
|
(test-flan--check "TAB inside a body shuts the section it is in"
|
|
(invisible-p (funcall body-of 'x86)))
|
|
(test-flan--check "and puts point back on its heading, not into hidden text"
|
|
(eq (flan-lower--section-at-point) 'x86))
|
|
|
|
;; `c' and `e': the buffer as a summary, and the buffer as everything.
|
|
(flan-lower-collapse-all)
|
|
(test-flan--check "c shuts every section"
|
|
(seq-every-p (lambda (s)
|
|
(invisible-p (funcall body-of (car s))))
|
|
flan-lower--sections))
|
|
(test-flan--check "and all four are still named, which is what makes it a summary"
|
|
(let ((v (funcall visible)))
|
|
(and (string-match-p "LLVM IR" v)
|
|
(string-match-p "LLVM -O0" v)
|
|
(string-match-p "LLVM -O2" v)
|
|
(string-match-p "x86 backend" v)
|
|
;; ...and not one line of any listing.
|
|
(not (string-match-p "first-line" v)))))
|
|
(flan-lower-expand-all)
|
|
(test-flan--check "e opens every section"
|
|
(seq-every-p (lambda (s)
|
|
(not (invisible-p (funcall body-of (car s)))))
|
|
flan-lower--sections))
|
|
|
|
;; n and p move between headings, the way they do in the inspector.
|
|
(goto-char (point-min))
|
|
(outline-next-visible-heading 1)
|
|
(test-flan--check "n moves to the first heading"
|
|
(eq (flan-lower--section-at-point) 'ir))
|
|
(outline-next-visible-heading 2)
|
|
(outline-previous-visible-heading 1)
|
|
(test-flan--check "n n p leaves point on the second"
|
|
(eq (flan-lower--section-at-point) 'O0)))
|
|
|
|
;; The point of the whole thing: the backend open and nothing else, then
|
|
;; the command run again on a *different* function. Keyed to the section
|
|
;; rather than to the function, so the preference survives the change of
|
|
;; subject -- which is the case that would fail if it were keyed the other
|
|
;; way, and the one the author asked for by name.
|
|
(flan-lower-collapse-all)
|
|
(with-current-buffer flan-lower-buffer
|
|
(flan-lower--goto-section 'x86)
|
|
(flan-lower-toggle))
|
|
(flan-lowering "main" file)
|
|
(with-current-buffer flan-lower-buffer
|
|
(test-flan--check "re-running names the new function"
|
|
(string-match-p "; every lowering of main" (buffer-string)))
|
|
(test-flan--check "and the section that was open is still the open one"
|
|
(not (invisible-p (funcall body-of 'x86))))
|
|
(test-flan--check "and the ones that were shut are still shut"
|
|
(and (invisible-p (funcall body-of 'ir))
|
|
(invisible-p (funcall body-of 'O0))
|
|
(invisible-p (funcall body-of 'O2))))
|
|
(test-flan--check "the new function's text is what is in there"
|
|
(string-match-p "x86-first-line of main" (buffer-string)))
|
|
|
|
;; A refresh is a redraw of the same thing, and must not throw the
|
|
;; reader back to the top of a listing they were part-way down.
|
|
(flan-lower-expand-all)
|
|
(goto-char (funcall body-of 'O2))
|
|
(forward-line 1)
|
|
;; The line's *text* as well as its coordinates: point is put back by
|
|
;; heading plus a line count, so comparing only the coordinates would
|
|
;; check that the two halves are inverses of each other rather than that
|
|
;; the reader is looking at what they were looking at.
|
|
(let ((was (flan-lower--position))
|
|
(line (buffer-substring-no-properties
|
|
(line-beginning-position) (line-end-position))))
|
|
(flan-lower-refresh)
|
|
(test-flan--check "g redraws without moving point off the line it was on"
|
|
(and (equal was (flan-lower--position))
|
|
(equal line (buffer-substring-no-properties
|
|
(line-beginning-position)
|
|
(line-end-position))))))
|
|
;; And from the header, where there is no section to come back to: the
|
|
;; top, which is where it was, rather than wherever the redraw ended.
|
|
(goto-char (point-min))
|
|
(flan-lower-refresh)
|
|
(test-flan--check "and from the header it comes back to the top, not the end"
|
|
(= (point) (point-min)))))
|
|
|
|
;; And once for real, against the compiler dune just built: four
|
|
;; subprocesses, four narrowings, and the one thing canned text cannot
|
|
;; check -- that the awk `dump.sh' does by hand finds the same function in
|
|
;; four formats that agree about nothing else.
|
|
(if (not (and flan (file-executable-p flan)
|
|
(executable-find "llc") (executable-find "as")
|
|
(executable-find "objdump")))
|
|
(message " skip lowering: no llc/as/objdump, or no compiler to run")
|
|
(let ((flan-lower-program flan)
|
|
;; A copy, because a toggle `setf's into this and a quoted literal
|
|
;; is a constant.
|
|
(flan-lower--state (copy-alist '((ir . t) (O0 . t) (O2 . t) (x86 . t)))))
|
|
(flan-lowering "step" program)
|
|
(with-current-buffer flan-lower-buffer
|
|
(let ((text (buffer-string)))
|
|
(test-flan--check "the IR section is the definition and nothing above it"
|
|
(string-match-p "^define .*flan\\.step" text))
|
|
(test-flan--check "the -O0 and -O2 sections are that label's own block"
|
|
(= 2 (length (seq-filter
|
|
(lambda (l) (string-match-p "\\`flan\\.step:" l))
|
|
(split-string text "\n")))))
|
|
(test-flan--check "and -O2 is the shorter of the two, which is the point"
|
|
(< (length (alist-get 'O2 flan-lower--texts))
|
|
(length (alist-get 'O0 flan-lower--texts))))
|
|
(test-flan--check "the x86 section is the backend's bytes, disassembled"
|
|
(and (string-match-p "<flan\\.step>:" text)
|
|
(string-match-p "push +%rbp" text))))
|
|
;; `r' is the other half of the caching claim: one section redrawn,
|
|
;; and the IR every section is downstream of left where it was. The
|
|
;; mtime is the only evidence of that, since a re-emitted out.ll would
|
|
;; have the same contents as the one it replaced.
|
|
(let ((mtime (file-attribute-modification-time
|
|
(file-attributes
|
|
(expand-file-name "out.ll" flan-lower--dir))))
|
|
(before (alist-get 'O2 flan-lower--texts)))
|
|
(flan-lower--goto-section 'O2)
|
|
(flan-lower-refresh-section)
|
|
(test-flan--check "r redraws one section without re-emitting the IR it reads"
|
|
(equal mtime (file-attribute-modification-time
|
|
(file-attributes
|
|
(expand-file-name "out.ll"
|
|
flan-lower--dir)))))
|
|
(test-flan--check "and the section it redrew says the same thing it did"
|
|
(equal before (alist-get 'O2 flan-lower--texts)))
|
|
(test-flan--check "and the other three are still there"
|
|
(and (alist-get 'ir flan-lower--texts)
|
|
(alist-get 'O0 flan-lower--texts)
|
|
(alist-get 'x86 flan-lower--texts))))
|
|
;; The scratch directory is this buffer's and goes with it; /tmp is
|
|
;; not a place to leave four intermediates behind per invocation.
|
|
(let ((dir flan-lower--dir))
|
|
(kill-buffer)
|
|
(test-flan--check "and the intermediates go when the buffer does"
|
|
(not (file-directory-p dir)))))))
|
|
|
|
|
|
;; ── Marking a form with (pause) ───────────────────────────────────────
|
|
;;
|
|
;; docs/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--defun-bounds))
|
|
(inner (flan--pause-bounds b '(4)))
|
|
(whole (flan--pause-bounds b '(16))))
|
|
(test-flan--check "no prefix marks nothing"
|
|
(null (flan--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--defun-bounds)))
|
|
(test-flan--check "and a prefix with point not nested falls back to it"
|
|
(equal (flan--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--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--wire-position pos)))
|
|
(test-flan--check "a marked position round-trips through the wire"
|
|
(= (flan--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--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--show-pause (match-beginning 0) (match-end 0))
|
|
(test-flan--check "a mark is drawn over the form"
|
|
(= 1 (length (flan--pause-overlays))))
|
|
(run-hooks 'pre-command-hook)
|
|
(test-flan--check "and survives the next command, where a rejection would not"
|
|
(= 1 (length (flan--pause-overlays))))
|
|
;; Re-marking the same form leaves one, not two stacked overlays whose
|
|
;; faces compound into something that is not the face.
|
|
(flan--show-pause (match-beginning 0) (match-end 0))
|
|
(test-flan--check "and marking it again leaves one mark, not two"
|
|
(= 1 (length (flan--pause-overlays))))
|
|
(flan-clear-pause)
|
|
(test-flan--check "and clearing takes it down"
|
|
(null (flan--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 program socket4)
|
|
(test-flan--check "a daemon to mark a form in"
|
|
(process-live-p flan--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--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-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-state) 'stopped)))
|
|
(setq tries (1- tries))
|
|
(flan--request (list :op "describe"))
|
|
(sleep-for 0.005)))
|
|
(test-flan--check "and the program stops there"
|
|
(eq (flan-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--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-restart "continue")
|
|
(let ((tries 400))
|
|
(while (and (> tries 0) (eq (flan-state) 'stopped))
|
|
(setq tries (1- tries))
|
|
(flan--request (list :op "describe"))
|
|
(sleep-for 0.005)))
|
|
(test-flan--check "and it resumes when continue is taken"
|
|
(not (eq (flan-state) 'stopped))))
|
|
(flan-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--text-at' exists for. `C-x C-e' sends a
|
|
;; raw substring and `flan--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 program socket5)
|
|
(test-flan--check "a daemon to expand macros against"
|
|
(process-live-p flan--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--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-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--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--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-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--request
|
|
(list :op "eval" :file file
|
|
:code "(defmacro m-inner [args] `(+ ~(at args 0) 1))"))
|
|
(flan--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-quit)
|
|
(ignore-errors (delete-file socket5)))
|
|
|
|
(if (zerop test-flan--failures)
|
|
(message "flan.el: all tests passed")
|
|
(message "\n%d failure(s)" test-flan--failures)
|
|
(kill-emacs 1)))
|
|
|
|
;;; test-flan.el ends here
|