Skip to main content

normalize_manifest/
purescript.rs

1//! Parser for `spago.yaml` files (PureScript/Spago).
2//!
3//! We use heuristic line-based parsing — no full YAML parser. The file has a
4//! well-known structure: `package:` section with `name:`, `version:`, and
5//! `dependencies:` keys.
6
7use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
8
9/// Parser for `spago.yaml` files.
10pub struct SpagoParser;
11
12impl ManifestParser for SpagoParser {
13    fn filename(&self) -> &'static str {
14        "spago.yaml"
15    }
16
17    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
18        let mut name: Option<String> = None;
19        let mut version: Option<String> = None;
20        let mut deps: Vec<DeclaredDep> = Vec::new();
21
22        #[derive(PartialEq, Clone, Copy)]
23        enum Section {
24            None,
25            Package,
26            Dependencies,
27        }
28
29        let mut section = Section::None;
30
31        for line in content.lines() {
32            // Detect section headers (zero-indented keys).
33            let trimmed = line.trim();
34
35            if trimmed.is_empty() || trimmed.starts_with('#') {
36                continue;
37            }
38
39            // Zero-indented keys switch sections.
40            if !line.starts_with(' ') && !line.starts_with('\t') {
41                if trimmed.starts_with("package:") {
42                    section = Section::Package;
43                } else {
44                    section = Section::None;
45                }
46                continue;
47            }
48
49            // Indented keys within the `package:` section.
50            if section == Section::Package {
51                // Two-space indented keys: name, version, dependencies.
52                if let Some(rest) = trimmed.strip_prefix("name:") {
53                    let v = rest.trim().trim_matches('"').trim_matches('\'').to_string();
54                    if !v.is_empty() && name.is_none() {
55                        name = Some(v);
56                    }
57                    continue;
58                }
59                if let Some(rest) = trimmed.strip_prefix("version:") {
60                    let v = rest.trim().trim_matches('"').trim_matches('\'').to_string();
61                    if !v.is_empty() && version.is_none() {
62                        version = Some(v);
63                    }
64                    continue;
65                }
66                if trimmed.starts_with("dependencies:") {
67                    section = Section::Dependencies;
68                    // The value might be on the same line (unlikely for lists).
69                    continue;
70                }
71                // Other indented keys in package section — stay in Package.
72                continue;
73            }
74
75            if section == Section::Dependencies {
76                // Dependency list items start with `- `.
77                if let Some(rest) = trimmed.strip_prefix("- ") {
78                    if let Some(dep) = parse_spago_dep(rest) {
79                        deps.push(dep);
80                    }
81                    continue;
82                }
83                // A non-list-item at the same or lower indent means we've
84                // left the dependencies list. If this is another key in the
85                // package section, go back to Package; otherwise, None.
86                if !trimmed.starts_with('-') {
87                    // Check if it's another package-level key.
88                    section = Section::Package;
89                }
90                continue;
91            }
92        }
93
94        Ok(ParsedManifest {
95            ecosystem: "pursuit",
96            name,
97            version,
98            dependencies: deps,
99        })
100    }
101}
102
103/// Parse a single dependency entry: bare name or `name: "version_range"`.
104fn parse_spago_dep(rest: &str) -> Option<DeclaredDep> {
105    let rest = rest.trim();
106    if rest.is_empty() {
107        return None;
108    }
109
110    // Inline mapping: `pkgname: ">=7.0.0 <8.0.0"` (YAML inline notation)
111    if let Some(colon_pos) = rest.find(':') {
112        let pkg_name = rest[..colon_pos].trim().to_string();
113        let ver_str = rest[colon_pos + 1..].trim();
114        let version_req = if ver_str.is_empty() {
115            None
116        } else {
117            Some(ver_str.trim_matches('"').trim_matches('\'').to_string())
118        };
119        if !pkg_name.is_empty() {
120            return Some(DeclaredDep {
121                name: pkg_name,
122                version_req,
123                kind: DepKind::Normal,
124            });
125        }
126    }
127
128    // Bare package name (strip trailing quote/whitespace noise).
129    let pkg_name = rest.trim_matches('"').trim_matches('\'').trim().to_string();
130    if pkg_name.is_empty() {
131        return None;
132    }
133    Some(DeclaredDep {
134        name: pkg_name,
135        version_req: None,
136        kind: DepKind::Normal,
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::ManifestParser;
144
145    const SAMPLE: &str = r#"package:
146  name: my-project
147  version: 0.1.0
148  dependencies:
149    - prelude
150    - effect
151    - console
152    - aff: ">=7.0.0 <8.0.0"
153
154workspace:
155  extra_packages: {}
156"#;
157
158    #[test]
159    fn test_parse_spago() {
160        let m = SpagoParser.parse(SAMPLE).unwrap();
161        assert_eq!(m.ecosystem, "pursuit");
162        assert_eq!(m.name.as_deref(), Some("my-project"));
163        assert_eq!(m.version.as_deref(), Some("0.1.0"));
164
165        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
166        assert!(names.contains(&"prelude"), "{names:?}");
167        assert!(names.contains(&"effect"), "{names:?}");
168        assert!(names.contains(&"console"), "{names:?}");
169        assert!(names.contains(&"aff"), "{names:?}");
170
171        let aff = m.dependencies.iter().find(|d| d.name == "aff").unwrap();
172        assert_eq!(aff.version_req.as_deref(), Some(">=7.0.0 <8.0.0"));
173    }
174
175    #[test]
176    fn test_workspace_excluded() {
177        let content = r#"package:
178  name: lib
179  version: 1.0.0
180  dependencies:
181    - prelude
182
183workspace:
184  extra_packages: {}
185"#;
186        let m = SpagoParser.parse(content).unwrap();
187        assert_eq!(m.name.as_deref(), Some("lib"));
188        assert_eq!(m.dependencies.len(), 1);
189    }
190}