1use std::collections::HashMap;
20use std::fs::File;
21use std::io::Read;
22use std::path::Path;
23
24use log::warn;
25use packageurl::PackageUrl;
26use serde_json::Value;
27
28use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, ResolvedPackage};
29
30use super::PackageParser;
31
32const FIELD_NAME: &str = "name";
33const FIELD_VERSION: &str = "version";
34const FIELD_DESCRIPTION: &str = "description";
35const FIELD_HOMEPAGE: &str = "homepage";
36const FIELD_TYPE: &str = "type";
37const FIELD_LICENSE: &str = "license";
38const FIELD_AUTHORS: &str = "authors";
39const FIELD_KEYWORDS: &str = "keywords";
40const FIELD_REQUIRE: &str = "require";
41const FIELD_REQUIRE_DEV: &str = "require-dev";
42const FIELD_PROVIDE: &str = "provide";
43const FIELD_CONFLICT: &str = "conflict";
44const FIELD_REPLACE: &str = "replace";
45const FIELD_SUGGEST: &str = "suggest";
46const FIELD_SUPPORT: &str = "support";
47const FIELD_AUTOLOAD: &str = "autoload";
48const FIELD_PSR4: &str = "psr-4";
49const FIELD_REPOSITORIES: &str = "repositories";
50
51const FIELD_PACKAGES: &str = "packages";
52const FIELD_PACKAGES_DEV: &str = "packages-dev";
53const FIELD_SOURCE: &str = "source";
54const FIELD_DIST: &str = "dist";
55
56pub struct ComposerJsonParser;
58
59impl PackageParser for ComposerJsonParser {
60 const PACKAGE_TYPE: PackageType = PackageType::Composer;
61
62 fn extract_packages(path: &Path) -> Vec<PackageData> {
63 let json_content = match read_json_file(path) {
64 Ok(content) => content,
65 Err(e) => {
66 warn!("Failed to read composer.json at {:?}: {}", path, e);
67 return vec![default_package_data(Some(DatasourceId::PhpComposerJson))];
68 }
69 };
70
71 let full_name = json_content
72 .get(FIELD_NAME)
73 .and_then(|value| value.as_str())
74 .map(|value| value.trim())
75 .filter(|value| !value.is_empty());
76
77 let (namespace, name) = split_optional_namespace_name(full_name);
78 let is_private = name.is_none();
79
80 let version = json_content
81 .get(FIELD_VERSION)
82 .and_then(|value| value.as_str())
83 .map(|value| value.trim().to_string());
84
85 let description = json_content
86 .get(FIELD_DESCRIPTION)
87 .and_then(|value| value.as_str())
88 .map(|value| value.trim().to_string())
89 .filter(|value| !value.is_empty());
90
91 let homepage_url = json_content
92 .get(FIELD_HOMEPAGE)
93 .and_then(|value| value.as_str())
94 .map(|value| value.trim().to_string())
95 .filter(|value| !value.is_empty());
96
97 let keywords = extract_keywords(&json_content);
98
99 let extracted_license_statement = extract_license_statement(&json_content);
100
101 let declared_license_expression = None;
103 let declared_license_expression_spdx = None;
104 let license_detections = Vec::new();
105
106 let dependencies =
107 extract_dependencies(&json_content, FIELD_REQUIRE, "require", true, false);
108 let dev_dependencies =
109 extract_dependencies(&json_content, FIELD_REQUIRE_DEV, "require-dev", false, true);
110 let provide_dependencies =
111 extract_dependencies(&json_content, FIELD_PROVIDE, "provide", true, false);
112 let conflict_dependencies =
113 extract_dependencies(&json_content, FIELD_CONFLICT, "conflict", true, true);
114 let replace_dependencies =
115 extract_dependencies(&json_content, FIELD_REPLACE, "replace", true, true);
116 let suggest_dependencies =
117 extract_dependencies(&json_content, FIELD_SUGGEST, "suggest", true, true);
118
119 let (bug_tracking_url, code_view_url) = extract_support(&json_content);
120 let vcs_url = extract_source_vcs_url(&json_content);
121 let download_url = extract_dist_download_url(&json_content);
122 let extra_data = build_extra_data(&json_content);
123 let parties = extract_parties(&json_content, &namespace);
124
125 vec![PackageData {
126 package_type: Some(Self::PACKAGE_TYPE),
127 namespace: namespace.clone(),
128 name: name.clone(),
129 version: version.clone(),
130 qualifiers: None,
131 subpath: None,
132 primary_language: Some("PHP".to_string()),
133 description,
134 release_date: None,
135 parties,
136 keywords,
137 homepage_url,
138 download_url,
139 size: None,
140 sha1: None,
141 md5: None,
142 sha256: None,
143 sha512: None,
144 bug_tracking_url,
145 code_view_url,
146 vcs_url,
147 copyright: None,
148 holder: None,
149 declared_license_expression,
150 declared_license_expression_spdx,
151 license_detections,
152 other_license_expression: None,
153 other_license_expression_spdx: None,
154 other_license_detections: Vec::new(),
155 extracted_license_statement,
156 notice_text: None,
157 source_packages: Vec::new(),
158 file_references: Vec::new(),
159 is_private,
160 is_virtual: false,
161 extra_data,
162 dependencies: [
163 dependencies,
164 dev_dependencies,
165 provide_dependencies,
166 conflict_dependencies,
167 replace_dependencies,
168 suggest_dependencies,
169 ]
170 .concat(),
171 repository_homepage_url: build_repository_homepage_url(&namespace, &name),
172 repository_download_url: None,
173 api_data_url: build_api_data_url(&namespace, &name),
174 datasource_id: Some(DatasourceId::PhpComposerJson),
175 purl: build_package_purl(&namespace, &name, &version),
176 }]
177 }
178
179 fn is_match(path: &Path) -> bool {
180 path.file_name()
181 .and_then(|name| name.to_str())
182 .is_some_and(is_composer_manifest_filename)
183 }
184}
185
186pub struct ComposerLockParser;
188
189impl PackageParser for ComposerLockParser {
190 const PACKAGE_TYPE: PackageType = PackageType::Composer;
191
192 fn extract_packages(path: &Path) -> Vec<PackageData> {
193 let json_content = match read_json_file(path) {
194 Ok(content) => content,
195 Err(e) => {
196 warn!("Failed to read composer.lock at {:?}: {}", path, e);
197 return vec![default_package_data(Some(DatasourceId::PhpComposerLock))];
198 }
199 };
200
201 let dependencies = extract_lock_dependencies(&json_content);
202
203 let mut package_data = default_package_data(Some(DatasourceId::PhpComposerLock));
204 package_data.dependencies = dependencies;
205 vec![package_data]
206 }
207
208 fn is_match(path: &Path) -> bool {
209 path.file_name()
210 .and_then(|name| name.to_str())
211 .is_some_and(is_composer_lock_filename)
212 }
213}
214
215fn is_composer_manifest_filename(name: &str) -> bool {
216 name == "composer.json"
217 || name.ends_with(".composer.json")
218 || (name.starts_with("composer.") && name.ends_with(".json"))
219}
220
221fn is_composer_lock_filename(name: &str) -> bool {
222 name == "composer.lock"
223 || name.ends_with(".composer.lock")
224 || (name.starts_with("composer.") && name.ends_with(".lock"))
225}
226
227fn read_json_file(path: &Path) -> Result<Value, String> {
228 let mut file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
229 let mut content = String::new();
230 file.read_to_string(&mut content)
231 .map_err(|e| format!("Failed to read file: {}", e))?;
232 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
233}
234
235fn extract_dependencies(
236 json_content: &Value,
237 field: &str,
238 scope: &str,
239 is_runtime: bool,
240 is_optional: bool,
241) -> Vec<Dependency> {
242 json_content
243 .get(field)
244 .and_then(|value| value.as_object())
245 .map_or_else(Vec::new, |deps| {
246 deps.iter()
247 .filter_map(|(name, requirement)| {
248 let requirement_str = requirement.as_str()?;
249 let (namespace, package_name) = split_namespace_name(name);
250 let is_pinned = is_composer_version_pinned(requirement_str);
251 let version_for_purl = if is_pinned {
252 Some(normalize_requirement_version(requirement_str))
253 } else {
254 None
255 };
256
257 let purl = build_dependency_purl(
258 namespace.as_deref(),
259 &package_name,
260 version_for_purl.as_deref(),
261 );
262
263 Some(Dependency {
264 purl,
265 extracted_requirement: Some(requirement_str.to_string()),
266 scope: Some(scope.to_string()),
267 is_runtime: Some(is_runtime),
268 is_optional: Some(is_optional),
269 is_pinned: Some(is_pinned),
270 is_direct: Some(true),
271 resolved_package: None,
272 extra_data: None,
273 })
274 })
275 .collect()
276 })
277}
278
279fn extract_lock_dependencies(json_content: &Value) -> Vec<Dependency> {
280 let mut dependencies = Vec::new();
281
282 let packages = json_content
283 .get(FIELD_PACKAGES)
284 .and_then(|value| value.as_array())
285 .map(|packages| packages.as_slice())
286 .unwrap_or(&[]);
287 let packages_dev = json_content
288 .get(FIELD_PACKAGES_DEV)
289 .and_then(|value| value.as_array())
290 .map(|packages| packages.as_slice())
291 .unwrap_or(&[]);
292
293 dependencies.reserve(packages.len() + packages_dev.len());
294 dependencies.extend(extract_lock_package_list(packages, "require", true, false));
295 dependencies.extend(extract_lock_package_list(
296 packages_dev,
297 "require-dev",
298 false,
299 true,
300 ));
301
302 dependencies
303}
304
305fn extract_lock_package_list(
306 packages: &[Value],
307 scope: &str,
308 is_runtime: bool,
309 is_optional: bool,
310) -> Vec<Dependency> {
311 let mut dependencies = Vec::new();
312
313 for package in packages {
314 if let Some(dependency) = build_lock_dependency(package, scope, is_runtime, is_optional) {
315 dependencies.push(dependency);
316 }
317 }
318
319 dependencies
320}
321
322fn build_lock_dependency(
323 package: &Value,
324 scope: &str,
325 is_runtime: bool,
326 is_optional: bool,
327) -> Option<Dependency> {
328 let name = package.get(FIELD_NAME).and_then(|value| value.as_str())?;
329 let version = package
330 .get(FIELD_VERSION)
331 .and_then(|value| value.as_str())?;
332 let package_type = package.get(FIELD_TYPE).and_then(|value| value.as_str());
333
334 let (namespace, package_name) = split_namespace_name(name);
335 let purl = build_dependency_purl(namespace.as_deref(), &package_name, Some(version));
336
337 let source = package
338 .get(FIELD_SOURCE)
339 .and_then(|value| value.as_object());
340 let dist = package.get(FIELD_DIST).and_then(|value| value.as_object());
341
342 let (sha1, sha256, sha512, dist_shasum) = extract_dist_hashes(dist);
343 let dist_url = dist
344 .and_then(|map| map.get("url"))
345 .and_then(|value| value.as_str())
346 .map(|value| value.to_string());
347
348 let mut extra_data = HashMap::new();
349
350 if let Some(package_type) = package_type {
351 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
352 }
353
354 if let Some(source_map) = source {
355 if let Some(source_reference) = source_map.get("reference").and_then(|value| value.as_str())
356 {
357 extra_data.insert(
358 "source_reference".to_string(),
359 Value::String(source_reference.to_string()),
360 );
361 }
362
363 if let Some(source_url) = source_map.get("url").and_then(|value| value.as_str()) {
364 extra_data.insert(
365 "source_url".to_string(),
366 Value::String(source_url.to_string()),
367 );
368 }
369
370 if let Some(source_type) = source_map.get("type").and_then(|value| value.as_str()) {
371 extra_data.insert(
372 "source_type".to_string(),
373 Value::String(source_type.to_string()),
374 );
375 }
376 }
377
378 if let Some(dist_map) = dist {
379 if let Some(dist_reference) = dist_map.get("reference").and_then(|value| value.as_str()) {
380 extra_data.insert(
381 "dist_reference".to_string(),
382 Value::String(dist_reference.to_string()),
383 );
384 }
385
386 if let Some(dist_url) = dist_map.get("url").and_then(|value| value.as_str()) {
387 extra_data.insert("dist_url".to_string(), Value::String(dist_url.to_string()));
388 }
389
390 if let Some(dist_type) = dist_map.get("type").and_then(|value| value.as_str()) {
391 extra_data.insert(
392 "dist_type".to_string(),
393 Value::String(dist_type.to_string()),
394 );
395 }
396 }
397
398 if let Some(shasum) = dist_shasum {
399 extra_data.insert("dist_shasum".to_string(), Value::String(shasum));
400 }
401
402 let extra_data = if extra_data.is_empty() {
403 None
404 } else {
405 Some(extra_data)
406 };
407
408 let resolved_package = ResolvedPackage {
409 package_type: ComposerLockParser::PACKAGE_TYPE,
410 namespace: namespace.clone().unwrap_or_default(),
411 name: package_name.clone(),
412 version: version.to_string(),
413 primary_language: Some("PHP".to_string()),
414 download_url: dist_url,
415 sha1,
416 sha256,
417 sha512,
418 md5: None,
419 is_virtual: true,
420 extra_data: None,
421 dependencies: Vec::new(),
422 repository_homepage_url: None,
423 repository_download_url: None,
424 api_data_url: None,
425 datasource_id: Some(DatasourceId::PhpComposerLock),
426 purl: None,
427 };
428
429 Some(Dependency {
430 purl,
431 extracted_requirement: None,
432 scope: Some(scope.to_string()),
433 is_runtime: Some(is_runtime),
434 is_optional: Some(is_optional),
435 is_pinned: Some(true),
436 is_direct: Some(true),
437 resolved_package: Some(Box::new(resolved_package)),
438 extra_data,
439 })
440}
441
442fn extract_dist_hashes(
443 dist: Option<&serde_json::Map<String, Value>>,
444) -> (
445 Option<String>,
446 Option<String>,
447 Option<String>,
448 Option<String>,
449) {
450 let mut sha1 = None;
451 let mut sha256 = None;
452 let mut sha512 = None;
453 let mut raw_shasum = None;
454
455 if let Some(dist) = dist {
456 if let Some(shasum) = dist.get("shasum").and_then(|value| value.as_str()) {
457 let trimmed = shasum.trim();
458 if !trimmed.is_empty() {
459 raw_shasum = Some(trimmed.to_string());
460 let (parsed_sha1, parsed_sha256, parsed_sha512) = parse_hash_value(trimmed);
461 sha1 = parsed_sha1;
462 sha256 = parsed_sha256;
463 sha512 = parsed_sha512;
464 }
465 }
466
467 if let Some(value) = dist.get("sha1").and_then(|value| value.as_str())
468 && is_hex_hash(value)
469 {
470 sha1 = Some(value.to_string());
471 }
472 if let Some(value) = dist.get("sha256").and_then(|value| value.as_str())
473 && is_hex_hash(value)
474 {
475 sha256 = Some(value.to_string());
476 }
477 if let Some(value) = dist.get("sha512").and_then(|value| value.as_str())
478 && is_hex_hash(value)
479 {
480 sha512 = Some(value.to_string());
481 }
482 }
483
484 (sha1, sha256, sha512, raw_shasum)
485}
486
487fn parse_hash_value(hash: &str) -> (Option<String>, Option<String>, Option<String>) {
488 let trimmed = hash.trim();
489 if trimmed.is_empty() || !is_hex_hash(trimmed) {
490 return (None, None, None);
491 }
492
493 match trimmed.len() {
494 40 => (Some(trimmed.to_string()), None, None),
495 64 => (None, Some(trimmed.to_string()), None),
496 128 => (None, None, Some(trimmed.to_string())),
497 _ => (None, None, None),
498 }
499}
500
501fn is_hex_hash(value: &str) -> bool {
502 value.chars().all(|c| c.is_ascii_hexdigit())
503}
504
505fn extract_license_statement(json_content: &Value) -> Option<String> {
506 let mut licenses = Vec::new();
507
508 if let Some(license_value) = json_content.get(FIELD_LICENSE) {
509 match license_value {
510 Value::String(value) => {
511 let trimmed = value.trim();
512 if !trimmed.is_empty() {
513 licenses.push(trimmed.to_string());
514 }
515 }
516 Value::Array(values) => {
517 for value in values {
518 if let Some(license_str) = value.as_str() {
519 let trimmed = license_str.trim();
520 if !trimmed.is_empty() {
521 licenses.push(trimmed.to_string());
522 }
523 }
524 }
525 }
526 _ => {}
527 }
528 }
529
530 if licenses.is_empty() {
531 return None;
532 }
533
534 if licenses.len() == 1 {
535 Some(licenses[0].clone())
536 } else {
537 Some(licenses.join(" OR "))
538 }
539}
540
541fn extract_keywords(json_content: &Value) -> Vec<String> {
542 json_content
543 .get(FIELD_KEYWORDS)
544 .and_then(|value| value.as_array())
545 .map(|values| {
546 values
547 .iter()
548 .filter_map(|value| value.as_str().map(|value| value.to_string()))
549 .collect()
550 })
551 .unwrap_or_default()
552}
553
554fn extract_parties(json_content: &Value, namespace: &Option<String>) -> Vec<Party> {
555 let mut parties = Vec::new();
556
557 if let Some(authors) = json_content
558 .get(FIELD_AUTHORS)
559 .and_then(|value| value.as_array())
560 {
561 for author in authors {
562 if let Some(author) = author.as_object() {
563 let name = author
564 .get("name")
565 .and_then(|value| value.as_str())
566 .map(|value| value.to_string());
567 let role = author
568 .get("role")
569 .and_then(|value| value.as_str())
570 .map(|value| value.to_string())
571 .or(Some("author".to_string()));
572 let email = author
573 .get("email")
574 .and_then(|value| value.as_str())
575 .map(|value| value.to_string());
576 let url = author
577 .get("homepage")
578 .and_then(|value| value.as_str())
579 .map(|value| value.to_string());
580
581 if name.is_some() || email.is_some() || url.is_some() {
582 parties.push(Party {
583 r#type: Some("person".to_string()),
584 role,
585 name,
586 email,
587 url,
588 organization: None,
589 organization_url: None,
590 timezone: None,
591 });
592 }
593 }
594 }
595 }
596
597 if let Some(vendor) = namespace
598 .as_ref()
599 .map(|value| value.trim())
600 .filter(|value| !value.is_empty())
601 {
602 parties.push(Party {
603 r#type: Some("person".to_string()),
604 role: Some("vendor".to_string()),
605 name: Some(vendor.to_string()),
606 email: None,
607 url: None,
608 organization: None,
609 organization_url: None,
610 timezone: None,
611 });
612 }
613
614 parties
615}
616
617fn extract_support(json_content: &Value) -> (Option<String>, Option<String>) {
618 let support = json_content.get(FIELD_SUPPORT).and_then(|v| v.as_object());
619
620 if let Some(support_obj) = support {
621 let bug_tracking_url = support_obj
622 .get("issues")
623 .and_then(|v| v.as_str())
624 .map(|s| s.to_string());
625
626 let code_view_url = support_obj
627 .get("source")
628 .and_then(|v| v.as_str())
629 .map(|s| s.to_string());
630
631 (bug_tracking_url, code_view_url)
632 } else {
633 (None, None)
634 }
635}
636
637fn build_extra_data(json_content: &Value) -> Option<HashMap<String, Value>> {
638 let mut extra_data = HashMap::new();
639
640 if let Some(package_type) = json_content
641 .get(FIELD_TYPE)
642 .and_then(|value| value.as_str())
643 {
644 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
645 }
646
647 if let Some(autoload) = json_content
648 .get(FIELD_AUTOLOAD)
649 .and_then(|value| value.as_object())
650 && let Some(psr4) = autoload.get(FIELD_PSR4)
651 {
652 extra_data.insert("autoload_psr4".to_string(), psr4.clone());
653 }
654
655 if let Some(repositories) = json_content.get(FIELD_REPOSITORIES) {
656 extra_data.insert("repositories".to_string(), repositories.clone());
657 }
658
659 if extra_data.is_empty() {
660 None
661 } else {
662 Some(extra_data)
663 }
664}
665
666fn extract_source_vcs_url(json_content: &Value) -> Option<String> {
667 let source = json_content.get(FIELD_SOURCE)?.as_object()?;
668 let source_type = source.get("type")?.as_str()?.trim();
669 let source_url = source.get("url")?.as_str()?.trim();
670 let source_reference = source
671 .get("reference")
672 .and_then(|value| value.as_str())
673 .map(str::trim)
674 .filter(|value| !value.is_empty());
675
676 if source_type.is_empty() || source_url.is_empty() {
677 return None;
678 }
679
680 Some(match source_reference {
681 Some(reference) => format!("{}+{}@{}", source_type, source_url, reference),
682 None => format!("{}+{}", source_type, source_url),
683 })
684}
685
686fn extract_dist_download_url(json_content: &Value) -> Option<String> {
687 json_content
688 .get(FIELD_DIST)
689 .and_then(|value| value.as_object())
690 .and_then(|dist| dist.get("url"))
691 .and_then(|value| value.as_str())
692 .map(|value| value.trim().to_string())
693 .filter(|value| !value.is_empty())
694}
695
696fn build_repository_homepage_url(
697 namespace: &Option<String>,
698 name: &Option<String>,
699) -> Option<String> {
700 match (
701 namespace.as_ref().filter(|value| !value.is_empty()),
702 name.as_ref(),
703 ) {
704 (Some(ns), Some(name)) => Some(format!("https://packagist.org/packages/{}/{}", ns, name)),
705 (None, Some(name)) => Some(format!("https://packagist.org/packages/{}", name)),
706 _ => None,
707 }
708}
709
710fn build_api_data_url(namespace: &Option<String>, name: &Option<String>) -> Option<String> {
711 match (namespace.as_ref(), name.as_ref()) {
712 (Some(ns), Some(name)) if !ns.is_empty() => Some(format!(
713 "https://packagist.org/p/packages/{}/{}.json",
714 ns, name
715 )),
716 (None, Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
717 (Some(_), Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
718 _ => None,
719 }
720}
721
722fn build_package_purl(
723 namespace: &Option<String>,
724 name: &Option<String>,
725 version: &Option<String>,
726) -> Option<String> {
727 let name = name.as_ref()?;
728 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
729 Ok(purl) => purl,
730 Err(e) => {
731 warn!(
732 "Failed to create PackageUrl for composer package '{}': {}",
733 name, e
734 );
735 return None;
736 }
737 };
738
739 if let Some(namespace) = namespace.as_ref().filter(|value| !value.is_empty())
740 && let Err(e) = package_url.with_namespace(namespace)
741 {
742 warn!(
743 "Failed to set namespace '{}' for composer package '{}': {}",
744 namespace, name, e
745 );
746 return None;
747 }
748
749 if let Some(version) = version.as_ref()
750 && let Err(e) = package_url.with_version(version)
751 {
752 warn!(
753 "Failed to set version '{}' for composer package '{}': {}",
754 version, name, e
755 );
756 return None;
757 }
758
759 Some(package_url.to_string())
760}
761
762fn build_dependency_purl(
763 namespace: Option<&str>,
764 name: &str,
765 version: Option<&str>,
766) -> Option<String> {
767 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
768 Ok(purl) => purl,
769 Err(e) => {
770 warn!(
771 "Failed to create PackageUrl for composer package '{}': {}",
772 name, e
773 );
774 return None;
775 }
776 };
777
778 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
779 && let Err(e) = package_url.with_namespace(namespace)
780 {
781 warn!(
782 "Failed to set namespace '{}' for composer package '{}': {}",
783 namespace, name, e
784 );
785 return None;
786 }
787
788 if let Some(version) = version
789 && let Err(e) = package_url.with_version(version)
790 {
791 warn!(
792 "Failed to set version '{}' for composer package '{}': {}",
793 version, name, e
794 );
795 return None;
796 }
797
798 Some(package_url.to_string())
799}
800
801fn split_optional_namespace_name(full_name: Option<&str>) -> (Option<String>, Option<String>) {
802 match full_name {
803 Some(full_name) => {
804 let (namespace, name) = split_namespace_name(full_name);
805 (namespace, Some(name))
806 }
807 None => (None, None),
808 }
809}
810
811fn split_namespace_name(full_name: &str) -> (Option<String>, String) {
812 let mut iter = full_name.splitn(2, '/');
813 let first = iter.next().unwrap_or("");
814 let second = iter.next();
815
816 if let Some(name) = second {
817 (Some(first.to_string()), name.to_string())
818 } else {
819 (None, first.to_string())
820 }
821}
822
823fn normalize_requirement_version(requirement: &str) -> String {
824 let trimmed = requirement.trim();
825 trimmed.trim_start_matches('=').trim().to_string()
826}
827
828fn is_composer_version_pinned(version: &str) -> bool {
829 let trimmed = version.trim();
830 if trimmed.is_empty() {
831 return false;
832 }
833
834 if trimmed.contains(" - ")
835 || trimmed.contains('|')
836 || trimmed.contains(',')
837 || trimmed.contains('^')
838 || trimmed.contains('~')
839 || trimmed.contains('>')
840 || trimmed.contains('<')
841 || trimmed.contains('*')
842 {
843 return false;
844 }
845
846 let without_prefix = trimmed.trim_start_matches('=').trim();
847 let without_prefix = without_prefix.strip_prefix('v').unwrap_or(without_prefix);
848 if without_prefix.is_empty() {
849 return false;
850 }
851
852 let lower = without_prefix.to_lowercase();
853 if lower.contains("dev") {
854 return false;
855 }
856
857 if without_prefix
858 .chars()
859 .any(|c| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
860 {
861 return false;
862 }
863
864 without_prefix.matches('.').count() >= 2
865}
866
867fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
868 PackageData {
869 package_type: Some(ComposerJsonParser::PACKAGE_TYPE),
870 primary_language: Some("PHP".to_string()),
871 datasource_id,
872 ..Default::default()
873 }
874}
875
876crate::register_parser!(
877 "PHP composer manifest",
878 &["**/*composer.json", "**/composer.*.json"],
879 "composer",
880 "PHP",
881 Some("https://getcomposer.org/doc/04-schema.md"),
882);
883
884crate::register_parser!(
885 "PHP composer lockfile",
886 &["**/*composer.lock", "**/composer.*.lock"],
887 "composer",
888 "PHP",
889 Some("https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"),
890);