1use anyhow::{Context, Result};
2use regex::Regex;
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 SwiftResolver;
14impl super::sealed::Sealed for SwiftResolver {}
15
16impl Resolver for SwiftResolver {
17 fn ecosystem(&self) -> Ecosystem {
18 Ecosystem::Swift
19 }
20
21 fn detect(&self, root: &Path) -> bool {
22 let manifest = root.join("Package.swift");
23 if !manifest.exists() {
24 return false;
25 }
26
27 if has_subdir_packages(root) {
29 return true;
30 }
31
32 let content = match std::fs::read_to_string(&manifest) {
34 Ok(c) => c,
35 Err(_) => return false,
36 };
37
38 let target_re =
39 Regex::new(r#"\.(target|executableTarget|testTarget)\(\s*name:\s*""#).unwrap();
40 let count = target_re.find_iter(&content).count();
41 count >= 2
42 }
43
44 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
45 if has_subdir_packages(root) {
47 self.resolve_multi_package(root)
48 } else {
49 self.resolve_multi_target(root)
50 }
51 }
52
53 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
54 file_to_package(graph, file)
55 }
56
57 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
58 vec![
59 "swift".into(),
60 "test".into(),
61 "--filter".into(),
62 package_id.0.clone(),
63 ]
64 }
65}
66
67impl SwiftResolver {
68 fn resolve_multi_target(&self, root: &Path) -> Result<ProjectGraph> {
70 let manifest = root.join("Package.swift");
71 let content = std::fs::read_to_string(&manifest).context("Failed to read Package.swift")?;
72
73 let targets = parse_swift_targets(&content);
74
75 tracing::debug!(
76 "Swift: found {} targets: {:?}",
77 targets.len(),
78 targets.iter().map(|t| &t.name).collect::<Vec<_>>()
79 );
80
81 let target_names: std::collections::HashSet<String> =
82 targets.iter().map(|t| t.name.clone()).collect();
83
84 let mut packages = HashMap::new();
85
86 for target in &targets {
87 let pkg_id = PackageId(target.name.clone());
88 let target_dir = root.join("Sources").join(&target.name);
89 packages.insert(
90 pkg_id.clone(),
91 Package {
92 id: pkg_id,
93 name: target.name.clone(),
94 version: None,
95 path: target_dir,
96 manifest_path: manifest.clone(),
97 },
98 );
99 }
100
101 let mut edges = Vec::new();
102 for target in &targets {
103 let from = PackageId(target.name.clone());
104 for dep in &target.dependencies {
105 if target_names.contains(dep) && dep != &target.name {
106 edges.push((from.clone(), PackageId(dep.clone())));
107 }
108 }
109 }
110
111 Ok(ProjectGraph {
112 packages,
113 edges,
114 root: root.to_path_buf(),
115 })
116 }
117
118 fn resolve_multi_package(&self, root: &Path) -> Result<ProjectGraph> {
120 let mut packages = HashMap::new();
121
122 let entries = std::fs::read_dir(root).context("Failed to read project root directory")?;
123
124 for entry in entries {
125 let entry = entry?;
126 let path = entry.path();
127 if !path.is_dir() {
128 continue;
129 }
130
131 let manifest = path.join("Package.swift");
132 if !manifest.exists() {
133 continue;
134 }
135
136 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
137 Some(n) => n.to_string(),
138 None => continue,
139 };
140
141 let pkg_id = PackageId(dir_name.clone());
142 packages.insert(
143 pkg_id.clone(),
144 Package {
145 id: pkg_id,
146 name: dir_name,
147 version: None,
148 path: path.clone(),
149 manifest_path: manifest,
150 },
151 );
152 }
153
154 tracing::debug!(
155 "Swift: found {} sub-packages: {:?}",
156 packages.len(),
157 packages.keys().collect::<Vec<_>>()
158 );
159
160 let package_names: std::collections::HashSet<String> =
162 packages.keys().map(|k| k.0.clone()).collect();
163
164 let path_dep_re = Regex::new(r#"\.package\(\s*path:\s*"([^"]+)""#).unwrap();
165 let mut edges = Vec::new();
166 for (pkg_id, pkg) in &packages {
167 let content = std::fs::read_to_string(&pkg.manifest_path)
168 .with_context(|| format!("Failed to read {}", pkg.manifest_path.display()))?;
169
170 for cap in path_dep_re.captures_iter(&content) {
171 if let Some(dep_path) = cap.get(1) {
172 let dep_name = Path::new(dep_path.as_str())
174 .file_name()
175 .and_then(|n| n.to_str())
176 .unwrap_or("")
177 .to_string();
178
179 if package_names.contains(&dep_name) && dep_name != pkg_id.0 {
180 edges.push((pkg_id.clone(), PackageId(dep_name)));
181 }
182 }
183 }
184 }
185
186 Ok(ProjectGraph {
187 packages,
188 edges,
189 root: root.to_path_buf(),
190 })
191 }
192}
193
194fn has_subdir_packages(root: &Path) -> bool {
196 let entries = match std::fs::read_dir(root) {
197 Ok(e) => e,
198 Err(_) => return false,
199 };
200
201 let mut count = 0;
202 for entry in entries.flatten() {
203 let path = entry.path();
204 if path.is_dir() && path.join("Package.swift").exists() {
205 count += 1;
206 if count >= 2 {
207 return true;
208 }
209 }
210 }
211
212 false
213}
214
215#[derive(Debug)]
217struct SwiftTarget {
218 name: String,
219 dependencies: Vec<String>,
220}
221
222fn parse_swift_targets(content: &str) -> Vec<SwiftTarget> {
228 let mut targets = Vec::new();
229
230 let target_re =
232 Regex::new(r#"\.(target|executableTarget|testTarget)\(\s*name:\s*"([^"]+)""#).unwrap();
233
234 let target_matches: Vec<(usize, String)> = target_re
235 .captures_iter(content)
236 .filter_map(|cap| {
237 let pos = cap.get(0)?.start();
238 let name = cap.get(2)?.as_str().to_string();
239 Some((pos, name))
240 })
241 .collect();
242
243 for (i, (pos, name)) in target_matches.iter().enumerate() {
246 let end = if i + 1 < target_matches.len() {
249 target_matches[i + 1].0
250 } else {
251 content.len()
252 };
253
254 let block = &content[*pos..end];
255 let deps = parse_target_dependencies(block);
256
257 targets.push(SwiftTarget {
258 name: name.clone(),
259 dependencies: deps,
260 });
261 }
262
263 targets
264}
265
266fn parse_target_dependencies(block: &str) -> Vec<String> {
272 let mut deps = Vec::new();
273
274 let deps_start = match block.find("dependencies:") {
276 Some(pos) => pos,
277 None => return deps,
278 };
279
280 let after_deps = &block[deps_start..];
281
282 let bracket_start = match after_deps.find('[') {
284 Some(pos) => pos,
285 None => return deps,
286 };
287
288 let bracket_content = &after_deps[bracket_start..];
290 let mut depth = 0;
291 let mut end_pos = bracket_content.len();
292 for (i, ch) in bracket_content.char_indices() {
293 match ch {
294 '[' => depth += 1,
295 ']' => {
296 depth -= 1;
297 if depth == 0 {
298 end_pos = i + 1;
299 break;
300 }
301 }
302 _ => {}
303 }
304 }
305
306 let deps_array = &bracket_content[..end_pos];
307
308 let product_re = Regex::new(r#"\.product\(\s*name:\s*"([^"]+)""#).unwrap();
310 for cap in product_re.captures_iter(deps_array) {
311 if let Some(name) = cap.get(1) {
312 let dep = name.as_str().to_string();
313 if !deps.contains(&dep) {
314 deps.push(dep);
315 }
316 }
317 }
318
319 let stripped = product_re.replace_all(deps_array, "");
322 let target_dep_re = Regex::new(r#"\.(target|byName)\(\s*name:\s*"([^"]+)""#).unwrap();
324 for cap in target_dep_re.captures_iter(deps_array) {
325 if let Some(name) = cap.get(2) {
326 let dep = name.as_str().to_string();
327 if !deps.contains(&dep) {
328 deps.push(dep);
329 }
330 }
331 }
332 let stripped = target_dep_re.replace_all(&stripped, "");
333
334 let bare_re = Regex::new(r#""([^"]+)""#).unwrap();
335 for cap in bare_re.captures_iter(&stripped) {
336 if let Some(name) = cap.get(1) {
337 let dep = name.as_str().to_string();
338 if !deps.contains(&dep) {
339 deps.push(dep);
340 }
341 }
342 }
343
344 deps
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 const SAMPLE_PACKAGE_SWIFT: &str = r#"
352// swift-tools-version: 5.9
353import PackageDescription
354
355let package = Package(
356 name: "MyPackage",
357 targets: [
358 .target(name: "Core", dependencies: []),
359 .target(name: "Networking", dependencies: ["Core"]),
360 .executableTarget(name: "CLI", dependencies: ["Core", "Networking"]),
361 .testTarget(name: "CoreTests", dependencies: ["Core"]),
362 ]
363)
364"#;
365
366 #[test]
367 fn test_detect_package_swift() {
368 let dir = tempfile::tempdir().unwrap();
369 std::fs::write(dir.path().join("Package.swift"), SAMPLE_PACKAGE_SWIFT).unwrap();
370 assert!(SwiftResolver.detect(dir.path()));
371 }
372
373 #[test]
374 fn test_detect_single_target_no_detect() {
375 let dir = tempfile::tempdir().unwrap();
376 let content = r#"
377let package = Package(
378 name: "SingleLib",
379 targets: [
380 .target(name: "SingleLib", dependencies: []),
381 ]
382)
383"#;
384 std::fs::write(dir.path().join("Package.swift"), content).unwrap();
385 assert!(!SwiftResolver.detect(dir.path()));
386 }
387
388 #[test]
389 fn test_detect_no_swift() {
390 let dir = tempfile::tempdir().unwrap();
391 assert!(!SwiftResolver.detect(dir.path()));
392 }
393
394 #[test]
395 fn test_parse_swift_targets() {
396 let targets = parse_swift_targets(SAMPLE_PACKAGE_SWIFT);
397 let names: Vec<&str> = targets.iter().map(|t| t.name.as_str()).collect();
398 assert_eq!(names, vec!["Core", "Networking", "CLI", "CoreTests"]);
399 }
400
401 #[test]
402 fn test_resolve_swift_package() {
403 let dir = tempfile::tempdir().unwrap();
404 std::fs::write(dir.path().join("Package.swift"), SAMPLE_PACKAGE_SWIFT).unwrap();
405
406 for name in &["Core", "Networking", "CLI", "CoreTests"] {
408 std::fs::create_dir_all(dir.path().join("Sources").join(name)).unwrap();
409 }
410
411 let graph = SwiftResolver.resolve(dir.path()).unwrap();
412
413 assert_eq!(graph.packages.len(), 4);
414 assert!(graph.packages.contains_key(&PackageId("Core".into())));
415 assert!(graph.packages.contains_key(&PackageId("Networking".into())));
416 assert!(graph.packages.contains_key(&PackageId("CLI".into())));
417 assert!(graph.packages.contains_key(&PackageId("CoreTests".into())));
418
419 assert!(graph
421 .edges
422 .contains(&(PackageId("Networking".into()), PackageId("Core".into()))));
423
424 assert!(graph
426 .edges
427 .contains(&(PackageId("CLI".into()), PackageId("Core".into()))));
428 assert!(graph
429 .edges
430 .contains(&(PackageId("CLI".into()), PackageId("Networking".into()))));
431
432 assert!(graph
434 .edges
435 .contains(&(PackageId("CoreTests".into()), PackageId("Core".into()))));
436
437 let core_deps: Vec<_> = graph
439 .edges
440 .iter()
441 .filter(|(from, _)| from.0 == "Core")
442 .collect();
443 assert!(core_deps.is_empty());
444 }
445
446 #[test]
447 fn test_test_command() {
448 let cmd = SwiftResolver.test_command(&PackageId("Core".into()));
449 assert_eq!(cmd, vec!["swift", "test", "--filter", "Core"]);
450 }
451
452 #[test]
453 fn test_detect_multi_package() {
454 let dir = tempfile::tempdir().unwrap();
455
456 std::fs::write(
458 dir.path().join("Package.swift"),
459 r#"let package = Package(name: "Root", targets: [.target(name: "Root", dependencies: [])])"#,
460 )
461 .unwrap();
462
463 std::fs::create_dir_all(dir.path().join("CoreLib")).unwrap();
465 std::fs::write(
466 dir.path().join("CoreLib/Package.swift"),
467 r#"let package = Package(name: "CoreLib")"#,
468 )
469 .unwrap();
470
471 std::fs::create_dir_all(dir.path().join("NetLib")).unwrap();
472 std::fs::write(
473 dir.path().join("NetLib/Package.swift"),
474 r#"let package = Package(name: "NetLib")"#,
475 )
476 .unwrap();
477
478 assert!(SwiftResolver.detect(dir.path()));
479 }
480
481 #[test]
482 fn test_resolve_multi_package() {
483 let dir = tempfile::tempdir().unwrap();
484
485 std::fs::create_dir_all(dir.path().join("CoreLib")).unwrap();
486 std::fs::write(
487 dir.path().join("CoreLib/Package.swift"),
488 r#"let package = Package(name: "CoreLib", targets: [.target(name: "CoreLib")])"#,
489 )
490 .unwrap();
491
492 std::fs::create_dir_all(dir.path().join("NetLib")).unwrap();
493 std::fs::write(
494 dir.path().join("NetLib/Package.swift"),
495 r#"
496let package = Package(
497 name: "NetLib",
498 dependencies: [.package(path: "../CoreLib")],
499 targets: [.target(name: "NetLib")]
500)
501"#,
502 )
503 .unwrap();
504
505 let graph = SwiftResolver.resolve_multi_package(dir.path()).unwrap();
506
507 assert_eq!(graph.packages.len(), 2);
508 assert!(graph.packages.contains_key(&PackageId("CoreLib".into())));
509 assert!(graph.packages.contains_key(&PackageId("NetLib".into())));
510
511 assert!(graph
513 .edges
514 .contains(&(PackageId("NetLib".into()), PackageId("CoreLib".into()))));
515 }
516
517 #[test]
518 fn test_parse_target_with_product_dependency() {
519 let content = r#"
520let package = Package(
521 name: "MyApp",
522 targets: [
523 .target(name: "App", dependencies: [
524 .product(name: "Logging", package: "swift-log"),
525 "Core",
526 ]),
527 .target(name: "Core", dependencies: []),
528 ]
529)
530"#;
531 let targets = parse_swift_targets(content);
532 assert_eq!(targets.len(), 2);
533
534 let app = &targets[0];
535 assert_eq!(app.name, "App");
536 assert!(app.dependencies.contains(&"Logging".to_string()));
537 assert!(app.dependencies.contains(&"Core".to_string()));
538 }
539}