Skip to main content

jsonschema_regex/
lib.rs

1mod syntax;
2
3use std::{borrow::Cow, fmt::Write};
4
5use regex_syntax::{
6    ast::{
7        self, parse::Parser, Ast, ClassPerl, ClassPerlKind, ClassSetItem, ErrorKind, Literal,
8        LiteralKind, Span, SpecialLiteralKind, Visitor,
9    },
10    hir::{Class, Hir, HirKind},
11};
12
13pub use syntax::is_valid_ecma_regex;
14
15/// Convert ECMA Script 262 regex to Rust regex on the best effort basis.
16///
17/// NOTE: Patterns with look arounds and backreferences are not supported.
18///
19/// # Errors
20///
21/// Errors are returned on unsupported or invalid regular expressions.
22#[allow(clippy::result_unit_err)]
23pub fn to_rust_regex(pattern: &str) -> Result<Cow<'_, str>, ()> {
24    let mut pattern = escape_class_set_syntax(pattern);
25    let mut ast = loop {
26        match Parser::new().parse(&pattern) {
27            Ok(ast) => break ast,
28            Err(error) if *error.kind() == ErrorKind::EscapeUnrecognized => {
29                let Span { start, end } = error.span();
30                let source = error.pattern();
31                if &source[start.offset..end.offset] == r"\c" {
32                    if let Some(letter) = &source[end.offset..].chars().next() {
33                        if letter.is_ascii_alphabetic() {
34                            let start = start.offset;
35                            let end = end.offset + 1;
36                            let replacement = ((*letter as u8) % 32) as char;
37                            match pattern {
38                                Cow::Borrowed(_) => {
39                                    let prefix = &source[..start];
40                                    let suffix = &source[end..];
41                                    pattern = Cow::Owned(format!("{prefix}{replacement}{suffix}"));
42                                }
43                                Cow::Owned(ref mut buffer) => {
44                                    let mut char_buffer = [0; 4];
45                                    let replacement = replacement.encode_utf8(&mut char_buffer);
46                                    buffer.replace_range(start..end, replacement);
47                                }
48                            }
49                            continue;
50                        }
51                    }
52                }
53                return Err(());
54            }
55            Err(error)
56                if matches!(
57                    error.kind(),
58                    ErrorKind::UnsupportedLookAround | ErrorKind::UnsupportedBackreference
59                ) =>
60            {
61                // Can't translate patterns with look arounds & backreferences
62                return Ok(pattern);
63            }
64            Err(_) => {
65                return Err(());
66            }
67        };
68    };
69    let mut has_changes;
70    loop {
71        let translator = Ecma262Translator::new(pattern);
72        (pattern, has_changes) = ast::visit(&ast, translator).map_err(|_| ())?;
73        if !has_changes {
74            return Ok(pattern);
75        }
76        match Parser::new().parse(&pattern) {
77            Ok(updated_ast) => {
78                ast = updated_ast;
79            }
80            Err(_) => {
81                return Err(());
82            }
83        }
84    }
85}
86
87#[derive(Clone, Copy, PartialEq)]
88enum ClassState {
89    AtItemStart,
90    AfterRangeStart,
91    ExpectRangeEnd,
92}
93
94impl ClassState {
95    fn consume_atom(&mut self) {
96        *self = match self {
97            Self::ExpectRangeEnd => Self::AtItemStart,
98            Self::AtItemStart | Self::AfterRangeStart => Self::AfterRangeStart,
99        };
100    }
101}
102
103fn escape_class_set_syntax(pattern: &str) -> Cow<'_, str> {
104    let mut escapes = Vec::new();
105    let mut in_class = false;
106    let mut escaped = false;
107    let mut class_state = ClassState::AtItemStart;
108    let mut previous_atom_is_class_escape = false;
109    let mut class_initial = false;
110    let mut chars = pattern.char_indices().peekable();
111    while let Some((offset, c)) = chars.next() {
112        if escaped {
113            escaped = false;
114            if in_class {
115                class_state.consume_atom();
116                previous_atom_is_class_escape = matches!(c, 'd' | 'D' | 's' | 'S' | 'w' | 'W');
117                class_initial = false;
118            }
119            continue;
120        }
121        if c == '\\' {
122            escaped = true;
123            continue;
124        }
125        if c == '[' {
126            if in_class {
127                escapes.push(offset);
128                class_state.consume_atom();
129                previous_atom_is_class_escape = false;
130                class_initial = false;
131            } else {
132                in_class = true;
133                class_state = ClassState::AtItemStart;
134                previous_atom_is_class_escape = false;
135                class_initial = true;
136            }
137            continue;
138        }
139        if c == ']' && in_class {
140            in_class = false;
141            class_state = ClassState::AtItemStart;
142            previous_atom_is_class_escape = false;
143            continue;
144        }
145        if !in_class {
146            continue;
147        }
148        if c == '^' && class_initial {
149            class_initial = false;
150            continue;
151        }
152        if c == '-'
153            && previous_atom_is_class_escape
154            && chars.peek().is_some_and(|(_, next)| *next == '-')
155        {
156            // Without the Unicode flag, a class escape cannot participate in a range. Annex B
157            // treats both hyphens in forms such as `[\w--z]` as literals.
158            escapes.push(offset);
159            let (next_offset, _) = chars.next().expect("the next character was peeked");
160            escapes.push(next_offset);
161            class_state.consume_atom();
162            previous_atom_is_class_escape = false;
163            class_initial = false;
164            continue;
165        }
166        if c == '-'
167            && class_state == ClassState::AfterRangeStart
168            && chars.peek().is_some_and(|(_, next)| *next != ']')
169        {
170            // This hyphen is an ECMAScript range delimiter. Keep it unescaped so ranges such as
171            // the `--b` part of `[a-z--b]` preserve their meaning.
172            class_state = ClassState::ExpectRangeEnd;
173            previous_atom_is_class_escape = false;
174            continue;
175        }
176        if matches!(c, '&' | '~') && chars.peek().is_some_and(|(_, next)| *next == c) {
177            escapes.push(offset);
178            let (next_offset, _) = chars.next().expect("the next character was peeked");
179            escapes.push(next_offset);
180            class_state.consume_atom();
181        } else if c == '-'
182            && (pattern.as_bytes().get(offset.wrapping_sub(1)) == Some(&b'-')
183                || chars.peek().is_some_and(|(_, next)| *next == '-'))
184        {
185            // Only escape literal hyphens. At least one hyphen in every valid adjacent pair is a
186            // class atom, and escaping that atom is sufficient to avoid Rust's `--` operator.
187            escapes.push(offset);
188        }
189        class_state.consume_atom();
190        previous_atom_is_class_escape = false;
191        class_initial = false;
192    }
193    if escapes.is_empty() {
194        return Cow::Borrowed(pattern);
195    }
196    let mut translated = String::with_capacity(pattern.len() + escapes.len());
197    let mut escapes = escapes.into_iter().peekable();
198    for (offset, c) in pattern.char_indices() {
199        if escapes.next_if_eq(&offset).is_some() {
200            translated.push('\\');
201        }
202        translated.push(c);
203    }
204    Cow::Owned(translated)
205}
206
207struct Ecma262Translator<'a> {
208    pattern: Cow<'a, str>,
209    offset: usize,
210    has_changes: bool,
211}
212
213impl<'a> Ecma262Translator<'a> {
214    fn new(input: Cow<'a, str>) -> Self {
215        Self {
216            pattern: input,
217            offset: 0,
218            has_changes: false,
219        }
220    }
221
222    fn replace_impl(&mut self, span: &Span, replacement: &str) {
223        let Span { start, end } = span;
224        match self.pattern {
225            Cow::Borrowed(pattern) => {
226                let prefix = &pattern[..start.offset];
227                let suffix = &pattern[end.offset..];
228                self.pattern = Cow::Owned(format!("{prefix}{replacement}{suffix}"));
229            }
230            Cow::Owned(ref mut buffer) => {
231                buffer.replace_range(
232                    start.offset + self.offset..end.offset + self.offset,
233                    replacement,
234                );
235            }
236        }
237        self.offset += replacement.len() - (end.offset - start.offset);
238        self.has_changes = true;
239    }
240
241    fn replace(&mut self, cls: &ClassPerl) {
242        match cls.kind {
243            ClassPerlKind::Digit => {
244                let replacement = if cls.negated { "[^0-9]" } else { "[0-9]" };
245                self.replace_impl(&cls.span, replacement);
246            }
247            ClassPerlKind::Word => {
248                let replacement = if cls.negated {
249                    "[^A-Za-z0-9_]"
250                } else {
251                    "[A-Za-z0-9_]"
252                };
253                self.replace_impl(&cls.span, replacement);
254            }
255            ClassPerlKind::Space => {
256                let mut replacement = String::from(if cls.negated { "[^" } else { "[" });
257                for (start, end) in ECMA_WHITESPACE_RANGES {
258                    // Writing to a `String` cannot fail.
259                    let _ = write!(
260                        replacement,
261                        "\\x{{{:x}}}-\\x{{{:x}}}",
262                        start as u32, end as u32
263                    );
264                }
265                replacement.push(']');
266                self.replace_impl(&cls.span, &replacement);
267            }
268        }
269    }
270}
271
272impl<'a> Visitor for Ecma262Translator<'a> {
273    type Output = (Cow<'a, str>, bool);
274    type Err = ast::Error;
275
276    fn finish(self) -> Result<Self::Output, Self::Err> {
277        Ok((self.pattern, self.has_changes))
278    }
279
280    fn visit_class_set_item_pre(&mut self, item: &ast::ClassSetItem) -> Result<(), Self::Err> {
281        if let ClassSetItem::Perl(cls) = item {
282            self.replace(cls);
283        }
284        Ok(())
285    }
286    fn visit_post(&mut self, ast: &Ast) -> Result<(), Self::Err> {
287        if self.has_changes {
288            return Ok(());
289        }
290        match ast {
291            Ast::ClassPerl(perl) => {
292                self.replace(perl);
293            }
294            Ast::Literal(literal) => {
295                if let Literal {
296                    kind: LiteralKind::Special(SpecialLiteralKind::Bell),
297                    ..
298                } = literal.as_ref()
299                {
300                    // Not possible to create a custom error, hence throw an arbitrary one from a
301                    // known invalid pattern.
302                    return Parser::new().parse("[").map(|_| ());
303                }
304            }
305            _ => (),
306        }
307        Ok(())
308    }
309}
310
311/// The result of analyzing a regex pattern for literal-match optimizations.
312#[derive(Debug, PartialEq)]
313pub enum PatternAnalysis<'a> {
314    /// `^prefix` -> use `starts_with(prefix)`.
315    Prefix(Cow<'a, str>),
316    /// `^exact$` -> use `== exact`.
317    Exact(Cow<'a, str>),
318    /// `^(a|b|c)$` -> linear scan over a small sorted set of literals.
319    Alternation(Vec<String>),
320    /// `^\S*$` -> no ECMA-262 whitespace character (see [`is_ecma_whitespace`]).
321    NoWhitespace,
322}
323
324/// Inclusive ranges of ECMA-262 `\s`: `WhiteSpace` (TAB, VT, FF, every `Zs`, BOM) and `LineTerminator`.
325const ECMA_WHITESPACE_RANGES: [(char, char); 10] = [
326    ('\t', '\r'),
327    (' ', ' '),
328    ('\u{00a0}', '\u{00a0}'),
329    ('\u{1680}', '\u{1680}'),
330    ('\u{2000}', '\u{200a}'),
331    ('\u{2028}', '\u{2029}'),
332    ('\u{202f}', '\u{202f}'),
333    ('\u{205f}', '\u{205f}'),
334    ('\u{3000}', '\u{3000}'),
335    ('\u{feff}', '\u{feff}'),
336];
337
338/// Returns `true` for ECMA-262 whitespace characters (`\s` in ECMA regex): the union of ASCII
339/// whitespace, `\u{00a0}`, and the Unicode space-separator category recognized by the spec.
340#[inline]
341#[must_use]
342pub fn is_ecma_whitespace(c: char) -> bool {
343    ECMA_WHITESPACE_RANGES
344        .iter()
345        .any(|&(start, end)| start <= c && c <= end)
346}
347
348/// Whether `input` holds any character `\s` matches.
349///
350/// All-ASCII input is scanned as bytes, which vectorizes; anything else is decoded, as before.
351/// `is_ascii` stops at the first non-ASCII byte, so input taking the decode path pays little for
352/// the check.
353#[must_use]
354#[inline]
355pub fn contains_ecma_whitespace(input: &str) -> bool {
356    if input.is_ascii() {
357        input
358            .as_bytes()
359            .iter()
360            .any(|byte| matches!(byte, b'\t' | b'\n' | 0x0b | 0x0c | b'\r' | b' '))
361    } else {
362        input.chars().any(is_ecma_whitespace)
363    }
364}
365
366/// Maps a backslash escape to its literal character, or `None` if the escape requires a regex engine.
367fn safe_escape(escaped: char) -> Option<char> {
368    matches!(escaped, '/' | '-' | '_' | '$' | '.').then_some(escaped)
369}
370
371/// Parse a single literal alternative for use inside `^(a|b|c)$`.
372/// Accepts alphanumeric chars, `-`, `_`, `/` and the safe escapes handled by [`safe_escape`].
373fn parse_literal_alternative(s: &str) -> Option<String> {
374    let mut result = String::new();
375    let mut chars = s.chars();
376    while let Some(c) = chars.next() {
377        match c {
378            '\\' => result.push(safe_escape(chars.next()?)?),
379            c if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/') => result.push(c),
380            _ => return None,
381        }
382    }
383    Some(result)
384}
385
386/// Analyze a regex pattern and return a [`PatternAnalysis`] if a literal optimization applies.
387///
388/// Handles:
389/// - `^prefix` -> [`PatternAnalysis::Prefix`] (use `starts_with`)
390/// - `^exact$` -> [`PatternAnalysis::Exact`] (use `==`)
391/// - `^(a|b|c)$` -> [`PatternAnalysis::Alternation`] (linear scan, sorted)
392/// - `^\S*$` -> [`PatternAnalysis::NoWhitespace`] (scan for [`is_ecma_whitespace`])
393///
394/// Accepts unescaped alphanumeric chars, `-`, `_`, `/` and the safe escapes `\/`, `\-`, `\_`, `\$`, `\.`.
395/// Returns `None` if a full regex engine is required.
396#[must_use]
397pub fn analyze_pattern(pattern: &str) -> Option<PatternAnalysis<'_>> {
398    if pattern == r"^\S*$" {
399        return Some(PatternAnalysis::NoWhitespace);
400    }
401
402    // Fast path: `^(a|b|c)$` alternation.
403    if let Some(inner) = pattern
404        .strip_prefix("^(")
405        .and_then(|s| s.strip_suffix(")$"))
406    {
407        let mut alternatives: Vec<String> = inner
408            .split('|')
409            .map(parse_literal_alternative)
410            .collect::<Option<_>>()?;
411        alternatives.sort_unstable();
412        return Some(PatternAnalysis::Alternation(alternatives));
413    }
414
415    let suffix = pattern.strip_prefix('^')?;
416
417    // Fast path: no backslashes, borrow directly from the input.
418    if !suffix.contains('\\') {
419        // Trailing `$` is the end-of-string anchor -> Exact match.
420        if let Some(body) = suffix.strip_suffix('$') {
421            return if body
422                .chars()
423                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/'))
424            {
425                Some(PatternAnalysis::Exact(Cow::Borrowed(body)))
426            } else {
427                None
428            };
429        }
430        return if suffix
431            .chars()
432            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/'))
433        {
434            Some(PatternAnalysis::Prefix(Cow::Borrowed(suffix)))
435        } else {
436            None
437        };
438    }
439
440    // Slow path: unescape via `safe_escape`; a bare `$` at end means Exact.
441    let mut result = String::with_capacity(suffix.len());
442    let mut chars = suffix.chars().peekable();
443    while let Some(c) = chars.next() {
444        match c {
445            '\\' => result.push(safe_escape(chars.next()?)?),
446            '$' => {
447                // End-of-string anchor: valid only as the last character.
448                if chars.peek().is_none() {
449                    return Some(PatternAnalysis::Exact(Cow::Owned(result)));
450                }
451                return None;
452            }
453            c if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/') => result.push(c),
454            _ => return None,
455        }
456    }
457    Some(PatternAnalysis::Prefix(Cow::Owned(result)))
458}
459
460/// A string `pattern` matches, or `None` where the syntax alone is not enough to build one.
461///
462/// A `pattern` is an unanchored search, so a look-around contributes nothing to the string built
463/// here, and the caller checks the result against the pattern anyway.
464#[must_use]
465pub fn pattern_witness(pattern: &str) -> Option<String> {
466    let hir = regex_syntax::parse(&to_rust_regex(pattern).ok()?).ok()?;
467    let mut witness = String::new();
468    write_witness(&hir, &mut witness).then_some(witness)
469}
470
471/// The most repetitions of one sub-expression written out.
472const WITNESS_REPETITIONS: u32 = 64;
473
474/// The longest witness built. Without a cap, nested repetitions multiply the length.
475const WITNESS_LENGTH: usize = 256;
476
477/// Appends a string matching `hir`, reporting whether one was found. On failure `witness` keeps a
478/// partial string, so a caller trying another branch truncates back to its own length first.
479fn write_witness(hir: &Hir, witness: &mut String) -> bool {
480    if witness.len() > WITNESS_LENGTH {
481        return false;
482    }
483    match hir.kind() {
484        HirKind::Empty | HirKind::Look(_) => true,
485        HirKind::Literal(literal) => match std::str::from_utf8(&literal.0) {
486            Ok(text) => {
487                witness.push_str(text);
488                true
489            }
490            Err(_) => false,
491        },
492        HirKind::Class(class) => {
493            let character = match class {
494                Class::Unicode(class) => pick(class.ranges().iter().map(|r| (r.start(), r.end()))),
495                Class::Bytes(class) => pick(
496                    class
497                        .ranges()
498                        .iter()
499                        .map(|r| (char::from(r.start()), char::from(r.end()))),
500                ),
501            };
502            character.is_some_and(|character| {
503                witness.push(character);
504                true
505            })
506        }
507        HirKind::Repetition(repetition) => {
508            if repetition.min > WITNESS_REPETITIONS {
509                return false;
510            }
511            (0..repetition.min).all(|_| write_witness(&repetition.sub, witness))
512        }
513        HirKind::Capture(capture) => write_witness(&capture.sub, witness),
514        HirKind::Concat(parts) => parts.iter().all(|part| write_witness(part, witness)),
515        HirKind::Alternation(branches) => {
516            let start = witness.len();
517            branches.iter().any(|branch| {
518                witness.truncate(start);
519                write_witness(branch, witness)
520            })
521        }
522    }
523}
524
525/// A character from a class, preferring `a` over whatever the class starts with.
526fn pick(ranges: impl Iterator<Item = (char, char)>) -> Option<char> {
527    let mut first = None;
528    for (start, end) in ranges {
529        if (start..=end).contains(&'a') {
530            return Some('a');
531        }
532        first.get_or_insert(start);
533    }
534    first
535}
536
537/// Try to extract a simple prefix from a pattern like `^prefix`.
538/// Only matches patterns with alphanumeric characters, hyphens, underscores, and forward slashes.
539/// The escaped form `\/` is also accepted and normalised to `/`.
540#[must_use]
541pub fn pattern_as_prefix(pattern: &str) -> Option<Cow<'_, str>> {
542    match analyze_pattern(pattern) {
543        Some(PatternAnalysis::Prefix(p)) => Some(p),
544        _ => None,
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use test_case::test_case;
552
553    #[test_case(r"\d", "[0-9]"; "digit class")]
554    #[test_case(r"\D", "[^0-9]"; "non-digit class")]
555    #[test_case(r"\w", "[A-Za-z0-9_]"; "word class")]
556    #[test_case(r"\W", "[^A-Za-z0-9_]"; "non-word class")]
557    #[test_case(r"[\d]", "[[0-9]]"; "digit class in character set")]
558    #[test_case(r"[\D]", "[[^0-9]]"; "non-digit class in character set")]
559    #[test_case(r"[\w]", "[[A-Za-z0-9_]]"; "word class in character set")]
560    #[test_case(r"[\W]", "[[^A-Za-z0-9_]]"; "non-word class in character set")]
561    #[test_case(r"\d+\w*", "[0-9]+[A-Za-z0-9_]*"; "combination of digit and word classes")]
562    #[test_case(r"\D*\W+", "[^0-9]*[^A-Za-z0-9_]+"; "combination of non-digit and non-word classes")]
563    #[test_case(r"[\d\w]", "[[0-9][A-Za-z0-9_]]"; "digit and word classes in character set")]
564    #[test_case(r"[^\d\w]", "[^[0-9][A-Za-z0-9_]]"; "negated digit and word classes in character set")]
565    #[test_case(r"[\d\w\d\w]", "[[0-9][A-Za-z0-9_][0-9][A-Za-z0-9_]]"; "multiple replacements")]
566    #[test_case(r"\cA\cB\cC", "\x01\x02\x03"; "multiple control characters")]
567    #[test_case(r"foo\cIbar\cXbaz", "foo\x09bar\x18baz"; "control characters mixed with text")]
568    #[test_case(r"\ca\cb\cc", "\x01\x02\x03"; "lowercase control characters")]
569    #[test_case(r"^[a-z&&^b]+$", r"^[a-z\&\&^b]+$"; "class intersection syntax is literal")]
570    #[test_case(r"^[a-z--b]$", r"^[a-z\--b]$"; "class difference syntax preserves ranges")]
571    #[test_case(r"^[\w--z]$", r"^[[A-Za-z0-9_]\-\-z]$"; "word class escape before doubled hyphen")]
572    #[test_case(r"^[\d--z]$", r"^[[0-9]\-\-z]$"; "digit class escape before doubled hyphen")]
573    #[test_case(r"^[a~~b]$", r"^[a\~\~b]$"; "class symmetric difference syntax is literal")]
574    #[test_case(r"^[[a]]$", r"^[\[a]]$"; "nested class opener is literal")]
575    #[test_case(r"[a-z]", r"[a-z]"; "ordinary class is unchanged")]
576    #[test_case(r"[\[\]]", r"[\[\]]"; "escaped brackets are unchanged")]
577    #[test_case(r"[^&]", r"[^&]"; "single ampersand is unchanged")]
578    #[test_case(r"a&&b", r"a&&b"; "operator syntax outside a class is unchanged")]
579    fn test_ecma262_to_rust_regex(input: &str, expected: &str) {
580        let result = to_rust_regex(input).unwrap();
581        assert_eq!(result, expected);
582    }
583
584    const ECMA_WHITESPACE: [char; 25] = [
585        '\t', '\n', '\u{b}', '\u{c}', '\r', ' ', '\u{a0}', '\u{1680}', '\u{2000}', '\u{2003}',
586        '\u{200a}', '\u{2028}', '\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}', '\u{feff}',
587        '\u{2001}', '\u{2002}', '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}',
588        '\u{2009}',
589    ];
590    const NOT_ECMA_WHITESPACE: [char; 7] = [
591        '\u{85}', '\u{1c}', '\u{200b}', '\u{180e}', 'a', '0', '\u{2010}',
592    ];
593
594    #[test_case(r"\s", true; "space class")]
595    #[test_case(r"\S", false; "non-space class")]
596    #[test_case(r"[\s]", true; "space class in set")]
597    #[test_case(r"[^\s]", false; "negated set of space class")]
598    #[test_case(r"[a\s]", true; "space class with literal in set")]
599    #[test_case(r"[\S]", false; "non-space class in set")]
600    fn space_class_matches_ecma_whitespace(pattern: &str, matches_whitespace: bool) {
601        let regex =
602            regex::Regex::new(&format!("^(?:{})$", to_rust_regex(pattern).unwrap())).unwrap();
603        for c in ECMA_WHITESPACE {
604            assert_eq!(
605                regex.is_match(&c.to_string()),
606                matches_whitespace,
607                "{pattern} on U+{:04X}",
608                c as u32
609            );
610        }
611        for c in NOT_ECMA_WHITESPACE {
612            let expected = if matches_whitespace {
613                c == 'a' && pattern.starts_with("[a")
614            } else {
615                true
616            };
617            assert_eq!(
618                regex.is_match(&c.to_string()),
619                expected,
620                "{pattern} on U+{:04X}",
621                c as u32
622            );
623        }
624    }
625
626    #[test]
627    fn whitespace_test_set_agrees_with_predicate() {
628        assert!(ECMA_WHITESPACE.into_iter().all(is_ecma_whitespace));
629        assert!(!NOT_ECMA_WHITESPACE.into_iter().any(is_ecma_whitespace));
630    }
631
632    #[test_case(r"\c"; "incomplete control character")]
633    #[test_case(r"\c?"; "invalid control character")]
634    #[test_case(r"\mA"; "another invalid control character")]
635    #[test_case(r"[a-z"; "unclosed character class")]
636    #[test_case(r"(abc"; "unclosed parenthesis")]
637    #[test_case(r"abc)"; "unmatched closing parenthesis")]
638    #[test_case(r"a{3,2}"; "invalid quantifier range")]
639    #[test_case(r"\"; "trailing backslash")]
640    #[test_case(r"[a-\w]"; "invalid character range")]
641    #[test_case(r"[z--]"; "invalid range ending in a hyphen")]
642    fn test_invalid_regex(input: &str) {
643        let result = to_rust_regex(input);
644        assert!(result.is_err(), "Expected error for input: {input}");
645    }
646
647    #[test]
648    fn doubled_hyphen_preserves_ecmascript_range_semantics() {
649        let translated = to_rust_regex(r"^[a-z--b]$").expect("valid ECMAScript regex");
650        let regex = regex::Regex::new(&translated).expect("translation compiles");
651
652        assert!(regex.is_match("0"));
653        assert!(regex.is_match("A"));
654        assert!(regex.is_match("z"));
655        assert!(!regex.is_match("{"));
656    }
657
658    #[test_case("^foo", Some("foo"))]
659    #[test_case("^x-", Some("x-"))]
660    #[test_case("^eo_band", Some("eo_band"))]
661    #[test_case("^path/to", Some("path/to"))]
662    #[test_case("^ABC123", Some("ABC123"))]
663    #[test_case("^\\/", Some("/"); "escaped slash prefix")]
664    #[test_case("^\\/path", Some("/path"); "escaped slash with suffix")]
665    #[test_case("^\\$ref", Some("$ref"); "escaped dollar ref")]
666    #[test_case("^\\$defs", Some("$defs"); "escaped dollar defs")]
667    #[test_case("foo", None; "no anchor")]
668    #[test_case("^foo$", None; "end anchor")]
669    #[test_case("^\\$ref$", None; "exact match dollar ref is not a prefix")]
670    #[test_case("^foo.*", None; "contains dot")]
671    #[test_case("^foo+", None; "contains plus")]
672    #[test_case("^foo?", None; "contains question")]
673    #[test_case("^[a-z]", None; "contains bracket")]
674    #[test_case("^foo|bar", None; "contains pipe")]
675    #[test_case("^foo(bar)", None; "contains parens")]
676    #[test_case("^foo\\d", None; "contains backslash-d")]
677    fn test_pattern_as_prefix(pattern: &str, expected: Option<&str>) {
678        assert_eq!(pattern_as_prefix(pattern).as_deref(), expected);
679    }
680
681    #[test_case("^foo", PatternAnalysis::Prefix("foo".into()) ; "prefix")]
682    #[test_case("^x-", PatternAnalysis::Prefix("x-".into()) ; "x_prefix")]
683    #[test_case("^\\$ref", PatternAnalysis::Prefix("$ref".into()) ; "escaped dollar ref prefix")]
684    #[test_case("^foo$", PatternAnalysis::Exact("foo".into()) ; "exact fast path")]
685    #[test_case("^\\$ref$", PatternAnalysis::Exact("$ref".into()) ; "exact escaped dollar ref")]
686    #[test_case(
687        "^(get|put|post|delete|options|head|patch|trace)$",
688        PatternAnalysis::Alternation(vec![
689            "delete".into(), "get".into(), "head".into(), "options".into(),
690            "patch".into(), "post".into(), "put".into(), "trace".into(),
691        ]) ; "http methods alternation"
692    )]
693    #[test_case(
694        "^(a|b|c)$",
695        PatternAnalysis::Alternation(vec!["a".into(), "b".into(), "c".into()]) ; "simple alternation sorted"
696    )]
697    #[test_case(
698        "^(a\\/b|c)$",
699        PatternAnalysis::Alternation(vec!["a/b".into(), "c".into()]) ; "alternation with escaped slash"
700    )]
701    #[test_case(
702        "^(x\\$y|z)$",
703        PatternAnalysis::Alternation(vec!["x$y".into(), "z".into()]) ; "alternation with escaped dollar"
704    )]
705    #[test_case("^a\\.b", PatternAnalysis::Prefix("a.b".into()) ; "escaped dot prefix")]
706    #[test_case("^a\\.b$", PatternAnalysis::Exact("a.b".into()) ; "escaped dot exact")]
707    #[test_case("^a\\-b\\_c", PatternAnalysis::Prefix("a-b_c".into()) ; "escaped dash underscore prefix")]
708    #[test_case(
709        "^(a\\.b|c\\-d)$",
710        PatternAnalysis::Alternation(vec!["a.b".into(), "c-d".into()]) ; "alternation with escaped dot and dash"
711    )]
712    #[test_case(r"^\S*$", PatternAnalysis::NoWhitespace ; "no whitespace")]
713    fn test_analyze_pattern(pattern: &str, expected: PatternAnalysis<'_>) {
714        assert_eq!(analyze_pattern(pattern), Some(expected));
715    }
716
717    #[test_case(' ' ; "space")]
718    #[test_case('\t' ; "tab")]
719    #[test_case('\u{00a0}' ; "nbsp")]
720    #[test_case('\u{2003}' ; "em space")]
721    #[test_case('\u{3000}' ; "ideographic space")]
722    #[test_case('\u{feff}' ; "bom")]
723    fn test_is_ecma_whitespace(c: char) {
724        assert!(is_ecma_whitespace(c));
725    }
726
727    #[test_case('a' ; "letter")]
728    #[test_case('0' ; "digit")]
729    #[test_case('\u{200b}' ; "zero width space")]
730    fn test_not_ecma_whitespace(c: char) {
731        assert!(!is_ecma_whitespace(c));
732    }
733
734    #[test_case("foo" ; "no anchor")]
735    #[test_case("^foo.*" ; "contains dot")]
736    #[test_case("^foo+" ; "contains plus")]
737    #[test_case("^[a-z]" ; "contains bracket")]
738    #[test_case("^(a|b^)$" ; "invalid char in alternation")]
739    #[test_case("^(a\\db)$" ; "invalid escape in alternation")]
740    #[test_case("^a\\-$b" ; "escaped then dollar not at end")]
741    fn test_analyze_pattern_none(pattern: &str) {
742        assert_eq!(analyze_pattern(pattern), None);
743    }
744
745    #[test_case("=", "="; "bare literal")]
746    #[test_case("b$", "b"; "tail anchored literal")]
747    #[test_case("^abc", "abc"; "prefix")]
748    #[test_case("^abc$", "abc"; "exact")]
749    #[test_case("^(red|green)$", "red"; "alternation")]
750    #[test_case(r"^\S*$", ""; "star repetition")]
751    #[test_case("", ""; "empty pattern")]
752    #[test_case("a.b", "aab"; "wildcard")]
753    #[test_case("a+", "a"; "plus repetition")]
754    #[test_case(r"\d{3}", "000"; "counted character class")]
755    #[test_case("^[a-z]+-[0-9]+$", "a-0"; "classes around a literal")]
756    fn test_pattern_witness(pattern: &str, expected: &str) {
757        let witness = pattern_witness(pattern).expect("has a witness");
758        assert_eq!(witness, expected);
759        let regex = regex::Regex::new(&to_rust_regex(pattern).expect("translates")).expect("valid");
760        assert!(
761            regex.is_match(&witness),
762            "`{witness}` does not match `{pattern}`"
763        );
764    }
765
766    #[test_case(r"(?=a)b"; "look ahead the translation rejects")]
767    #[test_case("a{100}"; "more repetitions than are written out")]
768    #[test_case("((a{64}){64}){64}"; "nested repetitions multiplying past the length cap")]
769    fn test_pattern_without_a_witness(pattern: &str) {
770        assert_eq!(pattern_witness(pattern), None);
771    }
772    #[test_case("" ; "empty")]
773    #[test_case("plain" ; "ascii without whitespace")]
774    #[test_case("\u{00e9}t\u{00e9}" ; "non-ascii without whitespace")]
775    #[test_case("\u{4e2d}\u{6587}" ; "multi byte without whitespace")]
776    fn no_whitespace(input: &str) {
777        assert!(!contains_ecma_whitespace(input));
778    }
779
780    #[test_case(" " ; "space")]
781    #[test_case("a\tb" ; "tab")]
782    #[test_case("a\nb" ; "newline")]
783    #[test_case("a\u{000b}b" ; "vertical tab")]
784    #[test_case("a\u{000c}b" ; "form feed")]
785    #[test_case("a\rb" ; "carriage return")]
786    #[test_case("a\u{00a0}b" ; "no break space")]
787    #[test_case("a\u{1680}b" ; "ogham space mark")]
788    #[test_case("a\u{2000}b" ; "en quad")]
789    #[test_case("a\u{200a}b" ; "hair space")]
790    #[test_case("a\u{2028}b" ; "line separator")]
791    #[test_case("a\u{2029}b" ; "paragraph separator")]
792    #[test_case("a\u{202f}b" ; "narrow no break space")]
793    #[test_case("a\u{205f}b" ; "medium mathematical space")]
794    #[test_case("a\u{3000}b" ; "ideographic space")]
795    #[test_case("a\u{feff}b" ; "zero width no break space")]
796    fn has_whitespace(input: &str) {
797        assert!(contains_ecma_whitespace(input));
798    }
799
800    // A byte scan must not mistake a continuation byte for whitespace.
801    #[test_case("\u{0a20}" ; "leading byte matches newline value")]
802    #[test_case("\u{2820}" ; "continuation byte matches space value")]
803    fn multi_byte_is_not_whitespace(input: &str) {
804        assert!(!contains_ecma_whitespace(input));
805        assert_eq!(
806            contains_ecma_whitespace(input),
807            input.chars().any(is_ecma_whitespace)
808        );
809    }
810    // The byte scan must agree with a decode for every character there is.
811    #[test]
812    fn agrees_with_decoding_on_every_scalar_value() {
813        let mut buffer = [0_u8; 4];
814        for value in 0..=0x0010_FFFF_u32 {
815            let Some(c) = char::from_u32(value) else {
816                continue;
817            };
818            let text = c.encode_utf8(&mut buffer);
819            assert_eq!(
820                contains_ecma_whitespace(text),
821                text.chars().any(is_ecma_whitespace),
822                "{c:?} (U+{value:04X})"
823            );
824        }
825    }
826
827    // Bytes of a multi-byte encoding are all >= 0x80, so no combination can spell an ASCII
828    // whitespace byte; these sequences check that directly.
829    #[test]
830    fn agrees_with_decoding_on_mixed_sequences() {
831        let alphabet = [
832            'a',
833            ' ',
834            '\t',
835            '\n',
836            '\r',
837            '\u{000b}',
838            '\u{000c}',
839            '\u{00a0}',
840            '\u{0a20}',
841            '\u{2820}',
842            '\u{2000}',
843            '\u{200a}',
844            '\u{2028}',
845            '\u{3000}',
846            '\u{feff}',
847            '\u{4e2d}',
848            '\u{10348}',
849        ];
850        let mut text = String::new();
851        for first in alphabet {
852            for second in alphabet {
853                for third in alphabet {
854                    text.clear();
855                    text.push(first);
856                    text.push(second);
857                    text.push(third);
858                    assert_eq!(
859                        contains_ecma_whitespace(&text),
860                        text.chars().any(is_ecma_whitespace),
861                        "{text:?}"
862                    );
863                }
864            }
865        }
866    }
867}