Skip to main content

provenant/parsers/
swift_show_dependencies.rs

1//! Parser for Swift show-dependencies deplock files.
2//!
3//! Extracts dependency information from `swift-show-dependencies.deplock` files
4//! created by the dependency-inspector tool.
5//!
6//! # Supported Formats
7//! - `swift-show-dependencies.deplock` - Swift dependency graph JSON
8//!
9//! # Implementation Notes
10//! - Format: JSON with nested dependency tree
11//! - Generated by deplock tool or `swift package show-dependencies`
12//! - Extracts full dependency graph with versions (beyond Python which only extracts name)
13//! - Spec: https://forums.swift.org/t/swiftpm-show-dependencies-without-fetching-dependencies/51154
14
15use std::path::Path;
16
17use crate::parser_warn as warn;
18use serde::{Deserialize, Serialize};
19
20use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
21use crate::parsers::utils::{
22    MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, truncate_field,
23};
24
25use super::PackageParser;
26
27const PACKAGE_TYPE: PackageType = PackageType::Swift;
28
29fn default_package_data() -> PackageData {
30    PackageData {
31        package_type: Some(PACKAGE_TYPE),
32        primary_language: Some("Swift".to_string()),
33        datasource_id: Some(DatasourceId::SwiftPackageShowDependencies),
34        ..Default::default()
35    }
36}
37
38pub struct SwiftShowDependenciesParser;
39
40#[derive(Debug, Deserialize, Serialize)]
41struct SwiftDeplock {
42    name: Option<String>,
43    version: Option<String>,
44    url: Option<String>,
45    #[serde(default)]
46    dependencies: Vec<SwiftDependency>,
47}
48
49#[derive(Debug, Deserialize, Serialize, Clone)]
50struct SwiftDependency {
51    identity: Option<String>,
52    name: Option<String>,
53    version: Option<String>,
54    url: Option<String>,
55    #[serde(default)]
56    dependencies: Vec<SwiftDependency>,
57}
58
59impl PackageParser for SwiftShowDependenciesParser {
60    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
61
62    fn is_match(path: &Path) -> bool {
63        path.file_name()
64            .and_then(|name| name.to_str())
65            .is_some_and(|name| name == "swift-show-dependencies.deplock")
66    }
67
68    fn extract_packages(path: &Path) -> Vec<PackageData> {
69        let content = match read_file_to_string(path, None) {
70            Ok(c) => c,
71            Err(e) => {
72                warn!(
73                    "Failed to read swift-show-dependencies.deplock {:?}: {}",
74                    path, e
75                );
76                return vec![default_package_data()];
77            }
78        };
79
80        vec![parse_swift_show_dependencies(&content)]
81    }
82}
83
84pub(crate) fn parse_swift_show_dependencies(content: &str) -> PackageData {
85    let data: SwiftDeplock = match serde_json::from_str(content) {
86        Ok(d) => d,
87        Err(e) => {
88            warn!("Failed to parse swift-show-dependencies.deplock: {}", e);
89            return default_package_data();
90        }
91    };
92
93    let dependencies = flatten_dependencies(&data.dependencies);
94    let version = normalize_version(data.version);
95    let homepage_url = normalize_remote_url(data.url);
96    let purl = create_root_purl(data.name.as_deref(), version.as_deref());
97
98    PackageData {
99        package_type: Some(PACKAGE_TYPE),
100        primary_language: Some("Swift".to_string()),
101        name: data.name.map(truncate_field),
102        version: version.map(truncate_field),
103        homepage_url: homepage_url.map(truncate_field),
104        dependencies,
105        datasource_id: Some(DatasourceId::SwiftPackageShowDependencies),
106        purl,
107        ..Default::default()
108    }
109}
110
111fn flatten_dependencies(deps: &[SwiftDependency]) -> Vec<Dependency> {
112    let mut result = Vec::new();
113
114    for dep in deps {
115        let mut guard: RecursionGuard<String> = RecursionGuard::new();
116        let mut depth_guard = RecursionGuard::<()>::depth_only();
117        flatten_dependency(dep, true, &mut result, &mut guard, &mut depth_guard);
118    }
119
120    result
121}
122
123fn flatten_dependency(
124    dep: &SwiftDependency,
125    is_direct: bool,
126    result: &mut Vec<Dependency>,
127    guard: &mut RecursionGuard<String>,
128    depth_guard: &mut RecursionGuard<()>,
129) {
130    if guard.exceeded() || depth_guard.exceeded() {
131        warn!("Recursion depth exceeded in swift dependency flattening");
132        return;
133    }
134
135    if result.len() >= MAX_ITERATION_COUNT {
136        return;
137    }
138
139    let dep_key = dep
140        .identity
141        .as_deref()
142        .or(dep.name.as_deref())
143        .unwrap_or("")
144        .to_string();
145    if !dep_key.is_empty() && guard.enter(dep_key.clone()) {
146        return;
147    }
148
149    if let Some(dependency) = build_dependency(dep, is_direct, depth_guard) {
150        result.push(dependency);
151    }
152
153    for child in &dep.dependencies {
154        flatten_dependency(child, false, result, guard, depth_guard);
155    }
156
157    if !dep_key.is_empty() {
158        guard.leave(dep_key);
159    }
160}
161
162fn build_dependency(
163    dep: &SwiftDependency,
164    is_direct: bool,
165    guard: &mut RecursionGuard<()>,
166) -> Option<Dependency> {
167    if guard.descend() {
168        warn!("Recursion depth exceeded in swift dependency building");
169        return None;
170    }
171
172    let name = truncate_field(dep.name.as_ref()?.clone());
173    let version = normalize_version(dep.version.clone());
174    let purl = create_dependency_purl(dep, &name, version.as_deref());
175    let nested_dependencies = dep
176        .dependencies
177        .iter()
178        .take(MAX_ITERATION_COUNT)
179        .filter_map(|child| build_dependency(child, true, guard))
180        .collect();
181
182    guard.ascend();
183
184    Some(Dependency {
185        purl: Some(truncate_field(purl.clone())),
186        extracted_requirement: version.clone().map(truncate_field),
187        scope: Some("dependencies".to_string()),
188        is_runtime: None,
189        is_optional: None,
190        is_pinned: Some(version.is_some()),
191        is_direct: Some(is_direct),
192        resolved_package: Some(Box::new(ResolvedPackage {
193            primary_language: Some("Swift".to_string()),
194            download_url: None,
195            sha1: None,
196            sha256: None,
197            sha512: None,
198            md5: None,
199            is_virtual: true,
200            extra_data: None,
201            dependencies: nested_dependencies,
202            repository_homepage_url: None,
203            repository_download_url: None,
204            api_data_url: None,
205            datasource_id: Some(DatasourceId::SwiftPackageShowDependencies),
206            purl: None,
207            ..ResolvedPackage::new(
208                PACKAGE_TYPE,
209                truncate_field(extract_namespace(dep.url.as_deref()).unwrap_or_default()),
210                name,
211                truncate_field(version.clone().unwrap_or_default()),
212            )
213        })),
214        extra_data: None,
215    })
216}
217
218fn create_dependency_purl(
219    dep: &SwiftDependency,
220    fallback_name: &str,
221    version: Option<&str>,
222) -> String {
223    if let Some(url) = dep.url.as_deref()
224        && let Some((namespace, name)) = parse_url_namespace_and_name(url)
225    {
226        let mut purl = format!("pkg:swift/{}/{}", namespace, name);
227        if let Some(version) = version {
228            purl.push('@');
229            purl.push_str(version);
230        }
231        return purl;
232    }
233
234    let mut purl = format!("pkg:swift/{}", fallback_name);
235    if let Some(version) = version {
236        purl.push('@');
237        purl.push_str(version);
238    }
239    purl
240}
241
242fn create_root_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
243    let name = name?.trim();
244    if name.is_empty() {
245        return None;
246    }
247
248    let mut purl = format!("pkg:swift/{}", name);
249    if let Some(version) = version {
250        purl.push('@');
251        purl.push_str(version);
252    }
253    Some(purl)
254}
255
256fn normalize_version(version: Option<String>) -> Option<String> {
257    version.and_then(|v| {
258        let trimmed = v.trim();
259        if trimmed.is_empty() || trimmed == "unspecified" {
260            None
261        } else {
262            Some(trimmed.to_string())
263        }
264    })
265}
266
267fn normalize_remote_url(url: Option<String>) -> Option<String> {
268    url.and_then(|value| {
269        let trimmed = value.trim();
270        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
271            Some(trimmed.to_string())
272        } else {
273            None
274        }
275    })
276}
277
278fn extract_namespace(url: Option<&str>) -> Option<String> {
279    parse_url_namespace_and_name(url?).map(|(namespace, _)| namespace)
280}
281
282fn parse_url_namespace_and_name(url: &str) -> Option<(String, String)> {
283    let trimmed = url.trim().trim_end_matches('/').trim_end_matches(".git");
284    let without_scheme = trimmed
285        .strip_prefix("https://")
286        .or_else(|| trimmed.strip_prefix("http://"))?;
287    let mut parts = without_scheme.split('/');
288    let host = parts.next()?;
289    let owner = parts.next()?;
290    let repo = parts.next()?;
291
292    Some((format!("{}/{}", host, owner), repo.to_string()))
293}
294
295crate::register_parser!(
296    "Swift show-dependencies deplock file",
297    &["*swift-show-dependencies.deplock"],
298    "swift",
299    "Swift",
300    Some(
301        "https://forums.swift.org/t/swiftpm-show-dependencies-without-fetching-dependencies/51154"
302    ),
303);