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.
@@ -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.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
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
- def emit(obj, buf, level = 0)
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 then emit_array(obj, buf, level)
91
- when Hash then emit_hash(obj, buf, level)
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 emit_coerced(obj, buf, level) if @coerce
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 — 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)
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
- emit(obj.as_json, buf, level)
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
- 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 << "]"
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 = " " * (@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)) << "}"
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
- 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 << "}"
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
- raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize non-finite Float #{flt}" unless flt.finite?
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
- raise SmarterJSON::GenerateError, "SmarterJSON.generate cannot serialize non-finite BigDecimal" unless num.finite?
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