smarter_json 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +1 -1
- data/docs/basic_write_api.md +57 -4
- data/docs/options.md +17 -6
- data/lib/smarter_json/generator.rb +115 -23
- data/lib/smarter_json/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44970fff9a74d5d18ef8ce1f909afb35ed8db60e92e7fd6ddee0d399ed98f826
|
|
4
|
+
data.tar.gz: 6b02c6491a049ce306bc07de1b5bb06cf71ec14a579f2f6a659ffc634b1b2555
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3040050ab8538786344d7cdafb7959b0b28e2c869b2c5c5609228226de4cc52f2a70a8460c97790f537c19eb44a6defb7774ca8945aacd26ae2040684f3587c6
|
|
7
|
+
data.tar.gz: 389be8c3a940d29cfef4bf14df69c4f917fcb6fc1ff37d4c3fc4c3ec716c3985363a408d17ebc303370a110f057e7fdd200f726a46d05966b83b9e5eb6ac446d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
|
|
2
2
|
# SmarterJSON Change Log
|
|
3
3
|
|
|
4
|
+
## 0.5.2 (2026-06-01)
|
|
5
|
+
- `generate` now supports pretty-printing via the `indent:` option (spaces per nesting level; default `0` = compact). Empty objects/arrays stay inline; `indent:` combined with `format: :ndjson` raises `ArgumentError`.
|
|
6
|
+
- `generate` adds `sort_keys:` (emit object keys in sorted order), `ascii_only:` (escape non-ASCII as `\uXXXX`, astral chars as surrogate pairs), and `script_safe:` (escape `</` and U+2028/U+2029 for safe embedding in an HTML `<script>` tag).
|
|
7
|
+
- `generate` adds opt-in `coerce:` — when `true`, a value that isn't natively supported (e.g. `Time`, `Date`, app objects) is converted via its own `as_json` (result re-emitted) or `to_json` (spliced); strict-by-default still raises `GenerateError`.
|
|
8
|
+
|
|
4
9
|
## 0.5.1 (2026-06-01)
|
|
5
10
|
- Unified the error classes under a single `SmarterJSON::Error` base: `ParseError` and `EncodingError` now inherit from it, and `generate` raises a new `GenerateError`. `rescue SmarterJSON::Error` now catches everything the gem raises.
|
|
6
11
|
- Added a CI test matrix (Ruby 2.6–4.0 + head, on Ubuntu and macOS).
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SmarterJSON
|
|
2
2
|
|
|
3
|
-
 [](https://codecov.io/gh/tilo/smarter_json) [](https://rubygems.org/gems/smarter_json) [](https://rubygems.org/gems/smarter_json) [](https://www.ruby-toolbox.com/projects/smarter_json)
|
|
3
|
+
 [](https://codecov.io/gh/tilo/smarter_json) <!-- [](https://rubygems.org/gems/smarter_json) --> [](https://rubygems.org/gems/smarter_json) [](https://www.ruby-toolbox.com/projects/smarter_json)
|
|
4
4
|
|
|
5
5
|
A lenient, fast JSON parser for Ruby. It parses strict JSON, JSON5, HJSON-style config, and the messy JSON-ish input humans actually write — and in benchmarks it matches or beats Oj on nearly every file. SmarterJSON is opinionated: we want your JSON processing to be successful. Other parsers are strict - they stop at the first deviation - SmarterJSON keeps going - it optimizes for getting your data out, not for policing the JSON spec.
|
|
6
6
|
|
data/docs/basic_write_api.md
CHANGED
|
@@ -53,12 +53,65 @@ Strings escape `"`, `\`, and the control characters `0x00–0x1F`; everything el
|
|
|
53
53
|
`generate` raises `SmarterJSON::Error` on input it cannot represent as strict JSON:
|
|
54
54
|
|
|
55
55
|
```ruby
|
|
56
|
-
SmarterJSON.generate(Time.now) # raises SmarterJSON::
|
|
57
|
-
SmarterJSON.generate(Float::INFINITY) # raises SmarterJSON::
|
|
58
|
-
SmarterJSON.generate(Float::NAN) # raises SmarterJSON::
|
|
56
|
+
SmarterJSON.generate(Time.now) # raises SmarterJSON::GenerateError — unsupported type
|
|
57
|
+
SmarterJSON.generate(Float::INFINITY) # raises SmarterJSON::GenerateError — non-finite Float
|
|
58
|
+
SmarterJSON.generate(Float::NAN) # raises SmarterJSON::GenerateError — non-finite Float
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
(`Infinity` and `NaN` are accepted on the *read* side as a leniency, but they are not valid JSON to *write*.)
|
|
61
|
+
(`GenerateError` is a kind of `SmarterJSON::Error`, so `rescue SmarterJSON::Error` catches it. `Infinity` and `NaN` are accepted on the *read* side as a leniency, but they are not valid JSON to *write*.)
|
|
62
|
+
|
|
63
|
+
By default `generate` is strict: it only writes the types above and raises on anything else. To serialize `Time`, `Date`, or your own objects, pass `coerce: true` — an unsupported value is then converted by its own `as_json` (whose result is re-emitted, so escaping/`indent`/`sort_keys` still apply) or, failing that, `to_json` (spliced verbatim):
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class Money
|
|
67
|
+
def as_json(*)
|
|
68
|
+
{ "cents" => @cents, "currency" => @currency }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
SmarterJSON.generate({ "price" => Money.new(500, "USD") }, coerce: true)
|
|
73
|
+
# => '{"price":{"cents":500,"currency":"USD"}}'
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Strict-by-default stays the default precisely so you opt in to delegating serialization rather than silently emitting an object's `to_s`. See [Configuration Options](./options.md).
|
|
77
|
+
|
|
78
|
+
## Pretty-printing
|
|
79
|
+
|
|
80
|
+
By default `generate` produces compact output (no spaces). Pass `indent:` (a number of spaces per nesting level) to pretty-print:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
SmarterJSON.generate({ "a" => 1, "b" => [2, 3] }, indent: 2)
|
|
84
|
+
# => "{\n \"a\": 1,\n \"b\": [\n 2,\n 3\n ]\n}"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
which prints as:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"a": 1,
|
|
92
|
+
"b": [
|
|
93
|
+
2,
|
|
94
|
+
3
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Empty objects and arrays stay inline (`{}` / `[]`) even when indenting. `indent: 0` (the default) is compact output. Pretty-printing is multi-line, so it can't be combined with `format: :ndjson` (where each record must be a single line) — doing so raises `ArgumentError`. See [Configuration Options](./options.md).
|
|
100
|
+
|
|
101
|
+
## Safe and canonical output
|
|
102
|
+
|
|
103
|
+
Three more options shape the output, and they compose with each other and with `indent:`:
|
|
104
|
+
|
|
105
|
+
- **`sort_keys: true`** — emit object keys in sorted order (Symbol keys sorted by their string form). Handy for canonical, diff-friendly JSON.
|
|
106
|
+
- **`ascii_only: true`** — escape every non-ASCII character as `\uXXXX` (characters above U+FFFF become a UTF-16 surrogate pair). The default emits raw UTF-8.
|
|
107
|
+
- **`script_safe: true`** — escape the `/` in `</` and the JavaScript line separators U+2028 / U+2029, so the output is safe to embed directly in an HTML `<script>` tag without breaking out of it.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
SmarterJSON.generate({ "b" => 2, "a" => 1 }, sort_keys: true) # => '{"a":1,"b":2}'
|
|
111
|
+
SmarterJSON.generate("</script>", script_safe: true) # => '"<\/script>"'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
See [Configuration Options](./options.md) for the full table.
|
|
62
115
|
|
|
63
116
|
## Writing NDJSON
|
|
64
117
|
|
data/docs/options.md
CHANGED
|
@@ -39,18 +39,29 @@ The default `:auto` preserves high-precision numbers as `BigDecimal`, matching O
|
|
|
39
39
|
|
|
40
40
|
## Writing
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
These options are passed to [`SmarterJSON.generate`](./basic_write_api.md) as the second argument.
|
|
43
43
|
|
|
44
44
|
| Option | Default | Explanation |
|
|
45
45
|
|------------|---------|-----------------------------------------------------------------------------------------------------------------------------|
|
|
46
|
-
| `:format`
|
|
46
|
+
| `:format` | `:json` | `:json` writes standard JSON (Hash → object, Array → array, scalar → scalar). `:ndjson` writes newline-delimited JSON: an Array becomes one element per line, any other value becomes a single line. |
|
|
47
|
+
| `:indent` | `0` | Spaces per nesting level for pretty-printing. `0` (the default) is compact output. Empty objects/arrays stay inline. Not allowed with `:ndjson` (a record must be a single line). |
|
|
48
|
+
| `:sort_keys` | `false` | Emit object keys in sorted order (Symbol keys sorted by their string form). Useful for canonical, diff-friendly output. |
|
|
49
|
+
| `:ascii_only` | `false` | Escape every non-ASCII character as `\uXXXX` (astral characters as a UTF-16 surrogate pair). The default emits raw UTF-8. |
|
|
50
|
+
| `:script_safe` | `false` | Escape the `/` in `</` and the JS line separators U+2028 / U+2029, so output is safe to embed in an HTML `<script>` tag. |
|
|
51
|
+
| `:coerce` | `false` | When `true`, a value that isn't natively supported is converted by its own `as_json` (the result is re-emitted, so the other options still apply) or, failing that, `to_json` (spliced verbatim). When `false` (the default), such a value raises `SmarterJSON::GenerateError`. |
|
|
47
52
|
|
|
48
|
-
Any other `:format` value raises `ArgumentError`.
|
|
53
|
+
Any other `:format` value, a negative/non-Integer `:indent`, or combining `:indent` with `:ndjson`, raises `ArgumentError`.
|
|
49
54
|
|
|
50
55
|
```ruby
|
|
51
|
-
SmarterJSON.generate([1, 2, 3])
|
|
52
|
-
SmarterJSON.generate([1, 2, 3], format: :ndjson)
|
|
53
|
-
SmarterJSON.generate({},
|
|
56
|
+
SmarterJSON.generate([1, 2, 3]) # => "[1,2,3]" (default :json — a single JSON array)
|
|
57
|
+
SmarterJSON.generate([1, 2, 3], format: :ndjson) # => "1\n2\n3\n" (one element per line)
|
|
58
|
+
SmarterJSON.generate({ "a" => 1 }, indent: 2) # => "{\n \"a\": 1\n}" (pretty-printed)
|
|
59
|
+
SmarterJSON.generate({ "b" => 2, "a" => 1 }, sort_keys: true) # => '{"a":1,"b":2}'
|
|
60
|
+
SmarterJSON.generate("café", ascii_only: true) # => '"caf\u00e9"'
|
|
61
|
+
SmarterJSON.generate("</script>", script_safe: true) # => '"<\/script>"'
|
|
62
|
+
SmarterJSON.generate(model, coerce: true) # => uses model.as_json (else model.to_json)
|
|
63
|
+
SmarterJSON.generate(model) # raises SmarterJSON::GenerateError (coerce off)
|
|
64
|
+
SmarterJSON.generate({}, format: :bogus) # raises ArgumentError
|
|
54
65
|
```
|
|
55
66
|
|
|
56
67
|
---------------
|
|
@@ -14,9 +14,13 @@ module SmarterJSON
|
|
|
14
14
|
# line; any other value writes as a single line. The
|
|
15
15
|
# inverse of process reading NDJSON back into an Array.
|
|
16
16
|
#
|
|
17
|
+
# options[:indent]: spaces per nesting level for pretty-printing (Integer, default
|
|
18
|
+
# 0 = compact). Empty objects/arrays stay inline. Not allowed with :ndjson (a
|
|
19
|
+
# record must be a single line) — combining them raises ArgumentError.
|
|
20
|
+
#
|
|
17
21
|
# Symbol keys/values are emitted as strings; BigDecimal as a JSON number.
|
|
18
22
|
# Unsupported types (Time, custom objects) and non-finite Floats raise
|
|
19
|
-
# SmarterJSON::
|
|
23
|
+
# SmarterJSON::GenerateError. Returns a String.
|
|
20
24
|
def generate(obj, options = {})
|
|
21
25
|
Generator.new(options).generate(obj)
|
|
22
26
|
end
|
|
@@ -35,6 +39,22 @@ module SmarterJSON
|
|
|
35
39
|
unless %i[json ndjson].include?(@format)
|
|
36
40
|
raise ArgumentError, "unknown writer format: #{@format.inspect} (expected :json or :ndjson)"
|
|
37
41
|
end
|
|
42
|
+
|
|
43
|
+
@indent = options.fetch(:indent, 0) # spaces per nesting level; 0 = compact (default)
|
|
44
|
+
unless @indent.is_a?(Integer) && @indent >= 0
|
|
45
|
+
raise ArgumentError, "indent must be a non-negative Integer, got #{@indent.inspect}"
|
|
46
|
+
end
|
|
47
|
+
if @indent > 0 && @format == :ndjson
|
|
48
|
+
raise ArgumentError, "indent is not compatible with format: :ndjson (each record must be a single line)"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@pretty = @indent > 0
|
|
52
|
+
|
|
53
|
+
@ascii_only = options.fetch(:ascii_only, false) # escape non-ASCII as \uXXXX
|
|
54
|
+
@script_safe = options.fetch(:script_safe, false) # escape </ and U+2028 / U+2029
|
|
55
|
+
@sort_keys = options.fetch(:sort_keys, false) # emit object keys in sorted order
|
|
56
|
+
@coerce = options.fetch(:coerce, false) # convert unknown types via as_json / to_json
|
|
57
|
+
@escape_re = build_escape_re
|
|
38
58
|
end
|
|
39
59
|
|
|
40
60
|
def generate(obj)
|
|
@@ -57,7 +77,7 @@ module SmarterJSON
|
|
|
57
77
|
|
|
58
78
|
private
|
|
59
79
|
|
|
60
|
-
def emit(obj, buf)
|
|
80
|
+
def emit(obj, buf, level = 0)
|
|
61
81
|
case obj
|
|
62
82
|
when nil then buf << "null"
|
|
63
83
|
when true then buf << "true"
|
|
@@ -67,39 +87,111 @@ module SmarterJSON
|
|
|
67
87
|
when Integer then buf << obj.to_s
|
|
68
88
|
when Float then emit_float(obj, buf)
|
|
69
89
|
when BigDecimal then emit_bigdecimal(obj, buf)
|
|
70
|
-
when Array then emit_array(obj, buf)
|
|
71
|
-
when Hash then emit_hash(obj, buf)
|
|
90
|
+
when Array then emit_array(obj, buf, level)
|
|
91
|
+
when Hash then emit_hash(obj, buf, level)
|
|
72
92
|
else
|
|
93
|
+
return emit_coerced(obj, buf, level) if @coerce
|
|
94
|
+
|
|
73
95
|
raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize #{obj.class}"
|
|
74
96
|
end
|
|
75
97
|
end
|
|
76
98
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
# coerce: true — let a value that isn't natively supported convert itself.
|
|
100
|
+
# Prefer as_json (its result is re-emitted through the normal pipeline, so the
|
|
101
|
+
# escaping/format options still apply); fall back to to_json (spliced as-is, so
|
|
102
|
+
# ascii_only / script_safe do not reach inside it). Raise if it defines neither.
|
|
103
|
+
def emit_coerced(obj, buf, level)
|
|
104
|
+
if obj.respond_to?(:as_json)
|
|
105
|
+
emit(obj.as_json, buf, level)
|
|
106
|
+
elsif obj.respond_to?(:to_json)
|
|
107
|
+
buf << obj.to_json
|
|
108
|
+
else
|
|
109
|
+
raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize #{obj.class} (no as_json or to_json)"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def emit_array(arr, buf, level)
|
|
114
|
+
return buf << "[]" if arr.empty? # empty stays inline, even in pretty mode
|
|
115
|
+
|
|
116
|
+
if @pretty
|
|
117
|
+
pad = " " * (@indent * (level + 1))
|
|
118
|
+
buf << "[\n"
|
|
119
|
+
arr.each_with_index do |v, i|
|
|
120
|
+
buf << ",\n" unless i.zero?
|
|
121
|
+
buf << pad
|
|
122
|
+
emit(v, buf, level + 1)
|
|
123
|
+
end
|
|
124
|
+
buf << "\n" << (" " * (@indent * level)) << "]"
|
|
125
|
+
else
|
|
126
|
+
buf << "["
|
|
127
|
+
arr.each_with_index do |v, i|
|
|
128
|
+
buf << "," unless i.zero?
|
|
129
|
+
emit(v, buf, level)
|
|
130
|
+
end
|
|
131
|
+
buf << "]"
|
|
82
132
|
end
|
|
83
|
-
buf << "]"
|
|
84
133
|
end
|
|
85
134
|
|
|
86
|
-
def emit_hash(hash, buf)
|
|
87
|
-
buf << "{"
|
|
88
|
-
|
|
89
|
-
hash.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
buf << "
|
|
94
|
-
|
|
135
|
+
def emit_hash(hash, buf, level)
|
|
136
|
+
return buf << "{}" if hash.empty? # empty stays inline, even in pretty mode
|
|
137
|
+
|
|
138
|
+
pairs = @sort_keys ? hash.sort_by { |k, _| k.is_a?(String) ? k : k.to_s } : hash
|
|
139
|
+
|
|
140
|
+
if @pretty
|
|
141
|
+
pad = " " * (@indent * (level + 1))
|
|
142
|
+
buf << "{\n"
|
|
143
|
+
first = true
|
|
144
|
+
pairs.each do |k, v|
|
|
145
|
+
buf << ",\n" unless first
|
|
146
|
+
first = false
|
|
147
|
+
buf << pad
|
|
148
|
+
emit_string(k.is_a?(String) ? k : k.to_s, buf) # Symbol/other keys -> string
|
|
149
|
+
buf << ": "
|
|
150
|
+
emit(v, buf, level + 1)
|
|
151
|
+
end
|
|
152
|
+
buf << "\n" << (" " * (@indent * level)) << "}"
|
|
153
|
+
else
|
|
154
|
+
buf << "{"
|
|
155
|
+
first = true
|
|
156
|
+
pairs.each do |k, v|
|
|
157
|
+
buf << "," unless first
|
|
158
|
+
first = false
|
|
159
|
+
emit_string(k.is_a?(String) ? k : k.to_s, buf) # Symbol/other keys -> string
|
|
160
|
+
buf << ":"
|
|
161
|
+
emit(v, buf, level)
|
|
162
|
+
end
|
|
163
|
+
buf << "}"
|
|
95
164
|
end
|
|
96
|
-
buf << "}"
|
|
97
165
|
end
|
|
98
166
|
|
|
99
167
|
def emit_string(str, buf)
|
|
100
|
-
buf << '"'
|
|
101
|
-
|
|
102
|
-
|
|
168
|
+
buf << '"' << str.gsub(@escape_re) { |m| escape_match(m) } << '"'
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Per-instance escape regex from the active options. Always: ", backslash, C0
|
|
172
|
+
# controls. script_safe adds the slash in </ and the JS line separators
|
|
173
|
+
# U+2028/U+2029. ascii_only adds every non-ASCII char.
|
|
174
|
+
def build_escape_re
|
|
175
|
+
res = [ESCAPE_RE]
|
|
176
|
+
res.unshift(%r{</}) if @script_safe
|
|
177
|
+
res << Regexp.new("[#{[0x2028, 0x2029].pack('U*')}]") if @script_safe
|
|
178
|
+
res << /[^\x00-\x7f]/ if @ascii_only
|
|
179
|
+
Regexp.union(*res)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def escape_match(m)
|
|
183
|
+
return "<\\/" if m == "</" # <\/ — stops </ from closing a <script> tag
|
|
184
|
+
|
|
185
|
+
ESCAPE[m] || unicode_escape(m)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# \uXXXX for a BMP char; a UTF-16 surrogate pair for astral (> U+FFFF) chars.
|
|
189
|
+
def unicode_escape(char)
|
|
190
|
+
cp = char.ord
|
|
191
|
+
return format("\\u%04x", cp) if cp <= 0xffff
|
|
192
|
+
|
|
193
|
+
cp -= 0x10000
|
|
194
|
+
format("\\u%04x\\u%04x", 0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff))
|
|
103
195
|
end
|
|
104
196
|
|
|
105
197
|
def emit_float(flt, buf)
|
data/lib/smarter_json/version.rb
CHANGED