Skip to main content

normalize_manifest/
stack.rs

1//! Parser for `stack.yaml` files (Haskell/Stack).
2//!
3//! Extracts `extra-deps:` entries. Stack uses Stackage snapshots for most deps;
4//! `extra-deps` lists packages not on the snapshot (usually pinned versions or git).
5//!
6//! Uses indent-aware line parsing rather than a full YAML library.
7
8use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
9
10/// Parser for `stack.yaml` files.
11pub struct StackParser;
12
13impl ManifestParser for StackParser {
14    fn filename(&self) -> &'static str {
15        "stack.yaml"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let mut deps = Vec::new();
20        let mut in_extra_deps = false;
21        let mut list_indent = 0usize;
22
23        for line in content.lines() {
24            let trimmed = line.trim();
25            if trimmed.is_empty() || trimmed.starts_with('#') {
26                continue;
27            }
28
29            let indent = line.len() - line.trim_start().len();
30
31            // Top-level key detection
32            if indent == 0 {
33                in_extra_deps = trimmed.starts_with("extra-deps:");
34                list_indent = 0;
35                // Handle inline list: `extra-deps: []`
36                if in_extra_deps && trimmed.contains('[') {
37                    // Empty or inline — not common, skip
38                    in_extra_deps = false;
39                }
40                continue;
41            }
42
43            if !in_extra_deps {
44                continue;
45            }
46
47            // List items start with `- `
48            if trimmed.starts_with("- ") || trimmed.starts_with('-') {
49                if list_indent == 0 {
50                    list_indent = indent;
51                }
52
53                let item = trimmed.trim_start_matches('-').trim();
54
55                if let Some(dep) = parse_stack_dep(item) {
56                    deps.push(dep);
57                }
58            }
59        }
60
61        Ok(ParsedManifest {
62            ecosystem: "stackage",
63            name: None,
64            version: None,
65            dependencies: deps,
66        })
67    }
68}
69
70fn parse_stack_dep(item: &str) -> Option<DeclaredDep> {
71    let item = item.trim().trim_matches('"').trim_matches('\'');
72    if item.is_empty() {
73        return None;
74    }
75
76    // Git dep: `git: ...` (multi-line object, heuristic: skip, we can't fully parse)
77    if item == "git:" || item.starts_with("git:") {
78        return None;
79    }
80
81    // Hackage form: `pkg-name-1.2.3`  or  `pkg-name-1.2.3@sha256:...`
82    // The package name uses hyphens; version is the last hyphenated segment starting with digit
83    let base = item.split('@').next().unwrap_or(item);
84
85    // Find where the version starts (last hyphen before a digit)
86    let name;
87    let version_req;
88
89    if let Some(ver_start) = find_version_start(base) {
90        name = base[..ver_start - 1].to_string(); // strip trailing hyphen
91        version_req = Some(base[ver_start..].to_string());
92    } else {
93        name = base.to_string();
94        version_req = None;
95    }
96
97    if name.is_empty() {
98        return None;
99    }
100
101    Some(DeclaredDep {
102        name,
103        version_req,
104        kind: DepKind::Normal,
105    })
106}
107
108/// Find the index where the version part starts in a `pkg-name-1.2.3` string.
109/// Returns the index of the first digit of the version (after the separating hyphen).
110fn find_version_start(s: &str) -> Option<usize> {
111    let bytes = s.as_bytes();
112    // Walk backwards from the end to find last hyphen before a digit sequence
113    (1..bytes.len())
114        .rev()
115        .find(|&i| bytes[i - 1] == b'-' && bytes[i].is_ascii_digit())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::ManifestParser;
122
123    #[test]
124    fn test_parse_stack_yaml() {
125        let content = r#"resolver: lts-21.0
126
127packages:
128  - .
129
130extra-deps:
131  - acme-pkg-1.2.3
132  - aeson-2.1.2.1
133  - text-2.0.2@sha256:abc123
134"#;
135        let m = StackParser.parse(content).unwrap();
136        assert_eq!(m.ecosystem, "stackage");
137        assert_eq!(m.dependencies.len(), 3);
138
139        let acme = m
140            .dependencies
141            .iter()
142            .find(|d| d.name == "acme-pkg")
143            .unwrap();
144        assert_eq!(acme.version_req.as_deref(), Some("1.2.3"));
145
146        let text = m.dependencies.iter().find(|d| d.name == "text").unwrap();
147        assert_eq!(text.version_req.as_deref(), Some("2.0.2"));
148    }
149}