use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::OnceLock;
use aho_corasick::AhoCorasick;
use anyhow::Result;
use rayon::prelude::*;
use regex::Regex;
use crate::po::PoKey;
use crate::scan::collect_source_files;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trace {
Substring,
Skeleton,
None,
}
#[derive(Debug, Default)]
pub struct AuditReport {
pub dead_total: usize,
pub no_trace: usize,
pub substring: usize,
pub skeleton: usize,
pub traced: Vec<(PoKey, Trace)>,
}
fn placeholder_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"%\([^)]+\)[a-zA-Z]|%(?:\d+\$)?[-+ 0#']*\d*(?:\.\d+)?[a-zA-Z]|\{[^}]*\}")
.expect("valid placeholder regex")
})
}
pub fn skeleton_fragments(msgid: &str, min_len: usize) -> Vec<String> {
let re = placeholder_re();
if !re.is_match(msgid) {
return Vec::new();
}
re.split(msgid)
.filter(|s| s.chars().count() >= min_len)
.map(|s| s.to_string())
.collect()
}
#[derive(Clone, Copy)]
enum Role {
Whole(usize),
Fragment(usize),
}
pub fn audit(
dead: &[&PoKey],
roots: &[PathBuf],
source_extensions: &[String],
ignore_dirs: &[String],
min_guard_len: usize,
) -> Result<AuditReport> {
let files = collect_source_files(roots, source_extensions, ignore_dirs)?;
let mut patterns: Vec<String> = Vec::new();
let mut roles: Vec<Role> = Vec::new();
let mut frag_counts: Vec<usize> = vec![0; dead.len()];
for (i, key) in dead.iter().enumerate() {
if key.msgid.is_empty() {
continue;
}
patterns.push(key.msgid.clone());
roles.push(Role::Whole(i));
let frags = skeleton_fragments(&key.msgid, min_guard_len);
frag_counts[i] = frags.len();
for f in frags {
patterns.push(f);
roles.push(Role::Fragment(i));
}
}
if patterns.is_empty() {
return Ok(AuditReport {
dead_total: dead.len(),
no_trace: dead.len(),
..Default::default()
});
}
let ac = AhoCorasick::new(&patterns).expect("valid pattern set");
let matched: HashSet<usize> = files
.par_iter()
.map(|(_, path)| {
let mut local = HashSet::new();
if let Ok(src) = std::fs::read_to_string(path) {
for m in ac.find_overlapping_iter(&src) {
local.insert(m.pattern().as_usize());
}
}
local
})
.reduce(HashSet::new, |mut acc, set| {
acc.extend(set);
acc
});
let mut whole_hit = vec![false; dead.len()];
let mut frag_hits = vec![0usize; dead.len()];
for pid in &matched {
match roles[*pid] {
Role::Whole(i) => whole_hit[i] = true,
Role::Fragment(i) => frag_hits[i] += 1,
}
}
let mut report = AuditReport {
dead_total: dead.len(),
..Default::default()
};
for (i, key) in dead.iter().enumerate() {
let trace = if whole_hit[i] {
Trace::Substring
} else if frag_counts[i] > 0 && frag_hits[i] == frag_counts[i] {
Trace::Skeleton
} else {
Trace::None
};
match trace {
Trace::Substring => {
report.substring += 1;
report.traced.push(((*key).clone(), trace));
}
Trace::Skeleton => {
report.skeleton += 1;
report.traced.push(((*key).clone(), trace));
}
Trace::None => report.no_trace += 1,
}
}
report
.traced
.sort_by(|a, b| (&a.0.msgctxt, &a.0.msgid).cmp(&(&b.0.msgctxt, &b.0.msgid)));
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(msgid: &str) -> PoKey {
PoKey {
msgctxt: None,
msgid: msgid.to_string(),
msgid_plural: None,
}
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dead-poets-audit-{tag}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn skeleton_fragments_split_on_placeholders() {
assert_eq!(
skeleton_fragments("Deleted %d contacts", 3),
vec!["Deleted ".to_string(), " contacts".to_string()]
);
assert_eq!(
skeleton_fragments("%1$s reacted to message", 3),
vec![" reacted to message".to_string()]
);
assert_eq!(
skeleton_fragments("Welcome {name} home", 3),
vec!["Welcome ".to_string(), " home".to_string()]
);
assert!(skeleton_fragments("plain key", 3).is_empty());
assert!(skeleton_fragments("%s: %s", 3).is_empty());
}
#[test]
fn audit_buckets_each_tier() {
let dir = temp_dir("tiers");
std::fs::write(
dir.join("a.php"),
"<?php // note: Verbatim dead phrase here\n\
$x = 'Deleted ' . $n . ' contacts now';\n\
notADeadKey('whatever'); ?>",
)
.unwrap();
let k_sub = key("Verbatim dead phrase");
let k_skel = key("Deleted %d contacts");
let k_none = key("Nowhere To Be Found");
let dead = vec![&k_sub, &k_skel, &k_none];
let report = audit(
&dead,
std::slice::from_ref(&dir),
&["php".to_string()],
&[],
3,
)
.unwrap();
assert_eq!(report.dead_total, 3);
assert_eq!(report.substring, 1);
assert_eq!(report.skeleton, 1);
assert_eq!(report.no_trace, 1);
let tier = |id: &str| {
report
.traced
.iter()
.find(|(k, _)| k.msgid == id)
.map(|(_, t)| *t)
};
assert_eq!(tier("Verbatim dead phrase"), Some(Trace::Substring));
assert_eq!(tier("Deleted %d contacts"), Some(Trace::Skeleton));
assert_eq!(tier("Nowhere To Be Found"), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn substring_beats_skeleton() {
let dir = temp_dir("precedence");
std::fs::write(dir.join("t.php"), "<?php $m = 'Deleted %d contacts'; ?>").unwrap();
let k = key("Deleted %d contacts");
let report = audit(
&[&k],
std::slice::from_ref(&dir),
&["php".to_string()],
&[],
3,
)
.unwrap();
assert_eq!(report.substring, 1);
assert_eq!(report.skeleton, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn deterministic_across_thread_counts() {
let dir = temp_dir("determinism");
for i in 0..6 {
std::fs::write(
dir.join(format!("f{i}.php")),
format!("<?php $a = 'trace key {i}'; ?>"),
)
.unwrap();
}
let keys: Vec<PoKey> = (0..6).map(|i| key(&format!("trace key {i}"))).collect();
let dead: Vec<&PoKey> = keys.iter().collect();
let run = |threads: usize| {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.unwrap()
.install(|| {
audit(
&dead,
std::slice::from_ref(&dir),
&["php".to_string()],
&[],
3,
)
.unwrap()
})
};
let single = run(1);
let multi = run(4);
assert_eq!(single.substring, multi.substring);
assert_eq!(single.no_trace, multi.no_trace);
assert_eq!(single.substring, 6);
std::fs::remove_dir_all(&dir).ok();
}
}