normalize_manifest/
erlang.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
13
14pub 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 if let Some(top_deps) = find_top_level_deps(content) {
27 extract_rebar_deps(&top_deps, DepKind::Normal, &mut deps);
28 }
29
30 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
44fn find_top_level_deps(content: &str) -> Option<String> {
46 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 if depth == 1 {
58 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 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 while i < total && chars[i] != '\n' {
75 i += 1;
76 }
77 }
78 _ => {}
79 }
80 i += 1;
81 }
82 None
83}
84
85fn 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 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
122fn 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 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 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 let consumed = brace_len(&chars[i..]);
156 i += consumed;
157 continue;
158 }
159 i += 1;
160 }
161}
162
163fn 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
190fn 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 if !chars.is_empty() && chars[0] == '[' {
198 i = 1;
199 }
200
201 while i < total {
202 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 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 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
249fn parse_rebar_dep_tuple(s: &str, kind: DepKind) -> Option<DeclaredDep> {
251 let inner = s
253 .trim()
254 .trim_start_matches('{')
255 .trim_end_matches('}')
256 .trim();
257
258 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 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 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 Some(DeclaredDep {
289 name,
290 version_req: None,
291 kind,
292 })
293}
294
295fn 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 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
308fn 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
330fn 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
348fn 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 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}