1use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use regex::Regex;
9use serde_json::Value as JsonValue;
10use yaml_serde::{Mapping, Value as YamlValue};
11
12use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
13use crate::parsers::utils::{
14 CappedIterExt, MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string,
15 split_name_email, truncate_field,
16};
17
18use super::PackageParser;
19use super::metadata::ParserMetadata;
20
21const PACKAGE_TYPE: PackageType = PackageType::Hackage;
22const PRIMARY_LANGUAGE: &str = "Haskell";
23
24pub struct HackageCabalParser;
25
26pub struct HackageCabalProjectParser;
27
28pub struct HackageStackYamlParser;
29
30impl PackageParser for HackageCabalParser {
31 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
32
33 fn metadata() -> Vec<ParserMetadata> {
34 vec![ParserMetadata {
35 description: "Hackage Cabal package manifest",
36 file_patterns: &["**/*.cabal"],
37 package_type: "hackage",
38 primary_language: "Haskell",
39 documentation_url: Some(
40 "https://cabal.readthedocs.io/en/stable/cabal-package-description-file.html",
41 ),
42 }]
43 }
44
45 fn is_match(path: &Path) -> bool {
46 path.extension().is_some_and(|ext| ext == "cabal")
47 }
48
49 fn extract_packages(path: &Path) -> Vec<PackageData> {
50 let content = match read_file_to_string(path, None) {
51 Ok(content) => content,
52 Err(error) => {
53 warn!("Failed to read cabal file {:?}: {}", path, error);
54 return vec![default_package_data(DatasourceId::HackageCabal)];
55 }
56 };
57
58 vec![parse_cabal_manifest(&content)]
59 }
60}
61
62impl PackageParser for HackageCabalProjectParser {
63 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
64
65 fn metadata() -> Vec<ParserMetadata> {
66 vec![ParserMetadata {
67 description: "Hackage cabal.project workspace file",
68 file_patterns: &["**/cabal.project"],
69 package_type: "hackage",
70 primary_language: "Haskell",
71 documentation_url: Some(
72 "https://cabal.readthedocs.io/en/stable/cabal-project-description-file.html",
73 ),
74 }]
75 }
76
77 fn is_match(path: &Path) -> bool {
78 path.file_name().is_some_and(|name| name == "cabal.project")
79 }
80
81 fn extract_packages(path: &Path) -> Vec<PackageData> {
82 let content = match read_file_to_string(path, None) {
83 Ok(content) => content,
84 Err(error) => {
85 warn!("Failed to read cabal.project {:?}: {}", path, error);
86 return vec![default_package_data(DatasourceId::HackageCabalProject)];
87 }
88 };
89
90 vec![parse_cabal_project(&content)]
91 }
92}
93
94impl PackageParser for HackageStackYamlParser {
95 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
96
97 fn metadata() -> Vec<ParserMetadata> {
98 vec![ParserMetadata {
99 description: "Hackage Stack project manifest",
100 file_patterns: &["**/stack.yaml"],
101 package_type: "hackage",
102 primary_language: "Haskell",
103 documentation_url: Some("https://docs.haskellstack.org/en/stable/configure/yaml/"),
104 }]
105 }
106
107 fn is_match(path: &Path) -> bool {
108 path.file_name().is_some_and(|name| name == "stack.yaml")
109 }
110
111 fn extract_packages(path: &Path) -> Vec<PackageData> {
112 let content = match read_file_to_string(path, None) {
113 Ok(content) => content,
114 Err(error) => {
115 warn!("Failed to read stack.yaml {:?}: {}", path, error);
116 return vec![default_package_data(DatasourceId::HackageStackYaml)];
117 }
118 };
119
120 let yaml: YamlValue = match yaml_serde::from_str(&content) {
121 Ok(yaml) => yaml,
122 Err(error) => {
123 warn!("Failed to parse stack.yaml {:?}: {}", path, error);
124 return vec![default_package_data(DatasourceId::HackageStackYaml)];
125 }
126 };
127
128 vec![parse_stack_yaml(&yaml)]
129 }
130}
131
132#[derive(Clone, Debug, Default)]
133struct ComponentContext {
134 component_type: String,
135 component_name: Option<String>,
136}
137
138#[derive(Debug, Default)]
139struct CabalData {
140 name: Option<String>,
141 version: Option<String>,
142 synopsis: Option<String>,
143 description: Option<String>,
144 license: Option<String>,
145 homepage_url: Option<String>,
146 bug_tracking_url: Option<String>,
147 vcs_url: Option<String>,
148 authors: Vec<String>,
149 maintainers: Vec<String>,
150 category_keywords: Vec<String>,
151 explicit_keywords: Vec<String>,
152 dependencies: Vec<Dependency>,
153}
154
155fn default_package_data(datasource_id: DatasourceId) -> PackageData {
156 PackageData {
157 package_type: Some(PACKAGE_TYPE),
158 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
159 datasource_id: Some(datasource_id),
160 ..Default::default()
161 }
162}
163
164fn parse_cabal_manifest(content: &str) -> PackageData {
165 let parsed = parse_cabal_data(content);
166 let keywords = merge_keywords(&parsed.category_keywords, &parsed.explicit_keywords);
167 let description =
168 combine_summary_and_description(&parsed.synopsis, &parsed.description).map(truncate_field);
169 let parties = build_parties(&parsed.authors, &parsed.maintainers);
170 let purl =
171 build_hackage_purl(parsed.name.as_deref(), parsed.version.as_deref()).map(truncate_field);
172 let repository_homepage_url = parsed
173 .name
174 .as_ref()
175 .map(|name| match parsed.version.as_ref() {
176 Some(version) => truncate_field(format!(
177 "https://hackage.haskell.org/package/{}-{}",
178 name, version
179 )),
180 None => truncate_field(format!("https://hackage.haskell.org/package/{}", name)),
181 });
182
183 PackageData {
184 package_type: Some(PACKAGE_TYPE),
185 namespace: None,
186 name: parsed.name,
187 version: parsed.version,
188 qualifiers: None,
189 subpath: None,
190 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
191 description,
192 release_date: None,
193 parties,
194 keywords,
195 homepage_url: parsed.homepage_url,
196 download_url: None,
197 size: None,
198 sha1: None,
199 md5: None,
200 sha256: None,
201 sha512: None,
202 bug_tracking_url: parsed.bug_tracking_url,
203 code_view_url: None,
204 vcs_url: parsed.vcs_url,
205 copyright: None,
206 holder: None,
207 declared_license_expression: None,
208 declared_license_expression_spdx: None,
209 license_detections: Vec::new(),
210 other_license_expression: None,
211 other_license_expression_spdx: None,
212 other_license_detections: Vec::new(),
213 extracted_license_statement: parsed.license,
214 notice_text: None,
215 source_packages: Vec::new(),
216 file_references: Vec::new(),
217 is_private: false,
218 is_virtual: false,
219 extra_data: None,
220 dependencies: parsed.dependencies,
221 repository_homepage_url,
222 repository_download_url: None,
223 api_data_url: None,
224 datasource_id: Some(DatasourceId::HackageCabal),
225 purl,
226 }
227}
228
229fn parse_cabal_project(content: &str) -> PackageData {
230 let mut package_data = default_package_data(DatasourceId::HackageCabalProject);
231 let lines: Vec<&str> = content.lines().collect();
232 let mut dependencies = Vec::new();
233 let mut extra_data = HashMap::new();
234 let mut source_repo_entries: Vec<HashMap<String, JsonValue>> = Vec::new();
235 let mut current_source_repo: Option<HashMap<String, JsonValue>> = None;
236 let mut index = 0;
237 let mut iteration_count = 0usize;
238
239 while index < lines.len() {
240 iteration_count += 1;
241 if iteration_count > MAX_ITERATION_COUNT {
242 warn!(
243 "parse_cabal_project: exceeded MAX_ITERATION_COUNT ({}) at line {}, stopping",
244 MAX_ITERATION_COUNT, index
245 );
246 break;
247 }
248
249 let cleaned = strip_cabal_comment(lines[index]);
250 let trimmed = cleaned.trim();
251 let indent = indentation(cleaned);
252
253 if trimmed.is_empty() {
254 index += 1;
255 continue;
256 }
257
258 if indent == 0 && trimmed == "source-repository-package" {
259 if let Some(entry) = current_source_repo.take() {
260 source_repo_entries.push(entry);
261 }
262 current_source_repo = Some(HashMap::new());
263 index += 1;
264 continue;
265 }
266
267 let Some((key, value, next_index)) = collect_indented_field(&lines, index) else {
268 if indent == 0
269 && let Some(entry) = current_source_repo.take()
270 {
271 source_repo_entries.push(entry);
272 }
273 index += 1;
274 continue;
275 };
276
277 if current_source_repo.is_some() && indent > 0 {
278 if let Some(source_repo) = current_source_repo.as_mut() {
279 source_repo.insert(
280 project_extra_key(&key),
281 parse_multiline_scalar_or_list(&value),
282 );
283 }
284 index = next_index + 1;
285 continue;
286 }
287
288 if current_source_repo.is_some()
289 && indent == 0
290 && key != "source-repository-package"
291 && let Some(entry) = current_source_repo.take()
292 {
293 source_repo_entries.push(entry);
294 }
295
296 match key.as_str() {
297 "packages" => {
298 dependencies.extend(parse_path_like_entries(&value, "packages", false));
299 }
300 "optional-packages" => {
301 dependencies.extend(parse_path_like_entries(&value, "optional-packages", true));
302 }
303 "extra-packages" => {
304 dependencies.extend(parse_hackage_spec_entries(&value, "extra-packages", None));
305 }
306 "import" => {
307 dependencies.extend(parse_import_entries(&value));
308 }
309 _ => {
310 extra_data.insert(
311 project_extra_key(&key),
312 parse_multiline_scalar_or_list(&value),
313 );
314 }
315 }
316
317 index = next_index + 1;
318 }
319
320 if let Some(entry) = current_source_repo.take() {
321 source_repo_entries.push(entry);
322 }
323
324 let limit =
325 capped_iteration_limit(source_repo_entries.len(), "cabal source-repository entries");
326 for entry in source_repo_entries.into_iter().take(limit) {
327 dependencies.push(build_source_repository_dependency(entry));
328 }
329
330 package_data.dependencies = dependencies;
331 package_data.extra_data = (!extra_data.is_empty()).then_some(extra_data);
332 package_data
333}
334
335fn parse_stack_yaml(yaml: &YamlValue) -> PackageData {
336 let mut package_data = default_package_data(DatasourceId::HackageStackYaml);
337 let Some(mapping) = yaml.as_mapping() else {
338 return package_data;
339 };
340
341 let mut dependencies = Vec::new();
342 let mut extra_data = HashMap::new();
343
344 if let Some(resolver) = mapping_get(mapping, "resolver")
345 && let Ok(value) = serde_json::to_value(resolver)
346 {
347 extra_data.insert("resolver".to_string(), value);
348 }
349
350 if let Some(snapshot) = mapping_get(mapping, "snapshot")
351 && let Ok(value) = serde_json::to_value(snapshot)
352 {
353 extra_data.insert("snapshot".to_string(), value);
354 }
355
356 if let Some(packages) = mapping_get(mapping, "packages") {
357 dependencies.extend(parse_stack_package_entries(packages));
358 }
359
360 if let Some(extra_deps) = mapping_get(mapping, "extra-deps") {
361 dependencies.extend(parse_stack_extra_dep_entries(extra_deps));
362 }
363
364 for (key, value) in mapping.iter().capped("stack.yaml top-level keys") {
365 let Some(key) = key.as_str() else {
366 continue;
367 };
368
369 if matches!(key, "resolver" | "snapshot" | "packages" | "extra-deps") {
370 continue;
371 }
372
373 if let Ok(json_value) = serde_json::to_value(value) {
374 extra_data.insert(key.to_string(), json_value);
375 }
376 }
377
378 package_data.dependencies = dependencies;
379 package_data.extra_data = (!extra_data.is_empty()).then_some(extra_data);
380 package_data
381}
382
383fn parse_cabal_data(content: &str) -> CabalData {
384 let mut data = CabalData::default();
385 let lines: Vec<&str> = content.lines().collect();
386 let mut current_component: Option<ComponentContext> = None;
387 let mut in_source_repository = false;
388 let mut index = 0;
389 let mut iteration_count = 0usize;
390
391 while index < lines.len() {
392 iteration_count += 1;
393 if iteration_count > MAX_ITERATION_COUNT {
394 warn!(
395 "parse_cabal_data: exceeded MAX_ITERATION_COUNT ({}) at line {}, stopping",
396 MAX_ITERATION_COUNT, index
397 );
398 break;
399 }
400 let cleaned = strip_cabal_comment(lines[index]);
401 let trimmed = cleaned.trim();
402 let indent = indentation(cleaned);
403
404 if trimmed.is_empty() {
405 index += 1;
406 continue;
407 }
408
409 if indent == 0 && !trimmed.contains(':') {
410 current_component = parse_component_header(trimmed);
411 in_source_repository = trimmed.starts_with("source-repository");
412 index += 1;
413 continue;
414 }
415
416 let Some((key, value, next_index)) = collect_indented_field(&lines, index) else {
417 index += 1;
418 continue;
419 };
420
421 match key.as_str() {
422 "name" if indent == 0 => data.name = clean_single_line(&value).map(truncate_field),
423 "version" if indent == 0 => {
424 data.version = clean_single_line(&value).map(truncate_field)
425 }
426 "synopsis" if indent == 0 => {
427 data.synopsis = clean_single_line(&value).map(truncate_field)
428 }
429 "description" if indent == 0 => {
430 data.description = normalize_cabal_multiline(&value).map(truncate_field);
431 }
432 "license" if indent == 0 => {
433 data.license = clean_single_line(&value).map(truncate_field)
434 }
435 "homepage" if indent == 0 => {
436 data.homepage_url = clean_single_line(&value).map(truncate_field)
437 }
438 "bug-reports" if indent == 0 => {
439 data.bug_tracking_url = clean_single_line(&value).map(truncate_field)
440 }
441 "author" if indent == 0 => data.authors.extend(split_comma_separated(&value)),
442 "maintainer" if indent == 0 => {
443 data.maintainers.extend(split_comma_separated(&value));
444 }
445 "category" if indent == 0 => {
446 data.category_keywords.extend(split_keywords(&value));
447 }
448 "keywords" if indent == 0 => {
449 data.explicit_keywords.extend(split_keywords(&value));
450 }
451 "location" if in_source_repository && data.vcs_url.is_none() => {
452 data.vcs_url = clean_single_line(&value).map(truncate_field);
453 }
454 "build-depends" => {
455 data.dependencies
456 .extend(parse_build_depends(&value, current_component.as_ref()));
457 }
458 _ => {}
459 }
460
461 index = next_index + 1;
462 }
463
464 data
465}
466
467fn parse_build_depends(value: &str, component: Option<&ComponentContext>) -> Vec<Dependency> {
468 if component.is_some_and(|component| component.component_type == "common") {
469 return Vec::new();
470 }
471
472 split_dependency_entries(value)
473 .into_iter()
474 .filter_map(|entry| {
475 parse_hackage_spec_dependency(&entry, Some("build-depends"), component, None)
476 })
477 .collect()
478}
479
480fn parse_path_like_entries(value: &str, scope: &str, optional: bool) -> Vec<Dependency> {
481 split_multiline_entries(value)
482 .into_iter()
483 .filter(|entry| !entry.is_empty())
484 .map(|entry| {
485 let mut extra_data = HashMap::new();
486 extra_data.insert("path".to_string(), JsonValue::String(entry.clone()));
487
488 Dependency {
489 purl: None,
490 extracted_requirement: Some(truncate_field(entry)),
491 scope: Some(scope.to_string()),
492 is_runtime: None,
493 is_optional: Some(optional),
494 is_pinned: Some(false),
495 is_direct: Some(true),
496 resolved_package: None,
497 extra_data: Some(extra_data),
498 }
499 })
500 .collect()
501}
502
503fn parse_import_entries(value: &str) -> Vec<Dependency> {
504 split_multiline_entries(value)
505 .into_iter()
506 .filter(|entry| !entry.is_empty())
507 .map(|entry| Dependency {
508 purl: None,
509 extracted_requirement: Some(truncate_field(entry)),
510 scope: Some("import".to_string()),
511 is_runtime: None,
512 is_optional: Some(false),
513 is_pinned: Some(false),
514 is_direct: Some(true),
515 resolved_package: None,
516 extra_data: None,
517 })
518 .collect()
519}
520
521fn parse_hackage_spec_entries(
522 value: &str,
523 scope: &str,
524 is_runtime: Option<bool>,
525) -> Vec<Dependency> {
526 split_multiline_entries(value)
527 .into_iter()
528 .filter_map(|entry| parse_hackage_spec_dependency(&entry, Some(scope), None, is_runtime))
529 .collect()
530}
531
532fn parse_stack_package_entries(value: &YamlValue) -> Vec<Dependency> {
533 let Some(sequence) = value.as_sequence() else {
534 return Vec::new();
535 };
536
537 sequence
538 .iter()
539 .capped("stack.yaml packages")
540 .filter_map(|entry| match entry {
541 YamlValue::String(path) => {
542 let mut extra_data = HashMap::new();
543 extra_data.insert("path".to_string(), JsonValue::String(path.clone()));
544
545 Some(Dependency {
546 purl: None,
547 extracted_requirement: Some(truncate_field(path.clone())),
548 scope: Some("packages".to_string()),
549 is_runtime: None,
550 is_optional: Some(false),
551 is_pinned: Some(false),
552 is_direct: Some(true),
553 resolved_package: None,
554 extra_data: Some(extra_data),
555 })
556 }
557 YamlValue::Mapping(map) => {
558 let extracted_requirement = mapping_string(map, "location")
559 .or_else(|| mapping_string(map, "git"))
560 .or_else(|| mapping_string(map, "url"))
561 .map(truncate_field);
562 let extra_data = serde_json::to_value(entry)
563 .ok()
564 .and_then(|value| value.as_object().cloned())
565 .map(|map| map.into_iter().collect::<HashMap<_, _>>());
566
567 Some(Dependency {
568 purl: None,
569 extracted_requirement,
570 scope: Some("packages".to_string()),
571 is_runtime: None,
572 is_optional: Some(false),
573 is_pinned: Some(mapping_string(map, "commit").is_some()),
574 is_direct: Some(true),
575 resolved_package: None,
576 extra_data,
577 })
578 }
579 _ => None,
580 })
581 .collect()
582}
583
584fn parse_stack_extra_dep_entries(value: &YamlValue) -> Vec<Dependency> {
585 let Some(sequence) = value.as_sequence() else {
586 return Vec::new();
587 };
588
589 sequence
590 .iter()
591 .capped("stack.yaml extra-deps")
592 .filter_map(|entry| match entry {
593 YamlValue::String(spec) => parse_stack_extra_dep_string(spec),
594 YamlValue::Mapping(map) => Some(parse_stack_extra_dep_mapping(map, entry)),
595 _ => None,
596 })
597 .collect()
598}
599
600fn parse_stack_extra_dep_string(spec: &str) -> Option<Dependency> {
601 let trimmed = spec.trim();
602 if trimmed.is_empty() {
603 return None;
604 }
605
606 let (package_spec, pantry_suffix) = trimmed
607 .split_once('@')
608 .map_or((trimmed, None), |(package_spec, suffix)| {
609 (package_spec, Some(suffix))
610 });
611
612 let mut dependency =
613 parse_hackage_spec_dependency(package_spec, Some("extra-deps"), None, None).unwrap_or(
614 Dependency {
615 purl: None,
616 extracted_requirement: Some(truncate_field(package_spec.to_string())),
617 scope: Some("extra-deps".to_string()),
618 is_runtime: None,
619 is_optional: Some(false),
620 is_pinned: Some(false),
621 is_direct: Some(true),
622 resolved_package: None,
623 extra_data: None,
624 },
625 );
626
627 if let Some(suffix) = pantry_suffix {
628 let mut extra_data = dependency.extra_data.take().unwrap_or_default();
629 extra_data.insert("pantry".to_string(), JsonValue::String(suffix.to_string()));
630 dependency.extra_data = Some(extra_data);
631 dependency.is_pinned = Some(true);
632 if dependency.extracted_requirement.is_none() {
633 dependency.extracted_requirement = Some(truncate_field(package_spec.to_string()));
634 }
635 }
636
637 dependency.scope = Some("extra-deps".to_string());
638 Some(dependency)
639}
640
641fn parse_stack_extra_dep_mapping(map: &Mapping, raw_value: &YamlValue) -> Dependency {
642 let name = mapping_string(map, "name");
643 let version = mapping_string(map, "version");
644 let purl = build_hackage_purl(name.as_deref(), version.as_deref()).map(truncate_field);
645 let extracted_requirement = version
646 .clone()
647 .or_else(|| mapping_string(map, "git"))
648 .or_else(|| mapping_string(map, "url"))
649 .map(truncate_field);
650 let extra_data = serde_json::to_value(raw_value)
651 .ok()
652 .and_then(|value| value.as_object().cloned())
653 .map(|map| map.into_iter().collect::<HashMap<_, _>>());
654
655 Dependency {
656 purl,
657 extracted_requirement,
658 scope: Some("extra-deps".to_string()),
659 is_runtime: None,
660 is_optional: Some(false),
661 is_pinned: Some(version.is_some() || mapping_string(map, "commit").is_some()),
662 is_direct: Some(true),
663 resolved_package: None,
664 extra_data,
665 }
666}
667
668fn build_source_repository_dependency(extra_data: HashMap<String, JsonValue>) -> Dependency {
669 let extracted_requirement = extra_data
670 .get("location")
671 .and_then(JsonValue::as_str)
672 .map(str::to_string)
673 .or_else(|| {
674 extra_data
675 .get("tag")
676 .and_then(JsonValue::as_str)
677 .map(str::to_string)
678 })
679 .map(truncate_field);
680
681 Dependency {
682 purl: None,
683 extracted_requirement,
684 scope: Some("source-repository-package".to_string()),
685 is_runtime: None,
686 is_optional: Some(false),
687 is_pinned: Some(
688 extra_data.contains_key("tag")
689 || extra_data.contains_key("commit")
690 || extra_data.contains_key("sha256"),
691 ),
692 is_direct: Some(true),
693 resolved_package: None,
694 extra_data: Some(extra_data),
695 }
696}
697
698fn parse_hackage_spec_dependency(
699 spec: &str,
700 scope: Option<&str>,
701 component: Option<&ComponentContext>,
702 is_runtime: Option<bool>,
703) -> Option<Dependency> {
704 let trimmed = spec.trim();
705 if trimmed.is_empty() {
706 return None;
707 }
708
709 let can_split_name_version = matches!(scope, Some("extra-packages" | "extra-deps"));
710
711 if can_split_name_version && let Some((name, version)) = split_hackage_name_version(trimmed) {
712 let mut extra_data = HashMap::new();
713 if let Some(component) = component {
714 extra_data.insert(
715 "component_type".to_string(),
716 JsonValue::String(component.component_type.clone()),
717 );
718 if let Some(component_name) = &component.component_name {
719 extra_data.insert(
720 "component_name".to_string(),
721 JsonValue::String(component_name.clone()),
722 );
723 }
724 }
725
726 return Some(Dependency {
727 purl: Some(truncate_field(format!("pkg:hackage/{}@{}", name, version))),
728 extracted_requirement: Some(truncate_field(version)),
729 scope: scope.map(str::to_string),
730 is_runtime: component.map(component_is_runtime).or(is_runtime),
731 is_optional: Some(false),
732 is_pinned: Some(true),
733 is_direct: Some(true),
734 resolved_package: None,
735 extra_data: (!extra_data.is_empty()).then_some(extra_data),
736 });
737 }
738
739 let name_re = Regex::new(r"^(?P<name>[A-Za-z0-9][A-Za-z0-9_\.-]*)").ok()?;
740 let captures = name_re.captures(trimmed)?;
741 let name = captures.name("name")?.as_str().to_string();
742 let requirement = trimmed[name.len()..].trim();
743 let implicit_name_version = if can_split_name_version && requirement.is_empty() {
744 split_hackage_name_version(trimmed)
745 } else {
746 None
747 };
748 let resolved_name = implicit_name_version
749 .as_ref()
750 .map(|(resolved_name, _)| resolved_name.as_str())
751 .unwrap_or(name.as_str());
752 let exact_version = exact_version_requirement(requirement).or_else(|| {
753 implicit_name_version
754 .as_ref()
755 .map(|(_, version)| version.clone())
756 });
757 let purl = if let Some(version) = exact_version.as_deref() {
758 Some(truncate_field(format!(
759 "pkg:hackage/{}@{}",
760 resolved_name, version
761 )))
762 } else {
763 Some(truncate_field(format!("pkg:hackage/{}", resolved_name)))
764 };
765
766 let mut extra_data = HashMap::new();
767 if let Some(component) = component {
768 extra_data.insert(
769 "component_type".to_string(),
770 JsonValue::String(component.component_type.clone()),
771 );
772 if let Some(component_name) = &component.component_name {
773 extra_data.insert(
774 "component_name".to_string(),
775 JsonValue::String(component_name.clone()),
776 );
777 }
778 }
779
780 let extracted_requirement = if let Some((_, version)) = implicit_name_version {
781 Some(truncate_field(version))
782 } else {
783 (!requirement.is_empty())
784 .then_some(requirement.to_string())
785 .map(truncate_field)
786 };
787
788 Some(Dependency {
789 purl,
790 extracted_requirement,
791 scope: scope.map(str::to_string),
792 is_runtime: component.map(component_is_runtime).or(is_runtime),
793 is_optional: Some(false),
794 is_pinned: Some(exact_version.is_some()),
795 is_direct: Some(true),
796 resolved_package: None,
797 extra_data: (!extra_data.is_empty()).then_some(extra_data),
798 })
799}
800
801fn component_is_runtime(component: &ComponentContext) -> bool {
802 !matches!(
803 component.component_type.as_str(),
804 "test-suite" | "benchmark"
805 )
806}
807
808fn parse_component_header(trimmed: &str) -> Option<ComponentContext> {
809 const COMPONENT_PREFIXES: &[&str] = &[
810 "library",
811 "foreign-library",
812 "executable",
813 "test-suite",
814 "benchmark",
815 "common",
816 ];
817
818 COMPONENT_PREFIXES.iter().find_map(|prefix| {
819 trimmed
820 .strip_prefix(prefix)
821 .map(|remainder| ComponentContext {
822 component_type: (*prefix).to_string(),
823 component_name: clean_single_line(remainder),
824 })
825 })
826}
827
828fn collect_indented_field(lines: &[&str], start_index: usize) -> Option<(String, String, usize)> {
829 let current = strip_cabal_comment(lines[start_index]);
830 let trimmed = current.trim();
831 let indent = indentation(current);
832 let colon_index = trimmed.find(':')?;
833 let key = trimmed[..colon_index].trim().to_ascii_lowercase();
834 let mut values = vec![trimmed[colon_index + 1..].trim().to_string()];
835 let mut last_index = start_index;
836
837 for (next_index, line) in lines.iter().enumerate().skip(start_index + 1) {
838 let next = strip_cabal_comment(line);
839 let next_trimmed = next.trim();
840 if next_trimmed.is_empty() {
841 break;
842 }
843
844 if indentation(next) <= indent {
845 break;
846 }
847
848 values.push(next_trimmed.to_string());
849 last_index = next_index;
850 }
851
852 Some((key, values.join("\n"), last_index))
853}
854
855fn split_dependency_entries(value: &str) -> Vec<String> {
856 let mut entries = Vec::new();
857 let mut current = String::new();
858 let mut paren_depth = 0usize;
859 let mut brace_depth = 0usize;
860 let mut bracket_depth = 0usize;
861
862 for character in value.chars().capped("cabal dependency entry characters") {
863 match character {
864 '(' => paren_depth += 1,
865 ')' => paren_depth = paren_depth.saturating_sub(1),
866 '{' => brace_depth += 1,
867 '}' => brace_depth = brace_depth.saturating_sub(1),
868 '[' => bracket_depth += 1,
869 ']' => bracket_depth = bracket_depth.saturating_sub(1),
870 ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
871 let trimmed = current.trim();
872 if !trimmed.is_empty() {
873 entries.push(trimmed.to_string());
874 }
875 current.clear();
876 continue;
877 }
878 _ => {}
879 }
880
881 current.push(character);
882 }
883
884 let trimmed = current.trim();
885 if !trimmed.is_empty() {
886 entries.push(trimmed.to_string());
887 }
888
889 entries
890}
891
892fn split_multiline_entries(value: &str) -> Vec<String> {
893 value
894 .lines()
895 .capped("cabal multiline entries")
896 .map(str::trim)
897 .filter(|line| !line.is_empty())
898 .map(|line| line.strip_prefix("-").unwrap_or(line).trim().to_string())
899 .collect()
900}
901
902fn parse_multiline_scalar_or_list(value: &str) -> JsonValue {
903 let entries = split_multiline_entries(value);
904 if entries.len() <= 1 {
905 clean_single_line(value)
906 .map(JsonValue::String)
907 .unwrap_or(JsonValue::Null)
908 } else {
909 JsonValue::Array(entries.into_iter().map(JsonValue::String).collect())
910 }
911}
912
913fn normalize_cabal_multiline(value: &str) -> Option<String> {
914 let lines: Vec<String> = value
915 .lines()
916 .map(str::trim)
917 .map(|line| {
918 if line == "." {
919 "".to_string()
920 } else {
921 line.to_string()
922 }
923 })
924 .collect();
925
926 let combined = lines.join("\n").trim().to_string();
927 (!combined.is_empty()).then_some(combined)
928}
929
930fn clean_single_line(value: &str) -> Option<String> {
931 let cleaned = value.trim();
932 (!cleaned.is_empty()).then_some(cleaned.to_string())
933}
934
935fn split_comma_separated(value: &str) -> Vec<String> {
936 value
937 .split(',')
938 .map(str::trim)
939 .filter(|part| !part.is_empty())
940 .map(str::to_string)
941 .collect()
942}
943
944fn split_keywords(value: &str) -> Vec<String> {
945 split_comma_separated(value)
946}
947
948fn merge_keywords(categories: &[String], keywords: &[String]) -> Vec<String> {
949 let mut seen = HashSet::new();
950 categories
951 .iter()
952 .chain(keywords.iter())
953 .filter_map(|keyword| {
954 let normalized = keyword.trim();
955 if normalized.is_empty() || !seen.insert(normalized.to_ascii_lowercase()) {
956 None
957 } else {
958 Some(normalized.to_string())
959 }
960 })
961 .collect()
962}
963
964fn combine_summary_and_description(
965 synopsis: &Option<String>,
966 description: &Option<String>,
967) -> Option<String> {
968 match (synopsis, description) {
969 (Some(synopsis), Some(description)) if synopsis == description => Some(synopsis.clone()),
970 (Some(synopsis), Some(description)) => Some(format!("{}\n\n{}", synopsis, description)),
971 (Some(synopsis), None) => Some(synopsis.clone()),
972 (None, Some(description)) => Some(description.clone()),
973 (None, None) => None,
974 }
975}
976
977fn build_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
978 let author_parties = authors
979 .iter()
980 .filter_map(|author| build_party(author, "author"));
981 let maintainer_parties = maintainers
982 .iter()
983 .filter_map(|maintainer| build_party(maintainer, "maintainer"));
984
985 author_parties.chain(maintainer_parties).collect()
986}
987
988fn build_party(value: &str, role: &str) -> Option<Party> {
989 let (name, email) = split_name_email(value.trim());
990 if name.is_none() && email.is_none() {
991 return None;
992 }
993
994 Some(Party {
995 r#type: Some(PartyType::Person),
996 role: Some(role.to_string()),
997 name,
998 email,
999 url: None,
1000 organization: None,
1001 organization_url: None,
1002 timezone: None,
1003 })
1004}
1005
1006fn build_hackage_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
1007 match (name, version) {
1008 (Some(name), Some(version)) => Some(format!("pkg:hackage/{}@{}", name, version)),
1009 (Some(name), None) => Some(format!("pkg:hackage/{}", name)),
1010 _ => None,
1011 }
1012}
1013
1014fn split_hackage_name_version(spec: &str) -> Option<(String, String)> {
1015 if spec.chars().any(|character| {
1016 character.is_whitespace() || matches!(character, '<' | '>' | '=' | '&' | '|' | '(' | ')')
1017 }) {
1018 return None;
1019 }
1020
1021 for (index, character) in spec.char_indices().rev() {
1022 if character != '-' {
1023 continue;
1024 }
1025
1026 let name = &spec[..index];
1027 let version = &spec[index + 1..];
1028
1029 if name.is_empty()
1030 || version.is_empty()
1031 || !version
1032 .chars()
1033 .next()
1034 .is_some_and(|character| character.is_ascii_digit())
1035 {
1036 continue;
1037 }
1038
1039 return Some((name.to_string(), version.to_string()));
1040 }
1041
1042 None
1043}
1044
1045fn exact_version_requirement(requirement: &str) -> Option<String> {
1046 let trimmed = requirement.trim();
1047 if trimmed.is_empty() {
1048 return None;
1049 }
1050
1051 let exact_re = Regex::new(r"^==\s*([A-Za-z0-9][A-Za-z0-9\.\-_+]*)$").ok()?;
1052 exact_re.captures(trimmed).and_then(|captures| {
1053 let version = captures.get(1)?.as_str();
1054 (!version.contains('*')).then_some(version.to_string())
1055 })
1056}
1057
1058fn project_extra_key(key: &str) -> String {
1059 key.replace('-', "_")
1060}
1061
1062fn strip_cabal_comment(line: &str) -> &str {
1063 let trimmed = line.trim_start();
1064 if trimmed.starts_with("--") {
1065 return "";
1066 }
1067
1068 let bytes = line.as_bytes();
1069 for index in 0..bytes.len().saturating_sub(1) {
1070 if bytes[index] == b'-'
1071 && bytes[index + 1] == b'-'
1072 && (index == 0 || bytes[index - 1].is_ascii_whitespace())
1073 {
1074 return line[..index].trim_end();
1075 }
1076 }
1077
1078 line
1079}
1080
1081fn indentation(line: &str) -> usize {
1082 line.chars()
1083 .take_while(|character| character.is_whitespace())
1084 .count()
1085}
1086
1087fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a YamlValue> {
1088 mapping.get(YamlValue::String(key.to_string()))
1089}
1090
1091fn mapping_string(mapping: &Mapping, key: &str) -> Option<String> {
1092 mapping_get(mapping, key)
1093 .and_then(YamlValue::as_str)
1094 .map(str::to_string)
1095}