nosj 0.1.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.
@@ -0,0 +1,25 @@
1
+ [package]
2
+ # The PACKAGE cannot share the parser crate's name (`nosj` is a
3
+ # dependency below); nosj_native resolves that clash and nothing else.
4
+ # The LIB TARGET is named nosj so the artifact is libnosj → nosj.bundle,
5
+ # required by lib/nosj.rb as "nosj/nosj" (Init_nosj set explicitly in
6
+ # lib.rs). Gem name, module, and API are plain nosj.
7
+ name = "nosj_native"
8
+ version = "0.1.0"
9
+ edition = "2021"
10
+ authors = ["Yaroslav Markin <yaroslav@markin.net>"]
11
+ license = "MIT"
12
+ publish = false
13
+
14
+ [lib]
15
+ name = "nosj"
16
+ crate-type = ["cdylib", "rlib"]
17
+
18
+ [dependencies]
19
+ magnus = { git = "https://github.com/matsadler/magnus", features = ["embed", "rb-sys"] }
20
+ rb-sys = { version = "0.9.124", default-features = false }
21
+ ahash = "0.8.11"
22
+ # First-party SIMD JSON parse/generate library (github.com/yaroslav/nosj).
23
+ # For coordinated crate+gem work, temporarily flip to
24
+ # { path = "../../../nosj" } and restore before any commit or release.
25
+ nosj = "0.1.1"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("nosj/nosj")
@@ -0,0 +1,61 @@
1
+ //! Generation failure carriers and their mapping onto the gem's
2
+ //! exception classes (NOSJ::GeneratorError, NOSJ::NestingError, or a
3
+ //! re-raise of the user's own exception).
4
+
5
+ use magnus::error::ErrorType;
6
+ use magnus::rb_sys::AsRawValue;
7
+ use magnus::value::ReprValue;
8
+ use magnus::{Error, ExceptionClass, Module, RModule, Ruby};
9
+
10
+ use super::ruby::rstring_bytes;
11
+
12
+ pub(super) enum GenFail {
13
+ /// Re-raise a Ruby exception captured by a protected call (user
14
+ /// `to_json` or `to_s`).
15
+ Reraise(Error),
16
+ /// NOSJ::GeneratorError with this message.
17
+ Generator(String),
18
+ /// NOSJ::GeneratorError carrying the caught exception's message
19
+ /// (encoding conversion failures; the gem wraps these the same way).
20
+ GeneratorFrom(Error),
21
+ /// NOSJ::NestingError naming the configured limit.
22
+ Nesting(usize),
23
+ }
24
+
25
+ fn nosj_exception(ruby: &Ruby, name: &str) -> ExceptionClass {
26
+ let lookup = || -> Result<ExceptionClass, Error> {
27
+ let m: RModule = ruby.define_module("NOSJ")?;
28
+ m.const_get(name)
29
+ };
30
+ lookup().unwrap_or_else(|_| ruby.exception_runtime_error())
31
+ }
32
+
33
+ /// The exception's `to_s` (its message), matching what the gem embeds
34
+ /// when it wraps a secondary exception.
35
+ fn error_message(err: &Error) -> String {
36
+ if let ErrorType::Exception(exc) = err.error_type() {
37
+ let s = unsafe { rb_sys::rb_obj_as_string(exc.as_value().as_raw()) };
38
+ // Safety: rb_obj_as_string returns a T_STRING; the bytes are
39
+ // copied into an owned String before any further Ruby call.
40
+ let bytes = unsafe { rstring_bytes(s) };
41
+ return String::from_utf8_lossy(bytes).into_owned();
42
+ }
43
+ err.to_string()
44
+ }
45
+
46
+ pub(super) fn raise_fail(ruby: &Ruby, fail: GenFail) -> Error {
47
+ match fail {
48
+ GenFail::Reraise(err) => err,
49
+ GenFail::Generator(msg) => Error::new(nosj_exception(ruby, "GeneratorError"), msg),
50
+ GenFail::GeneratorFrom(err) => {
51
+ let msg = error_message(&err);
52
+ Error::new(nosj_exception(ruby, "GeneratorError"), msg)
53
+ }
54
+ GenFail::Nesting(limit) => Error::new(
55
+ nosj_exception(ruby, "NestingError"),
56
+ format!(
57
+ "nesting of {limit} is too deep. Did you try to serialize objects with circular references?"
58
+ ),
59
+ ),
60
+ }
61
+ }
@@ -0,0 +1,53 @@
1
+ //! The one hand-written C-ABI callback in this extension.
2
+ //!
3
+ //! Ruby's only allocation-free hash iteration is `rb_hash_foreach`,
4
+ //! which takes a C function pointer. magnus's closure wrapper for it
5
+ //! (`RHash::foreach`) costs a measured 3-8% on object-heavy
6
+ //! generation (per-pair `Value` wrapping, Ruby-handle construction,
7
+ //! and result plumbing), so this module packs the same closure shape
8
+ //! over the raw API: the closure is monomorphized straight into the
9
+ //! trampoline and a pair costs exactly the callback dispatch.
10
+ //! Everything else in the extension stays on magnus.
11
+
12
+ use rb_sys::VALUE;
13
+ use std::os::raw::c_int;
14
+
15
+ /// Continue/stop signal from the per-pair closure, mirroring
16
+ /// `st_retval`'s two values the callback may return here.
17
+ pub(super) enum Step {
18
+ Continue,
19
+ Stop,
20
+ }
21
+
22
+ /// Iterate `hash`'s pairs, passing raw VALUEs to `f`.
23
+ ///
24
+ /// The closure must not let a Ruby exception cross this frame (route
25
+ /// raising calls through magnus's protect and report via [`Step`]);
26
+ /// panics abort the process (the extension builds with panic=abort),
27
+ /// never unwind into C.
28
+ ///
29
+ /// # Safety
30
+ ///
31
+ /// `hash` must be a live `T_HASH` VALUE.
32
+ pub(super) unsafe fn foreach_raw<F>(hash: VALUE, mut f: F)
33
+ where
34
+ F: FnMut(VALUE, VALUE) -> Step,
35
+ {
36
+ unsafe extern "C" fn trampoline<F>(k: VALUE, v: VALUE, arg: VALUE) -> c_int
37
+ where
38
+ F: FnMut(VALUE, VALUE) -> Step,
39
+ {
40
+ let f = unsafe { &mut *(arg as *mut F) };
41
+ match f(k, v) {
42
+ Step::Continue => 0,
43
+ Step::Stop => 1,
44
+ }
45
+ }
46
+ unsafe {
47
+ rb_sys::rb_hash_foreach(
48
+ hash,
49
+ Some(trampoline::<F>),
50
+ std::ptr::from_mut(&mut f) as VALUE,
51
+ );
52
+ }
53
+ }
@@ -0,0 +1,58 @@
1
+ //! Pre-escaped object-key cache for generation.
2
+
3
+ use rb_sys::VALUE;
4
+
5
+ use crate::state::{ensure_marked_shadow, VStackShadow};
6
+
7
+ /// Epoch eviction, like the parse-side key cache: at capacity the whole map
8
+ /// is cleared and hot keys repopulate; one unique-key-heavy document must
9
+ /// not degrade every later document.
10
+ const GEN_KEY_CACHE_CAP: usize = 2048;
11
+
12
+ /// Cache of quoted-and-escaped key bytes, keyed on the key string's VALUE
13
+ /// identity. Sound because Ruby freezes all string hash keys (immutable
14
+ /// content) and the GC-marked shadow keeps every cached key alive (an
15
+ /// address can never be reused for a different string while cached).
16
+ /// Documents emit few unique keys many times over (twitter: 94 unique keys,
17
+ /// 13,345 emissions), so key emission collapses to one short copy.
18
+ ///
19
+ /// Taken out of the thread-local for the duration of a generate call (like
20
+ /// the output buffer): recursive generation via a user `to_json` sees a
21
+ /// fresh default and cannot double-borrow.
22
+ #[derive(Default)]
23
+ pub(super) struct GenKeyCache {
24
+ map: ahash::AHashMap<VALUE, Box<[u8]>>,
25
+ shadow: Option<&'static mut VStackShadow>,
26
+ }
27
+
28
+ impl GenKeyCache {
29
+ pub(super) fn with_capacity(n: usize) -> Self {
30
+ GenKeyCache {
31
+ map: ahash::AHashMap::with_capacity(n),
32
+ shadow: None,
33
+ }
34
+ }
35
+
36
+ #[inline]
37
+ pub(super) fn get(&self, k: VALUE) -> Option<&[u8]> {
38
+ self.map.get(&k).map(Box::as_ref)
39
+ }
40
+
41
+ /// Cache `bytes` for `k`, evicting the whole epoch at capacity and
42
+ /// registering `k` with the GC-marked shadow that keeps it alive.
43
+ pub(super) fn store(&mut self, k: VALUE, bytes: Box<[u8]>) {
44
+ if self.map.len() >= GEN_KEY_CACHE_CAP {
45
+ self.map.clear();
46
+ if let Some(shadow) = self.shadow.as_mut() {
47
+ shadow.values.clear();
48
+ }
49
+ }
50
+ ensure_marked_shadow(&mut self.shadow);
51
+ self.shadow
52
+ .as_mut()
53
+ .expect("shadow just ensured")
54
+ .values
55
+ .push(k);
56
+ self.map.insert(k, bytes);
57
+ }
58
+ }
@@ -0,0 +1,171 @@
1
+ //! JSON generation: walks a Ruby object graph and emits JSON bytes through
2
+ //! nosj's SIMD emission kernels (escape scanning, Ruby-format floats).
3
+ //! Matches the JSON gem's `generate` semantics: same output bytes, same
4
+ //! error classes and messages, same options.
5
+ //!
6
+ //! - `opts.rs`: option-hash decoding into [`opts::GenConfig`].
7
+ //! - `walker.rs`: the recursive emitter (compact/pretty const-generic).
8
+ //! - `keys.rs`: the pre-escaped, VALUE-identity key cache.
9
+ //! - `ruby.rs`: `rb_protect` shims and inline RBasic flag readers.
10
+ //! - `errors.rs`: failure carriers mapped onto the gem's exceptions.
11
+ //!
12
+ //! Tried and rejected (2026-07-15, measured on Zen 4 AND Apple
13
+ //! Silicon): emitting through the crate's `EmitBuf` trait straight
14
+ //! into the returned Ruby String (both a pure heap-String buffer and a
15
+ //! stack-prefix hybrid with last-output-size spill hints). It removes
16
+ //! the final `rb_utf8_str_new` copy, and twitter generation won one
17
+ //! roll on Zen 4 (0.96), but the per-call multi-MB String allocation
18
+ //! loses to this warm pooled Vec everywhere else: ARM gsoc generation
19
+ //! regressed a deterministic 1.42x (13/13 wins fell to 10/13), x86
20
+ //! tiny documents up to 1.29x. The final copy was never the expensive
21
+ //! part; the warm buffer is.
22
+
23
+ mod errors;
24
+ mod hash_iter;
25
+ mod keys;
26
+ mod opts;
27
+ mod ruby;
28
+ mod walker;
29
+
30
+ use magnus::rb_sys::{AsRawValue, FromRawValue};
31
+ use magnus::{Error, RString, Ruby, Value};
32
+ use std::cell::RefCell;
33
+
34
+ use errors::raise_fail;
35
+ use keys::GenKeyCache;
36
+ use walker::Gen;
37
+
38
+ /// Per-thread generate scratch: the pooled output buffer (capacity
39
+ /// survives across calls) and the pre-escaped key cache. One
40
+ /// thread_local, borrowed ONCE for the whole call: in a shared library
41
+ /// every thread_local access is a `__tls_get_addr` call, and the old
42
+ /// take/put-back shape paid two of them plus struct moves per call
43
+ /// (measurable on 45-byte documents). A recursive generate via a user
44
+ /// `to_json` hits the failed `try_borrow_mut` arm and runs on a fresh
45
+ /// scratch instead of panicking the RefCell.
46
+ struct GenScratch {
47
+ buf: Vec<u8>,
48
+ keys: GenKeyCache,
49
+ }
50
+
51
+ thread_local! {
52
+ static GEN_SCRATCH: RefCell<GenScratch> = RefCell::new(GenScratch {
53
+ buf: Vec::new(),
54
+ keys: GenKeyCache::with_capacity(256),
55
+ });
56
+ }
57
+
58
+ /// `NOSJ.generate(obj, opts = nil)`, registered as a variadic native
59
+ /// method: no Ruby forwarder frame, and the nil-options path borrows
60
+ /// the shared [`opts::DEFAULT_CONFIG`] instead of building a config.
61
+ pub fn generate_entry(ruby: &Ruby, _rb_self: Value, args: &[Value]) -> Result<RString, Error> {
62
+ use magnus::value::ReprValue;
63
+ let (obj, opts) = match *args {
64
+ [obj] => (obj, None),
65
+ [obj, opts] if opts.is_nil() => (obj, None),
66
+ [obj, opts] => (obj, Some(opts)),
67
+ _ => {
68
+ return Err(Error::new(
69
+ ruby.exception_arg_error(),
70
+ format!(
71
+ "wrong number of arguments (given {}, expected 1..2)",
72
+ args.len()
73
+ ),
74
+ ));
75
+ }
76
+ };
77
+ let built;
78
+ let (cfg, cap_hint): (&opts::GenConfig, usize) = match opts {
79
+ None => (&opts::DEFAULT_CONFIG, 0),
80
+ Some(o) => {
81
+ let (c, hint) = opts::parse_gen_opts(ruby, o)?;
82
+ built = c;
83
+ (&built, hint)
84
+ }
85
+ };
86
+ generate_scratched(ruby, obj, cfg, cap_hint)
87
+ }
88
+
89
+ /// `NOSJ.generate_native(obj, opts)`: the fixed-arity entry kept for
90
+ /// the Ruby-level wrappers (pretty_generate merges options first).
91
+ pub fn generate_native(
92
+ ruby: &Ruby,
93
+ _rb_self: Value,
94
+ obj: Value,
95
+ opts: Value,
96
+ ) -> Result<RString, Error> {
97
+ use magnus::value::ReprValue;
98
+ if opts.is_nil() {
99
+ return generate_scratched(ruby, obj, &opts::DEFAULT_CONFIG, 0);
100
+ }
101
+ let (cfg, cap_hint) = opts::parse_gen_opts(ruby, opts)?;
102
+ generate_scratched(ruby, obj, &cfg, cap_hint)
103
+ }
104
+
105
+ fn generate_scratched(
106
+ ruby: &Ruby,
107
+ obj: Value,
108
+ cfg: &opts::GenConfig,
109
+ cap_hint: usize,
110
+ ) -> Result<RString, Error> {
111
+ GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
112
+ Ok(mut scratch) => {
113
+ let scratch = &mut *scratch;
114
+ generate_with(
115
+ ruby,
116
+ obj,
117
+ cfg,
118
+ cap_hint,
119
+ &mut scratch.buf,
120
+ &mut scratch.keys,
121
+ )
122
+ }
123
+ Err(_) => {
124
+ let mut buf = Vec::new();
125
+ let mut keys = GenKeyCache::default();
126
+ generate_with(ruby, obj, cfg, cap_hint, &mut buf, &mut keys)
127
+ }
128
+ })
129
+ }
130
+
131
+ fn generate_with(
132
+ ruby: &Ruby,
133
+ obj: Value,
134
+ cfg: &opts::GenConfig,
135
+ cap_hint: usize,
136
+ out: &mut Vec<u8>,
137
+ keys: &mut GenKeyCache,
138
+ ) -> Result<RString, Error> {
139
+ out.clear();
140
+ if out.capacity() < cap_hint {
141
+ out.reserve(cap_hint);
142
+ }
143
+
144
+ let mut g = Gen {
145
+ out,
146
+ cfg,
147
+ fail: None,
148
+ keys,
149
+ };
150
+ let result = if cfg.pretty {
151
+ g.emit_value::<true>(obj.as_raw(), cfg.start_depth)
152
+ } else {
153
+ g.emit_value::<false>(obj.as_raw(), cfg.start_depth)
154
+ };
155
+
156
+ let Gen { out, fail, .. } = g;
157
+ match (result, fail) {
158
+ (Ok(()), None) => Ok(unsafe {
159
+ RString::from_value(Value::from_raw(rb_sys::rb_utf8_str_new(
160
+ out.as_ptr().cast(),
161
+ out.len() as std::os::raw::c_long,
162
+ )))
163
+ .expect("rb_utf8_str_new returns a String")
164
+ }),
165
+ (_, Some(fail)) => Err(raise_fail(ruby, fail)),
166
+ (Err(()), None) => Err(Error::new(
167
+ ruby.exception_runtime_error(),
168
+ "generation failed without error detail",
169
+ )),
170
+ }
171
+ }
@@ -0,0 +1,162 @@
1
+ //! JSON.generate-compatible option decoding: formatting strings, escape
2
+ //! mode, nesting limits, and the buffer size hint.
3
+
4
+ use magnus::value::ReprValue;
5
+ use magnus::{Error, RHash, RString, Ruby, Value};
6
+ use nosj::emit::EscapeMode;
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>,
14
+ /// 0 = unlimited.
15
+ pub(super) max_nesting: usize,
16
+ pub(super) start_depth: usize,
17
+ pub(super) allow_nan: bool,
18
+ pub(super) strict: bool,
19
+ pub(super) mode: EscapeMode,
20
+ /// Precomputed "any formatting string set": scanning the five
21
+ /// vectors per call was measurable on tiny documents.
22
+ pub(super) pretty: bool,
23
+ }
24
+
25
+ /// The nil-options configuration, shared instead of rebuilt: stamping
26
+ /// a fresh ~140-byte GenConfig onto the stack per call was measurable
27
+ /// on tiny documents (the json gem likewise reuses a cached State for
28
+ /// the default options). Safe as a static: `Vec::new` is const and
29
+ /// allocation-free, and generation only ever borrows the config.
30
+ pub(super) static DEFAULT_CONFIG: GenConfig = GenConfig {
31
+ indent: Vec::new(),
32
+ space: Vec::new(),
33
+ space_before: Vec::new(),
34
+ object_nl: Vec::new(),
35
+ array_nl: Vec::new(),
36
+ max_nesting: 100,
37
+ start_depth: 0,
38
+ allow_nan: false,
39
+ strict: false,
40
+ mode: EscapeMode::Standard,
41
+ pretty: false,
42
+ };
43
+
44
+ impl Default for GenConfig {
45
+ fn default() -> Self {
46
+ GenConfig {
47
+ indent: Vec::new(),
48
+ space: Vec::new(),
49
+ space_before: Vec::new(),
50
+ object_nl: Vec::new(),
51
+ array_nl: Vec::new(),
52
+ max_nesting: 100,
53
+ start_depth: 0,
54
+ allow_nan: false,
55
+ strict: false,
56
+ mode: EscapeMode::Standard,
57
+ pretty: false,
58
+ }
59
+ }
60
+ }
61
+
62
+ fn opt_bytes(ruby: &Ruby, opts: RHash, name: &str) -> Result<Option<Vec<u8>>, Error> {
63
+ let v: Value = opts
64
+ .get(ruby.to_symbol(name))
65
+ .unwrap_or_else(|| ruby.qnil().as_value());
66
+ if v.is_nil() {
67
+ return Ok(None);
68
+ }
69
+ let s = RString::from_value(v).ok_or_else(|| {
70
+ Error::new(
71
+ ruby.exception_type_error(),
72
+ format!("{name} must be a String"),
73
+ )
74
+ })?;
75
+ Ok(Some(unsafe { s.as_slice() }.to_vec()))
76
+ }
77
+
78
+ fn opt_bool(ruby: &Ruby, opts: RHash, name: &str) -> Option<bool> {
79
+ let v: Value = opts.get(ruby.to_symbol(name))?;
80
+ if v.is_nil() {
81
+ None
82
+ } else {
83
+ Some(v.to_bool())
84
+ }
85
+ }
86
+
87
+ /// Decode a non-nil options hash (nil takes [`DEFAULT_CONFIG`] at the
88
+ /// call site without constructing anything).
89
+ pub(super) fn parse_gen_opts(ruby: &Ruby, opts: Value) -> Result<(GenConfig, usize), Error> {
90
+ let mut cfg = GenConfig::default();
91
+ let mut cap_hint = 0usize;
92
+ if opts.is_nil() {
93
+ return Ok((cfg, cap_hint));
94
+ }
95
+ let opts = RHash::from_value(opts)
96
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "options must be a Hash or nil"))?;
97
+
98
+ if let Some(v) = opt_bytes(ruby, opts, "indent")? {
99
+ cfg.indent = v;
100
+ }
101
+ if let Some(v) = opt_bytes(ruby, opts, "space")? {
102
+ cfg.space = v;
103
+ }
104
+ if let Some(v) = opt_bytes(ruby, opts, "space_before")? {
105
+ cfg.space_before = v;
106
+ }
107
+ if let Some(v) = opt_bytes(ruby, opts, "object_nl")? {
108
+ cfg.object_nl = v;
109
+ }
110
+ if let Some(v) = opt_bytes(ruby, opts, "array_nl")? {
111
+ cfg.array_nl = v;
112
+ }
113
+ if let Some(v) = opt_bool(ruby, opts, "allow_nan") {
114
+ cfg.allow_nan = v;
115
+ }
116
+ if let Some(v) = opt_bool(ruby, opts, "strict") {
117
+ cfg.strict = v;
118
+ }
119
+ let ascii = opt_bool(ruby, opts, "ascii_only").unwrap_or(false);
120
+ let script = opt_bool(ruby, opts, "script_safe").unwrap_or(false)
121
+ || opt_bool(ruby, opts, "escape_slash").unwrap_or(false);
122
+ if ascii {
123
+ cfg.mode = EscapeMode::AsciiOnly;
124
+ if script {
125
+ return Err(Error::new(
126
+ ruby.exception_arg_error(),
127
+ "NOSJ.generate: ascii_only and script_safe cannot be combined",
128
+ ));
129
+ }
130
+ } else if script {
131
+ cfg.mode = EscapeMode::ScriptSafe;
132
+ }
133
+ if let Some(v) = opts.get(ruby.to_symbol("max_nesting")) {
134
+ let v: Value = v;
135
+ // nil/false → unlimited; true → keep the default 100; Integer → limit.
136
+ if !v.to_bool() {
137
+ cfg.max_nesting = 0;
138
+ } else if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
139
+ cfg.max_nesting = if n <= 0 { 0 } else { n as usize };
140
+ }
141
+ }
142
+ if let Some(v) = opts.get(ruby.to_symbol("depth")) {
143
+ let v: Value = v;
144
+ if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
145
+ cfg.start_depth = if n <= 0 { 0 } else { n as usize };
146
+ }
147
+ }
148
+ if let Some(v) = opts.get(ruby.to_symbol("buffer_initial_length")) {
149
+ let v: Value = v;
150
+ if let Ok(n) = <i64 as magnus::TryConvert>::try_convert(v) {
151
+ if n > 0 {
152
+ cap_hint = n as usize;
153
+ }
154
+ }
155
+ }
156
+ cfg.pretty = !(cfg.indent.is_empty()
157
+ && cfg.space.is_empty()
158
+ && cfg.space_before.is_empty()
159
+ && cfg.object_nl.is_empty()
160
+ && cfg.array_nl.is_empty());
161
+ Ok((cfg, cap_hint))
162
+ }
@@ -0,0 +1,100 @@
1
+ //! Low-level Ruby helpers for generation: protected wrappers over the
2
+ //! user-reachable raising calls (a Ruby raise longjmping through Rust
3
+ //! frames is UB, so each goes through magnus's closure-based
4
+ //! `rb_sys::protect`; magnus owns the FFI trampoline) and inline
5
+ //! RBasic flag readers.
6
+
7
+ use magnus::Error;
8
+ use rb_sys::{ruby_special_consts, VALUE};
9
+ use std::os::raw::c_int;
10
+ use std::sync::OnceLock;
11
+
12
+ pub(super) const QNIL: VALUE = ruby_special_consts::RUBY_Qnil as VALUE;
13
+ pub(super) const QTRUE: VALUE = ruby_special_consts::RUBY_Qtrue as VALUE;
14
+ pub(super) const QFALSE: VALUE = ruby_special_consts::RUBY_Qfalse as VALUE;
15
+
16
+ /// `v.to_s`, protected.
17
+ pub(super) fn protected_to_s(v: VALUE) -> Result<VALUE, Error> {
18
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_as_string(v) })
19
+ }
20
+
21
+ /// `v.to_json`, protected.
22
+ pub(super) fn protected_to_json(v: VALUE) -> Result<VALUE, Error> {
23
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) })
24
+ }
25
+
26
+ /// Encode `v` to UTF-8, protected. `rb_str_encode` raises on
27
+ /// undefined/invalid conversions, matching the gem, which wraps that
28
+ /// exception as GeneratorError (`rb_str_export_to_enc` is lenient and
29
+ /// silently passes bad bytes through).
30
+ pub(super) fn protected_encode_utf8(v: VALUE) -> Result<VALUE, Error> {
31
+ magnus::rb_sys::protect(|| unsafe {
32
+ let utf8 = rb_sys::rb_enc_from_encoding(rb_sys::rb_utf8_encoding());
33
+ rb_sys::rb_str_encode(v, utf8, 0, QNIL)
34
+ })
35
+ }
36
+
37
+ /// Interned `to_json` method ID, resolved once per process.
38
+ pub(super) fn to_json_id() -> rb_sys::ID {
39
+ static TO_JSON: OnceLock<usize> = OnceLock::new();
40
+ *TO_JSON.get_or_init(|| unsafe { rb_sys::rb_intern(c"to_json".as_ptr()) } as usize)
41
+ as rb_sys::ID
42
+ }
43
+
44
+ pub(super) fn utf8_encindexes() -> (c_int, c_int) {
45
+ static IDX: OnceLock<(c_int, c_int)> = OnceLock::new();
46
+ *IDX.get_or_init(|| unsafe { (rb_sys::rb_utf8_encindex(), rb_sys::rb_usascii_encindex()) })
47
+ }
48
+
49
+ // Coderange and encoding index live in RBasic flags (public ABI); reading
50
+ // them inline instead of calling rb_enc_str_coderange / rb_enc_get_index is
51
+ // how the gem avoids two C calls per string (RB_ENC_CODERANGE,
52
+ // RB_ENCODING_GET_INLINED).
53
+ const CR_MASK: u64 = 3 << 20;
54
+ pub(super) const CR_7BIT: u64 = 1 << 20;
55
+ pub(super) const CR_VALID: u64 = 2 << 20;
56
+ const ENC_SHIFT: u64 = 22;
57
+ const ENC_MASK: u64 = 127 << 22;
58
+
59
+ #[inline(always)]
60
+ pub(super) fn str_coderange(s: VALUE) -> u64 {
61
+ let flags = unsafe { (*(s as *const rb_sys::RBasic)).flags };
62
+ let cr = flags & CR_MASK;
63
+ if cr != 0 {
64
+ cr
65
+ } else {
66
+ (unsafe { rb_sys::rb_enc_str_coderange(s) } as u64) & CR_MASK
67
+ }
68
+ }
69
+
70
+ #[inline(always)]
71
+ pub(super) fn str_enc_index(s: VALUE) -> c_int {
72
+ let flags = unsafe { (*(s as *const rb_sys::RBasic)).flags };
73
+ let idx = ((flags & ENC_MASK) >> ENC_SHIFT) as c_int;
74
+ if idx == 127 {
75
+ // RUBY_ENCODING_INLINE_MAX sentinel: index stored out of line.
76
+ unsafe { rb_sys::rb_enc_get_index(s) }
77
+ } else {
78
+ idx
79
+ }
80
+ }
81
+
82
+ #[inline(always)]
83
+ pub(super) fn is_special_const(v: VALUE) -> bool {
84
+ // RB_SPECIAL_CONST_P: immediates plus Qnil/Qfalse.
85
+ (v & (ruby_special_consts::RUBY_IMMEDIATE_MASK as VALUE)) != 0 || v == QNIL || v == QFALSE
86
+ }
87
+
88
+ /// Borrow a Ruby String's bytes.
89
+ ///
90
+ /// # Safety
91
+ /// `s` must be a `T_STRING` VALUE. The slice borrows the Ruby heap: it is
92
+ /// valid only until the next call that could mutate, reallocate, or free
93
+ /// the string, so callers must copy the bytes out before any Ruby call.
94
+ #[inline(always)]
95
+ pub(super) unsafe fn rstring_bytes<'a>(s: VALUE) -> &'a [u8] {
96
+ std::slice::from_raw_parts(
97
+ rb_sys::macros::RSTRING_PTR(s).cast::<u8>(),
98
+ rb_sys::macros::RSTRING_LEN(s) as usize,
99
+ )
100
+ }