Skip to main content

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