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 the program's module, and every expansion in a session. Rules out a counter per
module, seeded or not. module, seeded or not.
** NEXT A quasiquote inside a quasiquote is refused ** DONE A quasiquote inside a quasiquote nests
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. CLOSED: [2026-09-25]
Nothing counts nesting levels — not the reader, deliberately, and not the =Expand.quote= counts depth the way SBCL's =*backquote-depth*= does: an unquote
desugaring. Only a macro that writes a macro wants one. 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 ** 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. =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 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. 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 Nesting levels are counted by the desugaring and not by the reader, which stays as it was written. A quasiquote
desugaring. A quasiquote inside a quasiquote is refused by name. Only a macro that writes a macro wants one. 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 ### 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 The reader stays dumb and produces (quasiquote x), (unquote x) and
(unquote-splicing x) with no idea whether one is inside another. Counting (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 levels is this file's job, done in [quote] below. A macro that writes a
is refused by name. A macro that writes a macro is the only thing that wants macro is the only thing that wants a quasiquote inside a quasiquote. *)
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 sym loc s = Form.make (Form.Sym s) loc
let lst loc xs = Form.make (Form.List xs) 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 | Form.List [ { Form.v = Form.Sym "unquote-splicing"; _ }; x ] -> Some x
| _ -> None | _ -> 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 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 match unquote_of f with
(* The escape: whatever the program wrote, evaluated. It is already a Form, (* The escape: whatever the program wrote, evaluated. It is already a Form,
because a Form is what a macro body deals in. *) 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 -> | None ->
match splice_of f with match splice_of f with
| Some _ -> | Some _ when depth = 1 ->
Loc.fail loc Loc.fail loc
"~@x splices into a list or a vector, and there is nothing here for it \ "~@x splices into a list or a vector, and there is nothing here for it \
to splice into" to splice into"
| Some x -> wrapped "unquote-splicing" (quote ~depth:(depth - 1) x)
| None -> | None ->
match f.Form.v with match f.Form.v with
| Form.List ({ Form.v = Form.Sym "quasiquote"; _ } :: _) -> | Form.List [ { Form.v = Form.Sym "quasiquote"; _ }; x ] ->
Loc.fail loc wrapped "quasiquote" (quote ~depth:(depth + 1) x)
"a quasiquote inside a quasiquote is not implemented — build the \
inner form with form-cons"
| Form.Sym s -> node loc "Sym" "s" (Form.Str s) | Form.Sym s -> node loc "Sym" "s" (Form.Str s)
| Form.Kw s -> node loc "Kw" "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.Int i | Form.UInt (i, _) -> node loc "Int" "i" (Form.Int i)
| Form.Float x -> node loc "Float" "x" (Form.Float x) | Form.Float x -> node loc "Float" "x" (Form.Float x)
| Form.Str s -> node loc "Str" "s" (Form.Str s) | Form.Str s -> node loc "Str" "s" (Form.Str s)
| Form.Byte b -> node loc "Byte" "b" (Form.Int (Int64.of_int b)) | 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.List xs -> node loc "List" "xs" (seq ~depth loc xs).Form.v
| Form.Vec xs -> node loc "Vec" "xs" (seq 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 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, (* 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 so each item is consed onto what follows it and a splice is an append — the
three prelude functions and no fourth. *) three prelude functions and no fourth. A splice deeper than [depth] 1 is
and seq loc items = data like any other item. *)
and seq ~depth loc items =
List.fold_left List.fold_left
(fun acc (item : Form.t) -> (fun acc (item : Form.t) ->
match splice_of item with match splice_of item with
| Some x -> lst item.Form.loc [ sym item.Form.loc "form-append"; x; acc ] | Some x when depth = 1 ->
| None -> lst item.Form.loc [ sym item.Form.loc "form-cons"; quote item; acc ]) 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" ]) (lst loc [ sym loc "form-nil" ])
(List.rev items) (List.rev items)

View File

@ -423,6 +423,12 @@ let rec expand_form (l : loaded) (f : Form.t) : Form.t =
| _ -> f | _ -> f
and settle l first loc (f : Form.t) left = 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 match f.Form.v with
| Form.List ({ Form.v = Form.Sym m; _ } :: args) when List.mem_assoc m l.fns -> | Form.List ({ Form.v = Form.Sym m; _ } :: args) when List.mem_assoc m l.fns ->
if left <= 0 then if left <= 0 then
@ -572,10 +578,28 @@ let with_module (l : loaded) (f : unit -> 'a) : 'a =
Dynload.release ()) Dynload.release ())
f 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 match loaded_for forms with
| None -> forms | 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 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" outputs ~dev:true "a macro's parameter list, dev"
"programs/macro-params.flan" macro_params_out; "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 (* 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. *) program is expanded with are different names. 200 is the two colliding. *)
outputs "gensym counts across macro modules" 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. *) commonest thing a macro builds and Form.Vec is not Form.List. *)
desugars "a quasiquoted vector stays a vector" "`[~x 1]" desugars "a quasiquoted vector stays a vector" "`[~x 1]"
"(Form.Vec {.xs (form-cons x (form-cons (Form.Int {.i 1}) (form-nil)))})"; "(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, (* Levels are counted here, SBCL's way. The inner quasiquote is data, an
which is why the inner one is refused by name rather than given a meaning unquote belongs to the innermost quasiquote around it, and only the
nobody chose. *) unquotes at depth 1 are evaluated -- everything deeper comes back as the
parse_rejects "a quasiquote inside a quasiquote" "(defn f [] Form `(a `(b)))" (unquote x) it was read as, for the macro the output defines to desugar. *)
~needle:"quasiquote inside a quasiquote"; 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 (* 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. *) the reader cannot catch it because it does not track where it is. *)
parse_rejects "unquote outside a quasiquote" "(defn f [] () ~x)" parse_rejects "unquote outside a quasiquote" "(defn f [] () ~x)"