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