use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use parking_lot::Mutex;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use mir_types::Name;
pub struct MentionScanner {
epoch: u64,
ac: aho_corasick::AhoCorasick,
patterns: Vec<Name>,
}
impl MentionScanner {
pub(crate) fn build(epoch: u64, patterns: Vec<Name>) -> Option<Self> {
if patterns.is_empty() {
return None;
}
let ac = aho_corasick::AhoCorasick::builder()
.ascii_case_insensitive(true)
.build(patterns.iter().map(|n| n.as_str()))
.ok()?;
Some(Self {
epoch,
ac,
patterns,
})
}
pub fn epoch(&self) -> u64 {
self.epoch
}
pub fn scan(&self, hay: &str) -> Box<[Name]> {
let bytes = hay.as_bytes();
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut seen: FxHashSet<usize> = FxHashSet::default();
let mut hits: Vec<Name> = Vec::new();
for m in self.ac.find_overlapping_iter(hay) {
let pid = m.pattern().as_usize();
if seen.contains(&pid) {
continue;
}
if (m.start() == 0 || !is_ident(bytes[m.start() - 1]))
&& (m.end() == bytes.len() || !is_ident(bytes[m.end()]))
{
seen.insert(pid);
hits.push(self.patterns[pid]);
}
}
hits.sort_unstable();
hits.into_boxed_slice()
}
}
struct FileMentions {
text: Arc<str>,
epoch: u64,
names: Box<[Name]>,
}
#[derive(Clone, Copy)]
pub struct MentionQuery {
pub name: Name,
added_epoch: u64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ClassMentionStats {
pub universe_names: usize,
pub files_covered: usize,
pub total_mentions: usize,
pub scanner_bytes: usize,
pub scans_recorded: u64,
}
#[derive(Default)]
struct Universe {
epoch: u64,
names: FxHashMap<Name, u64>,
scanner: Option<Arc<MentionScanner>>,
}
#[derive(Default)]
pub struct ClassMentionIndex {
universe: Mutex<Universe>,
by_file: dashmap::DashMap<Arc<str>, FileMentions, FxBuildHasher>,
scans_recorded: AtomicU64,
}
impl ClassMentionIndex {
pub fn add_names(&self, names: impl IntoIterator<Item = Name>) {
let mut u = self.universe.lock();
let next = u.epoch + 1;
let mut added = false;
for n in names {
if let std::collections::hash_map::Entry::Vacant(v) = u.names.entry(n) {
v.insert(next);
added = true;
}
}
if added {
u.epoch = next;
}
}
pub fn scanner(&self) -> Option<Arc<MentionScanner>> {
let (epoch, patterns) = {
let u = self.universe.lock();
if u.names.is_empty() {
return None;
}
if let Some(s) = u.scanner.as_ref().filter(|s| s.epoch == u.epoch) {
return Some(s.clone());
}
(u.epoch, u.names.keys().copied().collect::<Vec<Name>>())
};
let scanner = Arc::new(MentionScanner::build(epoch, patterns)?);
let mut u = self.universe.lock();
if scanner.epoch == u.epoch {
u.scanner = Some(scanner.clone());
}
Some(scanner)
}
pub fn prepare_query(&self, needle: &str) -> Option<MentionQuery> {
let name = Name::new(needle).ascii_lowercase();
let added_epoch = *self.universe.lock().names.get(&name)?;
Some(MentionQuery { name, added_epoch })
}
pub fn answer(&self, file: &str, q: &MentionQuery, current_text: &Arc<str>) -> Option<bool> {
let e = self.by_file.get(file)?;
if !Arc::ptr_eq(&e.text, current_text) || e.epoch < q.added_epoch {
return None;
}
Some(e.names.binary_search(&q.name).is_ok())
}
pub fn is_current(&self, file: &str, text: &Arc<str>, epoch: u64) -> bool {
self.by_file
.get(file)
.is_some_and(|e| Arc::ptr_eq(&e.text, text) && e.epoch >= epoch)
}
pub fn set_file(&self, file: Arc<str>, text: Arc<str>, epoch: u64, names: Box<[Name]>) {
match self.by_file.entry(file) {
dashmap::mapref::entry::Entry::Occupied(mut o) => {
let prev = o.get();
if Arc::ptr_eq(&prev.text, &text) && prev.epoch >= epoch {
return;
}
o.insert(FileMentions { text, epoch, names });
}
dashmap::mapref::entry::Entry::Vacant(v) => {
v.insert(FileMentions { text, epoch, names });
}
}
self.scans_recorded.fetch_add(1, Ordering::Relaxed);
}
pub fn clear_file(&self, file: &str) {
self.by_file.remove(file);
}
pub fn stats(&self) -> ClassMentionStats {
let u = self.universe.lock();
ClassMentionStats {
universe_names: u.names.len(),
files_covered: self.by_file.len(),
total_mentions: self.by_file.iter().map(|e| e.names.len()).sum(),
scanner_bytes: u.scanner.as_ref().map(|s| s.ac.memory_usage()).unwrap_or(0),
scans_recorded: self.scans_recorded.load(Ordering::Relaxed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lc(s: &str) -> Name {
Name::new(s).ascii_lowercase()
}
fn scanner_for(idx: &ClassMentionIndex) -> Arc<MentionScanner> {
idx.scanner().unwrap()
}
#[test]
fn scan_is_whole_identifier_and_case_insensitive() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("Color"), lc("Save")]);
let s = scanner_for(&idx);
assert_eq!(s.scan("new COLOR();").as_ref(), &[lc("Color")]);
assert!(s.scan("$this->saveAll();").is_empty());
let both = s.scan("Color::save()");
assert_eq!(both.len(), 2);
}
#[test]
fn epoch_gates_needles_added_after_the_scan() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("Alpha")]);
let s1 = scanner_for(&idx);
let text: Arc<str> = Arc::from("uses Alpha and Beta");
let file: Arc<str> = Arc::from("a.php");
idx.set_file(file.clone(), text.clone(), s1.epoch(), s1.scan(&text));
let q_alpha = idx.prepare_query("Alpha").unwrap();
assert_eq!(idx.answer(&file, &q_alpha, &text), Some(true));
idx.add_names([lc("Beta")]);
let q_beta = idx.prepare_query("Beta").unwrap();
assert_eq!(idx.answer(&file, &q_beta, &text), None);
assert_eq!(idx.answer(&file, &q_alpha, &text), Some(true));
let s2 = scanner_for(&idx);
idx.set_file(file.clone(), text.clone(), s2.epoch(), s2.scan(&text));
assert_eq!(idx.answer(&file, &q_beta, &text), Some(true));
}
#[test]
fn text_identity_gates_answers() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("Job")]);
let s = scanner_for(&idx);
let text: Arc<str> = Arc::from("new Job();");
let file: Arc<str> = Arc::from("a.php");
idx.set_file(file.clone(), text.clone(), s.epoch(), s.scan(&text));
let q = idx.prepare_query("Job").unwrap();
assert_eq!(idx.answer(&file, &q, &text), Some(true));
let other: Arc<str> = Arc::from("new Job();");
assert_eq!(idx.answer(&file, &q, &other), None);
}
#[test]
fn re_adding_existing_names_does_not_bump_epoch() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("A"), lc("B")]);
let e1 = scanner_for(&idx).epoch();
idx.add_names([lc("A"), lc("B")]);
let e2 = scanner_for(&idx).epoch();
assert_eq!(e1, e2, "no new names → no epoch bump");
idx.add_names([lc("C")]);
let e3 = scanner_for(&idx).epoch();
assert_eq!(e3, e2 + 1);
}
#[test]
fn unknown_needle_prepares_no_query() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("Known")]);
assert!(idx.prepare_query("Unknown").is_none());
assert!(idx.prepare_query("known").is_some(), "case-folded lookup");
}
#[test]
fn set_file_keeps_newer_same_text_entry() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("A")]);
let text: Arc<str> = Arc::from("A");
let file: Arc<str> = Arc::from("f.php");
idx.set_file(file.clone(), text.clone(), 5, Box::new([lc("A")]));
idx.set_file(file.clone(), text.clone(), 3, Box::new([]));
let q = idx.prepare_query("A").unwrap();
assert_eq!(idx.answer(&file, &q, &text), Some(true));
}
#[test]
fn scanner_is_cached_per_epoch() {
let idx = ClassMentionIndex::default();
idx.add_names([lc("A")]);
let s1 = idx.scanner().unwrap();
let s2 = idx.scanner().unwrap();
assert!(Arc::ptr_eq(&s1, &s2), "same epoch → cached automaton");
idx.add_names([lc("B")]);
let s3 = idx.scanner().unwrap();
assert!(!Arc::ptr_eq(&s1, &s3), "universe grew → rebuilt automaton");
}
}