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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/Cargo.lock +3 -3
- data/README.md +215 -19
- data/ext/nosj/Cargo.toml +2 -2
- 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 +87 -9
- 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 +50 -14
- data/ext/nosj/src/patch.rs +547 -0
- data/ext/nosj/src/pointer.rs +12 -5
- data/ext/nosj/src/reformat.rs +301 -0
- data/ext/nosj/src/sink.rs +6 -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 +10 -2
|
@@ -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("ed);
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/nosj/multi_json.rb
CHANGED
|
@@ -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
|
|
39
|
+
rescue ::NOSJ::ParserError, ::NOSJ::NestingError => e
|
|
40
40
|
raise ParseError, e.message
|
|
41
41
|
end
|
|
42
42
|
|