debtmap 0.23.0

Code complexity and technical debt analyzer
Documentation
use crate::core::{DuplicationBlock, DuplicationLocation};
use std::collections::{BTreeMap, BTreeSet, HashSet};

mod blocks;
#[cfg(test)]
mod tests;

use blocks::{exact_blocks_not_in_pairs, fuzzy_block, sort_blocks};

pub(super) struct SimilarityChunk {
    normalized: String,
    pub location: DuplicationLocation,
}

impl SimilarityChunk {
    pub fn new(normalized: String, location: DuplicationLocation) -> Self {
        Self {
            normalized,
            location,
        }
    }
}

pub(super) struct ContentGroup {
    pub normalized: String,
    pub locations: Vec<DuplicationLocation>,
    tokens: HashSet<String>,
}

pub(super) fn group_similar_chunks(
    chunks: Vec<SimilarityChunk>,
    min_lines: usize,
    threshold: f64,
) -> Vec<DuplicationBlock> {
    if threshold == 1.0 {
        return exact_hash_blocks(chunks, min_lines);
    }

    let groups = group_exact_content(chunks);
    let pairs = matching_pairs(&groups, threshold);
    let fuzzy_groups: BTreeSet<_> = pairs.iter().flat_map(|pair| [pair.0, pair.1]).collect();
    let mut blocks = exact_blocks_not_in_pairs(&groups, &fuzzy_groups, min_lines);
    blocks.extend(
        pairs
            .into_iter()
            .map(|pair| fuzzy_block(&groups, pair, min_lines)),
    );
    sort_blocks(&mut blocks);
    blocks
}

fn exact_hash_blocks(chunks: Vec<SimilarityChunk>, min_lines: usize) -> Vec<DuplicationBlock> {
    let buckets = chunks.into_iter().fold(
        BTreeMap::<u64, Vec<SimilarityChunk>>::new(),
        |mut buckets, chunk| {
            let hash = xxhash_rust::xxh64::xxh64(chunk.normalized.as_bytes(), 0);
            buckets.entry(hash).or_default().push(chunk);
            buckets
        },
    );
    let mut blocks: Vec<_> = buckets
        .into_iter()
        .filter(|(_, chunks)| chunks.len() > 1)
        .flat_map(|(hash, chunks)| verified_exact_blocks(hash, chunks, min_lines))
        .collect();
    sort_blocks(&mut blocks);
    blocks
}

fn verified_exact_blocks(
    hash: u64,
    chunks: Vec<SimilarityChunk>,
    min_lines: usize,
) -> Vec<DuplicationBlock> {
    chunks
        .into_iter()
        .fold(
            BTreeMap::<String, Vec<DuplicationLocation>>::new(),
            |mut contents, chunk| {
                contents
                    .entry(chunk.normalized)
                    .or_default()
                    .push(chunk.location);
                contents
            },
        )
        .into_values()
        .filter(|locations| locations.len() > 1)
        .map(|locations| DuplicationBlock {
            hash,
            lines: min_lines,
            locations,
        })
        .collect()
}

pub(super) fn calculate_similarity(left: &str, right: &str) -> f64 {
    token_similarity(&tokenize(left), &tokenize(right))
}

fn group_exact_content(chunks: Vec<SimilarityChunk>) -> Vec<ContentGroup> {
    chunks
        .into_iter()
        .fold(
            BTreeMap::<String, Vec<DuplicationLocation>>::new(),
            |mut map, chunk| {
                map.entry(chunk.normalized)
                    .or_default()
                    .push(chunk.location);
                map
            },
        )
        .into_iter()
        .map(|(normalized, locations)| ContentGroup {
            tokens: tokenize(&normalized),
            normalized,
            locations,
        })
        .collect()
}

fn matching_pairs(groups: &[ContentGroup], threshold: f64) -> Vec<(usize, usize)> {
    token_postings(groups)
        .into_values()
        .fold(
            BTreeMap::<(usize, usize), usize>::new(),
            |mut overlaps, group_ids| {
                add_posting_overlaps(groups, threshold, &group_ids, &mut overlaps);
                overlaps
            },
        )
        .into_iter()
        .filter(|((left, right), intersection)| {
            jaccard_from_intersection(groups, *left, *right, *intersection) >= threshold
        })
        .map(|(pair, _)| pair)
        .collect()
}

fn token_postings(groups: &[ContentGroup]) -> BTreeMap<&str, Vec<usize>> {
    groups
        .iter()
        .enumerate()
        .flat_map(|(group_id, group)| {
            group
                .tokens
                .iter()
                .map(move |token| (token.as_str(), group_id))
        })
        .fold(BTreeMap::new(), |mut postings, (token, group_id)| {
            postings.entry(token).or_default().push(group_id);
            postings
        })
}

fn add_posting_overlaps(
    groups: &[ContentGroup],
    threshold: f64,
    group_ids: &[usize],
    overlaps: &mut BTreeMap<(usize, usize), usize>,
) {
    for (offset, left) in group_ids.iter().enumerate() {
        for right in group_ids[offset + 1..]
            .iter()
            .filter(|right| size_can_match(&groups[*left], &groups[**right], threshold))
        {
            *overlaps.entry((*left, *right)).or_default() += 1;
        }
    }
}

fn size_can_match(left: &ContentGroup, right: &ContentGroup, threshold: f64) -> bool {
    let smaller = left.tokens.len().min(right.tokens.len()) as f64;
    let larger = left.tokens.len().max(right.tokens.len()) as f64;
    larger > 0.0 && smaller / larger >= threshold
}

fn jaccard_from_intersection(
    groups: &[ContentGroup],
    left: usize,
    right: usize,
    intersection: usize,
) -> f64 {
    let union = groups[left].tokens.len() + groups[right].tokens.len() - intersection;
    intersection as f64 / union as f64
}

fn tokenize(content: &str) -> HashSet<String> {
    content
        .split_whitespace()
        .map(str::to_lowercase)
        .filter(|token| token.len() > 2)
        .collect()
}

fn token_similarity(left: &HashSet<String>, right: &HashSet<String>) -> f64 {
    let union = left.union(right).count();
    if union == 0 {
        return 0.0;
    }
    left.intersection(right).count() as f64 / union as f64
}