Skip to main content

provenant/parsers/
cran.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 CRAN R package DESCRIPTION files.
8//!
9//! Extracts package metadata and dependencies from R package DESCRIPTION files
10//! which use Debian Control File (DCF) format similar to RFC822.
11//!
12//! # Supported Formats
13//! - DESCRIPTION (CRAN R package manifest)
14//!
15//! # Key Features
16//! - Multi-type dependency extraction (Depends, Imports, Suggests, Enhances, LinkingTo)
17//! - Version constraint parsing with operators (>=, <=, >, <, ==)
18//! - Filters out R version requirements (not actual packages)
19//! - Author/Maintainer party extraction with email parsing
20//! - Package URL (purl) generation
21//!
22//! # Implementation Notes
23//! - Uses DCF/RFC822-like format with continuation lines
24//! - Field names are case-sensitive (Package, Version, Description, etc.)
25//! - Dependencies are comma-separated with optional version constraints
26//! - R version requirements (e.g., "R (>= 4.1.0)") are filtered out
27//! - Authors@R field is NOT parsed (requires R interpreter)
28
29use std::collections::HashMap;
30use std::path::Path;
31use std::sync::LazyLock;
32
33use crate::parser_warn as warn;
34use packageurl::PackageUrl;
35use regex::Regex;
36
37use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
38use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
39
40use super::PackageParser;
41
42/// CRAN R package DESCRIPTION file parser.
43///
44/// Extracts package metadata, dependencies, and party information from
45/// standard DESCRIPTION files used by R packages in the CRAN ecosystem.
46pub struct CranParser;
47
48impl PackageParser for CranParser {
49    const PACKAGE_TYPE: PackageType = PackageType::Cran;
50
51    fn is_match(path: &Path) -> bool {
52        path.file_name().is_some_and(|name| name == "DESCRIPTION")
53    }
54
55    fn extract_packages(path: &Path) -> Vec<PackageData> {
56        let content = match read_file_to_string(path, None) {
57            Ok(c) => c,
58            Err(e) => {
59                warn!("Failed to read DESCRIPTION at {:?}: {}", path, e);
60                return vec![default_package_data()];
61            }
62        };
63        let fields = parse_dcf(&content);
64
65        let name = fields
66            .get("Package")
67            .map(|s| truncate_field(s.trim().to_string()));
68        let version = fields
69            .get("Version")
70            .map(|s| truncate_field(s.trim().to_string()));
71
72        // Generate PURL
73        let purl = create_package_url(&name, &version);
74
75        // Generate repository URLs
76        let repository_homepage_url = name
77            .as_ref()
78            .map(|n| truncate_field(format!("https://cran.r-project.org/package={}", n)));
79
80        // Build description from Title and Description fields
81        let description = build_description(&fields);
82
83        // Extract license statement
84        let extracted_license_statement = fields
85            .get("License")
86            .map(|s| truncate_field(s.trim().to_string()));
87
88        // Extract URL field
89        let homepage_url = fields
90            .get("URL")
91            .map(|s| truncate_field(s.split(',').next().unwrap_or("").trim().to_string()))
92            .filter(|s| !s.is_empty());
93
94        // Extract parties (Author and Maintainer)
95        let mut parties = Vec::new();
96
97        // Parse Maintainer field
98        if let Some(maintainer_str) = fields.get("Maintainer")
99            && let Some(party) = parse_party(maintainer_str, "maintainer")
100        {
101            parties.push(party);
102        }
103
104        // Parse Author field
105        if let Some(author_str) = fields.get("Author") {
106            for author_part in split_author_entries(author_str) {
107                if let Some(party) = parse_party(author_part, "author") {
108                    parties.push(party);
109                }
110            }
111        }
112
113        // Extract dependencies from all dependency fields
114        let mut dependencies = Vec::new();
115
116        // Process each dependency type
117        for (field_name, scope) in [
118            ("Depends", None),
119            ("Imports", Some("imports")),
120            ("Suggests", Some("suggests")),
121            ("Enhances", Some("enhances")),
122            ("LinkingTo", Some("linkingto")),
123        ] {
124            if let Some(deps_str) = fields.get(field_name) {
125                dependencies.extend(parse_dependencies(deps_str, scope));
126            }
127        }
128
129        vec![PackageData {
130            package_type: Some(Self::PACKAGE_TYPE),
131            namespace: None,
132            name,
133            version,
134            qualifiers: None,
135            subpath: None,
136            primary_language: Some("R".to_string()),
137            description,
138            release_date: None,
139            parties,
140            keywords: Vec::new(),
141            homepage_url,
142            download_url: None,
143            size: None,
144            sha1: None,
145            md5: None,
146            sha256: None,
147            sha512: None,
148            bug_tracking_url: None,
149            code_view_url: None,
150            vcs_url: None,
151            copyright: None,
152            holder: None,
153            declared_license_expression: None,
154            declared_license_expression_spdx: None,
155            license_detections: Vec::new(),
156            other_license_expression: None,
157            other_license_expression_spdx: None,
158            other_license_detections: Vec::new(),
159            extracted_license_statement,
160            notice_text: None,
161            source_packages: Vec::new(),
162            file_references: Vec::new(),
163            is_private: false,
164            is_virtual: false,
165            extra_data: None,
166            dependencies,
167            repository_homepage_url,
168            repository_download_url: None,
169            api_data_url: None,
170            datasource_id: Some(DatasourceId::CranDescription),
171            purl,
172        }]
173    }
174
175    fn metadata() -> Vec<super::metadata::ParserMetadata> {
176        vec![super::metadata::ParserMetadata {
177            description: "CRAN R package DESCRIPTION file",
178            file_patterns: &["**/DESCRIPTION"],
179            package_type: "cran",
180            primary_language: "R",
181            documentation_url: Some(
182                "https://cran.r-project.org/doc/manuals/r-release/R-exts.html#The-DESCRIPTION-file",
183            ),
184        }]
185    }
186}
187
188fn parse_dcf(content: &str) -> HashMap<String, String> {
189    let mut fields: HashMap<String, String> = HashMap::new();
190    let mut current_field: Option<String> = None;
191    let mut current_value = String::new();
192
193    for line in content.lines().capped("DESCRIPTION DCF lines") {
194        // Check if line is a continuation (starts with whitespace)
195        if line.starts_with(' ') || line.starts_with('\t') {
196            if current_field.is_some() {
197                // Append to current value, replacing continuation line indent with space
198                if !current_value.is_empty() {
199                    current_value.push(' ');
200                }
201                current_value.push_str(line.trim_start());
202            }
203        } else if let Some((field_name, field_value)) = line.split_once(':') {
204            // New field: save previous field if any
205            if let Some(field) = current_field.take() {
206                fields.insert(field, truncate_field(current_value.clone()));
207                current_value.clear();
208            }
209
210            // Start new field
211            current_field = Some(field_name.trim().to_string());
212            current_value = field_value.trim_start().to_string();
213        }
214        // Else: empty line or invalid line - ignore
215    }
216
217    // Save the last field
218    if let Some(field) = current_field {
219        fields.insert(field, truncate_field(current_value));
220    }
221
222    fields
223}
224
225/// Parse a comma-separated dependency list with optional version constraints.
226///
227/// Format: "package1 (>= 1.0), package2, package3 (== 2.0)"
228/// Filters out R version requirements like "R (>= 4.1.0)"
229fn parse_dependencies(deps_str: &str, scope: Option<&str>) -> Vec<Dependency> {
230    let mut dependencies = Vec::new();
231
232    for dep in deps_str.split(',').capped("CRAN dependency list") {
233        let dep = dep.trim();
234        if dep.is_empty() {
235            continue;
236        }
237
238        let (name, extracted_requirement, is_pinned) = parse_version_constraint(dep);
239
240        // Skip R version requirements (not actual package dependencies)
241        if name == "R" {
242            continue;
243        }
244
245        // Create PURL for dependency
246        let purl = if is_pinned {
247            // For pinned versions, extract version from requirement
248            if let Some(ref req) = extracted_requirement {
249                if let Some(version) = extract_version_from_requirement(req) {
250                    match PackageUrl::new("cran", &name) {
251                        Ok(mut p) => {
252                            if p.with_version(&version).is_ok() {
253                                Some(p.to_string())
254                            } else {
255                                // Failed to set version, create without it
256                                PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
257                            }
258                        }
259                        Err(e) => {
260                            warn!(
261                                "Failed to create PURL for CRAN dependency '{}': {}",
262                                name, e
263                            );
264                            None
265                        }
266                    }
267                } else {
268                    // No version found in requirement
269                    PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
270                }
271            } else {
272                // No requirement
273                PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
274            }
275        } else {
276            // Not pinned, create PURL without version
277            PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
278        };
279
280        dependencies.push(Dependency {
281            purl,
282            extracted_requirement: extracted_requirement.map(truncate_field),
283            scope: scope.map(|s| truncate_field(s.to_string())),
284            is_runtime: Some(scope.is_none() || scope == Some("imports")),
285            is_optional: Some(scope == Some("suggests") || scope == Some("enhances")),
286            is_pinned: Some(is_pinned),
287            is_direct: Some(true),
288            resolved_package: None,
289            extra_data: None,
290        });
291    }
292
293    dependencies
294}
295
296static VERSION_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
297    Regex::new(r"^([a-zA-Z0-9.]+)\s*\(([><=]+)\s*([^)]+)\)\s*$").expect("valid regex")
298});
299
300/// Examples:
301/// - "cli (>= 3.6.2)" -> ("cli", Some(">= 3.6.2"), true)
302/// - "generics" -> ("generics", None, false)
303/// - "glue (== 1.3.2)" -> ("glue", Some("== 1.3.2"), true)
304fn parse_version_constraint(dep: &str) -> (String, Option<String>, bool) {
305    if let Some(captures) = VERSION_CONSTRAINT_RE.captures(dep) {
306        let name = match captures.get(1) {
307            Some(m) => truncate_field(m.as_str().to_string()),
308            None => return (truncate_field(dep.trim().to_string()), None, false),
309        };
310        let operator = match captures.get(2) {
311            Some(m) => m.as_str(),
312            None => return (name, None, false),
313        };
314        let version = match captures.get(3) {
315            Some(m) => m.as_str(),
316            None => return (name, None, false),
317        };
318        let requirement = truncate_field(format!("{} {}", operator, version));
319        let is_pinned = operator == "==";
320
321        (name, Some(requirement), is_pinned)
322    } else {
323        (truncate_field(dep.trim().to_string()), None, false)
324    }
325}
326
327/// Extract version number from a requirement string like ">= 3.6.2" or "== 1.0.0".
328fn extract_version_from_requirement(requirement: &str) -> Option<String> {
329    requirement
330        .split_whitespace()
331        .nth(1)
332        .map(|s| truncate_field(s.to_string()))
333}
334
335/// Build description from Title and Description fields.
336fn build_description(fields: &HashMap<String, String>) -> Option<String> {
337    let title = fields.get("Title").map(|s| s.trim());
338    let desc = fields.get("Description").map(|s| s.trim());
339
340    match (title, desc) {
341        (Some(t), Some(d)) if !t.is_empty() && !d.is_empty() => {
342            Some(truncate_field(format!("{}\n{}", t, d)))
343        }
344        (Some(t), _) if !t.is_empty() => Some(truncate_field(t.to_string())),
345        (_, Some(d)) if !d.is_empty() => Some(truncate_field(d.to_string())),
346        _ => None,
347    }
348}
349
350fn split_author_entries(author_str: &str) -> Vec<&str> {
351    let mut entries = Vec::new();
352    let mut start = 0;
353    let mut bracket_depth: usize = 0;
354    let mut paren_depth: usize = 0;
355
356    for (idx, ch) in author_str.char_indices().capped("CRAN author string") {
357        match ch {
358            '[' => bracket_depth += 1,
359            ']' => bracket_depth = bracket_depth.saturating_sub(1),
360            '(' => paren_depth += 1,
361            ')' => paren_depth = paren_depth.saturating_sub(1),
362            ',' if bracket_depth == 0 && paren_depth == 0 => {
363                let entry = author_str[start..idx].trim();
364                if !entry.is_empty() {
365                    entries.push(entry);
366                }
367                start = idx + 1;
368            }
369            _ => {}
370        }
371    }
372
373    let final_entry = author_str[start..].trim();
374    if !final_entry.is_empty() {
375        entries.push(final_entry);
376    }
377
378    entries
379}
380
381/// Parse party information from Author or Maintainer field.
382///
383/// Formats supported:
384/// - "Name <email@domain.com>"
385/// - "Name"
386/// - "email@domain.com"
387fn parse_party(info: &str, role: &str) -> Option<Party> {
388    let info = info.trim();
389    if info.is_empty() {
390        return None;
391    }
392
393    // Check for "Name <email>" format
394    if info.contains('<') && info.contains('>') {
395        let parts: Vec<&str> = info.split('<').collect();
396        if parts.len() == 2 {
397            let name = parts[0].trim().to_string();
398            let email = parts[1].trim_end_matches('>').trim().to_string();
399
400            if !email.contains('@') {
401                return Some(Party {
402                    r#type: Some(PartyType::Person),
403                    role: Some(truncate_field(role.to_string())),
404                    name: Some(truncate_field(info.to_string())),
405                    email: None,
406                    url: None,
407                    organization: None,
408                    organization_url: None,
409                    timezone: None,
410                });
411            }
412
413            return Some(Party {
414                r#type: Some(PartyType::Person),
415                role: Some(truncate_field(role.to_string())),
416                name: if name.is_empty() {
417                    None
418                } else {
419                    Some(truncate_field(name))
420                },
421                email: if email.is_empty() {
422                    None
423                } else {
424                    Some(truncate_field(email))
425                },
426                url: None,
427                organization: None,
428                organization_url: None,
429                timezone: None,
430            });
431        }
432    }
433
434    // Just a name or email
435    Some(Party {
436        r#type: Some(PartyType::Person),
437        role: Some(truncate_field(role.to_string())),
438        name: Some(truncate_field(info.to_string())),
439        email: None,
440        url: None,
441        organization: None,
442        organization_url: None,
443        timezone: None,
444    })
445}
446
447/// Create a package URL for a CRAN package.
448fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
449    name.as_ref().and_then(|name| {
450        let mut package_url = match PackageUrl::new("cran", name) {
451            Ok(p) => p,
452            Err(e) => {
453                warn!(
454                    "Failed to create PackageUrl for CRAN package '{}': {}",
455                    name, e
456                );
457                return None;
458            }
459        };
460
461        if let Some(v) = version
462            && let Err(e) = package_url.with_version(v)
463        {
464            warn!(
465                "Failed to set version '{}' for CRAN package '{}': {}",
466                v, name, e
467            );
468            return None;
469        }
470
471        Some(package_url.to_string())
472    })
473}
474
475fn default_package_data() -> PackageData {
476    PackageData {
477        package_type: Some(CranParser::PACKAGE_TYPE),
478        primary_language: Some("R".to_string()),
479        datasource_id: Some(DatasourceId::CranDescription),
480        ..Default::default()
481    }
482}