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#[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 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 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 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 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 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 return Parser::new().parse("[").map(|_| ());
303 }
304 }
305 _ => (),
306 }
307 Ok(())
308 }
309}
310
311#[derive(Debug, PartialEq)]
313pub enum PatternAnalysis<'a> {
314 Prefix(Cow<'a, str>),
316 Exact(Cow<'a, str>),
318 Alternation(Vec<String>),
320 NoWhitespace,
322}
323
324const 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#[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#[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
366fn safe_escape(escaped: char) -> Option<char> {
368 matches!(escaped, '/' | '-' | '_' | '$' | '.').then_some(escaped)
369}
370
371fn 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#[must_use]
397pub fn analyze_pattern(pattern: &str) -> Option<PatternAnalysis<'_>> {
398 if pattern == r"^\S*$" {
399 return Some(PatternAnalysis::NoWhitespace);
400 }
401
402 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 if !suffix.contains('\\') {
419 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 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 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#[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
471const WITNESS_REPETITIONS: u32 = 64;
473
474const WITNESS_LENGTH: usize = 256;
476
477fn 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
525fn 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#[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 #[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 #[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 #[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}