Quasiquote is a desugaring, and a defmacro is a defn

Two things that look like plumbing and are the frontend half of expansion.

A quasiquote becomes calls to the prelude's three form-building functions and
nothing else: form-nil, form-cons for an item, form-append for a splice. It is
pure, it needs nothing loaded, and it runs over every form on the way into
Parse.program and Parse.decl, which is what lets the prelude's own macros parse
in a process that has not built a macro module yet.

Running it *before* the expander's walk is not an ordering preference. A cond
macro's body contains a quasiquoted (cond ...) for its own tail; with the
quasiquote still standing, the walk would see that head and expand it then and
there, against the wrong arguments. Desugared first, that subform is a
(Form.Sym {.s "cond"}) and there is no head left to mistake. So the walk needs
no idea that quoting exists, which is the whole reason this runs first.

Nesting levels are not counted -- not by the reader, which was written that way
deliberately, and not here. A quasiquote inside a quasiquote is refused by
name. Only a macro that writes a macro wants one, nothing in the corpus does,
and CL's level arithmetic costs more than the use case is worth so far.

A defmacro is now an Ast.Defn: (defmacro m [args] body) is
(defn m [args [Form]] Form body). There is no Ast.Defmacro and there is not
going to be one -- a macro is [Form] -> Form, compiled by the same backend as
everything else, and the only thing that makes it a macro is that the expander
calls it at compile time. One parameter, the slice of forms at the call site,
so variadics come free in a language with no &rest; two parameters is a
misunderstanding rather than an arity error and says so.

Parse.expander is the hook the walk arrives through, because expanding a macro
means compiling and dlopening it, so the expander sits above Check and Build
and Parse sits below them. Nothing fills it in yet.

The quasiquote refusal stays as a backstop: it now means a form reached the
parser without coming through program or decl. gensym's refusal is gone -- it
is an ordinary prelude function returning a Form, and a macro body calls it
like any other.
This commit is contained in:
Joseph Ferano 2026-09-12 20:51:06 +07:00
parent a34b63af5d
commit 0e7f53a510
3 changed files with 198 additions and 36 deletions

View File

@ -144,3 +144,90 @@ let call ~loc (fn : Dynload.addr) (args : Form.t list) : Form.t =
let out = Dynload.take form_size in
Dynload.call fn a (Int64.of_int n) out;
unmarshal ~loc out
(* ── Quasiquote ────────────────────────────────────────────────────
A desugaring over [Form], and nothing more: a quasiquoted (if ~t ~b) becomes
calls to the prelude's form-building surface, which the checker then sees as
ordinary code. There is no quasiquote left in the language after this runs,
which is why the expander's own walk needs no idea that quoting exists: by
the time it looks for macro calls, a [cond] written inside a quasiquote is a
(Form.Sym {.s "cond"}) and there is no head there to mistake for a call the
compiler should make now.
The reader stays dumb and produces (quasiquote x), (unquote x) and
(unquote-splicing x) with no idea whether one is inside another. Counting
levels is this file's job, and it does not: a quasiquote inside a quasiquote
is refused by name. A macro that writes a macro is the only thing that wants
one, nothing in the corpus does, and CL's level arithmetic has a real cost
that no use case has asked for. *)
let sym loc s = Form.make (Form.Sym s) loc
let lst loc xs = Form.make (Form.List xs) loc
(* (Form.Case {.field value}) — a node of the image, written as the Flan
constructor the prelude declares. *)
let node loc case field v =
lst loc [ sym loc ("Form." ^ case);
Form.make (Form.Map [ sym loc ("." ^ field); Form.make v loc ]) loc ]
let unquote_of (f : Form.t) =
match f.Form.v with
| Form.List [ { Form.v = Form.Sym "unquote"; _ }; x ] -> Some x
| _ -> None
let splice_of (f : Form.t) =
match f.Form.v with
| Form.List [ { Form.v = Form.Sym "unquote-splicing"; _ }; x ] -> Some x
| _ -> None
let rec quote (f : Form.t) : Form.t =
let loc = f.Form.loc in
match unquote_of f with
(* The escape: whatever the program wrote, evaluated. It is already a Form,
because a Form is what a macro body deals in. *)
| Some x -> x
| None ->
match splice_of f with
| Some _ ->
Loc.fail loc
"~@x splices into a list or a vector, and there is nothing here for it \
to splice into"
| None ->
match f.Form.v with
| Form.List ({ Form.v = Form.Sym "quasiquote"; _ } :: _) ->
Loc.fail loc
"a quasiquote inside a quasiquote is not implemented: the reader does \
not count nesting levels and neither does this, so the inner one has \
no meaning to give. Build the inner form with form-cons"
| Form.Sym s -> node loc "Sym" "s" (Form.Str s)
| Form.Kw s -> node loc "Kw" "s" (Form.Str s)
| Form.Int i -> node loc "Int" "i" (Form.Int i)
| Form.Float x -> node loc "Float" "x" (Form.Float x)
| Form.Str s -> node loc "Str" "s" (Form.Str s)
| Form.Byte b -> node loc "Byte" "b" (Form.Int (Int64.of_int b))
| Form.List xs -> node loc "List" "xs" (seq loc xs).Form.v
| Form.Vec xs -> node loc "Vec" "xs" (seq loc xs).Form.v
| Form.Map xs -> node loc "Map" "xs" (seq loc xs).Form.v
(* The [Form] slice one bracket's worth of items comes to. Built right to left,
so each item is consed onto what follows it and a splice is an append the
three prelude functions and no fourth. *)
and seq loc items =
List.fold_left
(fun acc (item : Form.t) ->
match splice_of item with
| Some x -> lst item.Form.loc [ sym item.Form.loc "form-append"; x; acc ]
| None -> lst item.Form.loc [ sym item.Form.loc "form-cons"; quote item; acc ])
(lst loc [ sym loc "form-nil" ])
(List.rev items)
(* Every quasiquote in a form, outermost first. Pure, total, and dependent on
nothing but Form, which is what lets [Parse] run it on the way in rather
than needing the whole expander wired up first. *)
let rec quasiquote (f : Form.t) : Form.t =
match f.Form.v with
| Form.List [ { Form.v = Form.Sym "quasiquote"; _ }; x ] -> quote x
| Form.List xs -> Form.make (Form.List (List.map quasiquote xs)) f.Form.loc
| Form.Vec xs -> Form.make (Form.Vec (List.map quasiquote xs)) f.Form.loc
| Form.Map xs -> Form.make (Form.Map (List.map quasiquote xs)) f.Form.loc
| _ -> f

View File

@ -296,9 +296,14 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr =
(* The reader now produces these three, so they arrive here as ordinary heads
and would fall through to Call coming back from the checker as "unknown
name quasiquote", which says nothing about what is actually missing. *)
(* [Expand.quasiquote] runs over every form on the way into [program] and
[decl], so a quasiquote is gone before this file looks at it and this arm
cannot be reached by anything that came through either. It is kept as the
backstop for the path that did not: a form built by hand and handed
straight to [expr]. *)
| Sym "quasiquote" ->
fail f "`x is read, but not expanded: macro expansion is not wired up yet \
(NEXT.md says what it needs)"
fail f "a quasiquote reached the parser undesugared, which means this form \
did not come through Parse.program or Parse.decl"
(* Not a milestone, a mistake: these two mean nothing anywhere else, and the
reader cannot tell, because it does not track where it is. *)
@ -311,13 +316,6 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr =
| Sym "defmacro" ->
fail f "defmacro is a top-level declaration, not an expression"
(* Neither a reader token nor a special form: an ordinary function that a
macro body calls while the macro runs. There is nowhere for it to run
yet, so it says that rather than arriving as an unknown name. *)
| Sym "gensym" ->
fail f "gensym is only meaningful inside a macro body, and macro expansion \
is not wired up yet (NEXT.md says what it needs)"
(* Recognised, deliberately unimplemented. Rejected rather than left to fall
through to Call, where they would parse and mean nothing. *)
| Sym ("handler-case"
@ -760,25 +758,38 @@ let rec decl types (f : Form.t) : Ast.decl =
| [ n; t; v ] -> mk (Ast.Defconst (sym n, Some (texpr t), expr v))
| _ -> fail f "defconst is (defconst name Type? value)")
(* Checked for shape and then refused, which is deliberate. Getting the shape
wrong and getting the whole feature are two different mistakes, and a
"defmacro is (defmacro ...)" that only ever fired after expansion landed
would be a rule nothing enforced in the meantime.
(* A macro is an ordinary function, and this is where it becomes one:
[(defmacro m [args] body)] is [(defn m [args [Form]] Form body)]. There is
no [Ast.Defmacro] and there is not going to be one -- a macro has the type
[[Form] -> Form], it is compiled by the same backend as everything else,
and the only thing that makes it a macro is that [Expand] calls it at
compile time instead of the program calling it at run time.
The refusal is not about parsing. Expanding a macro means running it, and
there is no interpreter the compiled path is the only backend. So it
means compiling the macro and dlopening it into the compiler, which is
what Emit.redefinition and Build.shared already do for the dev loop.
NEXT.md writes down how that goes together. *)
One parameter, the slice of the argument forms, rather than one declared
parameter per argument. It needs no reader or parser change and it gives
variadics for free, which is what [unless] and [when] need in a language
with no &rest.
The shape rules stay exactly as they were, because they were enforced
before the feature existed on purpose: getting the shape wrong and getting
the whole feature are different mistakes. *)
| List ({ v = Sym "defmacro"; _ } :: args) ->
(match args with
| n :: { v = Form.Vec ps; _ } :: body when body <> [] ->
let name = sym n in
| n :: { v = Form.Vec [ p ]; _ } :: body when body <> [] ->
let form_t = { Ast.t = Ast.Tname "Form"; tloc = f.loc } in
mk (Ast.Defn
{ Ast.name = sym n;
params = [ { Ast.fname = sym p;
fty = { Ast.t = Ast.Tslice form_t; tloc = p.loc };
floc = p.loc } ];
ret = Some form_t; fbody = body_of body; nloc = n.loc })
| _ :: { v = Form.Vec ps; _ } :: body when body <> [] ->
List.iter (fun (p : Form.t) -> ignore (sym p)) ps;
fail f
"defmacro %s parses, but is not expanded: running a macro means \
compiling it and loading it into the compiler, which is not wired \
up yet (NEXT.md says what it needs)" name
"a macro takes one parameter, the forms at its call site, and this \
one names %d. There is no &rest and no arity: (defmacro m [args] \
...) and (len args) is how many were written"
(List.length ps)
| _ ->
fail f "defmacro is (defmacro name [param ...] body ...)")
@ -902,11 +913,37 @@ let prelude_types =
let declared_types (forms : Form.t list) : Names.t =
types_in (Lazy.force prelude_types) forms
(* Macro expansion, which runs over [Form] and therefore before anything in
this file. It cannot be called directly: expanding a macro means compiling
it and dlopening it, so the expander sits above [Check] and [Build] and this
module sits below them. [Macro] fills this in, and lib/dune passes -linkall
so that it always has -- an executable that links the library gets the
installation whether or not it names the module.
The default is the identity because [Macro] is what knows which names are
macros; with nothing installed, a call to one arrives at the checker as an
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 =
(* 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 types = declared_types forms in
temps := 0;
List.map (decl types) forms
(* Single-declaration entry point, for tests and the REPL. Sees the builtin and
prelude types plus whatever this one form declares. *)
let decl (f : Form.t) : Ast.decl = temps := 0; decl (declared_types [ f ]) f
let decl (f : Form.t) : Ast.decl =
temps := 0;
match !expander [ Expand.quasiquote f ] with
| [ f ] -> decl (declared_types [ f ]) f
| fs ->
(* One declaration in, one out. A macro at the top level would break that,
and there is no top-level macro call: [decl] dispatches on the head and
a macro name is not one of the heads it knows. *)
Loc.fail f.loc "expanding this declaration produced %d of them"
(List.length fs)

View File

@ -389,12 +389,23 @@ let () =
parse_rejects "restart-case" "(restart-case body (r [] 1))";
parse_rejects "loop/recur" "(loop [x 1] (recur x))";
(* ── Macros: the front half is here, the expander is not ───────── *)
(* Was "unknown top-level form (defmacro ...)" — refused, but not by name and
with no reason, which is the hole the house rule had at the top level. *)
parse_rejects "defmacro declaration" "(defmacro m [x] x)"
~needle:"not expanded";
(* Shape and feature are separate mistakes and get separate reasons. *)
(* ── Macros ─────────────────────────────────────────────────────── *)
(* A defmacro is a defn. There is no Ast.Defmacro and there is not going to
be one: a macro is [Form] -> Form, compiled by the same backend as
everything else, and what makes it a macro is that the expander calls it
at compile time rather than the program calling it at run time. *)
(match (parse_decl "(defmacro m [args] (at args 0))").d with
| Defn { name = "m"; params = [ p ]; ret = Some r; _ } ->
(match p.fty.t, r.t with
| Tslice { t = Tname "Form"; _ }, Tname "Form" -> ()
| _ -> check "defmacro is [Form] -> Form" false)
| _ -> check "defmacro parses as a defn" false);
(* One parameter, the forms at the call site. Two is not an arity mistake, it
is a misunderstanding of what a macro takes, and it gets its own reason. *)
parse_rejects "defmacro with two parameters" "(defmacro m [a b] a)"
~needle:"a macro takes one parameter";
(* Shape and feature were separate mistakes and stay separate reasons. *)
parse_rejects "defmacro with no body" "(defmacro m [x])"
~needle:"defmacro is (defmacro name [param ...] body ...)";
parse_rejects "defmacro with no params" "(defmacro m x)"
@ -404,18 +415,45 @@ let () =
parse_rejects "defmacro in expression position" "(defn f [] (defmacro m [] 1))"
~needle:"top-level declaration";
(* The reader now hands these three to the parser, so each says what is
actually wrong rather than arriving at the checker as an unknown name. *)
parse_rejects "quasiquote in a function" "(defn f [] `(a b))"
~needle:"not expanded";
(* Quasiquote is a desugaring over Form, and it has already run by the time
the parser sees anything, so what is written here is what a macro body
actually compiles to: the prelude's three form-building functions and
nothing else. Spelled out rather than described, because the desugaring
*is* the contract with the prelude. *)
let desugars name src want =
match read src with
| [ f ] ->
let got = Form.to_string (Expand.quasiquote f) in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n got: %s\n wanted: %s\n"
name got want
end
| _ -> check (name ^ ": one form") false
in
desugars "a quasiquoted list is form-cons over Form nodes" "`(a ~b)"
"(Form.List {.xs (form-cons (Form.Sym {.s \"a\"}) (form-cons b (form-nil)))})";
desugars "a splice is form-append" "`(a ~@bs)"
"(Form.List {.xs (form-cons (Form.Sym {.s \"a\"}) (form-append bs (form-nil)))})";
(* A vector keeps its bracket through the desugaring: a binding vector is the
commonest thing a macro builds and Form.Vec is not Form.List. *)
desugars "a quasiquoted vector stays a vector" "`[~x 1]"
"(Form.Vec {.xs (form-cons x (form-cons (Form.Int {.i 1}) (form-nil)))})";
(* Levels are not counted -- not by the reader, deliberately, and not here,
which is why the inner one is refused by name rather than given a meaning
nobody chose. *)
parse_rejects "a quasiquote inside a quasiquote" "(defn f [] Form `(a `(b)))"
~needle:"quasiquote inside a quasiquote";
(* Not a missing feature — an unquote outside a quasiquote is a mistake, and
the reader cannot catch it because it does not track where it is. *)
parse_rejects "unquote outside a quasiquote" "(defn f [] ~x)"
~needle:"means nothing outside a quasiquote";
parse_rejects "splice where a splice makes no sense" "(defn f [] (+ 1 ~@xs))"
~needle:"splices only into a list or a vector";
parse_rejects "gensym outside a macro" "(defn f [] (gensym))"
~needle:"only meaningful inside a macro body";
(* A splice with no bracket around it. The quasiquote is real here, so this
one is the desugaring's refusal and not the parser's. *)
parse_rejects "splice not inside a bracket" "(defn f [] Form `~@xs)"
~needle:"nothing here for it to splice into";
(* ── Malformed syntax is caught with a location ────────────────── *)
parse_rejects "odd let bindings" "(let [a])";