Skip to main content

provenant/parsers/
haxe.rs

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