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::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
38use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
39
40use super::PackageParser;
41
42pub 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 let purl = create_package_url(&name, &version);
74
75 let repository_homepage_url = name
77 .as_ref()
78 .map(|n| truncate_field(format!("https://cran.r-project.org/package={}", n)));
79
80 let description = build_description(&fields);
82
83 let extracted_license_statement = fields
85 .get("License")
86 .map(|s| truncate_field(s.trim().to_string()));
87
88 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 let mut parties = Vec::new();
96
97 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 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 let mut dependencies = Vec::new();
115
116 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 if line.starts_with(' ') || line.starts_with('\t') {
196 if current_field.is_some() {
197 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 if let Some(field) = current_field.take() {
206 fields.insert(field, truncate_field(current_value.clone()));
207 current_value.clear();
208 }
209
210 current_field = Some(field_name.trim().to_string());
212 current_value = field_value.trim_start().to_string();
213 }
214 }
216
217 if let Some(field) = current_field {
219 fields.insert(field, truncate_field(current_value));
220 }
221
222 fields
223}
224
225fn 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 if name == "R" {
242 continue;
243 }
244
245 let purl = if is_pinned {
247 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 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 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
270 }
271 } else {
272 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
274 }
275 } else {
276 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
300fn 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
327fn 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
335fn 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
381fn parse_party(info: &str, role: &str) -> Option<Party> {
388 let info = info.trim();
389 if info.is_empty() {
390 return None;
391 }
392
393 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 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
447fn 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}