mini_racer_rs 1.0.2 → 1.0.3

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: 1b53466394528b78785dc0cac7e884a5b59f0200ca037d405c95375152a12d72
4
- data.tar.gz: 06b9b34066ec568f0a41186f5f7c533c3a17f7acb90a625ec8e9fff22b3069f5
3
+ metadata.gz: dfd3987a3aebfd95013e67e552b2041b991fd7b0da4933f98ae92335f7caa3a4
4
+ data.tar.gz: 51ccdf38b39ab3f33f771f3cf7fdbd7422bda16037e8bc026cfd64dc6fdfbdae
5
5
  SHA512:
6
- metadata.gz: 7e9f4ee092b37374b56a701831bd4ba868af74de863c220a9f58aae583505cc3c4000106107cfe8c8e709552863d5b484b8715cd016306ecb06241f2d84fe759
7
- data.tar.gz: d5dc38c171b8d11683d53840d6c671cbf90ae042dba02f1a46e9786f343e555356808e41e2fb460b00a440c326a2da23f737b1149ab96d359b4e2269981c8b36
6
+ metadata.gz: ca5321d48ea8913f86d8966b6f475857d11b962c1f109e15f049fed590a517233f2cc3772fad99a9ff189f110995d3b2a0268f54e9f90aa48fcdd3cc1ac2b916
7
+ data.tar.gz: 3c2a6c804148cfd0bdf198d5a63ef4b440d63c667d7468ab646e8b85b277eb15ec0fa26f5d9877f426c01427e8f841723cf31d00bec0ee546881c895d373c61b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.3](https://github.com/andymai/mini_racer_rs/compare/v1.0.2...v1.0.3) (2026-10-18)
4
+
5
+
6
+ ### Performance
7
+
8
+ * **decode:** intern hash keys, build each Hash at once, and transcode strings in one pass ([#87](https://github.com/andymai/mini_racer_rs/issues/87)) ([220bfe7](https://github.com/andymai/mini_racer_rs/commit/220bfe7e6a9c8fa5a38befefcb4995756c377944))
9
+
3
10
  ## [1.0.2](https://github.com/andymai/mini_racer_rs/compare/v1.0.1...v1.0.2) (2026-10-18)
4
11
 
5
12
 
data/Cargo.lock CHANGED
@@ -458,7 +458,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
458
458
 
459
459
  [[package]]
460
460
  name = "mini_racer_engine"
461
- version = "1.0.2"
461
+ version = "1.0.3"
462
462
  dependencies = [
463
463
  "deno_core_icudata",
464
464
  "libc",
@@ -470,7 +470,7 @@ dependencies = [
470
470
 
471
471
  [[package]]
472
472
  name = "mini_racer_rs"
473
- version = "1.0.2"
473
+ version = "1.0.3"
474
474
  dependencies = [
475
475
  "magnus",
476
476
  "mini_racer_engine",
@@ -481,7 +481,7 @@ dependencies = [
481
481
 
482
482
  [[package]]
483
483
  name = "mini_racer_sync"
484
- version = "1.0.2"
484
+ version = "1.0.3"
485
485
  dependencies = [
486
486
  "loom",
487
487
  "thiserror",
@@ -489,7 +489,7 @@ dependencies = [
489
489
 
490
490
  [[package]]
491
491
  name = "mini_racer_wire"
492
- version = "1.0.2"
492
+ version = "1.0.3"
493
493
  dependencies = [
494
494
  "proptest",
495
495
  "thiserror",
data/Cargo.toml CHANGED
@@ -14,7 +14,7 @@ license = "MIT"
14
14
  publish = false
15
15
  repository = "https://github.com/andymai/mini_racer_rs"
16
16
  rust-version = "1.91"
17
- version = "1.0.2"
17
+ version = "1.0.3"
18
18
 
19
19
  [workspace.dependencies]
20
20
  deno_core_icudata = "=0.78.0"
@@ -85,6 +85,12 @@ pub trait Visitor {
85
85
 
86
86
  fn end(&mut self, frame: Self::Frame) -> Result<Self::Value, Self::Error>;
87
87
 
88
+ /// A key that is a string, for an entry of `frame` other than an error's
89
+ /// fields, which would otherwise reach `scalar` as text.
90
+ fn key(&mut self, _frame: &Self::Frame, text: Text<'_>) -> Result<Self::Value, Self::Error> {
91
+ self.scalar(Scalar::Text(text))
92
+ }
93
+
88
94
  /// Remembers `value` as V8's object `id` for later back references. Ids
89
95
  /// arrive in increasing order, except that a container whose `begin`
90
96
  /// returned no value is registered when it ends.
@@ -650,11 +656,27 @@ impl<'a, 'v, V: Visitor> Decoder<'a, 'v, V> {
650
656
  }
651
657
 
652
658
  fn insert(&mut self, frame: &mut V::Frame, depth: u32) -> Result<(), DecodeError<V::Error>> {
653
- let key = self.value(Self::child(depth)?)?;
654
- let value = self.value(Self::child(depth)?)?;
659
+ let depth = Self::child(depth)?;
660
+ let key = self.entry_key(frame, depth)?;
661
+ let value = self.value(depth)?;
655
662
  Self::visit(self.visitor.insert(frame, key, value))
656
663
  }
657
664
 
665
+ // A key that is not a string is read again as a value: handing `value` its
666
+ // tag would split `value` in two, and the half with the containers takes
667
+ // them all inline. Out of line, so `insert` stays inside the container's
668
+ // frame; a deep result repeats every frame on the way down.
669
+ #[inline(never)]
670
+ fn entry_key(&mut self, frame: &V::Frame, depth: u32) -> Decoded<V> {
671
+ let start = self.reader;
672
+ let tag = self.tag()?;
673
+ if let Some(text) = self.text(tag)? {
674
+ return Self::visit(self.visitor.key(frame, text));
675
+ }
676
+ self.reader = start;
677
+ self.value(depth)
678
+ }
679
+
658
680
  // Reads keyed entries up to `end`, which may follow padding only if a key does.
659
681
  fn entries(
660
682
  &mut self,
@@ -1,68 +1,130 @@
1
- // Returned HTML is mostly ASCII, which is copied in spans; only blocks holding
2
- // another byte are converted a byte at a time.
1
+ // Returned HTML is mostly ASCII, which is copied in blocks; only blocks
2
+ // holding another byte are converted a byte at a time.
3
3
  const LATIN1_BLOCK: usize = 32;
4
- // Each block's count of non-ASCII bytes is summed in a u8.
5
- const _: () = assert!(LATIN1_BLOCK <= u8::MAX as usize);
4
+ // Lengths are counted a block at a time in a u8, which vectorizes; a UTF-16
5
+ // unit adds at most 3.
6
+ const COUNT_BLOCK: usize = 64;
7
+ const _: () = assert!(COUNT_BLOCK * 3 <= u8::MAX as usize);
6
8
 
7
- #[expect(clippy::expect_used, reason = "callers pass only spans of ASCII bytes")]
8
- fn ascii(bytes: &[u8]) -> &str {
9
- std::str::from_utf8(bytes).expect("ASCII is UTF-8")
9
+ /// The length in UTF-8 of Latin-1 `bytes`.
10
+ #[must_use]
11
+ pub fn latin1_utf8_len(bytes: &[u8]) -> usize {
12
+ let wide = bytes.chunks(COUNT_BLOCK).fold(0_usize, |wide, block| {
13
+ let count = block.iter().fold(0_u8, |count, &byte| {
14
+ count.wrapping_add(u8::from(!byte.is_ascii()))
15
+ });
16
+ wide.saturating_add(usize::from(count))
17
+ });
18
+ bytes.len().saturating_add(wide)
10
19
  }
11
20
 
12
- #[must_use]
13
- pub fn latin1_to_utf8(bytes: &[u8]) -> String {
14
- if bytes.len() < LATIN1_BLOCK {
15
- return bytes.iter().copied().map(char::from).collect();
16
- }
17
- let (wide, mixed) =
18
- bytes
19
- .chunks(LATIN1_BLOCK)
20
- .fold((0_usize, 0_usize), |(wide, mixed), block| {
21
- let count = block
22
- .iter()
23
- .map(|&byte| u8::from(!byte.is_ascii()))
24
- .sum::<u8>();
25
- (
26
- wide.saturating_add(usize::from(count)),
27
- mixed.saturating_add(usize::from(count > 0)),
28
- )
29
- });
30
- let mut out = String::with_capacity(bytes.len().saturating_add(wide));
31
- // Text where most blocks need converting anyway, like accented prose, is
32
- // faster a byte at a time throughout.
33
- if mixed.saturating_mul(LATIN1_BLOCK).saturating_mul(2) > bytes.len() {
34
- out.extend(bytes.iter().copied().map(char::from));
35
- return out;
36
- }
37
- let mut span = 0;
21
+ // Writes `c` at `at` and returns where it ends, or `None` when it does not fit.
22
+ fn put(out: &mut [u8], at: usize, c: char) -> Option<usize> {
23
+ let end = at.checked_add(c.len_utf8())?;
24
+ c.encode_utf8(out.get_mut(at..end)?);
25
+ Some(end)
26
+ }
27
+
28
+ /// Writes the UTF-8 of Latin-1 `bytes` at the start of `out`, up to the last
29
+ /// character that fits, and returns how many bytes it wrote: all
30
+ /// `latin1_utf8_len(bytes)` of them when `out` is that long.
31
+ pub fn latin1_to_utf8_into(bytes: &[u8], out: &mut [u8]) -> usize {
38
32
  let mut at: usize = 0;
39
33
  for block in bytes.chunks(LATIN1_BLOCK) {
40
- let next = at.saturating_add(block.len());
41
- if !block.is_ascii() {
42
- if span < at {
43
- out.push_str(ascii(bytes.get(span..at).unwrap_or_default()));
44
- }
45
- for &byte in block {
46
- out.push(char::from(byte));
34
+ let end = at.saturating_add(block.len());
35
+ if block.is_ascii()
36
+ && let Some(slots) = out.get_mut(at..end)
37
+ {
38
+ slots.copy_from_slice(block);
39
+ at = end;
40
+ continue;
41
+ }
42
+ for &byte in block {
43
+ match put(out, at, char::from(byte)) {
44
+ Some(next) => at = next,
45
+ None => return at,
47
46
  }
48
- span = next;
49
47
  }
50
- at = next;
51
48
  }
52
- out.push_str(ascii(bytes.get(span..).unwrap_or_default()));
53
- out
49
+ at
54
50
  }
55
51
 
56
52
  #[must_use]
57
- pub fn utf16le_to_utf8(bytes: &[u8]) -> Option<String> {
53
+ #[expect(clippy::expect_used, reason = "only whole characters are written")]
54
+ pub fn latin1_to_utf8(bytes: &[u8]) -> String {
55
+ let mut out = vec![0; latin1_utf8_len(bytes)];
56
+ let written = latin1_to_utf8_into(bytes, &mut out);
57
+ out.truncate(written);
58
+ String::from_utf8(out).expect("Latin-1 transcodes to UTF-8")
59
+ }
60
+
61
+ fn utf16le_chars(bytes: &[u8]) -> Option<impl Iterator<Item = Result<char, ()>>> {
62
+ let (pairs, rest) = bytes.as_chunks::<2>();
63
+ let units = pairs.iter().copied().map(u16::from_le_bytes);
64
+ rest.is_empty()
65
+ .then(|| char::decode_utf16(units).map(|c| c.map_err(|_| ())))
66
+ }
67
+
68
+ /// The length in UTF-8 of UTF-16LE `bytes`, or `None` when they hold an odd
69
+ /// byte or a lone surrogate.
70
+ #[must_use]
71
+ pub fn utf16le_utf8_len(bytes: &[u8]) -> Option<usize> {
58
72
  let (pairs, rest) = bytes.as_chunks::<2>();
59
73
  if !rest.is_empty() {
60
74
  return None;
61
75
  }
62
- let units = pairs.iter().copied().map(u16::from_le_bytes);
63
- char::decode_utf16(units)
64
- .collect::<Result<String, _>>()
65
- .ok()
76
+ // A unit takes one byte, one more from U+0080 and one more from U+0800,
77
+ // which counts the two units of a surrogate pair as six bytes for its four.
78
+ let mut len: usize = 0;
79
+ let mut surrogates = false;
80
+ for block in pairs.chunks(COUNT_BLOCK) {
81
+ let mut count: u8 = 0;
82
+ for &pair in block {
83
+ let unit = u16::from_le_bytes(pair);
84
+ count = count
85
+ .wrapping_add(1)
86
+ .wrapping_add(u8::from(unit >= 0x80))
87
+ .wrapping_add(u8::from(unit >= 0x800));
88
+ surrogates |= (0xD800..0xE000).contains(&unit);
89
+ }
90
+ len = len.saturating_add(usize::from(count));
91
+ }
92
+ if !surrogates {
93
+ return Some(len);
94
+ }
95
+ let mut surrogate_pairs: usize = 0;
96
+ let mut units = pairs.iter().copied().map(u16::from_le_bytes);
97
+ while let Some(unit) = units.next() {
98
+ if (0xD800..0xDC00).contains(&unit) {
99
+ units.next().filter(|low| (0xDC00..0xE000).contains(low))?;
100
+ surrogate_pairs = surrogate_pairs.saturating_add(1);
101
+ } else if (0xDC00..0xE000).contains(&unit) {
102
+ return None;
103
+ }
104
+ }
105
+ Some(len.saturating_sub(surrogate_pairs.saturating_mul(2)))
106
+ }
107
+
108
+ /// Writes the UTF-8 of UTF-16LE `bytes` at the start of `out` and returns how
109
+ /// many bytes it wrote.
110
+ ///
111
+ /// `None` when they hold an odd byte or a lone surrogate, or when `out` lacks
112
+ /// room, which three bytes per unit never does.
113
+ pub fn utf16le_to_utf8_into(bytes: &[u8], out: &mut [u8]) -> Option<usize> {
114
+ let mut at = 0;
115
+ for c in utf16le_chars(bytes)? {
116
+ at = put(out, at, c.ok()?)?;
117
+ }
118
+ Some(at)
119
+ }
120
+
121
+ #[must_use]
122
+ #[expect(clippy::expect_used, reason = "only whole characters are written")]
123
+ pub fn utf16le_to_utf8(bytes: &[u8]) -> Option<String> {
124
+ let mut out = vec![0; utf16le_utf8_len(bytes)?];
125
+ let written = utf16le_to_utf8_into(bytes, &mut out)?;
126
+ out.truncate(written);
127
+ Some(String::from_utf8(out).expect("UTF-16 transcodes to UTF-8"))
66
128
  }
67
129
 
68
130
  #[cfg(test)]
@@ -83,8 +145,38 @@ mod tests {
83
145
  utf16le_to_utf8(&[0x3D, 0xD8, 0x00, 0xDE]).as_deref(),
84
146
  Some("😀")
85
147
  );
148
+ assert_eq!(
149
+ utf16le_utf8_len(&[0x3D, 0xD8, 0x00, 0xDE, 0x41, 0x00]),
150
+ Some(5)
151
+ );
86
152
  assert_eq!(utf16le_to_utf8(&[0x41]), None);
153
+ assert_eq!(utf16le_utf8_len(&[0x41]), None);
87
154
  assert_eq!(utf16le_to_utf8(&[0x3D, 0xD8]), None);
155
+ assert_eq!(utf16le_utf8_len(&[0x41, 0x00, 0x00, 0xDC]), None);
156
+ }
157
+
158
+ #[test]
159
+ fn writers_stop_where_text_does_not_decode_or_fit() {
160
+ let mut out = [0; 8];
161
+ assert_eq!(
162
+ utf16le_to_utf8_into(&[0x41, 0x00, 0x00, 0xDC], &mut out),
163
+ None
164
+ );
165
+ assert_eq!(utf16le_to_utf8_into(&[0x41, 0x00, 0x42], &mut out), None);
166
+ assert_eq!(
167
+ utf16le_to_utf8_into(&[0x3D, 0xD8, 0x00, 0xDE], out.get_mut(..3).unwrap()),
168
+ None
169
+ );
170
+ assert_eq!(
171
+ utf16le_to_utf8_into(&[0x3D, 0xD8, 0x00, 0xDE], &mut out),
172
+ Some(4)
173
+ );
174
+ let written = latin1_to_utf8_into(b"ab\xE9c", out.get_mut(..3).unwrap());
175
+ assert_eq!(out.get(..written), Some(b"ab".as_slice()));
176
+ }
177
+
178
+ fn utf16le(units: &[u16]) -> Vec<u8> {
179
+ units.iter().flat_map(|unit| unit.to_le_bytes()).collect()
88
180
  }
89
181
 
90
182
  proptest! {
@@ -100,7 +192,26 @@ mod tests {
100
192
  )) {
101
193
  let bytes: Vec<u8> = segments.into_iter().flat_map(|(run, wide)| run.into_iter().chain(wide)).collect();
102
194
  let by_byte: String = bytes.iter().copied().map(char::from).collect();
195
+ prop_assert_eq!(latin1_utf8_len(&bytes), by_byte.len());
196
+ let mut out = vec![0; bytes.len().saturating_mul(2)];
197
+ let written = latin1_to_utf8_into(&bytes, &mut out);
198
+ prop_assert_eq!(out.get(..written), Some(by_byte.as_bytes()));
103
199
  prop_assert_eq!(latin1_to_utf8(&bytes), by_byte);
104
200
  }
201
+
202
+ // Units from each range UTF-8 sizes differently, surrogates included,
203
+ // so pairs, lone halves and pairs in the wrong order all come up.
204
+ #[test]
205
+ fn utf16_matches_std(units in proptest::collection::vec(
206
+ prop_oneof![0u16..0x80, 0x80u16..0x800, 0x800u16..0xD800, 0xD800u16..0xE000, 0xE000u16..=0xFFFF],
207
+ 0..64,
208
+ )) {
209
+ let expected = String::from_utf16(&units).ok();
210
+ prop_assert_eq!(utf16le_utf8_len(&utf16le(&units)), expected.as_ref().map(String::len));
211
+ let mut out = vec![0; units.len().saturating_mul(3)];
212
+ let written = utf16le_to_utf8_into(&utf16le(&units), &mut out);
213
+ prop_assert_eq!(written.and_then(|n| out.get(..n)), expected.as_ref().map(String::as_bytes));
214
+ prop_assert_eq!(utf16le_to_utf8(&utf16le(&units)), expected);
215
+ }
105
216
  }
106
217
  }
@@ -1,3 +1,7 @@
1
+ use std::cmp::Ordering;
2
+ use std::ops::Range;
3
+
4
+ use magnus::typed_data::Obj;
1
5
  use magnus::value::ReprValue;
2
6
  use magnus::{
3
7
  Class, Error, Fixnum, IntoValue, Module, Object, RArray, RClass, RHash, RModule, RObject,
@@ -7,7 +11,7 @@ use mini_racer_wire::{
7
11
  Begun, Container, DecodeError, Limits, Scalar, Text, Visitor, decode_value, marker, text,
8
12
  };
9
13
 
10
- use crate::gc_root::StaticRoot;
14
+ use crate::gc_root::{Spill, StaticRoot};
11
15
  use crate::{errors, rb_raw};
12
16
 
13
17
  #[expect(
@@ -69,23 +73,194 @@ struct ErrorParts {
69
73
 
70
74
  const BATCH: usize = 64;
71
75
 
72
- #[derive(Clone, Copy)]
73
- enum Target {
74
- Array(RArray),
75
- Hash(RHash),
76
- }
77
-
78
- // Decoded elements and object pairs wait here so a batch of them costs one
79
- // rb_protect rather than one per value. Every container's begin and end
80
- // empties it, so only the innermost open container ever has values waiting,
81
- // and whatever a child does (a Map hashing an ancestor as its key) sees each
82
- // container with every value decoded so far, as mini_racer builds them.
76
+ // Decoded elements wait here so a batch of them costs one rb_protect rather
77
+ // than one per value. Every container's begin and end empties it, so only the
78
+ // innermost open array ever has elements waiting, and whatever a child does
79
+ // (a Map hashing an ancestor as its key) sees each array with every element
80
+ // decoded so far, as mini_racer builds them.
83
81
  struct Pending {
84
- target: Option<Target>,
82
+ target: Option<RArray>,
85
83
  values: [Value; BATCH],
86
84
  filled: usize,
87
85
  }
88
86
 
87
+ const PAIRS: usize = 128;
88
+
89
+ const SCRATCH: usize = 64 << 10;
90
+
91
+ // Every open hash's pairs wait here, innermost last, until the hash ends and
92
+ // takes them in one rb_hash_bulk_insert while it is still empty, so Ruby sizes
93
+ // its table once, as json 3.1's parser builds objects. A hash's run starts
94
+ // with the hash and the start of the run around it. Past PAIRS values the
95
+ // runs move to a Spill, whose mark pins them.
96
+ struct Pairs {
97
+ values: [Value; PAIRS],
98
+ spill: Option<Obj<Spill>>,
99
+ len: usize,
100
+ innermost: Option<usize>,
101
+ // Every pair below this is in its hash already.
102
+ inserted: usize,
103
+ }
104
+
105
+ impl Pairs {
106
+ // Out of line, as KeyCache::start is, so the values made here never take
107
+ // room in the decoder's recursive frames, which a deep result repeats.
108
+ #[inline(never)]
109
+ fn start<'p>(slot: &'p mut Option<Self>, ruby: &Ruby) -> &'p mut Self {
110
+ slot.insert(Self {
111
+ values: [ruby.qnil().as_value(); PAIRS],
112
+ spill: None,
113
+ len: 0,
114
+ innermost: None,
115
+ inserted: 0,
116
+ })
117
+ }
118
+
119
+ fn push(&mut self, ruby: &Ruby, value: Value) {
120
+ if self.spill.is_none() {
121
+ if let Some(slot) = self.values.get_mut(self.len) {
122
+ *slot = value;
123
+ self.len = self.len.saturating_add(1);
124
+ return;
125
+ }
126
+ self.spill = Some(Spill::new(ruby, &self.values));
127
+ }
128
+ if let Some(spill) = &self.spill {
129
+ spill.push(value);
130
+ }
131
+ self.len = self.len.saturating_add(1);
132
+ }
133
+
134
+ fn get(&self, ruby: &Ruby, at: usize) -> Option<Value> {
135
+ self.spill
136
+ .as_ref()
137
+ .map_or_else(|| self.values.get(at).copied(), |spill| spill.get(ruby, at))
138
+ }
139
+
140
+ fn open(&mut self, ruby: &Ruby, hash: RHash) {
141
+ let outer = self
142
+ .innermost
143
+ .and_then(|at| i64::try_from(at).ok())
144
+ .unwrap_or(-1);
145
+ self.innermost = Some(self.len);
146
+ self.push(ruby, hash.as_value());
147
+ self.push(ruby, ruby.integer_from_i64(outer).as_value());
148
+ }
149
+
150
+ fn outer(&self, ruby: &Ruby, at: usize) -> Option<usize> {
151
+ self.get(ruby, at.saturating_add(1))
152
+ .and_then(Fixnum::from_value)
153
+ .and_then(|outer| usize::try_from(outer.to_i64()).ok())
154
+ }
155
+
156
+ // An empty table takes the pairs at once; one that holds pairs already
157
+ // takes them one by one, since rb_hash_bulk_insert would rehash it whole.
158
+ fn put(&self, ruby: &Ruby, hash: RHash, range: Range<usize>) -> Result<(), Error> {
159
+ if range.is_empty() {
160
+ return Ok(());
161
+ }
162
+ let put = |pairs: &[Value]| {
163
+ if hash.is_empty() {
164
+ hash.bulk_insert(pairs)
165
+ } else {
166
+ rb_raw::hash_aset_pairs(ruby, hash, pairs.as_chunks::<2>().0)
167
+ }
168
+ };
169
+ match &self.spill {
170
+ Some(spill) => spill.with_values(ruby, range, put),
171
+ None => put(self.values.get(range).unwrap_or_default()),
172
+ }
173
+ }
174
+
175
+ fn close(&mut self, ruby: &Ruby, hash: RHash) -> Result<(), Error> {
176
+ let Some(at) = self.innermost else {
177
+ return Err(errors::internal(ruby, "decode pairs without a hash"));
178
+ };
179
+ self.put(
180
+ ruby,
181
+ hash,
182
+ at.saturating_add(2).max(self.inserted)..self.len,
183
+ )?;
184
+ self.innermost = self.outer(ruby, at);
185
+ self.len = at;
186
+ self.inserted = self.inserted.min(at);
187
+ if let Some(spill) = &self.spill {
188
+ spill.truncate(at);
189
+ }
190
+ Ok(())
191
+ }
192
+
193
+ fn put_all(&mut self, ruby: &Ruby) -> Result<(), Error> {
194
+ let mut end = self.len;
195
+ let mut run = self.innermost;
196
+ while let Some(at) = run
197
+ && end > self.inserted
198
+ {
199
+ let hash = self
200
+ .get(ruby, at)
201
+ .and_then(RHash::from_value)
202
+ .ok_or_else(|| errors::internal(ruby, "decode pairs without a hash"))?;
203
+ self.put(ruby, hash, at.saturating_add(2).max(self.inserted)..end)?;
204
+ end = at;
205
+ run = self.outer(ruby, at);
206
+ }
207
+ self.inserted = self.len;
208
+ Ok(())
209
+ }
210
+ }
211
+
212
+ // json 3.1's parser keeps the names of objects inside arrays, which repeat,
213
+ // for the rest of a parse: at most this many, each at most CACHED_KEY_BYTES
214
+ // long and starting with a letter.
215
+ const CACHED_KEYS: usize = 63;
216
+ const CACHED_KEY_BYTES: usize = 55;
217
+
218
+ // Interned keys sorted by length, then bytes, so a repeated key skips Ruby's
219
+ // fstring table.
220
+ struct KeyCache {
221
+ keys: [Value; CACHED_KEYS],
222
+ len: usize,
223
+ }
224
+
225
+ impl KeyCache {
226
+ #[inline(never)]
227
+ fn start<'c>(slot: &'c mut Option<Self>, ruby: &Ruby) -> &'c mut Self {
228
+ slot.insert(Self {
229
+ keys: [ruby.qnil().as_value(); CACHED_KEYS],
230
+ len: 0,
231
+ })
232
+ }
233
+
234
+ fn fetch(&mut self, ruby: &Ruby, bytes: &[u8]) -> Result<Value, Error> {
235
+ let held = self.keys.get(..self.len).unwrap_or_default();
236
+ let found = held.binary_search_by(|&key| {
237
+ RString::from_value(key).map_or(Ordering::Less, |key| {
238
+ rb_raw::with_string_bytes(ruby, key, |key| {
239
+ key.len().cmp(&bytes.len()).then_with(|| key.cmp(bytes))
240
+ })
241
+ })
242
+ });
243
+ let at = match found {
244
+ Ok(at) => {
245
+ return held
246
+ .get(at)
247
+ .copied()
248
+ .ok_or_else(|| errors::internal(ruby, "key cache"));
249
+ }
250
+ Err(at) => at,
251
+ };
252
+ let key = rb_raw::interned_utf8(ruby, bytes)?.as_value();
253
+ if self.len < CACHED_KEYS {
254
+ self.keys.copy_within(at..self.len, at.saturating_add(1));
255
+ if let Some(slot) = self.keys.get_mut(at) {
256
+ *slot = key;
257
+ }
258
+ self.len = self.len.saturating_add(1);
259
+ }
260
+ Ok(key)
261
+ }
262
+ }
263
+
89
264
  // The visitor and its frames live on the native stack for the whole decode,
90
265
  // where Ruby's GC finds them (I3); `refs` holds every value V8 gave an id,
91
266
  // made at the first, so a result without one allocates none.
@@ -93,10 +268,15 @@ struct RubyVisitor<'r> {
93
268
  ruby: &'r Ruby,
94
269
  refs: Option<RArray>,
95
270
  pending: Pending,
271
+ pairs: Option<Pairs>,
272
+ keys: Option<KeyCache>,
273
+ // Arrays and sets open around the current value.
274
+ arrays: u32,
275
+ scratch: Vec<u8>,
96
276
  }
97
277
 
98
278
  impl RubyVisitor<'_> {
99
- fn add(&mut self, target: Target, values: &[Value]) -> Result<(), Error> {
279
+ fn add(&mut self, target: RArray, values: &[Value]) -> Result<(), Error> {
100
280
  self.add_with(target, values.len(), |slots| slots.copy_from_slice(values))
101
281
  }
102
282
 
@@ -104,7 +284,7 @@ impl RubyVisitor<'_> {
104
284
  // sees each one as soon as it is made.
105
285
  fn add_with(
106
286
  &mut self,
107
- target: Target,
287
+ target: RArray,
108
288
  count: usize,
109
289
  fill: impl FnOnce(&mut [Value]),
110
290
  ) -> Result<(), Error> {
@@ -136,7 +316,7 @@ impl RubyVisitor<'_> {
136
316
  ) -> Result<(), Error> {
137
317
  let ruby = self.ruby;
138
318
  for chunk in values.chunks(BATCH) {
139
- self.add_with(Target::Array(array), chunk.len(), |slots| {
319
+ self.add_with(array, chunk.len(), |slots| {
140
320
  for (slot, &n) in slots.iter_mut().zip(chunk) {
141
321
  *slot = convert(ruby, n);
142
322
  }
@@ -164,32 +344,103 @@ impl RubyVisitor<'_> {
164
344
  let values = self.pending.values.get(..filled).unwrap_or_default();
165
345
  match self.pending.target {
166
346
  _ if filled == 0 => Ok(()),
167
- Some(Target::Array(array)) => array.cat(values),
168
- Some(Target::Hash(hash)) => {
169
- rb_raw::hash_aset_pairs(self.ruby, hash, values.as_chunks::<2>().0)
170
- }
347
+ Some(array) => array.cat(values),
171
348
  None => Err(errors::internal(self.ruby, "decode batch without a target")),
172
349
  }
173
350
  }
174
351
 
352
+ fn pairs(&mut self) -> Result<&mut Pairs, Error> {
353
+ let ruby = self.ruby;
354
+ self.pairs
355
+ .as_mut()
356
+ .ok_or_else(|| errors::internal(ruby, "decode pairs without a hash"))
357
+ }
358
+
359
+ fn open_hash(&mut self) -> RHash {
360
+ let ruby = self.ruby;
361
+ let hash = ruby.hash_new();
362
+ match &mut self.pairs {
363
+ Some(pairs) => pairs.open(ruby, hash),
364
+ None => Pairs::start(&mut self.pairs, ruby).open(ruby, hash),
365
+ }
366
+ hash
367
+ }
368
+
369
+ // A key whose hashing Ruby code could run for, or that holds other
370
+ // values, such as an Array that refers back to an open hash, first sees
371
+ // every open hash with all its pairs so far, as mini_racer stores each
372
+ // pair as it decodes it.
373
+ fn put_pair(&mut self, hash: RHash, key: Value, value: Value) -> Result<(), Error> {
374
+ let ruby = self.ruby;
375
+ let pairs = self.pairs()?;
376
+ if rb_raw::plain_key(ruby, key) {
377
+ pairs.push(ruby, key);
378
+ pairs.push(ruby, value);
379
+ return Ok(());
380
+ }
381
+ pairs.put_all(ruby)?;
382
+ rb_raw::hash_aset_pairs(ruby, hash, &[[key, value]])
383
+ }
384
+
175
385
  fn bad_state<T>(&self) -> Result<T, Error> {
176
386
  Err(errors::runtime(self.ruby, "bad state"))
177
387
  }
178
388
 
179
- fn string(&self, text: Text<'_>) -> Result<Value, Error> {
389
+ // Text is transcoded into one buffer, then copied into its String in one
390
+ // call: making a String to write into costs more Ruby calls than the copy
391
+ // saves. Up to SCRATCH bytes it is the decode's own, with room for the
392
+ // longest UTF-8 the text could be and zeroed only as it grows; longer
393
+ // text is measured first and gets a buffer its size, freed once copied,
394
+ // since the longest it could be is up to three times what it takes.
395
+ fn transcoded(
396
+ &mut self,
397
+ most: usize,
398
+ len: impl FnOnce() -> Option<usize>,
399
+ write: impl FnOnce(&mut [u8]) -> Option<usize>,
400
+ ) -> Option<Value> {
401
+ let ruby = self.ruby;
402
+ let mut measured;
403
+ let out = if most <= SCRATCH {
404
+ if self.scratch.len() < most {
405
+ self.scratch.resize(most, 0);
406
+ }
407
+ self.scratch.get_mut(..most).unwrap_or_default()
408
+ } else {
409
+ measured = vec![0; len()?];
410
+ measured.as_mut_slice()
411
+ };
412
+ let written = write(out)?;
413
+ Some(
414
+ ruby.enc_str_new(out.get(..written).unwrap_or_default(), ruby.utf8_encoding())
415
+ .as_value(),
416
+ )
417
+ }
418
+
419
+ fn string(&mut self, text: Text<'_>) -> Result<Value, Error> {
180
420
  let ruby = self.ruby;
181
421
  Ok(match text {
182
422
  Text::Utf8(bytes) => ruby.enc_str_new(bytes, ruby.utf8_encoding()).as_value(),
183
423
  Text::Latin1(bytes) if bytes.is_ascii() => {
184
424
  ruby.enc_str_new(bytes, ruby.utf8_encoding()).as_value()
185
425
  }
186
- Text::Latin1(bytes) => ruby.str_new(&text::latin1_to_utf8(bytes)).as_value(),
426
+ Text::Latin1(bytes) => self
427
+ .transcoded(
428
+ bytes.len().saturating_mul(2),
429
+ || Some(text::latin1_utf8_len(bytes)),
430
+ |out| Some(text::latin1_to_utf8_into(bytes, out)),
431
+ )
432
+ .ok_or_else(|| errors::internal(ruby, "Latin-1 text not transcoded"))?,
187
433
  Text::Utf16Le(bytes) if marker::is_javascript_function(bytes) => {
188
434
  JAVASCRIPT_FUNCTION.get(ruby).new_instance(())?
189
435
  }
190
436
  Text::Utf16Le(bytes) => {
191
- if let Some(utf8) = text::utf16le_to_utf8(bytes) {
192
- ruby.str_new(&utf8).as_value()
437
+ let most = bytes.len().saturating_mul(3).div_ceil(2);
438
+ if let Some(utf8) = self.transcoded(
439
+ most,
440
+ || text::utf16le_utf8_len(bytes),
441
+ |out| text::utf16le_to_utf8_into(bytes, out),
442
+ ) {
443
+ utf8
193
444
  } else {
194
445
  let utf16 = ruby
195
446
  .find_encoding("UTF-16LE")
@@ -200,6 +451,39 @@ impl RubyVisitor<'_> {
200
451
  })
201
452
  }
202
453
 
454
+ // The key `Hash#[]=` would store, made interned in the first place. One
455
+ // with a lone surrogate stays UTF-16LE, which the hash interns itself,
456
+ // and the function marker stays a JavaScriptFunction.
457
+ fn interned(&mut self, text: Text<'_>) -> Result<Value, Error> {
458
+ let ruby = self.ruby;
459
+ match text {
460
+ Text::Utf8(bytes) => self.cached(bytes),
461
+ Text::Latin1(bytes) if bytes.is_ascii() => self.cached(bytes),
462
+ Text::Latin1(bytes) => {
463
+ Ok(rb_raw::interned_utf8(ruby, text::latin1_to_utf8(bytes).as_bytes())?.as_value())
464
+ }
465
+ Text::Utf16Le(bytes) if marker::is_javascript_function(bytes) => self.string(text),
466
+ Text::Utf16Le(bytes) => match text::utf16le_to_utf8(bytes) {
467
+ Some(utf8) => Ok(rb_raw::interned_utf8(ruby, utf8.as_bytes())?.as_value()),
468
+ None => self.string(text),
469
+ },
470
+ }
471
+ }
472
+
473
+ fn cached(&mut self, bytes: &[u8]) -> Result<Value, Error> {
474
+ let ruby = self.ruby;
475
+ if self.arrays == 0
476
+ || bytes.len() > CACHED_KEY_BYTES
477
+ || !bytes.first().is_some_and(u8::is_ascii_alphabetic)
478
+ {
479
+ return Ok(rb_raw::interned_utf8(ruby, bytes)?.as_value());
480
+ }
481
+ match &mut self.keys {
482
+ Some(keys) => keys.fetch(ruby, bytes),
483
+ None => KeyCache::start(&mut self.keys, ruby).fetch(ruby, bytes),
484
+ }
485
+ }
486
+
203
487
  // mini_racer splits a millisecond time into seconds and microseconds,
204
488
  // truncating each, and lets Time normalize a negative remainder.
205
489
  #[expect(
@@ -286,8 +570,8 @@ impl Visitor for RubyVisitor<'_> {
286
570
  array: ruby.ary_new(),
287
571
  named: false,
288
572
  },
289
- Container::Object => Frame::Object(ruby.hash_new()),
290
- Container::Map => Frame::Map(ruby.hash_new()),
573
+ Container::Object => Frame::Object(self.open_hash()),
574
+ Container::Map => Frame::Map(self.open_hash()),
291
575
  // Allocated now so a cause can refer back to the error itself.
292
576
  Container::Error => Frame::Error(ErrorParts {
293
577
  error: errors::script_error(ruby).obj_alloc()?.as_value(),
@@ -297,7 +581,10 @@ impl Visitor for RubyVisitor<'_> {
297
581
  }),
298
582
  };
299
583
  let value = match &frame {
300
- Frame::Array(array) | Frame::Sparse { array, .. } => array.as_value(),
584
+ Frame::Array(array) | Frame::Sparse { array, .. } => {
585
+ self.arrays = self.arrays.saturating_add(1);
586
+ array.as_value()
587
+ }
301
588
  Frame::Object(hash) | Frame::Map(hash) => hash.as_value(),
302
589
  Frame::Error(parts) => parts.error,
303
590
  };
@@ -309,9 +596,7 @@ impl Visitor for RubyVisitor<'_> {
309
596
 
310
597
  fn push(&mut self, frame: &mut Frame, value: Value) -> Result<(), Error> {
311
598
  match frame {
312
- Frame::Array(array) | Frame::Sparse { array, .. } => {
313
- self.add(Target::Array(*array), &[value])
314
- }
599
+ Frame::Array(array) | Frame::Sparse { array, .. } => self.add(*array, &[value]),
315
600
  Frame::Object(_) | Frame::Map(_) | Frame::Error(_) => self.bad_state(),
316
601
  }
317
602
  }
@@ -321,7 +606,7 @@ impl Visitor for RubyVisitor<'_> {
321
606
  Frame::Array(_) => Ok(()),
322
607
  Frame::Sparse { array, named } => {
323
608
  if !*named && is_index(key) {
324
- self.add(Target::Array(*array), &[value])
609
+ self.add(*array, &[value])
325
610
  } else {
326
611
  *named = true;
327
612
  Ok(())
@@ -332,9 +617,9 @@ impl Visitor for RubyVisitor<'_> {
332
617
  Some(key) => key.as_value(),
333
618
  None => key.funcall::<_, _, Value>("to_s", ())?,
334
619
  };
335
- self.add(Target::Hash(*hash), &[key, value])
620
+ self.put_pair(*hash, key, value)
336
621
  }
337
- Frame::Map(hash) => hash.aset(key, value),
622
+ Frame::Map(hash) => self.put_pair(*hash, key, value),
338
623
  Frame::Error(parts) => {
339
624
  let field = RString::from_value(key)
340
625
  .map(|key| rb_raw::string_bytes(self.ruby, key))
@@ -353,12 +638,26 @@ impl Visitor for RubyVisitor<'_> {
353
638
  fn end(&mut self, frame: Frame) -> Result<Value, Error> {
354
639
  self.flush()?;
355
640
  match frame {
356
- Frame::Array(array) | Frame::Sparse { array, .. } => Ok(array.as_value()),
357
- Frame::Object(hash) | Frame::Map(hash) => Ok(hash.as_value()),
641
+ Frame::Array(array) | Frame::Sparse { array, .. } => {
642
+ self.arrays = self.arrays.saturating_sub(1);
643
+ Ok(array.as_value())
644
+ }
645
+ Frame::Object(hash) | Frame::Map(hash) => {
646
+ let ruby = self.ruby;
647
+ self.pairs()?.close(ruby, hash)?;
648
+ Ok(hash.as_value())
649
+ }
358
650
  Frame::Error(parts) => self.script_error(parts),
359
651
  }
360
652
  }
361
653
 
654
+ fn key(&mut self, frame: &Frame, text: Text<'_>) -> Result<Value, Error> {
655
+ match frame {
656
+ Frame::Object(_) | Frame::Map(_) => self.interned(text),
657
+ Frame::Array(_) | Frame::Sparse { .. } | Frame::Error(_) => self.string(text),
658
+ }
659
+ }
660
+
362
661
  fn register(&mut self, id: u32, value: Value) -> Result<(), Error> {
363
662
  let ruby = self.ruby;
364
663
  self.refs
@@ -429,6 +728,10 @@ pub fn value(ruby: &Ruby, bytes: &[u8]) -> Result<Value, Error> {
429
728
  values: [ruby.qnil().as_value(); BATCH],
430
729
  filled: 0,
431
730
  },
731
+ pairs: None,
732
+ keys: None,
733
+ arrays: 0,
734
+ scratch: Vec::new(),
432
735
  };
433
736
  decode_value(bytes, RESULT_LIMITS, &mut visitor).map_err(|e| match e {
434
737
  DecodeError::Visitor(error) => error,
@@ -1,4 +1,5 @@
1
1
  use std::cell::{Cell, RefCell};
2
+ use std::ops::Range;
2
3
  use std::sync::OnceLock;
3
4
 
4
5
  use magnus::gc::Marker;
@@ -110,8 +111,72 @@ impl Pins {
110
111
  }
111
112
  }
112
113
 
114
+ /// Ruby values a decode keeps in order past what fits on the native stack.
115
+ /// The holder's mark pins every value. Only touched under the GVL; a borrow
116
+ /// that spans a Ruby call is a shared one, which the mark shares, and no
117
+ /// mutable borrow does.
118
+ #[derive(Default, TypedData)]
119
+ #[magnus(class = "MiniRacerRs::Spill", free_immediately, mark)]
120
+ pub struct Spill(RefCell<Vec<Opaque<Value>>>);
121
+
122
+ impl DataTypeFunctions for Spill {
123
+ // As for Pins, a mutable borrow at mark time would leave values unmarked.
124
+ fn mark(&self, marker: &Marker) {
125
+ let Ok(values) = self.0.try_borrow() else {
126
+ std::process::abort();
127
+ };
128
+ for value in values.iter() {
129
+ marker.mark(*value);
130
+ }
131
+ }
132
+ }
133
+
134
+ impl Spill {
135
+ /// Holds `values` first. Keep using the returned `Obj` while the values
136
+ /// are needed; a reference into its data alone does not keep the holder
137
+ /// alive.
138
+ pub fn new(ruby: &Ruby, values: &[Value]) -> Obj<Self> {
139
+ let held = values.iter().copied().map(Opaque::from).collect();
140
+ ruby.obj_wrap(Self(RefCell::new(held)))
141
+ }
142
+
143
+ pub fn push(&self, value: Value) {
144
+ self.0.borrow_mut().push(Opaque::from(value));
145
+ }
146
+
147
+ pub fn truncate(&self, len: usize) {
148
+ self.0.borrow_mut().truncate(len);
149
+ }
150
+
151
+ pub fn get(&self, ruby: &Ruby, index: usize) -> Option<Value> {
152
+ self.0
153
+ .borrow()
154
+ .get(index)
155
+ .map(|value| ruby.get_inner(*value))
156
+ }
157
+
158
+ /// Runs `read` over the values in `range`; `read` may call Ruby.
159
+ pub fn with_values<R>(
160
+ &self,
161
+ _ruby: &Ruby,
162
+ range: Range<usize>,
163
+ read: impl FnOnce(&[Value]) -> R,
164
+ ) -> R {
165
+ let values = self.0.borrow();
166
+ let held = values.get(range).unwrap_or_default();
167
+ // SAFETY: Opaque is a transparent wrapper, so a slice of them has the
168
+ // layout of a slice of the values they wrap, which the `&Ruby`
169
+ // argument proves this thread may use.
170
+ let slice =
171
+ unsafe { std::slice::from_raw_parts(held.as_ptr().cast::<Value>(), held.len()) };
172
+ read(slice)
173
+ }
174
+ }
175
+
113
176
  pub fn init(ruby: &Ruby, rs: RModule) -> Result<(), Error> {
114
- let class = rs.define_class("Pins", ruby.class_object())?;
115
- class.undef_default_alloc_func();
177
+ for name in ["Pins", "Spill"] {
178
+ rs.define_class(name, ruby.class_object())?
179
+ .undef_default_alloc_func();
180
+ }
116
181
  Ok(())
117
182
  }
@@ -3,6 +3,10 @@ mod decode;
3
3
  mod encode;
4
4
  mod errors;
5
5
  mod fork;
6
+ #[allow(
7
+ unsafe_code,
8
+ reason = "a decode's spilled values are lent in place as the slice Ruby reads"
9
+ )]
6
10
  mod gc_root;
7
11
  mod guard;
8
12
  mod host;
@@ -1,4 +1,4 @@
1
- use std::ffi::{c_int, c_void};
1
+ use std::ffi::{c_int, c_long, c_void};
2
2
 
3
3
  use magnus::rb_sys::{AsRawId, AsRawValue, FromRawValue, protect};
4
4
  use magnus::value::{IntoId, LazyId, ReprValue};
@@ -185,6 +185,23 @@ pub fn ruby_type(_ruby: &Ruby, value: Value) -> RubyType {
185
185
  }
186
186
  }
187
187
 
188
+ /// Whether a Hash stores `key` without running Ruby code or reaching any
189
+ /// other object: Ruby hashes and compares these itself (`any_hash`,
190
+ /// `rb_any_cmp`), a String only while its class is String itself.
191
+ pub fn plain_key(ruby: &Ruby, key: Value) -> bool {
192
+ match ruby_type(ruby, key) {
193
+ RubyType::Fixnum
194
+ | RubyType::Bignum
195
+ | RubyType::Float
196
+ | RubyType::Symbol
197
+ | RubyType::Nil
198
+ | RubyType::True
199
+ | RubyType::False => true,
200
+ RubyType::String => key.class().as_raw() == ruby.class_string().as_raw(),
201
+ _ => false,
202
+ }
203
+ }
204
+
188
205
  /// An object's address, the identity `mini_racer`'s encoder keys repeated
189
206
  /// containers by; stable only while the object is pinned.
190
207
  pub fn identity(value: Value) -> u64 {
@@ -221,8 +238,9 @@ pub fn call_with_array(ruby: &Ruby, receiver: Value, args: RArray) -> Result<Val
221
238
  }
222
239
 
223
240
  /// Stores each key and value as `Hash#[]=` would, under one `rb_protect` for
224
- /// the whole slice. `pairs` must be where the GC sees them, on the native
225
- /// stack: storing a key can start a compacting collection.
241
+ /// the whole slice. `pairs` must be where the GC sees and pins them, on the
242
+ /// native stack or in a `gc_root::Spill`: storing a key can start a
243
+ /// compacting collection.
226
244
  // rb_hash_bulk_insert would take the slice in one call, but it rehashes the
227
245
  // whole table whenever the hash already has entries.
228
246
  pub fn hash_aset_pairs(_ruby: &Ruby, hash: RHash, pairs: &[[Value; 2]]) -> Result<(), Error> {
@@ -239,6 +257,24 @@ pub fn hash_aset_pairs(_ruby: &Ruby, hash: RHash, pairs: &[[Value; 2]]) -> Resul
239
257
  Ok(())
240
258
  }
241
259
 
260
+ /// The interned UTF-8 String of `bytes`, the frozen String `Hash#[]=` would
261
+ /// store as the key in place of a new one.
262
+ pub fn interned_utf8(ruby: &Ruby, bytes: &[u8]) -> Result<RString, Error> {
263
+ let len = c_long::try_from(bytes.len()).unwrap_or(c_long::MAX);
264
+ // SAFETY: rb_utf8_encoding only reads Ruby's built-in UTF-8 encoding.
265
+ let utf8 = unsafe { rb_sys::rb_utf8_encoding() };
266
+ let raw = protect(|| {
267
+ // SAFETY: the `&Ruby` argument proves this thread holds the GVL,
268
+ // `bytes` holds `len` bytes and outlives the call, which copies them
269
+ // when it makes a String, and `protect` catches an allocation failure.
270
+ unsafe { rb_sys::rb_enc_interned_str(bytes.as_ptr().cast(), len, utf8) }
271
+ })?;
272
+ // SAFETY: `raw` is the live String rb_enc_interned_str returned.
273
+ let value = unsafe { Value::from_raw(raw) };
274
+ RString::from_value(value)
275
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected String"))
276
+ }
277
+
242
278
  /// How Ruby's own type errors name `value`: nil, true and false by value,
243
279
  /// anything else by its class, never a singleton class, printed with `to_s`
244
280
  /// as Ruby prints it, overridden or not.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MiniRacerRs
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mini_racer_rs
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Aragon