Skip to main content

mati_core/analysis/resolvers/
elixir.rs

1//! Elixir import resolver.
2//!
3//! Converts CamelCase module names to snake_case file paths under `lib/`.
4//! Module names matching known Elixir/Erlang stdlib or common framework
5//! prefixes are skipped (Phoenix, Ecto, Plug, etc.).
6
7use super::{camel_to_snake, FileIndex, LanguageResolver};
8use crate::analysis::parser::ImportStatement;
9use crate::analysis::walker::Language;
10
11pub struct ElixirResolver;
12
13impl LanguageResolver for ElixirResolver {
14    fn resolve(
15        &self,
16        import: &ImportStatement,
17        _importing_file: &str,
18        file_index: &FileIndex,
19    ) -> Option<String> {
20        resolve_elixir(&import.path, file_index)
21    }
22
23    fn language(&self) -> Language {
24        Language::Elixir
25    }
26
27    fn name(&self) -> &'static str {
28        "elixir"
29    }
30}
31
32fn resolve_elixir(module_path: &str, file_index: &FileIndex) -> Option<String> {
33    if is_elixir_stdlib(module_path) {
34        return None;
35    }
36
37    // Convert MyApp.Router → my_app/router
38    let segments: Vec<String> = module_path.split('.').map(camel_to_snake).collect();
39    let rel = segments.join("/");
40
41    // Try under lib/: lib/my_app/router.ex
42    let lib_ex = format!("lib/{rel}.ex");
43    if file_index.contains(&lib_ex) {
44        return Some(lib_ex);
45    }
46
47    // Try .exs (test/script files)
48    let lib_exs = format!("lib/{rel}.exs");
49    if file_index.contains(&lib_exs) {
50        return Some(lib_exs);
51    }
52
53    // Try without lib/ prefix
54    let direct_ex = format!("{rel}.ex");
55    if file_index.contains(&direct_ex) {
56        return Some(direct_ex);
57    }
58
59    None
60}
61
62fn is_elixir_stdlib(module: &str) -> bool {
63    let first = module.split('.').next().unwrap_or(module);
64    matches!(
65        first,
66        "Absinthe"
67            | "Access"
68            | "Agent"
69            | "Application"
70            | "Atom"
71            | "Base"
72            | "Bitwise"
73            | "Broadway"
74            | "Code"
75            | "Collectable"
76            | "Date"
77            | "DateTime"
78            | "DynamicSupervisor"
79            | "Ecto"
80            | "Enum"
81            | "Enumerable"
82            | "ETS"
83            | "Exception"
84            | "ExUnit"
85            | "File"
86            | "Finch"
87            | "Float"
88            | "Flow"
89            | "GenServer"
90            | "GenStage"
91            | "HTTPoison"
92            | "IEx"
93            | "IO"
94            | "Inspect"
95            | "Integer"
96            | "Jason"
97            | "Kernel"
98            | "Keyword"
99            | "List"
100            | "LiveBook"
101            | "LiveView"
102            | "Logger"
103            | "Macro"
104            | "Map"
105            | "MapSet"
106            | "Mix"
107            | "Module"
108            | "NaiveDateTime"
109            | "Node"
110            | "Oban"
111            | "Path"
112            | "Phoenix"
113            | "Plug"
114            | "Poison"
115            | "Port"
116            | "Process"
117            | "Protocol"
118            | "Range"
119            | "Regex"
120            | "Registry"
121            | "Stream"
122            | "String"
123            | "Supervisor"
124            | "System"
125            | "Task"
126            | "Time"
127            | "Tuple"
128            | "URI"
129    )
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::analysis::parser::import::ImportKind;
136
137    fn idx(paths: &[&str]) -> FileIndex {
138        FileIndex::new(paths.iter().map(|s| s.to_string()))
139    }
140
141    fn import(path: &str) -> ImportStatement {
142        ImportStatement::new(path, ImportKind::Normal, 1)
143    }
144
145    // ── camel_to_snake tests ───────────────────────────────────────────────
146
147    #[test]
148    fn camel_to_snake_simple() {
149        assert_eq!(camel_to_snake("MyModule"), "my_module");
150    }
151
152    #[test]
153    fn camel_to_snake_acronym_start() {
154        assert_eq!(camel_to_snake("HTTPServer"), "http_server");
155    }
156
157    #[test]
158    fn camel_to_snake_acronym_only() {
159        assert_eq!(camel_to_snake("HTTP"), "http");
160    }
161
162    #[test]
163    fn camel_to_snake_mixed() {
164        assert_eq!(camel_to_snake("XMLParserV2"), "xml_parser_v2");
165    }
166
167    #[test]
168    fn camel_to_snake_single_word() {
169        assert_eq!(camel_to_snake("User"), "user");
170    }
171
172    #[test]
173    fn camel_to_snake_my_app() {
174        assert_eq!(camel_to_snake("MyApp"), "my_app");
175    }
176
177    #[test]
178    fn camel_to_snake_router() {
179        assert_eq!(camel_to_snake("Router"), "router");
180    }
181
182    // ── stdlib skip tests ──────────────────────────────────────────────────
183
184    #[test]
185    fn stdlib_skipped() {
186        let file_index = idx(&["lib/my_app.ex"]);
187        assert_eq!(
188            ElixirResolver.resolve(&import("Enum"), "lib/my_app.ex", &file_index),
189            None
190        );
191        assert_eq!(
192            ElixirResolver.resolve(&import("GenServer"), "lib/my_app.ex", &file_index),
193            None
194        );
195    }
196
197    #[test]
198    fn phoenix_skipped() {
199        let file_index = idx(&["lib/my_app.ex"]);
200        assert_eq!(
201            ElixirResolver.resolve(&import("Phoenix.Router"), "lib/my_app.ex", &file_index),
202            None
203        );
204    }
205
206    #[test]
207    fn ecto_skipped() {
208        let file_index = idx(&["lib/my_app.ex"]);
209        assert_eq!(
210            ElixirResolver.resolve(&import("Ecto.Schema"), "lib/my_app.ex", &file_index),
211            None
212        );
213    }
214
215    #[test]
216    fn plug_skipped() {
217        let file_index = idx(&["lib/my_app.ex"]);
218        assert_eq!(
219            ElixirResolver.resolve(&import("Plug.Conn"), "lib/my_app.ex", &file_index),
220            None
221        );
222    }
223
224    #[test]
225    fn absinthe_skipped() {
226        let file_index = idx(&["lib/my_app.ex"]);
227        assert_eq!(
228            ElixirResolver.resolve(&import("Absinthe.Schema"), "lib/my_app.ex", &file_index),
229            None
230        );
231    }
232
233    #[test]
234    fn broadway_skipped() {
235        let file_index = idx(&["lib/my_app.ex"]);
236        assert_eq!(
237            ElixirResolver.resolve(&import("Broadway"), "lib/my_app.ex", &file_index),
238            None
239        );
240    }
241
242    #[test]
243    fn oban_skipped() {
244        let file_index = idx(&["lib/my_app.ex"]);
245        assert_eq!(
246            ElixirResolver.resolve(&import("Oban.Worker"), "lib/my_app.ex", &file_index),
247            None
248        );
249    }
250
251    #[test]
252    fn ex_unit_skipped() {
253        let file_index = idx(&["lib/my_app.ex"]);
254        assert_eq!(
255            ElixirResolver.resolve(&import("ExUnit.Case"), "lib/my_app.ex", &file_index),
256            None
257        );
258    }
259
260    #[test]
261    fn mix_skipped() {
262        let file_index = idx(&["lib/my_app.ex"]);
263        assert_eq!(
264            ElixirResolver.resolve(&import("Mix.Task"), "lib/my_app.ex", &file_index),
265            None
266        );
267    }
268
269    #[test]
270    fn jason_skipped() {
271        let file_index = idx(&["lib/my_app.ex"]);
272        assert_eq!(
273            ElixirResolver.resolve(&import("Jason"), "lib/my_app.ex", &file_index),
274            None
275        );
276    }
277
278    // ── Resolution tests ───────────────────────────────────────────────────
279
280    #[test]
281    fn local_module_resolves() {
282        let file_index = idx(&["lib/my_app/router.ex"]);
283        let result = ElixirResolver.resolve(&import("MyApp.Router"), "lib/my_app.ex", &file_index);
284        assert_eq!(result, Some("lib/my_app/router.ex".into()));
285    }
286
287    #[test]
288    fn single_segment_local_resolves() {
289        let file_index = idx(&["lib/my_app.ex"]);
290        let result = ElixirResolver.resolve(&import("MyApp"), "lib/other.ex", &file_index);
291        assert_eq!(result, Some("lib/my_app.ex".into()));
292    }
293
294    #[test]
295    fn acronym_module_resolves() {
296        let file_index = idx(&["lib/my_app/http_server.ex"]);
297        let result =
298            ElixirResolver.resolve(&import("MyApp.HTTPServer"), "lib/my_app.ex", &file_index);
299        assert_eq!(result, Some("lib/my_app/http_server.ex".into()));
300    }
301
302    #[test]
303    fn xml_parser_module_resolves() {
304        let file_index = idx(&["lib/my_app/xml_parser.ex"]);
305        let result =
306            ElixirResolver.resolve(&import("MyApp.XMLParser"), "lib/my_app.ex", &file_index);
307        assert_eq!(result, Some("lib/my_app/xml_parser.ex".into()));
308    }
309
310    #[test]
311    fn nonexistent_returns_none() {
312        let file_index = idx(&["lib/my_app.ex"]);
313        assert_eq!(
314            ElixirResolver.resolve(&import("Missing.Module"), "lib/my_app.ex", &file_index),
315            None
316        );
317    }
318}