From c280b10b298a5f856ff92b6cf15d497a69e54969 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sat, 6 Jun 2026 22:19:35 -0400 Subject: [PATCH] maps: preserve nil keys/values (jolt-c7h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Janet structs drop nil, so {:b nil}, (assoc {} :b nil) and friends silently lost the entry — diverging from Clojure, where a nil value is still a present key. Map construction now uses a phm (which preserves nil) whenever a key or value is nil; the nil-free case stays a fast struct. Touched the whole construction path: the reader (a nil-bearing map literal form is a phm, not a struct), eval-form (evaluates phm map-literal forms, rebuilds preserving nil), build-map-literal (compiled path), core-assoc (promotes on a nil key/value), and the host map-form predicates h-map?/h-map-pairs (recognize a phm form) so the self-hosted analyzer handles {:b nil} literals. Safe now that the back end densifies phm IR nodes and the IR is nil-field-clean (prior commits): const-nil nodes go phm but read back correctly, and the common nodes stay structs, so fib stays compiled (0.08s, no interpret fallback). Also fixes clojure.set/rename-keys, which deleted the old key via (assoc m old nil) — a trick that only worked while nil was dropped; now uses dissoc. conformance 218/218 x3, suite green, clojure-test-suite 3919 -> 3926. --- src/jolt/clojure/set.clj | 7 ++++++- src/jolt/compiler.janet | 9 ++++++--- src/jolt/core.janet | 9 ++++++--- src/jolt/evaluator.janet | 40 ++++++++++++++++++++++++++------------- src/jolt/host_iface.janet | 12 ++++++++++-- src/jolt/reader.janet | 13 ++++++++++++- 6 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/jolt/clojure/set.clj b/src/jolt/clojure/set.clj index f480754..e9ad98a 100644 --- a/src/jolt/clojure/set.clj +++ b/src/jolt/clojure/set.clj @@ -24,7 +24,12 @@ (defn rename-keys [map kmap] - (reduce (fn [m [old new]] (if (contains? m old) (assoc m new (get m old) old nil) m)) map kmap)) + (reduce (fn [m [old new]] + (if (contains? map old) + (assoc m new (get map old)) + m)) + (apply dissoc map (keys kmap)) + kmap)) (defn map-invert [m] diff --git a/src/jolt/compiler.janet b/src/jolt/compiler.janet index 5e02452..799dceb 100644 --- a/src/jolt/compiler.janet +++ b/src/jolt/compiler.janet @@ -1004,12 +1004,15 @@ # collection, in which case a phm so the key compares by value. Embedded as a # function constant in emitted code (functions marshal by reference). (defn build-map-literal [& kvs] - (var coll-key false) + # phm (not a Janet struct) when a key is a collection (value-based hashing) or a + # key/value is nil (structs drop nil; phm preserves it, matching Clojure). + (var need-phm false) (var ki 0) (while (< ki (length kvs)) - (let [kk (in kvs ki)] (when (or (table? kk) (array? kk)) (set coll-key true))) + (let [kk (in kvs ki) vv (in kvs (+ ki 1))] + (when (or (table? kk) (array? kk) (nil? kk) (nil? vv)) (set need-phm true))) (+= ki 2)) - (if coll-key + (if need-phm (do (var m (make-phm)) (var j 0) (while (< j (length kvs)) (set m (phm-assoc m (in kvs j) (in kvs (+ j 1)))) (+= j 2)) m) diff --git a/src/jolt/core.janet b/src/jolt/core.janet index 6d759a7..600840d 100644 --- a/src/jolt/core.janet +++ b/src/jolt/core.janet @@ -370,11 +370,14 @@ (if (= idx (length result)) (array/push result v) (put result idx v))) (+= i 2)) (if (tuple? m) (tuple/slice (tuple ;result)) result)) - # map (struct/table). If any key is a collection, a Janet struct/table keys - # it by identity — promote to a phm so such keys compare by value. + # map (struct/table). Promote to a phm when any new key is a collection (a + # Janet struct/table would key it by identity) or any new key/value is nil (a + # struct drops nil; phm preserves it, matching Clojure). m itself is a struct + # here (phm handled above), so only the new kvs can introduce these. (let [coll-key (do (var c false) (var i 0) (while (< i (length kvs)) - (when (let [k (in kvs i)] (or (table? k) (array? k))) (set c true)) + (let [k (in kvs i) v (in kvs (+ i 1))] + (when (or (table? k) (array? k) (nil? k) (nil? v)) (set c true))) (+= i 2)) c)] (if coll-key (do (var result (make-phm)) diff --git a/src/jolt/evaluator.janet b/src/jolt/evaluator.janet index fdd90a6..e6869cd 100644 --- a/src/jolt/evaluator.janet +++ b/src/jolt/evaluator.janet @@ -1432,6 +1432,24 @@ args (map |(eval-form ctx bindings $) (tuple/slice form 1))] (jolt-invoke ctx f args))))) +# Build a map value from an array of evaluated [k v k v ...]. A phm (not a Janet +# struct) is used when a key is a collection (value-based hashing) OR a key/value +# is nil (Janet structs drop nil; phm preserves it, matching Clojure). The common +# scalar/nil-free case stays a struct. +(defn- map-needs-phm? [kvs] + (var need false) (var i 0) + (while (< i (length kvs)) + (let [k (in kvs i) v (in kvs (+ i 1))] + (when (or (table? k) (array? k) (nil? k) (nil? v)) (set need true) (break))) + (+= i 2)) + need) + +(defn- build-eval-map [kvs] + (if (map-needs-phm? kvs) + (do (var m (make-phm)) (var j 0) + (while (< j (length kvs)) (set m (phm-assoc m (in kvs j) (in kvs (+ j 1)))) (+= j 2)) m) + (struct ;kvs))) + (set eval-form (fn [ctx bindings form] (cond (nil? form) nil @@ -1467,19 +1485,15 @@ (each k (keys form) (array/push kvs (eval-form ctx bindings k)) (array/push kvs (eval-form ctx bindings (get form k)))) - # If any key is a collection (a Janet table/array — phm/pvec/plist/ - # record/list), a Janet struct would key it by identity; use a phm so - # such keys compare by value. - (var coll-key false) - (var ki 0) - (while (< ki (length kvs)) - (let [kk (in kvs ki)] (when (or (table? kk) (array? kk)) (set coll-key true))) - (+= ki 2)) - (if coll-key - (do (var m (make-phm)) (var j 0) - (while (< j (length kvs)) (set m (phm-assoc m (in kvs j) (in kvs (+ j 1)))) (+= j 2)) - m) - (struct ;kvs)))))))) + (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)))) + (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 4e43638..cb4a333 100644 --- a/src/jolt/host_iface.janet +++ b/src/jolt/host_iface.janet @@ -21,6 +21,7 @@ (use ./types) (use ./evaluator) (use ./core) +(import ./phm :as phm) # --------------------------------------------------------------------------- # Form introspection @@ -36,7 +37,12 @@ (defn h-list? [form] (array? form)) # a call / list (reader: array) (defn h-vector? [form] (tuple? form)) # a vector literal (reader: tuple) -(defn h-map? [form] (and (struct? form) (nil? (form :jolt/type)))) +# A map-literal form is a plain struct, or a phm when the reader preserved a nil +# key/value (Janet structs drop nil). Sets/chars/symbols are tagged structs (have +# :jolt/type); phm carries :jolt/deftype, distinct from those. +(defn h-map? [form] + (or (and (struct? form) (nil? (form :jolt/type))) + (phm/phm? form))) (defn h-set? [form] (and (struct? form) (= :jolt/set (form :jolt/type)))) (defn h-char? [form] (and (struct? form) (= :jolt/char (form :jolt/type)))) @@ -48,7 +54,9 @@ (defn h-elements [form] (make-vec form)) (defn h-vector-items [form] (make-vec form)) (defn h-map-pairs [form] - (make-vec (map (fn [k] (make-vec [k (get form k)])) (keys form)))) + (if (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 45016fd..569ee18 100644 --- a/src/jolt/reader.janet +++ b/src/jolt/reader.janet @@ -9,6 +9,7 @@ # Sets #{1 2} → tagged struct {:jolt/type :jolt/set :value [1 2]} (use ./types) +(import ./phm :as phm) # Forward declaration for mutual recursion (var read-form nil) @@ -266,6 +267,16 @@ (read-vec-items s new-pos (array/push items form)))))))) (read-vec-items s (+ pos 1) @[])) +# A map-literal form. Janet structs drop nil keys/values, so when a key or value +# is nil (e.g. {:a nil}) build a phm — it preserves nil, matching Clojure. The +# common nil-free case stays a struct: fast, and what the downstream map-form +# handling (evaluator/analyzer) already expects. Collection keys are left to +# eval-time construction (build-map-literal/eval-form), which phm-ifies them. +(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))) + (defn read-map [s pos] # pos is at opening brace (defn read-kvs [s pos kvs] @@ -273,7 +284,7 @@ (if (>= pos (length s)) (error "Unterminated map")) (if (= (s pos) 125) # } - [(struct ;kvs) (+ pos 1)] + [(reader-map kvs) (+ pos 1)] (let [[key new-pos] (read-form s pos)] (if (and (struct? key) (= :jolt/skip (key :jolt/type))) (read-kvs s new-pos kvs)