1mod container;
14pub mod data;
15mod duplication;
16pub mod engine;
17pub mod report;
18
19pub use report::{
20 BundleFile, BundleReport, ClassDensity, Container, CrossFileDuplication, CrossFileOccurrence,
21 Densities, EvidenceReport, Finding, Stats,
22};
23
24const SNIPPET_CAP: usize = 200;
27
28const DENSITY_FLOOR_WORDS: usize = 100;
32
33#[derive(Debug, Clone, Default)]
37pub struct AnalyzeOptions {
38 pub allow_terms: Vec<String>,
44}
45
46pub fn analyze(text: &str) -> EvidenceReport {
52 analyze_with(text, &AnalyzeOptions::default())
53}
54
55pub fn analyze_with(text: &str, opts: &AnalyzeOptions) -> EvidenceReport {
58 let cp = engine::compiled();
59 let mut hits = engine::scan_all(cp, text);
60 hits.sort_by(|a, b| {
61 (a.span.start, a.span.end, cp.rules[a.rule].id.as_str()).cmp(&(
62 b.span.start,
63 b.span.end,
64 cp.rules[b.rule].id.as_str(),
65 ))
66 });
67
68 let containers = container::Containers::scan(text);
69 let allow: Vec<String> = opts.allow_terms.iter().map(|t| t.to_lowercase()).collect();
70
71 let mut report = EvidenceReport {
72 stats: report::Stats {
73 word_count: count_words(text),
74 byte_len: text.len(),
75 densities: report::Densities::default(),
76 },
77 ..EvidenceReport::default()
78 };
79 for hit in hits {
80 let rule = &cp.rules[hit.rule];
81 let span = widen_to_char_boundaries(text, hit.span);
82 if span.start >= span.end {
83 continue;
84 }
85 let (snippet, snippet_truncated) = snippet_of(text, &span);
86 let finding = Finding {
87 rule_id: rule.id.clone(),
88 span: (span.start, span.end),
89 snippet,
90 snippet_truncated,
91 container: containers.classify(span.start),
92 topic_term: !allow.is_empty() && allow.contains(&text[span.clone()].to_lowercase()),
93 };
94 fn bucket_of(report: &mut EvidenceReport, category: data::Category) -> &mut Vec<Finding> {
96 match category {
97 data::Category::PasteResidue => &mut report.paste_residue,
98 data::Category::QualityPatterns => &mut report.quality_patterns,
99 data::Category::Injection => &mut report.injection_patterns,
100 }
101 }
102 if bucket_of(&mut report, rule.category)
103 .last()
104 .map(|f| f.rule_id == finding.rule_id && f.span == finding.span)
105 .unwrap_or(false)
106 {
107 continue;
108 }
109 if rule.category == data::Category::QualityPatterns {
110 let class = rule.class.expect("quality rules carry a class (loader)");
111 let slot = match class {
112 data::Class::Spike => &mut report.stats.densities.spike,
113 data::Class::Background => &mut report.stats.densities.background,
114 data::Class::Individual => &mut report.stats.densities.individual,
115 };
116 slot.hits += 1;
117 if finding.container == Container::Prose && !finding.topic_term {
118 slot.residual_hits += 1;
119 }
120 }
121 bucket_of(&mut report, rule.category).push(finding);
122 }
123 finish_densities(&mut report.stats);
124 report
125}
126
127fn finish_densities(stats: &mut report::Stats) {
131 let words = stats.word_count;
132 let d = &mut stats.densities;
133 for slot in [&mut d.spike, &mut d.background, &mut d.individual] {
134 if words >= DENSITY_FLOOR_WORDS {
135 slot.per_1k_words = Some(slot.hits as f64 * 1000.0 / words as f64);
136 slot.residual_per_1k_words = Some(slot.residual_hits as f64 * 1000.0 / words as f64);
137 }
138 }
139}
140
141pub fn analyze_bundle(docs: &[(String, String)]) -> BundleReport {
148 analyze_bundle_with(docs, &AnalyzeOptions::default())
149}
150
151pub fn analyze_bundle_with(docs: &[(String, String)], opts: &AnalyzeOptions) -> BundleReport {
154 let files = docs
155 .iter()
156 .map(|(path, text)| BundleFile {
157 path: path.clone(),
158 report: analyze_with(text, opts),
159 })
160 .collect();
161 BundleReport {
162 files,
163 cross_file_duplication: cross_file_duplication(docs),
164 }
165}
166
167fn cross_file_duplication(docs: &[(String, String)]) -> Vec<CrossFileDuplication> {
177 let cp = engine::compiled();
178 let Some(dr) = cp.duplication_rules.first() else {
179 return Vec::new();
180 };
181 let mut tokens = duplication::Tokens::new();
182 for (file, (_, text)) in docs.iter().enumerate() {
183 duplication::tokenize_into(&mut tokens, text, file as u32, file as u32);
185 }
186 let runs = duplication::find_runs(&tokens, dr.shingle_words, dr.min_run_words, true);
187 let mut entries: Vec<((usize, usize), Vec<usize>)> = Vec::new();
191 for run in &runs {
192 let key = (run.earlier, run.len);
193 match entries.iter_mut().find(|(k, _)| *k == key) {
194 Some((_, laters)) => laters.push(run.later),
195 None => entries.push((key, vec![run.later])),
196 }
197 }
198 entries.sort_by_key(|&((earlier, len), _)| (std::cmp::Reverse(len), earlier));
202 entries.truncate(dr.max_reports);
203 let containers: Vec<container::Containers> = docs
204 .iter()
205 .map(|(_, text)| container::Containers::scan(text))
206 .collect();
207 let occurrence = |tok: usize, len: usize| {
212 let toks = &tokens.toks;
213 let file = toks[tok].file as usize;
214 let text = &docs[file].1;
215 let span = widen_to_char_boundaries(text, toks[tok].start..toks[tok + len - 1].end);
216 let occ = CrossFileOccurrence {
217 path: docs[file].0.clone(),
218 span: (span.start, span.end),
219 container: containers[file].classify(span.start),
220 };
221 (file, occ)
222 };
223 let mut out: Vec<CrossFileDuplication> = entries
224 .into_iter()
225 .map(|((earlier, len), laters)| {
226 let (anchor_file, anchor) = occurrence(earlier, len);
227 let (snippet, snippet_truncated) =
228 snippet_of(&docs[anchor_file].1, &(anchor.span.0..anchor.span.1));
229 let mut occurrences = vec![anchor];
230 occurrences.extend(laters.iter().map(|&l| occurrence(l, len).1));
231 CrossFileDuplication {
232 snippet,
233 snippet_truncated,
234 occurrences,
235 }
236 })
237 .collect();
238 out.sort_by(|a, b| {
240 (&a.occurrences[0].path, a.occurrences[0].span)
241 .cmp(&(&b.occurrences[0].path, b.occurrences[0].span))
242 });
243 out
244}
245
246pub(crate) fn widen_to_char_boundaries(
250 src: &str,
251 mut span: std::ops::Range<usize>,
252) -> std::ops::Range<usize> {
253 span.start = span.start.min(src.len());
254 span.end = span.end.min(src.len());
255 while span.start > 0 && !src.is_char_boundary(span.start) {
256 span.start -= 1;
257 }
258 while span.end < src.len() && !src.is_char_boundary(span.end) {
259 span.end += 1;
260 }
261 span
262}
263
264fn snippet_of(src: &str, span: &std::ops::Range<usize>) -> (String, bool) {
268 let slice = &src[span.clone()];
269 if slice.len() <= SNIPPET_CAP {
270 return (slice.to_string(), false);
271 }
272 let mut end = SNIPPET_CAP;
273 while !slice.is_char_boundary(end) {
274 end -= 1;
275 }
276 (slice[..end].to_string(), true)
277}
278
279fn count_words(s: &str) -> usize {
281 let mut n = 0usize;
282 let mut in_word = false;
283 for c in s.chars() {
284 let w = unicode_ident::is_xid_continue(c);
285 if w && !in_word {
286 n += 1;
287 }
288 in_word = w;
289 }
290 n
291}