use super::{NodeLockEntry, NodeLockfile};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub fn parse_pnpm_lockfile(content: &str) -> Result<NodeLockfile, BoxError> {
let yaml: serde_yaml::Value = serde_yaml::from_str(content)?;
let mut entries = Vec::new();
if let Some(packages) = yaml.get("packages").and_then(serde_yaml::Value::as_mapping) {
for (key, info) in packages {
if let Some(key_str) = key.as_str()
&& let Some((name, version)) = parse_pnpm_package_key(key_str)
{
let dependencies = extract_pnpm_dependencies(info);
entries.push(NodeLockEntry {
name,
version,
dependencies,
});
}
}
}
if let Some(snapshots) = yaml
.get("snapshots")
.and_then(serde_yaml::Value::as_mapping)
{
for (key, info) in snapshots {
if let Some(key_str) = key.as_str()
&& let Some((name, version)) = parse_pnpm_package_key(key_str)
&& !entries.iter().any(|e| e.name == name && e.version == version)
{
let dependencies = extract_pnpm_dependencies(info);
entries.push(NodeLockEntry {
name,
version,
dependencies,
});
}
}
}
Ok(NodeLockfile { entries })
}
fn parse_pnpm_package_key(key: &str) -> Option<(String, String)> {
let key = key.strip_prefix('/')?;
let key = key.find('(').map_or(key, |paren_pos| &key[..paren_pos]);
if key.starts_with('@') {
let slash_pos = key.find('/')?;
let at_pos = key[slash_pos + 1..].find('@')? + slash_pos + 1;
let name = &key[..at_pos];
let version = &key[at_pos + 1..];
Some((name.to_string(), version.to_string()))
} else {
let at_pos = key.find('@')?;
let name = &key[..at_pos];
let version = &key[at_pos + 1..];
Some((name.to_string(), version.to_string()))
}
}
fn extract_pnpm_dependencies(info: &serde_yaml::Value) -> Vec<String> {
let mut deps = Vec::new();
for section in ["dependencies", "peerDependencies", "optionalDependencies"] {
if let Some(section_deps) = info.get(section).and_then(serde_yaml::Value::as_mapping) {
for (name, _) in section_deps {
if let Some(name_str) = name.as_str() {
deps.push(name_str.to_string());
}
}
}
}
deps
}
#[must_use]
pub fn parse_pnpm_lock_changes(changes: &[(char, String)]) -> Vec<String> {
let mut changed_packages = std::collections::BTreeSet::new();
let mut in_packages_section = false;
let mut current_package: Option<String> = None;
let mut has_version_change = false;
for (op, line) in changes {
let line_trimmed = line.trim();
if line_trimmed == "packages:" || line_trimmed == "snapshots:" {
in_packages_section = true;
continue;
}
if in_packages_section
&& !line.starts_with(' ')
&& !line.starts_with('\t')
&& line_trimmed.ends_with(':')
{
in_packages_section = false;
continue;
}
if !in_packages_section {
continue;
}
let stripped = line_trimmed.trim_start_matches(['\'', '"'].as_ref());
if stripped.starts_with('/') && stripped.ends_with(':') {
let key = stripped
.trim_end_matches(':')
.trim_end_matches(['\'', '"'].as_ref());
if let Some((name, _)) = parse_pnpm_package_key(key) {
if let Some(prev_name) = ¤t_package
&& (has_version_change || *op == '+')
{
changed_packages.insert(prev_name.clone());
}
current_package = Some(name);
has_version_change = *op == '+' || *op == '-';
}
} else if line_trimmed.starts_with("version:") && (*op == '+' || *op == '-') {
has_version_change = true;
}
}
if let Some(name) = current_package
&& has_version_change
{
changed_packages.insert(name);
}
changed_packages.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pnpm_package_key_simple() {
let result = parse_pnpm_package_key("/lodash@4.17.21");
assert_eq!(result, Some(("lodash".to_string(), "4.17.21".to_string())));
}
#[test]
fn test_parse_pnpm_package_key_scoped() {
let result = parse_pnpm_package_key("/@babel/core@7.23.0");
assert_eq!(
result,
Some(("@babel/core".to_string(), "7.23.0".to_string()))
);
}
#[test]
fn test_parse_pnpm_package_key_with_peers() {
let result = parse_pnpm_package_key("/react-dom@18.2.0(react@18.2.0)");
assert_eq!(
result,
Some(("react-dom".to_string(), "18.2.0".to_string()))
);
}
#[test]
fn test_parse_pnpm_lockfile() {
let content = r"
lockfileVersion: '9.0'
packages:
/lodash@4.17.21:
resolution: {integrity: sha512-abc}
dev: true
/@babel/core@7.23.0:
resolution: {integrity: sha512-def}
dependencies:
'@babel/helper-compilation-targets': 7.22.15
";
let lockfile = parse_pnpm_lockfile(content).unwrap();
assert_eq!(lockfile.entries.len(), 2);
let lodash = lockfile.find_by_name("lodash").unwrap();
assert_eq!(lodash.version, "4.17.21");
let babel = lockfile.find_by_name("@babel/core").unwrap();
assert_eq!(babel.version, "7.23.0");
assert!(
babel
.dependencies
.contains(&"@babel/helper-compilation-targets".to_string())
);
}
#[test]
fn test_parse_pnpm_lock_changes() {
let changes = vec![
(' ', "packages:".to_string()),
(' ', " /lodash@4.17.20:".to_string()),
('-', " version: 4.17.20".to_string()),
('+', " /lodash@4.17.21:".to_string()),
('+', " version: 4.17.21".to_string()),
];
let result = parse_pnpm_lock_changes(&changes);
assert!(result.contains(&"lodash".to_string()));
}
}