An error is a span with notes, and a run reports more than one
This commit is contained in:
commit
6891592d79
149
BUILT.md
149
BUILT.md
@ -4046,3 +4046,152 @@ 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 a place the compilation buffer knows about.
|
||||
|
||||
**Stated at its true strength, because it was checked rather than assumed** (`compile.el`, Emacs 30.2). The `gnu`
|
||||
entry in `compilation-error-regexp-alist-alist` puts `Note`/`note` in the *same capture group* as `Info`/`info` —
|
||||
group 7, level 0 — while `warning` is group 6, level 1. `compilation-skip-threshold` defaults to **1**, "skip
|
||||
anything less than warning". So: **errors** are navigable with `next-error` out of the box, which is the claim that
|
||||
matters and the one `M-x compile` rests on. **Notes** are parsed, coloured and clickable, and `next-error` steps over
|
||||
them until `compilation-skip-threshold` is 0. Renaming the label from `info:` to `note:` does not change that — same
|
||||
group. Labelling notes `warning:` *would* make them navigable at the default, and is refused: a note is not a
|
||||
warning, and a compile whose only complaint is an error would start reporting warnings that are not warnings.
|
||||
|
||||
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.
|
||||
|
||||
76
NEXT.md
76
NEXT.md
@ -921,47 +921,55 @@ 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.
|
||||
- **`Load` and `Shim` do not collect.** They sit between the two collecting phases and still stop at the first
|
||||
refusal, for pass one's reason: an import that could not be resolved leaves a hole the checker would report
|
||||
once per use.
|
||||
|
||||
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.
|
||||
**One claim checked rather than assumed,** and it is weaker than it first reads: `compile.el` groups `note` with
|
||||
`info` at level 0, and `compilation-skip-threshold` defaults to 1, so `next-error` walks the **errors** with no
|
||||
configuration — that part holds — but steps over the notes unless the threshold is set to 0. The notes are still
|
||||
parsed, coloured and clickable. Labelling them `warning:` would make them navigable and is refused: a note is not
|
||||
a warning.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
29
bin/main.ml
29
bin/main.ml
@ -1,9 +1,18 @@
|
||||
(* flan — milestone 2 driver. *)
|
||||
|
||||
(* Both error channels, in the one place that prints them. A single refusal
|
||||
still exits 1 and still opens with [file:line:col: message]; a driver that
|
||||
got to the end of the file hands over everything it found, sorted, with a
|
||||
count after it. Nothing here parses the message — the squiggle comes from
|
||||
the span and the classification from the kind. *)
|
||||
let with_errors path f =
|
||||
try f () with
|
||||
| Flan.Loc.Error (loc, msg) ->
|
||||
Printf.eprintf "%s: %s\n" (Flan.Loc.to_string loc) msg;
|
||||
| Flan.Loc.Error d ->
|
||||
prerr_endline (Flan.Loc.report d);
|
||||
ignore path;
|
||||
exit 1
|
||||
| Flan.Loc.Errors ds ->
|
||||
prerr_endline (Flan.Loc.report_all ds);
|
||||
ignore path;
|
||||
exit 1
|
||||
|
||||
@ -33,10 +42,14 @@ let summarise (d : Flan.Ast.decl) =
|
||||
(* Every path past [parse] goes through [Load]: an import is resolved into the
|
||||
declarations it stands for, and the package's C shim and linker arguments
|
||||
come back with them. *)
|
||||
(* Every driver here is the batch case, which is the one the workflow is: write
|
||||
everything, compile at the end, work through the list. So every one of them
|
||||
asks for the whole list rather than the first thing wrong. *)
|
||||
let load path : Flan.Load.t =
|
||||
Flan.Load.program ~file:path (Flan.Parse.program (Flan.Reader.read_file path))
|
||||
Flan.Load.program ~file:path
|
||||
(Flan.Parse.program_all (Flan.Reader.read_file path))
|
||||
|
||||
let checked path = Flan.Check.program (load path).decls
|
||||
let checked path = Flan.Check.program_all (load path).decls
|
||||
|
||||
(* What the source called each parameter, per function. The typed IR refers to
|
||||
locals by slot index and records no names — [Check] has them in its scope
|
||||
@ -123,7 +136,7 @@ let () =
|
||||
(fun path ->
|
||||
with_errors path (fun () ->
|
||||
Flan.Reader.read_file path
|
||||
|> Flan.Parse.program
|
||||
|> Flan.Parse.program_all
|
||||
|> List.iter (fun d -> print_endline (summarise d))))
|
||||
files
|
||||
| _ :: "check" :: files when files <> [] ->
|
||||
@ -277,7 +290,7 @@ let () =
|
||||
with_errors path (fun () ->
|
||||
let l = load path in
|
||||
let pnames = if debug then param_names l else [] in
|
||||
Flan.Check.program l.decls
|
||||
Flan.Check.program_all l.decls
|
||||
|> Flan.Emit.program ~checks ~dev ~debug ~pnames ~sanitize
|
||||
|> print_string))
|
||||
files
|
||||
@ -309,7 +322,7 @@ let () =
|
||||
in
|
||||
with_errors path (fun () ->
|
||||
let l = load path in
|
||||
let p = Flan.Check.program l.decls in
|
||||
let p = Flan.Check.program_all l.decls in
|
||||
(* The link follows the program, not the import list: a package nothing
|
||||
reachable calls into contributes no C and no linker argument, and its
|
||||
functions are not emitted either. That is what lets one file import
|
||||
@ -386,7 +399,7 @@ let () =
|
||||
(Printf.sprintf "flan-run-%d" (Unix.getpid ()))
|
||||
in
|
||||
let l = load path in
|
||||
let p = Flan.Check.program l.decls in
|
||||
let p = Flan.Check.program_all l.decls in
|
||||
let p, csrcs, lflags = Flan.Reach.link l p in
|
||||
ignore (Flan.Build.executable ~csrcs ~lflags p ~out:exe);
|
||||
let code =
|
||||
|
||||
162
lib/check.ml
162
lib/check.ml
@ -89,6 +89,33 @@ let new_env () = {
|
||||
lifted = [];
|
||||
}
|
||||
|
||||
(* Where a named type was declared, and what it has, as a note.
|
||||
|
||||
This is the second half of the two-place messages: a refusal that says
|
||||
[Cursor has no field pos] is true, and the reader's next move is always to
|
||||
go and look at Cursor. Attaching the declaration's location and its actual
|
||||
field names means the answer arrives with the question, and [next-error]
|
||||
will take you there because a note prints as an entry of its own. Empty when
|
||||
the name is not one this environment placed, so it degrades to the message
|
||||
alone rather than to a wrong pointer. *)
|
||||
let declared_note env name =
|
||||
match Hashtbl.find_opt env.locs name with
|
||||
| None -> []
|
||||
| Some at ->
|
||||
let names =
|
||||
match Hashtbl.find_opt env.structs name with
|
||||
| Some s -> List.map (fun (f : Tast.field) -> f.Tast.fname) s.Tast.fields
|
||||
| None ->
|
||||
(match Hashtbl.find_opt env.unions name with
|
||||
| Some u -> List.map (fun (c : Tast.variant) -> c.Tast.vname) u.Tast.cases
|
||||
| None -> [])
|
||||
in
|
||||
let what =
|
||||
if names = [] then name ^ " is declared here"
|
||||
else name ^ " is declared here, with " ^ String.concat ", " names
|
||||
in
|
||||
[ Loc.note at what ]
|
||||
|
||||
(* What a [break] or a [continue] may be talking about, innermost first.
|
||||
|
||||
[Lloop] is a loop it is lexically inside, carrying its label if it was given
|
||||
@ -272,12 +299,9 @@ let captured ctx loc name =
|
||||
"an fn is lifted into a function of its own and is handed nothing but \
|
||||
its parameters. Pass it in, or use a global"
|
||||
in
|
||||
raise
|
||||
(Loc.Error
|
||||
(loc,
|
||||
Printf.sprintf
|
||||
"%s cannot see %s: it is a local of the enclosing function, and \
|
||||
%s." what name why))
|
||||
Loc.failk "check/capture" loc
|
||||
"%s cannot see %s: it is a local of the enclosing function, and %s."
|
||||
what name why
|
||||
| _ -> ()
|
||||
|
||||
let scoped ctx f =
|
||||
@ -514,7 +538,7 @@ and resolve_name env ~seen loc n =
|
||||
below would otherwise report [f65] as unimplemented generics and send
|
||||
you to plan.org instead of to the character you mistyped. *)
|
||||
| _ when near_miss env n <> None ->
|
||||
fail loc "unknown type %s — did you mean %s?" n
|
||||
Loc.failk "check/unknown-type" loc "unknown type %s — did you mean %s?" n
|
||||
(Option.get (near_miss env n))
|
||||
(* Lowercase is a type variable, Capitalized is concrete — no sigil
|
||||
(plan.org, Types). A variable parses, but nothing at milestone 2 can
|
||||
@ -522,7 +546,7 @@ and resolve_name env ~seen loc n =
|
||||
| _ when n <> "" && n.[0] = Char.lowercase_ascii n.[0] ->
|
||||
unimplemented loc
|
||||
(Printf.sprintf "generic code over the type variable %s" n) 5
|
||||
| _ -> fail loc "unknown type %s" n
|
||||
| _ -> Loc.failk "check/unknown-type" loc "unknown type %s" n
|
||||
|
||||
and array_len env loc = function
|
||||
| Ast.Lint n -> n
|
||||
@ -1032,7 +1056,9 @@ let rec check ctx ?want (e : Ast.expr) : Tast.expr =
|
||||
let target, sname = struct_target ctx target in
|
||||
let s = Hashtbl.find ctx.env.structs sname in
|
||||
(match Tast.field_index s name with
|
||||
| None -> fail loc "%s has no field %s" sname name
|
||||
| None ->
|
||||
Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname)
|
||||
"%s has no field %s" sname name
|
||||
| Some i ->
|
||||
let fty = (List.nth s.Tast.fields i).Tast.fty in
|
||||
expect loc ~want (mk loc fty (Tast.Field (target, i))))
|
||||
@ -1269,7 +1295,8 @@ and var ctx loc ~want name =
|
||||
and pass that" name;
|
||||
expect loc ~want
|
||||
(mk loc (Types.Fn (params, ret)) (Tast.FnAddr (Tast.Fnval name)))
|
||||
| None -> captured ctx loc name; fail loc "unknown name %s" name)
|
||||
| None -> captured ctx loc name;
|
||||
Loc.failk "check/unknown-name" loc "unknown name %s" name)
|
||||
|
||||
(* Reading a move-only local. Every read is a move unless the site said it was
|
||||
a borrow, which is the conservative direction: passing one to a function,
|
||||
@ -1794,15 +1821,23 @@ and check_struct ctx ~want loc name kvs =
|
||||
"%s is a union, and a union value names the case as well as the \
|
||||
type — write (%s.%s {.field value ...}) for one of %s"
|
||||
name name (first_case_name ctx.env name) (case_list ctx.env name)
|
||||
else fail loc "unknown struct %s" name)
|
||||
else
|
||||
Loc.failk "check/unknown-struct" loc ~notes:(declared_note ctx.env name)
|
||||
"unknown struct %s" name)
|
||||
| Some s ->
|
||||
let seen = Hashtbl.create 8 in
|
||||
List.iter
|
||||
(fun (k, (v : Ast.expr)) ->
|
||||
if Hashtbl.mem seen k then
|
||||
fail v.Ast.loc "field %s is given twice" k;
|
||||
(match Hashtbl.find_opt seen k with
|
||||
| Some (first : Ast.expr) ->
|
||||
Loc.failk "check/duplicate-field" v.Ast.loc
|
||||
~notes:[ Loc.note first.Ast.loc (k ^ " is given here first") ]
|
||||
"field %s is given twice" k
|
||||
| None -> ());
|
||||
if Tast.field_index s k = None then
|
||||
fail v.Ast.loc "%s has no field %s" name k;
|
||||
Loc.failk "check/unknown-field" v.Ast.loc
|
||||
~notes:(declared_note ctx.env name)
|
||||
"%s has no field %s" name k;
|
||||
Hashtbl.add seen k v)
|
||||
kvs;
|
||||
(* Omitted fields are zeroed — ZII, the same rule as a declaration with no
|
||||
@ -1840,9 +1875,16 @@ and check_case ctx ~want loc uname (c : Tast.variant) kvs =
|
||||
let seen = Hashtbl.create 8 in
|
||||
List.iter
|
||||
(fun (k, (v : Ast.expr)) ->
|
||||
if Hashtbl.mem seen k then fail v.Ast.loc "field %s is given twice" k;
|
||||
(match Hashtbl.find_opt seen k with
|
||||
| Some (first : Ast.expr) ->
|
||||
Loc.failk "check/duplicate-field" v.Ast.loc
|
||||
~notes:[ Loc.note first.Ast.loc (k ^ " is given here first") ]
|
||||
"field %s is given twice" k
|
||||
| None -> ());
|
||||
if Tast.vfield_index c k = None then
|
||||
fail v.Ast.loc "%s has no field %s" full k;
|
||||
Loc.failk "check/unknown-field" v.Ast.loc
|
||||
~notes:(declared_note ctx.env uname)
|
||||
"%s has no field %s" full k;
|
||||
Hashtbl.add seen k v)
|
||||
kvs;
|
||||
let fields =
|
||||
@ -2021,7 +2063,12 @@ and check_match ctx ?want loc scrutinee arms =
|
||||
u.Tast.cases
|
||||
in
|
||||
if not !saw_wild && missing <> [] then
|
||||
fail loc
|
||||
(* The union's declaration, because that is where the case list this match
|
||||
failed to cover actually lives, and because adding a case there is what
|
||||
makes a match non-exhaustive in the first place. *)
|
||||
Loc.failk "check/non-exhaustive-match" loc
|
||||
~notes:(match subject with `Union u -> declared_note ctx.env u.Tast.uname
|
||||
| _ -> [])
|
||||
"this match is not exhaustive — %s %s no arm. Add %s, or a _ arm for \
|
||||
the rest"
|
||||
(String.concat ", " missing)
|
||||
@ -2069,12 +2116,15 @@ and check_place ctx loc (p : Ast.place) : Tast.place * Types.t =
|
||||
match Hashtbl.find_opt ctx.env.globals name with
|
||||
| Some (_, true) -> fail loc "%s is a constant" name
|
||||
| Some (ty, false) -> Tast.Pglobal name, ty
|
||||
| None -> captured ctx loc name; fail loc "unknown name %s" name)
|
||||
| None -> captured ctx loc name;
|
||||
Loc.failk "check/unknown-name" loc "unknown name %s" name)
|
||||
| Ast.Pfield (target, name) ->
|
||||
let target, sname = struct_target ctx target in
|
||||
let s = Hashtbl.find ctx.env.structs sname in
|
||||
(match Tast.field_index s name with
|
||||
| None -> fail loc "%s has no field %s" sname name
|
||||
| None ->
|
||||
Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname)
|
||||
"%s has no field %s" sname name
|
||||
| Some i -> Tast.Pfield (target, i), (List.nth s.Tast.fields i).Tast.fty)
|
||||
| Ast.Pindex (target, idx) ->
|
||||
let target = borrowed ctx target (fun () -> check ctx target) in
|
||||
@ -3927,7 +3977,7 @@ and named_call ctx ~want loc name args =
|
||||
else if String.contains name '/' then
|
||||
unimplemented loc
|
||||
(Printf.sprintf "the call %s into an imported package" name) 4
|
||||
else fail loc "unknown function %s" name
|
||||
else Loc.failk "check/unknown-function" loc "unknown function %s" name
|
||||
|
||||
and is_cast name =
|
||||
Types.ikind_of_name name <> None || Types.fkind_of_name name <> None
|
||||
@ -4006,9 +4056,16 @@ let collect env (decls : Ast.decl list) =
|
||||
match Ast.declared_name d with
|
||||
| None -> ()
|
||||
| Some n ->
|
||||
if Hashtbl.mem claimed n then
|
||||
fail d.Ast.dloc "%s is defined twice" n;
|
||||
Hashtbl.add claimed n ())
|
||||
(match Hashtbl.find_opt claimed n with
|
||||
| Some first ->
|
||||
(* The second one is the error, because it is the one to delete;
|
||||
the first is the note, because without it the message is a
|
||||
claim the reader has to go and verify. *)
|
||||
Loc.failk "check/defined-twice" d.Ast.dloc
|
||||
~notes:[ Loc.note first (n ^ " is already defined here") ]
|
||||
"%s is defined twice" n
|
||||
| None -> ());
|
||||
Hashtbl.add claimed n d.Ast.dloc)
|
||||
decls;
|
||||
(* Names first, so a struct may mention one declared below it. *)
|
||||
List.iter
|
||||
@ -4269,8 +4326,21 @@ let check_fn env (fn : Ast.fn) : Tast.fn =
|
||||
owner = fn.Ast.name } in
|
||||
List.iter2
|
||||
(fun (p : Ast.field) ty ->
|
||||
if List.mem_assoc p.Ast.fname ctx.scope then
|
||||
fail p.Ast.floc "%s has two parameters named %s" fn.Ast.name p.Ast.fname;
|
||||
if List.mem_assoc p.Ast.fname ctx.scope then begin
|
||||
let first =
|
||||
List.find_opt
|
||||
(fun (q : Ast.field) -> q.Ast.fname = p.Ast.fname)
|
||||
fn.Ast.params
|
||||
in
|
||||
let notes =
|
||||
match first with
|
||||
| Some q when q != p ->
|
||||
[ Loc.note q.Ast.floc ("the first " ^ p.Ast.fname ^ " is here") ]
|
||||
| _ -> []
|
||||
in
|
||||
Loc.failk "check/duplicate-parameter" p.Ast.floc ~notes
|
||||
"%s has two parameters named %s" fn.Ast.name p.Ast.fname
|
||||
end;
|
||||
ignore (bind ctx p.Ast.fname ty ~assignable:false))
|
||||
fn.Ast.params params;
|
||||
let body =
|
||||
@ -4435,7 +4505,11 @@ let check_main env =
|
||||
expression typed at a REPL against the program the process is running — and
|
||||
it has to be this one rather than anything rebuilt from declarations,
|
||||
because [program] prepends the prelude and no accumulated AST contains it. *)
|
||||
let program_with_env (decls : Ast.decl list) : Tast.program * env =
|
||||
(* Separate entry points below rather than a flag on the one the session calls,
|
||||
for the reason [Parse] gives at the same fork: [Loc.Errors] is a second
|
||||
exception that the session and the daemon do not catch, so the guarantee
|
||||
that they never see one should be structural and not a default argument. *)
|
||||
let build_program ~keep_going (decls : Ast.decl list) : Tast.program * env =
|
||||
let env = new_env () in
|
||||
let decls = Parse.program (Prelude.forms ()) @ decls in
|
||||
(* Before anything is collected: every (declare-c ...) becomes an ordinary
|
||||
@ -4443,18 +4517,34 @@ let program_with_env (decls : Ast.decl list) : Tast.program * env =
|
||||
flattening comes back to be compiled into the build. Nothing below this
|
||||
line knows the form exists. *)
|
||||
let decls, cshim = Shim.expand decls in
|
||||
(* Pass one, and it stops at the first thing it refuses. That is not
|
||||
laziness: every name, type and signature in the file comes from here, so a
|
||||
declaration this pass could not make sense of leaves a hole that pass two
|
||||
would report once per mention. A wrong signature is one error; the thirty
|
||||
"unknown name" lines under it are not errors, they are the same one.
|
||||
|
||||
Pass two is where the volume is, and it is where collecting pays. By the
|
||||
time it runs every signature is sound, so a body that fails to check
|
||||
cannot make the next body fail — which is what makes a declaration a
|
||||
resync point that needs no resynchronising. *)
|
||||
collect env decls;
|
||||
check_finite env;
|
||||
check_main env;
|
||||
let globals = List.filter_map (check_global env) decls in
|
||||
let s = Loc.sink ~on:keep_going in
|
||||
ignore (Loc.caught s (fun () -> check_main env));
|
||||
let globals =
|
||||
List.filter_map
|
||||
(fun d -> Option.join (Loc.caught s (fun () -> check_global env d)))
|
||||
decls
|
||||
in
|
||||
let fns =
|
||||
List.filter_map
|
||||
(fun (d : Ast.decl) ->
|
||||
match d.Ast.d with
|
||||
| Ast.Defn fn -> Some (check_fn env fn)
|
||||
| Ast.Defn fn -> Loc.caught s (fun () -> check_fn env fn)
|
||||
| _ -> None)
|
||||
decls
|
||||
in
|
||||
Loc.finish s;
|
||||
(* The handler clauses lifted out along the way. They are ordinary functions
|
||||
from here down; nothing in the backend knows they were written inside
|
||||
something else. *)
|
||||
@ -4478,7 +4568,19 @@ let program_with_env (decls : Ast.decl list) : Tast.program * env =
|
||||
globals; externs; fns; cshim },
|
||||
env)
|
||||
|
||||
let program (decls : Ast.decl list) : Tast.program = fst (program_with_env decls)
|
||||
(** The program and the environment, stopping at the first refusal. What a
|
||||
session needs, and it raises [Loc.Error] and never [Loc.Errors]. *)
|
||||
let program_with_env (decls : Ast.decl list) : Tast.program * env =
|
||||
build_program ~keep_going:false decls
|
||||
|
||||
let program (decls : Ast.decl list) : Tast.program =
|
||||
fst (build_program ~keep_going:false decls)
|
||||
|
||||
(** The same, reporting every declaration whose body it refuses rather than the
|
||||
first. Raises [Loc.Errors], so only a caller prepared for a list should be
|
||||
calling it. *)
|
||||
let program_all (decls : Ast.decl list) : Tast.program =
|
||||
fst (build_program ~keep_going:true decls)
|
||||
|
||||
(* One expression, checked against a program that is already running. The
|
||||
frame is empty — a REPL expression has no parameters and no enclosing
|
||||
|
||||
@ -464,7 +464,7 @@ let eval t ~code ~origin =
|
||||
("cannot reach the program on " ^ t.agent ^ ": "
|
||||
^ Unix.error_message e))
|
||||
| exception Failure m -> error m)
|
||||
| exception Loc.Error (l, msg) -> error ~loc:(Loc.to_string l) msg
|
||||
| exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> error ~loc:(Loc.to_string l) msg
|
||||
|
||||
(* Redefining a name installs a body; evaluating an expression has no name to
|
||||
install into, so the module carries a thunk the agent runs once. The value
|
||||
@ -503,7 +503,7 @@ let eval_expr t ~code ~origin =
|
||||
| exception Unix.Unix_error (e, _, _) ->
|
||||
error ("cannot reach the program: " ^ Unix.error_message e))
|
||||
| exception Failure m -> error m)
|
||||
| exception Loc.Error (l, msg) -> error ~loc:(Loc.to_string l) msg
|
||||
| exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> error ~loc:(Loc.to_string l) msg
|
||||
|
||||
let describe t =
|
||||
ok
|
||||
@ -1779,7 +1779,7 @@ let serve t fd =
|
||||
let op, reply =
|
||||
match Wire.parse src with
|
||||
| req -> (Wire.string_field req "op", handle t req)
|
||||
| exception Loc.Error (_, m) -> (None, error ("bad request: " ^ m))
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> (None, error ("bad request: " ^ m))
|
||||
in
|
||||
Wire.send fd (with_output t (with_break t reply));
|
||||
if op = Some "close" then true else go ()
|
||||
|
||||
@ -63,7 +63,7 @@ and pkg = { alias : string; dir : string; owns : string list;
|
||||
import. See [Cimport]. *)
|
||||
phidden : (string * string) list }
|
||||
|
||||
let fail loc fmt = Printf.ksprintf (fun m -> raise (Loc.Error (loc, m))) fmt
|
||||
let fail loc fmt = Printf.ksprintf (fun m -> Loc.raise_diag (Loc.diag loc m)) fmt
|
||||
|
||||
(* "vendor:raylib" -> the collection "vendor" and the subpath "raylib". A path
|
||||
with no colon is relative to the importing file's own directory. *)
|
||||
|
||||
333
lib/loc.ml
333
lib/loc.ml
@ -1,19 +1,334 @@
|
||||
(** Source locations. Every form carries one: error messages, the step debugger
|
||||
and nREPL's find-definition all need them, and retrofitting locations onto a
|
||||
reader is far worse than carrying them from the start. *)
|
||||
reader is far worse than carrying them from the start.
|
||||
|
||||
A location is a *span*, not a point. The start is what [file:line:col]
|
||||
prints and what every consumer that wants one place uses; the end is what
|
||||
lets an error underline the thing it is about. A column number cannot draw
|
||||
a squiggle and a span can, which is the whole reason the two extra fields
|
||||
are here.
|
||||
|
||||
The end is *exclusive* and defaults to the start, so a location nobody
|
||||
widened is a zero-width span at a point and every old call site keeps its
|
||||
old meaning. Only the reader knows where a form ends, so only the reader
|
||||
fills these in; a location the checker invents for a node with no syntax
|
||||
stays a point. *)
|
||||
|
||||
type t = {
|
||||
file : string;
|
||||
line : int; (* 1-based *)
|
||||
col : int; (* 1-based *)
|
||||
file : string;
|
||||
line : int; (* 1-based *)
|
||||
col : int; (* 1-based *)
|
||||
eline : int; (* 1-based, exclusive end *)
|
||||
ecol : int;
|
||||
(* The macro whose expansion produced whatever is at this position, if one
|
||||
did. It rides on the location rather than 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 here means an error raised
|
||||
anywhere downstream can say which macro it is really about, with no field
|
||||
added to Form, to Ast or to Tast. *)
|
||||
macro : string option;
|
||||
}
|
||||
|
||||
let make file line col = { file; line; col }
|
||||
let unknown = { file = "<unknown>"; line = 0; col = 0 }
|
||||
let make file line col =
|
||||
{ file; line; col; eline = line; ecol = col; macro = None }
|
||||
|
||||
let unknown = make "<unknown>" 0 0
|
||||
|
||||
(** Tag a location as coming out of [name]'s expansion, unless it already names
|
||||
a macro. Already-tagged wins because the tag is applied outermost-last: the
|
||||
macro the author actually wrote is the one worth naming, not whatever it
|
||||
expanded into on the way. *)
|
||||
let from_macro name (t : t) =
|
||||
match t.macro with None -> { t with macro = Some name } | Some _ -> t
|
||||
|
||||
(** [upto start stop] is [start] widened to end where [stop] begins. A [stop]
|
||||
that is not after [start], or is in another file, leaves it alone: a span
|
||||
that runs backwards would draw nonsense. *)
|
||||
let upto (start : t) (stop : t) =
|
||||
if
|
||||
stop.file = start.file
|
||||
&& (stop.line > start.line
|
||||
|| (stop.line = start.line && stop.col > start.col))
|
||||
then { start with eline = stop.line; ecol = stop.col }
|
||||
else start
|
||||
|
||||
(** True when the span covers more than its first line. The underline is drawn
|
||||
on the start line either way — a form that spans twenty lines is pointed
|
||||
at, not boxed — so this is what tells the renderer to run the underline to
|
||||
the end of that line rather than to [ecol]. *)
|
||||
let multiline (t : t) = t.eline > t.line
|
||||
|
||||
(** How many columns to underline on the start line, or [None] when the span
|
||||
was never widened and there is nothing but a point to draw. *)
|
||||
let width (t : t) =
|
||||
if t.eline = t.line && t.ecol > t.col then Some (t.ecol - t.col) else None
|
||||
|
||||
let to_string t = Printf.sprintf "%s:%d:%d" t.file t.line t.col
|
||||
|
||||
(** Raised by every stage of the frontend. *)
|
||||
exception Error of t * string
|
||||
(* ── Diagnostics ──────────────────────────────────────────
|
||||
|
||||
let fail loc fmt = Printf.ksprintf (fun msg -> raise (Error (loc, msg))) fmt
|
||||
An error is a value rather than a location and a string, and the three parts
|
||||
that make it one each buy something the pair could not express.
|
||||
|
||||
[kind] is a stable id — ["reader/unterminated-string"]. It classifies
|
||||
without any prose being parsed, so a message may be reworded freely and a
|
||||
test that asserts on *which* error this is keeps holding. It is not a
|
||||
replacement for the message: the messages here already state the reason and
|
||||
name what to write instead, and none of them changed.
|
||||
|
||||
[dloc] is a span, so a report can underline the thing it is about.
|
||||
|
||||
[notes] are the part that makes a message good. Each carries its own span
|
||||
and its own severity, so an error can say "this is wrong *here*" and
|
||||
"because of *that*, over there" and point at both. One string can only ever
|
||||
state one of the two, which is why volume of messages was never the whole
|
||||
of what was missing.
|
||||
|
||||
[expansion] names the macro call an error came from. A form a macro produced
|
||||
carries the call site's location, so without this the report would point at
|
||||
the call and say nothing about the code not being what was written there. It
|
||||
is filled in for errors raised *during* expansion; a checker error on a form
|
||||
a macro produced gets the call site's location without the macro's name,
|
||||
which is a limitation and not a claim. *)
|
||||
|
||||
type severity = Info | Warning | Err
|
||||
|
||||
let severity_str = function
|
||||
| Info -> "info"
|
||||
| Warning -> "warning"
|
||||
| Err -> "error"
|
||||
|
||||
type note = {
|
||||
nmsg : string;
|
||||
nloc : t;
|
||||
nsev : severity;
|
||||
}
|
||||
|
||||
type diag = {
|
||||
kind : string; (* stable id, never printed as the reason *)
|
||||
dloc : t; (* the primary span *)
|
||||
dmsg : string;
|
||||
notes : note list; (* in source order *)
|
||||
expansion : (string * t) option; (* macro name, and its call site *)
|
||||
}
|
||||
|
||||
(** Raised by every stage of the frontend, one diagnostic at a time.
|
||||
|
||||
This is still the single-error channel, and that is deliberate: the daemon
|
||||
and [Session.eval] evaluate *one* form and have one failure to report, so
|
||||
they keep catching this and take a location and a message out of it with
|
||||
[summary]. Only the batch drivers — the ones that compile a whole file —
|
||||
raise the list below, and nothing interactive has to know it exists. *)
|
||||
exception Error of diag
|
||||
|
||||
(** Raised by a driver that finished the file before reporting. Never empty,
|
||||
and never raised by a path that checks a single form. *)
|
||||
exception Errors of diag list
|
||||
|
||||
(** The one location and one message a caller with a single line to print gets
|
||||
out of a diagnostic. Notes are dropped here on purpose. *)
|
||||
let summary (d : diag) = (d.dloc, d.dmsg)
|
||||
|
||||
let before (a : t) (b : t) =
|
||||
if a.line <> b.line then compare a.line b.line else compare a.col b.col
|
||||
|
||||
(* Notes in source order, as jank sorts them: a reader follows a message that
|
||||
walks down the file and loses one that jumps about. *)
|
||||
let sort_notes (d : diag) =
|
||||
{ d with notes = List.stable_sort (fun x y -> before x.nloc y.nloc) d.notes }
|
||||
|
||||
let note ?(sev = Info) loc msg = { nmsg = msg; nloc = loc; nsev = sev }
|
||||
|
||||
let diag ?(kind = "error") ?(notes = []) ?expansion loc msg =
|
||||
(* The location already knows whether it came out of a macro, so an error
|
||||
does not have to be raised anywhere special to say so. An explicit
|
||||
[expansion] still wins, for the one caller that knows better. *)
|
||||
let expansion =
|
||||
match expansion with
|
||||
| Some _ as e -> e
|
||||
| None -> (match loc.macro with Some m -> Some (m, loc) | None -> None)
|
||||
in
|
||||
sort_notes { kind; dloc = loc; dmsg = msg; notes; expansion }
|
||||
|
||||
let raise_diag d = raise (Error (sort_notes d))
|
||||
|
||||
(** The plain refusal: a span and a reason, with the generic kind. Every call
|
||||
site that existed before spans is one of these and says exactly what it
|
||||
said. [failk] is the same thing with a kind, notes and an expansion. *)
|
||||
let fail loc fmt =
|
||||
Printf.ksprintf (fun msg -> raise_diag (diag loc msg)) fmt
|
||||
|
||||
let failk ?notes ?expansion kind loc fmt =
|
||||
Printf.ksprintf (fun msg -> raise_diag (diag ~kind ?notes ?expansion loc msg)) fmt
|
||||
|
||||
(* ── Collecting ─────────────────────────────────────────────────────
|
||||
|
||||
A sink holds what a pass found, so the pass can carry on to the next thing
|
||||
rather than stop at the first. It is switched on by the caller and not by
|
||||
the code that raises, which is what keeps the interactive path exactly as it
|
||||
was: the daemon checks one form and wants one exception, so it asks for a
|
||||
sink that is off, every [caught] re-raises, and nothing downstream ever sees
|
||||
a list.
|
||||
|
||||
A sink that is on is finished at a *phase* boundary and nowhere else. That
|
||||
is the whole of the resynchronisation story and it is deliberately crude:
|
||||
the hard part of recovery is not recording the error, it is not cascading
|
||||
afterwards, and a phase run on a foundation the phase before it already
|
||||
refused reports wreckage. Three real errors beat thirty of which
|
||||
twenty-seven are consequences of the first. *)
|
||||
|
||||
type sink = { on : bool; mutable found : diag list }
|
||||
|
||||
let sink ~on = { on; found = [] }
|
||||
|
||||
(** Run [f]. With the sink off this is [Some (f ())] and an error propagates as
|
||||
it always did. With it on, an error is recorded and the answer is [None],
|
||||
so the caller drops this one item and goes on to the next. *)
|
||||
let caught s f =
|
||||
if not s.on then Some (f ())
|
||||
else
|
||||
match f () with
|
||||
| x -> Some x
|
||||
| exception Error d -> s.found <- d :: s.found; None
|
||||
|
||||
let any s = s.found <> []
|
||||
|
||||
(** Raise everything found, in the order it was found, or return if the pass
|
||||
was clean. *)
|
||||
let finish s =
|
||||
match List.rev s.found with [] -> () | ds -> raise (Errors ds)
|
||||
|
||||
(* ── Reporting ──────────────────────────────────────────────────────
|
||||
|
||||
The first line of every entry is exactly [file:line:col: message], which is
|
||||
the GNU format Emacs's compilation-mode parses with no configuration. That
|
||||
is the whole of the editor story: once more than one of these comes out,
|
||||
[M-x compile] gives a clickable list and [next-error] walks it. Everything
|
||||
below the first line is indented, and compilation-mode ignores indented
|
||||
continuation lines, so the squiggle costs nothing there.
|
||||
|
||||
A note gets its own [file:line:col: note: ...] entry rather than being
|
||||
folded into the error's block, which is what gcc does and is the point of
|
||||
notes having locations at all: the second place is somewhere [next-error]
|
||||
can take you.
|
||||
|
||||
Everything degrades to the first line alone. 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 may have changed under us since it was
|
||||
read — in every one of those the message still prints and nothing raises out
|
||||
of the error printer, which would turn a diagnostic into a crash. *)
|
||||
|
||||
let source_cache : (string, string array option) Hashtbl.t = Hashtbl.create 8
|
||||
|
||||
let lines_of file =
|
||||
match Hashtbl.find_opt source_cache file with
|
||||
| Some v -> v
|
||||
| None ->
|
||||
let v =
|
||||
match open_in_bin file with
|
||||
| exception _ -> None
|
||||
| ic ->
|
||||
Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () ->
|
||||
match really_input_string ic (in_channel_length ic) with
|
||||
| exception _ -> None
|
||||
| s -> Some (Array.of_list (String.split_on_char '\n' s)))
|
||||
in
|
||||
Hashtbl.replace source_cache file v;
|
||||
v
|
||||
|
||||
let forget_sources () = Hashtbl.reset source_cache
|
||||
|
||||
let source_line (t : t) =
|
||||
if t.line <= 0 then None
|
||||
else
|
||||
match lines_of t.file with
|
||||
| None -> None
|
||||
| Some ls when t.line <= Array.length ls ->
|
||||
let l = ls.(t.line - 1) in
|
||||
(* A file written on Windows leaves the carriage return in the line, and
|
||||
it would print as a stray column. *)
|
||||
let n = String.length l in
|
||||
Some (if n > 0 && l.[n - 1] = '\r' then String.sub l 0 (n - 1) else l)
|
||||
| Some _ -> None
|
||||
|
||||
(* The gutter is as wide as the widest line number printed, so the bars line
|
||||
up. Four digits covers every file anyone will write by hand and the rest
|
||||
simply gets a wider gutter. *)
|
||||
let gutter n = String.length (string_of_int n)
|
||||
|
||||
(** The source line with the span underlined beneath it, or [None] when there
|
||||
is no source to show. Tabs in the prefix are copied into the underline
|
||||
rather than counted as one column, which is the only way the caret lands
|
||||
under the right character in a file that uses them. *)
|
||||
let squiggle ?(mark = '^') (t : t) =
|
||||
match source_line t with
|
||||
| None -> None
|
||||
| Some text ->
|
||||
let n = String.length text in
|
||||
let start = max 0 (min (t.col - 1) n) in
|
||||
let stop =
|
||||
if multiline t then n
|
||||
else match width t with
|
||||
| Some w -> min n (start + w)
|
||||
| None -> min n (start + 1)
|
||||
in
|
||||
let stop = max stop (min n (start + 1)) in
|
||||
let pad = Buffer.create 16 in
|
||||
for i = 0 to start - 1 do
|
||||
Buffer.add_char pad (if text.[i] = '\t' then '\t' else ' ')
|
||||
done;
|
||||
let bar = String.make (max 1 (stop - start)) mark in
|
||||
let g = gutter t.line in
|
||||
Some
|
||||
(Printf.sprintf " %*d | %s\n %*s | %s%s" g t.line text g ""
|
||||
(Buffer.contents pad) bar)
|
||||
|
||||
let entry ?(mark = '^') ?(label = "") (t : t) msg =
|
||||
let head = Printf.sprintf "%s: %s%s" (to_string t) label msg in
|
||||
match squiggle ~mark t with
|
||||
| None -> head
|
||||
| Some s -> head ^ "\n" ^ s
|
||||
|
||||
(** One diagnostic, rendered. The error, then its notes in source order, then
|
||||
the macro call it was expanded from if it was. No trailing newline. *)
|
||||
let report (d : diag) =
|
||||
let d = sort_notes d in
|
||||
let parts = ref [ entry d.dloc d.dmsg ] in
|
||||
List.iter
|
||||
(fun n ->
|
||||
let mark = match n.nsev with Info -> '-' | Warning -> '~' | Err -> '^' in
|
||||
parts :=
|
||||
entry ~mark ~label:(severity_str n.nsev ^ ": ") n.nloc n.nmsg :: !parts)
|
||||
d.notes;
|
||||
(match d.expansion with
|
||||
| None -> ()
|
||||
| Some (name, at) ->
|
||||
parts :=
|
||||
entry ~mark:'-' ~label:"note: " at
|
||||
(Printf.sprintf "expanded from the macro %s" name)
|
||||
:: !parts);
|
||||
String.concat "\n" (List.rev !parts)
|
||||
|
||||
(** Every diagnostic of a run, in source order, with a count. What a driver
|
||||
prints when it finished the file rather than stopping at the first thing
|
||||
it found. *)
|
||||
let report_all (ds : diag list) =
|
||||
(* Source order, with the placeless ones last. A diagnostic the checker
|
||||
raised against [unknown] — a wrong [main] signature is the one that
|
||||
happens — has line 0, and sorting on the number alone would put it at the
|
||||
top of the list, above every error that can actually be clicked. It is a
|
||||
real error and it is not anywhere, so it goes after the ones that are. *)
|
||||
let placed (d : diag) = d.dloc.line > 0 in
|
||||
let ds =
|
||||
List.stable_sort
|
||||
(fun a b ->
|
||||
match (placed a, placed b) with
|
||||
| true, false -> -1
|
||||
| false, true -> 1
|
||||
| _ -> before a.dloc b.dloc)
|
||||
ds
|
||||
in
|
||||
let n = List.length ds in
|
||||
String.concat "\n" (List.map report ds)
|
||||
^ Printf.sprintf "\n%d error%s" n (if n = 1 then "" else "s")
|
||||
|
||||
11
lib/macro.ml
11
lib/macro.ml
@ -176,7 +176,12 @@ let rec expand_form (l : loaded) (f : Form.t) : Form.t =
|
||||
match f.Form.v with
|
||||
| Form.List ({ Form.v = Form.Sym n; _ } :: args) when List.mem_assoc n l.fns ->
|
||||
let args = List.map (expand_form l) args in
|
||||
settle l n loc (Expand.call ~loc (List.assoc n l.fns) args) fuel
|
||||
(* The call site, tagged with the macro it is a call to. [Expand.unmarshal]
|
||||
stamps this onto every node the macro answers with, so from here down
|
||||
every form it produced knows where it came from and an error on one of
|
||||
them can say so. *)
|
||||
let from = Loc.from_macro n loc in
|
||||
settle l n loc (Expand.call ~loc:from (List.assoc n l.fns) args) fuel
|
||||
| Form.List xs -> Form.make (Form.List (List.map (expand_form l) xs)) loc
|
||||
| Form.Vec xs -> Form.make (Form.Vec (List.map (expand_form l) xs)) loc
|
||||
| Form.Map xs -> Form.make (Form.Map (List.map (expand_form l) xs)) loc
|
||||
@ -193,7 +198,9 @@ and settle l first loc (f : Form.t) left =
|
||||
first fuel
|
||||
else begin
|
||||
let args = List.map (expand_form l) args in
|
||||
settle l first loc (Expand.call ~loc (List.assoc m l.fns) args) (left - 1)
|
||||
let from = Loc.from_macro m loc in
|
||||
settle l first loc (Expand.call ~loc:from (List.assoc m l.fns) args)
|
||||
(left - 1)
|
||||
end
|
||||
(* Settled at the head. The rest of it may still hold macro calls — a cond
|
||||
expands to an if whose else-branch is another cond — so the ordinary walk
|
||||
|
||||
33
lib/parse.ml
33
lib/parse.ml
@ -736,7 +736,7 @@ let rec decl (f : Form.t) : Ast.decl =
|
||||
would be true and unhelpful. *)
|
||||
let rty =
|
||||
try texpr ret with
|
||||
| Loc.Error (loc, msg) ->
|
||||
| Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
|
||||
Loc.fail loc
|
||||
"%s. This is the return type, which every defn states -- a \
|
||||
function that returns nothing writes ()" msg
|
||||
@ -866,14 +866,41 @@ and variant (f : Form.t) : Ast.variant =
|
||||
unknown name, which is wrong but not silent. *)
|
||||
let expander : (Form.t list -> Form.t list) ref = ref (fun fs -> fs)
|
||||
|
||||
let program (forms : Form.t list) : Ast.decl list =
|
||||
(* Two entry points and not one function with a flag, and the reason is the
|
||||
daemon. [Loc.Errors] is a second exception, and the handlers in the session
|
||||
and in the daemon name only [Loc.Error] — so a list reaching them would be
|
||||
an unhandled exception and a dead session, which is the one thing the whole
|
||||
dev loop exists to prevent. A flag on the function the session already calls
|
||||
would put that one label away from happening. A separate name cannot: the
|
||||
session's call site has to be edited by someone for its behaviour to change.
|
||||
|
||||
[keep_going] asks for every bad declaration in the file rather than the
|
||||
first. The resync point is a top-level form, and it is the only honest one
|
||||
here: 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] is one error. *)
|
||||
let parse_forms ~keep_going (forms : Form.t list) : Ast.decl list =
|
||||
(* Quasiquote first and always, because it is pure and needs nothing loaded:
|
||||
it is what turns a macro body into ordinary code, and the prelude's own
|
||||
macros have to parse in a process that has not built a macro module yet.
|
||||
Then expansion, which may need one. *)
|
||||
let forms = !expander (List.map Expand.quasiquote forms) in
|
||||
temps := 0;
|
||||
List.map decl forms
|
||||
let s = Loc.sink ~on:keep_going in
|
||||
let decls = List.filter_map (fun f -> Loc.caught s (fun () -> decl f)) forms in
|
||||
Loc.finish s;
|
||||
decls
|
||||
|
||||
(** One file, stopping at the first declaration it cannot parse. Raises
|
||||
[Loc.Error], never [Loc.Errors]. *)
|
||||
let program (forms : Form.t list) : Ast.decl list =
|
||||
parse_forms ~keep_going:false forms
|
||||
|
||||
(** One file, reporting every declaration it cannot parse. Raises [Loc.Errors]
|
||||
when there was more than nothing wrong, so only a caller prepared for a
|
||||
list should be calling it. *)
|
||||
let program_all (forms : Form.t list) : Ast.decl list =
|
||||
parse_forms ~keep_going:true forms
|
||||
|
||||
(* Single-declaration entry point, for tests and the REPL. *)
|
||||
let decl (f : Form.t) : Ast.decl =
|
||||
|
||||
@ -39,6 +39,12 @@ type state = {
|
||||
let of_string ~file src = { src; file; pos = 0; line = 1; col = 1 }
|
||||
|
||||
let here st = Loc.make st.file st.line st.col
|
||||
|
||||
(* A form's location is the span it occupies, so every reader below takes the
|
||||
location it started at and closes it where the cursor now is. Doing it here,
|
||||
in the one place that knows both ends, is why nothing above [Reader] has to
|
||||
know a span exists. *)
|
||||
let spanned st loc v = Form.make v (Loc.upto loc (here st))
|
||||
let at_end st = st.pos >= String.length st.src
|
||||
let peek st = if at_end st then '\000' else st.src.[st.pos]
|
||||
let peek2 st =
|
||||
@ -83,7 +89,7 @@ let read_string st =
|
||||
advance st; (* opening quote *)
|
||||
let buf = Buffer.create 16 in
|
||||
let rec go () =
|
||||
if at_end st then Loc.fail loc "unterminated string"
|
||||
if at_end st then Loc.failk "reader/unterminated-string" loc "unterminated string"
|
||||
else match peek st with
|
||||
| '"' -> advance st
|
||||
| '\\' ->
|
||||
@ -94,18 +100,18 @@ let read_string st =
|
||||
(match c with
|
||||
| 'n' -> '\n' | 't' -> '\t' | 'r' -> '\r'
|
||||
| '\\' -> '\\' | '"' -> '"' | '0' -> '\000'
|
||||
| c -> Loc.fail loc "unknown string escape \\%c" c);
|
||||
| c -> Loc.failk "reader/unknown-string-escape" loc "unknown string escape \\%c" c);
|
||||
go ()
|
||||
| c -> advance st; Buffer.add_char buf c; go ()
|
||||
in
|
||||
go ();
|
||||
Form.make (Form.Str (Buffer.contents buf)) loc
|
||||
spanned st loc (Form.Str (Buffer.contents buf))
|
||||
|
||||
(* \space \tab \newline \return \nul, or \<any single char> *)
|
||||
let read_byte st =
|
||||
let loc = here st in
|
||||
advance st; (* backslash *)
|
||||
if at_end st then Loc.fail loc "expected a character after \\";
|
||||
if at_end st then Loc.failk "reader/incomplete-character" loc "expected a character after \\";
|
||||
let first = peek st in
|
||||
advance st;
|
||||
let rest = take_while st (fun c -> not (is_delimiter c)) in
|
||||
@ -117,9 +123,9 @@ let read_byte st =
|
||||
| "return" -> 13
|
||||
| "nul" -> 0
|
||||
| n when String.length n = 1 -> Char.code n.[0]
|
||||
| n -> Loc.fail loc "unknown character literal \\%s" n
|
||||
| n -> Loc.failk "reader/unknown-character" loc "unknown character literal \\%s" n
|
||||
in
|
||||
Form.make (Form.Byte code) loc
|
||||
spanned st loc (Form.Byte code)
|
||||
|
||||
(* A token that started with a digit, or with '-'/'+' followed by a digit. *)
|
||||
let read_number st =
|
||||
@ -132,26 +138,30 @@ let read_number st =
|
||||
in
|
||||
if is_hex then
|
||||
match Int64.of_string_opt text with
|
||||
| Some i -> Form.make (Form.Int i) loc
|
||||
| None -> Loc.fail loc "malformed hex literal %s" text
|
||||
| Some i -> spanned st loc (Form.Int i)
|
||||
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
||||
"malformed hex literal %s" text
|
||||
else if String.contains text '.' || String.contains text 'e' then
|
||||
match float_of_string_opt text with
|
||||
| Some f -> Form.make (Form.Float f) loc
|
||||
| None -> Loc.fail loc "malformed float literal %s" text
|
||||
| Some f -> spanned st loc (Form.Float f)
|
||||
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
||||
"malformed float literal %s" text
|
||||
else
|
||||
match Int64.of_string_opt text with
|
||||
| Some i -> Form.make (Form.Int i) loc
|
||||
| None -> Loc.fail loc "malformed integer literal %s" text
|
||||
| Some i -> spanned st loc (Form.Int i)
|
||||
| None -> Loc.failk "reader/malformed-number" (Loc.upto loc (here st))
|
||||
"malformed integer literal %s" text
|
||||
|
||||
let read_symbol_or_keyword st =
|
||||
let loc = here st in
|
||||
let text = take_while st (fun c -> not (is_delimiter c)) in
|
||||
if text = "" then Loc.fail loc "unexpected character %C" (peek st);
|
||||
if text = "" then Loc.failk "reader/unexpected-character" loc "unexpected character %C" (peek st);
|
||||
if text.[0] = ':' then begin
|
||||
if String.length text = 1 then Loc.fail loc "empty keyword";
|
||||
Form.make (Form.Kw (String.sub text 1 (String.length text - 1))) loc
|
||||
if String.length text = 1 then
|
||||
Loc.failk "reader/empty-keyword" (Loc.upto loc (here st)) "empty keyword";
|
||||
spanned st loc (Form.Kw (String.sub text 1 (String.length text - 1)))
|
||||
end else
|
||||
Form.make (Form.Sym text) loc
|
||||
spanned st loc (Form.Sym text)
|
||||
|
||||
(* ── Forms ─────────────────────────────────────────────────────────── *)
|
||||
|
||||
@ -170,9 +180,9 @@ let rec read_form st =
|
||||
skip_ignorable st;
|
||||
let loc = here st in
|
||||
match peek st with
|
||||
| '\000' -> Loc.fail loc "unexpected end of input"
|
||||
| '\000' -> Loc.failk "reader/unexpected-eof" loc "unexpected end of input"
|
||||
| '(' | '[' | '{' as open_c -> read_seq st open_c loc
|
||||
| ')' | ']' | '}' as c -> Loc.fail loc "unbalanced %C" c
|
||||
| ')' | ']' | '}' as c -> Loc.failk "reader/unbalanced" loc "unbalanced %C" c
|
||||
| '"' -> read_string st
|
||||
| '\\' -> read_byte st
|
||||
| '\'' -> read_sugar st loc "quote"
|
||||
@ -182,7 +192,7 @@ let rec read_form st =
|
||||
if peek st = '@' then (advance st; read_wrapped st loc "unquote-splicing")
|
||||
else read_wrapped st loc "unquote"
|
||||
| '^' ->
|
||||
Loc.fail loc "metadata (^) is not supported yet"
|
||||
Loc.failk "reader/metadata" loc "metadata (^) is not supported yet"
|
||||
|
||||
| c when is_digit c -> read_number st
|
||||
| ('-' | '+') when is_digit (peek2 st) -> read_number st
|
||||
@ -213,7 +223,7 @@ and read_sugar st loc name = advance st; read_wrapped st loc name
|
||||
|
||||
and read_wrapped st loc name =
|
||||
let inner = read_form st in
|
||||
Form.make (Form.List [ Form.make (Form.Sym name) loc; inner ]) loc
|
||||
spanned st loc (Form.List [ Form.make (Form.Sym name) loc; inner ])
|
||||
|
||||
and read_seq st open_c loc =
|
||||
advance st;
|
||||
@ -224,15 +234,26 @@ and read_seq st open_c loc =
|
||||
sequence would try to read a form and find [)]. *)
|
||||
skip_ignorable st;
|
||||
if at_end st then
|
||||
Loc.fail loc "unclosed %C, expected %C" open_c want
|
||||
(* Two places, and the second is the one that is usually news. The error
|
||||
is at the bracket that is still open, because that is where the fix
|
||||
goes; the note is where the file ran out, because that is how far the
|
||||
reader got believing the form was still being written. *)
|
||||
Loc.failk "reader/unclosed" loc
|
||||
~notes:[ Loc.note (here st) "the input ends here, still inside it" ]
|
||||
"unclosed %C, expected %C" open_c want
|
||||
else
|
||||
let c = peek st in
|
||||
if c = want then (advance st; List.rev acc)
|
||||
else if c = ')' || c = ']' || c = '}' then
|
||||
Loc.fail (here st) "expected %C to close %C, found %C" want open_c c
|
||||
(* The wrong closer is where the mistake reads, and the opener is what
|
||||
makes it wrong. Neither alone says which bracket to change. *)
|
||||
Loc.failk "reader/mismatched-closer" (here st)
|
||||
~notes:[ Loc.note loc (Printf.sprintf "%C is opened here" open_c) ]
|
||||
"expected %C to close %C, found %C" want open_c c
|
||||
else go (read_form st :: acc)
|
||||
in
|
||||
Form.make (wrap open_c (go [])) loc
|
||||
let items = go [] in
|
||||
spanned st loc (wrap open_c items)
|
||||
|
||||
(** All top-level forms in a source string. *)
|
||||
let read_all ~file src =
|
||||
|
||||
@ -505,7 +505,7 @@ let render_locals ?(origin = "<locals>") t ~frame ~(fn : Tast.fn) ~bound
|
||||
Some
|
||||
((lit (name ^ "\t" ^ Types.to_string ty ^ "\t") :: parts)
|
||||
@ [ lit ("\t" ^ string_of_int i ^ "\n") ])
|
||||
| exception Loc.Error (_, why) ->
|
||||
| exception Loc.Error { Loc.dmsg = why; _ } ->
|
||||
(* A type the structural printer has no arm for — a map, a function
|
||||
value, a type variable. Named, with the reason, rather than left out
|
||||
of the list: a local that is missing and a local that could not be
|
||||
@ -794,7 +794,7 @@ let render_slot ?(origin = "<inspect>") t ~frame ~(fn : Tast.fn) ~slot ~path
|
||||
| Error why -> Error (name ^ path_text path ^ ": " ^ why)
|
||||
| Ok v ->
|
||||
(match Render.render c 0 v with
|
||||
| exception Loc.Error (_, why) -> Error (name ^ path_text path ^ ": " ^ why)
|
||||
| exception Loc.Error { Loc.dmsg = why; _ } -> Error (name ^ path_text path ^ ": " ^ why)
|
||||
| parts ->
|
||||
let nullary n = { Tast.e = Tast.Call (n, []); ty = Types.Unit; loc } in
|
||||
t.thunks <- t.thunks + 1;
|
||||
@ -875,7 +875,7 @@ let render_globals ?(origin = "<globals>") t ~(globals : Tast.global list)
|
||||
Some
|
||||
((lit (g.Tast.gname ^ "\t" ^ Types.to_string g.Tast.gty ^ "\t") :: parts)
|
||||
@ [ lit "\n" ])
|
||||
| exception Loc.Error (_, why) ->
|
||||
| exception Loc.Error { Loc.dmsg = why; _ } ->
|
||||
(* A type the structural printer has no arm for. Named with its reason
|
||||
rather than left out, for [render_locals]'s reason: a global that is
|
||||
missing and a global that could not be printed are different facts,
|
||||
|
||||
@ -367,7 +367,7 @@ let () =
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n it was accepted\n" name
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m needle) then begin
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n said: %S\n wanted: %S in it\n"
|
||||
@ -1241,7 +1241,7 @@ let () =
|
||||
| () ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n it was accepted\n" name
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m needle) then begin
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n said: %S\n wanted: %S in it\n"
|
||||
@ -1602,7 +1602,7 @@ ERR@7 unexpected token: not the kind the caller was reading
|
||||
name n
|
||||
end)
|
||||
needles
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n refused: %s\n" name m
|
||||
in
|
||||
@ -1613,7 +1613,7 @@ ERR@7 unexpected token: not the kind the caller was reading
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s: accepted, and it should not have been\n" name
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m fragment) then begin
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n reason: %S\n wanted to contain: %S\n"
|
||||
@ -1944,6 +1944,41 @@ ERR@7 unexpected token: not the kind the caller was reading
|
||||
outputs ~opt:"-O0" "unless, now a prelude macro, -O0"
|
||||
"programs/macro-unless.flan" unless_out;
|
||||
|
||||
(* An error on code a macro produced says which macro, and it has to be
|
||||
asserted through a real expansion: the tag is put on by [Macro] and
|
||||
defaulted into the diagnostic by [Loc], and a unit test on either half
|
||||
alone would pass with the other one broken.
|
||||
|
||||
[clamp] with the wrong number of arguments expands into a call to a name
|
||||
that does not exist, on purpose -- that is how a prelude macro reports a
|
||||
misuse. So the checker refuses a name the author never wrote, which is
|
||||
exactly the case the field exists for. *)
|
||||
(let src = "(defn f [] i32 (clamp 1 2))\n(defn main [] i32 0)\n" in
|
||||
match
|
||||
Check.program (Parse.program (Reader.read_all ~file:"<expansion>" src))
|
||||
with
|
||||
| _ ->
|
||||
incr failures;
|
||||
print_endline "FAIL an error in an expansion is refused"
|
||||
| exception Loc.Error d ->
|
||||
(match d.Loc.expansion with
|
||||
| Some (name, _) when name = "clamp" -> ()
|
||||
| Some (name, _) ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL an error in an expansion names the wrong macro: %s\n"
|
||||
name
|
||||
| None ->
|
||||
incr failures;
|
||||
Printf.printf
|
||||
"FAIL an error in an expansion names no macro\n error: %s\n"
|
||||
d.Loc.dmsg);
|
||||
(* And it reaches the printed report, which is the only part a reader
|
||||
ever sees. *)
|
||||
if not (contains (Loc.report d) "expanded from the macro clamp") then begin
|
||||
incr failures;
|
||||
print_endline "FAIL the report does not say which macro"
|
||||
end);
|
||||
|
||||
(* The two ways expansion does not terminate, and they are different
|
||||
failures. A ring is a compile-order problem -- each body calls the other
|
||||
while the other is being compiled -- and there is no order, so it is
|
||||
@ -2039,7 +2074,7 @@ ERR@7 unexpected token: not the kind the caller was reading
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n it was accepted\n" name
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m "needs a byte-level encoder that does not exist")
|
||||
then begin
|
||||
incr failures;
|
||||
@ -2069,7 +2104,7 @@ ERR@7 unexpected token: not the kind the caller was reading
|
||||
(defn main [] i32 (match g A 0 (B x) x))")))
|
||||
with
|
||||
| _ -> ()
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n refused: %S\n" name m);
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ let reads name src expected =
|
||||
Printf.printf "FAIL %s\n src: %s\n got: %s\n wanted: %s\n"
|
||||
name src got expected
|
||||
end
|
||||
| exception Loc.Error (loc, msg) ->
|
||||
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
|
||||
name src (Loc.to_string loc) msg
|
||||
@ -73,7 +73,7 @@ let rejects ?needle name src =
|
||||
| exception Watchdog.Timeout ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s: the reader did not return\n" name
|
||||
| exception Loc.Error (_, msg) ->
|
||||
| exception Loc.Error { Loc.dmsg = msg; _ } ->
|
||||
(match needle with
|
||||
| Some n when not (contains msg n) ->
|
||||
incr failures;
|
||||
@ -246,9 +246,41 @@ let () =
|
||||
|
||||
(match read ~file:"f.flan" "(f\n bad" with
|
||||
| _ -> check "unclosed reports opening loc" false
|
||||
| exception Loc.Error (loc, _) ->
|
||||
| exception Loc.Error { Loc.dloc = loc; _ } ->
|
||||
check "unclosed reports opening loc" (loc.line = 1 && loc.col = 1));
|
||||
|
||||
(* ── Spans ─────────────────────────────────────────────────────
|
||||
A location ends where the form ends, which is what an underline needs
|
||||
and what a column number cannot give. Asserted on the width rather than
|
||||
on the end column alone: a span that never got widened is zero wide, and
|
||||
that is the failure mode worth catching — the field would exist, nothing
|
||||
would fill it, and every squiggle would be one character long. *)
|
||||
(match read ~file:"f.flan" "(foo bar)" with
|
||||
| [ l ] ->
|
||||
check "span covers the list" (Loc.width l.loc = Some 9);
|
||||
(match l.Form.v with
|
||||
| Form.List [ head; arg ] ->
|
||||
check "span covers the head symbol" (Loc.width head.loc = Some 3);
|
||||
check "span covers the argument" (Loc.width arg.loc = Some 3);
|
||||
check "span starts at the symbol" (arg.loc.col = 6)
|
||||
| _ -> check "span: two elements" false)
|
||||
| _ -> check "span: one form" false);
|
||||
|
||||
(match read ~file:"f.flan" "\"hi\" 42 :kw" with
|
||||
| [ s; n; k ] ->
|
||||
check "span covers a string with its quotes" (Loc.width s.loc = Some 4);
|
||||
check "span covers a number" (Loc.width n.loc = Some 2);
|
||||
check "span covers a keyword with its colon" (Loc.width k.loc = Some 3)
|
||||
| _ -> check "span: three atoms" false);
|
||||
|
||||
(* A form that runs over a line end has no width on its first line, and says
|
||||
so rather than reporting a negative one. *)
|
||||
(match read ~file:"f.flan" "(a\n b)" with
|
||||
| [ l ] ->
|
||||
check "multi-line span is flagged" (Loc.multiline l.loc);
|
||||
check "multi-line span has no single-line width" (Loc.width l.loc = None)
|
||||
| _ -> check "span: one multi-line form" false);
|
||||
|
||||
if !failures = 0 then print_endline "reader: all tests passed"
|
||||
else begin
|
||||
Printf.printf "\n%d failure(s)\n" !failures;
|
||||
@ -273,7 +305,7 @@ let parse_decl src =
|
||||
let parse_rejects ?needle name src =
|
||||
match read src |> Parse.program with
|
||||
| _ -> incr failures; Printf.printf "FAIL %s: expected a parse error\n" name
|
||||
| exception Loc.Error (_, msg) ->
|
||||
| exception Loc.Error { Loc.dmsg = msg; _ } ->
|
||||
(match needle with
|
||||
| Some n when not (contains msg n) ->
|
||||
incr failures;
|
||||
@ -512,7 +544,7 @@ let () =
|
||||
(fun path ->
|
||||
match read_file path |> Parse.program with
|
||||
| _ -> ()
|
||||
| exception Loc.Error (loc, msg) ->
|
||||
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s does not parse: %s: %s\n"
|
||||
path (Loc.to_string loc) msg)
|
||||
@ -603,7 +635,7 @@ let infers name src expected =
|
||||
name src got expected
|
||||
end
|
||||
| None -> incr failures; Printf.printf "FAIL %s: no probe\n" name)
|
||||
| exception Loc.Error (loc, msg) ->
|
||||
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
|
||||
name src (Loc.to_string loc) msg
|
||||
@ -615,7 +647,7 @@ let infers name src expected =
|
||||
let accepts name src =
|
||||
match checked src with
|
||||
| _ -> ()
|
||||
| exception Loc.Error (loc, msg) ->
|
||||
| exception Loc.Error { Loc.dloc = loc; dmsg = msg; _ } ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n src: %s\n error: %s: %s\n"
|
||||
name src (Loc.to_string loc) msg
|
||||
@ -631,7 +663,7 @@ let rejects_check name ?needle src =
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s: expected a type error\n src: %s\n" name src
|
||||
| exception Loc.Error (_, msg) ->
|
||||
| exception Loc.Error { Loc.dmsg = msg; _ } ->
|
||||
(match needle with
|
||||
| Some n
|
||||
when not
|
||||
@ -1610,9 +1642,121 @@ let () =
|
||||
check "a prelude macro calling a macro is refused by name"
|
||||
(match Macro.reduce ring with
|
||||
| _ -> false
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
contains m "the prelude macro n calls a macro");
|
||||
|
||||
(* ── Diagnostics: kind, notes, and more than one ───────────────
|
||||
The house rule is that a test asserts the *reason* a thing is refused. A
|
||||
kind is that assertion made stable: the message may be reworded and the
|
||||
row still holds, and a row that matches on a kind is saying something a
|
||||
substring match on prose could only approximate. The messages themselves
|
||||
are unchanged, so every existing needle still means what it meant. *)
|
||||
|
||||
let diag_of src =
|
||||
match checked src with
|
||||
| _ -> None
|
||||
| exception Loc.Error d -> Some d
|
||||
in
|
||||
let kind_is name src k =
|
||||
check name (match diag_of src with Some d -> d.Loc.kind = k | None -> false)
|
||||
in
|
||||
kind_is "unknown name has a kind"
|
||||
"(defn f [] i32 nope)" "check/unknown-name";
|
||||
kind_is "unknown field has a kind"
|
||||
"(defstruct S [a i32])\n(defn f [s S] i32 (.b s))" "check/unknown-field";
|
||||
kind_is "a name defined twice has a kind"
|
||||
"(defn f [] i32 1)\n(defn f [] i32 2)" "check/defined-twice";
|
||||
|
||||
(* The note is the half a location and a string could never carry: the
|
||||
*other* place, with its own span and its own explanation. *)
|
||||
(match diag_of "(defn f [] i32 1)\n(defn f [] i32 2)" with
|
||||
| Some d ->
|
||||
check "defined twice points at the second" (d.Loc.dloc.Loc.line = 2);
|
||||
(match d.Loc.notes with
|
||||
| [ n ] ->
|
||||
check "and notes the first" (n.Loc.nloc.Loc.line = 1);
|
||||
check "and says what it is" (contains n.Loc.nmsg "already defined")
|
||||
| ns -> check "defined twice has one note" (ns = []))
|
||||
| None -> check "defined twice is refused" false);
|
||||
|
||||
(match diag_of "(defstruct S [a i32])\n(defn f [s S] i32 (.b s))" with
|
||||
| Some d ->
|
||||
(match d.Loc.notes with
|
||||
| [ n ] ->
|
||||
check "an unknown field notes the declaration"
|
||||
(n.Loc.nloc.Loc.line = 1);
|
||||
check "and lists the fields there" (contains n.Loc.nmsg "with a")
|
||||
| _ -> check "an unknown field has one note" false)
|
||||
| None -> check "an unknown field is refused" false);
|
||||
|
||||
(* The reader's own two-place error. The bracket that is open is the error
|
||||
and the end of input is the note, because the fix goes at the first and
|
||||
the surprise is at the second. *)
|
||||
(match read "(f\n bad" with
|
||||
| _ -> check "unclosed is refused" false
|
||||
| exception Loc.Error d ->
|
||||
check "unclosed has a kind" (d.Loc.kind = "reader/unclosed");
|
||||
check "unclosed notes where the input ran out"
|
||||
(match d.Loc.notes with [ n ] -> n.Loc.nloc.Loc.line = 2 | _ -> false));
|
||||
|
||||
(match read "(f x]" with
|
||||
| _ -> check "a mismatched closer is refused" false
|
||||
| exception Loc.Error d ->
|
||||
check "a mismatched closer has a kind"
|
||||
(d.Loc.kind = "reader/mismatched-closer");
|
||||
check "and notes the opener"
|
||||
(match d.Loc.notes with [ n ] -> n.Loc.nloc.Loc.col = 1 | _ -> false));
|
||||
|
||||
(* More than one per run, which is the point of the whole batch. Three bad
|
||||
bodies, three diagnostics, and the count is exact: a checker that reported
|
||||
the first and a checker that reported thirty pieces of wreckage would both
|
||||
fail this row. *)
|
||||
(match
|
||||
Check.program_all
|
||||
(Parse.program_all
|
||||
(read "(defn a [] i32 nope1)\n\
|
||||
(defn b [] i32 nope2)\n\
|
||||
(defn c [] i32 nope3)\n"))
|
||||
with
|
||||
| _ -> check "three bad bodies are refused" false
|
||||
| exception Loc.Errors ds ->
|
||||
check "three bad bodies give three errors" (List.length ds = 3);
|
||||
check "and they are in source order"
|
||||
(List.map (fun (d : Loc.diag) -> d.Loc.dloc.Loc.line) ds = [ 1; 2; 3 ]));
|
||||
|
||||
(* The parser resynchronises on a top-level form, so two bad declarations are
|
||||
two errors rather than one. *)
|
||||
(match Parse.program_all (read "(defn a)\n(defn b)\n") with
|
||||
| _ -> check "two bad declarations are refused" false
|
||||
| exception Loc.Errors ds ->
|
||||
check "two bad declarations give two errors" (List.length ds = 2));
|
||||
|
||||
(* [Check.program] is a different function from [Check.program_all], and
|
||||
that is the guarantee: the session calls this one, it raises one
|
||||
diagnostic, and nobody can turn it into a list by passing a label. *)
|
||||
(match Check.program (Parse.program (read "(defn a [] i32 nope1)\n\
|
||||
(defn b [] i32 nope2)\n")) with
|
||||
| _ -> check "Check.program still refuses" false
|
||||
| exception Loc.Errors _ ->
|
||||
check "Check.program never answers with a list" false
|
||||
| exception Loc.Error _ -> ());
|
||||
|
||||
(* The first line of a report is exactly the GNU format compilation-mode
|
||||
parses, and the squiggle is on an indented line under it, which that mode
|
||||
ignores. Both halves are load-bearing and neither is visible from the
|
||||
message alone. *)
|
||||
(match diag_of "(defn f [] i32 nope)" with
|
||||
| Some d ->
|
||||
let lines = String.split_on_char '\n' (Loc.report d) in
|
||||
(match lines with
|
||||
| head :: rest ->
|
||||
check "the first line is file:line:col: message"
|
||||
(head = Loc.to_string d.Loc.dloc ^ ": " ^ d.Loc.dmsg);
|
||||
check "and the rest is indented"
|
||||
(List.for_all (fun l -> l = "" || l.[0] = ' ') rest)
|
||||
| [] -> check "a report has a first line" false)
|
||||
| None -> check "a report needs a diagnostic" false);
|
||||
|
||||
(* ── 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);
|
||||
|
||||
@ -31,7 +31,7 @@ let refuses ?(file = "programs/reload.flan") name src reason =
|
||||
let t, _ = Session.create ~file () in
|
||||
match Session.eval t src with
|
||||
| _ -> fail "%s was accepted" name
|
||||
| exception Loc.Error (_, msg) ->
|
||||
| exception Loc.Error { Loc.dmsg = msg; _ } ->
|
||||
if not (has msg reason) then
|
||||
fail "%s\n said: %S\n wanted it to mention: %S" name msg reason
|
||||
|
||||
@ -128,7 +128,7 @@ let () =
|
||||
| exception Loc.Error _ -> ());
|
||||
(match Session.eval t "(defn bump [] i64 (set counter (+ counter 6)) counter)" with
|
||||
| c -> if c.Session.fns <> [ "bump" ] then fail "the session did not recover"
|
||||
| exception Loc.Error (_, m) -> fail "the session was poisoned by a typo: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "the session was poisoned by a typo: %s" m);
|
||||
|
||||
(* A declaration the program already has, with no body and no new storage,
|
||||
is accepted and has nothing to send. Building a module for it would report
|
||||
@ -136,7 +136,7 @@ let () =
|
||||
program a reload it did not need. *)
|
||||
(match Session.eval t "(defvar counter i64)" with
|
||||
| c -> if c.Session.installs then fail "an empty change claimed to install"
|
||||
| exception Loc.Error (_, m) -> fail "redeclaring a var unchanged: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "redeclaring a var unchanged: %s" m);
|
||||
|
||||
(* A constant that is only ever read at run time is just bytes in the
|
||||
program's memory. A dev build emits it as a mutable global and the module
|
||||
@ -148,7 +148,7 @@ let () =
|
||||
fail "a changed run-time constant had nothing to install";
|
||||
if not (has c.Session.ir "store [2 x i32]") then
|
||||
fail "a changed run-time constant published no new value"
|
||||
| exception Loc.Error (_, m) -> fail "changing a run-time constant: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "changing a run-time constant: %s" m);
|
||||
(* And in a dev build its storage is writable, where a release build keeps
|
||||
it immutable and gets all the folding back. *)
|
||||
let host = checked_program "programs/reload.flan" in
|
||||
@ -174,7 +174,7 @@ let () =
|
||||
(* And once added, it is part of the session: a later form can use it. *)
|
||||
(match Session.eval t "(defn use-fresh [] i64 (set fresh 4) fresh)" with
|
||||
| _ -> ()
|
||||
| exception Loc.Error (_, m) -> fail "a name added earlier was forgotten: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "a name added earlier was forgotten: %s" m);
|
||||
|
||||
(* A file with imports, re-evaluated whole — the C-c C-k case. The session
|
||||
keeps the *expanded* declarations, so the package's names are replaced in
|
||||
@ -192,7 +192,7 @@ let () =
|
||||
| c ->
|
||||
if not (List.mem "game-draw" c.Session.fns) then
|
||||
fail "reloading sand.flan did not include its own functions"
|
||||
| exception Loc.Error (_, m) ->
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
fail "reloading a file with imports failed: %s" m);
|
||||
|
||||
(* A form typed into a file that is *imported as a package* has to be
|
||||
@ -211,7 +211,7 @@ let () =
|
||||
if c.Session.fns <> [ "agent/poll" ] then
|
||||
fail "a form from a package file reported %s, wanted agent/poll"
|
||||
(String.concat " " c.Session.fns)
|
||||
| exception Loc.Error (_, m) -> fail "redefining agent/poll: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "redefining agent/poll: %s" m);
|
||||
(* A package that is a single file, which is what sand.flan is to the
|
||||
headless driver. The file being edited *is* the package rather than a
|
||||
member of a directory, so matching on the directory alone would answer
|
||||
@ -223,7 +223,7 @@ let () =
|
||||
if c.Session.fns <> [ "sand/step" ] then
|
||||
fail "a form from a single-file package reported %s, wanted sand/step"
|
||||
(String.concat " " c.Session.fns)
|
||||
| exception Loc.Error (_, m) -> fail "redefining sand/step: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "redefining sand/step: %s" m);
|
||||
|
||||
(* And a file that is not a package keeps its names as written. *)
|
||||
(match Session.eval ~origin:"../sand.flan" t "(defn game-draw [] () (do))" with
|
||||
@ -231,7 +231,7 @@ let () =
|
||||
if c.Session.fns <> [ "game-draw" ] then
|
||||
fail "a form from the program's own file reported %s"
|
||||
(String.concat " " c.Session.fns)
|
||||
| exception Loc.Error (_, m) -> fail "redefining game-draw: %s" m);
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } -> fail "redefining game-draw: %s" m);
|
||||
|
||||
(* An expression's thunk leaves nothing behind, and the module says so, which
|
||||
is what lets the agent unload it: nothing may point into its text
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user