flan/docs/handoffs/HANDOFF-lowering-buffer.md

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 . @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.