From 646744ca1d641028b64ca435882a876c683d6380 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sun, 7 Jun 2026 16:29:50 -0400 Subject: [PATCH] self-host: gate the analyzer build on the kernel tier; move case/cond-> to the overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real bug behind the case/cond-> fixpoint regression wasn't gensym — it was build ordering. A top-level defn in a pre-kernel tier (the fresh-sym helper these macros need) gets compiled in compile mode, and that lazily builds the self-hosted analyzer via ensure-analyzer. 00-syntax loads before 00-kernel, so the analyzer was built against a core missing mapv/second/peek/...: its references to them were interned as forward-ref nil cells in jolt.analyzer. Those nil cells then shadowed the real clojure.core defs when the analyzer rebuilt itself (stage2), so the analyzer's own mapv calls went to nil — 'Cannot call nil as a function'. Only variadic-heavy paths (juxt+mapv) surfaced it. build-compiler! already documented that the kernel must be loaded first; nothing enforced it. Gate ensure-analyzer on a :kernel-ready? flag set after the kernel tier loads. A pre-kernel compile now falls back to the interpreter (compile-and-eval already handles that) instead of building the analyzer too early. fresh-sym ends up interpreted during 00-syntax load, which is fine. With ordering fixed, case and cond-> move to 00-syntax (they need a real gensym, so syntax-quote auto-gensym alone won't do). conformance 228x3, fixpoint stage1==2==3, clojure-test-suite 3930, full suite green, bench flat. --- jolt-core/clojure/core/00-syntax.clj | 36 ++++++++++++++++++ src/jolt/api.janet | 11 +++++- src/jolt/backend.janet | 12 +++++- src/jolt/core.janet | 57 +--------------------------- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/jolt-core/clojure/core/00-syntax.clj b/jolt-core/clojure/core/00-syntax.clj index 76053a3..0303c27 100644 --- a/jolt-core/clojure/core/00-syntax.clj +++ b/jolt-core/clojure/core/00-syntax.clj @@ -59,3 +59,39 @@ ;; Forward declaration is a no-op on Jolt — the compiler resolves forward refs via ;; pending cells (matching the prior Janet macro). (defmacro declare [& syms] `(do)) + +;; A fresh jolt symbol inside a macro body (a bare (gensym) returns a Janet symbol +;; the destructurer rejects). This defn compiles fine: by the time a tier triggers +;; the analyzer build the kernel is in place (the build is gated until then). +(defn- fresh-sym [] (symbol (str (gensym)))) + +;; cond->: thread expr through each (test form) pair, only when the test is truthy. +;; Linear nested let*, a distinct fresh symbol per step. +(defmacro cond-> [expr & clauses] + (let [step (fn step [prev cls] + (if (empty? cls) + prev + (let [t (first cls) + f (nth cls 1) + gn (fresh-sym) + call (if (seq? f) `(~(first f) ~prev ~@(rest f)) `(~f ~prev))] + `(let* [~gn (if ~t ~call ~prev)] ~(step gn (drop 2 cls)))))) + g0 (fresh-sym)] + `(let* [~g0 ~expr] ~(step g0 clauses)))) + +;; case: nested =/or tests (no jump table). Test constants are NOT evaluated — +;; symbols and list constants are quoted; a list in test position is a set (or). +(defmacro case [expr & clauses] + (let [g (fresh-sym) + mk-const (fn [c] (if (or (symbol? c) (seq? c)) `(quote ~c) c)) + mk-test (fn [c] + (if (seq? c) + `(or ~@(map (fn [v] `(= ~g ~(mk-const v))) c)) + `(= ~g ~(mk-const c)))) + build (fn build [cls] + (if (empty? cls) + nil + (if (empty? (rest cls)) + (first cls) + `(if ~(mk-test (first cls)) ~(nth cls 1) ~(build (drop 2 cls))))))] + `(let* [~g ~expr] ~(build clauses)))) diff --git a/src/jolt/api.janet b/src/jolt/api.janet index 35f4071..08d6112 100644 --- a/src/jolt/api.janet +++ b/src/jolt/api.janet @@ -72,12 +72,21 @@ (def core-dl (get env :aot-core?)) (def saved (ctx-current-ns ctx)) (ctx-set-current-ns ctx "clojure.core") + # Gate the analyzer build until the kernel tier loads (see ensure-analyzer): + # present-and-false here means pre-kernel compiles fall back to the interpreter. + (put env :kernel-ready? false) (each tier core-tiers (when-let [src (get stdlib-embed/sources (tier :ns))] (put env :direct-linking? core-dl) (if (and compile? (tier :kernel)) (backend/bootstrap-load-source ctx "clojure.core" src) - (eval-overlay-source ctx src)))) + (eval-overlay-source ctx src)) + # The self-hosted compiler depends on the kernel tier (second/peek/mapv/...). + # Mark it ready once that tier is in place so the analyzer can be built; a + # pre-kernel tier that triggers a compile (e.g. a defn in 00-syntax) instead + # falls back to the interpreter rather than building the analyzer against a + # half-loaded core (which would forward-ref the missing kernel fns to nil). + (when (tier :kernel) (put env :kernel-ready? true)))) (put env :direct-linking? user-dl) (ctx-set-current-ns ctx saved)) diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index 0ea32f3..d1d1961 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -306,7 +306,17 @@ (compile-load ctx "jolt.analyzer")) (defn- ensure-analyzer [ctx] - (when (= 0 (length ((ctx-find-ns ctx "jolt.analyzer") :mappings))) + # Don't build until the kernel tier is loaded (see api/load-core-overlay! and + # build-compiler!). Before then a compile request — e.g. a defn in a pre-kernel + # tier — must fall back to the interpreter, not build the analyzer against a + # core missing the fns it references (which would intern them as nil cells that + # then shadow the real definitions on the self-rebuild). The flag is absent in + # bare/test contexts that never load core; treat that as ready so those keep + # building the analyzer lazily as before. + (def env (ctx :env)) + (def gated (and (has-key? env :kernel-ready?) (not (get env :kernel-ready?)))) + (when (and (not gated) + (= 0 (length ((ctx-find-ns ctx "jolt.analyzer") :mappings)))) (build-compiler! ctx))) (defn rebuild-compiler! diff --git a/src/jolt/core.janet b/src/jolt/core.janet index d654671..1bd8041 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -2062,38 +2062,6 @@ (put gensym_counter :val (+ n 1)) {:jolt/type :symbol :ns nil :name (string prefix-string n)}) -(defn core-case - "Macro: (case expr val1 result1 ... default) - Supports single values, lists of values (one-of-many), and symbols." - [expr & clauses] - (def g (gensym)) - (defn make-const [c] - # case constants are literals, never evaluated: quote symbols and list - # literals (read as arrays) so e.g. `sym` and a wrapped list `(a b c)` match - # by value rather than resolving/calling. - (if (or (and (struct? c) (= :symbol (c :jolt/type))) (array? c)) - @[{:jolt/type :symbol :ns nil :name "quote"} c] - c)) - (defn make-test [c] - (if (array? c) - (let [or-args @[{:jolt/type :symbol :ns nil :name "or"}]] - (each v c - (array/push or-args @[{:jolt/type :symbol :ns nil :name "="} g (make-const v)])) - or-args) - @[{:jolt/type :symbol :ns nil :name "="} g (make-const c)])) - (defn build [cls] - (if (= 0 (length cls)) - nil - (if (= 1 (length cls)) - (first cls) - (let [c (first cls) - r (first (tuple/slice cls 1))] - @[{:jolt/type :symbol :ns nil :name "if"} - (make-test c) - r - (build (tuple/slice cls 2))])))) - @[{:jolt/type :symbol :ns nil :name "let*"} @[g expr] (build clauses)]) - # if-let / when-let / if-some / when-some bind the name ONLY in the then/body # branch — the else branch must see the surrounding scope, not the binding (so @@ -2170,27 +2138,6 @@ (build 0 (parse-groups bindings)) body)) -(defn core-cond-> - "Macro: (cond-> expr test form ...) — thread first only when test is true" - [expr & clauses] - (def g (gensym)) - (defn build [cls result-form] - (if (= 0 (length cls)) - result-form - (let [t (first cls) - f (in cls 1) - f-call (if (array? f) - (let [arr (array/slice f)] - (array/insert arr 1 result-form) - arr) - @[f result-form])] - (build (tuple/slice cls 2) - @[{:jolt/type :symbol :ns nil :name "if"} - t - f-call - result-form])))) - @[{:jolt/type :symbol :ns nil :name "let*"} @[g expr] (build clauses g)]) - (defn core-push-thread-bindings [b] (push-thread-bindings b)) (defn core-pop-thread-bindings [] (pop-thread-bindings)) @@ -3534,10 +3481,8 @@ "add-watch" core-add-watch "remove-watch" core-remove-watch "not" core-not - "case" core-case "for" core-for "when-let" core-when-let - "cond->" core-cond-> "defn" core-defn "defn-" core-defn- "derive" core-derive @@ -3617,7 +3562,7 @@ (defn core-macro-names "Set of core binding names that are macros." [] - @{"case" true "for" true "when-let" true "defn" true "defn-" true "fn" true "let" true "loop" true "defrecord" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "proxy" true "definterface" true "lazy-seq" true "lazy-cat" true "cond->" true "doseq" true}) + @{"for" true "when-let" true "defn" true "defn-" true "fn" true "let" true "loop" true "defrecord" true "defprotocol" true "extend-type" true "extend-protocol" true "extend" true "reify" true "proxy" true "definterface" true "lazy-seq" true "lazy-cat" true "doseq" true}) (def init-core! (fn [& args]