core: fn*/let*/loop* are plain-symbol primitives, matching Clojure (jolt-f79)

jolt-f79 asked to compile destructuring in fn* rest params. Checking
against Clojure inverts the premise: Clojure's fn* REJECTS destructuring
at compile time ('fn params must be Symbols'; let*/loop* 'Bad binding
form, expected symbol'). So the self-hosted analyzer was already correct
— fn*/let*/loop* are plain-symbol primitives; the fn/let/loop/defn
MACROS desugar destructuring. The real defect was the interpreter
leniently destructuring raw fn*, and defn emitting raw fn* to rely on it.

Changes:
- evaluator: fn*/let*/loop* now reject non-symbol binding forms with
  Clojure's exact messages (require-symbol-params/plain-sym?), so the
  interpreter agrees with the analyzer + Clojure.
- 00-syntax: defn emits the fn MACRO (not raw fn*) so destructuring
  params desugar; unnamed, so self-recursion still resolves via the var.
- 00-syntax: completing that exposed a real gap — the overlay destructure
  fn didn't handle kwargs (a map pattern bound against a fn's sequential
  rest); it had only worked via the interpreter's destructure-bind. Added
  the seq->map coercion to the map? branch (sequential: 1 map elt => that
  map, else apply hash-map), matching destructure-bind so interpret ==
  compile.

Net: fn*/let*/loop* are plain-symbol primitives across interpreter,
analyzer, and Clojure; all real destructuring (fn/defn/let/loop/macro
params, incl kwargs & {:keys}) compiles through the macros with no
interpreter fallback. Regression spec added.

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 78/78, all
unit+spec (destructuring 50/50).
This commit is contained in:
Yogthos 2026-06-09 13:14:34 -04:00
parent 20da34a432
commit d1d3f390ff
3 changed files with 62 additions and 10 deletions

View file

@ -102,11 +102,24 @@
(vloop 0 0 (conj (conj acc g) init))) (vloop 0 0 (conj (conj acc g) init)))
(map? pat) (map? pat)
(let* [g (symbol (str (gensym))) (let* [g (symbol (str (gensym)))
gm (symbol (str (gensym)))
;; kwargs: a map pattern may bind against the sequential rest
;; of a fn — (& {:keys [...]}) — which is a seq of alternating
;; k/v args, or a single trailing map. Coerce like Clojure (and
;; like the interpreter's destructure-bind, so interpret/compile
;; agree): a sequential value with one map element is that map,
;; otherwise (apply hash-map). A real map value is used as-is, so
;; ordinary map destructuring is unaffected. g holds init once;
;; gm is the coerced map every lookup (and :as) reads from.
coerce `(if (sequential? ~g)
(if (and (= 1 (count ~g)) (map? (first ~g)))
(first ~g)
(apply hash-map ~g))
~g)
or-map (get pat :or) or-map (get pat :or)
as-sym (get pat :as) as-sym (get pat :as)
base (if as-sym bound (conj (conj (conj (conj acc g) init) gm) coerce)
(conj (conj (conj (conj acc g) init) as-sym) g) base (if as-sym (conj (conj bound as-sym) gm) bound)
(conj (conj acc g) init))
group group
(fn* [a kw kind] (fn* [a kw kind]
(let* [names (get pat kw)] (let* [names (get pat kw)]
@ -125,8 +138,8 @@
fo (find-or or-map local)] fo (find-or or-map local)]
(conj (conj aa (symbol local)) (conj (conj aa (symbol local))
(if (nth fo 0) (if (nth fo 0)
`(get ~g ~keyform ~(nth fo 1)) `(get ~gm ~keyform ~(nth fo 1))
`(get ~g ~keyform))))) `(get ~gm ~keyform)))))
a names) a names)
a))) a)))
g1 (group base :keys :kw) g1 (group base :keys :kw)
@ -135,7 +148,7 @@
(reduce (fn* [a k] (reduce (fn* [a k]
(if (keyword? k) (if (keyword? k)
a a
(proc k `(get ~g ~(get pat k)) a))) (proc k `(get ~gm ~(get pat k)) a)))
g3 (keys pat))) g3 (keys pat)))
:else (throw (str "unsupported destructuring pattern")))) :else (throw (str "unsupported destructuring pattern"))))
ploop ploop
@ -212,8 +225,11 @@
(let [as (vec (map mk aftn))] (let [as (vec (map mk aftn))]
(if nm `(fn* ~nm ~@as) `(fn* ~@as)))))) (if nm `(fn* ~nm ~@as) `(fn* ~@as))))))
;; defn: drop an optional leading docstring and attr-map, then (def name (fn* ...)). ;; defn: drop an optional leading docstring and attr-map, then (def name (fn ...)).
;; Both single- and multi-arity reduce to (fn* ~@body) — fn* takes either a params ;; Emits the fn MACRO (not the fn* primitive) so destructuring params desugar — fn*
;; requires plain symbols (like Clojure). Unnamed (as before): self-recursion
;; resolves through the def'd var, so this only adds the desugaring step.
;; Both single- and multi-arity reduce to (fn ~@body) — fn takes either a params
;; vector + body or a sequence of ([params] body) clauses, so no arity branching is ;; vector + body or a sequence of ([params] body) clauses, so no arity branching is
;; needed. (map? is true for symbol forms too, so guard the attr-map with symbol?.) ;; needed. (map? is true for symbol forms too, so guard the attr-map with symbol?.)
;; Defined before fresh-sym below, which is a defn-. ;; Defined before fresh-sym below, which is a defn-.
@ -221,7 +237,7 @@
(let [body (if (and (seq body) (string? (first body))) (rest body) body) (let [body (if (and (seq body) (string? (first body))) (rest body) body)
body (if (and (seq body) (map? (first body)) (not (symbol? (first body)))) body (if (and (seq body) (map? (first body)) (not (symbol? (first body))))
(rest body) body)] (rest body) body)]
`(def ~fn-name (fn* ~@body)))) `(def ~fn-name (fn ~@body))))
;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own ;; Jolt doesn't enforce privacy, so defn- is just defn (matching how Clojure's own
;; defn- delegates to defn with :private metadata). ;; defn- delegates to defn with :private metadata).

View file

@ -517,6 +517,21 @@
(do (array/push fixed a) (+= i 1))))) (do (array/push fixed a) (+= i 1)))))
{:fixed (tuple/slice (tuple ;fixed)) :rest rest-pat}) {:fixed (tuple/slice (tuple ;fixed)) :rest rest-pat})
(defn- plain-sym? [p] (and (struct? p) (= :symbol (p :jolt/type))))
(defn- require-symbol-params
"fn* is a primitive: its params must be plain symbols. The fn/defn MACROS desugar
destructuring into plain params + a body let before emitting fn*, so fn* never
legitimately sees a pattern — matching Clojure, where (fn* [[a b]] ...) is the
compile error 'fn params must be Symbols'. Enforcing it here keeps the interpreter
consistent with the self-hosted analyzer (which also requires plain fn* params)
and with Clojure, instead of leniently destructuring a form Clojure rejects."
[param-info]
(each p (param-info :fixed)
(unless (plain-sym? p) (error "fn params must be Symbols")))
(let [r (param-info :rest)]
(when (and r (not (plain-sym? r))) (error "fn params must be Symbols"))))
(defn- d-get (defn- d-get
"Look up key k in a map-like value (phm/struct/table/nil)." "Look up key k in a map-like value (phm/struct/table/nil)."
[m k] [m k]
@ -925,6 +940,7 @@
(let [args-form (in pair 0) (let [args-form (in pair 0)
body (tuple/slice pair 1) body (tuple/slice pair 1)
param-info (parse-params args-form) param-info (parse-params args-form)
_ (require-symbol-params param-info)
fixed-pats (param-info :fixed) fixed-pats (param-info :fixed)
rest-pat (param-info :rest) rest-pat (param-info :rest)
n-fixed (length fixed-pats) n-fixed (length fixed-pats)
@ -962,6 +978,7 @@
(let [args-form (in form 1) (let [args-form (in form 1)
body (tuple/slice form 2) body (tuple/slice form 2)
param-info (parse-params args-form) param-info (parse-params args-form)
_ (require-symbol-params param-info)
fixed-pats (param-info :fixed) fixed-pats (param-info :fixed)
rest-pat (param-info :rest) rest-pat (param-info :rest)
defining-ns (ctx-current-ns ctx)] defining-ns (ctx-current-ns ctx)]
@ -994,6 +1011,9 @@
(let [len (length bind-vec)] (let [len (length bind-vec)]
(while (< i len) (while (< i len)
(let [pat (bind-vec i)] (let [pat (bind-vec i)]
# let* is a primitive (the let macro desugars destructuring);
# its binding names must be plain symbols, as in Clojure.
(unless (plain-sym? pat) (error "Bad binding form, expected symbol"))
(def val (eval-form ctx new-bindings (bind-vec (+ i 1)))) (def val (eval-form ctx new-bindings (bind-vec (+ i 1))))
(destructure-bind ctx new-bindings pat val) (destructure-bind ctx new-bindings pat val)
(+= i 2)))) (+= i 2))))
@ -1007,8 +1027,10 @@
patterns @[]] patterns @[]]
(var i 0) (var i 0)
(while (< i (length bind-vec)) (while (< i (length bind-vec))
# loop* is a primitive (the loop macro desugars destructuring);
# its binding names must be plain symbols, as in Clojure.
(unless (plain-sym? (bind-vec i)) (error "Bad binding form, expected symbol"))
(array/push init-vals (eval-form ctx bindings (bind-vec (+ i 1)))) (array/push init-vals (eval-form ctx bindings (bind-vec (+ i 1))))
# keep the binding form (symbol OR destructuring pattern)
(array/push patterns (bind-vec i)) (array/push patterns (bind-vec i))
(+= i 2)) (+= i 2))
(var loop-fn nil) (var loop-fn nil)

View file

@ -55,6 +55,20 @@
["loop map binding" "4" "(loop [{:keys [v]} {:v 1} n 0] (if (< n 2) (recur {:v (* v 2)} (inc n)) v))"] ["loop map binding" "4" "(loop [{:keys [v]} {:v 1} n 0] (if (< n 2) (recur {:v (* v 2)} (inc n)) v))"]
["loop init sees destr" "[1 2 3]" "(loop [[a b] [1 2] c (+ a b)] [a b c])"]) ["loop init sees destr" "[1 2 3]" "(loop [[a b] [1 2] c (+ a b)] [a b c])"])
# fn*/let*/loop* are PRIMITIVES: their binding forms must be plain symbols, exactly
# as in Clojure ("fn params must be Symbols" / "Bad binding form, expected symbol").
# Destructuring lives in the fn/let/loop/defn MACROS, which desugar to plain
# primitives — so the interpreter and the self-hosted analyzer agree (neither
# destructures fn*), and nothing silently falls back to a lenient interpreter path.
(defspec "destructure / primitives reject patterns (jolt-f79)"
["fn* fixed pattern" :throws "((fn* [[a b]] a) [1 2])"]
["fn* rest pattern" :throws "((fn* [a & [b]] b) 1 2 3)"]
["let* pattern" :throws "(let* [[a b] [1 2]] a)"]
["loop* pattern" :throws "(loop* [[a b] [1 2]] a)"]
# the macros still desugar the same patterns to plain primitives:
["fn desugars" "[1 2]" "((fn [[a b]] [a b]) [1 2])"]
["let desugars" "[1 2]" "(let [[a b] [1 2]] [a b])"])
(defspec "destructure / macro params" (defspec "destructure / macro params"
["macro & [a & more :as all]" ["macro & [a & more :as all]"
"[1 [2 3] [1 2 3]]" "[1 [2 3] [1 2 3]]"