Skip to main content

provenant/parsers/
erlang_otp.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use packageurl::PackageUrl;
8use serde_json::Value as JsonValue;
9
10use crate::models::{
11    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
12};
13use crate::parser_warn as warn;
14use crate::parsers::utils::{
15    MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, capped_iteration_limit, read_file_to_string,
16    truncate_field,
17};
18
19use super::PackageParser;
20use super::metadata::ParserMetadata;
21
22// ── Parser structs ──
23
24pub struct ErlangAppSrcParser;
25pub struct RebarConfigParser;
26pub struct RebarLockParser;
27
28// ── Erlang term AST ──
29
30#[derive(Clone, Debug)]
31enum ErlTerm {
32    Atom(String),
33    String(String),
34    Binary(String),
35    Integer(i64),
36    Float(f64),
37    Tuple(Vec<ErlTerm>),
38    List(Vec<ErlTerm>),
39    Map(Vec<(ErlTerm, ErlTerm)>),
40}
41
42// ── Erlang term parser ──
43
44struct ErlParser {
45    chars: Vec<char>,
46    pos: usize,
47    depth: usize,
48}
49
50impl ErlParser {
51    fn new(source: &str) -> Self {
52        Self {
53            chars: source.chars().collect(),
54            pos: 0,
55            depth: 0,
56        }
57    }
58
59    fn parse_term(&mut self) -> Result<ErlTerm, String> {
60        if self.depth >= MAX_RECURSION_DEPTH {
61            return Err("recursion depth exceeded".to_string());
62        }
63        self.depth += 1;
64        let result = self.parse_term_inner();
65        self.depth -= 1;
66        result
67    }
68
69    fn parse_term_inner(&mut self) -> Result<ErlTerm, String> {
70        self.skip_whitespace_and_comments();
71        match self.peek() {
72            Some('{') => self.parse_tuple(),
73            Some('[') => self.parse_list(),
74            Some('#') if self.peek_n(1) == Some('{') => self.parse_map(),
75            Some('"') => self.parse_string().map(ErlTerm::String),
76            Some('<') if self.peek_n(1) == Some('<') => self.parse_binary().map(ErlTerm::Binary),
77            Some('\'') => self.parse_quoted_atom().map(ErlTerm::Atom),
78            Some(c) if c.is_ascii_digit() || c == '-' => self.parse_number(),
79            Some(c) if c.is_ascii_lowercase() || c == '_' => self.parse_atom_or_bool(),
80            Some(c) => Err(format!(
81                "Unexpected character '{}' at position {}",
82                c, self.pos
83            )),
84            None => Err("Unexpected end of input".to_string()),
85        }
86    }
87
88    fn parse_tuple(&mut self) -> Result<ErlTerm, String> {
89        self.expect('{')?;
90        let items = self.parse_comma_separated('}')?;
91        Ok(ErlTerm::Tuple(items))
92    }
93
94    fn parse_list(&mut self) -> Result<ErlTerm, String> {
95        self.expect('[')?;
96        let items = self.parse_comma_separated(']')?;
97        Ok(ErlTerm::List(items))
98    }
99
100    fn parse_map(&mut self) -> Result<ErlTerm, String> {
101        self.expect('#')?;
102        self.expect('{')?;
103
104        let mut entries = Vec::new();
105        let mut count = 0usize;
106
107        loop {
108            self.skip_whitespace_and_comments();
109            if self.peek() == Some('}') {
110                self.pos += 1;
111                break;
112            }
113
114            if count >= MAX_ITERATION_COUNT {
115                return Err("too many map entries".to_string());
116            }
117
118            let key = self.parse_term()?;
119            self.skip_whitespace_and_comments();
120
121            match (self.peek(), self.peek_n(1)) {
122                (Some('='), Some('>')) | (Some(':'), Some('=')) => {
123                    self.pos += 2;
124                }
125                _ => {
126                    return Err(format!(
127                        "Expected map association operator at position {}",
128                        self.pos
129                    ));
130                }
131            }
132
133            let value = self.parse_term()?;
134            entries.push((key, value));
135            count += 1;
136
137            self.skip_whitespace_and_comments();
138            match self.peek() {
139                Some(',') => {
140                    self.pos += 1;
141                }
142                Some('}') => {
143                    self.pos += 1;
144                    break;
145                }
146                Some(c) => {
147                    return Err(format!(
148                        "Expected ',' or '}}' in map but found '{}' at position {}",
149                        c, self.pos
150                    ));
151                }
152                None => return Err("Unterminated map literal".to_string()),
153            }
154        }
155
156        Ok(ErlTerm::Map(entries))
157    }
158
159    fn parse_comma_separated(&mut self, closing: char) -> Result<Vec<ErlTerm>, String> {
160        let mut items = Vec::new();
161        let mut count = 0usize;
162        loop {
163            self.skip_whitespace_and_comments();
164            if self.peek() == Some(closing) {
165                self.pos += 1;
166                break;
167            }
168            if count >= MAX_ITERATION_COUNT {
169                return Err("too many items".to_string());
170            }
171            items.push(self.parse_term()?);
172            count += 1;
173            self.skip_whitespace_and_comments();
174            if self.peek() == Some(',') {
175                self.pos += 1;
176            } else if self.peek() == Some('|') {
177                // list tail syntax: [H | T] — skip rest
178                self.pos += 1;
179                self.parse_term()?;
180                self.skip_whitespace_and_comments();
181                if self.peek() == Some(closing) {
182                    self.pos += 1;
183                }
184                break;
185            }
186        }
187        Ok(items)
188    }
189
190    fn parse_string(&mut self) -> Result<String, String> {
191        self.expect('"')?;
192        let mut out = String::new();
193        while let Some(c) = self.peek() {
194            self.pos += 1;
195            match c {
196                '"' => return Ok(out),
197                '\\' => {
198                    let escaped = self
199                        .peek()
200                        .ok_or_else(|| "Unterminated string escape".to_string())?;
201                    self.pos += 1;
202                    out.push(match escaped {
203                        'n' => '\n',
204                        'r' => '\r',
205                        't' => '\t',
206                        '"' => '"',
207                        '\\' => '\\',
208                        other => other,
209                    });
210                }
211                other => out.push(other),
212            }
213        }
214        Err("Unterminated string literal".to_string())
215    }
216
217    fn parse_binary(&mut self) -> Result<String, String> {
218        self.expect('<')?;
219        self.expect('<')?;
220        self.skip_whitespace_and_comments();
221        let value = if self.peek() == Some('"') {
222            self.parse_string()?
223        } else {
224            String::new()
225        };
226        self.skip_whitespace_and_comments();
227        self.expect('>')?;
228        self.expect('>')?;
229        Ok(value)
230    }
231
232    fn parse_quoted_atom(&mut self) -> Result<String, String> {
233        self.expect('\'')?;
234        let mut out = String::new();
235        while let Some(c) = self.peek() {
236            self.pos += 1;
237            match c {
238                '\'' => return Ok(out),
239                '\\' => {
240                    if let Some(escaped) = self.peek() {
241                        self.pos += 1;
242                        out.push(escaped);
243                    }
244                }
245                other => out.push(other),
246            }
247        }
248        Err("Unterminated quoted atom".to_string())
249    }
250
251    fn parse_atom_or_bool(&mut self) -> Result<ErlTerm, String> {
252        let atom = self.parse_bare_atom()?;
253        match atom.as_str() {
254            "true" => Ok(ErlTerm::Atom("true".to_string())),
255            "false" => Ok(ErlTerm::Atom("false".to_string())),
256            _ => Ok(ErlTerm::Atom(atom)),
257        }
258    }
259
260    fn parse_bare_atom(&mut self) -> Result<String, String> {
261        let start = self.pos;
262        while let Some(c) = self.peek() {
263            if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
264                self.pos += 1;
265            } else {
266                break;
267            }
268        }
269        if self.pos == start {
270            return Err("Expected atom".to_string());
271        }
272        Ok(self.chars[start..self.pos].iter().collect())
273    }
274
275    fn parse_number(&mut self) -> Result<ErlTerm, String> {
276        let start = self.pos;
277        if self.peek() == Some('-') {
278            self.pos += 1;
279        }
280        while let Some(c) = self.peek() {
281            if c.is_ascii_digit() {
282                self.pos += 1;
283            } else {
284                break;
285            }
286        }
287        if self.peek() == Some('.') && self.peek_n(1).is_some_and(|c| c.is_ascii_digit()) {
288            self.pos += 1;
289            while let Some(c) = self.peek() {
290                if c.is_ascii_digit() {
291                    self.pos += 1;
292                } else {
293                    break;
294                }
295            }
296            let s: String = self.chars[start..self.pos].iter().collect();
297            return s
298                .parse::<f64>()
299                .map(ErlTerm::Float)
300                .map_err(|e| format!("Invalid float: {}", e));
301        }
302        let s: String = self.chars[start..self.pos].iter().collect();
303        s.parse::<i64>()
304            .map(ErlTerm::Integer)
305            .map_err(|e| format!("Invalid integer: {}", e))
306    }
307
308    fn skip_whitespace_and_comments(&mut self) {
309        loop {
310            match self.peek() {
311                Some(c) if c.is_whitespace() => {
312                    self.pos += 1;
313                }
314                Some('%') => {
315                    while let Some(c) = self.peek() {
316                        self.pos += 1;
317                        if c == '\n' {
318                            break;
319                        }
320                    }
321                }
322                _ => break,
323            }
324        }
325    }
326
327    fn expect(&mut self, expected: char) -> Result<(), String> {
328        self.skip_whitespace_and_comments();
329        match self.peek() {
330            Some(c) if c == expected => {
331                self.pos += 1;
332                Ok(())
333            }
334            Some(c) => Err(format!(
335                "Expected '{}' but found '{}' at position {}",
336                expected, c, self.pos
337            )),
338            None => Err(format!("Expected '{}' but reached end of input", expected)),
339        }
340    }
341
342    fn peek(&self) -> Option<char> {
343        self.chars.get(self.pos).copied()
344    }
345
346    fn peek_n(&self, n: usize) -> Option<char> {
347        self.chars.get(self.pos + n).copied()
348    }
349
350    fn is_eof(&self) -> bool {
351        self.pos >= self.chars.len()
352    }
353}
354
355fn parse_dotted_terms(content: &str) -> Result<Vec<ErlTerm>, String> {
356    let normalized = strip_template_placeholders(content);
357    let mut parser = ErlParser::new(&normalized);
358    let mut terms = Vec::new();
359    let mut count = 0usize;
360    loop {
361        parser.skip_whitespace_and_comments();
362        if parser.is_eof() {
363            break;
364        }
365        if parser.peek() == Some('.') {
366            parser.pos += 1;
367            continue;
368        }
369        if count >= MAX_ITERATION_COUNT {
370            warn!(
371                "Truncated Erlang term parsing at {} top-level terms (MAX_ITERATION_COUNT); remaining terms dropped",
372                MAX_ITERATION_COUNT
373            );
374            break;
375        }
376        let term = parser.parse_term()?;
377        terms.push(term);
378        count += 1;
379        parser.skip_whitespace_and_comments();
380        if parser.peek() == Some('.') {
381            parser.pos += 1;
382        }
383    }
384    Ok(terms)
385}
386
387fn strip_template_placeholders(source: &str) -> String {
388    let chars: Vec<char> = source.chars().collect();
389    let mut result = String::with_capacity(source.len());
390    let mut i = 0usize;
391    let mut in_string = false;
392    let mut in_quoted_atom = false;
393
394    while i < chars.len() {
395        let c = chars[i];
396
397        if in_string {
398            result.push(c);
399            i += 1;
400            if c == '\\' && i < chars.len() {
401                result.push(chars[i]);
402                i += 1;
403                continue;
404            }
405            if c == '"' {
406                in_string = false;
407            }
408            continue;
409        }
410
411        if in_quoted_atom {
412            result.push(c);
413            i += 1;
414            if c == '\\' && i < chars.len() {
415                result.push(chars[i]);
416                i += 1;
417                continue;
418            }
419            if c == '\'' {
420                in_quoted_atom = false;
421            }
422            continue;
423        }
424
425        match c {
426            '"' => {
427                in_string = true;
428                result.push(c);
429                i += 1;
430            }
431            '\'' => {
432                in_quoted_atom = true;
433                result.push(c);
434                i += 1;
435            }
436            '%' if chars.get(i + 1) != Some(&'%') => {
437                let line_end = chars[i..]
438                    .iter()
439                    .position(|&ch| ch == '\n')
440                    .map(|offset| i + offset)
441                    .unwrap_or(chars.len());
442
443                let last_percent = chars[i + 1..line_end]
444                    .iter()
445                    .rposition(|&ch| ch == '%')
446                    .map(|offset| i + 1 + offset);
447
448                if let Some(last_percent) = last_percent {
449                    let placeholder_body: String = chars[i + 1..last_percent].iter().collect();
450                    let trailing: String = chars[last_percent + 1..line_end].iter().collect();
451                    let looks_like_placeholder = !placeholder_body.is_empty()
452                        && placeholder_body.chars().all(|ch| {
453                            ch.is_ascii_uppercase()
454                                || ch.is_ascii_digit()
455                                || matches!(ch, '_' | ',' | '%')
456                        })
457                        && trailing
458                            .chars()
459                            .all(|ch| ch.is_whitespace() || matches!(ch, ',' | ']' | '}' | ')'));
460
461                    if looks_like_placeholder {
462                        i = last_percent + 1;
463                        continue;
464                    }
465                }
466
467                result.push(c);
468                i += 1;
469            }
470            _ => {
471                result.push(c);
472                i += 1;
473            }
474        }
475    }
476
477    result
478}
479
480// ── Helpers ──
481
482fn term_to_str(term: &ErlTerm) -> Option<String> {
483    match term {
484        ErlTerm::String(s) | ErlTerm::Binary(s) | ErlTerm::Atom(s) => Some(s.clone()),
485        ErlTerm::Integer(n) => Some(n.to_string()),
486        ErlTerm::Float(f) => Some(f.to_string()),
487        _ => None,
488    }
489}
490
491fn term_to_proplist(term: &ErlTerm) -> Option<Vec<(String, ErlTerm)>> {
492    let items = match term {
493        ErlTerm::List(items) => items,
494        _ => return None,
495    };
496    let mut result = Vec::new();
497    for item in items {
498        if let ErlTerm::Tuple(fields) = item
499            && fields.len() == 2
500            && let Some(key) = term_to_str(&fields[0])
501        {
502            result.push((key, fields[1].clone()));
503        }
504    }
505    Some(result)
506}
507
508fn term_to_key_value_pairs(term: &ErlTerm) -> Option<Vec<(String, ErlTerm)>> {
509    match term {
510        ErlTerm::Map(entries) => Some(
511            entries
512                .iter()
513                .filter_map(|(key, value)| term_to_str(key).map(|key| (key, value.clone())))
514                .collect(),
515        ),
516        _ => term_to_proplist(term),
517    }
518}
519
520fn term_to_atom_list(term: &ErlTerm) -> Vec<String> {
521    match term {
522        ErlTerm::List(items) => items.iter().filter_map(term_to_str).collect(),
523        _ => Vec::new(),
524    }
525}
526
527fn build_hex_purl(name: &str, version: Option<&str>) -> Option<String> {
528    let mut purl = PackageUrl::new("hex", name).ok()?;
529    if let Some(version) = version {
530        purl.with_version(version).ok()?;
531    }
532    Some(purl.to_string())
533}
534
535// ── ErlangAppSrcParser ──
536
537impl PackageParser for ErlangAppSrcParser {
538    const PACKAGE_TYPE: PackageType = PackageType::Hex;
539
540    fn metadata() -> Vec<ParserMetadata> {
541        vec![ParserMetadata {
542            description: "Erlang OTP application resource file",
543            file_patterns: &["**/*.app.src"],
544            package_type: "hex",
545            primary_language: "Erlang",
546            documentation_url: Some("https://www.erlang.org/doc/apps/kernel/application"),
547        }]
548    }
549
550    fn is_match(path: &Path) -> bool {
551        path.extension()
552            .and_then(|e| e.to_str())
553            .is_some_and(|ext| ext == "src")
554            && path
555                .file_stem()
556                .and_then(|s| s.to_str())
557                .is_some_and(|stem| stem.ends_with(".app"))
558    }
559
560    fn extract_packages(path: &Path) -> Vec<PackageData> {
561        let content = match read_file_to_string(path, None) {
562            Ok(c) => c,
563            Err(e) => {
564                warn!("Failed to read {:?}: {}", path, e);
565                return vec![default_app_src_package()];
566            }
567        };
568
569        match parse_app_src(&content) {
570            Ok(pkg) => vec![pkg],
571            Err(e) => {
572                warn!("Failed to parse {:?}: {}", path, e);
573                vec![default_app_src_package()]
574            }
575        }
576    }
577}
578
579fn default_app_src_package() -> PackageData {
580    PackageData {
581        package_type: Some(PackageType::Hex),
582        primary_language: Some("Erlang".to_string()),
583        datasource_id: Some(DatasourceId::ErlangOtpAppSrc),
584        ..Default::default()
585    }
586}
587
588fn parse_app_src(content: &str) -> Result<PackageData, String> {
589    let terms = parse_dotted_terms(content)?;
590
591    let app_tuple = terms
592        .into_iter()
593        .find_map(|term| {
594            if let ErlTerm::Tuple(fields) = &term
595                && fields.len() == 3
596                && term_to_str(&fields[0]).as_deref() == Some("application")
597            {
598                Some(term)
599            } else {
600                None
601            }
602        })
603        .ok_or_else(|| "No {application, _, _} tuple found".to_string())?;
604
605    let fields = match app_tuple {
606        ErlTerm::Tuple(fields) => fields,
607        _ => unreachable!(),
608    };
609
610    let app_name = term_to_str(&fields[1]);
611    let props = term_to_proplist(&fields[2]).unwrap_or_default();
612
613    let mut package = default_app_src_package();
614    package.name = app_name.map(truncate_field);
615
616    let mut extra_data = HashMap::new();
617
618    for (key, value) in &props {
619        match key.as_str() {
620            "vsn" => {
621                if let Some(v) = term_to_str(value)
622                    && !v.contains('%')
623                {
624                    package.version = Some(truncate_field(v));
625                }
626            }
627            "description" => {
628                package.description = term_to_str(value).map(truncate_field);
629            }
630            "licenses" => {
631                let licenses = term_to_atom_list(value);
632                if !licenses.is_empty() {
633                    package.extracted_license_statement = Some(truncate_field(licenses.join(", ")));
634                }
635            }
636            "links" => {
637                if let Some(link_props) = term_to_key_value_pairs(value) {
638                    for (link_name, link_val) in &link_props {
639                        if let Some(url) = term_to_str(link_val) {
640                            let lower = link_name.to_lowercase();
641                            if lower.contains("github")
642                                || lower.contains("source")
643                                || lower.contains("repo")
644                            {
645                                package.vcs_url = Some(truncate_field(url.clone()));
646                            }
647                            if package.homepage_url.is_none() {
648                                package.homepage_url = Some(truncate_field(url));
649                            }
650                        }
651                    }
652                }
653            }
654            "applications" => {
655                let apps = term_to_atom_list(value);
656                for app in apps {
657                    if is_otp_stdlib(&app) {
658                        continue;
659                    }
660                    package.dependencies.push(Dependency {
661                        purl: build_hex_purl(&app, None).map(truncate_field),
662                        extracted_requirement: None,
663                        scope: Some("dependencies".to_string()),
664                        is_runtime: Some(true),
665                        is_optional: None,
666                        is_pinned: None,
667                        is_direct: None,
668                        resolved_package: None,
669                        extra_data: None,
670                    });
671                }
672            }
673            "runtime_dependencies" => {
674                let deps = term_to_atom_list(value);
675                for dep_str in deps {
676                    if let Some((name, version)) = dep_str.split_once('-') {
677                        if is_otp_stdlib(name) {
678                            continue;
679                        }
680                        let version_str = if version.starts_with('@') {
681                            None
682                        } else {
683                            Some(version)
684                        };
685                        package.dependencies.push(Dependency {
686                            purl: build_hex_purl(name, version_str).map(truncate_field),
687                            extracted_requirement: version_str
688                                .map(|v| truncate_field(v.to_string())),
689                            scope: Some("dependencies".to_string()),
690                            is_runtime: Some(true),
691                            is_optional: None,
692                            is_pinned: None,
693                            is_direct: None,
694                            resolved_package: None,
695                            extra_data: None,
696                        });
697                    }
698                }
699            }
700            "maintainers" => {
701                let maintainers = term_to_atom_list(value);
702                if !maintainers.is_empty() {
703                    extra_data.insert(
704                        "maintainers".to_string(),
705                        JsonValue::Array(
706                            maintainers
707                                .into_iter()
708                                .map(|m| JsonValue::String(truncate_field(m)))
709                                .collect(),
710                        ),
711                    );
712                }
713            }
714            "keywords" => {
715                let keywords = term_to_atom_list(value);
716                if !keywords.is_empty() {
717                    package.keywords = keywords.into_iter().map(truncate_field).collect();
718                }
719            }
720            _ => {}
721        }
722    }
723
724    if let Some(ref name) = package.name {
725        package.purl = build_hex_purl(name, package.version.as_deref()).map(truncate_field);
726        package.repository_homepage_url =
727            Some(truncate_field(format!("https://hex.pm/packages/{}", name)));
728        package.api_data_url = Some(truncate_field(format!(
729            "https://hex.pm/api/packages/{}",
730            name
731        )));
732    }
733
734    if !extra_data.is_empty() {
735        package.extra_data = Some(extra_data);
736    }
737
738    Ok(package)
739}
740
741fn is_otp_stdlib(name: &str) -> bool {
742    matches!(
743        name,
744        "kernel"
745            | "stdlib"
746            | "sasl"
747            | "erts"
748            | "compiler"
749            | "crypto"
750            | "inets"
751            | "ssl"
752            | "public_key"
753            | "asn1"
754            | "syntax_tools"
755            | "tools"
756            | "os_mon"
757            | "runtime_tools"
758            | "mnesia"
759            | "observer"
760            | "wx"
761            | "debugger"
762            | "reltool"
763            | "xmerl"
764            | "edoc"
765            | "eunit"
766            | "common_test"
767            | "dialyzer"
768            | "et"
769            | "megaco"
770            | "parsetools"
771            | "snmp"
772            | "ssh"
773            | "tftp"
774            | "ftp"
775            | "erl_interface"
776            | "jinterface"
777            | "odbc"
778            | "eldap"
779            | "diameter"
780    )
781}
782
783// ── RebarConfigParser ──
784
785impl PackageParser for RebarConfigParser {
786    const PACKAGE_TYPE: PackageType = PackageType::Hex;
787
788    fn metadata() -> Vec<ParserMetadata> {
789        vec![ParserMetadata {
790            description: "Rebar3 configuration",
791            file_patterns: &["**/rebar.config"],
792            package_type: "hex",
793            primary_language: "Erlang",
794            documentation_url: Some("https://rebar3.org/docs/configuration/configuration/"),
795        }]
796    }
797
798    fn is_match(path: &Path) -> bool {
799        path.file_name().and_then(|n| n.to_str()) == Some("rebar.config")
800    }
801
802    fn extract_packages(path: &Path) -> Vec<PackageData> {
803        let content = match read_file_to_string(path, None) {
804            Ok(c) => c,
805            Err(e) => {
806                warn!("Failed to read {:?}: {}", path, e);
807                return vec![default_rebar_config_package()];
808            }
809        };
810
811        match parse_rebar_config(&content) {
812            Ok(pkg) => vec![pkg],
813            Err(e) => {
814                warn!("Failed to parse {:?}: {}", path, e);
815                vec![default_rebar_config_package()]
816            }
817        }
818    }
819}
820
821fn default_rebar_config_package() -> PackageData {
822    PackageData {
823        package_type: Some(PackageType::Hex),
824        primary_language: Some("Erlang".to_string()),
825        datasource_id: Some(DatasourceId::RebarConfig),
826        ..Default::default()
827    }
828}
829
830fn parse_rebar_config(content: &str) -> Result<PackageData, String> {
831    let terms = parse_dotted_terms(content)?;
832
833    let mut package = default_rebar_config_package();
834
835    for term in &terms {
836        if let ErlTerm::Tuple(fields) = term
837            && fields.len() == 2
838        {
839            let key = term_to_str(&fields[0]);
840            match key.as_deref() {
841                Some("deps") => {
842                    if let ErlTerm::List(deps) = &fields[1] {
843                        let deps_limit = capped_iteration_limit(deps.len(), "rebar.config deps");
844                        for dep in deps.iter().take(deps_limit) {
845                            if let Some(d) = parse_rebar_dep(dep) {
846                                package.dependencies.push(d);
847                            }
848                        }
849                    }
850                }
851                Some("profiles") => {
852                    parse_profile_deps(&fields[1], &mut package.dependencies);
853                }
854                _ => {}
855            }
856        }
857    }
858
859    Ok(package)
860}
861
862fn parse_rebar_dep(term: &ErlTerm) -> Option<Dependency> {
863    let fields = match term {
864        ErlTerm::Tuple(fields) => fields,
865        _ => return None,
866    };
867
868    if fields.is_empty() {
869        return None;
870    }
871
872    if let Some(key) = term_to_str(&fields[0])
873        && key.starts_with("if_")
874    {
875        return None;
876    }
877
878    let app_name = term_to_str(&fields[0])?;
879
880    match fields.len() {
881        // {Name, Version} or {Name, {git, URL, Ref}}
882        2 => {
883            if let Some(version) = term_to_str(&fields[1]) {
884                // {Name, Version}
885                Some(Dependency {
886                    purl: build_hex_purl(&app_name, Some(&version)).map(truncate_field),
887                    extracted_requirement: Some(truncate_field(version)),
888                    scope: Some("dependencies".to_string()),
889                    is_runtime: None,
890                    is_optional: None,
891                    is_pinned: None,
892                    is_direct: None,
893                    resolved_package: None,
894                    extra_data: None,
895                })
896            } else {
897                let package_name = extract_rebar_package_name(&fields[1], &app_name);
898                let vcs_url = extract_git_url(&fields[1]);
899                let version = extract_git_version(&fields[1]);
900                Some(Dependency {
901                    purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
902                    extracted_requirement: version.map(truncate_field),
903                    scope: Some("dependencies".to_string()),
904                    is_runtime: None,
905                    is_optional: None,
906                    is_pinned: None,
907                    is_direct: None,
908                    resolved_package: None,
909                    extra_data: build_rebar_dependency_extra_data(
910                        vcs_url,
911                        app_name.as_str(),
912                        package_name.as_str(),
913                    ),
914                })
915            }
916        }
917        // {Name, Version, Source}
918        3 => {
919            if let Some(version) = term_to_str(&fields[1]) {
920                let package_name = extract_rebar_package_name(&fields[2], &app_name);
921                let vcs_url = extract_git_url(&fields[2]);
922                Some(Dependency {
923                    purl: build_hex_purl(&package_name, Some(&version)).map(truncate_field),
924                    extracted_requirement: Some(truncate_field(version)),
925                    scope: Some("dependencies".to_string()),
926                    is_runtime: None,
927                    is_optional: None,
928                    is_pinned: None,
929                    is_direct: None,
930                    resolved_package: None,
931                    extra_data: build_rebar_dependency_extra_data(
932                        vcs_url,
933                        app_name.as_str(),
934                        package_name.as_str(),
935                    ),
936                })
937            } else {
938                let package_name = extract_rebar_package_name(&fields[1], &app_name);
939                let vcs_url = extract_git_url(&fields[1]);
940                let version = extract_git_version(&fields[1]);
941                Some(Dependency {
942                    purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
943                    extracted_requirement: version.map(truncate_field),
944                    scope: Some("dependencies".to_string()),
945                    is_runtime: None,
946                    is_optional: None,
947                    is_pinned: None,
948                    is_direct: None,
949                    resolved_package: None,
950                    extra_data: build_rebar_dependency_extra_data(
951                        vcs_url,
952                        app_name.as_str(),
953                        package_name.as_str(),
954                    ),
955                })
956            }
957        }
958        _ => None,
959    }
960}
961
962fn extract_rebar_package_name(term: &ErlTerm, fallback_name: &str) -> String {
963    if let ErlTerm::Tuple(fields) = term
964        && fields.len() >= 2
965        && term_to_str(&fields[0]).as_deref() == Some("pkg")
966        && let Some(package_name) = term_to_str(&fields[1])
967    {
968        package_name
969    } else {
970        fallback_name.to_string()
971    }
972}
973
974fn build_rebar_dependency_extra_data(
975    vcs_url: Option<String>,
976    app_name: &str,
977    package_name: &str,
978) -> Option<HashMap<String, JsonValue>> {
979    let mut extra_data = HashMap::new();
980
981    if let Some(url) = vcs_url {
982        extra_data.insert(
983            "vcs_url".to_string(),
984            JsonValue::String(truncate_field(url)),
985        );
986    }
987
988    if app_name != package_name {
989        extra_data.insert(
990            "app_name".to_string(),
991            JsonValue::String(truncate_field(app_name.to_string())),
992        );
993    }
994
995    if extra_data.is_empty() {
996        None
997    } else {
998        Some(extra_data)
999    }
1000}
1001
1002fn extract_git_url(term: &ErlTerm) -> Option<String> {
1003    if let ErlTerm::Tuple(fields) = term
1004        && fields.len() >= 2
1005        && matches!(
1006            term_to_str(&fields[0]).as_deref(),
1007            Some("git") | Some("git_subdir")
1008        )
1009    {
1010        term_to_str(&fields[1])
1011    } else {
1012        None
1013    }
1014}
1015
1016fn extract_git_version(term: &ErlTerm) -> Option<String> {
1017    if let ErlTerm::Tuple(fields) = term
1018        && fields.len() >= 3
1019        && matches!(
1020            term_to_str(&fields[0]).as_deref(),
1021            Some("git") | Some("git_subdir")
1022        )
1023    {
1024        if let ErlTerm::Tuple(ref_fields) = &fields[2]
1025            && ref_fields.len() == 2
1026        {
1027            let ref_type = term_to_str(&ref_fields[0])?;
1028            let ref_val = term_to_str(&ref_fields[1])?;
1029            match ref_type.as_str() {
1030                "tag" => Some(ref_val),
1031                _ => None,
1032            }
1033        } else {
1034            None
1035        }
1036    } else {
1037        None
1038    }
1039}
1040
1041fn parse_profile_deps(term: &ErlTerm, dependencies: &mut Vec<Dependency>) {
1042    let profiles = match term {
1043        ErlTerm::List(items) => items,
1044        _ => return,
1045    };
1046
1047    let profiles_limit = capped_iteration_limit(profiles.len(), "rebar.config profiles");
1048    for profile in profiles.iter().take(profiles_limit) {
1049        if let ErlTerm::Tuple(fields) = profile
1050            && fields.len() == 2
1051        {
1052            let profile_name = term_to_str(&fields[0]).unwrap_or_default();
1053            if let ErlTerm::List(profile_opts) = &fields[1] {
1054                for opt in profile_opts {
1055                    if let ErlTerm::Tuple(opt_fields) = opt
1056                        && opt_fields.len() == 2
1057                        && term_to_str(&opt_fields[0]).as_deref() == Some("deps")
1058                        && let ErlTerm::List(deps) = &opt_fields[1]
1059                    {
1060                        let profile_deps_limit =
1061                            capped_iteration_limit(deps.len(), "rebar.config profile deps");
1062                        for dep in deps.iter().take(profile_deps_limit) {
1063                            if let Some(mut d) = parse_rebar_dep(dep) {
1064                                d.scope = Some(truncate_field(profile_name.clone()));
1065                                dependencies.push(d);
1066                            }
1067                        }
1068                    }
1069                }
1070            }
1071        }
1072    }
1073}
1074
1075// ── RebarLockParser ──
1076
1077impl PackageParser for RebarLockParser {
1078    const PACKAGE_TYPE: PackageType = PackageType::Hex;
1079
1080    fn metadata() -> Vec<ParserMetadata> {
1081        vec![ParserMetadata {
1082            description: "Rebar3 lockfile",
1083            file_patterns: &["**/rebar.lock"],
1084            package_type: "hex",
1085            primary_language: "Erlang",
1086            documentation_url: Some("https://rebar3.org/docs/configuration/configuration/"),
1087        }]
1088    }
1089
1090    fn is_match(path: &Path) -> bool {
1091        path.file_name().and_then(|n| n.to_str()) == Some("rebar.lock")
1092    }
1093
1094    fn extract_packages(path: &Path) -> Vec<PackageData> {
1095        let content = match read_file_to_string(path, None) {
1096            Ok(c) => c,
1097            Err(e) => {
1098                warn!("Failed to read {:?}: {}", path, e);
1099                return vec![default_rebar_lock_package()];
1100            }
1101        };
1102
1103        match parse_rebar_lock(&content) {
1104            Ok(pkg) => vec![pkg],
1105            Err(e) => {
1106                warn!("Failed to parse {:?}: {}", path, e);
1107                vec![default_rebar_lock_package()]
1108            }
1109        }
1110    }
1111}
1112
1113fn default_rebar_lock_package() -> PackageData {
1114    PackageData {
1115        package_type: Some(PackageType::Hex),
1116        primary_language: Some("Erlang".to_string()),
1117        datasource_id: Some(DatasourceId::RebarLock),
1118        ..Default::default()
1119    }
1120}
1121
1122fn parse_rebar_lock(content: &str) -> Result<PackageData, String> {
1123    let terms = parse_dotted_terms(content)?;
1124
1125    // rebar.lock format: first term is either:
1126    // - {Version, [deps]}  (v2 format, e.g. {"1.2.0", [...]})
1127    // - [deps]             (v1 format, flat list)
1128    // Second term (if present): [{pkg_hash, [...]}, {pkg_hash_ext, [...]}]
1129
1130    let (dep_list, hash_map) = match terms.as_slice() {
1131        // v2 format: {"1.2.0", [deps]}
1132        [ErlTerm::Tuple(fields), rest @ ..] if fields.len() == 2 => {
1133            let deps = match &fields[1] {
1134                ErlTerm::List(items) => items.clone(),
1135                _ => return Err("Expected dependency list in lock tuple".to_string()),
1136            };
1137            let hashes = rest.first().map(extract_pkg_hashes).unwrap_or_default();
1138            (deps, hashes)
1139        }
1140        // v1 format: [deps]
1141        [ErlTerm::List(items), rest @ ..] => {
1142            let hashes = rest.first().map(extract_pkg_hashes).unwrap_or_default();
1143            (items.clone(), hashes)
1144        }
1145        _ => return Err("Unrecognized rebar.lock format".to_string()),
1146    };
1147
1148    let mut package = default_rebar_lock_package();
1149
1150    let dep_list_limit = capped_iteration_limit(dep_list.len(), "rebar.lock deps");
1151    for dep_term in dep_list.iter().take(dep_list_limit) {
1152        if let Some(dep) = parse_lock_dep(dep_term, &hash_map) {
1153            package.dependencies.push(dep);
1154        }
1155    }
1156
1157    Ok(package)
1158}
1159
1160fn parse_lock_dep(term: &ErlTerm, hashes: &HashMap<String, String>) -> Option<Dependency> {
1161    let fields = match term {
1162        ErlTerm::Tuple(fields) if fields.len() >= 3 => fields,
1163        _ => return None,
1164    };
1165
1166    let app_name = term_to_str(&fields[0])?;
1167    // fields[2] is the level (integer)
1168
1169    let (package_name, version, vcs_url) = match &fields[1] {
1170        // {pkg, <<"name">>, <<"version">>}
1171        ErlTerm::Tuple(pkg_fields)
1172            if pkg_fields.len() >= 3 && term_to_str(&pkg_fields[0]).as_deref() == Some("pkg") =>
1173        {
1174            let package_name = term_to_str(&pkg_fields[1]).unwrap_or_else(|| app_name.clone());
1175            let ver = term_to_str(&pkg_fields[2]);
1176            (package_name, ver, None)
1177        }
1178        // {git, "url", {ref, "hash"}}
1179        ErlTerm::Tuple(git_fields)
1180            if git_fields.len() >= 2
1181                && matches!(
1182                    term_to_str(&git_fields[0]).as_deref(),
1183                    Some("git") | Some("git_subdir")
1184                ) =>
1185        {
1186            let url = term_to_str(&git_fields[1]);
1187            let ver = if git_fields.len() >= 3 {
1188                extract_git_version_from_lock_ref(&git_fields[2])
1189            } else {
1190                None
1191            };
1192            (app_name.clone(), ver, url)
1193        }
1194        _ => (app_name.clone(), None, None),
1195    };
1196
1197    let sha256 = hashes
1198        .get(&app_name)
1199        .or_else(|| hashes.get(&package_name))
1200        .and_then(|h| Sha256Digest::from_hex(h).ok());
1201
1202    let resolved_package = ResolvedPackage {
1203        primary_language: Some("Erlang".to_string()),
1204        sha256,
1205        is_virtual: true,
1206        datasource_id: Some(DatasourceId::RebarLock),
1207        purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
1208        repository_homepage_url: Some(truncate_field(format!(
1209            "https://hex.pm/packages/{}",
1210            package_name
1211        ))),
1212        api_data_url: Some(truncate_field(format!(
1213            "https://hex.pm/api/packages/{}",
1214            package_name
1215        ))),
1216        ..ResolvedPackage::new(
1217            PackageType::Hex,
1218            String::new(),
1219            package_name.clone(),
1220            version.clone().unwrap_or_default(),
1221        )
1222    };
1223
1224    Some(Dependency {
1225        purl: build_hex_purl(&package_name, version.as_deref()).map(truncate_field),
1226        extracted_requirement: version.map(truncate_field),
1227        scope: Some("dependencies".to_string()),
1228        is_runtime: None,
1229        is_optional: None,
1230        is_pinned: Some(true),
1231        is_direct: None,
1232        resolved_package: Some(Box::new(resolved_package)),
1233        extra_data: build_rebar_dependency_extra_data(
1234            vcs_url,
1235            app_name.as_str(),
1236            package_name.as_str(),
1237        ),
1238    })
1239}
1240
1241fn extract_git_version_from_lock_ref(term: &ErlTerm) -> Option<String> {
1242    if let ErlTerm::Tuple(fields) = term
1243        && fields.len() == 2
1244        && term_to_str(&fields[0]).as_deref() == Some("ref")
1245    {
1246        term_to_str(&fields[1])
1247    } else {
1248        None
1249    }
1250}
1251
1252fn extract_pkg_hashes(term: &ErlTerm) -> HashMap<String, String> {
1253    let items = match term {
1254        ErlTerm::List(items) => items,
1255        _ => return HashMap::new(),
1256    };
1257
1258    let mut hashes = HashMap::new();
1259    for item in items {
1260        if let ErlTerm::Tuple(fields) = item
1261            && fields.len() == 2
1262            && term_to_str(&fields[0]).as_deref() == Some("pkg_hash")
1263            && let ErlTerm::List(hash_list) = &fields[1]
1264        {
1265            let hash_list_limit =
1266                capped_iteration_limit(hash_list.len(), "rebar.lock pkg_hash entries");
1267            for entry in hash_list.iter().take(hash_list_limit) {
1268                if let ErlTerm::Tuple(pair) = entry
1269                    && pair.len() == 2
1270                    && let (Some(name), Some(hash)) = (term_to_str(&pair[0]), term_to_str(&pair[1]))
1271                {
1272                    hashes.insert(name, hash);
1273                }
1274            }
1275        }
1276    }
1277    hashes
1278}