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