What the error value is, and the three things it deliberately is not

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.
This commit is contained in:
Joseph Ferano 2026-09-13 08:06:25 +07:00
parent 41b60d2e4a
commit a7ea3ef940
2 changed files with 174 additions and 35 deletions

140
BUILT.md
View File

@ -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
`<unknown>`, 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.

69
NEXT.md
View File

@ -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