Skip to main content

provenant/parsers/
swift_manifest_json.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use crate::parser_warn as warn;
6use packageurl::PackageUrl;
7use serde_json::Value;
8
9use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
10
11use super::PackageParser;
12
13/// Swift Package Manager manifest parser.
14///
15/// The parser reads pre-generated manifest JSON surfaces such as
16/// `Package.swift.json` and `Package.swift.deplock`.
17pub struct SwiftManifestJsonParser;
18
19impl PackageParser for SwiftManifestJsonParser {
20    const PACKAGE_TYPE: PackageType = PackageType::Swift;
21
22    fn extract_packages(path: &Path) -> Vec<PackageData> {
23        let filename = path.file_name().and_then(|n| n.to_str());
24
25        vec![if filename
26            .map(|n| n.ends_with(".swift.json") || n.ends_with(".swift.deplock"))
27            .unwrap_or(false)
28        {
29            let json_content = match read_swift_manifest_json(path) {
30                Ok(content) => content,
31                Err(e) => {
32                    warn!(
33                        "Failed to read or parse Swift manifest JSON at {:?}: {}",
34                        path, e
35                    );
36                    return vec![default_package_data(path)];
37                }
38            };
39            parse_swift_manifest(&json_content)
40        } else {
41            default_package_data(path)
42        }]
43    }
44
45    fn is_match(path: &Path) -> bool {
46        path.file_name()
47            .and_then(|name| name.to_str())
48            .is_some_and(|name| name.ends_with(".swift.json") || name.ends_with(".swift.deplock"))
49    }
50}
51
52fn read_swift_manifest_json(path: &Path) -> Result<Value, String> {
53    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
54
55    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
56}
57
58fn parse_swift_manifest(manifest: &Value) -> PackageData {
59    let name = manifest
60        .get("name")
61        .and_then(|v| v.as_str())
62        .map(String::from);
63
64    let dependencies = get_dependencies(manifest.get("dependencies"));
65    let platforms = manifest.get("platforms").cloned();
66
67    let tools_version = manifest
68        .get("toolsVersion")
69        .and_then(|tv| tv.get("_version"))
70        .and_then(|v| v.as_str())
71        .map(String::from);
72
73    let mut extra_data = HashMap::new();
74    if let Some(platforms_val) = platforms {
75        extra_data.insert("platforms".to_string(), platforms_val);
76    }
77    if let Some(ref tv) = tools_version {
78        extra_data.insert(
79            "swift_tools_version".to_string(),
80            serde_json::Value::String(tv.clone()),
81        );
82    }
83
84    let purl = create_package_url(&name, &None);
85
86    PackageData {
87        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
88        namespace: None,
89        name,
90        version: None,
91        qualifiers: None,
92        subpath: None,
93        primary_language: Some("Swift".to_string()),
94        description: None,
95        release_date: None,
96        parties: Vec::new(),
97        keywords: Vec::new(),
98        homepage_url: None,
99        download_url: None,
100        size: None,
101        sha1: None,
102        md5: None,
103        sha256: None,
104        sha512: None,
105        bug_tracking_url: None,
106        code_view_url: None,
107        vcs_url: None,
108        copyright: None,
109        holder: None,
110        declared_license_expression: None,
111        declared_license_expression_spdx: None,
112        license_detections: Vec::new(),
113        other_license_expression: None,
114        other_license_expression_spdx: None,
115        other_license_detections: Vec::new(),
116        extracted_license_statement: None,
117        notice_text: None,
118        source_packages: Vec::new(),
119        file_references: Vec::new(),
120        is_private: false,
121        is_virtual: false,
122        extra_data: if extra_data.is_empty() {
123            None
124        } else {
125            Some(extra_data)
126        },
127        dependencies,
128        repository_homepage_url: None,
129        repository_download_url: None,
130        api_data_url: None,
131        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
132        purl,
133    }
134}
135
136fn get_dependencies(dependencies: Option<&Value>) -> Vec<Dependency> {
137    let Some(deps_array) = dependencies.and_then(|v| v.as_array()) else {
138        return Vec::new();
139    };
140
141    let mut dependent_packages = Vec::new();
142
143    for dependency in deps_array {
144        if let Some(dep) = parse_manifest_dependency(dependency) {
145            dependent_packages.push(dep);
146        }
147    }
148
149    dependent_packages
150}
151
152fn parse_manifest_dependency(dependency: &Value) -> Option<Dependency> {
153    if let Some(source_control) = dependency.get("sourceControl").and_then(|v| v.as_array())
154        && let Some(source) = source_control.first()
155    {
156        let identity = source
157            .get("identity")
158            .and_then(|v| v.as_str())
159            .unwrap_or_default();
160
161        let (namespace, dep_name) = extract_namespace_and_name(source, identity);
162        let (version, is_pinned, requirement_kind) = extract_version_requirement(source);
163        let purl = create_dependency_purl(&namespace, &dep_name, &version, is_pinned);
164        let mut extra_data = HashMap::from([
165            (
166                "dependency_kind".to_string(),
167                serde_json::Value::String("sourceControl".to_string()),
168            ),
169            (
170                "requirement_kind".to_string(),
171                serde_json::Value::String(requirement_kind.to_string()),
172            ),
173        ]);
174        if let Some(remote) = source
175            .get("location")
176            .and_then(|loc| loc.get("remote"))
177            .and_then(|remote| remote.as_array())
178            .and_then(|arr| arr.first())
179            .and_then(|first| first.get("urlString"))
180            .and_then(|v| v.as_str())
181        {
182            extra_data.insert(
183                "location".to_string(),
184                serde_json::Value::String(remote.to_string()),
185            );
186        }
187
188        return Some(Dependency {
189            purl: Some(purl),
190            extracted_requirement: version,
191            scope: Some("dependencies".to_string()),
192            is_runtime: None,
193            is_optional: Some(false),
194            is_pinned: Some(is_pinned),
195            is_direct: Some(true),
196            resolved_package: None,
197            extra_data: Some(extra_data),
198        });
199    }
200
201    if let Some(file_system) = dependency.get("fileSystem").and_then(|v| v.as_array())
202        && let Some(source) = file_system.first()
203    {
204        let identity = source
205            .get("identity")
206            .and_then(|v| v.as_str())
207            .or_else(|| source.get("name").and_then(|v| v.as_str()))
208            .unwrap_or_default();
209        if identity.is_empty() {
210            return None;
211        }
212
213        let dep_name = identity.to_string();
214        let purl = create_dependency_purl(&None, &dep_name, &None, false);
215        let mut extra_data = HashMap::from([(
216            "dependency_kind".to_string(),
217            serde_json::Value::String("fileSystem".to_string()),
218        )]);
219        if let Some(path) = source.get("path").and_then(|v| v.as_str()) {
220            extra_data.insert(
221                "path".to_string(),
222                serde_json::Value::String(path.to_string()),
223            );
224        }
225
226        return Some(Dependency {
227            purl: Some(purl),
228            extracted_requirement: None,
229            scope: Some("dependencies".to_string()),
230            is_runtime: None,
231            is_optional: Some(false),
232            is_pinned: Some(false),
233            is_direct: Some(true),
234            resolved_package: None,
235            extra_data: Some(extra_data),
236        });
237    }
238
239    None
240}
241
242fn extract_namespace_and_name(source: &Value, identity: &str) -> (Option<String>, String) {
243    let url = source
244        .get("location")
245        .and_then(|loc| loc.get("remote"))
246        .and_then(|remote| remote.as_array())
247        .and_then(|arr| arr.first())
248        .and_then(|first| first.get("urlString"))
249        .and_then(|v| v.as_str());
250
251    match url {
252        Some(url_str) => get_namespace_and_name(url_str),
253        None => (None, identity.to_string()),
254    }
255}
256
257/// Parses a repository URL into (namespace, name).
258///
259/// Example: `https://github.com/apple/swift-argument-parser.git`
260/// yields namespace=`"github.com/apple"`, name=`"swift-argument-parser"`
261pub fn get_namespace_and_name(url: &str) -> (Option<String>, String) {
262    let (hostname, path) = if let Some(stripped) = url.strip_prefix("https://") {
263        let rest = stripped.trim_end_matches('/');
264        match rest.find('/') {
265            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
266            None => (Some(rest), ""),
267        }
268    } else if let Some(stripped) = url.strip_prefix("http://") {
269        let rest = stripped.trim_end_matches('/');
270        match rest.find('/') {
271            Some(idx) => (Some(&rest[..idx]), &rest[idx + 1..]),
272            None => (Some(rest), ""),
273        }
274    } else {
275        (None, url)
276    };
277
278    let clean_path = path
279        .strip_suffix(".git")
280        .unwrap_or(path)
281        .trim_end_matches('/');
282
283    if let Some(host) = hostname {
284        let canonical = format!("{}/{}", host, clean_path);
285        match canonical.rsplit_once('/') {
286            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
287            None => (None, canonical),
288        }
289    } else {
290        match clean_path.rsplit_once('/') {
291            Some((ns, name)) => (Some(ns.to_string()), name.to_string()),
292            None => (None, clean_path.to_string()),
293        }
294    }
295}
296
297/// Handles four requirement types:
298/// - `exact`: `["1.0.0"]` -> version="1.0.0", is_pinned=true
299/// - `range`: `[{"lowerBound": "1.0.0", "upperBound": "2.0.0"}]` -> version="vers:swift/>=1.0.0|<2.0.0", is_pinned=false
300/// - `branch`: `["main"]` -> version="main", is_pinned=false
301/// - `revision`: `["abc123"]` -> version="abc123", is_pinned=true
302fn extract_version_requirement(source: &Value) -> (Option<String>, bool, &'static str) {
303    let Some(requirement) = source.get("requirement") else {
304        return (None, false, "unknown");
305    };
306
307    if let Some(exact) = requirement.get("exact").and_then(|v| v.as_array())
308        && let Some(version) = exact.first().and_then(|v| v.as_str())
309    {
310        return (Some(version.to_string()), true, "exact");
311    }
312
313    if let Some(range) = requirement.get("range").and_then(|v| v.as_array())
314        && let Some(bound) = range.first()
315    {
316        let lower = bound.get("lowerBound").and_then(|v| v.as_str());
317        let upper = bound.get("upperBound").and_then(|v| v.as_str());
318        if let (Some(lb), Some(ub)) = (lower, upper) {
319            let vers = format!("vers:swift/>={lb}|<{ub}");
320            return (Some(vers), false, "range");
321        }
322    }
323
324    if let Some(branch) = requirement.get("branch").and_then(|v| v.as_array())
325        && let Some(branch_name) = branch.first().and_then(|v| v.as_str())
326    {
327        return (Some(branch_name.to_string()), false, "branch");
328    }
329
330    if let Some(revision) = requirement.get("revision").and_then(|v| v.as_array())
331        && let Some(rev) = revision.first().and_then(|v| v.as_str())
332    {
333        return (Some(rev.to_string()), true, "revision");
334    }
335
336    (None, false, "unknown")
337}
338
339fn create_dependency_purl(
340    namespace: &Option<String>,
341    name: &str,
342    version: &Option<String>,
343    is_pinned: bool,
344) -> String {
345    let mut purl = match PackageUrl::new(SwiftManifestJsonParser::PACKAGE_TYPE.as_str(), name) {
346        Ok(p) => p,
347        Err(e) => {
348            warn!(
349                "Failed to create PackageUrl for swift dependency '{}': {}",
350                name, e
351            );
352            return match (namespace, is_pinned.then_some(version.as_deref()).flatten()) {
353                (Some(ns), Some(v)) => format!("pkg:swift/{}/{}@{}", ns, name, v),
354                (Some(ns), None) => format!("pkg:swift/{}/{}", ns, name),
355                (None, Some(v)) => format!("pkg:swift/{}@{}", name, v),
356                (None, None) => format!("pkg:swift/{}", name),
357            };
358        }
359    };
360
361    if let Some(ns) = namespace
362        && let Err(e) = purl.with_namespace(ns)
363    {
364        warn!(
365            "Failed to set namespace '{}' for swift dependency '{}': {}",
366            ns, name, e
367        );
368    }
369
370    if is_pinned
371        && let Some(v) = version
372        && let Err(e) = purl.with_version(v)
373    {
374        warn!(
375            "Failed to set version '{}' for swift dependency '{}': {}",
376            v, name, e
377        );
378    }
379
380    purl.to_string()
381}
382
383fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
384    name.as_ref().and_then(|name| {
385        let mut package_url =
386            match PackageUrl::new(SwiftManifestJsonParser::PACKAGE_TYPE.as_str(), name) {
387                Ok(p) => p,
388                Err(e) => {
389                    warn!(
390                        "Failed to create PackageUrl for swift package '{}': {}",
391                        name, e
392                    );
393                    return None;
394                }
395            };
396
397        if let Some(v) = version
398            && let Err(e) = package_url.with_version(v)
399        {
400            warn!(
401                "Failed to set version '{}' for swift package '{}': {}",
402                v, name, e
403            );
404            return None;
405        }
406
407        Some(package_url.to_string())
408    })
409}
410
411fn default_package_data(path: &Path) -> PackageData {
412    let _ = path;
413
414    PackageData {
415        package_type: Some(SwiftManifestJsonParser::PACKAGE_TYPE),
416        primary_language: Some("Swift".to_string()),
417        datasource_id: Some(DatasourceId::SwiftPackageManifestJson),
418        ..Default::default()
419    }
420}
421
422crate::register_parser!(
423    "Swift Package Manager manifest JSON (Package.swift.json, Package.swift.deplock)",
424    &["**/Package.swift.json", "**/Package.swift.deplock"],
425    "swift",
426    "Swift",
427    Some("https://docs.swift.org/package-manager/PackageDescription/PackageDescription.html"),
428);