Skip to main content

provenant/parsers/
swift_show_dependencies.rs

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