normalize_manifest/
mix_exs.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8pub struct MixExsParser;
10
11impl ManifestParser for MixExsParser {
12 fn filename(&self) -> &'static str {
13 "mix.exs"
14 }
15
16 fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
17 let mut name = None;
18 let mut version = None;
19 let mut deps = Vec::new();
20 let mut in_deps_fn = false;
21 let mut brace_depth: i32 = 0;
22
23 for line in content.lines() {
24 let trimmed = line.trim();
25
26 if trimmed.starts_with("app:")
28 && trimmed.contains(':')
29 && let Some(app_name) = extract_atom_or_string(trimmed, "app:")
30 {
31 name = Some(app_name);
32 }
33 if trimmed.starts_with("version:")
34 && let Some(ver) = extract_atom_or_string(trimmed, "version:")
35 {
36 version = Some(ver);
37 }
38
39 if trimmed.starts_with("defp deps") || trimmed == "def deps do" {
41 in_deps_fn = true;
42 brace_depth = 0;
43 }
44
45 if in_deps_fn {
46 for ch in trimmed.chars() {
47 match ch {
48 '[' | '{' => brace_depth += 1,
49 ']' | '}' => {
50 brace_depth -= 1;
51 if brace_depth < 0 {
52 brace_depth = 0;
53 }
54 }
55 _ => {}
56 }
57 }
58
59 if trimmed.contains('{')
61 && trimmed.contains(':')
62 && let Some(dep) = parse_mix_dep(trimmed)
63 {
64 deps.push(dep);
65 }
66
67 if trimmed == "end" && brace_depth == 0 {
69 in_deps_fn = false;
70 }
71 }
72 }
73
74 Ok(ParsedManifest {
75 ecosystem: "hex",
76 name,
77 version,
78 dependencies: deps,
79 })
80 }
81}
82
83fn extract_atom_or_string(line: &str, prefix: &str) -> Option<String> {
84 let rest = line.split_once(prefix)?.1.trim();
85 if let Some(atom_rest) = rest.strip_prefix(':') {
87 let atom = atom_rest
88 .split(|c: char| !c.is_alphanumeric() && c != '_')
89 .next()?;
90 return Some(atom.to_string());
91 }
92 if let Some(inner) = rest.strip_prefix('"') {
93 let end = inner.find('"')?;
94 return Some(inner[..end].to_string());
95 }
96 None
97}
98
99fn parse_mix_dep(line: &str) -> Option<DeclaredDep> {
100 let brace_start = line.find('{')? + 1;
102 let inner = line[brace_start..].trim();
103
104 if !inner.starts_with(':') {
105 return None;
106 }
107 let name_end = inner[1..].find(|c: char| !c.is_alphanumeric() && c != '_')? + 1;
108 let name = inner[1..name_end].to_string();
109 if name.is_empty() {
110 return None;
111 }
112
113 let kind = if line.contains("only: :dev")
115 || line.contains("only: :test")
116 || line.contains("only: :docs")
117 || line.contains("only: [:dev")
118 || line.contains("only: [:test")
119 || line.contains("only: [:docs")
120 {
121 DepKind::Dev
122 } else {
123 DepKind::Normal
124 };
125
126 let after_name = &inner[name_end..];
128 let version_req = if let Some(q_start) = after_name.find('"') {
129 let rest = &after_name[q_start + 1..];
130 rest.find('"').map(|q_end| rest[..q_end].to_string())
131 } else {
132 None
133 };
134
135 Some(DeclaredDep {
136 name,
137 version_req,
138 kind,
139 })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::ManifestParser;
146
147 #[test]
148 fn test_parse_mix_exs() {
149 let content = r#"defmodule MyApp.MixProject do
150 use Mix.Project
151
152 def project do
153 [
154 app: :my_app,
155 version: "0.1.0",
156 elixir: "~> 1.14",
157 deps: deps()
158 ]
159 end
160
161 defp deps do
162 [
163 {:phoenix, "~> 1.7"},
164 {:ecto_sql, "~> 3.10"},
165 {:postgrex, ">= 0.0.0"},
166 {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
167 {:ex_doc, "~> 0.27", only: :dev, runtime: false}
168 ]
169 end
170end
171"#;
172 let m = MixExsParser.parse(content).unwrap();
173 assert_eq!(m.ecosystem, "hex");
174 assert_eq!(m.name.as_deref(), Some("my_app"));
175 assert_eq!(m.version.as_deref(), Some("0.1.0"));
176
177 let phoenix = m.dependencies.iter().find(|d| d.name == "phoenix").unwrap();
178 assert_eq!(phoenix.version_req.as_deref(), Some("~> 1.7"));
179 assert_eq!(phoenix.kind, DepKind::Normal);
180
181 let credo = m.dependencies.iter().find(|d| d.name == "credo").unwrap();
182 assert_eq!(credo.kind, DepKind::Dev);
183
184 let ex_doc = m.dependencies.iter().find(|d| d.name == "ex_doc").unwrap();
185 assert_eq!(ex_doc.kind, DepKind::Dev);
186 }
187}