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
;; 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.
;;
;;; 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:
@ -219,27 +260,88 @@ reply without a daemon behind them, and so that this file names
;; work without a handle to retain.
(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
(`(:field ,name) (format "(.%s %s)" name expr))
(`(:field ,name . ,_) (format "(.%s %s)" name expr))
(`(:index ,i) (format "(at %s %d)" expr i))
(_ 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
;; 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
;; teaches you nothing about the language.
(defun flan-inspect-refusal (node)
"Why NODE cannot be inspected, or nil if it can."
(defun flan-inspect-refusal (node &optional root)
"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)
('struct (and (null (plist-get node :children))
"a struct with no fields the renderer could reach"))
('seq (and (null (plist-get node :children))
"an empty sequence: there is no element to go into"))
('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
;; 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

BIN
emacs/flan-inspect.elc Normal file

Binary file not shown.