normalize_manifest/
npm.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use serde_json::Value;
5
6pub struct NpmParser;
8
9impl ManifestParser for NpmParser {
10 fn filename(&self) -> &'static str {
11 "package.json"
12 }
13
14 fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
15 let json: Value =
16 serde_json::from_str(content).map_err(|e| ManifestError(e.to_string()))?;
17
18 let name = json
19 .get("name")
20 .and_then(|v| v.as_str())
21 .map(|s| s.to_string());
22 let version = json
23 .get("version")
24 .and_then(|v| v.as_str())
25 .map(|s| s.to_string());
26
27 let mut deps = Vec::new();
28 extract_npm_deps(&json, "dependencies", DepKind::Normal, &mut deps);
29 extract_npm_deps(&json, "devDependencies", DepKind::Dev, &mut deps);
30 extract_npm_deps(&json, "peerDependencies", DepKind::Optional, &mut deps);
31
32 Ok(ParsedManifest {
33 ecosystem: "npm",
34 name,
35 version,
36 dependencies: deps,
37 })
38 }
39}
40
41fn extract_npm_deps(json: &Value, field: &str, kind: DepKind, out: &mut Vec<DeclaredDep>) {
42 if let Some(obj) = json.get(field).and_then(|v| v.as_object()) {
43 for (name, ver) in obj {
44 out.push(DeclaredDep {
45 name: name.clone(),
46 version_req: ver.as_str().map(|s| s.to_string()),
47 kind,
48 });
49 }
50 }
51}
52
53pub fn npm_entry_point(content: &str) -> Option<String> {
62 let json: Value = serde_json::from_str(content).ok()?;
63
64 if let Some(exports) = json.get("exports") {
66 if let Some(s) = exports.as_str() {
67 return Some(s.to_string());
68 }
69 if let Some(obj) = exports.as_object()
70 && let Some(dot) = obj.get(".")
71 && let Some(s) = extract_export_entry(dot)
72 {
73 return Some(s.to_string());
74 }
75 }
76
77 if let Some(s) = json.get("module").and_then(|v| v.as_str()) {
79 return Some(s.to_string());
80 }
81
82 if let Some(s) = json.get("main").and_then(|v| v.as_str()) {
84 return Some(s.to_string());
85 }
86
87 None
88}
89
90fn extract_export_entry(value: &Value) -> Option<&str> {
91 if let Some(s) = value.as_str() {
92 return Some(s);
93 }
94 if let Some(obj) = value.as_object() {
95 for key in &["import", "require", "default"] {
96 if let Some(entry) = obj.get(*key) {
97 if let Some(s) = entry.as_str() {
98 return Some(s);
99 }
100 if let Some(s) = extract_export_entry(entry) {
101 return Some(s);
102 }
103 }
104 }
105 }
106 None
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::ManifestParser;
113
114 #[test]
115 fn test_parse_package_json() {
116 let content = r#"{
117 "name": "my-app",
118 "version": "1.2.3",
119 "dependencies": {
120 "express": "^4.18.0",
121 "lodash": "^4.17.21"
122 },
123 "devDependencies": {
124 "jest": "^29.0.0"
125 }
126}"#;
127 let m = NpmParser.parse(content).unwrap();
128 assert_eq!(m.ecosystem, "npm");
129 assert_eq!(m.name.as_deref(), Some("my-app"));
130 assert_eq!(m.version.as_deref(), Some("1.2.3"));
131 assert_eq!(m.dependencies.len(), 3);
132
133 let normal: Vec<_> = m
134 .dependencies
135 .iter()
136 .filter(|d| d.kind == DepKind::Normal)
137 .collect();
138 assert_eq!(normal.len(), 2);
139
140 let dev: Vec<_> = m
141 .dependencies
142 .iter()
143 .filter(|d| d.kind == DepKind::Dev)
144 .collect();
145 assert_eq!(dev.len(), 1);
146 assert_eq!(dev[0].name, "jest");
147 }
148
149 #[test]
150 fn test_npm_entry_point_main() {
151 let content = r#"{"main": "dist/index.js"}"#;
152 assert_eq!(npm_entry_point(content).as_deref(), Some("dist/index.js"));
153 }
154
155 #[test]
156 fn test_npm_entry_point_module() {
157 let content = r#"{"module": "esm/index.js", "main": "cjs/index.js"}"#;
158 assert_eq!(npm_entry_point(content).as_deref(), Some("esm/index.js"));
160 }
161
162 #[test]
163 fn test_npm_entry_point_exports_string() {
164 let content = r#"{"exports": "./dist/index.js"}"#;
165 assert_eq!(npm_entry_point(content).as_deref(), Some("./dist/index.js"));
166 }
167
168 #[test]
169 fn test_npm_entry_point_exports_dot() {
170 let content =
171 r#"{"exports": {".": {"import": "./esm/index.js", "require": "./cjs/index.js"}}}"#;
172 assert_eq!(npm_entry_point(content).as_deref(), Some("./esm/index.js"));
173 }
174
175 #[test]
176 fn test_npm_entry_point_missing() {
177 let content = r#"{"name": "no-entry"}"#;
178 assert_eq!(npm_entry_point(content), None);
179 }
180}