Skip to content

Commit 5801fef

Browse files
authored
Add support for serializing EDN strings (#600)
* Add a safe `basilisp.edn/write-string` function for emitting only the EDN subset of Clojure data structures (which should be safer than using `pr-str` to print EDN). * Detach `basilisp.edn`'s default reader tags from those defined in `basilisp.lang.reader`, since we wouldn't want to accidentally add new default reader tags and have `basilisp.edn` support those tags unwittingly. * Add support for reading `##Inf`, `##-Inf`, and `##NaN` reader constants.
1 parent 44d2a0b commit 5801fef

File tree

3 files changed

+358
-15
lines changed

3 files changed

+358
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
* Added the current Python version (`:lpy36`, `:lpy37`, etc.) as a default reader feature for reader conditionals (#585)
1515
* Added default reader features for matching Python version ranges (`:lpy36+`, `:lpy38-`, etc.) (#593)
1616
* Added `lazy-cat` function for lazily concatenating sequences (#588)
17+
* Added support for writing EDN strings from `basilisp.edn` (#600)
1718

1819
### Changed
1920
* Moved `basilisp.lang.runtime.to_seq` to `basilisp.lang.seq` so it can be used within that module and by `basilisp.lang.runtime` without circular import (#588)

src/basilisp/edn.lpy

Lines changed: 174 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
(ns basilisp.edn
22
(:refer-basilisp :exclude [read read-string])
3-
(:require [basilisp.string :as str]))
3+
(:require [basilisp.string :as str])
4+
(:import datetime math uuid))
45

56
(declare ^:private read-next
67
^:private read-sym-or-num)
@@ -14,23 +15,38 @@
1415
(python/object))
1516

1617
(def ^:private default-edn-data-readers
17-
"Map of default data readers, which wrap the default readers and convert the
18-
SyntaxErrors into standard ExceptionInfo types."
19-
(reduce-kv (fn [m k tag-reader]
20-
(assoc m k (fn [v]
21-
(try
22-
(tag-reader v)
23-
(catch basilisp.lang.reader/SyntaxError e
24-
(throw
25-
(ex-info (get (.-args e) 0)
26-
{:error :tag-reader-error})))))))
27-
{}
28-
(dissoc default-data-readers 'py)))
18+
{'inst (fn [v]
19+
(try
20+
(basilisp.lang.util/inst-from-str v)
21+
(catch python/OverflowError _
22+
(throw
23+
(ex-info (str "Unrecognized date/time syntax: " v)
24+
{:error :tag-reader-error})))
25+
(catch python/ValueError _
26+
(throw
27+
(ex-info (str "Unrecognized date/time syntax: " v)
28+
{:error :tag-reader-error})))))
29+
'uuid (fn [v]
30+
(try
31+
(basilisp.lang.util/uuid-from-str v)
32+
(catch python/TypeError _
33+
(throw
34+
(ex-info (str "Unrecognized UUID format: " v)
35+
{:error :tag-reader-error})))
36+
(catch python/ValueError _
37+
(throw
38+
(ex-info (str "Unrecognized UUID syntax: " v)
39+
{:error :tag-reader-error})) )))})
2940

3041
(def ^:private eof
3142
"EOF marker if none is supplied."
3243
(python/object))
3344

45+
(def ^:private numeric-constants
46+
{'Inf (python/float "inf")
47+
'-Inf (- (python/float "inf"))
48+
'NaN (python/float "nan")})
49+
3450
(def ^:private special-chars
3551
"A mapping of special character names to the characters they represent."
3652
{"newline" "\n"
@@ -189,6 +205,7 @@
189205
(case (.peek reader)
190206
"_" :comment
191207
"{" :set
208+
"#" :constant
192209
:tag)))
193210

194211
(defmethod read-dispatch :comment
@@ -197,6 +214,23 @@
197214
(read-next reader opts)
198215
comment)
199216

217+
(defmethod read-dispatch :constant
218+
[reader opts]
219+
(assert-starts reader "#")
220+
(let [const-sym (read-sym-or-num reader opts)]
221+
(when-not (symbol? const-sym)
222+
(throw
223+
(ex-info "Reader constant must be a symbol"
224+
{:error :reader-constant-not-symbol
225+
:type (type const-sym)
226+
:value const-sym})))
227+
(if-let [const (get numeric-constants const-sym)]
228+
const
229+
(throw
230+
(ex-info "Unrecognized reader constant"
231+
{:error :no-reader-constant-for-symbol
232+
:sym const-sym})))))
233+
200234
(defmethod read-dispatch :set
201235
[reader opts]
202236
(assert-starts reader "{")
@@ -492,6 +526,110 @@
492526
{:error :eof}))
493527
e)))
494528

529+
;;;;;;;;;;;;;
530+
;; Writers ;;
531+
;;;;;;;;;;;;;
532+
533+
(defprotocol EDNEncodeable
534+
(write* [this writer]
535+
"Write the object `this` to the stream `writer` encoded as EDN.
536+
537+
Writer will be a file-like object supporting a `.write()` method."))
538+
539+
;; Rather than relying on the existing Lisp representations, we use custom
540+
;; implementations to avoid picking up any of the dynamic Vars which affect
541+
;; the result of `repr` calls. These include things like writing metadata or
542+
;; printing strings without quotes, neither of which is supported by the EDN
543+
;; spec.
544+
545+
(defn ^:private write-seq
546+
[e writer start-token end-token]
547+
(.write writer start-token)
548+
(doseq [entry (map-indexed (fn [i v] [i v]) (seq e))
549+
:let [[i v] entry]]
550+
(when (pos? i)
551+
(.write writer " "))
552+
(write* v writer))
553+
(.write writer end-token)
554+
nil)
555+
556+
(extend-protocol EDNEncodeable
557+
basilisp.lang.interfaces/IPersistentMap
558+
(write* [this writer]
559+
(.write writer "{")
560+
(doseq [entry (map-indexed (fn [i [k v]] [i k v]) (seq this))
561+
:let [[i k v] entry]]
562+
(when (pos? i)
563+
(.write writer " "))
564+
(write* k writer)
565+
(.write writer " ")
566+
(write* v writer))
567+
(.write writer "}")
568+
nil)
569+
basilisp.lang.interfaces/IPersistentList
570+
(write* [this writer]
571+
(write-seq this writer "(" ")"))
572+
basilisp.lang.interfaces/IPersistentSet
573+
(write* [this writer]
574+
(write-seq this writer "#{" "}"))
575+
basilisp.lang.interfaces/IPersistentVector
576+
(write* [this writer]
577+
(write-seq this writer "[" "]"))
578+
579+
basilisp.lang.keyword/Keyword
580+
(write* [this writer]
581+
(.write writer ":")
582+
(if-let [ns (namespace this)]
583+
(.write writer (str ns "/" (name this)))
584+
(.write writer (str (name this))))
585+
nil)
586+
basilisp.lang.symbol/Symbol
587+
(write* [this writer]
588+
(if-let [ns (namespace this)]
589+
(.write writer (str ns "/" (name this)))
590+
(.write writer (str (name this))))
591+
nil)
592+
593+
python/bool
594+
(write* [this writer]
595+
(.write writer (str/lower-case (python/repr this)))
596+
nil)
597+
python/int
598+
(write* [this writer]
599+
(.write writer (python/repr this))
600+
nil)
601+
python/float
602+
(write* [this writer]
603+
(->> (cond
604+
(math/isinf this) (if (pos? this) "##Inf" "##-Inf")
605+
(math/isnan this) "##NaN"
606+
:else (python/repr this))
607+
(.write writer))
608+
nil)
609+
python/str
610+
(write* [this writer]
611+
(.write writer "\"")
612+
(.write writer this)
613+
(.write writer "\"")
614+
nil)
615+
nil
616+
(write* [this writer]
617+
(.write writer "nil")
618+
nil)
619+
620+
datetime/datetime
621+
(write* [this writer]
622+
(.write writer "#inst \"")
623+
(.write writer (.isoformat this))
624+
(.write writer "\"")
625+
nil)
626+
uuid/UUID
627+
(write* [this writer]
628+
(.write writer "#uuid \"")
629+
(.write writer (python/str this))
630+
(.write writer "\"")
631+
nil))
632+
495633
;;;;;;;;;;;;;;;;;;;;;;
496634
;; Public Interface ;;
497635
;;;;;;;;;;;;;;;;;;;;;;
@@ -527,3 +665,26 @@
527665
(when-not (and (nil? s) (= "" s))
528666
(-> (io/StringIO s)
529667
(read opts)))))
668+
669+
(defn write
670+
"Serialize the object `o` as EDN to the writer object `writer` (which must be
671+
any file-like object supporting `.write()` method).
672+
673+
All Basilisp data structures are serializable to EDN by default. UUIDs and
674+
Instants (Python `datetime`s) are also supported by default. Support for other
675+
types may be added by extending the `EDNEncodeable` protocol for that type."
676+
([o]
677+
(write o *out*))
678+
([o writer]
679+
(write* o writer)))
680+
681+
(defn write-string
682+
"Serialize the object `o` as EDN and return the serialized object as a string.
683+
684+
All Basilisp data structures are serializable to EDN by default. UUIDs and
685+
Instants (Python `datetime`s) are also supported by default. Support for other
686+
types may be added by extending the `EDNEncodeable` protocol for that type."
687+
[o]
688+
(let [buf (io/StringIO "")]
689+
(write o buf)
690+
(.getvalue buf)))

0 commit comments

Comments
 (0)