A quasiquote inside a quasiquote nests, and a macro that writes a macro defines one the program can call

This commit is contained in:
Joseph Ferano 2026-09-25 10:14:41 +07:00
parent bf827dc55b
commit 0167fcef43
7 changed files with 161 additions and 31 deletions

View File

@ -81,10 +81,14 @@ back after. It counts across every module a compiler process loads — each roun
the program's module, and every expansion in a session. Rules out a counter per
module, seeded or not.
** NEXT A quasiquote inside a quasiquote is refused
Decided 2026-09-25: nest the way SBCL and Clojure both do. The desugaring counts depth, an unquote belongs to the innermost quasiquote, and =~~x= reaches out two levels — SBCL's =*backquote-depth*= in =src/code/backq.lisp=. The reader stays as it is.
Nothing counts nesting levels — not the reader, deliberately, and not the
desugaring. Only a macro that writes a macro wants one.
** DONE A quasiquote inside a quasiquote nests
CLOSED: [2026-09-25]
=Expand.quote= counts depth the way SBCL's =*backquote-depth*= does: an unquote
belongs to the innermost quasiquote and =~~x= reaches out two levels; deeper
forms come back as data. A macro's answer is desugared again, and a top-level
expansion that defines a macro re-runs the expander. =~~@x= is refused. There is
no =,',x=, since =quote= takes a symbol, and a macro defined by an expansion is
not exported from a package. docs/BUILT.md, "Quasiquote runs before the walk".
** TODO until, cond, when and dotimes are still special forms in parse.ml
=until= and =cond= are free to move to the prelude whenever somebody wants them.

View File

@ -3482,8 +3482,22 @@ quasiquoted call to itself; with the quasiquote still standing, the walk would s
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.
Nesting levels are counted nowhere: not by the reader, which was written that way deliberately, and not by the
desugaring. A quasiquote inside a quasiquote is refused by name. Only a macro that writes a macro wants one.
Nesting levels are counted by the desugaring and not by the reader, which stays as it was written. A quasiquote
inside a quasiquote raises the depth and an unquote lowers it, SBCL's `*backquote-depth*` in `src/code/backq.lisp`:
an unquote belongs to the innermost quasiquote around it, `~~x` reaches out two levels, and only the unquotes at
depth 1 are evaluated. Everything deeper comes back as the `(quasiquote x)`, `(unquote x)` and
`(unquote-splicing x)` forms it was read as, and is desugared in its turn when the macro that holds it runs.
That second desugaring is why `Macro.settle` runs `Expand.quasiquote` over whatever a macro answers, before it looks
at a single head: a macro that writes a macro answers a `defmacro` whose body still holds the inner quasiquote, and
the walk must not read a call inside it as a call. And because the macro module is fixed before the walk starts, a
macro that the expansion *defined* is not in it: `Macro.program` expands the whole run again when an expansion leaves
a new `defmacro` at the top level, bounded by the same fuel as `settle`.
`~~n` pastes the *form* `n` holds into the inner template as an expression the inner macro evaluates. There is no
`,',n`, because `quote` takes a symbol and not a form, so a literal reaches the inner macro as a `Form` constructor —
`(Form.Int {.i 5})` — and not as `5`. A `defmacro` produced by an expansion inside a package is not one of the
package's exported macros, because `Load` collects them from the forms as written.
### A call inside a quasiquote is output, not a dependency

View File

@ -210,10 +210,8 @@ let call ~loc (fn : Dynload.addr) (args : Form.t list) : Form.t =
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. *)
levels is this file's job, done in [quote] below. A macro that writes a
macro is the only thing that wants a quasiquote inside a quasiquote. *)
let sym loc s = Form.make (Form.Sym s) loc
let lst loc xs = Form.make (Form.List xs) loc
@ -234,43 +232,59 @@ let splice_of (f : Form.t) =
| Form.List [ { Form.v = Form.Sym "unquote-splicing"; _ }; x ] -> Some x
| _ -> None
let rec quote (f : Form.t) : Form.t =
(* [depth] is how many quasiquotes enclose [f], counting the one being
desugared as 1 — SBCL's [*backquote-depth*] in src/code/backq.lisp. A
quasiquote inside raises it and an unquote lowers it, so an unquote belongs
to the innermost quasiquote around it and [~~x] reaches out two. Only the
unquotes at depth 1 are evaluated now; the rest are data, rebuilt as the
(unquote x) and (quasiquote x) forms they were read as, for the macro the
output defines to desugar in its turn. *)
let rec quote ?(depth = 1) (f : Form.t) : Form.t =
let loc = f.Form.loc in
(* (head x) as a Form, [x] already desugared. *)
let wrapped head x =
node loc "List" "xs"
(lst loc [ sym loc "form-cons"; node loc "Sym" "s" (Form.Str head);
lst loc [ sym loc "form-cons"; x; lst loc [ sym loc "form-nil" ] ] ]).Form.v
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
| Some x when depth = 1 -> x
| Some x -> wrapped "unquote" (quote ~depth:(depth - 1) x)
| None ->
match splice_of f with
| Some _ ->
| Some _ when depth = 1 ->
Loc.fail loc
"~@x splices into a list or a vector, and there is nothing here for it \
to splice into"
| Some x -> wrapped "unquote-splicing" (quote ~depth:(depth - 1) x)
| None ->
match f.Form.v with
| Form.List ({ Form.v = Form.Sym "quasiquote"; _ } :: _) ->
Loc.fail loc
"a quasiquote inside a quasiquote is not implemented — build the \
inner form with form-cons"
| Form.List [ { Form.v = Form.Sym "quasiquote"; _ }; x ] ->
wrapped "quasiquote" (quote ~depth:(depth + 1) x)
| Form.Sym s -> node loc "Sym" "s" (Form.Str s)
| Form.Kw s -> node loc "Kw" "s" (Form.Str s)
| Form.Int i | Form.UInt (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
| Form.List xs -> node loc "List" "xs" (seq ~depth loc xs).Form.v
| Form.Vec xs -> node loc "Vec" "xs" (seq ~depth loc xs).Form.v
| Form.Map xs -> node loc "Map" "xs" (seq ~depth 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 =
three prelude functions and no fourth. A splice deeper than [depth] 1 is
data like any other item. *)
and seq ~depth 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 ])
| Some x when depth = 1 ->
lst item.Form.loc [ sym item.Form.loc "form-append"; x; acc ]
| _ ->
lst item.Form.loc [ sym item.Form.loc "form-cons"; quote ~depth item; acc ])
(lst loc [ sym loc "form-nil" ])
(List.rev items)

View File

@ -423,6 +423,12 @@ let rec expand_form (l : loaded) (f : Form.t) : Form.t =
| _ -> f
and settle l first loc (f : Form.t) left =
(* A macro that writes a macro answers a form with a quasiquote still in it —
the inner one, which the outer desugaring kept as data. It is desugared
here, before the walk below looks at heads, for the reason the program's
own forms are desugared before the first walk: a call written inside a
quasiquote is output, not a call. *)
let f = Expand.quasiquote f in
match f.Form.v with
| Form.List ({ Form.v = Form.Sym m; _ } :: args) when List.mem_assoc m l.fns ->
if left <= 0 then
@ -572,10 +578,28 @@ let with_module (l : loaded) (f : unit -> 'a) : 'a =
Dynload.release ())
f
let program (forms : Form.t list) : Form.t list =
(* A top-level macro call may answer a [defmacro], and the macro it defines was
not in the module the call ran against. So when an expansion leaves the top
level declaring a macro it did not declare before, the whole run is expanded
again against a module that has it. The forms already expanded have no call
left in them, so a second pass only reaches the calls to the new names. A
macro that defines a macro whose expansion defines another costs one pass
per level, and the fuel is the same bound [settle] uses. *)
let rec program_n left (forms : Form.t list) : Form.t list =
match loaded_for forms with
| None -> forms
| Some l -> with_module l (fun () -> List.map (expand_form l) forms)
| Some l ->
let before = macros_in forms in
let out = with_module l (fun () -> List.map (expand_form l) forms) in
let fresh = List.filter (fun n -> not (List.mem n before)) (macros_in out) in
if fresh = [] then out
else if left <= 0 then
Loc.fail
(List.find (fun f -> macro_name f = Some (List.hd fresh)) out).Form.loc
"macros defining macros did not settle after %d levels" fuel
else program_n (left - 1) out
let program (forms : Form.t list) : Form.t list = program_n fuel forms
let () = Parse.expander := program

View File

@ -0,0 +1,44 @@
;;;; Macros that write macros: a quasiquote inside a quasiquote.
;;;;
;;;; The outer quasiquote is desugared when the file is read, and the inner one
;;;; is data until the outer macro runs. What that macro answers is a defmacro
;;;; holding the inner quasiquote, which is desugared then, and the macro it
;;;; defines is compiled and called like one written by hand.
;; The inner ~x belongs to the inner quasiquote, so it is the generated
;; macro's own parameter and nothing the outer macro evaluates.
(defmacro defsquare [name]
`(defmacro ~name [x]
`(* ~x ~x)))
;; ~~n reaches out two levels: n is evaluated when defadder runs, and what it
;; holds is pasted into the inner template as an expression the generated
;; macro evaluates. A Form constructor is such an expression, so the literal
;; arrives as one.
(defmacro defadder [name n]
`(defmacro ~name [x]
`(+ ~x ~~n)))
;; ~@ inside the inner template is the inner macro's splice.
(defmacro defsum [name]
`(defmacro ~name [& xs]
`(+ 0 ~@xs)))
;; Two levels down: a macro that writes a macro that writes a macro.
(defmacro defsquarer [name]
`(defmacro ~name [inner]
`(defmacro ~inner [x]
`(* ~x ~x))))
(defsquare sq)
(defadder add5 (Form.Int {.i 5}))
(defsum total)
(defsquarer defsq2)
(defsq2 sq2)
(defn main [] i32
(print (sq 7)) (println "")
(print (add5 10)) (println "")
(print (total 1 2 3 4)) (println "")
(print (sq2 9)) (println "")
0)

View File

@ -3984,6 +3984,17 @@ level "1"
outputs ~dev:true "a macro's parameter list, dev"
"programs/macro-params.flan" macro_params_out;
(* A quasiquote inside a quasiquote: macros whose expansion is a defmacro,
and the macros they define called from main. Two levels deep at the
end, which is two re-passes of the expander. *)
let macro_writing_out = "49\n15\n10\n81\n" in
outputs "a macro that writes a macro" "programs/macro-writing.flan"
macro_writing_out;
outputs ~opt:"-O0" "a macro that writes a macro, -O0"
"programs/macro-writing.flan" macro_writing_out;
outputs ~dev:true "a macro that writes a macro, dev"
"programs/macro-writing.flan" macro_writing_out;
(* A gensym drawn in one round's module and one drawn in the module the
program is expanded with are different names. 200 is the two colliding. *)
outputs "gensym counts across macro modules"

View File

@ -664,11 +664,30 @@ let () =
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";
(* Levels are counted here, SBCL's way. The inner quasiquote is data, an
unquote belongs to the innermost quasiquote around it, and only the
unquotes at depth 1 are evaluated -- everything deeper comes back as the
(unquote x) it was read as, for the macro the output defines to desugar. *)
let q s = "(Form.Sym {.s \"" ^ s ^ "\"})" in
let wrap h x = "(Form.List {.xs (form-cons " ^ q h ^ " (form-cons " ^ x ^ " (form-nil)))})" in
let list1 x = "(Form.List {.xs (form-cons " ^ x ^ " (form-nil))})" in
desugars "a quasiquote inside a quasiquote is data" "``(b)"
(wrap "quasiquote" (list1 (q "b")));
desugars "an unquote at depth 2 is data" "``~x"
(wrap "quasiquote" (wrap "unquote" (q "x")));
desugars "~~x reaches the outer quasiquote" "``~~x"
(wrap "quasiquote" (wrap "unquote" "x"));
desugars "a splice at depth 2 is one item of data" "``(~@xs)"
(wrap "quasiquote" (list1 (wrap "unquote-splicing" (q "xs"))));
desugars "~@~xs splices at the inner level what the outer evaluates" "``(~@~xs)"
(wrap "quasiquote" (list1 (wrap "unquote-splicing" "xs")));
(* Three deep: ~~x under three quasiquotes is still data, one level short. *)
desugars "~~x under three quasiquotes is data" "```~~x"
(wrap "quasiquote" (wrap "quasiquote" (wrap "unquote" (wrap "unquote" (q "x")))));
(* ~ holds one form, so a splice directly inside one at the evaluating level
has nothing to splice into. *)
parse_rejects "~~@x" "(defn f [] Form ``~~@xs)"
~needle:"nothing here for it to splice into";
(* 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)"