1use petgraph::Graph;
28use serde::{Deserialize, Serialize};
29use std::collections::{HashMap, HashSet};
30
31use srcgraph_core::{ClassNode, EdgeKind};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct MethodFingerprint {
37 pub node_id: String,
38 pub class_name: String,
39 pub name: String,
40 pub tokens: String,
42 pub line: u32,
44 pub end_line: u32,
45 pub params: u32,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ClonePair {
51 pub m1: MethodFingerprint,
52 pub m2: MethodFingerprint,
53 pub similarity: f64,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CloneFamily {
59 pub id: usize,
60 pub size: usize,
61 pub members: Vec<MethodFingerprint>,
62 pub avg_similarity: f64,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct CloneAnalysis {
69 pub pairs: Vec<ClonePair>,
70 pub families: Vec<CloneFamily>,
71 pub total_methods: usize,
72 pub cloned_methods: usize,
74 pub clone_ratio: f64,
75}
76
77pub fn parse_method_fingerprints(
82 blob: &serde_json::Value,
83 node_id: &str,
84 class_name: &str,
85) -> Option<Vec<MethodFingerprint>> {
86 let methods = blob.get("methods")?.as_array()?;
87 let mut out = Vec::with_capacity(methods.len());
88 for m in methods {
89 let Some(obj) = m.as_object() else { continue };
90 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
91 let tokens = obj.get("tokens").and_then(|v| v.as_str()).unwrap_or("").to_owned();
92 let line = obj.get("line").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
93 let end_line = obj.get("endLine").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
94 let params = obj.get("params").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
95 out.push(MethodFingerprint {
96 node_id: node_id.to_owned(),
97 class_name: class_name.to_owned(),
98 name,
99 tokens,
100 line,
101 end_line,
102 params,
103 });
104 }
105 Some(out)
106}
107
108pub fn extract_ngrams(token_str: &str, n: usize) -> HashSet<String> {
116 let tokens: Vec<&str> = token_str.split_whitespace().collect();
117 let mut out = HashSet::new();
118 if tokens.is_empty() {
119 return out;
120 }
121 if tokens.len() < n {
122 out.insert(tokens.join("\u{1f}"));
123 return out;
124 }
125 for win in tokens.windows(n) {
126 out.insert(win.join("\u{1f}"));
127 }
128 out
129}
130
131pub fn ngram_jaccard(a_tokens: &str, b_tokens: &str, n: usize) -> f64 {
134 let a = extract_ngrams(a_tokens, n);
135 let b = extract_ngrams(b_tokens, n);
136 jaccard(&a, &b)
137}
138
139fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
140 if a.is_empty() && b.is_empty() {
141 return 0.0;
142 }
143 let inter = a.intersection(b).count();
144 let union = a.len() + b.len() - inter;
145 if union == 0 {
146 0.0
147 } else {
148 inter as f64 / union as f64
149 }
150}
151
152pub fn detect_clone_pairs(
156 methods: &[MethodFingerprint],
157 threshold: f64,
158 n: usize,
159) -> Vec<ClonePair> {
160 let ngram_sets: Vec<HashSet<String>> = methods
161 .iter()
162 .map(|m| extract_ngrams(&m.tokens, n))
163 .collect();
164 let token_counts: Vec<usize> = methods
165 .iter()
166 .map(|m| m.tokens.split_whitespace().count())
167 .collect();
168
169 let mut pairs = Vec::new();
170 for i in 0..methods.len() {
171 for j in (i + 1)..methods.len() {
172 let (ci, cj) = (token_counts[i], token_counts[j]);
174 if ci > 0 && cj > 0 {
175 let (lo, hi) = if ci < cj { (ci, cj) } else { (cj, ci) };
176 if (hi as f64) / (lo as f64) > 2.0 {
177 continue;
178 }
179 }
180 if methods[i].node_id == methods[j].node_id
182 && methods[i].name == methods[j].name
183 {
184 continue;
185 }
186 let a = &ngram_sets[i];
187 let b = &ngram_sets[j];
188 if a.is_empty() || b.is_empty() {
189 continue;
190 }
191 let sim = jaccard(a, b);
192 if sim >= threshold {
193 pairs.push(ClonePair {
194 m1: methods[i].clone(),
195 m2: methods[j].clone(),
196 similarity: (sim * 10_000.0).round() / 10_000.0,
197 });
198 }
199 }
200 }
201 pairs
202}
203
204fn method_key(m: &MethodFingerprint) -> String {
205 format!("{}::{}:{}", m.node_id, m.name, m.line)
206}
207
208pub fn group_clone_families(pairs: &[ClonePair]) -> Vec<CloneFamily> {
211 if pairs.is_empty() {
212 return Vec::new();
213 }
214
215 let mut key_to_idx: HashMap<String, usize> = HashMap::new();
217 let mut methods_by_idx: Vec<MethodFingerprint> = Vec::new();
218 let mut pair_idx: Vec<(usize, usize)> = Vec::with_capacity(pairs.len());
219
220 for p in pairs {
221 let k1 = method_key(&p.m1);
222 let k2 = method_key(&p.m2);
223 let i1 = *key_to_idx.entry(k1).or_insert_with(|| {
224 methods_by_idx.push(p.m1.clone());
225 methods_by_idx.len() - 1
226 });
227 let i2 = *key_to_idx.entry(k2).or_insert_with(|| {
228 methods_by_idx.push(p.m2.clone());
229 methods_by_idx.len() - 1
230 });
231 pair_idx.push((i1, i2));
232 }
233
234 let mut parent: Vec<usize> = (0..methods_by_idx.len()).collect();
235 fn find(parent: &mut [usize], mut x: usize) -> usize {
236 while parent[x] != x {
237 parent[x] = parent[parent[x]];
238 x = parent[x];
239 }
240 x
241 }
242 for &(a, b) in &pair_idx {
243 let ra = find(&mut parent, a);
244 let rb = find(&mut parent, b);
245 if ra != rb {
246 parent[ra] = rb;
247 }
248 }
249
250 let mut buckets: HashMap<usize, Vec<usize>> = HashMap::new();
252 for i in 0..methods_by_idx.len() {
253 let r = find(&mut parent, i);
254 buckets.entry(r).or_default().push(i);
255 }
256
257 let mut pair_sim: HashMap<(usize, usize), f64> = HashMap::new();
259 for (idx, p) in pairs.iter().enumerate() {
260 let (a, b) = pair_idx[idx];
261 let k = if a < b { (a, b) } else { (b, a) };
262 pair_sim.insert(k, p.similarity);
263 }
264
265 let mut families: Vec<CloneFamily> = buckets
266 .into_iter()
267 .map(|(_root, members)| {
268 let mut sum = 0.0;
270 let mut count = 0usize;
271 let mset: HashSet<usize> = members.iter().copied().collect();
272 for (&(a, b), &sim) in &pair_sim {
273 if mset.contains(&a) && mset.contains(&b) {
274 sum += sim;
275 count += 1;
276 }
277 }
278 let avg = if count > 0 { sum / count as f64 } else { 0.0 };
279 CloneFamily {
280 id: 0,
281 size: members.len(),
282 members: members.into_iter().map(|i| methods_by_idx[i].clone()).collect(),
283 avg_similarity: (avg * 10_000.0).round() / 10_000.0,
284 }
285 })
286 .collect();
287
288 families.sort_by(|a, b| b.size.cmp(&a.size));
290 for (i, f) in families.iter_mut().enumerate() {
291 f.id = i;
292 }
293 families
294}
295
296pub fn compute_clone_analysis<N, E>(
301 graph: &Graph<N, E>,
302 threshold: f64,
303 n: usize,
304) -> CloneAnalysis
305where
306 N: ClassNode,
307 E: EdgeKind,
308{
309 let mut all_methods: Vec<MethodFingerprint> = Vec::new();
310 for nx in graph.node_indices() {
311 let node = &graph[nx];
312 let Some(blob) = node.method_fingerprints() else {
313 continue;
314 };
315 let parsed = if let Some(s) = blob.as_str() {
318 serde_json::from_str::<serde_json::Value>(s)
319 .ok()
320 .and_then(|v| parse_method_fingerprints(&v, node.id(), node.id()))
321 } else {
322 parse_method_fingerprints(blob, node.id(), node.id())
323 };
324 if let Some(mut ms) = parsed {
325 all_methods.append(&mut ms);
326 }
327 }
328
329 let pairs = detect_clone_pairs(&all_methods, threshold, n);
330 let families = group_clone_families(&pairs);
331
332 let mut cloned_keys: HashSet<String> = HashSet::new();
333 for p in &pairs {
334 cloned_keys.insert(format!("{}::{}", p.m1.node_id, p.m1.name));
335 cloned_keys.insert(format!("{}::{}", p.m2.node_id, p.m2.name));
336 }
337 let total = all_methods.len();
338 let clone_ratio = if total > 0 {
339 (cloned_keys.len() as f64 / total as f64 * 10_000.0).round() / 10_000.0
340 } else {
341 0.0
342 };
343
344 CloneAnalysis {
345 pairs,
346 families,
347 total_methods: total,
348 cloned_methods: cloned_keys.len(),
349 clone_ratio,
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use srcgraph_core::{OwnedClassNode, OwnedGraph};
357 use petgraph::Graph;
358 use serde_json::json;
359
360 fn class(id: &str, fingerprints: Option<serde_json::Value>) -> OwnedClassNode {
361 OwnedClassNode {
362 id: id.to_owned(),
363 name: id.to_owned(),
364 namespace: "test".to_owned(),
365 line_count: 10,
366 method_count: 1,
367 halstead_eta1: 0,
368 halstead_eta2: 0,
369 halstead_n1: 0,
370 halstead_n2: 0,
371 method_connectivity: None,
372 method_fingerprints: fingerprints,
373 method_tokens: None,
374 call_sequences: None,
375 cyclomatic_complexity: None,
376 path_conditions: None,
377 invariants: None,
378 error_messages: None,
379 magic_numbers: None,
380 dead_code: None,
381 tenant_branches: None,
382 state_transitions: None,
383 }
384 }
385
386 fn fp(name: &str, tokens: &str, line: u32) -> serde_json::Value {
387 json!({"name": name, "tokens": tokens, "line": line, "endLine": line + 10, "params": 0})
388 }
389
390 #[test]
391 fn ngrams_short_stream_collapses_to_one_tuple() {
392 let ng = extract_ngrams("A B", 3);
393 assert_eq!(ng.len(), 1);
394 assert!(ng.contains(&"A\u{1f}B".to_owned()));
395 }
396
397 #[test]
398 fn ngrams_basic_trigrams() {
399 let ng = extract_ngrams("A B C D E", 3);
400 assert_eq!(ng.len(), 3);
401 assert!(ng.contains(&"A\u{1f}B\u{1f}C".to_owned()));
402 assert!(ng.contains(&"C\u{1f}D\u{1f}E".to_owned()));
403 }
404
405 #[test]
406 fn ngrams_empty_yields_empty() {
407 assert!(extract_ngrams("", 3).is_empty());
408 }
409
410 #[test]
411 fn jaccard_identical_is_one() {
412 let s = "KW_IF BINOP INVOKE KW_RETURN";
413 assert!((ngram_jaccard(s, s, 3) - 1.0).abs() < 1e-12);
414 }
415
416 #[test]
417 fn jaccard_disjoint_is_zero() {
418 let a = "X Y Z W V";
419 let b = "P Q R S T";
420 assert_eq!(ngram_jaccard(a, b, 3), 0.0);
421 }
422
423 #[test]
424 fn detect_pairs_identical_methods_match() {
425 let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
426 let methods = vec![
427 MethodFingerprint {
428 node_id: "A".into(),
429 class_name: "A".into(),
430 name: "foo".into(),
431 tokens: toks.into(),
432 line: 1,
433 end_line: 10,
434 params: 0,
435 },
436 MethodFingerprint {
437 node_id: "B".into(),
438 class_name: "B".into(),
439 name: "bar".into(),
440 tokens: toks.into(),
441 line: 1,
442 end_line: 10,
443 params: 0,
444 },
445 ];
446 let pairs = detect_clone_pairs(&methods, 0.7, 3);
447 assert_eq!(pairs.len(), 1);
448 assert!((pairs[0].similarity - 1.0).abs() < 1e-12);
449 }
450
451 #[test]
452 fn detect_pairs_skips_token_count_outliers() {
453 let methods = vec![
455 MethodFingerprint {
456 node_id: "A".into(),
457 class_name: "A".into(),
458 name: "small".into(),
459 tokens: "KW_IF BINOP".into(),
460 line: 1,
461 end_line: 2,
462 params: 0,
463 },
464 MethodFingerprint {
465 node_id: "B".into(),
466 class_name: "B".into(),
467 name: "big".into(),
468 tokens: "KW_IF BINOP INVOKE KW_RETURN KW_ELSE KW_FOR KW_WHILE KW_DO KW_TRY KW_CATCH".into(),
469 line: 1,
470 end_line: 20,
471 params: 0,
472 },
473 ];
474 let pairs = detect_clone_pairs(&methods, 0.0, 3);
475 assert!(pairs.is_empty());
476 }
477
478 #[test]
479 fn detect_pairs_skips_same_class_same_name() {
480 let toks = "KW_IF BINOP INVOKE";
482 let methods = vec![
483 MethodFingerprint {
484 node_id: "A".into(),
485 class_name: "A".into(),
486 name: "foo".into(),
487 tokens: toks.into(),
488 line: 1,
489 end_line: 5,
490 params: 0,
491 },
492 MethodFingerprint {
493 node_id: "A".into(),
494 class_name: "A".into(),
495 name: "foo".into(),
496 tokens: toks.into(),
497 line: 100,
498 end_line: 105,
499 params: 0,
500 },
501 ];
502 assert!(detect_clone_pairs(&methods, 0.5, 3).is_empty());
503 }
504
505 #[test]
506 fn families_union_transitive_chain() {
507 let mk = |n: &str, line: u32| MethodFingerprint {
509 node_id: n.into(),
510 class_name: n.into(),
511 name: n.into(),
512 tokens: String::new(),
513 line,
514 end_line: line + 1,
515 params: 0,
516 };
517 let foo = mk("foo", 1);
518 let bar = mk("bar", 1);
519 let baz = mk("baz", 1);
520 let pairs = vec![
521 ClonePair {
522 m1: foo.clone(),
523 m2: bar.clone(),
524 similarity: 0.9,
525 },
526 ClonePair {
527 m1: bar.clone(),
528 m2: baz.clone(),
529 similarity: 0.8,
530 },
531 ];
532 let fams = group_clone_families(&pairs);
533 assert_eq!(fams.len(), 1);
534 assert_eq!(fams[0].size, 3);
535 assert!((fams[0].avg_similarity - 0.85).abs() < 1e-6);
536 }
537
538 #[test]
539 fn families_sorted_by_size_desc() {
540 let mk = |n: &str| MethodFingerprint {
541 node_id: n.into(),
542 class_name: n.into(),
543 name: n.into(),
544 tokens: String::new(),
545 line: 1,
546 end_line: 2,
547 params: 0,
548 };
549 let pairs = vec![
550 ClonePair {
552 m1: mk("a"),
553 m2: mk("b"),
554 similarity: 0.8,
555 },
556 ClonePair {
558 m1: mk("p"),
559 m2: mk("q"),
560 similarity: 0.9,
561 },
562 ClonePair {
563 m1: mk("q"),
564 m2: mk("r"),
565 similarity: 0.9,
566 },
567 ];
568 let fams = group_clone_families(&pairs);
569 assert_eq!(fams.len(), 2);
570 assert_eq!(fams[0].size, 3);
571 assert_eq!(fams[0].id, 0);
572 assert_eq!(fams[1].size, 2);
573 assert_eq!(fams[1].id, 1);
574 }
575
576 #[test]
577 fn compute_clone_analysis_walks_graph() {
578 let mut g: OwnedGraph = Graph::new();
579 let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
580 g.add_node(class("A", Some(json!({"methods": [fp("foo", toks, 1)]}))));
581 g.add_node(class("B", Some(json!({"methods": [fp("bar", toks, 1)]}))));
582 g.add_node(class("C", Some(json!({"methods": [fp("baz", "X Y Z W V", 1)]}))));
584 g.add_node(class("D", None));
586
587 let r = compute_clone_analysis(&g, 0.7, 3);
588 assert_eq!(r.total_methods, 3);
589 assert_eq!(r.pairs.len(), 1);
590 assert_eq!(r.families.len(), 1);
591 assert_eq!(r.families[0].size, 2);
592 assert_eq!(r.cloned_methods, 2);
593 let expected = (2.0_f64 / 3.0 * 10_000.0).round() / 10_000.0;
594 assert!((r.clone_ratio - expected).abs() < 1e-6);
595 }
596
597 #[test]
598 fn compute_clone_analysis_accepts_string_encoded_blob() {
599 let toks = "KW_IF BINOP INVOKE KW_RETURN KW_ELSE";
601 let inner = json!({"methods": [fp("foo", toks, 1)]});
602 let mut g: OwnedGraph = Graph::new();
603 g.add_node(class("A", Some(serde_json::Value::String(inner.to_string()))));
604 g.add_node(class("B", Some(json!({"methods": [fp("bar", toks, 1)]}))));
605
606 let r = compute_clone_analysis(&g, 0.7, 3);
607 assert_eq!(r.total_methods, 2);
608 assert_eq!(r.pairs.len(), 1);
609 }
610}