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:
parent
41b60d2e4a
commit
a7ea3ef940
140
BUILT.md
140
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
|
||||
`<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
69
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user