Skip to main content

normalize_manifest/
cabal.rs

1//! Parser for `*.cabal` files (Haskell/Cabal).
2//!
3//! Heuristic extraction of `build-depends:` fields using line-pattern matching.
4//! Handles multi-line `build-depends` lists with comma-separated entries.
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `*.cabal` files.
9///
10/// Since cabal files use non-standard filenames (e.g. `mypkg.cabal`), this
11/// parser is not registered in `parse_manifest()` by filename. Use
12/// `parse_manifest_by_extension("cabal", content)` or call `CabalParser` directly.
13pub struct CabalParser;
14
15impl ManifestParser for CabalParser {
16    fn filename(&self) -> &'static str {
17        "*.cabal"
18    }
19
20    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
21        let mut name = None;
22        let mut version = None;
23        let mut deps: Vec<DeclaredDep> = Vec::new();
24        let mut in_build_depends = false;
25        let mut is_test = false;
26
27        for line in content.lines() {
28            let trimmed = line.trim();
29
30            if trimmed.is_empty() {
31                in_build_depends = false;
32                continue;
33            }
34            if trimmed.starts_with("--") {
35                continue;
36            }
37
38            let lower = trimmed.to_ascii_lowercase();
39
40            // Package-level name / version
41            if lower.starts_with("name:") && name.is_none() {
42                name = Some(trimmed["name:".len()..].trim().to_string());
43                continue;
44            }
45            if lower.starts_with("version:") && version.is_none() {
46                version = Some(trimmed["version:".len()..].trim().to_string());
47                continue;
48            }
49
50            // Detect component type (test-suite → Dev)
51            if lower.starts_with("test-suite") || lower.starts_with("benchmark") {
52                is_test = true;
53            }
54            if lower.starts_with("library") || lower.starts_with("executable") {
55                is_test = false;
56            }
57
58            // build-depends: pkg1 >= 1.0, pkg2
59            if lower.starts_with("build-depends:") {
60                in_build_depends = true;
61                let rest = &trimmed["build-depends:".len()..];
62                extract_cabal_deps(rest, is_test, &mut deps);
63                continue;
64            }
65
66            if in_build_depends {
67                // Continuation: must be indented (or start with comma)
68                if line.starts_with([' ', '\t']) || trimmed.starts_with(',') {
69                    extract_cabal_deps(trimmed, is_test, &mut deps);
70                } else {
71                    in_build_depends = false;
72                }
73            }
74        }
75
76        // Deduplicate (same dep may appear in both library and test-suite)
77        deps.dedup_by(|a, b| a.name == b.name && a.kind == b.kind);
78
79        Ok(ParsedManifest {
80            ecosystem: "cabal",
81            name,
82            version,
83            dependencies: deps,
84        })
85    }
86}
87
88fn extract_cabal_deps(line: &str, is_test: bool, out: &mut Vec<DeclaredDep>) {
89    let kind = if is_test {
90        DepKind::Dev
91    } else {
92        DepKind::Normal
93    };
94
95    for part in line.split(',') {
96        let part = part.trim().trim_start_matches(',').trim();
97        if part.is_empty() || part.starts_with("--") {
98            continue;
99        }
100
101        // Format: `pkg-name >= 1.0 && < 2.0`  or  `pkg-name`
102        // Package name is the first token (may contain hyphens and dots)
103        let mut tokens = part.splitn(2, ['>', '<', '=', '&', '!']);
104        let name_part = tokens.next().unwrap_or("").trim();
105
106        // Strip trailing version-constraint characters from name
107        let name = name_part
108            .trim_end_matches(['>', '<', '=', '~', ' '])
109            .to_string();
110
111        if name.is_empty() || name == "base" {
112            // `base` is the Haskell Prelude — always present, not a real dependency
113            continue;
114        }
115
116        // Extract version constraint: everything after the package name
117        let version_req = part
118            .find(['>', '<', '='])
119            .map(|idx| part[idx..].trim().to_string());
120
121        out.push(DeclaredDep {
122            name,
123            version_req,
124            kind,
125        });
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::ManifestParser;
133
134    #[test]
135    fn test_parse_cabal() {
136        let content = r#"cabal-version: 2.4
137name:          my-package
138version:       0.1.0.0
139license:       MIT
140
141library
142  exposed-modules: MyLib
143  build-depends:
144    base           >= 4.14 && < 5,
145    text           >= 1.2  && < 2.1,
146    aeson          >= 2.0
147
148test-suite my-test
149  type:         exitcode-stdio-1.0
150  build-depends:
151    base,
152    hspec >= 2.11
153"#;
154        let m = CabalParser.parse(content).unwrap();
155        assert_eq!(m.ecosystem, "cabal");
156        assert_eq!(m.name.as_deref(), Some("my-package"));
157        assert_eq!(m.version.as_deref(), Some("0.1.0.0"));
158
159        // base is filtered
160        assert!(!m.dependencies.iter().any(|d| d.name == "base"));
161
162        let text = m.dependencies.iter().find(|d| d.name == "text").unwrap();
163        assert_eq!(text.kind, DepKind::Normal);
164
165        let hspec = m.dependencies.iter().find(|d| d.name == "hspec").unwrap();
166        assert_eq!(hspec.kind, DepKind::Dev);
167    }
168}