~~@xs splices an unquote per element, a session can call a macro an expansion defined, and an enum member cannot be named else

This commit is contained in:
Joseph Ferano 2026-09-25 11:24:27 +07:00
parent d03b02b347
commit de26d6e625
10 changed files with 145 additions and 23 deletions

View File

@ -86,16 +86,11 @@ 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
expansion that defines a macro re-runs the expander, in a build and in a session.
=~~@x= splices an unquote per element, SBCL's =unquote*=. 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 A session does not remember a macro that an expansion defined
=Session.own_macros= reads =defmacro= heads off the forms as sent, so after
=(defsquare sq)= is evaluated the session's next form cannot call =sq=. A build
sees the whole file and can. The fix is collecting from the expanded forms,
which =Load.program= does not hand back.
** DONE A form the prelude relies on is built in; a form only programs use is a macro
CLOSED: [2026-09-25]
=cond=, =when= and =dotimes= are special forms in parse.ml; =inc=, =++=, =into=,

View File

@ -302,14 +302,35 @@ let rec quote ?(depth = 1) (f : Form.t) : Form.t =
and seq ~depth loc items =
List.fold_left
(fun acc (item : Form.t) ->
match splice_of item with
| Some x when depth = 1 ->
lst item.Form.loc [ sym item.Form.loc "form-append"; x; acc ]
| _ ->
match spliced ~depth 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 ~depth item; acc ])
(lst loc [ sym loc "form-nil" ])
(List.rev items)
(* The slice an item splices in, when it splices at all. At depth 1 that is
~@x. Deeper, an unquote whose own argument splices at the level below —
[~~@xs] — splices too: one (unquote x) per element, which is SBCL's
[unquote*] in src/code/backq.lisp, so the inner template receives ~a ~b ~c.
[~@~@xs] is the same with (unquote-splicing x). *)
and spliced ~depth (item : Form.t) : Form.t option =
let loc = item.Form.loc in
match splice_of item with
| Some x when depth = 1 -> Some x
| _ when depth = 1 -> None
| _ ->
let wrap head x =
match spliced ~depth:(depth - 1) x with
| Some e ->
Some (lst loc [ sym loc "form-wrap-each"; Form.make (Form.Str head) loc; e ])
| None -> None
in
match unquote_of item, splice_of item with
| Some x, _ -> wrap "unquote" x
| _, Some x -> wrap "unquote-splicing" x
| None, None -> None
(* 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. *)

View File

@ -592,6 +592,12 @@ let rec program_n left (forms : Form.t list) : Form.t list =
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
Parse.expansion_macros :=
!Parse.expansion_macros
@ List.filter
(fun f -> match macro_name f with
| Some n -> List.mem n fresh | None -> false)
out;
if fresh = [] then out
else if left <= 0 then
Loc.fail

View File

@ -1619,10 +1619,24 @@ let rec decl (f : Form.t) : Ast.decl =
(* Each member becomes its name, its value, whether that value was
written, and where the name is. The last two exist only so the
refusals here and below can be made; neither reaches the AST. *)
(* :else is match's catch-all, so a member spelled else could be
written everywhere but in a match arm, where it would mean every
member. Refused here rather than quietly shadowed there. *)
let member_ok (mf : Form.t) =
no_sigil mf;
match mf.v with
| Form.Sym "else" ->
Loc.failk "parse/enum-member-else" mf.loc
"%s cannot have a member named else: :else is the catch-all arm \
of a match, so a match could never name this member. Rename \
it, for example to otherwise"
ename
| _ -> ()
in
let rec members next = function
| [] -> []
| ({ v = Form.Sym m; loc } as mf) :: { v = Form.Int k; _ } :: rest ->
no_sigil mf;
member_ok mf;
(* The [let] is load-bearing rather than tidiness. OCaml leaves the
evaluation order of [::]'s two operands unspecified and in
practice takes the tail first, so an inlined [fits ... k] would
@ -1639,14 +1653,14 @@ let rec decl (f : Form.t) : Ast.decl =
number written; refused in the spelling it was written in. *)
| ({ v = Form.Sym m; _ } as mf) :: { v = Form.UInt (_, text); loc = vloc }
:: _ ->
no_sigil mf;
member_ok mf;
Loc.failk "parse/enum-value-out-of-range" vloc
"the member %s of %s is %s, which does not fit i32 — an enum's \
members run from -2147483648 to 2147483647. Give %s a value in \
that range, or use a defconst"
m ename text m
| ({ v = Form.Sym m; loc } as mf) :: rest ->
no_sigil mf;
member_ok mf;
let next = fits m loc ~explicit:false next in
(m, next, false, loc) :: members (Int64.add next 1L) rest
| bad :: _ ->
@ -1900,6 +1914,13 @@ let expander : (Form.t list -> Form.t list) ref = ref (fun fs -> fs)
whole of an evaluation. Empty is the ordinary case and costs nothing. *)
let imported_macros : Form.t list ref = ref []
(* Every [defmacro] an expansion produced at the top level, already desugared,
appended by [Macro.program]. A build needs nothing from it — the expander
re-runs over the whole file itself — but a session learns its macros from
the forms it was sent, and [(defsq sq)] sends no [defmacro]. The session
empties it before an evaluation and reads it after. *)
let expansion_macros : Form.t list ref = ref []
(* What those macros are allowed to *call*, and it is the same list the
importing program gets: the package's declarations, qualified under the
alias, as [Load] already built them.

View File

@ -2120,6 +2120,14 @@ let source = {flan|
(set i (+ i 1)))
(slice v)))
;; (head x) for every x, which is what ~~@xs splices into an inner template:
;; one unquote per element, as SBCL's unquote* builds (src/code/backq.lisp).
(defn form-wrap-each [head string xs [Form]] [Form]
(let [v (vec-new Form)]
(dotimes [i (length xs)]
(push v (Form.List {.xs (form-pair (Form.Sym {.s head}) (at xs i))})))
(slice v)))
;; The elements of a vector form, which is what a [ ] pattern in a macro's
;; parameter list unwraps. The other arm is unreachable from a generated
;; binding -- lib/expand.ml's check_call refuses a non-vector argument at the

View File

@ -111,15 +111,33 @@ let own_macros (forms : Form.t list) : Form.t list =
| _ -> None)
forms
(* The macros [load] defined by expanding [forms], beside the ones written in
them: [(defsq sq)] defines [sq] without a [defmacro] in sight, and the next
evaluation has to be able to call it. Only those expanded from these forms'
own file — [Load.program] also parses the packages they import, and a macro
a package's expansion defined belongs to the package. Written first, so an
expansion's newer body wins as a [defmacro]'s does. *)
let with_expansion_macros (forms : Form.t list) (load : unit -> 'a) : 'a * Form.t list =
Parse.expansion_macros := [];
let r = load () in
let files = List.map (fun (f : Form.t) -> f.Form.loc.Loc.file) forms in
let defined =
List.filter
(fun (f : Form.t) -> List.mem (Loc.call_site f.Form.loc).Loc.file files)
!Parse.expansion_macros
in
Parse.expansion_macros := [];
(r, Load.macro_union (own_macros forms) defined)
let create ?(debug = false) ?(x86 = false) ~file () =
let forms = Reader.read_file file in
let l = Load.program ~file forms in
let l, mine = with_expansion_macros forms (fun () -> Load.program ~file forms) in
let p, env = Check.program_with_env l.Load.decls in
({ file; decls = l.Load.decls; program = p; env; host = p; pkgs = l.Load.pkgs;
(* The file's own first, so that if the file being edited is itself a
package the program imports, the bare name wins for a form typed into
that buffer. [macro_union] keeps the left. *)
macros = Load.macro_union (own_macros forms) l.Load.macros;
macros = Load.macro_union mine l.Load.macros;
thunks = 0; debug; x86 }, l)
(* What a macro may call, for the same reason [macros] is held: an evaluation
@ -594,7 +612,9 @@ let eval ?(origin = "<eval>") ?pause t src : change =
the duplicate-name pass would reject it. *)
let macros = ref t.macros in
let incoming =
let l = Load.program ~file:t.file forms in
let l, mine =
with_expansion_macros forms (fun () -> Load.program ~file:t.file forms)
in
(* An evaluated import *adds* to the session's set, so a macro brought in
by C-c C-k is there for the C-c C-c after it. A union and not an
assignment: [Load.program] answers the macros of the imports it was
@ -632,8 +652,7 @@ let eval ?(origin = "<eval>") ?pause t src : change =
name. [Macro.program] dedupes the same way on the same rule, because
while this parse runs the old copy is still ambient. *)
macros :=
Load.macro_union (own_macros forms)
(Load.macro_union l.Load.macros t.macros);
Load.macro_union mine (Load.macro_union l.Load.macros t.macros);
let ds = l.Load.decls in
match package_of t origin with
| None -> ds

View File

@ -30,7 +30,20 @@
`(defmacro ~inner [x]
`(* ~x ~x))))
;; ~~@xs: each form the outer macro was handed becomes one ~x in the inner
;; template, in a list and in a vector.
(defmacro defmany [name & xs]
`(defmacro ~name []
`(+ 0 ~~@xs)))
(defmacro deflet [name & xs]
`(defmacro ~name [body]
`(let [~~@xs] ~body)))
(defsquare sq)
(defmany six (Form.Int {.i 1}) (Form.Int {.i 2}) (Form.Int {.i 3}))
(deflet with-ab (Form.Sym {.s "a"}) (Form.Int {.i 4})
(Form.Sym {.s "b"}) (Form.Int {.i 5}))
(defadder add5 (Form.Int {.i 5}))
(defsum total)
(defsquarer defsq2)
@ -41,4 +54,6 @@
(print (add5 10)) (println "")
(print (total 1 2 3 4)) (println "")
(print (sq2 9)) (println "")
(print (six)) (println "")
(print (with-ab (* a b))) (println "")
0)

View File

@ -4126,7 +4126,7 @@ level "1"
(* 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
let macro_writing_out = "49\n15\n10\n81\n6\n20\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"

View File

@ -684,9 +684,15 @@ let () =
(* 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)"
(* ~~@xs inside a bracket splices one (unquote x) per element, SBCL's
unquote*, in a list and in a vector alike. *)
let each = "(form-append (form-wrap-each \"unquote\" xs) (form-nil))" in
desugars "~~@xs in a list splices an unquote per element" "``(~~@xs)"
(wrap "quasiquote" ("(Form.List {.xs " ^ each ^ "})"));
desugars "~~@xs in a vector splices an unquote per element" "``[~~@xs]"
(wrap "quasiquote" ("(Form.Vec {.xs " ^ each ^ "})"));
(* Outside a bracket there is still nothing for it to splice into. *)
parse_rejects "~~@x outside a bracket" "(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. *)
@ -779,6 +785,12 @@ let () =
~needle:"an enum member is a name, optionally followed by an integer";
parse_rejects "a defenum with no member vector" "(defenum E)"
~needle:"defenum is (defenum Name [member value? ...])";
(* :else is match's catch-all, so a member named else could never be
matched. *)
parse_rejects "an enum member named else" "(defenum E [foo else])"
~needle:"E cannot have a member named else: :else is the catch-all arm";
parse_rejects "an enum member named else, with a value" "(defenum E [else 3])"
~needle:"cannot have a member named else";
(* ── Malformed syntax is caught with a location ────────────────── *)
parse_rejects "odd let bindings" "(let [a])";
@ -4089,6 +4101,11 @@ let () =
(k ^ "(defn f [k K] i32 (match k :lo 1 _ 2))");
accepts "match over an enum, :else for the rest"
(k ^ "(defn f [k K] i32 (match k :lo 1 :else 2))");
(* A data type's case is matched by a symbol, not a keyword, so a case
named else does not collide with :else and is not refused. *)
accepts "a data case named else is matched by name"
"(defdata D [(else) (foo [x i32])])\n\
(defn f [d D] i32 (match d else 1 (foo x) x))";
rejects_check "match over an enum that misses a member"
(k ^ "(defn f [k K] i32 (match k :lo 1 :hi 2))")
~needle:"this match is not exhaustive — :mid has no arm";

View File

@ -730,6 +730,26 @@ let () =
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "an edited defmacro: %s" m);
(* A macro that an expansion defined joins the session too. [(defsq sq6)]
sends no [defmacro], so reading the forms as sent would miss it and the
next evaluation would call [sq6] as a function taking a Form. *)
(match Session.eval ~origin:"programs/pkg-macro.flan" tm
"(defmacro defsq [name] `(defmacro ~name [x] `(* ~x ~x)))"
with
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "evaluating a macro-writing defmacro: %s" m);
(match Session.eval ~origin:"programs/pkg-macro.flan" tm "(defsq sq6)" with
| _ -> ()
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "evaluating a call to a macro-writing macro: %s" m);
(match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(sq6 6)" with
| c ->
if not (has c.Session.ir "6, 6") then
fail "a macro defined by an expansion did not expand to its body"
| exception Loc.Error { Loc.dmsg = m; _ } ->
fail "a macro defined by an expansion, called in the session: %s" m);
(* A mistake in a body the macro spliced, reported where it was written.
Same machinery as a build — [Macro.expand_form] and [Expand.call] — and
the point of asking it here is that the editor is where it is read: C-c