Skip to main content

cpd_semantic/
compare.rs

1//! Two codebases compared function by function (`--compare`): a project and
2//! its port to another language, or two implementations of one app, such as
3//! the iOS and the Android one. Every function of one side is paired with
4//! the functions of the other side that do the same job; what stays unpaired
5//! on a side is what the other side lacks.
6//!
7//! Functions pair up in two steps.
8//!
9//! 1. The rule of `--semantic` (see [`crate::search`]), between the two
10//!    sides only: mutual near-best matches that reach the threshold and stand
11//!    out from their backgrounds, where a function's background is the other
12//!    side. Functions smaller than `--min-tokens` or `--min-lines` stay out
13//!    of this step, as they do in `--semantic`: a short function resembles
14//!    too many others. Lines are counted with the first and the last, one
15//!    more than `--semantic` counts, so a function of exactly `--min-lines`
16//!    lines takes part here and not there.
17//! 2. Names. A function the first step left unpaired pairs with an unpaired
18//!    function of the other side under the same name, once case,
19//!    underscores, spaces and punctuation are ignored (`encodeBinary`,
20//!    `encode_binary`; the test titled `rounds cents` and `rounds_cents`), when their
21//!    similarity reaches the `medium` level (see [`Level`]): a name pair
22//!    skips the mutual-best and z-score checks of step 1, so it needs more
23//!    than step 1's threshold, or every `load` and `init` of two flat
24//!    codebases would pair. Here size does not matter:
25//!    a port often makes a function shorter, and a short function is exactly
26//!    the one step 1 cannot see. Names repeat across a codebase (`load`,
27//!    `checkPermissions` in every plugin), so a name pair has to stay within
28//!    the modules step 1 has linked: a module is the folder right under the
29//!    deepest folder all files of a side share (`notification` in
30//!    `android/notification/…`), and two modules are linked when one of
31//!    them holds the most of the other's step-1 pairs, so a single stray
32//!    pair links nothing. Two modules step 1 paired nothing in may pair by
33//!    name too. Namesakes in two files that step 1 has linked go first, then
34//!    the most similar.
35//!
36//! Tests and code are told apart ([`crate::test_code`]): a test pairs only
37//! with a test and code only with code, in both steps, so a test that calls
38//! a function never stands in for it.
39//!
40//! Only functions of at least `--min-tokens` tokens and `--min-lines` lines
41//! (counting the first and the last) count toward a side's totals; a smaller one shows up only as the partner of one that
42//! counts. Anonymous functions (callbacks, closures) take no part: they are
43//! pieces of the function around them, not something to port on their own.
44
45use 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/// Settings of a comparison.
54#[derive(Debug, Clone, Copy)]
55pub struct CompareParams {
56    /// The similarities the rules need, on the scale of the embedder's model.
57    pub thresholds: Thresholds,
58    /// Functions with fewer detection tokens do not count and do not take
59    /// part in step 1.
60    pub min_tokens: usize,
61    /// Functions of fewer lines, the first and the last included, do not
62    /// count and do not take part in step 1.
63    pub min_lines: usize,
64}
65
66/// A function of one side: `sides[side][source].units[unit]` of the sources
67/// given to [`compare`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct FunctionRef {
70    pub side: usize,
71    pub source: usize,
72    pub unit: usize,
73    /// Whether the function is big enough to count toward its side's totals.
74    pub counted: bool,
75    /// A test, not code (see [`crate::test_code`]); it pairs with tests only.
76    pub test: bool,
77}
78
79/// How a pair was found.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum MatchedBy {
82    /// The code: step 1 of the module docs.
83    Code,
84    /// The name, with enough similarity: step 2.
85    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/// How close a pair's code is, on the scale of the model that scored it:
98/// a cosine of 0.6 is high for one model and low for another.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
100pub enum Level {
101    /// Below the middle of the pair's threshold and the high bar: related
102    /// code pairs here too, so read both functions.
103    Low,
104    /// Between the two.
105    Medium,
106    /// At least the group floor of the rules (0.7125 with CodeRankEmbed, 0.8
107    /// with jina-embeddings-v2-base-code), and at least the pair's
108    /// threshold: almost always the same function.
109    High,
110}
111
112impl Level {
113    /// The level of `similarity` for a pair within one language or across
114    /// two, under `bars`.
115    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    /// The lowest similarities of the `medium` and the `high` level.
127    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/// Two functions that do the same job, one on each side.
143#[derive(Debug, Clone, Copy, PartialEq)]
144pub struct Pair {
145    /// Indexes into [`Comparison::functions`]: `a` on side 0, `b` on side 1.
146    pub a: usize,
147    pub b: usize,
148    /// Cosine similarity of their vectors.
149    pub similarity: f32,
150    pub level: Level,
151    pub matched_by: MatchedBy,
152}
153
154/// The result of [`compare`].
155#[derive(Debug, Clone, Default)]
156pub struct Comparison {
157    /// The named functions of both sides, side 0 first, in source order.
158    pub functions: Vec<FunctionRef>,
159    /// Every pair, in the order of their side-0 function.
160    pub pairs: Vec<Pair>,
161    /// Calls within each side, `(caller, callee)` as indexes into
162    /// [`Self::functions`], each once (see [`call_graph`]).
163    pub calls: Vec<(usize, usize)>,
164}
165
166impl Comparison {
167    /// For each function of [`Self::functions`], whether it is ready to
168    /// port: it counts, it has no partner yet, and every function it calls
169    /// has one. Porting these first means no port waits for a helper. Two
170    /// unpaired functions that call each other wait for each other, so
171    /// neither is ready.
172    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    /// Whether the function at `index` of [`Self::functions`] has a partner.
188    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
198/// Pair the functions of `sides[0]` with those of `sides[1]`; see the
199/// module docs. The two sides must not share a file. Fails only when the
200/// embedder does.
201pub 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    // The calls need no model, so a port not started yet has them too: with
260    // nothing paired, they alone say which functions to port first.
261    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    // Step 1: the rule of --semantic, across the sides, between functions
278    // that count.
279    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        &params.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, &params.thresholds),
307            matched_by: MatchedBy::Code,
308        }
309    })
310    .collect();
311
312    // Step 2: namesakes among the functions left unpaired.
313    let mut paired = vec![false; functions.len()];
314    let mut linked_files: FxHashSet<(u32, u32)> = FxHashSet::default();
315    // Step-1 pairs per module and module of the other side, for tests and
316    // for code apart: tests often sit in folders of their own (`tests/`),
317    // and their pairs must not decide which code modules match.
318    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    // Whether `other` holds the most pairs of `module` (ties count).
329    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, &params.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        // Linked files first, then the most similar; a tie goes to the
373        // earlier functions.
374        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, &params.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
403/// A called name that more functions of one side carry than this is too
404/// common to tell which of them is meant (`render`, `update`), so the call
405/// resolves to none of them, unless one is in the caller's own file.
406const MAX_CALL_TARGETS: usize = 3;
407
408/// The calls between the functions of each side, `(caller, callee)` as
409/// indexes into `items`, found by name the way `--semantic` finds them (see
410/// [`call_pairs`]): a name followed by `(` in the caller's code calls the
411/// functions of the caller's side that carry that name, in a language the
412/// caller's language calls into. A function in the caller's own file wins
413/// over the others; past [`MAX_CALL_TARGETS`] candidates the call is left
414/// out. A test's title is no name, so nothing calls a test.
415fn 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            // Its own name is the header or recursion, except for a test,
437            // whose name is a title.
438            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
467/// The module of each of `files`, the paths of one side: the folder right
468/// under the deepest folder they share, or `""` for a file in that folder
469/// itself. Files that sit higher than the rest, such as a build script next
470/// to `app/src/main/java/<module>/…`, do not pull the shared folder up to
471/// them: they get `""`, and the others their modules below.
472fn 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    // The length of the folder all of `among` share.
482    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        // Files in the shared folder itself step aside when the rest go
500        // down one more shared folder.
501        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
522/// A function name with case, underscores, spaces and punctuation ignored,
523/// so the names one function gets in different languages meet:
524/// `encodeBinary`, `encode_binary`, `_encode_binary` and `EncodeBinary` are
525/// all `encodebinary`. For a `test`, a leading `test` marker goes too, the
526/// mark of a test in pytest, Go, XCTest and JUnit 3 that a JavaScript test
527/// title does not carry: `test_rounds_cents`, `TestRoundsCents` and the
528/// test titled `rounds cents` are all `roundscents`. The marker is `test`
529/// followed by `_` or a capital, so the title `tests the rounding` keeps
530/// its words, and a code function such as `testConnection` keeps its name.
531pub 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
542/// `name` without a leading `test_`, `test` before a capital, or `Test`.
543fn 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    /// A function on lines `line..line + lines` of `tokens` tokens whose
564    /// text starts with the key of its vector.
565    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    /// Embeds a text as the vector registered for its first word.
594    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    /// A unit vector along `axis` blended with `noise` of `noise_axis`.
612    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    /// Unrelated functions on both sides, so every background is big
620    /// enough for a z-score.
621    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    /// An embedder knowing `named` and the fillers of both sides, which lie
634    /// mostly on axes of their own.
635    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    /// The names of each pair, side 0 first, and how it was found.
651    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        // The Python version is under --min-tokens, so step 1 does not see
723        // it; the name and the similarity pair it.
724        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        // Two `toString`s per side, the Kotlin ones too short for step 1:
777        // each pairs with the one it resembles.
778        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        // Vectors by position: the table keys on the first word, so give
803        // each function a text of its own.
804        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        // Two plugins per side. `load` pairs by code in both, which links
857        // android/camera with ios/camera and android/nfc with ios/nfc; the
858        // short `checkPermissions` of the nfc plugin pairs with its own
859        // namesake, even though camera's is more similar, and camera's
860        // `status`, alone on the Android side, finds no partner in nfc.
861        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        // Distinct texts for the namesakes, keyed by file.
902        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        // android/notify pairs by code twice with ios/notify and once with
959        // ios/scan, whose own pairs go mostly to android/scan. The `status`
960        // of android/notify has a namesake only in ios/scan, which neither
961        // module pairs with most, so the two stay unpaired.
962        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        // A build script next to the app does not merge the modules.
1042        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        // The Python test is closer to the Java code than to the Java
1056        // test, but tests and code never pair: the tests pair with each
1057        // other, the Java code stays unpaired.
1058        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    /// `unit` with `text` for its code.
1094    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            // 0: calls a helper in its own file, one in another file, a
1114            // name five files carry, and itself.
1115            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        // `render` is in four files: too common, no edge.
1127        let calls = call_graph(&items, |item| &units[item.unit], |_| 0);
1128        assert_eq!(calls, vec![(0, 1), (0, 2)]);
1129        // On another side nothing resolves.
1130        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        // The target is empty, so nothing pairs; the function that calls
1165        // nothing is ready and its caller waits for it.
1166        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        // Side 0: 0 calls 1 (ported) -> ready; 2 calls 3 (not ported) ->
1196        // blocked; 3 calls nothing -> ready; 4 and 5 call each other ->
1197        // neither; 6 is too small to count.
1198        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        // CodeRankEmbed: 0.4125 across, 0.6375 within, group floor 0.7125.
1221        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        // Within one language the middle moves up with the threshold.
1233        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        // jina-embeddings-v2-base-code: the group floor is 0.8.
1237        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        // Test titles, as the JavaScript extractor names test callbacks.
1256        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        // Code keeps its `test`: `testConnection` is not `connection`.
1267        assert_eq!(name_key("testConnection", false), "testconnection");
1268        assert_eq!(name_key("handles `null` input!", true), "handlesnullinput");
1269    }
1270}