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.
@@ -3,7 +3,7 @@
3
3
  //! Compact and pretty modes are one const-generic body, so the compact
4
4
  //! hot path carries no formatting branches.
5
5
 
6
- use nosj::emit::{self, EscapeMode};
6
+ use nosj::emit::{self, copy_short_raw, EscapeMode};
7
7
  use rb_sys::macros::{
8
8
  FIX2LONG, FIXNUM_P, FLONUM_P, RARRAY_CONST_PTR, RARRAY_LEN, RB_BUILTIN_TYPE, RHASH_SIZE,
9
9
  STATIC_SYM_P,
@@ -14,52 +14,16 @@ use super::errors::GenFail;
14
14
  use super::keys::GenKeyCache;
15
15
  use super::opts::GenConfig;
16
16
  use super::ruby::{
17
- is_special_const, protected_encode_utf8, protected_to_json, protected_to_s, rstring_bytes,
18
- str_coderange, str_enc_index, to_json_id, utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL,
19
- QTRUE,
17
+ is_json_fragment, is_special_const, protected_as_json, protected_encode_utf8,
18
+ protected_to_json, protected_to_s, rstring_bytes, str_coderange, str_enc_index, to_json_id,
19
+ utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL, QTRUE,
20
20
  };
21
21
 
22
- /// Overlapping-word copy for short runs through a raw pointer (the
23
- /// crate's `copy_small` shape, local because that helper is
24
- /// crate-internal): a size-laddered pair of unaligned loads/stores
25
- /// instead of a libc memmove call, whose per-call overhead measured
26
- /// 42% on tiny-copy-heavy generation. Cached keys over 32 bytes are
27
- /// rare enough for the memcpy fallback.
28
- ///
29
- /// # Safety
30
- ///
31
- /// `n` readable bytes at `src`, `n` writable bytes at `dst`, and the
32
- /// ranges must not overlap.
33
- #[inline(always)]
34
- unsafe fn copy_short_raw(src: *const u8, dst: *mut u8, n: usize) {
35
- /// One overlapping pair: word-size chunks at offset 0 and at
36
- /// `n - size` cover `size..=2*size` bytes; the overlapped middle
37
- /// is written twice with identical data.
38
- macro_rules! word_pair {
39
- ($t:ty) => {{
40
- const SIZE: usize = size_of::<$t>();
41
- dst.cast::<$t>()
42
- .write_unaligned(src.cast::<$t>().read_unaligned());
43
- dst.add(n - SIZE)
44
- .cast::<$t>()
45
- .write_unaligned(src.add(n - SIZE).cast::<$t>().read_unaligned());
46
- }};
47
- }
48
- if n >= 16 {
49
- if n <= 32 {
50
- word_pair!(u128);
51
- } else {
52
- std::ptr::copy_nonoverlapping(src, dst, n);
53
- }
54
- } else if n >= 8 {
55
- word_pair!(u64);
56
- } else if n >= 4 {
57
- word_pair!(u32);
58
- } else if n >= 2 {
59
- word_pair!(u16);
60
- } else if n == 1 {
61
- *dst = *src;
62
- }
22
+ /// Whether keys escaped under `mode` may be cached: the cached bytes
23
+ /// bake in the escape mode, so the scratch keeps one cache per
24
+ /// cacheable mode and hands `Gen` the matching one.
25
+ pub(super) fn mode_cacheable(mode: EscapeMode) -> bool {
26
+ matches!(mode, EscapeMode::Standard | EscapeMode::HtmlSafe)
63
27
  }
64
28
 
65
29
  pub(super) struct Gen<'a> {
@@ -69,6 +33,7 @@ pub(super) struct Gen<'a> {
69
33
  pub(super) fail: Option<GenFail>,
70
34
  /// Pre-escaped key cache, borrowed for the whole document (one
71
35
  /// thread-local borrow per generate call instead of one per key).
36
+ /// Always the cache matching `cfg.mode` (see [`mode_cacheable`]).
72
37
  pub(super) keys: &'a mut GenKeyCache,
73
38
  }
74
39
 
@@ -149,6 +114,11 @@ impl Gen<'_> {
149
114
  emit::write_f64(&mut *self.out, f);
150
115
  return Ok(());
151
116
  }
117
+ if self.cfg.rails {
118
+ // ActiveSupport's Float#as_json: non-finite floats are null.
119
+ self.out.extend_from_slice(b"null");
120
+ return Ok(());
121
+ }
152
122
  let name = if f.is_nan() {
153
123
  "NaN"
154
124
  } else if f > 0.0 {
@@ -166,13 +136,14 @@ impl Gen<'_> {
166
136
  }
167
137
 
168
138
  /// Emit an object key from the pre-escaped cache. Only frozen string
169
- /// keys in Standard escape mode are cacheable: frozen guarantees the
170
- /// content behind the VALUE can't change, and the cached bytes bake in
171
- /// the escape mode.
139
+ /// keys in a cacheable escape mode qualify: frozen guarantees the
140
+ /// content behind the VALUE can't change, and the cached bytes bake
141
+ /// in the escape mode, so each cacheable mode gets its own cache
142
+ /// instance from the scratch (see [`mode_cacheable`]).
172
143
  fn emit_key_cached(&mut self, k: VALUE) -> Result<(), ()> {
173
144
  const FL_FREEZE: u64 = rb_sys::ruby_fl_type::RUBY_FL_FREEZE as u64;
174
145
  let frozen = unsafe { (*(k as *const rb_sys::RBasic)).flags } & FL_FREEZE != 0;
175
- if !frozen || self.cfg.mode != EscapeMode::Standard {
146
+ if !frozen || !mode_cacheable(self.cfg.mode) {
176
147
  return self.emit_rstring_quoted(k);
177
148
  }
178
149
  if let Some(bytes) = self.keys.get(k) {
@@ -190,13 +161,13 @@ impl Gen<'_> {
190
161
  }
191
162
 
192
163
  /// The pre-escaped bytes for `k` when the cache may serve it:
193
- /// frozen string key, Standard escape mode (see [`Gen::emit_key_cached`]
194
- /// for why only that combination is cacheable). An associated fn
195
- /// over the split-out fields so callers keep `self.out` free.
164
+ /// frozen string key, cacheable escape mode (see
165
+ /// [`Gen::emit_key_cached`]). An associated fn over the split-out
166
+ /// fields so callers keep `self.out` free.
196
167
  #[inline(always)]
197
168
  fn cached_key_bytes<'k>(cfg: &GenConfig, keys: &'k GenKeyCache, k: VALUE) -> Option<&'k [u8]> {
198
169
  const FL_FREEZE: u64 = rb_sys::ruby_fl_type::RUBY_FL_FREEZE as u64;
199
- if cfg.mode == EscapeMode::Standard
170
+ if mode_cacheable(cfg.mode)
200
171
  && !is_special_const(k)
201
172
  && unsafe { RB_BUILTIN_TYPE(k) } == ruby_value_type::RUBY_T_STRING
202
173
  && unsafe { (*(k as *const rb_sys::RBasic)).flags } & FL_FREEZE != 0
@@ -303,11 +274,20 @@ impl Gen<'_> {
303
274
  }
304
275
  }
305
276
 
306
- /// Non-native type: strict raises; otherwise `to_json` if the object
307
- /// responds (result appended verbatim), else `to_s` as a JSON string,
308
- /// which is exactly what the gem's `Object#to_json` does.
309
- fn emit_fallback(&mut self, raw: VALUE) -> Result<(), ()> {
277
+ /// Non-native type: strict raises (except `JSON::Fragment`, which
278
+ /// the gem splices even under strict); otherwise `to_json` if the
279
+ /// object responds (result appended verbatim), else `to_s` as a
280
+ /// JSON string, which is exactly what the gem's `Object#to_json`
281
+ /// does. Rails mode recurses through `as_json` instead
282
+ /// (JSONGemEncoder#jsonify).
283
+ fn emit_fallback<const PRETTY: bool>(&mut self, raw: VALUE, depth: usize) -> Result<(), ()> {
284
+ if self.cfg.rails {
285
+ return self.emit_rails_fallback::<PRETTY>(raw, depth);
286
+ }
310
287
  if self.cfg.strict {
288
+ if is_json_fragment(raw) {
289
+ return self.splice_to_json(raw);
290
+ }
311
291
  let name = unsafe {
312
292
  std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw))
313
293
  .to_string_lossy()
@@ -341,6 +321,64 @@ impl Gen<'_> {
341
321
  }
342
322
  }
343
323
 
324
+ /// Splice `raw`'s `to_json` result verbatim: the JSON::Fragment
325
+ /// path (pre-rendered JSON, trusted like the gem trusts it).
326
+ fn splice_to_json(&mut self, raw: VALUE) -> Result<(), ()> {
327
+ match protected_to_json(raw) {
328
+ Ok(json)
329
+ if !is_special_const(json)
330
+ && unsafe { RB_BUILTIN_TYPE(json) } == ruby_value_type::RUBY_T_STRING =>
331
+ {
332
+ self.append_rstring_raw(json);
333
+ Ok(())
334
+ }
335
+ Ok(_) => {
336
+ self.fail = Some(GenFail::Generator(
337
+ "JSON::Fragment#to_json did not return a String".to_string(),
338
+ ));
339
+ Err(())
340
+ }
341
+ Err(exc) => {
342
+ self.fail = Some(GenFail::Reraise(exc));
343
+ Err(())
344
+ }
345
+ }
346
+ }
347
+
348
+ /// Rails-mode fallback, mirroring JSONGemEncoder#jsonify:
349
+ /// fragments splice through (like ActiveSupport passes them to the
350
+ /// gem); everything else is asked for its as_json representation
351
+ /// (no arguments; only the top-level value receives the encoder
352
+ /// options), which is emitted in its place. An as_json returning
353
+ /// the receiver would recurse forever, so it raises instead.
354
+ fn emit_rails_fallback<const PRETTY: bool>(
355
+ &mut self,
356
+ raw: VALUE,
357
+ depth: usize,
358
+ ) -> Result<(), ()> {
359
+ if is_json_fragment(raw) {
360
+ return self.splice_to_json(raw);
361
+ }
362
+ match protected_as_json(raw) {
363
+ Ok(json) if json == raw => {
364
+ let name = unsafe {
365
+ std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw))
366
+ .to_string_lossy()
367
+ .into_owned()
368
+ };
369
+ self.fail = Some(GenFail::Generator(format!(
370
+ "{name}#as_json returned the receiver"
371
+ )));
372
+ Err(())
373
+ }
374
+ Ok(json) => self.emit_value::<PRETTY>(json, depth),
375
+ Err(exc) => {
376
+ self.fail = Some(GenFail::Reraise(exc));
377
+ Err(())
378
+ }
379
+ }
380
+ }
381
+
344
382
  fn nesting_check(&mut self, inner: usize) -> Result<(), ()> {
345
383
  if self.cfg.max_nesting > 0 && inner > self.cfg.max_nesting {
346
384
  self.fail = Some(GenFail::Nesting(self.cfg.max_nesting));
@@ -601,7 +639,7 @@ impl Gen<'_> {
601
639
  let s = unsafe { rb_sys::rb_sym2str(raw) };
602
640
  self.emit_rstring_quoted(s)
603
641
  }
604
- _ => self.emit_fallback(raw),
642
+ _ => self.emit_fallback::<PRETTY>(raw, depth),
605
643
  };
606
644
  }
607
645
  if FIXNUM_P(raw) {
@@ -633,6 +671,6 @@ impl Gen<'_> {
633
671
  let s = unsafe { rb_sys::rb_sym2str(raw) };
634
672
  return self.emit_rstring_quoted(s);
635
673
  }
636
- self.emit_fallback(raw)
674
+ self.emit_fallback::<PRETTY>(raw, depth)
637
675
  }
638
676
  }
@@ -0,0 +1,422 @@
1
+ //! Lazy documents: `NOSJ.lazy` wraps a JSON document and resolves
2
+ //! access on demand through the crate's pointer skipper. Every node is
3
+ //! a byte span into a shared, immutable copy of the document; container
4
+ //! children come back as further lazy nodes, scalars materialize
5
+ //! immediately through the same sink machinery as a full parse. Nothing
6
+ //! outside the touched path is ever parsed, so pulling a few fields out
7
+ //! of a large document costs microseconds, not a full parse.
8
+ //!
9
+ //! Validation is as-you-go (the crate's skipper checks bracket balance
10
+ //! over skipped content and fully validates resolved targets), so a
11
+ //! malformed region raises when an access first walks it, not at
12
+ //! `NOSJ.lazy` time.
13
+
14
+ use std::sync::Arc;
15
+
16
+ use magnus::typed_data::Obj;
17
+ use magnus::value::ReprValue;
18
+ use magnus::{DataTypeFunctions, Error, RArray, RString, Ruby, TypedData, Value};
19
+
20
+ use crate::errors::parser_error_at;
21
+ use crate::parse::{materialize_at, parse_native_opts, span_of, utf8_input, ParseNativeOpts};
22
+ use crate::pointer::{path_to_pointer, push_escaped_token};
23
+ use crate::state::PULL_STATE;
24
+
25
+ /// The document bytes behind a node tree. A frozen Ruby source is
26
+ /// borrowed zero-copy: freezing rules out mutation, and every node
27
+ /// GC-marks the string with `rb_gc_mark` semantics, which both keeps it
28
+ /// alive and pins it against compaction, so the captured pointer stays
29
+ /// valid for as long as any node exists. Anything else is copied once.
30
+ pub(crate) enum DocBytes {
31
+ Owned(Vec<u8>),
32
+ Frozen {
33
+ source: rb_sys::VALUE,
34
+ ptr: *const u8,
35
+ len: usize,
36
+ },
37
+ /// A read-only file mapping (`NOSJ.load_lazy_file`): pages never
38
+ /// touched are never read off disk. Concurrent modification of the
39
+ /// mapped file by another process is documented as unsupported
40
+ /// (the standard mmap caveat).
41
+ Mmap(memmap2::Mmap),
42
+ }
43
+
44
+ /// The shared document: stable bytes plus the parse options every
45
+ /// materialization from this document uses.
46
+ struct DocInner {
47
+ bytes: DocBytes,
48
+ opts: ParseNativeOpts,
49
+ }
50
+
51
+ // SAFETY: the bytes are immutable for the document's whole life (an
52
+ // owned Vec, or a frozen Ruby string pinned and kept alive by every
53
+ // node's GC mark), so cross-thread reads are plain shared reads; the
54
+ // raw VALUE is only dereferenced by the GC mark, which runs at
55
+ // safepoints (the ShadowHandle contract in state.rs). There is no
56
+ // interior mutability anywhere in the type.
57
+ unsafe impl Send for DocInner {}
58
+ unsafe impl Sync for DocInner {}
59
+
60
+ impl DocInner {
61
+ fn bytes(&self) -> &[u8] {
62
+ match &self.bytes {
63
+ DocBytes::Owned(v) => v,
64
+ // SAFETY: the source string is frozen (no mutation, no
65
+ // buffer reallocation) and pinned+kept alive by every
66
+ // node's GC mark; see DocBytes.
67
+ DocBytes::Frozen { ptr, len, .. } => unsafe { std::slice::from_raw_parts(*ptr, *len) },
68
+ DocBytes::Mmap(m) => m,
69
+ }
70
+ }
71
+ }
72
+
73
+ const KIND_OBJECT: u8 = b'{';
74
+ const KIND_ARRAY: u8 = b'[';
75
+
76
+ /// One lazy container node: a byte span into its document. Spans always
77
+ /// come from the crate's resolver (token edges within the doc bytes),
78
+ /// and never cross the Ruby boundary, so they cannot be forged from
79
+ /// Ruby.
80
+ #[derive(TypedData)]
81
+ #[magnus(class = "NOSJ::Lazy", mark)]
82
+ pub struct LazyNode {
83
+ doc: Arc<DocInner>,
84
+ start: usize,
85
+ end: usize,
86
+ kind: u8,
87
+ }
88
+
89
+ impl DataTypeFunctions for LazyNode {
90
+ fn mark(&self, marker: &magnus::gc::Marker) {
91
+ if let DocBytes::Frozen { source, .. } = self.doc.bytes {
92
+ use magnus::rb_sys::FromRawValue;
93
+ // SAFETY: the VALUE was a live, frozen string at node
94
+ // creation and this mark is what keeps it that way.
95
+ marker.mark(unsafe { Value::from_raw(source) });
96
+ }
97
+ }
98
+ }
99
+
100
+ impl LazyNode {
101
+ fn span(&self) -> &[u8] {
102
+ &self.doc.bytes()[self.start..self.end]
103
+ }
104
+ }
105
+
106
+ /// Wrap a resolved raw-value slice: containers become new lazy nodes,
107
+ /// scalars materialize now. `sub` must be a subslice of `doc.bytes`.
108
+ fn resolved_to_value(ruby: &Ruby, doc: &Arc<DocInner>, sub: &[u8]) -> Result<Value, Error> {
109
+ let (start, end) = span_of(doc.bytes(), sub);
110
+ match sub.first().copied() {
111
+ Some(k) if k == KIND_OBJECT || k == KIND_ARRAY => {
112
+ let node = LazyNode {
113
+ doc: Arc::clone(doc),
114
+ start,
115
+ end,
116
+ kind: k,
117
+ };
118
+ let obj: Obj<LazyNode> = ruby.obj_wrap(node);
119
+ Ok(obj.as_value())
120
+ }
121
+ _ => materialize_at(ruby, doc.bytes(), start, end, &doc.opts),
122
+ }
123
+ }
124
+
125
+ /// Resolve `pointer` within `node`'s span. Shared by `__get` and
126
+ /// `__at_pointer`; both misses and negative-index paths return nil.
127
+ fn resolve_in_span(ruby: &Ruby, node: &LazyNode, pointer: &str) -> Result<Value, Error> {
128
+ // Resolve first (one PULL_STATE borrow, slice borrows the doc, not
129
+ // the buffers), then materialize (which re-borrows internally).
130
+ let resolved = PULL_STATE.with(|cell| {
131
+ let mut state = cell.borrow_mut();
132
+ // SAFETY: doc bytes were coderange-gated at NOSJ.lazy creation,
133
+ // and spans lie on token edges, so the span is valid UTF-8.
134
+ unsafe { nosj::pointer_utf8_unchecked(node.span(), pointer, &mut state.bufs) }
135
+ });
136
+ match resolved {
137
+ Ok(None) => Ok(ruby.qnil().as_value()),
138
+ Ok(Some(sub)) => resolved_to_value(ruby, &node.doc, sub.as_bytes()),
139
+ Err(e) if matches!(e.kind, nosj::ErrorKind::InvalidPointer) => {
140
+ Err(Error::new(ruby.exception_arg_error(), e.to_string()))
141
+ }
142
+ Err(e) => Err(reader_err(ruby, node, e)),
143
+ }
144
+ }
145
+
146
+ /// `NOSJ.lazy(source, opts)`: gate the encoding, copy the bytes, locate
147
+ /// the root value. Container roots wrap as lazy nodes; a scalar root has
148
+ /// nothing to defer and materializes immediately.
149
+ ///
150
+ /// The root span is the input trimmed of surrounding whitespace; no
151
+ /// byte of content is walked here (resolving via the "" pointer would
152
+ /// bracket-skip the whole document just to find the same end), so
153
+ /// malformation anywhere, including root-level trailing garbage,
154
+ /// surfaces on first access, per the module's lazy-validation contract.
155
+ pub fn lazy_native(
156
+ ruby: &Ruby,
157
+ _rb_self: Value,
158
+ data: RString,
159
+ opts: Value,
160
+ ) -> Result<Value, Error> {
161
+ let o = parse_native_opts(ruby, opts)?;
162
+ let input = utf8_input(ruby, &data)?;
163
+
164
+ // Frozen sources are borrowed zero-copy (see DocBytes); `data` is
165
+ // on the caller's machine stack, so it stays pinned through this
166
+ // call, and the node's mark takes over from the first GC on.
167
+ let bytes = if data.as_value().is_frozen() {
168
+ use magnus::rb_sys::AsRawValue;
169
+ DocBytes::Frozen {
170
+ source: data.as_raw(),
171
+ ptr: input.as_ptr(),
172
+ len: input.len(),
173
+ }
174
+ } else {
175
+ DocBytes::Owned(input.to_vec())
176
+ };
177
+ wrap_root(ruby, bytes, o)
178
+ }
179
+
180
+ /// Wrap document bytes as the root lazy value. Shared by `NOSJ.lazy`
181
+ /// and `NOSJ.load_lazy_file`; the bytes must already be known UTF-8.
182
+ pub(crate) fn wrap_root(
183
+ ruby: &Ruby,
184
+ bytes: DocBytes,
185
+ opts: ParseNativeOpts,
186
+ ) -> Result<Value, Error> {
187
+ const WS: [u8; 4] = *b" \t\n\r";
188
+ let doc = Arc::new(DocInner { bytes, opts });
189
+ let input = doc.bytes();
190
+ let Some(start) = input.iter().position(|b| !WS.contains(b)) else {
191
+ return Err(parser_error_at(
192
+ ruby,
193
+ input,
194
+ input.len(),
195
+ "unexpected end of input".into(),
196
+ ));
197
+ };
198
+ let end = input.iter().rposition(|b| !WS.contains(b)).unwrap() + 1;
199
+ let sub = &doc.bytes()[start..end];
200
+ resolved_to_value(ruby, &doc, sub)
201
+ }
202
+
203
+ /// `__get(token)`: one path step. Integer tokens are JSON Pointer
204
+ /// indices (negative ones resolve to nil, as in NOSJ.dig); String and
205
+ /// Symbol tokens are keys, `~`/`/`-escaped.
206
+ pub fn lazy_get(ruby: &Ruby, rb_self: Obj<LazyNode>, token: Value) -> Result<Value, Error> {
207
+ let mut ptr = String::new();
208
+ if let Some(int) = magnus::Integer::from_value(token) {
209
+ let idx = int.to_i64()?;
210
+ if idx < 0 {
211
+ return Ok(ruby.qnil().as_value());
212
+ }
213
+ ptr.push('/');
214
+ ptr.push_str(&idx.to_string());
215
+ } else if let Some(s) = RString::from_value(token) {
216
+ push_escaped_token(&mut ptr, &s.to_string()?);
217
+ } else if let Some(sym) = magnus::Symbol::from_value(token) {
218
+ push_escaped_token(&mut ptr, &sym.name()?);
219
+ } else {
220
+ return Err(Error::new(
221
+ ruby.exception_arg_error(),
222
+ "keys must be Strings, Symbols, or Integers",
223
+ ));
224
+ }
225
+ resolve_in_span(ruby, &rb_self, &ptr)
226
+ }
227
+
228
+ /// `__dig(path)`: the whole dig path fused into ONE pointer resolution
229
+ /// within this node's span, instead of one resolve (and one cached
230
+ /// node) per step. Semantics match `NOSJ.dig`: negative indices and
231
+ /// steps into scalars resolve to nil.
232
+ pub fn lazy_dig(ruby: &Ruby, rb_self: Obj<LazyNode>, path: RArray) -> Result<Value, Error> {
233
+ match path_to_pointer(ruby, path)? {
234
+ Some(ptr) => resolve_in_span(ruby, &rb_self, &ptr),
235
+ None => Ok(ruby.qnil().as_value()),
236
+ }
237
+ }
238
+
239
+ /// `__at_pointer(pointer)`: a full RFC 6901 pointer, resolved within
240
+ /// this node's subtree.
241
+ pub fn lazy_at_pointer(
242
+ ruby: &Ruby,
243
+ rb_self: Obj<LazyNode>,
244
+ pointer: RString,
245
+ ) -> Result<Value, Error> {
246
+ let ptr = pointer.to_string()?;
247
+ resolve_in_span(ruby, &rb_self, &ptr)
248
+ }
249
+
250
+ /// `__materialize`: the whole span as plain Ruby values, under the
251
+ /// document's parse options.
252
+ pub fn lazy_materialize(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<Value, Error> {
253
+ materialize_at(
254
+ ruby,
255
+ rb_self.doc.bytes(),
256
+ rb_self.start,
257
+ rb_self.end,
258
+ &rb_self.doc.opts,
259
+ )
260
+ }
261
+
262
+ /// `__kind`: `:object` or `:array`.
263
+ pub fn lazy_kind(ruby: &Ruby, rb_self: Obj<LazyNode>) -> magnus::Symbol {
264
+ match rb_self.kind {
265
+ KIND_OBJECT => ruby.to_symbol("object"),
266
+ _ => ruby.to_symbol("array"),
267
+ }
268
+ }
269
+
270
+ /// `__byte_size`: span length in bytes (cheap; used by #inspect).
271
+ pub fn lazy_byte_size(_ruby: &Ruby, rb_self: Obj<LazyNode>) -> usize {
272
+ rb_self.end - rb_self.start
273
+ }
274
+
275
+ /// A walk failure inside `node`'s span: the reader's offset is
276
+ /// span-relative, so shift by the node's start to report an absolute
277
+ /// document position.
278
+ fn reader_err(ruby: &Ruby, node: &LazyNode, e: nosj::ParseError) -> Error {
279
+ parser_error_at(ruby, node.doc.bytes(), node.start + e.offset, e.to_string())
280
+ }
281
+
282
+ /// `__keys`: the object's decoded keys, one Reader walk, values skipped.
283
+ pub fn lazy_keys(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<RArray, Error> {
284
+ if rb_self.kind != KIND_OBJECT {
285
+ return Err(Error::new(
286
+ ruby.exception_type_error(),
287
+ "keys on a JSON array",
288
+ ));
289
+ }
290
+ let out = ruby.ary_new();
291
+ PULL_STATE.with(|cell| -> Result<(), Error> {
292
+ let mut state = cell.borrow_mut();
293
+ // SAFETY: spans are valid UTF-8 (see resolve_in_span).
294
+ let mut r = unsafe { nosj::Reader::from_utf8_unchecked(rb_self.span(), &mut state.bufs) };
295
+ r.next_node().map_err(|e| reader_err(ruby, &rb_self, e))?;
296
+ let mut has = match r
297
+ .object_first_key()
298
+ .map_err(|e| reader_err(ruby, &rb_self, e))?
299
+ {
300
+ Some(k) => {
301
+ out.push(ruby.str_new(k))?;
302
+ true
303
+ }
304
+ None => false,
305
+ };
306
+ while has {
307
+ r.skip_value().map_err(|e| reader_err(ruby, &rb_self, e))?;
308
+ has = match r
309
+ .object_next_key()
310
+ .map_err(|e| reader_err(ruby, &rb_self, e))?
311
+ {
312
+ Some(k) => {
313
+ out.push(ruby.str_new(k))?;
314
+ true
315
+ }
316
+ None => false,
317
+ };
318
+ }
319
+ Ok(())
320
+ })?;
321
+ Ok(out)
322
+ }
323
+
324
+ /// `__size`: entry count (object pairs or array elements), one walk,
325
+ /// nothing materialized.
326
+ pub fn lazy_size(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<usize, Error> {
327
+ PULL_STATE.with(|cell| {
328
+ let mut state = cell.borrow_mut();
329
+ // SAFETY: spans are valid UTF-8 (see resolve_in_span).
330
+ let mut r = unsafe { nosj::Reader::from_utf8_unchecked(rb_self.span(), &mut state.bufs) };
331
+ r.next_node().map_err(|e| reader_err(ruby, &rb_self, e))?;
332
+ let mut n = 0usize;
333
+ if rb_self.kind == KIND_OBJECT {
334
+ let mut has = r
335
+ .object_first_key()
336
+ .map_err(|e| reader_err(ruby, &rb_self, e))?
337
+ .is_some();
338
+ while has {
339
+ n += 1;
340
+ r.skip_value().map_err(|e| reader_err(ruby, &rb_self, e))?;
341
+ has = r
342
+ .object_next_key()
343
+ .map_err(|e| reader_err(ruby, &rb_self, e))?
344
+ .is_some();
345
+ }
346
+ } else {
347
+ let mut has = r.array_first().map_err(|e| reader_err(ruby, &rb_self, e))?;
348
+ while has {
349
+ n += 1;
350
+ r.skip_value().map_err(|e| reader_err(ruby, &rb_self, e))?;
351
+ has = r.array_next().map_err(|e| reader_err(ruby, &rb_self, e))?;
352
+ }
353
+ }
354
+ Ok(n)
355
+ })
356
+ }
357
+
358
+ /// A child discovered during a container walk: a doc-relative span,
359
+ /// plus the owned key for object entries (decoded keys borrow the
360
+ /// reader's scratch, so they are copied out before phase two).
361
+ struct ChildDesc {
362
+ key: Option<String>,
363
+ start: usize,
364
+ end: usize,
365
+ }
366
+
367
+ /// `__children`: every direct child in ONE walk. Objects yield
368
+ /// `[key, child]` pairs, arrays yield children; containers wrap lazily,
369
+ /// scalars materialize. Two phases so the Reader's buffer borrow ends
370
+ /// before materialization re-borrows the thread state.
371
+ pub fn lazy_children(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<RArray, Error> {
372
+ let base = rb_self.doc.bytes().as_ptr() as usize;
373
+ let descs: Result<Vec<ChildDesc>, nosj::ParseError> = PULL_STATE.with(|cell| {
374
+ let mut state = cell.borrow_mut();
375
+ // SAFETY: spans are valid UTF-8 (see resolve_in_span).
376
+ let mut r = unsafe { nosj::Reader::from_utf8_unchecked(rb_self.span(), &mut state.bufs) };
377
+ r.next_node()?;
378
+ let mut out = Vec::new();
379
+ if rb_self.kind == KIND_OBJECT {
380
+ let mut key = r.object_first_key()?.map(String::from);
381
+ while let Some(k) = key {
382
+ let sub = r.skip_value()?;
383
+ let start = sub.as_ptr() as usize - base;
384
+ out.push(ChildDesc {
385
+ key: Some(k),
386
+ start,
387
+ end: start + sub.len(),
388
+ });
389
+ key = r.object_next_key()?.map(String::from);
390
+ }
391
+ } else {
392
+ let mut has = r.array_first()?;
393
+ while has {
394
+ let sub = r.skip_value()?;
395
+ let start = sub.as_ptr() as usize - base;
396
+ out.push(ChildDesc {
397
+ key: None,
398
+ start,
399
+ end: start + sub.len(),
400
+ });
401
+ has = r.array_next()?;
402
+ }
403
+ }
404
+ Ok(out)
405
+ });
406
+ let descs = descs.map_err(|e| reader_err(ruby, &rb_self, e))?;
407
+
408
+ let out = ruby.ary_new_capa(descs.len());
409
+ for d in descs {
410
+ let child = resolved_to_value(ruby, &rb_self.doc, &rb_self.doc.bytes()[d.start..d.end])?;
411
+ match d.key {
412
+ Some(k) => {
413
+ let pair = ruby.ary_new_capa(2);
414
+ pair.push(ruby.str_new(&k))?;
415
+ pair.push(child)?;
416
+ out.push(pair)?;
417
+ }
418
+ None => out.push(child)?,
419
+ }
420
+ }
421
+ Ok(out)
422
+ }