nosj 0.4.1 → 0.5.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: 938bbe04908f0865a510008040c4a1bf8f1e4b1cb80821de3757e3ac078f2e47
4
- data.tar.gz: feea0d89e6f7559642127a2333d01cc3f93b1d3763e54f6ed620b596a7102d30
3
+ metadata.gz: 354e310be9d2d56119d2db1f9ad13052c9a6bc8fba5c8d7b7993b78abf345e1e
4
+ data.tar.gz: b21acc76a56a10a56c62c370ff1dfcaa8dc263a96958fcac6c4f3c213c4bb542
5
5
  SHA512:
6
- metadata.gz: b1ff3aef1231141f9333030016f1ff308a81ca27a1ad8318fadf2ce42c77869756c5674716485c4b5be4ca948290f4450142c59eed76df2894bde460cd0530bc
7
- data.tar.gz: 5e40f2cfb493c13b546ef3367e02efafb7eea5697181b0daad9efeed89f06db58b0db798cd5cad2f09555ee900c673753595584829ab333bdc125c45eaeca41a
6
+ metadata.gz: c672e8653239dc1600ce19b4b38c31ae298dbf40a79449fe12ccf05335144eed7d210a115e87a3a60e8b534b868d3c0080079072ed21a8e67b398e2e8d7e8fc4
7
+ data.tar.gz: 22aadd62a736d86fe310ed099d995fe6006e149103eb95d41d128adc6164e6c2f64c2de49e6652437bccefbef70379b27cc3700d45fcad7ae1e2003ac6f05f41
data/CHANGELOG.md CHANGED
@@ -1,3 +1,64 @@
1
+ ## [0.5.0] - 2026-09-25
2
+
3
+ **json 3.0 compatibility.** nosj now matches the behavior of the json
4
+ gem 3.0, and stays compatible with both the json 2.x and 3.0
5
+ interfaces: `NOSJ.parse`, `NOSJ.generate`, and the rest of nosj's own
6
+ API follow json 3.0 semantics, while the `nosj/json` drop-in follows
7
+ whichever json gem your application has installed, 2.x or 3.0, down to
8
+ its calling conventions and the documents it accepts. Upgrading json
9
+ (or not) is your choice; nosj works with either.
10
+
11
+ Behavior changes in nosj's own API, for input that was accepted
12
+ before:
13
+
14
+ - Duplicate object keys raise `NOSJ::ParserError` (positioned at the
15
+ object repeating the key, like json 3) in `parse`, `load_file`,
16
+ `valid?` (returns false), `minify`/`reformat`, and everything that
17
+ materializes values. `allow_duplicate_key: true` restores the old
18
+ behavior: the last value wins (in `minify`, repeated keys pass
19
+ through).
20
+ - Lone UTF-16 surrogates such as `"\udc00"` raise `NOSJ::ParserError`
21
+ everywhere, trailing ones included (they used to decode to raw
22
+ WTF-8 bytes, and `minify` re-escaped them).
23
+ - `generate` raises `NOSJ::GeneratorError` for keys that render alike
24
+ (`{"a" => 1, :a => 2}`, `{1 => 1, "1" => 2}`), with json 3's exact
25
+ message; `allow_duplicate_key: true` emits them as before. Hashes
26
+ whose keys are all of one kind are never checked. The Rails encoder
27
+ is unchanged.
28
+ - `stats` still describes such documents rather than refusing them.
29
+ - Unknown options raise ArgumentError with json 3's message
30
+ (`unknown keyword: foo`) in every entry point, instead of being
31
+ ignored. json options nosj does not implement (`object_class`,
32
+ `array_class`, `decimal_class`, `on_load`, `create_additions`,
33
+ `allow_comments`, `allow_control_characters`,
34
+ `allow_invalid_escape`, `sort_keys`, `as_json`) raise unless falsy;
35
+ `on_load` and the newer ones used to be silently ignored.
36
+ `escape_slash` is gone, as in json 3: use `script_safe`.
37
+ `quirks_mode` is no longer accepted. `stats` takes only the options
38
+ it documents (`max_nesting`, `allow_nan`, `allow_trailing_comma`).
39
+
40
+ The `nosj/json` drop-in follows the installed json gem:
41
+
42
+ - With json 3.0, `JSON.parse` takes keyword options only, `JSON.dump`
43
+ uses json 3's defaults (nesting capped at 100), and whatever json 3
44
+ refuses (`quirks_mode`, `escape_slash`, `create_additions`, unknown
45
+ options, positional option hashes) raises exactly as json 3 raises.
46
+ - With json 2.x, everything behaves as before, including json 2's
47
+ acceptance of duplicate keys, lone surrogates, and comments, and
48
+ its handling of keys that render alike.
49
+ - Whenever the fast path refuses a call, the installed gem runs it
50
+ again and decides: exceptions are now the gem's own (message,
51
+ `json_path`, `invalid_object`) rather than nosj's messages re-raised
52
+ as JSON classes. The second pass happens on failures only, and a
53
+ `generate` run twice this way calls `to_json` again on the objects
54
+ visited before the refusal.
55
+ - Fixed: `JSON.dump` raised NameError (`_dump_default_options`) with
56
+ json older than 2.11, which includes the json bundled with Ruby 3.3
57
+ and 3.4.
58
+ - Fixed: `JSON.generate` (and `pretty_generate`, `dump`) with both
59
+ `ascii_only` and `script_safe` raised ArgumentError; that combination,
60
+ which nosj does not implement, now goes to the gem.
61
+
1
62
  ## [0.4.1] - 2026-09-25
2
63
 
3
64
  - Fixed a crash in `NOSJ.generate`: an object whose `to_json` or
data/Cargo.lock CHANGED
@@ -200,7 +200,7 @@ dependencies = [
200
200
 
201
201
  [[package]]
202
202
  name = "nosj_native"
203
- version = "0.4.1"
203
+ version = "0.5.0"
204
204
  dependencies = [
205
205
  "ahash",
206
206
  "magnus",
data/README.md CHANGED
@@ -65,6 +65,10 @@ through nosj:
65
65
  require "nosj/json"
66
66
  ```
67
67
 
68
+ It works with json 2.x and 3.0 alike and follows whichever your app
69
+ has installed: its calling conventions, its defaults, and the documents
70
+ it accepts.
71
+
68
72
  In a Bundler app (Rails included) that can live entirely in the
69
73
  Gemfile you can do this:
70
74
 
@@ -108,9 +112,11 @@ The `json` gem API, on the `NOSJ` module:
108
112
 
109
113
  ```ruby
110
114
  NOSJ.parse(src, symbolize_names: true) # also: freeze, max_nesting,
111
- # allow_nan, allow_trailing_comma
115
+ # allow_nan, allow_trailing_comma,
116
+ # allow_duplicate_key
112
117
  NOSJ.generate(obj) # indent, space, object_nl, ...,
113
- NOSJ.pretty_generate(obj) # ascii_only, script_safe, strict
118
+ NOSJ.pretty_generate(obj) # ascii_only, script_safe, strict,
119
+ # allow_duplicate_key
114
120
  ```
115
121
 
116
122
  ### Lazy documents
@@ -189,10 +195,11 @@ NOSJ.reformat_file("big.json") # straight off a memory map
189
195
  ```
190
196
 
191
197
  Output is exactly `generate(parse(json))`—canonical number spellings,
192
- normalized escapes, same formatting options—except duplicate keys pass
193
- through (a reformatter must not silently drop data) and lone-surrogate
194
- strings re-escape instead of raising. Acceptance options apply too:
195
- `minify(src, allow_trailing_comma: true)` normalizes the commas away.
198
+ normalized escapes, same formatting options—and it accepts exactly what
199
+ `parse` does. Acceptance options apply too:
200
+ `minify(src, allow_trailing_comma: true)` normalizes the commas away,
201
+ and under `allow_duplicate_key: true` repeated keys pass through (a
202
+ reformatter must not silently drop data).
196
203
 
197
204
  ### Byte-splicing edits and JSON Patch
198
205
 
@@ -403,19 +410,28 @@ Reproduce with `rake bench` (the parity-gated comparison, after a PGO retrain—
403
410
 
404
411
  ## Switching from the json gem
405
412
 
406
- You mostly don't have to do anything. Some differences:
407
-
408
- - The legacy object-deserialization options (`create_additions`,
409
- `object_class`, `array_class`, `decimal_class`) raise ArgumentError;
410
- the `nosj/json` drop-in falls back to the original gem for them.
411
- - Behaviors the `json` gem itself deprecates (JS comments, raw invalid
412
- UTF-8) follow the strict semantics instead.
413
+ You mostly don't have to do anything. `NOSJ.*` follows json 3.0,
414
+ whichever json your app runs; the `nosj/json` drop-in follows the
415
+ installed one, 2.x or 3.0. Some differences:
416
+
417
+ - json 3.0 semantics: duplicate keys, lone surrogates, JS comments,
418
+ and invalid UTF-8 are errors (`allow_duplicate_key: true` restores
419
+ last-key-wins), keys that render alike raise in `generate`, and
420
+ unknown options raise ArgumentError. With json 2.x installed, the
421
+ drop-in keeps json 2's leniency: when nosj refuses a call, the
422
+ installed gem runs it and has the last word.
423
+ - The json options nosj doesn't implement (`create_additions`,
424
+ `object_class`, `array_class`, `decimal_class`, `on_load`,
425
+ `allow_comments`, `allow_control_characters`,
426
+ `allow_invalid_escape`, `sort_keys`, `as_json`) raise ArgumentError
427
+ unless falsy; the drop-in passes them to the original gem.
413
428
  - Unlike `Array#dig`, negative indices in `NOSJ.dig` return nil (JSON
414
429
  Pointer has no equivalent).
415
430
  - Parse errors raise `NOSJ::ParserError` (`NOSJ::NestingError` past
416
431
  `max_nesting`, like the gem); messages use byte offsets rather than
417
432
  the gem's phrasing, and the exception carries `#line`, `#column`,
418
- and a caret `#snippet`.
433
+ and a caret `#snippet`. Through the drop-in, exceptions are the
434
+ installed gem's own (message, `json_path`, `invalid_object`).
419
435
 
420
436
  Everything else—including the gem's exact float formatting, which is
421
437
  not the shortest-round-trip form most libraries emit—matches
data/ext/nosj/Cargo.toml CHANGED
@@ -5,7 +5,7 @@
5
5
  # required by lib/nosj.rb as "nosj/nosj" (Init_nosj set explicitly in
6
6
  # lib.rs). Gem name, module, and API are plain nosj.
7
7
  name = "nosj_native"
8
- version = "0.4.1"
8
+ version = "0.5.0"
9
9
  edition = "2021"
10
10
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
11
11
  license = "MIT"
@@ -48,7 +48,14 @@ module NOSJFuzz
48
48
  rescue *PARSE_FAIL
49
49
  false
50
50
  end
51
- raise "stats disagrees with parse on acceptance" unless stats_ok == (status == :ok)
51
+ # stats describes documents rather than refusing them: it accepts
52
+ # everything parse accepts, plus duplicate keys and lone surrogates
53
+ # (json 3 refusals parse makes on top of the grammar).
54
+ raise "stats refused what parse accepted" if status == :ok && !stats_ok
55
+ if status == :err && stats_ok &&
56
+ try_parse(s, {allow_duplicate_key: true})[0] != :ok && !s.include?("\\u")
57
+ raise "stats accepted what parse refused for a grammar reason"
58
+ end
52
59
 
53
60
  min_status, min = begin
54
61
  [:ok, NOSJ.reformat_native(s, nil)]
@@ -59,24 +66,17 @@ module NOSJFuzz
59
66
  end
60
67
 
61
68
  if status == :err
62
- # The pipe may abort with GeneratorError (lone-surrogate key,
63
- # non-finite float) before the parser reaches whatever made the
64
- # whole document unparseable; any refusal is a refusal.
69
+ # The pipe may abort with GeneratorError (a non-finite float)
70
+ # before the parser reaches whatever made the whole document
71
+ # unparseable; any refusal is a refusal.
65
72
  raise "reformat accepted what parse refused" unless [:err, :generator].include?(min_status)
66
73
  return
67
74
  end
68
75
  if min_status == :generator
69
76
  # On a document parse accepts, only an overflow-to-Infinity
70
- # float (1e999, a 300-digit integer with a small exponent, maybe
71
- # hidden behind a duplicate key parse would discard) or a
72
- # lone-surrogate object key may abort the pipe. Rerunning with
73
- # allow_nan separates them: it lifts the float refusal but not
74
- # the key refusal, and the key requires a \u escape.
75
- begin
76
- NOSJ.reformat_native(s, {allow_nan: true})
77
- rescue NOSJ::GeneratorError
78
- raise "GeneratorError without a \\u escape in source" unless s.include?("\\u")
79
- end
77
+ # float (1e999, a 300-digit integer with a small exponent) may
78
+ # abort the pipe, and allow_nan lifts exactly that refusal.
79
+ stage("reformat with allow_nan") { NOSJ.reformat_native(s, {allow_nan: true}) }
80
80
  return
81
81
  end
82
82
  raise "reformat refused what parse accepted" unless min_status == :ok
@@ -140,11 +140,9 @@ module NOSJFuzz
140
140
  raise "yielded values diverge from per-line parses" unless yielded.eql?(reference)
141
141
 
142
142
  if status == :ok && !yielded.empty?
143
- begin
144
- ndjson = NOSJ.generate_lines_native(yielded, nil)
145
- rescue NOSJ::GeneratorError
146
- return # WTF-8 strings (lone surrogates) are not generable
147
- end
143
+ # Parsed values are always generable: parse refuses lone
144
+ # surrogates, the only source of unencodable strings.
145
+ ndjson = stage("generate_lines") { NOSJ.generate_lines_native(yielded, nil) }
148
146
  back = []
149
147
  NOSJ.each_line_native(ndjson, nil) { |v| back << v }
150
148
  raise "generate_lines does not round-trip" unless back.eql?(yielded)
@@ -168,16 +166,11 @@ module NOSJFuzz
168
166
  doc = utf8(doc_bytes)
169
167
  spec_status, spec = try_parse(utf8(spec_bytes))
170
168
  return unless spec_status == :ok
171
- # Replacement values with broken encoding (lone surrogates) raise
172
- # GeneratorError on insertion; the reference cannot mirror that.
173
- return unless deep_valid_encoding?(spec)
174
169
 
170
+ # Parsing refuses duplicate keys (raw-byte resolution would see the
171
+ # first occurrence, the tree the last) and lone surrogates, so an
172
+ # accepted tree and spec are free of both.
175
173
  tree_status, tree = try_parse(doc)
176
- # Duplicate keys: raw-byte resolution sees the first occurrence,
177
- # tree materialization keeps the last; the two sides cannot agree.
178
- if tree_status == :ok && NOSJ.stats_native(doc, nil)[:keys] != count_keys(tree)
179
- return
180
- end
181
174
 
182
175
  case spec
183
176
  when Hash
@@ -380,21 +373,4 @@ module NOSJFuzz
380
373
  else v
381
374
  end
382
375
  end
383
-
384
- def deep_valid_encoding?(v)
385
- case v
386
- when String then v.valid_encoding?
387
- when Array then v.all? { |e| deep_valid_encoding?(e) }
388
- when Hash then v.all? { |k, e| deep_valid_encoding?(k) && deep_valid_encoding?(e) }
389
- else true
390
- end
391
- end
392
-
393
- def count_keys(v)
394
- case v
395
- when Hash then v.size + v.sum { |_, e| count_keys(e) }
396
- when Array then v.sum { |e| count_keys(e) }
397
- else 0
398
- end
399
- end
400
376
  end
@@ -5,6 +5,9 @@ use magnus::value::ReprValue;
5
5
  use magnus::{Error, RHash, RString, Ruby, Value};
6
6
  use nosj::emit::EscapeMode;
7
7
 
8
+ use crate::opt_reader::{Opt, OptReader};
9
+ use crate::sink::MAX_NESTING;
10
+
8
11
  pub(crate) struct GenConfig {
9
12
  pub(crate) indent: Vec<u8>,
10
13
  pub(crate) space: Vec<u8>,
@@ -21,195 +24,129 @@ pub(crate) struct GenConfig {
21
24
  /// null (Float#as_json parity). Set only by the Rails entry, never
22
25
  /// from user option hashes.
23
26
  pub(super) rails: bool,
27
+ /// json 3: keys that render the same (`"a"` and `:a`) raise unless
28
+ /// this is set. The Rails configs keep ActiveSupport's own handling.
29
+ pub(super) allow_duplicate_key: bool,
24
30
  pub(crate) mode: EscapeMode,
25
31
  /// Precomputed "any formatting string set": scanning the five
26
32
  /// vectors per call was measurable on tiny documents.
27
33
  pub(super) pretty: bool,
28
34
  }
29
35
 
36
+ /// The json gem's defaults under an escape mode, for the plain walk or
37
+ /// the Rails encoder's (which keeps ActiveSupport's own key handling,
38
+ /// so it allows keys that render alike). A const fn because statics
39
+ /// cannot struct-update a type with `Vec` fields; `Vec::new` is const
40
+ /// and allocation-free.
41
+ const fn defaults(rails: bool, mode: EscapeMode) -> GenConfig {
42
+ GenConfig {
43
+ indent: Vec::new(),
44
+ space: Vec::new(),
45
+ space_before: Vec::new(),
46
+ object_nl: Vec::new(),
47
+ array_nl: Vec::new(),
48
+ max_nesting: MAX_NESTING,
49
+ start_depth: 0,
50
+ allow_nan: false,
51
+ strict: false,
52
+ rails,
53
+ allow_duplicate_key: rails,
54
+ mode,
55
+ pretty: false,
56
+ }
57
+ }
58
+
30
59
  /// The nil-options configuration, shared instead of rebuilt: stamping
31
60
  /// a fresh ~140-byte GenConfig onto the stack per call was measurable
32
61
  /// on tiny documents (the json gem likewise reuses a cached State for
33
- /// the default options). Safe as a static: `Vec::new` is const and
34
- /// allocation-free, and generation only ever borrows the config.
35
- pub(crate) static DEFAULT_CONFIG: GenConfig = GenConfig {
36
- indent: Vec::new(),
37
- space: Vec::new(),
38
- space_before: Vec::new(),
39
- object_nl: Vec::new(),
40
- array_nl: Vec::new(),
41
- max_nesting: 100,
42
- start_depth: 0,
43
- allow_nan: false,
44
- strict: false,
45
- rails: false,
46
- mode: EscapeMode::Standard,
47
- pretty: false,
48
- };
62
+ /// the default options). Safe as a static: generation only ever
63
+ /// borrows the config.
64
+ pub(crate) static DEFAULT_CONFIG: GenConfig = defaults(false, EscapeMode::Standard);
49
65
 
50
66
  /// The Rails-encoder configuration for ActiveSupport's default escape
51
67
  /// flags (HTML entities and JS separators both on, the overwhelmingly
52
68
  /// common case): escaping is fused into the crate's HtmlSafe kernels,
53
69
  /// one pass, no post-scan.
54
- pub(super) static RAILS_HTML_SAFE_CONFIG: GenConfig = GenConfig {
55
- indent: Vec::new(),
56
- space: Vec::new(),
57
- space_before: Vec::new(),
58
- object_nl: Vec::new(),
59
- array_nl: Vec::new(),
60
- max_nesting: 100,
61
- start_depth: 0,
62
- allow_nan: false,
63
- strict: false,
64
- rails: true,
65
- mode: EscapeMode::HtmlSafe,
66
- pretty: false,
67
- };
70
+ pub(super) static RAILS_HTML_SAFE_CONFIG: GenConfig = defaults(true, EscapeMode::HtmlSafe);
68
71
 
69
72
  /// Rails-encoder configuration with HTML entities on and JS separators
70
73
  /// off.
71
- pub(super) static RAILS_HTML_ENTITIES_CONFIG: GenConfig = GenConfig {
72
- indent: Vec::new(),
73
- space: Vec::new(),
74
- space_before: Vec::new(),
75
- object_nl: Vec::new(),
76
- array_nl: Vec::new(),
77
- max_nesting: 100,
78
- start_depth: 0,
79
- allow_nan: false,
80
- strict: false,
81
- rails: true,
82
- mode: EscapeMode::HtmlEntities,
83
- pretty: false,
84
- };
74
+ pub(super) static RAILS_HTML_ENTITIES_CONFIG: GenConfig = defaults(true, EscapeMode::HtmlEntities);
85
75
 
86
76
  /// Rails-encoder configuration with JS separators on and HTML entities
87
77
  /// off.
88
- pub(super) static RAILS_JS_SEPARATORS_CONFIG: GenConfig = GenConfig {
89
- indent: Vec::new(),
90
- space: Vec::new(),
91
- space_before: Vec::new(),
92
- object_nl: Vec::new(),
93
- array_nl: Vec::new(),
94
- max_nesting: 100,
95
- start_depth: 0,
96
- allow_nan: false,
97
- strict: false,
98
- rails: true,
99
- mode: EscapeMode::JsSeparators,
100
- pretty: false,
101
- };
78
+ pub(super) static RAILS_JS_SEPARATORS_CONFIG: GenConfig = defaults(true, EscapeMode::JsSeparators);
102
79
 
103
80
  /// The Rails-encoder configuration with every escape flag off
104
81
  /// (encode(escape: false)). Mirrors JSONGemEncoder#stringify, which
105
82
  /// generates with the json gem's defaults.
106
- pub(super) static RAILS_CONFIG: GenConfig = GenConfig {
107
- indent: Vec::new(),
108
- space: Vec::new(),
109
- space_before: Vec::new(),
110
- object_nl: Vec::new(),
111
- array_nl: Vec::new(),
112
- max_nesting: 100,
113
- start_depth: 0,
114
- allow_nan: false,
115
- strict: false,
116
- rails: true,
117
- mode: EscapeMode::Standard,
118
- pretty: false,
119
- };
83
+ pub(super) static RAILS_CONFIG: GenConfig = defaults(true, EscapeMode::Standard);
120
84
 
121
85
  impl Default for GenConfig {
122
86
  fn default() -> Self {
123
- GenConfig {
124
- indent: Vec::new(),
125
- space: Vec::new(),
126
- space_before: Vec::new(),
127
- object_nl: Vec::new(),
128
- array_nl: Vec::new(),
129
- max_nesting: 100,
130
- start_depth: 0,
131
- allow_nan: false,
132
- strict: false,
133
- rails: false,
134
- mode: EscapeMode::Standard,
135
- pretty: false,
136
- }
87
+ defaults(false, EscapeMode::Standard)
137
88
  }
138
89
  }
139
90
 
140
- fn opt_bytes(ruby: &Ruby, opts: RHash, name: &str) -> Result<Option<Vec<u8>>, Error> {
141
- let v: Value = opts
142
- .get(ruby.to_symbol(name))
143
- .unwrap_or_else(|| ruby.qnil().as_value());
144
- if v.is_nil() {
145
- return Ok(None);
146
- }
91
+ /// A formatting string option's bytes; empty when absent or nil.
92
+ fn opt_bytes(r: &mut OptReader, opt: Opt) -> Result<Vec<u8>, Error> {
93
+ let Some(v) = r.get(opt).filter(|v| !v.is_nil()) else {
94
+ return Ok(Vec::new());
95
+ };
147
96
  let s = RString::from_value(v).ok_or_else(|| {
148
97
  Error::new(
149
- ruby.exception_type_error(),
150
- format!("{name} must be a String"),
98
+ r.ruby().exception_type_error(),
99
+ format!("{} must be a String", opt.name()),
151
100
  )
152
101
  })?;
153
- Ok(Some(unsafe { s.as_slice() }.to_vec()))
102
+ Ok(unsafe { s.as_slice() }.to_vec())
154
103
  }
155
104
 
156
- fn opt_bool(ruby: &Ruby, opts: RHash, name: &str) -> Option<bool> {
157
- let v: Value = opts.get(ruby.to_symbol(name))?;
158
- if v.is_nil() {
159
- None
160
- } else {
161
- Some(v.to_bool())
162
- }
163
- }
164
-
165
- /// Decode a non-nil options hash (nil takes [`DEFAULT_CONFIG`] at the
166
- /// call site without constructing anything).
105
+ /// Decode a generate options hash (nil takes [`DEFAULT_CONFIG`] at the
106
+ /// call site without constructing anything); keys it does not read
107
+ /// raise ArgumentError, like json 3.
167
108
  pub(crate) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usize), Error> {
168
- let mut cfg = GenConfig::default();
169
- let mut cap_hint = 0usize;
170
109
  if opts.is_nil() {
171
- return Ok((cfg, cap_hint));
110
+ return Ok((GenConfig::default(), 0));
172
111
  }
173
112
  let opts = RHash::from_value(opts)
174
113
  .ok_or_else(|| Error::new(ruby.exception_type_error(), "options must be a Hash or nil"))?;
114
+ let mut reader = OptReader::new(ruby, opts);
115
+ let decoded = read_gen_opts(&mut reader)?;
116
+ reader.finish()?;
117
+ Ok(decoded)
118
+ }
175
119
 
176
- if let Some(v) = opt_bytes(ruby, opts, "indent")? {
177
- cfg.indent = v;
178
- }
179
- if let Some(v) = opt_bytes(ruby, opts, "space")? {
180
- cfg.space = v;
181
- }
182
- if let Some(v) = opt_bytes(ruby, opts, "space_before")? {
183
- cfg.space_before = v;
184
- }
185
- if let Some(v) = opt_bytes(ruby, opts, "object_nl")? {
186
- cfg.object_nl = v;
187
- }
188
- if let Some(v) = opt_bytes(ruby, opts, "array_nl")? {
189
- cfg.array_nl = v;
190
- }
191
- if let Some(v) = opt_bool(ruby, opts, "allow_nan") {
192
- cfg.allow_nan = v;
193
- }
194
- if let Some(v) = opt_bool(ruby, opts, "strict") {
195
- cfg.strict = v;
196
- }
197
- let ascii = opt_bool(ruby, opts, "ascii_only").unwrap_or(false);
198
- let script = opt_bool(ruby, opts, "script_safe").unwrap_or(false)
199
- || opt_bool(ruby, opts, "escape_slash").unwrap_or(false);
120
+ /// Read the json 3 generate options and the buffer size hint. sort_keys
121
+ /// and as_json, which NOSJ does not implement, raise unless falsy.
122
+ pub(crate) fn read_gen_opts(r: &mut OptReader) -> Result<(GenConfig, usize), Error> {
123
+ let mut cfg = GenConfig {
124
+ indent: opt_bytes(r, Opt::Indent)?,
125
+ space: opt_bytes(r, Opt::Space)?,
126
+ space_before: opt_bytes(r, Opt::SpaceBefore)?,
127
+ object_nl: opt_bytes(r, Opt::ObjectNl)?,
128
+ array_nl: opt_bytes(r, Opt::ArrayNl)?,
129
+ allow_nan: r.truthy(Opt::AllowNan),
130
+ strict: r.truthy(Opt::Strict),
131
+ allow_duplicate_key: r.truthy(Opt::AllowDuplicateKey),
132
+ ..GenConfig::default()
133
+ };
134
+ let mut cap_hint = 0usize;
135
+ r.tolerate(&[Opt::SortKeys, Opt::AsJson]);
136
+ let ascii = r.truthy(Opt::AsciiOnly);
137
+ let script = r.truthy(Opt::ScriptSafe);
200
138
  if ascii {
201
139
  cfg.mode = EscapeMode::AsciiOnly;
202
140
  if script {
203
141
  return Err(Error::new(
204
- ruby.exception_arg_error(),
142
+ r.ruby().exception_arg_error(),
205
143
  "NOSJ.generate: ascii_only and script_safe cannot be combined",
206
144
  ));
207
145
  }
208
146
  } else if script {
209
147
  cfg.mode = EscapeMode::ScriptSafe;
210
148
  }
211
- if let Some(v) = opts.get(ruby.to_symbol("max_nesting")) {
212
- let v: Value = v;
149
+ if let Some(v) = r.get(Opt::MaxNesting) {
213
150
  // nil/false → unlimited; true → keep the default 100; Integer → limit.
214
151
  if !v.to_bool() {
215
152
  cfg.max_nesting = 0;
@@ -217,14 +154,12 @@ pub(crate) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usi
217
154
  cfg.max_nesting = if n <= 0 { 0 } else { n as usize };
218
155
  }
219
156
  }
220
- if let Some(v) = opts.get(ruby.to_symbol("depth")) {
221
- let v: Value = v;
157
+ if let Some(v) = r.get(Opt::Depth) {
222
158
  if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
223
159
  cfg.start_depth = if n <= 0 { 0 } else { n as usize };
224
160
  }
225
161
  }
226
- if let Some(v) = opts.get(ruby.to_symbol("buffer_initial_length")) {
227
- let v: Value = v;
162
+ if let Some(v) = r.get(Opt::BufferInitialLength) {
228
163
  if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
229
164
  if n > 0 {
230
165
  cap_hint = n as usize;
@@ -18,6 +18,11 @@ pub(super) fn protected_to_s(v: VALUE) -> Result<VALUE, Error> {
18
18
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_as_string(v) })
19
19
  }
20
20
 
21
+ /// `v.inspect`, protected (user code for any element's `inspect`).
22
+ pub(super) fn protected_inspect(v: VALUE) -> Result<VALUE, Error> {
23
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_inspect(v) })
24
+ }
25
+
21
26
  /// `v.to_json`, protected.
22
27
  pub(super) fn protected_to_json(v: VALUE) -> Result<VALUE, Error> {
23
28
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) })