use crate::error::{Result, TqlError};
const SHORTHAND: &[(char, &str, Option<&str>)] = &[
('d', "[0-9]", Some("0-9")),
('D', "[^0-9]", None),
('w', "[A-Za-z0-9_]", Some("A-Za-z0-9_")),
('W', "[^A-Za-z0-9_]", None),
('s', "[ \t\n\r\u{0C}\u{0B}]", Some(" \t\n\r\u{0C}\u{0B}")),
('S', "[^ \t\n\r\u{0C}\u{0B}]", None),
];
const UNSUPPORTED: &[(&str, &str)] = &[
("(?=", "lookahead"),
("(?!", "negative lookahead"),
("(?<=", "lookbehind"),
("(?<!", "negative lookbehind"),
("(?P<", "named capture group"),
("(?P=", "named backreference"),
];
const INLINE_FLAG_CHARS: &[char] = &['a', 'i', 'm', 's', 'u', 'x'];
fn shorthand(ch: char) -> Option<(&'static str, Option<&'static str>)> {
SHORTHAND
.iter()
.find(|(key, _, _)| *key == ch)
.map(|(_, full, inner)| (*full, *inner))
}
fn unsupported_escape(ch: char) -> Option<&'static str> {
match ch {
'b' => Some("word boundary"),
'B' => Some("non-word boundary"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LuceneRegex {
pub pattern: String,
pub case_insensitive: bool,
pub changes: Vec<String>,
}
impl LuceneRegex {
pub fn changed(&self) -> bool {
!self.changes.is_empty()
}
}
pub fn is_lucene_safe(pattern: &str) -> bool {
match to_lucene_regex(pattern, false) {
Ok(translated) => !translated.changed(),
Err(_) => false,
}
}
fn match_inline_flags(chars: &[char]) -> Option<(Vec<char>, usize)> {
if chars.len() < 4 || chars[0] != '(' || chars[1] != '?' {
return None;
}
let mut end = 2;
while end < chars.len() && INLINE_FLAG_CHARS.contains(&chars[end]) {
end += 1;
}
if end == 2 || end >= chars.len() || chars[end] != ')' {
return None;
}
Some((chars[2..end].to_vec(), end + 1))
}
pub fn to_lucene_regex(pattern: &str, anchor: bool) -> Result<LuceneRegex> {
let mut changes: Vec<String> = Vec::new();
let mut case_insensitive = false;
let mut chars: Vec<char> = pattern.chars().collect();
if let Some((flags, end)) = match_inline_flags(&chars) {
let mut unsupported: Vec<char> = flags.iter().copied().filter(|c| *c != 'i').collect();
unsupported.sort_unstable();
unsupported.dedup();
if !unsupported.is_empty() {
let joined: String = unsupported.into_iter().collect();
return Err(TqlError::ValidationError(format!(
"regex uses inline flag(s) '{joined}', which Lucene's engine does not support"
)));
}
case_insensitive = flags.contains(&'i');
chars = chars[end..].to_vec();
changes.push("lifted (?i) to the query's case_insensitive flag".to_string());
}
let remaining: String = chars.iter().collect();
for (token, label) in UNSUPPORTED {
if remaining.contains(token) {
return Err(TqlError::ValidationError(format!(
"regex uses {label} ('{token}'), which has no equivalent in Lucene's \
finite-automaton engine; rewrite the pattern without it"
)));
}
}
let mut out: Vec<String> = Vec::new();
let mut in_class = false;
let mut anchored_start = false;
let mut anchored_end = false;
let mut i = 0usize;
let length = chars.len();
while i < length {
let ch = chars[i];
if ch == '\\' && i + 1 < length {
let nxt = chars[i + 1];
if nxt == 'A' && i == 0 {
anchored_start = true;
changes.push(r"dropped \A (Lucene anchors implicitly)".to_string());
i += 2;
continue;
}
if (nxt == 'Z' || nxt == 'z') && i + 2 == length {
anchored_end = true;
changes.push(format!(r"dropped \{nxt} (Lucene anchors implicitly)"));
i += 2;
continue;
}
if let Some(label) = unsupported_escape(nxt) {
return Err(TqlError::ValidationError(format!(
"regex uses {label} (\\{nxt}), which has no equivalent in Lucene's \
finite-automaton engine; rewrite the pattern without it"
)));
}
if nxt.is_ascii_digit() && nxt != '0' {
return Err(TqlError::ValidationError(
"regex uses a backreference, which has no equivalent in Lucene's \
finite-automaton engine"
.to_string(),
));
}
if let Some((full, inner)) = shorthand(nxt) {
let emitted = if in_class {
let Some(inner) = inner else {
return Err(TqlError::ValidationError(format!(
"regex nests a negated shorthand class (\\{nxt}) inside [...], \
which cannot be expressed as a single Lucene character class"
)));
};
inner
} else {
full
};
out.push(emitted.to_string());
changes.push(format!("\\{nxt} -> {emitted}"));
i += 2;
continue;
}
out.push(format!("{ch}{nxt}"));
i += 2;
continue;
}
if ch == ')' && !in_class && out.last().map(String::as_str) == Some("|") {
out.pop();
out.push(")?".to_string());
changes.push("(X|) -> (X)? (Lucene has no empty alternative)".to_string());
i += 1;
continue;
}
if ch == '[' && !in_class {
in_class = true;
out.push(ch.to_string());
i += 1;
continue;
}
if ch == ']' && in_class {
in_class = false;
out.push(ch.to_string());
i += 1;
continue;
}
if in_class {
if ch == '"' {
out.push("\\\"".to_string());
changes.push("escaped a bare \" (Lucene quote operator)".to_string());
} else {
out.push(ch.to_string());
}
i += 1;
continue;
}
if ch == '"' {
out.push("\\\"".to_string());
changes.push("escaped a bare \" (Lucene quote operator)".to_string());
i += 1;
continue;
}
if ch == '^' && i == 0 {
anchored_start = true;
changes.push("dropped ^ (Lucene anchors implicitly)".to_string());
i += 1;
continue;
}
if ch == '$' && i == length - 1 {
anchored_end = true;
changes.push("dropped $ (Lucene anchors implicitly)".to_string());
i += 1;
continue;
}
if ch == '(' && i + 2 < length && chars[i + 1] == '?' && chars[i + 2] == ':' {
out.push("(".to_string());
changes.push("(?: -> ( (Lucene has no non-capturing group)".to_string());
i += 3;
continue;
}
if matches!(ch, '*' | '+' | '?' | '}')
&& i + 1 < length
&& matches!(chars[i + 1], '?' | '+')
{
out.push(ch.to_string());
changes.push(format!(
"{ch}{} -> {ch} (no lazy/possessive quantifiers)",
chars[i + 1]
));
i += 2;
continue;
}
out.push(ch.to_string());
i += 1;
}
if in_class {
return Err(TqlError::ValidationError(
"regex has an unterminated character class '['".to_string(),
));
}
let mut translated: String = out.concat();
if anchored_start && anchored_end {
} else if anchored_start {
if !translated.ends_with(".*") {
translated.push_str(".*");
changes.push("^ became a trailing .* (Lucene matches whole terms)".to_string());
}
} else if anchored_end {
if !translated.starts_with(".*") {
translated = format!(".*{translated}");
changes.push("$ became a leading .* (Lucene matches whole terms)".to_string());
}
} else if anchor {
let before = translated.clone();
if !translated.starts_with(".*") {
translated = format!(".*{translated}");
}
if !translated.ends_with(".*") {
translated.push_str(".*");
}
if translated != before {
changes.push("wrapped in .* (Lucene matches whole terms, not substrings)".to_string());
}
}
Ok(LuceneRegex {
pattern: translated,
case_insensitive,
changes,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn translate(pattern: &str) -> String {
to_lucene_regex(pattern, false).unwrap().pattern
}
fn refusal(pattern: &str) -> String {
match to_lucene_regex(pattern, false) {
Ok(result) => panic!("expected a refusal, got {:?}", result.pattern),
Err(e) => e.to_string(),
}
}
#[test]
fn shorthand_classes_become_explicit() {
assert_eq!(translate(r"a\db"), "a[0-9]b");
assert_eq!(translate(r"a\wb"), "a[A-Za-z0-9_]b");
assert_eq!(translate(r"a\Db"), "a[^0-9]b");
assert_eq!(translate(r"a\Wb"), "a[^A-Za-z0-9_]b");
assert_eq!(translate(r"a\sb"), "a[ \t\n\r\u{0C}\u{0B}]b");
assert_eq!(translate(r"a\Sb"), "a[^ \t\n\r\u{0C}\u{0B}]b");
}
#[test]
fn shorthand_inside_a_class_does_not_nest_brackets() {
assert_eq!(translate(r"[\d-]"), "[0-9-]");
}
#[test]
fn negated_shorthand_inside_a_class_is_refused() {
assert!(refusal(r"[\D]").contains("negated shorthand"));
}
#[test]
fn bare_double_quote_is_escaped() {
assert_eq!(translate("set \"abc\""), "set \\\"abc\\\"");
}
#[test]
fn non_capturing_group_becomes_a_plain_group() {
assert_eq!(translate(r"(?:ab|cd)"), "(ab|cd)");
}
#[test]
fn lazy_quantifiers_become_greedy() {
assert_eq!(translate(r"a+?b"), "a+b");
assert_eq!(translate(r"a*?b"), "a*b");
assert_eq!(translate(r"a{2,3}?b"), "a{2,3}b");
}
#[test]
fn both_anchors_mean_exact_match() {
assert_eq!(translate("^abc$"), "abc");
}
#[test]
fn leading_anchor_becomes_a_trailing_star() {
assert_eq!(translate("^abc"), "abc.*");
}
#[test]
fn trailing_anchor_becomes_a_leading_star() {
assert_eq!(translate("abc$"), ".*abc");
}
#[test]
fn pcre_string_anchors_are_translated_not_refused() {
assert_eq!(translate(r"\Aabc\Z"), "abc");
assert_eq!(translate(r"\Aabc\z"), "abc");
}
#[test]
fn escaped_backslash_before_b_is_a_path_not_a_word_boundary() {
let source = r":\\ProgramData\\OEM\\CareCenter_.*\\BUnzip\\Setup_msi\.exe";
assert_eq!(translate(source), source);
}
#[test]
fn real_word_boundary_is_still_refused() {
assert!(refusal(r"foo\bbar").contains("word boundary"));
assert!(refusal(r"foo\Bbar").contains("non-word boundary"));
}
#[test]
fn escaped_backslash_before_a_digit_is_not_a_backreference() {
assert_eq!(translate(r"C:\\1st"), r"C:\\1st");
assert!(refusal(r"(a)\1").contains("backreference"));
}
#[test]
fn leading_case_insensitive_flag_is_lifted_to_the_query() {
let result = to_lucene_regex(r"(?i)abc", false).unwrap();
assert_eq!(result.pattern, "abc");
assert!(result.case_insensitive);
}
#[test]
fn unsupported_inline_flag_is_refused() {
assert!(refusal(r"(?s)a.b").contains("inline flag"));
}
#[test]
fn lookaround_is_refused() {
for source in [r"a(?=b)", r"a(?!b)", r"(?<=a)b", r"(?<!a)b"] {
assert!(
to_lucene_regex(source, false).is_err(),
"{source} should be refused"
);
}
}
#[test]
fn named_group_constructs_are_refused() {
assert!(refusal(r"(?P<name>a)").contains("named capture group"));
assert!(refusal(r"(?P=name)").contains("named backreference"));
}
#[test]
fn anchor_true_wraps_an_unanchored_pattern() {
assert_eq!(to_lucene_regex("abc", true).unwrap().pattern, ".*abc.*");
}
#[test]
fn anchor_true_respects_a_deliberate_anchor() {
assert_eq!(to_lucene_regex("^abc$", true).unwrap().pattern, "abc");
}
#[test]
fn anchor_false_leaves_the_pattern_alone() {
assert_eq!(to_lucene_regex("abc", false).unwrap().pattern, "abc");
}
#[test]
fn a_clean_pattern_reports_no_changes() {
let result = to_lucene_regex("[a-z]+foo", false).unwrap();
assert!(!result.changed());
assert!(is_lucene_safe("[a-z]+foo"));
assert!(!is_lucene_safe(r"foo\bbar"));
}
#[test]
fn a_translated_pattern_explains_itself() {
let result = to_lucene_regex("(?:\\d)\"", false).unwrap();
assert!(result.changed());
let joined = result.changes.join("; ");
assert!(joined.contains("\\d"), "{joined}");
assert!(joined.contains("(?:"), "{joined}");
assert!(joined.contains("quote"), "{joined}");
}
#[test]
fn unterminated_class_is_refused() {
assert!(refusal("[abc").contains("unterminated"));
}
#[test]
fn trailing_empty_alternative_becomes_an_optional_group() {
assert_eq!(translate(r"a(b|)c"), "a(b)?c");
}
#[test]
fn escaped_pipe_is_not_mistaken_for_an_alternative() {
assert_eq!(translate(r"(a\|)"), r"(a\|)");
}
#[test]
fn leading_empty_alternative_is_left_alone() {
assert_eq!(translate(r"(|a)b"), "(|a)b");
}
#[test]
fn a_lone_trailing_backslash_is_preserved() {
assert_eq!(translate(r"abc\"), r"abc\");
}
}