Skip to main content

affected_core/resolvers/
yarn.rs

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
9/// YarnResolver detects Yarn Berry (v2/v3/v4) workspaces via `.yarnrc.yml`.
10///
11/// Parsing is the same as npm (package.json workspaces) but test commands use `yarn`.
12/// Detection: `.yarnrc.yml` exists at root (takes priority over NpmResolver).
13pub 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        // Parse all workspace packages
57        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        // Build dependency edges
93        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            // Check both dependencies and devDependencies
112            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        // .yarnrc.yml to mark it as a Yarn Berry project
196        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        // Root package.json with workspaces
203        std::fs::write(
204            dir.join("package.json"),
205            r#"{"name": "root", "workspaces": ["packages/*"]}"#,
206        )
207        .unwrap();
208
209        // Package A depends on Package B
210        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        // Package B (no deps)
218        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        // pkg-a depends on pkg-b
265        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}