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;
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        // Parse all workspace packages
56        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        // Build dependency edges
92        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            // Check both dependencies and devDependencies
111            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        // .yarnrc.yml to mark it as a Yarn Berry project
195        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        // Root package.json with workspaces
202        std::fs::write(
203            dir.join("package.json"),
204            r#"{"name": "root", "workspaces": ["packages/*"]}"#,
205        )
206        .unwrap();
207
208        // Package A depends on Package B
209        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        // Package B (no deps)
217        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        // pkg-a depends on pkg-b
264        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}