Skip to main content

normalize_manifest/
pubspec.rs

1//! Parser for `pubspec.yaml` files (Dart/Flutter).
2//!
3//! Uses indent-aware line parsing rather than a full YAML library.
4//! Extracts `dependencies:` and `dev_dependencies:` sections.
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `pubspec.yaml` files.
9pub struct PubspecParser;
10
11impl ManifestParser for PubspecParser {
12    fn filename(&self) -> &'static str {
13        "pubspec.yaml"
14    }
15
16    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
17        let mut name = None;
18        let mut version = None;
19        let mut deps = Vec::new();
20
21        #[derive(PartialEq, Clone, Copy)]
22        enum Section {
23            None,
24            Deps,
25            DevDeps,
26        }
27
28        let mut section = Section::None;
29        let mut section_indent = 0usize;
30        let mut dep_name: Option<(String, DepKind)> = None;
31
32        for line in content.lines() {
33            if line.trim().is_empty() || line.trim().starts_with('#') {
34                continue;
35            }
36
37            let indent = leading_spaces(line);
38            let trimmed = line.trim();
39
40            // Top-level keys (indent == 0)
41            if indent == 0 {
42                // Flush any pending dep with no version
43                if let Some((dname, dkind)) = dep_name.take() {
44                    deps.push(DeclaredDep {
45                        name: dname,
46                        version_req: None,
47                        kind: dkind,
48                    });
49                }
50
51                if let Some(rest) = trimmed.strip_prefix("name:") {
52                    name = rest
53                        .trim()
54                        .trim_matches('"')
55                        .trim_matches('\'')
56                        .to_string()
57                        .into();
58                    section = Section::None;
59                } else if let Some(rest) = trimmed.strip_prefix("version:") {
60                    version = rest
61                        .trim()
62                        .trim_matches('"')
63                        .trim_matches('\'')
64                        .to_string()
65                        .into();
66                    section = Section::None;
67                } else if trimmed == "dependencies:" {
68                    section = Section::Deps;
69                    section_indent = 0;
70                } else if trimmed == "dev_dependencies:" {
71                    section = Section::DevDeps;
72                    section_indent = 0;
73                } else {
74                    section = Section::None;
75                }
76                continue;
77            }
78
79            if section == Section::None {
80                continue;
81            }
82
83            let kind = if section == Section::DevDeps {
84                DepKind::Dev
85            } else {
86                DepKind::Normal
87            };
88
89            // First-level entries under the section
90            if section_indent == 0 || indent == section_indent {
91                // Flush previous dep
92                if let Some((dname, dkind)) = dep_name.take() {
93                    deps.push(DeclaredDep {
94                        name: dname,
95                        version_req: None,
96                        kind: dkind,
97                    });
98                }
99
100                section_indent = indent;
101
102                // `  flutter:` or `  http: ^0.13.0`
103                if let Some(colon_idx) = trimmed.find(':') {
104                    let pkg_name = trimmed[..colon_idx].trim().to_string();
105                    let after_colon = trimmed[colon_idx + 1..].trim();
106
107                    // Skip `sdk: flutter` — it's a platform dep, not a package
108                    if after_colon == "sdk: flutter"
109                        || after_colon == "flutter"
110                        || after_colon.starts_with("sdk:")
111                    {
112                        continue;
113                    }
114
115                    if after_colon.is_empty() {
116                        // Version (or sub-keys) on next line(s)
117                        dep_name = Some((pkg_name, kind));
118                    } else {
119                        // Inline version: `http: ^0.13.0`
120                        deps.push(DeclaredDep {
121                            name: pkg_name,
122                            version_req: Some(after_colon.to_string()),
123                            kind,
124                        });
125                    }
126                }
127            } else if let Some((ref dname, dkind)) = dep_name {
128                // Sub-key of a dep: `version: ^1.0.0`, `path: ../pkg`, `sdk: flutter`, etc.
129                if trimmed.starts_with("sdk:") {
130                    // Platform/SDK dep — discard, not a real package
131                    dep_name = None;
132                } else if let Some(ver_rest) = trimmed.strip_prefix("version:") {
133                    let ver = ver_rest.trim().to_string();
134                    deps.push(DeclaredDep {
135                        name: dname.clone(),
136                        version_req: if ver.is_empty() { None } else { Some(ver) },
137                        kind: dkind,
138                    });
139                    dep_name = None;
140                }
141                // git/path deps: dep_name remains; flushed at next sibling entry with no version_req
142            }
143        }
144
145        // Flush last pending dep
146        if let Some((dname, dkind)) = dep_name.take() {
147            deps.push(DeclaredDep {
148                name: dname,
149                version_req: None,
150                kind: dkind,
151            });
152        }
153
154        Ok(ParsedManifest {
155            ecosystem: "pub",
156            name,
157            version,
158            dependencies: deps,
159        })
160    }
161}
162
163fn leading_spaces(line: &str) -> usize {
164    line.len() - line.trim_start().len()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::ManifestParser;
171
172    #[test]
173    fn test_parse_pubspec_yaml() {
174        let content = r#"name: my_flutter_app
175version: 1.0.0+1
176description: A new Flutter project.
177
178dependencies:
179  flutter:
180    sdk: flutter
181  http: ^0.13.0
182  provider:
183    version: ^6.0.0
184  path_provider: ^2.0.0
185
186dev_dependencies:
187  flutter_test:
188    sdk: flutter
189  mockito: ^5.0.0
190"#;
191        let m = PubspecParser.parse(content).unwrap();
192        assert_eq!(m.ecosystem, "pub");
193        assert_eq!(m.name.as_deref(), Some("my_flutter_app"));
194        assert_eq!(m.version.as_deref(), Some("1.0.0+1"));
195
196        let normal: Vec<_> = m
197            .dependencies
198            .iter()
199            .filter(|d| d.kind == DepKind::Normal)
200            .collect();
201        // flutter (sdk) is filtered, http + provider + path_provider remain
202        assert_eq!(normal.len(), 3);
203        assert!(normal.iter().any(|d| d.name == "http"));
204
205        let http = m.dependencies.iter().find(|d| d.name == "http").unwrap();
206        assert_eq!(http.version_req.as_deref(), Some("^0.13.0"));
207
208        let dev: Vec<_> = m
209            .dependencies
210            .iter()
211            .filter(|d| d.kind == DepKind::Dev)
212            .collect();
213        // flutter_test (sdk) is filtered, mockito remains
214        assert_eq!(dev.len(), 1);
215        assert_eq!(dev[0].name, "mockito");
216    }
217}