mod container;
pub mod data;
mod duplication;
pub mod engine;
pub mod report;
pub use report::{
BundleFile, BundleReport, ClassDensity, Container, CrossFileDuplication, CrossFileOccurrence,
Densities, EvidenceReport, Finding, Stats,
};
const SNIPPET_CAP: usize = 200;
const DENSITY_FLOOR_WORDS: usize = 100;
#[derive(Debug, Clone, Default)]
pub struct AnalyzeOptions {
pub allow_terms: Vec<String>,
}
pub fn analyze(text: &str) -> EvidenceReport {
analyze_with(text, &AnalyzeOptions::default())
}
pub fn analyze_with(text: &str, opts: &AnalyzeOptions) -> EvidenceReport {
let cp = engine::compiled();
let mut hits = engine::scan_all(cp, text);
hits.sort_by(|a, b| {
(a.span.start, a.span.end, cp.rules[a.rule].id.as_str()).cmp(&(
b.span.start,
b.span.end,
cp.rules[b.rule].id.as_str(),
))
});
let containers = container::Containers::scan(text);
let allow: Vec<String> = opts.allow_terms.iter().map(|t| t.to_lowercase()).collect();
let mut report = EvidenceReport {
stats: report::Stats {
word_count: count_words(text),
byte_len: text.len(),
densities: report::Densities::default(),
},
..EvidenceReport::default()
};
for hit in hits {
let rule = &cp.rules[hit.rule];
let span = widen_to_char_boundaries(text, hit.span);
if span.start >= span.end {
continue;
}
let (snippet, snippet_truncated) = snippet_of(text, &span);
let finding = Finding {
rule_id: rule.id.clone(),
span: (span.start, span.end),
snippet,
snippet_truncated,
container: containers.classify(span.start),
topic_term: !allow.is_empty() && allow.contains(&text[span.clone()].to_lowercase()),
};
fn bucket_of(report: &mut EvidenceReport, category: data::Category) -> &mut Vec<Finding> {
match category {
data::Category::PasteResidue => &mut report.paste_residue,
data::Category::QualityPatterns => &mut report.quality_patterns,
data::Category::Injection => &mut report.injection_patterns,
}
}
if bucket_of(&mut report, rule.category)
.last()
.map(|f| f.rule_id == finding.rule_id && f.span == finding.span)
.unwrap_or(false)
{
continue;
}
if rule.category == data::Category::QualityPatterns {
let class = rule.class.expect("quality rules carry a class (loader)");
let slot = match class {
data::Class::Spike => &mut report.stats.densities.spike,
data::Class::Background => &mut report.stats.densities.background,
data::Class::Individual => &mut report.stats.densities.individual,
};
slot.hits += 1;
if finding.container == Container::Prose && !finding.topic_term {
slot.residual_hits += 1;
}
}
bucket_of(&mut report, rule.category).push(finding);
}
finish_densities(&mut report.stats);
report
}
fn finish_densities(stats: &mut report::Stats) {
let words = stats.word_count;
let d = &mut stats.densities;
for slot in [&mut d.spike, &mut d.background, &mut d.individual] {
if words >= DENSITY_FLOOR_WORDS {
slot.per_1k_words = Some(slot.hits as f64 * 1000.0 / words as f64);
slot.residual_per_1k_words = Some(slot.residual_hits as f64 * 1000.0 / words as f64);
}
}
}
pub fn analyze_bundle(docs: &[(String, String)]) -> BundleReport {
analyze_bundle_with(docs, &AnalyzeOptions::default())
}
pub fn analyze_bundle_with(docs: &[(String, String)], opts: &AnalyzeOptions) -> BundleReport {
let files = docs
.iter()
.map(|(path, text)| BundleFile {
path: path.clone(),
report: analyze_with(text, opts),
})
.collect();
BundleReport {
files,
cross_file_duplication: cross_file_duplication(docs),
}
}
fn cross_file_duplication(docs: &[(String, String)]) -> Vec<CrossFileDuplication> {
let cp = engine::compiled();
let Some(dr) = cp.duplication_rules.first() else {
return Vec::new();
};
let mut tokens = duplication::Tokens::new();
for (file, (_, text)) in docs.iter().enumerate() {
duplication::tokenize_into(&mut tokens, text, file as u32, file as u32);
}
let runs = duplication::find_runs(&tokens, dr.shingle_words, dr.min_run_words, true);
let mut entries: Vec<((usize, usize), Vec<usize>)> = Vec::new();
for run in &runs {
let key = (run.earlier, run.len);
match entries.iter_mut().find(|(k, _)| *k == key) {
Some((_, laters)) => laters.push(run.later),
None => entries.push((key, vec![run.later])),
}
}
entries.sort_by_key(|&((earlier, len), _)| (std::cmp::Reverse(len), earlier));
entries.truncate(dr.max_reports);
let containers: Vec<container::Containers> = docs
.iter()
.map(|(_, text)| container::Containers::scan(text))
.collect();
let occurrence = |tok: usize, len: usize| {
let toks = &tokens.toks;
let file = toks[tok].file as usize;
let text = &docs[file].1;
let span = widen_to_char_boundaries(text, toks[tok].start..toks[tok + len - 1].end);
let occ = CrossFileOccurrence {
path: docs[file].0.clone(),
span: (span.start, span.end),
container: containers[file].classify(span.start),
};
(file, occ)
};
let mut out: Vec<CrossFileDuplication> = entries
.into_iter()
.map(|((earlier, len), laters)| {
let (anchor_file, anchor) = occurrence(earlier, len);
let (snippet, snippet_truncated) =
snippet_of(&docs[anchor_file].1, &(anchor.span.0..anchor.span.1));
let mut occurrences = vec![anchor];
occurrences.extend(laters.iter().map(|&l| occurrence(l, len).1));
CrossFileDuplication {
snippet,
snippet_truncated,
occurrences,
}
})
.collect();
out.sort_by(|a, b| {
(&a.occurrences[0].path, a.occurrences[0].span)
.cmp(&(&b.occurrences[0].path, b.occurrences[0].span))
});
out
}
pub(crate) fn widen_to_char_boundaries(
src: &str,
mut span: std::ops::Range<usize>,
) -> std::ops::Range<usize> {
span.start = span.start.min(src.len());
span.end = span.end.min(src.len());
while span.start > 0 && !src.is_char_boundary(span.start) {
span.start -= 1;
}
while span.end < src.len() && !src.is_char_boundary(span.end) {
span.end += 1;
}
span
}
fn snippet_of(src: &str, span: &std::ops::Range<usize>) -> (String, bool) {
let slice = &src[span.clone()];
if slice.len() <= SNIPPET_CAP {
return (slice.to_string(), false);
}
let mut end = SNIPPET_CAP;
while !slice.is_char_boundary(end) {
end -= 1;
}
(slice[..end].to_string(), true)
}
fn count_words(s: &str) -> usize {
let mut n = 0usize;
let mut in_word = false;
for c in s.chars() {
let w = unicode_ident::is_xid_continue(c);
if w && !in_word {
n += 1;
}
in_word = w;
}
n
}