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: build_hackage_purl(Some(&name), Some(&version)).map(truncate_field),
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 =
758        build_hackage_purl(Some(resolved_name), exact_version.as_deref()).map(truncate_field);
759
760    let mut extra_data = HashMap::new();
761    if let Some(component) = component {
762        extra_data.insert(
763            "component_type".to_string(),
764            JsonValue::String(component.component_type.clone()),
765        );
766        if let Some(component_name) = &component.component_name {
767            extra_data.insert(
768                "component_name".to_string(),
769                JsonValue::String(component_name.clone()),
770            );
771        }
772    }
773
774    let extracted_requirement = if let Some((_, version)) = implicit_name_version {
775        Some(truncate_field(version))
776    } else {
777        (!requirement.is_empty())
778            .then_some(requirement.to_string())
779            .map(truncate_field)
780    };
781
782    Some(Dependency {
783        purl,
784        extracted_requirement,
785        scope: scope.map(str::to_string),
786        is_runtime: component.map(component_is_runtime).or(is_runtime),
787        is_optional: Some(false),
788        is_pinned: Some(exact_version.is_some()),
789        is_direct: Some(true),
790        resolved_package: None,
791        extra_data: (!extra_data.is_empty()).then_some(extra_data),
792    })
793}
794
795fn component_is_runtime(component: &ComponentContext) -> bool {
796    !matches!(
797        component.component_type.as_str(),
798        "test-suite" | "benchmark"
799    )
800}
801
802fn parse_component_header(trimmed: &str) -> Option<ComponentContext> {
803    const COMPONENT_PREFIXES: &[&str] = &[
804        "library",
805        "foreign-library",
806        "executable",
807        "test-suite",
808        "benchmark",
809        "common",
810    ];
811
812    COMPONENT_PREFIXES.iter().find_map(|prefix| {
813        trimmed
814            .strip_prefix(prefix)
815            .map(|remainder| ComponentContext {
816                component_type: (*prefix).to_string(),
817                component_name: clean_single_line(remainder),
818            })
819    })
820}
821
822fn collect_indented_field(lines: &[&str], start_index: usize) -> Option<(String, String, usize)> {
823    let current = strip_cabal_comment(lines[start_index]);
824    let trimmed = current.trim();
825    let indent = indentation(current);
826    let colon_index = trimmed.find(':')?;
827    let key = trimmed[..colon_index].trim().to_ascii_lowercase();
828    let mut values = vec![trimmed[colon_index + 1..].trim().to_string()];
829    let mut last_index = start_index;
830
831    for (next_index, line) in lines.iter().enumerate().skip(start_index + 1) {
832        let next = strip_cabal_comment(line);
833        let next_trimmed = next.trim();
834        if next_trimmed.is_empty() {
835            break;
836        }
837
838        if indentation(next) <= indent {
839            break;
840        }
841
842        values.push(next_trimmed.to_string());
843        last_index = next_index;
844    }
845
846    Some((key, values.join("\n"), last_index))
847}
848
849fn split_dependency_entries(value: &str) -> Vec<String> {
850    let mut entries = Vec::new();
851    let mut current = String::new();
852    let mut paren_depth = 0usize;
853    let mut brace_depth = 0usize;
854    let mut bracket_depth = 0usize;
855
856    for character in value.chars().capped("cabal dependency entry characters") {
857        match character {
858            '(' => paren_depth += 1,
859            ')' => paren_depth = paren_depth.saturating_sub(1),
860            '{' => brace_depth += 1,
861            '}' => brace_depth = brace_depth.saturating_sub(1),
862            '[' => bracket_depth += 1,
863            ']' => bracket_depth = bracket_depth.saturating_sub(1),
864            ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
865                let trimmed = current.trim();
866                if !trimmed.is_empty() {
867                    entries.push(trimmed.to_string());
868                }
869                current.clear();
870                continue;
871            }
872            _ => {}
873        }
874
875        current.push(character);
876    }
877
878    let trimmed = current.trim();
879    if !trimmed.is_empty() {
880        entries.push(trimmed.to_string());
881    }
882
883    entries
884}
885
886fn split_multiline_entries(value: &str) -> Vec<String> {
887    value
888        .lines()
889        .capped("cabal multiline entries")
890        .map(str::trim)
891        .filter(|line| !line.is_empty())
892        .map(|line| line.strip_prefix("-").unwrap_or(line).trim().to_string())
893        .collect()
894}
895
896fn parse_multiline_scalar_or_list(value: &str) -> JsonValue {
897    let entries = split_multiline_entries(value);
898    if entries.len() <= 1 {
899        clean_single_line(value)
900            .map(JsonValue::String)
901            .unwrap_or(JsonValue::Null)
902    } else {
903        JsonValue::Array(entries.into_iter().map(JsonValue::String).collect())
904    }
905}
906
907fn normalize_cabal_multiline(value: &str) -> Option<String> {
908    let lines: Vec<String> = value
909        .lines()
910        .map(str::trim)
911        .map(|line| {
912            if line == "." {
913                "".to_string()
914            } else {
915                line.to_string()
916            }
917        })
918        .collect();
919
920    let combined = lines.join("\n").trim().to_string();
921    (!combined.is_empty()).then_some(combined)
922}
923
924fn clean_single_line(value: &str) -> Option<String> {
925    let cleaned = value.trim();
926    (!cleaned.is_empty()).then_some(cleaned.to_string())
927}
928
929fn split_comma_separated(value: &str) -> Vec<String> {
930    value
931        .split(',')
932        .map(str::trim)
933        .filter(|part| !part.is_empty())
934        .map(str::to_string)
935        .collect()
936}
937
938fn split_keywords(value: &str) -> Vec<String> {
939    split_comma_separated(value)
940}
941
942fn merge_keywords(categories: &[String], keywords: &[String]) -> Vec<String> {
943    let mut seen = HashSet::new();
944    categories
945        .iter()
946        .chain(keywords.iter())
947        .filter_map(|keyword| {
948            let normalized = keyword.trim();
949            if normalized.is_empty() || !seen.insert(normalized.to_ascii_lowercase()) {
950                None
951            } else {
952                Some(normalized.to_string())
953            }
954        })
955        .collect()
956}
957
958fn combine_summary_and_description(
959    synopsis: &Option<String>,
960    description: &Option<String>,
961) -> Option<String> {
962    match (synopsis, description) {
963        (Some(synopsis), Some(description)) if synopsis == description => Some(synopsis.clone()),
964        (Some(synopsis), Some(description)) => Some(format!("{}\n\n{}", synopsis, description)),
965        (Some(synopsis), None) => Some(synopsis.clone()),
966        (None, Some(description)) => Some(description.clone()),
967        (None, None) => None,
968    }
969}
970
971fn build_parties(authors: &[String], maintainers: &[String]) -> Vec<Party> {
972    let author_parties = authors
973        .iter()
974        .filter_map(|author| build_party(author, "author"));
975    let maintainer_parties = maintainers
976        .iter()
977        .filter_map(|maintainer| build_party(maintainer, "maintainer"));
978
979    author_parties.chain(maintainer_parties).collect()
980}
981
982fn build_party(value: &str, role: &str) -> Option<Party> {
983    let (name, email) = split_name_email(value.trim());
984    if name.is_none() && email.is_none() {
985        return None;
986    }
987
988    Some(Party {
989        r#type: Some(PartyType::Person),
990        role: Some(role.to_string()),
991        name,
992        email,
993        url: None,
994        organization: None,
995        organization_url: None,
996        timezone: None,
997    })
998}
999
1000/// Builds a hackage PURL through the encoder.
1001///
1002/// A `.cabal` `name:` field is free text, so a `/` in it would otherwise be read
1003/// back as a namespace separator — which hackage prohibits, making the PURL
1004/// unparsable.
1005pub(super) fn build_hackage_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
1006    crate::parsers::utils::simple_purl("hackage", name?, version)
1007}
1008
1009fn split_hackage_name_version(spec: &str) -> Option<(String, String)> {
1010    if spec.chars().any(|character| {
1011        character.is_whitespace() || matches!(character, '<' | '>' | '=' | '&' | '|' | '(' | ')')
1012    }) {
1013        return None;
1014    }
1015
1016    for (index, character) in spec.char_indices().rev() {
1017        if character != '-' {
1018            continue;
1019        }
1020
1021        let name = &spec[..index];
1022        let version = &spec[index + 1..];
1023
1024        if name.is_empty()
1025            || version.is_empty()
1026            || !version
1027                .chars()
1028                .next()
1029                .is_some_and(|character| character.is_ascii_digit())
1030        {
1031            continue;
1032        }
1033
1034        return Some((name.to_string(), version.to_string()));
1035    }
1036
1037    None
1038}
1039
1040fn exact_version_requirement(requirement: &str) -> Option<String> {
1041    let trimmed = requirement.trim();
1042    if trimmed.is_empty() {
1043        return None;
1044    }
1045
1046    let exact_re = Regex::new(r"^==\s*([A-Za-z0-9][A-Za-z0-9\.\-_+]*)$").ok()?;
1047    exact_re.captures(trimmed).and_then(|captures| {
1048        let version = captures.get(1)?.as_str();
1049        (!version.contains('*')).then_some(version.to_string())
1050    })
1051}
1052
1053fn project_extra_key(key: &str) -> String {
1054    key.replace('-', "_")
1055}
1056
1057fn strip_cabal_comment(line: &str) -> &str {
1058    let trimmed = line.trim_start();
1059    if trimmed.starts_with("--") {
1060        return "";
1061    }
1062
1063    let bytes = line.as_bytes();
1064    for index in 0..bytes.len().saturating_sub(1) {
1065        if bytes[index] == b'-'
1066            && bytes[index + 1] == b'-'
1067            && (index == 0 || bytes[index - 1].is_ascii_whitespace())
1068        {
1069            return line[..index].trim_end();
1070        }
1071    }
1072
1073    line
1074}
1075
1076fn indentation(line: &str) -> usize {
1077    line.chars()
1078        .take_while(|character| character.is_whitespace())
1079        .count()
1080}
1081
1082fn mapping_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a YamlValue> {
1083    mapping.get(YamlValue::String(key.to_string()))
1084}
1085
1086fn mapping_string(mapping: &Mapping, key: &str) -> Option<String> {
1087    mapping_get(mapping, key)
1088        .and_then(YamlValue::as_str)
1089        .map(str::to_string)
1090}