Skip to main content

affected_core/resolvers/
swift.rs

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
9/// SwiftResolver detects Swift Package Manager multi-target or multi-package projects.
10///
11/// Uses regex to parse `Package.swift` manifests for `.target(`, `.executableTarget(`,
12/// and `.testTarget(` declarations and their dependency arrays.
13pub 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        // Check for multi-package layout: subdirectories containing their own Package.swift
28        if has_subdir_packages(root) {
29            return true;
30        }
31
32        // Check for multi-target single Package.swift
33        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        // Decide mode: multi-package (subdirs) vs multi-target (single Package.swift)
46        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    /// Resolve a single Package.swift with multiple targets.
69    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    /// Resolve multiple Package.swift files in subdirectories.
119    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        // Build dependency edges by scanning each Package.swift for .package(path: "...") refs
161        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                    // Extract directory name from path (e.g., "../Core" -> "Core")
173                    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
194/// Check whether subdirectories of `root` contain their own `Package.swift`.
195fn 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/// A parsed Swift target with its name and local dependencies.
216#[derive(Debug)]
217struct SwiftTarget {
218    name: String,
219    dependencies: Vec<String>,
220}
221
222/// Parse target declarations from a `Package.swift` file.
223///
224/// Extracts `.target(name: "X", ...)`, `.executableTarget(name: "X", ...)`,
225/// and `.testTarget(name: "X", ...)` blocks, along with the dependency names
226/// found in each target's `dependencies:` array.
227fn parse_swift_targets(content: &str) -> Vec<SwiftTarget> {
228    let mut targets = Vec::new();
229
230    // First pass: find all target declarations and their positions.
231    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    // Second pass: for each target, extract the block up to a reasonable boundary
244    // and parse dependencies from it.
245    for (i, (pos, name)) in target_matches.iter().enumerate() {
246        // Determine the end of this target's text: either the start of the next target
247        // or end of file.
248        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
266/// Parse dependency names from a target block.
267///
268/// Looks for the `dependencies:` array and extracts:
269/// - Bare string dependencies: `"Foo"`
270/// - `.product(name: "Foo", ...)` references
271fn parse_target_dependencies(block: &str) -> Vec<String> {
272    let mut deps = Vec::new();
273
274    // Find the dependencies: [...] array within this block.
275    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    // Find the opening bracket
283    let bracket_start = match after_deps.find('[') {
284        Some(pos) => pos,
285        None => return deps,
286    };
287
288    // Find the matching closing bracket (handle nested brackets)
289    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    // Match .product(name: "Foo" references
309    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    // Match bare string dependencies: standalone "Foo" not inside .product(...)
320    // We strip out .product(...) blocks first, then find remaining quoted strings.
321    let stripped = product_re.replace_all(deps_array, "");
322    // Also strip .target(name: ... and .byName(name: ... references first
323    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        // Create target source directories (swift convention: Sources/<Target>/)
407        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        // Networking depends on Core
420        assert!(graph
421            .edges
422            .contains(&(PackageId("Networking".into()), PackageId("Core".into()))));
423
424        // CLI depends on Core and Networking
425        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        // CoreTests depends on Core
433        assert!(graph
434            .edges
435            .contains(&(PackageId("CoreTests".into()), PackageId("Core".into()))));
436
437        // Core has no internal dependencies
438        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        // Root Package.swift with only one target (would not qualify on its own)
457        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        // Two subdirectory packages
464        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        // NetLib depends on CoreLib
512        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}