Skip to main content

cpd_similarity/
lib.rs

1//! Structural similarity of functions (`--similarity`).
2//!
3//! Every function and method is parsed and its syntax tree normalized: the
4//! names of the functions and methods it calls stay, and so do its
5//! operators, while local names, field names and literals become markers and
6//! comments, punctuation and parentheses around one expression drop out.
7//! Each subtree of the normalized tree is a fingerprint ([`prints`]), and
8//! two units score the Jaccard index of their fingerprint sets: shared over
9//! all. A renamed copy scores 1, a copy with an edited statement loses the
10//! fingerprints of that statement and of the subtrees around it.
11//!
12//! A name has to stand for one name on the other side (issue #1217): the
13//! fingerprint of a subtree tells how its names repeat, so `x = x + x * x`
14//! loses the fingerprints `d = a + b * c` has, and a copy that swaps two
15//! names somewhere loses those of the subtrees that hold both places. With
16//! `--ignore-identifiers=blind` any name matches any other, as in 5.4.
17//!
18//! The units of each language ([`syntax`], [`clojure`]):
19//!
20//! - Python: functions and methods; a function in a function is part of it,
21//!   a method of a class declared in a function is a unit of its own;
22//! - JavaScript and TypeScript: functions and methods with a body outside
23//!   every function, and the variables and fields a function is assigned to;
24//! - Java: methods with a body, not those of classes declared in a method;
25//! - Kotlin, Scala, C#, C, C++, PHP, Ruby and Swift: functions and methods
26//!   with a body outside every function, constructors left out;
27//! - Go: functions and methods with a body;
28//! - Rust: functions with a body, not those in a function or a `mod tests`;
29//! - Clojure: every top-level form except `ns`;
30//!
31//! and the same in the code blocks of Markdown files and the scripts of Vue,
32//! Svelte and Astro components. Units pair within one language, JavaScript
33//! and TypeScript being one, and C and C++ another. Test files are left out
34//! ([`reads`]).
35//!
36//! The search is exact ([`join`]): every pair whose score reaches the
37//! threshold is found.
38
39mod clojure;
40mod join;
41mod prints;
42mod syntax;
43pub mod test_files;
44
45pub use join::jaccard;
46pub use prints::Names;
47/// Shared with `--semantic`, which names C and C++ functions the same way.
48pub use syntax::declared_name;
49
50use cpd_core::detect::{PathFilters, PreparedSource};
51use cpd_core::models::{
52    CloneKind, CpdClone, Fragment, Location, SimilarityMethod, StructuralMatch,
53};
54use cpd_tokenizer::line_index::LineIndex;
55use rustc_hash::FxHashMap;
56use std::collections::hash_map::Entry;
57use std::path::Path;
58
59/// The threshold `--similarity` compares at when it is given without one.
60pub const DEFAULT_THRESHOLD: f64 = 0.8;
61
62/// The fewest normalized nodes a unit has to have to be compared
63/// (`--min-nodes`): smaller ones match too easily.
64pub const DEFAULT_MIN_NODES: u32 = 20;
65
66/// The jscpd formats whose files hold units.
67const FORMATS: &[&str] = &[
68    "javascript",
69    "jsx",
70    "typescript",
71    "tsx",
72    "python",
73    "java",
74    "kotlin",
75    "scala",
76    "csharp",
77    "go",
78    "rust",
79    "c",
80    "c-header",
81    "cpp",
82    "cpp-header",
83    "php",
84    "ruby",
85    "swift",
86    "clojure",
87];
88
89/// Whether files of `format` hold units.
90pub fn supports(format: &str) -> bool {
91    FORMATS.contains(&format)
92}
93
94/// The formats whose files hold units, for messages and docs.
95pub fn supported_formats() -> &'static [&'static str] {
96    FORMATS
97}
98
99/// Formats whose files embed code with units in it: Markdown with its code
100/// blocks, and Vue, Svelte and Astro components with their scripts.
101pub fn embeds(format: &str) -> bool {
102    matches!(format, "markdown" | "md" | "vue" | "svelte" | "astro")
103}
104
105/// Whether `--similarity` reads the file at `path`, of `format`: a file of a
106/// format with units or one that embeds them, not a test file. Of the
107/// Python formats it reads `.py`, of the Clojure ones the source files, not
108/// EDN data, and no TypeScript declaration file.
109pub fn reads(path: &Path, format: &str) -> bool {
110    !is_test_file(path) && reads_with_tests(path, format)
111}
112
113/// [`reads`], test files included: the boundary rules of a config file
114/// count a copy in a test as a copy.
115pub fn reads_with_tests(path: &Path, format: &str) -> bool {
116    if embeds(format) {
117        return true;
118    }
119    let name = path
120        .file_name()
121        .and_then(|name| name.to_str())
122        .unwrap_or_default();
123    let extension = path
124        .extension()
125        .and_then(|e| e.to_str())
126        .unwrap_or_default()
127        .to_ascii_lowercase();
128    match format {
129        // A `.pyi` stub declares and does not implement; a script without an
130        // extension is read.
131        "python" => extension != "pyi",
132        // `.edn` is data.
133        "clojure" => extension != "edn",
134        "typescript" => !name.ends_with(".d.ts"),
135        _ => supports(format),
136    }
137}
138
139/// A test file, by the conventions of [`test_files::is_test_path`], or a
140/// pytest `conftest.py`.
141fn is_test_file(path: &Path) -> bool {
142    test_files::is_test_path(path)
143        || path.file_name().and_then(|name| name.to_str()) == Some("conftest.py")
144}
145
146/// The language a unit of `format` is written in.
147fn language(format: &str) -> &'static str {
148    match format {
149        "javascript" | "jsx" => "javascript",
150        "typescript" | "tsx" => "typescript",
151        "c" | "c-header" => "c",
152        "cpp" | "cpp-header" => "cpp",
153        "python" => "python",
154        "java" => "java",
155        "kotlin" => "kotlin",
156        "scala" => "scala",
157        "csharp" => "csharp",
158        "go" => "go",
159        "rust" => "rust",
160        "php" => "php",
161        "ruby" => "ruby",
162        "swift" => "swift",
163        "clojure" => "clojure",
164        _ => "",
165    }
166}
167
168/// The group a unit of `format` pairs within: JavaScript pairs with
169/// TypeScript and C with C++, read by one grammar or nearly one.
170fn group(format: &str) -> &'static str {
171    match language(format) {
172        "javascript" => "typescript",
173        "c" => "cpp",
174        other => other,
175    }
176}
177
178/// A unit: a function, a method, a Clojure form.
179#[derive(Debug, Clone, PartialEq)]
180pub struct Form {
181    /// Its name, or a stand-in such as `<anonymous>`.
182    pub name: String,
183    pub start: Location,
184    /// Where it ends: on its last line.
185    pub end: Location,
186    /// Lists and atoms in its normalized tree.
187    pub nodes: u32,
188    /// Its fingerprints, sorted.
189    pub fingerprints: Vec<u64>,
190    /// The detection tokens inside it, as an inclusive range into the
191    /// tokens of its source, once [`FormSource::new`] knows them.
192    pub range: [u32; 2],
193    pub tokens: u32,
194}
195
196impl Form {
197    /// The lines it spans, in jscpd's `end - start` convention that
198    /// `--min-lines` reads.
199    pub fn line_span(&self) -> u32 {
200        self.end.line.saturating_sub(self.start.line)
201    }
202}
203
204/// The units of `source`, a file of `format`, without the code in its
205/// `jscpd:ignore` blocks and in the byte ranges `ignored` (the matches of
206/// `--ignore-pattern`): a unit that lies in such code is none, and such code
207/// adds nothing to the units around it. Their names compare by `names`.
208pub fn forms(source: &str, format: &str, ignored: &[[usize; 2]], names: Names) -> Vec<Form> {
209    if source.is_empty() || !supports(format) {
210        return Vec::new();
211    }
212    let mut ranges = cpd_tokenizer::tokenizer::ignored_ranges(format, source);
213    ranges.extend_from_slice(ignored);
214    let ignored = cpd_tokenizer::tokenizer::merge_ranges(ranges);
215    let index = LineIndex::new(source.as_bytes());
216    let form = |name: String, start: usize, end: usize, nodes: u32, fingerprints: Vec<u64>| Form {
217        name,
218        start: index.location(start),
219        end: end_location(&index, source, start, end),
220        nodes,
221        fingerprints,
222        range: [0, 0],
223        tokens: 0,
224    };
225    if format == "clojure" {
226        return clojure::forms(source, &ignored, names)
227            .into_iter()
228            .map(|f| form(f.name, f.start_byte, f.end_byte, f.nodes, f.fingerprints))
229            .collect();
230    }
231    let Some(grammar) = syntax::Grammar::for_format(format) else {
232        return Vec::new();
233    };
234    syntax::forms(source, grammar, &ignored, names)
235        .into_iter()
236        .map(|f| form(f.name, f.start_byte, f.end_byte, f.nodes, f.fingerprints))
237        .collect()
238}
239
240/// Where a unit spanning `start..end` ends: at its last byte when it ends
241/// with a line break, so that it ends on the line of its code.
242fn end_location(index: &LineIndex, source: &str, start: usize, end: usize) -> Location {
243    match end > start && source.as_bytes().get(end - 1) == Some(&b'\n') {
244        true => index.location(end - 1),
245        false => index.location(end),
246    }
247}
248
249/// The units of the code `source`, a file of `host_format`, embeds, each
250/// with the format of its block: the code blocks of a Markdown file, the
251/// scripts and frontmatter of a component. Positions are the host's, so a
252/// unit reports where it sits in the `.md` or `.vue` file. `ignored` are
253/// byte ranges of the host; `names` as in [`forms`].
254pub fn embedded_forms(
255    source: &str,
256    host_format: &str,
257    ignored: &[[usize; 2]],
258    names: Names,
259) -> Vec<(String, Form)> {
260    let blocks = match host_format {
261        "markdown" | "md" => cpd_tokenizer::markdown::code_blocks(source),
262        "vue" | "svelte" | "astro" => cpd_tokenizer::sfc::script_blocks(source, host_format),
263        _ => return Vec::new(),
264    };
265    let host = LineIndex::new(source.as_bytes());
266    let mut out = Vec::new();
267    for (format, range) in blocks {
268        if !supports(&format) {
269            continue;
270        }
271        let local = cpd_tokenizer::tokenizer::ranges_in(ignored, range.clone());
272        let place = |location: &Location| host.location(range.start + location.offset as usize);
273        for mut form in forms(&source[range.clone()], &format, &local, names) {
274            form.start = place(&form.start);
275            form.end = place(&form.end);
276            out.push((format.clone(), form));
277        }
278    }
279    out
280}
281
282/// The detection tokens lying inside the byte range `start..end` of a
283/// source, as a half-open index range into its `spans`; `None` when there
284/// are none.
285pub fn token_range(
286    spans: &[(Location, Location)],
287    start: &Location,
288    end: &Location,
289) -> Option<(usize, usize)> {
290    let first = spans.partition_point(|(s, _)| s.offset < start.offset);
291    let last = spans.partition_point(|(_, e)| e.offset <= end.offset);
292    (first < last).then_some((first, last))
293}
294
295/// The units of one source, as the search reads them.
296#[derive(Debug, Clone, PartialEq)]
297pub struct FormSource {
298    pub id: String,
299    pub format: String,
300    /// Canonical path of the file behind a symlink; empty when it is `id`.
301    /// `--skip-isolated` reads it as it does for token clones.
302    pub real_path: String,
303    pub forms: Vec<Form>,
304}
305
306impl FormSource {
307    /// The units `forms` of `prepared`, a source a scan prepared for
308    /// detection, with the detection tokens each holds.
309    pub fn new(prepared: &PreparedSource, forms: Vec<Form>) -> Self {
310        Self::with_spans(
311            prepared.id.clone(),
312            prepared.format.clone(),
313            prepared.real_path.clone(),
314            forms,
315            &prepared.spans,
316        )
317    }
318
319    /// The units `forms` of a source whose detection tokens span `spans`.
320    pub fn with_spans(
321        id: String,
322        format: String,
323        real_path: String,
324        mut forms: Vec<Form>,
325        spans: &[(Location, Location)],
326    ) -> Self {
327        for form in &mut forms {
328            if let Some((first, last)) = token_range(spans, &form.start, &form.end) {
329                form.range = [first as u32, (last - 1) as u32];
330                form.tokens = (last - first) as u32;
331            }
332        }
333        Self {
334            id,
335            format,
336            real_path,
337            forms,
338        }
339    }
340}
341
342/// What a search finds: the pairs to report as clones and, when the search
343/// is asked for them, every pair.
344#[derive(Debug, Default)]
345pub struct SimilarPairs {
346    /// Every pair whose score reaches the threshold, most similar first, the
347    /// ones the clones of the token passes cover too: the `edn` report lists
348    /// them. Empty unless the search was asked to keep them.
349    pub all: Vec<CpdClone>,
350    /// The pairs that link look-alike units, in the order of their
351    /// positions: clones of their own. A group of look-alikes is reported as
352    /// the pairs that connect it, each unit with its closest match, as the
353    /// token passes pair every copy of a fragment with the first one; units
354    /// the clones of the token passes connect already are left out.
355    pub reported: Vec<CpdClone>,
356}
357
358/// How many units of a group of look-alikes a unit is linked with when not
359/// every pair is kept: enough for the group to stay connected when
360/// `--skip-local` or `--skip-isolated` drops some of its pairs.
361const LINKS: usize = 8;
362
363/// A pair of indexed units, each its (source, unit), with the fingerprints
364/// they share and the ones they have in all.
365#[derive(Debug, Clone, Copy)]
366struct Edge {
367    a: (usize, usize),
368    b: (usize, usize),
369    shared: u32,
370    total: u32,
371}
372
373/// The units of many sources, to pair among themselves or to search for a
374/// source outside them, such as a snippet. Owns its sources so a long-lived
375/// holder (the MCP server) can build it once per scan.
376pub struct SimilarityIndex {
377    sources: Vec<FormSource>,
378    /// The units large enough to compare: (source, unit) by group.
379    groups: FxHashMap<&'static str, Vec<(usize, usize)>>,
380    min_nodes: u32,
381    min_lines: u32,
382}
383
384impl SimilarityIndex {
385    /// Index every unit of at least `min_nodes` nodes spanning at least
386    /// `min_lines` lines.
387    pub fn build(sources: Vec<FormSource>, min_nodes: u32, min_lines: u32) -> Self {
388        let mut groups: FxHashMap<&'static str, Vec<(usize, usize)>> = FxHashMap::default();
389        for (si, source) in sources.iter().enumerate() {
390            let group = group(&source.format);
391            for (fi, form) in source.forms.iter().enumerate() {
392                if eligible(form, min_nodes, min_lines) {
393                    groups.entry(group).or_default().push((si, fi));
394                }
395            }
396        }
397        Self {
398            sources,
399            groups,
400            min_nodes,
401            min_lines,
402        }
403    }
404
405    /// The indexed sources, in the order they were given.
406    pub fn sources(&self) -> &[FormSource] {
407        &self.sources
408    }
409
410    /// The pairs of indexed units whose score reaches `threshold`: the ones
411    /// to report, and every one of them when `keep_all` is set. The pairs
412    /// that `filters` drops for token clones (`--skip-local`,
413    /// `--skip-isolated`) are left out, and so is a pair of units one of
414    /// which holds the other. The clones in `existing` connect the units
415    /// they cover.
416    pub fn pairs(
417        &self,
418        threshold: f64,
419        existing: &[CpdClone],
420        filters: &PathFilters,
421        keep_all: bool,
422    ) -> SimilarPairs {
423        let mut groups: Vec<(&&str, &Vec<(usize, usize)>)> = self.groups.iter().collect();
424        groups.sort_by_key(|(group, _)| **group);
425        let mut edges = Vec::new();
426        for (_, items) in groups {
427            self.group_edges(items, threshold, filters, keep_all, &mut edges);
428        }
429        edges.sort_by(|x, y| self.edge_order(x, y));
430        let reported = self.links(&edges, &Coverage::new(existing));
431        let mut all: Vec<CpdClone> = match keep_all {
432            true => edges.iter().map(|edge| self.clone_of(edge)).collect(),
433            false => Vec::new(),
434        };
435        all.sort_by(report_order);
436        SimilarPairs { all, reported }
437    }
438
439    /// The pairs of `items`, the units of one group, whose score reaches
440    /// `threshold`. Units with the same fingerprints score 1.0 with each
441    /// other, so the search runs on one of each and the pairs of the others
442    /// follow from it: every pair when `keep_all` is set, else the links to
443    /// a few of them.
444    fn group_edges(
445        &self,
446        items: &[(usize, usize)],
447        threshold: f64,
448        filters: &PathFilters,
449        keep_all: bool,
450        edges: &mut Vec<Edge>,
451    ) {
452        let set = |i: usize| {
453            let (si, fi) = items[i];
454            self.sources[si].forms[fi].fingerprints.as_slice()
455        };
456        let mut first: FxHashMap<&[u64], usize> = FxHashMap::default();
457        let mut copies: Vec<Vec<usize>> = Vec::new();
458        for i in 0..items.len() {
459            match first.entry(set(i)) {
460                Entry::Occupied(entry) => copies[*entry.get()].push(i),
461                Entry::Vacant(entry) => {
462                    entry.insert(copies.len());
463                    copies.push(vec![i]);
464                }
465            }
466        }
467        let linked = |n: usize| match keep_all {
468            true => n,
469            false => n.min(LINKS),
470        };
471        let mut add = |x: usize, y: usize, shared: u32, total: u32| {
472            let (a, b) = (items[x], items[y]);
473            if self.apart(a, b, filters) {
474                edges.push(Edge {
475                    a,
476                    b,
477                    shared,
478                    total,
479                });
480            }
481        };
482        for same in &copies {
483            let size = set(same[0]).len() as u32;
484            for (i, &x) in same.iter().enumerate().skip(1) {
485                for &y in &same[..linked(i)] {
486                    add(y, x, size, size);
487                }
488            }
489        }
490        let distinct: Vec<&[u64]> = copies.iter().map(|same| set(same[0])).collect();
491        for found in join::pairs(&distinct, threshold) {
492            let (xs, ys) = (&copies[found.a], &copies[found.b]);
493            for &x in xs {
494                for &y in &ys[..linked(ys.len())] {
495                    add(x, y, found.shared, found.total);
496                }
497            }
498        }
499    }
500
501    /// Whether two units can pair: they are not the same span or one inside
502    /// the other in one file, and `filters` keeps them.
503    fn apart(
504        &self,
505        (sa, fa): (usize, usize),
506        (sb, fb): (usize, usize),
507        filters: &PathFilters,
508    ) -> bool {
509        let (src_a, src_b) = (&self.sources[sa], &self.sources[sb]);
510        let (a, b) = (&src_a.forms[fa], &src_b.forms[fb]);
511        !same_span(src_a, a, src_b, b)
512            && !nested(src_a, a, src_b, b)
513            && !filters
514                .should_skip_sources((&src_a.id, &src_a.real_path), (&src_b.id, &src_b.real_path))
515    }
516
517    /// Most similar first, then by the places of the two units, so the links
518    /// chosen do not depend on the order of the search.
519    fn edge_order(&self, x: &Edge, y: &Edge) -> std::cmp::Ordering {
520        let product = |e: &Edge, f: &Edge| u64::from(e.shared) * u64::from(f.total);
521        product(y, x)
522            .cmp(&product(x, y))
523            .then_with(|| self.ends(x).cmp(&self.ends(y)))
524    }
525
526    /// The two units of a pair as their source and offset, the first first.
527    fn ends(&self, edge: &Edge) -> ((&str, u32), (&str, u32)) {
528        let end = |(si, fi): (usize, usize)| {
529            let source = &self.sources[si];
530            (source.id.as_str(), source.forms[fi].start.offset)
531        };
532        let (a, b) = (end(edge.a), end(edge.b));
533        match a <= b {
534            true => (a, b),
535            false => (b, a),
536        }
537    }
538
539    /// The pairs of `edges`, most similar first, that connect two units
540    /// neither an earlier pair nor a clone in `coverage` connects: each unit
541    /// with its closest match, and no pair a token clone already implies.
542    fn links(&self, edges: &[Edge], coverage: &Coverage) -> Vec<CpdClone> {
543        let mut base = Vec::with_capacity(self.sources.len());
544        let mut units = 0;
545        for source in &self.sources {
546            base.push(units);
547            units += source.forms.len();
548        }
549        let id = |(si, fi): (usize, usize)| base[si] + fi;
550        let span = |(si, fi): (usize, usize)| {
551            let source = &self.sources[si];
552            let form = &source.forms[fi];
553            (source.id.as_str(), form.start.line, form.end.line)
554        };
555        let mut parent: Vec<usize> = (0..units).collect();
556        for edge in edges {
557            if coverage.covers(span(edge.a), span(edge.b)) {
558                join_sets(&mut parent, id(edge.a), id(edge.b));
559            }
560        }
561        let mut reported: Vec<CpdClone> = edges
562            .iter()
563            .filter(|edge| join_sets(&mut parent, id(edge.a), id(edge.b)))
564            .map(|edge| self.clone_of(edge))
565            .collect();
566        reported.sort_by(|x, y| x.position_key().cmp(&y.position_key()));
567        reported
568    }
569
570    fn clone_of(&self, edge: &Edge) -> CpdClone {
571        let (src_a, src_b) = (&self.sources[edge.a.0], &self.sources[edge.b.0]);
572        let (a, b) = (&src_a.forms[edge.a.1], &src_b.forms[edge.b.1]);
573        make_clone(src_a, a, src_b, b, edge.shared, edge.total)
574    }
575
576    /// The indexed units whose score with a unit of `source`, a source
577    /// outside the index such as a snippet, reaches `threshold`, as clones,
578    /// most similar first. A pair the clones in `existing` cover is left out.
579    pub fn query_clones(
580        &self,
581        source: &FormSource,
582        threshold: f64,
583        existing: &[CpdClone],
584    ) -> Vec<CpdClone> {
585        let coverage = Coverage::new(existing);
586        let mut clones = Vec::new();
587        let Some(items) = self.groups.get(group(&source.format)) else {
588            return clones;
589        };
590        for form in &source.forms {
591            if !eligible(form, self.min_nodes, self.min_lines) {
592                continue;
593            }
594            let size = form.fingerprints.len() as f64;
595            for &(si, fi) in items {
596                let other = &self.sources[si];
597                let found = &other.forms[fi];
598                let other_size = found.fingerprints.len() as f64;
599                if other_size < threshold * size - 1e-9 || size < threshold * other_size - 1e-9 {
600                    continue;
601                }
602                let (shared, total) = join::overlap(&form.fingerprints, &found.fingerprints);
603                if (shared as f64 / total as f64) < threshold {
604                    continue;
605                }
606                let clone = make_clone(source, form, other, found, shared, total);
607                if !coverage.covers_clone(&clone) {
608                    clones.push(clone);
609                }
610            }
611        }
612        clones.sort_by(report_order);
613        clones
614    }
615}
616
617fn eligible(form: &Form, min_nodes: u32, min_lines: u32) -> bool {
618    form.nodes >= min_nodes && form.line_span() >= min_lines
619}
620
621/// Two units of one file over the same lines: Clojure forms on one line.
622fn same_span(src_a: &FormSource, a: &Form, src_b: &FormSource, b: &Form) -> bool {
623    src_a.id == src_b.id && a.start.line == b.start.line && a.end.line == b.end.line
624}
625
626/// Two units of one file one of which holds the other, such as a function
627/// and a method of a class declared in it.
628fn nested(src_a: &FormSource, a: &Form, src_b: &FormSource, b: &Form) -> bool {
629    let holds =
630        |x: &Form, y: &Form| x.start.offset <= y.start.offset && y.end.offset <= x.end.offset;
631    src_a.id == src_b.id && (holds(a, b) || holds(b, a))
632}
633
634/// The set `x` belongs to in the union-find `parent`.
635fn set_of(parent: &mut [usize], mut x: usize) -> usize {
636    while parent[x] != x {
637        parent[x] = parent[parent[x]];
638        x = parent[x];
639    }
640    x
641}
642
643/// Joins the sets of `x` and `y`; whether they were apart.
644fn join_sets(parent: &mut [usize], x: usize, y: usize) -> bool {
645    let (x, y) = (set_of(parent, x), set_of(parent, y));
646    if x == y {
647        return false;
648    }
649    parent[x.max(y)] = x.min(y);
650    true
651}
652
653/// The pairs of units in `sources` whose score reaches `threshold`, of at
654/// least `min_nodes` nodes and `min_lines` lines each (see
655/// [`SimilarityIndex::pairs`]).
656pub fn find_similar(
657    sources: Vec<FormSource>,
658    threshold: f64,
659    min_nodes: u32,
660    min_lines: u32,
661    existing: &[CpdClone],
662    filters: &PathFilters,
663    keep_all: bool,
664) -> SimilarPairs {
665    if sources.is_empty() {
666        return SimilarPairs::default();
667    }
668    SimilarityIndex::build(sources, min_nodes, min_lines)
669        .pairs(threshold, existing, filters, keep_all)
670}
671
672/// Most similar first, then by language, the first file and line, and the
673/// second file and line.
674fn report_order(x: &CpdClone, y: &CpdClone) -> std::cmp::Ordering {
675    let score = |c: &CpdClone| c.structure.as_ref().map_or(0.0, StructuralMatch::score);
676    let language = |c: &CpdClone| c.structure.as_ref().map(|s| s.language.clone());
677    score(y)
678        .total_cmp(&score(x))
679        .then_with(|| language(x).cmp(&language(y)))
680        .then_with(|| x.fragment_a.source_id.cmp(&y.fragment_a.source_id))
681        .then_with(|| x.fragment_a.start.line.cmp(&y.fragment_a.start.line))
682        .then_with(|| x.fragment_b.source_id.cmp(&y.fragment_b.source_id))
683        .then_with(|| x.fragment_b.start.line.cmp(&y.fragment_b.start.line))
684}
685
686fn make_clone(
687    src_a: &FormSource,
688    a: &Form,
689    src_b: &FormSource,
690    b: &Form,
691    shared: u32,
692    total: u32,
693) -> CpdClone {
694    let fragment = |src: &FormSource, f: &Form| Fragment {
695        source_id: src.id.clone(),
696        source_root: None,
697        start: f.start.clone(),
698        end: f.end.clone(),
699        range: f.range,
700        blame: None,
701    };
702    // The first fragment is the one of the first file, then the first one in
703    // it.
704    let a_first = (src_a.id.as_str(), a.start.offset) <= (src_b.id.as_str(), b.start.offset);
705    let ((src_a, a), (src_b, b)) = match a_first {
706        true => ((src_a, a), (src_b, b)),
707        false => ((src_b, b), (src_a, a)),
708    };
709    // A pair can join JavaScript and TypeScript, or C and C++: it is
710    // TypeScript or C++ then.
711    let languages = [language(&src_a.format), language(&src_b.format)];
712    let language = match languages {
713        [a, b] if a == b => a,
714        _ => group(&src_a.format),
715    };
716    CpdClone {
717        format: src_a.format.clone(),
718        fragment_a: fragment(src_a, a),
719        fragment_b: fragment(src_b, b),
720        token_count: a.tokens.min(b.tokens),
721        is_new: false,
722        kind: CloneKind::Similar,
723        similarity: Some((shared as f64 / total as f64) as f32),
724        similarity_method: Some(SimilarityMethod::Ast),
725        structure: Some(StructuralMatch {
726            language: language.to_string(),
727            nodes: [a.nodes, b.nodes],
728            shared,
729            total,
730        }),
731        unmatched_lines: [0, 0],
732        after_cut: false,
733    }
734}
735
736/// What the clones found so far report, indexed by source. A pair of
737/// functions is covered when one clone spans both, or when two clones copy
738/// one fragment into each of them: detection pairs every copy of a fragment
739/// with its first copy only, so with three copies the pair of the second and
740/// the third is implied and is not reported again as `similar`.
741pub struct Coverage<'a> {
742    /// For every source, the fragments of the clones in it, each with the
743    /// fragment it was copied with.
744    by_source: FxHashMap<&'a str, Vec<(&'a Fragment, &'a Fragment)>>,
745}
746
747impl<'a> Coverage<'a> {
748    pub fn new(existing: impl IntoIterator<Item = &'a CpdClone>) -> Self {
749        let mut by_source: FxHashMap<&'a str, Vec<(&'a Fragment, &'a Fragment)>> =
750            FxHashMap::default();
751        for clone in existing {
752            let (a, b) = (&clone.fragment_a, &clone.fragment_b);
753            by_source
754                .entry(a.source_id.as_str())
755                .or_default()
756                .push((a, b));
757            by_source
758                .entry(b.source_id.as_str())
759                .or_default()
760                .push((b, a));
761        }
762        Self { by_source }
763    }
764
765    /// Whether the clones cover the pair of functions `a` and `b`, each its
766    /// source id with its first and last line.
767    pub fn covers(&self, a: (&str, u32, u32), b: (&str, u32, u32)) -> bool {
768        // The fragments copied into a function: the partners of the clones
769        // that span at least 90% of its lines.
770        let partners = |(id, start, end): (&str, u32, u32)| {
771            self.by_source
772                .get(id)
773                .into_iter()
774                .flatten()
775                .filter(move |(here, _)| spans_lines(here, start, end))
776                .map(|(_, there)| *there)
777        };
778        let into_b: Vec<&Fragment> = partners(b).collect();
779        if into_b.is_empty() {
780            return false;
781        }
782        partners(a).any(|x| {
783            (x.source_id == b.0 && spans_lines(x, b.1, b.2))
784                || into_b
785                    .iter()
786                    .any(|y| x.source_id == y.source_id && overlap(x, y))
787        })
788    }
789
790    /// [`Self::covers`] for a pair of whole functions, such as one an index
791    /// found before these clones.
792    pub fn covers_clone(&self, pair: &CpdClone) -> bool {
793        let (a, b) = (&pair.fragment_a, &pair.fragment_b);
794        self.covers(
795            (&a.source_id, a.start.line, a.end.line),
796            (&b.source_id, b.start.line, b.end.line),
797        )
798    }
799}
800
801/// Leave out of the lines of each `--similarity` pair in `clones` the lines
802/// that the clones of the token passes there already hold in its files, so
803/// a line counts once. Statistics and the console read what remains (see
804/// [`CpdClone::matched_lines`]). Run it on the clones a report shows, after
805/// `--kind` drops any.
806pub fn discount_token_lines(clones: &mut [CpdClone]) {
807    let token_clone =
808        |c: &CpdClone| c.similarity_method != Some(SimilarityMethod::Ast) && !c.kind.is_semantic();
809    // The lines of each file that token clones hold, merged. A file is its
810    // path on disk: two scan roots can each hold an `index.js`.
811    let file = cpd_core::paths::resolve_fragment_path;
812    let mut held: FxHashMap<String, Vec<(u32, u32)>> = FxHashMap::default();
813    for clone in clones.iter().filter(|c| token_clone(c)) {
814        for f in [&clone.fragment_a, &clone.fragment_b] {
815            held.entry(file(f))
816                .or_default()
817                .push((f.start.line, f.end.line));
818        }
819    }
820    for ranges in held.values_mut() {
821        ranges.sort_unstable();
822        let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
823        for &(start, end) in ranges.iter() {
824            match merged.last_mut() {
825                Some(last) if start <= last.1.saturating_add(1) => last.1 = last.1.max(end),
826                _ => merged.push((start, end)),
827            }
828        }
829        *ranges = merged;
830    }
831    let shared = |f: &Fragment| -> u32 {
832        held.get(&file(f)).map_or(0, |ranges| {
833            ranges
834                .iter()
835                .map(|&(start, end)| {
836                    let (lo, hi) = (start.max(f.start.line), end.min(f.end.line));
837                    if lo <= hi { hi - lo + 1 } else { 0 }
838                })
839                .sum()
840        })
841    };
842    for clone in clones
843        .iter_mut()
844        .filter(|c| c.similarity_method == Some(SimilarityMethod::Ast))
845    {
846        clone.unmatched_lines = [shared(&clone.fragment_a), shared(&clone.fragment_b)];
847    }
848}
849
850/// Whether `frag` spans at least 90% of the lines `start..=end`.
851fn spans_lines(frag: &Fragment, start: u32, end: u32) -> bool {
852    let lo = frag.start.line.max(start);
853    let hi = frag.end.line.min(end);
854    if hi < lo {
855        return false;
856    }
857    let overlap = hi - lo + 1;
858    let span = end.saturating_sub(start) + 1;
859    overlap as f32 >= 0.9 * span as f32
860}
861
862/// Whether two fragments of one source share at least 90% of the lines of
863/// the shorter one.
864fn overlap(x: &Fragment, y: &Fragment) -> bool {
865    let lo = x.start.line.max(y.start.line);
866    let hi = x.end.line.min(y.end.line);
867    if hi < lo {
868        return false;
869    }
870    let shorter = x
871        .end
872        .line
873        .saturating_sub(x.start.line)
874        .min(y.end.line.saturating_sub(y.start.line))
875        + 1;
876    (hi - lo + 1) as f32 >= 0.9 * shorter as f32
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    fn source(id: &str, format: &str, code: &str) -> FormSource {
884        FormSource {
885            id: id.to_string(),
886            format: format.to_string(),
887            real_path: String::new(),
888            forms: forms(code, format, &[], Names::Consistent),
889        }
890    }
891
892    const ALPHA: &str = "def alpha(xs):\n    ys = filter(xs, 1)\n    total = 0\n    for y in ys:\n        total += y * 2\n    return map(total, inc)\n";
893    const BETA: &str = "def beta(items):\n    kept = filter(items, 5)\n    sum = 0\n    for k in kept:\n        sum += k * 2\n    return map(sum, dec)\n";
894    const GAMMA: &str = "def gamma(items):\n    kept = sorted(items, 5)\n    sum = 0\n    for k in kept:\n        sum -= k * 2\n    print(sum)\n    return map(sum, dec)\n";
895
896    #[test]
897    fn renamed_copies_pair_and_the_score_counts_shared_fingerprints() {
898        let sources = vec![
899            source("a.py", "python", ALPHA),
900            source("b.py", "python", BETA),
901            source("c.py", "python", GAMMA),
902        ];
903        let found = find_similar(
904            sources,
905            DEFAULT_THRESHOLD,
906            20,
907            3,
908            &[],
909            &PathFilters::default(),
910            true,
911        );
912        assert_eq!(found.all.len(), 1);
913        let pair = &found.all[0];
914        assert_eq!(pair.similarity, Some(1.0));
915        assert_eq!(pair.fragment_a.source_id, "a.py");
916        let structure = pair.structure.as_ref().unwrap();
917        assert_eq!(structure.language, "python");
918        assert_eq!(structure.shared, structure.total);
919        assert_eq!(found.reported.len(), 1);
920    }
921
922    #[test]
923    fn small_units_and_units_of_other_languages_do_not_pair() {
924        let ts = "function alpha(xs) {\n  const ys = filter(xs, 1);\n  let total = 0;\n  for (const y of ys) { total += y * 2; }\n  return map(total, inc);\n}\n";
925        let sources = vec![
926            source("a.py", "python", ALPHA),
927            source("a.ts", "typescript", ts),
928            source("b.js", "javascript", &ts.replace("alpha", "beta")),
929        ];
930        let found = find_similar(
931            sources.clone(),
932            DEFAULT_THRESHOLD,
933            20,
934            3,
935            &[],
936            &PathFilters::default(),
937            true,
938        );
939        assert_eq!(found.all.len(), 1);
940        assert_eq!(
941            found.all[0].structure.as_ref().unwrap().language,
942            "typescript"
943        );
944        let none = find_similar(
945            sources,
946            DEFAULT_THRESHOLD,
947            1000,
948            3,
949            &[],
950            &PathFilters::default(),
951            true,
952        );
953        assert!(none.all.is_empty());
954    }
955
956    #[test]
957    fn a_pair_a_token_clone_covers_is_found_but_not_reported() {
958        let sources = vec![
959            source("a.py", "python", ALPHA),
960            source("b.py", "python", BETA),
961        ];
962        let token_clone = CpdClone {
963            format: "python".into(),
964            fragment_a: Fragment::new(
965                "a.py",
966                Location::new(1, 0, 0),
967                Location::new(6, 0, 0),
968                [0, 40],
969            ),
970            fragment_b: Fragment::new(
971                "b.py",
972                Location::new(1, 0, 0),
973                Location::new(6, 0, 0),
974                [0, 40],
975            ),
976            token_count: 40,
977            is_new: false,
978            kind: CloneKind::Renamed,
979            similarity: None,
980            similarity_method: None,
981            structure: None,
982            unmatched_lines: [0, 0],
983            after_cut: false,
984        };
985        let found = find_similar(
986            sources,
987            DEFAULT_THRESHOLD,
988            20,
989            3,
990            &[token_clone],
991            &PathFilters::default(),
992            true,
993        );
994        assert_eq!(found.all.len(), 1);
995        assert!(found.reported.is_empty());
996    }
997
998    #[test]
999    fn a_snippet_finds_the_units_like_its_own() {
1000        let index = SimilarityIndex::build(
1001            vec![
1002                source("a.py", "python", ALPHA),
1003                source("c.py", "python", GAMMA),
1004            ],
1005            20,
1006            3,
1007        );
1008        let snippet = source("<snippet>", "python", BETA);
1009        let found = index.query_clones(&snippet, DEFAULT_THRESHOLD, &[]);
1010        assert_eq!(found.len(), 1);
1011        assert_eq!(found[0].fragment_b.source_id, "a.py");
1012    }
1013
1014    #[test]
1015    fn markdown_blocks_hold_units_at_their_place_in_the_host() {
1016        let md = format!("# Title\n\n```python\n{ALPHA}```\n");
1017        let found = embedded_forms(&md, "markdown", &[], Names::Consistent);
1018        assert_eq!(found.len(), 1);
1019        assert_eq!(found[0].0, "python");
1020        assert_eq!(found[0].1.start.line, 4);
1021        assert_eq!(found[0].1.end.line, 9);
1022    }
1023
1024    #[test]
1025    fn test_files_and_data_files_are_not_read() {
1026        assert!(reads(Path::new("src/app.py"), "python"));
1027        assert!(!reads(Path::new("tests/test_app.py"), "python"));
1028        assert!(!reads(Path::new("src/conftest.py"), "python"));
1029        assert!(!reads(Path::new("src/types.pyi"), "python"));
1030        assert!(!reads(Path::new("config.edn"), "clojure"));
1031        assert!(reads(Path::new("src/core.cljc"), "clojure"));
1032        assert!(!reads(Path::new("src/index.d.ts"), "typescript"));
1033        assert!(!reads(Path::new("src/app.test.ts"), "typescript"));
1034        assert!(!reads(
1035            Path::new("src/MyApp.Tests.Integration/CartFlow.cs"),
1036            "csharp"
1037        ));
1038        assert!(reads(
1039            Path::new("src/MSTest.TestAdapter/Execution.cs"),
1040            "csharp"
1041        ));
1042        assert!(reads(Path::new("docs/guide.md"), "markdown"));
1043        assert!(
1044            reads(Path::new("bin/deploy"), "python"),
1045            "a script without an extension"
1046        );
1047        assert!(reads(Path::new("src/core.cljx"), "clojure"));
1048    }
1049
1050    #[test]
1051    fn with_tests_a_test_file_is_read_and_data_still_is_not() {
1052        assert!(reads_with_tests(Path::new("tests/test_app.py"), "python"));
1053        assert!(reads_with_tests(Path::new("src/app.test.ts"), "typescript"));
1054        assert!(!reads_with_tests(Path::new("src/types.pyi"), "python"));
1055        assert!(!reads_with_tests(Path::new("config.edn"), "clojure"));
1056        assert!(!reads_with_tests(Path::new("src/index.d.ts"), "typescript"));
1057    }
1058
1059    #[test]
1060    fn a_unit_does_not_pair_with_one_nested_in_it() {
1061        let code = "def make_handler(config):\n    class Handler:\n        def handle(self, request):\n            check(request)\n            return send(config, request)\n    return Handler\n";
1062        let found = find_similar(
1063            vec![source("factory.py", "python", code)],
1064            0.01,
1065            1,
1066            0,
1067            &[],
1068            &PathFilters::default(),
1069            true,
1070        );
1071        assert!(found.all.is_empty(), "{:?}", found.all);
1072    }
1073
1074    #[test]
1075    fn a_group_of_look_alikes_is_reported_as_the_pairs_that_link_it() {
1076        let sources = || -> Vec<FormSource> {
1077            (0..5)
1078                .map(|i| {
1079                    let code = format!(
1080                        "def f{i}(order):\n    total = price(order) * 2\n    return round(total, 2)\n"
1081                    );
1082                    source(&format!("{i}.py"), "python", &code)
1083                })
1084                .collect()
1085        };
1086        let found = find_similar(
1087            sources(),
1088            DEFAULT_THRESHOLD,
1089            1,
1090            0,
1091            &[],
1092            &PathFilters::default(),
1093            true,
1094        );
1095        assert_eq!(found.all.len(), 10, "every pair of the five copies");
1096        assert_eq!(found.reported.len(), 4, "each copy linked once");
1097        let found = find_similar(
1098            sources(),
1099            DEFAULT_THRESHOLD,
1100            1,
1101            0,
1102            &[],
1103            &PathFilters::default(),
1104            false,
1105        );
1106        assert!(found.all.is_empty());
1107        assert_eq!(found.reported.len(), 4);
1108    }
1109}