Skip to main content

provenant/parsers/
hackage.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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}