From 38a04f7a473d10ebf7c479c3fa2151f228262d54 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 19 Sep 2026 03:12:38 +0700 Subject: [PATCH 1/2] The compiler's own names answer C-c C-v, and M-. says where they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `arena-new` is a builtin, so it is in no program's symbol table and `defs` never mentioned it — which made the editor answer "the running program defines no arena-new" about a name that works. The fix is not a better refusal: it is that the seventy-eight names the checker answers without being told are now in the reply, under a kind of their own. check.ml carries the table, beside the arms it describes, because a table in another file drifts from them with nothing said. test_flan reads both the arms and the table and fails on either having a name the other does not, in both directions — there is no reflecting over a match, so it reads the source. Each entry is a signature in `signature_of_fn`'s shape and one line. The arms that do not have one shape say what is true instead of pretending: `?` for an argument that may be left out, `|` for the types an arm really takes, and the checker's own predicate names for the type-directed ones. The three user-allocator names carry a bare name and no bracket list, because they are refused wherever they are written. `defs` grows a fifth string for the prose, and builtins are appended last so a completion table does not bury the names being worked on. A defn has no docstring to put there and does not get one here: the Tast keeps none, and that is a different piece of work. The editor reads the kind, not a special case. `flan-doc--where` and the xref backend both answer before their empty-location branch, because a global's missing location and a builtin's absent one are different facts and only one of them is about the daemon. --- emacs/MANUAL.md | 6 + emacs/flan.el | 35 +++++- emacs/test-flan.el | 56 +++++++++ lib/check.ml | 286 +++++++++++++++++++++++++++++++++++++++++++++ lib/dev.ml | 46 ++++++-- test/dune | 4 + test/test_dev.ml | 60 ++++++++-- test/test_flan.ml | 81 +++++++++++++ 8 files changed, 552 insertions(+), 22 deletions(-) diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index 150670d..e46bd06 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -640,6 +640,12 @@ Completion, eldoc and `M-.` all read one cached answer rather than asking the program per keystroke. It refreshes at the two moments the answer can have changed: when you connect, and after an evaluation the daemon accepted. +The answer covers the compiler's builtins as well as the program's own names, +so `C-c C-v` on `arena-new` or `map-next!` gives you its signature and a line +about what it does. They are marked `builtin`, and they come after the +program's names in a completion list. `M-.` on one refuses rather than jumping: +it is written in the compiler, so there is no file to open. + --- ## Every lowering of one function — `C-c C-l` diff --git a/emacs/flan.el b/emacs/flan.el index aea5491..52482d4 100644 --- a/emacs/flan.el +++ b/emacs/flan.el @@ -1395,8 +1395,15 @@ Interactively, the whole buffer: the point of asking is to be rid of them." ;; can have appeared in between, because this editor is the only client. (defvar flan--defs nil - "What the running program defines: a list of (NAME KIND SIGNATURE LOC). -LOC is the empty string where the daemon has none to give.") + "What the running program defines: a list of (NAME KIND SIGNATURE LOC DOC). +LOC is the empty string where the daemon has none to give. DOC is a line of +prose, and today only a builtin has one. + +The list is not only the program's own names: the daemon appends every +compiler builtin with a KIND of \"builtin\", after the program's, so that +`arena-new' is a name this end knows about rather than one it reports as +undefined. Nothing here special-cases them — a builtin is an entry like any +other, and KIND is what tells it apart where that matters.") (defun flan--forget-defs () "Drop what is known about the program's names." @@ -1503,6 +1510,13 @@ Nothing is offered when nothing is known — an empty table would look like (user-error "flan: %s could be %s; write the one you mean" identifier (string-join (mapcar #'car hits) " or ")) (user-error "flan: the running program defines no %s" identifier))) + ;; Ahead of the empty-location branch: `arena-new' has no location for a + ;; different reason than `ticks' does, and M-. on it should say which. + ;; There is nowhere to jump either way, so this refuses too — but with + ;; the answer to "where is it" rather than with a shrug about the daemon. + ((equal (nth 1 d) "builtin") + (user-error "flan: %s is a builtin, written in the compiler rather than \ +in this program; C-c C-v describes it" (car d))) ((equal (nth 3 d) "") ;; Tast.global and Tast.extern carry no Loc, so there is nothing to go ;; to. Guessing by searching for "(defvar ticks" would find the wrong @@ -1573,6 +1587,13 @@ Nothing is offered when nothing is known — an empty table would look like (let* ((loc (nth 3 d)) (parts (and (not (equal loc "")) (flan--parse-loc loc)))) (cond + ;; Before the empty-location branch, because a builtin's empty LOC means + ;; something that branch would get wrong. A global was written down and + ;; the Tast dropped where; a builtin was never written down at all, and + ;; "no location is reported for it" would read as a gap in the daemon + ;; rather than as the answer. The answer is the compiler. + ((equal (nth 1 d) "builtin") + (insert "Defined in the compiler, so there is no file to visit\n")) ;; Said, not omitted. A missing line reads as "it has no home"; the ;; truth is that Tast.global and Tast.extern carry no Loc, which is a ;; fact about the compiler and worth saying in the same words M-. uses. @@ -1620,6 +1641,16 @@ same words `M-.' does: picking one would be a guess about which you meant." (insert (propertize (nth 2 d) 'face 'font-lock-type-face) "\n\n") (insert (format "Kind %s\n" (nth 1 d))) (flan-doc--where d) + ;; The prose last rather than under the signature, so the four facts + ;; stay the block they have always been and a name with nothing to say + ;; about itself looks exactly as it did before. Filled, because the + ;; daemon sends one line and one line in a narrow window is two. + (when (and (nth 4 d) (not (equal (nth 4 d) ""))) + (insert "\n") + (let ((start (point))) + (insert (nth 4 d) "\n") + (let ((fill-column (min fill-column 76))) + (fill-region start (point))))) ;; Parameter names are not in the Tast — the checker keeps types — ;; so a signature is types only, and someone reading this buffer ;; should be told that rather than left to wonder. diff --git a/emacs/test-flan.el b/emacs/test-flan.el index d650ccc..9b30061 100644 --- a/emacs/test-flan.el +++ b/emacs/test-flan.el @@ -762,6 +762,62 @@ is written instead — the real `message' call the real command makes." (test-flan--check "and a name the program has not got is refused" (and raised (string-match-p "no no-such-name" raised)))) + ;; A builtin, which is the case this buffer used to refuse outright: the name + ;; is fine and the program's symbol table has never heard of it, so C-c C-v + ;; answered "the running program defines no arena-new" about a name that + ;; works. `arena-new' by name because it is the one that was reported. + (flan-doc "arena-new") + (with-current-buffer flan-doc-buffer + (let ((text (buffer-string))) + (test-flan--check "C-c C-v on a builtin answers" + (string-match-p "\\`arena-new" text)) + (test-flan--check "with its signature" + (string-match-p "arena-new \\[i64\\] Allocator" text)) + (test-flan--check "and says it is a builtin" + (string-match-p "Kind +builtin" text)) + ;; The honest answer to "where", and not the one an empty location would + ;; otherwise have produced: a global's location is missing, a builtin's + ;; never existed. + (test-flan--check "and that it lives in the compiler, not in a file" + (string-match-p "Defined +in the compiler" text)) + (test-flan--check "and carries a line about what it does" + (string-match-p "capacity is explicit" text)))) + ;; The other half of the table: a name written as a name rather than as a + ;; call, which `var' answers and which reads here as a builtin too. + (flan-doc "context/allocator") + (with-current-buffer flan-doc-buffer + (test-flan--check "and so does a builtin that is a name, not a call" + (string-match-p "Kind +builtin" (buffer-string)))) + ;; M-. has nowhere to jump either way, but the reason is the point: the + ;; refusal names the compiler instead of reporting a gap in the daemon. + (let ((raised nil)) + (condition-case err (xref-backend-definitions 'flan "arena-new") + (user-error (setq raised (error-message-string err)))) + (test-flan--check "M-. on a builtin says it is in the compiler" + (and raised (string-match-p "arena-new" raised) + (string-match-p "compiler" raised)))) + (test-flan--check "eldoc has a builtin's signature too" + (let ((said nil)) + (with-temp-buffer + (insert "arena-new") + (flan-eldoc-function + (lambda (s &rest _) (setq said s)))) + (and said (string-match-p "arena-new \\[i64\\]" said) + ;; The kind rides along, as it does for a global, + ;; so the echo area says which of the two this is. + (string-match-p "builtin" said)))) + (let* ((capf (with-temp-buffer + (insert "arena") + (flan-completion-at-point))) + (table (nth 2 capf))) + (test-flan--check "completion offers builtins" + (member "arena-new" (all-completions "arena-" table))) + ;; They are appended after the program's own names rather than merged in, + ;; so a table built from this order does not bury what is being worked on. + (test-flan--check "and still offers the program's names first" + (< (seq-position table "step") + (seq-position table "arena-new")))) + ;; ── The watch buffer ────────────────────────────────────────────────── ;; ;; test_dev.ml proves the table itself: a program pushes and the daemon reads diff --git a/lib/check.ml b/lib/check.ml index b8caf6f..be6bc30 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -1837,6 +1837,8 @@ and in_range loc k n = if ok then n else fail loc "%Ld does not fit in %s" n (Types.ikind_name k) +(* The arms that are names rather than calls, and the same rule holds for them: + each is in [builtins] below, and test_flan reads this match to check it. *) and var ctx loc ~want name = match name with | "true" | "false" -> @@ -3370,6 +3372,9 @@ and vec_at ctx loc (target : Tast.expr) (idx : Ast.expr list) = "a Vec takes exactly one index — (at v i) — and its element is indexed \ separately" +(* Every arm below is a name an editor can be asked about and no program ever + wrote down, so each one needs a line in [builtins] further down this file. + A new arm without an entry fails the build — test_flan reads both. *) and named_call ctx ~want loc name args = let prim p ty args = expect loc ~want (mk loc ty (Tast.Prim (p, args))) in match name with @@ -5145,6 +5150,287 @@ and binary ctx name loc ~want args = end | _ -> fail loc "%s takes two arguments" name +(* ── The builtins, said out loud ─────────────────────────────────────── + A name, a signature and one line, for every name [named_call] and [var] + answer without the program having written it. The editor's C-c C-v used to + say "the running program defines no arena-new", which was true and useless: + a builtin is in the compiler, so it is in no program's symbol table and + [Dev.defs] had nothing to hand over. This is what it hands over instead. + + It lives here rather than in dev.ml because it describes the arms above it, + and a table in another file drifts from them silently. [test_flan]'s + [builtin_table] reads this file and the arms and fails on either one having + a name the other does not, so the drift is a failing build rather than a + name that answers nothing. + + The signature follows [Dev.signature_of_fn]'s shape — [name [params] ret] — + so eldoc reads a builtin the way it reads a defn. Where an arm does not + have one shape the signature says what is true rather than inventing one: + [?] marks an argument that may be left out, [|] separates the types an arm + really does accept, and the predicate names are the compiler's own + ([numeric?], [ordered?], [equal?] — check.ml's [where] evaluator), because + a generic's author already writes them. Three names have no shape at all + and carry a bare name instead of a bracket list; their line says why. + + One line each, because this is read in an echo area and a help buffer. The + lines are the arms' own reasons, cut down — where an arm argues for a + decision above itself, the sentence a person needs at the call site is the + conclusion, not the argument. *) +let builtins : (string * string * string) list = + [ (* arithmetic and comparison *) + ("+", "+ [numeric? ...] numeric?", + "Sum, folded left over two or more operands that share one numeric \ + type — nothing widens implicitly."); + ("-", "- [numeric? ...] numeric?", + "Difference, folded left: (- a b c) is ((a - b) - c)."); + ("*", "* [numeric? ...] numeric?", + "Product, folded left over two or more operands of one numeric type."); + ("/", "/ [numeric? ...] numeric?", + "Quotient, folded left. Integer division truncates toward zero."); + ("%", "% [numeric? numeric?] numeric?", + "Remainder, and it stays at two operands: (% a b c) would mean \ + (% (% a b) c), which is a thing nobody writes on purpose."); + ("=", "= [equal? equal?] bool", + "Equality. It admits one type < does not — a handle, where \"the same \ + entity\" is the question the type exists to answer."); + ("!=", "!= [equal? equal?] bool", + "Inequality, over everything = accepts."); + ("<", "< [ordered? ordered?] bool", + "Less than. Machine numbers only: ordering a handle would order a \ + free-list slot index, which means nothing."); + ("<=", "<= [ordered? ordered?] bool", "Less than or equal."); + (">", "> [ordered? ordered?] bool", "Greater than."); + (">=", ">= [ordered? ordered?] bool", "Greater than or equal."); + ("not", "not [bool] bool", + "Negates a bool. Nothing else in this language is a truth value."); + ("bit-and", "bit-and [int ...] int", + "Bitwise and, folded left. Integers only, and every operand has the \ + same width."); + ("bit-or", "bit-or [int ...] int", "Bitwise or, folded left over integers."); + ("bit-xor", "bit-xor [int ...] int", + "Bitwise exclusive or, folded left over integers."); + ("<<", "<< [int int] int", + "Left shift. The count has the shifted value's own type, and a literal \ + count at or past its width is refused — LLVM calls that poison."); + (">>", ">> [int int] int", + "Right shift, by a count of the value's own type; a literal count at or \ + past the width is refused, as it is for <<."); + ("min", "min [ordered? ...] ordered?", + "The smallest of two or more operands, each of them evaluated exactly \ + once however many there are."); + ("max", "max [ordered? ...] ordered?", + "The largest of two or more operands, each evaluated exactly once."); + ("zeroed", "zeroed [] T", + "The all-bytes-zero value of whatever it is being stored into, so it \ + only means anything where a type is expected of it."); + ("destructure~nth", "destructure~nth [[n T] i32 i32 i32] T", + "Written by the compiler for a destructuring let, and unspellable: the \ + reader makes ~ a delimiter, so no source symbol can name this."); + + (* allocators, spec-memory.md *) + ("make-allocator", "make-allocator", + "A user-written allocator, not implemented and refused wherever it is \ + written. Use (arena-new n), which is the parameterised allocator that \ + does exist."); + ("allocator-from", "allocator-from", + "A user-written allocator, not implemented — see make-allocator."); + ("allocator", "allocator", + "A user-written allocator, not implemented — see make-allocator."); + ("heap-allocator", "heap-allocator [] Allocator", + "The process heap as an Allocator: it releases one block at a time, so \ + can-free? is true of it."); + ("arena-new", "arena-new [i64] Allocator", + "A new arena of exactly this many bytes. The capacity is explicit and \ + the backing store never grows, which is what makes \"exhausted\" a \ + state a test can reach on purpose."); + ("arena-destroy", "arena-destroy [Allocator] ()", + "Hands the arena's pages back to the system, which free-all \ + deliberately does not."); + ("free-all", "free-all [Allocator] ()", + "Releases everything the allocator holds and bumps its epoch, keeping \ + the capacity. It traps rather than quietly doing nothing when there is \ + no region to release."); + ("can-free?", "can-free? [Allocator] bool", + "Whether this allocator can release a single block, read off its \ + capability set rather than asked of a query procedure."); + ("can-free-all?", "can-free-all? [Allocator] bool", + "Whether this allocator can release everything it holds at once."); + ("alloc-epoch", "alloc-epoch [Allocator] i64", + "The counter free-all bumps. A container records it and traps if it \ + moved; this is the same number, readable, so a program can say what it \ + saw."); + ("alloc-id", "alloc-id [Allocator] i64", + "The allocator's identity — its address — which is what a condition's \ + :allocator field carries, so a handler holding several regions can \ + tell which one ran out."); + ("alloc-budget", "alloc-budget [Allocator] i64", + "The ceiling on live bytes, 0 for none. A handler that answers \ + StorageExhausted with retry is the one that raises it."); + ("set-alloc-budget", "set-alloc-budget [Allocator i64] ()", + "Sets the ceiling on live bytes; 0 for none. It is also how a program \ + exhausts an allocator on purpose."); + ("alloc-live-blocks", "alloc-live-blocks [Allocator] i64", + "How many blocks are still live — \"did you forget to free\", answered \ + at the tier that can answer it."); + ("with-allocator", "with-allocator [Allocator body ...] T", + "Runs the body with this allocator in the context, and answers the \ + body's last expression. It releases nothing: not at the end of the \ + body, not anywhere."); + + (* (Vec T) *) + ("vec-new", "vec-new [T? Allocator?] (Vec T)", + "An empty Vec. A let has no type annotation, so the element type is \ + written at the call — (vec-new i32) — wherever the context does not \ + say it; an allocator may be named the same way."); + ("push", "push [(Vec T) T] ()", + "Appends one element, growing the Vec through its allocator. Unit and \ + not an error code: a failed allocation signals StorageExhausted."); + ("reserve", "reserve [(Vec T)|(Map K V) i32] ()", + "Makes room for n more. For a map the number is entries rather than \ + slots — the block is sized so that n still sits under the load \ + factor."); + ("as-slice", "as-slice [(Vec T) i32? i32?] [T]", + "A non-owning view of the whole Vec, or of the half-open range \ + [lo hi). It carries no allocator, and a push, a put or a reserve may \ + invalidate it."); + ("free", "free [(Vec T)|(Map K V)] ()", + "Releases the container's block. It does not recurse into elements that \ + own storage — such a container is refused here, and releasing its \ + region with free-all is the answer."); + ("clone", "clone [(Vec T)|(Map K V) Allocator?] (Vec T)|(Map K V)", + "A deep, independent copy, from the current allocator or one named. \ + Refused for a container whose elements own storage: a bytewise copy \ + would alias the original's blocks under a name promising otherwise."); + + (* (Map K V) *) + ("map-new", "map-new [K? V? Allocator?] (Map K V)", + "An empty map. The key and value types are written at the call — \ + (map-new string i32) — wherever the context does not say them."); + ("put", "put [(Map K V) K V] ()", + "Inserts or replaces. Unit rather than an error code, and \ + (set (get m k) v) is not map syntax."); + ("get", "get [(Map K V) K] (Option V)", + "The value at the key, or None. Nothing signals here — a lookup that \ + finds nothing is an answer — and the value comes back as a copy of \ + its bytes."); + ("map-remove!", "map-remove! [(Map K V) K] (Option V)", + "Removes the entry and answers the value it held, or None if there was \ + none."); + ("map-next!", "map-next! [(Map K V) (Ptr i64) (Ptr K) (Ptr V)] bool", + "Walks the map one entry per call through a cursor the caller owns, and \ + is the whole of map iteration: (while (map-next! m (addr cur) (addr k) \ + (addr v)) ...)."); + ("has-key?", "has-key? [(Map K V) K] bool", + "Whether the key is present, copying no value — the form a condition \ + wants, where get would hand back an Option to match on."); + + (* assets, embedded at compile time *) + ("embed", "embed [\"path\" string?] [u8]", + "The file's bytes, read at compile time and baked in as a constant; \ + (embed \"p\" string) reads it as a string instead. The path is \ + relative to the file the form is written in, and the slice points into \ + read-only data."); + ("embed-dir", "embed-dir [\"path\"] [n EmbedFile]", + "Every file in the directory, read at compile time, as a fixed array of \ + EmbedFile. It does not descend."); + + (* files *) + ("slurp", "slurp [string Allocator?] (Vec u8)", + "Reads a whole file. No Result and no out-parameter: a failure to read \ + signals FileError under retry and use-value, and a failure to allocate \ + signals StorageExhausted."); + ("barf", "barf [string [u8]] ()", + "Writes a whole file. On the web target it signals FileError every \ + time, with the path — there is no conditional compilation, so the \ + program decides rather than the build."); + ("delete-file", "delete-file [string] ()", + "Removes the file, or signals FileError with retry and use-value. It \ + answers () and not a bool, because the failure is the condition."); + ("make-directory", "make-directory [string] ()", + "Creates the directory, or signals FileError. () for the reason \ + delete-file answers one."); + ("rename-file", "rename-file [string string] ()", + "Renames the first path to the second, or signals FileError. A \ + use-value names a different source for the same destination, which is \ + the direction a handler can act on."); + + (* containers *) + ("len", "len [[n T]|[T]|string|(Vec T)|(Map K V)] i32", + "How many elements. One question and one word across an array, a slice, \ + a string, a Vec and a Map."); + ("at", "at [collection i32 ...] T", + "The element at an index, bounds-checked — and for a Vec with the \ + allocator's epoch checked first. It is also a place, so \ + (set (at v i) x) goes through the same check."); + ("slice", "slice [[n T]|[T] i32 i32] [T]", + "The half-open range [lo hi) as a non-owning view. A bound may sit one \ + past the end; a literal pair that runs backwards is refused here."); + ("slice-from-ptr", "slice-from-ptr [(Ptr T) i32] [T]", + "Puts a length on a pointer that came back from C. The caller promises \ + it addresses that many initialised T and that they outlive the result; \ + the compiler checks none of it."); + ("addr", "addr [place] (Ptr T)", + "The address of a place — a name, (.field x), (at a i) or (deref p) — \ + and not of an arbitrary expression."); + ("deref", "deref [(Ptr T)] T", + "The value behind a pointer, and a place, so (set (deref p) x) writes \ + through it."); + + (* Option *) + ("Some", "Some [T] (Option T)", + "Wraps a value as a present Option. None is the other half, and is \ + written as a name rather than as a call."); + + (* the host primitives *) + ("bytes", "bytes [string] [u8]", + "A string seen as a byte slice. It costs nothing — both are a ptr and a \ + length at run time — and it decodes nothing. A literal's bytes are \ + constant data, so the slice looks writable and is not."); + ("string", "string [[u8]] string", + "A byte slice seen as a string, and free at run time. It does not check \ + UTF-8, because `string` does not claim UTF-8 — valid-utf8? is an \ + ordinary function you call when you care."); + ("bytes->f64", "bytes->f64 [[u8]] f64", "Parses a float out of the bytes."); + ("bytes->i64", "bytes->i64 [[u8]] i64", + "Parses an integer out of the bytes."); + ("f64->bytes", "f64->bytes [f64] [u8]", + "The number's text, in a frame slot belonging to this call site — so \ + two of them can be held at once, and neither survives its frame. Copy \ + the bytes to keep one."); + ("i64->bytes", "i64->bytes [i64] [u8]", + "The number's text, in a frame slot belonging to this call site; it \ + does not survive the frame."); + ("write-stdout", "write-stdout [[u8]] ()", + "Writes the bytes to standard output exactly as given: no newline and \ + no formatting."); + ("print", "print [T] ()", + "The structural printer, selected for the argument's concrete type. A \ + string prints raw at the top level and quoted inside a structure, and \ + a Ptr or a Handle prints its address rather than being followed. \ + Printing is a read, so it does not consume the value."); + ("println", "println [T] ()", "print, with a newline after it."); + ("exit", "exit [i32] never", + "Ends the process with this status. It has no value, so nothing written \ + after it runs."); + ("argv", "argv [] [string]", "The command line, as a slice of strings."); + + (* the five that are names rather than calls — [var]'s arms. Their + signature is the [name type] shape [Dev.defs] gives a global, because + that is what they are at the site: a value, not a call. *) + ("true", "true bool", "The true boolean literal."); + ("false", "false bool", "The false boolean literal."); + ("None", "None (Option T)", + "The absent Option. It takes its type from its context — a return type \ + or an annotated binding — because nothing about the word says what it \ + is an Option of."); + ("context/allocator", "context/allocator Allocator", + "The allocator in effect here: what with-allocator rebinds, and what an \ + allocating operation uses when none is named at the site."); + ("context/temp", "context/temp Allocator", + "The scratch allocator the calling convention carries beside \ + context/allocator.") + ] + (* ── Declarations: pass 1, collect ─────────────────────────────────── *) (* Constant folding, only over integers and only for defconst — enough for an diff --git a/lib/dev.ml b/lib/dev.ml index c0b4913..1093397 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -999,10 +999,17 @@ let describe t = connect and again after each install. Putting signatures on the poll would pay for them every time anyone looked at the output buffer. - One entry per name: (name kind signature loc). Four strings, so the editor - reads it with [read] and nothing here needs a new wire type. [loc] is empty - where there is none to give — only [Tast.fn] carries one — and an editor - that finds it empty must say so rather than guess a file. + One entry per name: (name kind signature loc doc). Five strings, so the + editor reads it with [read] and nothing here needs a new wire type. [loc] is + empty where there is none to give — only [Tast.fn] carries one — and an + editor that finds it empty must say so rather than guess a file. + + [doc] carries a line of prose, and today only a builtin has one. A [defn] + does not, and that is not an oversight this op can fix: the Tast keeps no + docstring, so the field would be empty for every program name until the + parser and the Tast both learn to hold one. It is a fifth string rather than + a sixth op because it is the same question — what is this name — asked at + the same two moments. Parameter *names* are not in the Tast, so a signature shows types only. *) let signature_of_fn (f : Tast.fn) = @@ -1010,8 +1017,10 @@ let signature_of_fn (f : Tast.fn) = (String.concat " " (List.map Types.to_string f.Tast.params)) (Types.to_string f.Tast.ret) -let entry ~name ~kind ~sign ~loc = - Wire.list [ Wire.quote name; Wire.quote kind; Wire.quote sign; Wire.quote loc ] +let entry ~name ~kind ~sign ~loc ?(doc = "") () = + Wire.list + [ Wire.quote name; Wire.quote kind; Wire.quote sign; Wire.quote loc; + Wire.quote doc ] let defs t = let p = t.session.Session.program in @@ -1025,7 +1034,7 @@ let defs t = | None -> Some (entry ~name:f.Tast.name ~kind:"fn" ~sign:(signature_of_fn f) - ~loc:(Loc.to_string f.Tast.floc))) + ~loc:(Loc.to_string f.Tast.floc) ())) p.Tast.fns in let globals = @@ -1035,7 +1044,7 @@ let defs t = ~kind:(if g.Tast.gconst then "const" else "var") ~sign: (Printf.sprintf "%s %s" g.Tast.gname (Types.to_string g.Tast.gty)) - ~loc:"") + ~loc:"" ()) p.Tast.globals in let externs = @@ -1046,10 +1055,27 @@ let defs t = (Printf.sprintf "%s [%s] %s" e.Tast.ename (String.concat " " (List.map Types.to_string e.Tast.eparams)) (Types.to_string e.Tast.eret)) - ~loc:"") + ~loc:"" ()) p.Tast.externs in - ok [ ":defs " ^ Wire.list (fns @ globals @ externs) ] + (* Last, so a program's own names sort ahead of them in every list an editor + builds out of this — a completion table above all, where 78 compiler names + interleaved with a handful of a program's own would bury the ones being + worked on. The kind is what separates them, and it is on every entry + already, so an editor that wants only the program's names filters rather + than asking a second op. + + [loc] is empty here as it is for a global, but for a different reason, and + the editor has to say the difference: a global was written somewhere and + the Tast dropped the location, while a builtin was never written down at + all. So the honest sentence names the compiler, and the kind is what an + editor keys it on. *) + let builtins = + List.map + (fun (name, sign, doc) -> entry ~name ~kind:"builtin" ~sign ~loc:"" ~doc ()) + Check.builtins + in + ok [ ":defs " ^ Wire.list (fns @ globals @ externs @ builtins) ] (* [(:op "layout" :type T)] — a struct's fields and their types. diff --git a/test/dune b/test/dune index 119dd1e..d8bff55 100644 --- a/test/dune +++ b/test/dune @@ -77,6 +77,10 @@ (file %{workspace_root}/bin/main.exe) ; The Emacs client, which test_emacs drives against a real daemon. (glob_files %{workspace_root}/emacs/*.el) + ; The checker's own source, which test_flan reads: there is no reflecting + ; over a match, so the only way to assert that Check.builtins still lists + ; every builtin arm is to read the arms. + (file %{workspace_root}/lib/check.ml) ; The WASI host the wasm32 case runs its module under, when no wasmtime or ; wasmer is installed. (file wasm-run.mjs))) diff --git a/test/test_dev.ml b/test/test_dev.ml index 77dfaf2..4486060 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -321,7 +321,13 @@ let () = carries what eldoc, completion and find-definition each need: a kind, a signature, and where the name is written where that is knowable. An empty location is the honest answer for a global — Tast.global has - no Loc — and an editor is expected to refuse rather than guess. *) + no Loc — and an editor is expected to refuse rather than guess. + + A fifth string carries a line of prose, and the compiler's builtins + ride the same op under a kind of their own: [arena-new] is a name an + editor is asked about and no program's symbol table holds, so without + them here C-c C-v answers "the running program defines no arena-new" + about a name that is perfectly good. *) let r = request c "(:op \"defs\")" in if status r <> "ok" then fail "defs: %s" (status r); (match Wire.field r "defs" with @@ -334,26 +340,60 @@ let () = ({ Form.v = Form.Str n; _ } :: { Form.v = Form.Str kind; _ } :: { Form.v = Form.Str sign; _ } - :: { Form.v = Form.Str loc; _ } :: []) - when String.equal n name -> Some (kind, sign, loc) + :: { Form.v = Form.Str loc; _ } + :: { Form.v = Form.Str doc; _ } :: []) + when String.equal n name -> Some (kind, sign, loc, doc) | _ -> None) entries in (match find "step" with - | Some ("fn", "step [] i64", loc) when String.length loc > 0 -> + | Some ("fn", "step [] i64", loc, "") when String.length loc > 0 -> (* Absolute, because an editor is not in this process's working directory and cannot resolve a relative one. *) if loc.[0] <> '/' then fail "a fn's location is relative: %s" loc - | Some (k, s, l) -> fail "step is described as (%s, %s, %s)" k s l + | Some (k, s, l, _) -> fail "step is described as (%s, %s, %s)" k s l | None -> fail "defs did not mention step"); (match find "ticks" with - | Some ("var", "ticks i64", "") -> () - | Some (k, s, l) -> fail "ticks is described as (%s, %s, %s)" k s l + | Some ("var", "ticks i64", "", "") -> () + | Some (k, s, l, _) -> fail "ticks is described as (%s, %s, %s)" k s l | None -> fail "defs did not mention ticks"); (match find "agent/wait-raw" with - | Some ("extern", _, _) -> () - | Some (k, _, _) -> fail "an extern is described as %s" k - | None -> fail "defs did not mention an imported extern") + | Some ("extern", _, _, _) -> () + | Some (k, _, _, _) -> fail "an extern is described as %s" k + | None -> fail "defs did not mention an imported extern"); + (* The builtin the author hit. It is here with a signature, a line of + prose and no location — and the empty location has to be read + against the kind, because a global's empty one means the Tast + dropped it while this one means there was never a file. *) + (match find "arena-new" with + | Some ("builtin", sign, "", doc) -> + if sign = "" then fail "arena-new has no signature"; + if doc = "" then fail "arena-new has no description" + | Some (k, _, l, _) -> + fail "arena-new is described as a %s at %S" k l + | None -> fail "defs did not mention arena-new"); + (* And a name in value position, which is [var]'s half of the same + table: it is a builtin too, and reads as one. *) + (match find "context/allocator" with + | Some ("builtin", _, "", doc) when doc <> "" -> () + | Some (k, _, _, _) -> fail "context/allocator is described as a %s" k + | None -> fail "defs did not mention context/allocator"); + (* A program's own names come first, so a completion table built out + of this order does not bury them under the compiler's. *) + let kind_of (e : Form.t) = + match e.Form.v with + | Form.List (_ :: { Form.v = Form.Str k; _ } :: _) -> k + | _ -> "" + in + let rec no_program_after_builtin seen = function + | [] -> () + | e :: rest -> + let k = kind_of e in + if seen && k <> "builtin" then + fail "a %s entry comes after a builtin in defs" k; + no_program_after_builtin (seen || k = "builtin") rest + in + no_program_after_builtin false entries | _ -> fail "defs did not answer with a list"); (* [layout]: a struct's fields and their types, out of [Tast.structs], diff --git a/test/test_flan.ml b/test/test_flan.ml index 4c6c92d..c8142c0 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -2691,6 +2691,87 @@ let () = ~needle:"is not a map key" "(defn f [k $t] () (let [m (map-new t i32)] (put m k 1) (free m)))"; + (* ── The builtin table against the arms it describes ────────────── + [Check.builtins] is what the editor's C-c C-v and M-. read for a name no + program wrote — [arena-new] and the seventy-seven others. A table like + that is worth less than nothing once it is stale: a builtin added without + an entry answers nothing, and an entry for an arm that was deleted + describes a name that no longer exists, which is worse because it reads + as authoritative. + + There is no way to reflect over an OCaml match, so this reads the source + instead. The two regions are [named_call]'s arms and [var]'s, each from + its own [and] down to the first catch-all at the same indentation, and + the names are the string literals in the arm heads. It is a regex over + one file and costs nothing, which is why it is in the default run rather + than behind an alias. *) + let arm_names () = + let src = + In_channel.with_open_bin "../lib/check.ml" In_channel.input_all + in + let lines = String.split_on_char '\n' src in + let starts_with p s = + String.length s >= String.length p && String.sub s 0 (String.length p) = p + in + let quoted line = + let out = ref [] and i = ref 0 and n = String.length line in + while !i < n do + if line.[!i] = '"' then begin + let j = ref (!i + 1) in + while !j < n && line.[!j] <> '"' do incr j done; + if !j < n then out := String.sub line (!i + 1) (!j - !i - 1) :: !out; + i := !j + 1 + end else incr i + done; + List.rev !out + in + let region head = + let rec drop = function + | [] -> [] + | l :: rest -> if starts_with head l then rest else drop rest + in + let rec take = function + | [] -> [] + | l :: rest -> + if starts_with " | _" l then [] + else if starts_with " | \"" l then quoted l @ take rest + else take rest + in + take (drop lines) + in + region "and named_call " @ region "and var ctx " + in + let arms = arm_names () in + let table = List.map (fun (n, _, _) -> n) Check.builtins in + check "every builtin arm is described" (arms <> [] && List.length arms > 60); + List.iter + (fun n -> + if not (List.mem n table) then begin + incr failures; + Printf.printf + "FAIL the builtin %s has an arm in check.ml and no entry in \ + Check.builtins\n" n + end) + arms; + List.iter + (fun n -> + if not (List.mem n arms) then begin + incr failures; + Printf.printf + "FAIL Check.builtins describes %s, which is no longer an arm\n" n + end) + table; + (* A signature and a line, for every one of them: an entry that is present + and empty answers the question no better than a missing one. *) + List.iter + (fun (n, sign, doc) -> + if sign = "" || doc = "" then begin + incr failures; + Printf.printf "FAIL the builtin %s has no %s\n" n + (if sign = "" then "signature" else "description") + end) + Check.builtins; + (* ── The acceptance program checks end to end ──────────────────── *) accepts "calc-me.flan type checks" (In_channel.with_open_bin "../calc-me.flan" In_channel.input_all); From 7f9017bb3e359cdddf9ddd49fb8587c163d86804 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 19 Sep 2026 03:12:43 +0700 Subject: [PATCH 2/2] The two lines in sand.flan that 668f0d6 did not mean to take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That commit is about C-M-x and touches three files; the third is a snapshot of an editing session caught mid-thought: (defvar frame Allocator (arena-new 262144)) (defvar game-data (embed (with-allocator frame ))) The second does not check — embed takes a path, and there is no path there — so the acceptance corpus fails to build and `dune test` has been red at the tip on its own account. Removed rather than repaired, because what it was going to say is the author's to finish. --- sand.flan | 3 --- 1 file changed, 3 deletions(-) diff --git a/sand.flan b/sand.flan index e26c606..5b28a81 100644 --- a/sand.flan +++ b/sand.flan @@ -180,9 +180,6 @@ (rl/get-color c)))))) (rl/draw-fps 20 20)) -(defvar frame Allocator (arena-new 262144)) -(defvar game-data (embed (with-allocator frame ))) - (defn main [] () (rl/set-trace-log-level :warning) (rl/init-window screen-width screen-height "SAND")