Skip to main content

normalize_manifest/
npm.rs

1//! Parser for `package.json` files (npm/Node.js).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use serde_json::Value;
5
6/// Parser for `package.json` files.
7pub 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
53/// Extract the entry point path from `package.json` content.
54///
55/// Checks `exports`, `module`, and `main` fields in order and returns the
56/// raw relative path string (e.g., `"./dist/index.js"`).  Existence checking
57/// is left to the caller.
58///
59/// This replaces the ad-hoc `get_package_entry_point` / `find_package_entry`
60/// functions in `normalize-local-deps`.
61pub fn npm_entry_point(content: &str) -> Option<String> {
62    let json: Value = serde_json::from_str(content).ok()?;
63
64    // exports field
65    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    // module field (ESM entry point)
78    if let Some(s) = json.get("module").and_then(|v| v.as_str()) {
79        return Some(s.to_string());
80    }
81
82    // main field
83    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        // module takes precedence over main
159        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}