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