diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-04 00:32:45 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-04 00:32:45 +0900 |
| commit | 6449a15de90fe8252fb288bd5eacb99dc2cd699a (patch) | |
| tree | 81a2b5c2c595a67112d7211db6dcb670efb09ea0 /crates/mozart-core/src | |
| parent | 5fc05048a456ce18fd9408ea985031865cf45550 (diff) | |
| download | php-mozart-6449a15de90fe8252fb288bd5eacb99dc2cd699a.tar.gz php-mozart-6449a15de90fe8252fb288bd5eacb99dc2cd699a.tar.zst php-mozart-6449a15de90fe8252fb288bd5eacb99dc2cd699a.zip | |
fix(compat): align repositories/version/platform parsing with Composer
Three Composer-compat bugs surfaced by the github_issues_9290 fixture,
fixed together since they form one resolution path:
- RawPackageData.repositories now accepts a JSON object keyed by name,
matching RepositoryFactory::createRepos which iterates either int-
or string-keyed arrays via PHP foreach.
- Version::parse fills every unspecified position of a `.x-dev` branch
with 9999999, mirroring VersionParser::normalizeBranch. Previously
`2.x-dev` parsed to 2.0.9999999.9999999-dev and failed to satisfy
^2.8.
- is_platform_package limits the `php-` family to the closed set
{64bit,ipv6,zts,debug} per PLATFORM_PACKAGE_REGEX. Vendor packages
like `php-http/client-common` are no longer misclassified.
Unblocks github_issues_7051, _8903, _9012, _9290.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-core/src')
| -rw-r--r-- | crates/mozart-core/src/package.rs | 98 | ||||
| -rw-r--r-- | crates/mozart-core/src/platform.rs | 43 |
2 files changed, 131 insertions, 10 deletions
diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs index 18714ec..ccbda1f 100644 --- a/crates/mozart-core/src/package.rs +++ b/crates/mozart-core/src/package.rs @@ -1,5 +1,7 @@ +use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::fmt; use std::fs; use std::path::Path; @@ -505,7 +507,15 @@ pub struct RawPackageData { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub replace: BTreeMap<String, String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// `repositories` accepts either a JSON array or a JSON object keyed by + /// repository name. Composer iterates `foreach ($repoConfigs as ...)` in + /// `RepositoryFactory::createRepos`, so PHP transparently handles either + /// shape; mirror that here. + #[serde( + default, + deserialize_with = "deserialize_repositories", + skip_serializing_if = "Vec::is_empty" + )] pub repositories: Vec<RawRepository>, #[serde(skip_serializing_if = "Option::is_none")] @@ -587,6 +597,51 @@ fn default_root_package_name() -> String { "__root__".to_string() } +/// Deserialize `repositories` from either a JSON array or a JSON object. +/// PHP's `json_decode($x, true)` produces an associative array in either +/// case, and `RepositoryFactory::createRepos` iterates the values without +/// caring whether the key was an int (array) or a string (object). The map +/// keys are dropped — `RawRepository` doesn't carry a name field, and +/// downstream code doesn't depend on the original keying. +fn deserialize_repositories<'de, D>(deserializer: D) -> Result<Vec<RawRepository>, D::Error> +where + D: Deserializer<'de>, +{ + struct RepositoriesVisitor; + + impl<'de> Visitor<'de> for RepositoriesVisitor { + type Value = Vec<RawRepository>; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a sequence or map of repository definitions") + } + + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'de>, + { + let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); + while let Some(repo) = seq.next_element::<RawRepository>()? { + out.push(repo); + } + Ok(out) + } + + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + let mut out = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((_key, repo)) = map.next_entry::<String, RawRepository>()? { + out.push(repo); + } + Ok(out) + } + } + + deserializer.deserialize_any(RepositoriesVisitor) +} + impl RawPackageData { pub fn new(name: String) -> Self { Self { @@ -828,4 +883,45 @@ mod tests { let raw = RawPackageData::new("foo/bar".to_string()); assert!(raw.validate_root_does_not_self_require().is_ok()); } + + #[test] + fn raw_repositories_array_form() { + let json = r#"{ + "name": "test/array", + "repositories": [ + {"type": "vcs", "url": "https://example.com/a"}, + {"type": "vcs", "url": "https://example.com/b"} + ] + }"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.repositories.len(), 2); + assert_eq!( + raw.repositories[0].url.as_deref(), + Some("https://example.com/a") + ); + assert_eq!( + raw.repositories[1].url.as_deref(), + Some("https://example.com/b") + ); + } + + #[test] + fn raw_repositories_object_form() { + let json = r#"{ + "name": "test/object", + "repositories": { + "first": {"type": "vcs", "url": "https://example.com/a"}, + "second": {"type": "package", "package": {"name": "x/y", "version": "1.0.0"}} + } + }"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.repositories.len(), 2); + assert_eq!(raw.repositories[0].repo_type, "vcs"); + assert_eq!( + raw.repositories[0].url.as_deref(), + Some("https://example.com/a") + ); + assert_eq!(raw.repositories[1].repo_type, "package"); + assert!(raw.repositories[1].package.is_some()); + } } diff --git a/crates/mozart-core/src/platform.rs b/crates/mozart-core/src/platform.rs index 819d8c9..47ce2a0 100644 --- a/crates/mozart-core/src/platform.rs +++ b/crates/mozart-core/src/platform.rs @@ -16,17 +16,32 @@ pub struct PlatformPackage { /// Returns true if the package name is a Composer platform package. /// -/// Platform packages include: php, php-*, ext-*, lib-*, composer, -/// composer-plugin-api, composer-runtime-api. +/// Mirrors `Composer\Repository\PlatformRepository::PLATFORM_PACKAGE_REGEX`: +/// `php`, `php-64bit`, `php-ipv6`, `php-zts`, `php-debug`, `hhvm`, +/// `ext-<name>`, `lib-<name>`, `composer`, `composer-plugin-api`, +/// `composer-runtime-api`. The `php-` family is a closed set, NOT a wildcard +/// — `php-http/client-common` is a regular vendor package, not a platform +/// requirement. pub fn is_platform_package(name: &str) -> bool { let lower = name.to_lowercase(); - lower == "php" - || lower.starts_with("php-") - || lower.starts_with("ext-") - || lower.starts_with("lib-") - || lower == "composer" - || lower == "composer-plugin-api" - || lower == "composer-runtime-api" + match lower.as_str() { + "php" + | "php-64bit" + | "php-ipv6" + | "php-zts" + | "php-debug" + | "hhvm" + | "composer" + | "composer-plugin-api" + | "composer-runtime-api" => true, + _ => { + (lower.starts_with("ext-") || lower.starts_with("lib-")) + && lower[4..] + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + } + } } // ─── Detection ─────────────────────────────────────────────────────────────── @@ -378,6 +393,16 @@ mod tests { } #[test] + fn test_is_platform_package_php_prefix_vendor() { + // Vendor packages whose names happen to start with "php-" are NOT + // platform packages. Composer's PLATFORM_PACKAGE_REGEX limits the + // `php-` family to a closed set of suffixes (64bit/ipv6/zts/debug). + assert!(!is_platform_package("php-http/client-common")); + assert!(!is_platform_package("php-http/httplug")); + assert!(!is_platform_package("php-amqplib/php-amqplib")); + } + + #[test] fn test_parse_platform_info_basic() { let output = "PHP_VERSION:8.2.1\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:1\nEXTENSIONS:\njson:8.2.1\nctype:8.2.1\n"; let packages = parse_platform_info(output); |
