Skip to main content

tiny_regex/
api.rs

1#![forbid(unsafe_code)]
2
3use core::ffi::CStr;
4
5use crate::raw;
6
7pub use crate::raw::{RegexBuf, DEFAULT_CCL, DEFAULT_MEMO, DEFAULT_NODES};
8
9/// `Regex` with the default node and character-class buffer sizes (32 nodes, 64-byte CCL,
10/// 256-byte memo table).
11///
12/// This is the common-case type. For non-default sizes use [`RegexBuf<N, CCL, MEMO>`][RegexBuf] directly.
13pub type Regex = raw::RegexBuf<DEFAULT_NODES, DEFAULT_CCL, DEFAULT_MEMO>;
14
15/// `Regex` with memoisation disabled — identical to the original tiny-regex-c behaviour.
16///
17/// Compiles and matches the same patterns as [`Regex`] but allocates no memo table.
18pub type TinyRegex = raw::RegexBuf<DEFAULT_NODES, DEFAULT_CCL, 0>;
19
20/// A single match within a haystack.
21///
22/// Returned by [`RegexBuf::find_at`] and yielded by [`Matches`]. The byte
23/// offsets [`start`][Match::start] and [`end`][Match::end] index into the
24/// original haystack passed to the search call.
25pub struct Match<'a> {
26    bytes: &'a [u8],
27    start: usize,
28    end: usize,
29}
30
31impl<'a> Match<'a> {
32    /// Byte offset of the first byte of the match within the haystack.
33    pub fn start(&self) -> usize {
34        self.start
35    }
36
37    /// Byte offset one past the last byte of the match within the haystack.
38    ///
39    /// For a zero-length match (e.g. `$` at end of string) `end == start`.
40    pub fn end(&self) -> usize {
41        self.end
42    }
43
44    /// The matched bytes as a slice of the original haystack.
45    pub fn as_bytes(&self) -> &'a [u8] {
46        self.bytes
47    }
48}
49
50/// An iterator over all non-overlapping matches of a pattern in a haystack.
51///
52/// Created by [`RegexBuf::find_iter`]. Yields [`Match`] values in left-to-right order.
53pub struct Matches<'a, 'b, const N: usize, const CCL: usize, const MEMO: usize> {
54    pattern: &'a raw::RegexBuf<N, CCL, MEMO>,
55    haystack: &'b CStr,
56    offset: usize,
57}
58
59impl<'a, 'b, const N: usize, const CCL: usize, const MEMO: usize>
60    Iterator for Matches<'a, 'b, N, CCL, MEMO>
61{
62    type Item = Match<'b>;
63
64    fn next(&mut self) -> Option<Match<'b>> {
65        if self.offset > self.haystack.to_bytes().len() {
66            return None;
67        }
68        let m = self.pattern.find_at(self.haystack, self.offset)?;
69        self.offset = if m.end > m.start { m.end } else { m.start + 1 };
70        Some(m)
71    }
72}
73
74impl<const N: usize, const CCL: usize, const MEMO: usize> raw::RegexBuf<N, CCL, MEMO> {
75    /// Search `haystack` for the first match of this pattern at or after byte
76    /// offset `start`.
77    ///
78    /// Returns `None` if no match is found. Each call stack-allocates a
79    /// zero-initialised memo table to cache failed `(pattern_node, text_offset)`
80    /// states and eliminate exponential backtracking.
81    pub fn find_at<'a>(&self, haystack: &'a CStr, start: usize) -> Option<Match<'a>> {
82        let mut matchlen: i32 = 0;
83        let idx = self.matchp_at(haystack, start, &mut matchlen);
84        if idx < 0 {
85            return None;
86        }
87        let start = idx as usize;
88        let end = start + matchlen as usize;
89        Some(Match {
90            bytes: &haystack.to_bytes()[start..end],
91            start,
92            end,
93        })
94    }
95
96    /// Return an iterator over all non-overlapping matches in `haystack`.
97    pub fn find_iter<'a, 'b>(&'a self, haystack: &'b CStr) -> Matches<'a, 'b, N, CCL, MEMO> {
98        Matches { pattern: self, haystack, offset: 0 }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    extern crate alloc;
105    extern crate std;
106    use super::*;
107    use alloc::vec::Vec;
108
109    #[test]
110    fn basic_match() {
111        let re = Regex::new(c"foo").unwrap();
112        let m = re.find_at(c"foo bar", 0).unwrap();
113        assert_eq!(m.start(), 0);
114        assert_eq!(m.end(), 3);
115    }
116
117    #[test]
118    fn find_at_offset() {
119        let re = Regex::new(c"foo").unwrap();
120        let m = re.find_at(c"foo bar foo", 1).unwrap();
121        assert_eq!(m.start(), 8);
122        assert_eq!(m.end(), 11);
123    }
124
125    #[test]
126    fn find_at_past_end_returns_none() {
127        let re = Regex::new(c"foo").unwrap();
128        assert!(re.find_at(c"foo", 4).is_none());
129    }
130
131    #[test]
132    fn find_iter_all_matches() {
133        let re = Regex::new(c"foo").unwrap();
134        let matches: Vec<_> = re.find_iter(c"foo bar foo").collect();
135        assert_eq!(matches.len(), 2);
136        assert_eq!((matches[0].start(), matches[0].end()), (0, 3));
137        assert_eq!((matches[1].start(), matches[1].end()), (8, 11));
138    }
139
140    #[test]
141    fn find_iter_empty_haystack() {
142        let re = Regex::new(c"foo").unwrap();
143        assert_eq!(re.find_iter(c"").count(), 0);
144    }
145
146    #[test]
147    fn new_invalid_pattern_returns_none() {
148        assert!(Regex::new(c"[abc").is_none());
149    }
150
151    #[test]
152    fn char_class_match() {
153        let re = Regex::new(c"[abc]+").unwrap();
154        let m = re.find_at(c"xyzabcxyz", 0).unwrap();
155        assert_eq!(m.start(), 3);
156        assert_eq!(m.end(), 6);
157    }
158
159    // Char-class nodes store a byte *offset* into the ccl array, not a pointer.
160    // Moving RegexBuf must not invalidate those offsets.
161    #[test]
162    fn move_preserves_char_class_match() {
163        fn compile() -> Regex { Regex::new(c"[a-z]+").unwrap() }
164        let re = compile(); // moved off compile()'s stack frame
165        let m = re.find_at(c"123abc", 0).unwrap();
166        assert_eq!((m.start(), m.end()), (3, 6));
167    }
168
169    #[test]
170    fn char_class_no_match() {
171        let re = Regex::new(c"[abc]").unwrap();
172        assert!(re.find_at(c"xyz", 0).is_none());
173    }
174
175    #[test]
176    fn char_range_match() {
177        let re = Regex::new(c"[a-z]+").unwrap();
178        let matches: Vec<_> = re.find_iter(c"123abc 456def").collect();
179        assert_eq!(matches.len(), 2);
180        assert_eq!((matches[0].start(), matches[0].end()), (3, 6));
181        assert_eq!((matches[1].start(), matches[1].end()), (10, 13));
182    }
183
184    #[test]
185    fn recompile_updates_pattern() {
186        let re = Regex::new(c"[abc]+").unwrap();
187        assert!(re.find_at(c"a", 0).is_some());
188        let re = re.recompile(c"[xyz]+").unwrap();
189        assert!(re.find_at(c"a", 0).is_none());
190        assert!(re.find_at(c"x", 0).is_some());
191    }
192
193    #[test]
194    fn recompile_invalid_pattern_returns_none() {
195        let re = Regex::new(c"foo").unwrap();
196        assert!(re.recompile(c"\\").is_none());
197    }
198
199    #[test]
200    fn new_empty_pattern_succeeds() {
201        assert!(Regex::new(c"").is_some());
202    }
203
204    #[test]
205    fn recompile_empty_pattern_succeeds() {
206        let re = Regex::new(c"foo").unwrap();
207        assert!(re.recompile(c"").is_some());
208    }
209
210    #[test]
211    fn concurrent_compile_then_match() {
212        use std::thread;
213
214        let handles: Vec<_> = (0..8)
215            .map(|i| {
216                thread::spawn(move || {
217                    let pattern = if i % 2 == 0 { c"[a-z]+" } else { c"[0-9]+" };
218                    let re = Regex::new(pattern).unwrap();
219                    if i % 2 == 0 {
220                        assert_eq!(re.find_iter(c"abc 123 def").count(), 2);
221                    } else {
222                        assert_eq!(re.find_iter(c"abc 123 def").count(), 1);
223                    }
224                })
225            })
226            .collect();
227
228        for h in handles {
229            h.join().unwrap();
230        }
231    }
232
233    #[test]
234    fn concurrent_match_shared_regex() {
235        use std::sync::Arc;
236        use std::thread;
237
238        let re = Arc::new(Regex::new(c"[a-z]+").unwrap());
239
240        let handles: Vec<_> = (0..8)
241            .map(|_| {
242                let re = Arc::clone(&re);
243                thread::spawn(move || {
244                    assert_eq!(re.find_iter(c"abc 123 def").count(), 2);
245                })
246            })
247            .collect();
248
249        for h in handles {
250            h.join().unwrap();
251        }
252    }
253
254    #[test]
255    fn new_trailing_backslash_returns_none() {
256        assert!(Regex::new(c"\\").is_none());
257    }
258
259    #[test]
260    fn compile_long_pattern_returns_none() {
261        // Default Regex (N=32): a pattern of 32 symbols needs 33 nodes
262        // (32 data + UNUSED sentinel) but capacity is 32 — must fail.
263        let pattern = alloc::ffi::CString::new("a".repeat(DEFAULT_NODES)).unwrap();
264        assert!(Regex::new(pattern.as_c_str()).is_none());
265    }
266
267    // Finding A: a (N-1)-symbol pattern fills all nodes and puts UNUSED at the
268    // last slot of the re_compiled array.  matchpattern used to form
269    // &pattern[2] from that slot — UB in C11.  The fix returns 1 directly for
270    // UNUSED without forming &pattern[2].
271    #[test]
272    fn max_length_pattern_matches() {
273        let pattern = alloc::ffi::CString::new("a".repeat(DEFAULT_NODES - 1)).unwrap();
274        let haystack = alloc::ffi::CString::new("a".repeat(DEFAULT_NODES - 1)).unwrap();
275        let re = Regex::new(pattern.as_c_str()).unwrap();
276        let m = re.find_at(haystack.as_c_str(), 0).unwrap();
277        assert_eq!(m.start(), 0);
278        assert_eq!(m.end(), DEFAULT_NODES - 1);
279    }
280
281    // Finding B: re_matchp's `if (utext[0] == '\0') return -1` guard discards any
282    // match found at the null-terminator position, including the only position where
283    // a standalone $ anchor can match.  Fix: remove the guard; a zero-length match
284    // at the end of string is correct for $ (and for patterns like a? or a*).
285    #[test]
286    fn dollar_anchor_alone_matches_end_of_string() {
287        let re = Regex::new(c"$").unwrap();
288        let m = re.find_at(c"foo", 0).unwrap();
289        assert_eq!(m.start(), 3);
290        assert_eq!(m.end(), 3);
291    }
292
293    #[test]
294    fn dollar_anchor_with_prefix_still_works() {
295        let re = Regex::new(c"foo$").unwrap();
296        let m = re.find_at(c"foo", 0).unwrap();
297        assert_eq!(m.start(), 0);
298        assert_eq!(m.end(), 3);
299        assert!(re.find_at(c"foo bar", 0).is_none());
300    }
301
302    #[test]
303    fn caret_anchor_matches_only_at_start() {
304        let re = Regex::new(c"^foo").unwrap();
305        let m = re.find_at(c"foobar", 0).unwrap();
306        assert_eq!((m.start(), m.end()), (0, 3));
307        assert!(re.find_at(c"barfoo", 0).is_none());
308    }
309
310    #[test]
311    fn dot_does_not_match_newline_by_default() {
312        let haystack = std::ffi::CString::new("a\nb").unwrap();
313        let re = Regex::new(c"a.b").unwrap();
314        assert!(re.find_at(haystack.as_c_str(), 0).is_none());
315    }
316
317    #[test]
318    fn dot_matches_any_char() {
319        let re = Regex::new(c"f.o").unwrap();
320        assert!(re.find_at(c"foo", 0).is_some());
321        assert!(re.find_at(c"fzo", 0).is_some());
322        assert!(re.find_at(c"fo", 0).is_none());
323    }
324
325    #[test]
326    fn star_matches_zero_or_more() {
327        let re = Regex::new(c"a*b").unwrap();
328        let m = re.find_at(c"b", 0).unwrap();
329        assert_eq!((m.start(), m.end()), (0, 1));
330        let m = re.find_at(c"aaab", 0).unwrap();
331        assert_eq!((m.start(), m.end()), (0, 4));
332        assert!(re.find_at(c"aaa", 0).is_none());
333    }
334
335    #[test]
336    fn plus_no_match_exercises_backtrack_failure() {
337        let re = Regex::new(c"a+b").unwrap();
338        assert!(re.find_at(c"aaa", 0).is_none());
339    }
340
341    #[test]
342    fn question_matches_zero_or_one() {
343        let re = Regex::new(c"colou?r").unwrap();
344        assert!(re.find_at(c"color", 0).is_some());
345        assert!(re.find_at(c"colour", 0).is_some());
346        assert!(re.find_at(c"colouur", 0).is_none());
347    }
348
349    #[test]
350    fn metachar_digit_matches() {
351        let re = Regex::new(c"\\d+").unwrap();
352        let m = re.find_at(c"abc123def", 0).unwrap();
353        assert_eq!((m.start(), m.end()), (3, 6));
354        assert!(re.find_at(c"abc", 0).is_none());
355    }
356
357    #[test]
358    fn metachar_non_digit_matches() {
359        let re = Regex::new(c"\\D+").unwrap();
360        let m = re.find_at(c"123abc", 0).unwrap();
361        assert_eq!((m.start(), m.end()), (3, 6));
362    }
363
364    #[test]
365    fn metachar_word_matches_including_underscore() {
366        let re = Regex::new(c"\\w+").unwrap();
367        let m = re.find_at(c"...abc_123", 0).unwrap();
368        assert_eq!((m.start(), m.end()), (3, 10));
369    }
370
371    #[test]
372    fn metachar_non_word_matches() {
373        let re = Regex::new(c"\\W+").unwrap();
374        let m = re.find_at(c"abc...", 0).unwrap();
375        assert_eq!((m.start(), m.end()), (3, 6));
376    }
377
378    #[test]
379    fn metachar_whitespace_matches() {
380        let re = Regex::new(c"\\s+").unwrap();
381        let m = re.find_at(c"abc   def", 0).unwrap();
382        assert_eq!((m.start(), m.end()), (3, 6));
383    }
384
385    #[test]
386    fn metachar_non_whitespace_matches() {
387        let re = Regex::new(c"\\S+").unwrap();
388        let m = re.find_at(c"   abc", 0).unwrap();
389        assert_eq!((m.start(), m.end()), (3, 6));
390    }
391
392    #[test]
393    fn escaped_literal_matches_exact_char() {
394        let re = Regex::new(c"\\.").unwrap();
395        let m = re.find_at(c"abc.def", 0).unwrap();
396        assert_eq!((m.start(), m.end()), (3, 4));
397        assert!(re.find_at(c"abc", 0).is_none());
398    }
399
400    #[test]
401    fn inverted_char_class_matches_complement() {
402        let re = Regex::new(c"[^abc]+").unwrap();
403        let m = re.find_at(c"abcxyz", 0).unwrap();
404        assert_eq!((m.start(), m.end()), (3, 6));
405        assert!(re.find_at(c"aaa", 0).is_none());
406    }
407
408    #[test]
409    fn metachar_inside_char_class_matches() {
410        let re = Regex::new(c"[\\d]+").unwrap();
411        let m = re.find_at(c"abc123", 0).unwrap();
412        assert_eq!((m.start(), m.end()), (3, 6));
413        assert!(re.find_at(c"abc", 0).is_none());
414    }
415
416    #[test]
417    fn char_class_post_loop_overflow_returns_none() {
418        // ccl_bufidx starts at 1; CCL-1 chars fill indices 1..CCL-1, leaving
419        // ccl_bufidx == CCL at loop exit — triggers the post-loop overflow check.
420        let pattern = alloc::ffi::CString::new(alloc::format!("[{}]", "a".repeat(DEFAULT_CCL - 1))).unwrap();
421        assert!(Regex::new(pattern.as_c_str()).is_none());
422    }
423
424    #[test]
425    fn char_class_in_loop_overflow_returns_none() {
426        // At the CCL-th char, ccl_bufidx is already CCL going into the loop
427        // body — triggers the in-loop overflow check (ccl_bufidx >= ccl_size).
428        let pattern = alloc::ffi::CString::new(alloc::format!("[{}]", "a".repeat(DEFAULT_CCL))).unwrap();
429        assert!(Regex::new(pattern.as_c_str()).is_none());
430    }
431
432    #[test]
433    fn char_class_backslash_overflow_returns_none() {
434        // CCL-2 normal chars bring ccl_bufidx to CCL-1; the next '\\'
435        // checks ccl_bufidx + 1 >= ccl_size — triggers the backslash-specific
436        // overflow guard that reserves space for two bytes.
437        let pattern = alloc::ffi::CString::new(alloc::format!("[{}\\d]", "a".repeat(DEFAULT_CCL - 2))).unwrap();
438        assert!(Regex::new(pattern.as_c_str()).is_none());
439    }
440
441    #[test]
442    fn inverted_char_class_incomplete_returns_none() {
443        assert!(Regex::new(c"[^").is_none());
444    }
445
446    #[test]
447    fn char_class_backslash_at_end_returns_none() {
448        assert!(Regex::new(c"[a\\").is_none());
449    }
450
451    #[test]
452    fn metachar_non_digit_in_char_class_matches() {
453        let re = Regex::new(c"[\\D]+").unwrap();
454        let m = re.find_at(c"123abc", 0).unwrap();
455        assert_eq!((m.start(), m.end()), (3, 6));
456    }
457
458    #[test]
459    fn metachar_word_in_char_class_matches() {
460        let re = Regex::new(c"[\\w]+").unwrap();
461        let m = re.find_at(c"...abc_1", 0).unwrap();
462        assert_eq!((m.start(), m.end()), (3, 8));
463    }
464
465    #[test]
466    fn metachar_non_word_in_char_class_matches() {
467        let re = Regex::new(c"[\\W]+").unwrap();
468        let m = re.find_at(c"abc...", 0).unwrap();
469        assert_eq!((m.start(), m.end()), (3, 6));
470    }
471
472    #[test]
473    fn metachar_whitespace_in_char_class_matches() {
474        let re = Regex::new(c"[\\s]+").unwrap();
475        let m = re.find_at(c"abc   ", 0).unwrap();
476        assert_eq!((m.start(), m.end()), (3, 6));
477    }
478
479    #[test]
480    fn metachar_non_whitespace_in_char_class_matches() {
481        let re = Regex::new(c"[\\S]+").unwrap();
482        let m = re.find_at(c"   abc", 0).unwrap();
483        assert_eq!((m.start(), m.end()), (3, 6));
484    }
485
486    #[test]
487    fn escaped_literal_in_char_class_matches() {
488        let re = Regex::new(c"[\\.]+").unwrap();
489        let m = re.find_at(c"abc...", 0).unwrap();
490        assert_eq!((m.start(), m.end()), (3, 6));
491        assert!(re.find_at(c"abc", 0).is_none());
492    }
493
494    #[test]
495    fn find_iter_zero_length_match_at_end_covers_offset_guard() {
496        let re = Regex::new(c"$").unwrap();
497        let matches: Vec<_> = re.find_iter(c"foo").collect();
498        assert_eq!(matches.len(), 1);
499        assert_eq!((matches[0].start(), matches[0].end()), (3, 3));
500    }
501
502    #[test]
503    fn char_class_trailing_dash_covers_matchrange_empty_str2() {
504        let re = Regex::new(c"[a-]+").unwrap();
505        let m = re.find_at(c"!a-!", 0).unwrap();
506        assert_eq!((m.start(), m.end()), (1, 3));
507    }
508
509    #[test]
510    fn dash_does_not_match_char_range() {
511        let re = Regex::new(c"[a-z]+").unwrap();
512        assert!(re.find_at(c"-", 0).is_none());
513    }
514
515    #[test]
516    fn question_no_match_covers_matchquestion_branches() {
517        let re = Regex::new(c"x?y").unwrap();
518        assert!(re.find_at(c"z", 0).is_none());
519    }
520
521    #[test]
522    fn end_anchor_mid_pattern_covers_end_non_unused_branch() {
523        let re = Regex::new(c"$a").unwrap();
524        assert!(re.find_at(c"a", 0).is_none());
525    }
526
527    #[test]
528    fn memo_bounds_pathological_backtrack() {
529        let re = Regex::new(c"a*a*b").unwrap();
530        let haystack = alloc::ffi::CString::new("a".repeat(32)).unwrap();
531        assert!(re.find_at(haystack.as_c_str(), 0).is_none());
532    }
533
534    // Memo tests ---------------------------------------------------------------
535
536    #[test]
537    fn per_attempt_memo_reset_finds_match_past_long_prefix() {
538        let re = Regex::new(c"a*a*b").unwrap();
539        let haystack = alloc::ffi::CString::new("a".repeat(200) + "cb").unwrap();
540        let m = re.find_at(haystack.as_c_str(), 0).unwrap();
541        assert_eq!(m.start(), 201);
542        assert_eq!(m.end(), 202);
543    }
544
545    #[test]
546    fn bplus_c_skips_long_decoy_brun() {
547        let re = Regex::new(c"b+c").unwrap();
548        let haystack = alloc::ffi::CString::new(
549            "a".repeat(50) + &"b".repeat(100) + &"a".repeat(50) + &"b".repeat(5) + "c" + &"a".repeat(10)
550        ).unwrap();
551        let m = re.find_at(haystack.as_c_str(), 0).unwrap();
552        assert_eq!(m.start(), 200);
553        assert_eq!(m.end(), 206);
554    }
555
556    #[test]
557    fn memo_finds_match_on_pathological_haystack() {
558        let haystack = alloc::ffi::CString::new("a".repeat(32) + "cb").unwrap();
559        let re = Regex::new(c"a*a*b").unwrap();
560        let m = re.find_at(haystack.as_c_str(), 0).unwrap();
561        assert_eq!(m.start(), 33);
562        assert_eq!(m.end(), 34);
563    }
564
565    #[test]
566    fn memo_correctly_returns_none_when_no_match_exists() {
567        let haystack = alloc::ffi::CString::new("a".repeat(32)).unwrap();
568        let re = Regex::new(c"a*a*b").unwrap();
569        assert!(re.find_at(haystack.as_c_str(), 0).is_none());
570    }
571
572    // Exercises various RegexBuf<N, CCL, MEMO> parameter combinations.
573    //
574    // Each row calls check::<N, CCL, MEMO>(pattern, haystack, expected match).
575    // The memo_stride passed to C is MEMO*8/N; we verify correctness across:
576    //   - minimum and odd N values
577    //   - MEMO=0 (no memo), tiny MEMO (stride=1), non-power-of-2 MEMO
578    //   - char classes (exercises CCL)
579    //   - backtracking-heavy patterns (stress the memo)
580    #[test]
581    fn regexbuf_parameter_variants() {
582        fn check<const N: usize, const CCL: usize, const MEMO: usize>(
583            pattern: &CStr,
584            haystack: &CStr,
585            expected: Option<(usize, usize)>,
586        ) {
587            let re = RegexBuf::<N, CCL, MEMO>::new(pattern).unwrap_or_else(|| {
588                panic!("compile failed: {pattern:?}  N={N} CCL={CCL} MEMO={MEMO}")
589            });
590            let got = re.find_at(haystack, 0).map(|m| (m.start(), m.end()));
591            assert_eq!(
592                got, expected,
593                "N={N} CCL={CCL} MEMO={MEMO}  pattern={pattern:?}  haystack={haystack:?}"
594            );
595        }
596
597        // --- N=2: absolute minimum (1 data node + UNUSED sentinel) ---
598        // pattern "a" → [CHAR('a'), UNUSED]
599        check::<2, 2, 0>(c"a", c"a",   Some((0, 1)));
600        check::<2, 2, 0>(c"a", c"b",   None);
601        check::<2, 2, 2>(c"a", c"xax", Some((1, 2)));  // stride = 2*8/2 = 8
602
603        // --- N=3: anchors, single quantifier ---
604        // "^a" → [BEGIN, CHAR('a'), UNUSED]
605        check::<3, 2, 0>(c"^a", c"ax", Some((0, 1)));
606        check::<3, 2, 0>(c"^a", c"ba", None);
607        // "a+" → [CHAR('a'), PLUS, UNUSED]
608        check::<3, 2, 0>(c"a+", c"aaa", Some((0, 3)));
609        check::<3, 2, 3>(c"a+", c"bbb", None);          // stride = 3*8/3 = 8
610
611        // --- N=4: two-operand quantifier (CHAR STAR CHAR UNUSED) ---
612        // "a*b" — exercises memo on the STAR backtrack path
613        check::<4, 2, 0>(c"a*b", c"b",    Some((0, 1)));
614        check::<4, 2, 0>(c"a*b", c"aaab", Some((0, 4)));
615        check::<4, 2, 0>(c"a*b", c"aaa",  None);
616        check::<4, 2, 4>(c"a*b", c"b",    Some((0, 1)));  // stride = 4*8/4 = 8
617        check::<4, 2, 4>(c"a*b", c"aaab", Some((0, 4)));
618        check::<4, 2, 4>(c"a*b", c"aaa",  None);
619
620        // --- N=5 (odd): "a+b+" → [CHAR(a), PLUS, CHAR(b), PLUS, UNUSED] ---
621        check::<5, 2, 0>(c"a+b+", c"ab",   Some((0, 2)));
622        check::<5, 2, 5>(c"a+b+", c"aabb", Some((0, 4)));  // stride = 5*8/5 = 8
623        check::<5, 2, 5>(c"a+b+", c"aa",   None);
624
625        // --- N=6: "a*b+c" → [CHAR(a), STAR, CHAR(b), PLUS, CHAR(c), UNUSED] ---
626        check::<6, 2, 0>(c"a*b+c", c"bc",   Some((0, 2)));
627        check::<6, 2, 6>(c"a*b+c", c"abbc", Some((0, 4)));  // stride = 6*8/6 = 8
628        check::<6, 2, 6>(c"a*b+c", c"ac",   None);
629
630        // --- N=7 (odd): pathological backtracker "a*a*b" (6 nodes, fits N=7) ---
631        check::<7, 2, 0>(c"a*a*b", c"b",    Some((0, 1)));
632        check::<7, 2, 7>(c"a*a*b", c"aaab", Some((0, 4)));  // stride = 7*8/7 = 8
633        check::<7, 2, 7>(c"a*a*b", c"aaa",  None);
634
635        // --- Non-divisible MEMO: N=3, MEMO=2 → stride = 2*8/3 = 5 ---
636        // stride rounds down; memo covers fewer offsets but must stay correct
637        check::<3, 2, 2>(c"a+", c"aaa", Some((0, 3)));
638        check::<3, 2, 2>(c"a+", c"bbb", None);
639
640        // --- Tiny memo stride: N=8, MEMO=1 → stride = 1*8/8 = 1 ---
641        // Only text offset 0 per node is memoised; deeper offsets fall through
642        check::<8, 2, 1>(c"a*b", c"b",    Some((0, 1)));
643        check::<8, 2, 1>(c"a*b", c"aaab", Some((0, 4)));
644        check::<8, 2, 1>(c"a*b", c"aaa",  None);
645
646        // --- Larger stride: N=8, MEMO=64 → stride = 64*8/8 = 64 ---
647        check::<8, 2, 64>(c"a+b+", c"aabb",  Some((0, 4)));
648        check::<8, 2, 64>(c"a+b+", c"bbbbb", None);
649
650        // --- Character classes ---
651        // "[a-z]+" → [CHAR_CLASS, PLUS, UNUSED] (3 nodes); needs CCL >= 5
652        check::<3, 8, 0>(c"[a-z]+", c"hello", Some((0, 5)));
653        check::<3, 8, 3>(c"[a-z]+", c"123",   None);          // stride = 8
654        check::<4, 8, 4>(c"[0-9]+", c"abc42", Some((3, 5)));  // stride = 8
655        // Inverted class
656        check::<3, 8, 0>(c"[^abc]+", c"abcxyz", Some((3, 6)));
657        check::<3, 8, 0>(c"[^abc]+", c"aaa",    None);
658
659        // SmallRe (bench type): N=8, CCL=16, MEMO=64 → stride = 64
660        check::<8, 16, 64>(c"[a-z]+", c"hello world", Some((0, 5)));
661        check::<8, 16, 64>(c"[a-z]+", c"123",         None);
662        check::<8, 16,  0>(c"[a-z]+", c"hello",       Some((0, 5)));  // no memo
663
664        // Default Regex sizes
665        check::<32, 64, 256>(c"[a-z]+[0-9]*", c"abc123", Some((0, 6)));
666        check::<32, 64, 256>(c"[a-z]+[0-9]*", c"abc",    Some((0, 3)));
667        check::<32, 64, 256>(c"[a-z]+[0-9]*", c"123",    None);
668    }
669
670    #[test]
671    fn memo_is_fresh_each_call() {
672        let h1 = alloc::ffi::CString::new("a".repeat(32) + "cb").unwrap();
673        let h2 = alloc::ffi::CString::new("xb").unwrap();
674        let re = Regex::new(c"a*a*b").unwrap();
675        let m1 = re.find_at(h1.as_c_str(), 0).unwrap();
676        assert_eq!(m1.start(), 33);
677        let m2 = re.find_at(h2.as_c_str(), 0).unwrap();
678        assert_eq!(m2.start(), 1);
679    }
680
681    #[test]
682    fn non_ascii_literal_byte_matches() {
683        let re = Regex::new(c"é").unwrap();
684        let m = re.find_at(c"caf\xc3\xa9", 0).unwrap();
685        assert_eq!(m.start(), 3);
686        assert_eq!(m.end(), 5);
687    }
688
689    #[test]
690    fn question_is_greedy() {
691        let re = Regex::new(c"a?").unwrap();
692        let m = re.find_at(c"a", 0).unwrap();
693        assert_eq!((m.start(), m.end()), (0, 1));
694    }
695
696    #[test]
697    fn matchlength_not_leaked_across_failed_attempts() {
698        let re = Regex::new(c".0+").unwrap();
699        let m = re.find_at(c"aa0", 0).unwrap();
700        assert_eq!(m.start(), 1);
701        assert_eq!(m.end(), 3);
702    }
703
704    #[test]
705    fn tiny_regex_no_memo_matches_correctly() {
706        let re = TinyRegex::new(c"[0-9]+").unwrap();
707        let m = re.find_at(c"foo42bar", 0).unwrap();
708        assert_eq!((m.start(), m.end()), (3, 5));
709        assert!(re.find_at(c"abc", 0).is_none());
710    }
711}