macroexpand-first analyzer order; one macro path; defmacro/letfn fixes

The analyzer checked special forms before expanding macros, the reverse of the
canonical read -> macroexpand -> analyze order (Clojure/CLJS analyze-seq). Move
macroexpansion to the front of analyze-list. Knock-on fixes:

- letfn was both a (broken) macro expanding to let* AND a primitive special
  (analyze-letfn, proper letrec*). Macroexpand-first surfaced the macro, breaking
  mutual recursion; remove the macro, keep letfn a primitive.
- defmacro is now compiled by the analyzer (a :set-var-style :defmacro node that
  defs the expander fn via the fn macro — so destructuring arglists desugar — and
  marks the var a macro), so a non-top-level (when … (defmacro …)) works. The
  runtime spine's separate top-level defmacro interception is removed: one path.

SCI load 162 -> 202/218.
This commit is contained in:
Yogthos 2026-06-22 00:54:16 -04:00
parent e6ee17b055
commit f18ae3bd46
8 changed files with 402 additions and 383 deletions

View file

@ -117,14 +117,9 @@
result
(let ((r (jolt-compile-eval-form (car fs) cur)))
(loop (cdr fs) r (chez-current-ns))))))
;; runtime defmacro: def the expander fn + mark the var a macro so subsequent
;; forms expand it (hc-macro? reads var-macro-table). Mirrors emit-image.ss
;; ei-emit-ns.
((ce-macro-form? form)
(let-values (((nm fn-form) (ce-defmacro->fn form)))
(def-var! ns nm (jolt-compile-eval-form fn-form ns))
(mark-macro! ns nm)
jolt-nil))
;; defmacro is compiled like any other form — the analyzer lowers it to a def
;; of the expander fn + (mark-macro! …) so subsequent forms expand it. One
;; macro-expansion path (no separate spine interception).
(else
(eval (read (open-input-string (jolt-analyze-emit-form form ns)))
(interaction-environment)))))

View file

@ -1,11 +1,11 @@
;; run-sci.ss — SCI conformance: load borkdude/sci's own source (vendor/sci) through
;; joltc and require its forms to compile+eval. A real-world Clojure-compatibility
;; stress test. Pure Chez, no Janet. Floor-gated like the corpus: a regression below
;; the floor (or the count today, 196/218) fails. Raise the floor as host gaps close
;; the floor (or the count today, 202/218) fails. Raise the floor as host gaps close
;; (the tail is genuine gaps — set! on vars, some macro/def shapes).
;;
;; chez --script host/chez/run-sci.ss
;; JOLT_SCI_FLOOR=N override the floor (default 196)
;; JOLT_SCI_FLOOR=N override the floor (default 202)
;; SCI_VERBOSE=1 print each failing form's error
(import (chezscheme))
@ -74,7 +74,7 @@
load-order)
(printf "\nSCI load: ~a/~a forms ok (~a fail)\n" total-ok (+ total-ok total-fail) total-fail)
(define floor (let ((s (getenv "JOLT_SCI_FLOOR"))) (if s (string->number s) 196)))
(define floor (let ((s (getenv "JOLT_SCI_FLOOR"))) (if s (string->number s) 202)))
(when (< total-ok floor)
(printf "REGRESSION: ~a forms loaded < floor ~a\n" total-ok floor))
(flush-output-port)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -225,10 +225,10 @@
;; Build the fn* form via a template (a reader-list array): cons/list in a macro
;; body produce a plist the evaluator can't call as a form.
(defmacro letfn [fnspecs & body]
(let [binds (reduce (fn [acc spec] (conj (conj acc (first spec)) `(fn* ~@(rest spec))))
[] fnspecs)]
`(let* [~@binds] ~@body)))
;; letfn is a primitive special form (analyze-letfn -> letrec*), not a macro: its
;; fns are mutually recursive, which a (let* …) expansion cannot express. Defining
;; it as a macro would shadow the special once macroexpansion runs first (the
;; canonical order), so it is intentionally NOT a macro here.
;; Dynamic binding: install a thread-binding frame of var->value (array-map keeps
;; var-get happy, unlike a phm), restore on exit.

View file

@ -34,7 +34,7 @@
(def ^:private handled
#{"quote" "if" "do" "def" "fn*" "let*" "loop*" "recur" "throw" "try"
"syntax-quote" "var" "letfn" "set!"})
"syntax-quote" "var" "letfn" "set!" "defmacro"})
(defn- uncompilable [why]
(throw (str "jolt/uncompilable: " why)))
@ -326,6 +326,23 @@
;; (set! *var* val): set the var's innermost thread binding, else its root
;; (jolt-var-set). A local target is a deftype mutable field — not yet
;; supported (jolt binds fields immutably); an interop (.-field) target too.
;; A defmacro that is not top-level (the spine intercepts those) — e.g. one
;; produced by a macro like (when … (defmacro …)). Lower it the way the spine
;; does: def the expander fn, then mark the var a macro at runtime so later
;; forms expand it. Strip a leading docstring / attr-map, as defmacro allows.
"defmacro" (let [name-sym (nth items 1)
nm (form-sym-name name-sym)
cur (compile-ns ctx)
after (drop 2 items)
after (if (string? (first after)) (rest after) after)
after (if (form-map? (first after)) (rest after) after)
;; build (fn params body…) and analyze it through the fn MACRO
;; so a destructuring macro arglist desugars (the fn* primitive
;; would not), then def it and mark the var a macro.
fn-form (cons (symbol "fn") after)]
(host-intern! ctx cur nm)
{:op :defmacro :ns cur :name nm
:fn (analyze ctx fn-form env)})
"set!" (let [target (nth items 1)]
(when-not (form-sym? target) (uncompilable "set! of a non-symbol target"))
(when (local? env (form-sym-name target)) (uncompilable "set! of a local"))
@ -441,6 +458,13 @@
hname (when (and (form-sym? head) (nil? (form-sym-ns head))) (form-sym-name head))
shadowed (and hname (local? env hname))]
(cond
;; Canonical order (Clojure/CLJS analyze-seq): macroexpand FIRST, then
;; dispatch special forms / interop / invoke. Expanding before the
;; special-form check means a head that is a macro always expands — even
;; one whose name is also in the special-form set — matching reference
;; read -> macroexpand -> analyze. A local shadows both.
(and (form-sym? head) (not shadowed) (form-macro? ctx head))
(analyze ctx (form-expand-1 ctx form) env)
(and hname (not shadowed) (contains? handled hname))
(analyze-special ctx hname items env)
(and hname (not shadowed) (method-head? hname))
@ -460,8 +484,6 @@
(analyze-field ctx hname items env)
(and hname (not shadowed) (form-special? hname))
(uncompilable (str "special form " hname))
(and (form-sym? head) (not shadowed) (form-macro? ctx head))
(analyze ctx (form-expand-1 ctx form) env)
:else
;; stamp the list form's source offset onto the :invoke (jolt-fqy)
;; so the success checker can report file:line:col. nil when the

View file

@ -379,6 +379,11 @@
:the-var (str "(jolt-var " (chez-str-lit (:ns node)) " " (chez-str-lit (:name node)) ")")
;; (set! *var* val) -> set the var's innermost binding (else root); returns val.
:set-var (str "(jolt-var-set " (emit (:the-var node)) " " (emit (:val node)) ")")
;; a non-top-level defmacro -> def the expander fn + mark the var a macro at
;; runtime (the spine does the same for top-level forms).
:defmacro (str "(begin (def-var! " (chez-str-lit (:ns node)) " " (chez-str-lit (:name node)) " "
(emit (:fn node)) ") (mark-macro! " (chez-str-lit (:ns node)) " "
(chez-str-lit (:name node)) ") jolt-nil)")
:host (throw (ex-info (str "emit: unsupported host ref `" (:name node) "`") {}))
:host-static (str "(host-static-ref " (chez-str-lit (:class node)) " "
(chez-str-lit (:member node)) ")")

View file

@ -97,6 +97,7 @@
:ret (f (get node :ret)))
(= op :throw) (assoc node :expr (f (get node :expr)))
(= op :set-var) (assoc node :val (f (get node :val)))
(= op :defmacro) (assoc node :fn (f (get node :fn)))
(= op :invoke) (assoc node :fn (f (get node :fn))
:args (mapv f (get node :args)))
(= op :vector) (assoc node :items (mapv f (get node :items)))