Skip to main content

provenant/parsers/
composer.rs

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