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 = 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 pub fn line_span(&self) -> u32 {
91 self.end.line.saturating_sub(self.start.line)
92 }
93}
94
95pub 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
113pub 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
140pub 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 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#[derive(Debug, Clone)]
170pub struct FunctionSource {
171 pub id: String,
172 pub format: String,
173 pub functions: Vec<FunctionSig>,
174}
175
176pub 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
190pub struct SimilarityIndex {
194 sources: Vec<FunctionSource>,
195 items: Vec<(usize, usize)>,
197 buckets: FxHashMap<(u8, u64), Vec<usize>>,
198 min_tokens: u32,
199 min_lines: u32,
200}
201
202impl SimilarityIndex {
203 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 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 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 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
314pub 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
337fn 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
360fn 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 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); edited[10] = 98; 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 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}