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
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 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 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 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(¤t_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}