use grep_regex::{RegexMatcher, RegexMatcherBuilder};
pub(super) fn build_matcher(pattern: &str) -> anyhow::Result<RegexMatcher> {
let sanitized = sanitize_braces(pattern);
match compile(&sanitized) {
Ok(matcher) => Ok(matcher),
Err(error) if is_group_error(&error.to_string()) => {
let escaped = escape_unescaped_parentheses(&sanitized);
compile(&escaped).or_else(|_| compile(®ex::escape(pattern)))
}
Err(_) => compile(®ex::escape(pattern)),
}
}
fn compile(pattern: &str) -> anyhow::Result<RegexMatcher> {
RegexMatcherBuilder::new()
.case_insensitive(false)
.multi_line(true)
.line_terminator(Some(b'\n'))
.build(pattern)
.map_err(Into::into)
}
fn is_group_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("unclosed group")
|| lower.contains("unopened group")
|| lower.contains("unclosed") && lower.contains("(")
|| lower.contains("unopened") && lower.contains(")")
}
pub(super) fn sanitize_braces(pattern: &str) -> String {
let chars = pattern.chars().collect::<Vec<_>>();
let mut out = String::with_capacity(pattern.len());
let mut index = 0;
let mut escaped = false;
while index < chars.len() {
let ch = chars[index];
if escaped {
out.push(ch);
if matches!(ch, 'x' | 'p' | 'P')
&& chars.get(index + 1) == Some(&'{')
&& let Some(end) = find_closing_brace(&chars, index + 2)
{
for c in &chars[index + 1..=end] {
out.push(*c);
}
index = end + 1;
escaped = false;
continue;
}
escaped = false;
index += 1;
continue;
}
match ch {
'\\' => {
out.push(ch);
escaped = true;
}
'{' if is_valid_quantifier_at(&chars, index) => {
let end = find_closing_brace(&chars, index + 1).unwrap_or(index);
for c in &chars[index..=end] {
out.push(*c);
}
index = end + 1;
continue;
}
'{' | '}' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
index += 1;
}
out
}
fn find_closing_brace(chars: &[char], mut index: usize) -> Option<usize> {
while index < chars.len() {
if chars[index] == '}' {
return Some(index);
}
index += 1;
}
None
}
fn is_valid_quantifier_at(chars: &[char], index: usize) -> bool {
if index == 0 || !can_quantify(chars[index - 1]) {
return false;
}
let mut cursor = index + 1;
let first_digits = cursor;
while chars.get(cursor).is_some_and(|ch| ch.is_ascii_digit()) {
cursor += 1;
}
if cursor == first_digits {
return false;
}
if chars.get(cursor) == Some(&',') {
cursor += 1;
while chars.get(cursor).is_some_and(|ch| ch.is_ascii_digit()) {
cursor += 1;
}
}
chars.get(cursor) == Some(&'}')
}
fn can_quantify(ch: char) -> bool {
!matches!(ch, '|' | '(' | '^')
}
pub(super) fn escape_unescaped_parentheses(pattern: &str) -> String {
let mut out = String::with_capacity(pattern.len());
let mut escaped = false;
for ch in pattern.chars() {
if escaped {
out.push(ch);
escaped = false;
continue;
}
match ch {
'\\' => {
out.push(ch);
escaped = true;
}
'(' | ')' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use grep_matcher::Matcher;
fn is_match(pattern: &str, haystack: &str) -> bool {
build_matcher(pattern)
.unwrap()
.is_match(haystack.as_bytes())
.unwrap()
}
#[test]
fn sanitize_braces_preserves_valid_regex_braces() {
assert_eq!(sanitize_braces("a{2,4}"), "a{2,4}");
assert_eq!(sanitize_braces("a{2}"), "a{2}");
assert_eq!(sanitize_braces("a{2,}"), "a{2,}");
assert_eq!(sanitize_braces(r"\x{1F600}"), r"\x{1F600}");
assert_eq!(sanitize_braces(r"\p{L}"), r"\p{L}");
assert_eq!(sanitize_braces(r"\P{Greek}"), r"\P{Greek}");
}
#[test]
fn sanitize_braces_escapes_stray_braces() {
assert_eq!(sanitize_braces("${x}"), r"$\{x\}");
assert_eq!(sanitize_braces("a } b"), r"a \} b");
}
#[test]
fn escape_unescaped_parentheses_rescues_group_errors() {
assert_eq!(escape_unescaped_parentheses("foo(bar"), r"foo\(bar");
assert!(is_match("foo(bar", "foo(bar"));
assert!(is_match("foo)bar", "foo)bar"));
}
#[test]
fn literal_fallback_after_total_regex_failure() {
assert!(is_match("[", "["));
}
#[test]
fn line_anchor_pattern_compiles() {
assert!(is_match("^needle$", "needle"));
assert!(!is_match("^needle$", "xneedle"));
}
}