smarter_json 0.6.0 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65b2a8f15607b861034e600fd42233328803ebbcb652e8cb8b162811e24779e2
4
- data.tar.gz: 191467cd1d29030d054c1cdaabb444523af54fb71486721182ddcdf9a4781d92
3
+ metadata.gz: 00a4ada7970ab645761573bd3b9704effda072064102ead760124716f0896289
4
+ data.tar.gz: a7232f3d8d06d3b7d770645b28a247774c83b8ac50603e4b7611006e852480d4
5
5
  SHA512:
6
- metadata.gz: c26d4ffb0789fbe7303b80d80877f02164f79f7fe2ae9eff0e17de90df4ba8da19568c7a1fa5d10d5d324c8f4a841cd8ad0917f5ab6a5f381368404f56a1ec5c
7
- data.tar.gz: 6c3fbbf21ec9f9c6300516268ad4e7741aeaab8a6732ab138497f6af5b02afbfd944fdce6d4c7095f699469b77fd2fa2cf5e65ea58712ad55da0e5c745fd6551
6
+ metadata.gz: a4b4b8737f10ebf09408014419a9d5cf2203b706766ea5acc358ad595c5cc34104b3f658f2ca6cf6b130124c514d89d035dea2a0ea16e619ffaec7da16da4a23
7
+ data.tar.gz: a317f74e3399d6b95327fb10a7b65d2ca0f07062d9097303ab32aefb6721a46a78784addd0a937c0c8f433c4acb4e7758af3986fa741bd0d5b816141ee101fd4
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
 
2
2
  # SmarterJSON Change Log
3
3
 
4
+ > 🚧 Getting ready for the 1.0.0 release - sorry for the interface changes - thank you for your patience! 🚧
5
+
6
+ ## 0.7.0 (2026-06-02)
7
+ - **Breaking:** replaced the `warnings:` option (and its `[result, warnings]` tuple return) with an `on_warning:` callable. Pass `on_warning: ->(w) { ... }` to be handed each `SmarterJSON::Warning` as the parser applies a lenient fix; `process` / `process_file` now always return the bare value (nil / value / Array) on every path. Unlike the tuple, this also fires on the streaming block form. The default (no handler) records nothing and costs nothing.
8
+
4
9
  ## 0.6.0 (2026-06-02)
5
10
  - Lenient comma handling: empty slots around / between commas are collapsed (`[1,,2]` → `[1,2]`, `[,1,]` → `[1]`, `{a:1,,b:2}` → `{a:1,b:2}`), on both the C and Ruby paths. No null is inserted for an empty slot.
6
11
  - A key with a colon but no value reads as null: `{a:}` → `{"a"=>nil}` (both paths).
@@ -9,12 +14,12 @@
9
14
  - Fixed a pure-Ruby bug where a `\u` escape whose next bytes split a multibyte character leaked `ArgumentError`; it now raises `SmarterJSON::ParseError`.
10
15
  - Added a property/fuzz test suite that checks C/Ruby parity and round-tripping on generated, mutated, and random input.
11
16
 
12
- ## 0.5.2 (2026-06-01)
17
+ ## 0.5.2 (2026-06-01) yanked
13
18
  - `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`.
14
19
  - `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).
15
20
  - `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`.
16
21
 
17
- ## 0.5.1 (2026-06-01)
22
+ ## 0.5.1 (2026-06-01) yanked
18
23
  - 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.
19
24
  - Added a CI test matrix (Ruby 2.6–4.0 + head, on Ubuntu and macOS).
20
25
  - Fixed the C extension build on Ruby 2.6 (declare `rb_hash_bulk_insert`, which 2.6 exports but does not declare in its headers); set the minimum Ruby to 2.6.
data/README.md CHANGED
@@ -81,7 +81,20 @@ SmarterJSON.process_file("events.ndjson") { |event| EventJob.perform_async(event
81
81
  | `bigdecimal_load` | `:auto` | `:auto` keeps high-precision decimals as `BigDecimal`; `:float` forces `Float`; `:bigdecimal` forces `BigDecimal` |
82
82
  | `acceleration` | `true` | `true` uses the C extension when compiled and loadable; `false` forces pure Ruby (identical results) |
83
83
  | `encoding` | `"UTF-8"` | labels the input's encoding (no transcoding pass; see below) |
84
- | `warnings` | `false` | when `true`, return `[result, warnings]` — `warnings` lists the lenient fixes applied (`:empty_slot`, `:empty_value`, `:duplicate_key`) |
84
+ | `on_warning` | `nil` | a callable invoked once per lenient fix applied (`:empty_slot`, `:empty_value`, `:duplicate_key`), passed a `SmarterJSON::Warning`; the return value is never changed. See below. |
85
+
86
+ ### Warnings (`on_warning`)
87
+
88
+ When the parser quietly fixes something lenient — collapses an empty comma slot, reads a key with no value as `null`, drops a duplicate key — it can tell you, without changing what `process` returns. Pass a callable as `on_warning:`; it is invoked once per fix with a `SmarterJSON::Warning` (`type`, `message`, `line`, `col`). It fires on every path, including the streaming block form. With no handler (the default) nothing is recorded and there is zero overhead.
89
+
90
+ ```ruby
91
+ # Collect them all:
92
+ warns = []
93
+ data = SmarterJSON.process(input, on_warning: ->(w) { warns << w })
94
+
95
+ # Or route them — log, count, raise:
96
+ SmarterJSON.process(input, on_warning: ->(w) { Rails.logger.warn(w) })
97
+ ```
85
98
 
86
99
  ## Performance
87
100
 
@@ -67,18 +67,19 @@ The streaming path reads the input as newline-delimited documents (NDJSON / JSON
67
67
 
68
68
  By default (`acceleration: true`) the C extension is used when it is compiled and loadable (`SmarterJSON::HAS_ACCELERATION` is then `true`); otherwise the pure-Ruby parser runs and produces identical results. Pass `acceleration: false` to force the pure-Ruby path. See [Configuration Options](./options.md).
69
69
 
70
- ## Seeing what was fixed: `warnings:`
70
+ ## Seeing what was fixed: `on_warning:`
71
71
 
72
- `process` and `process_file` are lenient — they salvage your data rather than reject a whole document over a stray comma. Pass `warnings: true` to also get back a record of what was adjusted, so the leniency is transparent instead of silent. The call then returns `[result, warnings]`:
72
+ `process` and `process_file` are lenient — they salvage your data rather than reject a whole document over a stray comma. Pass an `on_warning:` callable to also get a record of what was adjusted, so the leniency is transparent instead of silent. It is invoked once per fix and never changes the return value:
73
73
 
74
74
  ```ruby
75
- result, warnings = SmarterJSON.process("[1,,2]", warnings: true)
76
- result # => [1, 2]
77
- warnings.map(&:type) # => [:empty_slot]
78
- warnings.first.to_s # => "extra comma, collapsed an empty slot at line 1, col 4"
75
+ warns = []
76
+ result = SmarterJSON.process("[1,,2]", on_warning: ->(w) { warns << w })
77
+ result # => [1, 2]
78
+ warns.map(&:type) # => [:empty_slot]
79
+ warns.first.to_s # => "extra comma, collapsed an empty slot at line 1, col 4"
79
80
  ```
80
81
 
81
- Each warning is a `SmarterJSON::Warning` with `type`, `message`, `line`, and `col`. The types are `:empty_slot` (a collapsed empty comma slot), `:empty_value` (a key with no value, read as `null`), and `:duplicate_key` (a repeated key that was dropped). Clean input gives an empty `warnings` array. It works the same on the C and pure-Ruby paths. See [Configuration Options](./options.md).
82
+ Each warning is a `SmarterJSON::Warning` with `type`, `message`, `line`, and `col`. The types are `:empty_slot` (a collapsed empty comma slot), `:empty_value` (a key with no value, read as `null`), and `:duplicate_key` (a repeated key that was dropped). Clean input never invokes the handler. It fires on every path — including the streaming block form — and works the same on the C and pure-Ruby paths. See [Configuration Options](./options.md).
82
83
 
83
84
  ---------------
84
85
 
data/docs/options.md CHANGED
@@ -22,27 +22,28 @@ These options are passed to [`SmarterJSON.process`](./basic_read_api.md) and `Sm
22
22
  | `:bigdecimal_load`| `:auto` | `:auto` keeps high-precision decimals as `BigDecimal` (matches Oj); `:float` forces every number to `Float`; `:bigdecimal` forces every decimal to `BigDecimal`. |
23
23
  | `:acceleration` | `true` | Use the C extension when it is compiled and loadable; `false` forces the pure-Ruby parser. Both produce identical results. |
24
24
  | `:encoding` | `nil` | Labels the input's encoding (e.g. `"UTF-8"`). It does **not** trigger a transcoding pass — see below. |
25
- | `:warnings` | `false` | When `true`, return `[result, warnings]` instead of just `result` `warnings` lists the lenient fixes that were applied. See below. |
25
+ | `:on_warning` | `nil` | A callable invoked once per lenient fix applied, passed a `SmarterJSON::Warning`. Never changes the return value. See below. |
26
26
 
27
27
  ```ruby
28
28
  SmarterJSON.process('{"a": 1}', symbolize_keys: true) # => {:a=>1}
29
29
  SmarterJSON.process('{"a":1,"a":2}', duplicate_key: :raise) # raises SmarterJSON::ParseError
30
30
  SmarterJSON.process(big_decimal_json, bigdecimal_load: :float) # every number as Float (fastest)
31
- SmarterJSON.process("[1,,2]", warnings: true) # => [[1, 2], [#<SmarterJSON::Warning ...>]]
31
+ SmarterJSON.process("[1,,2]", on_warning: ->(w) { puts w }) # => [1, 2], and prints the warning
32
32
  ```
33
33
 
34
- ### A note on `:warnings`
34
+ ### A note on `:on_warning`
35
35
 
36
- `smarter_json` is lenient by design — it salvages your data instead of rejecting the whole document over a stray comma. `warnings: true` keeps that, but also hands back a record of what it had to fix, so leniency is transparent rather than silent. The call then returns a two-element `[result, warnings]`; `warnings` is an Array of `SmarterJSON::Warning`, each with `type` (a Symbol), `message`, `line`, and `col`:
36
+ `smarter_json` is lenient by design — it salvages your data instead of rejecting the whole document over a stray comma. `on_warning:` keeps that, but also hands you a record of what it had to fix, so leniency is transparent rather than silent. It takes a callable that the parser invokes once per fix, passing a `SmarterJSON::Warning` (with `type` (a Symbol), `message`, `line`, and `col`). It never changes the return value — `process` still hands back the bare value — and it fires on every path, including the streaming block form. With no handler (the default), nothing is recorded and there is zero overhead.
37
37
 
38
38
  ```ruby
39
- result, warnings = SmarterJSON.process("[1,,2]", warnings: true)
39
+ warns = []
40
+ result = SmarterJSON.process("[1,,2]", on_warning: ->(w) { warns << w })
40
41
  result # => [1, 2]
41
- warnings.map(&:type) # => [:empty_slot]
42
- warnings.first.to_s # => "extra comma, collapsed an empty slot at line 1, col 4"
42
+ warns.map(&:type) # => [:empty_slot]
43
+ warns.first.to_s # => "extra comma, collapsed an empty slot at line 1, col 4"
43
44
  ```
44
45
 
45
- The warning types are `:empty_slot` (a collapsed empty comma slot, e.g. `[1,,2]`), `:empty_value` (a key with no value, read as `null`, e.g. `{a:}`), and `:duplicate_key` (a repeated key that was dropped). Clean input returns an empty `warnings` array. Warnings work on both the C and pure-Ruby paths, so `acceleration:` doesn't change them.
46
+ The warning types are `:empty_slot` (a collapsed empty comma slot, e.g. `[1,,2]`), `:empty_value` (a key with no value, read as `null`, e.g. `{a:}`), and `:duplicate_key` (a repeated key that was dropped). Clean input never invokes the handler. Warnings work on both the C and pure-Ruby paths, so `acceleration:` doesn't change them.
46
47
 
47
48
  ### A note on `:encoding`
48
49
 
@@ -34,6 +34,7 @@ static VALUE cParseError;
34
34
  static VALUE cEncodingError;
35
35
  static VALUE cWarning;
36
36
  static ID fj_new_id;
37
+ static ID fj_call_id; /* cached :call (invoking the on_warning handler) */
37
38
  static VALUE fj_sym_empty_slot;
38
39
  static VALUE fj_sym_empty_value;
39
40
  static VALUE fj_sym_duplicate_key;
@@ -60,8 +61,7 @@ typedef struct {
60
61
  int dup_raise;
61
62
  int bigdecimal_load; /* 0 = float, 1 = auto, 2 = bigdecimal */
62
63
  fj_kc_slot *kcache; /* per-parse key cache (NULL when interning unavailable) */
63
- int collect_warnings; /* warnings: option record non-fatal lenient fixes */
64
- VALUE warnings; /* rb_ary of SmarterJSON::Warning when collecting, else Qnil */
64
+ VALUE on_warning; /* on_warning: callable invoked per non-fatal lenient fix, else Qnil */
65
65
  } fj_state;
66
66
 
67
67
  /* Line/column at the current byte position, computed lazily (only when raising
@@ -81,14 +81,16 @@ static void fj_line_col(fj_state *st, long *line, long *col) {
81
81
  *col = c;
82
82
  }
83
83
 
84
- /* Record a non-fatal lenient fix only when the parse was given warnings: true. */
84
+ /* Report a non-fatal lenient fix to the on_warning callable a no-op (and builds no
85
+ * Warning) when no handler was given. The internal Qnil guard is the safety net; the
86
+ * call sites also guard so the line/col scan is skipped on the fast path. */
85
87
  static void fj_warn(fj_state *st, VALUE type_sym, const char *msg) {
86
88
  long line, col;
87
- if (!st->collect_warnings) return;
89
+ if (st->on_warning == Qnil) return;
88
90
  fj_line_col(st, &line, &col);
89
- rb_ary_push(st->warnings,
90
- rb_funcall(cWarning, fj_new_id, 4, type_sym,
91
- rb_utf8_str_new_cstr(msg), LONG2NUM(line), LONG2NUM(col)));
91
+ rb_funcall(st->on_warning, fj_call_id, 1,
92
+ rb_funcall(cWarning, fj_new_id, 4, type_sym,
93
+ rb_utf8_str_new_cstr(msg), LONG2NUM(line), LONG2NUM(col)));
92
94
  }
93
95
 
94
96
  /* 1-based column of the current byte position (bytes since the last line start).
@@ -1161,9 +1163,9 @@ static VALUE fj_build_object(fj_state *st, const VALUE *pairs, long count) {
1161
1163
  long entries = count / 2, i;
1162
1164
  VALUE hash = rb_hash_new_capa(entries);
1163
1165
 
1164
- /* Fast path: bulk insert. Skipped when collecting warnings, which needs the
1165
- * per-member loop below to report each dropped duplicate key. */
1166
- if (!st->symbolize_keys && !st->dup_first_wins && !st->collect_warnings) {
1166
+ /* Fast path: bulk insert. Skipped when an on_warning handler is present, which needs
1167
+ * the per-member loop below to report each dropped duplicate key. */
1168
+ if (!st->symbolize_keys && !st->dup_first_wins && st->on_warning == Qnil) {
1167
1169
  rb_hash_bulk_insert(count, pairs, hash);
1168
1170
  if (st->dup_raise && fj_hash_len(hash) < entries) {
1169
1171
  VALUE seen = rb_hash_new_capa(entries);
@@ -1178,7 +1180,7 @@ static VALUE fj_build_object(fj_state *st, const VALUE *pairs, long count) {
1178
1180
 
1179
1181
  for (i = 0; i + 1 < count; i += 2) {
1180
1182
  VALUE k = st->symbolize_keys ? rb_funcall(pairs[i], fj_to_sym_id, 0) : pairs[i];
1181
- if (st->dup_first_wins || st->dup_raise || st->collect_warnings) {
1183
+ if (st->dup_first_wins || st->dup_raise || st->on_warning != Qnil) {
1182
1184
  if (RTEST(rb_funcall(hash, fj_key_p_id, 1, k))) {
1183
1185
  if (st->dup_raise) fj_error(st, "duplicate key");
1184
1186
  fj_warn(st, fj_sym_duplicate_key, "duplicate key");
@@ -1271,7 +1273,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) {
1271
1273
  fj_skip_ws_comments(st);
1272
1274
  b = fj_byte(st);
1273
1275
  if (b == ',') { /* collapsing separator: skip empty member */
1274
- if (st->collect_warnings && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot");
1276
+ if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot");
1275
1277
  vss = 0;
1276
1278
  fj_advance(st, 1);
1277
1279
  continue;
@@ -1323,7 +1325,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) {
1323
1325
  fj_skip_ws_comments(st);
1324
1326
  b = fj_byte(st);
1325
1327
  if (b == ',') { /* collapsing separator: skip empty slot */
1326
- if (st->collect_warnings && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot");
1328
+ if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot");
1327
1329
  vss = 0;
1328
1330
  fj_advance(st, 1);
1329
1331
  continue;
@@ -1412,8 +1414,7 @@ static VALUE fj_parse_c(VALUE self, VALUE input, VALUE opts) {
1412
1414
  else st.bigdecimal_load = 1; /* :auto (default), including nil */
1413
1415
  }
1414
1416
 
1415
- st.collect_warnings = RTEST(rb_hash_aref(opts, ID2SYM(rb_intern("warnings"))));
1416
- st.warnings = st.collect_warnings ? rb_ary_new() : Qnil;
1417
+ st.on_warning = rb_hash_aref(opts, ID2SYM(rb_intern("on_warning"))); /* Qnil when absent */
1417
1418
 
1418
1419
  if (st.len >= 3 && (unsigned char)st.buf[0] == 0xEF &&
1419
1420
  (unsigned char)st.buf[1] == 0xBB && (unsigned char)st.buf[2] == 0xBF) {
@@ -1439,10 +1440,10 @@ static VALUE fj_parse_c(VALUE self, VALUE input, VALUE opts) {
1439
1440
  * whitespace / newline / concatenation do), so a bracketless comma list still
1440
1441
  * raises in fj_parse_iter — the unsupported implicit-root array. */
1441
1442
  fj_skip_ws_comments(&st);
1442
- if (fj_eof(&st)) return st.collect_warnings ? rb_assoc_new(Qnil, st.warnings) : Qnil;
1443
+ if (fj_eof(&st)) return Qnil;
1443
1444
  value = fj_parse_iter(&st, fj_implicit_root_ahead(&st));
1444
1445
  fj_skip_ws_comments(&st);
1445
- if (fj_eof(&st)) return st.collect_warnings ? rb_assoc_new(value, st.warnings) : value;
1446
+ if (fj_eof(&st)) return value;
1446
1447
  {
1447
1448
  VALUE arr = rb_ary_new();
1448
1449
  rb_ary_push(arr, value);
@@ -1450,7 +1451,7 @@ static VALUE fj_parse_c(VALUE self, VALUE input, VALUE opts) {
1450
1451
  rb_ary_push(arr, fj_parse_iter(&st, fj_implicit_root_ahead(&st)));
1451
1452
  fj_skip_ws_comments(&st);
1452
1453
  } while (!fj_eof(&st));
1453
- return st.collect_warnings ? rb_assoc_new(arr, st.warnings) : arr;
1454
+ return arr;
1454
1455
  }
1455
1456
  }
1456
1457
 
@@ -1463,6 +1464,7 @@ void Init_smarter_json(void) {
1463
1464
  fj_to_sym_id = rb_intern("to_sym");
1464
1465
  fj_key_p_id = rb_intern("key?");
1465
1466
  fj_new_id = rb_intern("new");
1467
+ fj_call_id = rb_intern("call");
1466
1468
  fj_sym_empty_slot = ID2SYM(rb_intern("empty_slot"));
1467
1469
  fj_sym_empty_value = ID2SYM(rb_intern("empty_value"));
1468
1470
  fj_sym_duplicate_key = ID2SYM(rb_intern("duplicate_key"));
@@ -57,10 +57,9 @@ module SmarterJSON
57
57
  Parser.new(input, options).each_value(&block)
58
58
  end
59
59
  elsif options.fetch(:acceleration, true) && HAS_ACCELERATION
60
- parse_c(input, options) # returns [result, warnings] when options[:warnings]
60
+ parse_c(input, options)
61
61
  else
62
- parser = Parser.new(input, options)
63
- options.fetch(:warnings, false) ? [parser.parse, parser.warnings] : parser.parse
62
+ Parser.new(input, options).parse
64
63
  end
65
64
  end
66
65
 
@@ -143,14 +142,9 @@ module SmarterJSON
143
142
  symbolize_keys: false, # Symbol keys instead of String
144
143
  duplicate_key: :last_wins, # :last_wins | :first_wins | :raise
145
144
  bigdecimal_load: :auto, # :auto | :float | :bigdecimal (Oj-compatible)
146
- warnings: false, # collect non-fatal lenient fixes; process returns [result, warnings]
145
+ on_warning: nil, # a callable invoked once per non-fatal lenient fix (a SmarterJSON::Warning)
147
146
  }.freeze
148
147
 
149
- # Warnings collected during the parse (empty slots, empty values, dropped duplicate
150
- # keys). Empty unless the parser was built with warnings: true. Public so the module
151
- # functions can read it after parse / each_value.
152
- attr_reader :warnings
153
-
154
148
  def initialize(input, options = {})
155
149
  raise ArgumentError, "input must be a String" unless input.is_a?(String)
156
150
 
@@ -158,8 +152,7 @@ module SmarterJSON
158
152
  @symbolize_keys = opts[:symbolize_keys]
159
153
  @duplicate_key = opts[:duplicate_key]
160
154
  @bigdecimal_load = opts[:bigdecimal_load]
161
- @collect_warnings = opts[:warnings]
162
- @warnings = []
155
+ @on_warning = opts[:on_warning]
163
156
 
164
157
  encoding = opts[:encoding]
165
158
  @input = encoding ? input.dup.force_encoding(encoding) : input
@@ -263,7 +256,7 @@ module SmarterJSON
263
256
  # Commas are collapsing separators inside a container: an empty slot (leading,
264
257
  # interior, or trailing comma) adds nothing. Skip it; the next iteration reads
265
258
  # the following value/key or the closing bracket.
266
- warn(:empty_slot, "extra comma — collapsed an empty slot") unless vss
259
+ warn(:empty_slot, "extra comma — collapsed an empty slot") if @on_warning && !vss
267
260
  vss = false
268
261
  advance(1)
269
262
  elsif cur_obj
@@ -300,7 +293,7 @@ module SmarterJSON
300
293
  elsif [RBRACE, COMMA].include?(b)
301
294
  # key with a colon but no value -> null (don't consume } or ,; the loop does)
302
295
  store_member(cur, key, nil)
303
- warn(:empty_value, "key #{key.inspect} had no value — used null")
296
+ warn(:empty_value, "key #{key.inspect} had no value — used null") if @on_warning
304
297
  vss = true
305
298
  elsif b.nil?
306
299
  raise error("unexpected end of input")
@@ -573,7 +566,7 @@ module SmarterJSON
573
566
  if hash.key?(k)
574
567
  raise error("duplicate key #{k.inspect}") if @duplicate_key == :raise
575
568
 
576
- warn(:duplicate_key, "duplicate key #{k.inspect} — #{@duplicate_key}")
569
+ warn(:duplicate_key, "duplicate key #{k.inspect} — #{@duplicate_key}") if @on_warning
577
570
  return if @duplicate_key == :first_wins
578
571
  end
579
572
  hash[k] = value
@@ -933,11 +926,14 @@ module SmarterJSON
933
926
  value
934
927
  end
935
928
 
936
- # Record a non-fatal lenient fix (only when built with warnings: true).
929
+ # Report a non-fatal lenient fix to the on_warning callable. The call-site guards
930
+ # (`if @on_warning`) keep the message string from being built on the fast path; this
931
+ # internal guard is the safety net so a forgotten call-site guard can't crash a
932
+ # handler-less caller.
937
933
  def warn(type, message)
938
- return unless @collect_warnings
934
+ return unless @on_warning
939
935
 
940
- @warnings << Warning.new(type, message, @line, @col)
936
+ @on_warning.call(Warning.new(type, message, @line, @col))
941
937
  end
942
938
 
943
939
  def error(message)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmarterJSON
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -3,8 +3,8 @@
3
3
  module SmarterJSON
4
4
  # A non-fatal thing the parser worked around while staying lenient — e.g. an empty
5
5
  # comma slot it collapsed, a key with no value it read as null, or a duplicate key
6
- # it dropped. Surfaced only when process / process_file is called with warnings: true
7
- # (which then returns [result, warnings]); otherwise the parser stays silent.
6
+ # it dropped. Passed to the on_warning: callable (when process / process_file is given
7
+ # one) once per fix; otherwise the parser stays silent and builds no Warning at all.
8
8
  #
9
9
  # type — a Symbol you can branch on (:empty_slot, :empty_value, :duplicate_key)
10
10
  # message — human-readable description
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smarter_json
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tilo Sloboda
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-02 00:00:00.000000000 Z
10
+ date: 2026-06-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bigdecimal