From 15d599c0f3369abf35b99c5b76e6a274f559c2a1 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Fri, 12 Jun 2026 14:09:50 -0400 Subject: [PATCH] fix: map literals evaluate in source order; land the local-callee inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jolt-p3c: Clojure evaluates map-literal entries left to right, but the reader represented map forms as bare janet structs, so entries ran in hash order. The reader now carries [k v ...] source order out-of-band — on a struct PROTOTYPE (keys/kvs/length ignore protos, so macros that get/keys literal map forms see no change; jolt-equal? was already structural) and as a plain field on the phm rep (nil key/value). The analyzer (form-map-pairs), the interpreter's map eval, both syntax-quote walks, and core-sqmap (the lowered `{...} builder — the array-map case, where Clojure also preserves insertion order) all honor it, so the order survives macroexpansion in both modes. jolt-507 root-caused: the parked inline put a LOCAL in janet call-head position for the first time, and janet resolves head symbols against the macro table before lexical upvalues — clojure.core/repeat's self-name local expanded as janet's (repeat n & body) macro, compiling the self-call into a countdown loop returning nil. Everything in the issue (interpose, interleave) traced to that one name collision. The emitter now rebinds local callees to reserved _fp$ symbols (argument positions never consult the macro table), and the inline — direct calls for function locals, jolt-call only for IFn-collection leftovers — lands. Spec rows pin locals named repeat/seq/with called in head position. Gate green, suite 4718 steady, bench even with main. --- src/jolt/backend.janet | 16 +++++++++++ src/jolt/core.janet | 12 +++++++- src/jolt/evaluator.janet | 46 +++++++++++++++++++++---------- src/jolt/host_iface.janet | 14 +++++++++- src/jolt/reader.janet | 19 ++++++++++++- test/spec/map-fastpath-spec.janet | 25 ++++++++++++++--- 6 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/jolt/backend.janet b/src/jolt/backend.janet index 507cb62..7b13bf0 100644 --- a/src/jolt/backend.janet +++ b/src/jolt/backend.janet @@ -330,6 +330,22 @@ body (if (symbol? d-expr) body ['let [d d-expr] body])] (wrap body)))) (direct-call? ctx fnode) (tuple (emit ctx fnode) ;args) + # Local callee (closure param, let-bound fn, defn self-name): inline the + # function check so the overwhelmingly-common function case is a direct + # janet call with no variadic arg-tuple packing — jolt-call only handles + # the IFn-collection leftovers (jank's dynamic_call removal, jolt-507). + # The callee is rebound to a reserved _fp$ symbol first: a raw jolt local + # name in janet CALL-HEAD position resolves against janet's macro table + # before the lexical upvalue, so a local named like a janet core macro + # (clojure.core/repeat's self-name vs janet's repeat macro) would expand + # as that macro. Argument positions (the old jolt-call shape, the rebind + # here) never consult the macro table, so the rebind is safe. + (= :local (fnode :op)) + (let [fsym (jsym)] + ['let [fsym (emit ctx fnode)] + ['if ['function? fsym] + (tuple fsym ;args) + (tuple jolt-call fsym ;args)]]) (tuple jolt-call (emit ctx fnode) ;args))) (defn- emit-vector [ctx node] diff --git a/src/jolt/core.janet b/src/jolt/core.janet index cb906e9..15d5ae6 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -169,7 +169,17 @@ (tuple/slice r)) # Map builder: parts are alternating k v (no splicing in map syntax-quote). -(defn core-sqmap [& parts] (kvs->map (array ;parts))) +(defn core-sqmap [& parts] + # A syntax-quoted map template is Clojure's array-map case: construction + # order is source order and must survive into the built map, which usually + # becomes a FORM whose entries the evaluator walks (jolt-p3c). Same + # carriers as the reader: struct prototype / phm field. + (def kvs (array ;parts)) + (def m (kvs->map kvs)) + (cond + (struct? m) (struct/with-proto (struct :jolt/kv-order (tuple/slice kvs)) ;kvs) + (table? m) (do (put m :jolt/kv-order (tuple/slice kvs)) m) + m)) # Set builder: like core-sqvec but yields a set, so `#{~@a} splices into a set. (defn core-sqset [& parts] diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index 51d964f..096501e 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -261,9 +261,15 @@ (make-phs ;result)) (and (struct? form) (get form :jolt/type)) form (struct? form) - (do (var kvs @[]) (each k (keys form) - (array/push kvs (syntax-quote* ctx bindings k gsmap)) - (array/push kvs (syntax-quote* ctx bindings (get form k) gsmap))) (struct ;kvs)) + (do (var kvs @[]) + (def order (form-kv-order form)) + (if order + (each x order (array/push kvs (syntax-quote* ctx bindings x gsmap))) + (each k (keys form) + (array/push kvs (syntax-quote* ctx bindings k gsmap)) + (array/push kvs (syntax-quote* ctx bindings (get form k) gsmap)))) + # keep carrying source order through nested syntax-quote (jolt-p3c) + (struct/with-proto (struct :jolt/kv-order (tuple/slice kvs)) ;kvs)) form)) # Syntax-quote LOWERING: instead of evaluating a `(...) form to a value (what @@ -306,9 +312,12 @@ @[(sqsym* "quote") form] (struct? form) (do (var parts @[(sqsym* "__sqmap")]) - (each k (keys form) - (array/push parts (syntax-quote-lower ctx k gsmap)) - (array/push parts (syntax-quote-lower ctx (get form k) gsmap))) + (def order (form-kv-order form)) + (if order + (each x order (array/push parts (syntax-quote-lower ctx x gsmap))) + (each k (keys form) + (array/push parts (syntax-quote-lower ctx k gsmap)) + (array/push parts (syntax-quote-lower ctx (get form k) gsmap)))) parts) @[(sqsym* "quote") form]))) @@ -2353,19 +2362,26 @@ (error (string "No reader function for tag " tag)))) (if (get form :jolt/type) (error (string "Unexpected tagged form: " (form :jolt/type))) - # plain map literal: evaluate keys and values - (let [kvs @[]] - (each k (keys form) - (array/push kvs (eval-form ctx bindings k)) - (array/push kvs (eval-form ctx bindings (get form k)))) + # plain map literal: evaluate keys and values in SOURCE order when + # the reader order rides along (jolt-p3c), hash order otherwise + (let [kvs @[] + order (form-kv-order form)] + (if order + (each x order (array/push kvs (eval-form ctx bindings x))) + (each k (keys form) + (array/push kvs (eval-form ctx bindings k)) + (array/push kvs (eval-form ctx bindings (get form k))))) (build-eval-map kvs)))))))) # A phm map-literal FORM (reader emits one for {:a nil} etc., which a struct # would have dropped): evaluate its key/value forms and rebuild, preserving nil. (phm? form) - (let [kvs @[]] - (each e (phm-entries form) - (array/push kvs (eval-form ctx bindings (in e 0))) - (array/push kvs (eval-form ctx bindings (in e 1)))) + (let [kvs @[] + order (form-kv-order form)] + (if order + (each x order (array/push kvs (eval-form ctx bindings x))) + (each e (phm-entries form) + (array/push kvs (eval-form ctx bindings (in e 0))) + (array/push kvs (eval-form ctx bindings (in e 1))))) (build-eval-map kvs)) (array? form) (if (= 0 (length form)) diff --git a/src/jolt/host_iface.janet b/src/jolt/host_iface.janet index 2a0981b..a89ce39 100644 --- a/src/jolt/host_iface.janet +++ b/src/jolt/host_iface.janet @@ -22,6 +22,7 @@ (use ./evaluator) (use ./core) (import ./phm :as phm) +(import ./reader :as rdr) # --------------------------------------------------------------------------- # Form introspection @@ -54,7 +55,18 @@ (defn h-elements [form] (make-vec form)) (defn h-vector-items [form] (make-vec form)) (defn h-map-pairs [form] - (if (phm/phm? form) + # reader forms carry source order (jolt-p3c) — Clojure evaluates literal + # entries left to right; hash order only for constructed maps + (def order (rdr/form-kv-order form)) + (cond + order + (do (def out @[]) + (var i 0) + (while (< i (length order)) + (array/push out (make-vec [(in order i) (in order (+ i 1))])) + (+= i 2)) + (make-vec out)) + (phm/phm? form) (make-vec (map (fn [e] (make-vec [(in e 0) (in e 1)])) (phm/phm-entries form))) (make-vec (map (fn [k] (make-vec [k (get form k)])) (keys form))))) (defn h-set-items [form] (make-vec (form :value))) diff --git a/src/jolt/reader.janet b/src/jolt/reader.janet index 256591a..7b01ec9 100644 --- a/src/jolt/reader.janet +++ b/src/jolt/reader.janet @@ -305,7 +305,24 @@ (defn- reader-map [kvs] (var has-nil false) (var i 0) (while (< i (length kvs)) (when (nil? (in kvs i)) (set has-nil true) (break)) (++ i)) - (if has-nil (phm/make-phm kvs) (struct ;kvs))) + # Source order rides along out-of-band (jolt-p3c): struct iteration is hash + # order, but Clojure evaluates literal entries left to right. A struct + # PROTOTYPE carries it without changing the form's map behavior (keys/kvs/ + # length ignore protos; jolt-equal? compares maps structurally); the phm rep + # (nil key/value present) gets a plain extra field. + (if has-nil + (let [m (phm/make-phm kvs)] + (put m :jolt/kv-order (tuple/slice kvs)) + m) + (struct/with-proto (struct :jolt/kv-order (tuple/slice kvs)) ;kvs))) + +(defn form-kv-order + "Source-ordered [k v k v ...] tuple of a map FORM (nil for maps built at + runtime, which carry no reader order)." + [form] + (cond + (struct? form) (get (struct/getproto form) :jolt/kv-order) + (table? form) (get form :jolt/kv-order))) (defn read-map [s pos] # pos is at opening brace diff --git a/test/spec/map-fastpath-spec.janet b/test/spec/map-fastpath-spec.janet index 1df264b..cc4d3f7 100644 --- a/test/spec/map-fastpath-spec.janet +++ b/test/spec/map-fastpath-spec.janet @@ -35,10 +35,16 @@ ["collection key" ":v" "(get {[1 2] :v} [1 2])"] ["collection value-equal key" ":v" "(get {[1 2] :v} (vector 1 2))"] ["computed key" "1" "(get {(keyword \"a\") 1} :a)"] - # NOTE: entry ORDER is reader-hash order today, not source order (Clojure - # evaluates array-map literals left-to-right) — pinned loosely; see jolt-p3c - ["values evaluate exactly once each" "[1 2 3]" - "(do (def log (atom [])) {:a (swap! log conj 1) :b (swap! log conj 2) :c (swap! log conj 3)} (vec (sort (deref log))))"] + # jolt-p3c: literal entries evaluate left to right in SOURCE order (the + # reader carries [k v ...] order on a struct prototype / phm field) + ["values evaluate in source order" "[1 2 3]" + "(do (def log (atom [])) {:a (swap! log conj 1) :b (swap! log conj 2) :c (swap! log conj 3)} (deref log))"] + ["keys evaluate before their values, pairwise" "[:k1 :v1 :k2 :v2]" + "(do (def log (atom [])) {(do (swap! log conj :k1) :a) (do (swap! log conj :v1) 1) (do (swap! log conj :k2) :b) (do (swap! log conj :v2) 2)} (deref log))"] + ["source order with a nil value (phm form)" "[1 2 3]" + "(do (def log (atom [])) {:a (do (swap! log conj 1) nil) :b (swap! log conj 2) :c (swap! log conj 3)} (deref log))"] + ["source order through syntax-quote" "[1 2]" + "(do (def log (atom [])) (defmacro m-p3c [] `{:a ~(list 'swap! 'log 'conj 1) :b ~(list 'swap! 'log 'conj 2)}) (m-p3c) (deref log))"] ["count" "3" "(count {:a 1 :b 2 :c 3})"] ["equality with phm" "true" "(= {:a 1 :b 2} (assoc {:a 1} :b 2))"] ["keys work after assoc" "2" "(:b (assoc {:a 1 :b 2} :c 3))"] @@ -55,3 +61,14 @@ ["PI" "true" "(< 3.14 clojure.math/PI 3.15)"] ["require + alias" "5" "(do (require '[clojure.math :as m]) (long (m/hypot 3 4)))"] ["as a value" "[1 2]" "(mapv (comp long clojure.math/sqrt) [1 4])"]) + +# jolt-507: a jolt local in janet CALL-HEAD position must not resolve as a +# janet core macro of the same name (repeat/seq/loop are janet macros). The +# emitter rebinds local callees to reserved _fp$ symbols. +(defspec "calls / locals named like janet macros" + ["local fn named repeat" "[1 1]" "(let [repeat (fn [x] [x x])] (repeat 1))"] + ["local fn named seq" ":end" "((fn seq [n] (if (pos? n) (seq (dec n)) :end)) 2)"] + ["local fn named loop2" "[2 1]" "(let [with (fn [a b] [b a])] (with 1 2))"] + ["overlay repeat self-call (regression)" "(quote (0 0 0))" "(take 3 (repeat 0))"] + ["closure param called" "42" "((fn [f] (f 41)) inc)"] + ["param holding a keyword (IFn leftover)" "1" "((fn [f] (f {:a 1})) :a)"])