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