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 = match clause.rfind('}') {
182            Some(p) if p > bpos => p,
183            Some(_) => return None,
184            None => clause.len(),
185        };
186        let inner = &clause[bpos + 1..inner_end];
187        let mut selectors = Vec::new();
188        let mut wildcard = false;
189        for sel in inner.split(',') {
190            let name = sel
191                .split("=>")
192                .next()
193                .unwrap_or("")
194                .trim()
195                .trim_matches('`');
196            match name {
197                "_" | "*" | "given" => wildcard = true,
198                "" => {}
199                n if n
200                    .chars()
201                    .all(|c| c.is_alphanumeric() || c == '_' || c == '$') =>
202                {
203                    selectors.push(n.to_string());
204                }
205                _ => {}
206            }
207        }
208        return Some(ScalaImport {
209            prefix,
210            selectors,
211            wildcard,
212        });
213    }
214    if let Some(p) = clause
215        .strip_suffix("._")
216        .or_else(|| clause.strip_suffix(".*"))
217        .or_else(|| clause.strip_suffix(".given"))
218    {
219        return Some(ScalaImport {
220            prefix: p.to_string(),
221            selectors: Vec::new(),
222            wildcard: true,
223        });
224    }
225    if !clause
226        .chars()
227        .all(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.')
228    {
229        return None;
230    }
231    if let Some(pos) = clause.rfind('.') {
232        let sel = clause[pos + 1..].to_string();
233        if sel.is_empty() {
234            return None;
235        }
236        return Some(ScalaImport {
237            prefix: clause[..pos].to_string(),
238            selectors: vec![sel],
239            wildcard: false,
240        });
241    }
242    Some(ScalaImport {
243        prefix: String::new(),
244        selectors: vec![clause.to_string()],
245        wildcard: false,
246    })
247}
248
249fn parse_scala_imports(content: &str) -> Vec<ScalaImport> {
250    let mut out = Vec::new();
251    for cap in SCALA_IMPORT_LINE_RE.captures_iter(content) {
252        let rest = cap[1].split("//").next().unwrap_or("").trim();
253        for clause in split_top_level_commas(rest) {
254            if let Some(imp) = parse_scala_import_clause(clause) {
255                out.push(imp);
256            }
257        }
258    }
259    out
260}
261
262fn extract_imports(content: &str, path: &Path) -> FxHashSet<String> {
263    if is_java(path) {
264        JAVA_IMPORT_RE
265            .captures_iter(content)
266            .map(|c| c[1].to_string())
267            .collect()
268    } else if is_kotlin(path) {
269        KOTLIN_IMPORT_RE
270            .captures_iter(content)
271            .map(|c| c[1].to_string())
272            .collect()
273    } else if is_scala(path) {
274        let mut refs = FxHashSet::default();
275        for imp in parse_scala_imports(content) {
276            for sel in &imp.selectors {
277                if imp.prefix.is_empty() {
278                    refs.insert(sel.clone());
279                } else {
280                    refs.insert(format!("{}.{}", imp.prefix, sel));
281                }
282            }
283            if imp.wildcard && !imp.prefix.is_empty() {
284                refs.insert(imp.prefix.clone());
285            }
286        }
287        refs
288    } else {
289        FxHashSet::default()
290    }
291}
292
293fn extract_classes(content: &str, path: &Path) -> FxHashSet<String> {
294    if is_java(path) {
295        JAVA_CLASS_RE
296            .captures_iter(content)
297            .map(|c| c[1].to_string())
298            .collect()
299    } else if is_kotlin(path) {
300        KOTLIN_CLASS_RE
301            .captures_iter(content)
302            .map(|c| c[1].to_string())
303            .collect()
304    } else if is_scala(path) {
305        SCALA_CLASS_RE
306            .captures_iter(content)
307            .map(|c| c[1].to_string())
308            .collect()
309    } else {
310        FxHashSet::default()
311    }
312}
313
314fn extract_package(content: &str, path: &Path) -> Option<String> {
315    if is_scala(path) {
316        // Scala chains package clauses (`package com.acme` then `package svc`
317        // means com.acme.svc); `package object x` is a member of the enclosing
318        // package, not a chain segment.
319        let mut parts: Vec<String> = Vec::new();
320        for cap in SCALA_PACKAGE_RE.captures_iter(content) {
321            let seg = &cap[1];
322            if seg == "object" {
323                continue;
324            }
325            parts.push(seg.to_string());
326        }
327        if parts.is_empty() {
328            None
329        } else {
330            Some(parts.join("."))
331        }
332    } else {
333        JAVA_PACKAGE_RE.captures(content).map(|c| c[1].to_string())
334    }
335}
336
337fn extract_inheritance(content: &str, path: &Path) -> FxHashSet<String> {
338    let mut refs = FxHashSet::default();
339    if is_kotlin(path) {
340        for cap in KOTLIN_EXTENDS_RE.captures_iter(content) {
341            for type_cap in TYPE_REF_RE.captures_iter(&cap[1]) {
342                refs.insert(type_cap[1].to_string());
343            }
344        }
345    } else if is_java(path) {
346        for cap in JAVA_EXTENDS_RE.captures_iter(content) {
347            for type_cap in TYPE_REF_RE.captures_iter(&cap[1]) {
348                refs.insert(type_cap[1].to_string());
349            }
350        }
351    } else if is_scala(path) {
352        for cap in SCALA_EXTENDS_RE.captures_iter(content) {
353            refs.insert(cap[1].to_string());
354        }
355    }
356    refs
357}
358
359fn extract_type_refs(content: &str) -> FxHashSet<String> {
360    TYPE_REF_RE
361        .captures_iter(content)
362        .map(|c| c[1].to_string())
363        .collect()
364}
365
366fn extract_annotations(content: &str) -> FxHashSet<String> {
367    ANNOTATION_RE
368        .captures_iter(content)
369        .map(|c| c[1].to_string())
370        .collect()
371}
372
373fn extract_member_uses(content: &str) -> FxHashSet<String> {
374    MEMBER_USE_RE
375        .captures_iter(content)
376        .map(|c| c[1].to_lowercase())
377        .collect()
378}
379
380fn is_member_def(f: &Fragment) -> bool {
381    matches!(f.kind, FragmentKind::Function | FragmentKind::Property)
382}
383
384struct FileRelations<'a> {
385    file_pkg: FxHashMap<&'a str, String>,
386    import_files: FxHashMap<&'a str, FxHashSet<&'a str>>,
387    import_pkgs: FxHashMap<&'a str, FxHashSet<String>>,
388    named_files: FxHashMap<&'a str, FxHashSet<&'a str>>,
389    inh_pairs: FxHashSet<(&'a str, &'a str)>,
390}
391
392#[allow(clippy::too_many_arguments)]
393fn link_class<'a>(
394    edges: &mut EdgeDict,
395    rel: &mut FileRelations<'a>,
396    src: &'a Fragment,
397    name_lower: &str,
398    weight: f64,
399    reverse_factor: f64,
400    record_import: bool,
401    class_to_frags: &'a FxHashMap<String, Vec<FragmentId>>,
402    class_files: &FxHashMap<String, FxHashSet<&'a str>>,
403) {
404    if class_files
405        .get(name_lower)
406        .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
407    {
408        return;
409    }
410    if let Some(fids) = class_to_frags.get(name_lower) {
411        for fid in fids {
412            if fid != &src.id {
413                add_edge(edges, &src.id, fid, weight, reverse_factor);
414                let bucket = if record_import {
415                    &mut rel.import_files
416                } else {
417                    &mut rel.named_files
418                };
419                bucket
420                    .entry(src.path())
421                    .or_default()
422                    .insert(fid.path.as_ref());
423            }
424        }
425    }
426}
427
428impl<'a> FileRelations<'a> {
429    /// A member-name match alone is tags-grade evidence; it only becomes an
430    /// edge when the using file already relates to the defining file through
431    /// something it wrote down: an import, a named type, or inheritance.
432    /// Same-package proximity is deliberately not enough — it links call
433    /// sites to every sibling implementation body (measured on
434    /// bal_hard_008: `sink.consume` endorsing ConsoleSink's body).
435    fn confirmed(&self, user: &str, definer: &str) -> bool {
436        self.import_files
437            .get(user)
438            .is_some_and(|s| s.contains(definer))
439            || self
440                .named_files
441                .get(user)
442                .is_some_and(|s| s.contains(definer))
443            || self
444                .import_pkgs
445                .get(user)
446                .zip(self.file_pkg.get(definer))
447                .is_some_and(|(pkgs, pkg)| pkgs.contains(pkg))
448            || self.inh_pairs.contains(&(user, definer))
449    }
450}
451
452pub struct JVMEdgeBuilder;
453
454impl EdgeBuilder for JVMEdgeBuilder {
455    fn build(&self, fragments: &[Fragment], _repo_root: Option<&Path>) -> EdgeDict {
456        let jvm_frags: Vec<&Fragment> = fragments
457            .iter()
458            .filter(|f| is_jvm_file(Path::new(f.path())))
459            .collect();
460        if jvm_frags.is_empty() {
461            return FxHashMap::default();
462        }
463
464        let import_weight = EDGE_WEIGHTS["jvm_import"].forward;
465        let inheritance_weight = EDGE_WEIGHTS["jvm_inheritance"].forward;
466        let type_weight = EDGE_WEIGHTS["jvm_type"].forward;
467        let member_weight = EDGE_WEIGHTS["jvm_member"].forward;
468        let same_package_weight = EDGE_WEIGHTS["jvm_same_package"].forward;
469        let annotation_weight = EDGE_WEIGHTS["jvm_annotation"].forward;
470        let reverse_factor = EDGE_WEIGHTS["jvm_import"].reverse_factor;
471
472        let mut file_pkg: FxHashMap<&str, String> = FxHashMap::default();
473        for f in &jvm_frags {
474            if let Some(pkg) = extract_package(&f.content, Path::new(f.path())) {
475                file_pkg.entry(f.path()).or_insert(pkg);
476            }
477        }
478
479        let mut package_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
480        let mut class_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
481        let mut class_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
482        let mut fqn_to_frags: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
483        let mut member_defs: FxHashMap<String, Vec<FragmentId>> = FxHashMap::default();
484        let mut member_def_files: FxHashMap<String, FxHashSet<&str>> = FxHashMap::default();
485
486        for f in &jvm_frags {
487            let path = Path::new(f.path());
488            if let Some(pkg) = extract_package(&f.content, path) {
489                package_to_frags.entry(pkg).or_default().push(f.id.clone());
490            }
491            for cls in extract_classes(&f.content, path) {
492                let lower = cls.to_lowercase();
493                class_files
494                    .entry(lower.clone())
495                    .or_default()
496                    .insert(f.path());
497                class_to_frags.entry(lower).or_default().push(f.id.clone());
498                if let Some(pkg) = file_pkg.get(f.path()) {
499                    fqn_to_frags
500                        .entry(format!("{}.{}", pkg, cls).to_lowercase())
501                        .or_default()
502                        .push(f.id.clone());
503                }
504            }
505            if is_member_def(f) {
506                if let Some(name) = f.symbol_name.as_deref() {
507                    if name.len() >= 3 {
508                        let lower = name.to_lowercase();
509                        member_defs
510                            .entry(lower.clone())
511                            .or_default()
512                            .push(f.id.clone());
513                        member_def_files
514                            .entry(lower.clone())
515                            .or_default()
516                            .insert(f.path());
517                    }
518                }
519            }
520        }
521
522        let mut edges: EdgeDict = FxHashMap::default();
523        let mut rel = FileRelations {
524            file_pkg,
525            import_files: FxHashMap::default(),
526            import_pkgs: FxHashMap::default(),
527            named_files: FxHashMap::default(),
528            inh_pairs: FxHashSet::default(),
529        };
530
531        for jf in &jvm_frags {
532            let path = Path::new(jf.path());
533
534            if is_scala(path) {
535                for imp in parse_scala_imports(&jf.content) {
536                    for sel in &imp.selectors {
537                        let sel_lower = sel.to_lowercase();
538                        let mut hit_fqn = false;
539                        if !imp.prefix.is_empty() {
540                            let fqn = format!("{}.{}", imp.prefix, sel).to_lowercase();
541                            if let Some(fids) = fqn_to_frags.get(&fqn) {
542                                hit_fqn = true;
543                                for fid in fids {
544                                    if fid != &jf.id {
545                                        add_edge(
546                                            &mut edges,
547                                            &jf.id,
548                                            fid,
549                                            import_weight,
550                                            reverse_factor,
551                                        );
552                                        rel.import_files
553                                            .entry(jf.path())
554                                            .or_default()
555                                            .insert(fid.path.as_ref());
556                                    }
557                                }
558                            }
559                        }
560                        if !hit_fqn {
561                            link_class(
562                                &mut edges,
563                                &mut rel,
564                                jf,
565                                &sel_lower,
566                                import_weight,
567                                reverse_factor,
568                                true,
569                                &class_to_frags,
570                                &class_files,
571                            );
572                        }
573                    }
574                    if imp.wildcard && !imp.prefix.is_empty() {
575                        rel.import_pkgs
576                            .entry(jf.path())
577                            .or_default()
578                            .insert(imp.prefix.clone());
579                        for fid in package_to_frags.get(imp.prefix.as_str()).unwrap_or(&vec![]) {
580                            if fid != &jf.id {
581                                add_edge(&mut edges, &jf.id, fid, import_weight, reverse_factor);
582                            }
583                        }
584                        // `import Tables._` / `import pkg.Obj._` endorses an
585                        // object's members, not a package: the prefix leaf is
586                        // the object name.
587                        if let Some(leaf) = imp.prefix.rsplit('.').next() {
588                            if leaf.chars().next().is_some_and(|c| c.is_uppercase()) {
589                                link_class(
590                                    &mut edges,
591                                    &mut rel,
592                                    jf,
593                                    &leaf.to_lowercase(),
594                                    import_weight,
595                                    reverse_factor,
596                                    true,
597                                    &class_to_frags,
598                                    &class_files,
599                                );
600                            }
601                        }
602                    }
603                }
604            } else {
605                for imp in extract_imports(&jf.content, path) {
606                    if imp.ends_with(".*") {
607                        let pkg_prefix = &imp[..imp.len() - 2];
608                        rel.import_pkgs
609                            .entry(jf.path())
610                            .or_default()
611                            .insert(pkg_prefix.to_string());
612                        for fid in package_to_frags.get(pkg_prefix).unwrap_or(&vec![]) {
613                            if fid != &jf.id {
614                                add_edge(&mut edges, &jf.id, fid, import_weight, reverse_factor);
615                            }
616                        }
617                    } else {
618                        let mut hit_fqn = false;
619                        if let Some(fids) = fqn_to_frags.get(&imp.to_lowercase()) {
620                            hit_fqn = true;
621                            for fid in fids {
622                                if fid != &jf.id {
623                                    add_edge(
624                                        &mut edges,
625                                        &jf.id,
626                                        fid,
627                                        import_weight,
628                                        reverse_factor,
629                                    );
630                                    rel.import_files
631                                        .entry(jf.path())
632                                        .or_default()
633                                        .insert(fid.path.as_ref());
634                                }
635                            }
636                        }
637                        if !hit_fqn {
638                            if let Some(last) = imp.split('.').next_back() {
639                                link_class(
640                                    &mut edges,
641                                    &mut rel,
642                                    jf,
643                                    &last.to_lowercase(),
644                                    import_weight,
645                                    reverse_factor,
646                                    true,
647                                    &class_to_frags,
648                                    &class_files,
649                                );
650                            }
651                        }
652                    }
653                }
654            }
655
656            for inh_ref in extract_inheritance(&jf.content, path) {
657                let lower = inh_ref.to_lowercase();
658                if class_files
659                    .get(&lower)
660                    .is_some_and(|s| s.len() > MAX_FILES_PER_NAME)
661                {
662                    continue;
663                }
664                if let Some(fids) = class_to_frags.get(&lower) {
665                    for fid in fids {
666                        if fid != &jf.id {
667                            add_edge(&mut edges, &jf.id, fid, inheritance_weight, reverse_factor);
668                            let a = jf.path();
669                            let b: &str = fid.path.as_ref();
670                            if a != b {
671                                rel.inh_pairs.insert((a, b));
672                                rel.inh_pairs.insert((b, a));
673                            }
674                        }
675                    }
676                }
677            }
678
679            for type_ref in extract_type_refs(&jf.content) {
680                if !JVM_STDLIB_TYPES.contains(type_ref.as_str()) {
681                    link_class(
682                        &mut edges,
683                        &mut rel,
684                        jf,
685                        &type_ref.to_lowercase(),
686                        type_weight,
687                        reverse_factor,
688                        false,
689                        &class_to_frags,
690                        &class_files,
691                    );
692                }
693            }
694
695            for ann_ref in extract_annotations(&jf.content) {
696                link_class(
697                    &mut edges,
698                    &mut rel,
699                    jf,
700                    &ann_ref.to_lowercase(),
701                    annotation_weight,
702                    reverse_factor,
703                    false,
704                    &class_to_frags,
705                    &class_files,
706                );
707            }
708
709            if let Some(current_pkg) = extract_package(&jf.content, path) {
710                for fid in package_to_frags.get(&current_pkg).unwrap_or(&vec![]) {
711                    if fid != &jf.id {
712                        add_edge(&mut edges, &jf.id, fid, same_package_weight, reverse_factor);
713                    }
714                }
715            }
716        }
717
718        for jf in &jvm_frags {
719            let own = jf.symbol_name.as_deref().map(|s| s.to_lowercase());
720            for m in extract_member_uses(&jf.content) {
721                if own.as_deref() == Some(m.as_str()) {
722                    continue;
723                }
724                let Some(def_files) = member_def_files.get(&m) else {
725                    continue;
726                };
727                if def_files.len() > MAX_FILES_PER_NAME {
728                    continue;
729                }
730                let Some(defs) = member_defs.get(&m) else {
731                    continue;
732                };
733                for d in defs {
734                    let dst_path: &str = d.path.as_ref();
735                    if dst_path == jf.path() || d == &jf.id {
736                        continue;
737                    }
738                    if rel.confirmed(jf.path(), dst_path) {
739                        add_edge(&mut edges, &jf.id, d, member_weight, reverse_factor);
740                    }
741                }
742            }
743        }
744
745        edges
746    }
747
748    fn discover_related_files(
749        &self,
750        changed: &[PathBuf],
751        candidates: &[PathBuf],
752        _repo_root: Option<&Path>,
753        file_cache: Option<&FxHashMap<PathBuf, String>>,
754    ) -> Vec<PathBuf> {
755        let jvm_changed: Vec<&PathBuf> = changed.iter().filter(|f| is_jvm_file(f)).collect();
756        if jvm_changed.is_empty() {
757            return vec![];
758        }
759
760        let changed_set: FxHashSet<PathBuf> = changed.iter().cloned().collect();
761        let jvm_candidates: Vec<PathBuf> = candidates
762            .iter()
763            .filter(|c| is_jvm_file(c) && !changed_set.contains(*c))
764            .cloned()
765            .collect();
766
767        let mut discovered: FxHashSet<PathBuf> = FxHashSet::default();
768        let mut frontier: Vec<PathBuf> = jvm_changed.iter().map(|f| (*f).clone()).collect();
769
770        for _ in 0..SEMANTIC_DISCOVERY.max_depth {
771            let mut type_refs: FxHashSet<String> = FxHashSet::default();
772            let mut frontier_classes: FxHashSet<String> = FxHashSet::default();
773
774            for f in &frontier {
775                if let Some(content) = base::read_file_cached(f, file_cache) {
776                    type_refs.extend(extract_type_refs(&content));
777                    frontier_classes.extend(extract_classes(&content, f));
778                }
779            }
780
781            let mut hop_found: Vec<PathBuf> = Vec::new();
782            for c in &jvm_candidates {
783                if discovered.contains(c) {
784                    continue;
785                }
786                if let Some(content) = base::read_file_cached(c, file_cache) {
787                    let cand_classes = extract_classes(&content, c);
788                    let cand_type_refs = extract_type_refs(&content);
789
790                    if !cand_classes.is_disjoint(&type_refs)
791                        || !cand_type_refs.is_disjoint(&frontier_classes)
792                    {
793                        hop_found.push(c.clone());
794                        continue;
795                    }
796                    let cand_imports = extract_imports(&content, c);
797                    for imp in &cand_imports {
798                        if let Some(last) = imp.rsplit('.').next() {
799                            if frontier_classes.contains(last) {
800                                hop_found.push(c.clone());
801                                break;
802                            }
803                        }
804                    }
805                }
806            }
807
808            let new_files: Vec<PathBuf> = hop_found
809                .into_iter()
810                .filter(|f| !discovered.contains(f))
811                .collect();
812            if new_files.is_empty() {
813                break;
814            }
815            discovered.extend(new_files.iter().cloned());
816            frontier = new_files;
817        }
818
819        let mut result: Vec<PathBuf> = discovered.into_iter().collect();
820        result.sort();
821        result
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn parse_one(s: &str) -> ScalaImport {
830        let mut imports = parse_scala_imports(&format!("import {s}\n"));
831        assert_eq!(imports.len(), 1, "expected one import from {s:?}");
832        imports.remove(0)
833    }
834
835    #[test]
836    fn scala_import_with_brace_before_open_brace_is_rejected_not_a_panic() {
837        assert!(parse_scala_import_clause("a}.{B").is_none());
838        assert!(parse_scala_import_clause("}x{").is_none());
839        let open_only = parse_scala_import_clause("http.{Request").expect("still parses");
840        assert_eq!(open_only.prefix, "http");
841        assert_eq!(open_only.selectors, vec!["Request".to_string()]);
842    }
843
844    #[test]
845    fn scala_import_captures_the_class_not_a_truncated_prefix() {
846        let imp = parse_one("repo.UserRepository");
847        assert_eq!(imp.prefix, "repo");
848        assert_eq!(imp.selectors, vec!["UserRepository".to_string()]);
849        assert!(!imp.wildcard);
850    }
851
852    #[test]
853    fn scala_wildcard_and_brace_imports_resolve_selectors() {
854        let w = parse_one("com.foo._");
855        assert_eq!(w.prefix, "com.foo");
856        assert!(w.wildcard && w.selectors.is_empty());
857
858        let s3 = parse_one("com.foo.*");
859        assert!(s3.wildcard);
860
861        let braces = parse_one("http.{Request, Response}");
862        assert_eq!(braces.prefix, "http");
863        assert_eq!(
864            braces.selectors,
865            vec!["Request".to_string(), "Response".to_string()]
866        );
867
868        let rename = parse_one("a.b.{C => D, _}");
869        assert_eq!(rename.prefix, "a.b");
870        assert_eq!(rename.selectors, vec!["C".to_string()]);
871        assert!(rename.wildcard);
872
873        let object_rooted = parse_one("Tables._");
874        assert_eq!(object_rooted.prefix, "Tables");
875        assert!(object_rooted.wildcard);
876    }
877
878    #[test]
879    fn scala_chained_packages_join_and_package_object_is_skipped() {
880        let content = "package com.acme\npackage service\n\nclass X {}\n";
881        assert_eq!(
882            extract_package(content, Path::new("a.scala")),
883            Some("com.acme.service".to_string())
884        );
885        let pkg_obj = "package util\npackage object strings {\n  def slug = 1\n}\n";
886        assert_eq!(
887            extract_package(pkg_obj, Path::new("a.scala")),
888            Some("util".to_string())
889        );
890    }
891}