nosj 0.4.0 → 0.5.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.
@@ -6,9 +6,11 @@
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};
10
- use crate::sink::{NullSink, RubyValueSink, SinkAbort, MAX_NESTING};
11
- use crate::state::{ensure_marked_shadow, PullState, PULL_STATE};
9
+ use crate::errors::{nesting_error, nosj_exception, parser_error, parser_error_at};
10
+ use crate::locate::Repeat;
11
+ use crate::opt_reader::{Opt, OptReader};
12
+ use crate::sink::{DupKeys, NullSink, RubyValueSink, SinkAbort, MAX_NESTING};
13
+ use crate::state::{ensure_marked_shadow, with_pull_state, PullState};
12
14
 
13
15
  pub(crate) use crate::errors::parser_error as err;
14
16
 
@@ -22,6 +24,12 @@ pub(crate) fn span_of(source: &[u8], sub: &[u8]) -> (usize, usize) {
22
24
 
23
25
  /// Validate that `data` is UTF-8 (or US-ASCII) with intact coderange and
24
26
  /// hand out its byte slice.
27
+ ///
28
+ /// The slice borrows the string's buffer, which Ruby may reallocate or
29
+ /// swap (even for a frozen string: see `lazy::DocBytes`), so it must
30
+ /// not be held across anything that can run Ruby code: user callbacks,
31
+ /// yields, or option decoding (`to_int` and friends). Decode options
32
+ /// first; re-borrow after callbacks.
25
33
  pub(crate) fn utf8_input<'a>(ruby: &Ruby, data: &'a RString) -> Result<&'a [u8], Error> {
26
34
  let raw = data.as_raw();
27
35
  unsafe {
@@ -45,6 +53,8 @@ pub(crate) struct ParseNativeOpts {
45
53
  pub(crate) symbolize: bool,
46
54
  pub(crate) freeze: bool,
47
55
  pub(crate) max_nesting: usize,
56
+ /// json 3 default: a repeated key raises.
57
+ pub(crate) allow_duplicate_key: bool,
48
58
  pub(crate) popts: nosj::ParseOptions,
49
59
  }
50
60
 
@@ -54,113 +64,173 @@ impl Default for ParseNativeOpts {
54
64
  symbolize: false,
55
65
  freeze: false,
56
66
  max_nesting: MAX_NESTING,
67
+ allow_duplicate_key: false,
57
68
  popts: nosj::ParseOptions::default(),
58
69
  }
59
70
  }
60
71
  }
61
72
 
62
- /// Decode a JSON.parse-compatible options hash: symbolize_names, freeze,
63
- /// max_nesting, allow_nan, allow_trailing_comma. Unsupported gem options
64
- /// (object_class, array_class, decimal_class, create_additions) raise.
73
+ /// Decode a JSON.parse-compatible options hash (see [`read_parse_opts`]);
74
+ /// keys it does not read raise ArgumentError, like json 3.
65
75
  pub(crate) fn parse_native_opts(ruby: &Ruby, opts: Value) -> Result<ParseNativeOpts, Error> {
66
- use magnus::value::ReprValue;
67
- use magnus::RHash;
76
+ let Some(h) = options_hash(ruby, opts)? else {
77
+ return Ok(ParseNativeOpts::default());
78
+ };
79
+ let mut reader = OptReader::new(ruby, h);
80
+ let out = read_parse_opts(&mut reader)?;
81
+ reader.finish()?;
82
+ Ok(out)
83
+ }
68
84
 
69
- let mut out = ParseNativeOpts::default();
85
+ /// The non-empty options Hash in `opts`, or None for nil and `{}`
86
+ /// (json 3's keyword-only parse hands the drop-in an empty hash per
87
+ /// call).
88
+ pub(crate) fn options_hash(ruby: &Ruby, opts: Value) -> Result<Option<magnus::RHash>, Error> {
89
+ use magnus::value::ReprValue;
70
90
  if opts.is_nil() {
71
- return Ok(out);
91
+ return Ok(None);
72
92
  }
73
- let h = RHash::from_value(opts)
93
+ let h = magnus::RHash::from_value(opts)
74
94
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "options must be a Hash"))?;
95
+ Ok((!h.is_empty()).then_some(h))
96
+ }
75
97
 
76
- let truthy = |name: &str| -> bool {
77
- h.get(ruby.to_symbol(name))
78
- .is_some_and(|v: Value| v.to_bool())
98
+ /// Read symbolize_names, freeze, max_nesting, allow_nan,
99
+ /// allow_trailing_comma and allow_duplicate_key. The json options NOSJ
100
+ /// does not implement (object_class, array_class, decimal_class,
101
+ /// on_load, create_additions, allow_comments, allow_control_characters,
102
+ /// allow_invalid_escape) raise unless falsy.
103
+ pub(crate) fn read_parse_opts(r: &mut OptReader) -> Result<ParseNativeOpts, Error> {
104
+ let mut out = ParseNativeOpts {
105
+ symbolize: r.truthy(Opt::SymbolizeNames),
106
+ freeze: r.truthy(Opt::Freeze),
107
+ allow_duplicate_key: r.truthy(Opt::AllowDuplicateKey),
108
+ ..ParseNativeOpts::default()
79
109
  };
110
+ out.popts.allow_nan = r.truthy(Opt::AllowNan);
111
+ out.popts.allow_trailing_comma = r.truthy(Opt::AllowTrailingComma);
80
112
 
81
- out.symbolize = truthy("symbolize_names");
82
- out.freeze = truthy("freeze");
83
- out.popts.allow_nan = truthy("allow_nan");
84
- out.popts.allow_trailing_comma = truthy("allow_trailing_comma");
85
-
86
- if let Some(mn) = h.get(ruby.to_symbol("max_nesting")) {
87
- out.max_nesting =
88
- if mn.is_nil() || mn.to_bool() && magnus::Integer::from_value(mn).is_none() {
89
- MAX_NESTING // nil / true: gem default
90
- } else if !mn.to_bool() {
91
- usize::MAX // false: unlimited
92
- } else {
93
- magnus::Integer::from_value(mn)
94
- .and_then(|i| i.to_u64().ok())
95
- .map_or(MAX_NESTING, |n| n as usize)
96
- };
113
+ if let Some(mn) = r.get(Opt::MaxNesting) {
114
+ out.max_nesting = max_nesting_of(mn);
97
115
  }
98
116
 
99
- for unsupported in [
100
- "object_class",
101
- "array_class",
102
- "decimal_class",
103
- "create_additions",
104
- ] {
105
- if truthy(unsupported) {
106
- return Err(Error::new(
107
- ruby.exception_arg_error(),
108
- format!("NOSJ.parse does not support the {unsupported} option"),
109
- ));
110
- }
111
- }
117
+ r.tolerate(&[
118
+ Opt::ObjectClass,
119
+ Opt::ArrayClass,
120
+ Opt::DecimalClass,
121
+ Opt::OnLoad,
122
+ Opt::CreateAdditions,
123
+ Opt::AllowComments,
124
+ Opt::AllowControlCharacters,
125
+ Opt::AllowInvalidEscape,
126
+ ]);
112
127
  Ok(out)
113
128
  }
114
129
 
115
- /// Pop the root value off the sink stack, or map a drive failure onto
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.
119
- fn finish_drive(
130
+ /// A given `max_nesting` value as a limit: nil or true keep the gem's
131
+ /// default, false is unlimited, an Integer is the limit.
132
+ pub(crate) fn max_nesting_of(value: Value) -> usize {
133
+ use magnus::value::ReprValue;
134
+ if value.is_nil() || value.to_bool() && magnus::Integer::from_value(value).is_none() {
135
+ MAX_NESTING
136
+ } else if !value.to_bool() {
137
+ usize::MAX
138
+ } else {
139
+ magnus::Integer::from_value(value)
140
+ .and_then(|i| i.to_u64().ok())
141
+ .map_or(MAX_NESTING, |n| n as usize)
142
+ }
143
+ }
144
+
145
+ pub(crate) type DriveResult = Result<(), nosj::DriveError<SinkAbort>>;
146
+
147
+ /// A drive's failure as the gem's exception; shared by every driver.
148
+ /// `source[start..end]` is what was driven, within the full document,
149
+ /// so ParserError positions stay absolute when a subtree slice was
150
+ /// parsed. Sinks see no offsets, so their two document refusals are
151
+ /// positioned by a cold-path re-walk (`crate::locate`).
152
+ pub(crate) fn drive_error(
120
153
  ruby: &Ruby,
121
- result: Result<(), nosj::DriveError<SinkAbort>>,
122
- stack: &mut Vec<rb_sys::VALUE>,
123
- max_nesting: usize,
154
+ failure: nosj::DriveError<SinkAbort>,
155
+ o: &ParseNativeOpts,
124
156
  source: &[u8],
125
- base: usize,
126
- ) -> Result<Value, Error> {
127
- match result {
128
- Ok(()) => {
129
- let raw = stack
130
- .pop()
131
- .unwrap_or(rb_sys::special_consts::Qnil as rb_sys::VALUE);
132
- Ok(unsafe { Value::from_raw(raw) })
133
- }
134
- Err(nosj::DriveError::Sink(SinkAbort::Overflow)) => {
135
- Err(parser_error(ruby, "document too large".into()))
136
- }
137
- Err(nosj::DriveError::Sink(SinkAbort::BadBigint)) => {
138
- Err(parser_error(ruby, "invalid bignum".into()))
139
- }
140
- Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
141
- ruby,
142
- format!("nesting of {} is too deep", max_nesting.saturating_add(1)),
143
- )),
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
- // Also reformat-pipe-only, kept total for the same reason.
151
- Err(nosj::DriveError::Sink(SinkAbort::NonFiniteFloat(spelling))) => Err(parser_error(
157
+ (start, end): (usize, usize),
158
+ ) -> Error {
159
+ use magnus::value::ReprValue;
160
+ use nosj::DriveError::{Parse, Sink};
161
+
162
+ let driven = &source[start..end];
163
+ match failure {
164
+ Sink(SinkAbort::Overflow) => parser_error(ruby, "document too large".into()),
165
+ Sink(SinkAbort::BadBigint) => parser_error(ruby, "invalid bignum".into()),
166
+ Sink(SinkAbort::TooDeep) => nesting_error(
152
167
  ruby,
168
+ format!("nesting of {} is too deep", o.max_nesting.saturating_add(1)),
169
+ ),
170
+ // Positioned like json 3's: at the `{` of the object repeating
171
+ // the key.
172
+ Sink(SinkAbort::DuplicateKey) => match crate::locate::duplicate_key(driven, o.popts) {
173
+ Repeat::Found { at, key } => parser_error_at(
174
+ ruby,
175
+ source,
176
+ start + at,
177
+ format!(
178
+ "duplicate key {} at byte {at}",
179
+ ruby.str_new(&key).inspect()
180
+ ),
181
+ ),
182
+ Repeat::Absent | Repeat::Undecided => parser_error(ruby, "duplicate key".into()),
183
+ },
184
+ Sink(SinkAbort::LoneSurrogate) => match crate::locate::first_walk_error(driven, o.popts) {
185
+ Some(e) => parser_error_at(ruby, source, start + e.offset, e.to_string()),
186
+ None => parser_error(ruby, "lone UTF-16 surrogate".into()),
187
+ },
188
+ // Only the reformat pipe raises it: generation refuses a
189
+ // non-finite float, with the gem's GeneratorError.
190
+ Sink(SinkAbort::NonFiniteFloat(spelling)) => Error::new(
191
+ nosj_exception(ruby, "GeneratorError"),
153
192
  format!("{spelling} not allowed in JSON"),
154
- )),
155
- Err(nosj::DriveError::Parse(e)) => Err(parser_error_at(
156
- ruby,
157
- source,
158
- base + e.offset,
159
- e.to_string(),
160
- )),
193
+ ),
194
+ Parse(e) => parser_error_at(ruby, source, start + e.offset, e.to_string()),
161
195
  }
162
196
  }
163
197
 
198
+ /// Drive a sink that builds no Hash (`valid?`, the reformat pipe) under
199
+ /// json 3's duplicate-key rule. `drive(check_dups)` runs the parse. With
200
+ /// the check on, `DupKeys` compares key fingerprints, so a refusal is
201
+ /// confirmed exactly, and a collision (no object really repeats a key)
202
+ /// drives again with the check off.
203
+ pub(crate) fn drive_hashless(
204
+ input: &[u8],
205
+ o: &ParseNativeOpts,
206
+ mut drive: impl FnMut(bool) -> DriveResult,
207
+ ) -> DriveResult {
208
+ let result = drive(!o.allow_duplicate_key);
209
+ if matches!(result, Err(nosj::DriveError::Sink(SinkAbort::DuplicateKey)))
210
+ && matches!(crate::locate::duplicate_key(input, o.popts), Repeat::Absent)
211
+ {
212
+ return drive(false);
213
+ }
214
+ result
215
+ }
216
+
217
+ /// Pop the root value off the sink stack, or raise the drive's failure
218
+ /// (see [`drive_error`]).
219
+ fn finish_drive(
220
+ ruby: &Ruby,
221
+ result: DriveResult,
222
+ stack: &mut Vec<rb_sys::VALUE>,
223
+ o: &ParseNativeOpts,
224
+ source: &[u8],
225
+ span: (usize, usize),
226
+ ) -> Result<Value, Error> {
227
+ result.map_err(|failure| drive_error(ruby, failure, o, source, span))?;
228
+ let raw = stack
229
+ .pop()
230
+ .unwrap_or(rb_sys::special_consts::Qnil as rb_sys::VALUE);
231
+ Ok(unsafe { Value::from_raw(raw) })
232
+ }
233
+
164
234
  /// Drive the fused cursor over the whole of `source`. See
165
235
  /// [`materialize_at`].
166
236
  pub(crate) fn materialize(ruby: &Ruby, source: &[u8], o: &ParseNativeOpts) -> Result<Value, Error> {
@@ -178,8 +248,7 @@ pub(crate) fn materialize_at(
178
248
  end: usize,
179
249
  o: &ParseNativeOpts,
180
250
  ) -> Result<Value, Error> {
181
- PULL_STATE.with(|cell| {
182
- let mut state = cell.borrow_mut();
251
+ with_pull_state(|state| {
183
252
  ensure_marked_shadow(&mut state.vstack);
184
253
  ensure_marked_shadow(&mut state.key_shadow);
185
254
 
@@ -190,7 +259,7 @@ pub(crate) fn materialize_at(
190
259
  vstack,
191
260
  key_shadow,
192
261
  ..
193
- } = &mut *state;
262
+ } = state;
194
263
  let stack = &mut vstack.as_mut().unwrap().values;
195
264
  stack.clear();
196
265
 
@@ -202,13 +271,14 @@ pub(crate) fn materialize_at(
202
271
  symbolize: o.symbolize,
203
272
  freeze: o.freeze,
204
273
  max_nesting: o.max_nesting,
274
+ allow_duplicate_key: o.allow_duplicate_key,
205
275
  };
206
276
 
207
277
  // Safety: callers verified UTF-8 (coderange or nosj slice).
208
278
  let result = unsafe {
209
279
  nosj::parse_utf8_unchecked_with(&source[start..end], bufs, &mut sink, o.popts)
210
280
  };
211
- finish_drive(ruby, result, sink.stack, o.max_nesting, source, start)
281
+ finish_drive(ruby, result, sink.stack, o, source, (start, end))
212
282
  })
213
283
  }
214
284
 
@@ -238,15 +308,16 @@ pub fn valid_native(
238
308
  let Ok(input) = utf8_input(ruby, &data) else {
239
309
  return Ok(false);
240
310
  };
241
- let ok = PULL_STATE.with(|cell| {
242
- let mut state = cell.borrow_mut();
243
- let mut sink = NullSink {
244
- depth: 0,
245
- max_nesting: o.max_nesting,
246
- };
247
- // Safety: coderange verified by utf8_input.
248
- unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, o.popts) }
249
- .is_ok()
311
+ let result = drive_hashless(input, &o, |check_dups| {
312
+ with_pull_state(|state| {
313
+ let mut sink = NullSink {
314
+ depth: 0,
315
+ max_nesting: o.max_nesting,
316
+ dup_keys: DupKeys::new(&mut state.dup, check_dups),
317
+ };
318
+ // Safety: coderange verified by utf8_input.
319
+ unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, o.popts) }
320
+ })
250
321
  });
251
- Ok(ok)
322
+ Ok(result.is_ok())
252
323
  }
@@ -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(),