use std::collections::HashMap;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, AhoCorasickKind, MatchKind};
use super::jev_names::{name_key, NameSet};
use super::jev_obfuscate::TokenKind;
const SENTENCE_BREAKS: [char; 14] = [
'.', '!', '?', ':', ';', '\n', '*', '-', '>', '#', '(', '[', '"', '\'',
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MatcherError {
TooBig {
limit: usize,
},
Build,
}
struct Pat {
rank: u32,
kind: TokenKind,
word_start: bool,
word_end: bool,
}
struct Lexicon {
ac: AhoCorasick,
pats: Vec<Pat>,
fold: bool,
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn single(mut it: impl Iterator<Item = char>) -> Option<char> {
match (it.next(), it.next()) {
(Some(c), None) => Some(c),
_ => None,
}
}
fn fold_char(c: char) -> char {
if c.is_ascii() {
return c.to_ascii_lowercase();
}
let lower = single(c.to_lowercase()).unwrap_or(c);
let upper = single(lower.to_uppercase()).unwrap_or(lower);
single(upper.to_lowercase()).unwrap_or(lower)
}
fn normalize(text: &str, fold: bool) -> (String, Vec<usize>) {
let mut out = String::with_capacity(text.len());
let mut map = Vec::with_capacity(text.len() + 1);
let mut in_space = false;
for (i, c) in text.char_indices() {
if c.is_whitespace() {
if !in_space {
out.push(' ');
map.push(i);
}
in_space = true;
continue;
}
in_space = false;
let n = match c {
'\u{2019}' => '\'',
c if fold => fold_char(c),
c => c,
};
out.push(n);
map.resize(out.len(), i);
}
map.push(text.len());
(out, map)
}
impl Lexicon {
fn build(names: &[(&str, TokenKind)], fold: bool) -> Result<Option<Self>, MatcherError> {
if names.is_empty() {
return Ok(None);
}
let mut order: Vec<usize> = (0..names.len()).collect();
let chars: Vec<usize> = names.iter().map(|(n, _)| n.chars().count()).collect();
order.sort_by(|&a, &b| {
chars[b]
.cmp(&chars[a])
.then_with(|| names[a].0.cmp(names[b].0))
});
let mut rank = vec![0_u32; names.len()];
for (r, &i) in order.iter().enumerate() {
rank[i] = u32::try_from(r).map_err(|_| MatcherError::Build)?;
}
let edge = |c: Option<char>| c.is_some_and(|c| u8::try_from(c).is_ok_and(is_word_byte));
let pats = names
.iter()
.zip(rank)
.map(|((n, kind), rank)| Pat {
rank,
kind: *kind,
word_start: edge(n.chars().next()),
word_end: edge(n.chars().last()),
})
.collect();
let normalized = names.iter().map(|(n, _)| normalize(n, fold).0);
let ac = AhoCorasickBuilder::new()
.match_kind(MatchKind::Standard)
.kind(Some(AhoCorasickKind::ContiguousNFA))
.build(normalized)
.map_err(|_| MatcherError::Build)?;
Ok(Some(Self { ac, pats, fold }))
}
fn memory_usage(&self) -> usize {
self.ac.memory_usage() + self.pats.capacity() * std::mem::size_of::<Pat>()
}
fn find(&self, text: &str) -> Vec<(usize, usize, usize)> {
let (norm, map) = normalize(text, self.fold);
let bytes = text.as_bytes();
let mut hits: Vec<(usize, u32, usize, usize)> = self
.ac
.find_overlapping_iter(&norm)
.filter_map(|m| {
let id = m.pattern().as_usize();
let p = &self.pats[id];
let (s, e) = (map[m.start()], map[m.end()]);
let start_ok = !p.word_start || s == 0 || !is_word_byte(bytes[s - 1]);
let end_ok = !p.word_end || e == bytes.len() || !is_word_byte(bytes[e]);
(start_ok && end_ok).then_some((s, p.rank, e, id))
})
.collect();
hits.sort_unstable();
let mut out = Vec::new();
let mut pos = 0;
for (s, _, e, id) in hits {
if s >= pos {
out.push((s, e, id));
pos = e;
}
}
out
}
}
pub(super) struct NameMatcher {
folded: Option<Lexicon>,
titled: Option<Lexicon>,
}
impl NameMatcher {
pub(super) fn build(
set: &NameSet,
size_limit: usize,
) -> Result<Option<Self>, (usize, MatcherError)> {
let count = set.entries.len() + set.titled.len();
let fail = |e| (count, e);
let entries: Vec<(&str, TokenKind)> =
set.entries.iter().map(|(n, k)| (n.as_str(), *k)).collect();
let folded = Lexicon::build(&entries, true).map_err(fail)?;
let mut titled_kind: HashMap<String, TokenKind> = set
.titled
.iter()
.map(|t| (name_key(t), TokenKind::Person))
.collect();
if !titled_kind.is_empty() {
for (n, k) in &set.entries {
if let Some(kind) = titled_kind.get_mut(&name_key(n)) {
*kind = *k;
}
}
}
let titled: Vec<(&str, TokenKind)> = set
.titled
.iter()
.map(|t| {
let kind = titled_kind.get(&name_key(t)).copied();
(t.as_str(), kind.unwrap_or(TokenKind::Person))
})
.collect();
let titled = Lexicon::build(&titled, false).map_err(fail)?;
let m = Self { folded, titled };
if m.memory_usage() > size_limit {
return Err(fail(MatcherError::TooBig { limit: size_limit }));
}
Ok((m.folded.is_some() || m.titled.is_some()).then_some(m))
}
pub(super) fn memory_usage(&self) -> usize {
[&self.folded, &self.titled]
.into_iter()
.flatten()
.map(Lexicon::memory_usage)
.sum()
}
pub(super) fn apply(
&self,
text: &str,
mut token: impl FnMut(TokenKind, &str) -> String,
) -> String {
let mut out = text.to_string();
for (i, lex) in [&self.folded, &self.titled].into_iter().enumerate() {
let Some(lex) = lex else { continue };
let src = out;
let mut next = String::with_capacity(src.len());
let mut last = 0;
for (s, e, id) in lex.find(&src) {
let before = src[..s].trim_end_matches([' ', '\t']);
if i == 1 && (before.is_empty() || before.ends_with(SENTENCE_BREAKS)) {
continue;
}
next.push_str(&src[last..s]);
next.push_str(&token(lex.pats[id].kind, &src[s..e]));
last = e;
}
next.push_str(&src[last..]);
out = next;
}
out
}
}