1use crate::search::{
46 Embedder, Item, SemanticScope, SemanticUnit, Thresholds, UnitSource, VectorSpace, call_family,
47 call_pairs, called_names, dot, grammar_ids, matched_pairs,
48};
49use cpd_core::paths::clean_source_id;
50use rustc_hash::{FxHashMap, FxHashSet};
51use std::path::{Component, Path};
52
53#[derive(Debug, Clone, Copy)]
55pub struct CompareParams {
56 pub thresholds: Thresholds,
58 pub min_tokens: usize,
61 pub min_lines: usize,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct FunctionRef {
70 pub side: usize,
71 pub source: usize,
72 pub unit: usize,
73 pub counted: bool,
75 pub test: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum MatchedBy {
82 Code,
84 Name,
86}
87
88impl MatchedBy {
89 pub fn as_str(self) -> &'static str {
90 match self {
91 MatchedBy::Code => "code",
92 MatchedBy::Name => "name",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
100pub enum Level {
101 Low,
104 Medium,
106 High,
110}
111
112impl Level {
113 pub fn of(similarity: f32, same_language: bool, bars: &Thresholds) -> Self {
116 let (medium, high) = Self::floors(same_language, bars);
117 if similarity >= high {
118 Level::High
119 } else if similarity >= medium {
120 Level::Medium
121 } else {
122 Level::Low
123 }
124 }
125
126 pub fn floors(same_language: bool, bars: &Thresholds) -> (f32, f32) {
128 let threshold = bars.for_pair(same_language);
129 let high = bars.group_floor.max(threshold);
130 ((threshold + high) / 2.0, high)
131 }
132
133 pub fn as_str(self) -> &'static str {
134 match self {
135 Level::Low => "low",
136 Level::Medium => "medium",
137 Level::High => "high",
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq)]
144pub struct Pair {
145 pub a: usize,
147 pub b: usize,
148 pub similarity: f32,
150 pub level: Level,
151 pub matched_by: MatchedBy,
152}
153
154#[derive(Debug, Clone, Default)]
156pub struct Comparison {
157 pub functions: Vec<FunctionRef>,
159 pub pairs: Vec<Pair>,
161 pub calls: Vec<(usize, usize)>,
164}
165
166impl Comparison {
167 pub fn ready(&self) -> Vec<bool> {
173 let paired = self.paired();
174 let mut blocked = vec![false; self.functions.len()];
175 for &(caller, callee) in &self.calls {
176 if !paired[callee] {
177 blocked[caller] = true;
178 }
179 }
180 self.functions
181 .iter()
182 .enumerate()
183 .map(|(i, f)| f.counted && !paired[i] && !blocked[i])
184 .collect()
185 }
186
187 pub fn paired(&self) -> Vec<bool> {
189 let mut paired = vec![false; self.functions.len()];
190 for pair in &self.pairs {
191 paired[pair.a] = true;
192 paired[pair.b] = true;
193 }
194 paired
195 }
196}
197
198pub fn compare(
202 sides: [&[UnitSource]; 2],
203 embedder: &dyn Embedder,
204 params: &CompareParams,
205) -> Result<Comparison, String> {
206 let flat: Vec<(usize, usize, &UnitSource)> = sides
207 .iter()
208 .enumerate()
209 .flat_map(|(side, sources)| {
210 sources
211 .iter()
212 .enumerate()
213 .map(move |(index, source)| (side, index, source))
214 })
215 .collect();
216 let module_names = sides.map(|sources| {
217 modules(
218 &sources
219 .iter()
220 .map(|s| clean_source_id(&s.id))
221 .collect::<Vec<_>>(),
222 )
223 });
224 let mut files: FxHashMap<(usize, &str), u32> = FxHashMap::default();
225 let mut module_ids: FxHashMap<(usize, &str), u32> = FxHashMap::default();
226 let mut items = Vec::new();
227 let mut module_of = Vec::new();
228 let mut functions = Vec::new();
229 for (flat_index, &(side, source_index, source)) in flat.iter().enumerate() {
230 let next = files.len() as u32;
231 let file = *files
232 .entry((side, clean_source_id(&source.id)))
233 .or_insert(next);
234 let next = module_ids.len() as u32;
235 let module = *module_ids
236 .entry((side, module_names[side][source_index].as_str()))
237 .or_insert(next);
238 for (unit_index, unit) in source.units.iter().enumerate() {
239 if unit.name.starts_with('<') {
240 continue;
241 }
242 module_of.push(module);
243 items.push(Item {
244 source: flat_index,
245 unit: unit_index,
246 file,
247 });
248 functions.push(FunctionRef {
249 side,
250 source: source_index,
251 unit: unit_index,
252 counted: (unit.token_count as usize) >= params.min_tokens
253 && (unit.line_span() as usize) + 1 >= params.min_lines,
254 test: unit.test,
255 });
256 }
257 }
258 let unit = |item: &Item| -> &SemanticUnit { &flat[item.source].2.units[item.unit] };
259 let calls = call_graph(&items, unit, |i| functions[i].side);
262 let both_sides = [0, 1].map(|side| functions.iter().any(|f| f.side == side));
263 if both_sides.contains(&false) {
264 return Ok(Comparison {
265 functions,
266 pairs: Vec::new(),
267 calls,
268 });
269 }
270
271 let texts: Vec<&str> = items.iter().map(|item| unit(item).text.as_str()).collect();
272 let vectors = embedder.embed(&texts)?;
273 let space = VectorSpace::new(&vectors, texts.len())?;
274 let grammars = grammar_ids(&items, |item| unit(item).grammar);
275 let related = call_pairs(&items, unit);
276
277 let side_of = |i: usize| functions[i].side;
280 let rows = space.scan(
281 &items,
282 &grammars.of_item,
283 grammars.count,
284 &related,
285 |i, j| {
286 side_of(i) != side_of(j)
287 && functions[i].counted
288 && functions[j].counted
289 && functions[i].test == functions[j].test
290 },
291 );
292 let mut pairs: Vec<Pair> = matched_pairs(
293 &rows,
294 &grammars.of_item,
295 ¶ms.thresholds,
296 SemanticScope::All,
297 )
298 .into_iter()
299 .map(|(i, j, similarity)| {
300 let (a, b) = if side_of(i) == 0 { (i, j) } else { (j, i) };
301 let same_language = grammars.of_item[i] == grammars.of_item[j];
302 Pair {
303 a,
304 b,
305 similarity: similarity.min(1.0),
306 level: Level::of(similarity, same_language, ¶ms.thresholds),
307 matched_by: MatchedBy::Code,
308 }
309 })
310 .collect();
311
312 let mut paired = vec![false; functions.len()];
314 let mut linked_files: FxHashSet<(u32, u32)> = FxHashSet::default();
315 let mut links: FxHashMap<(bool, u32), FxHashMap<u32, usize>> = FxHashMap::default();
319 for pair in &pairs {
320 paired[pair.a] = true;
321 paired[pair.b] = true;
322 let kind = functions[pair.a].test;
323 let (ma, mb) = (module_of[pair.a], module_of[pair.b]);
324 *links.entry((kind, ma)).or_default().entry(mb).or_default() += 1;
325 *links.entry((kind, mb)).or_default().entry(ma).or_default() += 1;
326 linked_files.insert((items[pair.a].file, items[pair.b].file));
327 }
328 let main_link = |kind: bool, module: u32, other: u32| {
330 links.get(&(kind, module)).is_some_and(|counts| {
331 let most = counts.values().copied().max().unwrap_or(0);
332 counts.get(&other) == Some(&most)
333 })
334 };
335 let may_pair = |a: usize, b: usize| {
336 let kind = functions[a].test;
337 let (ma, mb) = (module_of[a], module_of[b]);
338 main_link(kind, ma, mb)
339 || main_link(kind, mb, ma)
340 || !(links.contains_key(&(kind, ma)) || links.contains_key(&(kind, mb)))
341 };
342 let mut by_name: FxHashMap<String, [Vec<usize>; 2]> = FxHashMap::default();
343 for (i, item) in items.iter().enumerate() {
344 let key = name_key(&unit(item).name, functions[i].test);
345 if !paired[i] && !key.is_empty() {
346 by_name.entry(key).or_default()[side_of(i)].push(i);
347 }
348 }
349 let mut by_name: Vec<_> = by_name.into_iter().collect();
350 by_name.sort_unstable_by(|x, y| x.0.cmp(&y.0));
351 for (_, [left, right]) in by_name {
352 let mut candidates: Vec<(bool, f32, usize, usize)> = Vec::new();
353 for &a in &left {
354 for &b in &right {
355 let counts = functions[a].counted || functions[b].counted;
356 if !counts
357 || functions[a].test != functions[b].test
358 || !may_pair(a, b)
359 || related[a].binary_search(&b).is_ok()
360 {
361 continue;
362 }
363 let same_language = grammars.of_item[a] == grammars.of_item[b];
364 let (medium, _) = Level::floors(same_language, ¶ms.thresholds);
365 let similarity = dot(space.row(a), space.row(b));
366 if similarity >= medium {
367 let files = linked_files.contains(&(items[a].file, items[b].file));
368 candidates.push((files, similarity, a, b));
369 }
370 }
371 }
372 candidates.sort_by(|x, y| {
375 (y.0.cmp(&x.0))
376 .then(y.1.total_cmp(&x.1))
377 .then((x.2, x.3).cmp(&(y.2, y.3)))
378 });
379 for (_, similarity, a, b) in candidates {
380 if paired[a] || paired[b] {
381 continue;
382 }
383 paired[a] = true;
384 paired[b] = true;
385 let same_language = grammars.of_item[a] == grammars.of_item[b];
386 pairs.push(Pair {
387 a,
388 b,
389 similarity: similarity.min(1.0),
390 level: Level::of(similarity, same_language, ¶ms.thresholds),
391 matched_by: MatchedBy::Name,
392 });
393 }
394 }
395 pairs.sort_by_key(|pair| (pair.a, pair.b));
396 Ok(Comparison {
397 functions,
398 pairs,
399 calls,
400 })
401}
402
403const MAX_CALL_TARGETS: usize = 3;
407
408fn call_graph<'u>(
416 items: &[Item],
417 unit: impl Fn(&Item) -> &'u SemanticUnit,
418 side_of: impl Fn(usize) -> usize,
419) -> Vec<(usize, usize)> {
420 let mut by_name: FxHashMap<(usize, &str), Vec<usize>> = FxHashMap::default();
421 for (i, item) in items.iter().enumerate() {
422 let u = unit(item);
423 if !u.test {
424 by_name
425 .entry((side_of(i), u.name.as_str()))
426 .or_default()
427 .push(i);
428 }
429 }
430 let mut calls = Vec::new();
431 for (i, item) in items.iter().enumerate() {
432 let own = unit(item);
433 let family = call_family(own.grammar);
434 let mut seen: FxHashSet<&str> = FxHashSet::default();
435 for callee in called_names(&own.text) {
436 if (callee == own.name && !own.test) || !seen.insert(callee) {
439 continue;
440 }
441 let Some(named) = by_name.get(&(side_of(i), callee)) else {
442 continue;
443 };
444 let candidates: Vec<usize> = named
445 .iter()
446 .copied()
447 .filter(|&j| j != i && call_family(unit(&items[j]).grammar) == family)
448 .collect();
449 let local: Vec<usize> = candidates
450 .iter()
451 .copied()
452 .filter(|&j| items[j].file == item.file)
453 .collect();
454 let targets = match (local.is_empty(), candidates.len()) {
455 (false, _) => local,
456 (true, n) if n <= MAX_CALL_TARGETS => candidates,
457 _ => Vec::new(),
458 };
459 calls.extend(targets.into_iter().map(|j| (i, j)));
460 }
461 }
462 calls.sort_unstable();
463 calls.dedup();
464 calls
465}
466
467fn modules(files: &[&str]) -> Vec<String> {
473 let dirs: Vec<Vec<Component<'_>>> = files
474 .iter()
475 .map(|f| {
476 let mut parts: Vec<Component<'_>> = Path::new(f).components().collect();
477 parts.pop();
478 parts
479 })
480 .collect();
481 let shared_by = |among: &[usize]| {
483 let Some((&first, rest)) = among.split_first() else {
484 return 0;
485 };
486 rest.iter().fold(dirs[first].len(), |n, &i| {
487 n.min(
488 dirs[first]
489 .iter()
490 .zip(&dirs[i])
491 .take_while(|(x, y)| x == y)
492 .count(),
493 )
494 })
495 };
496 let mut active: Vec<usize> = (0..dirs.len()).collect();
497 let mut shared = shared_by(&active);
498 loop {
499 let deeper: Vec<usize> = active
502 .iter()
503 .copied()
504 .filter(|&i| dirs[i].len() > shared)
505 .collect();
506 let below = shared_by(&deeper);
507 if deeper.len() == active.len() || deeper.len() < 2 || below <= shared {
508 break;
509 }
510 active = deeper;
511 shared = below;
512 }
513 let mut names = vec![String::new(); dirs.len()];
514 for i in active {
515 if let Some(c) = dirs[i].get(shared) {
516 names[i] = c.as_os_str().to_string_lossy().into_owned();
517 }
518 }
519 names
520}
521
522pub fn name_key(name: &str, test: bool) -> String {
532 let name = match test {
533 true => strip_test_marker(name),
534 false => name,
535 };
536 name.chars()
537 .filter(|c| c.is_alphanumeric())
538 .flat_map(char::to_lowercase)
539 .collect()
540}
541
542fn strip_test_marker(name: &str) -> &str {
544 let Some(rest) = name
545 .strip_prefix("test")
546 .or_else(|| name.strip_prefix("Test"))
547 else {
548 return name;
549 };
550 match rest.chars().next() {
551 Some('_') if rest.len() > 1 => &rest[1..],
552 Some(c) if c.is_uppercase() => rest,
553 _ => name,
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use cpd_core::models::Location;
561 use std::collections::HashMap;
562
563 fn unit(grammar: &'static str, name: &str, line: u32, lines: u32, tokens: u32) -> SemanticUnit {
566 SemanticUnit {
567 grammar,
568 name: name.to_string(),
569 start: Location::new(line, 0, line * 100),
570 end: Location::new(line + lines, 0, line * 100 + 90),
571 range: [line * 10, line * 10 + tokens - 1],
572 token_count: tokens,
573 text: format!("{name} body"),
574 test: false,
575 }
576 }
577
578 fn source(id: &str, format: &str, units: Vec<SemanticUnit>) -> UnitSource {
579 UnitSource {
580 id: id.to_string(),
581 format: format.to_string(),
582 units,
583 path_label: Default::default(),
584 }
585 }
586
587 const PARAMS: CompareParams = CompareParams {
588 thresholds: Thresholds::REFERENCE,
589 min_tokens: 50,
590 min_lines: 5,
591 };
592
593 struct Table(HashMap<String, Vec<f32>>);
595
596 impl Embedder for Table {
597 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, String> {
598 texts
599 .iter()
600 .map(|t| {
601 let key = t.split_whitespace().next().unwrap_or_default();
602 self.0
603 .get(key)
604 .cloned()
605 .ok_or(format!("no vector for {key}"))
606 })
607 .collect()
608 }
609 }
610
611 fn axis_vec(axis: usize, noise_axis: usize, noise: f32) -> Vec<f32> {
613 let mut v = vec![0.0; 128];
614 v[axis] = 1.0;
615 v[noise_axis] += noise;
616 v
617 }
618
619 fn fillers(side: &str, grammar: &'static str, format: &str) -> Vec<UnitSource> {
622 (0..30)
623 .map(|k| {
624 source(
625 &format!("{side}/filler{k}.x"),
626 format,
627 vec![unit(grammar, &format!("{side}filler{k}"), 1, 9, 60)],
628 )
629 })
630 .collect()
631 }
632
633 fn table(named: &[(&str, Vec<f32>)]) -> Table {
636 let mut table: HashMap<String, Vec<f32>> = named
637 .iter()
638 .map(|(k, v)| (k.to_string(), v.clone()))
639 .collect();
640 for (side, offset) in [("java", 64), ("python", 96)] {
641 for k in 0..30 {
642 let mut v = axis_vec(offset + k, 0, 0.15);
643 v[1] = 0.15;
644 table.insert(format!("{side}filler{k}"), v);
645 }
646 }
647 Table(table)
648 }
649
650 fn pair_names<'a>(
652 sides: [&'a [UnitSource]; 2],
653 result: &Comparison,
654 ) -> Vec<(&'a str, &'a str, MatchedBy)> {
655 let name = |index: usize| {
656 let f = result.functions[index];
657 sides[f.side][f.source].units[f.unit].name.as_str()
658 };
659 result
660 .pairs
661 .iter()
662 .map(|p| (name(p.a), name(p.b), p.matched_by))
663 .collect()
664 }
665
666 #[test]
667 fn a_ported_function_pairs_by_its_code_and_a_missing_one_stays_unpaired() {
668 let mut java = vec![source(
669 "java/QrCode.java",
670 "java",
671 vec![
672 unit("java", "drawVersion", 10, 20, 120),
673 unit("java", "makeKanji", 40, 20, 120),
674 ],
675 )];
676 java.extend(fillers("java", "java", "java"));
677 let mut python = vec![source(
678 "python/qrcodegen.py",
679 "python",
680 vec![unit("python", "draw_version_bits", 10, 15, 90)],
681 )];
682 python.extend(fillers("python", "python", "python"));
683 let embedder = table(&[
684 ("drawVersion", axis_vec(0, 2, 0.4)),
685 ("draw_version_bits", axis_vec(0, 3, 0.5)),
686 ("makeKanji", axis_vec(1, 2, 0.1)),
687 ]);
688 let sides = [java.as_slice(), python.as_slice()];
689 let result = compare(sides, &embedder, &PARAMS).unwrap();
690 assert_eq!(
691 pair_names(sides, &result),
692 vec![("drawVersion", "draw_version_bits", MatchedBy::Code)]
693 );
694 let paired = result.paired();
695 let missing: Vec<&str> = result
696 .functions
697 .iter()
698 .zip(&paired)
699 .filter(|(f, p)| f.counted && !**p && f.source == 0)
700 .map(|(f, _)| sides[f.side][f.source].units[f.unit].name.as_str())
701 .collect();
702 assert_eq!(missing, vec!["makeKanji"]);
703 }
704
705 #[test]
706 fn functions_of_one_side_never_pair() {
707 let java = vec![
708 source("java/A.java", "java", vec![unit("java", "twinA", 1, 9, 60)]),
709 source("java/B.java", "java", vec![unit("java", "twinB", 1, 9, 60)]),
710 ];
711 let python = fillers("python", "python", "python");
712 let embedder = table(&[
713 ("twinA", axis_vec(0, 1, 0.1)),
714 ("twinB", axis_vec(0, 1, 0.2)),
715 ]);
716 let result = compare([&java, &python], &embedder, &PARAMS).unwrap();
717 assert!(result.pairs.is_empty(), "{:?}", result.pairs);
718 }
719
720 #[test]
721 fn a_short_port_pairs_by_its_name() {
722 let mut java = vec![source(
725 "java/QrCode.java",
726 "java",
727 vec![unit("java", "finderPenaltyAddHistory", 10, 8, 70)],
728 )];
729 java.extend(fillers("java", "java", "java"));
730 let mut python = vec![source(
731 "python/qrcodegen.py",
732 "python",
733 vec![unit("python", "_finder_penalty_add_history", 10, 3, 25)],
734 )];
735 python.extend(fillers("python", "python", "python"));
736 let embedder = table(&[
737 ("finderPenaltyAddHistory", axis_vec(0, 2, 0.5)),
738 ("_finder_penalty_add_history", axis_vec(0, 3, 0.6)),
739 ]);
740 let sides = [java.as_slice(), python.as_slice()];
741 let result = compare(sides, &embedder, &PARAMS).unwrap();
742 assert_eq!(
743 pair_names(sides, &result),
744 vec![(
745 "finderPenaltyAddHistory",
746 "_finder_penalty_add_history",
747 MatchedBy::Name
748 )]
749 );
750 let python_fn = result.functions[result.pairs[0].b];
751 assert!(!python_fn.counted, "a short partner does not count");
752 }
753
754 #[test]
755 fn namesakes_that_do_different_things_do_not_pair() {
756 let java = vec![source(
757 "java/Reader.java",
758 "java",
759 vec![unit("java", "status", 1, 9, 60)],
760 )];
761 let python = vec![source(
762 "python/writer.py",
763 "python",
764 vec![unit("python", "Status", 1, 9, 60)],
765 )];
766 let embedder = table(&[
767 ("status", axis_vec(0, 2, 0.1)),
768 ("Status", axis_vec(1, 3, 0.1)),
769 ]);
770 let result = compare([&java, &python], &embedder, &PARAMS).unwrap();
771 assert!(result.pairs.is_empty(), "{:?}", result.pairs);
772 }
773
774 #[test]
775 fn namesakes_pair_best_first_and_once() {
776 let java = vec![
779 source(
780 "java/A.java",
781 "java",
782 vec![unit("java", "toString", 1, 9, 60)],
783 ),
784 source(
785 "java/B.java",
786 "java",
787 vec![unit("java", "toString", 20, 9, 60)],
788 ),
789 ];
790 let kotlin = vec![
791 source(
792 "kt/A.kt",
793 "kotlin",
794 vec![unit("kotlin", "toString", 1, 2, 20)],
795 ),
796 source(
797 "kt/B.kt",
798 "kotlin",
799 vec![unit("kotlin", "toString", 20, 2, 20)],
800 ),
801 ];
802 let mut sides = [java, kotlin];
805 for (s, prefix) in sides.iter_mut().zip(["j", "k"]) {
806 for (k, src) in s.iter_mut().enumerate() {
807 src.units[0].text = format!("{prefix}{k} body");
808 }
809 }
810 let embedder = table(&[
811 ("j0", axis_vec(0, 2, 0.3)),
812 ("j1", axis_vec(1, 2, 0.3)),
813 ("k0", axis_vec(1, 3, 0.4)),
814 ("k1", axis_vec(0, 3, 0.4)),
815 ]);
816 let result = compare([&sides[0], &sides[1]], &embedder, &PARAMS).unwrap();
817 let files: Vec<(&str, &str)> = result
818 .pairs
819 .iter()
820 .map(|p| {
821 let file = |i: usize| {
822 let f = result.functions[i];
823 sides[f.side][f.source].id.as_str()
824 };
825 (file(p.a), file(p.b))
826 })
827 .collect();
828 assert_eq!(
829 files,
830 vec![("java/A.java", "kt/B.kt"), ("java/B.java", "kt/A.kt")]
831 );
832 }
833
834 #[test]
835 fn anonymous_functions_take_no_part() {
836 let java = vec![source(
837 "java/A.java",
838 "java",
839 vec![
840 unit("java", "<lambda>", 1, 9, 60),
841 unit("java", "run", 20, 9, 60),
842 ],
843 )];
844 let python = vec![source(
845 "python/a.py",
846 "python",
847 vec![unit("python", "<lambda>", 1, 9, 60)],
848 )];
849 let result = compare([&java, &python], &table(&[]), &PARAMS).unwrap();
850 assert_eq!(result.functions.len(), 1);
851 assert!(result.pairs.is_empty());
852 }
853
854 #[test]
855 fn a_namesake_in_another_module_does_not_take_the_pair() {
856 let android = vec![
862 source(
863 "android/camera/src/Camera.kt",
864 "kotlin",
865 vec![unit("kotlin", "cameraLoad", 1, 9, 60)],
866 ),
867 source(
868 "android/nfc/src/Nfc.kt",
869 "kotlin",
870 vec![
871 unit("kotlin", "nfcLoad", 1, 9, 60),
872 unit("kotlin", "checkPermissions", 20, 9, 60),
873 ],
874 ),
875 source(
876 "android/camera/src/Status.kt",
877 "kotlin",
878 vec![unit("kotlin", "status", 1, 9, 60)],
879 ),
880 ];
881 let ios = vec![
882 source(
883 "ios/camera/Sources/Camera.swift",
884 "swift",
885 vec![
886 unit("swift", "cameraLoadIos", 1, 9, 60),
887 unit("swift", "checkPermissions", 20, 2, 20),
888 ],
889 ),
890 source(
891 "ios/nfc/Sources/Nfc.swift",
892 "swift",
893 vec![
894 unit("swift", "nfcLoadIos", 1, 9, 60),
895 unit("swift", "checkPermissions", 20, 2, 20),
896 unit("swift", "status", 40, 2, 20),
897 ],
898 ),
899 ];
900 let mut sides = [android, ios];
901 for (side, prefix) in sides.iter_mut().zip(["a", "i"]) {
903 for (k, src) in side.iter_mut().enumerate() {
904 for u in &mut src.units {
905 if matches!(u.name.as_str(), "checkPermissions" | "status") {
906 u.text = format!("{prefix}{k}{} body", u.name);
907 }
908 }
909 }
910 }
911 let embedder = table(&[
912 ("cameraLoad", axis_vec(0, 5, 0.2)),
913 ("cameraLoadIos", axis_vec(0, 6, 0.2)),
914 ("nfcLoad", axis_vec(1, 5, 0.2)),
915 ("nfcLoadIos", axis_vec(1, 6, 0.2)),
916 ("a1checkPermissions", axis_vec(2, 5, 0.2)),
917 ("i0checkPermissions", axis_vec(2, 6, 0.1)),
918 ("i1checkPermissions", axis_vec(2, 6, 0.6)),
919 ("a2status", axis_vec(3, 5, 0.2)),
920 ("i1status", axis_vec(3, 6, 0.2)),
921 ]);
922 let result = compare([&sides[0], &sides[1]], &embedder, &PARAMS).unwrap();
923 let described: Vec<(&str, &str, MatchedBy)> = result
924 .pairs
925 .iter()
926 .map(|p| {
927 let file = |i: usize| {
928 let f = result.functions[i];
929 sides[f.side][f.source].id.as_str()
930 };
931 (file(p.a), file(p.b), p.matched_by)
932 })
933 .collect();
934 assert_eq!(
935 described,
936 vec![
937 (
938 "android/camera/src/Camera.kt",
939 "ios/camera/Sources/Camera.swift",
940 MatchedBy::Code
941 ),
942 (
943 "android/nfc/src/Nfc.kt",
944 "ios/nfc/Sources/Nfc.swift",
945 MatchedBy::Code
946 ),
947 (
948 "android/nfc/src/Nfc.kt",
949 "ios/nfc/Sources/Nfc.swift",
950 MatchedBy::Name
951 ),
952 ]
953 );
954 }
955
956 #[test]
957 fn one_stray_code_pair_does_not_link_two_modules_for_names() {
958 let android = vec![
963 source(
964 "android/notify/src/Notify.kt",
965 "kotlin",
966 vec![
967 unit("kotlin", "show", 1, 9, 60),
968 unit("kotlin", "cancel", 20, 9, 60),
969 unit("kotlin", "permission", 40, 9, 60),
970 unit("kotlin", "status", 60, 9, 60),
971 ],
972 ),
973 source(
974 "android/scan/src/Scan.kt",
975 "kotlin",
976 vec![
977 unit("kotlin", "start", 1, 9, 60),
978 unit("kotlin", "stop", 20, 9, 60),
979 ],
980 ),
981 ];
982 let ios = vec![
983 source(
984 "ios/notify/Sources/Notify.swift",
985 "swift",
986 vec![
987 unit("swift", "showIos", 1, 9, 60),
988 unit("swift", "cancelIos", 20, 9, 60),
989 ],
990 ),
991 source(
992 "ios/scan/Sources/Scan.swift",
993 "swift",
994 vec![
995 unit("swift", "permissionIos", 1, 9, 60),
996 unit("swift", "Status", 20, 2, 20),
997 unit("swift", "startIos", 40, 9, 60),
998 unit("swift", "stopIos", 60, 9, 60),
999 ],
1000 ),
1001 ];
1002 let embedder = table(&[
1003 ("show", axis_vec(0, 5, 0.2)),
1004 ("showIos", axis_vec(0, 6, 0.2)),
1005 ("cancel", axis_vec(1, 5, 0.2)),
1006 ("cancelIos", axis_vec(1, 6, 0.2)),
1007 ("permission", axis_vec(2, 5, 0.2)),
1008 ("permissionIos", axis_vec(2, 6, 0.2)),
1009 ("status", axis_vec(3, 5, 0.2)),
1010 ("Status", axis_vec(3, 6, 0.2)),
1011 ("start", axis_vec(7, 5, 0.2)),
1012 ("startIos", axis_vec(7, 6, 0.2)),
1013 ("stop", axis_vec(8, 5, 0.2)),
1014 ("stopIos", axis_vec(8, 6, 0.2)),
1015 ]);
1016 let sides = [android.as_slice(), ios.as_slice()];
1017 let result = compare(sides, &embedder, &PARAMS).unwrap();
1018 assert_eq!(
1019 pair_names(sides, &result),
1020 vec![
1021 ("show", "showIos", MatchedBy::Code),
1022 ("cancel", "cancelIos", MatchedBy::Code),
1023 ("permission", "permissionIos", MatchedBy::Code),
1024 ("start", "startIos", MatchedBy::Code),
1025 ("stop", "stopIos", MatchedBy::Code),
1026 ]
1027 );
1028 }
1029
1030 #[test]
1031 fn modules_are_the_folders_under_the_shared_one() {
1032 assert_eq!(
1033 modules(&[
1034 "/r/android/nfc/src/main/Nfc.kt",
1035 "/r/android/camera/src/Camera.kt",
1036 "/r/android/Root.kt",
1037 ]),
1038 vec!["nfc", "camera", ""]
1039 );
1040 assert_eq!(modules(&["/r/java/A.java", "/r/java/B.java"]), vec!["", ""]);
1041 assert_eq!(
1043 modules(&[
1044 "/r/android/build.gradle.kts",
1045 "/r/android/app/src/main/java/camera/Camera.kt",
1046 "/r/android/app/src/main/java/nfc/Nfc.kt",
1047 ]),
1048 vec!["", "camera", "nfc"]
1049 );
1050 assert_eq!(modules(&["/r/java/x/A.java"]), vec![""]);
1051 }
1052
1053 #[test]
1054 fn a_test_pairs_only_with_a_test() {
1055 let mut java = vec![source(
1059 "java/Money.java",
1060 "java",
1061 vec![
1062 unit("java", "roundCents", 10, 9, 60),
1063 SemanticUnit {
1064 test: true,
1065 ..unit("java", "roundsCentsTest", 30, 9, 60)
1066 },
1067 ],
1068 )];
1069 java.extend(fillers("java", "java", "java"));
1070 let mut python = vec![source(
1071 "python/test_money.py",
1072 "python",
1073 vec![SemanticUnit {
1074 test: true,
1075 ..unit("python", "test_rounds_cents", 10, 9, 60)
1076 }],
1077 )];
1078 python.extend(fillers("python", "python", "python"));
1079 let embedder = table(&[
1080 ("roundCents", axis_vec(0, 2, 0.1)),
1081 ("roundsCentsTest", axis_vec(0, 3, 0.9)),
1082 ("test_rounds_cents", axis_vec(0, 4, 0.2)),
1083 ]);
1084 let sides = [java.as_slice(), python.as_slice()];
1085 let result = compare(sides, &embedder, &PARAMS).unwrap();
1086 assert_eq!(
1087 pair_names(sides, &result),
1088 vec![("roundsCentsTest", "test_rounds_cents", MatchedBy::Code)]
1089 );
1090 assert!(result.functions[result.pairs[0].a].test);
1091 }
1092
1093 fn with_text(u: SemanticUnit, text: &str) -> SemanticUnit {
1095 SemanticUnit {
1096 text: text.to_string(),
1097 ..u
1098 }
1099 }
1100
1101 #[test]
1102 fn calls_resolve_by_name_within_a_side() {
1103 let items: Vec<Item> = [0u32, 0, 1, 2, 3, 4, 5]
1104 .iter()
1105 .enumerate()
1106 .map(|(k, &file)| Item {
1107 source: 0,
1108 unit: k,
1109 file,
1110 })
1111 .collect();
1112 let units = [
1113 with_text(
1116 unit("rust", "main", 1, 9, 60),
1117 "fn main() { local(); shared(); render(); main(); }",
1118 ),
1119 with_text(unit("rust", "local", 20, 9, 60), "fn local() {}"),
1120 with_text(unit("rust", "shared", 1, 9, 60), "fn shared() {}"),
1121 with_text(unit("rust", "render", 1, 9, 60), "fn render() {}"),
1122 with_text(unit("rust", "render", 1, 9, 60), "fn render() {}"),
1123 with_text(unit("rust", "render", 1, 9, 60), "fn render() {}"),
1124 with_text(unit("rust", "render", 1, 9, 60), "fn render() {}"),
1125 ];
1126 let calls = call_graph(&items, |item| &units[item.unit], |_| 0);
1128 assert_eq!(calls, vec![(0, 1), (0, 2)]);
1129 let calls = call_graph(&items, |item| &units[item.unit], |i| usize::from(i > 0));
1131 assert!(calls.is_empty(), "{calls:?}");
1132 }
1133
1134 #[test]
1135 fn a_local_namesake_wins_and_tests_are_callers_not_callees() {
1136 let items: Vec<Item> = [0u32, 0, 1, 2]
1137 .iter()
1138 .enumerate()
1139 .map(|(k, &file)| Item {
1140 source: 0,
1141 unit: k,
1142 file,
1143 })
1144 .collect();
1145 let units = [
1146 with_text(unit("oxc", "run", 1, 9, 60), "function run() { parse(1) }"),
1147 with_text(unit("oxc", "parse", 20, 9, 60), "function parse(x) {}"),
1148 with_text(unit("oxc", "parse", 1, 9, 60), "function parse(x) {}"),
1149 SemanticUnit {
1150 test: true,
1151 ..with_text(unit("oxc", "run", 1, 9, 60), "it('run', () => { run () })")
1152 },
1153 ];
1154 let calls = call_graph(&items, |item| &units[item.unit], |_| 0);
1155 assert_eq!(
1156 calls,
1157 vec![(0, 1), (3, 0)],
1158 "the test titled `run` calls `run`"
1159 );
1160 }
1161
1162 #[test]
1163 fn a_port_not_started_yet_is_ready_from_its_leaves() {
1164 let java = vec![source(
1167 "java/A.java",
1168 "java",
1169 vec![
1170 with_text(unit("java", "run", 1, 9, 60), "void run() { helper(); }"),
1171 with_text(unit("java", "helper", 20, 9, 60), "void helper() {}"),
1172 ],
1173 )];
1174 let result = compare([&java, &[]], &table(&[]), &PARAMS).unwrap();
1175 assert_eq!(result.calls, vec![(0, 1)]);
1176 assert_eq!(result.ready(), vec![false, true]);
1177 }
1178
1179 #[test]
1180 fn a_function_is_ready_when_all_it_calls_is_ported() {
1181 let f = |side, counted| FunctionRef {
1182 side,
1183 source: 0,
1184 unit: 0,
1185 counted,
1186 test: false,
1187 };
1188 let pair = |a, b| Pair {
1189 a,
1190 b,
1191 similarity: 0.9,
1192 level: Level::High,
1193 matched_by: MatchedBy::Code,
1194 };
1195 let comparison = Comparison {
1199 functions: vec![
1200 f(0, true),
1201 f(0, true),
1202 f(0, true),
1203 f(0, true),
1204 f(0, true),
1205 f(0, true),
1206 f(0, false),
1207 f(1, true),
1208 ],
1209 pairs: vec![pair(1, 7)],
1210 calls: vec![(0, 1), (2, 3), (4, 5), (5, 4)],
1211 };
1212 assert_eq!(
1213 comparison.ready(),
1214 vec![true, false, false, true, false, false, false, false]
1215 );
1216 }
1217
1218 #[test]
1219 fn levels_follow_the_model_scale() {
1220 let bars = Thresholds {
1222 across: 0.4125,
1223 within: 0.6375,
1224 near_best: 0.075,
1225 group_floor: 0.7125,
1226 };
1227 let level = |s, same| Level::of(s, same, &bars);
1228 assert_eq!(level(0.45, false), Level::Low);
1229 assert_eq!(level(0.5625, false), Level::Medium);
1230 assert_eq!(level(0.69, false), Level::Medium);
1231 assert_eq!(level(0.7125, false), Level::High);
1232 assert_eq!(level(0.66, true), Level::Low);
1234 assert_eq!(level(0.68, true), Level::Medium);
1235 assert_eq!(level(0.9, true), Level::High);
1236 assert_eq!(
1238 Level::of(0.75, false, &Thresholds::REFERENCE),
1239 Level::Medium
1240 );
1241 assert_eq!(Level::of(0.8, false, &Thresholds::REFERENCE), Level::High);
1242 }
1243
1244 #[test]
1245 fn name_keys_ignore_case_and_underscores() {
1246 for name in [
1247 "encodeBinary",
1248 "encode_binary",
1249 "_encode_binary",
1250 "EncodeBinary",
1251 ] {
1252 assert_eq!(name_key(name, false), "encodebinary");
1253 }
1254 assert_eq!(name_key("__init__", false), "init");
1255 assert_eq!(
1257 name_key("rounds cents", true),
1258 name_key("rounds_cents", true)
1259 );
1260 for name in ["test_rounds_cents", "TestRoundsCents", "testRoundsCents"] {
1261 assert_eq!(name_key(name, true), "roundscents");
1262 }
1263 assert_eq!(name_key("test", true), "test", "a bare `test` stays");
1264 assert_eq!(name_key("tests the rounding", true), "teststherounding");
1265 assert_eq!(name_key("testing", true), "testing");
1266 assert_eq!(name_key("testConnection", false), "testconnection");
1268 assert_eq!(name_key("handles `null` input!", true), "handlesnullinput");
1269 }
1270}