1use std::collections::HashMap;
25use std::path::Path;
26
27use packageurl::PackageUrl;
28use serde_json::Value as JsonValue;
29
30use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
31use crate::parser_warn as warn;
32use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
33
34use super::PackageParser;
35use super::metadata::ParserMetadata;
36
37pub struct MixExsParser;
38
39impl PackageParser for MixExsParser {
40 const PACKAGE_TYPE: PackageType = PackageType::Hex;
41
42 fn metadata() -> Vec<ParserMetadata> {
43 vec![ParserMetadata {
44 description: "Elixir mix.exs project manifest",
45 file_patterns: &["**/mix.exs"],
46 package_type: "hex",
47 primary_language: "Elixir",
48 documentation_url: Some("https://hexdocs.pm/mix/Mix.Project.html"),
49 }]
50 }
51
52 fn is_match(path: &Path) -> bool {
53 path.file_name().and_then(|name| name.to_str()) == Some("mix.exs")
54 }
55
56 fn extract_packages(path: &Path) -> Vec<PackageData> {
57 let content = match read_file_to_string(path, None) {
58 Ok(content) => content,
59 Err(e) => {
60 warn!("Failed to read mix.exs at {:?}: {}", path, e);
61 return vec![default_package_data()];
62 }
63 };
64
65 vec![parse_mix_exs(&content)]
66 }
67}
68
69#[cfg(test)]
70pub(super) fn parse_mix_exs_for_test(content: &str) -> PackageData {
71 parse_mix_exs(content)
72}
73
74fn default_package_data() -> PackageData {
75 PackageData {
76 package_type: Some(PackageType::Hex),
77 primary_language: Some("Elixir".to_string()),
78 datasource_id: Some(DatasourceId::HexMixExs),
79 ..Default::default()
80 }
81}
82
83fn parse_mix_exs(content: &str) -> PackageData {
84 let mut package = default_package_data();
85
86 let module_version = extract_module_version(content);
87
88 if let Some(project_body) = extract_block_body(content, "def", "project") {
89 let entries = parse_keyword_list(&project_body);
90 if let Some(app) = entries
91 .iter()
92 .find(|(k, _)| k == "app")
93 .and_then(|(_, v)| atom_value(v))
94 {
95 package.name = Some(truncate_field(app));
96 }
97 if let Some((_, version_value)) = entries.iter().find(|(k, _)| k == "version") {
98 if let Some(version) = string_value(version_value) {
99 package.version = Some(truncate_field(version));
100 } else if is_module_version_attribute(version_value)
101 && let Some(version) = module_version.clone()
102 {
103 package.version = Some(truncate_field(version));
104 }
105 }
106 }
107
108 package.purl = build_hex_purl(package.name.as_deref(), package.version.as_deref());
109
110 package.dependencies = extract_deps(content);
111
112 package
113}
114
115fn extract_module_version(content: &str) -> Option<String> {
118 for line in content.lines().take(MAX_ITERATION_COUNT) {
119 let trimmed = line.trim_start();
120 if let Some(rest) = trimmed.strip_prefix("@version") {
121 let rest = rest.trim_start();
122 if let Some(value) = parse_leading_string(rest) {
123 return Some(value);
124 }
125 }
126 }
127 None
128}
129
130fn extract_block_body(content: &str, keyword: &str, name: &str) -> Option<String> {
134 let chars: Vec<char> = content.chars().collect();
135 let header = find_block_header(content, keyword, name)?;
136
137 let do_pos = find_do_after(&chars, header)?;
139 let body_start = do_pos + 2;
140
141 let mut depth = 1usize;
142 let mut idx = body_start;
143 let mut iterations = 0usize;
144 while idx < chars.len() {
145 iterations += 1;
146 if iterations > MAX_ITERATION_COUNT {
147 warn!("mix.exs block scan exceeded MAX_ITERATION_COUNT");
148 return None;
149 }
150 match chars[idx] {
151 '"' => {
152 idx = skip_string(&chars, idx);
153 continue;
154 }
155 '#' => {
156 idx = skip_line_comment(&chars, idx);
157 continue;
158 }
159 _ => {}
160 }
161 if let Some(word) = word_at(&chars, idx) {
162 match word.as_str() {
163 "do" | "fn" => depth += 1,
164 "end" => {
165 depth -= 1;
166 if depth == 0 {
167 let body: String = chars[body_start..idx].iter().collect();
168 return Some(body);
169 }
170 }
171 _ => {}
172 }
173 idx += word.chars().count();
174 continue;
175 }
176 idx += 1;
177 }
178 None
179}
180
181fn find_block_header(content: &str, keyword: &str, name: &str) -> Option<usize> {
184 let chars: Vec<char> = content.chars().collect();
185 let mut idx = 0usize;
186 while idx < chars.len() {
187 if let Some(word) = word_at(&chars, idx) {
188 if word == keyword {
189 let after_kw = idx + word.chars().count();
190 let mut cursor = skip_inline_ws(&chars, after_kw);
191 if let Some(next) = word_at(&chars, cursor)
192 && next == name
193 {
194 cursor += next.chars().count();
195 return Some(cursor);
196 }
197 }
198 idx += word.chars().count().max(1);
199 continue;
200 }
201 idx += 1;
202 }
203 None
204}
205
206fn find_do_after(chars: &[char], mut idx: usize) -> Option<usize> {
207 let mut iterations = 0usize;
208 while idx < chars.len() {
209 iterations += 1;
210 if iterations > MAX_ITERATION_COUNT {
211 return None;
212 }
213 match chars[idx] {
214 '"' => {
215 idx = skip_string(chars, idx);
216 continue;
217 }
218 '#' => {
219 idx = skip_line_comment(chars, idx);
220 continue;
221 }
222 _ => {}
223 }
224 if let Some(word) = word_at(chars, idx) {
225 if word == "do" {
226 return Some(idx);
227 }
228 idx += word.chars().count();
229 continue;
230 }
231 idx += 1;
232 }
233 None
234}
235
236fn extract_deps(content: &str) -> Vec<Dependency> {
239 let body = extract_block_body(content, "defp", "deps")
240 .or_else(|| extract_block_body(content, "def", "deps"));
241 let Some(body) = body else {
242 return Vec::new();
243 };
244
245 let Some(list_inner) = slice_outer_list(&body) else {
246 return Vec::new();
247 };
248
249 let tuples = split_top_level_items(&list_inner);
250 let mut dependencies = Vec::new();
251 for tuple_src in tuples.into_iter().take(MAX_ITERATION_COUNT) {
252 if let Some(dep) = parse_dep_tuple(&tuple_src) {
253 dependencies.push(dep);
254 }
255 }
256 dependencies
257}
258
259fn parse_dep_tuple(tuple_src: &str) -> Option<Dependency> {
260 let trimmed = tuple_src.trim();
261 let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?;
262 let items = split_top_level_items(inner);
263 if items.is_empty() {
264 return None;
265 }
266
267 let name = atom_literal(items[0].trim())?;
268
269 let mut requirement: Option<String> = None;
273 let mut options_start = 1usize;
274 if items.len() >= 2
275 && let Some(version) = parse_leading_string(items[1].trim())
276 {
277 requirement = Some(version);
278 options_start = 2;
279 }
280
281 let mut scope: Option<String> = None;
284 let mut is_optional: Option<bool> = None;
285 for item in items.iter().skip(options_start).take(MAX_ITERATION_COUNT) {
286 for (key, value) in parse_keyword_list(item) {
287 match key.as_str() {
288 "only" => {
289 if let Some(envs) = literal_env_list(&value) {
290 scope = Some(envs.join(","));
291 }
292 }
293 "optional" => {
294 if let Some(flag) = bool_value(&value) {
295 is_optional = Some(flag);
296 }
297 }
298 _ => {}
299 }
300 }
301 }
302
303 Some(Dependency {
304 purl: build_hex_purl(Some(&name), None).map(truncate_field),
305 extracted_requirement: requirement.map(truncate_field),
306 scope: scope.map(truncate_field),
307 is_runtime: None,
308 is_optional,
309 is_pinned: None,
310 is_direct: Some(true),
311 resolved_package: None,
312 extra_data: Some(HashMap::from([(
313 "app".to_string(),
314 JsonValue::String(truncate_field(name)),
315 )])),
316 })
317}
318
319fn atom_literal(src: &str) -> Option<String> {
321 let rest = src.strip_prefix(':')?;
322 let name: String = rest
323 .chars()
324 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '?' || *c == '!')
325 .collect();
326 if name.is_empty() { None } else { Some(name) }
327}
328
329fn literal_env_list(value: &str) -> Option<Vec<String>> {
332 let trimmed = value.trim();
333 if let Some(atom) = atom_literal(trimmed) {
334 return Some(vec![atom]);
335 }
336 let inner = trimmed.strip_prefix('[')?.strip_suffix(']')?;
337 let mut envs = Vec::new();
338 for item in split_top_level_items(inner)
339 .into_iter()
340 .take(MAX_ITERATION_COUNT)
341 {
342 let atom = atom_literal(item.trim())?;
343 envs.push(atom);
344 }
345 if envs.is_empty() { None } else { Some(envs) }
346}
347
348fn bool_value(value: &str) -> Option<bool> {
349 match value.trim() {
350 "true" => Some(true),
351 "false" => Some(false),
352 _ => None,
353 }
354}
355
356fn string_value(value: &str) -> Option<String> {
358 parse_leading_string(value.trim())
359}
360
361fn atom_value(value: &str) -> Option<String> {
362 atom_literal(value.trim())
363}
364
365fn is_module_version_attribute(value: &str) -> bool {
366 value.trim() == "@version"
367}
368
369fn parse_leading_string(src: &str) -> Option<String> {
372 let chars: Vec<char> = src.chars().collect();
373 if chars.first() != Some(&'"') {
374 return None;
375 }
376 let mut out = String::new();
377 let mut idx = 1usize;
378 while idx < chars.len() {
379 match chars[idx] {
380 '"' => {
381 let rest: String = chars[idx + 1..].iter().collect();
386 let rest = rest.trim_start();
387 if rest.is_empty() || rest.starts_with('#') {
388 return Some(out);
389 }
390 return None;
391 }
392 '\\' => {
393 idx += 1;
394 if idx >= chars.len() {
395 return None;
396 }
397 out.push(match chars[idx] {
398 'n' => '\n',
399 'r' => '\r',
400 't' => '\t',
401 other => other,
402 });
403 }
404 '#' if chars.get(idx + 1) == Some(&'{') => {
405 return None;
407 }
408 other => out.push(other),
409 }
410 idx += 1;
411 }
412 None
413}
414
415fn parse_keyword_list(src: &str) -> Vec<(String, String)> {
419 let trimmed = src.trim();
420 let inner = trimmed
421 .strip_prefix('[')
422 .and_then(|s| s.strip_suffix(']'))
423 .unwrap_or(trimmed);
424
425 let mut pairs = Vec::new();
426 for item in split_top_level_items(inner)
427 .into_iter()
428 .take(MAX_ITERATION_COUNT)
429 {
430 if let Some((key, value)) = split_keyword_entry(&item) {
431 pairs.push((key, value));
432 }
433 }
434 pairs
435}
436
437fn split_keyword_entry(item: &str) -> Option<(String, String)> {
440 let trimmed = item.trim();
441 let chars: Vec<char> = trimmed.chars().collect();
442 let mut idx = 0usize;
443 while idx < chars.len() {
444 let c = chars[idx];
445 if c.is_ascii_alphanumeric() || c == '_' || c == '?' || c == '!' {
446 idx += 1;
447 } else {
448 break;
449 }
450 }
451 if idx == 0 || chars.get(idx) != Some(&':') {
452 return None;
453 }
454 let key: String = chars[..idx].iter().collect();
455 let value: String = chars[idx + 1..].iter().collect();
456 Some((key, value.trim().to_string()))
457}
458
459fn slice_outer_list(body: &str) -> Option<String> {
462 let chars: Vec<char> = body.chars().collect();
463 let mut start = 0usize;
464 while start < chars.len() {
465 match chars[start] {
466 c if c.is_whitespace() => start += 1,
467 '#' => start = skip_line_comment(&chars, start),
468 '[' => break,
469 _ => return None,
470 }
471 }
472 if chars.get(start) != Some(&'[') {
473 return None;
474 }
475 let mut depth = 0usize;
477 let mut idx = start;
478 let mut iterations = 0usize;
479 while idx < chars.len() {
480 iterations += 1;
481 if iterations > MAX_ITERATION_COUNT {
482 return None;
483 }
484 match chars[idx] {
485 '"' => {
486 idx = skip_string(&chars, idx);
487 continue;
488 }
489 '#' if chars.get(idx + 1) != Some(&'{') => {
490 idx = skip_line_comment(&chars, idx);
491 continue;
492 }
493 '[' => depth += 1,
494 ']' => {
495 depth -= 1;
496 if depth == 0 {
497 let inner: String = chars[start + 1..idx].iter().collect();
498 return Some(inner);
499 }
500 }
501 _ => {}
502 }
503 idx += 1;
504 }
505 None
506}
507
508fn split_top_level_items(src: &str) -> Vec<String> {
511 let chars: Vec<char> = src.chars().collect();
512 let mut items = Vec::new();
513 let mut depth = 0i32;
514 let mut current = String::new();
515 let mut idx = 0usize;
516 let mut iterations = 0usize;
517 while idx < chars.len() {
518 iterations += 1;
519 if iterations > MAX_ITERATION_COUNT {
520 break;
521 }
522 let c = chars[idx];
523 match c {
524 '"' => {
525 let end = skip_string(&chars, idx);
526 current.extend(&chars[idx..end.min(chars.len())]);
527 idx = end;
528 continue;
529 }
530 '#' if chars.get(idx + 1) != Some(&'{') => {
531 idx = skip_line_comment(&chars, idx);
532 continue;
533 }
534 '(' | '[' | '{' => {
535 depth += 1;
536 current.push(c);
537 }
538 ')' | ']' | '}' => {
539 depth -= 1;
540 current.push(c);
541 }
542 ',' if depth == 0 => {
543 let item = current.trim().to_string();
544 if !item.is_empty() {
545 items.push(item);
546 }
547 current.clear();
548 }
549 _ => current.push(c),
550 }
551 idx += 1;
552 }
553 let item = current.trim().to_string();
554 if !item.is_empty() {
555 items.push(item);
556 }
557 items
558}
559
560fn skip_string(chars: &[char], idx: usize) -> usize {
563 let mut idx = idx + 1;
564 while idx < chars.len() {
565 match chars[idx] {
566 '\\' => idx += 2,
567 '"' => return idx + 1,
568 _ => idx += 1,
569 }
570 }
571 idx
572}
573
574fn skip_line_comment(chars: &[char], idx: usize) -> usize {
576 let mut idx = idx;
577 while idx < chars.len() && chars[idx] != '\n' {
578 idx += 1;
579 }
580 idx
581}
582
583fn skip_inline_ws(chars: &[char], mut idx: usize) -> usize {
584 while idx < chars.len() && (chars[idx] == ' ' || chars[idx] == '\t') {
585 idx += 1;
586 }
587 idx
588}
589
590fn word_at(chars: &[char], idx: usize) -> Option<String> {
594 let c = *chars.get(idx)?;
595 if !(c.is_ascii_alphabetic() || c == '_') {
596 return None;
597 }
598 if idx > 0 {
599 let prev = chars[idx - 1];
600 if prev.is_ascii_alphanumeric() || prev == '_' || prev == '?' || prev == '!' {
601 return None;
602 }
603 }
604 let word: String = chars[idx..]
605 .iter()
606 .take_while(|c| c.is_ascii_alphanumeric() || **c == '_' || **c == '?' || **c == '!')
607 .collect();
608 Some(word)
609}
610
611fn build_hex_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
612 let name = name?;
613 let mut purl = PackageUrl::new("hex", name).ok()?;
614 if let Some(version) = version {
615 purl.with_version(version).ok()?;
616 }
617 Some(purl.to_string())
618}