smarter_json 0.9.2 → 1.0.0
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/.gitignore +2 -0
- data/CHANGELOG.md +89 -55
- data/README.md +216 -73
- data/docs/_introduction.md +6 -12
- data/docs/basic_read_api.md +29 -19
- data/docs/basic_write_api.md +3 -3
- data/docs/examples.md +32 -23
- data/docs/options.md +20 -19
- data/ext/smarter_json/smarter_json.c +246 -92
- data/ext/smarter_json/vendor/LICENSE-fast_float-MIT +27 -0
- data/ext/smarter_json/vendor/eisel_lemire.h +117 -0
- data/ext/smarter_json/vendor/eisel_lemire.md +29 -0
- data/ext/smarter_json/vendor/eisel_lemire_powers.h +663 -0
- data/lib/smarter_json/backports.rb +28 -0
- data/lib/smarter_json/generator.rb +100 -65
- data/lib/smarter_json/options.rb +65 -0
- data/lib/smarter_json/parser.rb +441 -141
- data/lib/smarter_json/version.rb +1 -1
- data/lib/smarter_json.rb +3 -1
- metadata +21 -11
- data/ext/smarter_json/vendor/ryu.h +0 -819
- data/ext/smarter_json/vendor/ryu.md +0 -22
|
@@ -34,7 +34,17 @@ module SmarterJSON
|
|
|
34
34
|
# (including multi-byte UTF-8) is emitted raw — valid JSON.
|
|
35
35
|
ESCAPE_RE = /["\\\x00-\x1f]/.freeze
|
|
36
36
|
|
|
37
|
+
# Strict configuration: an unknown writer option is a caller bug, so it raises
|
|
38
|
+
# rather than being silently ignored.
|
|
39
|
+
KNOWN_OPTIONS = %i[format indent ascii_only script_safe sort_keys coerce allow_nan].freeze
|
|
40
|
+
|
|
37
41
|
def initialize(options = {})
|
|
42
|
+
unknown = options.keys - KNOWN_OPTIONS
|
|
43
|
+
unless unknown.empty?
|
|
44
|
+
raise ArgumentError, "SmarterJSON.generate: unknown option#{unknown.size == 1 ? '' : 's'} " \
|
|
45
|
+
"#{unknown.map(&:inspect).join(', ')} — valid keys: #{KNOWN_OPTIONS.map(&:inspect).join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
38
48
|
@format = options.fetch(:format, :json)
|
|
39
49
|
unless %i[json ndjson].include?(@format)
|
|
40
50
|
raise ArgumentError, "unknown writer format: #{@format.inspect} (expected :json or :ndjson)"
|
|
@@ -50,10 +60,11 @@ module SmarterJSON
|
|
|
50
60
|
|
|
51
61
|
@pretty = @indent > 0
|
|
52
62
|
|
|
53
|
-
@ascii_only = options
|
|
54
|
-
@script_safe = options
|
|
55
|
-
@sort_keys = options
|
|
56
|
-
@coerce = options
|
|
63
|
+
@ascii_only = boolean_option(options, :ascii_only) # escape non-ASCII as \uXXXX
|
|
64
|
+
@script_safe = boolean_option(options, :script_safe) # escape </ and U+2028 / U+2029
|
|
65
|
+
@sort_keys = boolean_option(options, :sort_keys) # emit object keys in sorted order
|
|
66
|
+
@coerce = boolean_option(options, :coerce) # convert unknown types via as_json / to_json
|
|
67
|
+
@allow_nan = boolean_option(options, :allow_nan) # emit NaN / Infinity / -Infinity (JSON5) instead of raising
|
|
57
68
|
@escape_re = build_escape_re
|
|
58
69
|
end
|
|
59
70
|
|
|
@@ -77,7 +88,48 @@ module SmarterJSON
|
|
|
77
88
|
|
|
78
89
|
private
|
|
79
90
|
|
|
80
|
-
|
|
91
|
+
# A boolean writer option must be exactly true or false — a wrong type is a
|
|
92
|
+
# caller bug, so it raises rather than being coerced or ignored.
|
|
93
|
+
def boolean_option(options, key)
|
|
94
|
+
value = options.fetch(key, false)
|
|
95
|
+
return value if value == true || value == false
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "#{key} must be true or false (got #{value.inspect})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Iterative serializer — an explicit frame stack (one frame per open container),
|
|
101
|
+
# mirroring the recursive structure but heap-allocated, so arbitrarily deep input
|
|
102
|
+
# cannot overflow the call stack (parity with the iterative parser). Output is
|
|
103
|
+
# byte-identical to the former recursive version. A frame is a small Array:
|
|
104
|
+
# [members, idx, is_hash, before_first, before_rest, colon, closer, level]
|
|
105
|
+
def emit(obj, buf)
|
|
106
|
+
stack = []
|
|
107
|
+
push_value(obj, 0, buf, stack)
|
|
108
|
+
until stack.empty?
|
|
109
|
+
frame = stack.last
|
|
110
|
+
members = frame[0]
|
|
111
|
+
i = frame[1]
|
|
112
|
+
if i == members.length
|
|
113
|
+
buf << frame[6] # closer
|
|
114
|
+
stack.pop
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
frame[1] = i + 1
|
|
118
|
+
buf << (i.zero? ? frame[3] : frame[4]) # opener-pad / separator-pad
|
|
119
|
+
if frame[2] # hash
|
|
120
|
+
k, v = members[i]
|
|
121
|
+
emit_string(k.is_a?(String) ? k : k.to_s, buf) # Symbol/other keys -> string
|
|
122
|
+
buf << frame[5] # colon
|
|
123
|
+
push_value(v, frame[7] + 1, buf, stack)
|
|
124
|
+
else
|
|
125
|
+
push_value(members[i], frame[7] + 1, buf, stack)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Emit one value at `level`: a scalar appends directly; a non-empty container writes
|
|
131
|
+
# its opener and pushes a frame for the driver above to walk (no recursion into it).
|
|
132
|
+
def push_value(obj, level, buf, stack)
|
|
81
133
|
case obj
|
|
82
134
|
when nil then buf << "null"
|
|
83
135
|
when true then buf << "true"
|
|
@@ -87,22 +139,30 @@ module SmarterJSON
|
|
|
87
139
|
when Integer then buf << obj.to_s
|
|
88
140
|
when Float then emit_float(obj, buf)
|
|
89
141
|
when BigDecimal then emit_bigdecimal(obj, buf)
|
|
90
|
-
when Array
|
|
91
|
-
|
|
142
|
+
when Array
|
|
143
|
+
return buf << "[]" if obj.empty? # empty stays inline, even in pretty mode
|
|
144
|
+
|
|
145
|
+
buf << (@pretty ? "[\n" : "[")
|
|
146
|
+
stack << container_frame(obj, false, level)
|
|
147
|
+
when Hash
|
|
148
|
+
return buf << "{}" if obj.empty? # empty stays inline, even in pretty mode
|
|
149
|
+
|
|
150
|
+
pairs = @sort_keys ? obj.sort_by { |k, _| k.is_a?(String) ? k : k.to_s } : obj.to_a
|
|
151
|
+
buf << (@pretty ? "{\n" : "{")
|
|
152
|
+
stack << container_frame(pairs, true, level)
|
|
92
153
|
else
|
|
93
|
-
return
|
|
154
|
+
return push_coerced(obj, level, buf, stack) if @coerce
|
|
94
155
|
|
|
95
156
|
raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize #{obj.class}"
|
|
96
157
|
end
|
|
97
158
|
end
|
|
98
159
|
|
|
99
|
-
# coerce: true —
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
def emit_coerced(obj, buf, level)
|
|
160
|
+
# coerce: true — prefer as_json (re-emitted through the normal pipeline, so the
|
|
161
|
+
# escaping/format options still apply); else to_json (spliced as-is, so ascii_only /
|
|
162
|
+
# script_safe do not reach inside it); else raise.
|
|
163
|
+
def push_coerced(obj, level, buf, stack)
|
|
104
164
|
if obj.respond_to?(:as_json)
|
|
105
|
-
|
|
165
|
+
push_value(obj.as_json, level, buf, stack)
|
|
106
166
|
elsif obj.respond_to?(:to_json)
|
|
107
167
|
buf << obj.to_json
|
|
108
168
|
else
|
|
@@ -110,57 +170,16 @@ module SmarterJSON
|
|
|
110
170
|
end
|
|
111
171
|
end
|
|
112
172
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 << "]"
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
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
|
-
|
|
173
|
+
# Build a frame for an open container at `level`, precomputing its punctuation/indent
|
|
174
|
+
# once (as the recursive version computed `pad` once per container).
|
|
175
|
+
def container_frame(members, is_hash, level)
|
|
176
|
+
close_glyph = is_hash ? "}" : "]"
|
|
140
177
|
if @pretty
|
|
141
|
-
pad
|
|
142
|
-
|
|
143
|
-
|
|
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)) << "}"
|
|
178
|
+
pad = " " * (@indent * (level + 1))
|
|
179
|
+
padl = " " * (@indent * level)
|
|
180
|
+
[members, 0, is_hash, pad, ",\n#{pad}", ": ", "\n#{padl}#{close_glyph}", level]
|
|
153
181
|
else
|
|
154
|
-
|
|
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 << "}"
|
|
182
|
+
[members, 0, is_hash, "", ",", ":", close_glyph, level]
|
|
164
183
|
end
|
|
165
184
|
end
|
|
166
185
|
|
|
@@ -195,15 +214,31 @@ module SmarterJSON
|
|
|
195
214
|
end
|
|
196
215
|
|
|
197
216
|
def emit_float(flt, buf)
|
|
198
|
-
|
|
217
|
+
unless flt.finite?
|
|
218
|
+
raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize non-finite Float #{flt}" unless @allow_nan
|
|
219
|
+
|
|
220
|
+
return buf << non_finite_literal(flt)
|
|
221
|
+
end
|
|
199
222
|
|
|
200
223
|
buf << flt.to_s # Ruby's Float#to_s is shortest round-trippable; e-notation is valid JSON
|
|
201
224
|
end
|
|
202
225
|
|
|
203
226
|
def emit_bigdecimal(num, buf)
|
|
204
|
-
|
|
227
|
+
unless num.finite?
|
|
228
|
+
raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize non-finite BigDecimal" unless @allow_nan
|
|
229
|
+
|
|
230
|
+
return buf << non_finite_literal(num)
|
|
231
|
+
end
|
|
205
232
|
|
|
206
233
|
buf << num.to_s("F") # plain decimal notation (BigDecimal's default "0.1e1" is not valid JSON)
|
|
207
234
|
end
|
|
235
|
+
|
|
236
|
+
# JSON5-style literals for non-finite numbers, emitted only when allow_nan: true.
|
|
237
|
+
# `infinite?` returns 1 / -1 / nil for both Float and BigDecimal.
|
|
238
|
+
def non_finite_literal(num)
|
|
239
|
+
return "NaN" if num.nan?
|
|
240
|
+
|
|
241
|
+
num.infinite? == 1 ? "Infinity" : "-Infinity"
|
|
242
|
+
end
|
|
208
243
|
end
|
|
209
244
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmarterJSON
|
|
4
|
+
# All reader settings live in one options hash (smarter_csv style). This module
|
|
5
|
+
# holds the defaults, merges the caller's overrides onto them, and validates the
|
|
6
|
+
# result — mirroring SmarterCSV::Reader::Options.
|
|
7
|
+
module Options
|
|
8
|
+
DEFAULT_OPTIONS = {
|
|
9
|
+
acceleration: true, # use the C extension when available; false forces pure Ruby
|
|
10
|
+
encoding: nil, # label the input's encoding (no transcoding); nil keeps the input's own (valid-UTF-8 ASCII-8BIT → UTF-8)
|
|
11
|
+
symbolize_keys: false, # Symbol keys instead of String
|
|
12
|
+
duplicate_key: :last_wins, # :last_wins | :first_wins (repeats are also reported via on_warning)
|
|
13
|
+
decimal_precision: :auto, # :auto | :float | :bigdecimal (Oj-compatible decimal handling)
|
|
14
|
+
on_warning: nil, # a callable invoked once per non-fatal lenient fix (a SmarterJSON::Warning)
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Merge the caller's overrides onto the defaults, validate, and return the hash.
|
|
20
|
+
def process_options(given_options = {})
|
|
21
|
+
options = DEFAULT_OPTIONS.merge(given_options || {})
|
|
22
|
+
validate_options!(options)
|
|
23
|
+
options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raise ArgumentError (consistent with the generator's option checks) listing
|
|
27
|
+
# every problem at once. Configuration is strict — unlike the lenient *data*
|
|
28
|
+
# handling, an unknown option key or a bad value raises, so a caller's typo or
|
|
29
|
+
# wrong type is caught immediately instead of silently having no effect.
|
|
30
|
+
def validate_options!(options)
|
|
31
|
+
errors = []
|
|
32
|
+
|
|
33
|
+
unknown = options.keys - DEFAULT_OPTIONS.keys
|
|
34
|
+
unless unknown.empty?
|
|
35
|
+
errors << "unknown option#{unknown.size == 1 ? '' : 's'} #{unknown.map(&:inspect).join(', ')} " \
|
|
36
|
+
"— valid keys: #{DEFAULT_OPTIONS.keys.map(&:inspect).join(', ')}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless %i[auto float bigdecimal].include?(options[:decimal_precision])
|
|
40
|
+
errors << "decimal_precision must be :auto, :float, or :bigdecimal (got #{options[:decimal_precision].inspect})"
|
|
41
|
+
end
|
|
42
|
+
unless %i[last_wins first_wins].include?(options[:duplicate_key])
|
|
43
|
+
errors << "duplicate_key must be :last_wins or :first_wins (got #{options[:duplicate_key].inspect})"
|
|
44
|
+
end
|
|
45
|
+
unless [true, false].include?(options[:acceleration])
|
|
46
|
+
errors << "acceleration must be true or false (got #{options[:acceleration].inspect})"
|
|
47
|
+
end
|
|
48
|
+
unless [true, false].include?(options[:symbolize_keys])
|
|
49
|
+
errors << "symbolize_keys must be true or false (got #{options[:symbolize_keys].inspect})"
|
|
50
|
+
end
|
|
51
|
+
on_warning = options[:on_warning]
|
|
52
|
+
unless on_warning.nil? || on_warning.respond_to?(:call)
|
|
53
|
+
errors << "on_warning must be nil or a callable (got #{on_warning.class})"
|
|
54
|
+
end
|
|
55
|
+
encoding = options[:encoding]
|
|
56
|
+
unless encoding.nil? || encoding.is_a?(String)
|
|
57
|
+
errors << "encoding must be nil or a String (got #{encoding.class})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "SmarterJSON: invalid options — #{errors.join('; ')}" if errors.any?
|
|
61
|
+
|
|
62
|
+
options
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|