One macro call, several declarations, and a refusal that carries a sentence

A type provider produces a struct and a reader over it, and a struct per
nesting level in the data. Expansion is form-for-form, so one call could only
ever become one declaration — which was enough while every macro expanded to an
expression. A top-level (do ...) is now its items, spliced in place, after
expansion and before the declaration walk. Nobody writes one in a file, and the
single-declaration entry point says so by name for anyone who tries.

And (compile-error "...") is what a macro expands to when it has to refuse. The
prelude's `unless` records the gap: a macro has no error facility, so a
malformed call answers a name nothing defines and the report is the right place
with the wrong sentence. A name carries a name. A type provider's refusals are
all sentence — the third element of this vector is a string where the first two
were integers, at line 3 column 9 of a file the compiler is not reading — and
no symbol an expansion could invent holds that. Loc.from_macro already stamps
the call site onto the expansion, so the location is the form the author wrote.

A builtin because it has to fail while checking: a declared function would
compile, link and run, and the compile it was meant to stop would have
succeeded.
This commit is contained in:
Joseph Ferano 2026-09-19 05:43:51 +07:00
parent 32ff350001
commit a2004ae7a6
3 changed files with 92 additions and 12 deletions

View File

@ -4289,6 +4289,41 @@ and named_call ctx ~want loc name args =
| _ ->
fail loc
"embed is (embed \"path\") for a [u8], or (embed \"path\" string)")
(* ── What a macro says when it has to refuse ───────────────────
The one thing a macro could not do, written down in the prelude where
[unless] settles for it: "a macro has no error facility: it runs inside
the compiler and anything it signals aborts the compile with no location.
So a malformed (unless) answers a name nothing defines, and the report is
'unknown name unless-takes-a-test-and-a-body' at the call site, which is
the right place and the wrong sentence."
A name nothing defines carries a name. It cannot carry a sentence, and a
type provider's refusals are all sentence: the third element of this
vector is a string where the first two were integers; there is no file at
assets/x.edn; :size is a map with keys of two kinds. Those name a position
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
the report. [Loc.from_macro] has already stamped the call site onto every
node of the expansion, so the location is the [defedn] the author wrote
and the sentence is the macro's which is the two 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
function would compile, link and run, and the compile it was meant to stop
would have succeeded. The whole of it is one arm, and the argument is a
literal for the same reason [embed]'s path is one there is nothing at
this point in a compile to compute a string from. *)
| "compile-error" ->
arity loc name 1 args;
(match (List.hd args).Ast.e with
| Ast.Str s -> fail loc "%s" s
| _ ->
fail (List.hd args).Ast.loc
"compile-error takes a literal string — it is reported while the \
program is being checked, so there is nothing here to build one \
from. A macro that has to refuse builds the sentence as it expands \
and puts it in the form")
| "embed-dir" ->
arity loc name 1 args;
let arg = List.hd args in
@ -5330,6 +5365,10 @@ let builtins : (string * string * string) list =
("embed-dir", "embed-dir [\"path\"] [n EmbedFile]",
"Every file in the directory, read at compile time, as a fixed array of \
EmbedFile. It does not descend.");
("compile-error", "compile-error [\"message\"] ()",
"Refuses the compile with that message, at the form it is written in. \
What a macro expands to when it has to say why: a name nothing defines \
carries a name, and this carries a sentence.");
(* files *)
("slurp", "slurp [string Allocator?] (Vec u8)",

View File

@ -311,18 +311,21 @@ let compile (names : string list) (extra : Form.t list) : loaded =
let dir_of (l : loaded) (loc : Loc.t) =
let dir = Filename.dirname loc.Loc.file in
let dir = if String.equal dir "." then "" else dir in
match Dynload.dl_sym l.handle "flan_macro_dir" with
| exception _ -> ()
| buf ->
let n = Dynload.dl_sym l.handle "flan_macro_dir_n" in
(* 4096 is FLAN_PATH_MAX, and a path at or over it is left unset rather
than truncated: half a directory is a path that resolves to the wrong
file, where none at all resolves to none. *)
if String.length dir > 0 && String.length dir < 4096 then begin
Dynload.poke_bytes buf 0 dir;
Dynload.poke_i64 n 0 (Int64.of_int (String.length dir))
end
else Dynload.poke_i64 n 0 0L
(* Not guarded. The symbol is in the module this just built, so its absence
means [runtime/flan_rt.c] and this file have come apart and the shape
that failure would take if it were swallowed is a relative path resolving
against the compiler's working directory, which reads some *other* file
and says nothing. A missing symbol raises out of [dl_sym] instead. *)
let buf = Dynload.dl_sym l.handle "flan_macro_dir" in
let n = Dynload.dl_sym l.handle "flan_macro_dir_n" in
(* 4096 is FLAN_PATH_MAX, and a path at or over it is left unset rather than
truncated: half a directory is a path that resolves to the wrong file,
where none at all resolves to none. *)
if String.length dir > 0 && String.length dir < 4096 then begin
Dynload.poke_bytes buf 0 dir;
Dynload.poke_i64 n 0 (Int64.of_int (String.length dir))
end
else Dynload.poke_i64 n 0 0L
(* ── The walk ──────────────────────────────────────────────────────
Bottom up: a macro's arguments are expanded before it is called, so nothing

View File

@ -1152,6 +1152,15 @@ let rec decl (f : Form.t) : Ast.decl =
| _ ->
fail f "defmacro is (defmacro name [param ...] body ...)")
(* Only reachable from the single-declaration entry point below: a file's
forms go through [splice] first, and a [do] there is its items. Said by
name because the two paths differ and the difference is not the author's
fault to guess at. *)
| List ({ v = Sym "do"; _ } :: _) ->
fail f
"a top-level (do ...) is several declarations spliced in place, and this \
is a position that takes exactly one a macro answering several is a \
file's form, not an expression's"
| List ({ v = Sym s; _ } :: _) -> fail f "unknown top-level form (%s ...)" s
| _ -> fail f "expected a top-level declaration, found %s" (Form.to_string f)
@ -1236,12 +1245,41 @@ let with_imported ?(decls = []) (ms : Form.t list) (f : unit -> 'a) : 'a =
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. *)
(* ── One call, several declarations ────────────────────────────────
Expansion is form-for-form: [Macro.expand_form] answers one [Form.t] per
input and the loop below turns each into one [Ast.decl]. Every macro written
until now expands to an *expression* [unless], [into], raylib's [with-*]
so one-for-one was the whole of what was needed.
A type provider is the first thing that is not. [(defedn Tileset "t.edn")]
has to produce the struct *and* the reader over it, and a nested map in the
data means a struct per nesting level: three declarations and more from one
form. There is no arrangement of one-for-one that reaches that.
So a [do] at the top level is its items, in place. It is the sequencing
spelling the language already has, it is Clojure's answer to exactly this,
and it is only ever reachable by a macro: nobody writes [(do (defn ...))] in
a file, and the message below still says so for anyone who tries and wrote
it wrong. Recursive, because a macro that splices what another macro
answered has a [do] inside a [do] and the nesting is not the author's to
flatten by hand.
It is spliced *after* expansion and before the declaration walk, so what is
spliced is already fully expanded a [do] holding a call to another macro
settled before it got here. *)
let rec splice (f : Form.t) : Form.t list =
match f.Form.v with
| Form.List ({ Form.v = Form.Sym "do"; _ } :: items) ->
List.concat_map splice items
| _ -> [ f ]
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
let forms = List.concat_map splice forms in
temps := 0;
let s = Loc.sink ~on:keep_going in
let decls = List.filter_map (fun f -> Loc.caught s (fun () -> decl f)) forms in