use std::collections::{BTreeSet, HashMap, HashSet};
use regex_syntax::hir::{Class, Hir, HirKind};
use roaring::RoaringTreemap;
use super::{NGRAM_N, NGRAM_TOKENIZER, ngram_to_token, tokenize_visitor};
const MAX_SET_SIZE: usize = 16;
const MAX_STRING_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum TrigramQuery {
All,
None,
Trigram(u32),
And(Vec<Self>),
Or(Vec<Self>),
}
impl TrigramQuery {
fn and(items: Vec<Self>) -> Self {
let mut flat = Vec::with_capacity(items.len());
for item in items {
match item {
Self::All => {} Self::None => return Self::None, Self::And(children) => flat.extend(children), other => flat.push(other),
}
}
flat.sort();
flat.dedup();
match flat.len() {
0 => Self::All,
1 => flat.pop().unwrap(),
_ => Self::And(flat),
}
}
fn or(items: Vec<Self>) -> Self {
let mut flat = Vec::with_capacity(items.len());
for item in items {
match item {
Self::All => return Self::All, Self::None => {} Self::Or(children) => flat.extend(children), other => flat.push(other),
}
}
flat.sort();
flat.dedup();
match flat.len() {
0 => Self::None,
1 => flat.pop().unwrap(),
_ => Self::Or(flat),
}
}
}
struct RegexInfo {
emptyable: bool,
exact: Option<BTreeSet<String>>,
prefix: BTreeSet<String>,
suffix: BTreeSet<String>,
match_q: TrigramQuery,
}
impl RegexInfo {
fn empty_string() -> Self {
let empty = BTreeSet::from([String::new()]);
Self {
emptyable: true,
exact: Some(empty.clone()),
prefix: empty.clone(),
suffix: empty,
match_q: TrigramQuery::All,
}
}
fn literal(s: &str) -> Self {
let set = BTreeSet::from([s.to_string()]);
Self {
emptyable: s.is_empty(),
exact: Some(set.clone()),
prefix: set.clone(),
suffix: set,
match_q: trigrams_of_string(s),
}
}
fn any_char() -> Self {
Self {
emptyable: false,
exact: None,
prefix: BTreeSet::new(),
suffix: BTreeSet::new(),
match_q: TrigramQuery::All,
}
}
fn bound(&mut self) {
let oversized_exact = self.exact.as_ref().is_some_and(|exact| {
exact.len() > MAX_SET_SIZE || exact.iter().any(|s| s.chars().count() > MAX_STRING_LEN)
});
if oversized_exact {
let exact = self.exact.take().expect("checked above");
self.fold_into_match(&exact);
}
self.prefix = self
.prefix
.iter()
.map(|s| leading(s, MAX_STRING_LEN))
.collect();
if self.prefix.len() > MAX_SET_SIZE {
let prefix = std::mem::take(&mut self.prefix);
self.fold_into_match(&prefix);
}
self.suffix = self
.suffix
.iter()
.map(|s| trailing(s, MAX_STRING_LEN))
.collect();
if self.suffix.len() > MAX_SET_SIZE {
let suffix = std::mem::take(&mut self.suffix);
self.fold_into_match(&suffix);
}
}
fn fold_into_match(&mut self, set: &BTreeSet<String>) {
let folded = trigrams_of_set(set.iter());
let current = std::mem::replace(&mut self.match_q, TrigramQuery::All);
self.match_q = TrigramQuery::and(vec![current, folded]);
}
}
fn trigrams_of_string(s: &str) -> TrigramQuery {
let mut tokens = Vec::new();
tokenize_visitor(&NGRAM_TOKENIZER, s, |ngram| {
tokens.push(TrigramQuery::Trigram(ngram_to_token(ngram, NGRAM_N)));
});
TrigramQuery::and(tokens)
}
fn trigrams_of_set<'a>(set: impl IntoIterator<Item = &'a String>) -> TrigramQuery {
let queries: Vec<_> = set.into_iter().map(|s| trigrams_of_string(s)).collect();
if queries.is_empty() {
return TrigramQuery::All;
}
TrigramQuery::or(queries)
}
fn cross_concat(a: &BTreeSet<String>, b: &BTreeSet<String>) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for x in a {
for y in b {
out.insert(format!("{x}{y}"));
}
}
out
}
fn leading(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
fn trailing(s: &str, n: usize) -> String {
let count = s.chars().count();
s.chars().skip(count.saturating_sub(n)).collect()
}
fn singleton_char(class: &Class) -> Option<char> {
match class {
Class::Unicode(u) => {
let ranges = u.ranges();
match ranges {
[r] if r.start() == r.end() => Some(r.start()),
_ => None,
}
}
Class::Bytes(b) => {
let ranges = b.ranges();
match ranges {
[r] if r.start() == r.end() && r.start() < 0x80 => Some(r.start() as char),
_ => None,
}
}
}
}
fn analyze(hir: &Hir) -> RegexInfo {
let mut info = match hir.kind() {
HirKind::Empty | HirKind::Look(_) => RegexInfo::empty_string(),
HirKind::Literal(lit) => match std::str::from_utf8(&lit.0) {
Ok(s) => RegexInfo::literal(s),
Err(_) => RegexInfo::any_char(),
},
HirKind::Class(class) => match singleton_char(class) {
Some(ch) => RegexInfo::literal(ch.encode_utf8(&mut [0u8; 4])),
None => RegexInfo::any_char(),
},
HirKind::Repetition(rep) => {
let inner = analyze(&rep.sub);
let at_least_one = rep.min >= 1;
RegexInfo {
emptyable: !at_least_one || inner.emptyable,
exact: None,
prefix: if at_least_one {
inner.prefix.clone()
} else {
BTreeSet::new()
},
suffix: if at_least_one {
inner.suffix.clone()
} else {
BTreeSet::new()
},
match_q: if at_least_one {
inner.match_q
} else {
TrigramQuery::All
},
}
}
HirKind::Capture(cap) => analyze(&cap.sub),
HirKind::Concat(subs) => analyze_concat(subs),
HirKind::Alternation(subs) => analyze_alternation(subs),
};
info.bound();
info
}
fn analyze_concat(subs: &[Hir]) -> RegexInfo {
let mut acc = RegexInfo::empty_string();
for sub in subs {
acc = concat_info(acc, analyze(sub));
}
acc
}
fn concat_info(acc: RegexInfo, next: RegexInfo) -> RegexInfo {
let emptyable = acc.emptyable && next.emptyable;
let boundary = if acc.suffix.is_empty() || next.prefix.is_empty() {
TrigramQuery::All
} else {
trigrams_of_set(cross_concat(&acc.suffix, &next.prefix).iter())
};
let exact = match (&acc.exact, &next.exact) {
(Some(a), Some(b)) if a.len().saturating_mul(b.len()) <= MAX_SET_SIZE => {
Some(cross_concat(a, b))
}
_ => None,
};
let prefix = match &acc.exact {
Some(a) if !next.prefix.is_empty() => cross_concat(a, &next.prefix),
Some(a) => a.clone(),
None => acc.prefix.clone(),
};
let suffix = match &next.exact {
Some(b) if !acc.suffix.is_empty() => cross_concat(&acc.suffix, b),
Some(b) => b.clone(),
None => next.suffix.clone(),
};
let match_q = TrigramQuery::and(vec![acc.match_q, next.match_q, boundary]);
let mut info = RegexInfo {
emptyable,
exact,
prefix,
suffix,
match_q,
};
info.bound();
info
}
fn analyze_alternation(subs: &[Hir]) -> RegexInfo {
let infos: Vec<RegexInfo> = subs.iter().map(analyze).collect();
let emptyable = infos.iter().any(|i| i.emptyable);
let exact = if infos.iter().all(|i| i.exact.is_some()) {
Some(
infos
.iter()
.flat_map(|i| i.exact.as_ref().unwrap().iter().cloned())
.collect(),
)
} else {
None
};
let prefix = if infos.iter().all(|i| !i.prefix.is_empty()) {
infos
.iter()
.flat_map(|i| i.prefix.iter().cloned())
.collect()
} else {
BTreeSet::new()
};
let suffix = if infos.iter().all(|i| !i.suffix.is_empty()) {
infos
.iter()
.flat_map(|i| i.suffix.iter().cloned())
.collect()
} else {
BTreeSet::new()
};
let match_q = TrigramQuery::or(infos.into_iter().map(|i| i.match_q).collect());
RegexInfo {
emptyable,
exact,
prefix,
suffix,
match_q,
}
}
pub fn regex_to_trigram_query(pattern: &str) -> TrigramQuery {
let Ok(hir) = regex_syntax::parse(pattern) else {
return TrigramQuery::All;
};
let info = analyze(&hir);
let mut conditions = vec![info.match_q];
if let Some(exact) = &info.exact {
if exact.is_empty() {
return TrigramQuery::None;
}
conditions.push(trigrams_of_set(exact.iter()));
}
conditions.push(trigrams_of_set(info.prefix.iter()));
conditions.push(trigrams_of_set(info.suffix.iter()));
TrigramQuery::and(conditions)
}
pub fn regex_can_use_index(pattern: &str) -> bool {
regex_to_trigram_query(pattern) != TrigramQuery::All
}
pub fn collect_tokens(query: &TrigramQuery, out: &mut HashSet<u32>) {
match query {
TrigramQuery::Trigram(token) => {
out.insert(*token);
}
TrigramQuery::And(items) | TrigramQuery::Or(items) => {
for item in items {
collect_tokens(item, out);
}
}
TrigramQuery::All | TrigramQuery::None => {}
}
}
pub fn eval_trigram_query(
query: &TrigramQuery,
bitmaps: &HashMap<u32, RoaringTreemap>,
) -> RoaringTreemap {
match query {
TrigramQuery::Trigram(token) => bitmaps.get(token).cloned().unwrap_or_default(),
TrigramQuery::And(items) => {
let mut iter = items.iter();
let mut acc = match iter.next() {
Some(first) => eval_trigram_query(first, bitmaps),
None => return RoaringTreemap::new(),
};
for item in iter {
if acc.is_empty() {
break;
}
acc &= &eval_trigram_query(item, bitmaps);
}
acc
}
TrigramQuery::Or(items) => {
let mut acc = RoaringTreemap::new();
for item in items {
acc |= &eval_trigram_query(item, bitmaps);
}
acc
}
TrigramQuery::All | TrigramQuery::None => RoaringTreemap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tri(trigram: &str) -> TrigramQuery {
TrigramQuery::Trigram(ngram_to_token(trigram, NGRAM_N))
}
fn q(pattern: &str) -> TrigramQuery {
regex_to_trigram_query(pattern)
}
#[test]
fn test_single_literal_trigram() {
assert_eq!(q("foo"), tri("foo"));
}
#[test]
fn test_multi_trigram_literal() {
assert_eq!(
q("foobar"),
TrigramQuery::and(vec![tri("foo"), tri("oob"), tri("oba"), tri("bar")])
);
}
#[test]
fn test_wildcard_splits_into_and() {
assert_eq!(
q("foo.*bar"),
TrigramQuery::and(vec![tri("foo"), tri("bar")])
);
}
#[test]
fn test_alternation_is_or() {
assert_eq!(
q("(cat|dog)"),
TrigramQuery::or(vec![tri("cat"), tri("dog")])
);
}
#[test]
fn test_anchors_are_transparent() {
assert_eq!(
q("^rhino"),
TrigramQuery::and(vec![tri("rhi"), tri("hin"), tri("ino")])
);
assert_eq!(q("nose$"), TrigramQuery::and(vec![tri("nos"), tri("ose")]));
}
#[test]
fn test_boundary_trigram_recovered_across_groups() {
assert_eq!(
q("fo(o)bar"), TrigramQuery::and(vec![tri("foo"), tri("oob"), tri("oba"), tri("bar")])
);
}
#[test]
fn test_no_trigram_yields_all() {
assert_eq!(q("a.b"), TrigramQuery::All);
assert_eq!(q(".*"), TrigramQuery::All);
assert_eq!(q("fo|ba"), TrigramQuery::All); }
#[test]
fn test_case_insensitive_not_accelerated() {
assert_eq!(q("(?i)Cat"), TrigramQuery::All);
}
#[test]
fn test_unparsable_pattern_yields_all() {
assert_eq!(q("("), TrigramQuery::All);
}
#[test]
fn test_large_alternation_stays_bounded() {
let pattern = (0..40)
.map(|i| format!("aa{i:02}zz"))
.collect::<Vec<_>>()
.join("|");
let result = q(&pattern);
assert_ne!(result, TrigramQuery::None);
}
#[test]
fn test_plus_requires_inner() {
assert_eq!(q("(abc)+"), tri("abc"));
}
#[test]
fn test_optional_group_is_not_required() {
assert_eq!(q("(foo)?bar"), tri("bar"));
}
#[test]
fn test_eval_and_or_with_missing_tokens() {
let foo = ngram_to_token("foo", NGRAM_N);
let bar = ngram_to_token("bar", NGRAM_N);
let mut bitmaps = HashMap::new();
bitmaps.insert(foo, RoaringTreemap::from_iter([1u64, 2, 3]));
bitmaps.insert(bar, RoaringTreemap::from_iter([2u64, 3, 4]));
let and = TrigramQuery::and(vec![tri("foo"), tri("bar")]);
assert_eq!(
eval_trigram_query(&and, &bitmaps),
RoaringTreemap::from_iter([2u64, 3])
);
let or = TrigramQuery::or(vec![tri("foo"), tri("bar")]);
assert_eq!(
eval_trigram_query(&or, &bitmaps),
RoaringTreemap::from_iter([1u64, 2, 3, 4])
);
let and_missing = TrigramQuery::and(vec![tri("foo"), tri("baz")]);
assert!(eval_trigram_query(&and_missing, &bitmaps).is_empty());
let or_missing = TrigramQuery::or(vec![tri("foo"), tri("baz")]);
assert_eq!(
eval_trigram_query(&or_missing, &bitmaps),
RoaringTreemap::from_iter([1u64, 2, 3])
);
}
#[test]
fn test_collect_tokens() {
let query = TrigramQuery::and(vec![
tri("foo"),
TrigramQuery::or(vec![tri("bar"), tri("baz")]),
]);
let mut tokens = HashSet::new();
collect_tokens(&query, &mut tokens);
assert_eq!(
tokens,
HashSet::from([
ngram_to_token("foo", NGRAM_N),
ngram_to_token("bar", NGRAM_N),
ngram_to_token("baz", NGRAM_N),
])
);
}
}