diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/main/clojure/clojure/contrib/json.clj | 305 | ||||
-rw-r--r-- | src/test/clojure/clojure/contrib/test_contrib/test_json.clj | 172 |
2 files changed, 477 insertions, 0 deletions
diff --git a/src/main/clojure/clojure/contrib/json.clj b/src/main/clojure/clojure/contrib/json.clj new file mode 100644 index 00000000..0af05913 --- /dev/null +++ b/src/main/clojure/clojure/contrib/json.clj @@ -0,0 +1,305 @@ +;;; json.clj: JavaScript Object Notation (JSON) parser/writer + +;; by Stuart Sierra, http://stuartsierra.com/ +;; January 30, 2010 + +;; Copyright (c) Stuart Sierra, 2010. All rights reserved. The use +;; and distribution terms for this software are covered by the Eclipse +;; Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) +;; which can be found in the file epl-v10.html at the root of this +;; distribution. By using this software in any fashion, you are +;; agreeing to be bound by the terms of this license. You must not +;; remove this notice, or any other, from this software. + + +(ns #^{:author "Stuart Sierra" + :doc "JavaScript Object Notation (JSON) parser/writer. + See http://www.json.org/ + To write JSON, use json-str, write-json, or print-json. + To read JSON, use read-json."} + clojure.contrib.json + (:require [clojure.contrib.java-utils :as j]) + (:import (java.io PushbackReader StringReader Reader EOFException))) + +(declare read-json-reader) + +(defn- read-json-array [#^PushbackReader stream keywordize?] + ;; Expects to be called with the head of the stream AFTER the + ;; opening bracket. + (loop [i (.read stream), result (transient [])] + (let [c (char i)] + (cond + (= i -1) (throw (EOFException. "JSON error (end-of-file inside array)")) + (Character/isWhitespace c) (recur (.read stream) result) + (= c \,) (recur (.read stream) result) + (= c \]) (persistent! result) + :else (do (.unread stream (int c)) + (let [element (read-json-reader stream keywordize? true nil)] + (recur (.read stream) (conj! result element)))))))) + +(defn- read-json-object [#^PushbackReader stream keywordize?] + ;; Expects to be called with the head of the stream AFTER the + ;; opening bracket. + (loop [i (.read stream), key nil, result (transient {})] + (let [c (char i)] + (cond + (= i -1) (throw (EOFException. "JSON error (end-of-file inside object)")) + + (Character/isWhitespace c) (recur (.read stream) key result) + + (= c \,) (recur (.read stream) nil result) + + (= c \:) (recur (.read stream) key result) + + (= c \}) (if (nil? key) + (persistent! result) + (throw (Exception. "JSON error (key missing value in object)"))) + + :else (do (.unread stream i) + (let [element (read-json-reader stream keywordize? true nil)] + (if (nil? key) + (if (string? element) + (recur (.read stream) element result) + (throw (Exception. "JSON error (non-string key in object)"))) + (recur (.read stream) nil + (assoc! result (if keywordize? (keyword key) key) + element))))))))) + +(defn- read-json-hex-character [#^PushbackReader stream] + ;; Expects to be called with the head of the stream AFTER the + ;; initial "\u". Reads the next four characters from the stream. + (let [digits [(.read stream) + (.read stream) + (.read stream) + (.read stream)]] + (when (some neg? digits) + (throw (EOFException. "JSON error (end-of-file inside Unicode character escape)"))) + (let [chars (map char digits)] + (when-not (every? #{\0 \1 \2 \3 \4 \5 \6 \7 \8 \9 \a \b \c \d \e \f \A \B \C \D \E \F} + chars) + (throw (Exception. "JSON error (invalid hex character in Unicode character escape)"))) + (char (Integer/parseInt (apply str chars) 16))))) + +(defn- read-json-escaped-character [#^PushbackReader stream] + ;; Expects to be called with the head of the stream AFTER the + ;; initial backslash. + (let [c (char (.read stream))] + (cond + (#{\" \\ \/} c) c + (= c \b) \backspace + (= c \f) \formfeed + (= c \n) \newline + (= c \r) \return + (= c \t) \tab + (= c \u) (read-json-hex-character stream)))) + +(defn- read-json-quoted-string [#^PushbackReader stream] + ;; Expects to be called with the head of the stream AFTER the + ;; opening quotation mark. + (let [buffer (StringBuilder.)] + (loop [i (.read stream)] + (let [c (char i)] + (cond + (= i -1) (throw (EOFException. "JSON error (end-of-file inside string)")) + (= c \") (str buffer) + (= c \\) (do (.append buffer (read-json-escaped-character stream)) + (recur (.read stream))) + :else (do (.append buffer c) + (recur (.read stream)))))))) + +(defn read-json-reader + ([#^PushbackReader stream keywordize? eof-error? eof-value] + (loop [i (.read stream)] + (let [c (char i)] + (cond + ;; Handle end-of-stream + (= i -1) (if eof-error? + (throw (EOFException. "JSON error (end-of-file)")) + eof-value) + + ;; Ignore whitespace + (Character/isWhitespace c) (recur (.read stream)) + + ;; Read numbers, true, and false with Clojure reader + (#{\- \0 \1 \2 \3 \4 \5 \6 \7 \8 \9} c) + (do (.unread stream i) + (read stream true nil)) + + ;; Read strings + (= c \") (read-json-quoted-string stream) + + ;; Read null as nil + (= c \n) (let [ull [(char (.read stream)) + (char (.read stream)) + (char (.read stream))]] + (if (= ull [\u \l \l]) + nil + (throw (Exception. (str "JSON error (expected null): " c ull))))) + + ;; Read true + (= c \t) (let [rue [(char (.read stream)) + (char (.read stream)) + (char (.read stream))]] + (if (= rue [\r \u \e]) + true + (throw (Exception. (str "JSON error (expected true): " c rue))))) + + ;; Read false + (= c \f) (let [alse [(char (.read stream)) + (char (.read stream)) + (char (.read stream)) + (char (.read stream))]] + (if (= alse [\a \l \s \e]) + false + (throw (Exception. (str "JSON error (expected false): " c alse))))) + + ;; Read JSON objects + (= c \{) (read-json-object stream keywordize?) + + ;; Read JSON arrays + (= c \[) (read-json-array stream keywordize?) + + :else (throw (Exception. (str "JSON error (unexpected character): " c)))))))) + +(defprotocol Read-JSON-From + (read-json-from [input keywordize? eof-error? eof-value] + "Reads one JSON value from input String or Reader. + If keywordize? is true, object keys will be converted to keywords. + If eof-error? is true, empty input will throw an EOFException; if + false EOF will return eof-value. ")) + +(extend-protocol + Read-JSON-From + String + (read-json-from [input keywordize? eof-error? eof-value] + (read-json-reader (PushbackReader. (StringReader. input)) + keywordize? eof-error? eof-value)) + PushbackReader + (read-json-from [input keywordize? eof-error? eof-value] + (read-json-reader (PushbackReader. (StringReader. input)) + keywordize? eof-error? eof-value)) + Reader + (read-json-from [input keywordize? eof-error? eof-value] + (read-json-reader (PushbackReader. input) + keywordize? eof-error? eof-value))) + +(defn read-json + "Reads one JSON value from input String or Reader. + If keywordize? is true (default), object keys will be converted to + keywords. If eof-error? is true (default), empty input will throw + an EOFException; if false EOF will return eof-value. " + ([input] + (read-json-from input true true nil)) + ([input keywordize?] + (read-json-from input keywordize? true nil)) + ([input keywordize? eof-error? eof-value] + (read-json-from input keywordize? eof-error? eof-value))) + +(defprotocol Print-JSON + (print-json [object] + "Print object to *out* as JSON")) + +(extend-protocol + Print-JSON + + nil + (print-json [x] (print "null")) + + clojure.lang.Named + (print-json [x] (print-json (name x))) + + java.lang.Boolean + (print-json [x] (pr x)) + + java.lang.Number + (print-json [x] (pr x)) + + java.math.BigInteger + (print-json [x] (print (str x))) + + java.math.BigDecimal + (print-json [x] (print (str x))) + + java.lang.CharSequence + (print-json [s] + (let [sb (StringBuilder. (count s))] + (.append sb \") + (dotimes [i (count s)] + (let [cp (Character/codePointAt s i)] + (cond + ;; Handle printable JSON escapes before ASCII + (= cp 34) (.append sb "\\\"") + (= cp 92) (.append sb "\\\\") + (= cp 47) (.append sb "\\/") + ;; Print simple ASCII characters + (< 31 cp 127) (.append sb (.charAt s i)) + ;; Handle non-printable JSON escapes + (= cp 8) (.append sb "\\b") + (= cp 12) (.append sb "\\f") + (= cp 10) (.append sb "\\n") + (= cp 13) (.append sb "\\r") + (= cp 9) (.append sb "\\t") + ;; Any other character is Hexadecimal-escaped + :else (.append sb (format "\\u%04x" cp))))) + (.append sb \") + (print (str sb)))) + + java.util.Map + (print-json [m] + (print \{) + (loop [x m] + (when (seq m) + (let [[k v] (first x)] + (when (nil? k) + (throw (Exception. "JSON object keys cannot be nil/null"))) + (print-json (j/as-str k)) + (print \:) + (print-json v)) + (let [nxt (next x)] + (when (seq nxt) + (print \,) + (recur nxt))))) + (print \})) + + java.util.Collection + (print-json [s] + (print \[) + (loop [x s] + (when (seq x) + (let [fst (first x) + nxt (next x)] + (print-json fst) + (when (seq nxt) + (print \,) + (recur nxt))))) + (print \])) + + clojure.lang.ISeq + (print-json [s] + (print \[) + (loop [x s] + (when (seq x) + (let [fst (first x) + nxt (next x)] + (print-json fst) + (when (seq nxt) + (print \,) + (recur nxt))))) + (print \])) + + java.lang.Object + (print-json [x] + (if (.isArray (class x)) + (print-json (seq x)) + (throw (Exception. "Don't know how to print JSON of " (class x)))))) + +(defn json-str + "Converts x to a JSON-formatted string." + [x] + (with-out-str (print-json x))) + +(defn write-json + "Writes JSON-formatted text to out." + [x out] + (binding [*out* out] + (print-json x))) diff --git a/src/test/clojure/clojure/contrib/test_contrib/test_json.clj b/src/test/clojure/clojure/contrib/test_contrib/test_json.clj new file mode 100644 index 00000000..36237ad5 --- /dev/null +++ b/src/test/clojure/clojure/contrib/test_contrib/test_json.clj @@ -0,0 +1,172 @@ +(ns clojure.contrib.test-contrib.test-json + (:use clojure.test clojure.contrib.json)) + +(deftest can-read-numbers + (is (= 42 (read-json "42"))) + (is (= -3 (read-json "-3"))) + (is (= 3.14159 (read-json "3.14159"))) + (is (= 6.022e23 (read-json "6.022e23")))) + +(deftest can-read-null + (is (= nil (read-json "null")))) + +(deftest can-read-strings + (is (= "Hello, World!" (read-json "\"Hello, World!\"")))) + +(deftest handles-escaped-slashes-in-strings + (is (= "/foo/bar" (read-json "\"\\/foo\\/bar\"")))) + +(deftest handles-unicode-escapes + (is (= " \u0beb " (read-json "\" \\u0bEb \"")))) + +(deftest handles-escaped-whitespace + (is (= "foo\nbar" (read-json "\"foo\\nbar\""))) + (is (= "foo\rbar" (read-json "\"foo\\rbar\""))) + (is (= "foo\tbar" (read-json "\"foo\\tbar\"")))) + +(deftest can-read-booleans + (is (= true (read-json "true"))) + (is (= false (read-json "false")))) + +(deftest can-ignore-whitespace + (is (= nil (read-json "\r\n null")))) + +(deftest can-read-arrays + (is (= [1 2 3] (read-json "[1,2,3]"))) + (is (= ["Ole" "Lena"] (read-json "[\"Ole\", \r\n \"Lena\"]")))) + +(deftest can-read-objects + (is (= {:a 1, :b 2} (read-json "{\"a\": 1, \"b\": 2}")))) + +(deftest can-read-nested-structures + (is (= {:a [1 2 {:b [3 "four"]} 5.5]} + (read-json "{\"a\":[1,2,{\"b\":[3,\"four\"]},5.5]}")))) + +(deftest disallows-non-string-keys + (is (thrown? Exception (read-json "{26:\"z\"")))) + +(deftest disallows-barewords + (is (thrown? Exception (read-json " foo ")))) + +(deftest disallows-unclosed-arrays + (is (thrown? Exception (read-json "[1, 2, ")))) + +(deftest disallows-unclosed-objects + (is (thrown? Exception (read-json "{\"a\":1, ")))) + +(deftest can-get-string-keys + (is (= {"a" [1 2 {"b" [3 "four"]} 5.5]} + (read-json "{\"a\":[1,2,{\"b\":[3,\"four\"]},5.5]}" false true nil)))) + +(declare *pass1-string*) + +(deftest pass1-test + (let [input (read-json *pass1-string* false true nil)] + (is (= "JSON Test Pattern pass1" (first input))) + (is (= "array with 1 element" (get-in input [1 "object with 1 member" 0]))) + (is (= 1234567890 (get-in input [8 "integer"]))) + (is (= "rosebud" (last input))))) + +; from http://www.json.org/JSON_checker/test/pass1.json +(def *pass1-string* + "[ + \"JSON Test Pattern pass1\", + {\"object with 1 member\":[\"array with 1 element\"]}, + {}, + [], + -42, + true, + false, + null, + { + \"integer\": 1234567890, + \"real\": -9876.543210, + \"e\": 0.123456789e-12, + \"E\": 1.234567890E+34, + \"\": 23456789012E66, + \"zero\": 0, + \"one\": 1, + \"space\": \" \", + \"quote\": \"\\\"\", + \"backslash\": \"\\\\\", + \"controls\": \"\\b\\f\\n\\r\\t\", + \"slash\": \"/ & \\/\", + \"alpha\": \"abcdefghijklmnopqrstuvwyz\", + \"ALPHA\": \"ABCDEFGHIJKLMNOPQRSTUVWYZ\", + \"digit\": \"0123456789\", + \"0123456789\": \"digit\", + \"special\": \"`1~!@#$%^&*()_+-={':[,]}|;.</>?\", + \"hex\": \"\\u0123\\u4567\\u89AB\\uCDEF\\uabcd\\uef4A\", + \"true\": true, + \"false\": false, + \"null\": null, + \"array\":[ ], + \"object\":{ }, + \"address\": \"50 St. James Street\", + \"url\": \"http://www.JSON.org/\", + \"comment\": \"// /* <!-- --\", + \"# -- --> */\": \" \", + \" s p a c e d \" :[1,2 , 3 + +, + +4 , 5 , 6 ,7 ],\"compact\":[1,2,3,4,5,6,7], + \"jsontext\": \"{\\\"object with 1 member\\\":[\\\"array with 1 element\\\"]}\", + \"quotes\": \"" \\u0022 %22 0x22 034 "\", + \"\\/\\\\\\\"\\uCAFE\\uBABE\\uAB98\\uFCDE\\ubcda\\uef4A\\b\\f\\n\\r\\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?\" +: \"A key can be any string\" + }, + 0.5 ,98.6 +, +99.44 +, + +1066, +1e1, +0.1e1, +1e-1, +1e00,2e+00,2e-00 +,\"rosebud\"]") + + +(deftest can-print-json-strings + (is (= "\"Hello, World!\"" (json-str "Hello, World!"))) + (is (= "\"\\\"Embedded\\\" Quotes\"" (json-str "\"Embedded\" Quotes")))) + +(deftest can-print-unicode + (is (= "\"\\u1234\\u4567\"" (json-str "\u1234\u4567")))) + +(deftest can-print-json-null + (is (= "null" (json-str nil)))) + +(deftest can-print-json-arrays + (is (= "[1,2,3]" (json-str [1 2 3]))) + (is (= "[1,2,3]" (json-str (list 1 2 3)))) + (is (= "[1,2,3]" (json-str (sorted-set 1 2 3)))) + (is (= "[1,2,3]" (json-str (seq [1 2 3]))))) + +(deftest can-print-java-arrays + (is (= "[1,2,3]" (json-str (into-array [1 2 3]))))) + +(deftest can-print-empty-arrays + (is (= "[]" (json-str []))) + (is (= "[]" (json-str (list)))) + (is (= "[]" (json-str #{})))) + +(deftest can-print-json-objects + (is (= "{\"a\":1,\"b\":2}" (json-str (sorted-map :a 1 :b 2))))) + +(deftest object-keys-must-be-strings + (is (= "{\"1\":1,\"2\":2") (json-str (sorted-map 1 1 2 2)))) + +(deftest can-print-empty-objects + (is (= "{}" (json-str {})))) + +(deftest accept-sequence-of-nils + (is (= "[null,null,null]" (json-str [nil nil nil])))) + +(deftest error-on-nil-keys + (is (thrown? Exception (json-str {nil 1})))) + +(deftest characters-in-symbols-are-escaped + (is (= "\"foo\\u1b1b\"" (json-str (symbol "foo\u1b1b"))))) |