Skip to main content

provenant/parsers/
swift_manifest_json.rs

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