nosj 0.1.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 +110 -1
- data/Cargo.lock +13 -3
- data/README.md +272 -24
- data/ext/nosj/Cargo.toml +5 -2
- data/ext/nosj/src/errors.rs +171 -0
- data/ext/nosj/src/files.rs +240 -0
- data/ext/nosj/src/gen/errors.rs +2 -9
- data/ext/nosj/src/gen/mod.rs +198 -20
- 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 +422 -0
- data/ext/nosj/src/lib.rs +60 -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 +26 -17
- 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/lazy.rb +165 -0
- 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 +434 -5
- data/sig/nosj.rbs +120 -0
- metadata +16 -3
data/ext/nosj/src/lib.rs
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
//! GVL-releasing indexed parse) plus shared option decoding and
|
|
6
6
|
//! input gating.
|
|
7
7
|
//! - `pointer.rs`: partial parsing (dig, at_pointer, batch forms).
|
|
8
|
+
//! - `lazy.rs`: lazy documents (NOSJ.lazy nodes resolving access on
|
|
9
|
+
//! demand over shared document bytes).
|
|
10
|
+
//! - `files.rs`: file entry points (load_file, write_file, and the
|
|
11
|
+
//! mmap-backed load_lazy_file / at_pointer_file / dig_file).
|
|
12
|
+
//! - `stats.rs`: document statistics (NOSJ.stats / stats_file) from a
|
|
13
|
+
//! counting sink pass.
|
|
8
14
|
//! - `sink.rs`: the VALUE-building and validation sinks with their
|
|
9
15
|
//! interned-key caches.
|
|
10
16
|
//! - `state.rs`: per-thread reusable state and the GC-marked value
|
|
@@ -12,11 +18,18 @@
|
|
|
12
18
|
//! - `gen/`: generation (JSON.generate-compatible walker, options,
|
|
13
19
|
//! key cache, protect shims, error mapping).
|
|
14
20
|
|
|
21
|
+
pub mod errors;
|
|
22
|
+
pub mod files;
|
|
15
23
|
pub mod gen;
|
|
24
|
+
pub mod lazy;
|
|
25
|
+
pub mod lines;
|
|
16
26
|
pub mod parse;
|
|
27
|
+
pub mod patch;
|
|
17
28
|
pub mod pointer;
|
|
29
|
+
pub mod reformat;
|
|
18
30
|
pub mod sink;
|
|
19
31
|
pub mod state;
|
|
32
|
+
pub mod stats;
|
|
20
33
|
|
|
21
34
|
use magnus::{method, prelude::*, Error, Ruby};
|
|
22
35
|
|
|
@@ -39,7 +52,54 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
39
52
|
"at_pointers_native",
|
|
40
53
|
method!(pointer::at_pointers_native, 3),
|
|
41
54
|
)?;
|
|
55
|
+
module.define_singleton_method("lazy_native", method!(lazy::lazy_native, 2))?;
|
|
56
|
+
module.define_singleton_method("load_file_native", method!(files::load_file_native, 2))?;
|
|
57
|
+
module.define_singleton_method("write_file_native", method!(files::write_file_native, 3))?;
|
|
58
|
+
module.define_singleton_method(
|
|
59
|
+
"load_lazy_file_native",
|
|
60
|
+
method!(files::load_lazy_file_native, 2),
|
|
61
|
+
)?;
|
|
62
|
+
module.define_singleton_method(
|
|
63
|
+
"at_pointer_file_native",
|
|
64
|
+
method!(files::at_pointer_file_native, 3),
|
|
65
|
+
)?;
|
|
66
|
+
module.define_singleton_method("dig_file_native", method!(files::dig_file_native, 2))?;
|
|
67
|
+
module.define_singleton_method("stats_native", method!(stats::stats_native, 2))?;
|
|
68
|
+
module.define_singleton_method("stats_file_native", method!(stats::stats_file_native, 2))?;
|
|
69
|
+
module.define_singleton_method("each_line_native", method!(lines::each_line_native, 2))?;
|
|
70
|
+
module.define_singleton_method(
|
|
71
|
+
"each_line_file_native",
|
|
72
|
+
method!(lines::each_line_file_native, 2),
|
|
73
|
+
)?;
|
|
74
|
+
module.define_singleton_method(
|
|
75
|
+
"generate_lines_native",
|
|
76
|
+
method!(gen::generate_lines_native, 2),
|
|
77
|
+
)?;
|
|
78
|
+
module.define_singleton_method("write_lines_native", method!(files::write_lines_native, 3))?;
|
|
79
|
+
module.define_singleton_method("splice_native", method!(patch::splice_native, 3))?;
|
|
80
|
+
module.define_singleton_method("patch_native", method!(patch::patch_native, 3))?;
|
|
81
|
+
module.define_singleton_method("reformat_native", method!(reformat::reformat_native, 2))?;
|
|
82
|
+
module.define_singleton_method(
|
|
83
|
+
"reformat_file_native",
|
|
84
|
+
method!(reformat::reformat_file_native, 2),
|
|
85
|
+
)?;
|
|
86
|
+
let lazy_class = module.define_class("Lazy", ruby.class_object())?;
|
|
87
|
+
// Nodes are only born from NOSJ.lazy / lazy resolution.
|
|
88
|
+
lazy_class.undef_default_alloc_func();
|
|
89
|
+
lazy_class.define_method("__get", method!(lazy::lazy_get, 1))?;
|
|
90
|
+
lazy_class.define_method("__dig", method!(lazy::lazy_dig, 1))?;
|
|
91
|
+
lazy_class.define_method("__at_pointer", method!(lazy::lazy_at_pointer, 1))?;
|
|
92
|
+
lazy_class.define_method("__materialize", method!(lazy::lazy_materialize, 0))?;
|
|
93
|
+
lazy_class.define_method("__kind", method!(lazy::lazy_kind, 0))?;
|
|
94
|
+
lazy_class.define_method("__byte_size", method!(lazy::lazy_byte_size, 0))?;
|
|
95
|
+
lazy_class.define_method("__keys", method!(lazy::lazy_keys, 0))?;
|
|
96
|
+
lazy_class.define_method("__size", method!(lazy::lazy_size, 0))?;
|
|
97
|
+
lazy_class.define_method("__children", method!(lazy::lazy_children, 0))?;
|
|
42
98
|
module.define_singleton_method("generate_native", method!(gen::generate_native, 2))?;
|
|
99
|
+
module.define_singleton_method(
|
|
100
|
+
"generate_rails_native",
|
|
101
|
+
method!(gen::generate_rails_native, 3),
|
|
102
|
+
)?;
|
|
43
103
|
// `generate` itself is native and variadic: the json gem routes
|
|
44
104
|
// its `generate` through a Ruby frame into C, so skipping our own
|
|
45
105
|
// forwarder frame is a straight per-call win on small documents.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//! NDJSON / JSON Lines: `NOSJ.each_line` walks a newline-delimited
|
|
2
|
+
//! document yielding one parsed value per line, and the file form does
|
|
3
|
+
//! it over a read-only memory map. A raw newline can never occur
|
|
4
|
+
//! INSIDE a JSON value (control characters must be escaped), so line
|
|
5
|
+
//! splitting is exact framing, and one-value-per-line is what the
|
|
6
|
+
//! format requires: a second value on a line fails as trailing
|
|
7
|
+
//! garbage, with an absolute document position on the error.
|
|
8
|
+
|
|
9
|
+
use magnus::value::ReprValue;
|
|
10
|
+
use magnus::{Error, RString, Ruby, Value};
|
|
11
|
+
|
|
12
|
+
use crate::files::with_mapped_file;
|
|
13
|
+
use crate::parse::{materialize_at, parse_native_opts, utf8_input, ParseNativeOpts};
|
|
14
|
+
|
|
15
|
+
/// Blank-line whitespace (the newline itself is the separator). Lines
|
|
16
|
+
/// holding only this are skipped, per the NDJSON convention that
|
|
17
|
+
/// parsers ignore empty lines (trailing newlines at EOF are universal).
|
|
18
|
+
const LINE_WS: [u8; 3] = *b" \t\r";
|
|
19
|
+
|
|
20
|
+
fn blank(line: &[u8]) -> bool {
|
|
21
|
+
line.iter().all(|b| LINE_WS.contains(b))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Yield one parsed value per non-blank line. Each line parses through
|
|
25
|
+
/// the shared sink machinery against the FULL source, so a malformed
|
|
26
|
+
/// line raises the rich ParserError whose `#line` is the physical
|
|
27
|
+
/// NDJSON line number. The thread state is borrowed per line, never
|
|
28
|
+
/// across a yield: the block is free to call back into NOSJ.
|
|
29
|
+
fn walk_lines(ruby: &Ruby, source: &[u8], o: &ParseNativeOpts) -> Result<(), Error> {
|
|
30
|
+
let mut pos = 0;
|
|
31
|
+
while pos < source.len() {
|
|
32
|
+
let line_end = source[pos..]
|
|
33
|
+
.iter()
|
|
34
|
+
.position(|&b| b == b'\n')
|
|
35
|
+
.map_or(source.len(), |p| pos + p);
|
|
36
|
+
if !blank(&source[pos..line_end]) {
|
|
37
|
+
let value = materialize_at(ruby, source, pos, line_end, o)?;
|
|
38
|
+
let _: Value = ruby.yield_value(value)?;
|
|
39
|
+
}
|
|
40
|
+
pos = line_end + 1;
|
|
41
|
+
}
|
|
42
|
+
Ok(())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// `NOSJ.each_line(source, opts) { |value| }`: the Ruby wrapper
|
|
46
|
+
/// guarantees a block (and builds the Enumerator otherwise).
|
|
47
|
+
pub fn each_line_native(
|
|
48
|
+
ruby: &Ruby,
|
|
49
|
+
_rb_self: Value,
|
|
50
|
+
data: RString,
|
|
51
|
+
opts: Value,
|
|
52
|
+
) -> Result<Value, Error> {
|
|
53
|
+
let o = parse_native_opts(ruby, opts)?;
|
|
54
|
+
let input = utf8_input(ruby, &data)?;
|
|
55
|
+
if data.as_value().is_frozen() {
|
|
56
|
+
// A frozen source cannot be mutated by the block, and `data`
|
|
57
|
+
// lives on this frame, so the borrow stays valid across yields.
|
|
58
|
+
walk_lines(ruby, input, &o)?;
|
|
59
|
+
} else {
|
|
60
|
+
// The block could mutate (or free the buffer of) an unfrozen
|
|
61
|
+
// source mid-iteration; walk a private copy. Same policy as
|
|
62
|
+
// NOSJ.lazy: pass a frozen string for zero-copy.
|
|
63
|
+
let owned = input.to_vec();
|
|
64
|
+
walk_lines(ruby, &owned, &o)?;
|
|
65
|
+
}
|
|
66
|
+
Ok(ruby.qnil().as_value())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// `NOSJ.each_line_file(path, opts) { |value| }`: NDJSON over a
|
|
70
|
+
/// read-only memory map; the file never becomes a Ruby String.
|
|
71
|
+
pub fn each_line_file_native(
|
|
72
|
+
ruby: &Ruby,
|
|
73
|
+
_rb_self: Value,
|
|
74
|
+
path: RString,
|
|
75
|
+
opts: Value,
|
|
76
|
+
) -> Result<Value, Error> {
|
|
77
|
+
let o = parse_native_opts(ruby, opts)?;
|
|
78
|
+
let p = path.to_string()?;
|
|
79
|
+
// An empty file is a valid, zero-event NDJSON stream, but mapping
|
|
80
|
+
// a zero-length file fails with EINVAL on Linux; answer before the
|
|
81
|
+
// mapper. Metadata failures (ENOENT, ...) fall through so the
|
|
82
|
+
// mapper raises the same Errno an open would.
|
|
83
|
+
if std::fs::metadata(&p).is_ok_and(|m| m.len() == 0) {
|
|
84
|
+
return Ok(ruby.qnil().as_value());
|
|
85
|
+
}
|
|
86
|
+
with_mapped_file(ruby, &p, |map| {
|
|
87
|
+
walk_lines(ruby, &map, &o)?;
|
|
88
|
+
Ok(ruby.qnil().as_value())
|
|
89
|
+
})
|
|
90
|
+
}
|
data/ext/nosj/src/parse.rs
CHANGED
|
@@ -6,12 +6,18 @@
|
|
|
6
6
|
use magnus::rb_sys::{AsRawValue, FromRawValue};
|
|
7
7
|
use magnus::{Error, RString, Ruby, Value};
|
|
8
8
|
|
|
9
|
+
use crate::errors::{nesting_error, parser_error, parser_error_at};
|
|
9
10
|
use crate::sink::{NullSink, RubyValueSink, SinkAbort, MAX_NESTING};
|
|
10
11
|
use crate::state::{ensure_marked_shadow, PullState, PULL_STATE};
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
pub(crate) use crate::errors::parser_error as err;
|
|
14
|
+
|
|
15
|
+
/// Byte range of `sub` within `source`. `sub` must be a subslice of
|
|
16
|
+
/// `source` (it always is here: pointer resolution and lazy spans hand
|
|
17
|
+
/// out slices borrowed from the document they resolved against).
|
|
18
|
+
pub(crate) fn span_of(source: &[u8], sub: &[u8]) -> (usize, usize) {
|
|
19
|
+
let start = sub.as_ptr() as usize - source.as_ptr() as usize;
|
|
20
|
+
(start, start + sub.len())
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/// Validate that `data` is UTF-8 (or US-ASCII) with intact coderange and
|
|
@@ -107,12 +113,16 @@ pub(crate) fn parse_native_opts(ruby: &Ruby, opts: Value) -> Result<ParseNativeO
|
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
/// Pop the root value off the sink stack, or map a drive failure onto
|
|
110
|
-
/// the gem's exceptions. Shared by every driver.
|
|
116
|
+
/// the gem's exceptions. Shared by every driver. `source`/`base` locate
|
|
117
|
+
/// the driven bytes within the full document, so ParserError positions
|
|
118
|
+
/// stay absolute when a subtree slice was parsed.
|
|
111
119
|
fn finish_drive(
|
|
112
120
|
ruby: &Ruby,
|
|
113
121
|
result: Result<(), nosj::DriveError<SinkAbort>>,
|
|
114
122
|
stack: &mut Vec<rb_sys::VALUE>,
|
|
115
123
|
max_nesting: usize,
|
|
124
|
+
source: &[u8],
|
|
125
|
+
base: usize,
|
|
116
126
|
) -> Result<Value, Error> {
|
|
117
127
|
match result {
|
|
118
128
|
Ok(()) => {
|
|
@@ -122,23 +132,47 @@ fn finish_drive(
|
|
|
122
132
|
Ok(unsafe { Value::from_raw(raw) })
|
|
123
133
|
}
|
|
124
134
|
Err(nosj::DriveError::Sink(SinkAbort::Overflow)) => {
|
|
125
|
-
Err(
|
|
135
|
+
Err(parser_error(ruby, "document too large".into()))
|
|
126
136
|
}
|
|
127
137
|
Err(nosj::DriveError::Sink(SinkAbort::BadBigint)) => {
|
|
128
|
-
Err(
|
|
138
|
+
Err(parser_error(ruby, "invalid bignum".into()))
|
|
129
139
|
}
|
|
130
|
-
Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(
|
|
140
|
+
Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
|
|
131
141
|
ruby,
|
|
132
142
|
format!("nesting of {} is too deep", max_nesting.saturating_add(1)),
|
|
133
143
|
)),
|
|
134
|
-
|
|
144
|
+
// Raised only by the reformat pipe's sink, which never drives
|
|
145
|
+
// through here; the match must stay total.
|
|
146
|
+
Err(nosj::DriveError::Sink(SinkAbort::BrokenUtf8Output)) => Err(parser_error(
|
|
147
|
+
ruby,
|
|
148
|
+
"source sequence is illegal/malformed utf-8".into(),
|
|
149
|
+
)),
|
|
150
|
+
Err(nosj::DriveError::Parse(e)) => Err(parser_error_at(
|
|
151
|
+
ruby,
|
|
152
|
+
source,
|
|
153
|
+
base + e.offset,
|
|
154
|
+
e.to_string(),
|
|
155
|
+
)),
|
|
135
156
|
}
|
|
136
157
|
}
|
|
137
158
|
|
|
138
|
-
/// Drive the fused cursor over
|
|
139
|
-
///
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
/// Drive the fused cursor over the whole of `source`. See
|
|
160
|
+
/// [`materialize_at`].
|
|
161
|
+
pub(crate) fn materialize(ruby: &Ruby, source: &[u8], o: &ParseNativeOpts) -> Result<Value, Error> {
|
|
162
|
+
materialize_at(ruby, source, 0, source.len(), o)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Drive the fused cursor over `source[start..end]`, building Ruby
|
|
166
|
+
/// values through the shared thread-local sink machinery. `source` must
|
|
167
|
+
/// be valid UTF-8 (see [`utf8_input`]); the full document is passed so
|
|
168
|
+
/// error positions come out absolute.
|
|
169
|
+
pub(crate) fn materialize_at(
|
|
170
|
+
ruby: &Ruby,
|
|
171
|
+
source: &[u8],
|
|
172
|
+
start: usize,
|
|
173
|
+
end: usize,
|
|
174
|
+
o: &ParseNativeOpts,
|
|
175
|
+
) -> Result<Value, Error> {
|
|
142
176
|
PULL_STATE.with(|cell| {
|
|
143
177
|
let mut state = cell.borrow_mut();
|
|
144
178
|
ensure_marked_shadow(&mut state.vstack);
|
|
@@ -166,8 +200,10 @@ pub(crate) fn materialize(ruby: &Ruby, input: &[u8], o: &ParseNativeOpts) -> Res
|
|
|
166
200
|
};
|
|
167
201
|
|
|
168
202
|
// Safety: callers verified UTF-8 (coderange or nosj slice).
|
|
169
|
-
let result = unsafe {
|
|
170
|
-
|
|
203
|
+
let result = unsafe {
|
|
204
|
+
nosj::parse_utf8_unchecked_with(&source[start..end], bufs, &mut sink, o.popts)
|
|
205
|
+
};
|
|
206
|
+
finish_drive(ruby, result, sink.stack, o.max_nesting, source, start)
|
|
171
207
|
})
|
|
172
208
|
}
|
|
173
209
|
|