1use rayon::prelude::*;
5use rustc_hash::{FxHashMap, FxHashSet};
6use std::path::Path;
7
8use crate::{
9 hash::{base_pow, hash_window, roll, token_hash},
10 models::{CpdClone, DetectionToken, Fragment, Location, SourceFile, TokenKind},
11};
12
13type WindowStore = FxHashMap<u64, Occurrence>;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23struct Occurrence {
24 source_id: usize,
26 token_start: usize,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34struct CloneDedupKey {
35 a_id: String,
36 a_start_line: u32,
37 b_id: String,
38 b_start_line: u32,
39}
40
41impl CloneDedupKey {
42 fn from_clone(c: &CpdClone) -> Self {
43 let a_key = (&c.fragment_a.source_id, c.fragment_a.start.line);
45 let b_key = (&c.fragment_b.source_id, c.fragment_b.start.line);
46 if a_key <= b_key {
47 Self {
48 a_id: c.fragment_a.source_id.clone(),
49 a_start_line: c.fragment_a.start.line,
50 b_id: c.fragment_b.source_id.clone(),
51 b_start_line: c.fragment_b.start.line,
52 }
53 } else {
54 Self {
55 a_id: c.fragment_b.source_id.clone(),
56 a_start_line: c.fragment_b.start.line,
57 b_id: c.fragment_a.source_id.clone(),
58 b_start_line: c.fragment_a.start.line,
59 }
60 }
61 }
62}
63
64pub fn detect(files: &[SourceFile], min_tokens: usize) -> Vec<CpdClone> {
73 detect_with_options(files, min_tokens, false, 0)
74}
75
76pub fn detect_with_options(
83 files: &[SourceFile],
84 min_tokens: usize,
85 skip_local: bool,
86 min_lines: usize,
87) -> Vec<CpdClone> {
88 if files.is_empty() || min_tokens == 0 {
89 return vec![];
90 }
91
92 let mut by_format: FxHashMap<&str, Vec<usize>> = FxHashMap::default();
94 for (idx, file) in files.iter().enumerate() {
95 by_format.entry(file.format.as_str()).or_default().push(idx);
96 }
97 let mut format_groups: Vec<(&str, Vec<usize>)> = by_format.into_iter().collect();
98 format_groups.sort_unstable_by_key(|(fmt, _)| *fmt);
99 for (_, group) in &mut format_groups {
100 group.sort_unstable_by_key(|&idx| files[idx].id.as_str());
101 }
102
103 let all_clones: Vec<Vec<CpdClone>> = format_groups
104 .into_par_iter()
105 .map(|(_format, indices)| {
106 let prepared: Vec<PreparedSource> = indices
110 .iter()
111 .map(|&idx| {
112 let file = &files[idx];
113 let mut hashes = Vec::with_capacity(file.tokens.len());
114 let mut spans: Vec<(Location, Location)> =
115 Vec::with_capacity(file.tokens.len());
116 for t in &file.tokens {
117 if t.kind == TokenKind::Ignore {
118 continue;
119 }
120 hashes.push(token_hash(t.kind.discriminant(), &t.value));
121 spans.push((t.start.clone(), t.end.clone()));
122 }
123 PreparedSource {
124 id: file.id.clone(),
125 format: file.format.clone(),
126 hashes,
127 spans,
128 }
129 })
130 .collect();
131 detect_in_group(&prepared, min_tokens, skip_local, min_lines)
132 })
133 .collect();
134
135 let mut clones: Vec<CpdClone> = all_clones.into_iter().flatten().collect();
136 dedup_exact_clones(&mut clones);
137 suppress_subclones(&mut clones);
138
139 clones.sort_by(|a, b| {
140 (
141 &a.fragment_a.source_id,
142 a.fragment_a.start.line,
143 &a.fragment_b.source_id,
144 a.fragment_b.start.line,
145 )
146 .cmp(&(
147 &b.fragment_a.source_id,
148 b.fragment_a.start.line,
149 &b.fragment_b.source_id,
150 b.fragment_b.start.line,
151 ))
152 });
153
154 clones
155}
156
157pub struct PreparedSource {
166 pub id: String,
167 pub format: String,
168 pub hashes: Vec<u64>,
169 pub spans: Vec<(Location, Location)>,
170}
171
172impl PreparedSource {
173 pub fn from_detection_tokens(id: String, format: String, tokens: &[DetectionToken]) -> Self {
175 let mut hashes = Vec::with_capacity(tokens.len());
176 let mut spans = Vec::with_capacity(tokens.len());
177 for t in tokens {
178 hashes.push(t.hash);
179 spans.push((t.start.clone(), t.end.clone()));
180 }
181 Self {
182 id,
183 format,
184 hashes,
185 spans,
186 }
187 }
188}
189
190pub fn detect_prepared(
194 format_groups: Vec<Vec<PreparedSource>>,
195 min_tokens: usize,
196 skip_local: bool,
197 min_lines: usize,
198) -> Vec<CpdClone> {
199 if format_groups.is_empty() || min_tokens == 0 {
200 return vec![];
201 }
202
203 let all_clones: Vec<Vec<CpdClone>> = format_groups
204 .into_par_iter()
205 .map(|group| detect_in_group(&group, min_tokens, skip_local, min_lines))
206 .collect();
207
208 let mut clones: Vec<CpdClone> = all_clones.into_iter().flatten().collect();
209 dedup_exact_clones(&mut clones);
210 suppress_subclones(&mut clones);
211
212 clones.sort_by(|a, b| {
213 (
214 &a.fragment_a.source_id,
215 a.fragment_a.start.line,
216 &a.fragment_b.source_id,
217 a.fragment_b.start.line,
218 )
219 .cmp(&(
220 &b.fragment_a.source_id,
221 b.fragment_a.start.line,
222 &b.fragment_b.source_id,
223 b.fragment_b.start.line,
224 ))
225 });
226
227 clones
228}
229
230fn detect_in_group(
235 prepared: &[PreparedSource],
236 min_tokens: usize,
237 skip_local: bool,
238 min_lines: usize,
239) -> Vec<CpdClone> {
240 let window_power = base_pow(min_tokens.saturating_sub(1));
243
244 let total_windows: usize = prepared
246 .iter()
247 .map(|p| p.hashes.len().saturating_sub(min_tokens))
248 .sum();
249 let mut store: WindowStore =
250 FxHashMap::with_capacity_and_hasher(total_windows, Default::default());
251
252 let mut clones: Vec<CpdClone> = Vec::new();
253 const SECONDARY_OCCURRENCE_CAP: usize = 2;
256 let mut repeated_windows: FxHashMap<u64, Vec<Occurrence>> = FxHashMap::default();
257
258 for (file_idx, source) in prepared.iter().enumerate() {
259 let hashes = &source.hashes;
260 if hashes.len() < min_tokens {
261 continue;
262 }
263 let windows_len = hashes.len() - min_tokens + 1;
264
265 let mut open_clone: Option<OpenClone> = None;
270
271 let mut window_hash = hash_window(&hashes[..min_tokens]);
272
273 for token_start in 0..windows_len {
274 if token_start > 0 {
275 window_hash = roll(
276 window_hash,
277 hashes[token_start - 1],
278 hashes[token_start + min_tokens - 1],
279 window_power,
280 );
281 }
282
283 let current = Occurrence {
284 source_id: file_idx,
285 token_start,
286 };
287
288 match store.get(&window_hash).copied() {
289 Some(stored) if windows_match(stored, current, prepared) => {
290 if open_clone.is_none() {
291 open_clone = Some(OpenClone {
292 stored_occurrence: stored,
293 current_start: token_start,
294 match_len: min_tokens,
295 });
296 } else if let Some(ref mut oc) = open_clone {
297 oc.match_len += 1;
299 }
300 remember_repeated_window(
301 &mut repeated_windows,
302 window_hash,
303 stored,
304 SECONDARY_OCCURRENCE_CAP,
305 );
306 remember_repeated_window(
307 &mut repeated_windows,
308 window_hash,
309 current,
310 SECONDARY_OCCURRENCE_CAP,
311 );
312 }
315 _ => {
316 flush_clone(
318 open_clone.take(),
319 file_idx,
320 prepared,
321 skip_local,
322 min_lines,
323 &mut clones,
324 );
325 store.insert(window_hash, current);
326 }
327 }
328 }
329
330 flush_clone(
332 open_clone.take(),
333 file_idx,
334 prepared,
335 skip_local,
336 min_lines,
337 &mut clones,
338 );
339 }
340
341 add_secondary_clones(
342 repeated_windows,
343 prepared,
344 min_tokens,
345 skip_local,
346 min_lines,
347 &mut clones,
348 );
349
350 clones
351}
352
353struct OpenClone {
358 stored_occurrence: Occurrence,
359 current_start: usize,
360 match_len: usize,
361}
362
363fn windows_match(stored: Occurrence, current: Occurrence, _prepared: &[PreparedSource]) -> bool {
366 if stored.source_id == current.source_id && stored.token_start == current.token_start {
367 return false; }
369 true
373}
374
375fn flush_clone(
381 open: Option<OpenClone>,
382 current_file_idx: usize,
383 prepared: &[PreparedSource],
384 skip_local: bool,
385 min_lines: usize,
386 clones: &mut Vec<CpdClone>,
387) {
388 let oc = match open {
389 Some(o) => o,
390 None => return,
391 };
392
393 let existing = &oc.stored_occurrence;
394 let cur_start = oc.current_start;
395 let match_len = oc.match_len;
396
397 let existing_file = &prepared[existing.source_id];
398 let current_file = &prepared[current_file_idx];
399
400 let ex_start = existing.token_start;
401 let ex_end = ex_start + match_len - 1;
402 let cur_end = cur_start + match_len - 1;
403
404 if existing.source_id == current_file_idx {
412 let overlap = if ex_start < cur_start {
413 ex_end > cur_start
415 } else {
416 cur_end > ex_start
418 };
419 if overlap {
420 return;
421 }
422 }
423
424 if skip_local {
426 let dir_a = Path::new(&existing_file.id).parent();
427 let dir_b = Path::new(¤t_file.id).parent();
428 if dir_a == dir_b {
429 return;
430 }
431 }
432
433 let fragment_a = match make_fragment(&existing_file.id, &existing_file.spans, ex_start, ex_end)
434 {
435 Some(f) => f,
436 None => return,
437 };
438 let fragment_b = match make_fragment(¤t_file.id, ¤t_file.spans, cur_start, cur_end)
439 {
440 Some(f) => f,
441 None => return,
442 };
443
444 if min_lines > 0 {
448 let span_a = fragment_a.end.line as i64 - fragment_a.start.line as i64;
449 let span_b = fragment_b.end.line as i64 - fragment_b.start.line as i64;
450 if span_a < min_lines as i64 && span_b < min_lines as i64 {
451 return;
452 }
453 }
454
455 clones.push(CpdClone {
456 format: current_file.format.clone(),
457 fragment_a,
458 fragment_b,
459 token_count: match_len as u32,
460 });
461}
462
463fn make_fragment(
464 source_id: &str,
465 spans: &[(Location, Location)],
466 start_idx: usize,
467 end_idx: usize,
468) -> Option<Fragment> {
469 let (first_start, _) = spans.get(start_idx)?;
470 let (_, last_end) = spans.get(end_idx)?;
471 Some(Fragment {
472 source_id: source_id.to_string(),
473 start: first_start.clone(),
474 end: last_end.clone(),
475 range: [start_idx as u32, end_idx as u32],
476 blame: None,
477 })
478}
479
480fn dedup_exact_clones(clones: &mut Vec<CpdClone>) {
485 for clone in clones.iter_mut() {
487 let a_key = (&clone.fragment_a.source_id, clone.fragment_a.start.line);
488 let b_key = (&clone.fragment_b.source_id, clone.fragment_b.start.line);
489 if a_key > b_key {
490 std::mem::swap(&mut clone.fragment_a, &mut clone.fragment_b);
491 }
492 }
493
494 let mut seen: FxHashSet<CloneDedupKey> = FxHashSet::default();
495 clones.retain(|c| seen.insert(CloneDedupKey::from_clone(c)));
496}
497
498fn suppress_subclones(clones: &mut Vec<CpdClone>) {
505 use std::cmp::Reverse;
506 clones.sort_by_key(|c| Reverse(c.token_count));
507
508 let n = clones.len();
509 let mut keep = vec![true; n];
510
511 for i in 0..n {
512 if !keep[i] {
513 continue;
514 }
515 let big_a_id = clones[i].fragment_a.source_id.clone();
516 let big_b_id = clones[i].fragment_b.source_id.clone();
517 let big_a_range = clones[i].fragment_a.range;
518 let big_b_range = clones[i].fragment_b.range;
519
520 for j in (i + 1)..n {
521 if !keep[j] {
522 continue;
523 }
524 let small = &clones[j];
525 if small.fragment_a.source_id != big_a_id || small.fragment_b.source_id != big_b_id {
526 continue;
527 }
528 if big_a_range[0] <= small.fragment_a.range[0]
530 && big_a_range[1] >= small.fragment_a.range[1]
531 && big_b_range[0] <= small.fragment_b.range[0]
532 && big_b_range[1] >= small.fragment_b.range[1]
533 {
534 keep[j] = false;
535 }
536 }
537 }
538
539 let mut i = 0;
540 clones.retain(|_| {
541 let k = keep[i];
542 i += 1;
543 k
544 });
545}
546
547fn remember_repeated_window(
552 repeated_windows: &mut FxHashMap<u64, Vec<Occurrence>>,
553 hash: u64,
554 occurrence: Occurrence,
555 cap: usize,
556) {
557 let bucket = repeated_windows.entry(hash).or_default();
558 if bucket
559 .iter()
560 .any(|s| s.source_id == occurrence.source_id && s.token_start == occurrence.token_start)
561 {
562 return;
563 }
564 if bucket.len() < cap {
565 bucket.push(occurrence);
566 }
567}
568
569fn add_secondary_clones(
570 repeated_windows: FxHashMap<u64, Vec<Occurrence>>,
571 prepared: &[PreparedSource],
572 min_tokens: usize,
573 skip_local: bool,
574 min_lines: usize,
575 clones: &mut Vec<CpdClone>,
576) {
577 if repeated_windows.is_empty() {
578 return;
579 }
580
581 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
582 struct Candidate {
583 source_a: usize,
584 source_b: usize,
585 token_a: usize,
586 token_b: usize,
587 }
588
589 let mut candidates: Vec<Candidate> = Vec::new();
590 for occurrences in repeated_windows.values() {
591 if occurrences.len() < 2 {
592 continue;
593 }
594 for li in 0..occurrences.len() {
595 for ri in li + 1..occurrences.len() {
596 let left = &occurrences[li];
597 let right = &occurrences[ri];
598 if left.source_id == right.source_id && left.token_start == right.token_start {
599 continue;
600 }
601 let lh = &prepared[left.source_id].hashes;
602 let rh = &prepared[right.source_id].hashes;
603 let la = left.token_start;
604 let ra = right.token_start;
605 if la + min_tokens > lh.len() || ra + min_tokens > rh.len() {
606 continue;
607 }
608 if lh[la..la + min_tokens] != rh[ra..ra + min_tokens] {
609 continue;
610 }
611 let (sa, ta, sb, tb) =
612 if (left.source_id, left.token_start) <= (right.source_id, right.token_start) {
613 (
614 left.source_id,
615 left.token_start,
616 right.source_id,
617 right.token_start,
618 )
619 } else {
620 (
621 right.source_id,
622 right.token_start,
623 left.source_id,
624 left.token_start,
625 )
626 };
627 candidates.push(Candidate {
628 source_a: sa,
629 source_b: sb,
630 token_a: ta,
631 token_b: tb,
632 });
633 }
634 }
635 }
636 if candidates.is_empty() {
637 return;
638 }
639 candidates.sort_unstable();
640 candidates.dedup();
641
642 let n_files = prepared.len();
644 let mut covered: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n_files];
645 for c in clones.iter() {
646 let fa = prepared.iter().position(|p| p.id == c.fragment_a.source_id);
647 let fb = prepared.iter().position(|p| p.id == c.fragment_b.source_id);
648 if let Some(idx) = fa {
649 covered[idx].push((c.fragment_a.start.line, c.fragment_a.end.line));
650 }
651 if let Some(idx) = fb {
652 covered[idx].push((c.fragment_b.start.line, c.fragment_b.end.line));
653 }
654 }
655 for ranges in &mut covered {
656 ranges.sort_unstable();
657 }
658
659 let line_extends_coverage = |file_idx: usize, start: u32, end: u32| -> bool {
660 let ranges = &covered[file_idx];
661 let mut next = start;
662 for &(rs, re) in ranges {
663 if re < next {
664 continue;
665 }
666 if rs > next {
667 return true;
668 }
669 next = next.max(re.saturating_add(1));
670 if next > end {
671 return false;
672 }
673 }
674 next <= end
675 };
676
677 for cand in candidates {
678 let existing = Occurrence {
680 source_id: cand.source_a,
681 token_start: cand.token_a,
682 };
683 let current = Occurrence {
684 source_id: cand.source_b,
685 token_start: cand.token_b,
686 };
687
688 if skip_local {
690 let dir_a = Path::new(&prepared[existing.source_id].id).parent();
691 let dir_b = Path::new(&prepared[current.source_id].id).parent();
692 if dir_a == dir_b {
693 continue;
694 }
695 }
696
697 let ex_hashes = &prepared[existing.source_id].hashes;
699 let cur_hashes = &prepared[current.source_id].hashes;
700 let max_extend = (ex_hashes
701 .len()
702 .saturating_sub(existing.token_start + min_tokens))
703 .min(
704 cur_hashes
705 .len()
706 .saturating_sub(current.token_start + min_tokens),
707 );
708 let mut extra = 0usize;
709 while extra < max_extend
710 && ex_hashes[existing.token_start + min_tokens + extra]
711 == cur_hashes[current.token_start + min_tokens + extra]
712 {
713 extra += 1;
714 }
715 let match_len = min_tokens + extra;
716
717 let ex_start = existing.token_start;
718 let ex_end = ex_start + match_len - 1;
719 let cur_start = current.token_start;
720 let cur_end = cur_start + match_len - 1;
721
722 let frag_a = match make_fragment(
723 &prepared[existing.source_id].id,
724 &prepared[existing.source_id].spans,
725 ex_start,
726 ex_end,
727 ) {
728 Some(f) => f,
729 None => continue,
730 };
731 let frag_b = match make_fragment(
732 &prepared[current.source_id].id,
733 &prepared[current.source_id].spans,
734 cur_start,
735 cur_end,
736 ) {
737 Some(f) => f,
738 None => continue,
739 };
740
741 let nc = CpdClone {
742 format: prepared[current.source_id].format.clone(),
743 fragment_a: frag_a,
744 fragment_b: frag_b,
745 token_count: match_len as u32,
746 };
747
748 if min_lines > 0 {
750 let span_a = nc.fragment_a.end.line as i64 - nc.fragment_a.start.line as i64;
751 let span_b = nc.fragment_b.end.line as i64 - nc.fragment_b.start.line as i64;
752 if span_a < min_lines as i64 && span_b < min_lines as i64 {
753 continue;
754 }
755 }
756
757 let extends_a = line_extends_coverage(
758 cand.source_a,
759 nc.fragment_a.start.line,
760 nc.fragment_a.end.line,
761 );
762 let extends_b = line_extends_coverage(
763 cand.source_b,
764 nc.fragment_b.start.line,
765 nc.fragment_b.end.line,
766 );
767 if extends_a || extends_b {
768 clones.push(nc);
769 }
770 }
771}
772
773#[cfg(test)]
778mod tests {
779 use super::*;
780 use crate::models::{Location, Token, TokenKind};
781
782 fn loc(line: u32, col: u32, offset: u32) -> Location {
783 Location {
784 line,
785 column: col,
786 offset,
787 }
788 }
789
790 fn make_token(kind: TokenKind, value: &str, line: u32, col: u32, offset: u32) -> Token {
791 let end_col = col + value.len() as u32;
792 let end_off = offset + value.len() as u32;
793 Token {
794 kind,
795 value: value.to_string(),
796 start: loc(line, col, offset),
797 end: loc(line, end_col, end_off),
798 }
799 }
800
801 fn make_file(id: &str, format: &str, tokens: Vec<Token>) -> SourceFile {
802 SourceFile {
803 id: id.to_string(),
804 format: format.to_string(),
805 tokens,
806 }
807 }
808
809 fn js_tokens_ab() -> Vec<Token> {
810 vec![
811 make_token(TokenKind::Keyword, "function", 1, 0, 0),
812 make_token(TokenKind::Other, "hello", 1, 9, 9),
813 make_token(TokenKind::Operator, "(", 1, 14, 14),
814 make_token(TokenKind::Operator, ")", 1, 15, 15),
815 make_token(TokenKind::Operator, "{", 1, 16, 16),
816 make_token(TokenKind::Keyword, "return", 2, 0, 18),
817 make_token(TokenKind::Literal, "42", 2, 7, 25),
818 make_token(TokenKind::Operator, ";", 2, 9, 27),
819 make_token(TokenKind::Operator, "}", 3, 0, 29),
820 ]
821 }
822
823 #[test]
824 fn empty_input_returns_empty() {
825 let result = detect(&[], 10);
826 assert!(result.is_empty());
827 }
828
829 #[test]
830 fn identical_files_detected_as_clone() {
831 let tokens = js_tokens_ab();
832 let file_a = make_file("a.js", "javascript", tokens.clone());
833 let file_b = make_file("b.js", "javascript", tokens);
834 let clones = detect(&[file_a, file_b], 5);
835 assert!(
836 !clones.is_empty(),
837 "identical files must produce at least one clone"
838 );
839 }
840
841 #[test]
842 fn min_tokens_threshold_respected() {
843 let tokens = js_tokens_ab(); let file_a = make_file("a.js", "javascript", tokens.clone());
845 let file_b = make_file("b.js", "javascript", tokens);
846 let clones = detect(&[file_a, file_b], 100);
847 assert!(
848 clones.is_empty(),
849 "no clones when min_tokens exceeds file length"
850 );
851 }
852
853 #[test]
854 fn deduplication_ab_ba_collapse() {
855 let tokens = js_tokens_ab();
856 let file_a = make_file("a.js", "javascript", tokens.clone());
857 let file_b = make_file("b.js", "javascript", tokens);
858 let clones = detect(&[file_a, file_b], 5);
859 assert_eq!(clones.len(), 1, "symmetric pairs must collapse to 1");
861 }
862
863 #[test]
864 fn different_formats_not_cross_detected() {
865 let tokens = js_tokens_ab();
866 let file_js = make_file("a.js", "javascript", tokens.clone());
867 let file_py = make_file("a.py", "python", tokens);
868 let clones = detect(&[file_js, file_py], 5);
869 assert!(
870 clones.is_empty(),
871 "tokens from different formats must not match"
872 );
873 }
874
875 #[test]
876 fn identical_files_maximal_clone() {
877 let tokens = js_tokens_ab();
880 let file_a = make_file("a.js", "javascript", tokens.clone());
881 let file_b = make_file("b.js", "javascript", tokens);
882 let clones = detect(&[file_a, file_b], 5);
883 assert_eq!(
884 clones.len(),
885 1,
886 "open_clone SM must produce one maximal clone"
887 );
888 assert_eq!(
889 clones[0].token_count, 9,
890 "maximal clone must cover all 9 tokens"
891 );
892 }
893
894 #[test]
895 fn three_identical_files_secondary_pass_adds_missing_pair() {
896 let tokens = js_tokens_ab();
897 let file_a = make_file("a.js", "javascript", tokens.clone());
898 let file_b = make_file("b.js", "javascript", tokens.clone());
899 let file_c = make_file("c.js", "javascript", tokens);
900 let clones = detect(&[file_a, file_b, file_c], 5);
901 assert!(
902 clones.len() >= 2,
903 "three identical files must yield at least 2 clone pairs, got {}",
904 clones.len()
905 );
906 }
907
908 #[test]
909 fn clones_sorted_by_source_and_line() {
910 let tokens = js_tokens_ab();
911 let file_a = make_file("a.js", "javascript", tokens.clone());
912 let file_b = make_file("b.js", "javascript", tokens);
913 let clones = detect(&[file_a, file_b], 5);
914 for i in 1..clones.len() {
915 let prev = &clones[i - 1];
916 let curr = &clones[i];
917 assert!(
918 (
919 &prev.fragment_a.source_id,
920 prev.fragment_a.start.line,
921 &prev.fragment_b.source_id,
922 prev.fragment_b.start.line,
923 ) <= (
924 &curr.fragment_a.source_id,
925 curr.fragment_a.start.line,
926 &curr.fragment_b.source_id,
927 curr.fragment_b.start.line,
928 ),
929 "clones must be sorted"
930 );
931 }
932 }
933}