normalize_manifest/
perl.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11pub 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 let mut block_kind: Option<DepKind> = None;
24 let mut brace_depth: i32 = 0;
25 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 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 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 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 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
87fn 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
100fn parse_cpan_dep_line(line: &str, kind: DepKind) -> Option<DeclaredDep> {
102 let rest = line
104 .trim_start_matches("requires")
105 .trim_start_matches("recommends")
106 .trim();
107
108 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; }
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 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}