From a2004ae7a64166cbeae47ecb2fa0ff0dd63ca9d6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 19 Sep 2026 05:43:51 +0700 Subject: [PATCH] One macro call, several declarations, and a refusal that carries a sentence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/check.ml | 39 +++++++++++++++++++++++++++++++++++++++ lib/macro.ml | 27 +++++++++++++++------------ lib/parse.ml | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index 9d7b1df..c9c411e 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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)", diff --git a/lib/macro.ml b/lib/macro.ml index 1653a20..7b3e5a2 100644 --- a/lib/macro.ml +++ b/lib/macro.ml @@ -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 diff --git a/lib/parse.ml b/lib/parse.ml index 2d519db..6ac707c 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -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