1use crate::detect::PreparedSource;
21use crate::models::{CloneKind, CpdClone, Fragment, Location, SimilarityMethod};
22use rustc_hash::{FxHashMap, FxHashSet};
23
24pub const SHINGLE_K: usize = 4;
26pub const MINHASH_SIZE: usize = 64;
28const BANDS: usize = 16;
29const ROWS: usize = 4;
30const _: () = assert!(BANDS * ROWS == MINHASH_SIZE);
31const MAX_BUCKET: usize = 256;
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct FunctionSig {
39 pub grammar: &'static str,
42 pub name: String,
44 pub start: Location,
45 pub end: Location,
46 pub range: [u32; 2],
48 pub token_count: u32,
50 pub shingles: Vec<u64>,
52 pub minhash: [u64; MINHASH_SIZE],
53}
54
55impl FunctionSig {
56 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 pub fn line_span(&self) -> u32 {
87 self.end.line.saturating_sub(self.start.line)
88 }
89}
90
91pub 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
104pub 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
122pub 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
149pub 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 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#[derive(Debug, Clone)]
179pub struct FunctionSource {
180 pub id: String,
181 pub format: String,
182 pub functions: Vec<FunctionSig>,
183}
184
185pub 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
199pub struct SimilarityIndex {
203 sources: Vec<FunctionSource>,
204 items: Vec<(usize, usize)>,
206 buckets: FxHashMap<(u8, u64), Vec<usize>>,
207 min_tokens: u32,
208 min_lines: u32,
209}
210
211impl SimilarityIndex {
212 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 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 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 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
316pub 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
339fn 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
362fn 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 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); edited[10] = 98; 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 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}