Skip to main content

provenant/parsers/
swift_manifest_json.rs

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