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    fn metadata() -> Vec<super::metadata::ParserMetadata> {
87        vec![super::metadata::ParserMetadata {
88            description: "Swift show-dependencies deplock file",
89            file_patterns: &["*swift-show-dependencies.deplock"],
90            package_type: "swift",
91            primary_language: "Swift",
92            documentation_url: Some(
93                "https://forums.swift.org/t/swiftpm-show-dependencies-without-fetching-dependencies/51154",
94            ),
95        }]
96    }
97}
98
99pub(crate) fn parse_swift_show_dependencies(content: &str) -> PackageData {
100    let data: SwiftDeplock = match serde_json::from_str(content) {
101        Ok(d) => d,
102        Err(e) => {
103            warn!("Failed to parse swift-show-dependencies.deplock: {}", e);
104            return default_package_data();
105        }
106    };
107
108    let dependencies = flatten_dependencies(&data.dependencies);
109    let version = normalize_version(data.version);
110    let homepage_url = normalize_remote_url(data.url);
111    let purl = create_root_purl(data.name.as_deref(), version.as_deref());
112
113    PackageData {
114        package_type: Some(PACKAGE_TYPE),
115        primary_language: Some("Swift".to_string()),
116        name: data.name.map(truncate_field),
117        version: version.map(truncate_field),
118        homepage_url: homepage_url.map(truncate_field),
119        dependencies,
120        datasource_id: Some(DatasourceId::SwiftPackageShowDependencies),
121        purl,
122        ..Default::default()
123    }
124}
125
126fn flatten_dependencies(deps: &[SwiftDependency]) -> Vec<Dependency> {
127    let mut result = Vec::new();
128
129    for dep in deps {
130        let mut guard: RecursionGuard<String> = RecursionGuard::new();
131        let mut depth_guard = RecursionGuard::<()>::depth_only();
132        flatten_dependency(dep, true, &mut result, &mut guard, &mut depth_guard);
133    }
134
135    result
136}
137
138fn flatten_dependency(
139    dep: &SwiftDependency,
140    is_direct: bool,
141    result: &mut Vec<Dependency>,
142    guard: &mut RecursionGuard<String>,
143    depth_guard: &mut RecursionGuard<()>,
144) {
145    if guard.exceeded() || depth_guard.exceeded() {
146        warn!("Recursion depth exceeded in swift dependency flattening");
147        return;
148    }
149
150    if result.len() >= MAX_ITERATION_COUNT {
151        return;
152    }
153
154    let dep_key = dep
155        .identity
156        .as_deref()
157        .or(dep.name.as_deref())
158        .unwrap_or("")
159        .to_string();
160    if !dep_key.is_empty() && guard.enter(dep_key.clone()) {
161        return;
162    }
163
164    if let Some(dependency) = build_dependency(dep, is_direct, depth_guard) {
165        result.push(dependency);
166    }
167
168    for child in &dep.dependencies {
169        flatten_dependency(child, false, result, guard, depth_guard);
170    }
171
172    if !dep_key.is_empty() {
173        guard.leave(dep_key);
174    }
175}
176
177fn build_dependency(
178    dep: &SwiftDependency,
179    is_direct: bool,
180    guard: &mut RecursionGuard<()>,
181) -> Option<Dependency> {
182    if guard.descend() {
183        warn!("Recursion depth exceeded in swift dependency building");
184        return None;
185    }
186
187    let name = truncate_field(dep.name.as_ref()?.clone());
188    let version = normalize_version(dep.version.clone());
189    let purl = create_dependency_purl(dep, &name, version.as_deref());
190    let nested_dependencies = dep
191        .dependencies
192        .iter()
193        .take(MAX_ITERATION_COUNT)
194        .filter_map(|child| build_dependency(child, true, guard))
195        .collect();
196
197    guard.ascend();
198
199    Some(Dependency {
200        purl: Some(truncate_field(purl.clone())),
201        extracted_requirement: version.clone().map(truncate_field),
202        scope: Some("dependencies".to_string()),
203        is_runtime: None,
204        is_optional: None,
205        is_pinned: Some(version.is_some()),
206        is_direct: Some(is_direct),
207        resolved_package: Some(Box::new(ResolvedPackage {
208            primary_language: Some("Swift".to_string()),
209            download_url: None,
210            sha1: None,
211            sha256: None,
212            sha512: None,
213            md5: None,
214            is_virtual: true,
215            extra_data: None,
216            dependencies: nested_dependencies,
217            repository_homepage_url: None,
218            repository_download_url: None,
219            api_data_url: None,
220            datasource_id: Some(DatasourceId::SwiftPackageShowDependencies),
221            purl: None,
222            ..ResolvedPackage::new(
223                PACKAGE_TYPE,
224                truncate_field(extract_namespace(dep.url.as_deref()).unwrap_or_default()),
225                name,
226                truncate_field(version.clone().unwrap_or_default()),
227            )
228        })),
229        extra_data: None,
230    })
231}
232
233fn create_dependency_purl(
234    dep: &SwiftDependency,
235    fallback_name: &str,
236    version: Option<&str>,
237) -> String {
238    if let Some(url) = dep.url.as_deref()
239        && let Some((namespace, name)) = parse_url_namespace_and_name(url)
240    {
241        let mut purl = format!("pkg:swift/{}/{}", namespace, name);
242        if let Some(version) = version {
243            purl.push('@');
244            purl.push_str(version);
245        }
246        return purl;
247    }
248
249    let mut purl = format!("pkg:swift/{}", fallback_name);
250    if let Some(version) = version {
251        purl.push('@');
252        purl.push_str(version);
253    }
254    purl
255}
256
257fn create_root_purl(name: Option<&str>, version: Option<&str>) -> Option<String> {
258    let name = name?.trim();
259    if name.is_empty() {
260        return None;
261    }
262
263    let mut purl = format!("pkg:swift/{}", name);
264    if let Some(version) = version {
265        purl.push('@');
266        purl.push_str(version);
267    }
268    Some(purl)
269}
270
271fn normalize_version(version: Option<String>) -> Option<String> {
272    version.and_then(|v| {
273        let trimmed = v.trim();
274        if trimmed.is_empty() || trimmed == "unspecified" {
275            None
276        } else {
277            Some(trimmed.to_string())
278        }
279    })
280}
281
282fn normalize_remote_url(url: Option<String>) -> Option<String> {
283    url.and_then(|value| {
284        let trimmed = value.trim();
285        if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
286            Some(trimmed.to_string())
287        } else {
288            None
289        }
290    })
291}
292
293fn extract_namespace(url: Option<&str>) -> Option<String> {
294    parse_url_namespace_and_name(url?).map(|(namespace, _)| namespace)
295}
296
297fn parse_url_namespace_and_name(url: &str) -> Option<(String, String)> {
298    let trimmed = url.trim().trim_end_matches('/').trim_end_matches(".git");
299    let without_scheme = trimmed
300        .strip_prefix("https://")
301        .or_else(|| trimmed.strip_prefix("http://"))?;
302    let mut parts = without_scheme.split('/');
303    let host = parts.next()?;
304    let owner = parts.next()?;
305    let repo = parts.next()?;
306
307    Some((format!("{}/{}", host, owner), repo.to_string()))
308}