Skip to main content

normalize_manifest/
crystal.rs

1//! Parser for `shard.yml` files (Crystal/Shards).
2//!
3//! Heuristic YAML parsing by indentation level:
4//! - `dependencies:` section → `DepKind::Normal`
5//! - `development_dependencies:` section → `DepKind::Dev`
6//! - Dep name: 2-space indented key
7//! - `version:` sub-key at 4+ spaces → version requirement
8
9use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
10
11/// Parser for `shard.yml` files (Crystal Shards).
12pub struct CrystalShardsParser;
13
14impl ManifestParser for CrystalShardsParser {
15    fn filename(&self) -> &'static str {
16        "shard.yml"
17    }
18
19    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
20        let mut name: Option<String> = None;
21        let mut version: Option<String> = None;
22        let mut deps: Vec<DeclaredDep> = Vec::new();
23
24        #[derive(PartialEq)]
25        enum Section {
26            None,
27            Dependencies,
28            DevDependencies,
29        }
30
31        let mut section = Section::None;
32        let mut current_dep_name: Option<String> = None;
33        let mut current_dep_kind = DepKind::Normal;
34        let mut current_dep_version: Option<String> = None;
35
36        for line in content.lines() {
37            // Skip blank lines and comments
38            let stripped = line.trim_end();
39            if stripped.trim().is_empty() || stripped.trim().starts_with('#') {
40                continue;
41            }
42
43            // Count leading spaces
44            let leading_spaces = stripped.len() - stripped.trim_start().len();
45            let trimmed = stripped.trim();
46
47            if leading_spaces == 0 {
48                // Flush any pending dep
49                flush_dep(
50                    &mut current_dep_name,
51                    &mut current_dep_version,
52                    current_dep_kind,
53                    &mut deps,
54                );
55
56                if trimmed == "dependencies:" {
57                    section = Section::Dependencies;
58                } else if trimmed == "development_dependencies:" {
59                    section = Section::DevDependencies;
60                } else {
61                    // Top-level key: value
62                    if let Some((key, val)) = split_key_value(trimmed) {
63                        match key {
64                            "name" => name = Some(val.to_string()),
65                            "version" => version = Some(val.to_string()),
66                            _ => {}
67                        }
68                    }
69                    section = Section::None;
70                }
71            } else if leading_spaces == 2
72                && (section == Section::Dependencies || section == Section::DevDependencies)
73            {
74                // Flush previous dep
75                flush_dep(
76                    &mut current_dep_name,
77                    &mut current_dep_version,
78                    current_dep_kind,
79                    &mut deps,
80                );
81
82                // A dep name entry: "  pkgname:"
83                if let Some(dep_name) = trimmed.strip_suffix(':')
84                    && !dep_name.starts_with('#')
85                {
86                    current_dep_name = Some(dep_name.to_string());
87                    current_dep_kind = if section == Section::DevDependencies {
88                        DepKind::Dev
89                    } else {
90                        DepKind::Normal
91                    };
92                    current_dep_version = None;
93                }
94            } else if leading_spaces >= 4 && current_dep_name.is_some() {
95                // Sub-key of a dep
96                if let Some((key, val)) = split_key_value(trimmed)
97                    && key == "version"
98                    && !val.is_empty()
99                {
100                    current_dep_version = Some(val.to_string());
101                }
102            }
103        }
104
105        // Flush last dep
106        flush_dep(
107            &mut current_dep_name,
108            &mut current_dep_version,
109            current_dep_kind,
110            &mut deps,
111        );
112
113        Ok(ParsedManifest {
114            ecosystem: "shards",
115            name,
116            version,
117            dependencies: deps,
118        })
119    }
120}
121
122fn flush_dep(
123    name: &mut Option<String>,
124    version: &mut Option<String>,
125    kind: DepKind,
126    deps: &mut Vec<DeclaredDep>,
127) {
128    if let Some(n) = name.take() {
129        deps.push(DeclaredDep {
130            name: n,
131            version_req: version.take(),
132            kind,
133        });
134    }
135    *version = None;
136}
137
138/// Split `key: value` or `key:` → `(key, value)`. Strips surrounding quotes.
139fn split_key_value(s: &str) -> Option<(&str, &str)> {
140    let colon = s.find(':')?;
141    let key = s[..colon].trim();
142    let raw_val = s[colon + 1..].trim();
143    // Strip surrounding quotes from value
144    let val = raw_val
145        .trim_start_matches('"')
146        .trim_end_matches('"')
147        .trim_start_matches('\'')
148        .trim_end_matches('\'');
149    Some((key, val))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::ManifestParser;
156
157    #[test]
158    fn test_parse_shard_yml() {
159        let content = r#"name: my_project
160version: 0.1.0
161
162authors:
163  - Alice <alice@example.com>
164
165dependencies:
166  mysql:
167    github: crystal-lang/crystal-mysql
168    version: ~> 0.13.0
169  kemal:
170    github: kemalcr/kemal
171    version: ~> 1.3
172  redis:
173    github: stefanwille/crystal-redis
174
175development_dependencies:
176  webmock:
177    github: manastech/webmock.cr
178    version: ~> 0.3
179"#;
180        let m = CrystalShardsParser.parse(content).unwrap();
181        assert_eq!(m.ecosystem, "shards");
182        assert_eq!(m.name.as_deref(), Some("my_project"));
183        assert_eq!(m.version.as_deref(), Some("0.1.0"));
184
185        let mysql = m.dependencies.iter().find(|d| d.name == "mysql").unwrap();
186        assert_eq!(mysql.kind, DepKind::Normal);
187        assert_eq!(mysql.version_req.as_deref(), Some("~> 0.13.0"));
188
189        let kemal = m.dependencies.iter().find(|d| d.name == "kemal").unwrap();
190        assert_eq!(kemal.version_req.as_deref(), Some("~> 1.3"));
191
192        let redis = m.dependencies.iter().find(|d| d.name == "redis").unwrap();
193        assert!(redis.version_req.is_none());
194
195        let webmock = m.dependencies.iter().find(|d| d.name == "webmock").unwrap();
196        assert_eq!(webmock.kind, DepKind::Dev);
197        assert_eq!(webmock.version_req.as_deref(), Some("~> 0.3"));
198    }
199
200    #[test]
201    fn test_no_dev_deps() {
202        let content = r#"name: minimal
203version: 0.2.0
204
205dependencies:
206  lucky:
207    github: luckyframework/lucky
208    version: ~> 1.0.0
209"#;
210        let m = CrystalShardsParser.parse(content).unwrap();
211        assert_eq!(m.dependencies.len(), 1);
212        assert_eq!(m.dependencies[0].name, "lucky");
213        assert_eq!(m.dependencies[0].kind, DepKind::Normal);
214    }
215}