12 KiB
Handoff — one buffer for every lowering
Branch dev-loop, worktree agent-a9d4028fa5752ba39, from f772162.
spike/x86/dump.sh landed at f772162 and 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. A shell script that prints all four
into a scrollback is not how anyone reads them, though: three of the four are noise on any given day, and the
one that is not is the one you are elbow-deep in. So the same four go into an Emacs buffer with collapsible
sections, and the buffer remembers which of them you had open.
M-x flan-lowering, C-c C-l in flan-mode.
The three decisions
1. It compiles the file; it does not ask the daemon
flan-disassemble (C-c C-a) asks the running program what the body it is calling for a name actually came
out as. This asks a different question — what does this source compile to, through four different lowerings —
and the two cannot be merged, for a concrete reason rather than a stylistic one: the daemon has the
installed body's .ll and its .so, and neither llc -O2 nor the hand-written backend has ever been run
over it. There is no answer in the daemon to give. llc -O2 on the installed IR would be a fifth thing,
built here and never run anywhere, and the backend's output for an installed body does not exist at all
unless the daemon was started --x86.
So the two commands sit beside each other and each says which question it answers. flan-disassemble keeps
its header, whose showing line is the daemon's own account of how much its answer claims. This buffer's
header says the opposite thing just as plainly: it names the file and the compiler flags, and says the
listing is what the source on disk compiles to and not what the program is running. Both docstrings
cross-reference the other.
2. outline-minor-mode, not magit-section and not overlays of my own
Nothing under emacs/ requires a package that is not in Emacs — the whole client is subr-x, seq,
pcase, cl-lib, xref, eldoc, comint, imenu and its own files. Taking a dependency on Magit for a
four-section buffer would be the first, and it would be for folding, which Emacs has had since before Magit
existed.
outline-minor-mode on GNU Emacs 30.2.50 is the whole of what was asked for: outline-cycle is TAB on a
heading, outline-hide-body and outline-show-all collapse and expand everything, and
outline-next-visible-heading / outline-previous-visible-heading are n and p. Hand-rolling it would be
a hundred lines re-implementing that badly.
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
One defvar, four entries, alive for the Emacs session and no longer. Not persisted to disk: it is four
booleans, a preference file for it would one day have to be migrated, and nobody has ever wanted yesterday's
fold state back.
Keyed to the section, not to the function. The workflow is "I am working on the backend today", which is
a fact about the section and holds across every function the author looks at while he is doing it. Keyed to
the function it would forget on every M-x flan-lowering of a different name, which is exactly the thing he
asked not to happen.
The alist is the single source of truth. A toggle mutates it and then applies it to the buffer; a refresh applies it to the newly drawn buffer. Visibility is never read back out — that would couple this to whether 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.
What was built
| 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 . @pageand@cellsboth 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-lis not inweb/index.html, soweb/examples/quotes.shdoes 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
desktoporsavehiststory for the fold state, deliberately: it lives for the Emacs session and no longer. - The batch run leaves its scratch directory behind, because
emacs -batchexiting runs nokill-buffer-hook. Interactively the hook fires.