use super::{NodeLockEntry, NodeLockfile};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub fn parse_npm_lockfile(content: &str) -> Result<NodeLockfile, BoxError> {
let json: serde_json::Value = serde_json::from_str(content)?;
let version = json
.get("lockfileVersion")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
let entries = if version >= 2 {
parse_packages_section(&json)?
} else {
parse_dependencies_section(&json)?
};
Ok(NodeLockfile { entries })
}
fn parse_packages_section(json: &serde_json::Value) -> Result<Vec<NodeLockEntry>, BoxError> {
let packages = json
.get("packages")
.and_then(|p| p.as_object())
.ok_or("Missing packages section")?;
let mut entries = Vec::new();
for (path, info) in packages {
if path.is_empty() {
continue;
}
let name = path.strip_prefix("node_modules/").map_or_else(
|| path.as_str(),
|stripped| {
stripped
.rfind("node_modules/")
.map_or(stripped, |last_nm| &stripped[last_nm + 13..])
},
);
if let Some(version) = info.get("version").and_then(serde_json::Value::as_str) {
let dependencies = extract_dependencies(info);
entries.push(NodeLockEntry {
name: name.to_string(),
version: version.to_string(),
dependencies,
});
}
}
Ok(entries)
}
#[allow(clippy::unnecessary_wraps)]
fn parse_dependencies_section(json: &serde_json::Value) -> Result<Vec<NodeLockEntry>, BoxError> {
let Some(dependencies) = json
.get("dependencies")
.and_then(serde_json::Value::as_object)
else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
parse_deps_recursive(dependencies, &mut entries);
Ok(entries)
}
fn parse_deps_recursive(
deps: &serde_json::Map<String, serde_json::Value>,
entries: &mut Vec<NodeLockEntry>,
) {
for (name, info) in deps {
if let Some(version) = info.get("version").and_then(serde_json::Value::as_str) {
let dependencies = extract_dependencies(info);
entries.push(NodeLockEntry {
name: name.clone(),
version: version.to_string(),
dependencies,
});
if let Some(nested) = info
.get("dependencies")
.and_then(serde_json::Value::as_object)
{
parse_deps_recursive(nested, entries);
}
}
}
}
fn extract_dependencies(info: &serde_json::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_json::Value::as_object) {
deps.extend(section_deps.keys().cloned());
}
}
if let Some(requires) = info.get("requires").and_then(serde_json::Value::as_object) {
deps.extend(requires.keys().cloned());
}
deps
}
#[must_use]
pub fn parse_npm_lock_changes(changes: &[(char, String)]) -> Vec<String> {
let mut changed_packages = std::collections::BTreeSet::new();
let mut current_path: Option<String> = None;
let mut has_version_change = false;
let mut is_new_entry = false;
let mut in_packages_section = false;
for (op, line) in changes {
let line = line.trim();
if line.contains("\"packages\"") {
in_packages_section = true;
continue;
}
if !in_packages_section {
continue;
}
if line.contains("\"node_modules/")
&& line.contains("\": {")
&& let Some(start) = line.find("\"node_modules/")
&& let Some(end) = line[start + 14..].find('"')
{
let path = &line[start + 14..start + 14 + end];
current_path = Some(extract_package_name_from_path(path));
has_version_change = false;
is_new_entry = *op == '+';
} else if line.starts_with("\"version\"") && (*op == '-' || *op == '+') {
has_version_change = true;
} else if line == "}," || line == "}" {
if let Some(name) = ¤t_path
&& (has_version_change || is_new_entry)
{
changed_packages.insert(name.clone());
}
current_path = None;
has_version_change = false;
is_new_entry = false;
}
}
changed_packages.into_iter().collect()
}
fn extract_package_name_from_path(path: &str) -> String {
path.rfind("node_modules/").map_or_else(
|| path.to_string(),
|last_nm| path[last_nm + 13..].to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_npm_lockfile_v3() {
let content = r#"
{
"name": "my-project",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "my-project",
"version": "1.0.0"
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
},
"node_modules/express": {
"version": "4.18.2",
"dependencies": {
"accepts": "~1.3.8"
}
}
}
}
"#;
let lockfile = parse_npm_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 express = lockfile.find_by_name("express").unwrap();
assert_eq!(express.version, "4.18.2");
assert!(express.dependencies.contains(&"accepts".to_string()));
}
#[test]
fn test_parse_npm_lock_changes() {
let changes = vec![
(' ', " \"packages\": {".to_string()),
(' ', " \"node_modules/lodash\": {".to_string()),
('-', " \"version\": \"4.17.20\"".to_string()),
('+', " \"version\": \"4.17.21\"".to_string()),
(' ', " },".to_string()),
];
let result = parse_npm_lock_changes(&changes);
assert_eq!(result, vec!["lodash"]);
}
#[test]
fn test_extract_package_name_scoped() {
assert_eq!(extract_package_name_from_path("@babel/core"), "@babel/core");
assert_eq!(
extract_package_name_from_path("node_modules/@types/node"),
"@types/node"
);
}
}