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