Skip to main content

provenant/parsers/
haxe.rs

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