Skip to main content

provenant/parsers/
cpan_dist_ini.rs

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