1use std::cell::RefCell;
31use std::collections::hash_map::Entry;
32use std::collections::{HashMap, HashSet};
33use std::path::Path;
34
35use tree_sitter::Parser;
36
37use crate::db::{Database, Fingerprint, Symbol, SymbolKind};
38use crate::error::Result;
39use crate::parser::Language;
40
41pub const NUM_PERMS: usize = 128;
43
44pub const SHINGLE_K: usize = 5;
46
47pub const LSH_BANDS: usize = 16;
49
50pub const LSH_ROWS: usize = 8;
52
53pub const MIN_THRESHOLD: f64 = 0.5;
56
57const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
58const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
59
60const SEED_STATE: u64 = 0x0c7f_1d5e_a5ed_c0de;
62
63const fn splitmix64(mut z: u64) -> u64 {
67 z = z.wrapping_add(0x9e37_79b9_7f4a_7c15);
68 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
69 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
70 z ^ (z >> 31)
71}
72
73const SEEDS: [u64; NUM_PERMS] = {
76 let mut seeds = [0u64; NUM_PERMS];
77 let mut state = SEED_STATE;
78 let mut i = 0;
79 while i < NUM_PERMS {
80 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
81 seeds[i] = splitmix64(state);
82 i += 1;
83 }
84 seeds
85};
86
87fn fnv1a(bytes: &[u8]) -> u64 {
89 let mut h = FNV_OFFSET;
90 for &b in bytes {
91 h = (h ^ b as u64).wrapping_mul(FNV_PRIME);
92 }
93 h
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Tok {
99 pub text: String,
100 pub line: u32,
101}
102
103fn ts_language(lang: Language) -> Option<tree_sitter::Language> {
106 match lang {
107 Language::Rust => Some(tree_sitter_rust::language()),
108 Language::TypeScript => Some(tree_sitter_typescript::language_typescript()),
109 Language::Tsx => Some(tree_sitter_typescript::language_tsx()),
110 Language::JavaScript | Language::Jsx => Some(tree_sitter_javascript::language()),
111 Language::Python => Some(tree_sitter_python::language()),
112 Language::Go => Some(tree_sitter_go::language()),
113 Language::Solidity | Language::Yaml | Language::Unknown => None,
114 }
115}
116
117thread_local! {
118 static PARSERS: RefCell<HashMap<&'static str, Parser>> = RefCell::new(HashMap::new());
121}
122
123fn normalize_leaf(kind: &str, text: &str) -> String {
125 if kind.contains("identifier") {
126 return "ID".to_string();
127 }
128 if kind.contains("string")
129 || kind.contains("char")
130 || kind.contains("template")
131 || kind.contains("rune")
132 {
133 return "LIT".to_string();
134 }
135 if matches!(
138 kind,
139 "integer_literal"
140 | "float_literal"
141 | "number"
142 | "int_literal"
143 | "imaginary_literal"
144 | "integer"
145 | "float"
146 ) {
147 return "LIT".to_string();
148 }
149 text.to_string()
150}
151
152pub fn tokenize(lang: Language, source: &str) -> Option<Vec<Tok>> {
158 let ts_lang = ts_language(lang)?;
159
160 PARSERS.with(|cell| {
161 let mut parsers = cell.borrow_mut();
162 let parser = parsers.entry(lang.as_str()).or_insert_with(|| {
163 let mut p = Parser::new();
164 p.set_language(ts_lang)
165 .expect("grammar/version mismatch for fingerprint parser");
166 p
167 });
168
169 let tree = parser.parse(source, None)?;
170 let bytes = source.as_bytes();
171 let mut tokens = Vec::new();
172
173 let mut stack = vec![tree.root_node()];
177 while let Some(node) = stack.pop() {
178 let kind = node.kind();
179 if kind.contains("comment") {
180 continue;
181 }
182 if node.child_count() == 0 {
183 let range = node.byte_range();
184 if range.is_empty() {
185 continue; }
187 let text = std::str::from_utf8(&bytes[range]).unwrap_or("");
188 tokens.push(Tok {
189 text: normalize_leaf(kind, text),
190 line: node.start_position().row as u32 + 1,
191 });
192 continue;
193 }
194 for i in (0..node.child_count()).rev() {
195 if let Some(child) = node.child(i) {
196 stack.push(child);
197 }
198 }
199 }
200
201 Some(tokens)
202 })
203}
204
205pub fn shingle_set(tokens: &[Tok]) -> HashSet<u64> {
211 let mut shingles = HashSet::new();
212 if tokens.len() < SHINGLE_K {
213 return shingles;
214 }
215 let hashes: Vec<u64> = tokens.iter().map(|t| fnv1a(t.text.as_bytes())).collect();
216 for window in hashes.windows(SHINGLE_K) {
217 let mut h = FNV_OFFSET;
218 for th in window {
219 for b in th.to_le_bytes() {
220 h = (h ^ b as u64).wrapping_mul(FNV_PRIME);
221 }
222 }
223 shingles.insert(h);
224 }
225 shingles
226}
227
228pub fn minhash(shingles: &HashSet<u64>) -> Option<[u64; NUM_PERMS]> {
232 if shingles.is_empty() {
233 return None;
234 }
235 let mut sig = [u64::MAX; NUM_PERMS];
236 for &x in shingles {
237 for (i, slot) in sig.iter_mut().enumerate() {
238 let h = splitmix64(x ^ SEEDS[i]);
239 if h < *slot {
240 *slot = h;
241 }
242 }
243 }
244 Some(sig)
245}
246
247pub fn signature_to_blob(sig: &[u64; NUM_PERMS]) -> Vec<u8> {
249 let mut blob = Vec::with_capacity(NUM_PERMS * 8);
250 for v in sig {
251 blob.extend_from_slice(&v.to_le_bytes());
252 }
253 blob
254}
255
256pub fn blob_to_signature(blob: &[u8]) -> Option<[u64; NUM_PERMS]> {
258 if blob.len() != NUM_PERMS * 8 {
259 return None;
260 }
261 let mut sig = [0u64; NUM_PERMS];
262 for (i, chunk) in blob.chunks_exact(8).enumerate() {
263 sig[i] = u64::from_le_bytes(chunk.try_into().ok()?);
264 }
265 Some(sig)
266}
267
268pub fn estimate_similarity(a: &[u64; NUM_PERMS], b: &[u64; NUM_PERMS]) -> f64 {
271 let matching = a.iter().zip(b.iter()).filter(|(x, y)| x == y).count();
272 matching as f64 / NUM_PERMS as f64
273}
274
275pub fn jaccard(a: &HashSet<u64>, b: &HashSet<u64>) -> f64 {
277 if a.is_empty() && b.is_empty() {
278 return 1.0;
279 }
280 if a.is_empty() || b.is_empty() {
281 return 0.0;
282 }
283 let intersection = a.intersection(b).count();
284 let union = a.len() + b.len() - intersection;
285 intersection as f64 / union as f64
286}
287
288pub fn band_keys(sig: &[u64; NUM_PERMS]) -> [u64; LSH_BANDS] {
291 let mut keys = [0u64; LSH_BANDS];
292 for (band, key) in keys.iter_mut().enumerate() {
293 let mut h = FNV_OFFSET;
294 for v in &sig[band * LSH_ROWS..(band + 1) * LSH_ROWS] {
295 for b in v.to_le_bytes() {
296 h = (h ^ b as u64).wrapping_mul(FNV_PRIME);
297 }
298 }
299 *key = h;
300 }
301 keys
302}
303
304pub fn file_fingerprints(
312 lang: Language,
313 source: &str,
314 rel_path: &str,
315 symbols: &[Symbol],
316 id_map: &HashMap<String, String>,
317) -> Vec<Fingerprint> {
318 let Some(tokens) = tokenize(lang, source) else {
319 return Vec::new();
320 };
321
322 let mut fingerprints = Vec::new();
323 for symbol in symbols {
324 if !matches!(symbol.kind, SymbolKind::Function | SymbolKind::Method) {
325 continue;
326 }
327 let Some(symbol_id) = id_map.get(&symbol.id) else {
328 continue;
329 };
330 let symbol_tokens: Vec<Tok> = tokens
331 .iter()
332 .filter(|t| t.line >= symbol.line_start && t.line <= symbol.line_end)
333 .cloned()
334 .collect();
335 let shingles = shingle_set(&symbol_tokens);
336 let Some(sig) = minhash(&shingles) else {
337 continue;
338 };
339 fingerprints.push(Fingerprint {
340 symbol_id: symbol_id.clone(),
341 file_path: rel_path.to_string(),
342 minhash: signature_to_blob(&sig),
343 token_count: symbol_tokens.len() as i64,
344 });
345 }
346 fingerprints
347}
348
349#[derive(Debug, Clone)]
351pub struct DuplicatePair {
352 pub a: Symbol,
353 pub b: Symbol,
354 pub similarity: f64,
356 pub token_count_a: i64,
357 pub token_count_b: i64,
358}
359
360pub fn find_near_duplicates(
370 db: &Database,
371 threshold: f64,
372 min_tokens: i64,
373 changed_files: Option<&HashSet<String>>,
374) -> Result<Vec<DuplicatePair>> {
375 let fingerprints = db.get_fingerprints(min_tokens)?;
376
377 let signatures: Vec<Option<[u64; NUM_PERMS]>> = fingerprints
380 .iter()
381 .map(|fp| blob_to_signature(&fp.minhash))
382 .collect();
383
384 let mut buckets: HashMap<(usize, u64), Vec<usize>> = HashMap::new();
386 for (idx, sig) in signatures.iter().enumerate() {
387 let Some(sig) = sig else { continue };
388 for (band, key) in band_keys(sig).iter().enumerate() {
389 buckets.entry((band, *key)).or_default().push(idx);
390 }
391 }
392
393 let mut candidates: HashSet<(usize, usize)> = HashSet::new();
394 for members in buckets.values() {
395 if members.len() < 2 {
396 continue;
397 }
398 for (n, &i) in members.iter().enumerate() {
399 for &j in &members[n + 1..] {
400 let pair = if i < j { (i, j) } else { (j, i) };
401 if pair.0 != pair.1 {
402 candidates.insert(pair);
403 }
404 }
405 }
406 }
407
408 if let Some(changed) = changed_files {
410 candidates.retain(|&(i, j)| {
411 changed.contains(&fingerprints[i].file_path)
412 || changed.contains(&fingerprints[j].file_path)
413 });
414 }
415
416 let mut shingle_cache: HashMap<usize, Option<HashSet<u64>>> = HashMap::new();
420 let mut symbol_cache: HashMap<usize, Option<Symbol>> = HashMap::new();
421 let mut pairs = Vec::new();
422
423 let mut sorted_candidates: Vec<(usize, usize)> = candidates.into_iter().collect();
424 sorted_candidates.sort_unstable();
425
426 for (i, j) in sorted_candidates {
427 for idx in [i, j] {
428 if let Entry::Vacant(entry) = symbol_cache.entry(idx) {
429 entry.insert(db.get_symbol(&fingerprints[idx].symbol_id)?);
430 }
431 if let Entry::Vacant(entry) = shingle_cache.entry(idx) {
432 entry.insert(symbol_cache[&idx].as_ref().and_then(symbol_shingles));
433 }
434 }
435
436 let (Some(sa), Some(sb)) = (&shingle_cache[&i], &shingle_cache[&j]) else {
437 continue;
438 };
439 let similarity = jaccard(sa, sb);
440 if similarity < threshold {
441 continue;
442 }
443 let (Some(a), Some(b)) = (&symbol_cache[&i], &symbol_cache[&j]) else {
444 continue;
445 };
446 pairs.push(DuplicatePair {
447 a: a.clone(),
448 b: b.clone(),
449 similarity,
450 token_count_a: fingerprints[i].token_count,
451 token_count_b: fingerprints[j].token_count,
452 });
453 }
454
455 pairs.sort_by(|x, y| {
456 y.similarity
457 .partial_cmp(&x.similarity)
458 .unwrap_or(std::cmp::Ordering::Equal)
459 .then_with(|| x.a.id.cmp(&y.a.id))
460 .then_with(|| x.b.id.cmp(&y.b.id))
461 });
462
463 Ok(pairs)
464}
465
466pub fn symbol_shingles(symbol: &Symbol) -> Option<HashSet<u64>> {
474 let source = symbol.source.as_ref()?;
475 let lang = Language::from_path(Path::new(&symbol.file_path));
476 let tokens = tokenize(lang, source)?;
477 Some(shingle_set(&tokens))
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn texts(lang: Language, source: &str) -> Vec<String> {
485 tokenize(lang, source)
486 .expect("tokenize failed")
487 .into_iter()
488 .map(|t| t.text)
489 .collect()
490 }
491
492 #[test]
493 fn test_tokenize_rust_normalization() {
494 let src = "fn add(first: i32) -> i32 {\n // a helpful remark\n first + 42\n}\n";
495 let toks = texts(Language::Rust, src);
496
497 assert!(toks.contains(&"ID".to_string()));
499 assert!(toks.contains(&"LIT".to_string()));
500 assert!(toks.contains(&"fn".to_string()));
501 assert!(!toks.contains(&"first".to_string()));
503 assert!(!toks.contains(&"42".to_string()));
504 assert!(!toks.iter().any(|t| t.contains("remark")));
506
507 let renamed = "fn add(second: i32) -> i32 {\n second + 7\n}\n";
509 assert_eq!(toks, texts(Language::Rust, renamed));
510 }
511
512 #[test]
513 fn test_tokenize_typescript_normalization() {
514 let src = "function greet(name: string): number {\n /* block comment */\n return name.length + 1;\n}\n";
515 let toks = texts(Language::TypeScript, src);
516 assert!(toks.contains(&"ID".to_string()));
517 assert!(toks.contains(&"LIT".to_string())); assert!(toks.contains(&"function".to_string()));
519 assert!(!toks.contains(&"name".to_string()));
520 assert!(!toks.contains(&"1".to_string()));
521 assert!(!toks.iter().any(|t| t.contains("block comment")));
522 }
523
524 #[test]
525 fn test_tokenize_python_normalization() {
526 let src = "def add(x):\n # trailing note\n return x + 42\n";
527 let toks = texts(Language::Python, src);
528 assert!(toks.contains(&"ID".to_string()));
529 assert!(toks.contains(&"LIT".to_string())); assert!(toks.contains(&"def".to_string()));
531 assert!(!toks.contains(&"x".to_string()));
532 assert!(!toks.contains(&"42".to_string()));
533 assert!(!toks.iter().any(|t| t.contains("note")));
534 }
535
536 #[test]
537 fn test_tokenize_go_normalization() {
538 let src = "package main\n\nfunc add(a int) int {\n\t// short comment\n\treturn a + 42\n}\n";
539 let toks = texts(Language::Go, src);
540 assert!(toks.contains(&"ID".to_string()));
541 assert!(toks.contains(&"LIT".to_string())); assert!(toks.contains(&"func".to_string()));
543 assert!(!toks.contains(&"a".to_string()));
544 assert!(!toks.contains(&"42".to_string()));
545 assert!(!toks.iter().any(|t| t.contains("short comment")));
546 }
547
548 #[test]
549 fn test_tokenize_unsupported_languages() {
550 assert!(tokenize(Language::Solidity, "contract C {}").is_none());
552 assert!(tokenize(Language::Yaml, "a: 1").is_none());
553 assert!(tokenize(Language::Unknown, "whatever").is_none());
554 }
555
556 #[test]
557 fn test_shingle_set_requires_k_tokens() {
558 let short: Vec<Tok> = (0..SHINGLE_K - 1)
559 .map(|i| Tok {
560 text: format!("t{}", i),
561 line: 1,
562 })
563 .collect();
564 assert!(shingle_set(&short).is_empty());
565 assert!(minhash(&shingle_set(&short)).is_none());
566
567 let exact: Vec<Tok> = (0..SHINGLE_K)
568 .map(|i| Tok {
569 text: format!("t{}", i),
570 line: 1,
571 })
572 .collect();
573 assert_eq!(shingle_set(&exact).len(), 1);
574 }
575
576 #[test]
577 fn test_minhash_estimate_close_to_exact_jaccard() {
578 let a: HashSet<u64> = (0..1000u64).map(splitmix64).collect();
580 let b: HashSet<u64> = (200..1200u64).map(splitmix64).collect();
581 let exact = jaccard(&a, &b);
582 assert!((exact - 800.0 / 1200.0).abs() < 1e-9);
583
584 let est = estimate_similarity(&minhash(&a).unwrap(), &minhash(&b).unwrap());
585 assert!(
586 (est - exact).abs() <= 0.08,
587 "estimate {} too far from exact {}",
588 est,
589 exact
590 );
591
592 assert_eq!(
594 estimate_similarity(&minhash(&a).unwrap(), &minhash(&a).unwrap()),
595 1.0
596 );
597 let c: HashSet<u64> = (10_000..10_500u64).map(splitmix64).collect();
598 let low = estimate_similarity(&minhash(&a).unwrap(), &minhash(&c).unwrap());
599 assert!(low <= 0.08, "disjoint sets estimated at {}", low);
600 }
601
602 #[test]
603 fn test_lsh_banding_finds_similar_pair() {
604 let a: HashSet<u64> = (0..200u64).map(splitmix64).collect();
607 let mut b = a.clone();
608 for i in 0..10u64 {
609 b.remove(&splitmix64(i));
610 b.insert(splitmix64(1_000_000 + i));
611 }
612 assert!(jaccard(&a, &b) > 0.89);
613
614 let keys_a = band_keys(&minhash(&a).unwrap());
615 let keys_b = band_keys(&minhash(&b).unwrap());
616 assert!(
617 keys_a.iter().zip(keys_b.iter()).any(|(x, y)| x == y),
618 "expected at least one shared LSH band for a ~0.9-similar pair"
619 );
620
621 let c: HashSet<u64> = (5_000..5_200u64).map(splitmix64).collect();
623 let keys_c = band_keys(&minhash(&c).unwrap());
624 assert!(!keys_a.iter().zip(keys_c.iter()).any(|(x, y)| x == y));
625 }
626
627 #[test]
628 fn test_fingerprints_are_deterministic() {
629 let src = "fn f(a: i32) -> i32 {\n let b = a * 2;\n b + 1\n}\n";
630 let t1 = tokenize(Language::Rust, src).unwrap();
631 let t2 = tokenize(Language::Rust, src).unwrap();
632 assert_eq!(t1, t2);
633
634 let blob1 = signature_to_blob(&minhash(&shingle_set(&t1)).unwrap());
635 let blob2 = signature_to_blob(&minhash(&shingle_set(&t2)).unwrap());
636 assert_eq!(blob1, blob2);
637 assert_eq!(blob1.len(), NUM_PERMS * 8);
638
639 let sig = minhash(&shingle_set(&t1)).unwrap();
641 assert_eq!(blob_to_signature(&blob1).unwrap(), sig);
642 assert!(blob_to_signature(&blob1[..100]).is_none());
643 }
644
645 #[test]
646 fn test_snippet_tokenization_matches_whole_file_slice() {
647 let file = "struct Foo {\n x: i32,\n}\n\nimpl Foo {\n fn double(&self) -> i32 {\n let value = self.x * 2;\n value + 1\n }\n}\n";
651 let whole = tokenize(Language::Rust, file).unwrap();
652
653 let sliced: Vec<String> = whole
655 .iter()
656 .filter(|t| t.line >= 6 && t.line <= 9)
657 .map(|t| t.text.clone())
658 .collect();
659 assert!(!sliced.is_empty());
660
661 let snippet: String = file.lines().skip(5).take(4).collect::<Vec<_>>().join("\n");
662 let snippet_toks: Vec<String> = tokenize(Language::Rust, &snippet)
663 .unwrap()
664 .into_iter()
665 .map(|t| t.text)
666 .collect();
667
668 assert_eq!(sliced, snippet_toks);
669 }
670
671 #[test]
672 fn test_jaccard_edge_cases() {
673 let empty: HashSet<u64> = HashSet::new();
674 let some: HashSet<u64> = [1, 2, 3].into_iter().collect();
675 assert_eq!(jaccard(&empty, &empty), 1.0);
676 assert_eq!(jaccard(&empty, &some), 0.0);
677 assert_eq!(jaccard(&some, &some), 1.0);
678 }
679}