maps: preserve nil keys/values (jolt-c7h)

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.
This commit is contained in:
Yogthos 2026-06-06 22:19:35 -04:00
parent b2ff65972b
commit c280b10b29
6 changed files with 67 additions and 23 deletions

View file

@ -24,7 +24,12 @@
(defn rename-keys (defn rename-keys
[map kmap] [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 (defn map-invert
[m] [m]

View file

@ -1004,12 +1004,15 @@
# collection, in which case a phm so the key compares by value. Embedded as a # collection, in which case a phm so the key compares by value. Embedded as a
# function constant in emitted code (functions marshal by reference). # function constant in emitted code (functions marshal by reference).
(defn build-map-literal [& kvs] (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) (var ki 0)
(while (< ki (length kvs)) (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)) (+= ki 2))
(if coll-key (if need-phm
(do (var m (make-phm)) (var j 0) (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)) (while (< j (length kvs)) (set m (phm-assoc m (in kvs j) (in kvs (+ j 1)))) (+= j 2))
m) m)

View file

@ -370,11 +370,14 @@
(if (= idx (length result)) (array/push result v) (put result idx v))) (if (= idx (length result)) (array/push result v) (put result idx v)))
(+= i 2)) (+= i 2))
(if (tuple? m) (tuple/slice (tuple ;result)) result)) (if (tuple? m) (tuple/slice (tuple ;result)) result))
# map (struct/table). If any key is a collection, a Janet struct/table keys # map (struct/table). Promote to a phm when any new key is a collection (a
# it by identity — promote to a phm so such keys compare by value. # 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) (let [coll-key (do (var c false) (var i 0)
(while (< i (length kvs)) (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)] (+= i 2)) c)]
(if coll-key (if coll-key
(do (var result (make-phm)) (do (var result (make-phm))

View file

@ -1432,6 +1432,24 @@
args (map |(eval-form ctx bindings $) (tuple/slice form 1))] args (map |(eval-form ctx bindings $) (tuple/slice form 1))]
(jolt-invoke ctx f args))))) (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] (set eval-form (fn [ctx bindings form]
(cond (cond
(nil? form) nil (nil? form) nil
@ -1467,19 +1485,15 @@
(each k (keys form) (each k (keys form)
(array/push kvs (eval-form ctx bindings k)) (array/push kvs (eval-form ctx bindings k))
(array/push kvs (eval-form ctx bindings (get form k)))) (array/push kvs (eval-form ctx bindings (get form k))))
# If any key is a collection (a Janet table/array — phm/pvec/plist/ (build-eval-map kvs)))))))
# record/list), a Janet struct would key it by identity; use a phm so # A phm map-literal FORM (reader emits one for {:a nil} etc., which a struct
# such keys compare by value. # would have dropped): evaluate its key/value forms and rebuild, preserving nil.
(var coll-key false) (phm? form)
(var ki 0) (let [kvs @[]]
(while (< ki (length kvs)) (each e (phm-entries form)
(let [kk (in kvs ki)] (when (or (table? kk) (array? kk)) (set coll-key true))) (array/push kvs (eval-form ctx bindings (in e 0)))
(+= ki 2)) (array/push kvs (eval-form ctx bindings (in e 1))))
(if coll-key (build-eval-map 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))))))))
(array? form) (array? form)
(if (= 0 (length form)) (if (= 0 (length form))
@[] @[]

View file

@ -21,6 +21,7 @@
(use ./types) (use ./types)
(use ./evaluator) (use ./evaluator)
(use ./core) (use ./core)
(import ./phm :as phm)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Form introspection # Form introspection
@ -36,7 +37,12 @@
(defn h-list? [form] (array? form)) # a call / list (reader: array) (defn h-list? [form] (array? form)) # a call / list (reader: array)
(defn h-vector? [form] (tuple? form)) # a vector literal (reader: tuple) (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-set? [form] (and (struct? form) (= :jolt/set (form :jolt/type))))
(defn h-char? [form] (and (struct? form) (= :jolt/char (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-elements [form] (make-vec form))
(defn h-vector-items [form] (make-vec form)) (defn h-vector-items [form] (make-vec form))
(defn h-map-pairs [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))) (defn h-set-items [form] (make-vec (form :value)))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -9,6 +9,7 @@
# Sets #{1 2} → tagged struct {:jolt/type :jolt/set :value [1 2]} # Sets #{1 2} → tagged struct {:jolt/type :jolt/set :value [1 2]}
(use ./types) (use ./types)
(import ./phm :as phm)
# Forward declaration for mutual recursion # Forward declaration for mutual recursion
(var read-form nil) (var read-form nil)
@ -266,6 +267,16 @@
(read-vec-items s new-pos (array/push items form)))))))) (read-vec-items s new-pos (array/push items form))))))))
(read-vec-items s (+ pos 1) @[])) (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] (defn read-map [s pos]
# pos is at opening brace # pos is at opening brace
(defn read-kvs [s pos kvs] (defn read-kvs [s pos kvs]
@ -273,7 +284,7 @@
(if (>= pos (length s)) (if (>= pos (length s))
(error "Unterminated map")) (error "Unterminated map"))
(if (= (s pos) 125) # } (if (= (s pos) 125) # }
[(struct ;kvs) (+ pos 1)] [(reader-map kvs) (+ pos 1)]
(let [[key new-pos] (read-form s pos)] (let [[key new-pos] (read-form s pos)]
(if (and (struct? key) (= :jolt/skip (key :jolt/type))) (if (and (struct? key) (= :jolt/skip (key :jolt/type)))
(read-kvs s new-pos kvs) (read-kvs s new-pos kvs)