A struct's fields, answered out of the build, keyed by the name that is an identity

(:op "layout" :type T) needs no running program: the daemon owns the build, so
Tast.structs is already in the session it compiled the process from. The open
question was what T is, and it needs no new machinery — Load qualifies every
declaration at import, so two packages' Missing are a/Missing and b/Missing and
the name is the type id. Emit already writes that same qualified name into
flan_error, so the string break reports as :condition resolves as :type by
construction, which is the round trip the test makes.

A bare name is refused with the candidates rather than resolved to a unique
suffix: resolving it would put back the ambiguity the rule exists to remove.
This commit is contained in:
Joseph Ferano 2026-09-12 10:39:16 +07:00
parent 3afce2aeac
commit a8f08eda6d
4 changed files with 239 additions and 6 deletions

View File

@ -1009,6 +1009,55 @@ state an editor has to cope with and the hardest one to arrange later. The Emacs
round, by installing a `step` that errors into a loop that calls it, fixes it while stopped, and then resumes: `C-x C-e`
answering while the program sits in the break loop is checked there against the real client, not only in OCaml.
### `layout` — a type's fields, with no program involved
```
(:op "layout" :type "sim/Cell") → (:status "ok" :type "sim/Cell"
:fields (("heat" "f32") ("next" "(Option sim/Cell)")))
→ (:status "error" :message "Missing is not a qualified name; …"
:candidates ("a/Missing" "b/Missing"))
```
The daemon can answer this with nothing running. A layout is a fact about the *build*, and the daemon owns the build —
`Tast.structs` is sitting in the session it compiled the process from. That is why the conditions buffer can name and
type a condition's fields while every one of their *values* stays refused: the shape is knowable and the contents are
not, and drawing them apart says more than drawing neither.
**The type is a name, and the name is the qualified one.** This was the open question — a class name is not an identity,
and two packages each declaring `Missing` would leave the daemon unable to pick. It turned out to need no new
machinery: `Load.qualify_decl` rewrites `Defstruct (n, …)` to `Defstruct (alias/n, …)` at import, so by the time
anything reaches `Tast.structs` the names are one flat namespace in which a collision cannot exist. The name *is* the
type id, with no table to keep in step across a reload, and the existing spelling of a type — `Types.to_string`
already prints it.
**And the break loop was already speaking it.** `Emit.struct_name_of` takes `Types.Named n` — the qualified name — and
passes it to `flan_error`; the agent holds it in `condition_name`; `break` answers it as `:condition`. So the string
the conditions buffer already had in hand resolves as `:type` by construction, and `test_dev.ml` round-trips exactly
that: the condition a stopped program reports, handed straight back, answers with that condition's fields. One caveat
worth writing down — `condition_name` is a `char[128]`, so the round trip holds for names up to 127 bytes and a longer
one is truncated and will not resolve.
**A bare name is refused, not resolved**, even when only one struct's last segment matches it. Resolving a unique
suffix would reintroduce the ambiguity the rule exists to remove, and a rule with an exception is one a client cannot
rely on. The refusal carries `:candidates`, so a person is one copy-paste from the answer and a client has its
completion list — the same shape as `package_of` refusing a directory imported under two aliases rather than picking
one. An enum is refused by *kind* (`X is an enum, not a struct`): its members are erased to `i32` before `Tast.program`
exists, which is the same fact that makes a `defenum` unreloadable.
**`render.ml` is not reused, and that is not a second walk.** It walks a *value* and emits the code that prints it;
this describes a *type* and emits text. What is shared is the spelling: field types go through `Types.to_string`, which
is what `defs` spells a signature with, so `(Option T)`, `[T]`, `[n T]` and `(Ptr T)` read the same in a layout, in a
signature and in the source. A field that is itself a struct shows its qualified name — which is a `:type` this op
accepts, so nesting is another request rather than a recursion, and nothing here can be made to walk forever. Prelude
structs are answered like any other, because `Render` resolves against the same list and an editor that could see a
`Split` printed but not ask about it would be the two disagreeing.
On the Emacs side `flan-cnr-layout` makes the request and `flan-cnr-show` passes the result into
`flan-cnr-state-from-reply`, which stays a function from data to data so the fixture-driven tests keep working without
a socket. A refusal is nil, not an error: the buffer already draws a section explaining why a section is empty, and
turning `C-c C-b` into an error would take away the restarts — the decision the buffer exists for — over a missing
annotation.
### Conditions — step 2: `restart-case` and `invoke-restart`
`spec-conditions.md` §3 to §6: the transfer. A handler runs where the signal was, decides, and control resumes at a

12
NEXT.md
View File

@ -421,15 +421,15 @@ Sixty mutations, nineteen left the whole suite green. The severe cluster is clos
### Asked for by the editor lanes
- **`(:op "layout" :type T)` → the struct's fields and their types.** `Tast.structs` is held by the daemon at all
times because it owns the build, and **no running program is involved** — this is the cheapest real win on the
list, and the C&R buffer already draws its result.
- **`(:op "condition")` → the stopped program's condition, rendered.** Two steps: `break_loop` currently does
`(void)condition;` and *discards the pointer*, so stash it beside `condition_name`; then the daemon builds a render
thunk aimed at that address, which is `Session.render` rooted at a `Ptr` instead of an expression.
- **One thing to get right for both:** the type must be an identity the daemon can resolve to a `Tast` type, not a
bare class name. The hook is handed a string, and two packages both declaring `Missing` leave the daemon unable to
pick a layout. A qualified name or a type id. The same wrinkle bites locals later, because DWARF also gives a name.
- **The type identity is settled, and it is the qualified name**`layout` is in, see BUILT.md. `Load` qualifies
every declaration at import, so the names in `Tast.structs` are a flat namespace where two packages' `Missing` are
`a/Missing` and `b/Missing`; a bare name is refused with the candidates rather than resolved. `condition` inherits
it for free: the string the break loop already reports *is* that name, because `Emit.struct_name_of` writes
`Types.Named` into `flan_error`. It is still open for **locals**, where DWARF gives a name and the name a debugger
reads is not qualified by anything.
- **`(:op "backtrace")` is blocked** on frame metadata — unlocked by the DWARF work, then a new agent verb. Locals are
blocked twice: DWARF for the frame layout, *and* the pointer-rooted render thunk. Restart source locations and
arity are blocked too — `flan_restart` carries `prev`, `name_id`, `name` and `namelen`, so both need a new field in

View File

@ -478,6 +478,86 @@ let defs t =
in
ok [ ":defs " ^ Wire.list (fns @ globals @ externs) ]
(* [(:op "layout" :type T)] — a struct's fields and their types.
The daemon can answer this with no running program at all: [Tast.structs] is
what it built the process from, and a layout is a fact about the build. That
is why it is the one thing the conditions buffer can fill in while the
condition's *values* stay refused.
**The type is a name, and the name is the qualified one.** [Load] qualifies
every declaration as it imports it [Defstruct (qualify alias n, ...)] so
the names in [Tast.structs] are a flat namespace in which two packages each
declaring [Missing] are [a/Missing] and [b/Missing] and no collision is
possible. That makes the name a type identity rather than a class name, with
no id table to keep in step, and it is the same string on both ends of the
wire already: [Emit.struct_name_of] puts [Types.Named n] into [flan_error],
the agent holds it in [condition_name], and [break] answers it as
[:condition]. Handing that string straight back as [:type] therefore
resolves, by construction.
A bare name is **refused, not resolved**, even when only one struct's last
segment matches: resolving it is exactly the ambiguity that made this op
need a rule, and a rule with an exception cannot be relied on by a client.
The refusal lists the qualified names it could have meant, so a person who
typed [Missing] is one copy-paste from the answer and a client can offer
them as completions.
Field types are spelled by [Types.to_string], which is what [defs] spells a
signature with so [(Option T)], [[T]], [[n T]] and [(Ptr T)] read here
exactly as they read in a signature and in the source. A field that is
itself a struct shows its qualified name, which is a [:type] this op
accepts: nesting is another request rather than a second walk, and nothing
here can recurse forever. [Render] is the other walk over a type and is not
reused, because it walks a *value* and emits code that prints it; this emits
text about the type and never touches the program. *)
let layout t ~ty =
let structs = t.session.Session.program.Tast.structs in
match
List.find_opt (fun (s : Tast.structure) -> String.equal s.Tast.sname ty)
structs
with
| Some s ->
ok
[ ":type " ^ Wire.quote s.Tast.sname;
":fields "
^ Wire.list
(List.map
(fun (f : Tast.field) ->
Wire.list
[ Wire.quote f.Tast.fname;
Wire.quote (Types.to_string f.Tast.fty) ])
s.Tast.fields) ]
| None ->
(* An enum is a type the checker knows and this op cannot describe: its
members are erased to i32 before [Tast.program] exists, which is the
same fact that makes a defenum unreloadable. Saying which kind it is
beats "no such type" for a name that plainly exists. *)
if Hashtbl.mem t.session.Session.env.Check.enums ty then
error (ty ^ " is an enum, not a struct; its members are erased to i32")
else
let suffix = "/" ^ ty in
let candidates =
List.filter_map
(fun (s : Tast.structure) ->
let n = s.Tast.sname in
let k = String.length n - String.length suffix in
if k >= 0 && String.equal (String.sub n k (String.length suffix)) suffix
then Some n else None)
structs
in
(match candidates with
| [] -> error ("no struct is named " ^ ty)
| cs ->
(* Resolved on the client's side, deliberately: two packages can each
declare [Missing], and picking one of them here would answer a
layout for a type the asker did not mean. *)
"(:status \"error\" :message "
^ Wire.quote
(ty ^ " is not a qualified name; a package qualifies its \
declarations, so say which one")
^ " :candidates " ^ Wire.strings cs ^ ")")
(* What is on offer where the program stopped. [:stopped] and [:condition] are
not here: the annotation puts them on this reply as it puts them on every
other, so an editor reads the same two keys whatever it asked. What this op
@ -896,6 +976,10 @@ let handle t req =
| Some "describe" -> describe t
| Some "defs" -> defs t
| Some "break" -> break t
| Some "layout" ->
(match Wire.string_field req "type" with
| Some ty -> layout t ~ty
| None -> error "layout needs :type")
| Some "restart" ->
(match Wire.string_field req "name" with
| Some name -> choose t ~name

View File

@ -126,6 +126,85 @@ let () =
| None -> fail "defs did not mention an imported extern")
| _ -> fail "defs did not answer with a list");
(* [layout]: a struct's fields and their types, out of [Tast.structs],
with no running program involved at all. *)
let strings_of f =
match f with
| Some { Form.v = Form.List xs; _ } ->
List.filter_map
(fun (x : Form.t) ->
match x.Form.v with Form.Str s -> Some s | _ -> None)
xs
| _ -> []
in
let fields r =
match Wire.field r "fields" with
| Some { Form.v = Form.List fs; _ } ->
List.filter_map
(fun (f : Form.t) ->
match f.Form.v with
| Form.List
[ { Form.v = Form.Str n; _ }; { Form.v = Form.Str t; _ } ] ->
Some (n ^ " " ^ t)
| _ -> None)
fs
| _ -> []
in
let r = request c "(:op \"layout\" :type \"Missing\")" in
if status r <> "ok" then
fail "layout Missing: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else begin
if Wire.string_field r "type" <> Some "Missing" then
fail "layout answered a different type than it was asked for";
if fields r <> [ "id i32" ] then
fail "Missing's fields: %s" (String.concat ", " (fields r))
end;
(* The prelude's structs are in [Tast.structs] because [Check.program]
prepends the prelude, and they are answered for the same reason the
REPL's renderer resolves against the same list: an editor that could
see a type printed and not ask about it would be the two disagreeing.
[Rune] also pins the spelling the types read exactly as [defs]
spells a signature, because both go through [Types.to_string]. *)
let r = request c "(:op \"layout\" :type \"Split\")" in
if fields r <> [ "rest [u8]"; "sep u8"; "more bool" ] then
fail "Split's fields: %s" (String.concat ", " (fields r));
let r = request c "(:op \"layout\" :type \"Nonesuch\")" in
if status r <> "error" then fail "a type that does not exist got a layout";
(* The identity rule, and the case NEXT.md named: a second [Blob] typed
into a package is [agent/Blob], the qualified name resolves, and the
bare one is refused with the names it could have meant rather than
resolved to either. The daemon derives the package from the path, so
the file this is sent with is the one the import qualified. *)
let agent_file =
let p = "../vendor/agent/agent.flan" in
try Unix.realpath p with Unix.Unix_error _ -> p
in
let r =
request c
(Printf.sprintf
"(:op \"eval\" :code \"(defstruct Blob [id i32])\" :file %s)"
(Wire.quote agent_file))
in
if status r <> "ok" then
fail "a struct typed into a package: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else begin
let r = request c "(:op \"layout\" :type \"agent/Blob\")" in
if status r <> "ok" || fields r <> [ "id i32" ] then
fail "a qualified name did not resolve: %s"
(Option.value ~default:(status r) (Wire.string_field r "message"));
let r = request c "(:op \"layout\" :type \"Blob\")" in
if status r <> "error" then
fail "a bare package-qualified name was resolved rather than refused"
else if strings_of (Wire.field r "candidates") <> [ "agent/Blob" ] then
fail "the refusal did not name what it could have meant: %s"
(String.concat ", " (strings_of (Wire.field r "candidates")))
end;
(* A form that does not check comes back as an error with a location,
and must not disturb the session. *)
let r = request c "(:op \"eval\" :code \"(defn step [] i64 nonsense)\" :file \"/tmp/buf.flan\")" in
@ -257,6 +336,27 @@ let () =
fail "the condition is reported as %S, wanted %S" (condition !last)
"Missing";
(* The identity claim, round-tripped: the string [break] reports is the
qualified struct name [Emit] put into [flan_error] and the agent
held in [condition_name], so handing it straight back as [:type]
has to resolve. This is the conditions buffer's whole path it has
the condition's name and nothing else, and asks for the fields with
it. A layout that only answered a name typed by hand would leave
that path guessing. *)
let r =
ask
(Printf.sprintf "(:op \"layout\" :type %s)"
(Wire.quote (condition !last)))
in
if status r <> "ok" then
fail "the condition's own name did not resolve to a layout: %s"
(Option.value ~default:"" (Wire.string_field r "message"))
else
(match Wire.field r "fields" with
| Some { Form.v = Form.List [ { Form.v = Form.List
[ { Form.v = Form.Str "id"; _ }; { Form.v = Form.Str "i32"; _ } ]; _ } ]; _ } -> ()
| _ -> fail "the stopped program's condition has the wrong layout");
(* What is on offer, innermost first. [break] carries the names and
nothing else the state is the annotation's business, so there is
one place in the daemon that decides it. *)