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