diff --git a/jolt-core/clojure/core/00-syntax.clj b/jolt-core/clojure/core/00-syntax.clj index aeb33f2..1b3131c 100644 --- a/jolt-core/clojure/core/00-syntax.clj +++ b/jolt-core/clojure/core/00-syntax.clj @@ -102,11 +102,24 @@ (vloop 0 0 (conj (conj acc g) init))) (map? pat) (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) as-sym (get pat :as) - base (if as-sym - (conj (conj (conj (conj acc g) init) as-sym) g) - (conj (conj acc g) init)) + bound (conj (conj (conj (conj acc g) init) gm) coerce) + base (if as-sym (conj (conj bound as-sym) gm) bound) group (fn* [a kw kind] (let* [names (get pat kw)] @@ -125,8 +138,8 @@ fo (find-or or-map local)] (conj (conj aa (symbol local)) (if (nth fo 0) - `(get ~g ~keyform ~(nth fo 1)) - `(get ~g ~keyform))))) + `(get ~gm ~keyform ~(nth fo 1)) + `(get ~gm ~keyform))))) a names) a))) g1 (group base :keys :kw) @@ -135,7 +148,7 @@ (reduce (fn* [a k] (if (keyword? k) a - (proc k `(get ~g ~(get pat k)) a))) + (proc k `(get ~gm ~(get pat k)) a))) g3 (keys pat))) :else (throw (str "unsupported destructuring pattern")))) ploop @@ -212,8 +225,11 @@ (let [as (vec (map mk aftn))] (if nm `(fn* ~nm ~@as) `(fn* ~@as)))))) -;; 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 +;; defn: drop an optional leading docstring and attr-map, then (def name (fn ...)). +;; 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 ;; needed. (map? is true for symbol forms too, so guard the attr-map with symbol?.) ;; 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) body (if (and (seq body) (map? (first body)) (not (symbol? (first 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 ;; defn- delegates to defn with :private metadata). diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 473ebbb..dfcae00 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -517,6 +517,21 @@ (do (array/push fixed a) (+= i 1))))) {: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 "Look up key k in a map-like value (phm/struct/table/nil)." [m k] @@ -925,6 +940,7 @@ (let [args-form (in pair 0) body (tuple/slice pair 1) param-info (parse-params args-form) + _ (require-symbol-params param-info) fixed-pats (param-info :fixed) rest-pat (param-info :rest) n-fixed (length fixed-pats) @@ -962,6 +978,7 @@ (let [args-form (in form 1) body (tuple/slice form 2) param-info (parse-params args-form) + _ (require-symbol-params param-info) fixed-pats (param-info :fixed) rest-pat (param-info :rest) defining-ns (ctx-current-ns ctx)] @@ -994,6 +1011,9 @@ (let [len (length bind-vec)] (while (< i len) (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)))) (destructure-bind ctx new-bindings pat val) (+= i 2)))) @@ -1007,8 +1027,10 @@ patterns @[]] (var i 0) (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)))) - # keep the binding form (symbol OR destructuring pattern) (array/push patterns (bind-vec i)) (+= i 2)) (var loop-fn nil) diff --git a/test/spec/destructuring-spec.janet b/test/spec/destructuring-spec.janet index 31d8afd..91525dd 100644 --- a/test/spec/destructuring-spec.janet +++ b/test/spec/destructuring-spec.janet @@ -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 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" ["macro & [a & more :as all]" "[1 [2 3] [1 2 3]]"