nosj 0.2.0 → 0.3.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.
@@ -23,7 +23,7 @@
23
23
  mod errors;
24
24
  mod hash_iter;
25
25
  mod keys;
26
- mod opts;
26
+ pub(crate) mod opts;
27
27
  mod ruby;
28
28
  mod walker;
29
29
 
@@ -46,12 +46,17 @@ use walker::Gen;
46
46
  struct GenScratch {
47
47
  buf: Vec<u8>,
48
48
  keys: GenKeyCache,
49
+ /// Keys pre-escaped under HtmlSafe (the Rails encoder): cached
50
+ /// bytes bake in the escape mode, so each cacheable mode owns a
51
+ /// cache (see walker::mode_cacheable).
52
+ html_keys: GenKeyCache,
49
53
  }
50
54
 
51
55
  thread_local! {
52
56
  static GEN_SCRATCH: RefCell<GenScratch> = RefCell::new(GenScratch {
53
57
  buf: Vec::new(),
54
58
  keys: GenKeyCache::with_capacity(256),
59
+ html_keys: GenKeyCache::with_capacity(64),
55
60
  });
56
61
  }
57
62
 
@@ -86,6 +91,29 @@ pub fn generate_entry(ruby: &Ruby, _rb_self: Value, args: &[Value]) -> Result<RS
86
91
  generate_scratched(ruby, obj, cfg, cap_hint)
87
92
  }
88
93
 
94
+ /// `NOSJ.generate_rails_native(obj, escape_html, escape_js)`: compact
95
+ /// generation under ActiveSupport walk semantics (non-native values
96
+ /// recurse through as_json, non-finite floats emit null). Every escape
97
+ /// flag combination maps to a crate escape mode, so HTML/JS-safety
98
+ /// escaping is always fused into the emission kernels: one pass, no
99
+ /// post-processing. The Rails encoder installed by
100
+ /// `require "nosj/rails"` is the only caller.
101
+ pub fn generate_rails_native(
102
+ ruby: &Ruby,
103
+ _rb_self: Value,
104
+ obj: Value,
105
+ escape_html: bool,
106
+ escape_js: bool,
107
+ ) -> Result<RString, Error> {
108
+ let cfg = match (escape_html, escape_js) {
109
+ (true, true) => &opts::RAILS_HTML_SAFE_CONFIG,
110
+ (true, false) => &opts::RAILS_HTML_ENTITIES_CONFIG,
111
+ (false, true) => &opts::RAILS_JS_SEPARATORS_CONFIG,
112
+ (false, false) => &opts::RAILS_CONFIG,
113
+ };
114
+ generate_scratched(ruby, obj, cfg, 0)
115
+ }
116
+
89
117
  /// `NOSJ.generate_native(obj, opts)`: the fixed-arity entry kept for
90
118
  /// the Ruby-level wrappers (pretty_generate merges options first).
91
119
  pub fn generate_native(
@@ -149,15 +177,18 @@ fn generate_scratched_into<R>(
149
177
  GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
150
178
  Ok(mut scratch) => {
151
179
  let scratch = &mut *scratch;
152
- generate_with(
153
- ruby,
154
- obj,
155
- cfg,
156
- cap_hint,
157
- &mut scratch.buf,
158
- &mut scratch.keys,
159
- finish,
160
- )
180
+ let GenScratch {
181
+ buf,
182
+ keys,
183
+ html_keys,
184
+ ..
185
+ } = scratch;
186
+ let keys = if cfg.mode == nosj::emit::EscapeMode::HtmlSafe {
187
+ html_keys
188
+ } else {
189
+ keys
190
+ };
191
+ generate_with(ruby, obj, cfg, cap_hint, buf, keys, finish)
161
192
  }
162
193
  Err(_) => {
163
194
  let mut buf = Vec::new();
@@ -180,7 +211,21 @@ fn generate_with<R>(
180
211
  if out.capacity() < cap_hint {
181
212
  out.reserve(cap_hint);
182
213
  }
214
+ emit_one(ruby, obj, cfg, out, keys)?;
215
+ finish(ruby, out)
216
+ }
183
217
 
218
+ /// Emit one value into `out` (appending, not clearing), mapping walker
219
+ /// failures onto the gem's exceptions. `inline(always)`: this is the
220
+ /// single-document hot path's only call layer.
221
+ #[inline(always)]
222
+ fn emit_one(
223
+ ruby: &Ruby,
224
+ obj: Value,
225
+ cfg: &opts::GenConfig,
226
+ out: &mut Vec<u8>,
227
+ keys: &mut GenKeyCache,
228
+ ) -> Result<(), Error> {
184
229
  let mut g = Gen {
185
230
  out,
186
231
  cfg,
@@ -193,9 +238,9 @@ fn generate_with<R>(
193
238
  g.emit_value::<false>(obj.as_raw(), cfg.start_depth)
194
239
  };
195
240
 
196
- let Gen { out, fail, .. } = g;
241
+ let Gen { fail, .. } = g;
197
242
  match (result, fail) {
198
- (Ok(()), None) => finish(ruby, out),
243
+ (Ok(()), None) => Ok(()),
199
244
  (_, Some(fail)) => Err(raise_fail(ruby, fail)),
200
245
  (Err(()), None) => Err(Error::new(
201
246
  ruby.exception_runtime_error(),
@@ -203,3 +248,102 @@ fn generate_with<R>(
203
248
  )),
204
249
  }
205
250
  }
251
+
252
+ /// Emit one value into `out` under `cfg`, borrowing the pooled key
253
+ /// cache. The splice/patch entries use this to generate replacement
254
+ /// values straight into their output buffer.
255
+ pub(crate) fn emit_into(
256
+ ruby: &Ruby,
257
+ obj: Value,
258
+ cfg: &opts::GenConfig,
259
+ out: &mut Vec<u8>,
260
+ ) -> Result<(), Error> {
261
+ GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
262
+ Ok(mut scratch) => emit_one(ruby, obj, cfg, out, &mut scratch.keys),
263
+ Err(_) => emit_one(ruby, obj, cfg, out, &mut GenKeyCache::default()),
264
+ })
265
+ }
266
+
267
+ /// Formatting strings holding a newline (or carriage return) would
268
+ /// split one value across NDJSON lines; the lines entries refuse them.
269
+ fn breaks_line_framing(cfg: &opts::GenConfig) -> bool {
270
+ [
271
+ &cfg.indent,
272
+ &cfg.space,
273
+ &cfg.space_before,
274
+ &cfg.object_nl,
275
+ &cfg.array_nl,
276
+ ]
277
+ .into_iter()
278
+ .any(|v| v.iter().any(|&b| b == b'\n' || b == b'\r'))
279
+ }
280
+
281
+ /// `NOSJ.generate_lines_native(values, opts)`: NDJSON generation, one
282
+ /// compact document per element into ONE pooled buffer, newline after
283
+ /// each (every line terminated, per the format).
284
+ pub fn generate_lines_native(
285
+ ruby: &Ruby,
286
+ _rb_self: Value,
287
+ values: magnus::RArray,
288
+ opts: Value,
289
+ ) -> Result<RString, Error> {
290
+ generate_lines_bytes_into(ruby, values, opts, finish_rstring)
291
+ }
292
+
293
+ /// The write-to-disk form of [`generate_lines_native`], used by
294
+ /// `NOSJ.write_lines`.
295
+ pub(crate) fn generate_lines_bytes_into<R>(
296
+ ruby: &Ruby,
297
+ values: magnus::RArray,
298
+ opts: Value,
299
+ finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
300
+ ) -> Result<R, Error> {
301
+ use magnus::value::ReprValue;
302
+ let built;
303
+ let (cfg, cap_hint): (&opts::GenConfig, usize) = if opts.is_nil() {
304
+ (&opts::DEFAULT_CONFIG, 0)
305
+ } else {
306
+ let (c, hint) = opts::parse_gen_opts(ruby, opts)?;
307
+ built = c;
308
+ (&built, hint)
309
+ };
310
+ if breaks_line_framing(cfg) {
311
+ return Err(Error::new(
312
+ ruby.exception_arg_error(),
313
+ "formatting options containing newlines would break JSON Lines framing",
314
+ ));
315
+ }
316
+ GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
317
+ Ok(mut scratch) => {
318
+ let scratch = &mut *scratch;
319
+ let GenScratch { buf, keys, .. } = scratch;
320
+ generate_lines_with(ruby, values, cfg, cap_hint, buf, keys, finish)
321
+ }
322
+ Err(_) => {
323
+ let mut buf = Vec::new();
324
+ let mut keys = GenKeyCache::default();
325
+ generate_lines_with(ruby, values, cfg, cap_hint, &mut buf, &mut keys, finish)
326
+ }
327
+ })
328
+ }
329
+
330
+ fn generate_lines_with<R>(
331
+ ruby: &Ruby,
332
+ values: magnus::RArray,
333
+ cfg: &opts::GenConfig,
334
+ cap_hint: usize,
335
+ out: &mut Vec<u8>,
336
+ keys: &mut GenKeyCache,
337
+ finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
338
+ ) -> Result<R, Error> {
339
+ out.clear();
340
+ if out.capacity() < cap_hint {
341
+ out.reserve(cap_hint);
342
+ }
343
+ for i in 0..values.len() {
344
+ let obj: Value = values.entry(i as isize)?;
345
+ emit_one(ruby, obj, cfg, out, keys)?;
346
+ out.push(b'\n');
347
+ }
348
+ finish(ruby, out)
349
+ }
@@ -5,18 +5,23 @@ use magnus::value::ReprValue;
5
5
  use magnus::{Error, RHash, RString, Ruby, Value};
6
6
  use nosj::emit::EscapeMode;
7
7
 
8
- pub(super) struct GenConfig {
9
- pub(super) indent: Vec<u8>,
10
- pub(super) space: Vec<u8>,
11
- pub(super) space_before: Vec<u8>,
12
- pub(super) object_nl: Vec<u8>,
13
- pub(super) array_nl: Vec<u8>,
8
+ pub(crate) struct GenConfig {
9
+ pub(crate) indent: Vec<u8>,
10
+ pub(crate) space: Vec<u8>,
11
+ pub(crate) space_before: Vec<u8>,
12
+ pub(crate) object_nl: Vec<u8>,
13
+ pub(crate) array_nl: Vec<u8>,
14
14
  /// 0 = unlimited.
15
15
  pub(super) max_nesting: usize,
16
16
  pub(super) start_depth: usize,
17
- pub(super) allow_nan: bool,
17
+ pub(crate) allow_nan: bool,
18
18
  pub(super) strict: bool,
19
- pub(super) mode: EscapeMode,
19
+ /// ActiveSupport walk semantics: non-native values recurse through
20
+ /// as_json instead of splicing to_json, and non-finite floats emit
21
+ /// null (Float#as_json parity). Set only by the Rails entry, never
22
+ /// from user option hashes.
23
+ pub(super) rails: bool,
24
+ pub(crate) mode: EscapeMode,
20
25
  /// Precomputed "any formatting string set": scanning the five
21
26
  /// vectors per call was measurable on tiny documents.
22
27
  pub(super) pretty: bool,
@@ -27,7 +32,7 @@ pub(super) struct GenConfig {
27
32
  /// on tiny documents (the json gem likewise reuses a cached State for
28
33
  /// the default options). Safe as a static: `Vec::new` is const and
29
34
  /// allocation-free, and generation only ever borrows the config.
30
- pub(super) static DEFAULT_CONFIG: GenConfig = GenConfig {
35
+ pub(crate) static DEFAULT_CONFIG: GenConfig = GenConfig {
31
36
  indent: Vec::new(),
32
37
  space: Vec::new(),
33
38
  space_before: Vec::new(),
@@ -37,6 +42,78 @@ pub(super) static DEFAULT_CONFIG: GenConfig = GenConfig {
37
42
  start_depth: 0,
38
43
  allow_nan: false,
39
44
  strict: false,
45
+ rails: false,
46
+ mode: EscapeMode::Standard,
47
+ pretty: false,
48
+ };
49
+
50
+ /// The Rails-encoder configuration for ActiveSupport's default escape
51
+ /// flags (HTML entities and JS separators both on, the overwhelmingly
52
+ /// common case): escaping is fused into the crate's HtmlSafe kernels,
53
+ /// one pass, no post-scan.
54
+ pub(super) static RAILS_HTML_SAFE_CONFIG: GenConfig = GenConfig {
55
+ indent: Vec::new(),
56
+ space: Vec::new(),
57
+ space_before: Vec::new(),
58
+ object_nl: Vec::new(),
59
+ array_nl: Vec::new(),
60
+ max_nesting: 100,
61
+ start_depth: 0,
62
+ allow_nan: false,
63
+ strict: false,
64
+ rails: true,
65
+ mode: EscapeMode::HtmlSafe,
66
+ pretty: false,
67
+ };
68
+
69
+ /// Rails-encoder configuration with HTML entities on and JS separators
70
+ /// off.
71
+ pub(super) static RAILS_HTML_ENTITIES_CONFIG: GenConfig = GenConfig {
72
+ indent: Vec::new(),
73
+ space: Vec::new(),
74
+ space_before: Vec::new(),
75
+ object_nl: Vec::new(),
76
+ array_nl: Vec::new(),
77
+ max_nesting: 100,
78
+ start_depth: 0,
79
+ allow_nan: false,
80
+ strict: false,
81
+ rails: true,
82
+ mode: EscapeMode::HtmlEntities,
83
+ pretty: false,
84
+ };
85
+
86
+ /// Rails-encoder configuration with JS separators on and HTML entities
87
+ /// off.
88
+ pub(super) static RAILS_JS_SEPARATORS_CONFIG: GenConfig = GenConfig {
89
+ indent: Vec::new(),
90
+ space: Vec::new(),
91
+ space_before: Vec::new(),
92
+ object_nl: Vec::new(),
93
+ array_nl: Vec::new(),
94
+ max_nesting: 100,
95
+ start_depth: 0,
96
+ allow_nan: false,
97
+ strict: false,
98
+ rails: true,
99
+ mode: EscapeMode::JsSeparators,
100
+ pretty: false,
101
+ };
102
+
103
+ /// The Rails-encoder configuration with every escape flag off
104
+ /// (encode(escape: false)). Mirrors JSONGemEncoder#stringify, which
105
+ /// generates with the json gem's defaults.
106
+ pub(super) static RAILS_CONFIG: GenConfig = GenConfig {
107
+ indent: Vec::new(),
108
+ space: Vec::new(),
109
+ space_before: Vec::new(),
110
+ object_nl: Vec::new(),
111
+ array_nl: Vec::new(),
112
+ max_nesting: 100,
113
+ start_depth: 0,
114
+ allow_nan: false,
115
+ strict: false,
116
+ rails: true,
40
117
  mode: EscapeMode::Standard,
41
118
  pretty: false,
42
119
  };
@@ -53,6 +130,7 @@ impl Default for GenConfig {
53
130
  start_depth: 0,
54
131
  allow_nan: false,
55
132
  strict: false,
133
+ rails: false,
56
134
  mode: EscapeMode::Standard,
57
135
  pretty: false,
58
136
  }
@@ -86,7 +164,7 @@ fn opt_bool(ruby: &Ruby, opts: RHash, name: &str) -> Option<bool> {
86
164
 
87
165
  /// Decode a non-nil options hash (nil takes [`DEFAULT_CONFIG`] at the
88
166
  /// call site without constructing anything).
89
- pub(super) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usize), Error> {
167
+ pub(crate) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usize), Error> {
90
168
  let mut cfg = GenConfig::default();
91
169
  let mut cap_hint = 0usize;
92
170
  if opts.is_nil() {
@@ -23,6 +23,57 @@ pub(super) fn protected_to_json(v: VALUE) -> Result<VALUE, Error> {
23
23
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) })
24
24
  }
25
25
 
26
+ /// `v.as_json`, protected. Argument-less on purpose: ActiveSupport's
27
+ /// JSONGemEncoder#jsonify recursion also calls as_json without
28
+ /// options (only the top-level value receives them).
29
+ pub(super) fn protected_as_json(v: VALUE) -> Result<VALUE, Error> {
30
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, as_json_id(), 0) })
31
+ }
32
+
33
+ /// Interned `as_json` method ID, resolved once per process.
34
+ fn as_json_id() -> rb_sys::ID {
35
+ static AS_JSON: OnceLock<usize> = OnceLock::new();
36
+ *AS_JSON.get_or_init(|| unsafe { rb_sys::rb_intern(c"as_json".as_ptr()) } as usize)
37
+ as rb_sys::ID
38
+ }
39
+
40
+ /// Whether `v` is a `JSON::Fragment` (pre-rendered JSON to splice
41
+ /// verbatim: the gem accepts fragments even under `strict`, and
42
+ /// ActiveSupport's encoder passes them through). The class is resolved
43
+ /// lazily and cached only on success, so a json gem loaded after the
44
+ /// first generate is still found; a fragment instance existing implies
45
+ /// its class does. The cached VALUE is a constant of the JSON module,
46
+ /// so it can never be collected.
47
+ pub(super) fn is_json_fragment(v: VALUE) -> bool {
48
+ use std::sync::atomic::{AtomicUsize, Ordering};
49
+ static FRAGMENT: AtomicUsize = AtomicUsize::new(0);
50
+ let mut cls = FRAGMENT.load(Ordering::Relaxed);
51
+ if cls == 0 {
52
+ cls = resolve_json_fragment();
53
+ if cls == 0 {
54
+ return false;
55
+ }
56
+ FRAGMENT.store(cls, Ordering::Relaxed);
57
+ }
58
+ unsafe { rb_sys::rb_obj_is_kind_of(v, cls as VALUE) != QFALSE }
59
+ }
60
+
61
+ fn resolve_json_fragment() -> usize {
62
+ unsafe {
63
+ let object = rb_sys::rb_cObject;
64
+ let json_id = rb_sys::rb_intern(c"JSON".as_ptr());
65
+ if rb_sys::rb_const_defined(object, json_id) == 0 {
66
+ return 0;
67
+ }
68
+ let json = rb_sys::rb_const_get(object, json_id);
69
+ let fragment_id = rb_sys::rb_intern(c"Fragment".as_ptr());
70
+ if rb_sys::rb_const_defined(json, fragment_id) == 0 {
71
+ return 0;
72
+ }
73
+ rb_sys::rb_const_get(json, fragment_id) as usize
74
+ }
75
+ }
76
+
26
77
  /// Encode `v` to UTF-8, protected. `rb_str_encode` raises on
27
78
  /// undefined/invalid conversions, matching the gem, which wraps that
28
79
  /// exception as GeneratorError (`rb_str_export_to_enc` is lenient and
@@ -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
  }