use regex_syntax::hir::literal::{ExtractKind, Extractor, Seq};
use regex_syntax::hir::{Hir, HirKind};
use unicode_segmentation::UnicodeSegmentation;
const MIN_TRIGRAM_CHARS: usize = 3;
const CLASS_LIMIT: usize = 10;
pub fn trigram_prefilter(pattern: &str) -> Option<String> {
best_groups(pattern).map(|(_, groups)| render_prefilter_groups(&groups))
}
pub fn trigram_prefilter_groups(pattern: &str) -> Option<Vec<Vec<String>>> {
best_groups(pattern).map(|(_, groups)| groups)
}
fn best_groups(pattern: &str) -> Option<(usize, Vec<Vec<String>>)> {
let hir = regex_syntax::parse(pattern).ok()?;
let mut best: Option<(usize, Vec<Vec<String>>)> = None;
for kind in [ExtractKind::Prefix, ExtractKind::Suffix] {
consider_more_selective(&mut best, extract_groups(&hir, kind));
}
if let HirKind::Concat(elements) = hir.kind() {
for element in elements {
if matches!(element.kind(), HirKind::Repetition(rep) if rep.min == 0) {
continue;
}
consider_more_selective(&mut best, extract_groups(element, ExtractKind::Prefix));
}
}
best
}
pub(crate) fn render_prefilter_groups(groups: &[Vec<String>]) -> String {
let render_group = |group: &Vec<String>| {
group
.iter()
.map(|trigram| format!("\"{}\"", trigram.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ")
};
if groups.len() == 1 {
render_group(&groups[0])
} else {
groups
.iter()
.map(|group| format!("({})", render_group(group)))
.collect::<Vec<_>>()
.join(" OR ")
}
}
fn extract_groups(hir: &Hir, kind: ExtractKind) -> Option<(usize, Vec<Vec<String>>)> {
let mut extractor = Extractor::new();
extractor.kind(kind);
extractor.limit_class(CLASS_LIMIT);
seq_to_groups(&extractor.extract(hir))
}
fn consider_more_selective(
best: &mut Option<(usize, Vec<Vec<String>>)>,
candidate: Option<(usize, Vec<Vec<String>>)>,
) {
if let Some((min_len, groups)) = candidate {
if best
.as_ref()
.is_none_or(|(best_len, _)| min_len > *best_len)
{
*best = Some((min_len, groups));
}
}
}
pub fn trigram_prefilter_all<I, S>(patterns: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut terms: Vec<String> = Vec::new();
for pattern in patterns {
terms.push(format!("({})", trigram_prefilter(pattern.as_ref())?));
}
if terms.is_empty() {
return None;
}
Some(terms.join(" OR "))
}
fn literal_to_trigrams(text: &str) -> Option<Vec<String>> {
let lowercase = text.to_lowercase();
if lowercase
.graphemes(true)
.any(|grapheme| grapheme.chars().count() > 1)
{
return None;
}
let chars: Vec<char> = lowercase.chars().collect();
if chars.len() < MIN_TRIGRAM_CHARS {
return None;
}
let mut trigrams: Vec<String> = chars
.windows(MIN_TRIGRAM_CHARS)
.map(|w| w.iter().collect())
.collect();
trigrams.sort();
trigrams.dedup();
Some(trigrams)
}
fn seq_to_groups(seq: &Seq) -> Option<(usize, Vec<Vec<String>>)> {
let literals = seq.literals()?; if literals.is_empty() {
return None;
}
let mut groups: Vec<Vec<String>> = Vec::new();
let mut min_len = usize::MAX;
for literal in literals {
let text = std::str::from_utf8(literal.as_bytes()).ok()?;
let len = text.chars().count();
if len < MIN_TRIGRAM_CHARS {
return None; }
min_len = min_len.min(len);
groups.push(literal_to_trigrams(text)?);
}
groups.sort();
groups.dedup();
Some((min_len, groups))
}
#[cfg(test)]
mod tests {
use super::*;
fn quoted_literals(query: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = query.chars().peekable();
while let Some(c) = chars.next() {
if c != '"' {
continue;
}
let mut lit = String::new();
while let Some(&n) = chars.peek() {
chars.next();
if n == '"' {
if chars.peek() == Some(&'"') {
chars.next();
lit.push('"');
continue;
}
break;
}
lit.push(n);
}
out.push(lit);
}
out
}
fn trigrams_of(s: &str) -> Vec<String> {
let chars: Vec<char> = s.to_lowercase().chars().collect();
if chars.len() < MIN_TRIGRAM_CHARS {
return Vec::new();
}
let mut out: Vec<String> = (0..=chars.len() - MIN_TRIGRAM_CHARS)
.map(|i| chars[i..i + MIN_TRIGRAM_CHARS].iter().collect())
.collect();
out.sort();
out.dedup();
out
}
fn parse_groups(query: &str) -> Vec<Vec<String>> {
query.split(" OR ").map(quoted_literals).collect()
}
#[test]
fn simple_literal_yields_one_term() {
let q = trigram_prefilter("ECONNRESET").expect("prefilterable");
assert!(!q.contains(" OR "), "single literal must not OR: {q}");
assert!(q.contains(" AND "), "must AND its trigrams: {q}");
assert_eq!(quoted_literals(&q), trigrams_of("econnreset"));
}
#[test]
fn alternation_yields_ored_terms() {
let q = trigram_prefilter("foobar|bazqux").expect("prefilterable");
let groups = parse_groups(&q);
assert_eq!(groups.len(), 2, "{q}");
assert!(groups.contains(&trigrams_of("foobar")), "{q}");
assert!(groups.contains(&trigrams_of("bazqux")), "{q}");
}
#[test]
fn too_short_literal_falls_back_to_none() {
assert_eq!(trigram_prefilter("ab"), None);
assert_eq!(trigram_prefilter("a|bb"), None);
}
#[test]
fn unbounded_prefix_falls_back_to_none() {
assert_eq!(trigram_prefilter("[a-z]+"), None);
assert_eq!(trigram_prefilter(".*"), None);
}
#[test]
fn suffix_used_when_prefix_is_too_short() {
let q = trigram_prefilter(r"\bno,?\s+that'?s\b").expect("suffix-prefilterable");
let lits = quoted_literals(&q);
assert!(lits.iter().any(|l| l == "tha"), "{lits:?}");
assert!(lits.iter().any(|l| l == "hat"), "{lits:?}");
}
#[test]
fn prefilter_is_a_superset_of_regex_matches() {
let cases: &[(&str, &[&str])] = &[
(
r"\byou forgot\b",
&["you forgot the tests", "well You Forgot it", "YOU FORGOT"],
),
(
r"\byou (deleted|removed|reverted)\b",
&["you deleted x", "you removed y", "You Reverted z"],
),
(
r"\bno,?\s+that'?s\b",
&["no, that's wrong", "no thats not it"],
),
(r"\balso need\b", &["we also need tests", "ALSO NEED more"]),
(r"\bbut you\b", &["ok but you missed it"]),
("ECONNRESET", &["socket hang up ECONNRESET here"]),
(r"\bstop doing\b", &["please stop doing that"]),
];
for (pat, texts) in cases {
let query = trigram_prefilter(pat)
.unwrap_or_else(|| panic!("expected {pat:?} to be prefilterable"));
let groups = parse_groups(&query);
assert!(!groups.is_empty(), "{pat:?} -> {query:?}");
let re = regex::Regex::new(&format!("(?i){pat}")).unwrap();
for text in *texts {
assert!(re.is_match(text), "fixture {text:?} must match {pat:?}");
let lowered = text.to_lowercase();
assert!(
groups
.iter()
.any(|g| g.iter().all(|tri| lowered.contains(tri.as_str()))),
"SUPERSET VIOLATION: {text:?} matched {pat:?} but no group of {groups:?} \
is fully contained",
);
}
}
}
#[test]
fn inner_literal_beats_prefix_and_suffix() {
let q = trigram_prefilter(r"error.*ECONNRESET.*occurred").expect("prefilterable");
assert_eq!(
parse_groups(&q),
vec![trigrams_of("econnreset")],
"inner literal selected: {q}"
);
let re = regex::Regex::new(r"(?i)error.*ECONNRESET.*occurred").unwrap();
let text = "error: the deploy ECONNRESET and then it occurred again";
assert!(re.is_match(text));
assert!(
text.to_lowercase().contains("econnreset"),
"candidate is a superset"
);
let q2 = trigram_prefilter(r"(prefix)?ECONNRESET").expect("prefilterable");
let lits2 = quoted_literals(&q2);
assert!(
trigrams_of("econnreset").iter().all(|t| lits2.contains(t)),
"{q2:?}"
);
}
#[test]
fn all_combines_or_and_fails_closed_on_unprefilterable() {
let q =
trigram_prefilter_all([r"\byou forgot\b", "ECONNRESET"]).expect("both prefilterable");
let lits = quoted_literals(&q);
assert!(
trigrams_of("you forgot").iter().all(|t| lits.contains(t)),
"{q}"
);
assert!(
trigrams_of("econnreset").iter().all(|t| lits.contains(t)),
"{q}"
);
assert_eq!(trigram_prefilter_all([r"\byou forgot\b", "ab"]), None);
}
#[test]
fn structured_groups_match_the_fts5_string() {
for pat in [
"ECONNRESET",
"foobar|bazqux",
r"\byou (deleted|removed|reverted)\b",
r"error.*ECONNRESET.*occurred",
] {
let groups = trigram_prefilter_groups(pat).expect("prefilterable");
let from_string = parse_groups(&trigram_prefilter(pat).unwrap());
let norm = |gs: &[Vec<String>]| {
let mut v: Vec<Vec<String>> = gs
.iter()
.map(|g| {
let mut g = g.clone();
g.sort();
g
})
.collect();
v.sort();
v
};
assert_eq!(norm(&groups), norm(&from_string), "mismatch for {pat:?}");
}
assert_eq!(trigram_prefilter_groups("ab"), None);
}
}