cuenv_workspaces/parsers/javascript/
pnpm.rs1use crate::core::traits::LockfileParser;
2use crate::core::types::{DependencyRef, DependencySource, LockfileEntry};
3use crate::error::{Error, Result};
4use serde::Deserialize;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Default, Clone, Copy)]
11pub struct PnpmLockfileParser;
12
13impl LockfileParser for PnpmLockfileParser {
14 fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
15 let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
16 source,
17 path: Some(lockfile_path.to_path_buf()),
18 operation: "reading pnpm-lock.yaml".to_string(),
19 })?;
20
21 let lockfile: PnpmLockfile =
22 serde_yaml::from_str(&contents).map_err(|source| Error::LockfileParseFailed {
23 path: lockfile_path.to_path_buf(),
24 message: source.to_string(),
25 })?;
26
27 if let Some(ref version_str) = lockfile.lockfile_version {
31 let major_version = version_str
33 .split('.')
34 .next()
35 .and_then(|v| v.trim_matches('\'').parse::<u32>().ok());
36
37 if major_version.is_none() {
38 return Err(Error::LockfileParseFailed {
39 path: lockfile_path.to_path_buf(),
40 message: format!(
41 "Invalid pnpm lockfileVersion format: '{version_str}'. Expected a numeric version like '6.0'.",
42 ),
43 });
44 }
45
46 if let Some(major) = major_version
48 && major > 9
49 {
50 tracing::warn!(
51 "Encountered pnpm lockfile version '{version_str}' which is newer than the highest tested version (9.0). Parsing may fail or be incomplete.",
52 );
53 }
54 }
56 let mut entries = Vec::new();
59
60 for (importer_path, importer) in lockfile.importers {
62 let entry = entry_from_importer(&importer_path, &importer);
63 entries.push(entry);
64 }
65
66 for (package_key, package_info) in lockfile.packages {
68 let entry = entry_from_package(lockfile_path, &package_key, &package_info)?;
69 entries.push(entry);
70 }
71
72 Ok(entries)
73 }
74
75 fn supports_lockfile(&self, path: &Path) -> bool {
76 matches!(
77 path.file_name().and_then(|n| n.to_str()),
78 Some("pnpm-lock.yaml")
79 )
80 }
81
82 fn lockfile_name(&self) -> &'static str {
83 "pnpm-lock.yaml"
84 }
85}
86
87#[derive(Debug, Deserialize, Default)]
88#[serde(rename_all = "camelCase")]
89struct PnpmLockfile {
90 #[serde(default)]
91 lockfile_version: Option<String>,
92 #[serde(default)]
93 importers: BTreeMap<String, PnpmImporter>,
94 #[serde(default)]
95 packages: BTreeMap<String, PnpmPackage>,
96}
97
98#[derive(Debug, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100struct PnpmImporter {
101 #[serde(default)]
102 dependencies: BTreeMap<String, String>,
103 #[serde(default)]
104 dev_dependencies: BTreeMap<String, String>,
105 #[serde(default)]
106 optional_dependencies: BTreeMap<String, String>,
107 #[serde(default)]
108 #[allow(dead_code)]
109 specifiers: BTreeMap<String, String>,
110}
111
112#[derive(Debug, Deserialize, Default)]
113#[serde(rename_all = "camelCase")]
114struct PnpmPackage {
115 #[serde(default)]
116 resolution: Option<PnpmResolution>,
117 #[serde(default)]
118 dependencies: BTreeMap<String, String>,
119 #[serde(default)]
120 dev_dependencies: BTreeMap<String, String>,
121 #[serde(default)]
122 optional_dependencies: BTreeMap<String, String>,
123 #[serde(default)]
124 peer_dependencies: BTreeMap<String, String>,
125 #[serde(default)]
127 integrity: Option<String>,
128 #[serde(default)]
129 #[allow(dead_code)]
130 dev: bool,
131 #[serde(default)]
132 #[allow(dead_code)]
133 optional: bool,
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(untagged)]
138enum PnpmResolution {
139 Registry { integrity: String, tarball: String },
140 Git { repo: String, commit: String },
141 Object(BTreeMap<String, serde_yaml::Value>),
142}
143
144fn entry_from_importer(importer_path: &str, importer: &PnpmImporter) -> LockfileEntry {
145 let name = if importer_path == "." {
146 "workspace-root".to_string()
147 } else {
148 importer_path
149 .trim_start_matches("./")
150 .rsplit('/')
151 .next()
152 .unwrap_or(importer_path)
153 .to_string()
154 };
155
156 let mut dependencies = Vec::new();
157 push_dependencies(&mut dependencies, &importer.dependencies);
158 push_dependencies(&mut dependencies, &importer.dev_dependencies);
159 push_dependencies(&mut dependencies, &importer.optional_dependencies);
160
161 let path = if importer_path == "." {
162 PathBuf::from(".")
163 } else {
164 PathBuf::from(importer_path.trim_start_matches("./"))
165 };
166
167 LockfileEntry {
168 name,
169 version: "0.0.0".to_string(),
170 source: DependencySource::Workspace(path),
171 checksum: None,
172 dependencies,
173 is_workspace_member: true,
174 }
175}
176
177fn entry_from_package(
178 lockfile_path: &Path,
179 package_key: &str,
180 package_info: &PnpmPackage,
181) -> Result<LockfileEntry> {
182 let (name, version) = parse_package_key(lockfile_path, package_key)?;
184
185 let source = determine_source(&name, package_info);
186
187 let checksum = package_info.integrity.clone().or_else(|| {
189 package_info.resolution.as_ref().and_then(|res| match res {
190 PnpmResolution::Registry { integrity, .. } => Some(integrity.clone()),
191 PnpmResolution::Object(map) => map
192 .get("integrity")
193 .and_then(|v| v.as_str())
194 .map(ToString::to_string),
195 PnpmResolution::Git { .. } => None,
196 })
197 });
198
199 let mut dependencies = Vec::new();
200 push_dependencies(&mut dependencies, &package_info.dependencies);
201 push_dependencies(&mut dependencies, &package_info.dev_dependencies);
202 push_dependencies(&mut dependencies, &package_info.optional_dependencies);
203 push_dependencies(&mut dependencies, &package_info.peer_dependencies);
204
205 Ok(LockfileEntry {
206 name,
207 version,
208 source,
209 checksum,
210 dependencies,
211 is_workspace_member: false,
212 })
213}
214
215fn parse_package_key(lockfile_path: &Path, package_key: &str) -> Result<(String, String)> {
216 let key = package_key.trim_start_matches('/');
218
219 if key.starts_with('@') {
221 let parts: Vec<&str> = key.splitn(3, '/').collect();
224 if parts.len() >= 3 {
225 let name = format!("{}/{}", parts[0], parts[1]);
227 let version_part = parts[2];
228
229 let version = version_part
231 .split('(')
232 .next()
233 .unwrap_or(version_part)
234 .trim_end_matches(')');
235
236 return Ok((name, version.to_string()));
237 } else if parts.len() == 2 {
238 let name = format!(
240 "{}/{}",
241 parts[0],
242 parts[1].split('@').next().unwrap_or(parts[1])
243 );
244 let version = parts[1]
245 .split('@')
246 .nth(1)
247 .ok_or_else(|| Error::LockfileParseFailed {
248 path: lockfile_path.to_path_buf(),
249 message: format!("Invalid scoped package key format: {package_key}"),
250 })?;
251
252 let version = version
253 .split('(')
254 .next()
255 .unwrap_or(version)
256 .trim_end_matches(')');
257
258 return Ok((name, version.to_string()));
259 }
260 }
261
262 if let Some(at_idx) = key.rfind('@') {
265 let name = &key[..at_idx];
266 let version = &key[at_idx + 1..];
267
268 let version = version
270 .split('(')
271 .next()
272 .unwrap_or(version)
273 .trim_end_matches(')');
274
275 Ok((name.to_string(), version.to_string()))
276 } else if let Some(last_slash) = key.rfind('/') {
277 let name = &key[..last_slash];
279 let version = &key[last_slash + 1..];
280
281 let version = version
283 .split('(')
284 .next()
285 .unwrap_or(version)
286 .trim_end_matches(')');
287
288 Ok((name.to_string(), version.to_string()))
289 } else {
290 Err(Error::LockfileParseFailed {
291 path: lockfile_path.to_path_buf(),
292 message: format!("Invalid pnpm package key format: {package_key}"),
293 })
294 }
295}
296
297fn determine_source(name: &str, package_info: &PnpmPackage) -> DependencySource {
298 if let Some(resolution) = &package_info.resolution {
299 match resolution {
300 PnpmResolution::Registry { tarball, .. } => DependencySource::Registry(tarball.clone()),
301 PnpmResolution::Git { repo, commit } => {
302 DependencySource::Git(format!("{repo}#{commit}"))
303 }
304 PnpmResolution::Object(map) => {
305 if let Some(tarball) = map.get("tarball").and_then(|v| v.as_str()) {
307 DependencySource::Registry(tarball.to_string())
308 } else if let Some(repo) = map.get("repo").and_then(|v| v.as_str()) {
309 let commit = map.get("commit").and_then(|v| v.as_str()).unwrap_or("HEAD");
310 DependencySource::Git(format!("{repo}#{commit}"))
311 } else if let Some(dir) = map.get("directory").and_then(|v| v.as_str()) {
312 DependencySource::Path(PathBuf::from(dir))
313 } else {
314 DependencySource::Registry(format!("npm:{name}"))
316 }
317 }
318 }
319 } else {
320 DependencySource::Registry(format!("npm:{name}"))
322 }
323}
324
325fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
326 for (name, version_req) in deps {
327 target.push(DependencyRef {
328 name: name.clone(),
329 version_req: version_req.clone(),
330 });
331 }
332}
333
334#[cfg(test)]
335#[allow(clippy::needless_raw_string_hashes, clippy::uninlined_format_args)]
336mod tests {
337 use super::*;
338 use std::io::Write;
339 use tempfile::NamedTempFile;
340
341 #[test]
342 fn parses_basic_pnpm_lock() {
343 let yaml = r#"
344lockfileVersion: '6.0'
345
346importers:
347 .:
348 dependencies:
349 left-pad: 1.3.0
350
351packages:
352 /left-pad@1.3.0:
353 resolution:
354 integrity: sha512-test123
355 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
356 dev: false
357"#;
358
359 let mut file = NamedTempFile::new().unwrap();
360 file.write_all(yaml.as_bytes()).unwrap();
361
362 let parser = PnpmLockfileParser;
363 let entries = parser.parse(file.path()).unwrap();
364
365 assert!(entries.len() >= 2);
366
367 let workspace = entries
368 .iter()
369 .find(|e| e.is_workspace_member)
370 .expect("workspace root");
371 assert_eq!(workspace.dependencies.len(), 1);
372
373 let left_pad = entries
374 .iter()
375 .find(|e| e.name == "left-pad")
376 .expect("left-pad");
377 assert_eq!(left_pad.version, "1.3.0");
378 assert!(!left_pad.is_workspace_member);
379 }
380
381 #[test]
382 fn parses_scoped_packages() {
383 let yaml = r#"
384lockfileVersion: '6.0'
385
386importers:
387 .:
388 dependencies: {}
389
390packages:
391 /@babel/core@7.22.5:
392 resolution:
393 integrity: sha512-xyz
394 tarball: https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz
395 dev: false
396"#;
397
398 let mut file = NamedTempFile::new().unwrap();
399 file.write_all(yaml.as_bytes()).unwrap();
400
401 let parser = PnpmLockfileParser;
402 let entries = parser.parse(file.path()).unwrap();
403
404 let babel = entries
405 .iter()
406 .find(|e| e.name == "@babel/core")
407 .expect("@babel/core");
408 assert_eq!(babel.version, "7.22.5");
409 }
410
411 #[test]
412 fn supports_expected_filename() {
413 let parser = PnpmLockfileParser;
414 assert!(parser.supports_lockfile(Path::new("/tmp/pnpm-lock.yaml")));
415 assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
416 }
417
418 #[test]
419 fn accepts_various_lockfile_versions() {
420 for version in ["5.4", "6.0", "7.0", "9.0", "10.0"] {
422 let yaml = format!(
423 r#"
424lockfileVersion: '{}'
425
426importers:
427 .:
428 dependencies:
429 left-pad: 1.3.0
430
431packages:
432 /left-pad@1.3.0:
433 resolution:
434 integrity: sha512-test
435 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
436 dev: false
437"#,
438 version
439 );
440
441 let mut file = NamedTempFile::new().unwrap();
442 file.write_all(yaml.as_bytes()).unwrap();
443
444 let parser = PnpmLockfileParser;
445 let result = parser.parse(file.path());
446 assert!(
447 result.is_ok(),
448 "Version {} should be accepted, got error: {:?}",
449 version,
450 result.err()
451 );
452 }
453 }
454
455 #[test]
456 fn rejects_invalid_lockfile_version_format() {
457 let yaml = r#"
458lockfileVersion: 'invalid'
459
460importers:
461 .:
462 dependencies: {}
463
464packages: {}
465"#;
466
467 let mut file = NamedTempFile::new().unwrap();
468 file.write_all(yaml.as_bytes()).unwrap();
469
470 let parser = PnpmLockfileParser;
471 let err = parser.parse(file.path()).unwrap_err();
472
473 match err {
474 Error::LockfileParseFailed { message, .. } => {
475 assert!(message.contains("Invalid pnpm lockfileVersion format"));
476 assert!(message.contains("invalid"));
477 }
478 other => panic!("Expected LockfileParseFailed, got: {:?}", other),
479 }
480 }
481
482 #[test]
483 fn accepts_supported_versions() {
484 for version in ["6.0", "9.0"] {
485 let yaml = format!(
486 r#"
487lockfileVersion: '{}'
488
489importers:
490 .:
491 dependencies:
492 left-pad: 1.3.0
493
494packages:
495 /left-pad@1.3.0:
496 resolution:
497 integrity: sha512-test
498 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
499 dev: false
500"#,
501 version
502 );
503
504 let mut file = NamedTempFile::new().unwrap();
505 file.write_all(yaml.as_bytes()).unwrap();
506
507 let parser = PnpmLockfileParser;
508 let result = parser.parse(file.path());
509 assert!(
510 result.is_ok(),
511 "Version {} should be supported, got error: {:?}",
512 version,
513 result.err()
514 );
515 }
516 }
517
518 #[test]
519 fn accepts_missing_lockfile_version() {
520 let yaml = r#"
522importers:
523 .:
524 dependencies:
525 left-pad: 1.3.0
526
527packages:
528 /left-pad@1.3.0:
529 resolution:
530 integrity: sha512-test
531 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
532 dev: false
533"#;
534
535 let mut file = NamedTempFile::new().unwrap();
536 file.write_all(yaml.as_bytes()).unwrap();
537
538 let parser = PnpmLockfileParser;
539 let result = parser.parse(file.path());
540 assert!(
541 result.is_ok(),
542 "Missing lockfileVersion should be accepted, got error: {:?}",
543 result.err()
544 );
545 }
546}