Skip to main content

normalize_manifest/
cabal_project.rs

1//! Parser for `cabal.project` files (Haskell/Cabal).
2//!
3//! Cabal project files configure multi-package Haskell projects. We
4//! heuristically extract `source-repository-package` stanzas, treating them
5//! as external (git-pinned) dependencies. The `location:` field provides the
6//! URL and the `tag:` field acts as the version requirement.
7
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `cabal.project` files.
11pub struct CabalProjectParser;
12
13impl ManifestParser for CabalProjectParser {
14    fn filename(&self) -> &'static str {
15        "cabal.project"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let mut deps: Vec<DeclaredDep> = Vec::new();
20
21        // We walk stanzas. A stanza starts at a zero-indented keyword line.
22        // Continuations are indented.
23
24        #[derive(PartialEq)]
25        enum Stanza {
26            Other,
27            SourceRepoPackage,
28        }
29
30        let mut stanza = Stanza::Other;
31        let mut current_location: Option<String> = None;
32        let mut current_tag: Option<String> = None;
33
34        let flush =
35            |loc: &mut Option<String>, tag: &mut Option<String>, deps: &mut Vec<DeclaredDep>| {
36                if let Some(url) = loc.take() {
37                    let dep_name = derive_name_from_url(&url);
38                    deps.push(DeclaredDep {
39                        name: dep_name,
40                        version_req: tag.take(),
41                        kind: DepKind::Normal,
42                    });
43                } else {
44                    tag.take();
45                }
46            };
47
48        for line in content.lines() {
49            let trimmed = line.trim();
50
51            // Skip blanks and comments.
52            if trimmed.is_empty() || trimmed.starts_with("--") {
53                continue;
54            }
55
56            // Zero-indented line = new stanza header.
57            if !line.starts_with(' ') && !line.starts_with('\t') {
58                // Flush previous source-repo-package stanza.
59                if stanza == Stanza::SourceRepoPackage {
60                    flush(&mut current_location, &mut current_tag, &mut deps);
61                }
62
63                let lower = trimmed.to_lowercase();
64                if lower.starts_with("source-repository-package") {
65                    stanza = Stanza::SourceRepoPackage;
66                } else {
67                    stanza = Stanza::Other;
68                }
69                continue;
70            }
71
72            // Indented fields within a stanza.
73            if stanza == Stanza::SourceRepoPackage {
74                if let Some(rest) = trimmed.strip_prefix("location:") {
75                    current_location = Some(rest.trim().to_string());
76                } else if let Some(rest) = trimmed.strip_prefix("tag:") {
77                    current_tag = Some(rest.trim().to_string());
78                }
79            }
80        }
81
82        // Flush the last stanza.
83        if stanza == Stanza::SourceRepoPackage {
84            flush(&mut current_location, &mut current_tag, &mut deps);
85        }
86
87        Ok(ParsedManifest {
88            ecosystem: "cabal",
89            // cabal.project doesn't declare a single package name/version.
90            name: None,
91            version: None,
92            dependencies: deps,
93        })
94    }
95}
96
97/// Derive a dependency name from a git URL.
98///
99/// Strips the `.git` suffix and takes the last path component.
100/// E.g. `https://github.com/someone/something.git` → `something`.
101fn derive_name_from_url(url: &str) -> String {
102    let url = url.trim_end_matches('/');
103    let last = url.rsplit('/').next().unwrap_or(url);
104    last.trim_end_matches(".git").to_string()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::ManifestParser;
111
112    const SAMPLE: &str = r#"packages: ./
113          ./lib
114
115source-repository-package
116  type: git
117  location: https://github.com/someone/something.git
118  tag: abc123
119
120source-repository-package
121  type: git
122  location: https://github.com/another/pkg.git
123  tag: v2.0.0
124
125constraints: text ==1.2.4.0,
126             bytestring >=0.11
127"#;
128
129    #[test]
130    fn test_parse_cabal_project() {
131        let m = CabalProjectParser.parse(SAMPLE).unwrap();
132        assert_eq!(m.ecosystem, "cabal");
133        assert!(m.name.is_none());
134        assert!(m.version.is_none());
135
136        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
137        assert!(names.contains(&"something"), "{names:?}");
138        assert!(names.contains(&"pkg"), "{names:?}");
139
140        let something = m
141            .dependencies
142            .iter()
143            .find(|d| d.name == "something")
144            .unwrap();
145        assert_eq!(something.version_req.as_deref(), Some("abc123"));
146        assert_eq!(something.kind, DepKind::Normal);
147
148        let pkg = m.dependencies.iter().find(|d| d.name == "pkg").unwrap();
149        assert_eq!(pkg.version_req.as_deref(), Some("v2.0.0"));
150    }
151
152    #[test]
153    fn test_no_source_repos() {
154        let content = r#"packages: ./
155
156constraints: base >=4.14
157"#;
158        let m = CabalProjectParser.parse(content).unwrap();
159        assert!(m.dependencies.is_empty());
160    }
161
162    #[test]
163    fn test_derive_name_from_url() {
164        assert_eq!(
165            derive_name_from_url("https://github.com/foo/bar.git"),
166            "bar"
167        );
168        assert_eq!(derive_name_from_url("https://github.com/foo/bar"), "bar");
169        assert_eq!(derive_name_from_url("https://github.com/foo/bar/"), "bar");
170    }
171}