Skip to main content

provenant/parsers/
mix_exs.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Static parser for Elixir `mix.exs` project manifests.
5//!
6//! `mix.exs` is arbitrary Elixir source code, not a data file. Provenant never
7//! executes it (ADR 0004). Instead this parser scans a **bounded literal
8//! subset** that the overwhelming majority of real projects use:
9//!
10//! - project identity from the `def project do [...]` keyword list:
11//!   - `app:` (an atom → the package name)
12//!   - `version:` (a string literal, or a simple top-level `@version "x.y.z"`
13//!     module attribute when `version: @version` is used)
14//! - direct dependencies from the `defp deps do [...]` (or `def deps`) list of
15//!   tuples such as `{:phoenix, "~> 1.7"}`, `{:ecto, ">= 3.0", only: :test}`,
16//!   or `{:foo, github: "..."}`.
17//!
18//! Anything dynamic (computed versions, helper-function deps, `System.get_env`,
19//! `if Mix.env()`, interpolated strings, …) is skipped rather than guessed: a
20//! tuple whose first element is not a literal atom, or whose version slot is not
21//! a string literal, simply contributes name-only (or is dropped) instead of
22//! producing a wrong requirement.
23//!
24//! # Umbrella projects
25//!
26//! A root `mix.exs` whose `project` keyword list carries a literal `apps_path:`
27//! string marks an [umbrella project](https://hexdocs.pm/mix/Mix.Project.html#module-umbrella-projects):
28//! its child applications live under that directory (one `mix.exs` per app) and
29//! typically share a single root `mix.lock`. This parser records `apps_path`
30//! (and the optional `apps:` allow-list) into `extra_data` so
31//! `src/assembly/mix_umbrella_merge.rs` can plan the umbrella topology; the
32//! parser itself stays file-local and does not look at sibling files.
33//!
34//! A dependency declared as `{:sibling, in_umbrella: true}` refers to another
35//! app in the same umbrella, not a real Hex package. Such deps are emitted with
36//! `purl: None` and `extra_data["in_umbrella"] = true` so they are never
37//! mistaken for (nonexistent, unversioned) Hex registry packages; umbrella
38//! assembly resolves them to the sibling app's actual package identity.
39
40use 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        // `apps_path:` marks this manifest as an umbrella root; the optional
125        // `apps:` allow-list restricts which child apps under that directory
126        // are actually part of the umbrella (Mix defaults to every app found
127        // there when `apps:` is absent).
128        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
161/// Resolve a top-level `@version "x.y.z"` module attribute. Only a single
162/// string-literal assignment is recognized; anything dynamic is ignored.
163fn 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
176/// Extract the body of a `<keyword> <name> do ... end` block (for example
177/// `def project do ... end`), balancing nested `do`/`fn`/block keywords against
178/// `end`. Returns the source slice between `do` and its matching `end`.
179fn 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    // Find the `do` that opens the block, starting at the header.
184    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
227/// Locate the byte-independent char index just past a `<keyword> <name>` header
228/// (e.g. `def project`), allowing `defp`, parentheses, and arbitrary spacing.
229fn 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
282/// Extract direct dependencies from the `deps` block. Supports `defp deps` and
283/// `def deps`. The body is expected to be a list literal `[ {..}, {..} ]`.
284fn 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    // The version requirement is the second positional element, but only when
316    // it is a string literal. `{:foo, github: "..."}` has options (not a
317    // version) in slot two, so guard on the literal-string shape.
318    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    // Remaining items may be `key: value` options; collect literal `only:`,
328    // `optional:`, and `in_umbrella:` only.
329    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    // `{:sibling, in_umbrella: true}` names another app in the same umbrella,
354    // not a real Hex package: it has no registry version and building a
355    // `pkg:hex/<name>` purl for it would fabricate a nonexistent, unversioned
356    // package. Leave the purl unset and flag it so umbrella assembly can
357    // resolve it to the sibling app's real (versioned) package identity.
358    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
386/// Parse a `:atom` literal, returning the atom name without the leading colon.
387fn 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
396/// `only:` may be a single atom (`:test`) or a list (`[:dev, :test]`). Return the
397/// environment names when every element is a literal atom; otherwise `None`.
398fn 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
423/// A `keyword:`-style value that is a literal string.
424fn 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
436/// Parse a leading double-quoted string literal at the start of `src`. Returns
437/// `None` for interpolated strings (`"#{...}"`) so dynamic values are skipped.
438fn 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                // The string closes here. Accept it only if nothing (other than a
449                // comment) follows: a value like `"1." <> patch` is a computed
450                // expression whose literal prefix must not be emitted as a partial
451                // version/requirement.
452                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                // String interpolation → dynamic, not a literal.
473                return None;
474            }
475            other => out.push(other),
476        }
477        idx += 1;
478    }
479    None
480}
481
482/// Parse a keyword list source fragment into `(key, value)` pairs, where keys
483/// are `key:` identifiers and values are the raw source up to the next
484/// top-level comma. Leading list/tuple delimiters are tolerated.
485fn 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
504/// Split a single `key: value` entry. The key is a bare identifier followed by a
505/// colon that is **not** part of an atom (`::`) and not a quoted string key.
506fn 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
526/// Given a block body, return the inner content of its outer `[ ... ]` list, if
527/// the body is a list literal (ignoring surrounding whitespace/comments).
528fn 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    // Find the matching close bracket.
543    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
575/// Split a comma-separated source fragment into top-level items, respecting
576/// nesting of `()[]{}`, string literals, and line comments.
577fn 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
627/// Advance past a double-quoted string starting at `idx` (which must be `"`).
628/// Returns the index just after the closing quote.
629fn 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
641/// Advance to the start of the next line after a `#` comment.
642fn 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
657/// Return the identifier word at `idx` only when it begins on a word boundary
658/// (the previous char is not part of an identifier), so `do`, `end`, and `fn`
659/// are matched as whole keywords rather than substrings of other identifiers.
660fn 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}