From 08545c757f09f64c00d5525f38ddf2093901ec7c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 25 Sep 2026 10:48:40 +0700 Subject: [PATCH] cond is a special form again because the prelude relies on it, and until is a prelude macro because only programs use it --- TODO.org | 10 ++---- docs/BUILT.md | 26 ++++----------- emacs/flan-mode.el | 6 ++-- emacs/test-flan-mode.el | 3 +- lib/macro.ml | 59 +++------------------------------- lib/parse.ml | 20 +++++++++--- lib/prelude.ml | 70 +++-------------------------------------- test/test_flan.ml | 19 ++--------- 8 files changed, 41 insertions(+), 172 deletions(-) diff --git a/TODO.org b/TODO.org index 28d1e7ab..8966ac8c 100644 --- a/TODO.org +++ b/TODO.org @@ -96,14 +96,10 @@ not exported from a package. docs/BUILT.md, "Quasiquote runs before the walk". sees the whole file and can. The fix is collecting from the expanded forms, which =Load.program= does not hand back. -** DONE until and cond are prelude macros; when and dotimes stay special forms +** DONE A form the prelude relies on is built in; a form only programs use is a macro CLOSED: [2026-09-25] -=cond= builds its chain through the prelude function =cond-chain= and refuses -through =compile-error=; =until= keeps its label. Prelude functions that call -=cond= drop out of the macro module, so =into-wrap= is written with =if=. The -macro module now holds only the macros the forms reach. =when= and =dotimes= stay: -the prelude's form-building functions use them. docs/BUILT.md, "cond and until -followed". +=cond=, =when= and =dotimes= are special forms in parse.ml; =inc=, =++=, =into=, +=unless=, =until= and =comment= are prelude macros. ** DONE Macros are imported from a package The old refusal claimed collecting a package's macros needed a second import diff --git a/docs/BUILT.md b/docs/BUILT.md index a1203dd6..02f33781 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -3592,27 +3592,13 @@ reason `-linkall` is not optional. Say plainly what that coverage is not: nothin before this landed, so `macro-unless.flan` is a test written after the feature. The corpus written before it is `sand.flan` and `web/examples/control.flan`, and both compile unchanged. -### `cond` and `until` followed, and `when` and `dotimes` stay +### The line between a special form and a macro -`cond` and `until` are prelude macros now. `until` peels an optional leading label and answers -`(while :label (not test) body ...)`; `cond` hands its clauses to the prelude function `cond-chain`, which builds the -same nested `if` the parser used to, with `(do)` as the innermost else. A function and not a self-call, because -`(cond)` is refused while the empty tail of a longer `cond` is not, and a recursive expansion sees both as `(cond)`. -Their refusals are `compile-error` calls: the caret is the whole call rather than the bad clause, since a node the -macro builds takes the call's location. - -`cond` was not free to move, whatever the old note said: six prelude functions used it. A prelude function that calls -a macro is dropped from the build of the macro module (`Macro.reduce`), which is harmless for `sign-f32`, the rune -decoders and `format-f64` — no macro calls them — and fatal for `into-wrap`, which the `into` macro calls. So -`into-wrap` and `cond-chain` are written with `if`. `when` and `dotimes` stay special forms: the prelude uses them in -dozens of functions, the form-building functions among them, and every one would drop out of the module that runs -macros. - -Moving `cond` also made nearly every file and package need a macro module, and that exposed the rule that a macro -body may call no function of its own file. `vendor/edn` parses its own files calling `cond` and never `defedn`, yet -`defedn`'s body calls the package's `refuse`, so compiling every macro the package declares failed. `Macro.loaded_for` -now compiles only the macros the forms reach: a name anywhere outside a `defmacro` (a quasiquoted call arrives as a -string, and counts), a head called inside a `defmacro` body, and whatever the reached macros' bodies name in turn. +A form the prelude itself relies on is built into the parser: `cond`, `when` and `dotimes`. A form only programs use +is a prelude macro: `inc`, `++`, `into`, `unless`, `until` and `comment`. The reason is `Macro.reduce`: a prelude +function that calls a macro is left out of the module that runs macros, so a form the prelude's own functions use +cannot be a macro without taking those functions away from every macro body. `until` peels an optional leading label +and answers `(while :label (not test) body ...)`. ## `break` and `continue`, and the rule that replaced a blanket refusal diff --git a/emacs/flan-mode.el b/emacs/flan-mode.el index 3e61f239..d7f25caf 100644 --- a/emacs/flan-mode.el +++ b/emacs/flan-mode.el @@ -146,9 +146,9 @@ of \=`, ~ and ~@, and the sigils are not symbols for a keyword rule to reach. recognises only in order to refuse them, and drawing those as keywords would advertise four forms that cannot be used. -`cond' and `until' are macros in the prelude and not heads of `Parse.form'. -They stay here because they are control flow a reader takes for `if' and -`while', which is what this face says.") +`until' is a macro in the prelude and not a head of `Parse.form'. It stays +here because it is control flow a reader takes for `while', which is what this +face says.") (defconst flan--builtins '(;; arithmetic, comparison, bits diff --git a/emacs/test-flan-mode.el b/emacs/test-flan-mode.el index a203fa3d..0c7ea074 100644 --- a/emacs/test-flan-mode.el +++ b/emacs/test-flan-mode.el @@ -405,9 +405,8 @@ ("(handler-case (go) [(E [c] 1)])" "handler-case" font-lock-keyword-face "handler-case") ;; Special forms the old list had never heard of. - ;; cond and until are prelude macros, drawn as the control - ;; flow they are. ("(cond (= a 1) 2)" "cond" font-lock-keyword-face "cond") + ;; until is a prelude macro, drawn as the control flow it is. ("(until (> i 3) (go))" "until" font-lock-keyword-face "until") ("(and a b)" "and" font-lock-keyword-face "and") ("(handler-bind [(E [c] 1)] (go))" "handler-bind" diff --git a/lib/macro.ml b/lib/macro.ml index 3a850ece..cd354d59 100644 --- a/lib/macro.ml +++ b/lib/macro.ml @@ -549,62 +549,11 @@ let loaded_for (forms : Form.t list) : loaded option = List.filter (fun (n, _) -> not (List.mem_assoc n mine)) imported in let mine = imported @ mine in - (* Only the macros these forms can reach. A macro body may call prelude - functions and other macros and nothing else, so a file or package whose - macro calls one of its own functions cannot have that macro compiled — - and it need not be, when nothing here calls it. That is the common case - for a package parsing its own files: they call [cond], a prelude macro, - and never the macros the package exports. - - Reached means named anywhere: as a symbol, or as the string a - quasiquoted name desugars to, since a macro that answers a call to - another needs that one in the module too. Over-reaching only compiles a - macro that would have been compiled anyway. *) - let mine = - let rec atoms (f : Form.t) acc = - match f.Form.v with - | Form.Sym n | Form.Str n -> n :: acc - | Form.List xs | Form.Vec xs | Form.Map xs -> - List.fold_left (fun a x -> atoms x a) acc xs - | _ -> acc - in - (* The heads a form calls. A macro body that calls another macro for - real is expanded by the same walk as everything else, whether or not - the macro it belongs to is ever called, so what it calls is reached. *) - let rec heads (f : Form.t) acc = - match f.Form.v with - | Form.List ({ Form.v = Form.Sym h; _ } :: rest) -> - List.fold_left (fun a x -> heads x a) (h :: acc) rest - | Form.List xs | Form.Vec xs | Form.Map xs -> - List.fold_left (fun a x -> heads x a) acc xs - | _ -> acc - in - let want = Hashtbl.create 16 in - List.iter - (fun f -> - let names = if macro_name f = None then atoms f [] else heads f [] in - List.iter (fun n -> Hashtbl.replace want n ()) names) - forms; - let changed = ref true in - let taken = Hashtbl.create 16 in - while !changed do - changed := false; - List.iter - (fun (n, f) -> - if Hashtbl.mem want n && not (Hashtbl.mem taken n) then begin - Hashtbl.replace taken n (); - changed := true; - List.iter (fun a -> Hashtbl.replace want a ()) (atoms f []) - end) - mine - done; - List.filter (fun (n, _) -> Hashtbl.mem taken n) mine - in let all = prelude @ List.map fst mine in - (* A file that calls no macro costs one scan and no module. Few do, since - cond is a macro; for a file with no macros of its own the module is the - prelude's alone, one cached .so shared by every such file, so the usual - cost is a stat and a dlopen. *) + (* The common case by a wide margin, and the reason a build that uses no + macro pays nothing: a file that calls none costs one scan and no + compiler. Without it every build in the suite would link a macro module + for the prelude's macros and pay a clang driver to answer nothing. *) if all = [] || not (List.exists (names_macro all) forms) then None else begin let extra = rounds ~prelude mine in diff --git a/lib/parse.ml b/lib/parse.ml index 3458841b..f675e709 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -361,10 +361,9 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = | [ c; t; e ] -> mk (Ast.If (expr c, expr t, Some (expr e))) | _ -> fail f "if is (if test then) or (if test then else)") - (* Sugar, desugared here. [cond] and [until] are prelude macros; [when] and - [dotimes] stay special forms because the prelude uses them in the - functions the macro module is built from, and a function calling a macro - cannot be compiled into that module. *) + (* Sugar, desugared here. [cond], [when] and [dotimes] are special forms + because the prelude relies on them; a form only programs use, such as + [until] or [unless], is a prelude macro. *) (* An empty body is allowed, and becomes the same [Ast.Do []] that [(do)] already means. There was never a reason for the restriction: [(when test)] is a guard whose consequent has not been written yet, which is a state a @@ -377,6 +376,8 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = mk (Ast.If (expr c, { Ast.e = Ast.Do (body_of body); loc = f.loc }, None)) | _ -> fail f "when is (when test body ...)") + | Sym "cond" -> cond f args + (* Short-circuiting, so they cannot be ordinary calls. *) | Sym "and" -> shortcircuit f args ~is_and:true | Sym "or" -> shortcircuit f args ~is_and:false @@ -1075,6 +1076,17 @@ and struct_fields f (items : Form.t list) : (string * Ast.expr) list = in ignore f; go items +and cond f (args : Form.t list) : Ast.expr = + let rec go = function + | [] -> { Ast.e = Ast.Do []; loc = f.loc } (* no clause matched: Unit *) + | { v = Kw "else"; _ } :: body :: _ -> expr body + | test :: body :: rest -> + { Ast.e = Ast.If (expr test, expr body, Some (go rest)); loc = f.loc } + | [ odd ] -> Loc.fail odd.loc "cond clause %s has no body" + (Form.to_string odd) + in + if args = [] then Loc.fail f.loc "cond needs at least one clause" else go args + (* Every test here is an [if]'s condition, so a dyn operand is truthy-tested (check.ml's check_truthy) exactly the way a bare [if]'s is, for both [and] and [or]. The *answer* is the operand that decided the form, which diff --git a/lib/prelude.ml b/lib/prelude.ml index d046b44b..f0632c7b 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -2185,64 +2185,8 @@ let source = {flan| `(unless-takes-a-test) `(if (not ~(at args 0)) (do ~@(form-rest args 1))))) -;; ── cond and until ──────────────────────────────────────────────────── +;; ── until ───────────────────────────────────────────────────────────── ;; -;; (cond a 1 b 2 :else 3) is (if a 1 (if b 2 3)). With no :else the innermost -;; else is (do), so a cond that matches nothing answers (). A test of :else -;; ends the chain and anything written after its body is not read. -;; -;; The chain is built by a function and not by the macro calling itself, -;; because (cond) with no clauses is refused while the empty tail of a longer -;; cond is not, and a recursive expansion cannot tell the two apart. The -;; function uses if and never cond: `cond` expands through it, so it is -;; compiled into the module that expands macros, which a function calling a -;; macro cannot be. -;; -;; A prelude function that does call cond is dropped from that module, and so -;; cannot be called from a macro body: sign-f32, the rune decoders and encoders, -;; and format-f64 (which calls clamp). None is a thing a macro reaches for. -(defn cond-no-body [c Form] Form - (let [v (vec-new u8)] - (match c - (Form.Sym s) - (do (append (addr v) (bytes-view "cond clause ")) - (append (addr v) (bytes-view s)) - (append (addr v) (bytes-view " has no body"))) - (Form.Kw k) - (do (append (addr v) (bytes-view "cond clause :")) - (append (addr v) (bytes-view k)) - (append (addr v) (bytes-view " has no body"))) - _ (append (addr v) (bytes-view "the last cond clause has no body"))) - (append (addr v) - (bytes-view ": every test is followed by the value it chooses")) - `(compile-error ~(Form.Str {.s (string (slice v))})))) - -(defn cond-chain [cs [Form]] Form - (let [n (length cs) - end n - acc `(do) - i 0] - (while (< i n) - (if (= (+ i 1) n) - (return (cond-no-body (at cs i))) - (if (match (at cs i) - (Form.Kw k) (bytes=? (bytes-view k) (bytes-view "else")) - _ false) - (do (set end i) - (set acc (at cs (+ i 1))) - (break)) - (set i (+ i 2))))) - (let [j end] - (while (> j 0) - (set j (- j 2)) - (set acc `(if ~(at cs j) ~(at cs (+ j 1)) ~acc))) - acc))) - -(defmacro cond [& args] - (if (= (length args) 0) - `(compile-error "cond needs at least one clause: (cond test body ...)") - (cond-chain args))) - ;; (until test body ...) is (while (not test) body ...), and a label written ;; first stays first: (until :outer test body ...). (defmacro until [& args] @@ -2394,14 +2338,10 @@ let source = {flan| `(into-transform-is-map-or-filter-of-one-function ~t) (let [head (at items 0) f (at items 1)] - ;; if and not cond: cond is a macro, and the `into` macro calls - ;; this function, so it has to be compiled into the module that - ;; expands macros — which a function calling a macro cannot be. - (if (form-sym? head "map") - (recur (- k 1) `(let [~x (~f ~x)] ~body)) - (if (form-sym? head "filter") - (recur (- k 1) `(when (~f ~x) ~body)) - `(into-transform-is-map-or-filter ~t))))))))) + (cond + (form-sym? head "map") (recur (- k 1) `(let [~x (~f ~x)] ~body)) + (form-sym? head "filter") (recur (- k 1) `(when (~f ~x) ~body)) + :else `(into-transform-is-map-or-filter ~t)))))))) ;; The items of a list form, and the empty slice for anything else — a ;; non-list transform falls into the arity complaint above rather than needing diff --git a/test/test_flan.ml b/test/test_flan.ml index 1121dbc2..0e1eab04 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -783,6 +783,7 @@ let () = (* ── Malformed syntax is caught with a location ────────────────── *) parse_rejects "odd let bindings" "(let [a])"; parse_rejects "odd field pairs" "(defstruct S [a])"; + parse_rejects "cond without body" "(cond a)"; parse_rejects "unknown top form" "(nope x)"; parse_rejects "break takes only a label" "(defn f [] () (break 1))" ~needle:"break is (break) or (break :label)"; @@ -1285,24 +1286,10 @@ let () = "(defn f [x] () (when x 0))"; accepts "dyn cond accepts a non-bool dyn condition" "(defn f [x] dyn (cond x 1 :else 2))"; - (* cond and until are prelude macros, and their refusals are compile-error - calls in the expansion: the caret is on the call and the sentence is the - macro's. *) - rejects_check "cond with no clauses" "(defn f [] () (cond))" - ~needle:"cond needs at least one clause"; - rejects_check "a cond test with no body" "(defn f [a bool] () (cond a))" - ~needle:"cond clause a has no body"; - rejects_check "a cond test that is not a name, with no body" - "(defn f [a bool] () (cond a 1 (= 1 2)))" - ~needle:"the last cond clause has no body"; - rejects_check "a cond :else with no body" "(defn f [a bool] () (cond a 1 :else))" - ~needle:"cond clause :else has no body"; + (* until is a prelude macro, and its refusal is a compile-error call in the + expansion. *) rejects_check "until with no test" "(defn f [] () (until :outer))" ~needle:"until is (until test body ...)"; - accepts "anything after cond's :else body is not read" - "(defn f [a bool] i32 (cond a 1 :else 2 junk))"; - accepts "a cond that matches nothing answers ()" - "(defn f [a bool] () (cond a (println \"a\")))"; accepts "until keeps its label for break" "(defn f [] () (let [i 0] (until :outer (> i 3) (set i (+ i 1)) (break :outer))))"; accepts "dyn and accepts a non-bool dyn operand"