nosj 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87c27bdaeada8a26a98f378d168befec0d1b50b497663bf09ea58b133358b98f
4
- data.tar.gz: 0756f4a05107fabcffe58c889c7a40487c7f060d944d9b5bce161430d4e0c70f
3
+ metadata.gz: 78314f13c659fff6d0e146ac0b951564e66b7b10deeb439e317703754a0100ef
4
+ data.tar.gz: 993f8a38c4e04dcd68a6ec8ee2dc62c611d2a04c1bc0ff23fc31d67ad69dd585
5
5
  SHA512:
6
- metadata.gz: ff14cc073152752000f9ad0102222c69e3a63ec561b8411886164c5e1c85fb30044d31e6286074169e1cc531a533b8d59d5b103473b4ae413c32c4e0d565b7f9
7
- data.tar.gz: 673019e943af6446a6bb63dacaaaee002f11c07e450154abd5b0842deffc1f798e924ec3531366d2717a71391bf630d98b83b1e2320e90aa3790c83cb2cd93dd
6
+ metadata.gz: 6b5f09e43ed85c2e345a35d0fd681900e205d4743161542d82ba5c3d1c80879c62bff3acf88f12dac5836334c67d933cc6241ead7dbe6a7a820d5f672bccd3d4
7
+ data.tar.gz: 0b48922585bb4863f9ed71fb9500a9a60b4c1f857baeaaf79136ed4c9e752aa71c9c23235e85d3bab38b1f0967b7b7212ed6d771be57d62d87fcd39e91666cd8
data/CHANGELOG.md CHANGED
@@ -1,4 +1,25 @@
1
- ## [0.1.0] - 2026-07-15
1
+ ## [0.2.0] - 2026-07-16
2
+
3
+ - File APIs. `NOSJ.load_file(path, opts)` parses a file directly
4
+ (~1.3× faster than `parse(File.read(path))`—no file-sized Ruby
5
+ String is created), and `NOSJ.write_file(path, obj, opts)` generates
6
+ straight to disk, returning the byte count like `File.write`.
7
+ `NOSJ.load_lazy_file(path, opts)` wraps a file as a lazy document
8
+ over a read-only memory map, and `NOSJ.at_pointer_file` /
9
+ `NOSJ.dig_file` pull single values out of a file without reading the
10
+ rest into Ruby. Missing files raise the usual `Errno` exceptions.
11
+ - `NOSJ.lazy`: lazy documents. Wrap a document once, then read only
12
+ what you need: `doc["users"][3]["name"]` parses just that path, `#dig`
13
+ and `#at_pointer` resolve whole paths, and `#keys`, `#size`, and
14
+ `#each` inspect a node without parsing its values. Containers come
15
+ back lazy, scalars come back as plain Ruby values, and repeated
16
+ reads are cached. `#value` (also `#to_h` / `#to_a`) materializes a
17
+ subtree under the usual parse options (`symbolize_names`, `freeze`,
18
+ ...). Pass a frozen string and creating the view is practically
19
+ free, even on megabyte documents. Malformed content raises on first
20
+ read, not at wrap time.
21
+
22
+ ## [0.1.0] - 2026-07-16
2
23
 
3
24
  Initial release.
4
25
 
data/Cargo.lock CHANGED
@@ -162,6 +162,15 @@ version = "2.8.3"
162
162
  source = "registry+https://github.com/rust-lang/crates.io-index"
163
163
  checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
164
164
 
165
+ [[package]]
166
+ name = "memmap2"
167
+ version = "0.9.11"
168
+ source = "registry+https://github.com/rust-lang/crates.io-index"
169
+ checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
170
+ dependencies = [
171
+ "libc",
172
+ ]
173
+
165
174
  [[package]]
166
175
  name = "minimal-lexical"
167
176
  version = "0.2.1"
@@ -189,10 +198,11 @@ dependencies = [
189
198
 
190
199
  [[package]]
191
200
  name = "nosj_native"
192
- version = "0.1.0"
201
+ version = "0.2.0"
193
202
  dependencies = [
194
203
  "ahash",
195
204
  "magnus",
205
+ "memmap2",
196
206
  "nosj",
197
207
  "rb-sys",
198
208
  ]
data/README.md CHANGED
@@ -10,10 +10,12 @@
10
10
  - It is **faster** than gem json and every
11
11
  third-party parser, including Oj, RapidJSON, FastJsonparser, Yajl. 1.0–1.8× faster than the bundled json gem, 1.3–11× faster than Oj, and up to 17×
12
12
  faster than Yajl—[see Benchmarks](#benchmarks).
13
+ - It has **lazy documents**: `NOSJ.lazy` wraps a document and parses a value only when you touch it—repeated access costs nanoseconds, and everything you never read is never parsed.
14
+ - It has a **partial parsing mode**: JSON Pointer lookups that pull single values out of big documents in microseconds, skipping everything else.
15
+ - It has **file APIs**: parse, generate, dig, and lazy-wrap files directly—no throwaway file-sized Ruby String, and the partial modes memory-map the file so unread pages never even leave the disk.
13
16
  - It comes **precompiled** (platform gems built with per-platform optimizations,
14
17
  nothing to compile on install).
15
- - It has a **partial parsing mode**: JSON Pointer lookups that pull single values out of big documents in microseconds, skipping everything else.
16
- - Same API and option names as gem json.
18
+ - Otherwise, same API and option names as gem json.
17
19
 
18
20
  **And there's more**: validate documents without building a single Ruby object, resolve whole batches of paths in one pass, and accelerate an entire application with a one-line drop-in.
19
21
 
@@ -83,17 +85,23 @@ NOSJ.generate(obj) # indent, space, object_nl, ...,
83
85
  NOSJ.pretty_generate(obj) # ascii_only, script_safe, strict
84
86
  ```
85
87
 
86
- **Validation without parsing.** `NOSJ.valid?` runs the full
87
- parser—tokenizers, string decode, number validationinto a null sink
88
- and allocates no Ruby objects at all. It is 2-4× faster than
89
- `NOSJ.parse`, which already leads every parser above:
88
+ **Lazy documents.** `NOSJ.lazy` wraps a document in a lazy view: read
89
+ a field and only that path is parsedcontainers stay lazy, scalars
90
+ arrive as plain Ruby values, and repeated reads are cached:
90
91
 
91
92
  ```ruby
92
- NOSJ.valid?('{"a":1}') #=> true
93
- NOSJ.valid?('{"a":}') #=> false
94
- NOSJ.valid?(src, max_nesting: false) # same options as parse
93
+ doc = NOSJ.lazy(json)
94
+ doc["users"][3]["name"] # parses only this path
95
+ doc.dig("meta", "count") # a whole path in one fused resolution
96
+ doc["users"].size # counted without materializing anything
97
+ doc["users"][3].value # materialize one subtree (parse options apply)
95
98
  ```
96
99
 
100
+ Pass a frozen string and creating the view is practically free—
101
+ nanoseconds, even on a megabyte document. Malformed content raises
102
+ when it is first read, not at wrap time.
103
+
104
+
97
105
  **Partial parsing.** Pull values out of a document without
98
106
  materializing the rest—skipped content is stepped over at SIMD block
99
107
  speed, so a lookup costs what it skips, not what the document weighs:
@@ -114,11 +122,38 @@ at the far end of a 570 KB document costs ~71µs, still 13× faster
114
122
  than parse-then-dig. Misses return nil; matched subtrees materialize
115
123
  with the same options as `parse` (`symbolize_names:`, `freeze:`).
116
124
 
125
+ **Files.** Every mode has a file-native form, so a document never
126
+ round-trips through a throwaway Ruby String:
127
+
128
+ ```ruby
129
+ NOSJ.load_file("config.json") # 1.3× File.read + parse
130
+ NOSJ.write_file("out.json", obj) # generate straight to disk
131
+ NOSJ.dig_file("huge.json", "users", 3, "name") # never reads the rest
132
+ NOSJ.at_pointer_file("huge.json", "/meta/count")
133
+ doc = NOSJ.load_lazy_file("huge.json") # lazy view over a memory map
134
+ ```
135
+
136
+ The partial and lazy forms memory-map the file, so pages you never
137
+ read are never loaded from disk. Missing files raise the usual
138
+ `Errno` exceptions. Measured numbers live in
139
+ [Benchmarks → File APIs](#file-apis).
140
+
141
+ **Validation without parsing.** `NOSJ.valid?` runs the full
142
+ parser—tokenizers, string decode, number validation—into a null sink
143
+ and allocates no Ruby objects at all. It is 2-4× faster than
144
+ `NOSJ.parse`, which already leads every parser above:
145
+
146
+ ```ruby
147
+ NOSJ.valid?('{"a":1}') #=> true
148
+ NOSJ.valid?('{"a":}') #=> false
149
+ NOSJ.valid?(src, max_nesting: false) # same options as parse
150
+ ```
151
+
117
152
  ## Benchmarks
118
153
 
119
154
  Every installed JSON gem, benchmark-ips: AWS EC2 c7a.2xlarge (AMD EPYC 9R14, Zen 4), Ruby 4.0.6 + YJIT, json 2.21.1, Oj 3.17.4, RapidJSON 0.4.0, FastJsonparser 0.6.0, Yajl 1.4.3, PGO build, 2026-07-16. `×N` = times slower than nosj.
120
155
 
121
- Parse:
156
+ ### Parse
122
157
 
123
158
  | file | nosj (i/s) | json | Oj | FastJsonparser | RapidJSON | Yajl |
124
159
  |---|---:|---:|---:|---:|---:|---:|
@@ -136,7 +171,7 @@ Parse:
136
171
  | tolstoy | **8.9k** | ×1.79 | ×1.96 | ×2.29 | ×2.10 | ×17.31 |
137
172
  | twitter | **1.1k** | ×1.09 | ×1.83 | ×2.25 | ×2.61 | ×5.40 |
138
173
 
139
- Generate:
174
+ ### Generate
140
175
 
141
176
  | file | nosj (i/s) | json | Oj | RapidJSON | Yajl |
142
177
  |---|---:|---:|---:|---:|---:|
@@ -157,6 +192,23 @@ Generate:
157
192
  \* canada-generate is a statistical tie with the json gem (within
158
193
  measurement error).
159
194
 
195
+ ### File APIs
196
+
197
+ twitter.json (570 KB) from a warm page cache, medians of 7 alternating
198
+ rounds against the plain-Ruby composition on the same parser (Apple
199
+ Silicon dev box, Ruby 4.0.6 + YJIT, PGO build, 2026-07-16):
200
+
201
+ | operation | µs/op | vs the Ruby way |
202
+ |---|---:|---|
203
+ | `NOSJ.load_file` (parse the whole file) | 948 | ×1.33 vs `NOSJ.parse(File.read(path))` |
204
+ | `NOSJ.dig_file` (one deep field) | 246 | ×5.2 vs read + parse + dig |
205
+ | `NOSJ.load_lazy_file` + one field | 257 | ×5.0 vs read + parse + dig |
206
+
207
+ `NOSJ.write_file` measures at parity with
208
+ `File.write(NOSJ.generate(obj))` on this box—file-write timings swing
209
+ too much for an honest multiplier; what it saves is the intermediate
210
+ file-sized Ruby String.
211
+
160
212
  Reproduce with `rake bench` (the parity-gated comparison, after a PGO retrain—the shipping configuration) or `rake bench:ips` (the multi-gem shoot-out).
161
213
 
162
214
  ## Switching from the json gem
data/ext/nosj/Cargo.toml CHANGED
@@ -5,7 +5,7 @@
5
5
  # required by lib/nosj.rb as "nosj/nosj" (Init_nosj set explicitly in
6
6
  # lib.rs). Gem name, module, and API are plain nosj.
7
7
  name = "nosj_native"
8
- version = "0.1.0"
8
+ version = "0.2.0"
9
9
  edition = "2021"
10
10
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
11
11
  license = "MIT"
@@ -19,6 +19,9 @@ crate-type = ["cdylib", "rlib"]
19
19
  magnus = { git = "https://github.com/matsadler/magnus", features = ["embed", "rb-sys"] }
20
20
  rb-sys = { version = "0.9.124", default-features = false }
21
21
  ahash = "0.8.11"
22
+ # Read-only file mapping for the file entry points (load_lazy_file,
23
+ # at_pointer_file, dig_file): pages never touched are never read.
24
+ memmap2 = "0.9"
22
25
  # First-party SIMD JSON parse/generate library (github.com/yaroslav/nosj).
23
26
  # For coordinated crate+gem work, temporarily flip to
24
27
  # { path = "../../../nosj" } and restore before any commit or release.
@@ -0,0 +1,218 @@
1
+ //! File entry points. `load_file` reads natively into a reused Rust
2
+ //! buffer, so a document never round-trips through a throwaway Ruby
3
+ //! String (the string allocation and its GC churn are ~37% of
4
+ //! `parse(File.read(path))` on a 570 KB document). `write_file`
5
+ //! streams the generator's pooled buffer straight to disk. The mmap
6
+ //! trio (`load_lazy_file`, `at_pointer_file`, `dig_file`) maps the
7
+ //! file read-only, so partial access never reads unused pages off
8
+ //! disk.
9
+ //!
10
+ //! I/O failures raise the mapped `Errno::*` exception, like `File`
11
+ //! methods do.
12
+
13
+ use std::cell::RefCell;
14
+ use std::fs;
15
+ use std::io::Read;
16
+
17
+ use magnus::value::ReprValue;
18
+ use magnus::{Error, RArray, RString, Ruby, Value};
19
+
20
+ use crate::gen;
21
+ use crate::lazy::{self, DocBytes};
22
+ use crate::parse::{err, materialize, parse_native_opts, ParseNativeOpts};
23
+ use crate::pointer::path_to_pointer;
24
+ use crate::state::PULL_STATE;
25
+
26
+ const NOT_UTF8: &str = "input is not valid UTF-8";
27
+
28
+ thread_local! {
29
+ /// Reused read buffer for `load_file`: capacity survives across
30
+ /// calls, so a hot loop over files allocates nothing.
31
+ static FILE_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
32
+ }
33
+
34
+ /// On Unix the raw OS error IS the errno `rb_syserr_new` expects.
35
+ #[cfg(unix)]
36
+ fn errno_of(e: &std::io::Error) -> Option<i32> {
37
+ e.raw_os_error()
38
+ }
39
+
40
+ /// On Windows the raw OS error is a Win32 code, not a POSIX errno
41
+ /// (ERROR_PATH_NOT_FOUND = 3 read as errno 3 raised Errno::ESRCH on
42
+ /// CI), so map the portable ErrorKind onto the classic CRT errno
43
+ /// values Ruby's Errno classes use. Unmapped kinds fall back to a
44
+ /// plain RuntimeError with the message.
45
+ #[cfg(windows)]
46
+ fn errno_of(e: &std::io::Error) -> Option<i32> {
47
+ const ENOENT: i32 = 2;
48
+ const EACCES: i32 = 13;
49
+ const EEXIST: i32 = 17;
50
+ match e.kind() {
51
+ std::io::ErrorKind::NotFound => Some(ENOENT),
52
+ std::io::ErrorKind::PermissionDenied => Some(EACCES),
53
+ std::io::ErrorKind::AlreadyExists => Some(EEXIST),
54
+ _ => None,
55
+ }
56
+ }
57
+
58
+ /// Map an I/O failure onto the matching `Errno::*` exception (class
59
+ /// parity with `File.read`/`File.write`; the message carries the path).
60
+ fn io_error(ruby: &Ruby, path: &str, e: &std::io::Error) -> Error {
61
+ use magnus::rb_sys::FromRawValue;
62
+ let Some(errno) = errno_of(e) else {
63
+ return err(ruby, format!("{e} - {path}"));
64
+ };
65
+ let Ok(cpath) = std::ffi::CString::new(path) else {
66
+ return err(ruby, format!("{e} - {path}"));
67
+ };
68
+ // SAFETY: rb_syserr_new returns a live Errno exception instance.
69
+ let exc = unsafe { Value::from_raw(rb_sys::rb_syserr_new(errno, cpath.as_ptr())) };
70
+ match magnus::Exception::from_value(exc) {
71
+ Some(exc) => exc.into(),
72
+ None => err(ruby, format!("{e} - {path}")),
73
+ }
74
+ }
75
+
76
+ /// `NOSJ.load_file(path, opts)`: parse a file without ever creating a
77
+ /// file-sized Ruby String.
78
+ pub fn load_file_native(
79
+ ruby: &Ruby,
80
+ _rb_self: Value,
81
+ path: RString,
82
+ opts: Value,
83
+ ) -> Result<Value, Error> {
84
+ let o = parse_native_opts(ruby, opts)?;
85
+ let p = path.to_string()?;
86
+ FILE_BUF.with(|cell| {
87
+ let mut buf = cell
88
+ .try_borrow_mut()
89
+ .map_or_else(|_| Vec::new(), |mut b| std::mem::take(&mut *b));
90
+ buf.clear();
91
+ let result = read_into(&mut buf, &p)
92
+ .map_err(|e| io_error(ruby, &p, &e))
93
+ .and_then(|()| {
94
+ if std::str::from_utf8(&buf).is_err() {
95
+ return Err(err(ruby, NOT_UTF8.into()));
96
+ }
97
+ materialize(ruby, &buf, &o)
98
+ });
99
+ if let Ok(mut slot) = cell.try_borrow_mut() {
100
+ *slot = buf;
101
+ }
102
+ result
103
+ })
104
+ }
105
+
106
+ fn read_into(buf: &mut Vec<u8>, path: &str) -> std::io::Result<()> {
107
+ let mut f = fs::File::open(path)?;
108
+ let hint = f.metadata().map(|m| m.len() as usize).unwrap_or(0);
109
+ buf.reserve(hint.saturating_add(1));
110
+ f.read_to_end(buf)?;
111
+ Ok(())
112
+ }
113
+
114
+ /// `NOSJ.write_file(path, obj, opts)`: generate under the usual
115
+ /// options and write the bytes straight from the generator's pooled
116
+ /// buffer. Returns the byte count, like `File.write`.
117
+ pub fn write_file_native(
118
+ ruby: &Ruby,
119
+ _rb_self: Value,
120
+ path: RString,
121
+ obj: Value,
122
+ opts: Value,
123
+ ) -> Result<usize, Error> {
124
+ let p = path.to_string()?;
125
+ gen::generate_bytes_into(ruby, obj, opts, |ruby, bytes| {
126
+ fs::write(&p, bytes).map_err(|e| io_error(ruby, &p, &e))?;
127
+ Ok(bytes.len())
128
+ })
129
+ }
130
+
131
+ /// Map `path` read-only and hand a UTF-8-checked view to `f`.
132
+ fn with_mapped_file<R>(
133
+ ruby: &Ruby,
134
+ path: &str,
135
+ f: impl FnOnce(memmap2::Mmap) -> Result<R, Error>,
136
+ ) -> Result<R, Error> {
137
+ let file = fs::File::open(path).map_err(|e| io_error(ruby, path, &e))?;
138
+ // SAFETY: the mapping is read-only; concurrent modification of the
139
+ // file by another process is documented as unsupported (the
140
+ // standard mmap caveat, shared with every mmap-based parser).
141
+ let map = unsafe { memmap2::Mmap::map(&file) }.map_err(|e| io_error(ruby, path, &e))?;
142
+ if std::str::from_utf8(&map).is_err() {
143
+ return Err(err(ruby, NOT_UTF8.into()));
144
+ }
145
+ f(map)
146
+ }
147
+
148
+ /// `NOSJ.load_lazy_file(path, opts)`: a lazy document over a read-only
149
+ /// file mapping. Creation costs one sequential UTF-8 scan; access is
150
+ /// the usual lazy resolution, and pages outside the touched paths are
151
+ /// never read.
152
+ pub fn load_lazy_file_native(
153
+ ruby: &Ruby,
154
+ _rb_self: Value,
155
+ path: RString,
156
+ opts: Value,
157
+ ) -> Result<Value, Error> {
158
+ let o = parse_native_opts(ruby, opts)?;
159
+ let p = path.to_string()?;
160
+ with_mapped_file(ruby, &p, |map| {
161
+ lazy::wrap_root(ruby, DocBytes::Mmap(map), o)
162
+ })
163
+ }
164
+
165
+ /// Resolve one pointer against a mapped file and materialize the
166
+ /// matched subtree; the mapping is dropped before returning.
167
+ fn resolve_file_pointer(
168
+ ruby: &Ruby,
169
+ path: &str,
170
+ pointer: &str,
171
+ o: &ParseNativeOpts,
172
+ ) -> Result<Value, Error> {
173
+ with_mapped_file(ruby, path, |map| {
174
+ let resolved = PULL_STATE.with(|cell| {
175
+ let mut state = cell.borrow_mut();
176
+ // SAFETY: UTF-8 checked by with_mapped_file.
177
+ unsafe { nosj::pointer_utf8_unchecked(&map, pointer, &mut state.bufs) }
178
+ });
179
+ match resolved {
180
+ Ok(None) => Ok(ruby.qnil().as_value()),
181
+ Ok(Some(slice)) => materialize(ruby, slice.as_bytes(), o),
182
+ Err(e) if matches!(e.kind, nosj::ErrorKind::InvalidPointer) => {
183
+ Err(Error::new(ruby.exception_arg_error(), e.to_string()))
184
+ }
185
+ Err(e) => Err(err(ruby, e.to_string())),
186
+ }
187
+ })
188
+ }
189
+
190
+ /// `NOSJ.at_pointer_file(path, pointer, opts)`: `NOSJ.at_pointer`
191
+ /// against a file, without reading it into Ruby.
192
+ pub fn at_pointer_file_native(
193
+ ruby: &Ruby,
194
+ _rb_self: Value,
195
+ path: RString,
196
+ pointer: RString,
197
+ opts: Value,
198
+ ) -> Result<Value, Error> {
199
+ let o = parse_native_opts(ruby, opts)?;
200
+ let p = path.to_string()?;
201
+ let ptr = pointer.to_string()?;
202
+ resolve_file_pointer(ruby, &p, &ptr, &o)
203
+ }
204
+
205
+ /// `NOSJ.dig_file(path, *dig_path)`: `NOSJ.dig` against a file.
206
+ /// Negative indices resolve to nil, as everywhere else.
207
+ pub fn dig_file_native(
208
+ ruby: &Ruby,
209
+ _rb_self: Value,
210
+ path: RString,
211
+ dig_path: RArray,
212
+ ) -> Result<Value, Error> {
213
+ let p = path.to_string()?;
214
+ match path_to_pointer(ruby, dig_path)? {
215
+ Some(ptr) => resolve_file_pointer(ruby, &p, &ptr, &ParseNativeOpts::default()),
216
+ None => Ok(ruby.qnil().as_value()),
217
+ }
218
+ }
@@ -102,12 +102,50 @@ pub fn generate_native(
102
102
  generate_scratched(ruby, obj, &cfg, cap_hint)
103
103
  }
104
104
 
105
+ /// Generate `obj` under a Ruby options hash (or nil) and hand the
106
+ /// finished bytes to `finish` instead of building a Ruby String: the
107
+ /// entry `NOSJ.write_file` uses to stream straight to disk.
108
+ pub(crate) fn generate_bytes_into<R>(
109
+ ruby: &Ruby,
110
+ obj: Value,
111
+ opts: Value,
112
+ finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
113
+ ) -> Result<R, Error> {
114
+ use magnus::value::ReprValue;
115
+ if opts.is_nil() {
116
+ return generate_scratched_into(ruby, obj, &opts::DEFAULT_CONFIG, 0, finish);
117
+ }
118
+ let (cfg, cap_hint) = opts::parse_gen_opts(ruby, opts)?;
119
+ generate_scratched_into(ruby, obj, &cfg, cap_hint, finish)
120
+ }
121
+
105
122
  fn generate_scratched(
106
123
  ruby: &Ruby,
107
124
  obj: Value,
108
125
  cfg: &opts::GenConfig,
109
126
  cap_hint: usize,
110
127
  ) -> Result<RString, Error> {
128
+ generate_scratched_into(ruby, obj, cfg, cap_hint, finish_rstring)
129
+ }
130
+
131
+ /// The default finisher: the generated bytes as a Ruby String.
132
+ fn finish_rstring(_ruby: &Ruby, out: &[u8]) -> Result<RString, Error> {
133
+ Ok(unsafe {
134
+ RString::from_value(Value::from_raw(rb_sys::rb_utf8_str_new(
135
+ out.as_ptr().cast(),
136
+ out.len() as std::os::raw::c_long,
137
+ )))
138
+ .expect("rb_utf8_str_new returns a String")
139
+ })
140
+ }
141
+
142
+ fn generate_scratched_into<R>(
143
+ ruby: &Ruby,
144
+ obj: Value,
145
+ cfg: &opts::GenConfig,
146
+ cap_hint: usize,
147
+ finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
148
+ ) -> Result<R, Error> {
111
149
  GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
112
150
  Ok(mut scratch) => {
113
151
  let scratch = &mut *scratch;
@@ -118,24 +156,26 @@ fn generate_scratched(
118
156
  cap_hint,
119
157
  &mut scratch.buf,
120
158
  &mut scratch.keys,
159
+ finish,
121
160
  )
122
161
  }
123
162
  Err(_) => {
124
163
  let mut buf = Vec::new();
125
164
  let mut keys = GenKeyCache::default();
126
- generate_with(ruby, obj, cfg, cap_hint, &mut buf, &mut keys)
165
+ generate_with(ruby, obj, cfg, cap_hint, &mut buf, &mut keys, finish)
127
166
  }
128
167
  })
129
168
  }
130
169
 
131
- fn generate_with(
170
+ fn generate_with<R>(
132
171
  ruby: &Ruby,
133
172
  obj: Value,
134
173
  cfg: &opts::GenConfig,
135
174
  cap_hint: usize,
136
175
  out: &mut Vec<u8>,
137
176
  keys: &mut GenKeyCache,
138
- ) -> Result<RString, Error> {
177
+ finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
178
+ ) -> Result<R, Error> {
139
179
  out.clear();
140
180
  if out.capacity() < cap_hint {
141
181
  out.reserve(cap_hint);
@@ -155,13 +195,7 @@ fn generate_with(
155
195
 
156
196
  let Gen { out, fail, .. } = g;
157
197
  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
- }),
198
+ (Ok(()), None) => finish(ruby, out),
165
199
  (_, Some(fail)) => Err(raise_fail(ruby, fail)),
166
200
  (Err(()), None) => Err(Error::new(
167
201
  ruby.exception_runtime_error(),