1use 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
41pub 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 let purl = create_package_url(&name, &version);
73
74 let repository_homepage_url = name
76 .as_ref()
77 .map(|n| truncate_field(format!("https://cran.r-project.org/package={}", n)));
78
79 let description = build_description(&fields);
81
82 let extracted_license_statement = fields
84 .get("License")
85 .map(|s| truncate_field(s.trim().to_string()));
86
87 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 let mut parties = Vec::new();
95
96 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 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 let mut dependencies = Vec::new();
114
115 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 if line.starts_with(' ') || line.starts_with('\t') {
195 if current_field.is_some() {
196 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 if let Some(field) = current_field.take() {
205 fields.insert(field, truncate_field(current_value.clone()));
206 current_value.clear();
207 }
208
209 current_field = Some(field_name.trim().to_string());
211 current_value = field_value.trim_start().to_string();
212 }
213 }
215
216 if let Some(field) = current_field {
218 fields.insert(field, truncate_field(current_value));
219 }
220
221 fields
222}
223
224fn 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 if name == "R" {
241 continue;
242 }
243
244 let purl = if is_pinned {
246 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 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 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
269 }
270 } else {
271 PackageUrl::new("cran", &name).ok().map(|p| p.to_string())
273 }
274 } else {
275 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
299fn 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
326fn 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
334fn 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
380fn parse_party(info: &str, role: &str) -> Option<Party> {
387 let info = info.trim();
388 if info.is_empty() {
389 return None;
390 }
391
392 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 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
446fn 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}