fn extract_blocks(
lines: &[&str],
path: &Path,
min_lines: usize,
max_tokens: usize,
detection_type: crate::cli::DuplicateType,
) -> Vec<(String, String, usize, usize, String)> {
let mut blocks = Vec::new();
let file_str = path.to_string_lossy().to_string();
match detection_type {
crate::cli::DuplicateType::Exact => {
extract_exact_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
}
crate::cli::DuplicateType::Fuzzy => {
extract_fuzzy_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
}
crate::cli::DuplicateType::Gapped => {
extract_fuzzy_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
extract_gapped_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
}
crate::cli::DuplicateType::All => {
extract_exact_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
extract_fuzzy_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
extract_gapped_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
}
crate::cli::DuplicateType::Renamed => {
extract_fuzzy_blocks(&mut blocks, lines, &file_str, min_lines, max_tokens);
}
crate::cli::DuplicateType::Semantic => {
warn_semantic_unimplemented();
}
}
blocks
}
fn warn_semantic_unimplemented() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
eprintln!(
"warning: --detection-type semantic (Type-4) is not implemented; \
no semantic clones are detected and the reported 0% is not a measurement. \
Use --detection-type all, exact, renamed, fuzzy or gapped."
);
});
}
fn extract_exact_blocks(
blocks: &mut Vec<(String, String, usize, usize, String)>,
lines: &[&str],
file_str: &str,
min_lines: usize,
max_tokens: usize,
) {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let min_lines = min_lines.max(1);
let substantive = substantive_lines(lines);
for window in substantive.windows(min_lines) {
let content = window
.iter()
.map(|(_, line)| *line)
.collect::<Vec<_>>()
.join("\n");
if count_tokens(&content) <= max_tokens {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let hash = format!("{:x}", hasher.finish());
let start = window[0].0;
let end = window[window.len() - 1].0;
blocks.push((hash, file_str.to_string(), start, end, content));
}
}
}
fn for_each_structural_block(
lines: &[&str],
min_lines: usize,
max_tokens: usize,
mut emit: impl FnMut(&str, usize, usize),
) {
let mut i = 0;
while i < lines.len() {
if is_block_start(lines[i]) {
let end = (find_block_end(&lines[i..]).unwrap_or(min_lines) + i).min(lines.len());
if end - i >= min_lines {
let block_lines = &lines[i..end];
let content = normalize_block(block_lines);
let substantive = substantive_lines(block_lines);
if substantive.len() >= min_lines && count_tokens(&content) <= max_tokens {
let start_line = substantive[0].0 + i;
let end_line = substantive[substantive.len() - 1].0 + i;
emit(&content, start_line, end_line);
}
}
i = end;
} else {
i += 1;
}
}
}
fn extract_fuzzy_blocks(
blocks: &mut Vec<(String, String, usize, usize, String)>,
lines: &[&str],
file_str: &str,
min_lines: usize,
max_tokens: usize,
) {
for_each_structural_block(lines, min_lines, max_tokens, |content, start, end| {
let hash = format!("f{}", hash_of(&normalize_identifiers(content)));
blocks.push((hash, file_str.to_string(), start, end, content.to_string()));
});
}
fn extract_gapped_blocks(
blocks: &mut Vec<(String, String, usize, usize, String)>,
lines: &[&str],
file_str: &str,
min_lines: usize,
max_tokens: usize,
) {
for_each_structural_block(lines, min_lines, max_tokens, |content, start, end| {
if let Some(skeleton) = gapped_skeleton(content, min_lines) {
let hash = format!("g{}", hash_of(&skeleton));
blocks.push((hash, file_str.to_string(), start, end, content.to_string()));
}
});
}
fn hash_of(text: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
const CONTROL_FLOW_KEYWORDS: &[&str] = &[
"case", "catch", "do", "elif", "else", "finally", "for", "if", "loop", "match", "switch",
"try", "when", "while",
];
fn gapped_skeleton(content: &str, min_lines: usize) -> Option<String> {
let normalized: Vec<String> = content.lines().map(normalize_identifiers).collect();
let kept: Vec<&str> = normalized
.iter()
.map(String::as_str)
.filter(|line| carries_structure(line))
.collect();
if kept.len() == normalized.len() {
return None;
}
if kept.len() < min_lines.max(1) {
return None;
}
if kept
.iter()
.filter(|line| carries_control_flow(line))
.count()
< 2
{
return None;
}
Some(kept.join("\n"))
}
fn carries_structure(normalized_line: &str) -> bool {
normalized_line.contains('{')
|| normalized_line.contains('}')
|| carries_control_flow(normalized_line)
}
fn carries_control_flow(normalized_line: &str) -> bool {
normalized_line
.split_whitespace()
.any(|word| CONTROL_FLOW_KEYWORDS.contains(&word))
}
const STRUCTURAL_KEYWORDS: &[&str] = &[
"and",
"as",
"async",
"await",
"bool",
"break",
"case",
"catch",
"class",
"const",
"continue",
"def",
"delete",
"do",
"elif",
"else",
"enum",
"export",
"extends",
"false",
"final",
"finally",
"float",
"fn",
"for",
"from",
"function",
"if",
"impl",
"import",
"in",
"int",
"interface",
"is",
"let",
"loop",
"match",
"mod",
"move",
"mut",
"new",
"nil",
"none",
"not",
"null",
"or",
"pass",
"private",
"protected",
"public",
"pub",
"raise",
"ref",
"return",
"self",
"static",
"std",
"str",
"string",
"struct",
"super",
"switch",
"this",
"throw",
"trait",
"true",
"try",
"type",
"typeof",
"use",
"var",
"void",
"where",
"while",
"with",
"yield",
];
fn normalize_identifiers(content: &str) -> String {
fn flush(word: &mut String, out: &mut String) {
if word.is_empty() {
return;
}
if STRUCTURAL_KEYWORDS.contains(&word.as_str()) {
out.push_str(word);
} else {
out.push('v');
}
out.push(' ');
word.clear();
}
let mut out = String::with_capacity(content.len());
let mut word = String::new();
for ch in content.chars() {
if ch.is_alphanumeric() || ch == '_' {
word.push(ch);
} else {
flush(&mut word, &mut out);
if !ch.is_whitespace() {
out.push(ch);
}
}
}
flush(&mut word, &mut out);
out
}
fn is_substantive_line(trimmed: &str) -> bool {
!trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#')
}
fn substantive_lines<'a>(lines: &[&'a str]) -> Vec<(usize, &'a str)> {
lines
.iter()
.enumerate()
.map(|(i, line)| (i + 1, line.trim()))
.filter(|(_, line)| is_substantive_line(line))
.collect()
}
fn normalize_block(lines: &[&str]) -> String {
substantive_lines(lines)
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("\n")
}
fn count_tokens(content: &str) -> usize {
content.split_whitespace().count()
}
fn is_block_start(line: &str) -> bool {
let trimmed = line.trim();
if is_function_declaration(trimmed) {
return true;
}
if is_type_declaration(trimmed) {
return true;
}
if is_block_opening(trimmed) {
return true;
}
false
}
fn is_function_declaration(line: &str) -> bool {
line.contains("fn ") || line.contains("function") || line.contains("def ")
}
fn is_type_declaration(line: &str) -> bool {
line.contains("class ") || line.contains("struct ") || line.contains("impl ")
}
fn is_block_opening(line: &str) -> bool {
line.ends_with('{') && !line.starts_with('{')
}
fn find_block_end(lines: &[&str]) -> Option<usize> {
let mut brace_count = 0;
let mut in_block = false;
for (i, line) in lines.iter().enumerate() {
for ch in line.chars() {
match ch {
'{' => {
brace_count += 1;
in_block = true;
}
'}' => {
brace_count -= 1;
if brace_count == 0 && in_block {
return Some(i + 1);
}
}
_ => {}
}
}
}
None
}
const DOCUMENTED_THRESHOLD_DEFAULT: f32 = 0.85;
fn warn_threshold_has_no_effect(
threshold: f32,
detection_type: &crate::cli::DuplicateType,
) -> bool {
if near_miss_enabled(detection_type) {
return false;
}
if (threshold - DOCUMENTED_THRESHOLD_DEFAULT).abs() < 1e-6 {
return false;
}
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
eprintln!(
"warning: --threshold {threshold} was ignored: --detection-type {detection_type} \
matches blocks by hash — a block is in a group or it is not — so no similarity cut-off changes \
the result. Use --detection-type gapped, fuzzy or all, where the threshold is the near-miss \
similarity cut-off."
);
});
true
}
fn classify_hash_group(texts: &[&str]) -> (CloneType, f32) {
let Some((first, rest)) = texts.split_first() else {
return (CloneType::Exact, 1.0);
};
if rest.iter().all(|t| t == first) {
return (CloneType::Exact, 1.0);
}
let total: f32 = rest.iter().map(|t| line_jaccard(first, t)).sum();
#[allow(clippy::cast_precision_loss)]
let mean = total / rest.len() as f32;
let first_shape = normalize_identifiers(first);
if !rest.iter().all(|t| normalize_identifiers(t) == first_shape) {
return (CloneType::NearMiss, mean.min(1.0 - f32::EPSILON));
}
(CloneType::Renamed, mean.min(1.0 - f32::EPSILON))
}
fn line_jaccard(a: &str, b: &str) -> f32 {
use std::collections::HashSet;
let left: HashSet<&str> = a.lines().collect();
let right: HashSet<&str> = b.lines().collect();
let union = left.union(&right).count();
if union == 0 {
return 0.0;
}
let intersection = left.intersection(&right).count();
#[allow(clippy::cast_precision_loss)]
let sim = intersection as f32 / union as f32;
sim
}
fn find_duplicate_blocks(
all_blocks: Vec<(String, String, usize, usize, String)>,
) -> Vec<DuplicateBlock> {
let mut hash_groups: HashMap<String, Vec<(String, usize, usize, String)>> = HashMap::new();
for (hash, file, start, end, content) in all_blocks {
hash_groups
.entry(hash)
.or_default()
.push((file, start, end, content));
}
let mut duplicates = Vec::new();
for (hash, mut locations) in hash_groups {
locations.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut kept: Vec<(String, usize, usize, String)> = Vec::new();
for loc in locations {
let overlaps_kept = kept
.last()
.is_some_and(|last| last.0 == loc.0 && loc.1 <= last.2);
if !overlaps_kept {
kept.push(loc);
}
}
let locations = kept;
if locations.len() > 1 {
let lines = locations[0].2 - locations[0].1 + 1;
let tokens = count_tokens(&locations[0].3);
let texts: Vec<&str> = locations.iter().map(|(_, _, _, c)| c.as_str()).collect();
let (clone_type, similarity) = classify_hash_group(&texts);
if hash.starts_with('g') && clone_type != CloneType::NearMiss {
continue;
}
let duplicate_locations: Vec<DuplicateLocation> = locations
.into_iter()
.map(|(file, start, end, content)| {
let preview = content.lines().take(3).collect::<Vec<_>>().join("\n");
DuplicateLocation {
file,
start_line: start,
end_line: end,
content_preview: if content.lines().count() > 3 {
format!("{preview}...")
} else {
preview
},
}
})
.collect();
duplicates.push(DuplicateBlock {
hash,
locations: duplicate_locations,
lines,
tokens,
similarity,
clone_type,
});
}
}
sort_duplicate_blocks(&mut duplicates);
duplicates
}
fn sort_duplicate_blocks(duplicates: &mut [DuplicateBlock]) {
duplicates.sort_by(|a, b| {
b.lines
.cmp(&a.lines)
.then_with(|| {
let a_first = a.locations.first();
let b_first = b.locations.first();
match (a_first, b_first) {
(Some(x), Some(y)) => (&x.file, x.start_line).cmp(&(&y.file, y.start_line)),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
.then_with(|| a.hash.cmp(&b.hash))
});
}
fn should_process_file(path: &Path, include: &Option<String>, exclude: &Option<String>) -> bool {
let path_str = path.to_string_lossy();
if let Some(excl) = exclude {
if matches_file_pattern(&path_str, excl) {
return false;
}
}
if let Some(incl) = include {
return matches_file_pattern(&path_str, incl);
}
true
}
fn matches_file_pattern(path_str: &str, pattern: &str) -> bool {
if !pattern.contains(['*', '?', '[', '{']) {
return path_str.contains(pattern);
}
match globset::Glob::new(pattern) {
Ok(glob) => glob.compile_matcher().is_match(path_str),
Err(_) => path_str.contains(pattern),
}
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some("rs" | "js" | "ts" | "py" | "java" | "cpp" | "c" | "kt" | "kts")
)
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod include_exclude_pattern_tests {
use super::*;
use std::path::Path;
const SOURCE: &str = "/home/u/proj/src/utils/path_validator.rs";
#[test]
fn documented_glob_patterns_match_source_files() {
for pattern in ["**/*.rs", "*.rs", "**", "*", "**/utils/*.rs"] {
assert!(
should_process_file(Path::new(SOURCE), &Some(pattern.to_string()), &None),
"--include '{pattern}' must select a .rs file"
);
assert!(
!should_process_file(Path::new(SOURCE), &None, &Some(pattern.to_string())),
"--exclude '{pattern}' must drop a .rs file"
);
}
}
#[test]
fn globs_that_do_not_match_still_filter() {
assert!(!should_process_file(
Path::new(SOURCE),
&Some("**/*.py".to_string()),
&None
));
assert!(should_process_file(
Path::new(SOURCE),
&None,
&Some("**/*.py".to_string())
));
}
#[test]
fn substring_patterns_are_still_honoured() {
let path = Path::new("src/utils/path_validator.rs");
assert!(should_process_file(
path,
&Some("path_validator".to_string()),
&None
));
assert!(!should_process_file(
path,
&Some("tests".to_string()),
&None
));
assert!(!should_process_file(path, &None, &Some(".rs".to_string())));
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod renamed_clone_tests {
use super::*;
use crate::cli::DuplicateType;
use std::path::Path;
const RENAMED_SOURCE: &str = "\
fn alpha(input: usize) -> usize {
let total = input + 1;
let doubled = total * 2;
doubled
}
fn beta(value: usize) -> usize {
let sum = value + 1;
let twice = sum * 2;
twice
}
fn gamma(arg: usize) -> usize {
let acc = arg + 1;
let scaled = acc * 2;
scaled
}
";
fn blocks_for(kind: DuplicateType) -> Vec<(String, String, usize, usize, String)> {
let lines: Vec<&str> = RENAMED_SOURCE.lines().collect();
extract_blocks(&lines, Path::new("dup.rs"), 4, 1000, kind)
}
#[test]
fn renamed_mode_finds_the_renamed_copies() {
let blocks = blocks_for(DuplicateType::Renamed);
assert!(!blocks.is_empty(), "renamed mode must extract blocks");
let duplicates = find_duplicate_blocks(blocks);
assert!(
duplicates.iter().any(|d| d.locations.len() >= 3),
"three renamed copies of one body are one duplicate group, got {duplicates:?}"
);
}
#[test]
fn gapped_and_fuzzy_find_the_renamed_copies_too() {
for kind in [DuplicateType::Gapped, DuplicateType::Fuzzy] {
let duplicates = find_duplicate_blocks(blocks_for(kind.clone()));
assert!(
duplicates.iter().any(|d| d.locations.len() >= 3),
"{kind:?} must report the renamed clones"
);
}
}
#[test]
fn exact_mode_does_not_claim_renamed_copies() {
let duplicates = find_duplicate_blocks(blocks_for(DuplicateType::Exact));
assert!(duplicates.iter().all(|d| d.locations.len() < 3));
}
#[test]
fn identifier_normalisation_erases_names_but_keeps_structure() {
let a = normalize_identifiers("let total = input + 1;");
let b = normalize_identifiers("let sum = value + 1;");
assert_eq!(a, b, "only the names differ");
let c = normalize_identifiers("let total = input - 1;");
assert_ne!(a, c, "the operator is structure, not a name");
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod threshold_tests {
use super::*;
use crate::cli::DuplicateType;
#[test]
fn a_threshold_that_cannot_act_is_disclosed() {
for kind in [
DuplicateType::Exact,
DuplicateType::Renamed,
DuplicateType::Semantic,
] {
for typed in [0.0_f32, 0.01, 0.5, 0.99, 1.0] {
assert!(
warn_threshold_has_no_effect(typed, &kind),
"--threshold {typed} changes nothing under {kind:?} and must say so"
);
}
}
}
#[test]
fn a_threshold_that_acts_is_not_disclaimed() {
for kind in [
DuplicateType::Gapped,
DuplicateType::Fuzzy,
DuplicateType::All,
] {
for typed in [0.0_f32, 0.5, 0.99] {
assert!(
!warn_threshold_has_no_effect(typed, &kind),
"--threshold {typed} controls the near-miss cut-off under {kind:?}"
);
}
}
}
#[test]
fn the_default_threshold_is_quiet() {
assert!(!warn_threshold_has_no_effect(
DOCUMENTED_THRESHOLD_DEFAULT,
&DuplicateType::Exact
));
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod gapped_clone_tests {
use super::*;
use crate::cli::DuplicateType;
use std::path::Path;
const GAPPED_SOURCE: &str = "\
fn alpha(items: &[u32]) -> u32 {
let mut total = 0;
for item in items {
if *item > 10 {
total += item;
} else {
total -= item;
}
}
total
}
fn beta(values: &[u32]) -> u32 {
let mut acc = 0;
let scale = 2;
for value in values {
if *value > 10 {
acc += value;
} else {
acc -= value;
}
}
acc
}
";
fn blocks_for(kind: DuplicateType) -> Vec<(String, String, usize, usize, String)> {
let lines: Vec<&str> = GAPPED_SOURCE.lines().collect();
extract_blocks(&lines, Path::new("dup.rs"), 5, 1000, kind)
}
#[test]
fn gapped_extracts_something_renamed_does_not() {
let renamed: Vec<String> = blocks_for(DuplicateType::Renamed)
.into_iter()
.map(|(hash, ..)| hash)
.collect();
let gapped: Vec<String> = blocks_for(DuplicateType::Gapped)
.into_iter()
.map(|(hash, ..)| hash)
.collect();
assert!(
gapped.iter().any(|h| !renamed.contains(h)),
"gapped must run a pass renamed does not; renamed={renamed:?} gapped={gapped:?}"
);
for hash in &renamed {
assert!(gapped.contains(hash), "gapped lost the renamed hash {hash}");
}
}
#[test]
fn only_gapped_pairs_bodies_separated_by_an_inserted_statement() {
let renamed = find_duplicate_blocks(blocks_for(DuplicateType::Renamed));
assert!(
renamed.is_empty(),
"one inserted statement defeats a Type-2 hash — that zero is a measurement: {renamed:?}"
);
let gapped = find_duplicate_blocks(blocks_for(DuplicateType::Gapped));
let paired: Vec<_> = gapped.iter().filter(|d| d.locations.len() >= 2).collect();
assert!(
!paired.is_empty(),
"gapped must pair two copies that differ by an inserted statement: {gapped:?}"
);
assert_eq!(
paired[0].clone_type,
CloneType::NearMiss,
"statements differ, not merely names, so the class is Type-3"
);
assert!(
paired[0].similarity < 1.0,
"a measured near-miss is never reported as identical"
);
}
#[test]
fn gapped_does_not_double_count_a_type_2_group() {
const TYPE_2_ONLY: &str = "\
fn alpha(items: &[u32]) -> u32 {
let mut total = 0;
for item in items {
if *item > 10 {
total += item;
} else {
total -= item;
}
}
total
}
fn beta(values: &[u32]) -> u32 {
let mut acc = 0;
for value in values {
if *value > 10 {
acc += value;
} else {
acc -= value;
}
}
acc
}
";
let blocks = |kind: DuplicateType| {
let lines: Vec<&str> = TYPE_2_ONLY.lines().collect();
find_duplicate_blocks(extract_blocks(&lines, Path::new("dup.rs"), 5, 1000, kind))
};
let renamed = blocks(DuplicateType::Renamed);
assert!(!renamed.is_empty(), "fixture must be a Type-2 clone");
assert_eq!(
renamed.len(),
blocks(DuplicateType::Gapped).len(),
"gapped must not report a Type-2 group a second time"
);
}
#[test]
fn all_is_a_superset_of_gapped() {
let gapped: Vec<String> = blocks_for(DuplicateType::Gapped)
.into_iter()
.map(|(hash, ..)| hash)
.collect();
let all: Vec<String> = blocks_for(DuplicateType::All)
.into_iter()
.map(|(hash, ..)| hash)
.collect();
for hash in &gapped {
assert!(all.contains(hash), "`all` dropped the gapped hash {hash}");
}
}
#[test]
fn skeleton_refuses_shapes_that_would_over_group() {
let straight_line = "fn v(x: usize) -> usize {\nlet a = x + 1;\nlet b = a * 2;\nb\n}";
assert!(gapped_skeleton(straight_line, 5).is_none());
let all_structural = "for a {\nif b {\n} else {\n}\n}";
assert!(gapped_skeleton(all_structural, 5).is_none());
}
#[test]
fn control_flow_detection_is_word_exact() {
assert!(carries_control_flow(&normalize_identifiers("if x {")));
assert!(!carries_control_flow(&normalize_identifiers("notify(x);")));
assert!(!carries_control_flow(&normalize_identifiers(
"let iffy = 1;"
)));
}
}