nosj 0.4.0 → 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.
@@ -10,33 +10,78 @@ use ahash::AHashMap;
10
10
  use magnus::typed_data::Obj;
11
11
  use magnus::{DataTypeFunctions, TypedData};
12
12
  use nosj::Buffers;
13
- use std::cell::RefCell;
13
+ use std::cell::Cell;
14
+ use std::thread::LocalKey;
14
15
 
15
- /// Everything a parse touches, allocated once per thread and reused:
16
- /// nosj's scratch buffers, the interned-key caches, and the GC-marked
17
- /// stacks.
16
+ use crate::sink::DupScratch;
17
+
18
+ /// Run `f` on a pooled thread-local value, taken OUT of its cell for the
19
+ /// call and stored back afterwards. Pooled state is never borrowed
20
+ /// across a call that can reach Ruby: any allocation can raise
21
+ /// NoMemoryError, whose longjmp skips these frames, and a RefCell borrow
22
+ /// held across it would stay borrowed for good (under panic=abort, the
23
+ /// next borrow kills the process). A value lost that way is only leaked;
24
+ /// the next call, like a nested one finding the cell empty, starts from
25
+ /// `T::default()`. The outermost call's value is the one stored back.
26
+ pub(crate) fn with_taken<T: Default, R>(
27
+ key: &'static LocalKey<Cell<T>>,
28
+ f: impl FnOnce(&mut T) -> R,
29
+ ) -> R {
30
+ let mut value = key.take();
31
+ let result = f(&mut value);
32
+ key.set(value);
33
+ result
34
+ }
35
+
36
+ /// Everything a parse touches, reused across calls: nosj's scratch
37
+ /// buffers, the interned-key caches, and the GC-marked stacks.
18
38
  pub(crate) struct PullState {
19
39
  pub(crate) bufs: Buffers,
20
40
  pub(crate) keys: AHashMap<Box<str>, rb_sys::VALUE>,
21
41
  /// Separate cache for symbolize_names mode: symbol and string VALUEs
22
42
  /// must never share a map.
23
43
  pub(crate) sym_keys: AHashMap<Box<str>, rb_sys::VALUE>,
24
- /// Leaked once per thread; kept alive + GC-marked via the wrapped
44
+ /// Leaked once per state; kept alive + GC-marked via the wrapped
25
45
  /// handle.
26
46
  pub(crate) vstack: Option<&'static mut VStackShadow>,
27
47
  /// Marked shadow holding the cached key VALUEs; keys are kept alive by
28
48
  /// this (collectable on epoch clear), NOT by per-key eternal GC pins.
29
49
  pub(crate) key_shadow: Option<&'static mut VStackShadow>,
50
+ /// Duplicate-key detection buffers for hash-less sinks.
51
+ pub(crate) dup: DupScratch,
52
+ }
53
+
54
+ impl PullState {
55
+ #[cold]
56
+ fn fresh() -> Box<Self> {
57
+ Box::new(PullState {
58
+ bufs: Buffers::new(),
59
+ keys: AHashMap::with_capacity(256),
60
+ sym_keys: AHashMap::new(),
61
+ vstack: None,
62
+ key_shadow: None,
63
+ dup: DupScratch::default(),
64
+ })
65
+ }
30
66
  }
31
67
 
32
68
  thread_local! {
33
- pub(crate) static PULL_STATE: RefCell<PullState> = RefCell::new(PullState {
34
- bufs: Buffers::new(),
35
- keys: AHashMap::with_capacity(256),
36
- sym_keys: AHashMap::new(),
37
- vstack: None,
38
- key_shadow: None,
39
- });
69
+ static PULL_STATE: Cell<Option<Box<PullState>>> = const { Cell::new(None) };
70
+ }
71
+
72
+ /// Run `f` on this thread's parse state, taken out like [`with_taken`]
73
+ /// (a state lost to a longjmp leaks its shadows' last VALUEs; a nested
74
+ /// call would start a fresh one). Unlike the generate scratch, parse
75
+ /// bodies never run Ruby code, so the thread cannot hop native threads
76
+ /// mid-call and one thread-local access serves both the take and the
77
+ /// put-back: measured ~5ns per call on tiny documents against two.
78
+ pub(crate) fn with_pull_state<R>(f: impl FnOnce(&mut PullState) -> R) -> R {
79
+ PULL_STATE.with(|cell| {
80
+ let mut state = cell.take().unwrap_or_else(PullState::fresh);
81
+ let result = f(&mut state);
82
+ cell.set(Some(state));
83
+ result
84
+ })
40
85
  }
41
86
 
42
87
  /// GC-marked holder for pending VALUEs.
@@ -48,6 +93,12 @@ pub(crate) struct VStackShadow {
48
93
  /// through [`DataTypeFunctions::mark`] (its trampoline, not ours),
49
94
  /// pinning every pending VALUE with `rb_gc_mark` semantics. The class
50
95
  /// is defined (and made a private constant) at init.
96
+ ///
97
+ /// Deliberately NOT `wb_protected`: parses push VALUEs into the shadow
98
+ /// with plain stores, no write barriers, so an old protected handle
99
+ /// would let the GC miss young values it holds (a use-after-free).
100
+ /// Staying write-barrier-unprotected makes every GC rescan the handle,
101
+ /// which costs nothing measurable: there are one to three per thread.
51
102
  #[derive(TypedData)]
52
103
  #[magnus(class = "NOSJ::ValueStackShadow", mark)]
53
104
  pub(crate) struct ShadowHandle(*const VStackShadow);
@@ -74,7 +125,7 @@ impl DataTypeFunctions for ShadowHandle {
74
125
  }
75
126
  }
76
127
 
77
- /// Create (once per thread) a leaked, GC-marked VStackShadow.
128
+ /// Create (once per owning state) a leaked, GC-marked VStackShadow.
78
129
  pub(crate) fn ensure_marked_shadow(slot: &mut Option<&'static mut VStackShadow>) {
79
130
  if slot.is_none() {
80
131
  let ruby = magnus::Ruby::get().expect("called on a Ruby thread");
@@ -83,7 +134,7 @@ pub(crate) fn ensure_marked_shadow(slot: &mut Option<&'static mut VStackShadow>)
83
134
  }));
84
135
  let ptr = std::ptr::from_mut::<VStackShadow>(shadow).cast_const();
85
136
  let handle: Obj<ShadowHandle> = ruby.obj_wrap(ShadowHandle(ptr));
86
- magnus::gc::register_mark_object(handle);
137
+ ruby.gc_register_mark_object(handle);
87
138
  *slot = Some(shadow);
88
139
  }
89
140
  }
@@ -8,11 +8,11 @@ use ahash::AHashMap;
8
8
  use magnus::value::ReprValue;
9
9
  use magnus::{Error, RHash, RString, Ruby, Value};
10
10
 
11
- use crate::errors::{nesting_error, parser_error, parser_error_at};
12
11
  use crate::files::with_mapped_file;
13
- use crate::parse::{parse_native_opts, utf8_input};
12
+ use crate::opt_reader::{Opt, OptReader};
13
+ use crate::parse::{drive_error, max_nesting_of, options_hash, utf8_input, ParseNativeOpts};
14
14
  use crate::sink::SinkAbort;
15
- use crate::state::PULL_STATE;
15
+ use crate::state::with_pull_state;
16
16
 
17
17
  /// What the document's root value was; reported as a Symbol. The
18
18
  /// Default is never observable (a successful pass always saw a root
@@ -239,41 +239,41 @@ fn stats_to_hash(ruby: &Ruby, s: &StatsSink, byte_size: usize) -> Result<Value,
239
239
  Ok(out.as_value())
240
240
  }
241
241
 
242
- /// Run the counting pass over already-UTF-8-vouched bytes and build
243
- /// the result. `max_nesting` here defaults to UNLIMITED (a deep blob
244
- /// is exactly what a diagnostic should describe, not refuse), unless
245
- /// the caller passes the option explicitly.
246
- fn stats_over(ruby: &Ruby, input: &[u8], opts: Value) -> Result<Value, Error> {
247
- let o = parse_native_opts(ruby, opts)?;
248
- let nesting_given =
249
- RHash::from_value(opts).is_some_and(|h| h.get(ruby.to_symbol("max_nesting")).is_some());
242
+ /// Stats' options: `max_nesting`, which here defaults to UNLIMITED (a
243
+ /// deep blob is exactly what a diagnostic should describe, not refuse),
244
+ /// and the grammar extensions. Decoded before the source is borrowed:
245
+ /// a hash lookup can run a key's own `hash`/`eql?`.
246
+ fn stats_opts(ruby: &Ruby, opts: Value) -> Result<ParseNativeOpts, Error> {
247
+ let mut o = ParseNativeOpts {
248
+ max_nesting: usize::MAX,
249
+ ..ParseNativeOpts::default()
250
+ };
251
+ let Some(h) = options_hash(ruby, opts)? else {
252
+ return Ok(o);
253
+ };
254
+ let mut r = OptReader::new(ruby, h);
255
+ if let Some(v) = r.get(Opt::MaxNesting) {
256
+ o.max_nesting = max_nesting_of(v);
257
+ }
258
+ o.popts.allow_nan = r.truthy(Opt::AllowNan);
259
+ o.popts.allow_trailing_comma = r.truthy(Opt::AllowTrailingComma);
260
+ r.finish()?;
261
+ Ok(o)
262
+ }
250
263
 
264
+ /// Run the counting pass over already-UTF-8-vouched bytes and build
265
+ /// the result.
266
+ fn stats_over(ruby: &Ruby, input: &[u8], o: &ParseNativeOpts) -> Result<Value, Error> {
251
267
  let mut sink = StatsSink {
252
- max_nesting: if nesting_given {
253
- o.max_nesting
254
- } else {
255
- usize::MAX
256
- },
268
+ max_nesting: o.max_nesting,
257
269
  ..StatsSink::default()
258
270
  };
259
- let result = PULL_STATE.with(|cell| {
260
- let mut state = cell.borrow_mut();
271
+ let result = with_pull_state(|state| {
261
272
  // Safety: callers verified UTF-8 (coderange or a full scan).
262
273
  unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, o.popts) }
263
274
  });
264
- match result {
265
- Ok(()) => stats_to_hash(ruby, &sink, input.len()),
266
- Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
267
- ruby,
268
- format!("nesting of {} is too deep", o.max_nesting.saturating_add(1)),
269
- )),
270
- // The other aborts cannot happen (this sink never raises them),
271
- // but the match must be total.
272
- Err(nosj::DriveError::Sink(_)) => Err(parser_error(ruby, "stats pass aborted".into())),
273
- Err(nosj::DriveError::Parse(e)) => {
274
- Err(parser_error_at(ruby, input, e.offset, e.to_string()))
275
- }
276
- }
275
+ result.map_err(|failure| drive_error(ruby, failure, o, input, (0, input.len())))?;
276
+ stats_to_hash(ruby, &sink, input.len())
277
277
  }
278
278
 
279
279
  /// `NOSJ.stats(source, opts)`: document statistics from one null-sink
@@ -284,8 +284,9 @@ pub fn stats_native(
284
284
  data: RString,
285
285
  opts: Value,
286
286
  ) -> Result<Value, Error> {
287
+ let o = stats_opts(ruby, opts)?;
287
288
  let input = utf8_input(ruby, &data)?;
288
- stats_over(ruby, input, opts)
289
+ stats_over(ruby, input, &o)
289
290
  }
290
291
 
291
292
  /// `NOSJ.stats_file(path, opts)`: `NOSJ.stats` against a memory-mapped
@@ -296,6 +297,7 @@ pub fn stats_file_native(
296
297
  path: RString,
297
298
  opts: Value,
298
299
  ) -> Result<Value, Error> {
300
+ let o = stats_opts(ruby, opts)?;
299
301
  let p = path.to_string()?;
300
- with_mapped_file(ruby, &p, |map| stats_over(ruby, &map, opts))
302
+ with_mapped_file(ruby, &p, |map| stats_over(ruby, &map, &o))
301
303
  }
data/lib/nosj/json.rb CHANGED
@@ -12,12 +12,18 @@
12
12
  # Entry points built on JSON.parse (JSON.load, JSON.parse!,
13
13
  # JSON.load_file, JSON.unsafe_load) pick up the fast path automatically
14
14
  # and keep their exact legacy behavior when they need unsupported options
15
- # (JSON.load's create_additions default always takes the fallback).
15
+ # (json 2's JSON.load passes create_additions, so it always takes the
16
+ # fallback).
16
17
  #
17
- # Exceptions from the fast path are re-raised as the JSON classes
18
- # (JSON::ParserError, JSON::GeneratorError, JSON::NestingError), so
19
- # existing rescue clauses keep working. Parse error MESSAGES are
20
- # NOSJ's (byte offsets rather than the gem's phrasing).
18
+ # The drop-in follows whichever json is installed, 2.x or 3.x: its
19
+ # calling conventions (json 3's keyword-only parse, its dump defaults)
20
+ # and its semantics. NOSJ implements json 3's (duplicate keys and lone
21
+ # surrogates are errors), so whenever the fast path refuses a call, the
22
+ # original gem runs it again and has the last word: json 2 accepts what
23
+ # it always accepted, and every exception is the gem's own, message,
24
+ # json_path and invalid_object included. That second pass happens on
25
+ # failures only; a generate run twice this way calls to_json on the
26
+ # objects visited before the refusal twice.
21
27
  #
22
28
  # Not rerouted: obj.to_json (core extensions drive the gem's generator
23
29
  # directly), and objects with a custom to_json inside a rerouted
@@ -30,15 +36,26 @@ module NOSJ
30
36
  # Implementation detail of `require "nosj/json"`.
31
37
  # @private
32
38
  module JSONDropIn
33
- # quirks_mode rides the fast path because NOSJ.parse is always
34
- # quirks-mode (top-level scalars parse) and ignores the key; Rails
35
- # 7.x passes it from ActiveSupport::JSON.decode.
39
+ # json 3 made parse's options keyword-only, fixed dump's defaults and
40
+ # raises for options json 2 ignored or aliased.
41
+ JSON3 = ::JSON::VERSION.to_i >= 3
42
+
36
43
  PARSE_OPTS = %i[symbolize_names freeze max_nesting allow_nan
37
- allow_trailing_comma quirks_mode].freeze
44
+ allow_trailing_comma allow_duplicate_key].freeze
45
+ # json 2 ignores quirks_mode, which Rails 7.x passes from
46
+ # ActiveSupport::JSON.decode, so its fast path takes the key and
47
+ # drops it (NOSJ.parse always parses top-level scalars, and refuses
48
+ # unknown keys). json 3 raises for it, so there it reaches the gem
49
+ # like any other unknown option.
50
+ QUIRKS_MODE = :quirks_mode
51
+ JSON2_PARSE_OPTS = (PARSE_OPTS + [QUIRKS_MODE]).freeze
38
52
  GENERATE_OPTS = %i[indent space space_before object_nl array_nl
39
- max_nesting allow_nan ascii_only script_safe
40
- escape_slash strict depth
41
- buffer_initial_length].freeze
53
+ max_nesting allow_nan ascii_only script_safe strict depth
54
+ buffer_initial_length allow_duplicate_key].freeze
55
+ JSON3_DUMP_DEFAULTS = {allow_nan: true}.freeze
56
+ # json 2.10 and older accept only strict: in dump's options hash,
57
+ # through this private helper; later versions merge any option.
58
+ DUMP_MERGES_OPTIONS = !::JSON.respond_to?(:merge_dump_options, true)
42
59
 
43
60
  module_function
44
61
 
@@ -52,6 +69,13 @@ module NOSJ
52
69
  true
53
70
  end
54
71
 
72
+ # Generate options the fast path handles: supported keys, minus the
73
+ # one combination NOSJ refuses (ascii_only with script_safe, which
74
+ # json supports).
75
+ def generate_supported?(opts)
76
+ supported?(opts, GENERATE_OPTS) && !(opts && opts[:ascii_only] && opts[:script_safe])
77
+ end
78
+
55
79
  def parse(source, opts)
56
80
  # NOSJ.parse is deliberately strict about encodings (json-3.0
57
81
  # semantics), but the drop-in must match the installed gem, which
@@ -61,30 +85,60 @@ module NOSJ
61
85
  # (copy-on-write bytes), and the validity scan is memoized
62
86
  # coderange the parse would compute anyway. Anything else
63
87
  # non-UTF-8 (UTF-16, ...) belongs to gem json, which transcodes.
88
+ input = source
64
89
  if source.is_a?(String)
65
90
  case source.encoding
66
91
  when Encoding::UTF_8, Encoding::US_ASCII
67
92
  # the fast path as-is
68
93
  when Encoding::BINARY
69
94
  utf8 = source.dup.force_encoding(Encoding::UTF_8)
70
- source = utf8 if utf8.valid_encoding?
95
+ input = utf8 if utf8.valid_encoding?
71
96
  else
72
- return ::JSON.nosj_original_parse(source, **(opts || {}))
97
+ return original_parse(source, opts)
73
98
  end
74
99
  end
75
- NOSJ.parse(source, opts)
76
- rescue NOSJ::NestingError => e
77
- raise ::JSON::NestingError, e.message
78
- rescue NOSJ::ParserError => e
79
- raise ::JSON::ParserError, e.message
100
+ NOSJ.parse(input, opts)
101
+ rescue NOSJ::ParserError, NOSJ::NestingError
102
+ original_parse(source, opts)
103
+ end
104
+
105
+ # json 2's options without quirks_mode (see QUIRKS_MODE), allocating
106
+ # nothing for Rails' lone `quirks_mode: true`.
107
+ def without_quirks_mode(opts)
108
+ return opts unless opts&.key?(QUIRKS_MODE)
109
+ if opts.size == 1
110
+ nil
111
+ else
112
+ opts.except(QUIRKS_MODE)
113
+ end
114
+ end
115
+
116
+ # The installed gem's parse. json 3 takes keywords only; json 2's
117
+ # positional options hash receives them just the same.
118
+ def original_parse(source, opts)
119
+ ::JSON.nosj_original_parse(source, **(opts || {}))
80
120
  end
81
121
 
82
122
  def generate(obj, opts, pretty)
83
123
  pretty ? NOSJ.pretty_generate(obj, opts) : NOSJ.generate(obj, opts)
84
- rescue NOSJ::NestingError => e
85
- raise ::JSON::NestingError, e.message
86
- rescue NOSJ::GeneratorError => e
87
- raise ::JSON::GeneratorError, e.message
124
+ rescue NOSJ::GeneratorError, NOSJ::NestingError
125
+ if pretty
126
+ ::JSON.nosj_original_pretty_generate(obj, opts)
127
+ else
128
+ ::JSON.nosj_original_generate(obj, opts)
129
+ end
130
+ end
131
+
132
+ # The options JSON.dump generates with before the caller's own: fixed
133
+ # in json 3; json 2 reads its user-settable dump_default_options,
134
+ # through the internal reader 2.11 added when it deprecated the
135
+ # public one. Which one is decided here, once.
136
+ if JSON3
137
+ def dump_defaults = JSON3_DUMP_DEFAULTS
138
+ elsif ::JSON.respond_to?(:_dump_default_options)
139
+ def dump_defaults = ::JSON._dump_default_options
140
+ else
141
+ def dump_defaults = ::JSON.dump_default_options
88
142
  end
89
143
  end
90
144
  end
@@ -101,16 +155,26 @@ module JSON
101
155
  alias_method :nosj_original_pretty_generate, :pretty_generate
102
156
  alias_method :nosj_original_dump, :dump
103
157
 
104
- def parse(source, opts = nil)
105
- if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::PARSE_OPTS)
106
- NOSJ::JSONDropIn.parse(source, opts)
107
- else
108
- nosj_original_parse(source, opts)
158
+ if NOSJ::JSONDropIn::JSON3
159
+ def parse(source, **opts)
160
+ if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::PARSE_OPTS)
161
+ NOSJ::JSONDropIn.parse(source, opts)
162
+ else
163
+ nosj_original_parse(source, **opts)
164
+ end
165
+ end
166
+ else
167
+ def parse(source, opts = nil)
168
+ if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::JSON2_PARSE_OPTS)
169
+ NOSJ::JSONDropIn.parse(source, NOSJ::JSONDropIn.without_quirks_mode(opts))
170
+ else
171
+ nosj_original_parse(source, opts)
172
+ end
109
173
  end
110
174
  end
111
175
 
112
176
  def generate(obj, opts = nil)
113
- if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::GENERATE_OPTS)
177
+ if NOSJ::JSONDropIn.generate_supported?(opts)
114
178
  NOSJ::JSONDropIn.generate(obj, opts, false)
115
179
  else
116
180
  nosj_original_generate(obj, opts)
@@ -118,7 +182,7 @@ module JSON
118
182
  end
119
183
 
120
184
  def pretty_generate(obj, opts = nil)
121
- if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::GENERATE_OPTS)
185
+ if NOSJ::JSONDropIn.generate_supported?(opts)
122
186
  NOSJ::JSONDropIn.generate(obj, opts, true)
123
187
  else
124
188
  nosj_original_pretty_generate(obj, opts)
@@ -127,17 +191,19 @@ module JSON
127
191
 
128
192
  def dump(obj, an_io = nil, limit = nil, kwargs = nil)
129
193
  # Fast path for the common shapes, dump(obj) and dump(obj, opts
130
- # hash), mirroring gem json: dump defaults merged under the
131
- # user's options, NestingError surfaced as ArgumentError. IO and
132
- # limit arguments take gem json's own dump.
133
- if limit.nil? && kwargs.nil? && (an_io.nil? || an_io.instance_of?(Hash))
134
- opts = _dump_default_options
194
+ # hash): the installed gem's dump defaults merged under the
195
+ # caller's options. IO and limit arguments, and anything the
196
+ # fast path refuses, take gem json's own dump, which also turns
197
+ # json 2's NestingError into its ArgumentError.
198
+ if limit.nil? && kwargs.nil? &&
199
+ (an_io.nil? || NOSJ::JSONDropIn::DUMP_MERGES_OPTIONS && an_io.instance_of?(Hash))
200
+ opts = NOSJ::JSONDropIn.dump_defaults
135
201
  opts = opts.merge(an_io) if an_io
136
- if NOSJ::JSONDropIn.supported?(opts, NOSJ::JSONDropIn::GENERATE_OPTS)
202
+ if NOSJ::JSONDropIn.generate_supported?(opts)
137
203
  begin
138
- return NOSJ::JSONDropIn.generate(obj, opts, false)
139
- rescue ::JSON::NestingError
140
- raise ArgumentError, "exceed depth limit"
204
+ return NOSJ.generate(obj, opts)
205
+ rescue NOSJ::GeneratorError, NOSJ::NestingError
206
+ # the gem's own dump below runs it again and decides
141
207
  end
142
208
  end
143
209
  end
data/lib/nosj/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module NOSJ
4
4
  # The gem version.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/lib/nosj.rb CHANGED
@@ -86,10 +86,13 @@ module NOSJ
86
86
  # Parses a JSON document, JSON.parse-compatible: same values, same
87
87
  # option names, same behavior, byte-for-byte.
88
88
  #
89
- # The +json+ gem's legacy object-deserialization options
90
- # (+object_class+, +array_class+, +decimal_class+,
91
- # +create_additions+) are deliberately unsupported and raise
92
- # ArgumentError.
89
+ # Options follow json 3: an unknown key raises ArgumentError
90
+ # (<code>unknown keyword: foo</code>). The json options nosj does not
91
+ # implement (+object_class+, +array_class+, +decimal_class+,
92
+ # +on_load+, +create_additions+, +allow_comments+,
93
+ # +allow_control_characters+, +allow_invalid_escape+) raise
94
+ # ArgumentError unless falsy, since their falsy default is nosj's
95
+ # behavior.
93
96
  #
94
97
  # @example
95
98
  # NOSJ.parse('{"a":[1,true]}') #=> {"a" => [1, true]}
@@ -98,12 +101,14 @@ module NOSJ
98
101
  # @param source [String] the JSON document (UTF-8 or US-ASCII)
99
102
  # @param opts [Hash, nil] +symbolize_names+, +freeze+, +max_nesting+
100
103
  # (Integer or +false+ for unlimited), +allow_nan+,
101
- # +allow_trailing_comma+
104
+ # +allow_trailing_comma+, +allow_duplicate_key+ (json 3 semantics:
105
+ # a repeated key raises unless this is true, then the last one wins)
102
106
  # @return [Object] the parsed value tree
103
- # @raise [ParserError] when the document is malformed or not UTF-8;
104
- # carries the failure position ({ParserError#line} and friends)
107
+ # @raise [ParserError] when the document is malformed, repeats a key,
108
+ # holds a lone surrogate (+"\udc00"+), or is not UTF-8; carries the
109
+ # failure position ({ParserError#line} and friends)
105
110
  # @raise [NestingError] when nesting exceeds +max_nesting+
106
- # @raise [ArgumentError] for unsupported options
111
+ # @raise [ArgumentError] for unknown or unsupported options
107
112
  def self.parse(source, opts = nil)
108
113
  parse_native(source, opts)
109
114
  end
@@ -119,12 +124,18 @@ module NOSJ
119
124
  # @param obj [Object] the value tree to serialize
120
125
  # @param opts [Hash, nil] +indent+, +space+, +space_before+,
121
126
  # +object_nl+, +array_nl+, +max_nesting+ (Integer or +false+),
122
- # +allow_nan+, +ascii_only+, +script_safe+ (alias +escape_slash+),
123
- # +strict+, +depth+, +buffer_initial_length+
127
+ # +allow_nan+, +ascii_only+, +script_safe+, +strict+, +depth+,
128
+ # +buffer_initial_length+, +allow_duplicate_key+ (json 3
129
+ # semantics: keys that render alike, like <code>"a"</code> and
130
+ # <code>:a</code>, raise unless true). As in json 3, an unknown
131
+ # key raises (+escape_slash+ is gone: use +script_safe+), and the
132
+ # unimplemented +sort_keys+ and +as_json+ raise unless falsy.
124
133
  # @return [String] the JSON document
125
134
  # @raise [GeneratorError] for non-finite floats without +allow_nan+,
126
- # unsupported objects under +strict+, or broken string encodings
135
+ # unsupported objects under +strict+, keys that render alike, or
136
+ # broken string encodings
127
137
  # @raise [NestingError] when nesting exceeds +max_nesting+
138
+ # @raise [ArgumentError] for unknown or unsupported options
128
139
 
129
140
  # Generates human-readable JSON, JSON.pretty_generate-compatible
130
141
  # (two-space indent, newlines between elements). Options override the
@@ -156,7 +167,7 @@ module NOSJ
156
167
  # @param source [String] the JSON document
157
168
  # @param opts [Hash, nil] same options as {.parse}
158
169
  # @return [Boolean]
159
- # @raise [ArgumentError] for unsupported options
170
+ # @raise [ArgumentError] for unknown or unsupported options
160
171
  def self.valid?(source, opts = nil)
161
172
  valid_native(source, opts)
162
173
  end
@@ -205,7 +216,8 @@ module NOSJ
205
216
 
206
217
  # Partial parsing by JSON Pointer (with the standard +~0+/+~1+
207
218
  # escapes). The matched subtree materializes under the same options
208
- # as {.parse}.
219
+ # as {.parse}; +allow_nan+ and +allow_trailing_comma+ also govern the
220
+ # walk to it.
209
221
  #
210
222
  # @example
211
223
  # NOSJ.at_pointer(json, "/users/3/name") #=> "grace" or nil
@@ -322,18 +334,19 @@ module NOSJ
322
334
  # Minifies a document without building any Ruby values: the parser's
323
335
  # events pipe straight into the emission kernels, SIMD in and SIMD
324
336
  # out. Output is exactly what <code>generate(parse(json))</code>
325
- # would produce, except duplicate object keys pass through instead of
326
- # being collapsed (a reformatter must not silently drop data).
327
- # Numbers come out in the canonical spelling (+1.50+ becomes +1.5+)
328
- # and string escapes are normalized.
337
+ # would produce, and it accepts exactly what {.parse} accepts; under
338
+ # +allow_duplicate_key+, repeated keys pass through instead of being
339
+ # collapsed (a reformatter must not silently drop data). Numbers come
340
+ # out in the canonical spelling (+1.50+ becomes +1.5+) and string
341
+ # escapes are normalized.
329
342
  #
330
343
  # @example
331
344
  # NOSJ.minify(%({ "a": [1, 2],\n "b": "x" })) #=> '{"a":[1,2],"b":"x"}'
332
345
  #
333
346
  # @param json [String] the document (UTF-8 or US-ASCII)
334
347
  # @param opts [Hash, nil] acceptance options (+allow_nan+,
335
- # +allow_trailing_comma+, +max_nesting+); trailing commas are
336
- # normalized away when accepted
348
+ # +allow_trailing_comma+, +allow_duplicate_key+, +max_nesting+);
349
+ # trailing commas are normalized away when accepted
337
350
  # @return [String] the minified document
338
351
  # @raise [ParserError] when the document is malformed
339
352
  # @raise [NestingError] past +max_nesting+
@@ -357,8 +370,7 @@ module NOSJ
357
370
  # @return [String] the reformatted document
358
371
  # @raise [ParserError] when the document is malformed
359
372
  # @raise [NestingError] past +max_nesting+
360
- # @raise [GeneratorError] when +ascii_only+ meets a lone-surrogate
361
- # string it cannot represent
373
+ # @raise [GeneratorError] for a non-finite float without +allow_nan+
362
374
  def self.reformat(json, opts = nil)
363
375
  if opts&.key?(:pretty)
364
376
  pretty = opts[:pretty]
@@ -597,7 +609,9 @@ module NOSJ
597
609
  #
598
610
  # @param source [String] the JSON document (UTF-8 or US-ASCII)
599
611
  # @param opts [Hash, nil] +max_nesting+, +allow_nan+,
600
- # +allow_trailing_comma+ (acceptance options only)
612
+ # +allow_trailing_comma+ (acceptance options only). Being a
613
+ # diagnostic, stats also describes documents {.parse} would refuse
614
+ # for a repeated key or a lone surrogate.
601
615
  # @return [Hash] the statistics described above
602
616
  # @raise [ParserError] when the document is malformed or not UTF-8
603
617
  def self.stats(source, opts = nil)
data/sig/nosj.rbs CHANGED
@@ -14,9 +14,10 @@ module NOSJ
14
14
  # Options arrive as a positional Hash (the JSON gem's own calling
15
15
  # convention; an explicit **kwargs would allocate per call), nil when
16
16
  # omitted. Parse: symbolize_names, freeze, max_nesting, allow_nan,
17
- # allow_trailing_comma. Generate: indent, space, space_before,
18
- # object_nl, array_nl, max_nesting, allow_nan, ascii_only,
19
- # script_safe/escape_slash, strict, depth, buffer_initial_length.
17
+ # allow_trailing_comma, allow_duplicate_key. Generate: indent, space,
18
+ # space_before, object_nl, array_nl, max_nesting, allow_nan,
19
+ # ascii_only, script_safe, strict, depth, buffer_initial_length,
20
+ # allow_duplicate_key. Unknown keys raise ArgumentError (json 3).
20
21
  type opts = Hash[Symbol, untyped]?
21
22
 
22
23
  # One NOSJ.dig path element (negative Integer indices resolve to nil).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nosj
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -64,6 +64,8 @@ files:
64
64
  - ext/nosj/src/lazy.rs
65
65
  - ext/nosj/src/lib.rs
66
66
  - ext/nosj/src/lines.rs
67
+ - ext/nosj/src/locate.rs
68
+ - ext/nosj/src/opt_reader.rs
67
69
  - ext/nosj/src/parse.rs
68
70
  - ext/nosj/src/patch.rs
69
71
  - ext/nosj/src/pointer.rs
@@ -104,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
106
  - !ruby/object:Gem::Version
105
107
  version: 3.3.11
106
108
  requirements: []
107
- rubygems_version: 4.0.16
109
+ rubygems_version: 4.0.20
108
110
  specification_version: 4
109
111
  summary: An extremely fast JSON parser and generator for Ruby, written in Rust and
110
112
  SIMD-accelerated on every platform.