From a8f08eda6dac0814bd4108845fc26ab4266cfd42 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:39:16 +0700 Subject: [PATCH 1/3] A struct's fields, answered out of the build, keyed by the name that is an identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (: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. --- BUILT.md | 49 +++++++++++++++++++++++ NEXT.md | 12 +++--- lib/dev.ml | 84 +++++++++++++++++++++++++++++++++++++++ test/test_dev.ml | 100 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 6 deletions(-) diff --git a/BUILT.md b/BUILT.md index 1fe5847..cb3c77b 100644 --- a/BUILT.md +++ b/BUILT.md @@ -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 diff --git a/NEXT.md b/NEXT.md index 4f2b851..10cb2a2 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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 diff --git a/lib/dev.ml b/lib/dev.ml index fac29e9..bd6c7d4 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -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 diff --git a/test/test_dev.ml b/test/test_dev.ml index 50ce32f..6d83f6d 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -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. *) From 404b8559c5d288eae8097576fc151c1f4b7cbc7e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:39:22 +0700 Subject: [PATCH 2/3] The conditions buffer names and types the fields it used to refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-c C-b asks layout with the condition's own name and draws the fields under it. The values stay refused, by name, because the shape of a condition is a fact about the build and its contents are a fact about the stopped frame — and only one of those is knowable today. A layout the daemon refuses is nil rather than an error: the buffer already draws a section saying why one is empty, and failing the whole command would take away the restarts over an annotation. --- emacs/MANUAL.md | 14 ++++++++++++ emacs/flan-cnr.el | 48 +++++++++++++++++++++++++++++++++------- emacs/test-flan-cider.el | 44 ++++++++++++++++++++++++++++++++++++ emacs/test-flan-dev.el | 11 +++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index 43a6cf3..a378fb9 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -160,6 +160,20 @@ silently omitted. **`C-c C-M-b`** is the same choice as a quick one-key prompt, when you already know which restart you want and do not need the buffer. +**The condition's fields are named and typed, and have no values.** Under the +condition you get the struct it is — `:path string`, `:tried i32` — because the +daemon compiled the program and knows what that type looks like without asking +the program anything. What is beside each field is a note saying the value is +not available, not a blank: a value lives in the stopped frame, and nothing yet +hands the break loop's condition pointer back. Knowing the shape is still worth +having — it tells you whether the field you were about to blame is a field of +this condition at all. + +If that section says it could not resolve the name, read it: a package +qualifies what it declares, so two packages' `Missing` are `a/Missing` and +`b/Missing`. The daemon refuses a bare name and says what it could have meant +rather than picking one. + After you choose, the program carries on from the restart. It never unwound, so everything it had is still there. diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 9e0d8c6..b95038c 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -70,7 +70,7 @@ from fixtures, and so `flan-dev.el' is named in one place.") (defconst flan-cnr-unavailable '((layout - . "which struct the class name refers to [needs a daemon op: `layout'. The field names and types are in `Tast.structs', which the daemon holds because it owns the build — no running program is involved. It is the one piece of this buffer that is a day's work on one side of the socket]") + . "the daemon could not resolve this condition's name to a struct [the `layout' op answers out of `Tast.structs' and needs a *qualified* name. A package qualifies what it declares, so two packages' `Missing' are two names and neither is `Missing'; the daemon refuses a bare one and lists what it could have meant rather than picking. A name it cannot place at all is a program built from source this daemon did not compile]") (value . "the break loop is handed the condition as an opaque pointer, and `break_loop' currently discards it; nothing at run time can render a value whose type it does not know [needs an agent verb: `condition', to stash and hand back the pointer, and a daemon-built render thunk aimed at it]") (site @@ -382,20 +382,51 @@ list already says it." "What a stopped Flan program is offering." (setq buffer-read-only t)) -(defun flan-cnr-state-from-reply (reply) +(defun flan-cnr-state-from-reply (reply &optional fields) "The buffer's state, out of a `break' REPLY. +FIELDS is the condition's layout, if it was asked for and answered — a list of +(NAME TYPE VALUE), where VALUE is nil because no running program was consulted +to get it. + Everything this cannot fill in is left nil deliberately: the renderer draws a section saying why rather than leaving one out, and a section that is absent -cannot be told from one that is empty." +cannot be told from one that is empty. + +Takes the layout rather than fetching it, so this stays a function from data to +data and the fixture-driven tests can drive it without a socket." (list :condition (plist-get reply :condition) :restarts (plist-get reply :restarts) - ;; Both nil deliberately: `break' answers a class name and a list of - ;; restart names, and nothing else exists to put here yet. The renderer - ;; draws a section saying why rather than leaving one out. - :fields nil + :fields fields + ;; Still nil deliberately: nothing here is attached to the stopped + ;; program's frames, and the renderer says so by name. :stack nil :locals nil)) +(defun flan-cnr-layout (type) + "The fields of TYPE, as the renderer wants them, or nil. +`layout' is answered out of the daemon's own `Tast.structs' — it owns the +build — so this costs no round trip to the program and works while it is +stopped, which is the only moment this buffer exists in. + +TYPE is the string `break' reported as `:condition', and that string is the +qualified struct name the compiler put into the error: `Emit' takes it from +`Types.Named', the agent holds it in `condition_name', and the daemon looks it +up by the same name. So it resolves without anything here knowing what package +it came from. + +A refusal is not an error here: nil means the layout section draws the reason +it is empty, which is the degradation the buffer is already built for." + (when (and type (not (string-empty-p type))) + (let ((r (funcall flan-cnr-request-function (list :op "layout" :type type)))) + (when (equal (plist-get r :status) "ok") + (mapcar (lambda (f) + ;; The value is nil, and that is the honest half: the shape + ;; of a condition is a fact about the build, its contents + ;; are a fact about the stopped frame, and only the first is + ;; knowable today. + (list (nth 0 f) (nth 1 f) nil)) + (plist-get r :fields)))))) + ;;;###autoload (defun flan-cnr-show () "Show what the stopped program is offering, in a buffer. @@ -410,7 +441,8 @@ walk from a running program." (let ((buf (get-buffer-create flan-cnr-buffer))) (with-current-buffer buf (unless (derived-mode-p 'flan-cnr-mode) (flan-cnr-mode)) - (flan-cnr--render (flan-cnr-state-from-reply r))) + (flan-cnr--render + (flan-cnr-state-from-reply r (flan-cnr-layout (plist-get r :condition))))) (pop-to-buffer buf) buf))) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index 7dfbfac..5f3698a 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -465,6 +465,50 @@ (test-flan--check "and n goes back to abort" (get-text-property (point) 'flan-cnr-abort))) + +;; The `layout' op, from this side: the condition's own name goes out as +;; `:type' and comes back as fields with no values. Two requests are made for +;; one `C-c C-b' — `break' then `layout' — so the stub records both. +(let ((asked nil)) + (let ((flan-cnr-request-function + (lambda (form) + (push form asked) + (pcase (plist-get form :op) + ("break" '(:status "ok" :stopped t :condition "sim/Missing" + :restarts ("retry"))) + ("layout" '(:status "ok" :type "sim/Missing" + :fields (("path" "string") ("tried" "i32")))))))) + (let ((text (with-current-buffer (save-window-excursion (flan-cnr-show)) + (buffer-string)))) + (test-flan--check "the condition's name is what `layout' is asked for" + (equal (plist-get (car (last asked)) :op) "break")) + (test-flan--check "and it is sent back verbatim, qualified as it came" + (equal (plist-get (car asked) :type) "sim/Missing")) + (test-flan--check "the fields are drawn, named and typed" + (string-match-p ":path *string" text)) + ;; Shape and contents are two different questions, and only the first is + ;; answerable without the pointer the break loop discards. + (test-flan--check "and every value still says why it is missing" + (string-match-p ":tried *i32 *value not available" text))))) + +;; A layout the daemon refuses — a bare name it will not guess between two +;; packages, or a type it cannot place — leaves the section drawing its reason +;; rather than turning `C-c C-b' into an error. The restarts are the decision +;; in front of you and they are still there. +(let ((flan-cnr-request-function + (lambda (form) + (pcase (plist-get form :op) + ("break" '(:status "ok" :stopped t :condition "Missing" + :restarts ("retry"))) + ("layout" '(:status "error" :message "Missing is not a qualified name" + :candidates ("a/Missing" "b/Missing"))))))) + (let ((text (with-current-buffer (save-window-excursion (flan-cnr-show)) + (buffer-string)))) + (test-flan--check "a refused layout is a section that says so" + (string-match-p "not available.*needs a \\*qualified\\* name" text)) + (test-flan--check "and the restarts are drawn anyway" + (string-match-p "\\[retry\\]" text)))) + (let ((flan-cnr-request-function (lambda (_) '(:status "ok" :restarts nil :stopped nil)))) (test-flan--check "a running program is refused, by name" diff --git a/emacs/test-flan-dev.el b/emacs/test-flan-dev.el index bc2dccf..7a89d58 100644 --- a/emacs/test-flan-dev.el +++ b/emacs/test-flan-dev.el @@ -105,6 +105,17 @@ is written instead — the real `message' call the real command makes." (test-flan--check "describe lists the program's globals" (member "ticks" (plist-get r :globals)))) + ;; `layout' against the real daemon, through `flan-cnr-layout', which is how + ;; the conditions buffer gets it. The reply is the first one with a list of + ;; lists in it, so `read' on this side is doing something it does nowhere + ;; else — and the daemon answers it without asking the program anything. + (require 'flan-cnr) + (let ((flan-cnr-request-function #'flan-dev--request)) + (test-flan--check "a struct's fields come back named and typed" + (equal (flan-cnr-layout "Missing") '(("id" "i32" nil)))) + (test-flan--check "and a type the daemon cannot place is nil, not an error" + (null (flan-cnr-layout "Nonesuch")))) + ;; C-c C-c on the form at point: put point inside `step' and send it. The ;; text comes from the buffer, so this exercises `beginning-of-defun' against ;; Flan's own syntax table as much as it does the wire. From 41025fc0acb2ea6525acf96d8b93347c7cff92c9 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 10:42:50 +0700 Subject: [PATCH 3/3] A union is not a missing struct either MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layout searched only Tast.structs, so a declared union came back as "no struct is named X" — which reads as "that type does not exist" about a type the checker knows. Refused by kind beside the enum, and both refusals now have a test: a new enum and a new union, evaluated into the session. --- BUILT.md | 4 +++- lib/dev.ml | 12 +++++++++--- test/test_dev.ml | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/BUILT.md b/BUILT.md index cb3c77b..710fe8c 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1042,7 +1042,9 @@ suffix would reintroduce the ambiguity the rule exists to remove, and a rule wit 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. +exists, which is the same fact that makes a `defenum` unreloadable. A union is refused the same way and for its own +reason — it is declared, and union *values* are milestone 6. Both are `Types.Named` at a use site, so falling through +to "no struct is named X" would say a type does not exist about one that plainly does. **`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 diff --git a/lib/dev.ml b/lib/dev.ml index bd6c7d4..8e921a2 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -529,12 +529,18 @@ let layout t ~ty = 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 + (* Two types the checker knows and this op cannot describe. An enum's 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. *) + same fact that makes a defenum unreloadable; a union is declared and + has no values yet. Either way, 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 if + List.exists (fun (u : Tast.union) -> String.equal u.Tast.uname ty) + t.session.Session.program.Tast.unions + then + error (ty ^ " is a union, not a struct; union values are milestone 6") else let suffix = "/" ^ ty in let candidates = diff --git a/test/test_dev.ml b/test/test_dev.ml index 6d83f6d..5ce5245 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -174,6 +174,44 @@ let () = let r = request c "(:op \"layout\" :type \"Nonesuch\")" in if status r <> "error" then fail "a type that does not exist got a layout"; + (* A name that plainly exists and is not a struct is refused by *kind*. + Both of these are types the checker knows and this op cannot + describe, and "no struct is named X" would read as "X does not + exist". *) + let refusal r = + Option.value ~default:(status r) (Wire.string_field r "message") + in + let contains hay needle = + let n = String.length needle in + let rec go i = + i + n <= String.length hay + && (String.equal (String.sub hay i n) needle || go (i + 1)) + in + go 0 + in + let r = + request c + "(:op \"eval\" :code \"(defenum Colour [red 0 green 1])\" :file \"/tmp/buf.flan\")" + in + if status r <> "ok" then fail "a new enum: %s" (refusal r) + else begin + let r = request c "(:op \"layout\" :type \"Colour\")" in + if status r <> "error" then fail "an enum answered a struct layout" + else if not (contains (refusal r) "is an enum") then + fail "an enum is refused as: %s" (refusal r) + end; + let r = + request c + "(:op \"eval\" :code \"(defunion Shape [(Circle [r f32])])\" :file \"/tmp/buf.flan\")" + in + if status r <> "ok" then fail "a new union: %s" (refusal r) + else begin + let r = request c "(:op \"layout\" :type \"Shape\")" in + if status r <> "error" then fail "a union answered a struct layout" + else if not (contains (refusal r) "is a union") then + fail "a union is refused as: %s" (refusal r) + end; + (* 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