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