Skip to main content

deps_dart/
lockfile.rs

1//! pubspec.lock file parsing.
2
3use deps_core::error::{DepsError, Result};
4use deps_core::lockfile::{
5    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
6    locate_lockfile_for_manifest,
7};
8use std::path::{Path, PathBuf};
9use tower_lsp_server::ls_types::Uri;
10use yaml_rust2::{Yaml, YamlLoader};
11
12pub struct PubspecLockParser;
13
14impl PubspecLockParser {
15    const LOCKFILE_NAMES: &'static [&'static str] = &["pubspec.lock"];
16}
17
18impl LockFileProvider for PubspecLockParser {
19    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
20        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
21    }
22
23    fn parse_lockfile<'a>(
24        &'a self,
25        lockfile_path: &'a Path,
26    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ResolvedPackages>> + Send + 'a>>
27    {
28        Box::pin(async move {
29            tracing::debug!("Parsing pubspec.lock: {}", lockfile_path.display());
30
31            let content = tokio::fs::read_to_string(lockfile_path)
32                .await
33                .map_err(|e| DepsError::ParseError {
34                    file_type: format!("pubspec.lock at {}", lockfile_path.display()),
35                    source: Box::new(e),
36                })?;
37
38            parse_pubspec_lock(&content)
39        })
40    }
41}
42
43pub fn parse_pubspec_lock(content: &str) -> Result<ResolvedPackages> {
44    let mut packages = ResolvedPackages::new();
45
46    let docs = YamlLoader::load_from_str(content).map_err(|e| DepsError::ParseError {
47        file_type: "pubspec.lock".into(),
48        source: Box::new(std::io::Error::other(e.to_string())),
49    })?;
50
51    let doc = match docs.first() {
52        Some(d) => d,
53        None => return Ok(packages),
54    };
55
56    if let Yaml::Hash(pkgs) = &doc["packages"] {
57        for (name_yaml, entry) in pkgs {
58            let Some(name) = name_yaml.as_str() else {
59                continue;
60            };
61            let Some(version) = entry["version"].as_str() else {
62                continue;
63            };
64
65            let source_type = entry["source"].as_str().unwrap_or("hosted");
66            let source = match source_type {
67                "hosted" => {
68                    let url = entry["description"]["url"]
69                        .as_str()
70                        .unwrap_or("https://pub.dev")
71                        .to_string();
72                    ResolvedSource::Registry {
73                        url,
74                        checksum: String::new(),
75                    }
76                }
77                "git" => {
78                    let url = entry["description"]["url"]
79                        .as_str()
80                        .unwrap_or("")
81                        .to_string();
82                    let rev = entry["description"]["resolved-ref"]
83                        .as_str()
84                        .unwrap_or("")
85                        .to_string();
86                    ResolvedSource::Git { url, rev }
87                }
88                "path" => {
89                    let path = entry["description"]["path"]
90                        .as_str()
91                        .unwrap_or("")
92                        .to_string();
93                    ResolvedSource::Path { path }
94                }
95                _ => ResolvedSource::Registry {
96                    url: "https://pub.dev".to_string(),
97                    checksum: String::new(),
98                },
99            };
100
101            // Remove surrounding quotes from version if present
102            let version = version.trim_matches('"').to_string();
103
104            packages.insert(ResolvedPackage {
105                name: name.to_string(),
106                version,
107                source,
108                dependencies: vec![],
109            });
110        }
111    }
112
113    tracing::info!("Parsed pubspec.lock: {} packages", packages.len());
114
115    Ok(packages)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_parse_simple_lock() {
124        let lock = r#"
125packages:
126  http:
127    dependency: "direct main"
128    description:
129      name: http
130      url: "https://pub.dev"
131    source: hosted
132    version: "1.2.0"
133  provider:
134    dependency: "direct main"
135    description:
136      name: provider
137      url: "https://pub.dev"
138    source: hosted
139    version: "6.1.2"
140"#;
141        let packages = parse_pubspec_lock(lock).unwrap();
142        assert_eq!(packages.len(), 2);
143        assert_eq!(packages.get_version("http"), Some("1.2.0"));
144        assert_eq!(packages.get_version("provider"), Some("6.1.2"));
145    }
146
147    #[test]
148    fn test_parse_git_source() {
149        let lock = r#"
150packages:
151  my_pkg:
152    dependency: "direct main"
153    description:
154      url: "https://github.com/user/repo.git"
155      resolved-ref: abc123
156    source: git
157    version: "0.1.0"
158"#;
159        let packages = parse_pubspec_lock(lock).unwrap();
160        let pkg = packages.get("my_pkg").unwrap();
161        match &pkg.source {
162            ResolvedSource::Git { url, rev } => {
163                assert_eq!(url, "https://github.com/user/repo.git");
164                assert_eq!(rev, "abc123");
165            }
166            _ => panic!("Expected Git source"),
167        }
168    }
169
170    #[test]
171    fn test_parse_path_source() {
172        let lock = r#"
173packages:
174  local_pkg:
175    dependency: "direct main"
176    description:
177      path: "../local_pkg"
178    source: path
179    version: "0.1.0"
180"#;
181        let packages = parse_pubspec_lock(lock).unwrap();
182        let pkg = packages.get("local_pkg").unwrap();
183        match &pkg.source {
184            ResolvedSource::Path { path } => {
185                assert_eq!(path, "../local_pkg");
186            }
187            _ => panic!("Expected Path source"),
188        }
189    }
190
191    #[test]
192    fn test_parse_empty_lock() {
193        let lock = "";
194        let packages = parse_pubspec_lock(lock).unwrap();
195        assert!(packages.is_empty());
196    }
197
198    #[test]
199    fn test_locate_lockfile() {
200        let temp_dir = tempfile::tempdir().unwrap();
201        let manifest_path = temp_dir.path().join("pubspec.yaml");
202        let lock_path = temp_dir.path().join("pubspec.lock");
203
204        std::fs::write(&manifest_path, "name: test").unwrap();
205        std::fs::write(&lock_path, "packages:\n").unwrap();
206
207        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
208        let parser = PubspecLockParser;
209
210        let located = parser.locate_lockfile(&manifest_uri);
211        assert!(located.is_some());
212        assert_eq!(located.unwrap(), lock_path);
213    }
214
215    #[test]
216    fn test_locate_lockfile_not_found() {
217        let temp_dir = tempfile::tempdir().unwrap();
218        let manifest_path = temp_dir.path().join("pubspec.yaml");
219        std::fs::write(&manifest_path, "name: test").unwrap();
220
221        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
222        let parser = PubspecLockParser;
223
224        assert!(parser.locate_lockfile(&manifest_uri).is_none());
225    }
226
227    #[tokio::test]
228    async fn test_parse_lockfile_from_file() {
229        let temp_dir = tempfile::tempdir().unwrap();
230        let lock_path = temp_dir.path().join("pubspec.lock");
231
232        let content = r#"
233packages:
234  http:
235    dependency: "direct main"
236    description:
237      name: http
238      url: "https://pub.dev"
239    source: hosted
240    version: "1.2.0"
241"#;
242        std::fs::write(&lock_path, content).unwrap();
243
244        let parser = PubspecLockParser;
245        let packages = parser.parse_lockfile(&lock_path).await.unwrap();
246        assert_eq!(packages.len(), 1);
247        assert_eq!(packages.get_version("http"), Some("1.2.0"));
248    }
249}