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
215#[allow(clippy::option_if_let_else, clippy::too_many_lines)] fn parse_package_key(lockfile_path: &Path, package_key: &str) -> Result<(String, String)> {
217 let key = package_key.trim_start_matches('/');
219
220 if key.starts_with('@') {
222 let parts: Vec<&str> = key.splitn(3, '/').collect();
225 if parts.len() >= 3 {
226 let name = format!("{}/{}", parts[0], parts[1]);
228 let version_part = parts[2];
229
230 let version = version_part
232 .split('(')
233 .next()
234 .unwrap_or(version_part)
235 .trim_end_matches(')');
236
237 return Ok((name, version.to_string()));
238 } else if parts.len() == 2 {
239 let name = format!(
241 "{}/{}",
242 parts[0],
243 parts[1].split('@').next().unwrap_or(parts[1])
244 );
245 let version = parts[1]
246 .split('@')
247 .nth(1)
248 .ok_or_else(|| Error::LockfileParseFailed {
249 path: lockfile_path.to_path_buf(),
250 message: format!("Invalid scoped package key format: {package_key}"),
251 })?;
252
253 let version = version
254 .split('(')
255 .next()
256 .unwrap_or(version)
257 .trim_end_matches(')');
258
259 return Ok((name, version.to_string()));
260 }
261 }
262
263 if let Some(at_idx) = key.rfind('@') {
266 let name = &key[..at_idx];
267 let version = &key[at_idx + 1..];
268
269 let version = version
271 .split('(')
272 .next()
273 .unwrap_or(version)
274 .trim_end_matches(')');
275
276 Ok((name.to_string(), version.to_string()))
277 } else if let Some(last_slash) = key.rfind('/') {
278 let name = &key[..last_slash];
280 let version = &key[last_slash + 1..];
281
282 let version = version
284 .split('(')
285 .next()
286 .unwrap_or(version)
287 .trim_end_matches(')');
288
289 Ok((name.to_string(), version.to_string()))
290 } else {
291 Err(Error::LockfileParseFailed {
292 path: lockfile_path.to_path_buf(),
293 message: format!("Invalid pnpm package key format: {package_key}"),
294 })
295 }
296}
297
298#[allow(clippy::option_if_let_else)] fn determine_source(name: &str, package_info: &PnpmPackage) -> DependencySource {
300 if let Some(resolution) = &package_info.resolution {
301 match resolution {
302 PnpmResolution::Registry { tarball, .. } => DependencySource::Registry(tarball.clone()),
303 PnpmResolution::Git { repo, commit } => {
304 DependencySource::Git(format!("{repo}#{commit}"))
305 }
306 PnpmResolution::Object(map) => {
307 if let Some(tarball) = map.get("tarball").and_then(|v| v.as_str()) {
309 DependencySource::Registry(tarball.to_string())
310 } else if let Some(repo) = map.get("repo").and_then(|v| v.as_str()) {
311 let commit = map.get("commit").and_then(|v| v.as_str()).unwrap_or("HEAD");
312 DependencySource::Git(format!("{repo}#{commit}"))
313 } else if let Some(dir) = map.get("directory").and_then(|v| v.as_str()) {
314 DependencySource::Path(PathBuf::from(dir))
315 } else {
316 DependencySource::Registry(format!("npm:{name}"))
318 }
319 }
320 }
321 } else {
322 DependencySource::Registry(format!("npm:{name}"))
324 }
325}
326
327fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
328 for (name, version_req) in deps {
329 target.push(DependencyRef {
330 name: name.clone(),
331 version_req: version_req.clone(),
332 });
333 }
334}
335
336#[cfg(test)]
337#[allow(clippy::needless_raw_string_hashes, clippy::uninlined_format_args)]
338mod tests {
339 use super::*;
340 use std::io::Write;
341 use tempfile::NamedTempFile;
342
343 #[test]
344 fn parses_basic_pnpm_lock() {
345 let yaml = r#"
346lockfileVersion: '6.0'
347
348importers:
349 .:
350 dependencies:
351 left-pad: 1.3.0
352
353packages:
354 /left-pad@1.3.0:
355 resolution:
356 integrity: sha512-test123
357 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
358 dev: false
359"#;
360
361 let mut file = NamedTempFile::new().unwrap();
362 file.write_all(yaml.as_bytes()).unwrap();
363
364 let parser = PnpmLockfileParser;
365 let entries = parser.parse(file.path()).unwrap();
366
367 assert!(entries.len() >= 2);
368
369 let workspace = entries
370 .iter()
371 .find(|e| e.is_workspace_member)
372 .expect("workspace root");
373 assert_eq!(workspace.dependencies.len(), 1);
374
375 let left_pad = entries
376 .iter()
377 .find(|e| e.name == "left-pad")
378 .expect("left-pad");
379 assert_eq!(left_pad.version, "1.3.0");
380 assert!(!left_pad.is_workspace_member);
381 }
382
383 #[test]
384 fn parses_scoped_packages() {
385 let yaml = r#"
386lockfileVersion: '6.0'
387
388importers:
389 .:
390 dependencies: {}
391
392packages:
393 /@babel/core@7.22.5:
394 resolution:
395 integrity: sha512-xyz
396 tarball: https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz
397 dev: false
398"#;
399
400 let mut file = NamedTempFile::new().unwrap();
401 file.write_all(yaml.as_bytes()).unwrap();
402
403 let parser = PnpmLockfileParser;
404 let entries = parser.parse(file.path()).unwrap();
405
406 let babel = entries
407 .iter()
408 .find(|e| e.name == "@babel/core")
409 .expect("@babel/core");
410 assert_eq!(babel.version, "7.22.5");
411 }
412
413 #[test]
414 fn supports_expected_filename() {
415 let parser = PnpmLockfileParser;
416 assert!(parser.supports_lockfile(Path::new("/tmp/pnpm-lock.yaml")));
417 assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
418 }
419
420 #[test]
421 fn accepts_various_lockfile_versions() {
422 for version in ["5.4", "6.0", "7.0", "9.0", "10.0"] {
424 let yaml = format!(
425 r#"
426lockfileVersion: '{}'
427
428importers:
429 .:
430 dependencies:
431 left-pad: 1.3.0
432
433packages:
434 /left-pad@1.3.0:
435 resolution:
436 integrity: sha512-test
437 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
438 dev: false
439"#,
440 version
441 );
442
443 let mut file = NamedTempFile::new().unwrap();
444 file.write_all(yaml.as_bytes()).unwrap();
445
446 let parser = PnpmLockfileParser;
447 let result = parser.parse(file.path());
448 assert!(
449 result.is_ok(),
450 "Version {} should be accepted, got error: {:?}",
451 version,
452 result.err()
453 );
454 }
455 }
456
457 #[test]
458 fn rejects_invalid_lockfile_version_format() {
459 let yaml = r#"
460lockfileVersion: 'invalid'
461
462importers:
463 .:
464 dependencies: {}
465
466packages: {}
467"#;
468
469 let mut file = NamedTempFile::new().unwrap();
470 file.write_all(yaml.as_bytes()).unwrap();
471
472 let parser = PnpmLockfileParser;
473 let err = parser.parse(file.path()).unwrap_err();
474
475 match err {
476 Error::LockfileParseFailed { message, .. } => {
477 assert!(message.contains("Invalid pnpm lockfileVersion format"));
478 assert!(message.contains("invalid"));
479 }
480 other => panic!("Expected LockfileParseFailed, got: {:?}", other),
481 }
482 }
483
484 #[test]
485 fn accepts_supported_versions() {
486 for version in ["6.0", "9.0"] {
487 let yaml = format!(
488 r#"
489lockfileVersion: '{}'
490
491importers:
492 .:
493 dependencies:
494 left-pad: 1.3.0
495
496packages:
497 /left-pad@1.3.0:
498 resolution:
499 integrity: sha512-test
500 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
501 dev: false
502"#,
503 version
504 );
505
506 let mut file = NamedTempFile::new().unwrap();
507 file.write_all(yaml.as_bytes()).unwrap();
508
509 let parser = PnpmLockfileParser;
510 let result = parser.parse(file.path());
511 assert!(
512 result.is_ok(),
513 "Version {} should be supported, got error: {:?}",
514 version,
515 result.err()
516 );
517 }
518 }
519
520 #[test]
521 fn accepts_missing_lockfile_version() {
522 let yaml = r#"
524importers:
525 .:
526 dependencies:
527 left-pad: 1.3.0
528
529packages:
530 /left-pad@1.3.0:
531 resolution:
532 integrity: sha512-test
533 tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
534 dev: false
535"#;
536
537 let mut file = NamedTempFile::new().unwrap();
538 file.write_all(yaml.as_bytes()).unwrap();
539
540 let parser = PnpmLockfileParser;
541 let result = parser.parse(file.path());
542 assert!(
543 result.is_ok(),
544 "Missing lockfileVersion should be accepted, got error: {:?}",
545 result.err()
546 );
547 }
548}