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.
@@ -5,34 +5,32 @@
5
5
  //! SIMD escape kernels on the way out there is nothing else.
6
6
  //!
7
7
  //! Output is exactly what `NOSJ.generate(NOSJ.parse(json), opts)`
8
- //! would produce, with two deliberate differences: duplicate object
9
- //! keys pass through (a reformatter must not silently drop data the
10
- //! way parse's last-key-wins materialization does), and lone-surrogate
11
- //! string values re-escape as `\uXXXX` instead of raising (the output
12
- //! must reparse; raw WTF-8 would not). Numbers come out in the gem's
13
- //! canonical spelling (`1.50` becomes `1.5`), and string escapes are
14
- //! normalized by the emission kernels.
8
+ //! would produce, and the pipe accepts exactly what parse accepts:
9
+ //! duplicate keys raise unless `allow_duplicate_key` (then they pass
10
+ //! through: a reformatter must not silently drop data the way parse's
11
+ //! last-key-wins materialization does), and lone surrogates raise.
12
+ //! Numbers come out in the gem's canonical spelling (`1.50` becomes
13
+ //! `1.5`), and string escapes are normalized by the emission kernels.
15
14
 
16
- use std::cell::RefCell;
15
+ use std::cell::Cell;
17
16
 
18
- use magnus::value::ReprValue;
19
17
  use magnus::{Error, RString, Ruby, Value};
20
- use nosj::emit::EscapeMode;
21
18
  use nosj::{FloatFormat, WriteOptions, Writer};
22
19
 
23
- use crate::errors::{nesting_error, nosj_exception, parser_error, parser_error_at};
24
20
  use crate::files::with_mapped_file;
25
- use crate::gen::opts::{parse_gen_opts, GenConfig, DEFAULT_CONFIG};
26
- use crate::parse::{parse_native_opts, utf8_input};
21
+ use crate::gen::opts::{read_gen_opts, GenConfig, DEFAULT_CONFIG};
22
+ use crate::opt_reader::OptReader;
23
+ use crate::parse::{
24
+ drive_error, drive_hashless, options_hash, read_parse_opts, utf8_input, ParseNativeOpts,
25
+ };
27
26
  use crate::patch::finish_string;
28
- use crate::sink::SinkAbort;
29
- use crate::state::PULL_STATE;
27
+ use crate::sink::{DupKeys, SinkAbort};
28
+ use crate::state::{with_pull_state, with_taken};
30
29
 
31
30
  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()) };
31
+ /// Pooled output buffer: capacity survives across calls (see
32
+ /// `state::with_taken`).
33
+ static PIPE_BUF: Cell<Vec<u8>> = const { Cell::new(Vec::new()) };
36
34
  }
37
35
 
38
36
  /// The event-to-Writer pipe. Structure events forward to the Writer's
@@ -42,61 +40,10 @@ struct PipeSink<'a> {
42
40
  w: Writer<'a>,
43
41
  depth: usize,
44
42
  max_nesting: usize,
45
- /// For re-escaping WTF-8 string content (see [`quote_wtf8`]).
46
- mode: EscapeMode,
47
43
  /// Non-finite floats pass through as literals only when the
48
44
  /// generate side allows them; see [`PipeSink::float`].
49
45
  allow_nan: bool,
50
- }
51
-
52
- const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
53
-
54
- /// WTF-8 lone surrogates arrive as one 3-byte sequence with this lead
55
- /// byte (the only ill-formed runs the parser ever emits).
56
- const WTF8_SURROGATE_LEAD: u8 = 0xED;
57
- const WTF8_SURROGATE_LEN: usize = 3;
58
- /// Payload bits of a UTF-8 lead / continuation byte.
59
- const UTF8_LEAD3_BITS: u32 = 0x0F;
60
- const UTF8_CONT_BITS: u32 = 0x3F;
61
-
62
- /// Quote and escape WTF-8 content: valid UTF-8 runs go through the
63
- /// configured escape kernel, and lone-surrogate sequences re-escape as
64
- /// `\uXXXX`, so the output reparses to the identical string in every
65
- /// mode (raw WTF-8 bytes would not: the parser requires UTF-8 input).
66
- /// This deliberately diverges from `generate`, which refuses
67
- /// broken-coderange strings: a reformatter must accept everything the
68
- /// parser accepts.
69
- fn quote_wtf8(out: &mut Vec<u8>, bytes: &[u8], mode: EscapeMode) {
70
- out.push(b'"');
71
- let mut rest = bytes;
72
- loop {
73
- match std::str::from_utf8(rest) {
74
- Ok(s) => {
75
- nosj::emit::escape_into(out, s.as_bytes(), mode);
76
- break;
77
- }
78
- Err(e) => {
79
- let valid = e.valid_up_to();
80
- nosj::emit::escape_into(out, &rest[..valid], mode);
81
- let sur = &rest[valid..];
82
- debug_assert!(
83
- sur.len() >= WTF8_SURROGATE_LEN && sur[0] == WTF8_SURROGATE_LEAD,
84
- "parser only emits lone-surrogate WTF-8"
85
- );
86
- // Standard 3-byte UTF-8 decode of the surrogate
87
- // codepoint (U+D800..U+DFFF), re-emitted as \uXXXX.
88
- let cp = ((u32::from(sur[0]) & UTF8_LEAD3_BITS) << 12)
89
- | ((u32::from(sur[1]) & UTF8_CONT_BITS) << 6)
90
- | (u32::from(sur[2]) & UTF8_CONT_BITS);
91
- out.extend_from_slice(b"\\u");
92
- for shift in [12, 8, 4, 0] {
93
- out.push(HEX_DIGITS[((cp >> shift) & 0xF) as usize]);
94
- }
95
- rest = &sur[WTF8_SURROGATE_LEN..];
96
- }
97
- }
98
- }
99
- out.push(b'"');
46
+ dup_keys: DupKeys<'a>,
100
47
  }
101
48
 
102
49
  impl PipeSink<'_> {
@@ -166,25 +113,18 @@ impl nosj::Sink for PipeSink<'_> {
166
113
  Ok(())
167
114
  }
168
115
 
169
- fn str_bytes(&mut self, value: &[u8]) -> Result<(), SinkAbort> {
170
- // Rare path (lone-surrogate content); a per-call buffer is fine.
171
- let mut quoted = Vec::with_capacity(value.len() + 8);
172
- quote_wtf8(&mut quoted, value, self.mode);
173
- self.w.value_raw(&quoted);
174
- Ok(())
116
+ fn str_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
117
+ Err(SinkAbort::LoneSurrogate)
175
118
  }
176
119
 
177
120
  fn key(&mut self, key: &str) -> Result<(), SinkAbort> {
121
+ self.dup_keys.key(key.as_bytes());
178
122
  self.w.key(key);
179
123
  Ok(())
180
124
  }
181
125
 
182
- fn key_bytes(&mut self, _key: &[u8]) -> Result<(), SinkAbort> {
183
- // A lone-surrogate KEY has no pre-serialized escape hatch in
184
- // the Writer (values have value_raw; a key_raw is on the crate
185
- // wishlist), so this pathological case keeps generate's
186
- // refusal semantics.
187
- Err(SinkAbort::BrokenUtf8Output)
126
+ fn key_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
127
+ Err(SinkAbort::LoneSurrogate)
188
128
  }
189
129
 
190
130
  fn begin_array(&mut self) -> Result<(), SinkAbort> {
@@ -200,7 +140,7 @@ impl nosj::Sink for PipeSink<'_> {
200
140
  }
201
141
 
202
142
  fn mark(&self) -> usize {
203
- 0
143
+ self.dup_keys.mark()
204
144
  }
205
145
 
206
146
  fn end_array(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
@@ -209,8 +149,9 @@ impl nosj::Sink for PipeSink<'_> {
209
149
  Ok(())
210
150
  }
211
151
 
212
- fn end_object(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
152
+ fn end_object(&mut self, mark: usize, _: usize) -> Result<(), SinkAbort> {
213
153
  self.depth -= 1;
154
+ self.dup_keys.close(mark)?;
214
155
  self.w.end_object();
215
156
  Ok(())
216
157
  }
@@ -230,61 +171,61 @@ fn write_options(cfg: &GenConfig) -> WriteOptions {
230
171
  w
231
172
  }
232
173
 
174
+ /// Decoded reformat options: parse acceptance plus generate formatting.
175
+ /// Decoded before the source is borrowed, since decoding can run Ruby
176
+ /// (an option value's `to_int`; see `parse::utf8_input`).
177
+ struct ReformatOpts {
178
+ parse: ParseNativeOpts,
179
+ generate: Option<GenConfig>,
180
+ }
181
+
182
+ impl ReformatOpts {
183
+ /// One reader over both option sets, so a key either reads is known.
184
+ fn decode(ruby: &Ruby, opts: Value) -> Result<Self, Error> {
185
+ let Some(h) = options_hash(ruby, opts)? else {
186
+ return Ok(Self {
187
+ parse: ParseNativeOpts::default(),
188
+ generate: None,
189
+ });
190
+ };
191
+ let mut reader = OptReader::new(ruby, h);
192
+ let parse = read_parse_opts(&mut reader)?;
193
+ let (generate, _) = read_gen_opts(&mut reader)?;
194
+ reader.finish()?;
195
+ Ok(Self {
196
+ parse,
197
+ generate: Some(generate),
198
+ })
199
+ }
200
+ }
201
+
233
202
  /// 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
- };
203
+ fn reformat_over(ruby: &Ruby, input: &[u8], opts: &ReformatOpts) -> Result<RString, Error> {
204
+ let po = &opts.parse;
205
+ let gcfg = opts.generate.as_ref().unwrap_or(&DEFAULT_CONFIG);
243
206
  let wopts = write_options(gcfg);
244
207
 
245
- PIPE_BUF.with(|cell| {
246
- let mut buf = cell.borrow_mut();
247
- buf.clear();
248
- // The output is at least input-sized for minify-shaped runs.
249
- buf.reserve(input.len());
250
- let mut sink = PipeSink {
251
- w: Writer::new(&mut buf, &wopts),
252
- depth: 0,
253
- max_nesting: po.max_nesting,
254
- mode: gcfg.mode,
255
- allow_nan: gcfg.allow_nan,
256
- };
257
- let result = PULL_STATE.with(|state_cell| {
258
- let mut state = state_cell.borrow_mut();
259
- // Safety: callers verified UTF-8 (coderange or full scan).
260
- unsafe { nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, po.popts) }
261
- });
262
- match result {
263
- Ok(()) => finish_string(&buf),
264
- Err(nosj::DriveError::Sink(SinkAbort::TooDeep)) => Err(nesting_error(
265
- ruby,
266
- format!(
267
- "nesting of {} is too deep",
268
- po.max_nesting.saturating_add(1)
269
- ),
270
- )),
271
- Err(nosj::DriveError::Sink(SinkAbort::BrokenUtf8Output)) => Err(Error::new(
272
- // Gem parity: generate raises GeneratorError for a
273
- // string ascii_only cannot represent.
274
- nosj_exception(ruby, "GeneratorError"),
275
- "source sequence is illegal/malformed utf-8",
276
- )),
277
- Err(nosj::DriveError::Sink(SinkAbort::NonFiniteFloat(spelling))) => Err(Error::new(
278
- nosj_exception(ruby, "GeneratorError"),
279
- format!("{spelling} not allowed in JSON"),
280
- )),
281
- Err(nosj::DriveError::Sink(_)) => {
282
- Err(parser_error(ruby, "reformat pass aborted".into()))
283
- }
284
- Err(nosj::DriveError::Parse(e)) => {
285
- Err(parser_error_at(ruby, input, e.offset, e.to_string()))
286
- }
287
- }
208
+ with_taken(&PIPE_BUF, |buf| {
209
+ drive_hashless(input, po, |check_dups| {
210
+ buf.clear();
211
+ // The output is at least input-sized for minify-shaped runs.
212
+ buf.reserve(input.len());
213
+ with_pull_state(|state| {
214
+ let mut sink = PipeSink {
215
+ w: Writer::new(buf, &wopts),
216
+ depth: 0,
217
+ max_nesting: po.max_nesting,
218
+ allow_nan: gcfg.allow_nan,
219
+ dup_keys: DupKeys::new(&mut state.dup, check_dups),
220
+ };
221
+ // Safety: callers verified UTF-8 (coderange or full scan).
222
+ unsafe {
223
+ nosj::parse_utf8_unchecked_with(input, &mut state.bufs, &mut sink, po.popts)
224
+ }
225
+ })
226
+ })
227
+ .map_err(|failure| drive_error(ruby, failure, po, input, (0, input.len())))?;
228
+ finish_string(buf)
288
229
  })
289
230
  }
290
231
 
@@ -296,8 +237,9 @@ pub fn reformat_native(
296
237
  data: RString,
297
238
  opts: Value,
298
239
  ) -> Result<RString, Error> {
240
+ let opts = ReformatOpts::decode(ruby, opts)?;
299
241
  let input = utf8_input(ruby, &data)?;
300
- reformat_over(ruby, input, opts)
242
+ reformat_over(ruby, input, &opts)
301
243
  }
302
244
 
303
245
  /// `NOSJ.reformat_file_native(path, opts)`: the pipe over a read-only
@@ -309,12 +251,13 @@ pub fn reformat_file_native(
309
251
  opts: Value,
310
252
  ) -> Result<RString, Error> {
311
253
  let p = path.to_string()?;
254
+ let opts = ReformatOpts::decode(ruby, opts)?;
312
255
  // Mapping a zero-length file fails with EINVAL on Linux; route an
313
256
  // empty file to the parser's own "unexpected end of input" so the
314
257
  // error class is deterministic across platforms. Metadata failures
315
258
  // fall through for the mapper's Errno.
316
259
  if std::fs::metadata(&p).is_ok_and(|m| m.len() == 0) {
317
- return reformat_over(ruby, &[], opts);
260
+ return reformat_over(ruby, &[], &opts);
318
261
  }
319
- with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, opts))
262
+ with_mapped_file(ruby, &p, |map| reformat_over(ruby, &map, &opts))
320
263
  }
data/ext/nosj/src/sink.rs CHANGED
@@ -1,7 +1,8 @@
1
1
  //! The nosj sinks: `RubyValueSink` builds Ruby VALUEs directly during
2
2
  //! the parse (with interned-key caches and gem-compatible option
3
3
  //! handling); `NullSink` powers `NOSJ.valid?` by discarding every
4
- //! event. Raw VALUE construction helpers live here too.
4
+ //! event; `DupKeys` gives hash-less sinks duplicate-key detection. Raw
5
+ //! VALUE construction helpers live here too.
5
6
 
6
7
  use ahash::AHashMap;
7
8
 
@@ -17,17 +18,20 @@ pub(crate) const MAX_NESTING: usize = 100;
17
18
  const KEY_CACHE_CAP: usize = 2048;
18
19
 
19
20
  /// Why a sink stopped the drive; mapped onto the gem's exceptions in
20
- /// [`crate::parse::finish_drive`].
21
+ /// [`crate::parse::drive_error`]. Sinks see no offsets, so the two
22
+ /// document refusals get their position from a cold-path re-walk
23
+ /// (`crate::locate`).
21
24
  pub(crate) enum SinkAbort {
22
25
  Overflow,
23
26
  BadBigint,
24
27
  TooDeep,
25
- /// The reformat pipe met a WTF-8 (lone-surrogate) object KEY,
26
- /// which the Writer has no pre-serialized escape hatch for; gem
27
- /// parity is the GeneratorError `generate` raises on the
28
- /// equivalent broken-coderange string. (String VALUES re-escape
29
- /// as \uXXXX instead.)
30
- BrokenUtf8Output,
28
+ /// An object repeats a key and `allow_duplicate_key` is off (json
29
+ /// 3 semantics). From `DupKeys` it may be a fingerprint collision,
30
+ /// which the exact cold-path check rules out.
31
+ DuplicateKey,
32
+ /// A string or key decodes to a lone UTF-16 surrogate (json 3
33
+ /// rejects trailing ones too, not only leading ones).
34
+ LoneSurrogate,
31
35
  /// The reformat pipe met a non-finite float without `allow_nan`.
32
36
  /// Parsing accepts huge-exponent literals like 1e999 as Infinity
33
37
  /// even in strict mode (gem parity), but generation refuses them;
@@ -35,24 +39,160 @@ pub(crate) enum SinkAbort {
35
39
  NonFiniteFloat(&'static str),
36
40
  }
37
41
 
38
- /// `RB_INT2FIX` ported from Ruby's public inline headers: fixnums are
39
- /// `(i << 1) + 1` for `|i| <= LONG_MAX / 2`, and the range is defined by
40
- /// the C `long`, which is 32-bit on Windows (LLP64): fixnums there hold
41
- /// only 31 bits, and tagging anything wider crashes Ruby with
42
- /// "Unnormalized Fixnum value". C extensions get the check inlined by the
43
- /// header; going through the extern `rb_ll2inum` costs an FFI call per
44
- /// integer. Non-fixable values still take the call.
45
- // The widening is an identity on LP64 hosts (clippy flags it there) but
46
- // required on Windows, where c_long is 32-bit.
47
- #[allow(clippy::unnecessary_cast)]
42
+ /// Duplicate-key detection for sinks that build no Hash (validation and
43
+ /// the reformat pipe): each key is remembered as a 64-bit fingerprint,
44
+ /// `mark()` at a container's start is the fingerprint count, and an
45
+ /// object's close checks only its own keys. Integers, because comparing
46
+ /// them is what keeps this cheap: comparing the key bytes themselves
47
+ /// measured up to 93% slower (twitter's 40-key objects). A fingerprint
48
+ /// collision reads as a duplicate, so callers confirm a hit exactly on
49
+ /// the cold path; a collision can only cost time.
50
+ ///
51
+ /// Measured split (twitter `valid?`): hashing and pushing each key is
52
+ /// ~0-2%; the close-time check is the cost, hence [`SeenTable`].
53
+ pub(crate) struct DupKeys<'a> {
54
+ scratch: &'a mut DupScratch,
55
+ enabled: bool,
56
+ }
57
+
58
+ /// [`DupKeys`]' pooled buffers (per thread, in `PullState`): the key
59
+ /// fingerprints of every open object, and the close-time set.
60
+ #[derive(Default)]
61
+ pub(crate) struct DupScratch {
62
+ fingerprints: Vec<u64>,
63
+ table: SeenTable,
64
+ }
65
+
66
+ /// Fixed seeds: fingerprints only need to spread keys, and a collision
67
+ /// merely sends the document to the exact cold-path check. (A leaner
68
+ /// hand-rolled hash measured no faster: hashing is not the cost.)
69
+ const FINGERPRINT: ahash::RandomState = ahash::RandomState::with_seeds(
70
+ 0x243f_6a88_85a3_08d3,
71
+ 0x1319_8a2e_0370_7344,
72
+ 0xa409_3822_299f_31d0,
73
+ 0x082e_fa98_ec4e_6c89,
74
+ );
75
+
76
+ /// Objects up to this many keys compare pairwise (a few compares beat
77
+ /// any table traffic).
78
+ const PAIRWISE_MAX: usize = 8;
79
+ /// Table slots; a power of two, so a fingerprint's low bits index it.
80
+ const TABLE_SLOTS: usize = 256;
81
+ /// Objects up to this many keys use the table, which then stays at
82
+ /// least half empty; larger ones sort.
83
+ const TABLE_MAX_KEYS: usize = TABLE_SLOTS / 2;
84
+
85
+ /// Open-addressing set for one object's close: fingerprints are already
86
+ /// uniformly mixed, so their low bits index it directly, and one probe
87
+ /// per key usually decides. Each close claims a fresh epoch, and a slot
88
+ /// is empty unless it carries the current one, so nothing is cleared
89
+ /// between objects.
90
+ struct SeenTable {
91
+ slots: Box<[(u64, u32)]>,
92
+ epoch: u32,
93
+ }
94
+
95
+ impl Default for SeenTable {
96
+ fn default() -> Self {
97
+ SeenTable {
98
+ slots: vec![(0, 0); TABLE_SLOTS].into_boxed_slice(),
99
+ epoch: 0,
100
+ }
101
+ }
102
+ }
103
+
104
+ impl SeenTable {
105
+ /// Whether `keys` repeats a fingerprint. Callers keep
106
+ /// `keys.len() <= TABLE_MAX_KEYS`, so a free slot always exists.
107
+ #[inline(always)]
108
+ fn any_repeat(&mut self, keys: &[u64]) -> bool {
109
+ const MASK: usize = TABLE_SLOTS - 1;
110
+ self.epoch = self.epoch.wrapping_add(1);
111
+ if self.epoch == 0 {
112
+ // Epoch 0 marks an empty slot; after a full wrap, really clear.
113
+ self.slots.fill((0, 0));
114
+ self.epoch = 1;
115
+ }
116
+ let epoch = self.epoch;
117
+ for &fp in keys {
118
+ let mut at = fp as usize & MASK;
119
+ loop {
120
+ let slot = &mut self.slots[at];
121
+ if slot.1 != epoch {
122
+ *slot = (fp, epoch);
123
+ break;
124
+ }
125
+ if slot.0 == fp {
126
+ return true;
127
+ }
128
+ at = (at + 1) & MASK;
129
+ }
130
+ }
131
+ false
132
+ }
133
+ }
134
+
135
+ impl<'a> DupKeys<'a> {
136
+ pub(crate) fn new(scratch: &'a mut DupScratch, enabled: bool) -> Self {
137
+ scratch.fingerprints.clear();
138
+ DupKeys { scratch, enabled }
139
+ }
140
+
141
+ #[inline(always)]
142
+ pub(crate) fn mark(&self) -> usize {
143
+ self.scratch.fingerprints.len()
144
+ }
145
+
146
+ #[inline(always)]
147
+ pub(crate) fn key(&mut self, key: &[u8]) {
148
+ if self.enabled {
149
+ self.scratch.fingerprints.push(FINGERPRINT.hash_one(key));
150
+ }
151
+ }
152
+
153
+ /// Close the object whose keys start at `mark`.
154
+ #[inline(always)]
155
+ pub(crate) fn close(&mut self, mark: usize) -> Result<(), SinkAbort> {
156
+ if !self.enabled {
157
+ return Ok(());
158
+ }
159
+ let DupScratch {
160
+ fingerprints,
161
+ table,
162
+ } = &mut *self.scratch;
163
+ let keys = &mut fingerprints[mark..];
164
+ let repeated = match keys.len() {
165
+ n if n <= PAIRWISE_MAX => (1..n).any(|i| keys[..i].contains(&keys[i])),
166
+ n if n <= TABLE_MAX_KEYS => table.any_repeat(keys),
167
+ _ => {
168
+ keys.sort_unstable();
169
+ keys.windows(2).any(|w| w[0] == w[1])
170
+ }
171
+ };
172
+ fingerprints.truncate(mark);
173
+ if repeated {
174
+ Err(SinkAbort::DuplicateKey)
175
+ } else {
176
+ Ok(())
177
+ }
178
+ }
179
+ }
180
+
181
+ /// Integer VALUE for `i` via rb-sys's inline `LONG2NUM` (the header
182
+ /// macro: a Fixnum tagged inline when it fits, else a Bignum), which
183
+ /// saves the FFI call per integer that `rb_ll2inum` costs. The fixable
184
+ /// range is defined by the C `long`, which is 32-bit on Windows (LLP64):
185
+ /// fixnums there hold only 31 bits, and tagging anything wider crashes
186
+ /// Ruby with "Unnormalized Fixnum value"; values beyond `long` take
187
+ /// `rb_ll2inum`.
188
+ // The conversion is an identity on LP64 hosts (clippy flags it there)
189
+ // but narrows on Windows, where c_long is 32-bit.
190
+ #[allow(clippy::useless_conversion, clippy::unnecessary_fallible_conversions)]
48
191
  #[inline(always)]
49
192
  fn int_to_raw(i: i64) -> rb_sys::VALUE {
50
- const FIXABLE_MIN: i64 = (std::os::raw::c_long::MIN / 2) as i64;
51
- const FIXABLE_MAX: i64 = (std::os::raw::c_long::MAX / 2) as i64;
52
- if (FIXABLE_MIN..=FIXABLE_MAX).contains(&i) {
53
- ((i as u64) << 1).wrapping_add(1) as rb_sys::VALUE
54
- } else {
55
- unsafe { rb_sys::rb_ll2inum(i) }
193
+ match std::os::raw::c_long::try_from(i) {
194
+ Ok(l) => rb_sys::macros::LONG2NUM(l),
195
+ Err(_) => unsafe { rb_sys::rb_ll2inum(i) },
56
196
  }
57
197
  }
58
198
 
@@ -146,6 +286,7 @@ pub(crate) struct RubyValueSink<'a> {
146
286
  pub(crate) symbolize: bool,
147
287
  pub(crate) freeze: bool,
148
288
  pub(crate) max_nesting: usize,
289
+ pub(crate) allow_duplicate_key: bool,
149
290
  }
150
291
 
151
292
  // Tried and rejected (2026-07-10): a jiter-style cache of repeated VALUE
@@ -228,35 +369,14 @@ impl nosj::Sink for RubyValueSink<'_> {
228
369
  self.push_raw(raw)
229
370
  }
230
371
 
231
- /// Lone-low-surrogate content: gem parity is a UTF-8-encoded Ruby string
232
- /// carrying the raw WTF-8 bytes (broken coderange, like the gem's).
233
- #[inline(always)]
234
- fn str_bytes(&mut self, value: &[u8]) -> Result<(), SinkAbort> {
235
- let raw = unsafe {
236
- let s = rb_sys::rb_utf8_str_new(
237
- value.as_ptr() as *const std::os::raw::c_char,
238
- value.len() as std::os::raw::c_long,
239
- );
240
- if self.freeze {
241
- rb_sys::rb_str_freeze(s)
242
- } else {
243
- s
244
- }
245
- };
246
- self.push_raw(raw)
372
+ /// Lone-surrogate content (the crate hands it over as WTF-8): json 3
373
+ /// rejects it, trailing surrogates included.
374
+ fn str_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
375
+ Err(SinkAbort::LoneSurrogate)
247
376
  }
248
377
 
249
- #[inline(always)]
250
- fn key_bytes(&mut self, key: &[u8]) -> Result<(), SinkAbort> {
251
- // Interning is skipped: these keys are pathological, not hot.
252
- let raw = unsafe {
253
- rb_sys::rb_utf8_str_new(
254
- key.as_ptr() as *const std::os::raw::c_char,
255
- key.len() as std::os::raw::c_long,
256
- )
257
- };
258
- let frozen = unsafe { rb_sys::rb_str_freeze(raw) };
259
- self.push_raw(frozen)
378
+ fn key_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
379
+ Err(SinkAbort::LoneSurrogate)
260
380
  }
261
381
 
262
382
  #[inline(always)]
@@ -314,19 +434,28 @@ impl nosj::Sink for RubyValueSink<'_> {
314
434
  }
315
435
  }
316
436
  self.stack.truncate(mark);
437
+ // A repeated key collapses into one entry: the hash comes out
438
+ // smaller than the pair count (one size read per object).
439
+ if !self.allow_duplicate_key
440
+ && (unsafe { rb_sys::macros::RHASH_SIZE(hash_raw) } as usize) < pairs
441
+ {
442
+ return Err(SinkAbort::DuplicateKey);
443
+ }
317
444
  self.push_raw(hash_raw)
318
445
  }
319
446
  }
320
447
 
321
448
  /// Validation-only sink: every event is a no-op except nesting-depth
322
- /// tracking, so `NOSJ.valid?` runs the full parser (tokenizers,
323
- /// string decode, number validation) without allocating a single VALUE.
324
- pub(crate) struct NullSink {
449
+ /// tracking and duplicate-key fingerprints, so `NOSJ.valid?` runs the
450
+ /// full parser (tokenizers, string decode, number validation) without
451
+ /// allocating a single VALUE.
452
+ pub(crate) struct NullSink<'a> {
325
453
  pub(crate) depth: usize,
326
454
  pub(crate) max_nesting: usize,
455
+ pub(crate) dup_keys: DupKeys<'a>,
327
456
  }
328
457
 
329
- impl nosj::Sink for NullSink {
458
+ impl nosj::Sink for NullSink<'_> {
330
459
  type Error = SinkAbort;
331
460
 
332
461
  fn null(&mut self) -> Result<(), SinkAbort> {
@@ -347,14 +476,18 @@ impl nosj::Sink for NullSink {
347
476
  fn str(&mut self, _: &str) -> Result<(), SinkAbort> {
348
477
  Ok(())
349
478
  }
350
- fn key(&mut self, _: &str) -> Result<(), SinkAbort> {
479
+ fn key(&mut self, key: &str) -> Result<(), SinkAbort> {
480
+ self.dup_keys.key(key.as_bytes());
351
481
  Ok(())
352
482
  }
353
483
  fn str_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
354
- Ok(())
484
+ Err(SinkAbort::LoneSurrogate)
485
+ }
486
+ fn key_bytes(&mut self, _: &[u8]) -> Result<(), SinkAbort> {
487
+ Err(SinkAbort::LoneSurrogate)
355
488
  }
356
489
  fn mark(&self) -> usize {
357
- 0
490
+ self.dup_keys.mark()
358
491
  }
359
492
  fn begin_array(&mut self) -> Result<(), SinkAbort> {
360
493
  self.depth += 1;
@@ -374,8 +507,8 @@ impl nosj::Sink for NullSink {
374
507
  self.depth -= 1;
375
508
  Ok(())
376
509
  }
377
- fn end_object(&mut self, _: usize, _: usize) -> Result<(), SinkAbort> {
510
+ fn end_object(&mut self, mark: usize, _: usize) -> Result<(), SinkAbort> {
378
511
  self.depth -= 1;
379
- Ok(())
512
+ self.dup_keys.close(mark)
380
513
  }
381
514
  }