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
24use 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
115/// Resolve a top-level `@version "x.y.z"` module attribute. Only a single
116/// string-literal assignment is recognized; anything dynamic is ignored.
117fn 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
130/// Extract the body of a `<keyword> <name> do ... end` block (for example
131/// `def project do ... end`), balancing nested `do`/`fn`/block keywords against
132/// `end`. Returns the source slice between `do` and its matching `end`.
133fn 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    // Find the `do` that opens the block, starting at the header.
138    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
181/// Locate the byte-independent char index just past a `<keyword> <name>` header
182/// (e.g. `def project`), allowing `defp`, parentheses, and arbitrary spacing.
183fn 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
236/// Extract direct dependencies from the `deps` block. Supports `defp deps` and
237/// `def deps`. The body is expected to be a list literal `[ {..}, {..} ]`.
238fn 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    // The version requirement is the second positional element, but only when
270    // it is a string literal. `{:foo, github: "..."}` has options (not a
271    // version) in slot two, so guard on the literal-string shape.
272    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    // Remaining items may be `key: value` options; collect literal `only:` and
282    // `optional:` only.
283    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
319/// Parse a `:atom` literal, returning the atom name without the leading colon.
320fn 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
329/// `only:` may be a single atom (`:test`) or a list (`[:dev, :test]`). Return the
330/// environment names when every element is a literal atom; otherwise `None`.
331fn 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
356/// A `keyword:`-style value that is a literal string.
357fn 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
369/// Parse a leading double-quoted string literal at the start of `src`. Returns
370/// `None` for interpolated strings (`"#{...}"`) so dynamic values are skipped.
371fn 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                // The string closes here. Accept it only if nothing (other than a
382                // comment) follows: a value like `"1." <> patch` is a computed
383                // expression whose literal prefix must not be emitted as a partial
384                // version/requirement.
385                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                // String interpolation → dynamic, not a literal.
406                return None;
407            }
408            other => out.push(other),
409        }
410        idx += 1;
411    }
412    None
413}
414
415/// Parse a keyword list source fragment into `(key, value)` pairs, where keys
416/// are `key:` identifiers and values are the raw source up to the next
417/// top-level comma. Leading list/tuple delimiters are tolerated.
418fn 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
437/// Split a single `key: value` entry. The key is a bare identifier followed by a
438/// colon that is **not** part of an atom (`::`) and not a quoted string key.
439fn 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
459/// Given a block body, return the inner content of its outer `[ ... ]` list, if
460/// the body is a list literal (ignoring surrounding whitespace/comments).
461fn 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    // Find the matching close bracket.
476    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
508/// Split a comma-separated source fragment into top-level items, respecting
509/// nesting of `()[]{}`, string literals, and line comments.
510fn 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
560/// Advance past a double-quoted string starting at `idx` (which must be `"`).
561/// Returns the index just after the closing quote.
562fn 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
574/// Advance to the start of the next line after a `#` comment.
575fn 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
590/// Return the identifier word at `idx` only when it begins on a word boundary
591/// (the previous char is not part of an identifier), so `do`, `end`, and `fn`
592/// are matched as whole keywords rather than substrings of other identifiers.
593fn 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}