One buffer for four lowerings, and it remembers which one you were reading

This commit is contained in:
Joseph Ferano 2026-09-14 11:25:05 +07:00
parent 5a2f627842
commit 042ddd73d9
5 changed files with 1068 additions and 12 deletions

View File

@ -41,11 +41,14 @@ heading, `outline-hide-body` and `outline-show-all` collapse and expand everythi
`outline-next-visible-heading` / `outline-previous-visible-heading` are `n` and `p`. Hand-rolling it would be
a hundred lines re-implementing that badly.
Headings are found by `outline-search-function` keyed to a text property this file puts on the four heading
lines, **not** by a regexp over the text. That is not fussiness: LLVM IR has `;` comments, assembler output
has `#` and `;` comments and lines that start with `.`, and `objdump` prints `<flan.step>:` label lines. Any
regexp loose enough to match four section headers matches inside three of the four bodies, and folding then
fragments in the middle of a listing.
`outline-regexp` is `"[▸▾] "` — the fold arrow that starts each heading, and nothing else. That is not
decoration standing in for structure, it is the one anchor available: LLVM IR has `;` comments, assembler
output has `#` and `;` comments and lines that start with `.`, and `objdump` prints `<flan.step>:` label
lines, so a regexp loose enough to match four section headers written in ordinary characters also matches
inside three of the four bodies, and folding then fragments in the middle of a listing. Neither arrow can
begin a line of any of the three. `outline-search-function` would have done the same job keyed to a text
property, at the cost of a four-argument protocol to get exactly right; the arrow is simpler and it is also
the affordance, so it earns its place twice.
### 3. The open/closed state is a global alist keyed by section, and nothing else
@ -63,11 +66,185 @@ applies it to the newly drawn buffer. Visibility is never read back out — that
outline folds with overlays or with text properties, which is a thing that has changed between Emacs
versions and is none of this file's business.
## Status
## What was built
- [x] Handoff stub, decisions fixed
- [ ] `emacs/flan-lower.el`
- [ ] Key in `flan-mode`, autoloads
- [ ] Tests in `emacs/test-flan-dev.el`
- [ ] `emacs/MANUAL.md`
- [ ] `dune test`, `@page`, `@cells`
| file | what |
|---|---|
| `emacs/flan-lower.el` | the whole feature: the four fetches, the narrowing, the renderer, the folding and the memory of it |
| `emacs/flan-mode.el` | `C-c C-l` and the autoload |
| `emacs/test-flan-dev.el` | 26 checks, after the disassembly section |
| `emacs/MANUAL.md` | its own section, plus rows in both key tables, the settings table and the file table |
## The keymap
| Key | Command | Does |
|---|---|---|
| `TAB`, `RET`, `mouse-1` | `flan-lower-toggle` | open or close the section point is in |
| `S-TAB` | `flan-lower-cycle-all` | close everything, or open everything if it is all shut |
| `c` | `flan-lower-collapse-all` | close every section |
| `e` | `flan-lower-expand-all` | open every section |
| `n` / `p` | `outline-next-visible-heading` / `outline-previous-visible-heading` | between headings |
| `g` | `flan-lower-refresh` | compile again, redraw all four |
| `r` | `flan-lower-refresh-section` | compile and redraw only this one |
| `q` | `quit-window` | |
`n` and `p` are outline's own commands bound straight into the map, which is
the same thing they mean in `flan-inspect-mode` and `flan-cnr-mode`: move
between the buffer's own units. `special-mode` binds the digits and `-` to
`digit-argument` and `negative-argument`, so the collapse and expand keys are
letters rather than the `+`/`-` pair they would otherwise have been.
`r` exists because the four are not the same price. The IR is the frontend
alone; `llc -O2` over a whole program's worth of it is the one that is felt.
The intermediates live in a scratch directory per buffer, so refreshing one
section reuses the IR the other three were made from. Refreshing the IR drops
every intermediate, because everything is downstream of it and the next `r` on
another section must not answer out of a file the old IR produced. The
directory goes with the buffer, on `kill-buffer-hook`, and there is a check
for that -- four intermediates per invocation left in `/tmp` would add up.
## Two things worth knowing
**The narrowing is `dump.sh`'s awk, ported.** The IR runs from `define
...@"flan.NAME"(` to `}`; the two assembler sections from the `flan.NAME:`
label to the `.size` that ends it; the disassembly from `<flan.NAME>:` to the
blank line after it. All four keep their last line, which is what makes a
closing brace part of the listing rather than the start of the next one. The
name is the Flan one -- `step`, not `flan.step` -- and a packaged function is
written the way the program names it, `sim/settle`, which is why every pattern
allows for LLVM's quoting.
**A section's failure is that section's text, not the buffer's.** `llc` can be
missing while the IR is perfectly readable, and a buffer that refused
altogether would be the wrong answer to that. Each fetch is wrapped, and the
error message -- which is the tool's own stderr -- becomes the body.
## Testing
26 checks in `emacs/test-flan-dev.el`, immediately after the disassembly
section and needing no daemon, which is itself the distinction between the two
commands.
Twenty-one of them drive the renderer with `flan-lower-fetch-function` rebound
to canned text. That is deliberate: folding, and the memory of what was
folded, are renderer properties, and putting four compilers and an `llc -O2`
behind every redraw would have cost seconds per redraw and proved nothing
about folding. The real fetch is exercised once at the end, guarded on `llc`,
`as` and `objdump`, and that one costs about 2.2s.
`buffer-string` is useless for any of it, because it returns hidden text too:
"all four are still named when everything is shut" would pass without anything
being shut. Every visibility check is `invisible-p`, which means what it says
whether outline folded with an overlay or with a text property.
- `dune test --root .` exit 0, every suite green, `emacs: all tests passed`.
- `dune build --root . @page` and `@cells` both exit 0.
## Transcript
`emacs -Q --batch -L emacs -l try.el -- _build/default/bin/main.exe
test/programs/dev-repl.flan`, printing the buffer *as displayed* — invisible
characters dropped, so a folded section really is absent rather than merely
unprinted.
Opened on `step`, with the remembered default (the IR open):
```
== M-x flan-lowering step ==
; every lowering of step
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▾ LLVM IR 8 lines
define i64 @"flan.step"(ptr %xfer) {
entry:
%t1 = load i64, ptr @"flan.ticks"
%t2 = add i64 %t1, 1
store i64 %t2, ptr @"flan.ticks"
%t3 = load i64, ptr @"flan.ticks"
ret i64 %t3
}
▸ LLVM -O0 13 lines
▸ LLVM -O2 10 lines
▸ x86 backend 18 lines
```
`TAB` on the x86 heading, then `TAB` on the IR heading — the state the author
described, his own backend open and the other three shut:
```
== TAB on the x86 heading, TAB on the IR heading ==
; every lowering of step
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 8 lines
▸ LLVM -O0 13 lines
▸ LLVM -O2 10 lines
▾ x86 backend 18 lines
0000000000012d94 <flan.step>:
12d94: push %rbp
12d95: mov %rsp,%rbp
...
```
The command run again on a *different* function. The preference is the
section's, so the backend is still the open one:
```
== M-x flan-lowering main -- a different function, same preference ==
; every lowering of main
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 48 lines
▸ LLVM -O0 63 lines
▸ LLVM -O2 58 lines
▾ x86 backend 69 lines
0000000000012df4 <flan.main>:
12df4: push %rbp
```
`c`, then `e`, then `n`/`p`. Collapsed, all four are still named with their
line counts, which is the buffer as a summary:
```
== c, collapse all ==
; every lowering of main
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 48 lines
▸ LLVM -O0 63 lines
▸ LLVM -O2 58 lines
▸ x86 backend 69 lines
== e, expand all, then n/p between headings ==
== e, expand all, then n/p between headings ==
n -> ▾ LLVM IR 48 lines
n n -> ▾ LLVM -O2 58 lines
p -> ▾ LLVM -O0 63 lines
```
## What is not here
- `C-c C-l` is not in `web/index.html`, so `web/examples/quotes.sh` does not
quote it. Its list of bindings is fixed and the page is a separate artefact;
adding a row there is a page change, not this one.
- A `desktop` or `savehist` story for the fold state, deliberately: it lives
for the Emacs session and no longer.
- The batch run leaves its scratch directory behind, because `emacs -batch`
exiting runs no `kill-buffer-hook`. Interactively the hook fires.

View File

@ -589,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-l` | every lowering of a function: IR, `-O0`, `-O2`, x86 backend |
| `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
@ -597,6 +598,79 @@ changed: when you connect, and after an evaluation the daemon accepted.
---
## Every lowering of one function — `C-c C-l`
`C-c C-l` on a name opens `*flan-lowering*`: the LLVM IR the frontend emits for
that function, what `llc` makes of it at `-O0` and at `-O2`, and what the
hand-written x86 backend emits, all narrowed to the one function. It is
`spike/x86/dump.sh` with a buffer around it. Reading one against another is the
only way to check a lowering by eye, and the reason the second backend is
trustworthy is that the two agree.
The four are folding sections, so the buffer is a four-line summary when
everything is shut:
```
; every lowering of step
; file ~/src/game.flan
; compiler /home/joe/src/flan/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 8 lines
▸ LLVM -O0 13 lines
▸ LLVM -O2 10 lines
▾ x86 backend 19 lines
0000000000012d94 <flan.step>:
12d94: push %rbp
...
```
Which sections are open is remembered for the rest of the Emacs session, and it
is remembered **by section, not by function**: if you are working on the backend
this week, `C-c C-l` on a different name leaves the backend open and the other
three shut. It is not written to disk — four booleans are not worth a
preference file.
| Key | Does |
|---|---|
| `TAB` | open or close the section point is in |
| `S-TAB` | close everything, or open everything if it is all shut |
| `c` | close every section |
| `e` | open every section |
| `n` / `p` | to the next or previous section heading |
| `g` | compile the file again and redraw all four |
| `r` | compile and redraw only the section point is in |
| `q` | bury the buffer |
`r` is worth the separate key because the four are not the same price: the IR
is the frontend alone, and `llc -O2` over a whole program's worth of it is the
one that is felt. Refreshing one section reuses the IR the other three were
made from. Refreshing the IR drops everything, since everything is downstream
of it.
This is **not** `C-c C-a`, and no prefix argument turns one into the other.
`C-c C-a` asks the *running program* what the body it is calling for a name
actually came out as; only the daemon can answer that, because the daemon
compiled that module and still has both its `.ll` and its `.so`. `C-c C-l` asks
what the *file on disk* compiles to, by compiling it, and needs no program
running at all. The two cannot be merged: neither `llc -O2` nor the x86 backend
has ever been run over an installed body, so there is no four-way answer in the
daemon to give. The `showing` line in each buffer's header says which of the two
you are reading.
`C-u C-c C-l` asks for the file as well as the name, for the case where the
function you want to read is not in the buffer you are in.
The four outputs need `llc`, `as` and `objdump` on `PATH` — the backend writes
machine code rather than mnemonics, so its section is assembled and
disassembled to be readable, which is also a check that the bytes are well
formed. A section whose tool is missing says so in place of its listing; the
other three are unaffected.
---
## When something is wrong
**An error draws an overlay** where it happened, with the message. It clears the
@ -638,6 +712,7 @@ Use `C-c C-g` if you need frames.
| `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-l` | every lowering of a function, in folding sections |
| `C-c C-g` | debug under lldb, through dape |
| `C-c C-d` | what the running program defines |
| `C-c C-v` | help on the name at point |
@ -665,6 +740,10 @@ Commands with no key: `M-x flan-dev` (start a program), `M-x flan-dev-quit`
| `flan-dev-poll-interval` | `1.0` | seconds between checks for whether it stopped |
| `flan-dev-daemon-buffer` | `"*flan-dev*"` | the daemon's own log |
| `flan-dev-start-timeout` | `60` | seconds to wait for a program to come up |
| `flan-lower-buffer` | `"*flan-lowering*"` | where `C-c C-l` writes |
| `flan-lower-program` | `"flan"` | the compiler `C-c C-l` shells out to |
| `flan-lower-flags` | `nil` | flags for `flan emit``("--dev")` for the dev lowerings |
| `flan-lower-llc` | `"llc"` | what the `-O0` and `-O2` sections are made with |
| `flan-watch-buffer` | `"*flan-watch*"` | where watched values are painted |
| `flan-watch-interval` | `0.2` | seconds between repaints — not the watch rate |
| `flan-watch-ghost-call-regexp` | `"watch\(?:-[[:alnum:]]+\)?"` | the head of a call ghost text anchors on |
@ -681,6 +760,7 @@ Commands with no key: `M-x flan-dev` (start a program), `M-x flan-dev-quit`
| `flan-watch.el` | watched values: the program pushes, this paints them in a buffer and inline |
| `flan-cnr.el` | the conditions-and-restarts buffer |
| `flan-inspect.el` | the value inspector |
| `flan-lower.el` | the lowering buffer: four outputs, folding, and which was open |
| `flan-dape.el` | lldb through dape; optional |
There is no Flan parser in any of them. The client sends text and the compiler

610
emacs/flan-lower.el Normal file
View File

@ -0,0 +1,610 @@
;;; flan-lower.el --- Every lowering of one function, in one buffer -*- lexical-binding: t; -*-
;; `spike/x86/dump.sh' prints four lowerings of one function side by side --
;; the LLVM IR the frontend emits, what `llc' makes of it at -O0 and at -O2,
;; and what the hand-written x86 backend emits. Reading one against another is
;; the only way to check a lowering by eye, and the whole reason the second
;; backend is trustworthy is that the two agree. This is that script with a
;; buffer around it.
;;
;; A scrollback is the wrong shape for it. Three of the four are noise on any
;; given day and the fourth is the one being worked on, so the four are
;; sections that fold, and which of them is open is remembered -- keyed to the
;; section and not to the function, because "I am on the backend this week" is
;; a fact about the backend and holds across every function looked at while it
;; is true.
;;
;; This is *not* `flan-disassemble', and the two cannot be merged. `C-c C-a'
;; asks the running program what the body it is calling for a name actually
;; came out as; only the daemon can answer that, because the daemon compiled
;; the module and still has both the .ll and the .so. Neither `llc -O2' nor
;; the hand-written backend has ever been run over that body, so there is no
;; four-way answer in the daemon to give. This command asks the other
;; question -- what does the source on disk compile to -- by compiling it, and
;; its header says so in as many words so that a listing never implies the
;; stronger claim.
;;
;; The folding is `outline-minor-mode', which is Emacs' own and has been since
;; long before Magit: `outline-flag-region' hides a subtree and
;; `outline-next-visible-heading' walks between them. Nothing under emacs/
;; requires a package that is not in Emacs and this does not start.
;;
;; Headings are found by the arrow that starts them, `▸' or `▾' at column
;; zero. That is not decoration standing in for structure: a regexp loose
;; enough to match four section headers written in ordinary characters also
;; matches inside three of the four bodies -- LLVM IR has `;' comments,
;; assembler output has `#' and `;' comments and lines beginning `.', and
;; objdump prints `<flan.step>:' label lines -- and folding then fragments in
;; the middle of a listing. Neither arrow can begin a line of any of the
;; three, so the arrow is both the affordance and the anchor.
;;; Code:
(require 'subr-x)
(require 'seq)
(require 'outline)
(require 'flan-dev)
(defgroup flan-lower nil
"Every lowering of one Flan function, side by side."
:group 'flan
:prefix "flan-lower-")
(defcustom flan-lower-buffer "*flan-lowering*"
"Buffer `flan-lowering' writes into."
:type 'string)
(defcustom flan-lower-program "flan"
"The Flan compiler this shells out to.
A buffer under this editor is usually being worked on beside the compiler
that built it, so the useful value is often an absolute path into a build
directory rather than whatever is first on PATH."
:type 'string)
(defcustom flan-lower-flags nil
"Flags passed to `flan emit', and so to both the IR and the x86 section.
`(\"--dev\")' compares the four dev lowerings rather than the four release
ones. Only `flan emit' understands them: `llc' is handed the IR that came
out, so the two LLVM sections follow whatever this chose without being told."
:type '(repeat string))
(defcustom flan-lower-llc "llc"
"The LLVM static compiler the -O0 and -O2 sections are made with."
:type 'string)
;; The four, in the order they lower: what the frontend wrote, what LLVM makes
;; of it twice, and what this project's own backend makes of it. The symbols
;; are what the remembered state is keyed by, so they are the one part of this
;; list that is not free to change.
(defconst flan-lower--sections
'((ir . "LLVM IR")
(O0 . "LLVM -O0")
(O2 . "LLVM -O2")
(x86 . "x86 backend"))
"Section ids and the names they are shown under, in order.")
(defconst flan-lower--heading-re "^[▸▾] "
"What a section heading looks like from the start of a line.")
;; Session state and nothing more. Four booleans do not want a preference
;; file: it would be a thing to migrate the first time a section is renamed,
;; and nobody has ever wanted yesterday's fold state back. The IR is open to
;; begin with because a buffer that opens with everything shut reads as though
;; the command failed.
(defvar flan-lower--state
'((ir . t) (O0 . nil) (O2 . nil) (x86 . nil))
"Which sections are open, for this Emacs session.
Keyed by section and not by function on purpose -- see the header. This is
the single source of truth: a toggle changes this and then applies it to the
buffer, and a redraw applies it to the buffer it has just drawn. Visibility
is never read back out, which would tie this to whether outline folds with
overlays or with text properties.")
(defvar flan-lower-fetch-function #'flan-lower--fetch
"How one section's text is obtained.
Called with (SECTION FILE NAME FLAGS) and answers a string, or signals an
error whose message becomes the section's body. A variable rather than a
call so that the renderer can be driven from a test without four subprocesses
and an `llc -O2' per redraw.")
(defvar-local flan-lower--file nil "Source file the sections were made from.")
(defvar-local flan-lower--name nil "Flan name the sections were narrowed to.")
(defvar-local flan-lower--flags nil "Flags `flan emit' was given.")
(defvar-local flan-lower--texts nil "Alist of section id to its text.")
(defvar-local flan-lower--dir nil "Scratch directory the intermediates live in.")
;;; Faces
(defface flan-lower-heading '((t :inherit bold))
"Face for a section heading.")
(defface flan-lower-count '((t :inherit shadow))
"Face for the line count beside a section heading.")
;;; Getting the four
(defun flan-lower--scratch ()
"The scratch directory for this buffer, made on first use.
The intermediates are kept rather than piped so that refreshing one section
does not recompile the file: `llc -O2' is the slow one and the IR it reads
has not changed."
(unless (and flan-lower--dir (file-directory-p flan-lower--dir))
(setq flan-lower--dir (make-temp-file "flan-lower-" t)))
flan-lower--dir)
(defun flan-lower--clean ()
"Remove this buffer's scratch directory."
(when (and flan-lower--dir (file-directory-p flan-lower--dir))
(delete-directory flan-lower--dir t))
(setq flan-lower--dir nil))
(defun flan-lower--run (out program &rest args)
"Run PROGRAM with ARGS, stdout to OUT, and answer OUT.
A failure is signalled with the program's own stderr, because every one of
these is a compiler and the compiler's complaint is the whole of what a
reader needs."
(let ((err (expand-file-name "stderr" (flan-lower--scratch))))
(unless (executable-find program)
(error "no %s on PATH" program))
(let ((code (apply #'call-process program nil (list (list :file out) err)
nil args)))
(unless (eq code 0)
(error "%s exited %s: %s" program code
(string-trim
(with-temp-buffer (insert-file-contents err) (buffer-string)))))
out)))
(defun flan-lower--slurp (file)
"The contents of FILE as a string."
(with-temp-buffer (insert-file-contents file) (buffer-string)))
(defun flan-lower--ll (file flags)
"The IR for FILE under FLAGS, made once and kept.
Every section is downstream of this one: `llc' reads it twice and the x86
section is the same frontend asked for a different back half."
(let ((ll (expand-file-name "out.ll" (flan-lower--scratch))))
(unless (file-exists-p ll)
(apply #'flan-lower--run ll flan-lower-program "emit" file flags))
ll))
(defun flan-lower--fetch (section file name flags)
"The text of SECTION for NAME in FILE, compiled with FLAGS.
Narrowed to the one function, which is almost always what is wanted: the
prelude is emitted too, so a two-line program is ten thousand lines of IR."
(let ((sym (concat "flan\\." (regexp-quote name))))
(pcase section
('ir
;; LLVM spells the name `@"flan.step"' when it needs quoting and
;; `@flan.step' when it does not, and a packaged name always needs it.
(flan-lower--narrow (flan-lower--slurp (flan-lower--ll file flags))
(concat "^define .*@\"?" sym "\"?(") "^}"))
((or 'O0 'O2)
(let ((s (expand-file-name (format "out.%s.s" section) (flan-lower--scratch))))
(unless (file-exists-p s)
(flan-lower--run s flan-lower-llc
(if (eq section 'O0) "-O0" "-O2")
(flan-lower--ll file flags) "-o" s))
;; `.size' ends the function in GAS output, and it is the last line
;; of it rather than the first line of the next.
(flan-lower--narrow (flan-lower--slurp s)
(concat "^\"?" sym "\"?:") "\\.size")))
('x86
(let ((dis (expand-file-name "out.x86.dis" (flan-lower--scratch))))
(unless (file-exists-p dis)
(let ((s (expand-file-name "out.x86.s" (flan-lower--scratch)))
(o (expand-file-name "out.x86.o" (flan-lower--scratch))))
;; The IR is asked for first even though this path does not read
;; it, so that a frontend error is reported once, in whichever
;; section is fetched first, rather than twice in two dialects.
(flan-lower--ll file flags)
(apply #'flan-lower--run s flan-lower-program
"emit" "--x86" file flags)
;; The backend writes machine code, not mnemonics, so its .s is
;; `.byte' blobs. Assembling and disassembling is what makes it
;; readable -- and it is also a check that the bytes are well
;; formed, which reading them never would be.
(flan-lower--run o "as" "--64" "-o" o s)
(flan-lower--run dis "objdump" "-d" "--no-show-raw-insn" o)))
(flan-lower--narrow (flan-lower--slurp dis)
(concat "<" sym ">:") "^$")))
(_ (error "no such section: %s" section)))))
(defun flan-lower--narrow (text start end)
"The run of TEXT from the line matching START to the line matching END.
Both lines are kept, which is what makes a function's closing brace and its
`.size' part of the listing rather than the start of the next one. Answers
nil when START never matches, which is what a name the file does not define
looks like."
(with-temp-buffer
(insert text)
(goto-char (point-min))
(when (re-search-forward start nil t)
(let ((beg (line-beginning-position)))
(forward-line 1)
(if (re-search-forward end nil t)
(buffer-substring-no-properties beg (line-beginning-position 2))
(buffer-substring-no-properties beg (point-max)))))))
(defun flan-lower--gather (sections)
"Fetch each of SECTIONS into `flan-lower--texts', replacing what was there.
One section's failure is that section's text and not the buffer's: `llc' can
be missing while the IR is perfectly readable, and a buffer that refuses
altogether would be the wrong answer to that."
(dolist (s sections)
(let ((text (condition-case err
(or (funcall flan-lower-fetch-function
s flan-lower--file flan-lower--name
flan-lower--flags)
(format "nothing for `%s' here\n" flan-lower--name))
(error (concat (error-message-string err) "\n")))))
(setf (alist-get s flan-lower--texts) text))))
;;; Drawing it
(defun flan-lower--header (label text)
"Insert a header line naming LABEL with TEXT, wrapped under the label.
Wrapped by hand rather than with `fill-region', which is what the sibling
buffer uses: filling canonicalises runs of spaces, and every value here is a
path or a flag list whose columns are the point of it."
(let* ((start (point))
(indent "; ")
(lead (format "; %-11s " label))
(col (length lead))
(fresh t))
(insert lead)
(dolist (word (split-string (or text "") "[ \t\n]+" t))
(cond
(fresh (setq fresh nil))
((> (+ col 1 (length word)) 78)
(insert "\n" indent)
(setq col (length indent)))
(t (insert " ") (setq col (1+ col))))
(insert word)
(setq col (+ col (length word))))
(insert "\n")
(put-text-property start (point) 'face 'font-lock-comment-face)))
;; Enough highlighting to tell the three languages apart at a glance, and no
;; more. A major mode for each would be three modes to keep, and what is
;; wanted here is only that a register not read like a mnemonic.
(defconst flan-lower--ir-keywords
(regexp-opt '("define" "declare" "ret" "load" "store" "call" "br" "add"
"sub" "mul" "sdiv" "udiv" "srem" "icmp" "fcmp" "alloca"
"getelementptr" "phi" "select" "switch" "bitcast" "zext"
"sext" "trunc" "inttoptr" "ptrtoint" "unreachable" "tail"
"and" "or" "xor" "shl" "lshr" "ashr" "label")
'symbols))
(defconst flan-lower--ir-types
(regexp-opt '("i1" "i8" "i16" "i32" "i64" "ptr" "void" "float" "double")
'symbols))
(defun flan-lower--rules (section)
"Highlighting rules for SECTION, as an alist of regexp to face."
(pcase section
('ir `((,flan-lower--ir-keywords . font-lock-keyword-face)
(,flan-lower--ir-types . font-lock-type-face)
("[%@][-A-Za-z0-9_.\"$/]+" . font-lock-variable-name-face)
("^\\s-*;.*$" . font-lock-comment-face)))
('x86 `(("^ *[0-9a-f]+:" . shadow)
("<[^>\n]+>" . font-lock-function-name-face)
("%[a-z0-9]+" . font-lock-variable-name-face)
("\\$0x[0-9a-f]+" . font-lock-constant-face)
("#.*$" . font-lock-comment-face)))
(_ `(("^\\s-*\\.[a-zA-Z_0-9.]+" . font-lock-preprocessor-face)
("^[^ \t\n#][^ \t\n]*:" . font-lock-function-name-face)
("%[a-z0-9]+" . font-lock-variable-name-face)
("\\$-?[0-9]+\\|\\$0x[0-9a-f]+" . font-lock-constant-face)
("#.*$" . font-lock-comment-face)))))
(defun flan-lower--fontify (beg end section)
"Highlight BEG..END as SECTION's language.
Earlier rules win, which is why comments come last in every list: a `#' to
the end of the line is a comment whatever it holds, so it is painted over
whatever the rules above it found in there."
(save-excursion
(dolist (rule (flan-lower--rules section))
(goto-char beg)
(while (re-search-forward (car rule) end t)
(let ((s (match-beginning 0)) (e (match-end 0)))
;; An anchored rule can match the empty string, and a search that
;; does not move is a loop that does not end.
(if (= s e)
(unless (eobp) (forward-char 1))
(unless (get-text-property s 'face)
(put-text-property s e 'face (cdr rule)))))))))
(defun flan-lower--insert-section (id label)
"Insert the heading and body for section ID under LABEL."
(let* ((raw (or (alist-get id flan-lower--texts) ""))
;; Trimmed, because the blank line that ends an objdump function is
;; the narrowing's terminator rather than part of the listing, and a
;; count that included it would be one instruction out.
(text (if (string-empty-p (string-trim raw)) raw
(concat (string-trim-right raw "\n+") "\n")))
(lines (if (string-empty-p text) 0
(1- (length (split-string text "\n")))))
(open (alist-get id flan-lower--state))
(hbeg (point)))
(insert (if open "" ""))
(insert (propertize label 'face 'flan-lower-heading))
(insert (propertize (format " %d line%s" lines (if (= lines 1) "" "s"))
'face 'flan-lower-count))
(insert "\n")
(put-text-property hbeg (point) 'flan-lower-section id)
;; The blank line after the listing belongs to the body and not to the
;; gap between sections. That is what makes an open section breathe and a
;; buffer with everything shut four adjacent lines -- a summary, which is
;; the shape it is meant to have when it is closed.
(let ((bbeg (point)))
(insert text)
(unless (string-suffix-p "\n" text) (insert "\n"))
(flan-lower--fontify bbeg (point) id)
(insert "\n")
(put-text-property bbeg (point) 'flan-lower-section id))))
(defun flan-lower--draw ()
"Erase this buffer and write the header and the four sections into it."
(let ((inhibit-read-only t))
(erase-buffer)
(let ((start (point)))
(insert (format "; every lowering of %s\n" flan-lower--name))
(put-text-property start (point) 'face 'font-lock-comment-face))
(flan-lower--header "file" (abbreviate-file-name (or flan-lower--file "")))
(flan-lower--header "compiler" flan-lower-program)
(flan-lower--header "flags" (if flan-lower--flags
(string-join flan-lower--flags " ")
"none"))
;; The counterpart of `flan-disassemble's `showing' line, and it has to be
;; here for the same reason: four listings of a name look exactly like an
;; answer about the program, and this one is an answer about the file.
(flan-lower--header
"showing" "what this file compiles to, not what a running program is \
calling for this name -- for that, C-c C-a")
(insert "\n")
(pcase-dolist (`(,id . ,label) flan-lower--sections)
(flan-lower--insert-section id label))
(flan-lower--apply-state)
(set-buffer-modified-p nil)))
;;; Folding
(defun flan-lower--heading-bounds ()
"Bounds of the section whose heading point is on, as (HEAD-END . SUB-END)."
(let ((hend (line-end-position))
(send (save-excursion
(forward-line 1)
(if (re-search-forward flan-lower--heading-re nil t)
(1- (match-beginning 0))
(point-max)))))
(cons hend (max hend send))))
(defun flan-lower--apply-state ()
"Fold every section the way `flan-lower--state' says, arrows and all."
(let ((inhibit-read-only t)
(modified (buffer-modified-p)))
(save-excursion
(goto-char (point-min))
(while (re-search-forward flan-lower--heading-re nil t)
(goto-char (match-beginning 0))
(let* ((id (get-text-property (point) 'flan-lower-section))
(open (alist-get id flan-lower--state))
(props (text-properties-at (point))))
;; The arrow is rewritten rather than redrawn with the rest, so the
;; properties that make it a heading have to be put back by hand:
;; inserted text inherits nothing the caller did not arrange.
(delete-char 1)
(insert (if open "" ""))
(set-text-properties (1- (point)) (point) props)
(goto-char (match-beginning 0))
(pcase-let ((`(,hend . ,send) (flan-lower--heading-bounds)))
(outline-flag-region hend send (not open))
;; Onwards from the end of this heading, so the next search does
;; not find the arrow that was just rewritten.
(goto-char hend)))))
(set-buffer-modified-p modified)))
(defun flan-lower--section-at-point ()
"The section point is in, heading or body, or nil above the first one."
(get-text-property (point) 'flan-lower-section))
(defun flan-lower--goto-section (id)
"Put point on section ID's heading, if it is here."
(let ((pos (save-excursion
(goto-char (point-min))
(catch 'found
(while (re-search-forward flan-lower--heading-re nil t)
(when (eq (get-text-property (match-beginning 0)
'flan-lower-section)
id)
(throw 'found (match-beginning 0))))
nil))))
(when pos (goto-char pos))))
(defun flan-lower-toggle (&optional event)
"Open or close the section point is in, or the one EVENT was over.
Above the first heading there is nothing to fold, so this moves to the first
section instead of doing nothing -- the same bargain `flan-cnr-tab' makes."
(interactive (list last-nonmenu-event))
(when (and event (listp event) (mouse-event-p event)) (mouse-set-point event))
(let ((id (flan-lower--section-at-point)))
(if (null id)
(outline-next-visible-heading 1)
(setf (alist-get id flan-lower--state) (not (alist-get id flan-lower--state)))
;; Point may be inside a body that is about to be hidden, and a point
;; in invisible text is a cursor that appears to have vanished.
(flan-lower--goto-section id)
(flan-lower--apply-state))))
(defun flan-lower--set-all (open)
"Open every section if OPEN, else close every one."
(pcase-dolist (`(,id . ,_) flan-lower--sections)
(setf (alist-get id flan-lower--state) open))
(let ((id (flan-lower--section-at-point)))
(when (and id (not open)) (flan-lower--goto-section id)))
(flan-lower--apply-state))
(defun flan-lower-collapse-all ()
"Close every section, leaving the four names and their line counts."
(interactive)
(flan-lower--set-all nil))
(defun flan-lower-expand-all ()
"Open every section."
(interactive)
(flan-lower--set-all t))
(defun flan-lower-cycle-all ()
"Close everything if anything is open, and otherwise open everything."
(interactive)
(flan-lower--set-all
(not (seq-some (lambda (s) (alist-get (car s) flan-lower--state))
flan-lower--sections))))
;;; Refreshing
(defun flan-lower--position ()
"Where point is, as (SECTION . LINE-WITHIN-IT), for putting it back."
(let ((id (flan-lower--section-at-point)))
(when id
(cons id (count-lines
(or (save-excursion (flan-lower--goto-section id) (point))
(point-min))
(line-beginning-position))))))
(defun flan-lower--restore (pos screen-line)
"Put point back at POS and the window back at SCREEN-LINE."
(when pos
(when (flan-lower--goto-section (car pos))
(forward-line (cdr pos))))
;; Guarded because under `emacs -batch' there is no window at all, and an
;; unguarded `set-window-start' would be a failure with nothing to do with
;; what was being refreshed.
(when-let ((win (and screen-line (get-buffer-window (current-buffer)))))
(set-window-start win (save-excursion
(vertical-motion (- screen-line))
(point)))))
(defun flan-lower--redraw (sections)
"Re-fetch SECTIONS and draw the buffer again, keeping point where it was."
(let* ((pos (flan-lower--position))
(win (get-buffer-window (current-buffer)))
;; How far down the window point is sitting, so that a redraw is a
;; redraw and not a jump to the top of a listing being read.
(screen-line (and win (count-screen-lines (window-start win) (point)))))
(flan-lower--gather sections)
(flan-lower--draw)
(flan-lower--restore pos screen-line)))
(defun flan-lower-refresh ()
"Compile the file again and redraw all four sections."
(interactive)
(flan-lower--clean)
(setq flan-lower--texts nil)
(flan-lower--redraw (mapcar #'car flan-lower--sections)))
(defun flan-lower-refresh-section ()
"Compile and redraw only the section point is in.
Worth its own key because the four are not the same price: the IR is the
frontend alone, and `llc -O2' over a whole program's worth of it is the one
that is felt."
(interactive)
(let ((id (flan-lower--section-at-point)))
(unless id (user-error "flan: point is not in a section"))
;; Only this section's intermediate goes; the IR everything is downstream
;; of stays, which is the whole saving.
;; The IR is what the other three are made from, so refreshing it drops
;; their intermediates too -- they are not redrawn here, but the next `r'
;; on one of them must not answer out of a file the old IR produced.
(dolist (f (pcase id
('ir '("out.ll" "out.O0.s" "out.O2.s"
"out.x86.s" "out.x86.o" "out.x86.dis"))
('O0 '("out.O0.s"))
('O2 '("out.O2.s"))
('x86 '("out.x86.s" "out.x86.o" "out.x86.dis"))))
(let ((p (expand-file-name f (flan-lower--scratch))))
(when (file-exists-p p) (delete-file p))))
(flan-lower--redraw (list id))))
;;; The mode
(defvar flan-lower-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "TAB") #'flan-lower-toggle)
(define-key map (kbd "RET") #'flan-lower-toggle)
(define-key map [mouse-1] #'flan-lower-toggle)
(define-key map [backtab] #'flan-lower-cycle-all)
(define-key map "c" #'flan-lower-collapse-all)
(define-key map "e" #'flan-lower-expand-all)
;; `n' and `p' move between the buffer's own units, the way they do in
;; `flan-inspect-mode' and `flan-cnr-mode'. Here the unit is a section.
(define-key map "n" #'outline-next-visible-heading)
(define-key map "p" #'outline-previous-visible-heading)
(define-key map "g" #'flan-lower-refresh)
(define-key map "r" #'flan-lower-refresh-section)
(define-key map "q" #'quit-window)
map)
"Keys in `flan-lower-mode'.")
(define-derived-mode flan-lower-mode special-mode "flan-lower"
"Every lowering of one Flan function, in foldable sections.
\\{flan-lower-mode-map}"
(setq buffer-read-only t)
(setq-local truncate-lines t)
;; Set before the minor mode goes on, because that is when it reads them.
(setq-local outline-regexp "[▸▾] ")
(setq-local outline-level (lambda () 1))
(outline-minor-mode 1)
(add-hook 'kill-buffer-hook #'flan-lower--clean nil t))
;;;###autoload
(defun flan-lowering (name &optional file)
"Show every lowering of NAME in FILE: the IR, -O0, -O2, and the x86 backend.
FILE defaults to the file the current buffer is visiting. NAME is the Flan
one -- `step', not `flan.step' -- and a packaged function is written the way
the program names it, `sim/settle'.
What this shows is what FILE compiles to today, which is not the same claim
as `flan-disassemble' (\\[flan-disassemble]): that one asks the running program what the body
it is calling for a name actually came out as, and only the daemon can
answer it. The two cannot be merged, because the daemon has the installed
body's IR and its object and has never run either `llc -O2' or the x86
backend over it.
Which sections are open is remembered for the session, by section rather
than by function: asking for a different name leaves whichever of the four
was being read open."
(interactive
(list (or (thing-at-point 'symbol t)
(completing-read
"Lowerings of: "
(mapcar #'car (seq-filter (lambda (d) (equal (nth 1 d) "fn"))
flan-dev--defs))
nil nil nil nil
(and (fboundp 'flan-current-defun-name)
(flan-current-defun-name))))
(when current-prefix-arg
(read-file-name "Lowerings from file: " nil nil t))))
(let ((src (or file buffer-file-name)))
(unless src
(user-error "flan: no file to compile; visit one or give it a file"))
(with-current-buffer (get-buffer-create flan-lower-buffer)
(unless (derived-mode-p 'flan-lower-mode) (flan-lower-mode))
;; A new name means new intermediates only if the file changed; the
;; narrowing is done here, so the same file's IR is reused across names.
(unless (equal flan-lower--file (expand-file-name src))
(flan-lower--clean))
(setq flan-lower--file (expand-file-name src)
flan-lower--name name
flan-lower--flags flan-lower-flags
flan-lower--texts nil)
(flan-lower--gather (mapcar #'car flan-lower--sections))
(flan-lower--draw)
(goto-char (point-min)))
(display-buffer flan-lower-buffer)))
(provide 'flan-lower)
;;; flan-lower.el ends here

View File

@ -73,6 +73,10 @@
(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)
;; The other question about the same function, and the reason it is a second
;; command rather than a fifth argument to the first: `flan-disassemble' asks
;; the running program, and this compiles the file.
(autoload 'flan-lowering "flan-lower" 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.
@ -195,6 +199,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)
;; Every lowering of the name at point -- the IR, `llc' at -O0 and at -O2,
;; and the hand-written x86 backend -- in one buffer of folding sections.
;; Beside C-c C-a and not under it: that one answers for the program that
;; is running, this one for the file on disk, and no prefix argument can
;; make one of those into the other.
(define-key map (kbd "C-c C-l") #'flan-lowering)
;; 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

View File

@ -14,6 +14,7 @@
(require 'flan-dev)
(require 'flan-repl)
(require 'flan-watch)
(require 'flan-lower)
(defvar test-flan--failures 0)
@ -809,6 +810,184 @@ is written instead — the real `message' call the real command makes."
(flan-dev-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))
;; 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)
(let ((was (flan-lower--position)))
(flan-lower-refresh)
(test-flan--check "g redraws without moving point off the line it was on"
(equal was (flan-lower--position))))))
;; 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)
(flan-lower--state '((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))))
;; 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