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