Skip to main content

provenant/parsers/
haxe.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Haxe package manifests (haxelib.json).
7//!
8//! Extracts package metadata and dependencies from Haxe haxelib.json files.
9//!
10//! # Supported Formats
11//! - haxelib.json (Haxe package manifest)
12//!
13//! # Key Features
14//! - Dependency extraction with pinned/unpinned version tracking
15//! - Contributor extraction with haxelib.org profile URLs
16//! - License statement extraction
17//! - Package URL (purl) generation
18//!
19//! # Implementation Notes
20//! - Dependencies with empty string value mean unpinned (latest version)
21//! - License must be one of: GPL, LGPL, BSD, Public, MIT, Apache
22//! - All fields are extracted with graceful error handling
23
24use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
25use crate::parser_warn as warn;
26use packageurl::PackageUrl;
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::path::Path;
30
31use super::PackageParser;
32use super::license_normalization::{
33    DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
34    empty_declared_license_data,
35};
36use super::metadata::ParserMetadata;
37use super::utils::{CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field};
38
39/// Haxe package manifest (haxelib.json) parser.
40///
41/// Extracts package metadata, dependencies, and contributor information from
42/// standard JSON haxelib.json manifest files used by the Haxe package manager.
43pub struct HaxeParser;
44
45impl PackageParser for HaxeParser {
46    const PACKAGE_TYPE: PackageType = PackageType::Haxe;
47
48    fn metadata() -> Vec<ParserMetadata> {
49        vec![ParserMetadata {
50            description: "Haxe haxelib.json package manifest",
51            file_patterns: &["**/haxelib.json"],
52            package_type: "haxe",
53            primary_language: "Haxe",
54            documentation_url: Some(
55                "https://lib.haxe.org/documentation/creating-a-haxelib-package/",
56            ),
57        }]
58    }
59
60    fn is_match(path: &Path) -> bool {
61        path.file_name().is_some_and(|name| name == "haxelib.json")
62    }
63
64    fn extract_packages(path: &Path) -> Vec<PackageData> {
65        let json_content = match read_haxelib_json(path) {
66            Ok(content) => content,
67            Err(e) => {
68                warn!("Failed to read or parse haxelib.json at {:?}: {}", path, e);
69                return vec![default_package_data()];
70            }
71        };
72
73        let name = json_content.name.map(truncate_field);
74        let version = json_content.version.map(truncate_field);
75
76        // Generate PURL
77        let purl = create_package_url(&name, &version);
78        let extracted_license_statement = json_content.license.map(truncate_field);
79        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
80            normalize_haxe_declared_license(extracted_license_statement.as_deref());
81
82        // Generate URLs
83        let (repository_homepage_url, download_url, repository_download_url) =
84            if let Some(ref n) = name {
85                let home = format!("https://lib.haxe.org/p/{}", n);
86                if let Some(ref v) = version {
87                    let dl = format!("https://lib.haxe.org/p/{}/{}/download/", n, v);
88                    (Some(home), Some(dl.clone()), Some(dl))
89                } else {
90                    (Some(home), None, None)
91                }
92            } else {
93                (None, None, None)
94            };
95
96        // Extract dependencies (maintain insertion order by sorting).
97        // `dependencies` is a std HashMap; sort before capping so truncation
98        // drops a deterministic set of entries when the cap is exceeded.
99        let mut dependencies = Vec::new();
100        let mut deps_list: Vec<_> = json_content.dependencies.into_iter().collect();
101        deps_list.sort_by(|a, b| a.0.cmp(&b.0));
102        let deps_limit = capped_iteration_limit(deps_list.len(), "haxelib.json dependencies");
103
104        for (dep_name, dep_version) in deps_list.into_iter().take(deps_limit) {
105            let is_pinned = !dep_version.is_empty();
106            let dep_purl = create_dep_package_url(&dep_name, &dep_version, is_pinned);
107
108            dependencies.push(Dependency {
109                purl: dep_purl,
110                extracted_requirement: None,
111                scope: None,
112                is_runtime: Some(true),
113                is_optional: Some(false),
114                is_pinned: Some(is_pinned),
115                is_direct: Some(true),
116                resolved_package: None,
117                extra_data: None,
118            });
119        }
120
121        // Extract contributors as parties
122        let mut parties = Vec::new();
123        for contrib in json_content
124            .contributors
125            .into_iter()
126            .capped("haxelib.json contributors")
127        {
128            parties.push(Party {
129                r#type: Some(PartyType::Person),
130                role: Some("contributor".to_string()),
131                name: Some(truncate_field(contrib.clone())),
132                email: None,
133                url: Some(format!("https://lib.haxe.org/u/{}", contrib)),
134                organization: None,
135                organization_url: None,
136                timezone: None,
137            });
138        }
139
140        vec![PackageData {
141            package_type: Some(Self::PACKAGE_TYPE),
142            namespace: None,
143            name,
144            version,
145            qualifiers: None,
146            subpath: None,
147            primary_language: Some("Haxe".to_string()),
148            description: json_content.description.map(truncate_field),
149            release_date: None,
150            parties,
151            keywords: json_content
152                .tags
153                .into_iter()
154                .capped("haxelib.json tags")
155                .map(truncate_field)
156                .collect(),
157            homepage_url: json_content.url.map(truncate_field),
158            download_url,
159            size: None,
160            sha1: None,
161            md5: None,
162            sha256: None,
163            sha512: None,
164            bug_tracking_url: None,
165            code_view_url: None,
166            vcs_url: None,
167            copyright: None,
168            holder: None,
169            declared_license_expression,
170            declared_license_expression_spdx,
171            license_detections,
172            other_license_expression: None,
173            other_license_expression_spdx: None,
174            other_license_detections: Vec::new(),
175            extracted_license_statement,
176            notice_text: None,
177            source_packages: Vec::new(),
178            file_references: Vec::new(),
179            is_private: false,
180            is_virtual: false,
181            extra_data: None,
182            dependencies,
183            repository_homepage_url,
184            repository_download_url,
185            api_data_url: None,
186            datasource_id: Some(DatasourceId::HaxelibJson),
187            purl,
188        }]
189    }
190}
191
192/// Internal structure for deserializing haxelib.json files.
193#[derive(Debug, Deserialize, Serialize)]
194struct HaxelibJson {
195    #[serde(default)]
196    name: Option<String>,
197    #[serde(default)]
198    version: Option<String>,
199    #[serde(default)]
200    license: Option<String>,
201    #[serde(default)]
202    url: Option<String>,
203    #[serde(default)]
204    description: Option<String>,
205    #[serde(default)]
206    tags: Vec<String>,
207    #[serde(default)]
208    contributors: Vec<String>,
209    #[serde(default)]
210    dependencies: HashMap<String, String>,
211}
212
213/// Read and parse a haxelib.json file.
214fn read_haxelib_json(path: &Path) -> Result<HaxelibJson, String> {
215    let content =
216        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
217
218    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
219}
220
221/// Create a package URL for a Haxe package.
222fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
223    name.as_ref().and_then(|name| {
224        let mut package_url = match PackageUrl::new("haxe", name) {
225            Ok(p) => p,
226            Err(e) => {
227                warn!(
228                    "Failed to create PackageUrl for haxe package '{}': {}",
229                    name, e
230                );
231                return None;
232            }
233        };
234
235        if let Some(v) = version
236            && let Err(e) = package_url.with_version(v)
237        {
238            warn!(
239                "Failed to set version '{}' for haxe package '{}': {}",
240                v, name, e
241            );
242            return None;
243        }
244
245        Some(package_url.to_string())
246    })
247}
248
249/// Create a package URL for a Haxe dependency.
250fn create_dep_package_url(name: &str, version: &str, is_pinned: bool) -> Option<String> {
251    let mut package_url = match PackageUrl::new("haxe", name) {
252        Ok(p) => p,
253        Err(e) => {
254            warn!(
255                "Failed to create PackageUrl for haxe dependency '{}': {}",
256                name, e
257            );
258            return None;
259        }
260    };
261
262    if is_pinned && let Err(e) = package_url.with_version(version) {
263        warn!(
264            "Failed to set version '{}' for haxe dependency '{}': {}",
265            version, name, e
266        );
267        return None;
268    }
269
270    Some(package_url.to_string())
271}
272
273fn default_package_data() -> PackageData {
274    PackageData {
275        package_type: Some(HaxeParser::PACKAGE_TYPE),
276        primary_language: Some("Haxe".to_string()),
277        datasource_id: Some(DatasourceId::HaxelibJson),
278        ..Default::default()
279    }
280}
281
282fn normalize_haxe_declared_license(
283    statement: Option<&str>,
284) -> (
285    Option<String>,
286    Option<String>,
287    Vec<crate::models::LicenseDetection>,
288) {
289    match statement.map(str::trim).filter(|value| !value.is_empty()) {
290        Some("MIT") => build_declared_license_data_from_pair(
291            "mit",
292            "MIT",
293            DeclaredLicenseMatchMetadata::single_line("MIT"),
294        ),
295        _ => empty_declared_license_data(),
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::models::DatasourceId;
303    use std::path::PathBuf;
304
305    #[test]
306    fn test_is_match() {
307        let valid_path = PathBuf::from("/some/path/haxelib.json");
308        let invalid_path = PathBuf::from("/some/path/not_haxelib.json");
309
310        assert!(HaxeParser::is_match(&valid_path));
311        assert!(!HaxeParser::is_match(&invalid_path));
312    }
313
314    #[test]
315    fn test_extract_from_testdata_basic() {
316        let haxelib_path = PathBuf::from("testdata/haxe/basic/haxelib.json");
317        let package_data = HaxeParser::extract_first_package(&haxelib_path);
318
319        assert_eq!(package_data.package_type, Some(PackageType::Haxe));
320        assert_eq!(package_data.name, Some("haxelib".to_string()));
321        assert_eq!(package_data.version, Some("3.4.0".to_string()));
322        assert_eq!(
323            package_data.homepage_url,
324            Some("https://lib.haxe.org/documentation/".to_string())
325        );
326        assert_eq!(
327            package_data.download_url,
328            Some("https://lib.haxe.org/p/haxelib/3.4.0/download/".to_string())
329        );
330        assert_eq!(
331            package_data.repository_homepage_url,
332            Some("https://lib.haxe.org/p/haxelib".to_string())
333        );
334        assert_eq!(
335            package_data.extracted_license_statement,
336            Some("GPL".to_string())
337        );
338
339        // Check PURL
340        assert_eq!(
341            package_data.purl,
342            Some("pkg:haxe/haxelib@3.4.0".to_string())
343        );
344
345        // Check contributors extraction
346        assert_eq!(package_data.parties.len(), 6);
347        let names: Vec<&str> = package_data
348            .parties
349            .iter()
350            .filter_map(|p| p.name.as_deref())
351            .collect();
352        assert!(names.contains(&"back2dos"));
353        assert!(names.contains(&"ncannasse"));
354    }
355
356    #[test]
357    fn test_extract_with_dependencies() {
358        let haxelib_path = PathBuf::from("testdata/haxe/deps/haxelib.json");
359        let package_data = HaxeParser::extract_first_package(&haxelib_path);
360
361        assert_eq!(package_data.name, Some("selecthxml".to_string()));
362        assert_eq!(package_data.version, Some("0.5.1".to_string()));
363
364        // Check dependencies: tink_core (unpinned), tink_macro (pinned to 3.23)
365        assert_eq!(package_data.dependencies.len(), 2);
366
367        let pinned_deps: Vec<_> = package_data
368            .dependencies
369            .iter()
370            .filter(|d| d.is_pinned == Some(true))
371            .collect();
372        assert_eq!(pinned_deps.len(), 1);
373        assert!(pinned_deps[0].purl.as_ref().unwrap().contains("@3.23"));
374
375        let unpinned_deps: Vec<_> = package_data
376            .dependencies
377            .iter()
378            .filter(|d| d.is_pinned == Some(false))
379            .collect();
380        assert_eq!(unpinned_deps.len(), 1);
381    }
382
383    #[test]
384    fn test_extract_with_tags() {
385        let haxelib_path = PathBuf::from("testdata/haxe/tags/haxelib.json");
386        let package_data = HaxeParser::extract_first_package(&haxelib_path);
387
388        assert_eq!(package_data.name, Some("tink_core".to_string()));
389        assert_eq!(package_data.version, Some("1.18.0".to_string()));
390        assert_eq!(
391            package_data.extracted_license_statement,
392            Some("MIT".to_string())
393        );
394
395        // Check keywords extracted from tags
396        assert_eq!(
397            package_data.keywords,
398            vec![
399                "tink".to_string(),
400                "cross".to_string(),
401                "utility".to_string(),
402                "reactive".to_string(),
403                "functional".to_string(),
404                "async".to_string(),
405                "lazy".to_string(),
406                "signal".to_string(),
407                "event".to_string(),
408            ]
409        );
410    }
411
412    #[test]
413    fn test_invalid_file() {
414        let nonexistent_path = PathBuf::from("testdata/haxe/nonexistent/haxelib.json");
415        let package_data = HaxeParser::extract_first_package(&nonexistent_path);
416
417        // Should return default data with proper type and datasource
418        assert_eq!(package_data.package_type, Some(PackageType::Haxe));
419        assert_eq!(package_data.datasource_id, Some(DatasourceId::HaxelibJson));
420        assert!(package_data.name.is_none());
421    }
422}