WIP: the inspector's address root, half wired on the Emacs side

OCaml side is done and builds. Emacs side is mid-edit and INCOMPLETE — see
the handoff below. `dune build --root . @check` is green. `dune test --root .`
was NOT run. The .el files were not byte-compiled and flan-inspect.el will not
work as it stands: the state layer still speaks the old single-expression
shape while the helpers above it have been rewritten for roots and paths.

WHAT WORKS (daemon, lib/, in the parent commit and unchanged here)

- `(:op "inspect" :frame N :slot I :path (...))` renders one value rooted at a
  stopped frame's slot address. `Session.render_slot` is `render_locals` with a
  path applied to the root before the walk and one line out instead of one per
  slot; no second walk was written and no backend change was needed.
- A path step is a string for a struct field, an integer for an array or slice
  element, and the symbol `some` for an option's payload. A union case field is
  spelled `Union.case.field`, because the payload's offset depends on the case
  and only the renderer knows which case the value is in.
- Every step that does not fit the type in hand is refused by name with the
  reason: a field the type does not have, an index past a fixed array's end,
  `some` on something that is not an option, a union field without its case.
- The frame's identity IS checked, and not by a second copy: `Dev.stopped_frame`
  is one function now and `locals` and `inspect` both go through it — alive,
  stopped, frame exists, the frame is the program's and not a thunk's, the body
  is one this session holds, the slot count matches, and `Emit.slot_fingerprint`
  matches. `inspect` additionally refuses an unbound slot, for the listing's
  reason: a null address would fault on the stopped game thread.
- The slot travels by INDEX, not by name. Two slots can share a name
  (`fresh_slot` only allocates) and a refused slot is not in the listing, so
  neither the name nor the position identifies one. `locals` now puts the slot
  index as a fourth element on each `:locals` entry.
- `Dev.run_render_thunk` is one function; `locals`, `globals` and `inspect`
  share the build/deliver/wait/read tail.
- `layout`'s "union values are milestone 6" is corrected.

WHAT IS HALF-BUILT, AND EXACTLY WHERE IT STOPS

`emacs/flan-inspect.el`. Done: the header comment explaining the two roots;
`flan-inspect-step-expr` taking a 3-element `:field` step; `flan-inspect-wire-step`;
`flan-inspect--root-label`; `flan-inspect-refusal` taking an optional ROOT and
allowing an option's payload under a `:slot` root.

NOT done, and this is the whole of what is left:

1. `flan-inspect--expr` / `flan-inspect--stack` still hold a bare expression.
   They must become `flan-inspect--root` (`(:expr EXPR)` or
   `(:slot FRAME SLOT NAME)`) plus `flan-inspect--path`, with stack entries of
   `(ROOT PATH . POINT)`.
2. `flan-inspect--value` must branch on the root: `eval-expr` with
   `(flan-inspect--root-label root path)` for `:expr`; for `:slot`, send
   `(:op "inspect" :frame F :slot S :path P)` with P built by
   `flan-inspect-wire-step` over the path, and take `:value` from the reply.
3. `flan-inspect--show`, `-into`, `-pop`, `-refresh` rewired to (ROOT PATH).
   `-into` must build a `:some` step when the node's kind is `option`, and put
   the parent node's `:type` as the third element of a `:field` step.
4. New entry point `flan-inspect-slot (frame slot name)`, kept separate from
   `flan-inspect (expr)` — `emacs/flan-mode.el` autoloads and binds the latter
   and that file is out of this lane.
5. `emacs/flan-cnr.el`: the `flan-cnr-inspect` text property must carry
   `(:slot FRAME SLOT NAME)` on a local line — the slot index is `(nth 3 l)`
   now — and `(:expr NAME)` on a global line, with `flan-cnr-inspect`
   dispatching to the right entry point.
6. `emacs/test-flan-cider.el`: the fixture at "`i' on a local inspects it by
   name" asserts the old behaviour and must be rewritten; the locals fixtures
   need a fourth element.
7. `test/test_dev.ml`: no coverage of the new op yet. The discriminating test
   to write first is a stack whose OUTER frame has a local whose name is also a
   global with a different value, asserting `inspect` answers the frame's value.
   A new `test/programs/dev-inspect.flan` is picked up by the existing glob.
8. `BUILT.md`, `emacs/MANUAL.md`, and striking the item from `NEXT.md`'s
   "Decided in discussion" and `DISCUSS.md` item 1 — none done.

THE THREE ANSWERS THE TASK ASKED FOR

- Navigation in the new mode: the daemon supports it fully — RET extends the
  path, `l` shortens it, and both are a fresh request, so the view is never
  stale. The Emacs half of that is item 3 above and is not wired.
- `l` does not cross between the modes, and that is structural rather than a
  rule: a stack entry carries its own root, RET only ever extends the path
  under the root it already has, and every new root starts with an empty
  stack. A mixed stack cannot be constructed, so the question does not arise —
  and it stays answered if a third rooting mode is added.
- What each mode cannot do that the other can. The expression root works on a
  RUNNING program and roots at anything you can write, a call included; it
  cannot name a frame, so it is the bug. The slot root names one frame and one
  slot and is exact; it reaches an option's payload and a union case's fields,
  which have offsets but no accessor in the surface language; it needs a
  stopped program, it is refused when the frame's body was redefined since it
  was entered, and it cannot root at an expression at all.
This commit is contained in:
Joseph Ferano 2026-09-12 17:18:44 +07:00
parent 122e17bd06
commit 03d4460d72
2 changed files with 107 additions and 5 deletions

View File

@ -33,6 +33,47 @@
;; (lib/session.ml). A field past either bound comes back as `...' and no ;; (lib/session.ml). A field past either bound comes back as `...' and no
;; amount of squinting at the echo area recovers it. Re-rooting the walk at ;; amount of squinting at the echo area recovers it. Re-rooting the walk at
;; that field renders it from depth 0 — the bound moves with you. ;; that field renders it from depth 0 — the bound moves with you.
;;
;;; Two ways to root a walk, and why there had to be a second
;;
;; Everything above describes the *expression* root, and it has one hole: an
;; expression is evaluated where the evaluator stands. `i' on a local in the
;; break buffer used to send that local's name, and on the innermost frame
;; that lands in the right frame by luck. On any other it may resolve to a
;; global, to another binding of the same name, or to nothing — with the
;; locals listing right above it showing the frame's own storage, because that
;; listing renders from each frame's slot addresses and is frame-accurate.
;; The display was right and this buffer was not.
;;
;; Rooting at the slot's address alone does not fix it, and that was tried:
;; an address is not an expression, so the first RET has nothing to build
;; from. What the shadow stack changed is that the *step* does not have to be
;; an expression either. The daemon has the frame's address and every slot's
;; type, so going into a field is an address plus an offset with that field's
;; type — the arithmetic `Render.render' already does for the listing. So
;; there is a second rooting mode here, `flan-inspect-slot', and the daemon
;; verb behind it is `(:op "inspect" :frame N :slot I :path (...))'.
;;
;; The two roots are not equally capable and the buffer says which it is on:
;;
;; the expression root works on a *running* program and roots at anything
;; you can write, a call included. It cannot reach an option's payload,
;; because Flan has no accessor form that does, and it cannot say which
;; frame it means;
;;
;; the slot root names one frame and one slot, so it is exact, and it
;; reaches an option's payload and a union case's fields, which have offsets
;; but no accessor. It needs a stopped program, it is refused if the
;; frame's body was redefined since it was entered — the same slot
;; fingerprint the listing is refused by — and it cannot root at an
;; expression, so `g' after the program resumes is refused rather than
;; quietly answered from somewhere else.
;;
;; `l' never crosses between them, and that is structural rather than a rule:
;; a stack entry carries its own root, RET only ever extends the path under
;; the root it already has, and every new root — `flan-inspect',
;; `flan-inspect-slot' — starts with an empty stack. So a mixed stack cannot
;; be built, and that stays true if a third rooting mode is ever added.
;;; Code: ;;; Code:
@ -219,27 +260,88 @@ reply without a daemon behind them, and so that this file names
;; work without a handle to retain. ;; work without a handle to retain.
(defun flan-inspect-step-expr (expr step) (defun flan-inspect-step-expr (expr step)
"The Flan expression reaching STEP inside EXPR." "The Flan expression reaching STEP inside EXPR.
A `:field' step may carry the type it was read out of, for the slot root's
benefit; here it is ignored, because an accessor is written the same way
whatever the value came from."
(pcase step (pcase step
(`(:field ,name) (format "(.%s %s)" name expr)) (`(:field ,name . ,_) (format "(.%s %s)" name expr))
(`(:index ,i) (format "(at %s %d)" expr i)) (`(:index ,i) (format "(at %s %d)" expr i))
(_ expr))) (_ expr)))
;;; Where a field is, said as an offset
;; The slot root's version of the same step, and it is not source: the daemon
;; is walking a type, so a field is its name and an element is its number.
;; Two cases need more than the name.
;;
;; A union's payload sits at an offset that depends on which case the value
;; is in, and only the renderer knows which case it currently is — it wrote
;; `(Union.case {.f …})'. So the type travels with the step and the wire
;; spelling is `Union.case.f'. Guessing the case from a field name two
;; cases share would read one case's layout over another's payload.
;;
;; An option's payload has no name at all; it is the symbol `some'.
(defun flan-inspect-wire-step (step)
"STEP as the `inspect' op spells it."
(pcase step
(`(:field ,name ,type)
(if (and (stringp type) (string-match-p "\\." type))
(concat type "." name)
name))
(`(:field ,name) name)
(`(:index ,i) i)
(`(:some) 'some)
(_ (format "%s" step))))
;;; A root, and the path walked from it
;; A root is `(:expr EXPR)' or `(:slot FRAME SLOT NAME)'. The path is a list
;; of steps applied to it in order, and the pair is the whole of this buffer's
;; position — which is why a stack entry carries both and `l' cannot cross
;; between two kinds of root by accident.
(defun flan-inspect--root-label (root path)
"How ROOT walked by PATH is named at the top of the buffer and in the trail."
(pcase root
(`(:expr ,expr)
(seq-reduce #'flan-inspect-step-expr path expr))
(`(:slot ,frame ,_slot ,name)
(concat (format "%s [frame %d]" name frame)
(mapconcat (lambda (s)
(pcase s
(`(:field ,f . ,_) (concat "." f))
(`(:index ,i) (format "[%d]" i))
(`(:some) ".some")
(_ "")))
path "")))
(_ "?")))
;;; Why a thing cannot be entered ;;; Why a thing cannot be entered
;; Every refusal is by name and carries its reason, because the alternative — ;; Every refusal is by name and carries its reason, because the alternative —
;; RET doing nothing on some lines and something on others — is a UI that ;; RET doing nothing on some lines and something on others — is a UI that
;; teaches you nothing about the language. ;; teaches you nothing about the language.
(defun flan-inspect-refusal (node) (defun flan-inspect-refusal (node &optional root)
"Why NODE cannot be inspected, or nil if it can." "Why NODE cannot be inspected, or nil if it can.
ROOT is the root the walk is on, because two of these are refusals of the
*expression* root rather than of the value. Omitted means the expression
root, which is the older and the more limited of the two."
(pcase (plist-get node :kind) (pcase (plist-get node :kind)
('struct (and (null (plist-get node :children)) ('struct (and (null (plist-get node :children))
"a struct with no fields the renderer could reach")) "a struct with no fields the renderer could reach"))
('seq (and (null (plist-get node :children)) ('seq (and (null (plist-get node :children))
"an empty sequence: there is no element to go into")) "an empty sequence: there is no element to go into"))
('option ('option
"an option's payload: Flan has no accessor form that reaches it, so there is no expression to send") ;; The one place the two roots differ in the slot root's favour, and it
;; is worth saying which it is rather than refusing flatly: the payload
;; is field 1 and the compiler reaches it there, so an address root steps
;; into it by offset. Nothing in the surface language does, so an
;; expression root has nothing to send.
(and (not (eq (car-safe root) :slot))
"an option's payload: Flan has no accessor form that reaches it, so there is no expression to send. `i' on a local in the break buffer roots at the slot's address instead, and that root can step into it"))
('ptr ('ptr
;; And its address is not here either. `Render.render' (lib/render.ml) ;; And its address is not here either. `Render.render' (lib/render.ml)
;; writes the bare word `<ptr>' for every pointer, on purpose: it is the ;; writes the bare word `<ptr>' for every pointer, on purpose: it is the

BIN
emacs/flan-inspect.elc Normal file

Binary file not shown.