Skip to main content

provenant/parsers/
swift_show_dependencies.rs

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