Skip to main content

provenant/parsers/
haxe.rs

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