1use anyhow::Result;
2use regex::Regex;
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct ElixirResolver;
13impl super::sealed::Sealed for ElixirResolver {}
14
15impl Resolver for ElixirResolver {
16 fn ecosystem(&self) -> Ecosystem {
17 Ecosystem::Elixir
18 }
19
20 fn detect(&self, root: &Path) -> bool {
21 root.join("mix.exs").exists() && root.join("apps").is_dir()
22 }
23
24 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
25 let apps_dir = root.join("apps");
26
27 let mut app_dirs = Vec::new();
29 for entry in std::fs::read_dir(&apps_dir)? {
30 let entry = entry?;
31 let path = entry.path();
32 if path.is_dir() && path.join("mix.exs").exists() {
33 app_dirs.push(path);
34 }
35 }
36
37 let mut packages = HashMap::new();
38 let mut name_to_id: HashMap<String, PackageId> = HashMap::new();
39 let mut app_contents: HashMap<PackageId, String> = HashMap::new();
40
41 for app_dir in &app_dirs {
42 let dir_name = app_dir
43 .file_name()
44 .unwrap_or_default()
45 .to_string_lossy()
46 .to_string();
47
48 let mix_path = app_dir.join("mix.exs");
49 let content = std::fs::read_to_string(&mix_path)?;
50
51 let app_name = parse_app_name(&content, &dir_name);
52 let rel_path = format!("apps/{}", dir_name);
53 let pkg_id = PackageId(rel_path);
54
55 tracing::debug!("Elixir: discovered app '{}' at {:?}", app_name, app_dir);
56
57 name_to_id.insert(app_name.clone(), pkg_id.clone());
58 app_contents.insert(pkg_id.clone(), content);
59 packages.insert(
60 pkg_id.clone(),
61 Package {
62 id: pkg_id,
63 name: app_name,
64 version: None,
65 path: app_dir.clone(),
66 manifest_path: mix_path,
67 },
68 );
69 }
70
71 let known_apps: HashSet<&str> = name_to_id.keys().map(|s| s.as_str()).collect();
73 let mut edges = Vec::new();
74
75 for (pkg_id, content) in &app_contents {
76 let deps = parse_umbrella_deps(content);
77 for dep_name in &deps {
78 if known_apps.contains(dep_name.as_str()) {
79 if let Some(to_id) = name_to_id.get(dep_name) {
80 edges.push((pkg_id.clone(), to_id.clone()));
81 }
82 }
83 }
84 }
85
86 Ok(ProjectGraph {
87 packages,
88 edges,
89 root: root.to_path_buf(),
90 })
91 }
92
93 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
94 file_to_package(graph, file)
95 }
96
97 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
98 let app_name = package_id.0.strip_prefix("apps/").unwrap_or(&package_id.0);
100 vec![
101 "mix".into(),
102 "cmd".into(),
103 "--app".into(),
104 app_name.into(),
105 "mix".into(),
106 "test".into(),
107 ]
108 }
109}
110
111fn parse_app_name(content: &str, dir_name: &str) -> String {
116 let re = Regex::new(r"app:\s*:([\w]+)").expect("invalid regex");
117 re.captures(content)
118 .and_then(|caps| caps.get(1))
119 .map(|m| m.as_str().to_string())
120 .unwrap_or_else(|| dir_name.to_string())
121}
122
123fn parse_umbrella_deps(content: &str) -> Vec<String> {
129 let mut deps = Vec::new();
130
131 let re_umbrella = Regex::new(r#"\{:([\w]+),\s*in_umbrella:\s*true\}"#).expect("invalid regex");
133 for caps in re_umbrella.captures_iter(content) {
134 if let Some(m) = caps.get(1) {
135 deps.push(m.as_str().to_string());
136 }
137 }
138
139 let re_path = Regex::new(r#"\{:([\w]+),.*?path:\s*""#).expect("invalid regex");
141 for caps in re_path.captures_iter(content) {
142 if let Some(m) = caps.get(1) {
143 let name = m.as_str().to_string();
144 if !deps.contains(&name) {
145 deps.push(name);
146 }
147 }
148 }
149
150 deps
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_detect_umbrella() {
159 let dir = tempfile::tempdir().unwrap();
160 std::fs::write(
161 dir.path().join("mix.exs"),
162 r#"defmodule MyUmbrella.MixProject do
163 use Mix.Project
164
165 def project do
166 [apps_path: "apps"]
167 end
168end"#,
169 )
170 .unwrap();
171 std::fs::create_dir_all(dir.path().join("apps")).unwrap();
172
173 assert!(ElixirResolver.detect(dir.path()));
174 }
175
176 #[test]
177 fn test_detect_no_apps_dir() {
178 let dir = tempfile::tempdir().unwrap();
179 std::fs::write(
180 dir.path().join("mix.exs"),
181 r#"defmodule MyApp.MixProject do
182 use Mix.Project
183end"#,
184 )
185 .unwrap();
186
187 assert!(!ElixirResolver.detect(dir.path()));
188 }
189
190 #[test]
191 fn test_detect_no_elixir() {
192 let dir = tempfile::tempdir().unwrap();
193 assert!(!ElixirResolver.detect(dir.path()));
194 }
195
196 #[test]
197 fn test_parse_app_name() {
198 let content = r#"defmodule Api.MixProject do
199 use Mix.Project
200
201 def project do
202 [
203 app: :api,
204 version: "0.1.0",
205 deps: deps()
206 ]
207 end
208end"#;
209
210 assert_eq!(parse_app_name(content, "fallback"), "api");
211 }
212
213 #[test]
214 fn test_parse_app_name_fallback() {
215 let content = r#"defmodule Weird.MixProject do
216 use Mix.Project
217end"#;
218
219 assert_eq!(parse_app_name(content, "my_dir"), "my_dir");
220 }
221
222 #[test]
223 fn test_parse_umbrella_deps() {
224 let content = r#"defmodule Api.MixProject do
225 use Mix.Project
226
227 def project do
228 [
229 app: :api,
230 version: "0.1.0",
231 deps: deps()
232 ]
233 end
234
235 defp deps do
236 [
237 {:core, in_umbrella: true},
238 {:shared, in_umbrella: true},
239 {:phoenix, "~> 1.7"},
240 {:utils, path: "../utils"}
241 ]
242 end
243end"#;
244
245 let deps = parse_umbrella_deps(content);
246 assert_eq!(deps, vec!["core", "shared", "utils"]);
247 }
248
249 #[test]
250 fn test_resolve_umbrella_project() {
251 let dir = tempfile::tempdir().unwrap();
252
253 std::fs::write(
255 dir.path().join("mix.exs"),
256 r#"defmodule MyUmbrella.MixProject do
257 use Mix.Project
258
259 def project do
260 [apps_path: "apps"]
261 end
262end"#,
263 )
264 .unwrap();
265
266 let apps = dir.path().join("apps");
268 std::fs::create_dir_all(apps.join("core")).unwrap();
269 std::fs::write(
270 apps.join("core/mix.exs"),
271 r#"defmodule Core.MixProject do
272 use Mix.Project
273
274 def project do
275 [
276 app: :core,
277 version: "0.1.0",
278 deps: deps()
279 ]
280 end
281
282 defp deps do
283 [
284 {:jason, "~> 1.4"}
285 ]
286 end
287end"#,
288 )
289 .unwrap();
290
291 std::fs::create_dir_all(apps.join("shared")).unwrap();
292 std::fs::write(
293 apps.join("shared/mix.exs"),
294 r#"defmodule Shared.MixProject do
295 use Mix.Project
296
297 def project do
298 [
299 app: :shared,
300 version: "0.1.0",
301 deps: deps()
302 ]
303 end
304
305 defp deps do
306 [
307 {:core, in_umbrella: true}
308 ]
309 end
310end"#,
311 )
312 .unwrap();
313
314 std::fs::create_dir_all(apps.join("api")).unwrap();
315 std::fs::write(
316 apps.join("api/mix.exs"),
317 r#"defmodule Api.MixProject do
318 use Mix.Project
319
320 def project do
321 [
322 app: :api,
323 version: "0.1.0",
324 deps: deps()
325 ]
326 end
327
328 defp deps do
329 [
330 {:core, in_umbrella: true},
331 {:shared, in_umbrella: true},
332 {:phoenix, "~> 1.7"}
333 ]
334 end
335end"#,
336 )
337 .unwrap();
338
339 let graph = ElixirResolver.resolve(dir.path()).unwrap();
340
341 assert_eq!(graph.packages.len(), 3);
343 assert!(graph.packages.contains_key(&PackageId("apps/core".into())));
344 assert!(graph
345 .packages
346 .contains_key(&PackageId("apps/shared".into())));
347 assert!(graph.packages.contains_key(&PackageId("apps/api".into())));
348
349 assert_eq!(graph.packages[&PackageId("apps/core".into())].name, "core");
351 assert_eq!(
352 graph.packages[&PackageId("apps/shared".into())].name,
353 "shared"
354 );
355 assert_eq!(graph.packages[&PackageId("apps/api".into())].name, "api");
356
357 assert!(graph.edges.contains(&(
359 PackageId("apps/shared".into()),
360 PackageId("apps/core".into()),
361 )));
362
363 assert!(graph
365 .edges
366 .contains(&(PackageId("apps/api".into()), PackageId("apps/core".into()),)));
367 assert!(graph.edges.contains(&(
368 PackageId("apps/api".into()),
369 PackageId("apps/shared".into()),
370 )));
371
372 let core_edges: Vec<_> = graph
374 .edges
375 .iter()
376 .filter(|(from, _)| from == &PackageId("apps/core".into()))
377 .collect();
378 assert!(core_edges.is_empty());
379 }
380
381 #[test]
382 fn test_test_command() {
383 let cmd = ElixirResolver.test_command(&PackageId("apps/api".into()));
384 assert_eq!(cmd, vec!["mix", "cmd", "--app", "api", "mix", "test"]);
385 }
386}