nosj 0.2.0 → 0.3.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.
@@ -0,0 +1,301 @@
1
+ //! Reformat without building values: `NOSJ.minify` / `NOSJ.reformat`
2
+ //! drive the full parser straight into the crate's `Writer`, a pure
3
+ //! event-to-bytes pipe. No Ruby object is allocated for the document,
4
+ //! only the result String; between the SIMD scan on the way in and the
5
+ //! SIMD escape kernels on the way out there is nothing else.
6
+ //!
7
+ //! Output is exactly what `NOSJ.generate(NOSJ.parse(json), opts)`
8
+ //! would produce, with two deliberate differences: duplicate object
9
+ //! keys pass through (a reformatter must not silently drop data the
10
+ //! way parse's last-key-wins materialization does), and lone-surrogate
11
+ //! string values re-escape as `\uXXXX` instead of raising (the output
12
+ //! must reparse; raw WTF-8 would not). Numbers come out in the gem's
13
+ //! canonical spelling (`1.50` becomes `1.5`), and string escapes are
14
+ //! normalized by the emission kernels.
15
+
16
+ use std::cell::RefCell;
17
+
18
+ use magnus::value::ReprValue;
19
+ use magnus::{Error, RString, Ruby, Value};
20
+ use nosj::emit::EscapeMode;
21
+ use nosj::{FloatFormat, WriteOptions, Writer};
22
+
23
+ use crate::errors::{nesting_error, nosj_exception, parser_error, parser_error_at};
24
+ use crate::files::with_mapped_file;
25
+ use crate::gen::opts::{parse_gen_opts, GenConfig, DEFAULT_CONFIG};
26
+ use crate::parse::{parse_native_opts, utf8_input};
27
+ use crate::patch::finish_string;
28
+ use crate::sink::SinkAbort;
29
+ use crate::state::PULL_STATE;
30
+
31
+ thread_local! {
32
+ /// Pooled output buffer: capacity survives across calls. The pipe
33
+ /// never calls back into Ruby, so the borrow spans the whole drive
34
+ /// without any reentrancy concern.
35
+ static PIPE_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
36
+ }
37
+
38
+ /// The event-to-Writer pipe. Structure events forward to the Writer's
39
+ /// grammar state (separators, layout, indentation); scalar events
40
+ /// forward to its emission kernels.
41
+ struct PipeSink<'a> {
42
+ w: Writer<'a>,
43
+ depth: usize,
44
+ max_nesting: usize,
45
+ /// For re-escaping WTF-8 string content (see [`quote_wtf8`]).
46
+ mode: EscapeMode,
47
+ }
48
+
49
+ const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
50
+
51
+ /// WTF-8 lone surrogates arrive as one 3-byte sequence with this lead
52
+ /// byte (the only ill-formed runs the parser ever emits).
53
+ const WTF8_SURROGATE_LEAD: u8 = 0xED;
54
+ const WTF8_SURROGATE_LEN: usize = 3;
55
+ /// Payload bits of a UTF-8 lead / continuation byte.
56
+ const UTF8_LEAD3_BITS: u32 = 0x0F;
57
+ const UTF8_CONT_BITS: u32 = 0x3F;
58
+
59
+ /// Quote and escape WTF-8 content: valid UTF-8 runs go through the
60
+ /// configured escape kernel, and lone-surrogate sequences re-escape as
61
+ /// `\uXXXX`, so the output reparses to the identical string in every
62
+ /// mode (raw WTF-8 bytes would not: the parser requires UTF-8 input).
63
+ /// This deliberately diverges from `generate`, which refuses
64
+ /// broken-coderange strings: a reformatter must accept everything the
65
+ /// parser accepts.
66
+ fn quote_wtf8(out: &mut Vec<u8>, bytes: &[u8], mode: EscapeMode) {
67
+ out.push(b'"');
68
+ let mut rest = bytes;
69
+ loop {
70
+ match std::str::from_utf8(rest) {
71
+ Ok(s) => {
72
+ nosj::emit::escape_into(out, s.as_bytes(), mode);
73
+ break;
74
+ }
75
+ Err(e) => {
76
+ let valid = e.valid_up_to();
77
+ nosj::emit::escape_into(out, &rest[..valid], mode);
78
+ let sur = &rest[valid..];
79
+ debug_assert!(
80
+ sur.len() >= WTF8_SURROGATE_LEN && sur[0] == WTF8_SURROGATE_LEAD,
81
+ "parser only emits lone-surrogate WTF-8"
82
+ );
83
+ // Standard 3-byte UTF-8 decode of the surrogate
84
+ // codepoint (U+D800..U+DFFF), re-emitted as \uXXXX.
85
+ let cp = ((u32::from(sur[0]) & UTF8_LEAD3_BITS) << 12)
86
+ | ((u32::from(sur[1]) & UTF8_CONT_BITS) << 6)
87
+ | (u32::from(sur[2]) & UTF8_CONT_BITS);
88
+ out.extend_from_slice(b"\\u");
89
+ for shift in [12, 8, 4, 0] {
90
+ out.push(HEX_DIGITS[((cp >> shift) & 0xF) as usize]);
91
+ }
92
+ rest = &sur[WTF8_SURROGATE_LEN..];
93
+ }
94
+ }
95
+ }
96
+ out.push(b'"');
97
+ }
98
+
99
+ impl PipeSink<'_> {
100
+ fn enter(&mut self) -> Result<(), SinkAbort> {
101
+ self.depth += 1;
102
+ if self.depth > self.max_nesting {
103
+ return Err(SinkAbort::TooDeep);
104
+ }
105
+ Ok(())
106
+ }
107
+ }
108
+
109
+ impl nosj::Sink for PipeSink<'_> {
110
+ type Error = SinkAbort;
111
+
112
+ fn null(&mut self) -> Result<(), SinkAbort> {
113
+ self.w.null();
114
+ Ok(())
115
+ }
116
+
117
+ fn boolean(&mut self, value: bool) -> Result<(), SinkAbort> {
118
+ self.w.boolean(value);
119
+ Ok(())
120
+ }
121
+
122
+ fn int(&mut self, value: i64) -> Result<(), SinkAbort> {
123
+ self.w.int(value);
124
+ Ok(())
125
+ }
126
+
127
+ fn float(&mut self, value: f64) -> Result<(), SinkAbort> {
128
+ if value.is_finite() {
129
+ self.w.float(value);
130
+ } else {
131
+ // Reached only when the parse accepted them (allow_nan);
132
+ // the gem's literals go straight through.
133
+ self.w.value_raw(if value.is_nan() {
134
+ b"NaN"
135
+ } else if value > 0.0 {
136
+ b"Infinity"
137
+ } else {
138
+ b"-Infinity"
139
+ });
140
+ }
141
+ Ok(())
142
+ }
143
+
144
+ fn big_int(&mut self, digits: &str) -> Result<(), SinkAbort> {
145
+ // Verbatim digit passthrough: no bignum is ever built.
146
+ self.w.value_raw(digits.as_bytes());
147
+ Ok(())
148
+ }
149
+
150
+ fn str(&mut self, value: &str) -> Result<(), SinkAbort> {
151
+ self.w.str(value);
152
+ Ok(())
153
+ }
154
+
155
+ fn str_bytes(&mut self, value: &[u8]) -> Result<(), SinkAbort> {
156
+ // Rare path (lone-surrogate content); a per-call buffer is fine.
157
+ let mut quoted = Vec::with_capacity(value.len() + 8);
158
+ quote_wtf8(&mut quoted, value, self.mode);
159
+ self.w.value_raw(&quoted);
160
+ Ok(())
161
+ }
162
+
163
+ fn key(&mut self, key: &str) -> Result<(), SinkAbort> {
164
+ self.w.key(key);
165
+ Ok(())
166
+ }
167
+
168
+ fn key_bytes(&mut self, _key: &[u8]) -> Result<(), SinkAbort> {
169
+ // A lone-surrogate KEY has no pre-serialized escape hatch in
170
+ // the Writer (values have value_raw; a key_raw is on the crate
171
+ // wishlist), so this pathological case keeps generate's
172
+ // refusal semantics.
173
+ Err(SinkAbort::BrokenUtf8Output)
174
+ }
175
+
176
+ fn begin_array(&mut self) -> Result<(), SinkAbort> {
177
+ self.enter()?;
178
+ self.w.begin_array();
179
+ Ok(())
180
+ }
181
+
182
+ fn begin_object(&mut self) -> Result<(), SinkAbort> {
183
+ self.enter()?;
184
+ self.w.begin_object();
185
+ Ok(())
186
+ }
187
+
188
+ fn mark(&self) -> usize {
189
+ 0
190
+ }
191
+
192
+ fn end_array(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
193
+ self.depth -= 1;
194
+ self.w.end_array();
195
+ Ok(())
196
+ }
197
+
198
+ fn end_object(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
199
+ self.depth -= 1;
200
+ self.w.end_object();
201
+ Ok(())
202
+ }
203
+ }
204
+
205
+ /// Map a GenConfig (the gem's generate-option decoding) onto the
206
+ /// crate's WriteOptions.
207
+ fn write_options(cfg: &GenConfig) -> WriteOptions {
208
+ let mut w = WriteOptions::COMPACT;
209
+ w.indent = cfg.indent.clone();
210
+ w.space = cfg.space.clone();
211
+ w.space_before = cfg.space_before.clone();
212
+ w.object_nl = cfg.object_nl.clone();
213
+ w.array_nl = cfg.array_nl.clone();
214
+ w.escape = cfg.mode;
215
+ w.float = FloatFormat::Fpconv;
216
+ w
217
+ }
218
+
219
+ /// Run the pipe over already-UTF-8-vouched bytes.
220
+ fn reformat_over(ruby: &Ruby, input: &[u8], opts: Value) -> Result<RString, Error> {
221
+ let po = parse_native_opts(ruby, opts)?;
222
+ let built;
223
+ let gcfg: &GenConfig = if opts.is_nil() {
224
+ &DEFAULT_CONFIG
225
+ } else {
226
+ built = parse_gen_opts(ruby, opts)?.0;
227
+ &built
228
+ };
229
+ let wopts = write_options(gcfg);
230
+
231
+ PIPE_BUF.with(|cell| {
232
+ let mut buf = cell.borrow_mut();
233
+ buf.clear();
234
+ // The output is at least input-sized for minify-shaped runs.
235
+ buf.reserve(input.len());
236
+ let mut sink = PipeSink {
237
+ w: Writer::new(&mut buf, &wopts),
238
+ depth: 0,
239
+ max_nesting: po.max_nesting,
240
+ mode: gcfg.mode,
241
+ };
242
+ let result = PULL_STATE.with(|state_cell| {
243
+ let mut state = state_cell.borrow_mut();
244
+ // Safety: callers verified UTF-8 (coderange or full scan).
245
+ unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, po.popts) }
246
+ });
247
+ match result {
248
+ Ok(()) => finish_string(&buf),
249
+ Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
250
+ ruby,
251
+ format!(
252
+ "nesting of {} is too deep",
253
+ po.max_nesting.saturating_add(1)
254
+ ),
255
+ )),
256
+ Err(nosj::DriveError::Sink(SinkAbort::BrokenUtf8Output)) => Err(Error::new(
257
+ // Gem parity: generate raises GeneratorError for a
258
+ // string ascii_only cannot represent.
259
+ nosj_exception(ruby, "GeneratorError"),
260
+ "source sequence is illegal/malformed utf-8",
261
+ )),
262
+ Err(nosj::DriveError::Sink(_)) => {
263
+ Err(parser_error(ruby, "reformat pass aborted".into()))
264
+ }
265
+ Err(nosj::DriveError::Parse(e)) => {
266
+ Err(parser_error_at(ruby, input, e.offset, e.to_string()))
267
+ }
268
+ }
269
+ })
270
+ }
271
+
272
+ /// `NOSJ.reformat_native(source, opts)`: minify and reformat share
273
+ /// this entry; the formatting defaults are compact.
274
+ pub fn reformat_native(
275
+ ruby: &Ruby,
276
+ _rb_self: Value,
277
+ data: RString,
278
+ opts: Value,
279
+ ) -> Result<RString, Error> {
280
+ let input = utf8_input(ruby, &data)?;
281
+ reformat_over(ruby, input, opts)
282
+ }
283
+
284
+ /// `NOSJ.reformat_file_native(path, opts)`: the pipe over a read-only
285
+ /// memory map; the input document never becomes a Ruby String.
286
+ pub fn reformat_file_native(
287
+ ruby: &Ruby,
288
+ _rb_self: Value,
289
+ path: RString,
290
+ opts: Value,
291
+ ) -> Result<RString, Error> {
292
+ let p = path.to_string()?;
293
+ // Mapping a zero-length file fails with EINVAL on Linux; route an
294
+ // empty file to the parser's own "unexpected end of input" so the
295
+ // error class is deterministic across platforms. Metadata failures
296
+ // fall through for the mapper's Errno.
297
+ if std::fs::metadata(&p).is_ok_and(|m| m.len() == 0) {
298
+ return reformat_over(ruby, &[], opts);
299
+ }
300
+ with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, opts))
301
+ }
data/ext/nosj/src/sink.rs CHANGED
@@ -22,6 +22,12 @@ pub(crate) enum SinkAbort {
22
22
  Overflow,
23
23
  BadBigint,
24
24
  TooDeep,
25
+ /// The reformat pipe met a WTF-8 (lone-surrogate) object KEY,
26
+ /// which the Writer has no pre-serialized escape hatch for; gem
27
+ /// parity is the GeneratorError `generate` raises on the
28
+ /// equivalent broken-coderange string. (String VALUES re-escape
29
+ /// as \uXXXX instead.)
30
+ BrokenUtf8Output,
25
31
  }
26
32
 
27
33
  /// `RB_INT2FIX` ported from Ruby's public inline headers: fixnums are
@@ -0,0 +1,301 @@
1
+ //! `NOSJ.stats` / `NOSJ.stats_file`: one full-parser pass into a
2
+ //! counting sink (the `NOSJ.valid?` machinery with counters instead of
3
+ //! discards), answering "what is this 40 MB blob" without building a
4
+ //! single Ruby value for the document. Only the small result Hash is
5
+ //! allocated, after the pass.
6
+
7
+ use ahash::AHashMap;
8
+ use magnus::value::ReprValue;
9
+ use magnus::{Error, RHash, RString, Ruby, Value};
10
+
11
+ use crate::errors::{nesting_error, parser_error, parser_error_at};
12
+ use crate::files::with_mapped_file;
13
+ use crate::parse::{parse_native_opts, utf8_input};
14
+ use crate::sink::SinkAbort;
15
+ use crate::state::PULL_STATE;
16
+
17
+ /// What the document's root value was; reported as a Symbol. The
18
+ /// Default is never observable (a successful pass always saw a root
19
+ /// event); it only makes the sink derivable.
20
+ #[derive(Clone, Copy, Default)]
21
+ enum RootKind {
22
+ Object,
23
+ Array,
24
+ String,
25
+ Integer,
26
+ Float,
27
+ Boolean,
28
+ #[default]
29
+ Null,
30
+ }
31
+
32
+ impl RootKind {
33
+ fn name(self) -> &'static str {
34
+ match self {
35
+ Self::Object => "object",
36
+ Self::Array => "array",
37
+ Self::String => "string",
38
+ Self::Integer => "integer",
39
+ Self::Float => "float",
40
+ Self::Boolean => "boolean",
41
+ Self::Null => "null",
42
+ }
43
+ }
44
+ }
45
+
46
+ /// Counting sink: every event increments counters; nothing else is
47
+ /// retained except the key histogram (memory proportional to the
48
+ /// number of UNIQUE keys, unbounded by design: this is a diagnostic
49
+ /// pass over a document the caller already holds).
50
+ #[derive(Default)]
51
+ struct StatsSink {
52
+ depth: usize,
53
+ max_nesting: usize,
54
+ root: Option<RootKind>,
55
+ max_depth: usize,
56
+ objects: u64,
57
+ arrays: u64,
58
+ max_object_entries: u64,
59
+ max_array_length: u64,
60
+ nulls: u64,
61
+ booleans: u64,
62
+ integers: u64,
63
+ floats: u64,
64
+ strings: u64,
65
+ string_bytes: u64,
66
+ max_string_bytes: u64,
67
+ keys: u64,
68
+ histogram: AHashMap<Box<str>, u64>,
69
+ }
70
+
71
+ impl StatsSink {
72
+ fn root(&mut self, kind: RootKind) {
73
+ if self.root.is_none() {
74
+ self.root = Some(kind);
75
+ }
76
+ }
77
+
78
+ fn string(&mut self, bytes: u64) {
79
+ self.strings += 1;
80
+ self.string_bytes += bytes;
81
+ self.max_string_bytes = self.max_string_bytes.max(bytes);
82
+ }
83
+
84
+ fn count_key(&mut self, key: &str) {
85
+ self.keys += 1;
86
+ *self.histogram.entry(Box::from(key)).or_insert(0) += 1;
87
+ }
88
+
89
+ fn enter_container(&mut self, kind: RootKind) -> Result<(), SinkAbort> {
90
+ self.root(kind);
91
+ self.depth += 1;
92
+ if self.depth > self.max_nesting {
93
+ return Err(SinkAbort::TooDeep);
94
+ }
95
+ self.max_depth = self.max_depth.max(self.depth);
96
+ Ok(())
97
+ }
98
+
99
+ fn total_values(&self) -> u64 {
100
+ self.objects
101
+ + self.arrays
102
+ + self.strings
103
+ + self.integers
104
+ + self.floats
105
+ + self.booleans
106
+ + self.nulls
107
+ }
108
+ }
109
+
110
+ impl nosj::Sink for StatsSink {
111
+ type Error = SinkAbort;
112
+
113
+ fn null(&mut self) -> Result<(), SinkAbort> {
114
+ self.root(RootKind::Null);
115
+ self.nulls += 1;
116
+ Ok(())
117
+ }
118
+
119
+ fn boolean(&mut self, _: bool) -> Result<(), SinkAbort> {
120
+ self.root(RootKind::Boolean);
121
+ self.booleans += 1;
122
+ Ok(())
123
+ }
124
+
125
+ fn int(&mut self, _: i64) -> Result<(), SinkAbort> {
126
+ self.root(RootKind::Integer);
127
+ self.integers += 1;
128
+ Ok(())
129
+ }
130
+
131
+ fn float(&mut self, _: f64) -> Result<(), SinkAbort> {
132
+ self.root(RootKind::Float);
133
+ self.floats += 1;
134
+ Ok(())
135
+ }
136
+
137
+ fn big_int(&mut self, _: &str) -> Result<(), SinkAbort> {
138
+ self.root(RootKind::Integer);
139
+ self.integers += 1;
140
+ Ok(())
141
+ }
142
+
143
+ fn str(&mut self, value: &str) -> Result<(), SinkAbort> {
144
+ self.root(RootKind::String);
145
+ self.string(value.len() as u64);
146
+ Ok(())
147
+ }
148
+
149
+ fn str_bytes(&mut self, value: &[u8]) -> Result<(), SinkAbort> {
150
+ self.root(RootKind::String);
151
+ self.string(value.len() as u64);
152
+ Ok(())
153
+ }
154
+
155
+ fn key(&mut self, key: &str) -> Result<(), SinkAbort> {
156
+ self.count_key(key);
157
+ Ok(())
158
+ }
159
+
160
+ fn key_bytes(&mut self, key: &[u8]) -> Result<(), SinkAbort> {
161
+ // Broken-WTF-8 keys are pathological; a lossy conversion keeps
162
+ // the histogram total consistent with the key count.
163
+ self.count_key(&String::from_utf8_lossy(key));
164
+ Ok(())
165
+ }
166
+
167
+ fn begin_array(&mut self) -> Result<(), SinkAbort> {
168
+ self.enter_container(RootKind::Array)
169
+ }
170
+
171
+ fn begin_object(&mut self) -> Result<(), SinkAbort> {
172
+ self.enter_container(RootKind::Object)
173
+ }
174
+
175
+ fn mark(&self) -> usize {
176
+ 0
177
+ }
178
+
179
+ fn end_array(&mut self, _: usize, len: usize) -> Result<(), SinkAbort> {
180
+ self.depth -= 1;
181
+ self.arrays += 1;
182
+ self.max_array_length = self.max_array_length.max(len as u64);
183
+ Ok(())
184
+ }
185
+
186
+ fn end_object(&mut self, _: usize, pairs: usize) -> Result<(), SinkAbort> {
187
+ self.depth -= 1;
188
+ self.objects += 1;
189
+ self.max_object_entries = self.max_object_entries.max(pairs as u64);
190
+ Ok(())
191
+ }
192
+ }
193
+
194
+ /// Assemble the result Hash. Sub-hashes group related counters; the
195
+ /// histogram is sorted by count (descending), ties by key, so
196
+ /// `.first(10)` reads as a top-10.
197
+ fn stats_to_hash(ruby: &Ruby, s: &StatsSink, byte_size: usize) -> Result<Value, Error> {
198
+ let set = |h: &RHash, name: &str, v: u64| h.aset(ruby.to_symbol(name), v);
199
+
200
+ let values = ruby.hash_new();
201
+ set(&values, "total", s.total_values())?;
202
+ set(&values, "objects", s.objects)?;
203
+ set(&values, "arrays", s.arrays)?;
204
+ set(&values, "strings", s.strings)?;
205
+ set(&values, "integers", s.integers)?;
206
+ set(&values, "floats", s.floats)?;
207
+ set(&values, "booleans", s.booleans)?;
208
+ set(&values, "nulls", s.nulls)?;
209
+
210
+ let keys = ruby.hash_new();
211
+ set(&keys, "total", s.keys)?;
212
+ set(&keys, "unique", s.histogram.len() as u64)?;
213
+
214
+ let containers = ruby.hash_new();
215
+ set(&containers, "max_object_entries", s.max_object_entries)?;
216
+ set(&containers, "max_array_length", s.max_array_length)?;
217
+
218
+ let strings = ruby.hash_new();
219
+ set(&strings, "bytes", s.string_bytes)?;
220
+ set(&strings, "max_bytes", s.max_string_bytes)?;
221
+
222
+ let mut sorted: Vec<(&str, u64)> = s.histogram.iter().map(|(k, &n)| (&**k, n)).collect();
223
+ sorted.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
224
+ let histogram = ruby.hash_new_capa(sorted.len());
225
+ for (key, count) in sorted {
226
+ histogram.aset(ruby.str_new(key), count)?;
227
+ }
228
+
229
+ let out = ruby.hash_new();
230
+ set(&out, "byte_size", byte_size as u64)?;
231
+ let root = s.root.unwrap_or_default();
232
+ out.aset(ruby.to_symbol("root"), ruby.to_symbol(root.name()))?;
233
+ set(&out, "max_depth", s.max_depth as u64)?;
234
+ out.aset(ruby.to_symbol("values"), values)?;
235
+ out.aset(ruby.to_symbol("keys"), keys)?;
236
+ out.aset(ruby.to_symbol("key_histogram"), histogram)?;
237
+ out.aset(ruby.to_symbol("containers"), containers)?;
238
+ out.aset(ruby.to_symbol("strings"), strings)?;
239
+ Ok(out.as_value())
240
+ }
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());
250
+
251
+ let mut sink = StatsSink {
252
+ max_nesting: if nesting_given {
253
+ o.max_nesting
254
+ } else {
255
+ usize::MAX
256
+ },
257
+ ..StatsSink::default()
258
+ };
259
+ let result = PULL_STATE.with(|cell| {
260
+ let mut state = cell.borrow_mut();
261
+ // Safety: callers verified UTF-8 (coderange or a full scan).
262
+ unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, o.popts) }
263
+ });
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
+ }
277
+ }
278
+
279
+ /// `NOSJ.stats(source, opts)`: document statistics from one null-sink
280
+ /// parser pass.
281
+ pub fn stats_native(
282
+ ruby: &Ruby,
283
+ _rb_self: Value,
284
+ data: RString,
285
+ opts: Value,
286
+ ) -> Result<Value, Error> {
287
+ let input = utf8_input(ruby, &data)?;
288
+ stats_over(ruby, input, opts)
289
+ }
290
+
291
+ /// `NOSJ.stats_file(path, opts)`: `NOSJ.stats` against a memory-mapped
292
+ /// file; `byte_size` is the file's size.
293
+ pub fn stats_file_native(
294
+ ruby: &Ruby,
295
+ _rb_self: Value,
296
+ path: RString,
297
+ opts: Value,
298
+ ) -> Result<Value, Error> {
299
+ let p = path.to_string()?;
300
+ with_mapped_file(ruby, &p, |map| stats_over(ruby, &map, opts))
301
+ }
data/lib/nosj/json.rb CHANGED
@@ -6,7 +6,7 @@
6
6
  #
7
7
  # reroutes JSON.parse, JSON.generate, JSON.pretty_generate and JSON.dump
8
8
  # through NOSJ whenever the requested options fall within NOSJ's
9
- # supported set, and falls back to the original json implementation for
9
+ # supported set, and falls back to gem json's own implementation for
10
10
  # everything else (create_additions, object_class/array_class,
11
11
  # decimal_class, on_load procs, JSON::State instances, IO arguments).
12
12
  # Entry points built on JSON.parse (JSON.load, JSON.parse!,
@@ -30,8 +30,11 @@ module NOSJ
30
30
  # Implementation detail of `require "nosj/json"`.
31
31
  # @private
32
32
  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.
33
36
  PARSE_OPTS = %i[symbolize_names freeze max_nesting allow_nan
34
- allow_trailing_comma].freeze
37
+ allow_trailing_comma quirks_mode].freeze
35
38
  GENERATE_OPTS = %i[indent space space_before object_nl array_nl
36
39
  max_nesting allow_nan ascii_only script_safe
37
40
  escape_slash strict depth
@@ -41,7 +44,7 @@ module NOSJ
41
44
 
42
45
  # The fast path handles nil or a plain Hash whose every key NOSJ
43
46
  # implements; anything else (JSON::State, exotic options, string
44
- # keys) belongs to the original implementation.
47
+ # keys) belongs to gem json.
45
48
  def supported?(opts, allowed)
46
49
  return true if opts.nil?
47
50
  return false unless opts.instance_of?(Hash)
@@ -50,8 +53,29 @@ module NOSJ
50
53
  end
51
54
 
52
55
  def parse(source, opts)
56
+ # NOSJ.parse is deliberately strict about encodings (json-3.0
57
+ # semantics), but the drop-in must match the installed gem, which
58
+ # accepts more. BINARY strings holding valid UTF-8 are the big
59
+ # real-world case: Rack delivers request bodies as BINARY, so
60
+ # Rails JSON params come through here. Retagging a dup is cheap
61
+ # (copy-on-write bytes), and the validity scan is memoized
62
+ # coderange the parse would compute anyway. Anything else
63
+ # non-UTF-8 (UTF-16, ...) belongs to gem json, which transcodes.
64
+ if source.is_a?(String)
65
+ case source.encoding
66
+ when Encoding::UTF_8, Encoding::US_ASCII
67
+ # the fast path as-is
68
+ when Encoding::BINARY
69
+ utf8 = source.dup.force_encoding(Encoding::UTF_8)
70
+ source = utf8 if utf8.valid_encoding?
71
+ else
72
+ return ::JSON.nosj_original_parse(source, **(opts || {}))
73
+ end
74
+ end
53
75
  NOSJ.parse(source, opts)
54
- rescue RuntimeError => e
76
+ rescue NOSJ::NestingError => e
77
+ raise ::JSON::NestingError, e.message
78
+ rescue NOSJ::ParserError => e
55
79
  raise ::JSON::ParserError, e.message
56
80
  end
57
81
 
@@ -103,9 +127,9 @@ module JSON
103
127
 
104
128
  def dump(obj, an_io = nil, limit = nil, kwargs = nil)
105
129
  # Fast path for the common shapes, dump(obj) and dump(obj, opts
106
- # hash), mirroring the gem: dump defaults merged under the
130
+ # hash), mirroring gem json: dump defaults merged under the
107
131
  # user's options, NestingError surfaced as ArgumentError. IO and
108
- # limit arguments take the original implementation.
132
+ # limit arguments take gem json's own dump.
109
133
  if limit.nil? && kwargs.nil? && (an_io.nil? || an_io.instance_of?(Hash))
110
134
  opts = _dump_default_options
111
135
  opts = opts.merge(an_io) if an_io
@@ -36,7 +36,7 @@ module NOSJ
36
36
  # @raise [JSON::ParserError] when the document is malformed
37
37
  def load(string, options = {})
38
38
  ::NOSJ.parse(string, options[:symbolize_names] ? SYMBOLIZE : nil)
39
- rescue RuntimeError => e
39
+ rescue ::NOSJ::ParserError, ::NOSJ::NestingError => e
40
40
  raise ParseError, e.message
41
41
  end
42
42