Skip to main content

normalize_manifest/
mix_exs.rs

1//! Parser for `mix.exs` files (Elixir/Hex).
2//!
3//! Extracts `{:pkg, "~> 1.0"}` tuples from the `deps/0` function.
4//! Detects dev/test deps via `only: :dev`, `only: :test`, or `only: [:dev, :test]`.
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `mix.exs` files.
9pub 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            // Detect app name and version from project/0
27            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            // Detect start of deps/0 function
40            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                // Parse dep tuple: {:pkg_name, "~> 1.0"} or {:pkg, git: "...", only: :dev}
60                if trimmed.contains('{')
61                    && trimmed.contains(':')
62                    && let Some(dep) = parse_mix_dep(trimmed)
63                {
64                    deps.push(dep);
65                }
66
67                // Exit deps function when indentation returns to base (simplified: look for `end`)
68                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    // Atom: :my_app  or  string: "my_app"
86    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    // Find `{:atom_name` opening
101    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    // Determine kind from `only:` annotation
114    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    // Extract version string if present: first `"..."` after the name
127    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}