Skip to main content

mati_core/analysis/resolvers/
scala.rs

1//! Scala import resolver.
2//!
3//! Resolves local Scala imports by converting dotted package paths to
4//! filesystem paths, tried against the repo root, `src/main/scala/`,
5//! and dynamically-discovered sbt source roots (for multi-project
6//! layouts like `subproject/shared/src/main/scala/`).
7//! Reuses `dotted_to_path` from the Java resolver.
8//!
9//! # Known limitations
10//!
11//! - Package objects (`package.scala` files) are not resolved
12//! - Selective imports `import com.acme.{Foo, Bar}` resolve to the
13//!   package prefix, not individual members
14//! - Akka framework classes are treated as external; local Akka
15//!   subclasses will not produce edges
16//! - Implicit imports from the `scala` package are not modeled
17//! - Companion objects and type aliases are not distinguished from
18//!   class files
19//!
20//! These limitations mean blast radius ranking and import-based
21//! propagation will be less accurate for Scala projects with
22//! non-standard build layouts. Edge counts on real Scala projects will
23//! be lower than the parser's imports list would suggest.
24
25use super::java::dotted_to_path;
26use super::{FileIndex, LanguageResolver};
27use crate::analysis::parser::ImportStatement;
28use crate::analysis::walker::Language;
29
30pub struct ScalaResolver;
31
32impl LanguageResolver for ScalaResolver {
33    fn resolve(
34        &self,
35        import: &ImportStatement,
36        _importing_file: &str,
37        file_index: &FileIndex,
38    ) -> Option<String> {
39        resolve_scala(&import.path, file_index)
40    }
41
42    fn language(&self) -> Language {
43        Language::Scala
44    }
45
46    fn name(&self) -> &'static str {
47        "scala"
48    }
49}
50
51fn resolve_scala(import_path: &str, file_index: &FileIndex) -> Option<String> {
52    if is_scala_stdlib(import_path) {
53        return None;
54    }
55
56    // Strip Scala wildcard `._` and selective import braces
57    let clean = import_path
58        .split('{')
59        .next()
60        .unwrap_or(import_path)
61        .trim_end_matches("._")
62        .trim_end_matches('.');
63
64    let rel = dotted_to_path(clean);
65
66    // Try direct file: com/example/Foo.scala
67    let direct = format!("{rel}.scala");
68    if file_index.contains(&direct) {
69        return Some(direct);
70    }
71
72    // Try sbt/Maven layout: src/main/scala/com/example/Foo.scala
73    let sbt = format!("src/main/scala/{rel}.scala");
74    if file_index.contains(&sbt) {
75        return Some(sbt);
76    }
77
78    // Try discovered source roots (multi-project sbt layouts).
79    // E.g. "zio-json/shared/src/main/scala/" + "zio/json/JsonDecoder.scala"
80    for root in file_index.scala_source_roots() {
81        let candidate = format!("{root}{rel}.scala");
82        if file_index.contains(&candidate) {
83            return Some(candidate);
84        }
85    }
86
87    None
88}
89
90fn is_scala_stdlib(path: &str) -> bool {
91    path.starts_with("scala.")
92        || path.starts_with("java.")
93        || path.starts_with("javax.")
94        || path.starts_with("akka.")
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::analysis::parser::import::ImportKind;
101
102    fn idx(paths: &[&str]) -> FileIndex {
103        FileIndex::new(paths.iter().map(|s| s.to_string()))
104    }
105
106    fn import(path: &str) -> ImportStatement {
107        ImportStatement::new(path, ImportKind::Normal, 1)
108    }
109
110    #[test]
111    fn stdlib_skipped() {
112        let file_index = idx(&["Main.scala"]);
113        assert_eq!(
114            ScalaResolver.resolve(
115                &import("scala.collection.mutable"),
116                "Main.scala",
117                &file_index
118            ),
119            None
120        );
121    }
122
123    #[test]
124    fn local_class_resolves() {
125        let file_index = idx(&["com/example/Utils.scala", "Main.scala"]);
126        let result = ScalaResolver.resolve(&import("com.example.Utils"), "Main.scala", &file_index);
127        assert_eq!(result, Some("com/example/Utils.scala".into()));
128    }
129
130    #[test]
131    fn sbt_layout_resolves() {
132        let file_index = idx(&["src/main/scala/com/example/Utils.scala", "Main.scala"]);
133        let result = ScalaResolver.resolve(&import("com.example.Utils"), "Main.scala", &file_index);
134        assert_eq!(
135            result,
136            Some("src/main/scala/com/example/Utils.scala".into())
137        );
138    }
139
140    #[test]
141    fn wildcard_stripped() {
142        let file_index = idx(&["com/example.scala", "Main.scala"]);
143        let result = ScalaResolver.resolve(&import("com.example._"), "Main.scala", &file_index);
144        assert_eq!(result, Some("com/example.scala".into()));
145    }
146
147    #[test]
148    fn nonexistent_returns_none() {
149        let file_index = idx(&["Main.scala"]);
150        assert_eq!(
151            ScalaResolver.resolve(&import("com.example.Missing"), "Main.scala", &file_index),
152            None
153        );
154    }
155
156    // ── Multi-project sbt source root tests ────────────────────────────
157
158    #[test]
159    fn simple_src_main_scala_resolves() {
160        // Flat layout already covered by sbt_layout_resolves above,
161        // but verify the hardcoded path still works.
162        let file_index = idx(&[
163            "src/main/scala/foo/Bar.scala",
164            "src/main/scala/foo/Main.scala",
165        ]);
166        let result = ScalaResolver.resolve(&import("foo.Bar"), "Main.scala", &file_index);
167        assert_eq!(result, Some("src/main/scala/foo/Bar.scala".into()));
168    }
169
170    #[test]
171    fn multi_project_subproject_resolves() {
172        let mut file_index = idx(&[
173            "myproject/src/main/scala/foo/Bar.scala",
174            "myproject/src/main/scala/foo/Main.scala",
175        ]);
176        file_index.set_scala_source_roots(vec!["myproject/src/main/scala/".to_string()]);
177        let result = ScalaResolver.resolve(&import("foo.Bar"), "Main.scala", &file_index);
178        assert_eq!(
179            result,
180            Some("myproject/src/main/scala/foo/Bar.scala".into())
181        );
182    }
183
184    #[test]
185    fn shared_directory_resolves() {
186        // The zio-json pattern: subproject/shared/src/main/scala/
187        let mut file_index = idx(&[
188            "zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala",
189            "zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala",
190        ]);
191        file_index.set_scala_source_roots(vec!["zio-json/shared/src/main/scala/".to_string()]);
192        let result = ScalaResolver.resolve(
193            &import("zio.json.JsonDecoder"),
194            "zio-json/shared/src/main/scala/zio/json/JsonEncoder.scala",
195            &file_index,
196        );
197        assert_eq!(
198            result,
199            Some("zio-json/shared/src/main/scala/zio/json/JsonDecoder.scala".into())
200        );
201    }
202
203    #[test]
204    fn scala_version_specific_root_resolves() {
205        let mut file_index = idx(&[
206            "myproject/src/main/scala-2.13/foo/Compat.scala",
207            "myproject/src/main/scala/foo/Main.scala",
208        ]);
209        file_index.set_scala_source_roots(vec![
210            "myproject/src/main/scala-2.13/".to_string(),
211            "myproject/src/main/scala/".to_string(),
212        ]);
213        let result = ScalaResolver.resolve(&import("foo.Compat"), "Main.scala", &file_index);
214        assert_eq!(
215            result,
216            Some("myproject/src/main/scala-2.13/foo/Compat.scala".into())
217        );
218    }
219
220    #[test]
221    fn test_source_root_resolves() {
222        let mut file_index = idx(&[
223            "myproject/src/test/scala/foo/BarSpec.scala",
224            "myproject/src/main/scala/foo/Bar.scala",
225        ]);
226        file_index.set_scala_source_roots(vec![
227            "myproject/src/main/scala/".to_string(),
228            "myproject/src/test/scala/".to_string(),
229        ]);
230        let result = ScalaResolver.resolve(&import("foo.BarSpec"), "Main.scala", &file_index);
231        assert_eq!(
232            result,
233            Some("myproject/src/test/scala/foo/BarSpec.scala".into())
234        );
235    }
236
237    #[test]
238    fn cross_source_root_imports_resolve() {
239        // Integration: imports from one source root resolve targets in another.
240        let mut file_index = idx(&[
241            "core/src/main/scala/com/acme/Model.scala",
242            "web/src/main/scala/com/acme/Controller.scala",
243        ]);
244        file_index.set_scala_source_roots(vec![
245            "core/src/main/scala/".to_string(),
246            "web/src/main/scala/".to_string(),
247        ]);
248        // Controller imports Model across sub-projects.
249        let result = ScalaResolver.resolve(
250            &import("com.acme.Model"),
251            "web/src/main/scala/com/acme/Controller.scala",
252            &file_index,
253        );
254        assert_eq!(
255            result,
256            Some("core/src/main/scala/com/acme/Model.scala".into())
257        );
258    }
259}