A form a macro splices through is reported on the line it was written on

This commit is contained in:
Joseph Ferano 2026-09-21 19:14:49 +07:00
parent d9bb882bbe
commit a14a872a44
13 changed files with 427 additions and 79 deletions

114
FIX.org
View File

@ -6353,3 +6353,117 @@ out on ~program_asked~, so a re-run installs what is queued and then enters
~main~. That is ~lib/dev.ml~, which another lane holds, so it is written down ~main~. That is ~lib/dev.ml~, which another lane holds, so it is written down
here rather than done. The test row asserts the behaviour as it is, with the here rather than done. The test row asserts the behaviour as it is, with the
extra re-run spelled out. extra re-run spelled out.
* An error in a macro's body is reported where it was written, 2026-09-21
The author, on a falling-sand program:
#+begin_quote
sand.flan:45:7: + takes two arguments or more, given 1
#+end_quote
Line 45 is a ~do-grid~ call. The mistake — ~(+ mr)~ with one operand — is on
line 49, inside the body the author passed to it. Every node of an expansion
carried the call site's location, so every error inside one landed on the call
and the ~^^^^~ underlined a macro that was perfectly fine. A macro that wraps
a twenty-line body made twenty lines of code report at one of them.
** What a Form carries, and what it does not
A ~Form~ crosses to the compiled macro as 24 bytes: a tag and two payload
words. It mirrors ~Form.value~, not ~Form.t~, so there is no ~loc~ field, and
there is not going to be one — the padding hole at offset 4 belongs to the
queued structured-error work and nothing here touches the layout, the prelude's
~defdata Form~, or ~lib/form.ml~.
What a Form does carry, for every case but ~Int~, ~Float~ and ~Byte~, is a
pointer: a name's or a string's bytes, or a bracket's children. This compiler
allocated that memory. And when a macro splices an argument through — which is
what ~~@body~ is — the 24-byte struct is copied but the pointer inside it is
not. The address is the part that survives the trip.
** SBCL, read rather than recalled
~src/compiler/ir1tran.lisp~ has two mechanisms for this and they are not
equally good. ~*source-paths*~ (line 38) is an EQ hash table from cons to
source path, filled by ~find-source-paths~ walking a form before conversion; a
macro that splices returns the same conses, so the lookup still answers.
Nothing is stored on the object and identity is the whole key.
~recover-source-paths~ (line 918) is the fallback for macros that copy instead
of splice: a structural EQUAL match between the original and the expansion. Its
own comment admits false matches on duplicates and reordering, and SBCL gates it
on ~debug > 1~.
The first is what this is. ~Expand.write~ records the payload pointer of each
node against its ~Loc.t~ in a table that lives for one call; ~Expand.unmarshal~
looks each node's pointer up. A hit is a form the author wrote and it keeps its
own line. The second mechanism is not built and is not wanted: a guess that is
usually right is worse than a miss, because a location nobody can distrust is
the only kind worth printing.
** What a miss does
It inherits — from the nearest enclosing node that resolved, and not from the
call site. Only a node with no located ancestor at all falls back to the call,
and that means its whole subtree was the macro's invention, which is exactly
when the call is the right place.
~Int~, ~Float~ and ~Byte~ always miss, because their payload is the value and
there is no pointer to ask about. So a bad ~5~ in ~(foo (bar 5))~ is reported
at ~(bar 5)~, the smallest thing around it that has a place. SBCL draws the
same line for the same reason: ~source-form-has-path-p~ excludes symbols,
fixnums and characters.
Pointer reuse cannot confuse it. Nothing ~Dynload~ hands out is freed until
~Dynload.release~ runs between the rounds in ~Macro~ or on the way out of an
expansion, so no address recorded during a call can be handed to a second
allocation while the table still holds it — and the table is one call's and is
dropped when the call returns either way. A macro that stashes a form in a
global and answers with it on a later call gets a miss, which is the harmless
direction.
** Two rounds
~settle~ re-expands until the head stops being a macro. A form that survives
two rounds is written out a second time, and what is recorded the second time
is the ~Loc.t~ it came back with — its own, recovered in round one. So it keeps
the line the author wrote it on and not the intermediate call.
~Loc.from_macro~ is outermost-wins, so the ~note:~ names the macro the author
called and not the one it expanded into.
** One addition beyond the brief: Loc.msite
~Loc.t~ had ~macro~ and nothing else, and ~Loc.diag~ pointed the "expanded from
the macro X" note at the diagnostic's own location. That was right only while
the two were the same place. The moment a spliced form keeps its own line, the
error is at line 49 and the call is at line 45, and one field cannot be both —
the note would have said "expanded from the macro do-grid" while pointing at a
line with no ~do-grid~ on it.
So ~Loc.t~ gains ~msite : t option~, the call site for a location that is not
itself the call, set where the macro name is stamped and read by ~Loc.diag~.
Empty means this position *is* the call site, which is what every location that
has never been moved says, so nothing else changed. This is ~lib/loc.ml~ and
not the Form ABI.
The report now reads, verbatim from the pinned test, which is the sand.flan
shape cut down to a file the suite can compile:
#+begin_example
programs/macro-loc-body.flan:18:15: + takes two arguments or more, given 1
18 | (let [v (+ mr)]
| ^^^^^^
programs/macro-loc-body.flan:17:5: note: expanded from the macro do-grid
17 | (do-grid [br rows bc cols]
| --------------------------
#+end_example
** Pinned
Four programs under ~test/programs/macro-loc-*.flan~, each asserted on the
rendered report rather than on the location record — the line, the squiggle
under the right form and the note pointing at the call read as one sentence and
are three fields here. The spliced body several lines below its call; the
literal that takes the form around it; the node a macro built against the
leaves it built it from; and a body through two rounds of expansion. The
session path, which the editor reads, has a row of its own in
~test/test_session.ml~.
No existing assertion moved. Every test in the tree that pins a diagnostic
location on a macro call pins one raised *before* expansion — the four
parameter-list refusals in ~Expand.check_call~ — or one on a node the macro
built, and both still report at the call. The suite was green on the first run
after the change.

View File

@ -7908,10 +7908,10 @@ and named_call ?(qualified = false) ctx ~want loc name args =
in a *data* file, which no symbol the expansion could invent will hold. in a *data* file, which no symbol the expansion could invent will hold.
So a macro that has to refuse expands to a call to this, and the string is So a macro that has to refuse expands to a call to this, and the string is
the report. [Loc.from_macro] has already stamped the call site onto every the report. The string is a form the macro built, so it has no line of its
node of the expansion, so the location is the [defedn] the author wrote own and takes the call site's — the [defedn] the author wrote. The
and the sentence is the macro's — which is the two halves the prelude's location is theirs and the sentence is the macro's, which is the two
note says are never both right at once. halves the prelude's note says are never both right at once.
A builtin and not a declaration, because it has to fail *here*: a declared A builtin and not a declaration, because it has to fail *here*: a declared
function would compile, link and run, and the compile it was meant to stop function would compile, link and run, and the compile it was meant to stop

View File

@ -57,20 +57,44 @@ let tag_of_int = function
dynload_stubs.c, one field at a time. Everything allocated here is owned by dynload_stubs.c, one field at a time. Everything allocated here is owned by
[Dynload] and released together after the call. *) [Dynload] and released together after the call. *)
(* ── Which node a pointer belongs to ───────────────────────────────
A [Form] on the wire has no [loc] field and is not getting one. What it does
have, for every case but the three that fit inside the payload, is a
pointer: a string's bytes, or a bracket's children. This compiler allocated
that memory, so the address names the node it was written for — and when a
macro splices one of its arguments through untouched, the 24-byte struct is
copied but the pointer inside it is not. The address is the part that
survives the trip, so it is what a form coming back out is recognised by.
That is SBCL's [*source-paths*] (src/compiler/ir1tran.lisp), an EQ table
from the conses of a form to where they were read, which still answers after
a macro splices those same conses into its expansion. The address stands in
for [eq] because across a C ABI nothing else can: two sides that share no
heap share no notion of identity but the pointer.
One table per call, made in [call] and gone when it returns. A macro that
keeps a form from one call and answers with it in another gets a miss, and a
miss is the harmless direction. *)
type sites = (Dynload.addr, Loc.t) Hashtbl.t
(* Into an existing 24 bytes, which is what an argument array needs: the macro (* Into an existing 24 bytes, which is what an argument array needs: the macro
takes a [Form] slice, and a slice is contiguous elements and not an array of takes a [Form] slice, and a slice is contiguous elements and not an array of
pointers — so this writes *into* memory the caller took, and every caller pointers — so this writes *into* memory the caller took, and every caller
here takes it as part of an array. *) here takes it as part of an array. *)
let rec write p (f : Form.t) = let rec write (sites : sites) p (f : Form.t) =
let tag t = Dynload.poke_i32 p 0 (tag_int t) in let tag t = Dynload.poke_i32 p 0 (tag_int t) in
let note b = Hashtbl.replace sites b f.Form.loc in
let str t s = let str t s =
tag t; tag t;
let n = String.length s in let n = String.length s in
(* A zero-length string still gets a pointer, because a slice with a null (* A zero-length string still gets a pointer, because a slice with a null
base is not the same value as one with a live base and a zero length -- base is not the same value as one with a live base and a zero length --
the difference shows the day something concatenates onto it. *) the difference shows the day something concatenates onto it. It also
keeps every node's key distinct, which the table above depends on. *)
let b = Dynload.take (max n 1) in let b = Dynload.take (max n 1) in
if n > 0 then Dynload.poke_bytes b 0 s; if n > 0 then Dynload.poke_bytes b 0 s;
note b;
Dynload.poke_ptr p ptr_off b; Dynload.poke_ptr p ptr_off b;
Dynload.poke_i64 p len_off (Int64.of_int n) Dynload.poke_i64 p len_off (Int64.of_int n)
in in
@ -78,7 +102,10 @@ let rec write p (f : Form.t) =
tag t; tag t;
let n = List.length xs in let n = List.length xs in
let b = Dynload.take (max (n * form_size) 1) in let b = Dynload.take (max (n * form_size) 1) in
List.iteri (fun i x -> write (Nativeint.add b (Nativeint.of_int (i * form_size))) x) xs; List.iteri
(fun i x -> write sites (Nativeint.add b (Nativeint.of_int (i * form_size))) x)
xs;
note b;
Dynload.poke_ptr p ptr_off b; Dynload.poke_ptr p ptr_off b;
Dynload.poke_i64 p len_off (Int64.of_int n) Dynload.poke_i64 p len_off (Int64.of_int n)
in in
@ -94,37 +121,59 @@ let rec write p (f : Form.t) =
| Form.Map xs -> seq TMap xs | Form.Map xs -> seq TMap xs
(* ── Reading one back ────────────────────────────────────────────── (* ── Reading one back ──────────────────────────────────────────────
[loc] is the call site's, stamped onto every node. A macro cannot invent a Three ways a node gets a location, and which one applies is decided by the
source location and the image has no room for one: Form on the Flan side table above.
mirrors [Form.value], not [Form.t]. So an error inside an expansion points
at the call that produced it, which is the part of "the error carries the
expansion" that can be had now without the structured-error rewrite. *)
let rec unmarshal ~loc (p : Dynload.addr) : Form.t = A hit is a form the author wrote: it went in on some line, the macro passed
let str () = it through, and it comes back with the pointer it went in with. It keeps its
let b = Dynload.peek_ptr p ptr_off in own line, tagged with the macro whose call it was written inside — so the
let n = Int64.to_int (Dynload.peek_i64 p len_off) in error is reported where the code is and the [note:] still says which call
if n = 0 then "" else Dynload.peek_bytes b 0 n put it there.
A miss is a node the macro built, and it has no line of its own anywhere. It
takes the nearest enclosing node that did have one, which is the smallest
piece of what the author wrote that contains it. Only a node with no located
ancestor at all falls back to the call site, and that means the whole
subtree was the macro's invention.
[Int], [Float] and [Byte] are always a miss, because their payload is the
value and there is no pointer to ask about — reading one as an address would
be a number pretending to be a node. So a bad [5] in [(foo (bar 5))] is
reported at [(bar 5)], the nearest thing that has a place. That is the same
line SBCL draws: its [source-form-has-path-p] excludes symbols, fixnums and
characters for the same reason. *)
let rec unmarshal ~(sites : sites) ~loc (p : Dynload.addr) : Form.t =
let ptr () = Dynload.peek_ptr p ptr_off in
let len () = Int64.to_int (Dynload.peek_i64 p len_off) in
let str () = let n = len () in if n = 0 then "" else Dynload.peek_bytes (ptr ()) 0 n in
(* The node's own location, or the one it inherits. [Loc.from_macro] is
outermost-wins and the call site is already tagged, so a form that came
through two expansions keeps the name of the macro the author wrote. *)
let here () =
match Hashtbl.find_opt sites (ptr ()) with
| None -> loc
| Some own ->
(match loc.Loc.macro with
| None -> own
| Some m -> Loc.from_macro ~at:(Loc.call_site loc) m own)
in in
let seq () = let seq loc =
let b = Dynload.peek_ptr p ptr_off in let b = ptr () and n = len () in
let n = Int64.to_int (Dynload.peek_i64 p len_off) in
List.init n (fun i -> List.init n (fun i ->
unmarshal ~loc (Nativeint.add b (Nativeint.of_int (i * form_size)))) unmarshal ~sites ~loc (Nativeint.add b (Nativeint.of_int (i * form_size))))
in in
let v = match tag_of_int (Dynload.peek_i32 p 0) with
match tag_of_int (Dynload.peek_i32 p 0) with | TInt -> Form.make (Form.Int (Dynload.peek_i64 p payload)) loc
| TSym -> Form.Sym (str ()) | TFloat -> Form.make (Form.Float (Dynload.peek_f64 p payload)) loc
| TKw -> Form.Kw (str ()) | TByte ->
| TStr -> Form.Str (str ()) Form.make (Form.Byte (Int32.to_int (Dynload.peek_i32 p payload) land 0xff)) loc
| TInt -> Form.Int (Dynload.peek_i64 p payload) | TSym -> Form.make (Form.Sym (str ())) (here ())
| TFloat -> Form.Float (Dynload.peek_f64 p payload) | TKw -> Form.make (Form.Kw (str ())) (here ())
| TByte -> Form.Byte (Int32.to_int (Dynload.peek_i32 p payload) land 0xff) | TStr -> Form.make (Form.Str (str ())) (here ())
| TList -> Form.List (seq ()) | TList -> let l = here () in Form.make (Form.List (seq l)) l
| TVec -> Form.Vec (seq ()) | TVec -> let l = here () in Form.make (Form.Vec (seq l)) l
| TMap -> Form.Map (seq ()) | TMap -> let l = here () in Form.make (Form.Map (seq l)) l
in
Form.make v loc
(* ── One call ────────────────────────────────────────────────────── (* ── One call ──────────────────────────────────────────────────────
The arguments are one contiguous run of Forms, not an array of pointers, The arguments are one contiguous run of Forms, not an array of pointers,
@ -133,13 +182,19 @@ let rec unmarshal ~loc (p : Dynload.addr) : Form.t =
let call ~loc (fn : Dynload.addr) (args : Form.t list) : Form.t = let call ~loc (fn : Dynload.addr) (args : Form.t list) : Form.t =
let n = List.length args in let n = List.length args in
(* This call's, and no other's. Nothing [Dynload] hands out is freed before
the whole expansion is over — [Dynload.release] runs between the rounds in
[Macro] and on the way out of one — so no address recorded here can be
handed to a second allocation while the table still holds it, and the
table is dropped the moment this returns either way. *)
let sites : sites = Hashtbl.create 64 in
let a = Dynload.take (max (n * form_size) 1) in let a = Dynload.take (max (n * form_size) 1) in
List.iteri List.iteri
(fun i x -> write (Nativeint.add a (Nativeint.of_int (i * form_size))) x) (fun i x -> write sites (Nativeint.add a (Nativeint.of_int (i * form_size))) x)
args; args;
let out = Dynload.take form_size in let out = Dynload.take form_size in
Dynload.call fn a (Int64.of_int n) out; Dynload.call fn a (Int64.of_int n) out;
unmarshal ~loc out unmarshal ~sites ~loc out
(* ── Quasiquote ──────────────────────────────────────────────────── (* ── Quasiquote ────────────────────────────────────────────────────
A desugaring over [Form], and nothing more: a quasiquoted (if ~t ~b) becomes A desugaring over [Form], and nothing more: a quasiquoted (if ~t ~b) becomes
@ -343,12 +398,11 @@ let params_of (v : Form.t) : msig =
sg sg
(* ── Checking a call against it ──────────────────────────────────── (* ── Checking a call against it ────────────────────────────────────
Before expansion, so the location is the call's own and not the Before expansion, so the refusal is about the call as it is written and
[Loc.from_macro] stamp every node of an expansion carries. That is the whole points at it. That is the whole reason this is a separate pass rather than
reason this is a separate pass rather than something the macro body could something the macro body could do: a macro has no error facility, and a call
do: a macro has no error facility, and by the time its body runs the only that does not fit the parameter list is a mistake in the call rather than in
location left is the one it was called from anyway — stamped onto forms the anything the expansion would go on to produce. *)
author never wrote. *)
let written (sg : msig) = Form.to_string sg.src let written (sg : msig) = Form.to_string sg.src

View File

@ -22,25 +22,42 @@ type t = {
ecol : int; ecol : int;
(* The macro whose expansion produced whatever is at this position, if one (* 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 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 is the thing that already travels: [Expand.unmarshal] puts this on every
site onto every node a macro answers with, and that stamp goes on through node a macro answers with, and it goes on through the AST and the typed IR
the AST and the typed IR untouched. Tagging it here means an error raised untouched. Tagging it here means an error raised anywhere downstream can
anywhere downstream can say which macro it is really about, with no field say which macro it is really about, with no field added to Form, to Ast or
added to Form, to Ast or to Tast. *) to Tast. *)
macro : string option; macro : string option;
(* Where that macro was called, when this position is not the call itself.
The two came apart when [Expand.unmarshal] learned to give a spliced form
back the line the author wrote it on: the error is at the author's line
and the call is elsewhere, so one field cannot be both. Empty means this
position *is* the call site, which is what every location that has never
been moved says. *)
msite : t option;
} }
let make file line col = let make file line col =
{ file; line; col; eline = line; ecol = col; macro = None } { file; line; col; eline = line; ecol = col; macro = None; msite = None }
let unknown = make "<unknown>" 0 0 let unknown = make "<unknown>" 0 0
(** Tag a location as coming out of [name]'s expansion, unless it already names (** 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 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 macro the author actually wrote is the one worth naming, not whatever it
expanded into on the way. *) expanded into on the way.
let from_macro name (t : t) =
match t.macro with None -> { t with macro = Some name } | Some _ -> t [at] is where the call was written, for a location that is somewhere else —
a form the author wrote inside the call and the macro passed through. Left
out, the location is the call site and says so by leaving [msite] empty. *)
let from_macro ?at name (t : t) =
match t.macro with
| Some _ -> t
| None -> { t with macro = Some name; msite = at }
(** Where to point when saying which macro a position came out of: the call
site if this position is not it. *)
let call_site (t : t) = match t.msite with Some s -> s | None -> t
(** [upto start stop] is [start] widened to end where [stop] begins. A [stop] (** [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 is not after [start], or is in another file, leaves it alone: a span
@ -85,12 +102,12 @@ let to_string t = Printf.sprintf "%s:%d:%d" t.file t.line t.col
state one of the two, which is why volume of messages was never the whole state one of the two, which is why volume of messages was never the whole
of what was missing. of what was missing.
[expansion] names the macro call an error came from. A form a macro produced [expansion] names the macro call an error came from, and points at the call.
carries the call site's location, so without this the report would point at A form the author wrote inside a macro call keeps its own line through the
the call and say nothing about the code not being what was written there. It expansion, so the error is reported where it was written and this is the
is filled in for errors raised *during* expansion; a checker error on a form second place the reader needs: the call that took the form and put it
a macro produced gets the call site's location without the macro's name, somewhere it does not work. A form the macro built has no line of its own
which is a limitation and not a claim. *) and is reported at the call, where the two coincide. *)
type severity = Info | Warning | Err type severity = Info | Warning | Err
@ -147,7 +164,8 @@ let diag ?(kind = "error") ?(notes = []) ?expansion loc msg =
let expansion = let expansion =
match expansion with match expansion with
| Some _ as e -> e | Some _ as e -> e
| None -> (match loc.macro with Some m -> Some (m, loc) | None -> None) | None ->
(match loc.macro with Some m -> Some (m, call_site loc) | None -> None)
in in
sort_notes { kind; dloc = loc; dmsg = msg; notes; expansion } sort_notes { kind; dloc = loc; dmsg = msg; notes; expansion }

View File

@ -319,9 +319,10 @@ let compile (names : string list) (extra : Form.t list) : loaded =
(* ── Where the call site is ──────────────────────────────────────── (* ── Where the call site is ────────────────────────────────────────
The one thing a macro cannot find out for itself and the one it needs to The one thing a macro cannot find out for itself and the one it needs to
read a data file: a Form carries no location — deliberately, see read a data file: a Form on the wire carries no location — the compiler
[Expand.unmarshal] — so a macro handed [(defedn T "assets/x.edn")] knows the keeps track of that on its own side, see [Expand.unmarshal] — so a macro
path and not what it is relative to. [(embed "assets/x.edn")] resolves handed [(defedn T "assets/x.edn")] knows the path and not what it is
relative to. [(embed "assets/x.edn")] resolves
against the directory of the source file the form is written in, and a macro against the directory of the source file the form is written in, and a macro
reading a file has to resolve it the same way or a package's data would reading a file has to resolve it the same way or a package's data would
depend on where flan was invoked from. depend on where flan was invoked from.
@ -393,11 +394,12 @@ let rec expand_form (l : loaded) (f : Form.t) : Form.t =
match f.Form.v with match f.Form.v with
| Form.List ({ Form.v = Form.Sym n; _ } :: args) when List.mem_assoc n l.fns -> | Form.List ({ Form.v = Form.Sym n; _ } :: args) when List.mem_assoc n l.fns ->
let args = List.map (expand_form l) args in let args = List.map (expand_form l) args in
(* The call site, tagged with the macro it is a call to. [Expand.unmarshal] (* The call site, tagged with the macro it is a call to. It is what a node
stamps it onto every node the macro answers with, so from here down the macro *built* is reported at — a form the author wrote inside the
every form it produced knows where it came from and an error on one of call keeps its own line, which [Expand.unmarshal] recovers — and either
them can say so. [checked_call] is where that tagging happens, along way the tag is what lets the report name the macro. [checked_call] is
with the arity and destructuring check that has to come first. *) where that tagging happens, along with the arity and destructuring
check that has to come first. *)
settle l n loc (checked_call l n ~loc args) fuel settle l n loc (checked_call l n ~loc args) fuel
| Form.List xs -> Form.make (Form.List (List.map (expand_form l) xs)) loc | 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.Vec xs -> Form.make (Form.Vec (List.map (expand_form l) xs)) loc
@ -570,8 +572,8 @@ let () = Parse.expander := program
Both answer the name at the head when it is a macro, so the editor can say Both answer the name at the head when it is a macro, so the editor can say
*which* macro it just ran rather than only that something changed. That name *which* macro it just ran rather than only that something changed. That name
is the one thing the printed text cannot carry: [Loc.from_macro] is is the one thing the printed text cannot carry: [Loc.from_macro] is
outermost-wins, so every node of a full expansion is stamped with the macro outermost-wins, so a form the author wrote is tagged with the macro they
the author wrote and the intermediate names are gone by the time it settles. called and the intermediate names are gone by the time it settles.
One step is outermost-only, and that is a deliberate difference from One step is outermost-only, and that is a deliberate difference from
[expand_form], which expands a call's arguments *before* calling it. So [expand_form], which expands a call's arguments *before* calling it. So

View File

@ -2056,11 +2056,13 @@ let source = {flan|
;; edited together. ;; edited together.
;; ;;
;; It mirrors Form.value and not Form.t: there is no `loc` field. A macro ;; It mirrors Form.value and not Form.t: there is no `loc` field. A macro
;; cannot invent a source location and should not carry one, so the compiler ;; cannot invent a source location and should not carry one, so locations stay
;; stamps the *call site's* location onto every node of what a macro returns. ;; on the compiler's side. It still knows where a form came from: every case
;; That is the structural version of "keep the source location of the call ;; but the three that fit inside the payload holds a pointer into memory the
;; site attached to what a macro produces", and it is what the queued ;; compiler allocated, and lib/expand.ml keeps a table from that address to the
;; structured-error work will read. ;; line the form was read on. A form a macro splices through comes back holding
;; that pointer, so an error on it is reported where it was written; a node the
;; macro built is reported at the call.
;; ;;
;; Case order is the tag order (docs/BUILT.md, data types), so this list is a layout ;; Case order is the tag order (docs/BUILT.md, data types), so this list is a layout
;; contract with lib/expand.ml's marshaller and may not be reordered. ;; contract with lib/expand.ml's marshaller and may not be reordered.

View File

@ -2,8 +2,8 @@
;;;; ;;;;
;;;; Nothing here is a run-time claim and nothing here expands: check_call ;;;; Nothing here is a run-time claim and nothing here expands: check_call
;;;; counts the call against the list before do-grid is ever run, so the ;;;; counts the call against the list before do-grid is ever run, so the
;;;; location is the line below rather than a node of an expansion stamped ;;;; location is the line below and there is no expansion to report anywhere
;;;; with Loc.from_macro. ;;;; else.
(defmacro do-grid [[r rows c cols] & body] (defmacro do-grid [[r rows c cols] & body]
`(dotimes [~r ~rows] (dotimes [~c ~cols] ~@body))) `(dotimes [~r ~rows] (dotimes [~c ~cols] ~@body)))

View File

@ -0,0 +1,20 @@
;;;; A mistake in a body a macro spliced is reported where it was written.
;;;;
;;;; do-grid takes the body apart and puts it back inside two dotimes, and
;;;; nothing in it is rewritten on the way. So the (+ mr) below is the very
;;;; form the author wrote, and the line it is reported on is the one it is
;;;; written on -- not the line of the do-grid call, which is where every node
;;;; of an expansion used to land.
(defmacro do-grid [[r rows c cols] & body]
`(dotimes [~r ~rows]
(dotimes [~c ~cols]
~@body)))
(defn main [] i32
(let [rows 4
cols 4
mr 1]
(do-grid [br rows bc cols]
(let [v (+ mr)]
(set v v))))
0)

View File

@ -0,0 +1,19 @@
;;;; A number a macro passed through is reported at the form around it.
;;;;
;;;; A Form on the wire carries a pointer for a name, a string and a bracket,
;;;; and that pointer is how a form that went into a macro is recognised when
;;;; it comes back out. An integer has no pointer -- its payload is the value
;;;; -- so the 5 below has nothing to be recognised by and takes the location
;;;; of the (g ...) around it, which does. So the error lands on the line the
;;;; call opens on rather than the line the number is on, and the two are kept
;;;; apart below so that the difference is visible.
(defmacro thru [& body]
`(do ~@body))
(defn g [s string] i32 0)
(defn main [] i32
(thru
(g
5))
0)

View File

@ -0,0 +1,18 @@
;;;; Two rounds of expansion, and the body comes through both of them.
;;;;
;;;; outer expands into a call to inner, which expands again. The forms below
;;;; are written once and marshalled twice, so the second round has to find
;;;; the location the first round gave back rather than the call site it was
;;;; passing through. The note names outer, the macro that was written here,
;;;; and not inner, which nobody wrote.
(defmacro inner [& body]
`(do ~@body))
(defmacro outer [& body]
`(inner ~@body))
(defn main [] i32
(outer
(let [a 1]
(+ a nowhere)))
0)

View File

@ -0,0 +1,17 @@
;;;; A node the macro built has no line; the leaves it built it from keep
;;;; theirs.
;;;;
;;;; flip does not splice a body, it assembles a new list out of its two
;;;; arguments. That list is the macro's own -- it is reported at the call --
;;;; while nosuchname inside it is the author's form, spliced through
;;;; untouched, and is reported where it is written.
(defmacro flip [a b]
`(~b ~a))
(defn g [x i32] i32 x)
(defn main [] i32
(flip
nosuchname
g)
0)

View File

@ -4118,6 +4118,61 @@ level "1"
print_endline "FAIL the report does not say which macro" print_endline "FAIL the report does not say which macro"
end); end);
(* ── Where a mistake inside a macro call is reported ─────────────
A form the author writes inside a macro call is handed to the macro and
usually handed straight back, and it comes back holding the pointer it
went out with — the string's bytes, or a bracket's children, memory this
compiler allocated. [Expand] keeps a table of those addresses for the
length of one call, which is how a spliced form gets its own line back
instead of the call's. SBCL's [*source-paths*] does the same thing with
an EQ table over conses.
Each row is asserted on the *rendered* report rather than on the
location record, because the three parts that have to line up — the
line, the squiggle under the right form, and the note pointing at the
call — read as one sentence and are three fields here. *)
let reports name path wanted =
match
let l = Load.program ~file:path (Reader.read_file path) in
ignore (Check.program l.Load.decls)
with
| () ->
incr failures;
Printf.printf "FAIL %s\n it was accepted\n" name
| exception Loc.Error d ->
let text = Loc.report d in
List.iter
(fun w ->
if not (contains text w) then begin
incr failures;
Printf.printf "FAIL %s\n said: %s\n wanted: %S in it\n"
name text w
end)
wanted
in
(* The falling-sand case this was written for: the body is several lines
below the call, and the call is what used to be reported. *)
reports "a mistake in a spliced body" "programs/macro-loc-body.flan"
[ "macro-loc-body.flan:18:15: + takes two arguments or more, given 1";
"macro-loc-body.flan:17:5: note: expanded from the macro do-grid" ];
(* A number has no pointer to be recognised by, so it takes the location of
the call around it — a form the author wrote, which does have one. *)
reports "a literal a macro passed through" "programs/macro-loc-literal.flan"
[ "macro-loc-literal.flan:17:5: expected string, found the integer \
literal 5";
"macro-loc-literal.flan:16:3: note: expanded from the macro thru" ];
(* The list is the macro's and is reported at the call; the name inside it
is the author's and is reported where it is written. *)
reports "a node the macro built" "programs/macro-loc-rebuilt.flan"
[ "macro-loc-rebuilt.flan:15:5: unknown name nosuchname";
"macro-loc-rebuilt.flan:14:3: note: expanded from the macro flip" ];
(* Marshalled twice. The location survives both rounds, and the note names
the macro that was written rather than the one it expanded into. *)
reports "a body through two rounds of expansion"
"programs/macro-loc-nested.flan"
[ "macro-loc-nested.flan:17:12: unknown name nowhere";
"macro-loc-nested.flan:15:3: note: expanded from the macro outer" ];
(* Every prelude macro's arity guard, said the only way a macro can say (* Every prelude macro's arity guard, said the only way a macro can say
anything: a call to a name nothing defines, reported at the call site. anything: a call to a name nothing defines, reported at the call site.
The point of pinning these is that without a guard the macro would The point of pinning these is that without a guard the macro would
@ -4164,10 +4219,8 @@ level "1"
"none can be compiled first"; "none can be compiled first";
(* A call that does not fit the macro's parameter list, in all four of the (* A call that does not fit the macro's parameter list, in all four of the
ways it can fail to. Every one of them is refused *before* the macro is ways it can fail to. Every one of them is refused *before* the macro is
expanded, which is why each message carries the call's own location expanded, which is why each message is about the call as it is written
rather than the [Loc.from_macro] stamp every node of an expansion gets — and points at it: there is no expansion yet to point anywhere else. *)
that stamping is a documented limitation waiting on the structured-error
rewrite, and these four are the part of it that does not have to wait. *)
refuses "a macro call with too few arguments" "programs/macro-arity.flan" refuses "a macro call with too few arguments" "programs/macro-arity.flan"
"do-grid takes at least 1 argument and this call gives 0 — its \ "do-grid takes at least 1 argument and this call gives 0 — its \
parameter list is [[r rows c cols] & body], where &body is the rest"; parameter list is [[r rows c cols] & body], where &body is the rest";

View File

@ -690,6 +690,37 @@ let () =
| exception Loc.Error { Loc.dmsg = m; _ } -> | exception Loc.Error { Loc.dmsg = m; _ } ->
fail "an edited defmacro: %s" m); fail "an edited defmacro: %s" m);
(* A mistake in a body the macro spliced, reported where it was written.
Same machinery as a build — [Macro.expand_form] and [Expand.call] — and
the point of asking it here is that the editor is where it is read: C-c
C-c on a form four lines long puts the cursor on the line the message
names, and naming the macro call would put it on the wrong one every
time. The body is on the third line of what is sent and the call is on
the first. *)
(match Session.eval ~origin:"programs/pkg-macro.flan" tm
"(defmacro splice [& body] `(do ~@body))"
with
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "evaluating a splicing defmacro: %s" m);
(match
Session.eval ~origin:"programs/pkg-macro.flan" tm
"(defn spliced [] i32\n\
\ (splice\n\
\ (+ nowhere 1))\n\
\ 0)"
with
| _ -> fail "a mistake in a spliced body was accepted"
| exception Loc.Error d ->
if d.Loc.dloc.Loc.line <> 3 then
fail "a mistake in a spliced body was reported on line %d, not 3: %s"
d.Loc.dloc.Loc.line d.Loc.dmsg;
(match d.Loc.expansion with
| Some ("splice", at) when at.Loc.line = 2 -> ()
| Some (n, at) ->
fail "the note on a spliced body says %s at line %d" n at.Loc.line
| None -> fail "the note on a spliced body names no macro"));
(* The robustness lane's property, held for macros too: the commit is below (* The robustness lane's property, held for macros too: the commit is below
the checker, so a [defmacro] that does not check leaves the session the checker, so a [defmacro] that does not check leaves the session
holding nothing of it. The body calls an unknown name, so the form parses holding nothing of it. The body calls an unknown name, so the form parses