core: wrap macro expanders in fn (not fn*) so destructured arglists compile

Follow-up to the staged macro-compile change. The macro-recompile pass
wrapped each expander in the raw fn* primitive, which punts on a
destructuring rest param — so if-not/if-let/if-some/assert (using the
canonical '& [else]') couldn't compile and had been rewritten to plain
rest params as a workaround.

Root cause: fn* is the primitive; the fn MACRO is what desugars
destructuring (rest, map, nested) into the body before lowering. Wrapping
expanders in fn instead of fn* compiles any destructured macro arglist
uniformly, so the workaround is unnecessary — reverted those 4 macros to
the canonical '& [else]' forms.

Net: rest-destructuring is fully compiled for all normal code (fn/defn/
let/macro params). Only the hand-written raw fn* primitive still punts
(jolt-f79, downgraded to P4 — falls back to interpreter, still correct).

47/47 overlay macros compiled. Gate green: conformance 267x3,
fallback-zero 31/5, bootstrap-fixpoint stage1==2==3, self-host,
staged-bootstrap, lazy-infinite 44/44, clojure-test-suite >=4034/67,
sci-bootstrap, features, all unit+spec.
This commit is contained in:
Yogthos 2026-06-09 08:52:01 -04:00
parent ad5216a178
commit 20da34a432
3 changed files with 25 additions and 22 deletions

View file

@ -12,27 +12,25 @@
(defmacro comment [& body] nil)
;; Single arglist (Jolt defmacro is single-arity); the optional else is the head of
;; a plain rest param — (first else) is the else form or nil. (A `& [else]`-style
;; rest-destructure reads the same but the analyzer can't yet compile destructuring
;; in a rest param, which would force this expander to stay interpreted — jolt-f79.)
(defmacro if-not [test then & else]
`(if (not ~test) ~then ~(first else)))
;; Single arglist (Jolt defmacro is single-arity); the optional else defaults nil
;; via rest-destructuring.
(defmacro if-not [test then & [else]]
`(if (not ~test) ~then ~else))
;; Conditional binding macros: the name is bound ONLY in the taken branch (the
;; auto-gensym temp# tests the value; the else/empty branch sees the surrounding
;; scope). temp# is a single template-local gensym — referenced twice, same symbol.
(defmacro if-let [bindings then & else]
(defmacro if-let [bindings then & [else]]
(let [form (bindings 0) tst (bindings 1)]
`(let [temp# ~tst]
(if temp# (let [~form temp#] ~then) ~(first else)))))
(if temp# (let [~form temp#] ~then) ~else))))
;; when-let lives in 00-syntax (not here): 20-coll uses it, which loads before this tier.
(defmacro if-some [bindings then & else]
(defmacro if-some [bindings then & [else]]
(let [form (bindings 0) tst (bindings 1)]
`(let [temp# ~tst]
(if (some? temp#) (let [~form temp#] ~then) ~(first else)))))
(if (some? temp#) (let [~form temp#] ~then) ~else))))
(defmacro when-some [bindings & body]
(let [form (bindings 0) tst (bindings 1)]
@ -93,9 +91,8 @@
(partition 2 clauses))]
`(let [~g ~expr ~@(thread-binds g steps)] ~(if (empty? steps) g (last steps)))))
(defmacro assert [x & message]
(let [m (first message)
msg (if m m (str "Assert failed: " (pr-str x)))]
(defmacro assert [x & [message]]
(let [msg (if message message (str "Assert failed: " (pr-str x)))]
`(when-not ~x (throw (ex-info ~msg {})))))
(defmacro delay [& body]

View file

@ -14,18 +14,21 @@
(import ./stdlib_embed :as stdlib-embed)
(import ./host_iface :as host)
# A defmacro expander compiles to a native fn (built as (fn* args body...) and run
# A defmacro expander compiles to a native fn (built as (fn args body...) and run
# through the self-hosted pipeline) so macro expansion is COMPILED code, zero runtime
# cost — instead of an interpreted closure, mirroring Clojure (macros are ordinary
# compiled fns). Returns nil when the analyzer isn't built yet (the early macros,
# expanded WHILE the analyzer is being bootstrapped) or the body isn't compilable; in
# that case defmacro keeps an interpreted closure, and backend/recompile-macros!
# replaces it with a compiled expander once the analyzer comes alive (staged
# bootstrap — the interpreter is a build-time crutch, gone by steady state).
# compiled fns). Wrapped in the `fn` MACRO (not the `fn*` primitive) so a destructured
# macro arglist — `[a & [b]]`, `[& {:keys [x]}]`, nested — desugars before lowering;
# raw fn* punts on a destructuring rest param. Returns nil when the analyzer isn't
# built yet (the early macros, expanded WHILE the analyzer is being bootstrapped) or
# the body isn't compilable; in that case defmacro keeps an interpreted closure, and
# backend/recompile-macros! replaces it with a compiled expander once the analyzer
# comes alive (staged bootstrap — the interpreter is a build-time crutch, gone by
# steady state).
(set macro-compile-hook
(fn [ctx args-form body]
(backend/try-compile-fn ctx
(array/concat @[{:jolt/type :symbol :ns nil :name "fn*"} args-form] body))))
(array/concat @[{:jolt/type :symbol :ns nil :name "fn"} args-form] body))))
(defn normalize-pvecs
"Deep-convert any sequential (pvec/tuple/array) to a Janet tuple. Test helper

View file

@ -373,7 +373,10 @@
(def r (protect (eval (compiled 1) (comp/ctx-janet-env ctx))))
(when (r 0) (r 1)))))
(def- fn*-sym {:jolt/type :symbol :ns nil :name "fn*"})
# Wrap expanders in the `fn` MACRO, not the `fn*` primitive: `fn` desugars a
# destructured macro arglist (`[a & [b]]`, `[& {:keys [x]}]`) before lowering,
# whereas raw fn* punts on a destructuring rest param.
(def- fn-sym {:jolt/type :symbol :ns nil :name "fn"})
(defn recompile-macros!
"Staged-bootstrap second pass: once the self-hosted analyzer is alive, replace
@ -398,7 +401,7 @@
(not (v :macro-uses-env)))
(def [args-form body] (v :macro-src))
(def compiled
(try-compile-fn ctx (array/concat @[fn*-sym args-form] body)))
(try-compile-fn ctx (array/concat @[fn-sym args-form] body)))
(when compiled
(bind-root v compiled)
(put v :macro-compiled true)