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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -0
- data/Cargo.lock +3 -3
- data/README.md +220 -19
- data/ext/nosj/Cargo.toml +2 -2
- data/ext/nosj/fuzz/Cargo.toml +41 -0
- data/ext/nosj/fuzz/fuzz_targets/lines.rs +9 -0
- data/ext/nosj/fuzz/fuzz_targets/patch.rs +10 -0
- data/ext/nosj/fuzz/fuzz_targets/reformat.rs +10 -0
- data/ext/nosj/fuzz/src/lib.rs +48 -0
- data/ext/nosj/fuzz/src/prelude.rb +400 -0
- data/ext/nosj/src/errors.rs +171 -0
- data/ext/nosj/src/files.rs +29 -7
- data/ext/nosj/src/gen/errors.rs +2 -9
- data/ext/nosj/src/gen/mod.rs +156 -12
- data/ext/nosj/src/gen/opts.rs +88 -10
- data/ext/nosj/src/gen/ruby.rs +51 -0
- data/ext/nosj/src/gen/walker.rs +97 -59
- data/ext/nosj/src/lazy.rs +42 -22
- data/ext/nosj/src/lib.rs +30 -0
- data/ext/nosj/src/lines.rs +90 -0
- data/ext/nosj/src/parse.rs +55 -14
- data/ext/nosj/src/patch.rs +547 -0
- data/ext/nosj/src/pointer.rs +12 -5
- data/ext/nosj/src/reformat.rs +320 -0
- data/ext/nosj/src/sink.rs +11 -0
- data/ext/nosj/src/stats.rs +301 -0
- data/lib/nosj/json.rb +30 -6
- data/lib/nosj/multi_json.rb +1 -1
- data/lib/nosj/rails.rb +76 -0
- data/lib/nosj/version.rb +1 -1
- data/lib/nosj.rb +351 -5
- data/sig/nosj.rbs +66 -0
- metadata +16 -2
|
@@ -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("ed);
|
|
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
|
+
}
|