Skip to main content

normalize_manifest/
erlang.rs

1//! Parser for `rebar.config` files (Erlang/rebar3).
2//!
3//! Heuristic Erlang term parsing:
4//! - `{deps, [...]}` → `DepKind::Normal`
5//! - `{profiles, [{dev, [{deps, [...]}]}, {test, [{deps, [...]}]}]}` → `DepKind::Dev`
6//!
7//! Dep formats recognized:
8//! - `{name, "version"}` — hex package with version
9//! - `{name, {git, URL, {tag, "version"}}}` — git dep with tag
10//! - `name` — bare atom (no version)
11
12use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
13
14/// Parser for `rebar.config` files (Erlang/rebar3).
15pub struct RebarConfigParser;
16
17impl ManifestParser for RebarConfigParser {
18    fn filename(&self) -> &'static str {
19        "rebar.config"
20    }
21
22    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
23        let mut deps: Vec<DeclaredDep> = Vec::new();
24
25        // Find top-level {deps, [...]}
26        if let Some(top_deps) = find_top_level_deps(content) {
27            extract_rebar_deps(&top_deps, DepKind::Normal, &mut deps);
28        }
29
30        // Find {profiles, [...]} and extract dev/test deps
31        if let Some(profiles) = find_profiles_block(content) {
32            extract_profile_deps(&profiles, &mut deps);
33        }
34
35        Ok(ParsedManifest {
36            ecosystem: "hex",
37            name: None,
38            version: None,
39            dependencies: deps,
40        })
41    }
42}
43
44/// Find the content of the top-level `{deps, [...]}` tuple.
45fn find_top_level_deps(content: &str) -> Option<String> {
46    // Look for `{deps,` at low brace depth
47    let mut depth = 0i32;
48    let chars: Vec<char> = content.chars().collect();
49    let total = chars.len();
50    let mut i = 0;
51
52    while i < total {
53        match chars[i] {
54            '{' => {
55                depth += 1;
56                // At depth 1 opening a new tuple
57                if depth == 1 {
58                    // Check if this is {deps, ...}
59                    let rest: String = chars[i..].iter().take(10).collect();
60                    let rest_trimmed = rest.trim_start_matches('{').trim_start();
61                    if rest_trimmed.starts_with("deps") {
62                        // Find the deps list
63                        if let Some(bracket) = chars[i..].iter().position(|&c| c == '[') {
64                            let list_start = i + bracket;
65                            let list = extract_bracket_content(&chars[list_start..]);
66                            return Some(list);
67                        }
68                    }
69                }
70            }
71            '}' => depth -= 1,
72            '%' => {
73                // Erlang comment — skip to end of line
74                while i < total && chars[i] != '\n' {
75                    i += 1;
76                }
77            }
78            _ => {}
79        }
80        i += 1;
81    }
82    None
83}
84
85/// Find the content inside the `{profiles, [...]}` tuple.
86fn find_profiles_block(content: &str) -> Option<String> {
87    let chars: Vec<char> = content.chars().collect();
88    let total = chars.len();
89    let mut i = 0;
90    let mut depth = 0i32;
91
92    while i < total {
93        match chars[i] {
94            '{' => {
95                depth += 1;
96                if depth == 1 {
97                    let rest: String = chars[i..].iter().take(12).collect();
98                    let inner = rest.trim_start_matches('{').trim_start();
99                    if inner.starts_with("profiles") {
100                        // Find the list [...]
101                        if let Some(bracket) = chars[i..].iter().position(|&c| c == '[') {
102                            let list_start = i + bracket;
103                            let list = extract_bracket_content(&chars[list_start..]);
104                            return Some(list);
105                        }
106                    }
107                }
108            }
109            '}' => depth -= 1,
110            '%' => {
111                while i < total && chars[i] != '\n' {
112                    i += 1;
113                }
114            }
115            _ => {}
116        }
117        i += 1;
118    }
119    None
120}
121
122/// Extract profile deps from the content of `{profiles, [...]}`.
123fn extract_profile_deps(profiles_content: &str, deps: &mut Vec<DeclaredDep>) {
124    let chars: Vec<char> = profiles_content.chars().collect();
125    let total = chars.len();
126    let mut i = 0;
127
128    while i < total {
129        if chars[i] == '{' {
130            // Read the profile name atom
131            let tuple_start = i + 1;
132            let atom_end = chars[tuple_start..]
133                .iter()
134                .position(|&c| c == ',' || c.is_whitespace())
135                .map(|p| tuple_start + p)
136                .unwrap_or(total);
137
138            let profile_name: String = chars[tuple_start..atom_end].iter().collect();
139            let profile_name = profile_name.trim();
140
141            let is_dev = matches!(profile_name, "dev" | "test");
142
143            // Inside this profile tuple, look for {deps, [...]}
144            let tuple_content = extract_brace_content(&chars[i..]);
145            if let Some(dep_list) = find_deps_in_string(&tuple_content) {
146                let kind = if is_dev {
147                    DepKind::Dev
148                } else {
149                    DepKind::Normal
150                };
151                extract_rebar_deps(&dep_list, kind, deps);
152            }
153
154            // Skip past this tuple
155            let consumed = brace_len(&chars[i..]);
156            i += consumed;
157            continue;
158        }
159        i += 1;
160    }
161}
162
163/// Find `{deps, [...]}` inside a string and return the bracket content.
164fn find_deps_in_string(s: &str) -> Option<String> {
165    let chars: Vec<char> = s.chars().collect();
166    let total = chars.len();
167    let mut i = 0;
168
169    while i < total {
170        match chars[i] {
171            '{' => {
172                let rest: String = chars[i..].iter().take(8).collect();
173                let inner = rest.trim_start_matches('{').trim_start();
174                if inner.starts_with("deps")
175                    && let Some(bracket) = chars[i..].iter().position(|&c| c == '[')
176                {
177                    let list_start = i + bracket;
178                    return Some(extract_bracket_content(&chars[list_start..]));
179                }
180            }
181            '}' => {}
182
183            _ => {}
184        }
185        i += 1;
186    }
187    None
188}
189
190/// Extract deps from a `[...]` dep list string.
191fn extract_rebar_deps(list_content: &str, kind: DepKind, out: &mut Vec<DeclaredDep>) {
192    let chars: Vec<char> = list_content.chars().collect();
193    let total = chars.len();
194    let mut i = 0;
195
196    // Skip opening `[`
197    if !chars.is_empty() && chars[0] == '[' {
198        i = 1;
199    }
200
201    while i < total {
202        // Skip whitespace and commas
203        while i < total && (chars[i].is_whitespace() || chars[i] == ',') {
204            i += 1;
205        }
206        if i >= total || chars[i] == ']' {
207            break;
208        }
209
210        if chars[i] == '%' {
211            while i < total && chars[i] != '\n' {
212                i += 1;
213            }
214            continue;
215        }
216
217        if chars[i] == '{' {
218            // Tuple dep: {name, "version"} or {name, {git, ...}}
219            let tuple = extract_brace_content(&chars[i..]);
220            if let Some(dep) = parse_rebar_dep_tuple(&tuple, kind) {
221                out.push(dep);
222            }
223            let consumed = brace_len(&chars[i..]);
224            i += consumed;
225        } else {
226            // Bare atom dep
227            let atom_start = i;
228            while i < total
229                && !chars[i].is_whitespace()
230                && chars[i] != ','
231                && chars[i] != ']'
232                && chars[i] != '}'
233            {
234                i += 1;
235            }
236            let atom: String = chars[atom_start..i].iter().collect();
237            let atom = atom.trim();
238            if !atom.is_empty() {
239                out.push(DeclaredDep {
240                    name: atom.to_string(),
241                    version_req: None,
242                    kind,
243                });
244            }
245        }
246    }
247}
248
249/// Parse `{name, "version"}` or `{name, {git, URL, {tag, "version"}}}`.
250fn parse_rebar_dep_tuple(s: &str, kind: DepKind) -> Option<DeclaredDep> {
251    // Strip outer braces
252    let inner = s
253        .trim()
254        .trim_start_matches('{')
255        .trim_end_matches('}')
256        .trim();
257
258    // First token is the dep name atom
259    let comma_pos = inner.find(',')?;
260    let name = inner[..comma_pos].trim().to_string();
261    if name.is_empty() {
262        return None;
263    }
264
265    let rest = inner[comma_pos + 1..].trim();
266
267    // `"version"` — simple hex version
268    if rest.starts_with('"') {
269        let ver = rest.trim_matches('"').to_string();
270        return Some(DeclaredDep {
271            name,
272            version_req: if ver.is_empty() { None } else { Some(ver) },
273            kind,
274        });
275    }
276
277    // `{git, URL, {tag, "version"}}` — extract tag version
278    if rest.starts_with('{') {
279        let tag_ver = extract_git_tag_version(rest);
280        return Some(DeclaredDep {
281            name,
282            version_req: tag_ver,
283            kind,
284        });
285    }
286
287    // Fallback: dep name only
288    Some(DeclaredDep {
289        name,
290        version_req: None,
291        kind,
292    })
293}
294
295/// Extract the tag version from `{git, URL, {tag, "3.9.2"}}`.
296fn extract_git_tag_version(s: &str) -> Option<String> {
297    let tag_pos = s.find("tag")?;
298    let after_tag = &s[tag_pos + 3..].trim_start();
299    // After "tag" there may be a comma then the version
300    let after_comma = after_tag.trim_start_matches(',').trim_start();
301    if let Some(inner) = after_comma.strip_prefix('"') {
302        let end = inner.find('"')?;
303        return Some(inner[..end].to_string());
304    }
305    None
306}
307
308/// Extract the full content (including outer `{...}`) as a string from a char slice.
309fn extract_brace_content(chars: &[char]) -> String {
310    let mut depth = 0i32;
311    let mut result = String::new();
312    for &ch in chars {
313        match ch {
314            '{' => depth += 1,
315            '}' => {
316                depth -= 1;
317                result.push(ch);
318                if depth == 0 {
319                    return result;
320                }
321                continue;
322            }
323            _ => {}
324        }
325        result.push(ch);
326    }
327    result
328}
329
330/// Return number of chars consumed by one `{...}` block.
331fn brace_len(chars: &[char]) -> usize {
332    let mut depth = 0i32;
333    for (i, &ch) in chars.iter().enumerate() {
334        match ch {
335            '{' => depth += 1,
336            '}' => {
337                depth -= 1;
338                if depth == 0 {
339                    return i + 1;
340                }
341            }
342            _ => {}
343        }
344    }
345    chars.len()
346}
347
348/// Extract the full content (including outer `[...]`) as a string from a char slice.
349fn extract_bracket_content(chars: &[char]) -> String {
350    let mut depth = 0i32;
351    let mut result = String::new();
352    for &ch in chars {
353        match ch {
354            '[' => depth += 1,
355            ']' => {
356                depth -= 1;
357                result.push(ch);
358                if depth == 0 {
359                    return result;
360                }
361                continue;
362            }
363            _ => {}
364        }
365        result.push(ch);
366    }
367    result
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::ManifestParser;
374
375    #[test]
376    fn test_parse_rebar_config() {
377        let content = r#"{deps, [
378    {cowboy, "2.10.0"},
379    {jsx, "3.1.0"},
380    {lager, {git, "https://github.com/erlang-lager/lager.git", {tag, "3.9.2"}}},
381    jsx
382]}.
383{profiles, [
384    {dev, [{deps, [
385        {recon, "2.5.4"}
386    ]}]},
387    {test, [{deps, [
388        {proper, "1.4.0"}
389    ]}]}
390]}.
391"#;
392        let m = RebarConfigParser.parse(content).unwrap();
393        assert_eq!(m.ecosystem, "hex");
394
395        let cowboy = m.dependencies.iter().find(|d| d.name == "cowboy").unwrap();
396        assert_eq!(cowboy.kind, DepKind::Normal);
397        assert_eq!(cowboy.version_req.as_deref(), Some("2.10.0"));
398
399        let lager = m.dependencies.iter().find(|d| d.name == "lager").unwrap();
400        assert_eq!(lager.kind, DepKind::Normal);
401        assert_eq!(lager.version_req.as_deref(), Some("3.9.2"));
402
403        // bare atom
404        let jsx = m.dependencies.iter().find(|d| d.name == "jsx").unwrap();
405        assert_eq!(jsx.kind, DepKind::Normal);
406
407        let recon = m.dependencies.iter().find(|d| d.name == "recon").unwrap();
408        assert_eq!(recon.kind, DepKind::Dev);
409
410        let proper = m.dependencies.iter().find(|d| d.name == "proper").unwrap();
411        assert_eq!(proper.kind, DepKind::Dev);
412    }
413
414    #[test]
415    fn test_minimal_rebar() {
416        let content = "{deps, [{cowboy, \"2.9.0\"}]}.\n";
417        let m = RebarConfigParser.parse(content).unwrap();
418        assert_eq!(m.dependencies.len(), 1);
419        assert_eq!(m.dependencies[0].name, "cowboy");
420        assert_eq!(m.dependencies[0].version_req.as_deref(), Some("2.9.0"));
421    }
422}