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