Skip to main content

chrono_node/core/
regex.rs

1//! JavaScript regular expressions on top of `fancy-regex`.
2//!
3//! Port patterns **verbatim from the TypeScript source** (the text between `/.../` or inside
4//! `new RegExp("...")` after JS string unescaping) and pass the JS flags. `JsRegex` translates
5//! the parts where JS and Rust differ:
6//!
7//! | JS (no `u` flag) | Rust default | Translation |
8//! |---|---|---|
9//! | `\d`, `\w`, `\b` are ASCII-only | Unicode-aware | explicit ASCII classes; `\b` as lookarounds |
10//! | `\s` is a fixed set incl. U+FEFF | Unicode `White_Space` | the JS set, listed explicitly |
11//! | `.` excludes `\n \r U+2028 U+2029` | excludes `\n` only | explicit class |
12//! | `\–` (escaped non-ASCII) is a literal | error | the literal character |
13//! | `[`, `&&`, `--`, `~~` in a class are literals | class syntax | escaped |
14//! | lookahead/lookbehind, backreferences | unsupported in `regex` | `fancy-regex` handles them |
15//!
16//! | `i` compares `Canonicalize(ch)`: the single-unit uppercase, never non-ASCII → ASCII, BMP only | Unicode simple case folding (U+017F LONG S ~ `s`, U+212A KELVIN SIGN ~ `k`, ...) | no `(?i)`: each literal becomes its JS-equivalence set, each class is closed under it |
17//!
18//! With `i`, backreferences are rejected (JS compares them canonicalized; no ported pattern uses one).
19//!
20//! **Unicode mode (`u`)**, as far as ported patterns need it (`[^\p{L}\p{N}\p{M}_]` in Spanish):
21//! `\p{..}`/`\P{..}` are Unicode property classes and `\u{..}` a code point (both passed to Rust),
22//! and `iu` compares by Unicode **simple case folding** (ECMA-262 `Canonicalize` with `rer.[[Unicode]]`),
23//! which is exactly Rust's `(?i)`. That includes `\w`/`\b` matching U+017F and U+212A, as JS `iu`
24//! does. `v` (unicode sets) is not supported.
25
26use std::collections::HashMap;
27use std::sync::{LazyLock, RwLock};
28
29use fancy_regex::Regex;
30
31/// JS `Canonicalize(ch)` for a non-unicode, case-insensitive regex (ECMA-262 §22.2.2.7.3): the
32/// uppercase of the single UTF-16 code unit if it is one code unit, unless that maps a non-ASCII
33/// character onto ASCII. Astral characters (surrogate pairs in JS) never fold.
34fn js_canonicalize(c: char) -> char {
35    if c as u32 > 0xFFFF {
36        return c;
37    }
38    let mut up = c.to_uppercase();
39    match (up.next(), up.next()) {
40        (Some(u), None) if (u as u32) <= 0xFFFF && !((c as u32) >= 128 && (u as u32) < 128) => u,
41        _ => c,
42    }
43}
44
45/// The BMP characters that share a `js_canonicalize` value with at least one other, by that value.
46static JS_CASE_GROUPS: LazyLock<HashMap<char, Vec<char>>> = LazyLock::new(|| {
47    let mut by: HashMap<char, Vec<char>> = HashMap::new();
48    for cp in 0..=0xFFFFu32 {
49        if let Some(c) = char::from_u32(cp) {
50            by.entry(js_canonicalize(c)).or_default().push(c);
51        }
52    }
53    by.retain(|_, v| v.len() > 1);
54    by
55});
56
57/// The characters a JS `/i` (non-unicode) regex treats as equal to `c`, including `c`.
58fn js_case_equivalents(c: char) -> Vec<char> {
59    JS_CASE_GROUPS
60        .get(&js_canonicalize(c))
61        .cloned()
62        .unwrap_or_else(|| vec![c])
63}
64
65fn class_char(c: char) -> String {
66    format!(r"\x{{{:X}}}", c as u32)
67}
68
69const JS_WHITESPACE: &str =
70    r"\t\n\x0B\x0C\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}";
71const WORD: &str = "A-Za-z0-9_";
72const NON_WORD_IN_CLASS: &str = r"\x{0}-\x{2F}\x{3A}-\x{40}\x{5B}-\x{5E}\x{60}\x{7B}-\x{10FFFF}";
73/// `\W` inside a class under `iu`: `(?i)` closes a class under case folding, and U+017F / U+212A
74/// fold to `s` / `k`, so they must be left out (JS `iu` counts them as word characters).
75const NON_WORD_IN_CLASS_IU: &str =
76    r"\x{0}-\x{2F}\x{3A}-\x{40}\x{5B}-\x{5E}\x{60}\x{7B}-\x{17E}\x{180}-\x{2129}\x{212B}-\x{10FFFF}";
77const NON_DIGIT_IN_CLASS: &str = r"\x{0}-\x{2F}\x{3A}-\x{10FFFF}";
78const WORD_BOUNDARY: &str = r"(?:(?<=[A-Za-z0-9_])(?![A-Za-z0-9_])|(?<![A-Za-z0-9_])(?=[A-Za-z0-9_]))";
79const NOT_WORD_BOUNDARY: &str = r"(?:(?<=[A-Za-z0-9_])(?=[A-Za-z0-9_])|(?<![A-Za-z0-9_])(?![A-Za-z0-9_]))";
80const META: &str = r"\.+*?()|[]{}^$#&-~";
81
82const COMPILED_CAP: usize = 4096;
83/// source → flags → compiled. Nested so a hit borrows `&str` keys (no allocation), under a shared lock.
84type CompiledCache = HashMap<String, HashMap<String, JsRegex>>;
85static COMPILED: LazyLock<RwLock<CompiledCache>> = LazyLock::new(|| RwLock::new(HashMap::new()));
86
87fn cached(source: &str, flags: &str) -> Option<JsRegex> {
88    let cache = COMPILED.read().unwrap_or_else(|e| e.into_inner());
89    cache.get(source).and_then(|by_flags| by_flags.get(flags)).cloned()
90}
91
92/// A compiled JS regular expression.
93#[derive(Clone, Debug)]
94pub struct JsRegex {
95    source: String,
96    flags: String,
97    re: Regex,
98}
99
100/// The result of `RegExp.prototype.exec`: `index` is a **byte** offset into the searched text,
101/// and `groups[i]` is `match[i]` (`None` = `undefined`, a group that didn't participate).
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct RegexMatch {
104    pub index: usize,
105    pub groups: Vec<Option<String>>,
106}
107
108impl RegexMatch {
109    /// `match[i]`, or `None` if undefined or out of range.
110    pub fn get(&self, i: usize) -> Option<&str> {
111        self.groups.get(i).and_then(|g| g.as_deref())
112    }
113
114    /// `match[0]`.
115    pub fn text(&self) -> &str {
116        self.get(0).unwrap_or("")
117    }
118
119    /// `match.length`.
120    pub fn len(&self) -> usize {
121        self.groups.len()
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.groups.is_empty()
126    }
127}
128
129impl JsRegex {
130    /// Compiles a JS pattern. Panics on an invalid pattern: patterns are constants, and every one
131    /// is compiled by the unit tests of the module that owns it.
132    pub fn new(source: &str, flags: &str) -> JsRegex {
133        JsRegex::try_new(source, flags).unwrap_or_else(|e| panic!("invalid JS regex /{source}/{flags}: {e}"))
134    }
135
136    /// Compiled once per `(source, flags)` in the process: upstream builds `new RegExp(...)` inside
137    /// methods called on every parse (`innerPattern()`), and a ported step that does the same must not
138    /// recompile a Unicode case-insensitive pattern each time (ru/uk ran ~30x slower than other locales).
139    pub fn try_new(source: &str, flags: &str) -> Result<JsRegex, String> {
140        if let Some(hit) = cached(source, flags) {
141            return Ok(hit);
142        }
143        let translated = translate(source, flags)?;
144        let re = Regex::new(&translated).map_err(|e| format!("{e} (translated: {translated})"))?;
145        let compiled = JsRegex {
146            source: source.to_string(),
147            flags: flags.to_string(),
148            re,
149        };
150        let mut cache = COMPILED.write().unwrap_or_else(|e| e.into_inner());
151        // Patterns come from the ported code (finite); the cap only guards against unbounded growth.
152        let entries: usize = cache.values().map(HashMap::len).sum();
153        if entries < COMPILED_CAP {
154            cache
155                .entry(source.to_string())
156                .or_default()
157                .insert(flags.to_string(), compiled.clone());
158        }
159        Ok(compiled)
160    }
161
162    /// `regex.source`: the original JS pattern text.
163    pub fn source(&self) -> &str {
164        &self.source
165    }
166
167    /// `regex.flags`.
168    pub fn flags(&self) -> &str {
169        &self.flags
170    }
171
172    /// `regex.exec(text)` for a non-global regex: the first match, searching from the start.
173    /// A backtracking-limit error counts as "no match".
174    pub fn exec(&self, text: &str) -> Option<RegexMatch> {
175        let caps = self.re.captures(text).ok()??;
176        let index = caps.get(0)?.start();
177        let groups = (0..caps.len())
178            .map(|i| caps.get(i).map(|m| m.as_str().to_string()))
179            .collect();
180        Some(RegexMatch { index, groups })
181    }
182
183    /// `regex.test(text)`, or the truthiness of `text.match(regex)`.
184    pub fn is_match(&self, text: &str) -> bool {
185        self.re.is_match(text).unwrap_or(false)
186    }
187}
188
189/// A lazily compiled `&'static JsRegex`: `js_regex!(r"(\d+)\s*days?", "i")`.
190#[macro_export]
191macro_rules! js_regex {
192    ($source:expr) => {
193        $crate::js_regex!($source, "")
194    };
195    ($source:expr, $flags:expr) => {{
196        static RE: std::sync::LazyLock<$crate::core::regex::JsRegex> =
197            std::sync::LazyLock::new(|| $crate::core::regex::JsRegex::new($source, $flags));
198        &*RE
199    }};
200}
201
202fn push_literal(out: &mut String, c: char) {
203    if META.contains(c) {
204        out.push('\\');
205    }
206    out.push(c);
207}
208
209fn hex_digits(chars: &[char], start: usize, count: usize) -> Option<String> {
210    let s: String = chars.get(start..start + count)?.iter().collect();
211    s.chars().all(|c| c.is_ascii_hexdigit()).then_some(s)
212}
213
214/// Length of a `{n}`, `{n,}` or `{n,m}` quantifier starting at `chars[i] == '{'`, if valid.
215fn quantifier_len(chars: &[char], i: usize) -> Option<usize> {
216    let mut j = i + 1;
217    let digits_start = j;
218    while j < chars.len() && chars[j].is_ascii_digit() {
219        j += 1;
220    }
221    if j == digits_start {
222        return None;
223    }
224    if chars.get(j) == Some(&',') {
225        j += 1;
226        while j < chars.len() && chars[j].is_ascii_digit() {
227            j += 1;
228        }
229    }
230    (chars.get(j) == Some(&'}')).then_some(j - i + 1)
231}
232
233/// A class atom: its code-point ranges, whether it is a single character, and its source length.
234type ClassAtom = (Vec<(u32, u32)>, bool, usize);
235
236/// One class atom at `chars[i]` (after `[`): its code-point ranges and its length, or None at `]`.
237/// Ranges for class escapes (`\d`, `\W`, ...) are returned with `single = false` (no `a-b` with them).
238fn class_atom(chars: &[char], i: usize) -> Result<Option<ClassAtom>, String> {
239    let Some(&c) = chars.get(i) else {
240        return Err("unterminated character class".into());
241    };
242    if c == ']' {
243        return Ok(None);
244    }
245    if c != '\\' {
246        return Ok(Some((vec![(c as u32, c as u32)], true, 1)));
247    }
248    let Some(&e) = chars.get(i + 1) else {
249        return Err("trailing backslash".into());
250    };
251    let one = |v: u32, len: usize| Ok(Some((vec![(v, v)], true, len)));
252    let set = |r: Vec<(u32, u32)>| Ok(Some((r, false, 2)));
253    let word = vec![(0x30, 0x39), (0x41, 0x5A), (0x5F, 0x5F), (0x61, 0x7A)];
254    match e {
255        'd' => set(vec![(0x30, 0x39)]),
256        'D' => set(vec![(0, 0x2F), (0x3A, 0x10FFFF)]),
257        'w' => set(word),
258        'W' => set(vec![
259            (0, 0x2F),
260            (0x3A, 0x40),
261            (0x5B, 0x5E),
262            (0x60, 0x60),
263            (0x7B, 0x10FFFF),
264        ]),
265        's' => set(vec![
266            (0x09, 0x0D),
267            (0x20, 0x20),
268            (0xA0, 0xA0),
269            (0x1680, 0x1680),
270            (0x2000, 0x200A),
271            (0x2028, 0x2029),
272            (0x202F, 0x202F),
273            (0x205F, 0x205F),
274            (0x3000, 0x3000),
275            (0xFEFF, 0xFEFF),
276        ]),
277        'b' => one(8, 2),
278        'n' => one(10, 2),
279        'r' => one(13, 2),
280        't' => one(9, 2),
281        'f' => one(12, 2),
282        'v' => one(11, 2),
283        'u' => match hex_digits(chars, i + 2, 4) {
284            Some(h) => one(u32::from_str_radix(&h, 16).unwrap(), 6),
285            None => one('u' as u32, 2),
286        },
287        'x' => match hex_digits(chars, i + 2, 2) {
288            Some(h) => one(u32::from_str_radix(&h, 16).unwrap(), 4),
289            None => one('x' as u32, 2),
290        },
291        '0' if !chars.get(i + 2).is_some_and(|d| d.is_ascii_digit()) => one(0, 2),
292        other => one(other as u32, 2),
293    }
294}
295
296/// Whether `out` ends with an unescaped `-` (an even number of backslashes before it).
297fn ends_with_bare_dash(out: &str) -> bool {
298    let Some(rest) = out.strip_suffix('-') else {
299        return false;
300    };
301    rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 0
302}
303
304/// For a case-insensitive class starting at `chars[start]` (its first member): the characters JS
305/// treats as equal to a member but the class doesn't list, as class text to append before `]`.
306fn class_case_closure(chars: &[char], start: usize) -> Result<String, String> {
307    let mut ranges: Vec<(u32, u32)> = Vec::new();
308    let mut i = start;
309    while let Some((r, single, len)) = class_atom(chars, i)? {
310        i += len;
311        // `a-b`: a range between two single characters (not at the end of the class).
312        if single && chars.get(i) == Some(&'-') && chars.get(i + 1).is_some_and(|&n| n != ']') {
313            if let Some((r2, true, len2)) = class_atom(chars, i + 1)? {
314                ranges.push((r[0].0, r2[0].1));
315                i += 1 + len2;
316                continue;
317            }
318        }
319        ranges.extend(r);
320    }
321    let has = |c: char| ranges.iter().any(|&(a, b)| (a..=b).contains(&(c as u32)));
322    let mut extra: Vec<char> = Vec::new();
323    for group in JS_CASE_GROUPS.values() {
324        if group.iter().any(|&c| has(c)) {
325            extra.extend(group.iter().copied().filter(|&c| !has(c)));
326        }
327    }
328    extra.sort_unstable();
329    Ok(extra.into_iter().map(class_char).collect())
330}
331
332/// A literal character outside a class: with `i`, the set of its JS equivalents.
333fn push_ci_literal(out: &mut String, c: char, ci: bool) {
334    let eq = if ci { js_case_equivalents(c) } else { vec![c] };
335    if eq.len() > 1 {
336        out.push('[');
337        for e in eq {
338            out.push_str(&class_char(e));
339        }
340        out.push(']');
341    } else {
342        push_literal(out, c);
343    }
344}
345
346/// Translates JS regex source (non-unicode mode) to `fancy-regex` syntax.
347pub fn translate(source: &str, flags: &str) -> Result<String, String> {
348    let mut out = String::new();
349    let mut inline = String::new();
350    let unicode = flags.contains('u');
351    // Without `u`, `i` is applied by expansion (JS canonicalize), not by Rust's Unicode case
352    // folding. With `u`, JS folds by simple case folding: Rust's `(?i)`.
353    let ci = flags.contains('i') && !unicode;
354    let unicode_ci = flags.contains('i') && unicode;
355    for f in flags.chars() {
356        match f {
357            'i' if unicode => inline.push('i'),
358            'i' | 'u' => {}
359            'm' | 's' => inline.push(f),
360            'g' | 'y' | 'd' => return Err(format!("flag '{f}' changes exec() state; not supported")),
361            'v' => return Err(format!("flag '{f}' (unicode sets) is not supported")),
362            _ => return Err(format!("unknown flag '{f}'")),
363        }
364    }
365    if !inline.is_empty() {
366        out.push_str(&format!("(?{inline})"));
367    }
368
369    let chars: Vec<char> = source.chars().collect();
370    let n = chars.len();
371    let mut in_class = false;
372    let mut class_extra = String::new();
373    let mut i = 0;
374    while i < n {
375        let c = chars[i];
376        if c == '\\' {
377            i += 1;
378            let Some(&e) = chars.get(i) else {
379                return Err("trailing backslash".into());
380            };
381            match e {
382                'd' => out.push_str(if in_class { "0-9" } else { "[0-9]" }),
383                'D' => {
384                    if in_class {
385                        out.push_str(NON_DIGIT_IN_CLASS)
386                    } else {
387                        out.push_str("[^0-9]")
388                    }
389                }
390                'w' => {
391                    if in_class {
392                        out.push_str(WORD)
393                    } else {
394                        out.push_str(&format!("[{WORD}]"))
395                    }
396                }
397                'W' => {
398                    if in_class {
399                        out.push_str(if unicode_ci {
400                            NON_WORD_IN_CLASS_IU
401                        } else {
402                            NON_WORD_IN_CLASS
403                        })
404                    } else {
405                        out.push_str(&format!("[^{WORD}]"))
406                    }
407                }
408                's' => {
409                    if in_class {
410                        out.push_str(JS_WHITESPACE)
411                    } else {
412                        out.push_str(&format!("[{JS_WHITESPACE}]"))
413                    }
414                }
415                'S' => {
416                    if in_class {
417                        return Err(r"\S inside a character class is not supported".into());
418                    }
419                    out.push_str(&format!("[^{JS_WHITESPACE}]"));
420                }
421                'b' => out.push_str(if in_class { r"\x{8}" } else { WORD_BOUNDARY }),
422                'B' => {
423                    if in_class {
424                        return Err(r"\B inside a character class".into());
425                    }
426                    out.push_str(NOT_WORD_BOUNDARY);
427                }
428                'n' => out.push_str(r"\n"),
429                'r' => out.push_str(r"\r"),
430                't' => out.push_str(r"\t"),
431                'f' => out.push_str(r"\x{C}"),
432                'v' => out.push_str(r"\x{B}"),
433                'u' if unicode && chars.get(i + 1) == Some(&'{') => {
434                    let close = chars[i + 1..]
435                        .iter()
436                        .position(|&c| c == '}')
437                        .ok_or("unterminated \\u{")?;
438                    let h: String = chars[i + 2..i + 1 + close].iter().collect();
439                    if h.is_empty() || !h.chars().all(|c| c.is_ascii_hexdigit()) {
440                        return Err(format!(r"invalid \u{{{h}}}"));
441                    }
442                    out.push_str(&format!(r"\x{{{h}}}"));
443                    i += close + 1;
444                }
445                'p' | 'P' if unicode && chars.get(i + 1) == Some(&'{') => {
446                    let close = chars[i + 1..]
447                        .iter()
448                        .position(|&c| c == '}')
449                        .ok_or("unterminated \\p{")?;
450                    let name: String = chars[i + 2..i + 1 + close].iter().collect();
451                    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '=') {
452                        return Err(format!(r"invalid \{e}{{{name}}}"));
453                    }
454                    out.push_str(&format!(r"\{e}{{{name}}}"));
455                    i += close + 1;
456                }
457                'u' => match hex_digits(&chars, i + 1, 4) {
458                    Some(h) => {
459                        let ch = char::from_u32(u32::from_str_radix(&h, 16).unwrap());
460                        match ch {
461                            Some(ch) if ci && !in_class => push_ci_literal(&mut out, ch, ci),
462                            _ => out.push_str(&format!(r"\x{{{h}}}")),
463                        }
464                        i += 4;
465                    }
466                    None => push_ci_literal(&mut out, 'u', ci && !in_class),
467                },
468                'x' => match hex_digits(&chars, i + 1, 2) {
469                    Some(h) => {
470                        let ch = char::from_u32(u32::from_str_radix(&h, 16).unwrap());
471                        match ch {
472                            Some(ch) if ci && !in_class => push_ci_literal(&mut out, ch, ci),
473                            _ => out.push_str(&format!(r"\x{{{h}}}")),
474                        }
475                        i += 2;
476                    }
477                    None => push_ci_literal(&mut out, 'x', ci && !in_class),
478                },
479                '0' if !chars.get(i + 1).is_some_and(|d| d.is_ascii_digit()) => out.push_str(r"\x{0}"),
480                '1'..='9' if !in_class => {
481                    if ci {
482                        return Err("backreferences with the `i` flag are not supported".into());
483                    }
484                    out.push('\\');
485                    out.push(e);
486                    while chars.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
487                        i += 1;
488                        out.push(chars[i]);
489                    }
490                }
491                'c' | 'k' | 'p' | 'P' => return Err(format!(r"\{e} is not supported")),
492                // Identity escapes: `\-`, `\.`, `\/`, `\–`, `\:` ... are the literal character.
493                other => push_ci_literal(&mut out, other, ci && !in_class),
494            }
495            i += 1;
496            continue;
497        }
498
499        if in_class {
500            match c {
501                ']' => {
502                    in_class = false;
503                    if !class_extra.is_empty() && ends_with_bare_dash(&out) {
504                        // JS reads a final `-` as a literal; the appended members would make it a range.
505                        out.pop();
506                        out.push_str(r"\-");
507                    }
508                    out.push_str(&std::mem::take(&mut class_extra));
509                    out.push(']');
510                }
511                '[' | '&' | '~' => {
512                    out.push('\\');
513                    out.push(c);
514                }
515                '-' if chars.get(i + 1) == Some(&'-') => out.push_str(r"\-"),
516                _ => out.push(c),
517            }
518            i += 1;
519            continue;
520        }
521
522        match c {
523            '[' => {
524                if chars.get(i + 1) == Some(&']') || (chars.get(i + 1) == Some(&'^') && chars.get(i + 2) == Some(&']'))
525                {
526                    return Err("empty character classes ([] or [^]) are not supported".into());
527                }
528                in_class = true;
529                out.push('[');
530                if chars.get(i + 1) == Some(&'^') {
531                    out.push('^');
532                    i += 1;
533                }
534                if ci {
535                    class_extra = class_case_closure(&chars, i + 1)?;
536                }
537            }
538            '.' => out.push_str(r"[^\n\r\x{2028}\x{2029}]"),
539            '(' if chars.get(i + 1) == Some(&'?')
540                && chars.get(i + 2) == Some(&'<')
541                && !matches!(chars.get(i + 3), Some('=') | Some('!')) =>
542            {
543                out.push_str("(?P<");
544                i += 3;
545                // The group name is copied verbatim: it isn't matched text, so `i` mustn't expand it.
546                while let Some(&n) = chars.get(i) {
547                    out.push(n);
548                    i += 1;
549                    if n == '>' {
550                        break;
551                    }
552                }
553                continue;
554            }
555            '{' => match quantifier_len(&chars, i) {
556                Some(len) => {
557                    out.extend(&chars[i..i + len]);
558                    i += len;
559                    continue;
560                }
561                None => out.push_str(r"\{"),
562            },
563            '}' => out.push_str(r"\}"),
564            // Syntax characters (`( ) | * + ? ^ $`) have no case equivalents, so they pass unchanged.
565            _ if ci && js_case_equivalents(c).len() > 1 => push_ci_literal(&mut out, c, true),
566            _ => out.push(c),
567        }
568        i += 1;
569    }
570    if in_class {
571        return Err("unterminated character class".into());
572    }
573    Ok(out)
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn compiled_once_per_source_and_flags() {
582        let src = r"(?:с|со)?\s*(сегодня|завтра)(?=[^\p{L}\p{N}_]|$)";
583        let a = JsRegex::new(src, "iu");
584        // Cached unless the (global, capped) cache was already full when it was compiled.
585        let full = COMPILED
586            .read()
587            .unwrap_or_else(|e| e.into_inner())
588            .values()
589            .map(HashMap::len)
590            .sum::<usize>()
591            >= COMPILED_CAP;
592        assert!(full || cached(src, "iu").is_some());
593        let b = JsRegex::new(src, "iu");
594        assert_eq!((a.source(), a.flags()), (b.source(), b.flags()));
595        assert_eq!(
596            b.exec("с Завтра").map(|m| m.groups),
597            Some(vec![Some("с Завтра".into()), Some("Завтра".into())])
598        );
599        // Other flags are a different regex.
600        assert!(JsRegex::new(src, "u").exec("с Завтра").is_none());
601        assert!(JsRegex::try_new("(", "").is_err());
602    }
603
604    fn m(src: &str, flags: &str, text: &str) -> Option<(usize, Vec<Option<String>>)> {
605        JsRegex::new(src, flags).exec(text).map(|m| (m.index, m.groups))
606    }
607
608    #[test]
609    fn ascii_classes_like_js() {
610        assert!(JsRegex::new(r"^\w+$", "").is_match("abc_09"));
611        assert!(!JsRegex::new(r"^\w+$", "").is_match("café"));
612        assert!(!JsRegex::new(r"^\d$", "").is_match("٣")); // Arabic-Indic digit
613        assert!(JsRegex::new(r"^\W$", "").is_match("é"));
614        assert!(JsRegex::new(r"^[\W]$", "").is_match("é"));
615        assert!(!JsRegex::new(r"^[\W]$", "").is_match("a"));
616        assert!(JsRegex::new(r"^[\D]$", "").is_match("x"));
617    }
618
619    #[test]
620    fn whitespace_and_dot_like_js() {
621        assert!(JsRegex::new(r"^\s$", "").is_match("\u{FEFF}"));
622        assert!(JsRegex::new(r"^\s$", "").is_match("\u{3000}"));
623        assert!(!JsRegex::new(r"^\s$", "").is_match("\u{85}")); // NEL is not JS whitespace
624        assert!(!JsRegex::new(r"^.$", "").is_match("\r"));
625        assert!(!JsRegex::new(r"^.$", "").is_match("\u{2028}"));
626        assert!(JsRegex::new(r"^.$", "").is_match("é"));
627    }
628
629    #[test]
630    fn word_boundary_is_ascii() {
631        // "é" is not a JS word character, so there is a boundary between "caf" and "é".
632        assert_eq!(m(r"caf\b", "", "café"), Some((0, vec![Some("caf".into())])));
633        assert_eq!(m(r"\bnow\b", "i", "right NOW!"), Some((6, vec![Some("NOW".into())])));
634        assert!(JsRegex::new(r"a\Bb", "").is_match("ab"));
635    }
636
637    #[test]
638    fn lookaround_and_groups() {
639        // The ENCasualDateParser pattern, with lookahead and a left-boundary group.
640        let re = JsRegex::new(r"(\W|^)(now|today|tonight|last\s*night)(?=\W|$)", "i");
641        let mm = re.exec("see you Tonight.").unwrap();
642        assert_eq!(mm.index, 7);
643        assert_eq!(
644            mm.groups,
645            vec![Some(" Tonight".into()), Some(" ".into()), Some("Tonight".into())]
646        );
647        assert!(re.exec("nowhere").is_none());
648        // Undefined optional groups are None.
649        let mm = JsRegex::new(r"(a)(b)?", "").exec("a").unwrap();
650        assert_eq!(mm.get(2), None);
651        assert_eq!(mm.len(), 3);
652        assert!(JsRegex::new(r"(?<!x)y", "").is_match("ay"));
653    }
654
655    #[test]
656    fn escapes_and_literals() {
657        assert!(JsRegex::new(r"^\–\〜\:$", "").is_match("–〜:"));
658        assert!(JsRegex::new(r"^\/\-\.$", "").is_match("/-."));
659        assert!(JsRegex::new(r"^[\-\–~&]+$", "").is_match("-–~&"));
660        assert!(JsRegex::new(r"^\u00e9\x41$", "").is_match("éA"));
661        assert!(JsRegex::new(r"^a{2,3}$", "").is_match("aaa"));
662        assert!(JsRegex::new(r"^{x}$", "").is_match("{x}"));
663        assert!(JsRegex::new(r"^(?<word>ab)\1$", "").is_match("abab"));
664        assert!(JsRegex::new(r"^[\[\]]+$", "").is_match("[]"));
665    }
666
667    #[test]
668    fn rejects_unsupported() {
669        assert!(JsRegex::try_new("a", "g").is_err());
670        assert!(JsRegex::try_new("a", "v").is_err());
671        assert!(JsRegex::try_new("[]", "").is_err());
672    }
673
674    #[test]
675    fn case_insensitive_flag() {
676        assert!(JsRegex::new(r"^TODAY$", "i").is_match("today"));
677        assert!(!JsRegex::new(r"^TODAY$", "").is_match("today"));
678    }
679
680    /// JS `/i` without `u` folds by `Canonicalize` (single-unit uppercase, never non-ASCII onto
681    /// ASCII), not by Unicode case folding. Found by the reviewer on #136 (the ` EſT` example).
682    #[test]
683    fn case_insensitive_is_js_canonicalize() {
684        let abbr = JsRegex::new(r"^\s*([A-Z]{2,4})(?:\W|$)", "i");
685        assert_eq!(
686            abbr.exec(" EsT").and_then(|m| m.get(1).map(str::to_string)),
687            Some("EsT".into())
688        );
689        assert!(!abbr.is_match(" E\u{17F}T"), "U+017F LONG S is not `s` in JS");
690        let k = JsRegex::new("k", "i");
691        assert!(k.is_match("K") && k.is_match("k"));
692        assert!(!k.is_match("\u{212A}"), "U+212A KELVIN SIGN is not `k` in JS");
693        assert!(!JsRegex::new("[a-z]", "i").is_match("\u{212A}"));
694        assert!(!JsRegex::new("[a-z]", "i").is_match("\u{17F}"));
695        // Real case pairs still fold, inside and outside classes.
696        assert!(JsRegex::new("é", "i").is_match("É") && JsRegex::new("[à-ÿ]", "i").is_match("É"));
697        let sigma = JsRegex::new("σ", "i");
698        assert!(sigma.is_match("Σ") && sigma.is_match("ς"));
699        // Full mappings don't count: ß is not SS.
700        assert!(!JsRegex::new("^ß$", "i").is_match("SS") && !JsRegex::new("^s$", "i").is_match("ß"));
701        // Negated classes are closed before negation.
702        let not_lower = JsRegex::new("^[^a-z]$", "i");
703        assert!(!not_lower.is_match("A") && not_lower.is_match("\u{17F}") && not_lower.is_match("\u{212A}"));
704        // Escapes fold like literals: \x41 is `A`; \u017F stays itself.
705        assert!(JsRegex::new(r"^\x41$", "i").is_match("a"));
706        assert!(!JsRegex::new(r"^\u017F$", "i").is_match("s"));
707        // Astral characters are surrogate pairs in JS: they never fold without `u`.
708        assert!(!JsRegex::new("\u{10400}", "i").is_match("\u{10428}"));
709        // Without `i`, nothing changes.
710        assert!(!JsRegex::new("k", "").is_match("K"));
711    }
712
713    #[test]
714    fn case_insensitive_class_keeps_a_final_dash_literal() {
715        // Appended case equivalents must not turn a final `-` into a range (`[k-\x{4B}]`, `[A-\x{61}]`).
716        let k = JsRegex::new("^[k-]$", "i");
717        assert!(k.is_match("K") && k.is_match("-"));
718        let a = JsRegex::new("^[A-]$", "i");
719        assert!(a.is_match("a") && a.is_match("-") && !a.is_match("_"));
720        let x = JsRegex::new("^[x-]$", "i");
721        assert!(x.is_match("X"));
722        let lead = JsRegex::new("^[-a]$", "i");
723        assert!(lead.is_match("A") && !lead.is_match("_"));
724        assert!(JsRegex::new(r"^[a\-]$", "i").is_match("A"));
725        let not_a = JsRegex::new("^[^A-]$", "i");
726        assert!(!not_a.is_match("-") && !not_a.is_match("a") && not_a.is_match("_") && not_a.is_match("["));
727    }
728
729    #[test]
730    fn case_insensitive_group_names_are_not_expanded() {
731        let re = JsRegex::new("(?<ab>.)(?<AB>.)", "i");
732        assert!(re.is_match("xy"));
733        assert!(JsRegex::new(r"^(?<word>ab)$", "i").is_match("AB"));
734    }
735
736    #[test]
737    fn case_insensitive_backreferences_are_rejected() {
738        assert!(JsRegex::try_new(r"(a)\1", "i").is_err());
739        assert!(JsRegex::try_new(r"(a)\1", "").is_ok());
740    }
741
742    #[test]
743    fn js_canonicalize_rules() {
744        assert_eq!(js_canonicalize('a'), 'A');
745        assert_eq!(js_canonicalize('\u{17F}'), '\u{17F}', "non-ASCII never maps onto ASCII");
746        assert_eq!(
747            js_canonicalize('\u{131}'),
748            '\u{131}',
749            "dotless i: its uppercase I is ASCII"
750        );
751        assert_eq!(js_canonicalize('ß'), 'ß', "uppercase is two units (SS)");
752        assert_eq!(js_canonicalize('ς'), 'Σ');
753    }
754
755    #[test]
756    fn unicode_mode_like_js() {
757        // Expected values from Node (indexes here are bytes; JS reports UTF-16 units).
758        // ESCasualDateParser's look-behind check, `/(?:^|[^\p{L}\p{N}\p{M}_])(?:a|en)\s+la\s*$/iu`.
759        let src = r"(?:^|[^\p{L}\p{N}\p{M}_])(?:a|en)\s+la\s*$";
760        let at = |t: &str| JsRegex::new(src, "iu").exec(t).map(|m| (m.index, m.text().to_string()));
761        assert_eq!(at("Nos vemos a la "), Some((9, " a la ".into())));
762        assert_eq!(at("EN  LA"), Some((0, "EN  LA".into())));
763        assert_eq!(at("é en la"), Some((2, " en la".into())));
764        assert_eq!(at("a la"), Some((0, "a la".into())));
765        for t in ["casa la ", "ña la ", "1a la ", "_en la"] {
766            assert_eq!(at(t), None, "{t:?}");
767        }
768        // `iu` folds by simple case folding: U+017F and U+212A are word characters (not with `i` alone).
769        for (src, flags, text, hit) in [
770            (r"^\w$", "iu", "ſ", true),
771            (r"^\w$", "iu", "\u{212A}", true),
772            (r"^\w$", "iu", "é", false),
773            (r"^\w$", "i", "ſ", false),
774            (r"^\w$", "i", "\u{212A}", false),
775            (r"^k$", "iu", "\u{212A}", true),
776            (r"^ß$", "iu", "ẞ", true),
777            (r"^ß$", "iu", "SS", false),
778            (r"^[^\p{L}]$", "u", "é", false),
779            (r"^[^\p{L}]$", "u", "1", true),
780            (r"^\p{M}$", "u", "\u{301}", true),
781            (r"^\P{L}+$", "u", "12 !", true),
782            (r"^\P{L}+$", "u", "a1", false),
783        ] {
784            assert_eq!(
785                JsRegex::new(src, flags).is_match(text),
786                hit,
787                "/{src}/{flags} on {text:?}"
788            );
789        }
790        // `\W` and `\w` in and outside a class under `iu` (Copilot review of #208): s S k K ſ K a é !
791        let probe = ["s", "S", "k", "K", "ſ", "\u{212A}", "a", "é", "!"];
792        for (src, flags, want) in [
793            (r"^[\W]$", "iu", "000000011"),
794            (r"^\W$", "iu", "000000011"),
795            (r"^[\W]$", "i", "000011011"),
796            (r"^[\w]$", "iu", "111111100"),
797            (r"^[^\w]$", "iu", "000000011"),
798            (r"^[^\W]$", "iu", "111111100"),
799        ] {
800            let re = JsRegex::new(src, flags);
801            let got: String = probe.iter().map(|t| if re.is_match(t) { '1' } else { '0' }).collect();
802            assert_eq!(got, want, "/{src}/{flags}");
803        }
804        assert!(JsRegex::new(r"a\Bſ", "iu").is_match("aſ"));
805        assert!(!JsRegex::new(r"a\bſ", "iu").is_match("aſ"));
806        assert!(JsRegex::new(r"k\B\u212A", "iu").is_match("k\u{212A}"));
807        assert!(JsRegex::new(r"\bſ", "iu").is_match(" ſ"));
808        assert_eq!(JsRegex::new(r"\u{1F600}", "u").exec("x😀").map(|m| m.index), Some(1));
809        // Without `u`, `\p` stays unsupported (JS would read it as a literal `p`).
810        assert!(JsRegex::try_new(r"\p{L}", "").is_err());
811    }
812}