Skip to main content

_diffctx/edges/semantic/
jvm.rs

1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::edge_weights::SEMANTIC_DISCOVERY;
8use crate::config::extensions::{
9    JAVA_EXTENSIONS, JVM_EXTENSIONS, KOTLIN_EXTENSIONS, SCALA_EXTENSIONS,
10};
11use crate::config::weights::EDGE_WEIGHTS;
12use crate::types::{Fragment, FragmentId, FragmentKind};
13
14use super::super::EdgeDict;
15use super::super::base::{self, EdgeBuilder, add_edge};
16
17/// Same ambiguity bar as `CFamilySemanticWeights::max_files_per_name`: a name
18/// resolving to more files than this names a vocabulary word, not a
19/// dependency, and is skipped outright rather than truncated.
20const MAX_FILES_PER_NAME: usize = 8;
21
22fn is_jvm_file(path: &Path) -> bool {
23    let ext = base::file_ext(path);
24    JVM_EXTENSIONS.contains(ext.as_str())
25}
26
27fn is_java(path: &Path) -> bool {
28    let ext = base::file_ext(path);
29    JAVA_EXTENSIONS.contains(ext.as_str())
30}
31
32fn is_kotlin(path: &Path) -> bool {
33    let ext = base::file_ext(path);
34    KOTLIN_EXTENSIONS.contains(ext.as_str())
35}
36
37fn is_scala(path: &Path) -> bool {
38    let ext = base::file_ext(path);
39    SCALA_EXTENSIONS.contains(ext.as_str())
40}
41
42static KOTLIN_IMPORT_RE: Lazy<Regex> = Lazy::new(|| {
43    Regex::new(r"(?m)^\s*import\s+([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*(?:\.[A-Z]\w*|\.\*)?)")
44        .unwrap()
45});
46static KOTLIN_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
47    Regex::new(r"(?m)^\s*(?:\w+\s+)*(?:class|interface|object|enum)\s+([A-Z]\w*)").unwrap()
48});
49static JAVA_IMPORT_RE: Lazy<Regex> = Lazy::new(|| {
50    Regex::new(r"(?m)^\s*import\s+(?:static\s+)?([a-z][a-z0-9_.]*(?:\.\*)?)\s*;").unwrap()
51});
52static JAVA_PACKAGE_RE: Lazy<Regex> =
53    Lazy::new(|| Regex::new(r"(?m)^\s*package\s+([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*)").unwrap());
54static JAVA_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
55    Regex::new(r"(?m)^\s*(?:\w+\s+)*(?:class|interface|enum|@interface)\s+([A-Z]\w*)").unwrap()
56});
57static SCALA_IMPORT_LINE_RE: Lazy<Regex> =
58    Lazy::new(|| Regex::new(r"(?m)^\s*import\s+(.+)$").unwrap());
59static SCALA_PACKAGE_RE: Lazy<Regex> =
60    Lazy::new(|| Regex::new(r"(?m)^\s*package\s+([A-Za-z_][\w.]*)").unwrap());
61static SCALA_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
62    Regex::new(r"(?m)^\s*(?:\w+\s+)*(?:class|trait|object|enum)\s+([A-Z]\w*)").unwrap()
63});
64static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
65static ANNOTATION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"@([A-Z]\w*)").unwrap());
66static MEMBER_USE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.\s*([a-zA-Z_]\w{2,})").unwrap());
67static KOTLIN_EXTENDS_RE: Lazy<Regex> = Lazy::new(|| {
68    Regex::new(r"(?:class|interface|object)\s+\w+(?:<[^>]*>)?(?:\([^)]*\))?\s*:\s*([^{]+)").unwrap()
69});
70static JAVA_EXTENDS_RE: Lazy<Regex> = Lazy::new(|| {
71    Regex::new(r"(?m)\b(?:extends|implements)\s+([A-Z]\w*(?:\s*,\s*[A-Z]\w*)*)").unwrap()
72});
73static SCALA_EXTENDS_RE: Lazy<Regex> =
74    Lazy::new(|| Regex::new(r"(?m)\b(?:extends|with)\s+([A-Z]\w*)").unwrap());
75
76static JVM_STDLIB_TYPES: Lazy<FxHashSet<&str>> = Lazy::new(|| {
77    [
78        "String",
79        "Integer",
80        "Long",
81        "Double",
82        "Float",
83        "Boolean",
84        "Byte",
85        "Short",
86        "Character",
87        "Object",
88        "Class",
89        "System",
90        "Math",
91        "Collections",
92        "Arrays",
93        "Optional",
94        "HashMap",
95        "ArrayList",
96        "LinkedList",
97        "Iterator",
98        "Iterable",
99        "Comparable",
100        "Runnable",
101        "Thread",
102        "Exception",
103        "RuntimeException",
104        "IllegalArgumentException",
105        "IllegalStateException",
106        "NullPointerException",
107        "IndexOutOfBoundsException",
108        "IOException",
109        "InputStream",
110        "OutputStream",
111        "StringBuilder",
112        "StringBuffer",
113        "Number",
114        "Enum",
115        "Void",
116        "Override",
117        "Unit",
118        "Any",
119        "AnyVal",
120        "AnyRef",
121        "Nothing",
122        "Option",
123        "Some",
124        "Either",
125        "Left",
126        "Right",
127        "Try",
128        "Success",
129        "Failure",
130        "Future",
131        "Promise",
132        "Seq",
133        "Vector",
134        "Map",
135        "Set",
136        "Tuple",
137        "Function",
138        "Product",
139        "Serializable",
140        "Pair",
141        "Triple",
142        "Sequence",
143    ]
144    .iter()
145    .copied()
146    .collect()
147});
148
149struct ScalaImport {
150    prefix: String,
151    selectors: Vec<String>,
152    wildcard: bool,
153}
154
155fn split_top_level_commas(s: &str) -> Vec<&str> {
156    let mut parts = Vec::new();
157    let mut depth = 0usize;
158    let mut start = 0usize;
159    for (i, c) in s.char_indices() {
160        match c {
161            '{' => depth += 1,
162            '}' => depth = depth.saturating_sub(1),
163            ',' if depth == 0 => {
164                parts.push(&s[start..i]);
165                start = i + 1;
166            }
167            _ => {}
168        }
169    }
170    parts.push(&s[start..]);
171    parts
172}
173
174fn parse_scala_import_clause(clause: &str) -> Option<ScalaImport> {
175    let clause = clause.trim();
176    if clause.is_empty() {
177        return None;
178    }
179    if let Some(bpos) = clause.find('{') {
180        let prefix = clause[..bpos].trim().trim_end_matches('.').to_string();
181        let inner_end = clause.rfind('}').unwrap_or(clause.len());
182        let inner = &clause[bpos + 1..inner_end];
183        let mut selectors = Vec::new();
184        let mut wildcard = false;
185        for sel in inner.split(',') {
186            let name = sel
187                .split("=>")
188                .next()
189                .unwrap_or("")
190                .trim()
191                .trim_matches('`');
192            match name {
193                "_" | "*" | "given" => wildcard = true,
194                "" => {}
195                n if n
196                    .chars()
197                    .all(|c| c.is_alphanumeric() || c == '_' || c == '$') =>
198                {
199                    selectors.push(n.to_string());
200                }
201                _ => {}
202            }
203        }
204        return Some(ScalaImport {
205            prefix,
206            selectors,
207            wildcard,
208        });
209    }
210    if let Some(p) = clause
211        .strip_suffix("._")
212        .or_else(|| clause.strip_suffix(".*"))
213        .or_else(|| clause.strip_suffix(".given"))
214    {
215        return Some(ScalaImport {
216            prefix: p.to_string(),
217            selectors: Vec::new(),
218            wildcard: true,
219        });
220    }
221    if !clause
222        .chars()
223        .all(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.')
224    {
225        return None;
226    }
227    if let Some(pos) = clause.rfind('.') {
228        let sel = clause[pos + 1..].to_string();
229        if sel.is_empty() {
230            return None;
231        }
232        return Some(ScalaImport {
233            prefix: clause[..pos].to_string(),
234            selectors: vec![sel],
235            wildcard: false,
236        });
237    }
238    Some(ScalaImport {
239        prefix: String::new(),
240        selectors: vec![clause.to_string()],
241        wildcard: false,
242    })
243}
244
245fn parse_scala_imports(content: &str) -> Vec<ScalaImport> {
246    let mut out = Vec::new();
247    for cap in SCALA_IMPORT_LINE_RE.captures_iter(content) {
248        let rest = cap[1].split("//").next().unwrap_or("").trim();
249        for clause in split_top_level_commas(rest) {
250            if let Some(imp) = parse_scala_import_clause(clause) {
251                out.push(imp);
252            }
253        }
254    }
255    out
256}
257
258fn extract_imports(content: &str, path: &Path) -> FxHashSet<String> {
259    if is_java(path) {
260        JAVA_IMPORT_RE
261            .captures_iter(content)
262            .map(|c| c[1].to_string())
263            .collect()
264    } else if is_kotlin(path) {
265        KOTLIN_IMPORT_RE
266            .captures_iter(content)
267            .map(|c| c[1].to_string())
268            .collect()
269    } else if is_scala(path) {
270        let mut refs = FxHashSet::default();
271        for imp in parse_scala_imports(content) {
272            for sel in &imp.selectors {
273                if imp.prefix.is_empty() {
274                    refs.insert(sel.clone());
275                } else {
276                    refs.insert(format!("{}.{}", imp.prefix, sel));
277                }
278            }
279            if imp.wildcard && !imp.prefix.is_empty() {
280                refs.insert(imp.prefix.clone());
281            }
282        }
283        refs
284    } else {
285        FxHashSet::default()
286    }
287}
288
289fn extract_classes(content: &str, path: &Path) -> FxHashSet<String> {
290    if is_java(path) {
291        JAVA_CLASS_RE
292            .captures_iter(content)
293            .map(|c| c[1].to_string())
294            .collect()
295    } else if is_kotlin(path) {
296        KOTLIN_CLASS_RE
297            .captures_iter(content)
298            .map(|c| c[1].to_string())
299            .collect()
300    } else if is_scala(path) {
301        SCALA_CLASS_RE
302            .captures_iter(content)
303            .map(|c| c[1].to_string())
304            .collect()
305    } else {
306        FxHashSet::default()
307    }
308}
309
310fn extract_package(content: &str, path: &Path) -> Option<String> {
311    if is_scala(path) {
312        // Scala chains package clauses (`package com.acme` then `package svc`
313        // means com.acme.svc); `package object x` is a member of the enclosing
314        // package, not a chain segment.
315        let mut parts: Vec<String> = Vec::new();
316        for cap in SCALA_PACKAGE_RE.captures_iter(content) {
317            let seg = &cap[1];
318            if seg == "object" {
319                continue;
320            }
321            parts.push(seg.to_string());
322        }
323        if parts.is_empty() {
324            None
325        } else {
326            Some(parts.join("."))
327        }
328    } else {
329        JAVA_PACKAGE_RE.captures(content).map(|c| c[1].to_string())
330    }
331}
332
333fn extract_inheritance(content: &str, path: &Path) -> FxHashSet<String> {
334    let mut refs = FxHashSet::default();
335    if is_kotlin(path) {
336        for cap in KOTLIN_EXTENDS_RE.captures_iter(content) {
337            for type_cap in TYPE_REF_RE.captures_iter(&cap[1]) {
338                refs.insert(type_cap[1].to_string());
339            }
340        }
341    } else if is_java(path) {
342        for cap in JAVA_EXTENDS_RE.captures_iter(content) {
343            for type_cap in TYPE_REF_RE.captures_iter(&cap[1]) {
344                refs.insert(type_cap[1].to_string());
345            }
346        }
347    } else if is_scala(path) {
348        for cap in SCALA_EXTENDS_RE.captures_iter(content) {
349            refs.insert(cap[1].to_string());
350        }
351    }
352    refs
353}
354
355fn extract_type_refs(content: &str) -> FxHashSet<String> {
356    TYPE_REF_RE
357        .captures_iter(content)
358        .map(|c| c[1].to_string())
359        .collect()
360}
361
362fn extract_annotations(content: &str) -> FxHashSet<String> {
363    ANNOTATION_RE
364        .captures_iter(content)
365        .map(|c| c[1].to_string())
366        .collect()
367}
368
369fn extract_member_uses(content: &str) -> FxHashSet<String> {
370    MEMBER_USE_RE
371        .captures_iter(content)
372        .map(|c| c[1].to_lowercase())
373        .collect()
374}
375
376fn is_member_def(f: &Fragment) -> bool {
377    matches!(f.kind, FragmentKind::Function | FragmentKind::Property)
378}
379
380struct FileRelations<'a> {
381    file_pkg: FxHashMap<&'a str, String>,
382    import_files: FxHashMap<&'a str, FxHashSet<&'a str>>,
383    import_pkgs: FxHashMap<&'a str, FxHashSet<String>>,
384    named_files: FxHashMap<&'a str, FxHashSet<&'a str>>,
385    inh_pairs: FxHashSet<(&'a str, &'a str)>,
386}
387
388#[allow(clippy::too_many_arguments)]
389fn link_class<'a>(
390    edges: &mut EdgeDict,
391    rel: &mut FileRelations<'a>,
392    src: &'a Fragment,
393    name_lower: &str,
394    weight: f64,
395    reverse_factor: f64,
396    record_import: bool,
397    class_to_frags: &'a FxHashMap<String, Vec<FragmentId>>,
398    class_files: &FxHashMap<String, FxHashSet<&'a str>>,
399) {
400    if class_files
401        .get(name_lower)
402        .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
403    {
404        return;
405    }
406    if let Some(fids) = class_to_frags.get(name_lower) {
407        for fid in fids {
408            if fid != &src.id {
409                add_edge(edges, &src.id, fid, weight, reverse_factor);
410                let bucket = if record_import {
411                    &mut rel.import_files
412                } else {
413                    &mut rel.named_files
414                };
415                bucket
416                    .entry(src.path())
417                    .or_default()
418                    .insert(fid.path.as_ref());
419            }
420        }
421    }
422}
423
424impl<'a> FileRelations<'a> {
425    /// A member-name match alone is tags-grade evidence; it only becomes an
426    /// edge when the using file already relates to the defining file through
427    /// something it wrote down: an import, a named type, or inheritance.
428    /// Same-package proximity is deliberately not enough — it links call
429    /// sites to every sibling implementation body (measured on
430    /// bal_hard_008: `sink.consume` endorsing ConsoleSink's body).
431    fn confirmed(&self, user: &str, definer: &str) -> bool {
432        self.import_files
433            .get(user)
434            .is_some_and(|s| s.contains(definer))
435            || self
436                .named_files
437                .get(user)
438                .is_some_and(|s| s.contains(definer))
439            || self
440                .import_pkgs
441                .get(user)
442                .zip(self.file_pkg.get(definer))
443                .is_some_and(|(pkgs, pkg)| pkgs.contains(pkg))
444            || self.inh_pairs.contains(&(user, definer))
445    }
446}
447
448pub struct JVMEdgeBuilder;
449
450impl EdgeBuilder for JVMEdgeBuilder {
451    fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
452        let jvm_frags: Vec<&Fragment> = fragments
453            .iter()
454            .filter(|f| is_jvm_file(Path::new(f.path())))
455            .collect();
456        if jvm_frags.is_empty() {
457            return FxHashMap::default();
458        }
459
460        let import_weight = EDGE_WEIGHTS["jvm_import"].forward;
461        let inheritance_weight = EDGE_WEIGHTS["jvm_inheritance"].forward;
462        let type_weight = EDGE_WEIGHTS["jvm_type"].forward;
463        let member_weight = EDGE_WEIGHTS["jvm_member"].forward;
464        let same_package_weight = EDGE_WEIGHTS["jvm_same_package"].forward;
465        let annotation_weight = EDGE_WEIGHTS["jvm_annotation"].forward;
466        let reverse_factor = EDGE_WEIGHTS["jvm_import"].reverse_factor;
467
468        let mut file_pkg: FxHashMap<&str, String> = FxHashMap::default();
469        for f in &jvm_frags {
470            if let Some(pkg) = extract_package(&f.content, Path::new(f.path())) {
471                file_pkg.entry(f.path()).or_insert(pkg);
472            }
473        }
474
475        let mut package_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
476        let mut class_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
477        let mut class_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
478        let mut fqn_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
479        let mut member_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
480        let mut member_def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
481
482        for f in &jvm_frags {
483            let path = Path::new(f.path());
484            if let Some(pkg) = extract_package(&f.content, path) {
485                package_to_frags.entry(pkg).or_default().push(f.id.clone());
486            }
487            for cls in extract_classes(&f.content, path) {
488                let lower = cls.to_lowercase();
489                class_files
490                    .entry(lower.clone())
491                    .or_default()
492                    .insert(f.path());
493                class_to_frags.entry(lower).or_default().push(f.id.clone());
494                if let Some(pkg) = file_pkg.get(f.path()) {
495                    fqn_to_frags
496                        .entry(format!("{}.{}", pkg, cls).to_lowercase())
497                        .or_default()
498                        .push(f.id.clone());
499                }
500            }
501            if is_member_def(f) {
502                if let Some(name) = f.symbol_name.as_deref() {
503                    if name.len() >= 3 {
504                        let lower = name.to_lowercase();
505                        member_defs
506                            .entry(lower.clone())
507                            .or_default()
508                            .push(f.id.clone());
509                        member_def_files
510                            .entry(lower.clone())
511                            .or_default()
512                            .insert(f.path());
513                    }
514                }
515            }
516        }
517
518        let mut edges: EdgeDict = FxHashMap::default();
519        let mut rel = FileRelations {
520            file_pkg,
521            import_files: FxHashMap::default(),
522            import_pkgs: FxHashMap::default(),
523            named_files: FxHashMap::default(),
524            inh_pairs: FxHashSet::default(),
525        };
526
527        for jf in &jvm_frags {
528            let path = Path::new(jf.path());
529
530            if is_scala(path) {
531                for imp in parse_scala_imports(&jf.content) {
532                    for sel in &imp.selectors {
533                        let sel_lower = sel.to_lowercase();
534                        let mut hit_fqn = false;
535                        if !imp.prefix.is_empty() {
536                            let fqn = format!("{}.{}", imp.prefix, sel).to_lowercase();
537                            if let Some(fids) = fqn_to_frags.get(&fqn) {
538                                hit_fqn = true;
539                                for fid in fids {
540                                    if fid != &jf.id {
541                                        add_edge(
542                                            &mut edges,
543                                            &jf.id,
544                                            fid,
545                                            import_weight,
546                                            reverse_factor,
547                                        );
548                                        rel.import_files
549                                            .entry(jf.path())
550                                            .or_default()
551                                            .insert(fid.path.as_ref());
552                                    }
553                                }
554                            }
555                        }
556                        if !hit_fqn {
557                            link_class(
558                                &mut edges,
559                                &mut rel,
560                                jf,
561                                &sel_lower,
562                                import_weight,
563                                reverse_factor,
564                                true,
565                                &class_to_frags,
566                                &class_files,
567                            );
568                        }
569                    }
570                    if imp.wildcard && !imp.prefix.is_empty() {
571                        rel.import_pkgs
572                            .entry(jf.path())
573                            .or_default()
574                            .insert(imp.prefix.clone());
575                        for fid in package_to_frags.get(imp.prefix.as_str()).unwrap_or(&vec![]) {
576                            if fid != &jf.id {
577                                add_edge(&mut edges, &jf.id, fid, import_weight, reverse_factor);
578                            }
579                        }
580                        // `import Tables._` / `import pkg.Obj._` endorses an
581                        // object's members, not a package: the prefix leaf is
582                        // the object name.
583                        if let Some(leaf) = imp.prefix.rsplit('.').next() {
584                            if leaf.chars().next().is_some_and(|c| c.is_uppercase()) {
585                                link_class(
586                                    &mut edges,
587                                    &mut rel,
588                                    jf,
589                                    &leaf.to_lowercase(),
590                                    import_weight,
591                                    reverse_factor,
592                                    true,
593                                    &class_to_frags,
594                                    &class_files,
595                                );
596                            }
597                        }
598                    }
599                }
600            } else {
601                for imp in extract_imports(&jf.content, path) {
602                    if imp.ends_with(".*") {
603                        let pkg_prefix = &imp[..imp.len() - 2];
604                        rel.import_pkgs
605                            .entry(jf.path())
606                            .or_default()
607                            .insert(pkg_prefix.to_string());
608                        for fid in package_to_frags.get(pkg_prefix).unwrap_or(&vec![]) {
609                            if fid != &jf.id {
610                                add_edge(&mut edges, &jf.id, fid, import_weight, reverse_factor);
611                            }
612                        }
613                    } else {
614                        let mut hit_fqn = false;
615                        if let Some(fids) = fqn_to_frags.get(&imp.to_lowercase()) {
616                            hit_fqn = true;
617                            for fid in fids {
618                                if fid != &jf.id {
619                                    add_edge(
620                                        &mut edges,
621                                        &jf.id,
622                                        fid,
623                                        import_weight,
624                                        reverse_factor,
625                                    );
626                                    rel.import_files
627                                        .entry(jf.path())
628                                        .or_default()
629                                        .insert(fid.path.as_ref());
630                                }
631                            }
632                        }
633                        if !hit_fqn {
634                            if let Some(last) = imp.split('.').next_back() {
635                                link_class(
636                                    &mut edges,
637                                    &mut rel,
638                                    jf,
639                                    &last.to_lowercase(),
640                                    import_weight,
641                                    reverse_factor,
642                                    true,
643                                    &class_to_frags,
644                                    &class_files,
645                                );
646                            }
647                        }
648                    }
649                }
650            }
651
652            for inh_ref in extract_inheritance(&jf.content, path) {
653                let lower = inh_ref.to_lowercase();
654                if class_files
655                    .get(&lower)
656                    .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
657                {
658                    continue;
659                }
660                if let Some(fids) = class_to_frags.get(&lower) {
661                    for fid in fids {
662                        if fid != &jf.id {
663                            add_edge(&mut edges, &jf.id, fid, inheritance_weight, reverse_factor);
664                            let a = jf.path();
665                            let b: &str = fid.path.as_ref();
666                            if a != b {
667                                rel.inh_pairs.insert((a, b));
668                                rel.inh_pairs.insert((b, a));
669                            }
670                        }
671                    }
672                }
673            }
674
675            for type_ref in extract_type_refs(&jf.content) {
676                if !JVM_STDLIB_TYPES.contains(type_ref.as_str()) {
677                    link_class(
678                        &mut edges,
679                        &mut rel,
680                        jf,
681                        &type_ref.to_lowercase(),
682                        type_weight,
683                        reverse_factor,
684                        false,
685                        &class_to_frags,
686                        &class_files,
687                    );
688                }
689            }
690
691            for ann_ref in extract_annotations(&jf.content) {
692                link_class(
693                    &mut edges,
694                    &mut rel,
695                    jf,
696                    &ann_ref.to_lowercase(),
697                    annotation_weight,
698                    reverse_factor,
699                    false,
700                    &class_to_frags,
701                    &class_files,
702                );
703            }
704
705            if let Some(current_pkg) = extract_package(&jf.content, path) {
706                for fid in package_to_frags.get(&current_pkg).unwrap_or(&vec![]) {
707                    if fid != &jf.id {
708                        add_edge(&mut edges, &jf.id, fid, same_package_weight, reverse_factor);
709                    }
710                }
711            }
712        }
713
714        for jf in &jvm_frags {
715            let own = jf.symbol_name.as_deref().map(|s| s.to_lowercase());
716            for m in extract_member_uses(&jf.content) {
717                if own.as_deref() == Some(m.as_str()) {
718                    continue;
719                }
720                let Some(def_files) = member_def_files.get(&m) else {
721                    continue;
722                };
723                if def_files.len() > MAX_FILES_PER_NAME {
724                    continue;
725                }
726                let Some(defs) = member_defs.get(&m) else {
727                    continue;
728                };
729                for d in defs {
730                    let dst_path: &str = d.path.as_ref();
731                    if dst_path == jf.path() || d == &jf.id {
732                        continue;
733                    }
734                    if rel.confirmed(jf.path(), dst_path) {
735                        add_edge(&mut edges, &jf.id, d, member_weight, reverse_factor);
736                    }
737                }
738            }
739        }
740
741        edges
742    }
743
744    fn discover_related_files(
745        &self,
746        changed: &[PathBuf],
747        candidates: &[PathBuf],
748        _repo_root: Option<&Path>,
749        file_cache: Option<&FxHashMap<PathBuf, String>>,
750    ) -> Vec<PathBuf> {
751        let jvm_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_jvm_file(f)).collect();
752        if jvm_changed.is_empty() {
753            return vec![];
754        }
755
756        let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
757        let jvm_candidates: Vec<PathBuf> = candidates
758            .iter()
759            .filter(|c| is_jvm_file(c) && !changed_set.contains(*c))
760            .cloned()
761            .collect();
762
763        let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
764        let mut frontier: Vec<PathBuf> = jvm_changed.iter().map(|f| (*f).clone()).collect();
765
766        for _ in 0..SEMANTIC_DISCOVERY.max_depth {
767            let mut type_refs: FxHashSet<String> = FxHashSet::default();
768            let mut frontier_classes: FxHashSet<String> = FxHashSet::default();
769
770            for f in &frontier {
771                if let Some(content) = base::read_file_cached(f, file_cache) {
772                    type_refs.extend(extract_type_refs(&content));
773                    frontier_classes.extend(extract_classes(&content, f));
774                }
775            }
776
777            let mut hop_found: Vec<PathBuf> = Vec::new();
778            for c in &jvm_candidates {
779                if discovered.contains(c) {
780                    continue;
781                }
782                if let Some(content) = base::read_file_cached(c, file_cache) {
783                    let cand_classes = extract_classes(&content, c);
784                    let cand_type_refs = extract_type_refs(&content);
785
786                    if !cand_classes.is_disjoint(&type_refs)
787                        || !cand_type_refs.is_disjoint(&frontier_classes)
788                    {
789                        hop_found.push(c.clone());
790                        continue;
791                    }
792                    let cand_imports = extract_imports(&content, c);
793                    for imp in &cand_imports {
794                        if let Some(last) = imp.rsplit('.').next() {
795                            if frontier_classes.contains(last) {
796                                hop_found.push(c.clone());
797                                break;
798                            }
799                        }
800                    }
801                }
802            }
803
804            let new_files: Vec<PathBuf> = hop_found
805                .into_iter()
806                .filter(|f| !discovered.contains(f))
807                .collect();
808            if new_files.is_empty() {
809                break;
810            }
811            discovered.extend(new_files.iter().cloned());
812            frontier = new_files;
813        }
814
815        let mut result: Vec<PathBuf> = discovered.into_iter().collect();
816        result.sort();
817        result
818    }
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    fn parse_one(s: &str) -> ScalaImport {
826        let mut imports = parse_scala_imports(&format!("import {s}\n"));
827        assert_eq!(imports.len(), 1, "expected one import from {s:?}");
828        imports.remove(0)
829    }
830
831    #[test]
832    fn scala_import_captures_the_class_not_a_truncated_prefix() {
833        let imp = parse_one("repo.UserRepository");
834        assert_eq!(imp.prefix, "repo");
835        assert_eq!(imp.selectors, vec!["UserRepository".to_string()]);
836        assert!(!imp.wildcard);
837    }
838
839    #[test]
840    fn scala_wildcard_and_brace_imports_resolve_selectors() {
841        let w = parse_one("com.foo._");
842        assert_eq!(w.prefix, "com.foo");
843        assert!(w.wildcard && w.selectors.is_empty());
844
845        let s3 = parse_one("com.foo.*");
846        assert!(s3.wildcard);
847
848        let braces = parse_one("http.{Request, Response}");
849        assert_eq!(braces.prefix, "http");
850        assert_eq!(
851            braces.selectors,
852            vec!["Request".to_string(), "Response".to_string()]
853        );
854
855        let rename = parse_one("a.b.{C => D, _}");
856        assert_eq!(rename.prefix, "a.b");
857        assert_eq!(rename.selectors, vec!["C".to_string()]);
858        assert!(rename.wildcard);
859
860        let object_rooted = parse_one("Tables._");
861        assert_eq!(object_rooted.prefix, "Tables");
862        assert!(object_rooted.wildcard);
863    }
864
865    #[test]
866    fn scala_chained_packages_join_and_package_object_is_skipped() {
867        let content = "package com.acme\npackage service\n\nclass X {}\n";
868        assert_eq!(
869            extract_package(content, Path::new("a.scala")),
870            Some("com.acme.service".to_string())
871        );
872        let pkg_obj = "package util\npackage object strings {\n  def slug = 1\n}\n";
873        assert_eq!(
874            extract_package(pkg_obj, Path::new("a.scala")),
875            Some("util".to_string())
876        );
877    }
878}