Skip to main content

normalize_manifest/
perl.rs

1//! Parser for `cpanfile` files (Perl/CPAN).
2//!
3//! Heuristic line-based parsing:
4//! - `requires 'Pkg'` / `requires 'Pkg', '>= 1.0'` → `DepKind::Normal`
5//! - `recommends 'Pkg'` → `DepKind::Optional`
6//! - `on 'test' => sub { ... }` / `on 'develop' => sub { ... }` → `DepKind::Dev`
7//! - Tracks the current `on` block context.
8
9use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11/// Parser for `cpanfile` files.
12pub struct CpanfileParser;
13
14impl ManifestParser for CpanfileParser {
15    fn filename(&self) -> &'static str {
16        "cpanfile"
17    }
18
19    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
20        let mut deps: Vec<DeclaredDep> = Vec::new();
21
22        // Track context: None = top-level, Some(kind) = inside an on block
23        let mut block_kind: Option<DepKind> = None;
24        let mut brace_depth: i32 = 0;
25        // Have we opened the on-block yet?
26        let mut in_on_header = false;
27
28        for line in content.lines() {
29            let trimmed = line.trim();
30
31            if trimmed.is_empty() || trimmed.starts_with('#') {
32                continue;
33            }
34
35            // Count braces to track block depth
36            for ch in trimmed.chars() {
37                match ch {
38                    '{' => {
39                        brace_depth += 1;
40                        if in_on_header {
41                            in_on_header = false;
42                        }
43                    }
44                    '}' => {
45                        brace_depth -= 1;
46                        if brace_depth == 0 {
47                            block_kind = None;
48                        }
49                    }
50                    _ => {}
51                }
52            }
53
54            // `on 'test' => sub {` or `on 'develop' => sub {`
55            if trimmed.starts_with("on ") || trimmed.starts_with("on\t") {
56                block_kind = Some(parse_on_kind(trimmed));
57                in_on_header = true;
58                continue;
59            }
60
61            // `requires 'Pkg'` / `requires 'Pkg', '>= 1.0';`
62            if trimmed.starts_with("requires ") || trimmed.starts_with("requires\t") {
63                let kind = block_kind.unwrap_or(DepKind::Normal);
64                if let Some(dep) = parse_cpan_dep_line(trimmed, kind) {
65                    deps.push(dep);
66                }
67                continue;
68            }
69
70            // `recommends 'Pkg'`
71            if (trimmed.starts_with("recommends ") || trimmed.starts_with("recommends\t"))
72                && let Some(dep) = parse_cpan_dep_line(trimmed, DepKind::Optional)
73            {
74                deps.push(dep);
75            }
76        }
77
78        Ok(ParsedManifest {
79            ecosystem: "cpan",
80            name: None,
81            version: None,
82            dependencies: deps,
83        })
84    }
85}
86
87/// Determine `DepKind` from `on 'test' => sub {` line.
88fn parse_on_kind(line: &str) -> DepKind {
89    if line.contains("'test'")
90        || line.contains("\"test\"")
91        || line.contains("'develop'")
92        || line.contains("\"develop\"")
93    {
94        DepKind::Dev
95    } else {
96        DepKind::Optional
97    }
98}
99
100/// Parse `requires 'Pkg', '>= 1.0';` or `recommends 'Pkg';`.
101fn parse_cpan_dep_line(line: &str, kind: DepKind) -> Option<DeclaredDep> {
102    // Strip verb keyword
103    let rest = line
104        .trim_start_matches("requires")
105        .trim_start_matches("recommends")
106        .trim();
107
108    // Collect all single- or double-quoted strings
109    let quoted = extract_quoted_strings(rest);
110    if quoted.is_empty() {
111        return None;
112    }
113
114    let name = quoted[0].clone();
115    if name.is_empty() || name == "perl" {
116        return None; // Skip the perl runtime itself
117    }
118
119    let version_req = quoted.get(1).cloned().filter(|v| !v.is_empty());
120
121    Some(DeclaredDep {
122        name,
123        version_req,
124        kind,
125    })
126}
127
128fn extract_quoted_strings(s: &str) -> Vec<String> {
129    let mut result = Vec::new();
130    let mut chars = s.chars().peekable();
131    while let Some(ch) = chars.next() {
132        if ch == '\'' || ch == '"' {
133            let mut token = String::new();
134            for inner in chars.by_ref() {
135                if inner == ch {
136                    break;
137                }
138                token.push(inner);
139            }
140            result.push(token);
141        }
142    }
143    result
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::ManifestParser;
150
151    #[test]
152    fn test_parse_cpanfile() {
153        let content = r#"requires 'perl', '5.10.0';
154requires 'Moose', '>= 2.0';
155requires 'namespace::autoclean';
156recommends 'DateTime';
157on 'test' => sub {
158    requires 'Test::More', '>= 0.98';
159    requires 'Test::Exception';
160};
161on 'develop' => sub {
162    requires 'Dist::Zilla';
163    requires 'Pod::Coverage';
164};
165"#;
166        let m = CpanfileParser.parse(content).unwrap();
167        assert_eq!(m.ecosystem, "cpan");
168
169        let moose = m.dependencies.iter().find(|d| d.name == "Moose").unwrap();
170        assert_eq!(moose.kind, DepKind::Normal);
171        assert_eq!(moose.version_req.as_deref(), Some(">= 2.0"));
172
173        let ns = m
174            .dependencies
175            .iter()
176            .find(|d| d.name == "namespace::autoclean")
177            .unwrap();
178        assert_eq!(ns.kind, DepKind::Normal);
179        assert!(ns.version_req.is_none());
180
181        let dt = m
182            .dependencies
183            .iter()
184            .find(|d| d.name == "DateTime")
185            .unwrap();
186        assert_eq!(dt.kind, DepKind::Optional);
187
188        let tm = m
189            .dependencies
190            .iter()
191            .find(|d| d.name == "Test::More")
192            .unwrap();
193        assert_eq!(tm.kind, DepKind::Dev);
194        assert_eq!(tm.version_req.as_deref(), Some(">= 0.98"));
195
196        let dz = m
197            .dependencies
198            .iter()
199            .find(|d| d.name == "Dist::Zilla")
200            .unwrap();
201        assert_eq!(dz.kind, DepKind::Dev);
202
203        // perl runtime should be skipped
204        assert!(!m.dependencies.iter().any(|d| d.name == "perl"));
205    }
206
207    #[test]
208    fn test_nested_on_blocks() {
209        let content =
210            "requires 'Scalar::Util';\non 'test' => sub {\n    requires 'Test::Deep';\n};\n";
211        let m = CpanfileParser.parse(content).unwrap();
212        let su = m
213            .dependencies
214            .iter()
215            .find(|d| d.name == "Scalar::Util")
216            .unwrap();
217        assert_eq!(su.kind, DepKind::Normal);
218        let td = m
219            .dependencies
220            .iter()
221            .find(|d| d.name == "Test::Deep")
222            .unwrap();
223        assert_eq!(td.kind, DepKind::Dev);
224    }
225}