1use 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
40pub 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 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 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 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 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#[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
214fn 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
222fn 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
250fn 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 assert_eq!(
342 package_data.purl,
343 Some("pkg:haxe/haxelib@3.4.0".to_string())
344 );
345
346 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 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 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 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}