nosj 0.2.0 → 0.3.1

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,320 @@
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
+ /// Non-finite floats pass through as literals only when the
48
+ /// generate side allows them; see [`PipeSink::float`].
49
+ allow_nan: bool,
50
+ }
51
+
52
+ const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
53
+
54
+ /// WTF-8 lone surrogates arrive as one 3-byte sequence with this lead
55
+ /// byte (the only ill-formed runs the parser ever emits).
56
+ const WTF8_SURROGATE_LEAD: u8 = 0xED;
57
+ const WTF8_SURROGATE_LEN: usize = 3;
58
+ /// Payload bits of a UTF-8 lead / continuation byte.
59
+ const UTF8_LEAD3_BITS: u32 = 0x0F;
60
+ const UTF8_CONT_BITS: u32 = 0x3F;
61
+
62
+ /// Quote and escape WTF-8 content: valid UTF-8 runs go through the
63
+ /// configured escape kernel, and lone-surrogate sequences re-escape as
64
+ /// `\uXXXX`, so the output reparses to the identical string in every
65
+ /// mode (raw WTF-8 bytes would not: the parser requires UTF-8 input).
66
+ /// This deliberately diverges from `generate`, which refuses
67
+ /// broken-coderange strings: a reformatter must accept everything the
68
+ /// parser accepts.
69
+ fn quote_wtf8(out: &mut Vec<u8>, bytes: &[u8], mode: EscapeMode) {
70
+ out.push(b'"');
71
+ let mut rest = bytes;
72
+ loop {
73
+ match std::str::from_utf8(rest) {
74
+ Ok(s) => {
75
+ nosj::emit::escape_into(out, s.as_bytes(), mode);
76
+ break;
77
+ }
78
+ Err(e) => {
79
+ let valid = e.valid_up_to();
80
+ nosj::emit::escape_into(out, &rest[..valid], mode);
81
+ let sur = &rest[valid..];
82
+ debug_assert!(
83
+ sur.len() >= WTF8_SURROGATE_LEN && sur[0] == WTF8_SURROGATE_LEAD,
84
+ "parser only emits lone-surrogate WTF-8"
85
+ );
86
+ // Standard 3-byte UTF-8 decode of the surrogate
87
+ // codepoint (U+D800..U+DFFF), re-emitted as \uXXXX.
88
+ let cp = ((u32::from(sur[0]) & UTF8_LEAD3_BITS) << 12)
89
+ | ((u32::from(sur[1]) & UTF8_CONT_BITS) << 6)
90
+ | (u32::from(sur[2]) & UTF8_CONT_BITS);
91
+ out.extend_from_slice(b"\\u");
92
+ for shift in [12, 8, 4, 0] {
93
+ out.push(HEX_DIGITS[((cp >> shift) & 0xF) as usize]);
94
+ }
95
+ rest = &sur[WTF8_SURROGATE_LEN..];
96
+ }
97
+ }
98
+ }
99
+ out.push(b'"');
100
+ }
101
+
102
+ impl PipeSink<'_> {
103
+ fn enter(&mut self) -> Result<(), SinkAbort> {
104
+ self.depth += 1;
105
+ if self.depth > self.max_nesting {
106
+ return Err(SinkAbort::TooDeep);
107
+ }
108
+ Ok(())
109
+ }
110
+ }
111
+
112
+ impl nosj::Sink for PipeSink<'_> {
113
+ type Error = SinkAbort;
114
+
115
+ fn null(&mut self) -> Result<(), SinkAbort> {
116
+ self.w.null();
117
+ Ok(())
118
+ }
119
+
120
+ fn boolean(&mut self, value: bool) -> Result<(), SinkAbort> {
121
+ self.w.boolean(value);
122
+ Ok(())
123
+ }
124
+
125
+ fn int(&mut self, value: i64) -> Result<(), SinkAbort> {
126
+ self.w.int(value);
127
+ Ok(())
128
+ }
129
+
130
+ fn float(&mut self, value: f64) -> Result<(), SinkAbort> {
131
+ if value.is_finite() {
132
+ self.w.float(value);
133
+ return Ok(());
134
+ }
135
+ let spelling = if value.is_nan() {
136
+ "NaN"
137
+ } else if value > 0.0 {
138
+ "Infinity"
139
+ } else {
140
+ "-Infinity"
141
+ };
142
+ // Not just the allow_nan keywords: a huge-exponent literal
143
+ // (1e999) parses to Infinity in strict mode too, and generate
144
+ // refuses to emit it. Gem parity either way. The parser only
145
+ // delivers the f64, never the original digits, so passing the
146
+ // source spelling through is not an option. One asymmetry
147
+ // follows: the pipe streams duplicate-key entries that
148
+ // parse's last-key-wins would discard, so a document with an
149
+ // overflowing literal shadowed by a duplicate key raises here
150
+ // even though generate(parse(x)) would succeed.
151
+ if !self.allow_nan {
152
+ return Err(SinkAbort::NonFiniteFloat(spelling));
153
+ }
154
+ self.w.value_raw(spelling.as_bytes());
155
+ Ok(())
156
+ }
157
+
158
+ fn big_int(&mut self, digits: &str) -> Result<(), SinkAbort> {
159
+ // Verbatim digit passthrough: no bignum is ever built.
160
+ self.w.value_raw(digits.as_bytes());
161
+ Ok(())
162
+ }
163
+
164
+ fn str(&mut self, value: &str) -> Result<(), SinkAbort> {
165
+ self.w.str(value);
166
+ Ok(())
167
+ }
168
+
169
+ fn str_bytes(&mut self, value: &[u8]) -> Result<(), SinkAbort> {
170
+ // Rare path (lone-surrogate content); a per-call buffer is fine.
171
+ let mut quoted = Vec::with_capacity(value.len() + 8);
172
+ quote_wtf8(&mut quoted, value, self.mode);
173
+ self.w.value_raw(&quoted);
174
+ Ok(())
175
+ }
176
+
177
+ fn key(&mut self, key: &str) -> Result<(), SinkAbort> {
178
+ self.w.key(key);
179
+ Ok(())
180
+ }
181
+
182
+ fn key_bytes(&mut self, _key: &[u8]) -> Result<(), SinkAbort> {
183
+ // A lone-surrogate KEY has no pre-serialized escape hatch in
184
+ // the Writer (values have value_raw; a key_raw is on the crate
185
+ // wishlist), so this pathological case keeps generate's
186
+ // refusal semantics.
187
+ Err(SinkAbort::BrokenUtf8Output)
188
+ }
189
+
190
+ fn begin_array(&mut self) -> Result<(), SinkAbort> {
191
+ self.enter()?;
192
+ self.w.begin_array();
193
+ Ok(())
194
+ }
195
+
196
+ fn begin_object(&mut self) -> Result<(), SinkAbort> {
197
+ self.enter()?;
198
+ self.w.begin_object();
199
+ Ok(())
200
+ }
201
+
202
+ fn mark(&self) -> usize {
203
+ 0
204
+ }
205
+
206
+ fn end_array(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
207
+ self.depth -= 1;
208
+ self.w.end_array();
209
+ Ok(())
210
+ }
211
+
212
+ fn end_object(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
213
+ self.depth -= 1;
214
+ self.w.end_object();
215
+ Ok(())
216
+ }
217
+ }
218
+
219
+ /// Map a GenConfig (the gem's generate-option decoding) onto the
220
+ /// crate's WriteOptions.
221
+ fn write_options(cfg: &GenConfig) -> WriteOptions {
222
+ let mut w = WriteOptions::COMPACT;
223
+ w.indent = cfg.indent.clone();
224
+ w.space = cfg.space.clone();
225
+ w.space_before = cfg.space_before.clone();
226
+ w.object_nl = cfg.object_nl.clone();
227
+ w.array_nl = cfg.array_nl.clone();
228
+ w.escape = cfg.mode;
229
+ w.float = FloatFormat::Fpconv;
230
+ w
231
+ }
232
+
233
+ /// Run the pipe over already-UTF-8-vouched bytes.
234
+ fn reformat_over(ruby: &Ruby, input: &[u8], opts: Value) -> Result<RString, Error> {
235
+ let po = parse_native_opts(ruby, opts)?;
236
+ let built;
237
+ let gcfg: &GenConfig = if opts.is_nil() {
238
+ &DEFAULT_CONFIG
239
+ } else {
240
+ built = parse_gen_opts(ruby, opts)?.0;
241
+ &built
242
+ };
243
+ let wopts = write_options(gcfg);
244
+
245
+ PIPE_BUF.with(|cell| {
246
+ let mut buf = cell.borrow_mut();
247
+ buf.clear();
248
+ // The output is at least input-sized for minify-shaped runs.
249
+ buf.reserve(input.len());
250
+ let mut sink = PipeSink {
251
+ w: Writer::new(&mut buf, &wopts),
252
+ depth: 0,
253
+ max_nesting: po.max_nesting,
254
+ mode: gcfg.mode,
255
+ allow_nan: gcfg.allow_nan,
256
+ };
257
+ let result = PULL_STATE.with(|state_cell| {
258
+ let mut state = state_cell.borrow_mut();
259
+ // Safety: callers verified UTF-8 (coderange or full scan).
260
+ unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, po.popts) }
261
+ });
262
+ match result {
263
+ Ok(()) => finish_string(&buf),
264
+ Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
265
+ ruby,
266
+ format!(
267
+ "nesting of {} is too deep",
268
+ po.max_nesting.saturating_add(1)
269
+ ),
270
+ )),
271
+ Err(nosj::DriveError::Sink(SinkAbort::BrokenUtf8Output)) => Err(Error::new(
272
+ // Gem parity: generate raises GeneratorError for a
273
+ // string ascii_only cannot represent.
274
+ nosj_exception(ruby, "GeneratorError"),
275
+ "source sequence is illegal/malformed utf-8",
276
+ )),
277
+ Err(nosj::DriveError::Sink(SinkAbort::NonFiniteFloat(spelling))) => Err(Error::new(
278
+ nosj_exception(ruby, "GeneratorError"),
279
+ format!("{spelling} not allowed in JSON"),
280
+ )),
281
+ Err(nosj::DriveError::Sink(_)) => {
282
+ Err(parser_error(ruby, "reformat pass aborted".into()))
283
+ }
284
+ Err(nosj::DriveError::Parse(e)) => {
285
+ Err(parser_error_at(ruby, input, e.offset, e.to_string()))
286
+ }
287
+ }
288
+ })
289
+ }
290
+
291
+ /// `NOSJ.reformat_native(source, opts)`: minify and reformat share
292
+ /// this entry; the formatting defaults are compact.
293
+ pub fn reformat_native(
294
+ ruby: &Ruby,
295
+ _rb_self: Value,
296
+ data: RString,
297
+ opts: Value,
298
+ ) -> Result<RString, Error> {
299
+ let input = utf8_input(ruby, &data)?;
300
+ reformat_over(ruby, input, opts)
301
+ }
302
+
303
+ /// `NOSJ.reformat_file_native(path, opts)`: the pipe over a read-only
304
+ /// memory map; the input document never becomes a Ruby String.
305
+ pub fn reformat_file_native(
306
+ ruby: &Ruby,
307
+ _rb_self: Value,
308
+ path: RString,
309
+ opts: Value,
310
+ ) -> Result<RString, Error> {
311
+ let p = path.to_string()?;
312
+ // Mapping a zero-length file fails with EINVAL on Linux; route an
313
+ // empty file to the parser's own "unexpected end of input" so the
314
+ // error class is deterministic across platforms. Metadata failures
315
+ // fall through for the mapper's Errno.
316
+ if std::fs::metadata(&p).is_ok_and(|m| m.len() == 0) {
317
+ return reformat_over(ruby, &[], opts);
318
+ }
319
+ with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, opts))
320
+ }
data/ext/nosj/src/sink.rs CHANGED
@@ -22,6 +22,17 @@ 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,
31
+ /// The reformat pipe met a non-finite float without `allow_nan`.
32
+ /// Parsing accepts huge-exponent literals like 1e999 as Infinity
33
+ /// even in strict mode (gem parity), but generation refuses them;
34
+ /// carries the JSON spelling for the gem-exact error message.
35
+ NonFiniteFloat(&'static str),
25
36
  }
26
37
 
27
38
  /// `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
+ }