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
17const 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
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 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 let mut open = clause_text.matches('{').count();
268 let mut close = clause_text.matches('}').count();
269 let mut joined = 0;
270 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 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 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 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(¤t_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}