A union's fields have no accessor, so RET refuses them where none exists

The capability lists were written before the code held the line they claim.
Under an expression root, RET on a field of a union built `(.at s)' and sent
it, and the checker refused it — "a union's fields belong to a case ... they
are reached by (match ...)". A refusal from the far end of a socket is exactly
what this buffer's own comment says not to do: every refusal is by name, here,
with the reason, because RET working on some lines and erroring on others
teaches nothing about the language.

It is a refusal of the *parent* and not of the value at point, which is why it
is not in `flan-inspect-refusal': a struct field that merely holds a union is
an ordinary accessor and has to stay enterable. It is a field of the union
itself that cannot be written. The two cases are one test each.

The slot root steps into it by offset and is unaffected, which is the
difference the manual now claims and the tests now show.

`lib/dev.ml' cited DISCUSS.md item 1 as a hole; item 1 is the answer now, so
it cites BUILT.md instead. And the item 1 stub is two sentences and a pointer
— everything else in it is in BUILT.md verbatim, and DISCUSS.md's own header
says nothing in it is a decision.
This commit is contained in:
Joseph Ferano 2026-09-12 20:40:02 +07:00
parent 0389c2282c
commit 306fc88094
4 changed files with 55 additions and 14 deletions

View File

@ -10,19 +10,9 @@ Settled decisions live in `NEXT.md`. Reasons for what already exists live in `BU
## 1. `i`, the inspector, and the frame it cannot see — answered and built
Option 2 was taken: the inspector has a second rooting mode, at a frame and a slot index. `BUILT.md`'s "Two ways to
root a walk" says what each root can and cannot do and why both are kept. The number stays here because other files
cite these by number; the question itself is no longer open.
One thing the original entry recorded turned out to be half right, and is worth keeping here rather than deleting with it. "Root the inspector
at the slot's address does not work, because an address is not an expression and the first `RET` has nothing to build
from" — the premise is true and the conclusion was wrong. What changes it is that the *step* need not be an expression
either: the daemon holds the frame's address and every slot's type, so a field is an address plus an offset with that
field's type, which is the arithmetic the locals listing already does. The navigation objection was to rooting at an
address while still stepping by source.
`l` crossing between the modes was listed as a cost of option 2 and it is not one. A stack entry carries its own root
and `RET` only extends the path under the root it already has, so a mixed stack cannot be built at all.
Answered, and built as option 2. `BUILT.md`'s "Two ways to root a walk, and why neither subsumes the other" is where
it lives now, including the correction to what this entry said about rooting at an address. The number is kept because
other files cite these by number; nothing here is open.
## 2. Annotating the IR and the disassembly with the source

View File

@ -623,6 +623,25 @@ root, which is what makes a mixed stack unconstructible."
(unless step (user-error "flan: nothing to inspect on this line"))
(let ((why (flan-inspect-refusal node flan-inspect--root)))
(when why (user-error "flan: %s" why)))
;; A union case's field, under an expression root. This is a refusal of
;; the *parent* and not of the value at point, which is why it is here and
;; not in `flan-inspect-refusal\=': a struct field that happens to hold a
;; union is reached by an ordinary accessor and must stay enterable; it is
;; a field *of the union itself* that has no accessor. `(match ...)\=' is
;; how a union is opened in the language, and it binds names rather than
;; producing a value to send, so there is nothing to build here. The
;; renderer wrote the head as `Union.case\=', which is the one type spelling
;; with a dot in it — a package qualifies with a slash.
(let ((ty (plist-get flan-inspect--node :type)))
(when (and (not (eq (car-safe flan-inspect--root) :slot))
(stringp ty)
(string-match-p "\\." ty))
(user-error
"flan: %s"
(concat "a union case's field: it is reached by (match ...) in the "
"language, not by an accessor, so there is no expression to "
"send. `i' on a local in the break buffer roots at the frame's "
"slot instead, and that root steps into it by offset"))))
;; The line carries the step that names the field; what the wire needs
;; beyond the name is the type it is a field *of*, and that is this
;; buffer's own node — the parent of the one at point. A union's payload

View File

@ -457,6 +457,37 @@ unwind would send the next one to a daemon that is not there."
(test-flan--check "an element is its number"
(equal (flan-inspect-wire-step '(:index 2)) 2))
;; And the other half of that pair: under an *expression* root there is no
;; accessor to send, so RET refuses there rather than sending `(.at s)' for
;; the checker to reject. A union's fields are reached by `(match ...)' in
;; the language, which binds names rather than producing a value. It is a
;; refusal of the parent, not of the value at point — a struct field that
;; merely *holds* a union is an ordinary accessor and stays enterable.
(let ((buf (test-flan--inspect "s" "(Shape.circle {.at (V {.x 1 .y 2})})")))
(with-current-buffer buf
(goto-char (point-min))
(flan-inspect-next)
(test-flan--check "an expression root refuses a union case's field"
(string-match-p
"reached by (match"
(or (test-flan--caught #'flan-inspect-into) "")))))
(let ((asked nil))
(let ((flan-inspect-request-function
(lambda (form) (push (plist-get form :code) asked)
(list :status "ok"
:value "(Cell {.id 1 .s (Shape.circle {.at (V {.x 1 .y 2})})})")))
(flan-inspect-buffer " *test-inspect*"))
(when (get-buffer " *test-inspect*") (kill-buffer " *test-inspect*"))
(save-window-excursion
(flan-inspect--show '(:expr "c") nil)
(with-current-buffer " *test-inspect*"
(goto-char (point-min))
(flan-inspect-next) (flan-inspect-next) ; .s, which holds the union
(test-flan--check "but a struct field that merely holds one is enterable"
(progn (flan-inspect-into) (equal (car asked) "(.s c)")))))))
(test-flan--slot
0 1 "s"
(lambda (form)

View File

@ -921,7 +921,8 @@ let locals t ~frame =
refused) ]))
(* [(:op "inspect" :frame N :slot I :path (...))] — the inspector's second
rooting mode, and the answer to the hole [DISCUSS.md] item 1 named.
rooting mode. [BUILT.md]'s "Two ways to root a walk" says what each root
can and cannot do; this is the half that names a frame.
[i] in the break buffer used to send a local's *name* to be evaluated as an
expression. On the innermost frame that happens to be right; on any other