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