jolt/stdlib/clojure/edn.clj
Yogthos 44d4875a24 Strict reader tokens; edn mode with the reference's error contracts
The reader now rejects what the JVM reader rejects: a token that starts
like a number but doesn't parse is NumberFormatException (1a, 08, 0x2g,
2r2 — never a symbol); ratio parts are digit runs (1/-1 invalid) with a
zero denominator throwing ArithmeticException; empty ns/name parts are
invalid tokens (:, ::, foo/, /foo) while /, ns//, and :/ stay valid;
duplicate map keys and set elements throw at read; unsupported string
escapes and octal escapes past \377 throw; a stray close delimiter is
'Unmatched delimiter'; \r ends line comments. #inst validates its
calendar fields progressively (leap years included) and #uuid demands
canonical hex. 1-arg symbol splits its ns at the FIRST slash
(Symbol.intern): (symbol "foo/bar/baz") is foo/"bar/baz".

clojure.edn gets its own strict seam (__read-form-edn): auto-resolved
keywords are invalid there, every #_ discarded form validates through the
same :readers/:default pipeline (an unreadable tagged element throws even
when discarded), built-in tags win over :default, M literals construct
BigDecimals, lists satisfy list?, and EOF honors :eof — an opts map
without :eof makes end-of-input an error.

clojure.edn-test.read-string goes 246 pass / 46 fail / 5 errors -> 297/0/0
(fully clean). cts baseline 5904 -> 5955 pass, 23 errors, 56 baselined
namespaces. 9 JVM-certified corpus rows; reader spec section.
2026-07-02 12:00:13 -04:00

93 lines
4.8 KiB
Clojure

;; clojure.edn — reading EDN data. Delegates to the Jolt reader via
;; clojure.core/read-string (which parses, never evaluates — safe for EDN), and
;; adds the opts-map arity with :eof plus nil/blank-input handling.
(ns clojure.edn
"Reading EDN data."
(:require [clojure.string :as cstr]))
;; The reader yields set literals as a FORM ({:jolt/type :jolt/set :value [...]})
;; rather than a constructed set, so build the actual values, recursing into
;; maps/vectors/lists. (Lists stay lists — EDN never evaluates them as code.)
;; Re-attach the source value's metadata to each rebuilt collection — read-string
;; preserves reader metadata (^:ref […]) but this rebuild would otherwise drop it,
;; which a metadata-driven config lib (aero/integrant) relies on.
(defn- edn->value [opts x]
(cond
;; Reader FORMS are detected by :jolt/type tag, never by map? — strict map?
;; (correctly) excludes tagged structs, so the old (and (map? x) ...) guard
;; would skip them.
(= :jolt/set (get x :jolt/type))
(let [vs (map (fn [v] (edn->value opts v)) (get x :value))
st (set vs)]
;; duplicate literal elements are invalid edn
(when (< (count st) (count vs))
(throw (new IllegalArgumentException
(str "Duplicate key: " (pr-str (some (fn [[k n]] (when (< 1 n) k))
(frequencies vs)))))))
(with-meta st (edn->value opts (meta x))))
;; Tagged elements: a reader from the :readers opt wins, then the built-in
;; data readers (#uuid/#inst + registered); an unknown tag falls to the
;; :default opt fn (called with tag and value, as in Clojure) or throws.
(= :jolt/tagged (get x :jolt/type))
(let [tag (get x :tag)
v (edn->value opts (get x :form))
;; the reader stores the tag as a :#name keyword; :readers maps are
;; keyed by the SYMBOL (Clojure's shape) — normalize for lookup
tag-sym (let [n (name tag)]
(symbol (if (= "#" (subs n 0 1)) (subs n 1) n)))
custom (get (get opts :readers) tag-sym)]
(cond
custom (custom v)
;; the built-in edn tags win over :default (a :readers entry can
;; override them; an unknown-tag :default never sees #inst/#uuid)
(contains? #{'inst 'uuid 'bigdec} tag-sym) (__read-tagged tag v)
;; Clojure calls :default with the tag as a SYMBOL and the value.
(get opts :default) ((get opts :default) tag-sym v)
:else (__read-tagged tag v)))
(map? x)
(with-meta (into {} (map (fn [e] [(edn->value opts (key e)) (edn->value opts (val e))]) x)) (edn->value opts (meta x)))
(vector? x) (with-meta (mapv (fn [v] (edn->value opts v)) x) (edn->value opts (meta x)))
;; a constructed set: recurse into its elements too, so a tagged literal
;; inside #{…} gets the :readers/:default treatment (aero's #ref in a set).
(set? x) (with-meta (set (map (fn [v] (edn->value opts v)) x)) (edn->value opts (meta x)))
;; edn lists are lists (list? holds), not lazy seqs
(seq? x) (with-meta (apply list (map (fn [v] (edn->value opts v)) x)) (edn->value opts (meta x)))
:else x))
;; Private helper, NOT named read-string: an unqualified (read-string …) call
;; dispatches the core read-string SPECIAL FORM (by name, regardless of ns), so
;; the 1-arity can't delegate to the 2-arity through that name.
(defn- read-edn [opts s]
;; the strict edn seam: no auto-resolved keywords, invalid tokens throw, and
;; each #_ discard is validated through the same :readers/:default pipeline.
;; EOF (blank/comment-only/nil input) honors :eof; an opts map WITHOUT :eof
;; makes end-of-input an error, like the reference.
(let [v (__read-form-edn s (fn [form] (edn->value opts form) nil))]
(if (= v :jolt/reader-eof)
(if (contains? opts :eof)
(get opts :eof)
(throw (ex-info "EOF while reading" {})))
(edn->value opts v))))
(defn read-string
"Reads one object from the string s. The no-opts arity returns nil at end of
input; with an opts map, :eof sets the value returned at end of input and its
absence makes end-of-input an error."
([s] (read-edn {:eof nil} s))
([opts s] (read-edn opts s)))
(defn- drain-reader
"All remaining content of a reader as a string. Shim readers (StringReader,
PushbackReader, io/reader results) expose char-wise .read; a raw file
handle is read whole."
[reader]
(loop [acc (transient []) c (.read reader)]
(if (== -1 c)
(apply str (map char (persistent! acc)))
(recur (conj! acc c) (.read reader)))))
(defn read
"Reads one EDN object from reader (a PushbackReader or any jolt reader).
Returns the :eof option value (default nil) at end of input."
([reader] (read {} reader))
([opts reader] (read-edn opts (drain-reader reader))))