Skip to main content

ncu/parsers/
package_json.rs

1use anyhow::{Context, Result};
2use check_updates_core::{Dependency, VersionSpec};
3use std::fs;
4use std::path::Path;
5
6pub struct PackageJsonParser;
7
8impl PackageJsonParser {
9    pub fn new() -> Self {
10        Self
11    }
12
13    /// Parse dependencies from a package.json file
14    pub fn parse(&self, path: &Path) -> Result<Vec<Dependency>> {
15        let content = fs::read_to_string(path)
16            .with_context(|| format!("Failed to read {}", path.display()))?;
17
18        let parsed: serde_json::Value = serde_json::from_str(&content)
19            .with_context(|| format!("Failed to parse JSON in {}", path.display()))?;
20
21        let mut deps = Vec::new();
22
23        // Parse dependencies
24        if let Some(dependencies) = parsed.get("dependencies").and_then(|v| v.as_object()) {
25            deps.extend(self.parse_deps(dependencies, path, &content));
26        }
27
28        // Parse devDependencies
29        if let Some(dev_deps) = parsed.get("devDependencies").and_then(|v| v.as_object()) {
30            deps.extend(self.parse_deps(dev_deps, path, &content));
31        }
32
33        // Parse peerDependencies
34        if let Some(peer_deps) = parsed.get("peerDependencies").and_then(|v| v.as_object()) {
35            deps.extend(self.parse_deps(peer_deps, path, &content));
36        }
37
38        // Parse optionalDependencies
39        if let Some(opt_deps) = parsed.get("optionalDependencies").and_then(|v| v.as_object()) {
40            deps.extend(self.parse_deps(opt_deps, path, &content));
41        }
42
43        Ok(deps)
44    }
45
46    fn parse_deps(
47        &self,
48        deps: &serde_json::Map<String, serde_json::Value>,
49        source_file: &Path,
50        content: &str,
51    ) -> Vec<Dependency> {
52        let mut result = Vec::new();
53
54        for (name, version_value) in deps {
55            if let Some(version_str) = version_value.as_str() {
56                // Skip non-registry deps (git, file, link, workspace)
57                if version_str.starts_with("git")
58                    || version_str.starts_with("file:")
59                    || version_str.starts_with("link:")
60                    || version_str.starts_with("workspace:")
61                    || version_str.contains("github:")
62                    || version_str.contains("://")
63                {
64                    continue;
65                }
66
67                if let Ok(version_spec) = Self::parse_npm_version(version_str) {
68                    let line_number = Self::find_line_number(content, name);
69                    let original_line = content
70                        .lines()
71                        .nth(line_number.saturating_sub(1))
72                        .unwrap_or("")
73                        .to_string();
74
75                    result.push(Dependency {
76                        name: name.clone(),
77                        version_spec,
78                        source_file: source_file.to_path_buf(),
79                        line_number,
80                        original_line,
81                    });
82                }
83            }
84        }
85
86        result
87    }
88
89    /// Parse npm version spec into VersionSpec
90    fn parse_npm_version(s: &str) -> Result<VersionSpec> {
91        let s = s.trim();
92
93        // npm uses same caret/tilde semantics
94        // ^1.2.3, ~1.2.3, >=1.0.0, 1.2.3, etc.
95        VersionSpec::parse(s).map_err(|e| anyhow::anyhow!("{e}"))
96    }
97
98    fn find_line_number(content: &str, package_name: &str) -> usize {
99        for (i, line) in content.lines().enumerate() {
100            if line.contains(&format!("\"{package_name}\"")) {
101                return i + 1;
102            }
103        }
104        1
105    }
106}
107
108impl Default for PackageJsonParser {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::io::Write;
118    use tempfile::NamedTempFile;
119
120    #[test]
121    fn test_parse_dependencies() -> Result<()> {
122        let mut file = NamedTempFile::new()?;
123        writeln!(
124            file,
125            r#"{{
126  "name": "test",
127  "dependencies": {{
128    "express": "^4.18.0",
129    "lodash": "~4.17.0"
130  }},
131  "devDependencies": {{
132    "typescript": "^5.0.0"
133  }}
134}}"#
135        )?;
136
137        let parser = PackageJsonParser::new();
138        let deps = parser.parse(&file.path().to_path_buf())?;
139
140        assert_eq!(deps.len(), 3);
141
142        let express = deps.iter().find(|d| d.name == "express").unwrap();
143        assert_eq!(express.version_spec.version_string().unwrap(), "4.18.0");
144
145        Ok(())
146    }
147
148    #[test]
149    fn test_skip_git_deps() -> Result<()> {
150        let mut file = NamedTempFile::new()?;
151        writeln!(
152            file,
153            r#"{{
154  "dependencies": {{
155    "express": "^4.18.0",
156    "my-pkg": "git+https://github.com/user/repo.git",
157    "local": "file:../local"
158  }}
159}}"#
160        )?;
161
162        let parser = PackageJsonParser::new();
163        let deps = parser.parse(&file.path().to_path_buf())?;
164
165        assert_eq!(deps.len(), 1);
166        assert_eq!(deps[0].name, "express");
167
168        Ok(())
169    }
170}