1use std::collections::HashMap;
27use std::sync::{LazyLock, RwLock};
28
29use fancy_regex::Regex;
30
31fn 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
45static 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
57fn 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}";
73const 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;
83type 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#[derive(Clone, Debug)]
94pub struct JsRegex {
95 source: String,
96 flags: String,
97 re: Regex,
98}
99
100#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct RegexMatch {
104 pub index: usize,
105 pub groups: Vec<Option<String>>,
106}
107
108impl RegexMatch {
109 pub fn get(&self, i: usize) -> Option<&str> {
111 self.groups.get(i).and_then(|g| g.as_deref())
112 }
113
114 pub fn text(&self) -> &str {
116 self.get(0).unwrap_or("")
117 }
118
119 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 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 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 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 pub fn source(&self) -> &str {
164 &self.source
165 }
166
167 pub fn flags(&self) -> &str {
169 &self.flags
170 }
171
172 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 pub fn is_match(&self, text: &str) -> bool {
185 self.re.is_match(text).unwrap_or(false)
186 }
187}
188
189#[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
214fn 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
233type ClassAtom = (Vec<(u32, u32)>, bool, usize);
235
236fn 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
296fn 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
304fn 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 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
332fn 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
346pub 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 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 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 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 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 _ 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 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 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("٣")); 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}")); 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 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 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 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 #[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 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 assert!(!JsRegex::new("^ß$", "i").is_match("SS") && !JsRegex::new("^s$", "i").is_match("ß"));
701 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 assert!(JsRegex::new(r"^\x41$", "i").is_match("a"));
706 assert!(!JsRegex::new(r"^\u017F$", "i").is_match("s"));
707 assert!(!JsRegex::new("\u{10400}", "i").is_match("\u{10428}"));
709 assert!(!JsRegex::new("k", "").is_match("K"));
711 }
712
713 #[test]
714 fn case_insensitive_class_keeps_a_final_dash_literal() {
715 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 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 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 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 assert!(JsRegex::try_new(r"\p{L}", "").is_err());
811 }
812}