diff --git a/TODO.org b/TODO.org index a76e8535..16a6f44f 100644 --- a/TODO.org +++ b/TODO.org @@ -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. diff --git a/docs/BUILT.md b/docs/BUILT.md index 930c83da..1b159d88 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -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 diff --git a/lib/expand.ml b/lib/expand.ml index 98bbfe94..c93fb444 100644 --- a/lib/expand.ml +++ b/lib/expand.ml @@ -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) diff --git a/lib/macro.ml b/lib/macro.ml index 4691b651..cd354d59 100644 --- a/lib/macro.ml +++ b/lib/macro.ml @@ -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 diff --git a/test/programs/macro-writing.flan b/test/programs/macro-writing.flan new file mode 100644 index 00000000..e7102fc3 --- /dev/null +++ b/test/programs/macro-writing.flan @@ -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) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index eb357e3b..005fad8d 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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" diff --git a/test/test_flan.ml b/test/test_flan.ml index d39a06df..257db810 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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)"