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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/Cargo.lock +375 -0
- data/Cargo.toml +27 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +13 -0
- data/README.md +214 -0
- data/ext/nosj/Cargo.toml +25 -0
- data/ext/nosj/extconf.rb +6 -0
- data/ext/nosj/src/gen/errors.rs +61 -0
- data/ext/nosj/src/gen/hash_iter.rs +53 -0
- data/ext/nosj/src/gen/keys.rs +58 -0
- data/ext/nosj/src/gen/mod.rs +171 -0
- data/ext/nosj/src/gen/opts.rs +162 -0
- data/ext/nosj/src/gen/ruby.rs +100 -0
- data/ext/nosj/src/gen/walker.rs +638 -0
- data/ext/nosj/src/lib.rs +56 -0
- data/ext/nosj/src/parse.rs +211 -0
- data/ext/nosj/src/pointer.rs +190 -0
- data/ext/nosj/src/sink.rs +370 -0
- data/ext/nosj/src/state.rs +89 -0
- data/lib/nosj/json.rb +124 -0
- data/lib/nosj/multi_json.rb +51 -0
- data/lib/nosj/native.rb +11 -0
- data/lib/nosj/version.rb +6 -0
- data/lib/nosj.rb +193 -0
- data/sig/nosj.rbs +61 -0
- metadata +92 -0
data/ext/nosj/Cargo.toml
ADDED
|
@@ -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"
|
data/ext/nosj/extconf.rb
ADDED
|
@@ -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
|
+
}
|