Skip to main content

provenant/parsers/
swift_show_dependencies.rs

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