1use 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
48pub 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 let purl = create_package_url(&name, &version);
80
81 let repository_homepage_url = name
83 .as_ref()
84 .map(|n| truncate_field(format!("https://cran.r-project.org/package={}", n)));
85
86 let description = build_description(&fields);
88
89 let extracted_license_statement = fields
91 .get("License")
92 .map(|s| truncate_field(s.trim().to_string()));
93
94 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
99 normalize_r_declared_license(extracted_license_statement.as_deref());
100
101 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 let mut parties = Vec::new();
109
110 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 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 let mut dependencies = Vec::new();
128
129 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 if line.starts_with(' ') || line.starts_with('\t') {
209 if current_field.is_some() {
210 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 if let Some(field) = current_field.take() {
219 fields.insert(field, truncate_field(current_value.clone()));
220 current_value.clear();
221 }
222
223 current_field = Some(field_name.trim().to_string());
225 current_value = field_value.trim_start().to_string();
226 }
227 }
229
230 if let Some(field) = current_field {
232 fields.insert(field, truncate_field(current_value));
233 }
234
235 fields
236}
237
238fn 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 if name == "R" {
255 continue;
256 }
257
258 let purl = if is_pinned {
260 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 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 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
283 }
284 } else {
285 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
287 }
288 } else {
289 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
313fn 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
340fn 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
348fn 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
394fn parse_party(info: &str, role: &str) -> Option<Party> {
401 let info = info.trim();
402 if info.is_empty() {
403 return None;
404 }
405
406 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 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
460fn 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
488fn 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 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
549fn 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
561fn 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
579static R_GNU_FAMILY_RE: LazyLock<Regex> =
582 LazyLock::new(|| Regex::new(r"(?i)^(A?GPL|LGPL)-(\d+)(\.\d+)?$").expect("valid regex"));
583
584fn 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
604fn 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}