A macro call says what it expands to, and a Form learns to print itself

C-c C-m. One step on the bare key, the fixpoint under C-u: a macro may
quasiquote a call to another macro, and Loc.from_macro is outermost-wins, so
by the time a full expansion settles the intermediate name is gone. One step
is the only thing that can say which macro produced what.

The expansion runs against the macros the *session* holds -- the prelude's,
its imports', and every defmacro evaluated since it started -- and writes
nothing back: a defmacro handed to C-c C-m does not join the session by having
been looked at.

Both non-termination refusals stay refusals, and only where they are needed.
One step makes one call and does not look at the answer, so (s/spin) one-
stepped answers with itself; all the way hits the fuel and names the macro,
inside Dev.serve's guard, so the daemon replies rather than hanging. Macro's
module handling is a Fun.protect now -- a build that raised was a process
about to exit, and the daemon is not that process.

No printer for a Form existed. Form.to_string is an error-message renderer and
is what Macro.key digests, so it is untouched; Form.to_source round-trips
floats, strings and bytes through the reader, and Form.pretty decides where
the line breaks go and leaves the columns to flan-mode.

The answer is a read-only flan-mode buffer shaped like the disassembly one,
with cnr's idea in it: m expands the form at point one more step in place.
Three inherited keys refuse by name -- an expansion is in no file. The text is
sent padded onto its own line and its own column, unlike C-x C-e, so the
refusal lands on the call and not at the start of its line.
This commit is contained in:
Joseph Ferano 2026-09-13 21:06:58 +07:00
parent 9fc04193d9
commit 3dd9f61b7d
13 changed files with 1234 additions and 10 deletions

102
BUILT.md
View File

@ -5505,3 +5505,105 @@ Both editor paths work and they are different wraps — `Parse.expr`'s for `C-x
plus editing that macro and calling it again; the assertions are on the IR and not on the absence of an
exception, because an expression that did not expand raises while one that expanded to the *wrong* thing does
not.
## `C-c C-m`: what a macro call expands to, and a printer for `Form`
Macros grew far enough today that the editor had to be able to ask. The argument is one line: **a macro is
importable from a package, arriving qualified**, so `(rl/with-drawing …)` is a call whose `defmacro` lives in
another directory. There is nothing beside the call site to read — and there would be nothing to read even if the
`defmacro` were in the buffer, because a macro answers a `Form` and nobody ever wrote that down.
### One step on the key, all the way under `C-u`
Both, because the difference is real here and the second is nearly free once the first exists. A macro may
quasiquote a call to another macro — `mac/quad` answers `(mac/twice (mac/twice n))` — so one step and the fixpoint
are different text.
One step is the *bare* key, and the reason is `Loc.from_macro`. It is outermost-wins by design: the macro the
author actually wrote is the one worth naming. So by the time a full expansion settles, every node of it is stamped
`mac/quad` and `mac/twice` is unnameable. All the way is the answer the compiler acts on; one step is the only way
to find out which macro produced what. `C-u` for the other half rather than a second key is the rule `C-c C-a`
already follows: it is the same question asked of the same form.
One step is **outermost-only**, and that is a deliberate divergence from `Macro.expand_form`, which expands a
call's arguments before calling it. `(mac/twice (mac/twice 1))` one-stepped here is `(+ (mac/twice 1) (mac/twice
1))`; the compiler's own first move is `(mac/twice (+ 1 1))`. Different intermediates, the same fixpoint. Written
down because someone will otherwise discover it and file it.
### The session's macros, not a fresh read
`Session.macroexpand` reads `t.macros` and writes nothing at all. Re-reading the file would answer with macros the
session was never told about, and with whatever is *saved* rather than what is typed — the unsaved-versus-saved
skew "A session expands the buffer's own macros" already refused, and it is worse here than anywhere, because an
expansion that disagrees with what an evaluation does is a lie about what the code *is*.
Writing nothing is a property and it is pinned: a `defmacro` handed to `C-c C-m` must not join the session by
having been looked at, so `test_session` and `test_repl` both expand one and then require the name to still be
unknown. `C-c C-c` is where a declaration goes.
### The refusals, and the one that must not be reachable
The verb sits inside the guard the robustness lane put round the whole of `handle` in `Dev.serve` — confirmed
rather than assumed, and it is why `Dev.macroexpand` has no `Loc.Error` arm of its own. Both non-termination
refusals raise `Loc.Error` and come back as replies with the call site on them.
**The bound has to be reached, and only where it is needed.** A hang in the daemon wedges the editor with the
program still on screen. So: all the way runs `Macro.expand_form`, hits `Macro.fuel` and names the macro; one step
makes exactly one call and does not look at what comes back, so `(s/spin)` one-stepped *answers*, with `(s/spin)`
and the name `s/spin`. A command that refused to show anybody the thing they were trying to see would be the wrong
kind of safe. The ring is refused a level up, while the package holding it is parsed, so no session over one can
exist and nothing on this path can reach it.
`Macro`'s module handling is now a `Fun.protect`. It used to be two statements after the call, which was enough
while a build was the only caller — a build that raised was a process about to exit. The daemon is not that
process: a macro that does not settle raises, the editor is told, and the next `C-c C-m` does it again, so
`Dynload.owned` must not be a list that only grows over a session's lifetime.
### `Form.to_source` and `Form.pretty`, because no printer existed
`Form.to_string` is an error-message renderer — one line, no width, `%S` and `%g`. It is also what `Macro.key`
digests, so every cached macro module on disk is keyed by what it prints; it is untouched. What an expansion needs
is text a *reader reads back*, and three of its spellings are not round trips, all reachable because a macro may
build any literal: `%g` prints 1.0 as `1` and truncates at six digits, `%S` is OCaml's escaping where the reader
takes exactly six escapes, and `Byte` falls through to `\<char>` where the reader names `\nul` and `\return`.
`Form.pretty` decides **where the line breaks go and not where the columns do**. Structure is a printer's job;
indentation is `flan-mode`'s, which is where this project's rules already live — the buffer runs `indent-region`
over what it is shown, so nothing in OCaml has to know that a `let` aligns its bindings under the bracket. A client
with no Emacs still gets something readable rather than one very long line.
### Where the answer goes
`*flan-macroexpansion*`, a read-only `flan-mode` buffer — what is in it is Flan source and reading it is the whole
point, so the font-lock and the indentation are the ones already there. It follows `flan-disassemble`'s shape,
header comment lines and all, rather than `flan-cnr`'s: cnr's folding is frame-and-locals machinery with its own
text properties and there is nothing here to fold. What *was* taken from cnr is the idea its header names — a key
that expands in place. `m` (or `C-c C-m`) takes the form at point one more step and puts the result where it was,
which is what makes one-step-by-default usable rather than a thing you press once and lose. `a` goes to the
fixpoint, `g` asks again — useful after re-evaluating the `defmacro` — and `q` closes.
Three keys inherited from `flan-mode-map` are shadowed by name, not unbound, so pressing one says why: `C-c C-c`,
`C-c C-k` and `C-x C-e` over an expansion would install a body nobody wrote under a name somebody did.
**What `Loc.from_macro` means for what is printed** is a header line, because there is nowhere else it could be
said. Every node below it carries the *call site's* file, line and column with the macro's name stamped on it —
which is how an error inside expanded code points at the call you wrote — and therefore none of it has a location
of its own, nothing in it is the text of any file, and there is nothing for `M-.` to jump to.
### What gets sent, and the padding that is not `C-x C-e`'s
The region is `C-x C-e`'s, plus one case it has no need of: the form *at* point when point is on its opening
delimiter. A macro call is a form you put point on, and `backward-sexp` from an open paren takes the previous
sibling, which is never what was meant.
The text is padded onto its own line **and its own column**, which `flan-dev--text` does not do and says why it
does not: a top-level form starts at column 1, so the columns already agreed. A macro call does not — it is
written well inside a `defn` — and the refusal this path can get carries a column measured from the start of the
snippet, so an unpadded send would draw "did not settle" at the start of the line. Leading newlines and leading
spaces are both whitespace the reader skips. `test-flan-dev.el` checks the arithmetic the only way that proves it:
it evaluates a spinning `defmacro` into a live session, asks for the fixpoint of a call to it from a known buffer
position, and requires the error overlay to start at exactly that position.
The in-buffer `m` sends its text *un*padded, which is the honest shape there: the expansion is in no file, so
there is no line or column for a refusal to be drawn at. The `:file` still goes on the wire, because that is what
says which session's macros to expand against.

View File

@ -108,6 +108,41 @@ hit it as many times as you like; an ordinary `C-c C-c` over the same form (or
expression instead of printing its value. That one does not stick, because there
is no definition for it to stick to.
### `C-c C-m` — what a macro call expands to
Put point on a macro call and press it. `*flan-macroexpansion*` opens with the
code that call turns into, as Flan source, indented and coloured like any other.
**One step by default, `C-u C-c C-m` for all the way.** The two genuinely
differ: a macro may produce a call to another macro, so `(mac/quad 3)` is
`(mac/twice (mac/twice 3))` after one step and `(+ (+ 3 3) (+ 3 3))` at the end.
One step is the default because it is the one that can say *which* macro
produced what — the full expansion is tagged with the name you wrote and
nothing else.
Inside the buffer, `m` expands the form at point one more step **in place**,
`a` takes it to the end, `g` asks again — useful after you have re-evaluated
the `defmacro` — and `q` closes it.
This matters most for a macro you cannot read beside the call: a package's
macros arrive qualified, so `rl/with-drawing` is defined in another directory
and there is nothing next to the call site to look at.
It expands against the macros **this session holds** — the prelude's, the ones
its imports brought in, and every `defmacro` you have evaluated since it
started — not against a fresh read of the file. So it agrees with what an
evaluation does, and it sees the macro you have typed rather than the one you
last saved.
Two things the buffer says out loud. Nothing in it has a location of its own:
every node carries the *call site's* file, line and column with the macro's
name stamped on it, which is how an error inside expanded code points at the
call you wrote. And it is not the text of any file, so `C-c C-c`, `C-c C-k` and
`C-x C-e` in that buffer refuse rather than installing code nobody wrote.
A macro that never settles is refused at a bound and named, with the refusal
drawn on the call — it does not hang.
### `C-c C-k` — the whole buffer
The whole buffer, sent as **one** module rather than as a form at a time. That
@ -554,6 +589,7 @@ mode's, so they work in a file you have only opened.
| `C-c C-d` | what the running program currently defines |
| `C-c C-v` | help on the name at point |
| `C-c C-a` | disassemble a function; `C-u` first for its LLVM IR |
| `C-c C-m` | what the macro call at point expands to; `C-u` for all the way |
Completion, eldoc and `M-.` all read one cached answer rather than asking the
program per keystroke. It refreshes at the two moments the answer can have
@ -599,6 +635,8 @@ Use `C-c C-g` if you need frames.
| `C-c C-b` | a stopped program: condition, restarts, stack |
| `C-c C-M-b` | the same restarts, as a one-key prompt |
| `C-c C-i` | inspect a value |
| `C-c C-m` | what the macro call at point expands to |
| `C-u C-c C-m` | ...all the way, rather than one step |
| `C-c C-a` | disassemble; `C-u` first for LLVM IR |
| `C-c C-g` | debug under lldb, through dape |
| `C-c C-d` | what the running program defines |
@ -609,8 +647,9 @@ Use `C-c C-g` if you need frames.
Commands with no key: `M-x flan-dev` (start a program), `M-x flan-dev-quit`
(stop it), `M-x flan-watch` (the watch buffer), `M-x flan-watch-stop`,
`M-x flan-watch-ghost-mode` (the same values inline),
`M-x flan-inspect-address` (what is at an address), and `M-x flan-allocations`
/ `M-x flan-leaks` (where the memory went, and what is still held).
`M-x flan-inspect-address` (what is at an address), `M-x flan-macroexpand-all`
(the `C-u` half of `C-c C-m`, by name), and `M-x flan-allocations` /
`M-x flan-leaks` (where the memory went, and what is still held).
---

View File

@ -32,6 +32,11 @@
(require 'cl-lib)
(require 'xref)
(require 'eldoc)
;; The macroexpansion buffer is a `flan-mode' buffer — what is in it is Flan
;; source and reading it is the point — so the mode has to exist by the time
;; this file defines one derived from it. Not a cycle: flan-mode.el autoloads
;; the commands here and requires nothing back.
(require 'flan-mode)
(defgroup flan-dev nil
"Talking to a running Flan program."
@ -1444,6 +1449,28 @@ columns already were, because a top-level form starts at column 1."
(concat (make-string (1- (line-number-at-pos start)) ?\n)
(buffer-substring-no-properties start end)))
(defun flan-dev--text-at (start end)
"The buffer text from START to END, on the line AND column it is written at.
`flan-dev--text' pads lines only, and says why it needs nothing more: a
top-level form starts at column 1, so the columns already agreed. A macro
call does not. It is written somewhere inside a `defn', and a refusal the
daemon reports against it a macro that never settles is the one that
happens carries a column that would otherwise be measured from the start of
the snippet and drawn at the start of the line.
Leading newlines and leading spaces are both whitespace the reader skips, so
padding with each is the whole fix. Byte columns, for the reason
`flan-dev--wire-position' gives: the reader walks the source a byte at a time,
and a space is one byte, so a byte count is exactly how many to write."
(save-excursion
(goto-char start)
(concat (make-string (1- (line-number-at-pos start)) ?\n)
(make-string (- (position-bytes start)
(position-bytes (line-beginning-position)))
?\s)
(buffer-substring-no-properties start end))))
(defun flan-dev--defun-bounds ()
"Bounds of the top-level form containing or preceding point, as (START . END)."
(save-excursion
@ -1657,6 +1684,259 @@ that the IR half is findable by name rather than only by a modifier."
nil t))))
(flan-disassemble name t))
;;; What a macro call expands to
;; CIDER's `C-c C-m', and the reason it arrives now rather than with macros
;; themselves: a macro is importable from a package, so `(rl/with-drawing ...)'
;; is a call whose `defmacro' lives in another directory. Until the editor can
;; ask, there is nothing to read beside the call site at all — and the answer
;; is not in the file either way, because a macro answers a `Form' and nobody
;; ever wrote that down.
;;
;; Two commands off one key, the way `C-c C-a' already does it: `C-c C-m' is
;; one step and `C-u C-c C-m' is all the way. One step is the bare key because
;; it is the one that can name the macro that ran. A macro may quasiquote a
;; call to another macro — `mac/quad' answers `(mac/twice (mac/twice n))' — and
;; `Loc.from_macro' is outermost-wins, so by the time a full expansion settles
;; every node of it is stamped with the name the author wrote and the
;; intermediate ones are gone. All the way is the answer the compiler acts on;
;; one step is how you find out which macro produced what.
;;
;; The buffer is `flan-mode', because what it holds is Flan source and the
;; whole point is to read it: the indentation and the font-lock are this
;; project's own. Three keys are shadowed for exactly that reason — the text
;; in here is *not* the text of any file, it has no locations of its own, and
;; `C-c C-c' over it would install a body nobody wrote under a name somebody
;; did.
;;
;; From cnr, the idea rather than the code: a key that expands in place. `C-c
;; C-m' inside the buffer takes the form at point one more step and puts the
;; result where it was, which is what makes one-step-by-default usable rather
;; than a thing you press twice and lose. cnr's own folding is frame-and-locals
;; machinery with its own text properties and there is nothing here to fold.
(defcustom flan-macroexpansion-buffer "*flan-macroexpansion*"
"Where `flan-macroexpand' draws."
:type 'string)
(defvar flan-macroexpand-request-function #'flan-dev--request
"How the macroexpansion buffer reaches the daemon.
One plist in, the reply plist out. A variable for `flan-cnr-request-function''s
reason: so the renderer can be driven from a fixture.")
(defvar-local flan-macroexpand--origin nil
"What this buffer is showing, as a plist: :code, :file and :all.
`g' re-asks it, so an expansion can be refreshed after the `defmacro' behind
it has been re-evaluated.")
(defun flan-macroexpand--bounds ()
"Bounds of the form to expand, as (START . END).
The form before point, which is the region `C-x C-e' chooses and the one
CIDER's own macroexpand uses and the form *at* point when point sits on its
opening delimiter, which `C-x C-e' has no need of and this does. A macro call
is a form you put point on; `backward-sexp' from an open paren takes the
previous sibling, which is never what was meant."
(save-excursion
(skip-chars-forward " \t")
(if (looking-at-p "[([{]")
(cons (point) (save-excursion (forward-sexp) (point)))
(let ((end (point)))
(backward-sexp)
(cons (point) end)))))
(defun flan-macroexpand--ask (code file all)
"Ask the daemon what CODE, written in FILE, expands to.
ALL asks for the fixpoint rather than one step. Answers the reply plist, or
signals having drawn the refusal where it happened, which is why the caller
sends padded text."
(let ((r (funcall flan-macroexpand-request-function
(list :op "macroexpand" :code code :file file
:all (if all t nil)))))
(unless (equal (plist-get r :status) "ok")
(let ((loc (plist-get r :loc))
(msg (plist-get r :message)))
;; The two refusals that reach here are a macro that never settles and
;; a ring, and both name a macro at a location — so mark it, exactly as
;; a refused evaluation is marked. This must not itself signal: the
;; error the caller is owed is the daemon's.
(ignore-errors (flan-dev--show-error loc (or msg "refused")))
(user-error "flan: %s%s" (or msg "refused")
(if loc (format " (%s)" loc) ""))))
r))
(defun flan-macroexpand--insert-code (text)
"Insert TEXT as code and let `flan-mode' decide its columns.
`Form.pretty' puts the line breaks in that is structure, and a printer has
to choose it and stops there. Where the columns go is this project's
indentation, which lives in `flan-mode' and not in the compiler."
(let ((start (point))
;; `indent-region' reports its progress, which is noise in the echo
;; area over a form and a hundred lines of it in a batch run.
(inhibit-message t))
(insert text "\n")
(indent-region start (point))))
(defun flan-macroexpand--render (reply code file all)
"Draw REPLY, the expansion of CODE from FILE, into the macroexpansion buffer."
(with-current-buffer (get-buffer-create flan-macroexpansion-buffer)
(let ((inhibit-read-only t))
(erase-buffer)
(flan-macroexpansion-mode)
(setq flan-macroexpand--origin (list :code code :file file :all all))
(let ((start (point)))
(insert (format "; macroexpansion, %s\n"
(if all "all the way" "one step")))
(put-text-property start (point) 'face 'font-lock-comment-face))
(flan-disassemble--header "of" (string-trim code))
(flan-disassemble--header
"macro" (or (plist-get reply :macro)
"none — the head of this form is not a macro"))
;; What `Loc.from_macro' means for what is printed, said here because
;; there is nowhere else it could be said. Every node below carries the
;; *call site's* file, line and column with the macro's name stamped on
;; it, so an error in expanded code points at the call you wrote. None of
;; it has a location of its own, nothing in it is the text of any file,
;; and there is nothing here for `M-.' to jump to.
(flan-disassemble--header
"locations"
(format "every node below carries %s's own %s, tagged with the macro \
that produced it; the text has no locations of its own and is not in any file"
(file-name-nondirectory file)
(if (plist-get reply :macro) "line and column" "position")))
(when (plist-get reply :note)
(flan-disassemble--header "note" (plist-get reply :note)))
(insert "\n")
(flan-macroexpand--insert-code (plist-get reply :text))
;; Point on the code rather than on the header. Found by the blank line
;; that separates them and not by counting: a header line is filled, so
;; a long one is two lines and a count would drift onto it.
(goto-char (point-min))
(if (re-search-forward "^$" nil t) (forward-line 1) (goto-char (point-min)))))
(display-buffer flan-macroexpansion-buffer))
;;;###autoload
(defun flan-macroexpand (&optional all)
"Show what the macro call before point expands to.
One step, which is the one that can name the macro that ran. With a prefix
argument, or non-nil ALL, expand to the fixpoint instead a macro may
quasiquote a call to another macro, so the two genuinely differ.
The form is expanded against the macros this *session* holds: the prelude's,
the ones its imports brought in, and every `defmacro' evaluated since it
started. Not a fresh read of the file, which would answer with what is saved
rather than with what is typed."
(interactive "P")
(let* ((b (flan-macroexpand--bounds))
(file (or buffer-file-name "<buffer>"))
;; Padded onto its own line *and column*, unlike `C-x C-e', because
;; the refusals this path can get name a location inside the snippet
;; and a macro call is written well inside a line.
(code (flan-dev--text-at (car b) (cdr b)))
(r (flan-macroexpand--ask code file all)))
(flan-macroexpand--render r (buffer-substring-no-properties (car b) (cdr b))
file all)
(pulse-momentary-highlight-region (car b) (cdr b))
(unless (plist-get r :expanded)
(message "flan: %s" (or (plist-get r :note) "nothing expanded")))
r))
;;;###autoload
(defun flan-macroexpand-all ()
"Expand the macro call before point to its fixpoint.
`flan-macroexpand' with a prefix argument does the same thing; this exists so
the other half is findable by name and not only by a modifier."
(interactive)
(flan-macroexpand t))
(defun flan-macroexpand-again (&optional all)
"Expand the form at point one more step, in place.
The buffer holds code with no file behind it, so the expansion replaces the
text it came from rather than opening anything. With a prefix argument, or
non-nil ALL, take it all the way instead."
(interactive "P")
(unless flan-macroexpand--origin
(user-error "flan: this is not a macroexpansion buffer"))
(let* ((b (flan-macroexpand--bounds))
(code (buffer-substring-no-properties (car b) (cdr b)))
;; Unpadded, and that is the honest shape here: this text is in no
;; file, so there is no line or column for a refusal to be drawn at.
;; The file still goes on the wire — it is what tells the daemon which
;; session's macros to expand against.
(r (flan-macroexpand--ask code (plist-get flan-macroexpand--origin :file)
all)))
(if (not (plist-get r :expanded))
(message "flan: %s" (or (plist-get r :note) "nothing expanded"))
(let ((inhibit-read-only t)
(start (car b)))
(delete-region (car b) (cdr b))
(goto-char start)
;; No trailing newline from `--insert-code': this is a form inside a
;; line, not a section.
(let ((from (point)))
(insert (plist-get r :text))
(indent-region from (point))
(pulse-momentary-highlight-region from (point)))
(goto-char start))
(message "flan: %s" (or (plist-get r :macro) "expanded")))))
(defun flan-macroexpand-all-again ()
"Expand the form at point to its fixpoint, in place."
(interactive)
(flan-macroexpand-again t))
(defun flan-macroexpand-refresh ()
"Ask again for the expansion this buffer is showing.
The `defmacro' behind it may have been re-evaluated since, and an expansion
drawn from the old body is exactly the staleness this whole feature exists to
remove."
(interactive)
(unless flan-macroexpand--origin
(user-error "flan: this is not a macroexpansion buffer"))
(let* ((o flan-macroexpand--origin)
(r (flan-macroexpand--ask (plist-get o :code) (plist-get o :file)
(plist-get o :all))))
(flan-macroexpand--render r (plist-get o :code) (plist-get o :file)
(plist-get o :all))))
(defun flan-macroexpand--not-source ()
"Refuse to evaluate the contents of the macroexpansion buffer."
(interactive)
(user-error
"flan: this is an expansion, not source — it is in no file, and installing \
it would redefine a name with code nobody wrote"))
(defvar flan-macroexpansion-mode-map
(let ((map (make-sparse-keymap)))
;; The three that would send this buffer's text to the daemon as though it
;; were a file. Inherited from `flan-mode-map' and shadowed by name rather
;; than by unbinding, so pressing one says why instead of doing nothing.
(define-key map (kbd "C-c C-c") #'flan-macroexpand--not-source)
(define-key map (kbd "C-c C-k") #'flan-macroexpand--not-source)
(define-key map (kbd "C-x C-e") #'flan-macroexpand--not-source)
(define-key map (kbd "C-c C-m") #'flan-macroexpand-again)
;; One-key aliases, as the break buffer has: this is a buffer you read with
;; one hand.
(define-key map (kbd "m") #'flan-macroexpand-again)
(define-key map (kbd "a") #'flan-macroexpand-all-again)
(define-key map (kbd "g") #'flan-macroexpand-refresh)
(define-key map (kbd "q") #'quit-window)
map)
"Keymap for `flan-macroexpansion-mode'.")
(define-derived-mode flan-macroexpansion-mode flan-mode "Flan-Macro"
"Mode for the buffer `flan-macroexpand' writes.
Derived from `flan-mode' because what is in it is Flan source and reading it
is the whole point: the indentation and the font-lock are the ones this
project already has. Read-only, and the three keys that would send its text
back to the daemon say why they will not.
\\{flan-macroexpansion-mode-map}"
(setq buffer-read-only t))
;;; What the program is made of, and what it is still holding
;; Two readings of one table. A dev build records the type at every

View File

@ -73,6 +73,11 @@
(autoload 'flan-dev-restart-program "flan-dev" nil t)
;; Bound below, like the rest, and it was the one missing an autoload.
(autoload 'flan-disassemble "flan-dev" nil t)
;; C-c C-m and the half of it that is findable by name rather than by a
;; modifier. Real autoloads for the reason stated above: a `declare-function'
;; would leave M-x with nothing to load.
(autoload 'flan-macroexpand "flan-dev" nil t)
(autoload 'flan-macroexpand-all "flan-dev" nil t)
(defgroup flan nil
"Editing and evaluating Flan."
@ -190,6 +195,12 @@ line is off screen."
;; IR it was built from. C-u for the IR rather than a second key: it is
;; the same question asked of the same body.
(define-key map (kbd "C-c C-a") #'flan-disassemble)
;; What the macro call before point expands to. CIDER's key, and `C-u' for
;; the fixpoint rather than a second one — the same question asked of the
;; same form, which is the rule `C-c C-a' above already follows. One step
;; is the bare key because it is the one that can name the macro that ran:
;; a full expansion is stamped with the outermost name only.
(define-key map (kbd "C-c C-m") #'flan-macroexpand)
;; The way out when a reload is refused: rebuild, relaunch, reconnect.
(define-key map (kbd "C-c C-x") #'flan-dev-restart-program)
map)

View File

@ -1115,6 +1115,84 @@ stopped program, which is the case where it should fire."
(string-match-p "unhandled" text)))
;;; The macroexpansion buffer
;; The same kind of thing again: a reply in, a buffer out. What the live test
;; in test-flan-dev.el cannot easily reach is the shape where *nothing*
;; expanded — a head that is not a macro, and a macro whose answer is its own
;; call — because both have to be arranged in a program. Here they are a
;; plist.
(message "\nthe macroexpansion buffer")
(let ((flan-macroexpand-request-function
(lambda (_) '(:status "ok" :text "(if (not false)\n (do 1 2))"
:flat "(if (not false) (do 1 2))"
:expanded t :macro "unless"))))
(with-temp-buffer
(flan-mode)
(insert "(unless false 1 2)")
(goto-char (point-min))
(flan-macroexpand))
(let ((text (with-current-buffer flan-macroexpansion-buffer (buffer-string))))
(test-flan--check "the expansion is drawn"
(string-match-p (regexp-quote "(if (not false)") text))
(test-flan--check "with the macro that produced it named"
(string-match-p "macro +unless" text))
(test-flan--check "and the form it came from"
(string-match-p (regexp-quote "(unless false 1 2)") text))
;; The one thing a reader of this buffer has to be told, because there is
;; nowhere else it could be said: nothing in it has a location of its own.
(test-flan--check "and what the locations in it mean"
(string-match-p "no locations of its own" text))
(test-flan--check "one step says so, rather than implying a fixpoint"
(string-match-p "one step" text))))
;; A head that is not a macro. The form comes back as itself, and the buffer
;; has to say which kind of nothing that is rather than looking like a very
;; short expansion.
(let ((flan-macroexpand-request-function
(lambda (_) '(:status "ok" :text "(+ 1 2)" :flat "(+ 1 2)"
:expanded nil
:note "the head of this form is not a macro"))))
(with-temp-buffer
(flan-mode)
(insert "(+ 1 2)")
(goto-char (point-min))
(flan-macroexpand))
(let ((text (with-current-buffer flan-macroexpansion-buffer (buffer-string))))
(test-flan--check "a head that is not a macro says so"
(string-match-p "not a macro" text))
(test-flan--check "and the macro line says none rather than being absent"
(string-match-p "macro +none" text))))
;; The refusals reach here as an ordinary error reply, and the command has to
;; signal rather than draw an empty buffer: a macro that never settles is the
;; one that gets here.
(let ((flan-macroexpand-request-function
(lambda (_) '(:status "error"
:message "expanding s/spin did not settle after 200 rounds"
:loc "buf.flan:3:3"))))
(with-temp-buffer
(flan-mode)
(insert "(s/spin)")
(goto-char (point-min))
(test-flan--check
"a refused expansion signals, carrying the daemon's words"
(let ((said (test-flan--caught #'flan-macroexpand)))
(and said (string-match-p "did not settle" said))))))
;; And the buffer refuses to be treated as source, which is the one way it
;; could do damage: the text in it is in no file and installing it would
;; redefine a name with code nobody wrote.
(with-current-buffer (get-buffer-create flan-macroexpansion-buffer)
(test-flan--check
"the expansion buffer refuses C-c C-c, C-c C-k and C-x C-e"
(seq-every-p
(lambda (k)
(eq (key-binding (kbd k)) #'flan-macroexpand--not-source))
'("C-c C-c" "C-c C-k" "C-x C-e"))))
;; `flan-mode' itself — indentation, which is a function from text to text and
;; so belongs with the other fixture-driven checks rather than with anything
;; that needs a daemon. Loaded rather than run separately because

View File

@ -947,6 +947,142 @@ is written instead — the real `message' call the real command makes."
(flan-dev-quit)
(ignore-errors (delete-file socket4)))
;; ── C-c C-m: what a macro call expands to ─────────────────────────────
;;
;; test_session.ml drives the expansion itself and is where the language
;; cases live. What this adds is the half that is only true in an editor:
;; which region the command picks, that the expansion arrives as readable
;; source in a buffer, that expanding again in place works, and — the one
;; worth a real daemon — that a macro which never settles comes back as a
;; refusal drawn on the call, at its own line *and its own column*.
;;
;; That last one is what `flan-dev--text-at' exists for. `C-x C-e' sends a
;; raw substring and `flan-dev--text' pads lines only, because a top-level
;; form starts at column 1; a macro call is written well inside a line, and
;; a refusal against it would otherwise be drawn at the start of that line.
(let ((socket5 (concat socket "-macro")))
(ignore-errors (delete-file socket5))
(flan-dev program socket5)
(test-flan--check "a daemon to expand macros against"
(process-live-p flan-dev--connection))
(with-current-buffer (get-file-buffer file)
(goto-char (point-max))
(insert "\n(defn expandable [] i32\n (unless false 1 2)\n (clamp 9 0 3))\n")
;; One step, on a prelude macro. Point on the opening paren, which is
;; where a reader puts it and which `C-x C-e' could not use.
(goto-char (point-max))
(search-backward "(unless false 1 2)")
(flan-macroexpand)
(test-flan--check
"C-c C-m expands the form point is on"
(with-current-buffer flan-macroexpansion-buffer
(string-match-p "(if (not false)" (buffer-string))))
(test-flan--check
"and says which macro ran"
(with-current-buffer flan-macroexpansion-buffer
(string-match-p "macro +unless" (buffer-string))))
(test-flan--check
"and the buffer is Flan source, not a dump"
(with-current-buffer flan-macroexpansion-buffer
(and (derived-mode-p 'flan-mode) buffer-read-only)))
;; The three keys that would send an expansion back as though it were a
;; file refuse by name rather than doing nothing.
(test-flan--check
"C-c C-c in the expansion buffer refuses, and says why"
(with-current-buffer flan-macroexpansion-buffer
(condition-case e (progn (call-interactively
(key-binding (kbd "C-c C-c")))
nil)
(user-error (string-match-p "not source" (error-message-string e))))))
;; All the way, off the same key with a prefix.
(goto-char (point-max))
(search-backward "(clamp 9 0 3)")
(flan-macroexpand t)
(test-flan--check
"C-u C-c C-m expands all the way"
(with-current-buffer flan-macroexpansion-buffer
(and (string-match-p "all the way" (buffer-string))
(string-match-p (regexp-quote "(min 3 (max 0 9))")
(buffer-string)))))
;; A macro that never settles, evaluated into the session and then asked
;; about. One step answers — it makes one call and does not look at what
;; comes back — and all the way is refused at the bound rather than
;; hanging the daemon, which is the failure that would wedge the editor
;; with the program still on screen.
(flan-dev--request
(list :op "eval" :file file
:code "(defmacro spinner [args] `(spinner ~@args))"))
(goto-char (point-max))
(insert "\n(defn spun [] i32\n (spinner 1))\n")
(goto-char (point-max))
(search-backward "(spinner 1)")
(let ((call (point))
(r (flan-macroexpand)))
(test-flan--check
"one step of a macro that does not settle answers"
(and (null (plist-get r :expanded))
(equal (plist-get r :macro) "spinner")))
(flan-dev-clear-errors)
(goto-char call)
(let ((said (condition-case e (progn (flan-macroexpand t) nil)
(user-error (error-message-string e)))))
(test-flan--check
"and all the way is refused rather than hanging"
(and said (string-match-p "did not settle" said)))
;; The whole of `flan-dev--text-at': the refusal's location is the
;; call's own line and column, so the overlay lands on the call and
;; not at the start of the line it is written on.
(let ((ovs (flan-dev--error-overlays)))
(test-flan--check
"and the refusal is drawn on the call, at its own column"
(and (= 1 (length ovs))
(= (overlay-start (car ovs)) call)))))
(flan-dev-clear-errors))
;; Expanding again in place, which is what makes one-step-by-default
;; usable rather than a thing you press once and lose. Two macros into
;; the session, the outer one quasiquoting a call to the inner: that is
;; the shape where one step leaves a macro call standing, and it is the
;; only shape where expanding in place has anything to do.
(flan-dev--request
(list :op "eval" :file file
:code "(defmacro m-inner [args] `(+ ~(at args 0) 1))"))
(flan-dev--request
(list :op "eval" :file file
:code "(defmacro m-outer [args] `(m-inner ~(at args 0)))"))
(goto-char (point-max))
(insert "\n(defn outered [] i32 (m-outer 5))\n")
(goto-char (point-max))
(search-backward "(m-outer 5)")
(flan-macroexpand)
(test-flan--check
"one step leaves the call to the macro it quasiquoted"
(with-current-buffer flan-macroexpansion-buffer
(string-match-p (regexp-quote "(m-inner 5)") (buffer-string))))
(with-current-buffer flan-macroexpansion-buffer
(goto-char (point-min))
(search-forward "(m-inner 5)")
(goto-char (match-beginning 0))
(flan-macroexpand-again)
(test-flan--check
"m in the expansion buffer expands the form at point in place"
(and (string-match-p (regexp-quote "(+ 5 1)") (buffer-string))
(not (string-match-p (regexp-quote "(m-inner 5)")
(buffer-string))))))
;; And the same call taken all the way in one go, which is the half the
;; prefix argument is for: no intermediate at all.
(goto-char (point-max))
(search-backward "(m-outer 5)")
(flan-macroexpand t)
(test-flan--check
"C-u goes straight to the fixpoint"
(with-current-buffer flan-macroexpansion-buffer
(and (string-match-p (regexp-quote "(+ 5 1)") (buffer-string))
(not (string-match-p (regexp-quote "(m-inner 5)")
(buffer-string)))))))
(flan-dev-quit)
(ignore-errors (delete-file socket5)))
(if (zerop test-flan--failures)
(message "flan-dev.el: all tests passed")
(message "\n%d failure(s)" test-flan--failures)

View File

@ -546,6 +546,52 @@ let eval_expr t ~code ~origin ~pause =
| exception Failure m -> error m)
| exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> error ~loc:(Loc.to_string l) msg
(* What a macro call expands to — [C-c C-m], and the one verb here that never
touches the program.
Deliberately not gated on [alive t]. Every other verb in this file is a
question about a running process and says so when there is not one;
expansion is a question about the *compiler*, answered out of the macros the
session holds, and it is still answerable after the program has exited. That
is worth having rather than tidying away: the moment you most want to know
what a macro produced is often just after the code it produced crashed.
No [Loc.Error] arm either, and that is not an omission. The guard round
[handle] in [serve] catches everything non-fatal and answers it with
[reply_of_exn] which is where the two non-termination refusals land, with
the call site's location on them, rather than in a hang that would leave the
editor waiting on a daemon with the program still on screen. *)
let macroexpand t ~code ~origin ~all =
let x = Session.macroexpand ~origin ~all t.session code in
ok
([ (* Line breaks in, columns left to the editor: see [Form.pretty]. *)
":text " ^ Wire.quote (Form.pretty x.Session.xafter);
(* And the same thing on one line, for a client with no indenter and for
the echo area. *)
":flat " ^ Wire.quote (Form.to_source x.Session.xafter);
":source " ^ Wire.quote (Form.to_source x.Session.xbefore);
(if all then ":all t" else ":all nil");
(if x.Session.xchanged then ":expanded t" else ":expanded nil") ]
@ (match x.Session.xmacro with
| Some n -> [ ":macro " ^ Wire.quote n ]
| None -> [])
@
(* The two ways of coming back unchanged are different facts and the
editor should not have to guess which it has. Only the one where a
macro *did* run is a statement about that macro. *)
match (x.Session.xchanged, x.Session.xmacro) with
| true, _ -> []
| false, None ->
[ ":note "
^ Wire.quote
"the head of this form is not a macro this session holds — the \
prelude's, an import's, and every defmacro evaluated since it \
started are what it can expand" ]
| false, Some n ->
[ ":note "
^ Wire.quote
(n ^ " expanded to the call it was given, unchanged") ])
let describe t =
ok
[ ":fns "
@ -2102,6 +2148,25 @@ let handle t req =
in
eval_expr t ~code ~origin ~pause
| None -> error "eval-expr needs :code")
(* [:all], absent or [nil] being false and anything else true — the spelling
[:pause], [:on] and [:reset] already use. One step is the default because
it is the one that can name the macro that ran: a full expansion of a
macro that quasiquotes a call to another is stamped with the outermost
name only, [Loc.from_macro] being outermost-wins, so the intermediate is
unnameable by the time it settles. *)
| Some "macroexpand" ->
(match Wire.string_field req "code" with
| Some code ->
let origin =
match Wire.string_field req "file" with Some f -> f | None -> "<editor>"
in
let all =
match Wire.field req "all" with
| Some { Form.v = Form.Sym "nil"; _ } | None -> false
| Some _ -> true
in
macroexpand t ~code ~origin ~all
| None -> error "macroexpand needs :code")
| Some "describe" -> describe t
| Some "defs" -> defs t
| Some "break" -> break t

View File

@ -39,3 +39,148 @@ let rec to_string f =
| List l -> "(" ^ seq l ^ ")"
| Vec l -> "[" ^ seq l ^ "]"
| Map l -> "{" ^ seq l ^ "}"
(* ── Printing a Form back as source ─────────────────────────────────
[to_string] above is an error-message renderer: one line, no width, and
[%S] and [%g] where a reader's own spelling was never needed to say
"expected a name, found this". It is also what [Macro.key] digests, so it
is left exactly as it is every cached macro module on disk is keyed by
what it prints today.
What follows is the other job, and it arrived with [C-c C-m]: text a person
reads and a reader reads back. An expansion is *only* text. A macro answers
a [Form] and nothing in the language ever wrote it down, so unlike every
other thing the editor shows there is no file to point at and no source to
fall back on whatever this prints is the whole of what anybody sees.
Three places where [to_string] is not a round trip, all of them reachable
from an expansion because a macro may build any literal at all:
- [%g] prints 1.0 as "1", which reads back as an [Int], and it truncates at
six significant digits. Shortest-round-trip here, then a ".0" when
nothing in the text says "float".
- [%S] is OCaml's escaping. The reader takes exactly six escapes newline,
tab, return, backslash, quote and nul and every other byte literally,
so the three-digit decimal escape [%S] writes would not read back.
- [Byte] falls through to \<char>, which spells 0 and 13 as a NUL and a
carriage return sitting in the middle of the source. The reader has names
for those and this uses them. *)
let escape s =
let b = Buffer.create (String.length s + 2) in
String.iter
(fun c ->
match c with
| '\n' -> Buffer.add_string b "\\n"
| '\t' -> Buffer.add_string b "\\t"
| '\r' -> Buffer.add_string b "\\r"
| '\\' -> Buffer.add_string b "\\\\"
| '"' -> Buffer.add_string b "\\\""
| '\000' -> Buffer.add_string b "\\0"
| c -> Buffer.add_char b c)
s;
Buffer.contents b
let float_repr x =
(* The shortest of the three precisions that survives [float_of_string] is
the one the reader parses back to the same bits. 15 covers almost every
literal anyone writes; 17 covers every double there is. *)
let rec shortest = function
| [] -> Printf.sprintf "%.17g" x
| p :: ps ->
let s = Printf.sprintf "%.*g" p x in
if float_of_string s = x then s else shortest ps
in
let s = shortest [ 15; 16; 17 ] in
(* "1" is an integer to the reader, so a float whose text has no point and
no exponent needs one. Guarded on the text and not on the value: nan and
the infinities print as words, and "nan.0" is no improvement on a literal
no reader accepts either way. *)
let plain =
s <> ""
&& String.for_all (fun c -> (c >= '0' && c <= '9') || c = '-' || c = '+') s
in
if plain then s ^ ".0" else s
let byte_repr b =
match b with
| 32 -> "\\space"
| 9 -> "\\tab"
| 10 -> "\\newline"
| 13 -> "\\return"
| 0 -> "\\nul"
(* Printable ASCII is written as itself. Anything else has no spelling in
the reader at all [read_byte] takes a name or a single character so
it is written as the decimal the reader would have to grow, rather than
as a byte that would corrupt the line it is on. *)
| b when b > 32 && b < 127 -> Printf.sprintf "\\%c" (Char.chr b)
| b -> Printf.sprintf "\\%d" b
(** One line, and a reader reads it back. *)
let rec to_source f =
let seq l = String.concat " " (List.map to_source l) in
match f.v with
| Sym s -> s
| Kw s -> ":" ^ s
| Int i -> Int64.to_string i
| Float x -> float_repr x
| Str s -> "\"" ^ escape s ^ "\""
| Byte b -> byte_repr b
| List l -> "(" ^ seq l ^ ")"
| Vec l -> "[" ^ seq l ^ "]"
| Map l -> "{" ^ seq l ^ "}"
(** The same text with line breaks in it, for a form too wide to read on one.
Where the breaks go, and deliberately not where the columns do. A list
that fits is written flat; one that does not keeps its head on the opening
line and puts each remaining element on its own, two columns in. That is
the structural half, which a printer has to decide. The indentation is the
editor's: [flan-mode] re-indents what it is shown, and that is where this
project's indentation rules already live, so nothing here tries to know
that a [let] aligns its bindings under the bracket. A client with no Emacs
still gets something readable rather than one very long line. *)
let pretty ?(width = 72) (f : t) : string =
let b = Buffer.create 256 in
let rec go col f =
let flat = to_source f in
if col + String.length flat <= width then Buffer.add_string b flat
else
let elements ind rest =
List.iter
(fun e ->
Buffer.add_char b '\n';
Buffer.add_string b (String.make ind ' ');
go ind e)
rest
in
match f.v with
(* A call or a special form: the head names what this is, so it stays on
the opening line whatever the rest of it costs. *)
| List (({ v = Sym _; _ } as h) :: rest) ->
Buffer.add_char b '(';
Buffer.add_string b (to_source h);
elements (col + 2) rest;
Buffer.add_char b ')'
| List (x :: rest) ->
Buffer.add_char b '(';
go (col + 1) x;
elements (col + 1) rest;
Buffer.add_char b ')'
| Vec (x :: rest) ->
Buffer.add_char b '[';
go (col + 1) x;
elements (col + 1) rest;
Buffer.add_char b ']'
| Map (x :: rest) ->
Buffer.add_char b '{';
go (col + 1) x;
elements (col + 1) rest;
Buffer.add_char b '}'
(* An empty bracket, or a single atom longer than the width. Neither has
a break in it to take. *)
| _ -> Buffer.add_string b flat
in
go 0 f;
Buffer.contents b

View File

@ -291,8 +291,17 @@ let rounds ~(prelude : string list) (pending : (string * Form.t) list)
a macro would otherwise re-read the whole of it on every parse. *)
let prelude_macros = lazy (macros_in (Prelude.forms ()))
let program (forms : Form.t list) : Form.t list =
if !building then forms
(* The module the forms below will be expanded against, or [None] when there is
nothing to expand them with.
Split out of [program] rather than copied into the editor's path, because
everything in it is a decision with a paragraph attached which names are
ambient, which shadow which, and the shortcut that keeps a macro-free build
free of a clang driver. Two copies of that would drift, and the second copy
is the one a reader would not know to distrust. [program] below is the whole
of what used to be here; [expand_step] and [expand_all] are the editor's. *)
let loaded_for (forms : Form.t list) : loaded option =
if !building then None
else
let prelude = Lazy.force prelude_macros in
(* The prelude's own macros are dropped from [mine], and the reason is that
@ -340,14 +349,84 @@ let program (forms : Form.t list) : Form.t list =
macro pays nothing: a file that calls none costs one scan and no
compiler. Without it every build in the suite would link a macro module
for the prelude's macros and pay a clang driver to answer nothing. *)
if all = [] || not (List.exists (names_macro all) forms) then forms
if all = [] || not (List.exists (names_macro all) forms) then None
else begin
let extra = rounds ~prelude mine in
let l = compile all (List.map snd extra) in
let out = List.map (expand_form l) forms in
Dynload.dl_close l.handle;
Dynload.release ();
out
Some (compile all (List.map snd extra))
end
(* Closing the handle and freeing the marshaller's scratch, on the way out of
an expansion however it ends.
[Fun.protect] and not two statements after the call, which is what this was
while a build was the only caller: a build that raised was a compile that
failed and a process about to exit, so a leaked handle and a few unfreed
argument buffers cost nothing anybody could measure. The daemon is not that
process. A macro that does not settle raises out of here, the editor is told
and stays connected, and the next [C-c C-m] does it again so the one thing
[Dynload.owned] must not become is a list that only ever grows over a
session's lifetime. *)
let with_module (l : loaded) (f : unit -> 'a) : 'a =
Fun.protect
~finally:(fun () ->
Dynload.dl_close l.handle;
Dynload.release ())
f
let program (forms : Form.t list) : Form.t list =
match loaded_for forms with
| None -> forms
| Some l -> with_module l (fun () -> List.map (expand_form l) forms)
let () = Parse.expander := program
(* ── What the editor asks ──────────────────────────────────────────
[C-c C-m]. Two questions and not one, because a macro that quasiquotes a
call to another macro makes the difference real: [mac/quad] answers
[(mac/twice (mac/twice n))], and the fixpoint of that says nothing about
which macro produced what.
Both answer the name at the head when it is a macro, so the editor can say
*which* macro it just ran rather than only that something changed. That name
is the one thing the printed text cannot carry: [Loc.from_macro] is
outermost-wins, so every node of a full expansion is stamped with the macro
the author wrote and the intermediate names are gone by the time it settles.
One step is outermost-only, and that is a deliberate difference from
[expand_form], which expands a call's arguments *before* calling it. So
[(mac/twice (mac/twice 1))] one-stepped here is [(+ (mac/twice 1)
(mac/twice 1))], where the compiler's own first move is [(mac/twice (+ 1
1))]. Different intermediates, the same fixpoint. One step is a view of what
this macro did; all the way is the answer the compiler acts on. *)
let head_macro (l : loaded) (f : Form.t) : string option =
match f.Form.v with
| Form.List ({ Form.v = Form.Sym n; _ } :: _) when List.mem_assoc n l.fns ->
Some n
| _ -> None
(** One round of the outermost call, or the form unchanged when its head does
not name a macro. Nothing here needs the fuel [settle] carries: one call is
one call, and what it answers is not looked at again. *)
let expand_step (f : Form.t) : Form.t * string option =
match loaded_for [ f ] with
| None -> (f, None)
| Some l ->
with_module l (fun () ->
match f.Form.v with
| Form.List ({ Form.v = Form.Sym n; _ } :: args)
when List.mem_assoc n l.fns ->
( Expand.call ~loc:(Loc.from_macro n f.Form.loc) (List.assoc n l.fns)
args,
Some n )
| _ -> (f, None))
(** To the fixpoint, through exactly the walk a build goes through — so the
text this answers is the text the checker is about to be handed, and the
refusals are the build's own. A macro that does not settle raises
[Loc.Error] out of [settle] at the bound, and a ring was refused a level up
in [rounds] before anything was compiled at all. *)
let expand_all (f : Form.t) : Form.t * string option =
match loaded_for [ f ] with
| None -> (f, None)
| Some l -> with_module l (fun () -> (expand_form l f, head_macro l f))

View File

@ -1248,3 +1248,72 @@ let eval_expr ?(origin = "<eval>") ?(pause = false) t src : change =
lost track of what the program contains is worse than one that died. *)
t.program <- { t.program with Tast.fns = t.program.Tast.fns @ fresh };
{ ir; names = []; fns = []; installs = true }
(* ── What a macro call expands to ──────────────────────────────────── *)
(* [C-c C-m]. The one thing the editor can ask that compiles nothing, sends
nothing to the program, and leaves the session exactly as it found it.
It is a session operation rather than a [Reader] plus a [Macro] call for the
reason the whole of this file exists: what a call expands to is decided by
*which macros this session holds* the prelude's, the ones its imports
brought in, and the [defmacro]s the buffer has evaluated since it started
and that set lives in [t.macros] and nowhere else. Expanding against a fresh
read of the file would answer with macros the session was never told about
and with a version of the ones it was told about that is whatever happens to
be *saved*. An expansion that disagreed with what an evaluation does would
be worse than no expansion at all: a macro decides what the code is.
Nothing here is assigned. [eval] commits [t.macros] and [eval_expr] bumps
[t.thunks] and [t.program]; this reads and writes nothing, so a form that
only *looks* like a declaration a [defmacro] handed to [C-c C-m] does
not join the session by having been looked at. [test_session] pins that. *)
type expansion = {
xbefore : Form.t; (* what was sent, quasiquote already desugared *)
xafter : Form.t; (* what it expands to *)
xmacro : string option; (* the macro at the head, when the head is one *)
(* False when the expansion is the form itself. Reported rather than left for
the editor to diff, because the two ways of being unchanged are different
facts: a head that names no macro, and a macro whose answer is its own
call. Only [xmacro] tells them apart. *)
xchanged : bool;
}
(* [all] is the difference between CIDER's two commands, and it is real here
rather than inherited: a macro may quasiquote a call to another macro, so
[(mac/quad 3)] one-stepped is [(mac/twice (mac/twice 3))] and all the way is
[(+ (+ 3 3) (+ 3 3))].
The refusals both live on the [all] path and both are [Loc.Error], which the
daemon answers as a reply: a macro that never settles is stopped by
[Macro.fuel] and named, and a ring of macros was refused while the package
holding it was parsed, which is before any session over it could exist. One
step cannot reach either it makes exactly one call and does not look at
the answer so [(s/spin)] one-stepped is a fact about the macro and only
[C-u] refuses. *)
let macroexpand ?(origin = "<eval>") ~(all : bool) t (src : string) : expansion =
let form =
match Reader.read_all ~file:origin src with
| [ f ] -> f
| [] -> fail Loc.unknown "nothing to expand"
| _ :: f :: _ -> fail f.Form.loc "one form at a time"
in
(* Desugared first, exactly as [Parse.parse_forms] does it and for its
reason: a quasiquote that has not been rewritten still looks like a call
to something named [quasiquote], and the [Form.Sym] inside it looks like a
head anything could mistake for one. *)
let before = Expand.quasiquote form in
(* And the session's macros in front of it, as [eval] and [eval_expr] both
put them: [Macro.program] reads [Parse.imported_macros] directly. *)
Parse.with_imported t.macros @@ fun () ->
let after, name =
if all then Macro.expand_all before else Macro.expand_step before
in
{ xbefore = before; xafter = after; xmacro = name;
(* Structural, through the printer both halves are shown with, so "changed"
means "would print differently" which is the claim the buffer makes.
There is no equality on [Form.t] to use instead: it carries a [Loc.t],
and [Loc.from_macro] stamps a name onto every node a macro answers, so
even an identity expansion compares unequal. *)
xchanged = Form.to_source after <> Form.to_source before }

View File

@ -232,6 +232,92 @@ let () =
(Option.value ~default:(status r) (field r "message")));
value "a macro defined at the editor" "(thrice 14)" "42";
(* ── C-c C-m over the socket ────────────────────────────────────
[test_session] drives the expansion itself and is where the cases
live; this is the same question asked the way a person asks it, and
what it adds is the daemon: the reply's fields, the pretty text
surviving the framing, and the part worth a socket that a request
which compiles a macro module and refuses it comes back as a reply
rather than as a daemon that stopped answering.
The macro set here is [printers.flan]'s, so it covers the prelude's
and the buffer's own; the imported-package half is [test_session]'s,
over [pkg-macro.flan]. *)
let expands ?(all = false) name code want =
let r =
request c
(Printf.sprintf
"(:op \"macroexpand\" :code %s :file \"/tmp/buf.flan\" :all %s)"
(quote code) (if all then "t" else "nil"))
in
match field r "flat" with
| Some v when v = want -> ()
| Some v -> fail "%s\n got: %S\n wanted: %S" name v want
| None ->
fail "%s: %s" name (Option.value ~default:(status r) (field r "message"))
in
expands "a prelude macro, expanded" "(clamp 9 0 3)" "(min 3 (max 0 9))";
expands "the file's own macro, expanded" "(tenfold 7)" "(* 7 10)";
(* And the one typed at the editor a moment ago, which is the session's
set rather than the file's: nothing on disk declares [thrice]. *)
expands "a macro defined at the editor, expanded" "(thrice 14)" "(* 14 3)";
(* A form with no macro in it comes back as itself, and says so rather
than echoing and leaving the editor to diff. *)
(let r =
request c
(Printf.sprintf
"(:op \"macroexpand\" :code %s :file \"/tmp/buf.flan\")"
(quote "(+ 1 2)"))
in
(* [:expanded] is a flag and therefore a symbol, not a string — the
spelling [:stopped] and [:overflow] already use so it is read out
of the form rather than through [Wire.string_field]. *)
let flag k =
match Wire.field r k with
| Some f -> Form.to_source f
| None -> "<none>"
in
if flag "expanded" <> "nil" then
fail "a form that is not a macro call reported :expanded %s"
(flag "expanded");
if field r "note" = None then
fail "a form that is not a macro call gave no reason");
(* The name of the macro that ran rides on the reply, because the text
cannot carry it: [Loc.from_macro] is outermost-wins. *)
(let r =
request c
(Printf.sprintf
"(:op \"macroexpand\" :code %s :file \"/tmp/buf.flan\")"
(quote "(tenfold 7)"))
in
if field r "macro" <> Some "tenfold" then
fail "the reply named %s as the macro that ran"
(Option.value ~default:"<none>" (field r "macro")));
(* :text is the same expansion with the line breaks in it. Nothing here
asserts where they go that is [Form.pretty]'s and the editor's only
that the field is there and reads back as the same form. *)
(let r =
request c
(Printf.sprintf
"(:op \"macroexpand\" :code %s :file \"/tmp/buf.flan\")"
(quote "(tenfold 7)"))
in
match field r "text" with
| Some t when t = "(* 7 10)" -> ()
| Some t -> fail ":text was %S" t
| None -> fail ":text was missing");
(* And the session is untouched by having been asked: a [defmacro] handed
to C-c C-m does not join it. C-c C-c is where a declaration goes, and
the round trip below is the only way to check the aftermath. *)
ignore
(request c
(Printf.sprintf
"(:op \"macroexpand\" :code %s :file \"/tmp/buf.flan\")"
(quote "(defmacro looked-at [args] `(* ~(at args 0) 5))")));
(let r = evals "(looked-at 3)" in
if status r = "ok" then
fail "a defmacro joined the session by being macroexpanded");
ignore (request c "(:op \"close\")");
Unix.close c
end;

View File

@ -529,6 +529,139 @@ let () =
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "a session that refused a macro could not evaluate afterwards: %s" m);
(* ── C-c C-m: what a macro call expands to ─────────────────────────
The verb that compiles nothing and sends nothing. What it has to get right
is the set it expands against: whatever *this session* holds, which is the
prelude's macros, the ones its imports brought in, and every [defmacro]
the buffer has evaluated since it started. A fresh read of the file would
answer with a different set and with whatever is saved rather than what is
typed, and an expansion that disagreed with an evaluation is the quietest
wrongness there is a macro decides what the code *is*.
Asserted on the printed text, because the printed text is the whole of
what a person is shown. There is no IR to read here and nothing to compare
structurally: a [Form] carries a [Loc.t] and [Loc.from_macro] stamps a
name onto every node a macro answers, so even an identity expansion is
structurally unequal to its input. *)
let expands ?(all = false) name src want =
match Session.macroexpand ~origin:"programs/pkg-macro.flan" ~all tm src with
| x ->
let got = Form.to_source x.Session.xafter in
if got <> want then
fail "%s\n got: %S\n wanted: %S" name got want
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "%s: %s" name m
in
(* A prelude macro: ambient, in every session, and the set that worked before
any of this existed. *)
expands "a prelude macro, one step" "(unless false 1 2)"
"(if (not false) (do 1 2))";
(* An imported package's, arriving qualified. This is most of the argument
for the feature: the definition is in another directory and cannot be read
beside the call site any more. *)
expands "an imported package macro, one step" "(mac/twice 4)" "(+ 4 4)";
(* The buffer's own, which is the set a session had to start holding before
an editor could ask anything about it. *)
expands "the file's own macro, one step" "(tenfold 7)" "(* 7 10)";
(* And the one that makes the two commands different rather than one with a
flag: [quad] quasiquotes a call to [twice], so a single step stops with a
macro call still in it and the full expansion does not. Both are true and
they answer different questions. *)
expands "a macro that expands to a call to another macro, one step"
"(mac/quad 3)" "(mac/twice (mac/twice 3))";
expands ~all:true "a macro that expands to a call to another macro, all the way"
"(mac/quad 3)" "(+ (+ 3 3) (+ 3 3))";
(* Outermost-only, which is where one step and the compiler's own first move
deliberately differ: [expand_form] expands a call's *arguments* before
calling it, so the compiler's first move here is [(mac/twice (+ 3 3))].
Same fixpoint, different intermediate, and the intermediate is the whole
of what one step is for. *)
expands "one step does not expand the arguments first"
"(mac/twice (mac/twice 3))" "(+ (mac/twice 3) (mac/twice 3))";
(* Not a macro call at all. The form comes back as it was, and the answer
that matters is [xmacro]: nothing ran. *)
(match Session.macroexpand ~origin:"programs/pkg-macro.flan" ~all:false tm
"(+ 1 2)"
with
| x ->
if x.Session.xchanged || x.Session.xmacro <> None then
fail "a form whose head is not a macro reported a macro"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "expanding a form that is not a macro call: %s" m);
(* And the name of the macro that ran, which is the one thing the text cannot
carry: [Loc.from_macro] is outermost-wins, so a full expansion is stamped
with the macro the author wrote and every intermediate name is gone. *)
(match Session.macroexpand ~origin:"programs/pkg-macro.flan" ~all:false tm
"(mac/quad 3)"
with
| x when x.Session.xmacro = Some "mac/quad" -> ()
| x ->
fail "expanding (mac/quad 3) named %s"
(match x.Session.xmacro with None -> "nothing" | Some n -> n)
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "naming the macro: %s" m);
(* The session is not touched by having been asked. [eval] commits
[t.macros], [eval_expr] bumps [t.thunks] and [t.program]; this writes
nothing, so a [defmacro] handed to C-c C-m must not join the session by
having been looked at. C-c C-c is where a declaration goes.
The head is not a macro, so nothing expands; the claim is about the
*aftermath*, and it is checked the only way it can be by calling the
name and requiring it to still be unknown. *)
(match Session.macroexpand ~origin:"programs/pkg-macro.flan" ~all:false tm
"(defmacro looked-at [args] `(* ~(at args 0) 5))"
with
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "expanding a defmacro: %s" m);
(match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(looked-at 3)" with
| _ -> fail "a defmacro joined the session by being macroexpanded"
| exception Loc.Error _ -> ());
(* Non-termination, on this path, in both directions.
One step makes exactly one call and does not look at what comes back, so
[(s/spin)] one-stepped is a fact about the macro and terminates the
bound must be where it is needed and nowhere else, or the command would
refuse to show anybody the thing they are trying to see.
All the way is the path with the fuel on it, and what has to be true is
that the bound is *reached* rather than the daemon hanging: a hang here
wedges the editor with the program still running and no way to say so.
[Loc.Error] out of this call is what [Dev.serve]'s guard answers as a
reply. *)
(match Session.macroexpand ~origin:"programs/pkg-macro-idle.flan" ~all:false ti
"(s/spin)"
with
| x ->
if Form.to_source x.Session.xafter <> "(s/spin)" then
fail "one step of a macro that does not settle answered %S"
(Form.to_source x.Session.xafter)
else if x.Session.xchanged then
fail "one step of a macro that expands to itself reported a change"
else if x.Session.xmacro <> Some "s/spin" then
fail "one step of a macro that does not settle did not name it"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "one step of a macro that does not settle was refused: %s" m);
(match Session.macroexpand ~origin:"programs/pkg-macro-idle.flan" ~all:true ti
"(s/spin)"
with
| _ -> fail "a macro that does not settle was expanded all the way"
| exception Loc.Error { Loc.dmsg = m; _ } ->
if not (has m "did not settle") then
fail "expanding a macro that does not settle all the way said %S" m);
(* And the session survives the refusal, as it does after an evaluation that
was refused. *)
(match Session.macroexpand ~origin:"programs/pkg-macro-idle.flan" ~all:true ti
"(mac/twice 21)"
with
| x ->
if Form.to_source x.Session.xafter <> "(+ 21 21)" then
fail "after a refused expansion, (mac/twice 21) expanded to %S"
(Form.to_source x.Session.xafter)
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "a session that refused an expansion could not expand afterwards: %s" m);
(* A form typed into a file that is *imported as a package* has to be
qualified the way the import qualified it, or it splices as a brand-new
unrelated name: the evaluation reports success and the running program

View File

@ -1679,6 +1679,7 @@ something surprising.</p>
<tr><td><kbd>C-c C-b</kbd></td><td>a stopped program: the condition, the restarts, the stack</td></tr>
<tr><td><kbd>C-c C-M-b</kbd></td><td>the same restarts, as a one-key prompt</td></tr>
<tr><td><kbd>C-c C-i</kbd></td><td>inspect a value, navigating into its fields</td></tr>
<tr><td><kbd>C-c C-m</kbd></td><td>what the macro call at point expands to, one step; <kbd>C-u</kbd> first for all the way</td></tr>
<tr><td><kbd>C-c C-a</kbd></td><td>disassemble a function; <kbd>C-u</kbd> first for its LLVM IR</td></tr>
<tr><td><kbd>C-c C-g</kbd></td><td>debug under lldb, through dape &mdash; bound only once <code>flan-dape.el</code> is loaded, so <code>flan-mode</code> works without dape installed</td></tr>
<tr><td><kbd>C-c C-d</kbd></td><td>what the running program currently defines</td></tr>