From a7ea3ef940b9e45294a8354dc062a0a57d74c28a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 08:06:25 +0700 Subject: [PATCH] What the error value is, and the three things it deliberately is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILT.md gets the design: why the span went into Loc.t rather than beside it, why macro provenance went the same way, why the first line of a report is still the GNU format, and what the daemon sees. NEXT.md item 8 is struck through, with the parts that were not built stated plainly so they do not read as oversights — the reader does not collect, because a paren stream cannot be resynchronised; pass one of the checker does not collect, because thirty unknown-name lines under one wrong signature are the same error thirty times; and there are not a hundred kinds, because the count was never the feature. --- BUILT.md | 140 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ NEXT.md | 69 ++++++++++++++------------- 2 files changed, 174 insertions(+), 35 deletions(-) diff --git a/BUILT.md b/BUILT.md index b6b7d8d..4ee2c22 100644 --- a/BUILT.md +++ b/BUILT.md @@ -3897,3 +3897,143 @@ composite renderer and ghost text want the same form, for different reasons. `fl things ghost text would need — overlay invalidation as the buffer is edited, and a rule for a watch inside a loop, which the buffer sidesteps by showing the last value written and which inline has no obvious answer that does not become the query UI this design exists to avoid. + +## An error is a value, and there is more than one of them + +`lib/loc.ml` used to carry a point and a message, and `Loc.Error` was the frontend's one exception, so the first +error ended the run. The author's workflow is write everything, compile at the end, work through the list — which +cannot happen when there is never a list. The messages themselves were already good; they state the reason and name +what to write instead, and **none of them changed**. What was missing was structure and volume. + +### The location is a span + +`Loc.t` grew an exclusive end, defaulting to the start. That is the whole trick: a location nobody widened is a +zero-width span at a point, so every call site that existed before means exactly what it meant, and `Loc.to_string` +still prints `file:line:col`. Only the reader knows where a form ends, so only the reader fills them in — one helper +in the one place that holds both ends, which is why nothing above `Reader` had to learn a span exists. `Form`, `Ast` +and `Tast` were not touched and did not need to be. + +A column number cannot draw an underline and a span can. That is what the field is for and it is the only reason it +is there. + +### The error itself + +```ocaml +type diag = { + kind : string; (* "reader/unclosed", stable *) + dloc : t; (* the primary span *) + dmsg : string; + notes : note list; (* each with its own span and severity *) + expansion : (string * t) option; (* the macro it came out of *) +} +exception Error of diag +exception Errors of diag list +``` + +Three parts, each buying something the old pair could not express. + +**`kind`** is a stable id. It classifies with no prose parsed, so a message can be reworded without breaking anything +that depends on *which* error this is. The reader's fourteen refusals all carry one; in the checker they go on the +errors a test names and the handful common enough to be worth classifying. **Not a hundred of them.** jank has about +a hundred because it is mature, and the count is not the feature — with 163 refusal sites in `check.ml` alone, +minting an id for each would be a sweep that never ends and that nothing reads. + +**`notes`** are the part that was actually missing, and they are the secret of an Elm-quality message. Each carries +its own span and its own severity, so an error says "this is wrong *here*" **and** "because of *that*, over there", +and points at both. One location and one string can only ever state one of the two. What has them today: + +- a name defined twice points at the second, because that is the one to delete, and notes the first; +- a duplicate parameter and a duplicate field do the same; +- an unknown field, an unknown struct and a non-exhaustive `match` note the *declaration* and list what is actually + there, so the reader's next move arrives with the question instead of after it; +- the reader's unclosed bracket is the clearest case — the error sits on the bracket, because that is where the fix + goes, and the note sits where the file ran out, because that is the surprise. A mismatched closer is the mirror of + it: the wrong closer is where the mistake reads, and the opener is what makes it wrong, and neither alone says + which bracket to change. + +**`expansion`** names the macro an error is really about. It rides on the *location*, not on the form, because the +location is the thing that already travels: `Expand.unmarshal` stamps the call site onto every node a macro answers +with, and that stamp goes on through the AST and the typed IR untouched. Tagging it there means an error raised +anywhere downstream can name the macro with **no field added to `Form`, to `Ast` or to `Tast`**. Outermost wins — the +macro the author wrote is the one worth naming, not whatever it expanded into on the way down. + +### The squiggle + +The first line of an entry is exactly `file:line:col: message`, which is the GNU format `compilation-mode` parses +with no configuration. That is the whole of the editor story: once more than one comes out, `M-x compile` gives a +clickable list and `next-error` walks it. Everything under the first line is indented, and `compilation-mode` ignores +indented continuation lines, so the underline is free: + +``` +prog.flan:6:3: Cursor has no field pos + 6 | (.pos c)) + | ^^^^^^^^ +prog.flan:1:1: info: Cursor is declared here, with row, col + 1 | (defstruct Cursor + | ----------------- +``` + +A note gets an **entry of its own** rather than being folded into the error's block. That is gcc's shape and it is the +point of notes having locations at all: the second place becomes somewhere `next-error` can take you. + +Every part of it degrades to the bare first line. A location the checker invented has line 0 and a file called +``, the prelude and the REPL have names that are not paths, and a file can change under us between being +read and being blamed. An error printer that can raise is worse than one that prints less. Placeless diagnostics sort +*last*: a wrong `main` signature is raised against `unknown`, and sorting on the line number alone put it above every +error that could actually be clicked. + +The source cache in `loc.ml` is process-lifetime, which is right for `flan build` — a fresh process per run. The +daemon is long-lived and never calls `report`; the interactive path draws no squiggle, it takes a location and a +message. `Loc.forget_sources` exists for the day that changes. + +### Collecting, and where it stops + +A sink holds what a pass found so the pass can go on to the next thing. It is switched on by the caller, not by the +code that raises. Two resync points, and both are places the work already had a boundary: + +- **In the parser, a top-level form.** The reader already found where each declaration ends, so skipping a bad one + costs nothing and cannot lose its place. Inside a declaration there is no such landmark, so one bad `defn` stays + one error. +- **In the checker, the two passes.** Pass one — which builds every name, type and signature — **still stops at the + first refusal**, and that is deliberate rather than unfinished. A signature it could not make sense of leaves a hole + that pass two would report once per mention; thirty "unknown name" lines under one wrong signature are not thirty + errors, they are the same one. Pass two is where the volume is and where collecting pays, and by then every + signature is sound, so a body that fails cannot make the next body fail. That is what makes a declaration a resync + point needing no resynchronising. + +**The reader does not collect at all.** There is no resynchronising a paren stream: after an unclosed bracket the +reader has no way to know whether the next `)` closes the form it is in or the one above it, and guessing produces a +file-shaped pile of nonsense. First error, stop. That is a decision, not an omission. + +### What the daemon sees, which was the open question + +Changing the error type without touching `dev.ml` and `session.ml` needed a compatible way to get one location and +one message out. The answer is that **the single-diagnostic exception is still the single-diagnostic exception**. +`Session.eval` and the daemon evaluate one form and have one failure to report; they keep catching `Loc.Error` and +take the pair out of it with `Loc.summary`. Only a driver that compiles a whole file raises `Loc.Errors`. + +That guarantee is **structural and not conventional**. `Parse.program` / `Check.program` stop at the first refusal; +`Parse.program_all` / `Check.program_all` collect. Two names rather than one function with a `~keep_going` label, +because `Loc.Errors` is a second exception that the session's handlers do not name — a list reaching them would be an +unhandled exception and a dead session, which is the one thing the dev loop exists to prevent. With a label that was +one keystroke away at a call site the session already uses. With two names, somebody has to edit the session. + +### What it looks like + +``` +$ flan check bad.flan +bad.flan:2:8: unknown name bogus + 2 | (+ a bogus)) + | ^^^^^ +bad.flan:5:8: unknown name nope + 5 | (- a nope)) + | ^^^^ +bad.flan:8:3: unknown function mystery + 8 | (mystery 1 2)) + | ^^^^^^^^^^^^^ +3 errors +``` + +**No editor work was needed and none was done.** Flycheck and a structured JSON report were both considered and are +not wanted: the workflow is compile-at-the-end, not live linting, and the GNU first line already buys the clickable +list. diff --git a/NEXT.md b/NEXT.md index 70ec092..4a9cb5c 100644 --- a/NEXT.md +++ b/NEXT.md @@ -862,47 +862,46 @@ run one lane at a time; item 4 is disjoint and runs alongside any of them. because `check_dotimes` folds the step into the body and a `continue` branching to the header would skip it and hang. -8. **Errors: a structured value with spans and notes, and more than one per compile.** One piece of work, not two — - both need `Loc.Error` to stop being a single location plus a string. +8. ~~**Errors: a structured value with spans and notes, and more than one per compile.**~~ **Built.** See + *An error is a value, and there is more than one of them* in [`BUILT.md`](BUILT.md). `Loc.Error` carries a + `diag` — a stable `kind`, a *span*, `notes` that each have their own span and severity, and the macro expansion + the error came out of — and `flan check`/`flan build` print the source line with the offending span underlined, + in the GNU format `compilation-mode` already parses. No editor work was needed and none was done. - **Today:** `lib/loc.ml` carries a point location and a message, and `Loc.Error` is the frontend's *one* exception, so - the first error aborts the run. The author's workflow is write everything, compile at the end, squash the list — - which cannot work when there is never a list. The *content* of the messages is already good; they state the reason - and name what to write instead. What is missing is structure and volume. + What made it cheap, and is worth knowing before anything else is retrofitted onto locations: **the span went into + `Loc.t` itself**, as an exclusive end defaulting to the start. A location nobody widened is a zero-width span at a + point, so every one of the ~260 refusal sites kept its meaning, only the reader had to learn to fill the end in, + and `Form`, `Ast` and `Tast` were not touched. **Macro provenance went the same way** — a `macro : string option` + on the location — because `Expand.unmarshal` already stamps the call site onto every node a macro produces, so the + tag travels to the checker for free. - **jank is the model** (`~/Repositories/jank`, `compiler+runtime/include/cpp/jank/error.hpp`). It is a Lisp on LLVM - with unusually good diagnostics and three things worth taking: + **Three things deliberately not built, so they do not read as oversights:** - - **A named `kind` per error** — roughly a hundred, `lex_unterminated_string`, `parse_odd_entries_in_map` — each with - a stable string id. Machine-readable classification with no JSON mode and no prose parsing. - - **A source *span*, not a point.** This is what draws Elm's squiggle: you underline a range. A column number cannot. - - **Notes: an error carries zero or more, each with its own span and its own severity** (info/warning/error), sorted - by position. **This is the actual secret of Elm-quality messages** — "this is wrong *here*" plus "because of *that* - over there", two places highlighted and each explained. One location and one string can never express it. + - **The reader does not collect.** There is no resynchronising a paren stream — after an unclosed bracket nothing + knows whether the next `)` closes this form or the one above it. First error, stop. + - **Pass one of the checker does not collect either.** Signatures are a foundation: a declaration pass one could + not make sense of leaves a hole that pass two reports once per mention, and thirty "unknown name" lines under + one wrong signature are the same error thirty times. Pass two — bodies, where the volume is — collects per + declaration. + - **There are not a hundred kinds.** The reader's fourteen have them and the checker's have them where a test + asserts on one; `check.ml` alone has 163 refusal sites and minting an id for each is a sweep nothing reads. - jank also carries the **macro expansion** an error came from, which this project will want once macros land, and it - is worth building the field now rather than retrofitting it. + **What the daemon sees, which the brief asked to be worked out and stated:** the single-diagnostic exception is + still the single-diagnostic exception. `Session.eval` and the daemon check one form, keep catching `Loc.Error`, + and take a location and a message out with `Loc.summary`; `dev.ml` and `session.ml` needed nothing but the + pattern rewrite. The list is a second exception, `Loc.Errors`, raised only by `Parse.program_all` / + `Check.program_all` — **separate names rather than a `~keep_going` flag**, so a list cannot reach a handler that + does not name it without somebody editing the session. - **Then collect rather than raise:** finish the function, finish the file, report everything found. Error recovery in - a checker is real work — the hard part is resynchronising after a bad form without cascading nonsense — and it is - what the workflow actually needs. + **Left for later, small and independent:** notes on the type-mismatch errors, which are the most common class and + want the *parameter's* declaration as the second place — `env.fns` stores types and not locations today, so that + is a small change to what `collect` records. And a checker error on macro-produced code names the macro but has no + separate location to point at, because the expansion has no source of its own; the note lands on the call site + beside the error, which tells the reader the code being refused is not the code they wrote and no more than that. - **No editor work is required.** Flan already prints `file:line:col: message`, the GNU format Emacs's - `compilation-mode` parses with no configuration, so `M-x compile` gives a clickable list and `next-error` free. - Flycheck and a structured JSON report were both considered and are **not** wanted — the workflow is - compile-at-the-end, not live linting. - - **Cannot run beside the current lanes**: it touches every file that raises, which is the whole frontend. - -8b. **The old entry, kept for its one extra fact:** Raised by the author's workflow: write everything, compile at the end, squash - the list. That does not work today — `Loc.Error` is the frontend's **one** exception, so the first error aborts the - run and you get them one at a time, which is exactly the loop that workflow exists to avoid. - - The fix is in the checker, not in tooling: collect errors and carry on — finish the function, finish the file, - report everything found. **No editor work is needed once that exists.** Flan already prints `file:line:col: message`, - which is the GNU format Emacs's `compilation-mode` parses with no configuration, so `M-x compile` gives a clickable - list and `next-error` for free. Flycheck and a structured JSON report were both considered and are **not** wanted: - the author's workflow is compile-at-the-end, not live linting. +8b. ~~**The old entry, kept for its one extra fact.**~~ **Subsumed by 8, and it was right about the tooling:** no + editor work was needed and none was done. Flycheck and a structured JSON report stay refused for the reason it + gave — the workflow is compile-at-the-end, not live linting. 9. **Signature generations and stale-caller warnings.** The biggest remaining hole in "you never restart the program" — a changed signature is still refused rather than versioned. Last because it is the largest and nothing else waits on