1use deps_core::error::{DepsError, Result};
4use deps_core::lockfile::{
5 LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
6 locate_lockfile_for_manifest, read_lockfile_content,
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 = read_lockfile_content(lockfile_path, "pubspec.lock").await?;
32
33 parse_pubspec_lock(&content)
34 })
35 }
36}
37
38pub fn parse_pubspec_lock(content: &str) -> Result<ResolvedPackages> {
39 if let Err(depth) =
40 deps_core::check_yaml_nesting_depth(content, deps_core::MAX_YAML_NESTING_DEPTH)
41 {
42 return Err(DepsError::ParseError {
43 file_type: "pubspec.lock".into(),
44 source: Box::new(std::io::Error::other(format!(
45 "YAML nesting depth {depth} exceeds maximum of {}",
46 deps_core::MAX_YAML_NESTING_DEPTH
47 ))),
48 });
49 }
50
51 if let Err(bytes) = deps_core::check_yaml_expansion(content, deps_core::MAX_YAML_EXPANDED_BYTES)
52 {
53 return Err(DepsError::ParseError {
54 file_type: "pubspec.lock".into(),
55 source: Box::new(std::io::Error::other(format!(
56 "YAML expansion {bytes} bytes exceeds maximum of {} bytes",
57 deps_core::MAX_YAML_EXPANDED_BYTES
58 ))),
59 });
60 }
61
62 let mut packages = ResolvedPackages::new();
63
64 let docs = YamlLoader::load_from_str(content).map_err(|e| DepsError::ParseError {
65 file_type: "pubspec.lock".into(),
66 source: Box::new(std::io::Error::other(e.to_string())),
67 })?;
68
69 let doc = match docs.first() {
70 Some(d) => d,
71 None => return Ok(packages),
72 };
73
74 if let Yaml::Hash(pkgs) = &doc["packages"] {
75 for (name_yaml, entry) in pkgs {
76 let Some(name) = name_yaml.as_str() else {
77 continue;
78 };
79 let Some(version) = entry["version"].as_str() else {
80 continue;
81 };
82
83 let source_type = entry["source"].as_str().unwrap_or("hosted");
84 let source = match source_type {
85 "hosted" => {
86 let url = entry["description"]["url"]
87 .as_str()
88 .unwrap_or("https://pub.dev")
89 .to_string();
90 ResolvedSource::Registry {
91 url,
92 checksum: String::new(),
93 }
94 }
95 "git" => {
96 let url = entry["description"]["url"]
97 .as_str()
98 .unwrap_or("")
99 .to_string();
100 let rev = entry["description"]["resolved-ref"]
101 .as_str()
102 .unwrap_or("")
103 .to_string();
104 ResolvedSource::Git { url, rev }
105 }
106 "path" => {
107 let path = entry["description"]["path"]
108 .as_str()
109 .unwrap_or("")
110 .to_string();
111 ResolvedSource::Path { path }
112 }
113 _ => ResolvedSource::Registry {
114 url: "https://pub.dev".to_string(),
115 checksum: String::new(),
116 },
117 };
118
119 let version = version.trim_matches('"').to_string();
121
122 packages.insert(ResolvedPackage {
123 name: name.to_string(),
124 version,
125 source,
126 dependencies: vec![],
127 });
128 }
129 }
130
131 tracing::info!("Parsed pubspec.lock: {} packages", packages.len());
132
133 Ok(packages)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_parse_simple_lock() {
142 let lock = r#"
143packages:
144 http:
145 dependency: "direct main"
146 description:
147 name: http
148 url: "https://pub.dev"
149 source: hosted
150 version: "1.2.0"
151 provider:
152 dependency: "direct main"
153 description:
154 name: provider
155 url: "https://pub.dev"
156 source: hosted
157 version: "6.1.2"
158"#;
159 let packages = parse_pubspec_lock(lock).unwrap();
160 assert_eq!(packages.len(), 2);
161 assert_eq!(packages.get_version("http"), Some("1.2.0"));
162 assert_eq!(packages.get_version("provider"), Some("6.1.2"));
163 }
164
165 #[test]
166 fn test_parse_git_source() {
167 let lock = r#"
168packages:
169 my_pkg:
170 dependency: "direct main"
171 description:
172 url: "https://github.com/user/repo.git"
173 resolved-ref: abc123
174 source: git
175 version: "0.1.0"
176"#;
177 let packages = parse_pubspec_lock(lock).unwrap();
178 let pkg = packages.get("my_pkg").unwrap();
179 match &pkg.source {
180 ResolvedSource::Git { url, rev } => {
181 assert_eq!(url, "https://github.com/user/repo.git");
182 assert_eq!(rev, "abc123");
183 }
184 _ => panic!("Expected Git source"),
185 }
186 }
187
188 #[test]
189 fn test_parse_path_source() {
190 let lock = r#"
191packages:
192 local_pkg:
193 dependency: "direct main"
194 description:
195 path: "../local_pkg"
196 source: path
197 version: "0.1.0"
198"#;
199 let packages = parse_pubspec_lock(lock).unwrap();
200 let pkg = packages.get("local_pkg").unwrap();
201 match &pkg.source {
202 ResolvedSource::Path { path } => {
203 assert_eq!(path, "../local_pkg");
204 }
205 _ => panic!("Expected Path source"),
206 }
207 }
208
209 #[test]
210 fn test_parse_empty_lock() {
211 let lock = "";
212 let packages = parse_pubspec_lock(lock).unwrap();
213 assert!(packages.is_empty());
214 }
215
216 #[test]
217 fn test_deeply_nested_lock_rejected_not_crashed() {
218 let lock = format!("{}1", "- ".repeat(6000));
224 let result = parse_pubspec_lock(&lock);
225 assert!(result.is_err());
226 }
227
228 #[test]
229 fn test_deeply_nested_lock_with_apostrophe_rejected_not_crashed() {
230 let lock = format!(
233 "packages:\n http:\n description: it doesn't matter\n{}1",
234 "- ".repeat(6000)
235 );
236 let result = parse_pubspec_lock(&lock);
237 assert!(result.is_err());
238 }
239
240 #[test]
241 fn test_anchor_alias_expansion_bomb_rejected_not_oomed() {
242 let mut lock = String::from("packages:\n a0: &a0 [x, x]\n");
248 for i in 1..=30 {
249 lock.push_str(&format!(
250 " a{i}: &a{i} [*a{prev}, *a{prev}]\n",
251 prev = i - 1
252 ));
253 }
254 let result = parse_pubspec_lock(&lock);
255 let err = result.expect_err("expected the expansion budget to reject this");
259 assert!(
260 err.to_string().contains("YAML expansion"),
261 "unexpected error message: {err}"
262 );
263 }
264
265 #[test]
266 fn test_asterisk_in_description_not_misread_as_alias() {
267 let lock = r#"
268packages:
269 http:
270 dependency: "direct main"
271 description: A package for *multiplier* http requests
272 source: hosted
273 version: "1.2.0"
274"#;
275 let result = parse_pubspec_lock(lock);
276 assert!(result.is_ok());
277 }
278
279 #[test]
280 fn test_realistic_pubspec_lock_still_parses() {
281 let lock = r#"
282packages:
283 http:
284 dependency: "direct main"
285 description:
286 name: http
287 url: "https://pub.dev"
288 source: hosted
289 version: "1.2.0"
290"#;
291 let packages = parse_pubspec_lock(lock).unwrap();
292 assert_eq!(packages.get_version("http"), Some("1.2.0"));
293 }
294
295 #[test]
296 fn test_locate_lockfile() {
297 let temp_dir = tempfile::tempdir().unwrap();
298 let manifest_path = temp_dir.path().join("pubspec.yaml");
299 let lock_path = temp_dir.path().join("pubspec.lock");
300
301 std::fs::write(&manifest_path, "name: test").unwrap();
302 std::fs::write(&lock_path, "packages:\n").unwrap();
303
304 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
305 let parser = PubspecLockParser;
306
307 let located = parser.locate_lockfile(&manifest_uri);
308 assert!(located.is_some());
309 assert_eq!(located.unwrap(), lock_path);
310 }
311
312 #[test]
313 fn test_locate_lockfile_not_found() {
314 let temp_dir = tempfile::tempdir().unwrap();
315 let manifest_path = temp_dir.path().join("pubspec.yaml");
316 std::fs::write(&manifest_path, "name: test").unwrap();
317
318 let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
319 let parser = PubspecLockParser;
320
321 assert!(parser.locate_lockfile(&manifest_uri).is_none());
322 }
323
324 #[tokio::test]
325 async fn test_parse_lockfile_from_file() {
326 let temp_dir = tempfile::tempdir().unwrap();
327 let lock_path = temp_dir.path().join("pubspec.lock");
328
329 let content = r#"
330packages:
331 http:
332 dependency: "direct main"
333 description:
334 name: http
335 url: "https://pub.dev"
336 source: hosted
337 version: "1.2.0"
338"#;
339 std::fs::write(&lock_path, content).unwrap();
340
341 let parser = PubspecLockParser;
342 let packages = parser.parse_lockfile(&lock_path).await.unwrap();
343 assert_eq!(packages.len(), 1);
344 assert_eq!(packages.get_version("http"), Some("1.2.0"));
345 }
346}