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