Skip to main content

normalize_manifest/
composer.rs

1//! Parser for `composer.json` files (PHP/Composer).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use serde_json::Value;
5
6/// Parser for `composer.json` files.
7pub struct ComposerParser;
8
9impl ManifestParser for ComposerParser {
10    fn filename(&self) -> &'static str {
11        "composer.json"
12    }
13
14    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
15        let json: Value =
16            serde_json::from_str(content).map_err(|e| ManifestError(e.to_string()))?;
17
18        let name = json
19            .get("name")
20            .and_then(|v| v.as_str())
21            .map(|s| s.to_string());
22        let version = json
23            .get("version")
24            .and_then(|v| v.as_str())
25            .map(|s| s.to_string());
26
27        let mut deps = Vec::new();
28        extract_composer_deps(&json, "require", DepKind::Normal, &mut deps);
29        extract_composer_deps(&json, "require-dev", DepKind::Dev, &mut deps);
30
31        Ok(ParsedManifest {
32            ecosystem: "composer",
33            name,
34            version,
35            dependencies: deps,
36        })
37    }
38}
39
40fn extract_composer_deps(json: &Value, field: &str, kind: DepKind, out: &mut Vec<DeclaredDep>) {
41    let Some(obj) = json.get(field).and_then(|v| v.as_object()) else {
42        return;
43    };
44    for (name, ver) in obj {
45        // Skip platform requirements (php, ext-*, lib-*)
46        if name == "php" || name.starts_with("ext-") || name.starts_with("lib-") {
47            continue;
48        }
49        out.push(DeclaredDep {
50            name: name.clone(),
51            version_req: ver.as_str().map(|s| s.to_string()),
52            kind,
53        });
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::ManifestParser;
61
62    #[test]
63    fn test_parse_composer_json() {
64        let content = r#"{
65  "name": "vendor/my-package",
66  "version": "1.0.0",
67  "require": {
68    "php": ">=8.1",
69    "ext-json": "*",
70    "symfony/framework-bundle": "^6.0",
71    "doctrine/orm": "^2.13"
72  },
73  "require-dev": {
74    "phpunit/phpunit": "^10.0"
75  }
76}"#;
77        let m = ComposerParser.parse(content).unwrap();
78        assert_eq!(m.ecosystem, "composer");
79        assert_eq!(m.name.as_deref(), Some("vendor/my-package"));
80        assert_eq!(m.version.as_deref(), Some("1.0.0"));
81
82        // php and ext-json are filtered out
83        let normal: Vec<_> = m
84            .dependencies
85            .iter()
86            .filter(|d| d.kind == DepKind::Normal)
87            .collect();
88        assert_eq!(normal.len(), 2);
89        assert!(normal.iter().any(|d| d.name == "symfony/framework-bundle"));
90        assert!(normal.iter().any(|d| d.name == "doctrine/orm"));
91
92        let dev: Vec<_> = m
93            .dependencies
94            .iter()
95            .filter(|d| d.kind == DepKind::Dev)
96            .collect();
97        assert_eq!(dev.len(), 1);
98        assert_eq!(dev[0].name, "phpunit/phpunit");
99    }
100}