From 56a746bb73e7c6c94d7da87a24a9abf46db0d16b Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sun, 7 Jun 2026 09:41:51 -0400 Subject: [PATCH] compiler: compile defmacro expanders (native-speed macro expansion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A defmacro now compiles its expander to a native Janet fn — built as (fn* args body...) and run through the self-hosted pipeline (its backtick body is now compilable, prior commit) — instead of an interpreted closure. Macro expansion happens at compile time with zero runtime cost, the proper Lisp model. Wiring: evaluator exposes a macro-compile-hook the api sets to backend/ try-compile-fn; the defmacro handler uses the compiled expander when available, falling back to the interpreted closure when the analyzer isn't built yet (early tiers) or the body uses &env/&form (no such params on the compiled fn). conformance 228/228 x3 (incl. REPL defmacro + gensym hygiene), full suite green. --- src/jolt/api.janet | 9 +++++++++ src/jolt/backend.janet | 14 ++++++++++++++ src/jolt/evaluator.janet | 26 +++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/jolt/api.janet b/src/jolt/api.janet index dee502d..35f4071 100644 --- a/src/jolt/api.janet +++ b/src/jolt/api.janet @@ -14,6 +14,15 @@ (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 +# through the self-hosted pipeline) so macro expansion is compiled, zero runtime +# cost — instead of an interpreted closure. Returns nil (interpreted fallback) when +# the analyzer isn't built yet or the body isn't compilable. +(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)))) + (defn normalize-pvecs "Deep-convert any sequential (pvec/tuple/array) to a Janet tuple. Test helper so Janet-level `=`/deep= can compare jolt collection results against Janet diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index ab13908..0ea32f3 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -341,3 +341,17 @@ (if (compiled 0) (eval (compiled 1) (comp/ctx-janet-env ctx)) (eval-form ctx @{} form))) + +(defn analyzer-built? [ctx] + (> (length ((ctx-find-ns ctx "jolt.analyzer") :mappings)) 0)) + +(defn try-compile-fn + "Compile a fn* form to a native Janet fn via the self-hosted pipeline, or nil if + it can't be compiled (analyzer not yet built, or the body isn't compilable). + Used to compile macro expanders for native-speed expansion." + [ctx fn-form] + (when (analyzer-built? ctx) + (def compiled (protect (emit-ir ctx (analyze-form ctx fn-form)))) + (when (compiled 0) + (def r (protect (eval (compiled 1) (comp/ctx-janet-env ctx)))) + (when (r 0) (r 1))))) diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index b35e08b..b4652f4 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -34,6 +34,22 @@ (var eval-form nil) +# Compile hook for macro expanders: set by the api to (fn [ctx args-form body] -> +# compiled-janet-fn | nil). When set and the body is compilable (no &env/&form, +# analyzer available), defmacro uses the compiled expander instead of the +# interpreted closure — macro expansion at native speed, zero runtime cost. +(var macro-compile-hook nil) + +(defn- form-uses-sym? [form nm] + (cond + (and (struct? form) (= :symbol (form :jolt/type))) (= nm (form :name)) + (or (array? form) (tuple? form)) + (do (var found false) (each x form (when (form-uses-sym? x nm) (set found true) (break))) found) + (and (struct? form) (nil? (form :jolt/type))) + (do (var found false) (each k (keys form) + (when (or (form-uses-sym? k nm) (form-uses-sym? (get form k) nm)) (set found true) (break))) found) + false)) + # A transient is a tagged mutable table @{:jolt/type :jolt/transient :kind ...}. (defn- jolt-transient? [x] (and (table? x) (= :jolt/transient (get x :jolt/type)))) @@ -737,7 +753,7 @@ fixed-pats (param-info :fixed) rest-pat (param-info :rest) defining-ns (ctx-current-ns ctx)] - (def macro-fn (fn [& macro-args] + (def interp-fn (fn [& macro-args] (var new-bindings @{}) (table/setproto new-bindings bindings) (put new-bindings "&env" @{}) # implicit &env for macro bodies (table — nil-safe) @@ -757,6 +773,14 @@ (set result (eval-form ctx new-bindings bf))) (ctx-set-current-ns ctx saved-ns) result)) + # Prefer a COMPILED expander (native-speed expansion, zero runtime + # cost). Skip when the body uses &env/&form (the compiled fn has no + # such params) — those fall back to the interpreted closure. + (def uses-env (or (form-uses-sym? body "&env") (form-uses-sym? body "&form"))) + (def compiled-fn + (when (and macro-compile-hook (not uses-env)) + (macro-compile-hook ctx args-form body))) + (def macro-fn (or compiled-fn interp-fn)) (let [ns-name (ctx-current-ns ctx) ns (ctx-find-ns ctx ns-name)] (def v (ns-intern ns (name-sym :name) macro-fn))