fast_regexp 0.6.2 → 0.7.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: ece32247dfc6514f567a262e759bf5bc28a63d45703547316851dabdd19f5d27
4
- data.tar.gz: f68fc5c53b8544fff834dabc4e1d2e516d0a57d6c529e273b416b33f8f6194f4
3
+ metadata.gz: a08e75e08540db9629b91a3c82a460a901a857fa8d40db6180d8efec1fd2eb9e
4
+ data.tar.gz: cdf0f2d5a053346c412369b1751605b618c2e9fc6f8594dfd6a4c86b08e6a56f
5
5
  SHA512:
6
- metadata.gz: f787fa035f0a0ffa54d717af678068d832831884743210f68094565e57545c2e7da80af7e246af8a255416bbf235092364c850925d926e05fac704e1039957bc
7
- data.tar.gz: 7cc68035055b11e1f2aa2f89c61e558cd6e5b7e40e69b0b3b793c67b572877abad4800a1258520e65f7b25b14323160e4dd70a13813aff9b8639c622cb51a008
6
+ metadata.gz: 474a1577c07cda797f422caa596e35c2831d33c0acd8dfaf1dd5d1397f9c036118264bb814e0fb32a96c7e43db32f797fce1d0c8f2a36eeb187417ca45d9ccaa
7
+ data.tar.gz: 76298cd1b9e8a2648f48e695eb4d1199d12a24b5dc384b8d6f7acc9495769a8ef605403c0f9d4b0ec37192408850770ff72e7827be56f99124bc3f3855798d6f
data/README.md CHANGED
@@ -244,7 +244,18 @@ In-depth docs live under [`docs/`](docs/README.md), organized via the
244
244
  - **Tutorial:** [Getting started](docs/tutorials/getting-started.md)
245
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)
246
246
  - **Reference:** [`Fast::Regexp`](docs/reference/fast-regexp.md), [`MatchData`](docs/reference/fast-regexp-matchdata.md), [`Set`](docs/reference/fast-regexp-set.md)
247
- - **Explainers:** [Engine fallback](docs/explainers/engine-fallback.md)
247
+ - **Explainers:** [Engine fallback](docs/explainers/engine-fallback.md), [Benchmarks](docs/explainers/benchmarks.md)
248
+
249
+ ## Performance
250
+
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`.
248
259
 
249
260
  ## Development
250
261
 
@@ -253,6 +264,7 @@ bin/setup # install deps
253
264
  bin/console # interactive prompt to play around
254
265
  rake compile # (re)compile extension
255
266
  rake spec # run tests
267
+ bundle exec ruby benchmark/operations.rb # the benchmarks page's table
256
268
  ```
257
269
 
258
270
  ## Contributing
@@ -1,15 +1,25 @@
1
1
  use magnus::{
2
- function, method,
2
+ function, gc, method,
3
3
  scan_args::{get_kwargs, scan_args},
4
- value::ReprValue,
5
- Error, Module, Object, RArray, RClass, RHash, RString, Ruby, Symbol, TryConvert, Value,
4
+ value::{Opaque, ReprValue},
5
+ DataTypeFunctions, Error, Integer, Module, Object, RArray, RClass, RHash, RString, Ruby,
6
+ Symbol, TryConvert, TypedData, Value,
6
7
  };
7
8
  use regex::bytes::{NoExpand, Regex, RegexBuilder, RegexSet, RegexSetBuilder};
8
9
  use std::collections::HashMap;
9
- use std::sync::Arc;
10
-
11
- fn haystack_bytes(haystack: &RString) -> Vec<u8> {
12
- unsafe { haystack.as_slice() }.to_vec()
10
+ use std::sync::{Arc, OnceLock};
11
+
12
+ /// A haystack's bytes, read in place for the duration of one native call.
13
+ ///
14
+ /// 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
17
+ /// frees nor moves it — a method argument is rooted and pinned on the VM
18
+ /// stack for the whole call. Paths that *do* run Ruby code mid-call (the
19
+ /// block forms of `sub`/`gsub`) read a frozen snapshot instead; see
20
+ /// [`FastMatchData`].
21
+ fn bytes(haystack: &RString) -> &[u8] {
22
+ unsafe { haystack.as_slice() }
13
23
  }
14
24
 
15
25
  fn utf8_string(ruby: &Ruby, bytes: &[u8]) -> RString {
@@ -17,31 +27,31 @@ fn utf8_string(ruby: &Ruby, bytes: &[u8]) -> RString {
17
27
  }
18
28
 
19
29
  fn arg_error(message: impl Into<String>) -> Error {
20
- Error::new(
21
- Ruby::get().unwrap().exception_arg_error(),
22
- message.into(),
23
- )
30
+ Error::new(Ruby::get().unwrap().exception_arg_error(), message.into())
24
31
  }
25
32
 
26
33
  fn type_error(message: impl Into<String>) -> Error {
27
- Error::new(
28
- Ruby::get().unwrap().exception_type_error(),
29
- message.into(),
30
- )
34
+ Error::new(Ruby::get().unwrap().exception_type_error(), message.into())
31
35
  }
32
36
 
33
37
  type CaptureOffset = Option<(usize, usize)>;
34
38
 
35
- /// Inner state shared between a compiled regex and any [`FastMatchData`] it
36
- /// produces — wrapped in `Arc` so positions, names, and haystack bytes can be
37
- /// shared cheaply across many matches (e.g. from `#scan_matches`).
39
+ /// Inner state shared between a compiled regex and every [`FastMatchData`]
40
+ /// it produces, behind an `Arc`.
38
41
  struct RegexInner {
39
42
  regex: Regex,
40
43
  /// `name -> capture index`. Empty when the pattern has no named captures.
41
44
  names: HashMap<String, usize>,
42
- /// Ordered list of capture-group names (None for unnamed groups), matching
43
- /// the indices produced by `regex.captures_iter()`.
45
+ /// Capture-group names in index order (`None` for unnamed groups).
44
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>>,
45
55
  }
46
56
 
47
57
  impl RegexInner {
@@ -51,26 +61,71 @@ impl RegexInner {
51
61
  .build()
52
62
  .map_err(|e| arg_error(e.to_string()))?;
53
63
 
54
- let name_index: Vec<Option<String>> =
55
- regex.capture_names().map(|n| n.map(String::from)).collect();
56
64
  let mut names = HashMap::new();
57
- for (idx, name) in name_index.iter().enumerate() {
58
- if let Some(n) = name {
59
- names.insert(n.clone(), idx);
60
- }
65
+ let mut name_index = Vec::with_capacity(regex.captures_len());
66
+ 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
+ }));
61
71
  }
62
72
 
63
73
  Ok(Self {
64
74
  regex,
65
75
  names,
66
76
  name_index,
77
+ ruby_names: OnceLock::new(),
67
78
  })
68
79
  }
80
+
81
+ fn mark(&self, marker: &gc::Marker) {
82
+ if let Some(names) = self.ruby_names.get() {
83
+ marker.mark(*names);
84
+ }
85
+ }
86
+
87
+ /// The interned names Array (see the field), built on first use.
88
+ 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
+ }))
100
+ }
101
+
102
+ /// Capture-group names in declaration order, unnamed groups skipped.
103
+ fn names(&self, ruby: &Ruby) -> RArray {
104
+ let names = self.ruby_names(ruby);
105
+ // SAFETY: the Array is reachable from the wrapper this runs on behalf
106
+ // of and no Ruby code runs while the slice is read.
107
+ let values = unsafe { names.as_slice() };
108
+ ruby.ary_from_iter(values.iter().skip(1).filter(|v| !v.is_nil()).copied())
109
+ }
110
+
111
+ /// Every group's `(start, end)` for one match, index 0 the whole match.
112
+ fn offsets(&self, caps: &regex::bytes::Captures) -> Vec<CaptureOffset> {
113
+ (0..self.regex.captures_len())
114
+ .map(|i| caps.get(i).map(|m| (m.start(), m.end())))
115
+ .collect()
116
+ }
69
117
  }
70
118
 
71
- #[magnus::wrap(class = "Fast::Regexp::Native", free_immediately, size)]
119
+ #[derive(TypedData)]
120
+ #[magnus(class = "Fast::Regexp::Native", free_immediately, size, mark)]
72
121
  pub struct FastRegexp(Arc<RegexInner>);
73
122
 
123
+ impl DataTypeFunctions for FastRegexp {
124
+ fn mark(&self, marker: &gc::Marker) {
125
+ self.0.mark(marker);
126
+ }
127
+ }
128
+
74
129
  impl FastRegexp {
75
130
  pub fn new(args: &[Value]) -> Result<Self, Error> {
76
131
  let args = scan_args::<(String,), (), (), (), RHash, ()>(args)?;
@@ -83,61 +138,47 @@ impl FastRegexp {
83
138
  Ok(Self(Arc::new(RegexInner::from_pattern(&pattern, unicode)?)))
84
139
  }
85
140
 
86
- fn build_match_data(
87
- &self,
88
- haystack: Arc<Vec<u8>>,
89
- offsets: Vec<CaptureOffset>,
90
- ) -> FastMatchData {
141
+ fn match_data(&self, haystack: RString, captures: Vec<CaptureOffset>) -> FastMatchData {
91
142
  FastMatchData {
92
- haystack,
93
- captures: offsets,
143
+ haystack: haystack.into(),
144
+ captures,
94
145
  inner: self.0.clone(),
95
146
  }
96
147
  }
97
148
 
149
+ /// First match as a MatchData, or nil. The search runs over the live
150
+ /// haystack; only a hit pays for the frozen snapshot the MatchData keeps.
98
151
  pub fn rmatch(&self, haystack: RString) -> Option<FastMatchData> {
99
- let regex = &self.0.regex;
100
- let bytes = haystack_bytes(&haystack);
101
-
102
- let offsets = regex.captures(&bytes).map(|caps| {
103
- (0..regex.captures_len())
104
- .map(|i| caps.get(i).map(|m| (m.start(), m.end())))
105
- .collect::<Vec<_>>()
106
- })?;
152
+ let inner = &self.0;
153
+ let captures = inner
154
+ .regex
155
+ .captures(bytes(&haystack))
156
+ .map(|caps| inner.offsets(&caps))?;
157
+ Some(self.match_data(RString::new_frozen(haystack), captures))
158
+ }
107
159
 
108
- Some(self.build_match_data(Arc::new(bytes), offsets))
160
+ /// Byte offset of the first match, or nil — `=~` without a MatchData.
161
+ pub fn find(&self, haystack: RString) -> Option<usize> {
162
+ self.0.regex.find(bytes(&haystack)).map(|m| m.start())
109
163
  }
110
164
 
111
165
  pub fn scan(ruby: &Ruby, rb_self: &Self, haystack: RString) -> Result<RArray, Error> {
112
166
  let regex = &rb_self.0.regex;
113
- let bytes = haystack_bytes(&haystack);
167
+ let bytes = bytes(&haystack);
114
168
 
115
169
  if regex.captures_len() == 1 {
116
- let ranges: Vec<(usize, usize)> = regex
117
- .find_iter(&bytes)
118
- .map(|m| (m.start(), m.end()))
119
- .collect();
120
- let result = ruby.ary_new_capa(ranges.len());
121
- for (s, e) in ranges {
122
- result.push(utf8_string(ruby, &bytes[s..e]))?;
170
+ let result = ruby.ary_new();
171
+ for m in regex.find_iter(bytes) {
172
+ result.push(utf8_string(ruby, m.as_bytes()))?;
123
173
  }
124
174
  Ok(result)
125
175
  } else {
126
- let groups: Vec<Vec<CaptureOffset>> = regex
127
- .captures_iter(&bytes)
128
- .map(|caps| {
129
- caps.iter()
130
- .skip(1)
131
- .map(|c| c.map(|m| (m.start(), m.end())))
132
- .collect()
133
- })
134
- .collect();
135
- let result = ruby.ary_new_capa(groups.len());
136
- for group_ranges in groups {
137
- let group = ruby.ary_new_capa(group_ranges.len());
138
- for range in group_ranges {
139
- match range {
140
- Some((s, e)) => group.push(utf8_string(ruby, &bytes[s..e]))?,
176
+ let result = ruby.ary_new();
177
+ for caps in regex.captures_iter(bytes) {
178
+ let group = ruby.ary_new_capa(regex.captures_len() - 1);
179
+ for m in caps.iter().skip(1) {
180
+ match m {
181
+ Some(m) => group.push(utf8_string(ruby, m.as_bytes()))?,
141
182
  None => group.push(())?,
142
183
  }
143
184
  }
@@ -147,32 +188,29 @@ impl FastRegexp {
147
188
  }
148
189
  }
149
190
 
191
+ /// Every match as a MatchData, all sharing one frozen snapshot of the
192
+ /// haystack (taken only if there is at least one match).
150
193
  pub fn scan_matches(ruby: &Ruby, rb_self: &Self, haystack: RString) -> Result<RArray, Error> {
151
- let regex = &rb_self.0.regex;
152
- let bytes = haystack_bytes(&haystack);
153
- let n_groups = regex.captures_len();
154
-
155
- let all: Vec<Vec<CaptureOffset>> = regex
156
- .captures_iter(&bytes)
157
- .map(|caps| {
158
- (0..n_groups)
159
- .map(|i| caps.get(i).map(|m| (m.start(), m.end())))
160
- .collect()
161
- })
194
+ let inner = &rb_self.0;
195
+ let all: Vec<Vec<CaptureOffset>> = inner
196
+ .regex
197
+ .captures_iter(bytes(&haystack))
198
+ .map(|caps| inner.offsets(&caps))
162
199
  .collect();
163
200
 
164
- let shared = Arc::new(bytes);
165
201
  let result = ruby.ary_new_capa(all.len());
166
- for offsets in all {
167
- result.push(rb_self.build_match_data(shared.clone(), offsets))?;
202
+ if all.is_empty() {
203
+ return Ok(result);
204
+ }
205
+ let snapshot = RString::new_frozen(haystack);
206
+ for captures in all {
207
+ result.push(rb_self.match_data(snapshot, captures))?;
168
208
  }
169
209
  Ok(result)
170
210
  }
171
211
 
172
212
  pub fn is_match(&self, haystack: RString) -> bool {
173
- let regex = &self.0.regex;
174
- let bytes = haystack_bytes(&haystack);
175
- regex.is_match(&bytes)
213
+ self.0.regex.is_match(bytes(&haystack))
176
214
  }
177
215
 
178
216
  pub fn sub_str(
@@ -183,12 +221,11 @@ impl FastRegexp {
183
221
  literal: bool,
184
222
  ) -> RString {
185
223
  let regex = &rb_self.0.regex;
186
- let bytes = haystack_bytes(&haystack);
187
- let repl = haystack_bytes(&replacement);
188
- let out: Vec<u8> = if literal {
189
- regex.replace(&bytes, NoExpand(&repl)).into_owned()
224
+ let (bytes, repl) = (bytes(&haystack), bytes(&replacement));
225
+ let out = if literal {
226
+ regex.replace(bytes, NoExpand(repl))
190
227
  } else {
191
- regex.replace(&bytes, &repl[..]).into_owned()
228
+ regex.replace(bytes, repl)
192
229
  };
193
230
  utf8_string(ruby, &out)
194
231
  }
@@ -201,16 +238,70 @@ impl FastRegexp {
201
238
  literal: bool,
202
239
  ) -> RString {
203
240
  let regex = &rb_self.0.regex;
204
- let bytes = haystack_bytes(&haystack);
205
- let repl = haystack_bytes(&replacement);
206
- let out: Vec<u8> = if literal {
207
- regex.replace_all(&bytes, NoExpand(&repl)).into_owned()
241
+ let (bytes, repl) = (bytes(&haystack), bytes(&replacement));
242
+ let out = if literal {
243
+ regex.replace_all(bytes, NoExpand(repl))
208
244
  } else {
209
- regex.replace_all(&bytes, &repl[..]).into_owned()
245
+ regex.replace_all(bytes, repl)
210
246
  };
211
247
  utf8_string(ruby, &out)
212
248
  }
213
249
 
250
+ /// The block forms of `sub` (`limit` 1) and `gsub` (no limit) in one
251
+ /// pass: each match is yielded as a MatchData and the block's result
252
+ /// (via `to_s`) is spliced in for it. The block runs arbitrary Ruby, so
253
+ /// everything is read from a frozen snapshot of the haystack rather than
254
+ /// the live string — the same snapshot every yielded MatchData keeps.
255
+ fn replace_block(
256
+ ruby: &Ruby,
257
+ rb_self: &Self,
258
+ haystack: RString,
259
+ limit: Option<usize>,
260
+ ) -> Result<RString, Error> {
261
+ let inner = &rb_self.0;
262
+ if !inner.regex.is_match(bytes(&haystack)) {
263
+ // No match: a copy of the haystack, encoding and all, as
264
+ // `String#sub`/`#gsub` return (copy-on-write, nothing copied yet).
265
+ return Ok(RString::new_shared(haystack));
266
+ }
267
+ let snapshot = RString::new_frozen(haystack);
268
+ let bytes = bytes(&snapshot);
269
+ // Built as a UTF-8 String from the start: the haystack's own bytes go
270
+ // in raw (`cat`), each block result through Ruby's encoding
271
+ // negotiation (`buf_append`, as `String#gsub` appends), so an
272
+ // incompatible replacement raises Encoding::CompatibilityError rather
273
+ // than leaving invalid bytes behind. The block's result is taken as
274
+ // `rb_obj_as_string` takes it: a String as is, anything else via
275
+ // `to_s`, falling back to `Object#to_s` when that returns a non-String.
276
+ let out = utf8_string(ruby, &[]);
277
+ let mut cursor = 0;
278
+ for caps in inner
279
+ .regex
280
+ .captures_iter(bytes)
281
+ .take(limit.unwrap_or(usize::MAX))
282
+ {
283
+ let whole = caps.get(0).expect("group 0 is the match");
284
+ out.cat(&bytes[cursor..whole.start()]);
285
+ let replacement: Value =
286
+ ruby.yield_value(rb_self.match_data(snapshot, inner.offsets(&caps)))?;
287
+ out.buf_append(replacement.to_r_string()?)?;
288
+ cursor = whole.end();
289
+ }
290
+ out.cat(&bytes[cursor..]);
291
+ // Keep the snapshot reachable from this frame until the last read of
292
+ // `bytes` above, whatever the yielded MatchData objects' fate.
293
+ std::hint::black_box(snapshot);
294
+ Ok(out)
295
+ }
296
+
297
+ pub fn sub_block(ruby: &Ruby, rb_self: &Self, haystack: RString) -> Result<RString, Error> {
298
+ Self::replace_block(ruby, rb_self, haystack, Some(1))
299
+ }
300
+
301
+ pub fn gsub_block(ruby: &Ruby, rb_self: &Self, haystack: RString) -> Result<RString, Error> {
302
+ Self::replace_block(ruby, rb_self, haystack, None)
303
+ }
304
+
214
305
  pub fn pattern(&self) -> &str {
215
306
  self.0.regex.as_str()
216
307
  }
@@ -220,27 +311,46 @@ impl FastRegexp {
220
311
  self.0.regex.captures_len() - 1
221
312
  }
222
313
 
223
- pub fn names(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
224
- let arr = ruby.ary_new();
225
- for name in rb_self.0.name_index.iter().skip(1).flatten() {
226
- arr.push(name.as_str())?;
227
- }
228
- Ok(arr)
314
+ pub fn names(ruby: &Ruby, rb_self: &Self) -> RArray {
315
+ rb_self.0.names(ruby)
229
316
  }
230
317
  }
231
318
 
232
- #[magnus::wrap(class = "Fast::Regexp::Native::MatchData", free_immediately, size)]
319
+ /// One match: capture offsets into a frozen snapshot of the haystack. The
320
+ /// snapshot is `rb_str_new_frozen` of the caller's string — the string
321
+ /// itself when it was already frozen, otherwise a frozen copy-on-write
322
+ /// sibling sharing its buffer — so a later mutation of the caller's string
323
+ /// can't reach it, and nothing was copied to guarantee that.
324
+ #[derive(TypedData)]
325
+ #[magnus(
326
+ class = "Fast::Regexp::Native::MatchData",
327
+ free_immediately,
328
+ size,
329
+ mark
330
+ )]
233
331
  pub struct FastMatchData {
234
- haystack: Arc<Vec<u8>>,
332
+ haystack: Opaque<RString>,
235
333
  /// Index 0 is the whole match. Subsequent entries are capture groups in
236
334
  /// order; `None` indicates a group that did not participate.
237
335
  captures: Vec<CaptureOffset>,
238
336
  inner: Arc<RegexInner>,
239
337
  }
240
338
 
339
+ impl DataTypeFunctions for FastMatchData {
340
+ fn mark(&self, marker: &gc::Marker) {
341
+ marker.mark(self.haystack);
342
+ self.inner.mark(marker);
343
+ }
344
+ }
345
+
241
346
  impl FastMatchData {
347
+ fn haystack(&self, ruby: &Ruby) -> RString {
348
+ ruby.get_inner(self.haystack)
349
+ }
350
+
242
351
  fn slice(&self, ruby: &Ruby, range: (usize, usize)) -> RString {
243
- utf8_string(ruby, &self.haystack[range.0..range.1])
352
+ let haystack = self.haystack(ruby);
353
+ utf8_string(ruby, &bytes(&haystack)[range.0..range.1])
244
354
  }
245
355
 
246
356
  fn capture_index(&self, idx: i64) -> Option<usize> {
@@ -255,21 +365,40 @@ impl FastMatchData {
255
365
 
256
366
  /// Returns `Some(Some(range))` for a participating group, `Some(None)` for
257
367
  /// a known-but-non-participating group, `None` for an out-of-range index
258
- /// or an unknown capture name.
368
+ /// or an unknown capture name. Dispatches on the key's type rather than
369
+ /// attempting conversions: a failed conversion raises (and allocates an
370
+ /// exception) on the way to being caught.
259
371
  fn resolve(&self, key: Value) -> Result<Option<CaptureOffset>, Error> {
260
- if let Ok(i) = <i64 as TryConvert>::try_convert(key) {
261
- return Ok(self.capture_index(i).map(|i| self.captures[i]));
372
+ if let Some(index) = Integer::from_value(key) {
373
+ return Ok(self
374
+ .capture_index(index.to_i64()?)
375
+ .map(|i| self.captures[i]));
376
+ }
377
+ let by_name = |name: &str| self.inner.names.get(name).map(|&i| self.captures[i]);
378
+ if let Some(symbol) = Symbol::from_value(key) {
379
+ return Ok(by_name(&symbol.name()?));
262
380
  }
263
- if let Ok(sym) = <Symbol as TryConvert>::try_convert(key) {
264
- let name = sym.name()?.into_owned();
265
- return Ok(self.inner.names.get(&name).map(|&i| self.captures[i]));
381
+ if let Some(name) = RString::from_value(key) {
382
+ // A UTF-8 (or ASCII) name is read in place; any other encoding
383
+ // is transcoded, and one that can't be names no group.
384
+ return Ok(match unsafe { name.as_str() } {
385
+ Ok(name) => by_name(name),
386
+ Err(_) => name.to_string().ok().and_then(|name| by_name(&name)),
387
+ });
266
388
  }
267
- if let Ok(name) = <String as TryConvert>::try_convert(key) {
268
- return Ok(self.inner.names.get(&name).map(|&i| self.captures[i]));
389
+ // Anything else that converts to an Integer (a Float, a `to_int`
390
+ // object) indexes like one, as `::MatchData#[]` allows; anything
391
+ // that converts to a String (`to_str`) names a group, as this class
392
+ // has always allowed.
393
+ if let Ok(index) = i64::try_convert(key) {
394
+ return Ok(self.capture_index(index).map(|i| self.captures[i]));
395
+ }
396
+ match RString::try_convert(key) {
397
+ Ok(name) => Ok(by_name(&name.to_string()?)),
398
+ Err(_) => Err(type_error(
399
+ "no implicit conversion of capture key into Integer, String, or Symbol",
400
+ )),
269
401
  }
270
- Err(type_error(
271
- "no implicit conversion of capture key into Integer, String, or Symbol",
272
- ))
273
402
  }
274
403
 
275
404
  fn whole(&self) -> (usize, usize) {
@@ -283,49 +412,47 @@ impl FastMatchData {
283
412
  }
284
413
  }
285
414
 
286
- pub fn to_a(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
287
- let arr = ruby.ary_new_capa(rb_self.captures.len());
288
- for cap in &rb_self.captures {
415
+ fn slices(&self, ruby: &Ruby, captures: &[CaptureOffset]) -> Result<RArray, Error> {
416
+ let arr = ruby.ary_new_capa(captures.len());
417
+ for cap in captures {
289
418
  match cap {
290
- Some(r) => arr.push(rb_self.slice(ruby, *r))?,
419
+ Some(r) => arr.push(self.slice(ruby, *r))?,
291
420
  None => arr.push(())?,
292
421
  }
293
422
  }
294
423
  Ok(arr)
295
424
  }
296
425
 
426
+ pub fn to_a(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
427
+ rb_self.slices(ruby, &rb_self.captures)
428
+ }
429
+
297
430
  pub fn captures(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
298
- let arr = ruby.ary_new_capa(rb_self.captures.len() - 1);
299
- for cap in rb_self.captures.iter().skip(1) {
300
- match cap {
301
- Some(r) => arr.push(rb_self.slice(ruby, *r))?,
302
- None => arr.push(())?,
303
- }
304
- }
305
- Ok(arr)
431
+ rb_self.slices(ruby, &rb_self.captures[1..])
306
432
  }
307
433
 
308
434
  pub fn named_captures(ruby: &Ruby, rb_self: &Self) -> Result<RHash, Error> {
309
435
  let hash = ruby.hash_new();
436
+ let names = rb_self.inner.ruby_names(ruby);
437
+ // SAFETY: the Array is reachable from this match's wrapper and no
438
+ // Ruby code runs while the slice is read.
439
+ let names = unsafe { names.as_slice() };
310
440
  // Iterate in declaration order so the hash preserves regex order.
311
- for (idx, name) in rb_self.inner.name_index.iter().enumerate() {
312
- if let Some(name) = name {
313
- let value: Value = match rb_self.captures[idx] {
314
- Some(r) => rb_self.slice(ruby, r).as_value(),
315
- None => ruby.qnil().as_value(),
316
- };
317
- hash.aset(name.as_str(), value)?;
441
+ for (idx, name) in names.iter().enumerate() {
442
+ if name.is_nil() {
443
+ continue;
318
444
  }
445
+ let value: Value = match rb_self.captures[idx] {
446
+ Some(r) => rb_self.slice(ruby, r).as_value(),
447
+ None => ruby.qnil().as_value(),
448
+ };
449
+ hash.aset(*name, value)?;
319
450
  }
320
451
  Ok(hash)
321
452
  }
322
453
 
323
- pub fn names(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
324
- let arr = ruby.ary_new();
325
- for name in rb_self.inner.name_index.iter().skip(1).flatten() {
326
- arr.push(name.as_str())?;
327
- }
328
- Ok(arr)
454
+ pub fn names(ruby: &Ruby, rb_self: &Self) -> RArray {
455
+ rb_self.inner.names(ruby)
329
456
  }
330
457
 
331
458
  pub fn size(&self) -> usize {
@@ -334,22 +461,23 @@ impl FastMatchData {
334
461
 
335
462
  pub fn pre_match(ruby: &Ruby, rb_self: &Self) -> RString {
336
463
  let (s, _) = rb_self.whole();
337
- utf8_string(ruby, &rb_self.haystack[..s])
464
+ rb_self.slice(ruby, (0, s))
338
465
  }
339
466
 
340
467
  pub fn post_match(ruby: &Ruby, rb_self: &Self) -> RString {
341
468
  let (_, e) = rb_self.whole();
342
- utf8_string(ruby, &rb_self.haystack[e..])
469
+ let haystack = rb_self.haystack(ruby);
470
+ let len = bytes(&haystack).len();
471
+ rb_self.slice(ruby, (e, len))
343
472
  }
344
473
 
345
474
  pub fn whole_match(ruby: &Ruby, rb_self: &Self) -> RString {
346
475
  rb_self.slice(ruby, rb_self.whole())
347
476
  }
348
477
 
478
+ /// The frozen snapshot the match was taken over (see the struct doc).
349
479
  pub fn string(ruby: &Ruby, rb_self: &Self) -> RString {
350
- let s = utf8_string(ruby, &rb_self.haystack);
351
- s.freeze();
352
- s
480
+ rb_self.haystack(ruby)
353
481
  }
354
482
 
355
483
  pub fn byteoffset(ruby: &Ruby, rb_self: &Self, key: Value) -> Result<Value, Error> {
@@ -384,7 +512,8 @@ impl FastMatchData {
384
512
 
385
513
  pub fn inspect(ruby: &Ruby, rb_self: &Self) -> RString {
386
514
  let (s, e) = rb_self.whole();
387
- let matched = String::from_utf8_lossy(&rb_self.haystack[s..e]);
515
+ let haystack = rb_self.haystack(ruby);
516
+ let matched = String::from_utf8_lossy(&bytes(&haystack)[s..e]);
388
517
  ruby.str_new(&format!("#<Fast::Regexp::MatchData {:?}>", matched))
389
518
  }
390
519
  }
@@ -410,15 +539,11 @@ impl FastRegexpSet {
410
539
  }
411
540
 
412
541
  pub fn matches(&self, haystack: RString) -> Vec<usize> {
413
- let set = &self.0;
414
- let bytes = haystack_bytes(&haystack);
415
- set.matches(&bytes).iter().collect()
542
+ self.0.matches(bytes(&haystack)).iter().collect()
416
543
  }
417
544
 
418
545
  pub fn is_match(&self, haystack: RString) -> bool {
419
- let set = &self.0;
420
- let bytes = haystack_bytes(&haystack);
421
- set.is_match(&bytes)
546
+ self.0.is_match(bytes(&haystack))
422
547
  }
423
548
 
424
549
  pub fn patterns(&self) -> Vec<String> {
@@ -429,15 +554,19 @@ impl FastRegexpSet {
429
554
  #[magnus::init]
430
555
  pub fn init(ruby: &Ruby) -> Result<(), Error> {
431
556
  let object_class = ruby.class_object();
432
- // Fast::Regexp must already be defined as a class by lib/fast_regexp.rb
433
- // before this extension is required — the Ruby façade owns that constant
434
- // and delegates to the Native class registered below.
557
+ // Fast::Regexp and Fast::Regexp::MatchData must already be defined by
558
+ // lib/fast_regexp.rb before this extension is required — the Ruby façade
559
+ // owns those constants; the Native classes registered below sit under
560
+ // them, and Native::MatchData subclasses the façade's MatchData so a
561
+ // native match is a Fast::Regexp::MatchData with no wrapper around it.
435
562
  let fast_module: magnus::RModule = object_class.const_get("Fast")?;
436
563
  let regexp_facade: RClass = fast_module.const_get("Regexp")?;
564
+ let match_data_facade: RClass = regexp_facade.const_get("MatchData")?;
437
565
  let regexp_class = regexp_facade.define_class("Native", object_class)?;
438
566
 
439
567
  regexp_class.define_singleton_method("_native_new", function!(FastRegexp::new, -1))?;
440
568
  regexp_class.define_method("_native_match", method!(FastRegexp::rmatch, 1))?;
569
+ regexp_class.define_method("_native_find", method!(FastRegexp::find, 1))?;
441
570
  regexp_class.define_method("match?", method!(FastRegexp::is_match, 1))?;
442
571
  regexp_class.define_method("scan", method!(FastRegexp::scan, 1))?;
443
572
  regexp_class.define_method("scan_matches", method!(FastRegexp::scan_matches, 1))?;
@@ -446,8 +575,10 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
446
575
  regexp_class.define_method("names", method!(FastRegexp::names, 0))?;
447
576
  regexp_class.define_method("_native_sub", method!(FastRegexp::sub_str, 3))?;
448
577
  regexp_class.define_method("_native_gsub", method!(FastRegexp::gsub_str, 3))?;
578
+ regexp_class.define_method("_native_sub_block", method!(FastRegexp::sub_block, 1))?;
579
+ regexp_class.define_method("_native_gsub_block", method!(FastRegexp::gsub_block, 1))?;
449
580
 
450
- let match_data_class = regexp_class.define_class("MatchData", object_class)?;
581
+ let match_data_class = regexp_class.define_class("MatchData", match_data_facade)?;
451
582
  match_data_class.define_method("[]", method!(FastMatchData::aref, 1))?;
452
583
  match_data_class.define_method("to_a", method!(FastMatchData::to_a, 0))?;
453
584
  match_data_class.define_method("captures", method!(FastMatchData::captures, 0))?;
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fast
4
4
  class Regexp
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
data/lib/fast_regexp.rb CHANGED
@@ -21,6 +21,62 @@ module Fast
21
21
  candidates = [File.join(base, abi, "fast_regexp"), File.join(base, "fast_regexp")]
22
22
  candidates.find { |stem| NATIVE_EXTENSIONS.any? { |ext| File.exist?(stem + ext) } }
23
23
  end
24
+
25
+ # One match, whichever engine produced it. On the fast path the match
26
+ # *is* a `Fast::Regexp::Native::MatchData`, a subclass of this one
27
+ # defined by the extension (so it must exist before the extension
28
+ # loads); on the stdlib path it's an instance of this class wrapping the
29
+ # `::MatchData`. Same public surface either way.
30
+ class MatchData
31
+ include Enumerable
32
+
33
+ attr_reader :backend
34
+
35
+ def initialize(backend)
36
+ @backend = backend
37
+ end
38
+
39
+ def native? = false
40
+ def stdlib? = true
41
+
42
+ def native = nil
43
+ def stdlib = @backend
44
+
45
+ def [](key) = @backend[key]
46
+ def to_a = @backend.to_a
47
+ def captures = @backend.captures
48
+ def named_captures = @backend.named_captures
49
+ def names = @backend.names
50
+ def size = @backend.size
51
+ alias_method :length, :size
52
+ def pre_match = @backend.pre_match
53
+ def post_match = @backend.post_match
54
+ def to_s = @backend.to_s
55
+ alias_method :match, :to_s
56
+
57
+ # The haystack the match was taken over: `::MatchData#string` is already
58
+ # a frozen snapshot, the same thing the native subclass keeps.
59
+ def string = @backend.string
60
+
61
+ # Byte-based offsets. `::MatchData#byteoffset` exists since Ruby 3.2;
62
+ # its `byte_begin`/`byte_end` don't (3.4 added `bytebegin`/`byteend`),
63
+ # so those two derive from `byteoffset` here.
64
+ def byteoffset(key) = @backend.byteoffset(key)
65
+ def byte_begin(key) = byteoffset(key)[0]
66
+ def byte_end(key) = byteoffset(key)[1]
67
+
68
+ def each(&block) = to_a.each(&block)
69
+ def values_at(*indices) = indices.map { |i| self[i] }
70
+
71
+ def ==(other)
72
+ other.is_a?(MatchData) && to_a == other.to_a && string == other.string
73
+ end
74
+ alias_method :eql?, :==
75
+
76
+ def hash = [to_a, string].hash
77
+
78
+ def inspect = "#<Fast::Regexp::MatchData #{to_s.inspect}>"
79
+ end
24
80
  end
25
81
  end
26
82
 
@@ -30,6 +86,22 @@ require native
30
86
 
31
87
  module Fast
32
88
  class Regexp
89
+ class Native
90
+ class MatchData
91
+ def native? = true
92
+ def stdlib? = false
93
+
94
+ def native = self
95
+ def stdlib = nil
96
+ def backend = self
97
+
98
+ # Immutable, so a copy is the object itself (the wrapped class has no
99
+ # allocator for `dup`/`clone` to go through).
100
+ def dup = self
101
+ def clone(freeze: nil) = self
102
+ end
103
+ end
104
+
33
105
  RUBY_FLAG_MAP = {
34
106
  ::Regexp::IGNORECASE => "i",
35
107
  ::Regexp::EXTENDED => "x",
@@ -96,8 +168,9 @@ module Fast
96
168
 
97
169
  def match(haystack)
98
170
  haystack = coerce_string(haystack)
99
- raw = fast? ? @backend._native_match(haystack) : @backend.match(haystack)
100
- raw && MatchData.new(raw, haystack)
171
+ return @backend._native_match(haystack) if fast?
172
+ m = @backend.match(haystack)
173
+ m && MatchData.new(m)
101
174
  end
102
175
 
103
176
  def match?(haystack)
@@ -113,8 +186,10 @@ module Fast
113
186
  # also returns bytes here for API consistency).
114
187
  def =~(other)
115
188
  return nil unless other.respond_to?(:to_str)
116
- m = match(other.to_str)
117
- m && m.byte_begin(0)
189
+ haystack = other.to_str
190
+ return @backend._native_find(haystack) if fast?
191
+ m = @backend.match(haystack)
192
+ m && m.byteoffset(0)[0]
118
193
  end
119
194
 
120
195
  def scan(haystack)
@@ -123,22 +198,17 @@ module Fast
123
198
 
124
199
  def scan_matches(haystack)
125
200
  haystack = coerce_string(haystack)
126
- if fast?
127
- @backend.scan_matches(haystack).map { |m| MatchData.new(m, haystack) }
128
- else
129
- results = []
130
- haystack.scan(@backend) { results << MatchData.new(::Regexp.last_match, haystack) }
131
- results
132
- end
201
+ return @backend.scan_matches(haystack) if fast?
202
+ results = []
203
+ haystack.scan(@backend) { results << MatchData.new(::Regexp.last_match) }
204
+ results
133
205
  end
134
206
 
135
207
  def sub(haystack, replacement = nil, literal: false, &block)
136
208
  haystack = coerce_string(haystack)
137
- if block
209
+ if block_given?
138
210
  raise ArgumentError, "wrong number of arguments (given 2, expected 1 with block)" if replacement
139
- m = match(haystack)
140
- return haystack.dup unless m
141
- "#{m.pre_match}#{block.call(m)}#{m.post_match}"
211
+ fast? ? @backend._native_sub_block(haystack, &block) : stdlib_sub_with_block(haystack, &block)
142
212
  else
143
213
  raise ArgumentError, "wrong number of arguments (given 1, expected 2)" if replacement.nil?
144
214
  replacement = coerce_string(replacement)
@@ -152,9 +222,9 @@ module Fast
152
222
 
153
223
  def gsub(haystack, replacement = nil, literal: false, &block)
154
224
  haystack = coerce_string(haystack)
155
- if block
225
+ if block_given?
156
226
  raise ArgumentError, "wrong number of arguments (given 2, expected 1 with block)" if replacement
157
- fast? ? fast_gsub_with_block(haystack, &block) : stdlib_gsub_with_block(haystack, &block)
227
+ fast? ? @backend._native_gsub_block(haystack, &block) : stdlib_gsub_with_block(haystack, &block)
158
228
  else
159
229
  raise ArgumentError, "wrong number of arguments (given 1, expected 2)" if replacement.nil?
160
230
  replacement = coerce_string(replacement)
@@ -254,29 +324,14 @@ module Fast
254
324
  end
255
325
  end
256
326
 
257
- # Fast path: rust/regex has no native iterate-with-replace, so we scan all
258
- # match positions up-front, then splice the result by byte offset in one
259
- # pass. Stays UTF-8 since haystack and match slices are.
260
- def fast_gsub_with_block(haystack)
261
- matches = scan_matches(haystack)
262
- return haystack.dup if matches.empty?
263
-
264
- out = String.new(encoding: Encoding::UTF_8)
265
- cursor = 0
266
- matches.each do |m|
267
- bs, be = m.byteoffset(0)
268
- out << haystack.byteslice(cursor, bs - cursor) if bs > cursor
269
- out << yield(m).to_s
270
- cursor = be
271
- end
272
- out << haystack.byteslice(cursor, haystack.bytesize - cursor) if cursor < haystack.bytesize
273
- out
327
+ # Stdlib path: String#sub/#gsub already do single-pass iterate-and-replace
328
+ # and set $~ inside the block, so wrap the current ::MatchData and yield.
329
+ def stdlib_sub_with_block(haystack)
330
+ haystack.sub(@backend) { yield(MatchData.new(::Regexp.last_match)).to_s }
274
331
  end
275
332
 
276
- # Stdlib path: String#gsub already does single-pass iterate-and-replace
277
- # and sets $~ inside the block, so wrap the current ::MatchData and yield.
278
333
  def stdlib_gsub_with_block(haystack)
279
- haystack.gsub(@backend) { yield(MatchData.new(::Regexp.last_match, haystack)).to_s }
334
+ haystack.gsub(@backend) { yield(MatchData.new(::Regexp.last_match)).to_s }
280
335
  end
281
336
 
282
337
  def coerce_string(value)
@@ -284,53 +339,5 @@ module Fast
284
339
  return value.to_str if value.respond_to?(:to_str)
285
340
  raise TypeError, "no implicit conversion of #{value.class} into String"
286
341
  end
287
-
288
- # Wraps either a Fast::Regexp::Native::MatchData or a stdlib ::MatchData
289
- # so callers see one type regardless of which backend ran.
290
- class MatchData
291
- include Enumerable
292
-
293
- attr_reader :backend, :string
294
-
295
- def initialize(backend, haystack)
296
- @backend = backend
297
- @string = haystack
298
- end
299
-
300
- def native? = @backend.is_a?(Fast::Regexp::Native::MatchData)
301
- def stdlib? = !native?
302
-
303
- def native = native? ? @backend : nil
304
- def stdlib = stdlib? ? @backend : nil
305
-
306
- def [](key) = @backend[key]
307
- def to_a = @backend.to_a
308
- def captures = @backend.captures
309
- def named_captures = @backend.named_captures
310
- def names = @backend.names
311
- def size = @backend.size
312
- alias_method :length, :size
313
- def pre_match = @backend.pre_match
314
- def post_match = @backend.post_match
315
- def to_s = @backend.to_s
316
-
317
- # Byte-based offsets. Both backends expose these (stdlib MatchData has
318
- # `byteoffset` / `byte_begin` / `byte_end` since Ruby 3.2).
319
- def byteoffset(key) = @backend.byteoffset(key)
320
- def byte_begin(key) = @backend.byte_begin(key)
321
- def byte_end(key) = @backend.byte_end(key)
322
-
323
- def each(&block) = to_a.each(&block)
324
- def values_at(*indices) = indices.map { |i| self[i] }
325
-
326
- def ==(other)
327
- other.is_a?(MatchData) && to_a == other.to_a && string == other.string
328
- end
329
- alias_method :eql?, :==
330
-
331
- def hash = [to_a, string].hash
332
-
333
- def inspect = "#<Fast::Regexp::MatchData #{to_s.inspect}>"
334
- end
335
342
  end
336
343
  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.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Jacobs
@@ -11,7 +11,7 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Ruby bindings to rust/regex with a Fast::Regexp API: MatchData, sub/gsub,
14
- ===, =~, named captures, and GVL release for thread/fiber-friendly matching.'
14
+ ===, =~, & named captures.'
15
15
  email:
16
16
  - eric@ebj.dev
17
17
  executables: []
@@ -33,6 +33,7 @@ licenses:
33
33
  - MIT
34
34
  metadata:
35
35
  bug_tracker_uri: https://github.com/jetpks/fast_regexp/issues
36
+ changelog_uri: https://github.com/jetpks/fast_regexp/blob/main/CHANGELOG.md
36
37
  homepage_uri: https://github.com/jetpks/fast_regexp
37
38
  source_code_uri: https://github.com/jetpks/fast_regexp
38
39
  rdoc_options: []