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