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
11enum DartMode {
13 Workspace,
15 Melos,
17 Generic,
19}
20
21fn parse_pubspec_name(content: &str) -> Option<String> {
23 for line in content.lines() {
24 if line.starts_with("name:") {
26 let value = line.trim_start_matches("name:").trim();
27 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
37fn 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 if line.trim().is_empty() {
50 continue;
51 }
52
53 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 in_dep_section = false;
64 continue;
65 }
66 }
67
68 if in_dep_section {
69 let stripped = line.trim();
70
71 let indent = line.len() - line.trim_start().len();
73
74 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 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 if root.join("melos.yaml").exists() {
111 return true;
112 }
113
114 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 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 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 fn detect_mode(&self, root: &Path) -> Result<DartMode> {
220 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 if root.join("melos.yaml").exists() {
237 return Ok(DartMode::Melos);
238 }
239
240 Ok(DartMode::Generic)
242 }
243
244 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 if !line.starts_with(' ') && !line.starts_with('\t') {
264 if trimmed == "workspace:" {
265 in_workspace = true;
266 continue;
267 } else if in_workspace {
268 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 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 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 dirs.sort();
354 Ok(dirs)
355 }
356
357 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 fn create_dart_workspace(dir: &Path) {
387 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 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 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 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 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 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 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 assert!(!DartResolver.detect(dir.path()));
472
473 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 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 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 assert!(graph
503 .edges
504 .contains(&(PackageId("api".into()), PackageId("core".into()),)));
505
506 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 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 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 assert!(!deps.contains(&"flutter".to_string()));
576 }
577
578 #[test]
579 fn test_resolve_generic_mode() {
580 let dir = tempfile::tempdir().unwrap();
581
582 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}