Skip to main content

provenant/parsers/
swift_show_dependencies.rs

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