From d4cef997184e7dfab266e55c4226dae4a645388f Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 20:16:13 +0700 Subject: [PATCH] The house rule had a hole at the top level, and the reader just widened it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (defmacro m [x] x) answered "unknown top-level form (defmacro ...)" — refused, but not by name and with no reason, because the refusal list only covered expressions. Now it checks the shape and then refuses, which are two different mistakes and get two different reasons: a defmacro with no body is a typo, a defmacro with a body is a feature that is not here. The reader's new sigils made this urgent rather than tidy. quasiquote, unquote and unquote-splicing are now real heads arriving at the parser, and without a case each they would fall through to Call and come back from the checker as "unknown name quasiquote" — which tells you nothing about what is missing. unquote and unquote-splicing are refused as mistakes rather than as milestones: they mean nothing outside a quasiquote and the reader cannot notice, because it does not track where it is. gensym is neither a reader token nor a special form — it is a function a macro body calls while the macro runs, and there is nowhere for it to run. Refused by name so it does not arrive as an unknown one. --- lib/parse.ml | 50 ++++++++++++++++++++++++++++++++++++++++++++++- test/test_flan.ml | 42 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/lib/parse.ml b/lib/parse.ml index c5db730..dc166f4 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -266,6 +266,32 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = and they need argument marshalling and a runtime arity check that \ this version does not do") + (* ── macros ────────────────────────────────────────────────────── *) + (* 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. *) + | Sym "quasiquote" -> + fail f "`x is read, but not expanded: macro expansion is not wired up yet \ + (NEXT.md says what it needs)" + + (* Not a milestone, a mistake: these two mean nothing anywhere else, and the + reader cannot tell, because it does not track where it is. *) + | Sym "unquote" -> + fail f "~x means nothing outside a quasiquote" + | Sym "unquote-splicing" -> + fail f "~@x means nothing outside a quasiquote, and splices only into a \ + list or a vector" + + | 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" @@ -275,7 +301,7 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = the restart stack without committing to one. *) | "find-restart" | "compute-restarts" | "errdefer" | "with-allocator" | "loop" | "recur" - | "defmacro" | "await" as name) -> + | "await" as name) -> fail f "%s is not implemented yet (see the build sequence in plan.org)" name (* ── field access: (.pos c) ────────────────────────────────────── *) @@ -480,6 +506,28 @@ 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. + + 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. *) + | List ({ v = Sym "defmacro"; _ } :: args) -> + (match args with + | n :: { v = Form.Vec ps; _ } :: body when body <> [] -> + let name = sym n in + 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 + | _ -> + fail f "defmacro is (defmacro name [param ...] body ...)") + | List ({ v = Sym s; _ } :: _) -> fail f "unknown top-level form (%s ...)" s | _ -> fail f "expected a top-level declaration, found %s" (Form.to_string f) diff --git a/test/test_flan.ml b/test/test_flan.ml index 12c2e60..54a7edc 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -179,10 +179,19 @@ let parse_decl src = | [ f ] -> Parse.decl f | _ -> failwith "test source must be exactly one form" -let parse_rejects name src = +(* [needle] again: the house rule is that an unimplemented form is refused by + name with the reason, so a test that only proves *something* failed does not + observe the rule it is there for. *) +let parse_rejects ?needle name src = match Reader.read_all ~file:"" src |> Parse.program with | _ -> incr failures; Printf.printf "FAIL %s: expected a parse error\n" name - | exception Loc.Error _ -> () + | exception Loc.Error (_, msg) -> + (match needle with + | Some n when not (contains msg n) -> + incr failures; + Printf.printf "FAIL %s: wrong reason\n wanted: %s\n got: %s\n" + name n msg + | _ -> ()) let () = let open Ast in @@ -291,7 +300,34 @@ let () = parse_rejects "handler-bind" "(handler-bind [E h] body)"; parse_rejects "restart-case" "(restart-case body (r [] 1))"; parse_rejects "loop/recur" "(loop [x 1] (recur x))"; - parse_rejects "defmacro" "(defmacro m [] 1)"; + + (* ── 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. *) + 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)" + ~needle:"defmacro is (defmacro name [param ...] body ...)"; + parse_rejects "defmacro with a non-name param" "(defmacro m [1] x)" + ~needle:"expected a name"; + 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"; + (* 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"; (* ── Malformed syntax is caught with a location ────────────────── *) parse_rejects "odd let bindings" "(let [a])";