fast_regexp 0.8.0 → 0.9.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: 806b011e667e08fc7b43faf6c2bbd9908e8ad44404e42b4a6cf92f3f48093437
4
- data.tar.gz: 6d75b7c522f679843f3c2bb55323a56f84917ba7de8d71ae0863fc7865fbd2d4
3
+ metadata.gz: e6b732dadd490b380381133c5d755016c834952213d151d909ab3bd4dc0276d6
4
+ data.tar.gz: c03fa2c959dbd59b65fbafac9dd72fb537131bef777722d9a95d4ad10a6be191
5
5
  SHA512:
6
- metadata.gz: a5104acf3940699cc3a11bd1919c935f24065ae6d60c7214afc16ec6de4fe7ae153136c4dff6e7fba0115fc3eb73fd988cdc0ef0d103100ec0f5e385909f14c5
7
- data.tar.gz: d4854d8aaa0b91a1e0a0ec6ed6bfe5caf76be6cbbb3360d2c7e8548e8ad3d10958febe7b9b12ef1e6e2a37b6dbbfd0a78b9977fd9ecdf7eb96c1b3d47033d732
6
+ metadata.gz: 1b8b6c63c8af3d0d0863465fa35b75b9d9d64f20c817b5782e3ed40022ab03ff53a414cd22a5843b58a919ac17ce21f8479d3becadd38bebfdfa4cdd92e67014
7
+ data.tar.gz: 22f5d0a36706934cc3c1b7cf7eb9b8bfef8de839e09c80dba181cb3947fcd92ee2c1ae56f8494c4aa03c850e5e4af7ae656b5d05bea983e137742b58aaead745
data/Cargo.lock CHANGED
@@ -72,6 +72,7 @@ name = "fast_regexp"
72
72
  version = "0.1.0"
73
73
  dependencies = [
74
74
  "magnus",
75
+ "rb-sys",
75
76
  "regex",
76
77
  ]
77
78
 
data/README.md CHANGED
@@ -20,7 +20,7 @@ gem install fast_regexp
20
20
  ```
21
21
 
22
22
  Precompiled native gems are published for **arm64-darwin**, **x86_64-linux**,
23
- and **aarch64-linux** against Ruby **3.3**, **3.4**, and **4.0** — no Rust
23
+ and **aarch64-linux** against Ruby **3.4** and **4.0** — no Rust
24
24
  toolchain required on those platforms.
25
25
 
26
26
  On any other platform/Ruby combo, Bundler/RubyGems falls back to the source
@@ -39,7 +39,7 @@ Regular expressions should be pre-compiled before use:
39
39
 
40
40
  ```ruby
41
41
  re = Fast::Regexp.new('p.t{2}ern*')
42
- # => #<Fast::Regexp:...>
42
+ # => #<Fast::Regexp "p.t{2}ern*">
43
43
  ```
44
44
 
45
45
  > [!TIP]
@@ -101,6 +101,9 @@ Fast::Regexp.new('(\w+):(\d+)').scan("ruby:123, rust:456")
101
101
  # => [["ruby", "123"], ["rust", "456"]]
102
102
  ```
103
103
 
104
+ Both `#scan` and `#scan_matches` run on either engine and return identical
105
+ results, including on patterns that fell back to stdlib `::Regexp`.
106
+
104
107
  For per-match positions and pre/post-match access, use `#scan_matches`:
105
108
 
106
109
  ```ruby
@@ -216,13 +219,28 @@ Fast::Regexp::Set.new(["abc", "def"]).patterns
216
219
 
217
220
  ## Encoding
218
221
 
219
- Currently, `fast_regexp` expects the haystack to be an UTF-8 string.
222
+ `fast_regexp` expects UTF-8 haystacks — ASCII-8BIT strings work too, and match
223
+ results always come back tagged UTF-8. Matching runs over the raw bytes
224
+ (rust/regex's `regex::bytes`), so searching a haystack that contains invalid
225
+ UTF-8 doesn't raise, where stdlib raises `ArgumentError`:
226
+
227
+ ```ruby
228
+ "abc\xFFdef" =~ /a(.)c/
229
+ # => ArgumentError: invalid byte sequence in UTF-8
220
230
 
221
- It also supports parsing of strings with invalid UTF-8 characters by default. It's achieved via using `regex::bytes` instead of plain `regex` under the hood, so any byte sequence can be matched. The output match is encoded as UTF-8 string.
231
+ Fast::Regexp.new('a(.)c').match("abc\xFFdef")
232
+ # => #<Fast::Regexp::MatchData "abc">
233
+ ```
222
234
 
223
- In case unicode awarness of matchers should be disabled, both `Fast::Regexp` and `Fast::Regexp::Set` support `unicode: false` option:
235
+ In the default Unicode-aware mode a `.` or character class won't match
236
+ *across* an invalid byte. To match bytes anywhere, disable Unicode
237
+ awareness — both `Fast::Regexp` and `Fast::Regexp::Set` accept `unicode:`
238
+ (default `true`):
224
239
 
225
240
  ```ruby
241
+ Fast::Regexp.new('c(.)d', unicode: false).match("abc\xFFdef").to_a
242
+ # => ["c\xFFd", "\xFF"]
243
+
226
244
  Fast::Regexp.new('\w+').match('ю٤夏')[0]
227
245
  # => "ю٤夏"
228
246
 
@@ -236,26 +254,76 @@ Fast::Regexp::Set.new(['\w', '\d', '\s'], unicode: false).match("ю٤\u2000")
236
254
  # => []
237
255
  ```
238
256
 
257
+ ## Ractors
258
+
259
+ Patterns, sets, and match results support `Ractor.make_shareable` — it
260
+ returns the same object, now frozen. The native extension declares itself
261
+ Ractor-safe at load time, so there is nothing to enable:
262
+
263
+ ```ruby
264
+ re = Ractor.make_shareable(Fast::Regexp.new('(?P<word>\w+)=(?P<num>\d+)'))
265
+
266
+ workers = Array.new(4) do
267
+ Ractor.new(re) do |pattern|
268
+ pattern.match("a=1 b=2")[:word] # => "a"
269
+ end
270
+ end
271
+ workers.map { |r| r.respond_to?(:value) ? r.value : r.take }
272
+ # => ["a", "a", "a", "a"]
273
+ ```
274
+
275
+ Ruby 3.4 reads a Ractor's result with `#take`; Ruby 4.0 replaced it with
276
+ `#value`. The `respond_to?` read above runs on both — the same idiom the
277
+ spec suite uses.
278
+
279
+ What is guaranteed (each point pinned by
280
+ [`spec/fast_regexp/ractor_spec.rb`](spec/fast_regexp/ractor_spec.rb)):
281
+
282
+ - A shared pattern is fully usable in every worker: `match`, `match?`,
283
+ `===`, `=~`, `scan`, `scan_matches`, and `sub`/`gsub` in template and
284
+ block form all work, with the same results as on the main Ractor.
285
+ - Workers can `require "fast_regexp"` concurrently, including as the gem's
286
+ first load, and can construct their own patterns, matches, and sets —
287
+ the first construction of every native type works inside a non-main
288
+ Ractor.
289
+ - Matches are frozen snapshots: the haystack is snapshotted at match time,
290
+ so mutating it afterwards and then running GC with compaction leaves a
291
+ shared match's data intact. `MatchData#string` returns that snapshot.
292
+ - Stdlib-fallback patterns are shareable too, and substitute correctly
293
+ after being frozen or made shareable, without any warm-up call.
294
+
295
+ Recipes: [Use fast_regexp inside Ractors](docs/how-to/use-in-ractors.md).
296
+ Per-class details: the "Ractor safety" sections of
297
+ [`Fast::Regexp`](docs/reference/fast-regexp.md),
298
+ [`MatchData`](docs/reference/fast-regexp-matchdata.md), and
299
+ [`Set`](docs/reference/fast-regexp-set.md).
300
+
239
301
  ## Documentation
240
302
 
241
303
  In-depth docs live under [`docs/`](docs/README.md), organized via the
242
304
  [Diátaxis](https://diataxis.fr/) framework:
243
305
 
244
306
  - **Tutorial:** [Getting started](docs/tutorials/getting-started.md)
245
- - **How-to:** [Migrate from stdlib `::Regexp`](docs/how-to/migrate-from-stdlib-regexp.md), [Handle unsupported syntax](docs/how-to/handle-unsupported-syntax.md)
307
+ - **How-to:** [Migrate from stdlib `::Regexp`](docs/how-to/migrate-from-stdlib-regexp.md), [Handle unsupported syntax](docs/how-to/handle-unsupported-syntax.md), [Use fast_regexp inside Ractors](docs/how-to/use-in-ractors.md)
246
308
  - **Reference:** [`Fast::Regexp`](docs/reference/fast-regexp.md), [`MatchData`](docs/reference/fast-regexp-matchdata.md), [`Set`](docs/reference/fast-regexp-set.md)
247
309
  - **Explainers:** [Engine fallback](docs/explainers/engine-fallback.md), [Benchmarks](docs/explainers/benchmarks.md)
248
310
 
249
311
  ## Performance
250
312
 
251
- Matching runs 8–10x stdlib `::Regexp` on a hit or a miss, scanning 2–5x,
252
- `gsub` with a template 7–13x; reading a `MatchData` costs the same objects
253
- as `::MatchData`, and `match?`, `=~`, `===` and `Set#match?` allocate
254
- nothing. Compiling a pattern is the one place rust/regex is slower (it
255
- builds its automaton up front), so compile once and keep the object. Every
256
- operation, with the stdlib equivalent beside it, is on the
257
- [benchmarks page](docs/explainers/benchmarks.md); reproduce with
258
- `bundle exec ruby benchmark/operations.rb`.
313
+ Measured with `bundle exec ruby benchmark/operations.rb` — one run on
314
+ 2026-11-05, Apple M4 Max, `ruby 4.0.9`, fast_regexp 0.9.0 (the full table is
315
+ on the [benchmarks page](docs/explainers/benchmarks.md)):
316
+
317
+ - `match?` runs 8–9x stdlib `::Regexp`, on a hit and on a 200 KB miss;
318
+ `scan` 2–5x; `gsub` with a template 7x on a 200 KB haystack — in one
319
+ object, where stdlib allocates a `String` per match. `Set#match?` over
320
+ four patterns is 2x a `Regexp.union`.
321
+ - Reading a `MatchData` costs the same objects as `::MatchData`: each
322
+ accessor allocates exactly what it returns. `match?`, `=~`, `===` and
323
+ `Set#match?` allocate nothing.
324
+ - Compiling a pattern is the one place rust/regex is slower: it builds its
325
+ automaton up front (~256 µs for a small pattern in that run, against
326
+ stdlib's ~1 µs), so compile once and keep the object.
259
327
 
260
328
  ## Development
261
329
 
@@ -8,4 +8,5 @@ crate-type = ["cdylib"]
8
8
 
9
9
  [dependencies]
10
10
  magnus = "0.8"
11
+ rb-sys = "0.9"
11
12
  regex = "1"
@@ -1,19 +1,20 @@
1
1
  use magnus::{
2
2
  function, gc, method,
3
3
  scan_args::{get_kwargs, scan_args},
4
+ typed_data::Obj,
4
5
  value::{Opaque, ReprValue},
5
6
  DataTypeFunctions, Error, Integer, Module, Object, RArray, RClass, RHash, RString, Ruby,
6
7
  Symbol, TryConvert, TypedData, Value,
7
8
  };
8
9
  use regex::bytes::{NoExpand, Regex, RegexBuilder, RegexSet, RegexSetBuilder};
9
10
  use std::collections::HashMap;
10
- use std::sync::{Arc, OnceLock};
11
+ use std::sync::Arc;
11
12
 
12
13
  /// A haystack's bytes, read in place for the duration of one native call.
13
14
  ///
14
15
  /// Sound because the call hands nothing back to Ruby while the borrow lives:
15
- /// the GVL is held and no block is yielded, so no Ruby code can mutate the
16
- /// string, and a GC triggered by the result objects the call builds neither
16
+ /// the current Ractor's GVL is held and no block is yielded; strings shared
17
+ /// with other Ractors are frozen. A GC triggered by result objects neither
17
18
  /// frees nor moves it — a method argument is rooted and pinned on the VM
18
19
  /// stack for the whole call. Paths that *do* run Ruby code mid-call (the
19
20
  /// block forms of `sub`/`gsub`) read a frozen snapshot instead; see
@@ -42,61 +43,47 @@ struct RegexInner {
42
43
  regex: Regex,
43
44
  /// `name -> capture index`. Empty when the pattern has no named captures.
44
45
  names: HashMap<String, usize>,
45
- /// Capture-group names in index order (`None` for unnamed groups).
46
- name_index: Vec<Option<String>>,
47
- /// `name_index` as one Ruby Array of interned frozen Strings (nil for
48
- /// unnamed groups), so `names` and `named_captures` hand out the same
49
- /// String objects on every call. Built on first use, from a method of a
50
- /// live wrapper, so something marks it from the moment it exists; built
51
- /// into the Array as each name is interned, because the interning table
52
- /// holds its strings only weakly and a GC between two interns would
53
- /// otherwise free the first. Marked by every wrapper holding this `Arc`.
54
- ruby_names: OnceLock<Opaque<RArray>>,
46
+ /// Frozen Array of interned frozen Strings in capture order (nil for
47
+ /// unnamed groups). Built before publication so all Ruby state remains
48
+ /// immutable when the regex or a match is shared between Ractors.
49
+ ruby_names: Opaque<RArray>,
55
50
  }
56
51
 
57
52
  impl RegexInner {
58
- fn from_pattern(pattern: &str, unicode: bool) -> Result<Self, Error> {
53
+ fn from_pattern(ruby: &Ruby, pattern: &str, unicode: bool) -> Result<Self, Error> {
59
54
  let regex = RegexBuilder::new(pattern)
60
55
  .unicode(unicode)
61
56
  .build()
62
57
  .map_err(|e| arg_error(e.to_string()))?;
63
58
 
64
59
  let mut names = HashMap::new();
65
- let mut name_index = Vec::with_capacity(regex.captures_len());
60
+ let ruby_names = ruby.ary_new_capa(regex.captures_len());
66
61
  for (idx, name) in regex.capture_names().enumerate() {
67
- name_index.push(name.map(|n| {
68
- names.insert(n.to_owned(), idx);
69
- n.to_owned()
70
- }));
62
+ match name {
63
+ Some(name) => {
64
+ names.insert(name.to_owned(), idx);
65
+ // Root each interned string immediately: the interning
66
+ // table holds it weakly, and the next push may run GC.
67
+ ruby_names.push(ruby.str_new(name).to_interned_str())?;
68
+ }
69
+ None => ruby_names.push(())?,
70
+ }
71
71
  }
72
+ ruby_names.freeze();
72
73
 
73
74
  Ok(Self {
74
75
  regex,
75
76
  names,
76
- name_index,
77
- ruby_names: OnceLock::new(),
77
+ ruby_names: ruby_names.into(),
78
78
  })
79
79
  }
80
80
 
81
81
  fn mark(&self, marker: &gc::Marker) {
82
- if let Some(names) = self.ruby_names.get() {
83
- marker.mark(*names);
84
- }
82
+ marker.mark(self.ruby_names);
85
83
  }
86
84
 
87
- /// The interned names Array (see the field), built on first use.
88
85
  fn ruby_names(&self, ruby: &Ruby) -> RArray {
89
- ruby.get_inner(*self.ruby_names.get_or_init(|| {
90
- let names = ruby.ary_new_capa(self.name_index.len());
91
- for name in &self.name_index {
92
- let pushed = match name {
93
- Some(name) => names.push(ruby.str_new(name).to_interned_str()),
94
- None => names.push(()),
95
- };
96
- pushed.expect("pushing onto a fresh, unfrozen Array");
97
- }
98
- Opaque::from(names)
99
- }))
86
+ ruby.get_inner(self.ruby_names)
100
87
  }
101
88
 
102
89
  /// Capture-group names in declaration order, unnamed groups skipped.
@@ -117,7 +104,13 @@ impl RegexInner {
117
104
  }
118
105
 
119
106
  #[derive(TypedData)]
120
- #[magnus(class = "Fast::Regexp::Native", free_immediately, size, mark)]
107
+ #[magnus(
108
+ class = "Fast::Regexp::Native",
109
+ free_immediately,
110
+ size,
111
+ mark,
112
+ frozen_shareable
113
+ )]
121
114
  pub struct FastRegexp(Arc<RegexInner>);
122
115
 
123
116
  impl DataTypeFunctions for FastRegexp {
@@ -127,7 +120,7 @@ impl DataTypeFunctions for FastRegexp {
127
120
  }
128
121
 
129
122
  impl FastRegexp {
130
- pub fn new(args: &[Value]) -> Result<Self, Error> {
123
+ pub fn new(ruby: &Ruby, args: &[Value]) -> Result<Obj<Self>, Error> {
131
124
  let args = scan_args::<(String,), (), (), (), RHash, ()>(args)?;
132
125
  let kwargs = get_kwargs::<_, (), (Option<bool>,), ()>(args.keywords, &[], &["unicode"])?;
133
126
 
@@ -135,7 +128,12 @@ impl FastRegexp {
135
128
  let (unicode,) = kwargs.optional;
136
129
  let unicode = unicode.unwrap_or(true);
137
130
 
138
- Ok(Self(Arc::new(RegexInner::from_pattern(&pattern, unicode)?)))
131
+ let inner = RegexInner::from_pattern(ruby, &pattern, unicode)?;
132
+ let names = inner.ruby_names(ruby);
133
+ let wrapped = ruby.obj_wrap(Self(Arc::new(inner)));
134
+ // Keep the Array rooted on this stack until the wrapper can mark it.
135
+ std::hint::black_box(names);
136
+ Ok(wrapped)
139
137
  }
140
138
 
141
139
  fn match_data(&self, haystack: RString, captures: Vec<CaptureOffset>) -> FastMatchData {
@@ -326,7 +324,8 @@ impl FastRegexp {
326
324
  class = "Fast::Regexp::Native::MatchData",
327
325
  free_immediately,
328
326
  size,
329
- mark
327
+ mark,
328
+ frozen_shareable
330
329
  )]
331
330
  pub struct FastMatchData {
332
331
  haystack: Opaque<RString>,
@@ -518,7 +517,7 @@ impl FastMatchData {
518
517
  }
519
518
  }
520
519
 
521
- #[magnus::wrap(class = "Fast::Regexp::Set", free_immediately, size)]
520
+ #[magnus::wrap(class = "Fast::Regexp::Set", free_immediately, size, frozen_shareable)]
522
521
  pub struct FastRegexpSet(RegexSet);
523
522
 
524
523
  impl FastRegexpSet {
@@ -553,6 +552,8 @@ impl FastRegexpSet {
553
552
 
554
553
  #[magnus::init]
555
554
  pub fn init(ruby: &Ruby) -> Result<(), Error> {
555
+ // All shared Rust state is Sync; retained Ruby objects are immutable.
556
+ unsafe { rb_sys::rb_ext_ractor_safe(true) };
556
557
  let object_class = ruby.class_object();
557
558
  // Fast::Regexp and Fast::Regexp::MatchData must already be defined by
558
559
  // lib/fast_regexp.rb before this extension is required — the Ruby façade
@@ -604,5 +605,11 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
604
605
  regexp_set_class.define_method("match?", method!(FastRegexpSet::is_match, 1))?;
605
606
  regexp_set_class.define_method("patterns", method!(FastRegexpSet::patterns, 0))?;
606
607
 
608
+ // Magnus caches these classes and disables their allocators on first use.
609
+ // Complete that setup here, before workers can construct native objects.
610
+ FastRegexp::class(ruby);
611
+ FastMatchData::class(ruby);
612
+ FastRegexpSet::class(ruby);
613
+
607
614
  Ok(())
608
615
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  class Regexp
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
data/lib/fast_regexp.rb CHANGED
@@ -157,6 +157,10 @@ module Fast
157
157
  raise ArgumentError, "backend must be one of #{BACKENDS.inspect}" unless BACKENDS.include?(backend)
158
158
  @pattern = pattern
159
159
  @backend = compile_backend(pattern, original, backend, opts)
160
+ if stdlib?
161
+ # Prepare $N-to-name translation before the pattern can be frozen.
162
+ @stdlib_name_by_index = @backend.named_captures.flat_map { |n, idxs| idxs.map { |i| [i, n] } }.to_h.freeze
163
+ end
160
164
  end
161
165
 
162
166
  def fast? = @backend.is_a?(Native)
@@ -193,7 +197,9 @@ module Fast
193
197
  end
194
198
 
195
199
  def scan(haystack)
196
- @backend.scan(coerce_string(haystack))
200
+ haystack = coerce_string(haystack)
201
+ return @backend.scan(haystack) if fast?
202
+ haystack.scan(@backend)
197
203
  end
198
204
 
199
205
  def scan_matches(haystack)
@@ -300,13 +306,6 @@ module Fast
300
306
  raise(fallback_from ? ArgumentError.new(fallback_from.message) : e)
301
307
  end
302
308
 
303
- # Maps positional capture indices to names for the stdlib backend so we
304
- # can translate `$N` to `\k<name>` (Ruby's gsub ignores `\N` for named
305
- # groups). Only invoked from the stdlib path.
306
- def stdlib_name_by_index
307
- @stdlib_name_by_index ||= @backend.named_captures.flat_map { |n, idxs| idxs.map { |i| [i, n] } }.to_h
308
- end
309
-
310
309
  # Translate rust/regex replacement syntax ($N, ${name}, $$) into Ruby
311
310
  # syntax (\N, \k<name>, $) for the stdlib fallback path.
312
311
  def stdlib_replacement(template, literal)
@@ -317,7 +316,7 @@ module Fast
317
316
  case token
318
317
  when "$" then "$"
319
318
  when /\A\d+\z/
320
- name = stdlib_name_by_index[token.to_i]
319
+ name = @stdlib_name_by_index[token.to_i]
321
320
  name ? "\\k<#{name}>" : "\\#{token}"
322
321
  else "\\k<#{token[1..-2]}>"
323
322
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_regexp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs