The compiler finishes the file before it reports

A sink collects 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, which is what
leaves the interactive path untouched: the daemon checks one form, asks for
a sink that is off, and still gets one exception.

Two resync points, and both are places the work already had a boundary. In
the parser it is a top-level form — the reader found where each declaration
ends, so skipping a bad one cannot lose its place, while inside a
declaration there is no such landmark and one bad defn stays one error. In
the checker it is the two passes: pass one, which builds every name and
signature, still stops at the first refusal, because 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.

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.
This commit is contained in:
Joseph Ferano 2026-09-13 07:56:17 +07:00
parent 2f1d20dfc3
commit 2efed1630f
4 changed files with 87 additions and 17 deletions

View File

@ -3,7 +3,7 @@
(* 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
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
@ -42,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 ~keep_going:true (Flan.Reader.read_file path))
let checked path = Flan.Check.program (load path).decls
let checked path = Flan.Check.program ~keep_going:true (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
@ -132,7 +136,7 @@ let () =
(fun path ->
with_errors path (fun () ->
Flan.Reader.read_file path
|> Flan.Parse.program
|> Flan.Parse.program ~keep_going:true
|> List.iter (fun d -> print_endline (summarise d))))
files
| _ :: "check" :: files when files <> [] ->
@ -286,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 ~keep_going:true l.decls
|> Flan.Emit.program ~checks ~dev ~debug ~pnames ~sanitize
|> print_string))
files
@ -318,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 ~keep_going:true 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
@ -395,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 ~keep_going:true l.decls in
let p, csrcs, lflags = Flan.Reach.link l p in
ignore (Flan.Build.executable ~csrcs ~lflags p ~out:exe);
let code =

View File

@ -4132,7 +4132,8 @@ 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 =
let program_with_env ?(keep_going = false) (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
@ -4140,18 +4141,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. *)
@ -4175,7 +4192,8 @@ 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)
let program ?keep_going (decls : Ast.decl list) : Tast.program =
fst (program_with_env ?keep_going decls)
(* One expression, checked against a program that is already running. The
frame is empty a REPL expression has no parameters and no enclosing

View File

@ -49,12 +49,12 @@ let width (t : t) =
let to_string t = Printf.sprintf "%s:%d:%d" t.file t.line t.col
(* ── Diagnostics ──────────────────────────────────────────
(* ── Diagnostics ──────────────────────────────────────────
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
[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
@ -101,7 +101,7 @@ type diag = {
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 —
[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
@ -137,6 +137,43 @@ let fail loc 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

View File

@ -866,14 +866,25 @@ 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 =
(* [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.
Off by default, because the daemon parses one form at a time and wants one
exception. *)
let program ?(keep_going = false) (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
(* Single-declaration entry point, for tests and the REPL. *)
let decl (f : Form.t) : Ast.decl =