Skip to main content

provenant/parsers/
cpan_dist_ini.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for CPAN dist.ini files.
7//!
8//! Extracts Perl package metadata from `dist.ini` files used by Dist::Zilla.
9//!
10//! # Supported Formats
11//! - `dist.ini` - CPAN Dist::Zilla configuration
12//!
13//! # Implementation Notes
14//! - Format: INI-style configuration file
15//! - Spec: https://metacpan.org/pod/Dist::Zilla::Tutorial
16//! - Extracts: name, version, author, license, copyright_holder, abstract
17//! - Dependencies from [Prereq] sections (beyond Python which has no parser)
18
19use std::collections::HashMap;
20use std::path::Path;
21
22use crate::parser_warn as warn;
23use crate::parsers::utils::{
24    CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
25};
26use serde_json::json;
27
28use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
29
30use super::PackageParser;
31use super::license_normalization::{
32    DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
33    empty_declared_license_data, normalize_declared_license_key, normalize_spdx_expression,
34};
35
36const PACKAGE_TYPE: PackageType = PackageType::Cpan;
37
38pub struct CpanDistIniParser;
39
40impl PackageParser for CpanDistIniParser {
41    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
42
43    fn is_match(path: &Path) -> bool {
44        path.to_str().is_some_and(|p| p.ends_with("/dist.ini"))
45    }
46
47    fn extract_packages(path: &Path) -> Vec<PackageData> {
48        let content = match read_file_to_string(path, None) {
49            Ok(c) => c,
50            Err(e) => {
51                warn!("Failed to read dist.ini file {:?}: {}", path, e);
52                return vec![PackageData {
53                    package_type: Some(PACKAGE_TYPE),
54                    primary_language: Some("Perl".to_string()),
55                    datasource_id: Some(DatasourceId::CpanDistIni),
56                    ..Default::default()
57                }];
58            }
59        };
60
61        vec![parse_dist_ini(&content)]
62    }
63
64    fn metadata() -> Vec<super::metadata::ParserMetadata> {
65        vec![super::metadata::ParserMetadata {
66            description: "CPAN Perl dist.ini",
67            file_patterns: &["*/dist.ini"],
68            package_type: "cpan",
69            primary_language: "Perl",
70            documentation_url: Some("https://metacpan.org/pod/Dist::Zilla::Tutorial"),
71        }]
72    }
73}
74
75pub(crate) fn parse_dist_ini(content: &str) -> PackageData {
76    let (root_fields, sections) = parse_ini_structure(content);
77
78    let name = root_fields
79        .get("name")
80        .map(|s| truncate_field(s.replace('-', "::")));
81    let version = root_fields.get("version").cloned().map(truncate_field);
82    let description = root_fields.get("abstract").cloned().map(truncate_field);
83    let extracted_license_statement = root_fields.get("license").cloned().map(truncate_field);
84    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
85        extracted_license_statement
86            .as_deref()
87            .and_then(normalize_cpan_dist_ini_license)
88            .map(|normalized| {
89                build_declared_license_data(
90                    normalized,
91                    DeclaredLicenseMatchMetadata::single_line(
92                        extracted_license_statement.as_deref().unwrap_or_default(),
93                    ),
94                )
95            })
96            .unwrap_or_else(empty_declared_license_data);
97    let copyright_holder = root_fields
98        .get("copyright_holder")
99        .cloned()
100        .map(truncate_field);
101
102    let parties = parse_author(&root_fields);
103    let dependencies = parse_dependencies(&sections);
104
105    let mut extra_data = HashMap::new();
106    if let Some(holder) = copyright_holder {
107        extra_data.insert("copyright_holder".to_string(), json!(holder));
108    }
109    if let Some(year) = root_fields.get("copyright_year") {
110        extra_data.insert("copyright_year".to_string(), json!(year));
111    }
112
113    PackageData {
114        package_type: Some(PACKAGE_TYPE),
115        namespace: Some("cpan".to_string()),
116        name,
117        version,
118        description,
119        declared_license_expression,
120        declared_license_expression_spdx,
121        license_detections,
122        extracted_license_statement,
123        parties,
124        dependencies,
125        extra_data: if extra_data.is_empty() {
126            None
127        } else {
128            Some(extra_data)
129        },
130        datasource_id: Some(DatasourceId::CpanDistIni),
131        primary_language: Some("Perl".to_string()),
132        ..Default::default()
133    }
134}
135
136fn normalize_cpan_dist_ini_license(value: &str) -> Option<NormalizedDeclaredLicense> {
137    match value.trim() {
138        "Perl_5" => Some(NormalizedDeclaredLicense::new(
139            "gpl-1.0-plus OR artistic-perl-1.0",
140            "GPL-1.0-or-later OR Artistic-1.0-Perl",
141        )),
142        other => normalize_spdx_expression(other).or_else(|| normalize_declared_license_key(other)),
143    }
144}
145
146fn parse_ini_structure(
147    content: &str,
148) -> (
149    HashMap<String, String>,
150    HashMap<String, HashMap<String, String>>,
151) {
152    let mut root_fields = HashMap::new();
153    let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
154    let mut current_section: Option<String> = None;
155
156    for line in content.lines().capped("dist.ini lines") {
157        let line = line.trim();
158
159        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
160            continue;
161        }
162
163        if line.starts_with('[') && line.ends_with(']') {
164            current_section = Some(line[1..line.len() - 1].to_string());
165            continue;
166        }
167
168        if let Some((key, value)) = line.split_once('=') {
169            let key = key.trim().to_string();
170            let value = truncate_field(value.trim().to_string());
171
172            if let Some(section_name) = &current_section {
173                sections
174                    .entry(section_name.clone())
175                    .or_default()
176                    .insert(key, value);
177            } else {
178                root_fields.insert(key, value);
179            }
180        }
181    }
182
183    (root_fields, sections)
184}
185
186fn parse_author(fields: &HashMap<String, String>) -> Vec<Party> {
187    fields
188        .get("author")
189        .map(|author_str| {
190            if let Some((name, email)) = parse_author_string(author_str) {
191                vec![Party {
192                    role: Some("author".to_string()),
193                    name: Some(name),
194                    email: Some(email),
195                    r#type: None,
196                    url: None,
197                    organization: None,
198                    organization_url: None,
199                    timezone: None,
200                }]
201            } else {
202                vec![Party {
203                    role: Some("author".to_string()),
204                    name: Some(truncate_field(author_str.clone())),
205                    r#type: None,
206                    email: None,
207                    url: None,
208                    organization: None,
209                    organization_url: None,
210                    timezone: None,
211                }]
212            }
213        })
214        .unwrap_or_default()
215}
216
217fn parse_author_string(s: &str) -> Option<(String, String)> {
218    if let Some(start) = s.find('<')
219        && let Some(end) = s.find('>')
220    {
221        let name = truncate_field(s[..start].trim().to_string());
222        let email = truncate_field(s[start + 1..end].trim().to_string());
223        return Some((name, email));
224    }
225    None
226}
227
228fn parse_dependencies(sections: &HashMap<String, HashMap<String, String>>) -> Vec<Dependency> {
229    let mut dependencies = Vec::new();
230
231    let mut sorted_sections: Vec<_> = sections.iter().collect();
232    sorted_sections.sort_by_key(|(left_name, _)| *left_name);
233
234    let sections_limit = capped_iteration_limit(sorted_sections.len(), "dist.ini prereq sections");
235    for (section_name, fields) in sorted_sections.iter().take(sections_limit) {
236        let Some(scope) = classify_prereq_scope(section_name) else {
237            continue;
238        };
239
240        let mut sorted_fields: Vec<_> = fields.iter().collect();
241        sorted_fields.sort_by_key(|(left_name, _)| *left_name);
242
243        let fields_limit = capped_iteration_limit(sorted_fields.len(), "dist.ini prereq modules");
244        for (module_name, version_req) in sorted_fields.iter().take(fields_limit) {
245            let purl = truncate_field(format!(
246                "pkg:cpan/{}",
247                crate::parsers::cpan::cpan_distribution_name(module_name)
248            ));
249            let extracted_requirement = if version_req.as_str() == "0" || version_req.is_empty() {
250                None
251            } else {
252                Some(truncate_field(version_req.to_string()))
253            };
254
255            dependencies.push(Dependency {
256                purl: Some(purl),
257                scope: Some(scope.clone()),
258                extracted_requirement,
259                is_runtime: Some(scope == "runtime"),
260                is_optional: Some(false),
261                is_pinned: None,
262                is_direct: None,
263                resolved_package: None,
264                extra_data: None,
265            });
266        }
267    }
268
269    dependencies
270}
271
272fn classify_prereq_scope(section_name: &str) -> Option<String> {
273    if !section_name.starts_with("Prereq") {
274        return None;
275    }
276
277    if section_name.contains("TestRequires") || section_name.contains("Test") {
278        Some("test".to_string())
279    } else if section_name.contains("BuildRequires") || section_name.contains("Build") {
280        Some("build".to_string())
281    } else if section_name.contains("ConfigureRequires") || section_name.contains("Configure") {
282        Some("configure".to_string())
283    } else {
284        Some("runtime".to_string())
285    }
286}