use fancy_regex::{Captures, Regex};
const WORD: &str = "0-9A-Za-z_";
const BOUNDARY: &str =
concat!("(?:(?<=[0-9A-Za-z_])(?![0-9A-Za-z_])", "|(?<![0-9A-Za-z_])(?=[0-9A-Za-z_]))");
const NOT_BOUNDARY: &str =
concat!("(?:(?<=[0-9A-Za-z_])(?=[0-9A-Za-z_])", "|(?<![0-9A-Za-z_])(?![0-9A-Za-z_]))");
const SPACE: &str = r" \t\n\r\x0B\x0C";
pub(crate) fn translate(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len() + 32);
let mut chars = pattern.chars();
let mut in_class = false;
while let Some(ch) = chars.next() {
if ch != '\\' {
match ch {
'[' if !in_class => in_class = true,
']' if in_class => in_class = false,
_ => {}
}
out.push(ch);
continue;
}
let Some(escaped) = chars.next() else {
out.push('\\');
break;
};
let (bare, wrapped, negated) = match escaped {
'd' => ("0-9", "[0-9]", "[^0-9]"),
'w' => (WORD, "[0-9A-Za-z_]", "[^0-9A-Za-z_]"),
's' => (SPACE, "[ \\t\\n\\r\\x0B\\x0C]", "[^ \\t\\n\\r\\x0B\\x0C]"),
'b' if !in_class => ("", BOUNDARY, ""),
'B' if !in_class => ("", NOT_BOUNDARY, ""),
'b' => ("", "\\x08", ""),
'D' | 'W' | 'S' => {
let negated = match escaped {
'D' => "[^0-9]",
'W' => "[^0-9A-Za-z_]",
_ => "[^ \\t\\n\\r\\x0B\\x0C]",
};
out.push_str(negated);
continue;
}
_ => {
out.push('\\');
out.push(escaped);
continue;
}
};
let _ = negated;
out.push_str(if in_class { bare } else { wrapped });
}
out
}
pub(crate) fn compile(pattern: &str) -> Regex {
let translated = translate(pattern);
Regex::new(&translated)
.unwrap_or_else(|e| panic!("invalid pattern {pattern:?} -> {translated:?}: {e}"))
}
pub(crate) fn compile_i(pattern: &str) -> Regex {
compile(&format!("(?i){}", ascii_case_only(pattern)))
}
fn ascii_case_only(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
match ch {
'\\' => {
out.push(ch);
if let Some((_, escaped)) = chars.next() {
out.push(escaped);
}
}
'[' => {
let rest = &pattern[i..];
let mut body = rest.char_indices().skip(1).peekable();
if matches!(body.peek(), Some((_, '^'))) {
body.next();
}
if matches!(body.peek(), Some((_, ']'))) {
body.next();
}
let mut end = None;
while let Some((offset, c)) = body.next() {
match c {
'\\' => {
body.next();
}
']' => {
end = Some(i + offset + 1);
break;
}
_ => {}
}
}
let Some(end) = end else {
out.push(ch);
continue;
};
let class = &pattern[i..end];
if class.is_ascii() {
out.push_str(class);
} else {
out.push_str("(?-i:");
out.push_str(class);
out.push(')');
}
while chars.peek().is_some_and(|&(j, _)| j < end) {
chars.next();
}
}
c if !c.is_ascii() => {
out.push_str("(?-i:");
out.push(c);
out.push(')');
}
c => out.push(c),
}
}
out
}
pub(crate) fn sub<F>(text: &str, re: &Regex, mut f: F) -> String
where
F: FnMut(&Captures<'_, str>) -> String,
{
sub_ctx(text, re, |caps, _| f(caps))
}
pub(crate) struct MatchContext<'t> {
pub prefix: &'t str,
pub suffix: &'t str,
}
pub(crate) fn sub_ctx<F>(text: &str, re: &Regex, mut f: F) -> String
where
F: FnMut(&Captures<'_, str>, &str) -> String,
{
sub_around(text, re, |caps, ctx| f(caps, ctx.prefix))
}
pub(crate) fn sub_around<F>(text: &str, re: &Regex, mut f: F) -> String
where
F: FnMut(&Captures<'_, str>, &MatchContext<'_>) -> String,
{
let mut out: Option<String> = None;
let mut last = 0;
let mut pos = 0;
while pos <= text.len() {
let Ok(Some(caps)) = re.captures_from_pos(text, pos) else { break };
let m = caps.get(0).expect("group 0 always participates");
let out = out.get_or_insert_with(|| String::with_capacity(text.len()));
out.push_str(&text[last..m.start()]);
let ctx = MatchContext { prefix: &text[last..m.start()], suffix: &text[m.end()..] };
let replacement = f(&caps, &ctx);
out.push_str(&replacement);
last = m.end();
pos = if m.end() == m.start() { next_boundary(text, m.end()) } else { m.end() };
}
match out {
Some(mut out) => {
out.push_str(&text[last..]);
out
}
None => text.to_owned(),
}
}
pub(crate) fn each<F>(text: &str, re: &Regex, mut f: F)
where
F: FnMut(&Captures<'_, str>),
{
let mut pos = 0;
while pos <= text.len() {
let Ok(Some(caps)) = re.captures_from_pos(text, pos) else { break };
let m = caps.get(0).expect("group 0 always participates");
let end = m.end();
f(&caps);
pos = if end == m.start() { next_boundary(text, end) } else { end };
}
}
fn next_boundary(text: &str, index: usize) -> usize {
let mut next = index + 1;
while next < text.len() && !text.is_char_boundary(next) {
next += 1;
}
next
}
pub(crate) fn cap<'t>(caps: &Captures<'t, str>, index: usize) -> &'t str {
caps.get(index).map_or("", |m| m.as_str())
}
pub(crate) fn matched(caps: &Captures<'_, str>, index: usize) -> bool {
caps.get(index).is_some()
}
pub(crate) fn cap_start(caps: &Captures<'_, str>, index: usize) -> usize {
caps.get(index).map_or(0, |m| m.start())
}
pub(crate) fn whole<'t>(caps: &Captures<'t, str>) -> &'t str {
cap(caps, 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translates_ascii_escapes() {
assert_eq!(translate(r"\d+"), "[0-9]+");
assert_eq!(translate(r"[\d.,]"), "[0-9.,]");
assert_eq!(translate(r"\w"), "[0-9A-Za-z_]");
assert!(translate(r"\bfoo").starts_with("(?:(?<="));
}
#[test]
fn case_insensitivity_is_ascii_only() {
let re = compile_i("січня|Січня");
assert!(re.is_match("січня").unwrap());
assert!(re.is_match("Січня").unwrap());
assert!(!re.is_match("СІЧНЯ").unwrap());
assert!(compile_i("abc").is_match("ABC").unwrap());
}
#[test]
fn word_boundary_is_ascii_only() {
let re = compile(r"\b\d{4}\b");
assert_eq!(re.find("рік2024ось").unwrap().map(|m| m.as_str()), Some("2024"));
assert!(re.find("x2024y").unwrap().is_none());
}
}