1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct NpmResolver;
10impl super::sealed::Sealed for NpmResolver {}
11
12#[derive(Deserialize)]
13struct RootPackageJson {
14 workspaces: Option<WorkspacesField>,
15}
16
17#[derive(Deserialize)]
18#[serde(untagged)]
19enum WorkspacesField {
20 Array(Vec<String>),
21 Object { packages: Vec<String> },
22}
23
24#[derive(Deserialize)]
25struct PackageJson {
26 name: Option<String>,
27 version: Option<String>,
28 dependencies: Option<HashMap<String, String>>,
29 #[serde(rename = "devDependencies")]
30 dev_dependencies: Option<HashMap<String, String>>,
31}
32
33impl Resolver for NpmResolver {
34 fn ecosystem(&self) -> Ecosystem {
35 Ecosystem::Npm
36 }
37
38 fn detect(&self, root: &Path) -> bool {
39 if root.join("pnpm-workspace.yaml").exists() {
40 return true;
41 }
42 let pkg = root.join("package.json");
43 if !pkg.exists() {
44 return false;
45 }
46 std::fs::read_to_string(&pkg)
47 .map(|c| c.contains("\"workspaces\""))
48 .unwrap_or(false)
49 }
50
51 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
52 let workspace_globs = self.find_workspace_globs(root)?;
53 let pkg_dirs = self.expand_globs(root, &workspace_globs)?;
54
55 let mut packages = HashMap::new();
57 let mut name_to_id = HashMap::new();
58
59 for dir in &pkg_dirs {
60 let pkg_json_path = dir.join("package.json");
61 if !pkg_json_path.exists() {
62 continue;
63 }
64
65 let content = std::fs::read_to_string(&pkg_json_path)
66 .with_context(|| format!("Failed to read {}", pkg_json_path.display()))?;
67 let pkg: PackageJson = serde_json::from_str(&content)
68 .with_context(|| format!("Failed to parse {}", pkg_json_path.display()))?;
69
70 let name = match &pkg.name {
71 Some(n) => n.clone(),
72 None => continue,
73 };
74
75 let pkg_id = PackageId(name.clone());
76 name_to_id.insert(name.clone(), pkg_id.clone());
77 packages.insert(
78 pkg_id.clone(),
79 Package {
80 id: pkg_id,
81 name: name.clone(),
82 version: pkg.version.clone(),
83 path: dir.clone(),
84 manifest_path: pkg_json_path,
85 },
86 );
87 }
88
89 let mut edges = Vec::new();
91 let workspace_names: std::collections::HashSet<&str> =
92 name_to_id.keys().map(|s| s.as_str()).collect();
93
94 for dir in &pkg_dirs {
95 let pkg_json_path = dir.join("package.json");
96 if !pkg_json_path.exists() {
97 continue;
98 }
99
100 let content = std::fs::read_to_string(&pkg_json_path)?;
101 let pkg: PackageJson = serde_json::from_str(&content)?;
102
103 let from_name = match &pkg.name {
104 Some(n) => n.clone(),
105 None => continue,
106 };
107
108 let all_deps: Vec<&str> = pkg
110 .dependencies
111 .iter()
112 .flat_map(|d| d.keys())
113 .chain(pkg.dev_dependencies.iter().flat_map(|d| d.keys()))
114 .map(|s| s.as_str())
115 .collect();
116
117 for dep_name in all_deps {
118 if workspace_names.contains(dep_name) {
119 edges.push((
120 PackageId(from_name.clone()),
121 PackageId(dep_name.to_string()),
122 ));
123 }
124 }
125 }
126
127 Ok(ProjectGraph {
128 packages,
129 edges,
130 root: root.to_path_buf(),
131 })
132 }
133
134 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
135 file_to_package(graph, file)
136 }
137
138 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
139 vec![
141 "npm".into(),
142 "test".into(),
143 "--workspace".into(),
144 package_id.0.clone(),
145 ]
146 }
147}
148
149impl NpmResolver {
150 fn find_workspace_globs(&self, root: &Path) -> Result<Vec<String>> {
151 let pnpm_path = root.join("pnpm-workspace.yaml");
153 if pnpm_path.exists() {
154 let content = std::fs::read_to_string(&pnpm_path)?;
155 let mut globs = Vec::new();
161 let mut in_packages = false;
162 for line in content.lines() {
163 let trimmed = line.trim();
164 if trimmed == "packages:" {
165 in_packages = true;
166 continue;
167 }
168 if in_packages {
169 if trimmed.starts_with("- ") {
170 let glob = trimmed
171 .trim_start_matches("- ")
172 .trim_matches('\'')
173 .trim_matches('"')
174 .to_string();
175 globs.push(glob);
176 } else if !trimmed.is_empty() {
177 break;
178 }
179 }
180 }
181 if !globs.is_empty() {
182 return Ok(globs);
183 }
184 }
185
186 let pkg_path = root.join("package.json");
188 let content = std::fs::read_to_string(&pkg_path).context("No package.json found")?;
189 let root_pkg: RootPackageJson =
190 serde_json::from_str(&content).context("Failed to parse root package.json")?;
191
192 match root_pkg.workspaces {
193 Some(WorkspacesField::Array(globs)) => Ok(globs),
194 Some(WorkspacesField::Object { packages }) => Ok(packages),
195 None => anyhow::bail!("No workspaces field found in package.json"),
196 }
197 }
198
199 pub fn expand_globs(&self, root: &Path, globs: &[String]) -> Result<Vec<PathBuf>> {
200 let mut dirs = Vec::new();
201
202 for pattern in globs {
203 let full_pattern = root.join(pattern).join("package.json");
204 let pattern_str = full_pattern.to_str().unwrap_or("");
205
206 match glob::glob(pattern_str) {
207 Ok(paths) => {
208 for entry in paths.filter_map(|p| p.ok()) {
209 if let Some(parent) = entry.parent() {
210 dirs.push(parent.to_path_buf());
211 }
212 }
213 }
214 Err(_) => continue,
215 }
216 }
217
218 Ok(dirs)
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn create_npm_workspace(dir: &std::path::Path) {
227 std::fs::write(
229 dir.join("package.json"),
230 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
231 )
232 .unwrap();
233
234 std::fs::create_dir_all(dir.join("packages/pkg-a")).unwrap();
236 std::fs::write(
237 dir.join("packages/pkg-a/package.json"),
238 r#"{"name": "@scope/pkg-a", "version": "1.0.0", "dependencies": {"@scope/pkg-b": "workspace:*"}}"#,
239 )
240 .unwrap();
241
242 std::fs::create_dir_all(dir.join("packages/pkg-b")).unwrap();
244 std::fs::write(
245 dir.join("packages/pkg-b/package.json"),
246 r#"{"name": "@scope/pkg-b", "version": "1.0.0"}"#,
247 )
248 .unwrap();
249 }
250
251 #[test]
252 fn test_detect_npm_workspace() {
253 let dir = tempfile::tempdir().unwrap();
254 create_npm_workspace(dir.path());
255 assert!(NpmResolver.detect(dir.path()));
256 }
257
258 #[test]
259 fn test_detect_pnpm_workspace() {
260 let dir = tempfile::tempdir().unwrap();
261 std::fs::write(
262 dir.path().join("pnpm-workspace.yaml"),
263 "packages:\n - 'packages/*'\n",
264 )
265 .unwrap();
266 assert!(NpmResolver.detect(dir.path()));
267 }
268
269 #[test]
270 fn test_detect_no_workspaces() {
271 let dir = tempfile::tempdir().unwrap();
272 std::fs::write(dir.path().join("package.json"), r#"{"name": "solo"}"#).unwrap();
273 assert!(!NpmResolver.detect(dir.path()));
274 }
275
276 #[test]
277 fn test_detect_empty_dir() {
278 let dir = tempfile::tempdir().unwrap();
279 assert!(!NpmResolver.detect(dir.path()));
280 }
281
282 #[test]
283 fn test_resolve_npm_workspace() {
284 let dir = tempfile::tempdir().unwrap();
285 create_npm_workspace(dir.path());
286
287 let graph = NpmResolver.resolve(dir.path()).unwrap();
288 assert_eq!(graph.packages.len(), 2);
289 assert!(graph
290 .packages
291 .contains_key(&PackageId("@scope/pkg-a".into())));
292 assert!(graph
293 .packages
294 .contains_key(&PackageId("@scope/pkg-b".into())));
295
296 assert!(graph.edges.contains(&(
298 PackageId("@scope/pkg-a".into()),
299 PackageId("@scope/pkg-b".into()),
300 )));
301 }
302
303 #[test]
304 fn test_resolve_pnpm_workspace() {
305 let dir = tempfile::tempdir().unwrap();
306
307 std::fs::write(
308 dir.path().join("pnpm-workspace.yaml"),
309 "packages:\n - 'packages/*'\n",
310 )
311 .unwrap();
312
313 std::fs::create_dir_all(dir.path().join("packages/foo")).unwrap();
314 std::fs::write(
315 dir.path().join("packages/foo/package.json"),
316 r#"{"name": "foo", "version": "1.0.0"}"#,
317 )
318 .unwrap();
319
320 std::fs::create_dir_all(dir.path().join("packages/bar")).unwrap();
321 std::fs::write(
322 dir.path().join("packages/bar/package.json"),
323 r#"{"name": "bar", "version": "1.0.0", "dependencies": {"foo": "workspace:*"}}"#,
324 )
325 .unwrap();
326
327 let graph = NpmResolver.resolve(dir.path()).unwrap();
328 assert_eq!(graph.packages.len(), 2);
329 assert!(graph
330 .edges
331 .contains(&(PackageId("bar".into()), PackageId("foo".into()),)));
332 }
333
334 #[test]
335 fn test_expand_globs() {
336 let dir = tempfile::tempdir().unwrap();
337 std::fs::create_dir_all(dir.path().join("packages/a")).unwrap();
338 std::fs::write(dir.path().join("packages/a/package.json"), "{}").unwrap();
339 std::fs::create_dir_all(dir.path().join("packages/b")).unwrap();
340 std::fs::write(dir.path().join("packages/b/package.json"), "{}").unwrap();
341
342 let globs = vec!["packages/*".to_string()];
343 let dirs = NpmResolver.expand_globs(dir.path(), &globs).unwrap();
344 assert_eq!(dirs.len(), 2);
345 }
346
347 #[test]
348 fn test_test_command() {
349 let cmd = NpmResolver.test_command(&PackageId("my-pkg".into()));
350 assert_eq!(cmd, vec!["npm", "test", "--workspace", "my-pkg"]);
351 }
352
353 #[test]
354 fn test_dev_dependencies_create_edges() {
355 let dir = tempfile::tempdir().unwrap();
356 std::fs::write(
357 dir.path().join("package.json"),
358 r#"{"name": "root", "workspaces": ["packages/*"]}"#,
359 )
360 .unwrap();
361
362 std::fs::create_dir_all(dir.path().join("packages/lib")).unwrap();
363 std::fs::write(
364 dir.path().join("packages/lib/package.json"),
365 r#"{"name": "lib", "version": "1.0.0"}"#,
366 )
367 .unwrap();
368
369 std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
370 std::fs::write(
371 dir.path().join("packages/app/package.json"),
372 r#"{"name": "app", "version": "1.0.0", "devDependencies": {"lib": "workspace:*"}}"#,
373 )
374 .unwrap();
375
376 let graph = NpmResolver.resolve(dir.path()).unwrap();
377 assert!(graph
378 .edges
379 .contains(&(PackageId("app".into()), PackageId("lib".into()),)));
380 }
381}