use super::{NodeLockEntry, NodeLockfile};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub fn parse_bun_lockfile(content: &str) -> Result<NodeLockfile, BoxError> {
let clean_content = strip_jsonc_comments(content);
let json: serde_json::Value = serde_json::from_str(&clean_content)?;
let mut entries = Vec::new();
if let Some(packages) = json.get("packages").and_then(serde_json::Value::as_object) {
for (path, info) in packages {
if path.is_empty() || !path.contains('@') {
continue;
}
if let Some((name, version)) = parse_bun_package_entry(path, info) {
let dependencies = extract_bun_dependencies(info);
entries.push(NodeLockEntry {
name,
version,
dependencies,
});
}
}
}
Ok(NodeLockfile { entries })
}
fn strip_jsonc_comments(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut chars = content.chars().peekable();
let mut in_string = false;
let mut escape_next = false;
while let Some(c) = chars.next() {
if escape_next {
result.push(c);
escape_next = false;
continue;
}
match c {
'\\' if in_string => {
result.push(c);
escape_next = true;
}
'"' => {
result.push(c);
in_string = !in_string;
}
'/' if !in_string => {
if let Some(&next) = chars.peek() {
match next {
'/' => {
chars.next(); for c in chars.by_ref() {
if c == '\n' {
result.push('\n');
break;
}
}
}
'*' => {
chars.next(); while let Some(c) = chars.next() {
if c == '*' && chars.peek() == Some(&'/') {
chars.next(); result.push(' '); break;
}
}
}
_ => result.push(c),
}
} else {
result.push(c);
}
}
_ => result.push(c),
}
}
result
}
fn parse_bun_package_entry(path: &str, info: &serde_json::Value) -> Option<(String, String)> {
if let Some(version) = info.get("version").and_then(serde_json::Value::as_str) {
let name = extract_package_name(path);
return Some((name, version.to_string()));
}
if let Some(at_pos) = path.rfind('@')
&& at_pos > 0
{
let name = &path[..at_pos];
let version = &path[at_pos + 1..];
return Some((name.to_string(), version.to_string()));
}
None
}
fn extract_package_name(path: &str) -> String {
if let Some(stripped) = path.strip_prefix("node_modules/") {
if let Some(last_nm) = stripped.rfind("node_modules/") {
return stripped[last_nm + 13..].to_string();
}
return stripped.to_string();
}
if let Some(at_pos) = path.rfind('@')
&& at_pos > 0
&& !path.starts_with('@')
{
return path[..at_pos].to_string();
}
if path.starts_with('@')
&& let Some(slash) = path.find('/')
&& let Some(version_at) = path[slash..].find('@')
{
return path[..slash + version_at].to_string();
}
path.to_string()
}
fn extract_bun_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());
}
}
deps
}
#[must_use]
pub fn parse_bun_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('@') || line.contains("node_modules/"))
&& line.ends_with(": {")
&& line.starts_with('"')
{
if let Some(end) = line.find("\": {") {
let path = &line[1..end];
let name = extract_package_name(path);
if let Some(prev) = ¤t_path
&& (has_version_change || is_new_entry)
{
changed_packages.insert(prev.clone());
}
current_path = Some(name);
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) = current_path
&& (has_version_change || is_new_entry)
{
changed_packages.insert(name);
}
changed_packages.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_jsonc_comments() {
let input = r#"
{
// This is a line comment
"name": "test",
/* This is a
block comment */
"version": "1.0.0"
}
"#;
let result = strip_jsonc_comments(input);
assert!(!result.contains("//"));
assert!(!result.contains("/*"));
assert!(result.contains("\"name\""));
assert!(result.contains("\"version\""));
}
#[test]
fn test_extract_package_name() {
assert_eq!(extract_package_name("lodash"), "lodash");
assert_eq!(extract_package_name("lodash@4.17.21"), "lodash");
assert_eq!(extract_package_name("node_modules/lodash"), "lodash");
assert_eq!(
extract_package_name("node_modules/@babel/core"),
"@babel/core"
);
}
#[test]
fn test_parse_bun_lockfile() {
let content = r#"
{
"lockfileVersion": 0,
"packages": {
"lodash@4.17.21": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
}
}
}
"#;
let lockfile = parse_bun_lockfile(content).unwrap();
assert_eq!(lockfile.entries.len(), 1);
let lodash = lockfile.find_by_name("lodash").unwrap();
assert_eq!(lodash.version, "4.17.21");
}
#[test]
fn test_parse_bun_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_bun_lock_changes(&changes);
assert!(result.contains(&"lodash".to_string()));
}
}