nosj 0.3.2 → 0.4.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.
data/ext/nosj/src/lazy.rs CHANGED
@@ -20,20 +20,20 @@ use magnus::{DataTypeFunctions, Error, RArray, RString, Ruby, TypedData, Value};
20
20
  use crate::errors::parser_error_at;
21
21
  use crate::parse::{materialize_at, parse_native_opts, span_of, utf8_input, ParseNativeOpts};
22
22
  use crate::pointer::{path_to_pointer, push_escaped_token};
23
- use crate::state::PULL_STATE;
23
+ use crate::state::with_pull_state;
24
24
 
25
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.
26
+ /// borrowed zero-copy: freezing rules out any change to its CONTENT,
27
+ /// and every node GC-marks the string with `rb_gc_mark` semantics
28
+ /// (alive, and pinned against compaction). Freezing does NOT pin the
29
+ /// buffer, though: deduplicating a frozen String subclass or a string
30
+ /// carrying ivars (`-str`) swaps in an identical shared buffer and
31
+ /// frees the old one. So the bytes are re-read from the string on every
32
+ /// access; same content means every span stays valid. Anything else is
33
+ /// copied once.
30
34
  pub(crate) enum DocBytes {
31
35
  Owned(Vec<u8>),
32
- Frozen {
33
- source: rb_sys::VALUE,
34
- ptr: *const u8,
35
- len: usize,
36
- },
36
+ Frozen(RString),
37
37
  /// A read-only file mapping (`NOSJ.load_lazy_file`): pages never
38
38
  /// touched are never read off disk. Concurrent modification of the
39
39
  /// mapped file by another process is documented as unsupported
@@ -48,23 +48,24 @@ struct DocInner {
48
48
  opts: ParseNativeOpts,
49
49
  }
50
50
 
51
- // SAFETY: the bytes are immutable for the document's whole life (an
51
+ // SAFETY: the content is immutable for the document's whole life (an
52
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.
53
+ // node's GC mark), so cross-thread reads are plain shared reads of it.
54
+ // There is no interior mutability anywhere in the type.
57
55
  unsafe impl Send for DocInner {}
58
56
  unsafe impl Sync for DocInner {}
59
57
 
60
58
  impl DocInner {
59
+ /// The document bytes. For a frozen source the slice is valid only
60
+ /// until the next Ruby call (which could swap the string's buffer;
61
+ /// see DocBytes): callers finish with it, or call this again,
62
+ /// before running Ruby code.
61
63
  fn bytes(&self) -> &[u8] {
62
64
  match &self.bytes {
63
65
  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) },
66
+ // SAFETY: a live string (kept alive and pinned by every
67
+ // node's GC mark), read fresh on each call; see DocBytes.
68
+ DocBytes::Frozen(source) => unsafe { source.as_slice() },
68
69
  DocBytes::Mmap(m) => m,
69
70
  }
70
71
  }
@@ -77,8 +78,16 @@ const KIND_ARRAY: u8 = b'[';
77
78
  /// come from the crate's resolver (token edges within the doc bytes),
78
79
  /// and never cross the Ruby boundary, so they cannot be forged from
79
80
  /// Ruby.
81
+ ///
82
+ /// `wb_protected`: a node's only Ruby reference (a frozen source, in
83
+ /// the shared `DocInner`) is set before the root node is wrapped and
84
+ /// never written again, so there is no write to put a barrier on, and
85
+ /// nodes promote to the old generation instead of being rescanned by
86
+ /// every minor GC (children cache Ruby-side, in barrier-protected
87
+ /// ivars). `free_immediately`: dropping a node calls no Ruby API (it
88
+ /// releases a Vec, an mmap, or nothing).
80
89
  #[derive(TypedData)]
81
- #[magnus(class = "NOSJ::Lazy", mark)]
90
+ #[magnus(class = "NOSJ::Lazy", free_immediately, mark, wb_protected)]
82
91
  pub struct LazyNode {
83
92
  doc: Arc<DocInner>,
84
93
  start: usize,
@@ -88,11 +97,10 @@ pub struct LazyNode {
88
97
 
89
98
  impl DataTypeFunctions for LazyNode {
90
99
  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) });
100
+ // The source was a live, frozen string at node creation, and
101
+ // this mark is what keeps it that way.
102
+ if let DocBytes::Frozen(source) = self.doc.bytes {
103
+ marker.mark(source);
96
104
  }
97
105
  }
98
106
  }
@@ -101,6 +109,13 @@ impl LazyNode {
101
109
  fn span(&self) -> &[u8] {
102
110
  &self.doc.bytes()[self.start..self.end]
103
111
  }
112
+
113
+ /// A Reader over this node's span, walking the grammar the document
114
+ /// was opened with (trailing commas, NaN keywords).
115
+ fn reader<'a, 'b>(&'a self, bufs: &'b mut nosj::Buffers) -> nosj::Reader<'a, 'b> {
116
+ // SAFETY: spans are valid UTF-8 (see resolve_in_span).
117
+ unsafe { nosj::Reader::from_utf8_unchecked_with(self.span(), bufs, self.doc.opts.popts) }
118
+ }
104
119
  }
105
120
 
106
121
  /// Wrap a resolved raw-value slice: containers become new lazy nodes,
@@ -125,13 +140,19 @@ fn resolved_to_value(ruby: &Ruby, doc: &Arc<DocInner>, sub: &[u8]) -> Result<Val
125
140
  /// Resolve `pointer` within `node`'s span. Shared by `__get` and
126
141
  /// `__at_pointer`; both misses and negative-index paths return nil.
127
142
  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();
143
+ // Resolve, then materialize, as two separate uses of the parse
144
+ // state; the resolved slice borrows the doc, not the state.
145
+ let resolved = with_pull_state(|state| {
132
146
  // SAFETY: doc bytes were coderange-gated at NOSJ.lazy creation,
133
147
  // 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) }
148
+ unsafe {
149
+ nosj::pointer_utf8_unchecked_with(
150
+ node.span(),
151
+ pointer,
152
+ &mut state.bufs,
153
+ node.doc.opts.popts,
154
+ )
155
+ }
135
156
  });
136
157
  match resolved {
137
158
  Ok(None) => Ok(ruby.qnil().as_value()),
@@ -165,12 +186,7 @@ pub fn lazy_native(
165
186
  // on the caller's machine stack, so it stays pinned through this
166
187
  // call, and the node's mark takes over from the first GC on.
167
188
  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
- }
189
+ DocBytes::Frozen(data)
174
190
  } else {
175
191
  DocBytes::Owned(input.to_vec())
176
192
  };
@@ -288,10 +304,8 @@ pub fn lazy_keys(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<RArray, Error> {
288
304
  ));
289
305
  }
290
306
  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) };
307
+ with_pull_state(|state| -> Result<(), Error> {
308
+ let mut r = rb_self.reader(&mut state.bufs);
295
309
  r.next_node().map_err(|e| reader_err(ruby, &rb_self, e))?;
296
310
  let mut has = match r
297
311
  .object_first_key()
@@ -324,10 +338,8 @@ pub fn lazy_keys(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<RArray, Error> {
324
338
  /// `__size`: entry count (object pairs or array elements), one walk,
325
339
  /// nothing materialized.
326
340
  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) };
341
+ with_pull_state(|state| {
342
+ let mut r = rb_self.reader(&mut state.bufs);
331
343
  r.next_node().map_err(|e| reader_err(ruby, &rb_self, e))?;
332
344
  let mut n = 0usize;
333
345
  if rb_self.kind == KIND_OBJECT {
@@ -366,14 +378,12 @@ struct ChildDesc {
366
378
 
367
379
  /// `__children`: every direct child in ONE walk. Objects yield
368
380
  /// `[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.
381
+ /// scalars materialize. Two phases so the walk's use of the parse state
382
+ /// ends before materialization needs it (nested, it would start fresh).
371
383
  pub fn lazy_children(ruby: &Ruby, rb_self: Obj<LazyNode>) -> Result<RArray, Error> {
372
384
  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) };
385
+ let descs: Result<Vec<ChildDesc>, nosj::ParseError> = with_pull_state(|state| {
386
+ let mut r = rb_self.reader(&mut state.bufs);
377
387
  r.next_node()?;
378
388
  let mut out = Vec::new();
379
389
  if rb_self.kind == KIND_OBJECT {
data/ext/nosj/src/lib.rs CHANGED
@@ -37,6 +37,14 @@ use magnus::{method, prelude::*, Error, Ruby};
37
37
  // "nosj/nosj"), not the package name nosj_native (see Cargo.toml).
38
38
  #[magnus::init(name = "nosj")]
39
39
  fn init(ruby: &Ruby) -> Result<(), Error> {
40
+ // Declared before any method exists: Ruby marks the methods defined
41
+ // after this call as callable from every Ractor. What backs it: the
42
+ // generate scratch is taken out of its thread-local for a call (see
43
+ // gen::GEN_SCRATCH), the key caches hold only shareable VALUEs
44
+ // (interned strings, static symbols), and the warm-up at the end of
45
+ // init resolves every lazily-initialized static on the main Ractor.
46
+ // SAFETY: a flag write on the VM's extension-load state.
47
+ unsafe { rb_sys::rb_ext_ractor_safe(true) };
40
48
  compile_info();
41
49
 
42
50
  let module = ruby.define_module("NOSJ")?;
@@ -104,6 +112,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
104
112
  // its `generate` through a Ruby frame into C, so skipping our own
105
113
  // forwarder frame is a straight per-call win on small documents.
106
114
  module.define_singleton_method("generate", method!(gen::generate_entry, -1))?;
115
+
116
+ // Warm-up on the main Ractor: magnus resolves a TypedData class (and
117
+ // gen/ruby.rs its interned IDs) behind a blocking lazy initializer
118
+ // that calls into the VM, and two Ractors racing that first touch
119
+ // deadlock (the loser parks natively and never joins the VM barrier
120
+ // the winner needs).
121
+ let _ = <state::ShadowHandle as magnus::TypedData>::class(ruby);
122
+ let _ = <lazy::LazyNode as magnus::TypedData>::class(ruby);
123
+ gen::warm_up();
107
124
  Ok(())
108
125
  }
109
126
 
@@ -24,22 +24,31 @@ fn blank(line: &[u8]) -> bool {
24
24
  /// Yield one parsed value per non-blank line. Each line parses through
25
25
  /// the shared sink machinery against the FULL source, so a malformed
26
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> {
27
+ /// NDJSON line number. No slice is held across a yield: the block runs
28
+ /// arbitrary Ruby (even deduplicating a frozen source swaps its buffer,
29
+ /// see `lazy::DocBytes`), so `source` hands out the bytes afresh for
30
+ /// every line, with identical content and so identical offsets.
31
+ fn walk_lines<'s>(
32
+ ruby: &Ruby,
33
+ source: impl Fn() -> &'s [u8],
34
+ o: &ParseNativeOpts,
35
+ ) -> Result<(), Error> {
30
36
  let mut pos = 0;
31
- while pos < source.len() {
32
- let line_end = source[pos..]
37
+ loop {
38
+ let bytes = source();
39
+ if pos >= bytes.len() {
40
+ return Ok(());
41
+ }
42
+ let line_end = bytes[pos..]
33
43
  .iter()
34
44
  .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)?;
45
+ .map_or(bytes.len(), |p| pos + p);
46
+ if !blank(&bytes[pos..line_end]) {
47
+ let value = materialize_at(ruby, bytes, pos, line_end, o)?;
38
48
  let _: Value = ruby.yield_value(value)?;
39
49
  }
40
50
  pos = line_end + 1;
41
51
  }
42
- Ok(())
43
52
  }
44
53
 
45
54
  /// `NOSJ.each_line(source, opts) { |value| }`: the Ruby wrapper
@@ -53,15 +62,16 @@ pub fn each_line_native(
53
62
  let o = parse_native_opts(ruby, opts)?;
54
63
  let input = utf8_input(ruby, &data)?;
55
64
  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)?;
65
+ // A frozen source keeps its content (never its buffer, see
66
+ // walk_lines), so it is re-read per line, zero-copy.
67
+ // SAFETY: validated UTF-8 above; `data` lives on this frame.
68
+ walk_lines(ruby, || unsafe { data.as_slice() }, &o)?;
59
69
  } 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.
70
+ // The block could rewrite an unfrozen source mid-iteration;
71
+ // walk a private copy. Same policy as NOSJ.lazy: pass a frozen
72
+ // string for zero-copy.
63
73
  let owned = input.to_vec();
64
- walk_lines(ruby, &owned, &o)?;
74
+ walk_lines(ruby, || &owned, &o)?;
65
75
  }
66
76
  Ok(ruby.qnil().as_value())
67
77
  }
@@ -84,7 +94,7 @@ pub fn each_line_file_native(
84
94
  return Ok(ruby.qnil().as_value());
85
95
  }
86
96
  with_mapped_file(ruby, &p, |map| {
87
- walk_lines(ruby, &map, &o)?;
97
+ walk_lines(ruby, || &map, &o)?;
88
98
  Ok(ruby.qnil().as_value())
89
99
  })
90
100
  }
@@ -8,7 +8,7 @@ use magnus::{Error, RString, Ruby, Value};
8
8
 
9
9
  use crate::errors::{nesting_error, parser_error, parser_error_at};
10
10
  use crate::sink::{NullSink, RubyValueSink, SinkAbort, MAX_NESTING};
11
- use crate::state::{ensure_marked_shadow, PullState, PULL_STATE};
11
+ use crate::state::{ensure_marked_shadow, with_pull_state, PullState};
12
12
 
13
13
  pub(crate) use crate::errors::parser_error as err;
14
14
 
@@ -22,6 +22,12 @@ pub(crate) fn span_of(source: &[u8], sub: &[u8]) -> (usize, usize) {
22
22
 
23
23
  /// Validate that `data` is UTF-8 (or US-ASCII) with intact coderange and
24
24
  /// hand out its byte slice.
25
+ ///
26
+ /// The slice borrows the string's buffer, which Ruby may reallocate or
27
+ /// swap (even for a frozen string: see `lazy::DocBytes`), so it must
28
+ /// not be held across anything that can run Ruby code: user callbacks,
29
+ /// yields, or option decoding (`to_int` and friends). Decode options
30
+ /// first; re-borrow after callbacks.
25
31
  pub(crate) fn utf8_input<'a>(ruby: &Ruby, data: &'a RString) -> Result<&'a [u8], Error> {
26
32
  let raw = data.as_raw();
27
33
  unsafe {
@@ -178,8 +184,7 @@ pub(crate) fn materialize_at(
178
184
  end: usize,
179
185
  o: &ParseNativeOpts,
180
186
  ) -> Result<Value, Error> {
181
- PULL_STATE.with(|cell| {
182
- let mut state = cell.borrow_mut();
187
+ with_pull_state(|state| {
183
188
  ensure_marked_shadow(&mut state.vstack);
184
189
  ensure_marked_shadow(&mut state.key_shadow);
185
190
 
@@ -190,7 +195,7 @@ pub(crate) fn materialize_at(
190
195
  vstack,
191
196
  key_shadow,
192
197
  ..
193
- } = &mut *state;
198
+ } = state;
194
199
  let stack = &mut vstack.as_mut().unwrap().values;
195
200
  stack.clear();
196
201
 
@@ -238,8 +243,7 @@ pub fn valid_native(
238
243
  let Ok(input) = utf8_input(ruby, &data) else {
239
244
  return Ok(false);
240
245
  };
241
- let ok = PULL_STATE.with(|cell| {
242
- let mut state = cell.borrow_mut();
246
+ let ok = with_pull_state(|state| {
243
247
  let mut sink = NullSink {
244
248
  depth: 0,
245
249
  max_nesting: o.max_nesting,
@@ -16,7 +16,7 @@ use magnus::{Error, ExceptionClass, RArray, RHash, RString, Ruby, Value};
16
16
  use crate::errors::{nosj_exception, parser_error_at};
17
17
  use crate::gen::{self, opts::GenConfig};
18
18
  use crate::parse::{materialize_at, span_of, utf8_input, ParseNativeOpts};
19
- use crate::state::PULL_STATE;
19
+ use crate::state::with_pull_state;
20
20
 
21
21
  const WS: [u8; 4] = *b" \t\n\r";
22
22
 
@@ -46,8 +46,7 @@ fn gen_config<'a>(
46
46
  /// Parse failures raise the rich ParserError (absolute positions);
47
47
  /// pointer syntax errors raise ArgumentError, like `at_pointer`.
48
48
  fn span_at(ruby: &Ruby, doc: &[u8], pointer: &str) -> Result<Option<(usize, usize)>, Error> {
49
- let resolved = PULL_STATE.with(|cell| {
50
- let mut state = cell.borrow_mut();
49
+ let resolved = with_pull_state(|state| {
51
50
  // SAFETY: every entry validated the document bytes as UTF-8.
52
51
  unsafe { nosj::pointer_utf8_unchecked(doc, pointer, &mut state.bufs) }
53
52
  });
@@ -82,8 +81,7 @@ fn container_children(
82
81
  ) -> Result<(u8, Vec<ChildSpan>), Error> {
83
82
  let span = &doc[start..end];
84
83
  let kind = span.first().copied().unwrap_or(0);
85
- let walk: Result<Vec<ChildSpan>, nosj::ParseError> = PULL_STATE.with(|cell| {
86
- let mut state = cell.borrow_mut();
84
+ let walk: Result<Vec<ChildSpan>, nosj::ParseError> = with_pull_state(|state| {
87
85
  // SAFETY: doc validated UTF-8 by the entry; spans lie on token
88
86
  // edges.
89
87
  let mut r = unsafe { nosj::Reader::from_utf8_unchecked(span, &mut state.bufs) };
@@ -360,6 +358,12 @@ fn op_test(ruby: &Ruby, doc: &[u8], path: &str, expected: Value) -> Result<(), E
360
358
  /// `NOSJ.splice(json, edits, opts)`: batch pointer replacement. All
361
359
  /// targets resolve in ONE forward pass; the output is built in one
362
360
  /// sweep copying every byte outside the target spans untouched.
361
+ ///
362
+ /// Every value is generated BEFORE the source bytes are borrowed:
363
+ /// generation runs user code (`to_json`, `to_s`) that may mutate or
364
+ /// reallocate the source, or drop the only Ruby reference to a later
365
+ /// value. Rendering inside the edits iteration keeps each value a live
366
+ /// argument while it runs, and nothing after the borrow calls Ruby.
363
367
  pub fn splice_native(
364
368
  ruby: &Ruby,
365
369
  _rb_self: Value,
@@ -369,21 +373,26 @@ pub fn splice_native(
369
373
  ) -> Result<RString, Error> {
370
374
  let mut slot = None;
371
375
  let cfg = gen_config(ruby, opts, &mut slot)?;
372
- let input = utf8_input(ruby, &data)?;
376
+ // Validate the encoding up front (error precedence); the bytes are
377
+ // borrowed again only after every callback has run.
378
+ utf8_input(ruby, &data)?;
373
379
 
374
380
  let mut pointers: Vec<String> = Vec::with_capacity(edits.len());
375
- let mut values: Vec<Value> = Vec::with_capacity(edits.len());
381
+ let mut rendered: Vec<u8> = Vec::new();
382
+ let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(edits.len());
376
383
  edits.foreach(|k: Value, v: Value| {
377
384
  let ptr = RString::from_value(k)
378
385
  .ok_or_else(|| arg_error(ruby, "splice pointers must be Strings".into()))?;
379
386
  pointers.push(ptr.to_string()?);
380
- values.push(v);
387
+ let start = rendered.len();
388
+ gen::emit_into(ruby, v, cfg, &mut rendered)?;
389
+ ranges.push((start, rendered.len()));
381
390
  Ok(magnus::r_hash::ForEach::Continue)
382
391
  })?;
383
392
 
393
+ let input = utf8_input(ruby, &data)?;
384
394
  let refs: Vec<&str> = pointers.iter().map(String::as_str).collect();
385
- let resolved = PULL_STATE.with(|cell| {
386
- let mut state = cell.borrow_mut();
395
+ let resolved = with_pull_state(|state| {
387
396
  // SAFETY: coderange verified by utf8_input.
388
397
  unsafe { nosj::pointers_utf8_unchecked(input, &refs, &mut state.bufs) }
389
398
  });
@@ -395,7 +404,7 @@ pub fn splice_native(
395
404
  Err(e) => return Err(parser_error_at(ruby, input, e.offset, e.to_string())),
396
405
  };
397
406
 
398
- let mut spans: Vec<(usize, usize, Value)> = Vec::with_capacity(pointers.len());
407
+ let mut spans: Vec<(usize, usize, usize)> = Vec::with_capacity(pointers.len());
399
408
  for (i, hit) in hits.into_iter().enumerate() {
400
409
  let Some(slice) = hit else {
401
410
  let exc: ExceptionClass = ruby.exception_key_error();
@@ -405,7 +414,7 @@ pub fn splice_native(
405
414
  ));
406
415
  };
407
416
  let (s, e) = span_of(input, slice.as_bytes());
408
- spans.push((s, e, values[i]));
417
+ spans.push((s, e, i));
409
418
  }
410
419
  spans.sort_unstable_by_key(|&(s, _, _)| s);
411
420
  for pair in spans.windows(2) {
@@ -417,11 +426,12 @@ pub fn splice_native(
417
426
  }
418
427
  }
419
428
 
420
- let mut out = Vec::with_capacity(input.len() + 64);
429
+ let mut out = Vec::with_capacity(input.len() + rendered.len());
421
430
  let mut pos = 0;
422
- for &(s, e, v) in &spans {
431
+ for &(s, e, edit) in &spans {
432
+ let (rs, re) = ranges[edit];
423
433
  out.extend_from_slice(&input[pos..s]);
424
- gen::emit_into(ruby, v, cfg, &mut out)?;
434
+ out.extend_from_slice(&rendered[rs..re]);
425
435
  pos = e;
426
436
  }
427
437
  out.extend_from_slice(&input[pos..]);
@@ -7,7 +7,7 @@ use magnus::{Error, RString, Ruby, Value};
7
7
 
8
8
  use crate::errors::parser_error_at;
9
9
  use crate::parse::{materialize_at, parse_native_opts, span_of, utf8_input, ParseNativeOpts};
10
- use crate::state::PULL_STATE;
10
+ use crate::state::with_pull_state;
11
11
 
12
12
  /// Resolve one JSON Pointer against `data`, materializing the matched
13
13
  /// subtree; `nil` when the pointer misses.
@@ -19,12 +19,11 @@ fn at_pointer_impl(
19
19
  ) -> Result<Value, Error> {
20
20
  use magnus::value::ReprValue;
21
21
  let input = utf8_input(ruby, &data)?;
22
- // Resolve first (one PULL_STATE borrow), then materialize (a fresh
23
- // borrow); the resolved slice borrows `input`, not the buffers.
24
- let resolved = PULL_STATE.with(|cell| {
25
- let mut state = cell.borrow_mut();
22
+ // Resolve, then materialize, as two separate uses of the parse
23
+ // state; the resolved slice borrows `input`, not the state.
24
+ let resolved = with_pull_state(|state| {
26
25
  // Safety: coderange verified above.
27
- unsafe { nosj::pointer_utf8_unchecked(input, pointer, &mut state.bufs) }
26
+ unsafe { nosj::pointer_utf8_unchecked_with(input, pointer, &mut state.bufs, o.popts) }
28
27
  });
29
28
  match resolved {
30
29
  Ok(None) => Ok(ruby.qnil().as_value()),
@@ -125,12 +124,11 @@ fn at_pointers_impl(
125
124
  let input = utf8_input(ruby, &data)?;
126
125
  let live: Vec<&str> = pointers.iter().flatten().map(String::as_str).collect();
127
126
 
128
- // Resolve first (one PULL_STATE borrow); the resolved slices borrow
129
- // `input`, not the buffers, so materializing can re-borrow freely.
130
- let resolved = PULL_STATE.with(|cell| {
131
- let mut state = cell.borrow_mut();
127
+ // Resolve first (one use of the parse state); the resolved slices
128
+ // borrow `input`, not the state, so each materializes separately.
129
+ let resolved = with_pull_state(|state| {
132
130
  // Safety: coderange verified by utf8_input.
133
- unsafe { nosj::pointers_utf8_unchecked(input, &live, &mut state.bufs) }
131
+ unsafe { nosj::pointers_utf8_unchecked_with(input, &live, &mut state.bufs, o.popts) }
134
132
  });
135
133
  let mut hits = match resolved {
136
134
  Ok(hits) => hits.into_iter(),
@@ -13,7 +13,7 @@
13
13
  //! canonical spelling (`1.50` becomes `1.5`), and string escapes are
14
14
  //! normalized by the emission kernels.
15
15
 
16
- use std::cell::RefCell;
16
+ use std::cell::Cell;
17
17
 
18
18
  use magnus::value::ReprValue;
19
19
  use magnus::{Error, RString, Ruby, Value};
@@ -23,16 +23,15 @@ use nosj::{FloatFormat, WriteOptions, Writer};
23
23
  use crate::errors::{nesting_error, nosj_exception, parser_error, parser_error_at};
24
24
  use crate::files::with_mapped_file;
25
25
  use crate::gen::opts::{parse_gen_opts, GenConfig, DEFAULT_CONFIG};
26
- use crate::parse::{parse_native_opts, utf8_input};
26
+ use crate::parse::{parse_native_opts, utf8_input, ParseNativeOpts};
27
27
  use crate::patch::finish_string;
28
28
  use crate::sink::SinkAbort;
29
- use crate::state::PULL_STATE;
29
+ use crate::state::{with_pull_state, with_taken};
30
30
 
31
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()) };
32
+ /// Pooled output buffer: capacity survives across calls (see
33
+ /// `state::with_taken`).
34
+ static PIPE_BUF: Cell<Vec<u8>> = const { Cell::new(Vec::new()) };
36
35
  }
37
36
 
38
37
  /// The event-to-Writer pipe. Structure events forward to the Writer's
@@ -230,37 +229,50 @@ fn write_options(cfg: &GenConfig) -> WriteOptions {
230
229
  w
231
230
  }
232
231
 
232
+ /// Decoded reformat options: parse acceptance plus generate formatting.
233
+ /// Decoded before the source is borrowed, since decoding can run Ruby
234
+ /// (an option value's `to_int`; see `parse::utf8_input`).
235
+ struct ReformatOpts {
236
+ parse: ParseNativeOpts,
237
+ generate: Option<GenConfig>,
238
+ }
239
+
240
+ impl ReformatOpts {
241
+ fn decode(ruby: &Ruby, opts: Value) -> Result<Self, Error> {
242
+ Ok(Self {
243
+ parse: parse_native_opts(ruby, opts)?,
244
+ generate: if opts.is_nil() {
245
+ None
246
+ } else {
247
+ Some(parse_gen_opts(ruby, opts)?.0)
248
+ },
249
+ })
250
+ }
251
+ }
252
+
233
253
  /// 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
- };
254
+ fn reformat_over(ruby: &Ruby, input: &[u8], opts: &ReformatOpts) -> Result<RString, Error> {
255
+ let po = &opts.parse;
256
+ let gcfg = opts.generate.as_ref().unwrap_or(&DEFAULT_CONFIG);
243
257
  let wopts = write_options(gcfg);
244
258
 
245
- PIPE_BUF.with(|cell| {
246
- let mut buf = cell.borrow_mut();
259
+ with_taken(&PIPE_BUF, |buf| {
247
260
  buf.clear();
248
261
  // The output is at least input-sized for minify-shaped runs.
249
262
  buf.reserve(input.len());
250
263
  let mut sink = PipeSink {
251
- w: Writer::new(&mut buf, &wopts),
264
+ w: Writer::new(buf, &wopts),
252
265
  depth: 0,
253
266
  max_nesting: po.max_nesting,
254
267
  mode: gcfg.mode,
255
268
  allow_nan: gcfg.allow_nan,
256
269
  };
257
- let result = PULL_STATE.with(|state_cell| {
258
- let mut state = state_cell.borrow_mut();
270
+ let result = with_pull_state(|state| {
259
271
  // Safety: callers verified UTF-8 (coderange or full scan).
260
272
  unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, po.popts) }
261
273
  });
262
274
  match result {
263
- Ok(()) => finish_string(&buf),
275
+ Ok(()) => finish_string(buf),
264
276
  Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
265
277
  ruby,
266
278
  format!(
@@ -296,8 +308,9 @@ pub fn reformat_native(
296
308
  data: RString,
297
309
  opts: Value,
298
310
  ) -> Result<RString, Error> {
311
+ let opts = ReformatOpts::decode(ruby, opts)?;
299
312
  let input = utf8_input(ruby, &data)?;
300
- reformat_over(ruby, input, opts)
313
+ reformat_over(ruby, input, &opts)
301
314
  }
302
315
 
303
316
  /// `NOSJ.reformat_file_native(path, opts)`: the pipe over a read-only
@@ -309,12 +322,13 @@ pub fn reformat_file_native(
309
322
  opts: Value,
310
323
  ) -> Result<RString, Error> {
311
324
  let p = path.to_string()?;
325
+ let opts = ReformatOpts::decode(ruby, opts)?;
312
326
  // Mapping a zero-length file fails with EINVAL on Linux; route an
313
327
  // empty file to the parser's own "unexpected end of input" so the
314
328
  // error class is deterministic across platforms. Metadata failures
315
329
  // fall through for the mapper's Errno.
316
330
  if std::fs::metadata(&p).is_ok_and(|m| m.len() == 0) {
317
- return reformat_over(ruby, &[], opts);
331
+ return reformat_over(ruby, &[], &opts);
318
332
  }
319
- with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, opts))
333
+ with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, &opts))
320
334
  }