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::{
38    DatasourceId, Dependency, LicenseDetection, PackageData, PackageType, Party, PartyType,
39};
40use crate::parsers::license_normalization::{
41    DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
42    combine_normalized_licenses, normalize_declared_license_key, normalize_spdx_expression,
43};
44use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
45
46use super::PackageParser;
47
48/// CRAN R package DESCRIPTION file parser.
49///
50/// Extracts package metadata, dependencies, and party information from
51/// standard DESCRIPTION files used by R packages in the CRAN ecosystem.
52pub struct CranParser;
53
54impl PackageParser for CranParser {
55    const PACKAGE_TYPE: PackageType = PackageType::Cran;
56
57    fn is_match(path: &Path) -> bool {
58        path.file_name().is_some_and(|name| name == "DESCRIPTION")
59    }
60
61    fn extract_packages(path: &Path) -> Vec<PackageData> {
62        let content = match read_file_to_string(path, None) {
63            Ok(c) => c,
64            Err(e) => {
65                warn!("Failed to read DESCRIPTION at {:?}: {}", path, e);
66                return vec![default_package_data()];
67            }
68        };
69        let fields = parse_dcf(&content);
70
71        let name = fields
72            .get("Package")
73            .map(|s| truncate_field(s.trim().to_string()));
74        let version = fields
75            .get("Version")
76            .map(|s| truncate_field(s.trim().to_string()));
77
78        // Generate PURL
79        let purl = create_package_url(&name, &version);
80
81        // Generate repository URLs
82        let repository_homepage_url = name
83            .as_ref()
84            .map(|n| truncate_field(format!("https://cran.r-project.org/package={}", n)));
85
86        // Build description from Title and Description fields
87        let description = build_description(&fields);
88
89        // Extract license statement
90        let extracted_license_statement = fields
91            .get("License")
92            .map(|s| truncate_field(s.trim().to_string()));
93
94        // Recover the SPDX core from R-specific `License:` idioms (e.g.
95        // `MIT + file LICENSE`, `GPL-2 | file LICENSE`, `BSD_3_clause`) that the
96        // shared declared-license normalization cannot parse. Cases this leaves
97        // unset fall through to the shared post-extraction populate step.
98        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
99            normalize_r_declared_license(extracted_license_statement.as_deref());
100
101        // Extract URL field
102        let homepage_url = fields
103            .get("URL")
104            .map(|s| truncate_field(s.split(',').next().unwrap_or("").trim().to_string()))
105            .filter(|s| !s.is_empty());
106
107        // Extract parties (Author and Maintainer)
108        let mut parties = Vec::new();
109
110        // Parse Maintainer field
111        if let Some(maintainer_str) = fields.get("Maintainer")
112            && let Some(party) = parse_party(maintainer_str, "maintainer")
113        {
114            parties.push(party);
115        }
116
117        // Parse Author field
118        if let Some(author_str) = fields.get("Author") {
119            for author_part in split_author_entries(author_str) {
120                if let Some(party) = parse_party(author_part, "author") {
121                    parties.push(party);
122                }
123            }
124        }
125
126        // Extract dependencies from all dependency fields
127        let mut dependencies = Vec::new();
128
129        // Process each dependency type
130        for (field_name, scope) in [
131            ("Depends", None),
132            ("Imports", Some("imports")),
133            ("Suggests", Some("suggests")),
134            ("Enhances", Some("enhances")),
135            ("LinkingTo", Some("linkingto")),
136        ] {
137            if let Some(deps_str) = fields.get(field_name) {
138                dependencies.extend(parse_dependencies(deps_str, scope));
139            }
140        }
141
142        vec![PackageData {
143            package_type: Some(Self::PACKAGE_TYPE),
144            namespace: None,
145            name,
146            version,
147            qualifiers: None,
148            subpath: None,
149            primary_language: Some("R".to_string()),
150            description,
151            release_date: None,
152            parties,
153            keywords: Vec::new(),
154            homepage_url,
155            download_url: None,
156            size: None,
157            sha1: None,
158            md5: None,
159            sha256: None,
160            sha512: None,
161            bug_tracking_url: None,
162            code_view_url: None,
163            vcs_url: None,
164            copyright: None,
165            holder: None,
166            declared_license_expression,
167            declared_license_expression_spdx,
168            license_detections,
169            other_license_expression: None,
170            other_license_expression_spdx: None,
171            other_license_detections: Vec::new(),
172            extracted_license_statement,
173            notice_text: None,
174            source_packages: Vec::new(),
175            file_references: Vec::new(),
176            is_private: false,
177            is_virtual: false,
178            extra_data: None,
179            dependencies,
180            repository_homepage_url,
181            repository_download_url: None,
182            api_data_url: None,
183            datasource_id: Some(DatasourceId::CranDescription),
184            purl,
185        }]
186    }
187
188    fn metadata() -> Vec<super::metadata::ParserMetadata> {
189        vec![super::metadata::ParserMetadata {
190            description: "CRAN R package DESCRIPTION file",
191            file_patterns: &["**/DESCRIPTION"],
192            package_type: "cran",
193            primary_language: "R",
194            documentation_url: Some(
195                "https://cran.r-project.org/doc/manuals/r-release/R-exts.html#The-DESCRIPTION-file",
196            ),
197        }]
198    }
199}
200
201fn parse_dcf(content: &str) -> HashMap<String, String> {
202    let mut fields: HashMap<String, String> = HashMap::new();
203    let mut current_field: Option<String> = None;
204    let mut current_value = String::new();
205
206    for line in content.lines().capped("DESCRIPTION DCF lines") {
207        // Check if line is a continuation (starts with whitespace)
208        if line.starts_with(' ') || line.starts_with('\t') {
209            if current_field.is_some() {
210                // Append to current value, replacing continuation line indent with space
211                if !current_value.is_empty() {
212                    current_value.push(' ');
213                }
214                current_value.push_str(line.trim_start());
215            }
216        } else if let Some((field_name, field_value)) = line.split_once(':') {
217            // New field: save previous field if any
218            if let Some(field) = current_field.take() {
219                fields.insert(field, truncate_field(current_value.clone()));
220                current_value.clear();
221            }
222
223            // Start new field
224            current_field = Some(field_name.trim().to_string());
225            current_value = field_value.trim_start().to_string();
226        }
227        // Else: empty line or invalid line - ignore
228    }
229
230    // Save the last field
231    if let Some(field) = current_field {
232        fields.insert(field, truncate_field(current_value));
233    }
234
235    fields
236}
237
238/// Parse a comma-separated dependency list with optional version constraints.
239///
240/// Format: "package1 (>= 1.0), package2, package3 (== 2.0)"
241/// Filters out R version requirements like "R (>= 4.1.0)"
242fn parse_dependencies(deps_str: &str, scope: Option<&str>) -> Vec<Dependency> {
243    let mut dependencies = Vec::new();
244
245    for dep in deps_str.split(',').capped("CRAN dependency list") {
246        let dep = dep.trim();
247        if dep.is_empty() {
248            continue;
249        }
250
251        let (name, extracted_requirement, is_pinned) = parse_version_constraint(dep);
252
253        // Skip R version requirements (not actual package dependencies)
254        if name == "R" {
255            continue;
256        }
257
258        // Create PURL for dependency
259        let purl = if is_pinned {
260            // For pinned versions, extract version from requirement
261            if let Some(ref req) = extracted_requirement {
262                if let Some(version) = extract_version_from_requirement(req) {
263                    match PackageUrl::new("cran", &name) {
264                        Ok(mut p) => {
265                            if p.with_version(&version).is_ok() {
266                                Some(p.to_string())
267                            } else {
268                                // Failed to set version, create without it
269                                PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
270                            }
271                        }
272                        Err(e) => {
273                            warn!(
274                                "Failed to create PURL for CRAN dependency '{}': {}",
275                                name, e
276                            );
277                            None
278                        }
279                    }
280                } else {
281                    // No version found in requirement
282                    PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
283                }
284            } else {
285                // No requirement
286                PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
287            }
288        } else {
289            // Not pinned, create PURL without version
290            PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
291        };
292
293        dependencies.push(Dependency {
294            purl,
295            extracted_requirement: extracted_requirement.map(truncate_field),
296            scope: scope.map(|s| truncate_field(s.to_string())),
297            is_runtime: Some(scope.is_none() || scope == Some("imports")),
298            is_optional: Some(scope == Some("suggests") || scope == Some("enhances")),
299            is_pinned: Some(is_pinned),
300            is_direct: Some(true),
301            resolved_package: None,
302            extra_data: None,
303        });
304    }
305
306    dependencies
307}
308
309static VERSION_CONSTRAINT_RE: LazyLock<Regex> = LazyLock::new(|| {
310    Regex::new(r"^([a-zA-Z0-9.]+)\s*\(([><=]+)\s*([^)]+)\)\s*$").expect("valid regex")
311});
312
313/// Examples:
314/// - "cli (>= 3.6.2)" -> ("cli", Some(">= 3.6.2"), true)
315/// - "generics" -> ("generics", None, false)
316/// - "glue (== 1.3.2)" -> ("glue", Some("== 1.3.2"), true)
317fn parse_version_constraint(dep: &str) -> (String, Option<String>, bool) {
318    if let Some(captures) = VERSION_CONSTRAINT_RE.captures(dep) {
319        let name = match captures.get(1) {
320            Some(m) => truncate_field(m.as_str().to_string()),
321            None => return (truncate_field(dep.trim().to_string()), None, false),
322        };
323        let operator = match captures.get(2) {
324            Some(m) => m.as_str(),
325            None => return (name, None, false),
326        };
327        let version = match captures.get(3) {
328            Some(m) => m.as_str(),
329            None => return (name, None, false),
330        };
331        let requirement = truncate_field(format!("{} {}", operator, version));
332        let is_pinned = operator == "==";
333
334        (name, Some(requirement), is_pinned)
335    } else {
336        (truncate_field(dep.trim().to_string()), None, false)
337    }
338}
339
340/// Extract version number from a requirement string like ">= 3.6.2" or "== 1.0.0".
341fn extract_version_from_requirement(requirement: &str) -> Option<String> {
342    requirement
343        .split_whitespace()
344        .nth(1)
345        .map(|s| truncate_field(s.to_string()))
346}
347
348/// Build description from Title and Description fields.
349fn build_description(fields: &HashMap<String, String>) -> Option<String> {
350    let title = fields.get("Title").map(|s| s.trim());
351    let desc = fields.get("Description").map(|s| s.trim());
352
353    match (title, desc) {
354        (Some(t), Some(d)) if !t.is_empty() && !d.is_empty() => {
355            Some(truncate_field(format!("{}\n{}", t, d)))
356        }
357        (Some(t), _) if !t.is_empty() => Some(truncate_field(t.to_string())),
358        (_, Some(d)) if !d.is_empty() => Some(truncate_field(d.to_string())),
359        _ => None,
360    }
361}
362
363fn split_author_entries(author_str: &str) -> Vec<&str> {
364    let mut entries = Vec::new();
365    let mut start = 0;
366    let mut bracket_depth: usize = 0;
367    let mut paren_depth: usize = 0;
368
369    for (idx, ch) in author_str.char_indices().capped("CRAN author string") {
370        match ch {
371            '[' => bracket_depth += 1,
372            ']' => bracket_depth = bracket_depth.saturating_sub(1),
373            '(' => paren_depth += 1,
374            ')' => paren_depth = paren_depth.saturating_sub(1),
375            ',' if bracket_depth == 0 && paren_depth == 0 => {
376                let entry = author_str[start..idx].trim();
377                if !entry.is_empty() {
378                    entries.push(entry);
379                }
380                start = idx + 1;
381            }
382            _ => {}
383        }
384    }
385
386    let final_entry = author_str[start..].trim();
387    if !final_entry.is_empty() {
388        entries.push(final_entry);
389    }
390
391    entries
392}
393
394/// Parse party information from Author or Maintainer field.
395///
396/// Formats supported:
397/// - "Name <email@domain.com>"
398/// - "Name"
399/// - "email@domain.com"
400fn parse_party(info: &str, role: &str) -> Option<Party> {
401    let info = info.trim();
402    if info.is_empty() {
403        return None;
404    }
405
406    // Check for "Name <email>" format
407    if info.contains('<') && info.contains('>') {
408        let parts: Vec<&str> = info.split('<').collect();
409        if parts.len() == 2 {
410            let name = parts[0].trim().to_string();
411            let email = parts[1].trim_end_matches('>').trim().to_string();
412
413            if !email.contains('@') {
414                return Some(Party {
415                    r#type: Some(PartyType::Person),
416                    role: Some(truncate_field(role.to_string())),
417                    name: Some(truncate_field(info.to_string())),
418                    email: None,
419                    url: None,
420                    organization: None,
421                    organization_url: None,
422                    timezone: None,
423                });
424            }
425
426            return Some(Party {
427                r#type: Some(PartyType::Person),
428                role: Some(truncate_field(role.to_string())),
429                name: if name.is_empty() {
430                    None
431                } else {
432                    Some(truncate_field(name))
433                },
434                email: if email.is_empty() {
435                    None
436                } else {
437                    Some(truncate_field(email))
438                },
439                url: None,
440                organization: None,
441                organization_url: None,
442                timezone: None,
443            });
444        }
445    }
446
447    // Just a name or email
448    Some(Party {
449        r#type: Some(PartyType::Person),
450        role: Some(truncate_field(role.to_string())),
451        name: Some(truncate_field(info.to_string())),
452        email: None,
453        url: None,
454        organization: None,
455        organization_url: None,
456        timezone: None,
457    })
458}
459
460/// Create a package URL for a CRAN package.
461fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
462    name.as_ref().and_then(|name| {
463        let mut package_url = match PackageUrl::new("cran", name) {
464            Ok(p) => p,
465            Err(e) => {
466                warn!(
467                    "Failed to create PackageUrl for CRAN package '{}': {}",
468                    name, e
469                );
470                return None;
471            }
472        };
473
474        if let Some(v) = version
475            && let Err(e) = package_url.with_version(v)
476        {
477            warn!(
478                "Failed to set version '{}' for CRAN package '{}': {}",
479                v, name, e
480            );
481            return None;
482        }
483
484        Some(package_url.to_string())
485    })
486}
487
488/// Normalizes an R `License:` field into declared-license data, recovering the
489/// SPDX core from R-specific idioms.
490///
491/// R's `DESCRIPTION` `License:` grammar is not SPDX: `|` separates alternatives
492/// (OR), `+ file <NAME>` / `| file <NAME>` attach a supplementary file holding
493/// the year/holder (the license itself is the non-file part), and BSD licenses
494/// use underscore spellings (`BSD_3_clause`, `BSD_2_clause`).
495///
496/// This handles only the idioms the shared declared-license normalization cannot
497/// parse. When the statement carries no R-specific idiom (e.g. a bare `GPL` or a
498/// version-range `GPL (>= 3)`), this returns empty data so the statement falls
499/// through to the shared post-extraction populate step, which already resolves
500/// those forms. It is deliberately conservative: a pure `+ file <NAME>` pointer
501/// is skipped, but if any real license alternative cannot be resolved (e.g. a
502/// version-range form like `GPL (>= 3)` mixed with `|`), the whole statement is
503/// left unset rather than emitting a partial that silently drops an operand.
504fn normalize_r_declared_license(
505    statement: Option<&str>,
506) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
507    let Some(statement) = statement.map(str::trim).filter(|value| !value.is_empty()) else {
508        return empty_license_data();
509    };
510
511    if !has_r_license_idiom(statement) {
512        return empty_license_data();
513    }
514
515    // `|` separates OR alternatives. Each alternative may carry a `+ file <NAME>`
516    // (or be a bare `file <NAME>`) clause that is dropped; a pure `file <NAME>`
517    // alternative yields no license core and is skipped.
518    //
519    // Every remaining license core must normalize. If any real alternative does
520    // not (e.g. a version-range form this idiom layer does not expand, as in
521    // `GPL-2 | GPL (>= 3)`), bail to an honest null via the shared path rather
522    // than silently dropping that alternative and emitting a misleading partial.
523    let mut normalized: Vec<NormalizedDeclaredLicense> = Vec::new();
524    for core in statement
525        .split('|')
526        .capped("R License alternatives")
527        .filter_map(strip_supplementary_file_clause)
528    {
529        let Some(license) = normalize_r_license_core(core) else {
530            return empty_license_data();
531        };
532        normalized.push(license);
533    }
534
535    if normalized.is_empty() {
536        return empty_license_data();
537    }
538
539    let Some(combined) = combine_normalized_licenses(normalized, " OR ") else {
540        return empty_license_data();
541    };
542
543    build_declared_license_data(
544        combined,
545        DeclaredLicenseMatchMetadata::single_line(statement),
546    )
547}
548
549/// Returns true when the statement uses an R-specific `License:` idiom that the
550/// shared SPDX/declared normalization cannot parse on its own: an alternative
551/// separator (`|`), a supplementary `file` clause, or an underscore BSD
552/// spelling.
553fn has_r_license_idiom(statement: &str) -> bool {
554    if statement.contains('|') {
555        return true;
556    }
557    let lower = statement.to_ascii_lowercase();
558    lower.contains("file ") || lower.contains("bsd_3_clause") || lower.contains("bsd_2_clause")
559}
560
561/// Drops a trailing `+ file <NAME>` supplementary-file clause from one
562/// alternative and returns the remaining license core. A bare `file <NAME>`
563/// alternative (no license core) yields `None` so it is skipped.
564fn strip_supplementary_file_clause(alternative: &str) -> Option<String> {
565    let core = alternative
566        .split('+')
567        .capped("R License components")
568        .map(str::trim)
569        .filter(|component| {
570            !component.is_empty() && !component.to_ascii_lowercase().starts_with("file ")
571        })
572        .collect::<Vec<_>>()
573        .join(" + ");
574
575    let core = core.trim();
576    (!core.is_empty()).then(|| core.to_string())
577}
578
579/// Matches R's bare GNU-family version spelling (`GPL-2`, `LGPL-2.1`, `AGPL-3`)
580/// so the major-only form can be expanded to the SPDX point release.
581static R_GNU_FAMILY_RE: LazyLock<Regex> =
582    LazyLock::new(|| Regex::new(r"(?i)^(A?GPL|LGPL)-(\d+)(\.\d+)?$").expect("valid regex"));
583
584/// Resolves a single cleaned R license core into a normalized declared license,
585/// mapping R's non-SPDX spellings to SPDX before normalization.
586///
587/// R writes BSD licenses with underscores (`BSD_3_clause`) and GNU licenses with
588/// a bare major version (`GPL-2`, `LGPL-3`); the latter is not valid SPDX, which
589/// requires the point release (`GPL-2.0`). A major-only GNU version is expanded
590/// to its canonical point release (`GPL-2` -> `GPL-2.0`), matching R's "version N
591/// exactly" meaning (the `-only` SPDX form). Version-or-later forms such as
592/// `GPL (>= 2)` are not handled here; they carry no R-specific idiom and are left
593/// to the shared post-extraction populate step.
594fn normalize_r_license_core(core: String) -> Option<NormalizedDeclaredLicense> {
595    let mapped = match core.to_ascii_lowercase().as_str() {
596        "bsd_3_clause" => "BSD-3-Clause".to_string(),
597        "bsd_2_clause" => "BSD-2-Clause".to_string(),
598        _ => expand_r_gnu_family_version(&core),
599    };
600
601    normalize_spdx_expression(&mapped).or_else(|| normalize_declared_license_key(&mapped))
602}
603
604/// Expands R's bare GNU-family version (`GPL-2`) to the SPDX point release
605/// (`GPL-2.0`), leaving an explicit minor version (`LGPL-2.1`) and any
606/// non-GNU value untouched.
607fn expand_r_gnu_family_version(core: &str) -> String {
608    match R_GNU_FAMILY_RE.captures(core) {
609        Some(captures) if captures.get(3).is_none() => {
610            format!("{}-{}.0", &captures[1], &captures[2])
611        }
612        _ => core.to_string(),
613    }
614}
615
616fn empty_license_data() -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
617    (None, None, Vec::new())
618}
619
620fn default_package_data() -> PackageData {
621    PackageData {
622        package_type: Some(CranParser::PACKAGE_TYPE),
623        primary_language: Some("R".to_string()),
624        datasource_id: Some(DatasourceId::CranDescription),
625        ..Default::default()
626    }
627}