Skip to main content

cpd_core/
similarity.rs

1//! Function-level similarity (issue #999, stage 2).
2//!
3//! Each function is summarized by the bag of `k`-grams over the pre-order
4//! sequence of its AST node types ("shingles"). Two functions are similar
5//! when the weighted Jaccard index of their shingle bags reaches the
6//! configured threshold. Candidate pairs come from MinHash + LSH banding so
7//! the search stays close to linear in the number of functions; the exact
8//! bag Jaccard is only computed for candidates.
9//!
10//! Node *types* only: identifier names and literal values do not take part,
11//! so a renamed copy scores 1.0 and an edited copy scores by how much of
12//! its structure survived. Positions always reference the original source.
13//!
14//! The scoring is grammar-agnostic: node-type ids are opaque `u16`s from
15//! whichever extractor produced them (`cpd_tokenizer::functions`), and a
16//! signature records its `grammar` so functions are only compared within
17//! one grammar. Adding a language means adding an extractor, not touching
18//! this module.
19
20use crate::detect::PreparedSource;
21use crate::models::{CloneKind, CpdClone, Fragment, Location, SimilarityMethod};
22use rustc_hash::{FxHashMap, FxHashSet};
23
24/// Shingle length over the node-type sequence.
25pub const SHINGLE_K: usize = 4;
26/// MinHash signature size; `BANDS * ROWS` must equal it.
27pub const MINHASH_SIZE: usize = 64;
28const BANDS: usize = 16;
29const ROWS: usize = 4;
30const _: () = assert!(BANDS * ROWS == MINHASH_SIZE);
31/// Buckets larger than this are truncated before pairing: a bucket that
32/// size means hundreds of structurally identical functions, and the first
33/// members already carry the signal.
34const MAX_BUCKET: usize = 256;
35
36/// Structural summary of one function, method or arrow function.
37#[derive(Debug, Clone, PartialEq)]
38pub struct FunctionSig {
39    /// Grammar that produced the node-type sequence; pairs are only formed
40    /// within one grammar.
41    pub grammar: &'static str,
42    /// Declared or inferred name (`<arrow>` / `<anonymous>` when none).
43    pub name: String,
44    pub start: Location,
45    pub end: Location,
46    /// Inclusive detection-token index range inside the owning source.
47    pub range: [u32; 2],
48    /// Detection tokens covered by the function.
49    pub token_count: u32,
50    /// Sorted bag of shingle hashes.
51    pub shingles: Vec<u64>,
52    pub minhash: [u64; MINHASH_SIZE],
53}
54
55impl FunctionSig {
56    /// Build a signature from a node-type sequence and the owning source's
57    /// token spans. Returns `None` when no detection token lies inside the
58    /// function's byte range (comment-only or type-only bodies).
59    pub fn build(
60        grammar: &'static str,
61        name: String,
62        start: Location,
63        end: Location,
64        kinds: &[u16],
65        spans: &[(Location, Location)],
66    ) -> Option<Self> {
67        let first = spans.partition_point(|(s, _)| s.offset < start.offset);
68        let last = spans.partition_point(|(_, e)| e.offset <= end.offset);
69        if first >= last {
70            return None;
71        }
72        let shingles = shingles_from_kinds(kinds, SHINGLE_K);
73        if shingles.is_empty() {
74            return None;
75        }
76        let minhash = minhash(&shingles);
77        Some(Self {
78            grammar,
79            name,
80            start,
81            end,
82            range: [first as u32, (last - 1) as u32],
83            token_count: (last - first) as u32,
84            shingles,
85            minhash,
86        })
87    }
88
89    /// Lines spanned, in jscpd's `end - start` convention.
90    pub fn line_span(&self) -> u32 {
91        self.end.line.saturating_sub(self.start.line)
92    }
93}
94
95/// Hash every `k`-gram of `kinds`; the result is sorted so it can be used as
96/// a multiset by [`bag_jaccard`].
97pub fn shingles_from_kinds(kinds: &[u16], k: usize) -> Vec<u64> {
98    if kinds.len() < k {
99        return Vec::new();
100    }
101    let mut out: Vec<u64> = kinds
102        .windows(k)
103        .map(|w| {
104            w.iter().fold(0xcbf2_9ce4_8422_2325u64, |acc, &t| {
105                (acc ^ u64::from(t)).wrapping_mul(0x0000_0100_0000_01b3)
106            })
107        })
108        .collect();
109    out.sort_unstable();
110    out
111}
112
113/// Weighted (multiset) Jaccard index of two sorted shingle bags.
114pub fn bag_jaccard(a: &[u64], b: &[u64]) -> f32 {
115    if a.is_empty() && b.is_empty() {
116        return 0.0;
117    }
118    let (mut i, mut j) = (0usize, 0usize);
119    let (mut inter, mut union) = (0usize, 0usize);
120    while i < a.len() && j < b.len() {
121        match a[i].cmp(&b[j]) {
122            std::cmp::Ordering::Less => {
123                i += 1;
124            }
125            std::cmp::Ordering::Greater => {
126                j += 1;
127            }
128            std::cmp::Ordering::Equal => {
129                inter += 1;
130                i += 1;
131                j += 1;
132            }
133        }
134        union += 1;
135    }
136    union += (a.len() - i) + (b.len() - j);
137    inter as f32 / union as f32
138}
139
140/// MinHash signature over the *distinct* shingles of a sorted bag.
141pub fn minhash(sorted_shingles: &[u64]) -> [u64; MINHASH_SIZE] {
142    let mut sig = [u64::MAX; MINHASH_SIZE];
143    let mut prev: Option<u64> = None;
144    for &s in sorted_shingles {
145        if prev == Some(s) {
146            continue;
147        }
148        prev = Some(s);
149        for (i, slot) in sig.iter_mut().enumerate() {
150            let h = mix(s, i as u64);
151            if h < *slot {
152                *slot = h;
153            }
154        }
155    }
156    sig
157}
158
159#[inline]
160fn mix(h: u64, i: u64) -> u64 {
161    // splitmix64 finalizer over (shingle, hash index): cheap and well mixed.
162    let mut z = h ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
163    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
164    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
165    z ^ (z >> 31)
166}
167
168/// The functions of one source, as needed by the similarity search.
169#[derive(Debug, Clone)]
170pub struct FunctionSource {
171    pub id: String,
172    pub format: String,
173    pub functions: Vec<FunctionSig>,
174}
175
176/// Pull the function signatures out of prepared sources (clones only the
177/// sources that carry any, so a run without `--similarity` copies nothing).
178pub fn collect_function_sources(prepared: &[PreparedSource]) -> Vec<FunctionSource> {
179    prepared
180        .iter()
181        .filter(|p| !p.functions.is_empty())
182        .map(|p| FunctionSource {
183            id: p.id.clone(),
184            format: p.format.clone(),
185            functions: p.functions.clone(),
186        })
187        .collect()
188}
189
190/// LSH index over the functions of many sources. Owns its sources so a
191/// long-lived holder (the MCP server) can build it once per scan and answer
192/// any number of queries from it.
193pub struct SimilarityIndex {
194    sources: Vec<FunctionSource>,
195    /// (source index, function index) per item.
196    items: Vec<(usize, usize)>,
197    buckets: FxHashMap<(u8, u64), Vec<usize>>,
198    min_tokens: u32,
199    min_lines: u32,
200}
201
202impl SimilarityIndex {
203    /// Index every function with at least `min_tokens` tokens and a line
204    /// span of at least `min_lines` (jscpd's usual clone thresholds).
205    pub fn build(sources: Vec<FunctionSource>, min_tokens: usize, min_lines: usize) -> Self {
206        let mut items = Vec::new();
207        let mut buckets: FxHashMap<(u8, u64), Vec<usize>> = FxHashMap::default();
208        for (si, src) in sources.iter().enumerate() {
209            for (fi, f) in src.functions.iter().enumerate() {
210                if !Self::eligible(f, min_tokens as u32, min_lines as u32) {
211                    continue;
212                }
213                let item = items.len();
214                items.push((si, fi));
215                for (band, key) in band_keys(&f.minhash) {
216                    buckets.entry((band, key)).or_default().push(item);
217                }
218            }
219        }
220        Self {
221            sources,
222            items,
223            buckets,
224            min_tokens: min_tokens as u32,
225            min_lines: min_lines as u32,
226        }
227    }
228
229    /// The indexed sources, in the order they were given.
230    pub fn sources(&self) -> &[FunctionSource] {
231        &self.sources
232    }
233
234    fn eligible(f: &FunctionSig, min_tokens: u32, min_lines: u32) -> bool {
235        f.token_count >= min_tokens && f.line_span() >= min_lines
236    }
237
238    fn sig(&self, item: usize) -> &FunctionSig {
239        let (si, fi) = self.items[item];
240        &self.sources[si].functions[fi]
241    }
242
243    /// Indexed functions structurally similar to `query`, best first.
244    /// Returns `(source index, function index, similarity)`.
245    pub fn query(&self, query: &FunctionSig, threshold: f32) -> Vec<(usize, usize, f32)> {
246        if !Self::eligible(query, self.min_tokens, self.min_lines) {
247            return Vec::new();
248        }
249        let mut seen: FxHashSet<usize> = FxHashSet::default();
250        let mut hits = Vec::new();
251        for (band, key) in band_keys(&query.minhash) {
252            let Some(bucket) = self.buckets.get(&(band, key)) else {
253                continue;
254            };
255            for &item in bucket.iter().take(MAX_BUCKET) {
256                if !seen.insert(item) {
257                    continue;
258                }
259                let cand = self.sig(item);
260                if let Some(sim) = score(query, cand, threshold) {
261                    let (si, fi) = self.items[item];
262                    hits.push((si, fi, sim));
263                }
264            }
265        }
266        hits.sort_by(|a, b| b.2.total_cmp(&a.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1)));
267        hits
268    }
269
270    /// All similar pairs among the indexed functions, as `similar` clones.
271    /// Pairs already covered by a clone in `existing` (an exact or renamed
272    /// match spanning both functions) are left out so nothing is reported
273    /// twice.
274    pub fn all_pairs(&self, threshold: f32, existing: &[CpdClone]) -> Vec<CpdClone> {
275        let mut pairs: FxHashSet<(usize, usize)> = FxHashSet::default();
276        for bucket in self.buckets.values() {
277            let members = &bucket[..bucket.len().min(MAX_BUCKET)];
278            for (x, &a) in members.iter().enumerate() {
279                for &b in &members[x + 1..] {
280                    pairs.insert((a.min(b), a.max(b)));
281                }
282            }
283        }
284        let mut clones: Vec<CpdClone> = pairs
285            .into_iter()
286            .filter_map(|(a, b)| {
287                let (sa, fa) = self.items[a];
288                let (sb, fb) = self.items[b];
289                let (fa_sig, fb_sig) = (self.sig(a), self.sig(b));
290                if sa == sb && nested(fa_sig, fb_sig) {
291                    return None;
292                }
293                let sim = score(fa_sig, fb_sig, threshold)?;
294                let (src_a, src_b) = (&self.sources[sa], &self.sources[sb]);
295                if covered_by_existing(src_a, fa_sig, src_b, fb_sig, existing) {
296                    return None;
297                }
298                let _ = (fa, fb);
299                Some(make_clone(src_a, fa_sig, src_b, fb_sig, sim))
300            })
301            .collect();
302        clones.sort_by(|x, y| {
303            x.fragment_a
304                .source_id
305                .cmp(&y.fragment_a.source_id)
306                .then(x.fragment_a.start.line.cmp(&y.fragment_a.start.line))
307                .then(x.fragment_b.source_id.cmp(&y.fragment_b.source_id))
308                .then(x.fragment_b.start.line.cmp(&y.fragment_b.start.line))
309        });
310        clones
311    }
312}
313
314/// Find similar function pairs across `sources` (issue #999, stage 2).
315pub fn find_similar_functions(
316    sources: Vec<FunctionSource>,
317    threshold: f32,
318    min_tokens: usize,
319    min_lines: usize,
320    existing: &[CpdClone],
321) -> Vec<CpdClone> {
322    if sources.is_empty() {
323        return Vec::new();
324    }
325    SimilarityIndex::build(sources, min_tokens, min_lines).all_pairs(threshold, existing)
326}
327
328fn band_keys(minhash: &[u64; MINHASH_SIZE]) -> impl Iterator<Item = (u8, u64)> + '_ {
329    minhash.chunks(ROWS).enumerate().map(|(band, rows)| {
330        let key = rows.iter().fold(0x9E37_79B9_7F4A_7C15u64, |acc, &r| {
331            mix(acc ^ r, band as u64)
332        });
333        (band as u8, key)
334    })
335}
336
337/// Exact score for a candidate pair, `None` below `threshold`. The size
338/// ratio bounds the Jaccard index from above, so it is checked first.
339fn score(a: &FunctionSig, b: &FunctionSig, threshold: f32) -> Option<f32> {
340    if a.grammar != b.grammar {
341        return None;
342    }
343    let (small, large) = if a.shingles.len() <= b.shingles.len() {
344        (a.shingles.len(), b.shingles.len())
345    } else {
346        (b.shingles.len(), a.shingles.len())
347    };
348    if (small as f32 / large as f32) < threshold {
349        return None;
350    }
351    let sim = bag_jaccard(&a.shingles, &b.shingles);
352    (sim >= threshold).then_some(sim)
353}
354
355fn nested(a: &FunctionSig, b: &FunctionSig) -> bool {
356    (a.range[0] <= b.range[0] && b.range[1] <= a.range[1])
357        || (b.range[0] <= a.range[0] && a.range[1] <= b.range[1])
358}
359
360/// True when an existing clone between the same two sources already spans
361/// at least 90% of the lines of both functions.
362fn covered_by_existing(
363    src_a: &FunctionSource,
364    a: &FunctionSig,
365    src_b: &FunctionSource,
366    b: &FunctionSig,
367    existing: &[CpdClone],
368) -> bool {
369    let covers = |frag: &Fragment, f: &FunctionSig| {
370        let lo = frag.start.line.max(f.start.line);
371        let hi = frag.end.line.min(f.end.line);
372        if hi < lo {
373            return false;
374        }
375        let overlap = hi - lo + 1;
376        let span = f.end.line - f.start.line + 1;
377        overlap as f32 >= 0.9 * span as f32
378    };
379    existing.iter().any(|c| {
380        (c.fragment_a.source_id == src_a.id
381            && c.fragment_b.source_id == src_b.id
382            && covers(&c.fragment_a, a)
383            && covers(&c.fragment_b, b))
384            || (c.fragment_a.source_id == src_b.id
385                && c.fragment_b.source_id == src_a.id
386                && covers(&c.fragment_a, b)
387                && covers(&c.fragment_b, a))
388    })
389}
390
391fn make_clone(
392    src_a: &FunctionSource,
393    a: &FunctionSig,
394    src_b: &FunctionSource,
395    b: &FunctionSig,
396    similarity: f32,
397) -> CpdClone {
398    let frag = |src: &FunctionSource, f: &FunctionSig| Fragment {
399        source_id: src.id.clone(),
400        source_root: None,
401        start: f.start.clone(),
402        end: f.end.clone(),
403        range: f.range,
404        blame: None,
405    };
406    // Deterministic fragment order: by source id, then position.
407    let a_first = (src_a.id.as_str(), a.start.line) <= (src_b.id.as_str(), b.start.line);
408    let (fa, fb) = if a_first {
409        (frag(src_a, a), frag(src_b, b))
410    } else {
411        (frag(src_b, b), frag(src_a, a))
412    };
413    CpdClone {
414        format: src_a.format.clone(),
415        fragment_a: fa,
416        fragment_b: fb,
417        token_count: a.token_count.min(b.token_count),
418        is_new: false,
419        kind: CloneKind::Similar,
420        similarity: Some(similarity),
421        similarity_method: Some(SimilarityMethod::Ast),
422        unmatched_lines: [0, 0],
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn loc(line: u32, offset: u32) -> Location {
431        Location {
432            line,
433            column: 0,
434            offset,
435        }
436    }
437
438    fn spans(n: u32) -> Vec<(Location, Location)> {
439        (0..n)
440            .map(|i| (loc(i + 1, i * 10), loc(i + 1, i * 10 + 5)))
441            .collect()
442    }
443
444    fn sig(name: &str, kinds: &[u16], first_tok: u32, last_tok: u32) -> FunctionSig {
445        let spans = spans(last_tok + 1);
446        FunctionSig::build(
447            "test",
448            name.into(),
449            loc(first_tok + 1, first_tok * 10),
450            loc(last_tok + 1, last_tok * 10 + 5),
451            kinds,
452            &spans,
453        )
454        .unwrap()
455    }
456
457    #[test]
458    fn shingles_are_order_sensitive_and_sorted() {
459        let a = shingles_from_kinds(&[1, 2, 3, 4, 5], 4);
460        let b = shingles_from_kinds(&[5, 4, 3, 2, 1], 4);
461        assert_eq!(a.len(), 2);
462        assert_ne!(a, b);
463        assert!(a.windows(2).all(|w| w[0] <= w[1]));
464        assert!(shingles_from_kinds(&[1, 2, 3], 4).is_empty());
465    }
466
467    #[test]
468    fn bag_jaccard_counts_multiplicity() {
469        assert_eq!(bag_jaccard(&[1, 2, 3], &[1, 2, 3]), 1.0);
470        assert_eq!(bag_jaccard(&[1, 1, 2], &[1, 2, 2]), 0.5);
471        assert_eq!(bag_jaccard(&[1, 2], &[3, 4]), 0.0);
472        assert_eq!(bag_jaccard(&[], &[]), 0.0);
473    }
474
475    #[test]
476    fn minhash_of_equal_sets_is_equal_and_of_disjoint_sets_differs() {
477        let a = minhash(&[1, 2, 3, 3, 4]);
478        let b = minhash(&[1, 2, 3, 4]);
479        assert_eq!(a, b, "multiplicity does not affect the signature");
480        let c = minhash(&[10, 20, 30, 40]);
481        assert_ne!(a, c);
482    }
483
484    #[test]
485    fn build_maps_bytes_to_token_range_and_rejects_empty_bodies() {
486        let s = sig("f", &[1, 2, 3, 4, 5, 6], 2, 7);
487        assert_eq!(s.range, [2, 7]);
488        assert_eq!(s.token_count, 6);
489        assert_eq!(s.line_span(), 5);
490        let spans = spans(4);
491        assert!(
492            FunctionSig::build(
493                "test",
494                "g".into(),
495                loc(9, 900),
496                loc(9, 950),
497                &[1, 2, 3, 4],
498                &spans
499            )
500            .is_none()
501        );
502    }
503
504    fn kinds(seed: u16, n: usize) -> Vec<u16> {
505        (0..n).map(|i| ((i as u16 * 7 + seed) % 23) + 1).collect()
506    }
507
508    #[test]
509    fn index_finds_edited_copies_and_ignores_unrelated_functions() {
510        let base = kinds(1, 80);
511        let mut edited = base.clone();
512        edited.insert(40, 99); // one inserted node
513        edited[10] = 98; // one changed node
514        // A genuinely different structure, not a shifted copy of `base`.
515        let other: Vec<u16> = (0..80u16).map(|i| (i * i * 3 + 11) % 29 + 1).collect();
516        let sources = vec![
517            FunctionSource {
518                id: "a.js".into(),
519                format: "javascript".into(),
520                functions: vec![sig("base", &base, 0, 60)],
521            },
522            FunctionSource {
523                id: "b.js".into(),
524                format: "javascript".into(),
525                functions: vec![sig("edited", &edited, 0, 62), sig("other", &other, 70, 140)],
526            },
527        ];
528        let clones = find_similar_functions(sources.clone(), 0.75, 10, 3, &[]);
529        assert_eq!(clones.len(), 1, "{clones:?}");
530        let c = &clones[0];
531        assert_eq!(c.kind, CloneKind::Similar);
532        assert_eq!(c.fragment_a.source_id, "a.js");
533        assert_eq!(c.fragment_b.source_id, "b.js");
534        assert_eq!(c.fragment_b.start.line, 1);
535        let sim = c.similarity.unwrap();
536        // two node edits in 80 nodes break 8 of the 77 shingles
537        assert!(sim > 0.75 && sim < 0.9, "got {sim}");
538        assert_eq!(c.token_count, 61);
539        assert!(find_similar_functions(sources.clone(), 0.99, 10, 3, &[]).is_empty());
540    }
541
542    #[test]
543    fn index_skips_nested_functions_small_functions_and_covered_pairs() {
544        let base = kinds(1, 80);
545        let outer = sig("outer", &base, 0, 60);
546        let inner = sig("inner", &base, 10, 50);
547        let same_file = vec![FunctionSource {
548            id: "a.js".into(),
549            format: "javascript".into(),
550            functions: vec![outer.clone(), inner],
551        }];
552        assert!(find_similar_functions(same_file.clone(), 0.5, 10, 3, &[]).is_empty());
553
554        let two = vec![
555            FunctionSource {
556                id: "a.js".into(),
557                format: "javascript".into(),
558                functions: vec![outer.clone()],
559            },
560            FunctionSource {
561                id: "b.js".into(),
562                format: "javascript".into(),
563                functions: vec![sig("copy", &base, 0, 60)],
564            },
565        ];
566        assert_eq!(
567            find_similar_functions(two.clone(), 0.5, 10, 3, &[]).len(),
568            1
569        );
570        assert!(
571            find_similar_functions(two.clone(), 0.5, 100, 3, &[]).is_empty(),
572            "min_tokens"
573        );
574        assert!(
575            find_similar_functions(two.clone(), 0.5, 10, 100, &[]).is_empty(),
576            "min_lines"
577        );
578
579        let mut exact = make_clone(&two[0], &outer, &two[1], &two[1].functions[0], 1.0);
580        exact.kind = CloneKind::Exact;
581        exact.similarity = None;
582        assert!(
583            find_similar_functions(two.clone(), 0.5, 10, 3, &[exact]).is_empty(),
584            "already reported"
585        );
586    }
587
588    #[test]
589    fn functions_of_different_grammars_are_never_paired() {
590        let base = kinds(1, 80);
591        let mut foreign = sig("copy", &base, 0, 60);
592        foreign.grammar = "tree-sitter-python";
593        let sources = vec![
594            FunctionSource {
595                id: "a.js".into(),
596                format: "javascript".into(),
597                functions: vec![sig("orig", &base, 0, 60)],
598            },
599            FunctionSource {
600                id: "b.py".into(),
601                format: "python".into(),
602                functions: vec![foreign],
603            },
604        ];
605        assert!(find_similar_functions(sources, 0.5, 10, 3, &[]).is_empty());
606    }
607
608    #[test]
609    fn query_returns_best_match_first() {
610        let base = kinds(1, 80);
611        let mut near = base.clone();
612        near[3] = 99;
613        let mut far = base.clone();
614        for k in far.iter_mut().take(20) {
615            *k = 99;
616        }
617        let sources = vec![FunctionSource {
618            id: "lib.js".into(),
619            format: "javascript".into(),
620            functions: vec![sig("far", &far, 0, 60), sig("near", &near, 70, 130)],
621        }];
622        let index = SimilarityIndex::build(sources, 10, 3);
623        let hits = index.query(&sig("q", &base, 0, 60), 0.5);
624        assert_eq!(hits.len(), 2);
625        assert_eq!(hits[0].1, 1, "near first");
626        assert!(hits[0].2 > hits[1].2);
627    }
628}