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,6 +5,9 @@ use magnus::value::ReprValue;
5
5
  use magnus::{Error, RHash, RString, Ruby, Value};
6
6
  use nosj::emit::EscapeMode;
7
7
 
8
+ use crate::opt_reader::{Opt, OptReader};
9
+ use crate::sink::MAX_NESTING;
10
+
8
11
  pub(crate) struct GenConfig {
9
12
  pub(crate) indent: Vec<u8>,
10
13
  pub(crate) space: Vec<u8>,
@@ -21,195 +24,129 @@ pub(crate) struct GenConfig {
21
24
  /// null (Float#as_json parity). Set only by the Rails entry, never
22
25
  /// from user option hashes.
23
26
  pub(super) rails: bool,
27
+ /// json 3: keys that render the same (`"a"` and `:a`) raise unless
28
+ /// this is set. The Rails configs keep ActiveSupport's own handling.
29
+ pub(super) allow_duplicate_key: bool,
24
30
  pub(crate) mode: EscapeMode,
25
31
  /// Precomputed "any formatting string set": scanning the five
26
32
  /// vectors per call was measurable on tiny documents.
27
33
  pub(super) pretty: bool,
28
34
  }
29
35
 
36
+ /// The json gem's defaults under an escape mode, for the plain walk or
37
+ /// the Rails encoder's (which keeps ActiveSupport's own key handling,
38
+ /// so it allows keys that render alike). A const fn because statics
39
+ /// cannot struct-update a type with `Vec` fields; `Vec::new` is const
40
+ /// and allocation-free.
41
+ const fn defaults(rails: bool, mode: EscapeMode) -> GenConfig {
42
+ GenConfig {
43
+ indent: Vec::new(),
44
+ space: Vec::new(),
45
+ space_before: Vec::new(),
46
+ object_nl: Vec::new(),
47
+ array_nl: Vec::new(),
48
+ max_nesting: MAX_NESTING,
49
+ start_depth: 0,
50
+ allow_nan: false,
51
+ strict: false,
52
+ rails,
53
+ allow_duplicate_key: rails,
54
+ mode,
55
+ pretty: false,
56
+ }
57
+ }
58
+
30
59
  /// The nil-options configuration, shared instead of rebuilt: stamping
31
60
  /// a fresh ~140-byte GenConfig onto the stack per call was measurable
32
61
  /// on tiny documents (the json gem likewise reuses a cached State for
33
- /// the default options). Safe as a static: `Vec::new` is const and
34
- /// allocation-free, and generation only ever borrows the config.
35
- pub(crate) static DEFAULT_CONFIG: GenConfig = GenConfig {
36
- indent: Vec::new(),
37
- space: Vec::new(),
38
- space_before: Vec::new(),
39
- object_nl: Vec::new(),
40
- array_nl: Vec::new(),
41
- max_nesting: 100,
42
- start_depth: 0,
43
- allow_nan: false,
44
- strict: false,
45
- rails: false,
46
- mode: EscapeMode::Standard,
47
- pretty: false,
48
- };
62
+ /// the default options). Safe as a static: generation only ever
63
+ /// borrows the config.
64
+ pub(crate) static DEFAULT_CONFIG: GenConfig = defaults(false, EscapeMode::Standard);
49
65
 
50
66
  /// The Rails-encoder configuration for ActiveSupport's default escape
51
67
  /// flags (HTML entities and JS separators both on, the overwhelmingly
52
68
  /// common case): escaping is fused into the crate's HtmlSafe kernels,
53
69
  /// 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
- };
70
+ pub(super) static RAILS_HTML_SAFE_CONFIG: GenConfig = defaults(true, EscapeMode::HtmlSafe);
68
71
 
69
72
  /// Rails-encoder configuration with HTML entities on and JS separators
70
73
  /// 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
- };
74
+ pub(super) static RAILS_HTML_ENTITIES_CONFIG: GenConfig = defaults(true, EscapeMode::HtmlEntities);
85
75
 
86
76
  /// Rails-encoder configuration with JS separators on and HTML entities
87
77
  /// 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
- };
78
+ pub(super) static RAILS_JS_SEPARATORS_CONFIG: GenConfig = defaults(true, EscapeMode::JsSeparators);
102
79
 
103
80
  /// The Rails-encoder configuration with every escape flag off
104
81
  /// (encode(escape: false)). Mirrors JSONGemEncoder#stringify, which
105
82
  /// 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,
117
- mode: EscapeMode::Standard,
118
- pretty: false,
119
- };
83
+ pub(super) static RAILS_CONFIG: GenConfig = defaults(true, EscapeMode::Standard);
120
84
 
121
85
  impl Default for GenConfig {
122
86
  fn default() -> Self {
123
- GenConfig {
124
- indent: Vec::new(),
125
- space: Vec::new(),
126
- space_before: Vec::new(),
127
- object_nl: Vec::new(),
128
- array_nl: Vec::new(),
129
- max_nesting: 100,
130
- start_depth: 0,
131
- allow_nan: false,
132
- strict: false,
133
- rails: false,
134
- mode: EscapeMode::Standard,
135
- pretty: false,
136
- }
87
+ defaults(false, EscapeMode::Standard)
137
88
  }
138
89
  }
139
90
 
140
- fn opt_bytes(ruby: &Ruby, opts: RHash, name: &str) -> Result<Option<Vec<u8>>, Error> {
141
- let v: Value = opts
142
- .get(ruby.to_symbol(name))
143
- .unwrap_or_else(|| ruby.qnil().as_value());
144
- if v.is_nil() {
145
- return Ok(None);
146
- }
91
+ /// A formatting string option's bytes; empty when absent or nil.
92
+ fn opt_bytes(r: &mut OptReader, opt: Opt) -> Result<Vec<u8>, Error> {
93
+ let Some(v) = r.get(opt).filter(|v| !v.is_nil()) else {
94
+ return Ok(Vec::new());
95
+ };
147
96
  let s = RString::from_value(v).ok_or_else(|| {
148
97
  Error::new(
149
- ruby.exception_type_error(),
150
- format!("{name} must be a String"),
98
+ r.ruby().exception_type_error(),
99
+ format!("{} must be a String", opt.name()),
151
100
  )
152
101
  })?;
153
- Ok(Some(unsafe { s.as_slice() }.to_vec()))
102
+ Ok(unsafe { s.as_slice() }.to_vec())
154
103
  }
155
104
 
156
- fn opt_bool(ruby: &Ruby, opts: RHash, name: &str) -> Option<bool> {
157
- let v: Value = opts.get(ruby.to_symbol(name))?;
158
- if v.is_nil() {
159
- None
160
- } else {
161
- Some(v.to_bool())
162
- }
163
- }
164
-
165
- /// Decode a non-nil options hash (nil takes [`DEFAULT_CONFIG`] at the
166
- /// call site without constructing anything).
105
+ /// Decode a generate options hash (nil takes [`DEFAULT_CONFIG`] at the
106
+ /// call site without constructing anything); keys it does not read
107
+ /// raise ArgumentError, like json 3.
167
108
  pub(crate) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usize), Error> {
168
- let mut cfg = GenConfig::default();
169
- let mut cap_hint = 0usize;
170
109
  if opts.is_nil() {
171
- return Ok((cfg, cap_hint));
110
+ return Ok((GenConfig::default(), 0));
172
111
  }
173
112
  let opts = RHash::from_value(opts)
174
113
  .ok_or_else(|| Error::new(ruby.exception_type_error(), "options must be a Hash or nil"))?;
114
+ let mut reader = OptReader::new(ruby, opts);
115
+ let decoded = read_gen_opts(&mut reader)?;
116
+ reader.finish()?;
117
+ Ok(decoded)
118
+ }
175
119
 
176
- if let Some(v) = opt_bytes(ruby, opts, "indent")? {
177
- cfg.indent = v;
178
- }
179
- if let Some(v) = opt_bytes(ruby, opts, "space")? {
180
- cfg.space = v;
181
- }
182
- if let Some(v) = opt_bytes(ruby, opts, "space_before")? {
183
- cfg.space_before = v;
184
- }
185
- if let Some(v) = opt_bytes(ruby, opts, "object_nl")? {
186
- cfg.object_nl = v;
187
- }
188
- if let Some(v) = opt_bytes(ruby, opts, "array_nl")? {
189
- cfg.array_nl = v;
190
- }
191
- if let Some(v) = opt_bool(ruby, opts, "allow_nan") {
192
- cfg.allow_nan = v;
193
- }
194
- if let Some(v) = opt_bool(ruby, opts, "strict") {
195
- cfg.strict = v;
196
- }
197
- let ascii = opt_bool(ruby, opts, "ascii_only").unwrap_or(false);
198
- let script = opt_bool(ruby, opts, "script_safe").unwrap_or(false)
199
- || opt_bool(ruby, opts, "escape_slash").unwrap_or(false);
120
+ /// Read the json 3 generate options and the buffer size hint. sort_keys
121
+ /// and as_json, which NOSJ does not implement, raise unless falsy.
122
+ pub(crate) fn read_gen_opts(r: &mut OptReader) -> Result<(GenConfig, usize), Error> {
123
+ let mut cfg = GenConfig {
124
+ indent: opt_bytes(r, Opt::Indent)?,
125
+ space: opt_bytes(r, Opt::Space)?,
126
+ space_before: opt_bytes(r, Opt::SpaceBefore)?,
127
+ object_nl: opt_bytes(r, Opt::ObjectNl)?,
128
+ array_nl: opt_bytes(r, Opt::ArrayNl)?,
129
+ allow_nan: r.truthy(Opt::AllowNan),
130
+ strict: r.truthy(Opt::Strict),
131
+ allow_duplicate_key: r.truthy(Opt::AllowDuplicateKey),
132
+ ..GenConfig::default()
133
+ };
134
+ let mut cap_hint = 0usize;
135
+ r.tolerate(&[Opt::SortKeys, Opt::AsJson]);
136
+ let ascii = r.truthy(Opt::AsciiOnly);
137
+ let script = r.truthy(Opt::ScriptSafe);
200
138
  if ascii {
201
139
  cfg.mode = EscapeMode::AsciiOnly;
202
140
  if script {
203
141
  return Err(Error::new(
204
- ruby.exception_arg_error(),
142
+ r.ruby().exception_arg_error(),
205
143
  "NOSJ.generate: ascii_only and script_safe cannot be combined",
206
144
  ));
207
145
  }
208
146
  } else if script {
209
147
  cfg.mode = EscapeMode::ScriptSafe;
210
148
  }
211
- if let Some(v) = opts.get(ruby.to_symbol("max_nesting")) {
212
- let v: Value = v;
149
+ if let Some(v) = r.get(Opt::MaxNesting) {
213
150
  // nil/false → unlimited; true → keep the default 100; Integer → limit.
214
151
  if !v.to_bool() {
215
152
  cfg.max_nesting = 0;
@@ -217,14 +154,12 @@ pub(crate) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usi
217
154
  cfg.max_nesting = if n <= 0 { 0 } else { n as usize };
218
155
  }
219
156
  }
220
- if let Some(v) = opts.get(ruby.to_symbol("depth")) {
221
- let v: Value = v;
157
+ if let Some(v) = r.get(Opt::Depth) {
222
158
  if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
223
159
  cfg.start_depth = if n <= 0 { 0 } else { n as usize };
224
160
  }
225
161
  }
226
- if let Some(v) = opts.get(ruby.to_symbol("buffer_initial_length")) {
227
- let v: Value = v;
162
+ if let Some(v) = r.get(Opt::BufferInitialLength) {
228
163
  if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
229
164
  if n > 0 {
230
165
  cap_hint = n as usize;
@@ -18,11 +18,31 @@ pub(super) fn protected_to_s(v: VALUE) -> Result<VALUE, Error> {
18
18
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_as_string(v) })
19
19
  }
20
20
 
21
+ /// `v.inspect`, protected (user code for any element's `inspect`).
22
+ pub(super) fn protected_inspect(v: VALUE) -> Result<VALUE, Error> {
23
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_inspect(v) })
24
+ }
25
+
21
26
  /// `v.to_json`, protected.
22
27
  pub(super) fn protected_to_json(v: VALUE) -> Result<VALUE, Error> {
23
28
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) })
24
29
  }
25
30
 
31
+ /// `v.to_json` if `v.respond_to?(:to_json)`, else `None`, under ONE
32
+ /// protect: `rb_respond_to` dispatches to a user-defined `respond_to?` /
33
+ /// `respond_to_missing?`, which may raise just like `to_json` itself.
34
+ pub(super) fn protected_to_json_if_responds(v: VALUE) -> Result<Option<VALUE>, Error> {
35
+ const QUNDEF: VALUE = ruby_special_consts::RUBY_Qundef as VALUE;
36
+ let json = magnus::rb_sys::protect(|| unsafe {
37
+ if rb_sys::rb_respond_to(v, to_json_id()) != 0 {
38
+ rb_sys::rb_funcall(v, to_json_id(), 0)
39
+ } else {
40
+ QUNDEF
41
+ }
42
+ })?;
43
+ Ok((json != QUNDEF).then_some(json))
44
+ }
45
+
26
46
  /// `v.as_json`, protected. Argument-less on purpose: ActiveSupport's
27
47
  /// JSONGemEncoder#jsonify recursion also calls as_json without
28
48
  /// options (only the top-level value receives them).
@@ -53,33 +73,37 @@ pub(crate) fn warm_up() {
53
73
  /// first generate is still found; a fragment instance existing implies
54
74
  /// its class does. The cached VALUE is a constant of the JSON module,
55
75
  /// so it can never be collected.
56
- pub(super) fn is_json_fragment(v: VALUE) -> bool {
76
+ pub(super) fn is_json_fragment(v: VALUE) -> Result<bool, Error> {
57
77
  use std::sync::atomic::{AtomicUsize, Ordering};
58
78
  static FRAGMENT: AtomicUsize = AtomicUsize::new(0);
59
- let mut cls = FRAGMENT.load(Ordering::Relaxed);
79
+ let mut cls = FRAGMENT.load(Ordering::Relaxed) as VALUE;
60
80
  if cls == 0 {
61
- cls = resolve_json_fragment();
81
+ cls = resolve_json_fragment()?;
62
82
  if cls == 0 {
63
- return false;
83
+ return Ok(false);
64
84
  }
65
- FRAGMENT.store(cls, Ordering::Relaxed);
85
+ FRAGMENT.store(cls as usize, Ordering::Relaxed);
66
86
  }
67
- unsafe { rb_sys::rb_obj_is_kind_of(v, cls as VALUE) != QFALSE }
87
+ Ok(unsafe { rb_sys::rb_obj_is_kind_of(v, cls) != QFALSE })
68
88
  }
69
89
 
70
- fn resolve_json_fragment() -> usize {
90
+ /// `JSON::Fragment`, or 0 while undefined. Only the `rb_const_get`s are
91
+ /// protected: fetching a constant can run its pending autoload (user
92
+ /// code, whose raise propagates as any constant reference's would),
93
+ /// while `rb_const_defined` never loads anything.
94
+ fn resolve_json_fragment() -> Result<VALUE, Error> {
71
95
  unsafe {
72
96
  let object = rb_sys::rb_cObject;
73
97
  let json_id = rb_sys::rb_intern(c"JSON".as_ptr());
74
98
  if rb_sys::rb_const_defined(object, json_id) == 0 {
75
- return 0;
99
+ return Ok(0);
76
100
  }
77
- let json = rb_sys::rb_const_get(object, json_id);
101
+ let json = magnus::rb_sys::protect(|| rb_sys::rb_const_get(object, json_id))?;
78
102
  let fragment_id = rb_sys::rb_intern(c"Fragment".as_ptr());
79
103
  if rb_sys::rb_const_defined(json, fragment_id) == 0 {
80
- return 0;
104
+ return Ok(0);
81
105
  }
82
- rb_sys::rb_const_get(json, fragment_id) as usize
106
+ magnus::rb_sys::protect(|| rb_sys::rb_const_get(json, fragment_id))
83
107
  }
84
108
  }
85
109
 
@@ -95,7 +119,7 @@ pub(super) fn protected_encode_utf8(v: VALUE) -> Result<VALUE, Error> {
95
119
  }
96
120
 
97
121
  /// Interned `to_json` method ID, resolved once per process.
98
- pub(super) fn to_json_id() -> rb_sys::ID {
122
+ fn to_json_id() -> rb_sys::ID {
99
123
  static TO_JSON: OnceLock<usize> = OnceLock::new();
100
124
  *TO_JSON.get_or_init(|| unsafe { rb_sys::rb_intern(c"to_json".as_ptr()) } as usize)
101
125
  as rb_sys::ID
@@ -109,12 +133,16 @@ pub(super) fn utf8_encindexes() -> (c_int, c_int) {
109
133
  // Coderange and encoding index live in RBasic flags (public ABI); reading
110
134
  // them inline instead of calling rb_enc_str_coderange / rb_enc_get_index is
111
135
  // how the gem avoids two C calls per string (RB_ENC_CODERANGE,
112
- // RB_ENCODING_GET_INLINED).
113
- const CR_MASK: u64 = 3 << 20;
114
- pub(super) const CR_7BIT: u64 = 1 << 20;
115
- pub(super) const CR_VALID: u64 = 2 << 20;
116
- const ENC_SHIFT: u64 = 22;
117
- const ENC_MASK: u64 = 127 << 22;
136
+ // RB_ENCODING_GET_INLINED). The bit layout comes from rb-sys's bindings,
137
+ // generated from the headers of the Ruby being built against, so it
138
+ // follows any layout change instead of silently misreading flags.
139
+ const CR_MASK: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_MASK as u64;
140
+ pub(super) const CR_7BIT: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_7BIT as u64;
141
+ pub(super) const CR_VALID: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_VALID as u64;
142
+ const ENC_SHIFT: u64 = rb_sys::ruby_encoding_consts::RUBY_ENCODING_SHIFT as u64;
143
+ const ENC_MASK: u64 = rb_sys::ruby_encoding_consts::RUBY_ENCODING_MASK as u64;
144
+ /// Inline encoding-index sentinel: the real index is stored out of line.
145
+ const ENC_INLINE_MAX: c_int = rb_sys::ruby_encoding_consts::RUBY_ENCODING_INLINE_MAX as c_int;
118
146
 
119
147
  #[inline(always)]
120
148
  pub(super) fn str_coderange(s: VALUE) -> u64 {
@@ -131,18 +159,18 @@ pub(super) fn str_coderange(s: VALUE) -> u64 {
131
159
  pub(super) fn str_enc_index(s: VALUE) -> c_int {
132
160
  let flags = unsafe { (*(s as *const rb_sys::RBasic)).flags };
133
161
  let idx = ((flags & ENC_MASK) >> ENC_SHIFT) as c_int;
134
- if idx == 127 {
135
- // RUBY_ENCODING_INLINE_MAX sentinel: index stored out of line.
162
+ if idx == ENC_INLINE_MAX {
136
163
  unsafe { rb_sys::rb_enc_get_index(s) }
137
164
  } else {
138
165
  idx
139
166
  }
140
167
  }
141
168
 
169
+ /// `RB_SPECIAL_CONST_P` (immediates plus Qnil/Qfalse), through rb-sys's
170
+ /// inline versioned stable API rather than a hand-copied bit test.
142
171
  #[inline(always)]
143
172
  pub(super) fn is_special_const(v: VALUE) -> bool {
144
- // RB_SPECIAL_CONST_P: immediates plus Qnil/Qfalse.
145
- (v & (ruby_special_consts::RUBY_IMMEDIATE_MASK as VALUE)) != 0 || v == QNIL || v == QFALSE
173
+ rb_sys::macros::SPECIAL_CONST_P(v)
146
174
  }
147
175
 
148
176
  /// Borrow a Ruby String's bytes.