use std::borrow::Cow;
use std::collections::HashMap;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use icu_normalizer::ComposingNormalizerBorrowed;
use icu_properties::props::DefaultIgnorableCodePoint;
use icu_properties::CodePointSetData;
use super::{TermMode, TermRule};
pub struct Matcher {
rules: Vec<TermRule>,
folded: Option<Automaton>,
exact: Option<Automaton>,
}
struct Automaton {
automaton: AhoCorasick,
rules: Vec<Vec<usize>>,
}
#[derive(Debug)]
pub struct Unbuildable(pub String);
impl Matcher {
pub fn new(rules: Vec<TermRule>) -> Result<Matcher, Unbuildable> {
let mut folded = Vec::new();
let mut exact = Vec::new();
for (index, rule) in rules.iter().enumerate() {
let normalized = normalize(&rule.term);
if normalized.is_empty() {
continue;
}
if rule.case_sensitive {
exact.push((normalized.into_owned(), index));
} else {
folded.push((fold(&normalized).into_owned(), index));
}
}
Ok(Matcher {
folded: Automaton::build(folded)?,
exact: Automaton::build(exact)?,
rules,
})
}
pub fn rules(&self) -> &[TermRule] {
&self.rules
}
pub fn find(&self, text: &str) -> Vec<usize> {
let mut found = Vec::new();
if self.folded.is_none() && self.exact.is_none() {
return found;
}
let normalized = normalize(text);
if let Some(exact) = &self.exact {
exact.collect(&normalized, &self.rules, &mut found);
}
if let Some(folded) = &self.folded {
folded.collect(&fold(&normalized), &self.rules, &mut found);
}
found.sort_unstable();
found.dedup();
found
}
}
impl Automaton {
fn build(patterns: Vec<(String, usize)>) -> Result<Option<Automaton>, Unbuildable> {
if patterns.is_empty() {
return Ok(None);
}
let mut spellings: Vec<String> = Vec::new();
let mut rules: Vec<Vec<usize>> = Vec::new();
let mut known: HashMap<String, usize> = HashMap::new();
for (spelling, rule) in patterns {
match known.get(&spelling) {
Some(&at) => rules[at].push(rule),
None => {
known.insert(spelling.clone(), spellings.len());
spellings.push(spelling);
rules.push(vec![rule]);
}
}
}
let automaton = AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard)
.build(&spellings)
.map_err(|error| Unbuildable(error.to_string()))?;
Ok(Some(Automaton { automaton, rules }))
}
fn collect(&self, text: &str, rules: &[TermRule], found: &mut Vec<usize>) {
for hit in self.automaton.find_overlapping_iter(text) {
for &index in &self.rules[hit.pattern().as_usize()] {
if found.contains(&index) {
continue;
}
if accepts(rules[index].mode, text, hit.start(), hit.end()) {
found.push(index);
}
}
}
}
}
fn accepts(mode: TermMode, text: &str, start: usize, end: usize) -> bool {
let before = text[..start].chars().next_back();
let after = text[end..].chars().next();
match mode {
TermMode::Substring => true,
TermMode::WholeWord => !before.is_some_and(is_word) && !after.is_some_and(is_word),
TermMode::OwnerNameOnly => {
!before.is_some_and(continues_a_name)
&& match after {
None => true,
Some('.') => {
let rest = &text[end + 1..];
match rest.strip_prefix("git") {
Some(tail) => !tail.chars().next().is_some_and(continues_a_name),
None => !rest.chars().next().is_some_and(is_word),
}
}
Some(c) => !continues_a_name(c),
}
}
}
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn continues_a_name(c: char) -> bool {
is_word(c) || c == '-' || c == '.'
}
pub fn normalize(text: &str) -> Cow<'_, str> {
if text.is_ascii() {
return Cow::Borrowed(text);
}
let ignorable = CodePointSetData::new::<DefaultIgnorableCodePoint>();
let stripped: String = text.chars().filter(|c| !ignorable.contains(*c)).collect();
let composed = ComposingNormalizerBorrowed::new_nfkc().normalize(&stripped);
Cow::Owned(
composed
.chars()
.filter(|c| !ignorable.contains(*c))
.collect(),
)
}
fn fold(text: &str) -> Cow<'_, str> {
if text.is_ascii() {
if text.bytes().any(|b| b.is_ascii_uppercase()) {
Cow::Owned(text.to_ascii_lowercase())
} else {
Cow::Borrowed(text)
}
} else {
Cow::Owned(text.to_lowercase())
}
}
pub fn same_term(a: &str, b: &str) -> bool {
term_key(a) == term_key(b)
}
pub fn term_key(term: &str) -> String {
fold(&normalize(term)).into_owned()
}