lldb already speaks DAP; Emacs only needs to be told how to build

No DAP implementation here, and there should not be one. `flan build --debug'
puts DWARF in the executable, lldb reads it, lldb-dap speaks the protocol — so
what was actually missing was a dape-configs entry that knows to build a .flan
file first and where the binary lands.

The build goes through dape's own `compile' key rather than a shell-out, so a
rejected program lands in a compilation buffer and next-error walks it. Flan's
diagnostics are already file:line:col.

`flan-debug' goes through `dape--config-eval' and not `alist-get'. `dape'
takes a config whose forms are already evaluated — that is what M-x dape does
after reading one — and handing it the stored entry would pass the list
(flan-dape--binary (flan-dape--source)) to lldb as a program name. Driven
headlessly to prove it: a breakpoint set by line in the .flan buffer, hit,
reported as flan.tick at debug.flan:19 with c and n in scope.

The keybinding is registered from here rather than in flan-mode.el, so this
file is the only thing anyone has to load to get it and flan-mode keeps
working for someone who never installs dape.

The two frictions are written down at the bottom of flan-dape.el from lldb
transcripts, not from reasoning about what ought to happen, because the guess
I started from was wrong. Across a reload a breakpoint set by *name* gains a
second location and both stay live — the old body is still mapped and still
what old call sites reach. One set by *file and line* stops firing, and not
because dape pinned it to an address: the redefinition module has no line
table to resolve against. Given one, lldb does re-resolve on dlopen.

Which names the gap: Emit.redefinition takes ~debug and Session.eval does not
pass it, so `flan reload' and the `flan dev' daemon build modules without
DWARF. lib/session.ml is the dev loop's file, not this lane's.

test-flan-dape.el is not in dune test. It wants Emacs, dape, lldb-dap and a
built flan at once, and wiring four optional things into the acceptance table
would make that table's failures mean less, not more.
This commit is contained in:
Joseph Ferano 2026-09-12 03:58:26 +07:00
parent 67aa82457d
commit 5f5cc8bee9
2 changed files with 367 additions and 0 deletions

229
emacs/flan-dape.el Normal file
View File

@ -0,0 +1,229 @@
;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*-
;; The other half of the dev loop. flan-dev.el is about a program that keeps
;; running while you change it; this is about stopping one and reading it.
;;
;; There is no DAP implementation here and there should not be. `flan build
;; --debug' writes DWARF into the executable, lldb reads it, and `lldb-dap'
;; speaks DAP on lldb's behalf — so what is left for Emacs is a `dape-configs'
;; entry that knows how to build a .flan file and where the binary lands.
;;
;; It works at all because of the layout. A Flan struct is its C struct, a
;; slot is an ordinary alloca and there are no tag words or object headers
;; anywhere (plan.org, Memory), so lldb's own C support prints a Flan value
;; correctly with nothing taught to it. The compile unit says DW_LANG_C99 for
;; that reason.
;;
;; M-x flan-debug builds the file the current buffer is visiting and stops it
;; at `main'. Breakpoints are ordinary dape breakpoints in the .flan buffer —
;; `dape-breakpoint-toggle' on a line — because the DWARF line table names the
;; .flan file, not the generated .ll. `dape-breakpoint-global' works too, and
;; is the way to break on a function without hunting for its first line.
;;
;; Two things are worth knowing before they surprise you; both have their own
;; heading at the bottom of this file, written from lldb transcripts rather
;; than from reasoning about what ought to happen:
;;
;; - a --dev build and a --debug build are different builds, and M-x
;; flan-debug does not attach to the program `flan dev' is running;
;; - across a redefinition a breakpoint set by NAME gains a second location
;; and both stay live, while one set by FILE AND LINE stops firing —
;; because the redefinition module carries no DWARF yet.
;;; Code:
(require 'subr-x)
(declare-function dape "dape" (config &optional skip-compile))
(declare-function dape--config-eval "dape" (key options &optional skip-functions))
(declare-function dape-breakpoint-toggle "dape" ())
(defvar dape-configs)
(defgroup flan-dape nil
"Debugging a Flan program under lldb."
:group 'flan
:prefix "flan-dape-")
(defcustom flan-dape-command "flan"
"The flan executable used to build a program for debugging.
Its own option rather than flan-dev.el's `flan-dev-command', because this
file is meant to load without that one: editing Flan, debugging Flan and
attaching to a running Flan are three independent things to want."
:type 'string)
(defcustom flan-dape-adapter "lldb-dap"
"The DAP adapter binary.
Fedora and Debian ship it as lldb-dap; older LLVM called it lldb-vscode."
:type 'string)
(defcustom flan-dape-stop-at-entry t
"Whether to stop at the program's entry before running.
On by default: a debug session that starts by running to completion has
told you nothing, and the first thing anyone does is set a breakpoint."
:type 'boolean)
(defcustom flan-dape-extra-flags '()
"Extra flags passed to `flan build' alongside --debug.
--dev belongs here if you want the cells as well as the line tables; see
\"Reloading and breakpoints\" at the bottom of flan-dape.el for what that
does and does not buy."
:type '(repeat string))
(defun flan-dape--source ()
"The .flan file this session is about.
The buffer's own file, or the nearest one up from it so M-x flan-debug
from a *compilation* buffer or a dired still has an answer."
(or (and buffer-file-name
(string-suffix-p ".flan" buffer-file-name)
buffer-file-name)
(car (directory-files default-directory t "\\.flan\\'"))
(user-error "No .flan file here to debug")))
(defun flan-dape--binary (source)
"Where the debug build of SOURCE goes.
Not beside the source. A debug build is -O0 with DWARF in it and is not
the artefact anyone means by the program's name, so it must not overwrite
one made by `flan build'."
(expand-file-name
(concat "flan-dbg-" (file-name-base source))
temporary-file-directory))
(defun flan-dape--compile-command (source)
"The shell command that builds SOURCE for debugging."
(mapconcat #'shell-quote-argument
(append (list flan-dape-command "build" source "--debug")
flan-dape-extra-flags
(list "-o" (flan-dape--binary source)))
" "))
;; The entry itself. `compile' is dape's own pre-launch hook, so the build
;; happens through `compile-command' and its errors land in a compilation
;; buffer that `next-error' walks — which is the whole reason not to shell out
;; from here. Flan's diagnostics are file:line:col, so they are already in a
;; shape compilation-mode understands.
;;
;; The unquoted forms are evaluated by `dape--config-eval' when a session
;; starts, in the buffer it started from — which is why `flan-dape--source'
;; can just read `buffer-file-name'. `dape' itself expects an already
;; evaluated config, so `flan-debug' below must not hand it the raw entry.
(defconst flan-dape-config
'(modes (flan-mode)
ensure dape-ensure-command
command-cwd dape-command-cwd
compile (flan-dape--compile-command (flan-dape--source))
:type "lldb-dap"
:request "launch"
:cwd "."
:program (flan-dape--binary (flan-dape--source))
:args []
:stopOnEntry flan-dape-stop-at-entry)
"The `dape-configs' entry for a Flan program, without the adapter command.
Separate from the registration below so a user who wants a variant a
different adapter, extra launch arguments can start from this rather
than retype it.")
;;;###autoload
(defun flan-dape-register ()
"Add the `flan' entry to `dape-configs'.
Idempotent, so reloading this file does not stack duplicates."
(when (boundp 'dape-configs)
(setq dape-configs
(cons (cons 'flan (append (list 'command flan-dape-adapter)
(copy-sequence flan-dape-config)))
(assq-delete-all 'flan dape-configs)))))
;;;###autoload
(defun flan-debug ()
"Build the Flan file at point with debug info and start dape on it.
Equivalent to \\[dape] with the `flan' configuration, and exists so the
common case is one command rather than a config prompt."
(interactive)
(require 'dape)
(flan-dape-register)
;; Through `dape--config-eval', not `alist-get': `dape' takes a config whose
;; forms have already been evaluated — that is what \[dape] does after
;; reading one — and handing it the stored entry would pass the *list*
;; (flan-dape--binary (flan-dape--source)) to lldb as a program name.
(dape (dape--config-eval 'flan nil)))
;; Registered on load and again after dape loads, because either order
;; happens: a user may load this from their init before dape exists.
(with-eval-after-load 'dape (flan-dape-register))
(flan-dape-register)
;; The binding goes in here rather than in flan-mode.el so that this file is
;; the only thing that has to be loaded to get it, and flan-mode keeps working
;; for anyone who never installs dape. C-c C-g, for "go": every other letter
;; that suggests debugging is taken — C-c C-d is `flan-describe', C-c C-b is
;; `flan-break', which is the condition system's break loop and a different
;; thing entirely.
;;;###autoload
(with-eval-after-load 'flan-mode
(define-key (symbol-value 'flan-mode-map) (kbd "C-c C-g") #'flan-debug))
;;; --dev and --debug are different builds
;;
;; `flan dev' — what flan-dev.el connects to — builds with --dev: every
;; cross-function call goes through a cell so a redefinition can be installed,
;; and -rdynamic exports those cells. `flan build --debug' is a different
;; axis: -O0, DWARF, and no cells unless --dev is also passed.
;;
;; They compose, and `flan-dape-extra-flags' is where to say so, but they do
;; not share a process. M-x flan-debug launches its own program under lldb;
;; it does not attach to the one `flan dev' is running. Attaching to that one
;; would want lldb-dap's attach request and a pid, which is a further thing and
;; is not implemented here — said plainly rather than half-offered.
;;
;; What --dev costs the debugger is less than it sounds. A call site becomes a
;; load from a mutable global and an indirect call through the result, so the
;; callee is found at run time rather than bound at link time. Stack walking
;; is unaffected: the frame is laid out the same way and lldb reads it the
;; same way, so a backtrace through a cell still names the Flan caller and its
;; line. Stepping *into* a call is where it shows — `step' lands in whatever
;; the cell currently holds, which is the honest answer and occasionally not
;; the one on the screen, if the body was redefined since.
;;
;;; Reloading and breakpoints
;;
;; The first confusing thing anyone will hit, so it is written down rather
;; than discovered. What follows was measured with lldb against a --dev
;; --debug build of test/programs/reload.flan and a redefinition module built
;; by `flan reload'; none of it is inference.
;;
;; Each redefinition is a fresh .so that the program dlopens, and the cell is
;; then pointed at the new body. Nothing is ever dlclosed, so the old body is
;; still mapped, and every call site that has not gone through the cell again
;; still reaches it. There are therefore two live bodies, and what a
;; breakpoint does depends on how it was set.
;;
;; A breakpoint set by NAME follows the reload by itself. lldb re-resolves
;; name breakpoints against each module as it loads, so on the dlopen it prints
;; "1 location added to breakpoint 1" and then has two:
;;
;; 1: name = 'flan.bump', locations = 2, resolved = 2, hit count = 2
;; 1.1: where = host`flan.bump + 12 at reload.flan:34:3, ... hit count = 1
;; 1.2: where = v2.so`flan.bump, address = 0x00007ffff7fba1a0, ... hit count = 1
;;
;; Both fire, and both are correct — 1.1 is not stale, it is the body the old
;; call sites still run. That is `dape-breakpoint-global', which sets by name.
;;
;; A breakpoint set by FILE AND LINE does not follow, and the reason is not
;; that dape pinned it to an address. It stays at locations = 1 because the
;; redefinition module carries no line table for it to resolve against. Given
;; one it does follow: a pending breakpoint on a file the executable had never
;; heard of went from "no locations (pending)" to "1 location added" the moment
;; a .so with DWARF for that file was dlopened, and stopped with full source.
;; So the gap is exactly one missing thing, and nothing about dlopen.
;;
;; That missing thing, by name: `Emit.redefinition' takes a ~debug argument and
;; `Session.eval' does not pass it, so `flan reload' and the `flan dev' daemon
;; build modules without DWARF. Until they do, a reloaded body breaks by name
;; and shows disassembly instead of source, and a line breakpoint in the .flan
;; buffer silently stops firing after the first C-c C-c. lib/session.ml is the
;; dev loop's file and is not this one's to change.
;;
;; So, for now: debug with `dape-breakpoint-global' if you are also reloading,
;; and use line breakpoints for a program you are only running.
(provide 'flan-dape)
;;; flan-dape.el ends here

138
emacs/test-flan-dape.el Normal file
View File

@ -0,0 +1,138 @@
;;; test-flan-dape.el --- Drive a real dape session at a Flan program -*- lexical-binding: t; -*-
;; Run from the repository root, with flan on PATH and dape on the load path:
;;
;; emacs -Q --batch -L emacs -L /path/to/dape -l emacs/test-flan-dape.el
;;
;; Not part of `dune test'. It needs Emacs, dape, lldb-dap and a built flan
;; all at once, and wiring four optional things into the acceptance table
;; would make the table's failures mean less rather than more — the DWARF
;; itself is tested there, against LLVM and against lldb directly. What this
;; adds is the last link: that dape, driving lldb-dap, sets a breakpoint from
;; a .flan buffer, hits it, and reports Flan frames and locals.
;;
;; It exits non-zero and says why if any of that does not happen.
;;; Code:
(require 'flan-mode)
(require 'dape)
(require 'flan-dape)
(defvar flan-dape-test--program "test/programs/debug.flan")
(defvar flan-dape-test--failures 0)
(defun flan-dape-test--fail (fmt &rest args)
(setq flan-dape-test--failures (1+ flan-dape-test--failures))
(message "FAIL %s" (apply #'format fmt args)))
(defun flan-dape-test--ok (what)
(message " ok %s" what))
;; Batch Emacs has no idle loop, so every wait is an explicit pump.
(defun flan-dape-test--pump (pred secs)
(let ((deadline (+ (float-time) secs)))
(while (and (< (float-time) deadline) (not (funcall pred)))
(accept-process-output nil 0.05))
(funcall pred)))
(defun flan-dape-test--stopped ()
(dape--live-connection 'stopped t))
(unless (executable-find flan-dape-command)
(message "test-flan-dape: skipped (no %s on PATH)" flan-dape-command)
(kill-emacs 0))
(unless (executable-find flan-dape-adapter)
(message "test-flan-dape: skipped (no %s on PATH)" flan-dape-adapter)
(kill-emacs 0))
(setq dape-cwd-function (lambda () default-directory))
;; The breakpoint is set in the .flan buffer, by line, before anything is
;; built — which is the only way a person would ever set one, and the thing
;; that cannot work without a line table naming the .flan file.
(with-current-buffer (find-file-noselect flan-dape-test--program)
(unless (eq major-mode 'flan-mode)
(flan-dape-test--fail "%s did not open in flan-mode" flan-dape-test--program))
(unless (eq (key-binding (kbd "C-c C-g")) 'flan-debug)
(flan-dape-test--fail "C-c C-g is not bound to flan-debug in a Flan buffer"))
(goto-char (point-min))
(search-forward "(set (.heat c)")
(dape-breakpoint-toggle)
(let ((config (dape--config-eval 'flan nil)))
;; `dape--config-eval' is where the unquoted forms in `flan-dape-config'
;; turn into strings. If this is a list rather than a path, `flan-debug'
;; handed dape an unevaluated config and lldb would be given a program
;; named "(flan-dape--binary ...)".
(unless (stringp (plist-get config :program))
(flan-dape-test--fail ":program did not evaluate to a path: %S"
(plist-get config :program)))
(unless (zerop (call-process-shell-command (plist-get config 'compile) nil nil))
(flan-dape-test--fail "the configured build failed: %s"
(plist-get config 'compile))
(kill-emacs 1))
(flan-dape-test--ok "the config builds the program it points lldb at")
(dape config 'skip-compile)))
(if (not (flan-dape-test--pump #'flan-dape-test--stopped 60))
(flan-dape-test--fail "the session never stopped at the entry point")
(flan-dape-test--ok "lldb-dap launched and stopped at entry")
(dape-continue (flan-dape-test--stopped))
;; The entry stop has to clear before the next one counts as the breakpoint.
(flan-dape-test--pump (lambda () (not (flan-dape-test--stopped))) 5)
(if (not (flan-dape-test--pump #'flan-dape-test--stopped 60))
(flan-dape-test--fail "the breakpoint in %s never hit" flan-dape-test--program)
(let* ((conn (flan-dape-test--stopped))
(thread (car (dape--threads conn)))
(frames (plist-get thread :stackFrames))
(top (car frames)))
(flan-dape-test--ok "the breakpoint hit")
(unless (equal (plist-get top :name) "flan.tick")
(flan-dape-test--fail "the top frame is %S, wanted flan.tick"
(plist-get top :name)))
(unless (equal (plist-get (plist-get top :source) :name) "debug.flan")
(flan-dape-test--fail "the top frame's source is %S, wanted debug.flan"
(plist-get (plist-get top :source) :name)))
(unless (integerp (plist-get top :line))
(flan-dape-test--fail "the top frame has no line number"))
(flan-dape-test--ok
(format "frame %s at %s:%s" (plist-get top :name)
(plist-get (plist-get top :source) :name) (plist-get top :line)))
;; Locals, which dape fetches lazily — so they are asked for here.
(let ((seen nil) (done nil))
(dape-request
conn :scopes (list :frameId (plist-get top :id))
(lambda (body _err)
(dolist (scope (append (plist-get body :scopes) nil))
(dape-request
conn :variables
(list :variablesReference (plist-get scope :variablesReference))
(lambda (body _err)
(dolist (v (append (plist-get body :variables) nil))
(push (list (plist-get v :type) (plist-get v :name)
(plist-get v :value))
seen)))))
(setq done t)))
(flan-dape-test--pump (lambda () done) 15)
(accept-process-output nil 1.0)
(dolist (want '(("int" "n" "41") ("Cell *" "c" nil)))
(let ((hit (seq-find (lambda (v)
(and (equal (nth 0 v) (nth 0 want))
(equal (nth 1 v) (nth 1 want))
(or (null (nth 2 want))
(equal (nth 2 v) (nth 2 want)))))
seen)))
(if hit
(flan-dape-test--ok (format "%s %s = %s" (nth 0 hit) (nth 1 hit)
(nth 2 hit)))
(flan-dape-test--fail "no local %s of type %s; saw %S"
(nth 1 want) (nth 0 want) seen))))))))
(ignore-errors (dape-kill (dape--live-connection 'parent)))
(if (zerop flan-dape-test--failures)
(progn (message "flan-dape: all tests passed") (kill-emacs 0))
(message "%d failure(s)" flan-dape-test--failures)
(kill-emacs 1))
;;; test-flan-dape.el ends here