Skip to main content

affected_core/resolvers/
dart.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use crate::resolvers::{file_to_package, Resolver};
6use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
7
8pub struct DartResolver;
9impl super::sealed::Sealed for DartResolver {}
10
11/// Which monorepo mode we detected.
12enum DartMode {
13    /// Dart 3.6+ native workspaces — root `pubspec.yaml` has a `workspace:` section.
14    Workspace,
15    /// Melos monorepo tool — `melos.yaml` at root.
16    Melos,
17    /// Generic — 2+ `pubspec.yaml` files in immediate subdirectories.
18    Generic,
19}
20
21/// Extract the `name:` field from a `pubspec.yaml` file's content.
22fn parse_pubspec_name(content: &str) -> Option<String> {
23    for line in content.lines() {
24        // Only match top-level `name:` (no leading whitespace)
25        if line.starts_with("name:") {
26            let value = line.trim_start_matches("name:").trim();
27            // Strip optional surrounding quotes
28            let value = value.trim_matches('\'').trim_matches('"');
29            if !value.is_empty() {
30                return Some(value.to_string());
31            }
32        }
33    }
34    None
35}
36
37/// Extract dependency names from both `dependencies:` and `dev_dependencies:` sections
38/// of a `pubspec.yaml` file's content.
39///
40/// We parse line-by-line, tracking when we enter a dep section (indentation level 0
41/// for the section header, indented entries underneath). Each top-level key inside
42/// the section is treated as a dependency name.
43fn parse_pubspec_deps(content: &str) -> Vec<String> {
44    let mut deps = Vec::new();
45    let mut in_dep_section = false;
46
47    for line in content.lines() {
48        // Skip blank lines — they don't end sections in YAML
49        if line.trim().is_empty() {
50            continue;
51        }
52
53        // A top-level key (no leading whitespace)
54        let is_top_level = !line.starts_with(' ') && !line.starts_with('\t');
55
56        if is_top_level {
57            let trimmed = line.trim();
58            if trimmed == "dependencies:" || trimmed == "dev_dependencies:" {
59                in_dep_section = true;
60                continue;
61            } else {
62                // Any other top-level key ends the dep section
63                in_dep_section = false;
64                continue;
65            }
66        }
67
68        if in_dep_section {
69            let stripped = line.trim();
70
71            // Determine indent: count leading spaces
72            let indent = line.len() - line.trim_start().len();
73
74            // We treat indent == 2..=4 as a direct child entry (typical pubspec uses 2).
75            // Deeper indentation belongs to the previous entry's value block.
76            if (2..=4).contains(&indent) && stripped.contains(':') {
77                let dep_name = stripped.split(':').next().unwrap_or("").trim();
78                if !dep_name.is_empty() && !dep_name.starts_with('#') {
79                    deps.push(dep_name.to_string());
80                }
81            }
82        }
83    }
84
85    deps
86}
87
88impl Resolver for DartResolver {
89    fn ecosystem(&self) -> Ecosystem {
90        Ecosystem::Dart
91    }
92
93    fn detect(&self, root: &Path) -> bool {
94        // Mode 1: Dart 3.6+ native workspace
95        let root_pubspec = root.join("pubspec.yaml");
96        if root_pubspec.exists() {
97            if let Ok(content) = std::fs::read_to_string(&root_pubspec) {
98                for line in content.lines() {
99                    if !line.starts_with(' ') && !line.starts_with('\t') {
100                        let trimmed = line.trim();
101                        if trimmed == "workspace:" || trimmed.starts_with("workspace:") {
102                            return true;
103                        }
104                    }
105                }
106            }
107        }
108
109        // Mode 2: Melos
110        if root.join("melos.yaml").exists() {
111            return true;
112        }
113
114        // Mode 3: Generic — 2+ pubspec.yaml in immediate subdirs
115        if let Ok(entries) = std::fs::read_dir(root) {
116            let count = entries
117                .filter_map(|e| e.ok())
118                .filter(|e| e.path().is_dir())
119                .filter(|e| e.path().join("pubspec.yaml").exists())
120                .count();
121            if count >= 2 {
122                return true;
123            }
124        }
125
126        false
127    }
128
129    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
130        let mode = self.detect_mode(root)?;
131        let pkg_dirs = match mode {
132            DartMode::Workspace => self.resolve_workspace(root)?,
133            DartMode::Melos => self.resolve_melos(root)?,
134            DartMode::Generic => self.resolve_generic(root)?,
135        };
136
137        // Parse all workspace packages
138        let mut packages = HashMap::new();
139        let mut name_to_id = HashMap::new();
140
141        for dir in &pkg_dirs {
142            let pubspec_path = dir.join("pubspec.yaml");
143            if !pubspec_path.exists() {
144                continue;
145            }
146
147            let content = std::fs::read_to_string(&pubspec_path)
148                .with_context(|| format!("Failed to read {}", pubspec_path.display()))?;
149
150            let name = match parse_pubspec_name(&content) {
151                Some(n) => n,
152                None => continue,
153            };
154
155            let pkg_id = PackageId(name.clone());
156            name_to_id.insert(name.clone(), pkg_id.clone());
157            packages.insert(
158                pkg_id.clone(),
159                Package {
160                    id: pkg_id,
161                    name: name.clone(),
162                    version: None,
163                    path: dir.clone(),
164                    manifest_path: pubspec_path,
165                },
166            );
167        }
168
169        // Build dependency edges
170        let mut edges = Vec::new();
171        let workspace_names: std::collections::HashSet<&str> =
172            name_to_id.keys().map(|s| s.as_str()).collect();
173
174        for dir in &pkg_dirs {
175            let pubspec_path = dir.join("pubspec.yaml");
176            if !pubspec_path.exists() {
177                continue;
178            }
179
180            let content = std::fs::read_to_string(&pubspec_path)?;
181
182            let from_name = match parse_pubspec_name(&content) {
183                Some(n) => n,
184                None => continue,
185            };
186
187            let all_deps = parse_pubspec_deps(&content);
188
189            for dep_name in all_deps {
190                if workspace_names.contains(dep_name.as_str()) {
191                    edges.push((PackageId(from_name.clone()), PackageId(dep_name)));
192                }
193            }
194        }
195
196        Ok(ProjectGraph {
197            packages,
198            edges,
199            root: root.to_path_buf(),
200        })
201    }
202
203    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
204        file_to_package(graph, file)
205    }
206
207    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
208        vec![
209            "dart".into(),
210            "test".into(),
211            "-C".into(),
212            package_id.0.clone(),
213        ]
214    }
215}
216
217impl DartResolver {
218    /// Determine which monorepo mode the project uses.
219    fn detect_mode(&self, root: &Path) -> Result<DartMode> {
220        // Priority 1: Dart 3.6+ native workspace
221        let root_pubspec = root.join("pubspec.yaml");
222        if root_pubspec.exists() {
223            if let Ok(content) = std::fs::read_to_string(&root_pubspec) {
224                for line in content.lines() {
225                    if !line.starts_with(' ') && !line.starts_with('\t') {
226                        let trimmed = line.trim();
227                        if trimmed == "workspace:" || trimmed.starts_with("workspace:") {
228                            return Ok(DartMode::Workspace);
229                        }
230                    }
231                }
232            }
233        }
234
235        // Priority 2: Melos
236        if root.join("melos.yaml").exists() {
237            return Ok(DartMode::Melos);
238        }
239
240        // Priority 3: Generic
241        Ok(DartMode::Generic)
242    }
243
244    /// Parse the root `pubspec.yaml` for a `workspace:` section containing a list of paths.
245    ///
246    /// ```yaml
247    /// workspace:
248    ///   - packages/core
249    ///   - packages/api
250    /// ```
251    fn resolve_workspace(&self, root: &Path) -> Result<Vec<PathBuf>> {
252        let pubspec_path = root.join("pubspec.yaml");
253        let content =
254            std::fs::read_to_string(&pubspec_path).context("Failed to read root pubspec.yaml")?;
255
256        let mut dirs = Vec::new();
257        let mut in_workspace = false;
258
259        for line in content.lines() {
260            let trimmed = line.trim();
261
262            // Detect top-level `workspace:` key
263            if !line.starts_with(' ') && !line.starts_with('\t') {
264                if trimmed == "workspace:" {
265                    in_workspace = true;
266                    continue;
267                } else if in_workspace {
268                    // Another top-level key ends the workspace section
269                    break;
270                }
271                continue;
272            }
273
274            if in_workspace {
275                if trimmed.starts_with("- ") {
276                    let path = trimmed
277                        .trim_start_matches("- ")
278                        .trim_matches('\'')
279                        .trim_matches('"')
280                        .to_string();
281                    let abs = root.join(&path);
282                    if abs.is_dir() {
283                        dirs.push(abs);
284                    }
285                } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
286                    break;
287                }
288            }
289        }
290
291        Ok(dirs)
292    }
293
294    /// Parse `melos.yaml` for the `packages:` field containing glob patterns,
295    /// then expand them to find directories containing `pubspec.yaml`.
296    ///
297    /// ```yaml
298    /// packages:
299    ///   - packages/*
300    /// ```
301    fn resolve_melos(&self, root: &Path) -> Result<Vec<PathBuf>> {
302        let melos_path = root.join("melos.yaml");
303        let content = std::fs::read_to_string(&melos_path).context("Failed to read melos.yaml")?;
304
305        let mut globs = Vec::new();
306        let mut in_packages = false;
307
308        for line in content.lines() {
309            let trimmed = line.trim();
310
311            if !line.starts_with(' ') && !line.starts_with('\t') {
312                if trimmed == "packages:" {
313                    in_packages = true;
314                    continue;
315                } else if in_packages {
316                    break;
317                }
318                continue;
319            }
320
321            if in_packages {
322                if trimmed.starts_with("- ") {
323                    let glob = trimmed
324                        .trim_start_matches("- ")
325                        .trim_matches('\'')
326                        .trim_matches('"')
327                        .to_string();
328                    globs.push(glob);
329                } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
330                    break;
331                }
332            }
333        }
334
335        self.expand_globs(root, &globs)
336    }
337
338    /// Scan immediate subdirectories for `pubspec.yaml` files.
339    fn resolve_generic(&self, root: &Path) -> Result<Vec<PathBuf>> {
340        let mut dirs = Vec::new();
341
342        let entries = std::fs::read_dir(root)
343            .with_context(|| format!("Failed to read directory {}", root.display()))?;
344
345        for entry in entries.filter_map(|e| e.ok()) {
346            let path = entry.path();
347            if path.is_dir() && path.join("pubspec.yaml").exists() {
348                dirs.push(path);
349            }
350        }
351
352        // Sort for deterministic ordering
353        dirs.sort();
354        Ok(dirs)
355    }
356
357    /// Expand glob patterns to find directories containing `pubspec.yaml`.
358    fn expand_globs(&self, root: &Path, globs: &[String]) -> Result<Vec<PathBuf>> {
359        let mut dirs = Vec::new();
360
361        for pattern in globs {
362            let full_pattern = root.join(pattern).join("pubspec.yaml");
363            let pattern_str = full_pattern.to_str().unwrap_or("");
364
365            match glob::glob(pattern_str) {
366                Ok(paths) => {
367                    for entry in paths.filter_map(|p| p.ok()) {
368                        if let Some(parent) = entry.parent() {
369                            dirs.push(parent.to_path_buf());
370                        }
371                    }
372                }
373                Err(_) => continue,
374            }
375        }
376
377        Ok(dirs)
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    /// Create a Dart 3.6+ native workspace layout.
386    fn create_dart_workspace(dir: &Path) {
387        // Root pubspec.yaml with workspace members
388        std::fs::write(
389            dir.join("pubspec.yaml"),
390            "name: my_workspace\n\
391             workspace:\n  - packages/core\n  - packages/api\n",
392        )
393        .unwrap();
394
395        // packages/core
396        std::fs::create_dir_all(dir.join("packages/core")).unwrap();
397        std::fs::write(
398            dir.join("packages/core/pubspec.yaml"),
399            "name: core\n\n\
400             dependencies:\n  flutter:\n    sdk: flutter\n",
401        )
402        .unwrap();
403
404        // packages/api depends on core
405        std::fs::create_dir_all(dir.join("packages/api")).unwrap();
406        std::fs::write(
407            dir.join("packages/api/pubspec.yaml"),
408            "name: api\n\n\
409             dependencies:\n  core:\n    path: ../core\n\n\
410             dev_dependencies:\n  test: ^1.0.0\n",
411        )
412        .unwrap();
413    }
414
415    /// Create a Melos monorepo layout.
416    fn create_melos_project(dir: &Path) {
417        std::fs::write(
418            dir.join("melos.yaml"),
419            "name: my_project\npackages:\n  - packages/*\n",
420        )
421        .unwrap();
422
423        std::fs::write(dir.join("pubspec.yaml"), "name: my_project\n").unwrap();
424
425        // packages/alpha
426        std::fs::create_dir_all(dir.join("packages/alpha")).unwrap();
427        std::fs::write(
428            dir.join("packages/alpha/pubspec.yaml"),
429            "name: alpha\n\n\
430             dependencies:\n  beta:\n    path: ../beta\n",
431        )
432        .unwrap();
433
434        // packages/beta
435        std::fs::create_dir_all(dir.join("packages/beta")).unwrap();
436        std::fs::write(dir.join("packages/beta/pubspec.yaml"), "name: beta\n").unwrap();
437    }
438
439    #[test]
440    fn test_detect_dart_workspace() {
441        let dir = tempfile::tempdir().unwrap();
442        create_dart_workspace(dir.path());
443        assert!(DartResolver.detect(dir.path()));
444    }
445
446    #[test]
447    fn test_detect_melos() {
448        let dir = tempfile::tempdir().unwrap();
449        create_melos_project(dir.path());
450        assert!(DartResolver.detect(dir.path()));
451    }
452
453    #[test]
454    fn test_detect_generic_multiple_pubspecs() {
455        let dir = tempfile::tempdir().unwrap();
456
457        // Two subdirs with pubspec.yaml, no workspace key, no melos.yaml
458        std::fs::create_dir_all(dir.path().join("app_a")).unwrap();
459        std::fs::write(dir.path().join("app_a/pubspec.yaml"), "name: app_a\n").unwrap();
460
461        std::fs::create_dir_all(dir.path().join("app_b")).unwrap();
462        std::fs::write(dir.path().join("app_b/pubspec.yaml"), "name: app_b\n").unwrap();
463
464        assert!(DartResolver.detect(dir.path()));
465    }
466
467    #[test]
468    fn test_detect_no_dart() {
469        let dir = tempfile::tempdir().unwrap();
470        // Empty directory — nothing to detect
471        assert!(!DartResolver.detect(dir.path()));
472
473        // Single pubspec.yaml without workspace key is not enough
474        let dir2 = tempfile::tempdir().unwrap();
475        std::fs::write(dir2.path().join("pubspec.yaml"), "name: solo_app\n").unwrap();
476        assert!(!DartResolver.detect(dir2.path()));
477
478        // Single subdir with pubspec.yaml is not enough for generic mode
479        let dir3 = tempfile::tempdir().unwrap();
480        std::fs::create_dir_all(dir3.path().join("only_one")).unwrap();
481        std::fs::write(
482            dir3.path().join("only_one/pubspec.yaml"),
483            "name: only_one\n",
484        )
485        .unwrap();
486        assert!(!DartResolver.detect(dir3.path()));
487    }
488
489    #[test]
490    fn test_resolve_dart_workspace() {
491        let dir = tempfile::tempdir().unwrap();
492        create_dart_workspace(dir.path());
493
494        let graph = DartResolver.resolve(dir.path()).unwrap();
495
496        // Should discover core and api
497        assert_eq!(graph.packages.len(), 2);
498        assert!(graph.packages.contains_key(&PackageId("core".into())));
499        assert!(graph.packages.contains_key(&PackageId("api".into())));
500
501        // api depends on core
502        assert!(graph
503            .edges
504            .contains(&(PackageId("api".into()), PackageId("core".into()),)));
505
506        // Verify paths
507        let core_pkg = &graph.packages[&PackageId("core".into())];
508        assert!(core_pkg.path.ends_with("packages/core"));
509        assert!(core_pkg
510            .manifest_path
511            .ends_with("packages/core/pubspec.yaml"));
512    }
513
514    #[test]
515    fn test_resolve_melos_project() {
516        let dir = tempfile::tempdir().unwrap();
517        create_melos_project(dir.path());
518
519        let graph = DartResolver.resolve(dir.path()).unwrap();
520
521        // Should discover alpha and beta
522        assert_eq!(graph.packages.len(), 2);
523        assert!(graph.packages.contains_key(&PackageId("alpha".into())));
524        assert!(graph.packages.contains_key(&PackageId("beta".into())));
525
526        // alpha depends on beta
527        assert!(graph
528            .edges
529            .contains(&(PackageId("alpha".into()), PackageId("beta".into()),)));
530    }
531
532    #[test]
533    fn test_test_command() {
534        let cmd = DartResolver.test_command(&PackageId("my_package".into()));
535        assert_eq!(cmd, vec!["dart", "test", "-C", "my_package"]);
536    }
537
538    #[test]
539    fn test_parse_pubspec_name() {
540        assert_eq!(
541            parse_pubspec_name("name: my_app\nversion: 1.0.0\n"),
542            Some("my_app".into()),
543        );
544        assert_eq!(
545            parse_pubspec_name("name: 'quoted_name'\n"),
546            Some("quoted_name".into()),
547        );
548        assert_eq!(parse_pubspec_name("version: 1.0.0\n"), None);
549    }
550
551    #[test]
552    fn test_parse_pubspec_deps() {
553        let content = "\
554name: my_app
555
556dependencies:
557  core:
558    path: ../core
559  http: ^0.13.0
560
561dev_dependencies:
562  test_utils:
563    path: ../test_utils
564  lints: ^2.0.0
565
566flutter:
567  uses-material-design: true
568";
569        let deps = parse_pubspec_deps(content);
570        assert!(deps.contains(&"core".to_string()));
571        assert!(deps.contains(&"http".to_string()));
572        assert!(deps.contains(&"test_utils".to_string()));
573        assert!(deps.contains(&"lints".to_string()));
574        // `flutter` is a top-level key, not a dep
575        assert!(!deps.contains(&"flutter".to_string()));
576    }
577
578    #[test]
579    fn test_resolve_generic_mode() {
580        let dir = tempfile::tempdir().unwrap();
581
582        // Two subdirs with pubspec.yaml, one depends on the other
583        std::fs::create_dir_all(dir.path().join("pkg_a")).unwrap();
584        std::fs::write(
585            dir.path().join("pkg_a/pubspec.yaml"),
586            "name: pkg_a\n\n\
587             dependencies:\n  pkg_b:\n    path: ../pkg_b\n",
588        )
589        .unwrap();
590
591        std::fs::create_dir_all(dir.path().join("pkg_b")).unwrap();
592        std::fs::write(dir.path().join("pkg_b/pubspec.yaml"), "name: pkg_b\n").unwrap();
593
594        let graph = DartResolver.resolve(dir.path()).unwrap();
595        assert_eq!(graph.packages.len(), 2);
596        assert!(graph
597            .edges
598            .contains(&(PackageId("pkg_a".into()), PackageId("pkg_b".into()),)));
599    }
600
601    #[test]
602    fn test_dev_dependencies_create_edges() {
603        let dir = tempfile::tempdir().unwrap();
604
605        std::fs::write(
606            dir.path().join("pubspec.yaml"),
607            "name: root_ws\nworkspace:\n  - packages/lib\n  - packages/app\n",
608        )
609        .unwrap();
610
611        std::fs::create_dir_all(dir.path().join("packages/lib")).unwrap();
612        std::fs::write(dir.path().join("packages/lib/pubspec.yaml"), "name: lib\n").unwrap();
613
614        std::fs::create_dir_all(dir.path().join("packages/app")).unwrap();
615        std::fs::write(
616            dir.path().join("packages/app/pubspec.yaml"),
617            "name: app\n\n\
618             dev_dependencies:\n  lib:\n    path: ../lib\n",
619        )
620        .unwrap();
621
622        let graph = DartResolver.resolve(dir.path()).unwrap();
623        assert!(graph
624            .edges
625            .contains(&(PackageId("app".into()), PackageId("lib".into()),)));
626    }
627}