1use std::path::{Path, PathBuf};
4
5use fallow_config::DuplicatesConfig;
6use fallow_types::discover::DiscoveredFile;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::results::DuplicationAnalysis;
10
11#[path = "duplication_detector/mod.rs"]
12mod detector;
13
14#[cfg(test)]
15pub(crate) use detector::token_types;
16pub(crate) use detector::types;
17
18#[doc(hidden)]
21pub use detector::{detect, normalize, tokenize};
22
23pub type CloneGroup = fallow_types::duplicates::CloneGroup;
25pub type CloneGroupKind = fallow_types::duplicates::CloneGroupKind;
27pub type CloneInstance = fallow_types::duplicates::CloneInstance;
29pub type DefaultIgnoreSkips = fallow_types::duplicates::DefaultIgnoreSkips;
31pub type DuplicationReport = fallow_types::duplicates::DuplicationReport;
33pub type DuplicationStats = fallow_types::duplicates::DuplicationStats;
35pub type RefactoringKind = fallow_types::duplicates::RefactoringKind;
37pub type RefactoringSuggestion = fallow_types::duplicates::RefactoringSuggestion;
39
40pub use detector::{
41 CloneFingerprintKey, CloneFingerprintSet, FINGERPRINT_PREFIX, clone_fingerprint,
42 dominant_identifier, group_refactoring_suggestion, is_unparsed_collision_key,
43};
44
45pub fn refresh_clone_families(report: &mut DuplicationReport, root: &Path) {
47 report.clone_families = detector::families::group_into_families(&report.clone_groups, root);
48 report.mirrored_directories =
49 detector::families::detect_mirrored_directories(&report.clone_families, root);
50}
51
52pub fn refresh_scoped_report(report: &mut DuplicationReport, root: &Path) {
59 refresh_clone_families(report, root);
60 report.stats = recompute_stats(report);
61 report.sort();
62}
63
64#[derive(Debug, Clone, Copy)]
68pub struct DuplicationScope<'a> {
69 pub changes: Option<&'a crate::change_scope::ChangeScope>,
72 pub diff: Option<&'a fallow_output::DiffIndex>,
74 pub workspace_roots: Option<&'a [PathBuf]>,
77}
78
79pub fn apply_scope(report: &mut DuplicationReport, scope: &DuplicationScope<'_>, root: &Path) {
87 if let Some(changes) = scope.changes {
88 changes.retain_duplication(report, root);
89 }
90 if let Some(diff) = scope.diff {
91 crate::diff_scope::filter_duplication_by_diff(report, diff, root);
92 }
93 if let Some(roots) = scope.workspace_roots {
94 filter_to_workspaces(report, roots, root);
95 }
96}
97
98pub fn filter_to_workspaces(report: &mut DuplicationReport, roots: &[PathBuf], root: &Path) {
107 report.clone_groups.retain(|group| {
108 group
109 .instances
110 .iter()
111 .any(|instance| roots.iter().any(|scope| instance.file.starts_with(scope)))
112 });
113 refresh_scoped_report(report, root);
114}
115
116pub fn apply_top(report: &mut DuplicationReport, n: usize, root: &Path) {
125 report.sort();
126 report.clone_groups.truncate(n);
127 refresh_clone_families(report, root);
128 report.sort();
129}
130
131#[must_use]
141pub fn recompute_stats(report: &DuplicationReport) -> DuplicationStats {
142 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
143 let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
144 let mut duplicated_tokens = 0usize;
145 let mut clone_instances = 0usize;
146
147 for group in &report.clone_groups {
148 for instance in &group.instances {
149 files_with_clones.insert(&instance.file);
150 clone_instances += 1;
151 let lines = file_dup_lines.entry(&instance.file).or_default();
152 for line in instance.start_line..=instance.end_line {
153 lines.insert(line);
154 }
155 }
156 duplicated_tokens += group.token_count * group.instances.len().saturating_sub(1);
157 }
158
159 let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
160
161 DuplicationStats {
162 total_files: report.stats.total_files,
163 files_with_clones: files_with_clones.len(),
164 total_lines: report.stats.total_lines,
165 duplicated_lines,
166 total_tokens: report.stats.total_tokens,
167 duplicated_tokens: duplicated_tokens.min(report.stats.total_tokens),
168 clone_groups: report.clone_groups.len(),
169 clone_families: report.clone_families.len(),
170 clone_instances,
171 duplication_percentage: if report.stats.total_lines > 0 {
172 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
173 } else {
174 0.0
175 },
176 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
177 clone_groups_ignored: report.stats.clone_groups_ignored,
178 near_candidates_skipped: report.stats.near_candidates_skipped,
179 }
180}
181
182#[must_use]
189pub fn source_token_kinds_equivalent(
190 path: &Path,
191 current: &str,
192 base: &str,
193 cross_language: bool,
194) -> bool {
195 let current_tokens = detector::tokenize::tokenize_file(path, current, cross_language);
196 let base_tokens = detector::tokenize::tokenize_file(path, base, cross_language);
197 if current_tokens.tokens.len() != base_tokens.tokens.len() {
198 return false;
199 }
200 current_tokens
201 .tokens
202 .iter()
203 .zip(&base_tokens.tokens)
204 .all(|(current_token, base_token)| {
205 if current_token.kind != base_token.kind {
206 return false;
207 }
208 if matches!(
209 current_token.kind,
210 detector::token_types::TokenKind::TemplateLiteral
211 | detector::token_types::TokenKind::RegExpLiteral
212 ) {
213 return match (
214 current.get(current_token.span.start as usize..current_token.span.end as usize),
215 base.get(base_token.span.start as usize..base_token.span.end as usize),
216 ) {
217 (Some(current_literal), Some(base_literal)) => current_literal == base_literal,
218 _ => false,
219 };
220 }
221 true
222 })
223}
224
225#[must_use]
227pub fn find_duplicates(
228 root: &Path,
229 files: &[DiscoveredFile],
230 config: &DuplicatesConfig,
231) -> DuplicationReport {
232 detector::find_duplicates(root, files, config)
233}
234
235#[must_use]
237pub fn find_duplicates_with_defaults(
238 root: &Path,
239 files: &[DiscoveredFile],
240 config: &DuplicatesConfig,
241 cache_dir: Option<&Path>,
242) -> DuplicationAnalysis {
243 detector::detect_duplicates(root, files, config, None, cache_dir)
244}
245
246#[must_use]
248pub fn find_duplicates_touching_files_with_defaults(
249 root: &Path,
250 files: &[DiscoveredFile],
251 config: &DuplicatesConfig,
252 changed_files: &[PathBuf],
253 cache_dir: Option<&Path>,
254) -> DuplicationAnalysis {
255 let changed_files = changed_files.iter().cloned().collect::<FxHashSet<_>>();
256 detector::detect_duplicates(root, files, config, Some(&changed_files), cache_dir)
257}
258
259#[cfg(test)]
260mod tests {
261 use std::path::PathBuf;
262
263 use super::*;
264
265 fn instance(file: &str, start_line: usize, end_line: usize) -> CloneInstance {
266 CloneInstance {
267 is_symlink: false,
268 file: PathBuf::from(file),
269 start_line,
270 end_line,
271 start_col: 0,
272 end_col: 0,
273 fragment: String::new(),
274 }
275 }
276
277 fn report(clone_groups: Vec<CloneGroup>) -> DuplicationReport {
278 DuplicationReport {
279 clone_groups,
280 clone_families: Vec::new(),
281 mirrored_directories: Vec::new(),
282 stats: DuplicationStats {
283 total_files: 3,
284 total_lines: 100,
285 total_tokens: 1_000,
286 clone_groups_below_min_occurrences: 4,
287 ..DuplicationStats::default()
288 },
289 }
290 }
291
292 #[test]
293 fn recompute_stats_deduplicates_overlapping_lines_per_file() {
294 let report = report(vec![
295 CloneGroup {
296 instances: vec![instance("src/a.ts", 1, 10), instance("src/b.ts", 20, 24)],
297 token_count: 30,
298 line_count: 10,
299 similarity: None,
300 },
301 CloneGroup {
302 instances: vec![instance("src/a.ts", 5, 12), instance("src/c.ts", 40, 44)],
303 token_count: 20,
304 line_count: 8,
305 similarity: None,
306 },
307 ]);
308
309 let stats = recompute_stats(&report);
310
311 assert_eq!(stats.total_files, 3);
312 assert_eq!(stats.files_with_clones, 3);
313 assert_eq!(stats.total_lines, 100);
314 assert_eq!(stats.duplicated_lines, 22);
315 assert_eq!(stats.total_tokens, 1_000);
316 assert_eq!(stats.duplicated_tokens, 50);
317 assert_eq!(stats.clone_groups, 2);
318 assert_eq!(stats.clone_instances, 4);
319 assert!((stats.duplication_percentage - 22.0).abs() < f64::EPSILON);
320 assert_eq!(stats.clone_groups_below_min_occurrences, 4);
321 }
322
323 #[test]
324 fn recompute_stats_handles_zero_total_lines() {
325 let mut report = report(vec![CloneGroup {
326 instances: vec![instance("src/a.ts", 1, 1)],
327 token_count: 5,
328 line_count: 1,
329 similarity: None,
330 }]);
331 report.stats.total_lines = 0;
332
333 let stats = recompute_stats(&report);
334
335 assert_eq!(stats.duplicated_lines, 1);
336 assert!(stats.duplication_percentage.abs() < f64::EPSILON);
337 }
338
339 #[test]
340 fn clone_fingerprint_set_delegates_without_leaking_core_type() {
341 let groups = vec![CloneGroup {
342 instances: vec![
343 CloneInstance {
344 fragment: "const value = 1;".to_string(),
345 ..instance("src/a.ts", 1, 1)
346 },
347 CloneInstance {
348 fragment: "const value = 1;".to_string(),
349 ..instance("src/b.ts", 2, 2)
350 },
351 ],
352 token_count: 5,
353 line_count: 1,
354 similarity: None,
355 }];
356 let fingerprints = CloneFingerprintSet::from_groups(&groups);
357 let fingerprint = fingerprints.fingerprint_for_group(&groups[0]);
358
359 assert!(fingerprint.starts_with(FINGERPRINT_PREFIX));
360 assert!(fingerprints.find_group(&groups, &fingerprint).is_some());
361 }
362}