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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbc1f54c56cf5a1fd4c569660faf9f4115e5e7ed8442ac9e8e7105bc880a3912
4
- data.tar.gz: 5e68d7b1dafa55347cf5de1ee10e8ac39a97b645996fd0c58538799bbbb1191d
3
+ metadata.gz: 44970fff9a74d5d18ef8ce1f909afb35ed8db60e92e7fd6ddee0d399ed98f826
4
+ data.tar.gz: 6b02c6491a049ce306bc07de1b5bb06cf71ec14a579f2f6a659ffc634b1b2555
5
5
  SHA512:
6
- metadata.gz: 0e84fb4caf1fc9b192aa0e88f5111c3178557078abd8a31e7ce73373590e2487344c8df9ad61e718756e78a15239b460fd05b5f7a6fdbede35d8859f1873f5be
7
- data.tar.gz: 9d808b8b3e8465ce7b12ae861053e67a77ef1550df425c6383ffe4b799ba401a6f927f692b09709942fdb9e690a1c9d25a5f4453d57b9b532e18f979d171199d
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
- ![Gem Version](https://img.shields.io/gem/v/smarter_json) [![codecov](https://codecov.io/gh/tilo/smarter_json/branch/main/graph/badge.svg)](https://codecov.io/gh/tilo/smarter_json) [![Downloads](https://img.shields.io/gem/dt/smarter_json)](https://rubygems.org/gems/smarter_json) [![RubyGems](https://img.shields.io/badge/RubyGems-smarter__json-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/smarter_json) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-smarter__json-brightgreen)](https://www.ruby-toolbox.com/projects/smarter_json)
3
+ ![Gem Version](https://img.shields.io/gem/v/smarter_json) [![codecov](https://codecov.io/gh/tilo/smarter_json/branch/main/graph/badge.svg)](https://codecov.io/gh/tilo/smarter_json) <!-- [![Downloads](https://img.shields.io/gem/dt/smarter_json)](https://rubygems.org/gems/smarter_json) --> [![RubyGems](https://img.shields.io/badge/RubyGems-smarter__json-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/smarter_json) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-smarter__json-brightgreen)](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
 
@@ -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::Error — unsupported type
57
- SmarterJSON.generate(Float::INFINITY) # raises SmarterJSON::Error — non-finite Float
58
- SmarterJSON.generate(Float::NAN) # raises SmarterJSON::Error — non-finite Float
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
- This option is passed to [`SmarterJSON.generate`](./basic_write_api.md) as the second argument.
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` | `: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. |
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]) # => "[1,2,3]" (default :json — a single JSON array)
52
- SmarterJSON.generate([1, 2, 3], format: :ndjson) # => "1\n2\n3\n" (one element per line)
53
- SmarterJSON.generate({}, format: :bogus) # raises ArgumentError
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::Error. Returns a String.
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
- def emit_array(arr, buf)
78
- buf << "["
79
- arr.each_with_index do |v, i|
80
- buf << "," unless i.zero?
81
- emit(v, buf)
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
- first = true
89
- hash.each do |k, v|
90
- buf << "," unless first
91
- first = false
92
- emit_string(k.is_a?(String) ? k : k.to_s, buf) # Symbol/other keys -> string
93
- buf << ":"
94
- emit(v, buf)
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
- buf << str.gsub(ESCAPE_RE) { |c| ESCAPE[c] || format("\\u%04x", c.ord) }
102
- buf << '"'
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmarterJSON
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smarter_json
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tilo Sloboda