1use 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);