diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 14:19:42 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 14:19:42 +0900 |
| commit | 2d46dc9091c4fa1b68361425c561dad773a343b4 (patch) | |
| tree | e924acfb4ccb62a86136e78e8b27c1a8b862e90a /crates | |
| parent | 294bd3dd425a374eda13a52b925a2cd0c4db7f0a (diff) | |
| download | php-mozart-2d46dc9091c4fa1b68361425c561dad773a343b4.tar.gz php-mozart-2d46dc9091c4fa1b68361425c561dad773a343b4.tar.zst php-mozart-2d46dc9091c4fa1b68361425c561dad773a343b4.zip | |
feat(resolver): add inline and branch alias support
Inline aliases ("1.0.x-dev as 1.0.0") now use the right side for
constraint matching via parse_for_constraint(), while parse() keeps
the left side for version identity.
Branch aliases from extra.branch-alias metadata create synthetic
dev-stability entries in the resolver, allowing constraints like
^2.0 to match dev-master aliased to 2.x-dev. Real releases take
precedence via entry().or_insert().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart/src/constraint.rs | 85 | ||||
| -rw-r--r-- | crates/mozart/src/packagist.rs | 130 | ||||
| -rw-r--r-- | crates/mozart/src/resolver.rs | 189 |
3 files changed, 361 insertions, 43 deletions
diff --git a/crates/mozart/src/constraint.rs b/crates/mozart/src/constraint.rs index d009028..f17b504 100644 --- a/crates/mozart/src/constraint.rs +++ b/crates/mozart/src/constraint.rs @@ -94,6 +94,9 @@ impl Ord for Version { impl Version { /// Parse a version string into a `Version` struct using Composer normalization rules. + /// + /// For inline aliases (`"1.0.x-dev as 1.0.0"`), the LEFT side (the real branch version) + /// is used. This is the correct behaviour for identifying *what* version a package provides. pub fn parse(input: &str) -> Result<Version, String> { let s = input.trim(); @@ -168,6 +171,28 @@ impl Version { parse_classical_version(s) } + /// Parse a version string for use inside a *constraint expression*. + /// + /// The difference from [`Version::parse`] is the treatment of inline aliases: + /// `"1.0.x-dev as 1.0.0"` → takes the **right** side (`1.0.0`). + /// + /// Inline aliases appear in `require` fields like: + /// ```text + /// "some/package": "1.0.x-dev as 1.0.0" + /// ``` + /// Here the author wants the constraint to be satisfied by the real version `1.0.0`, + /// while the left side (`1.0.x-dev`) indicates the branch that provides it. + pub fn parse_for_constraint(input: &str) -> Result<Version, String> { + let s = input.trim(); + // For inline aliases, take the RIGHT side (alias target) + let s = if let Some(pos) = s.find(" as ") { + s[pos + 4..].trim() + } else { + s + }; + Version::parse(s) + } + /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { Version { @@ -361,6 +386,12 @@ fn split_or(s: &str) -> Vec<&str> { /// Parse an AND group (space or comma separated constraints). fn parse_and_group(s: &str) -> Result<VersionConstraint, String> { + // Detect inline alias first: "1.0.x-dev as 1.0.0" + // The entire expression is a single atomic constraint; parse it directly. + if s.contains(" as ") { + return parse_single(s); + } + // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit if let Some(idx) = s.find(" - ") { let before = s[..idx].trim(); @@ -461,28 +492,30 @@ fn parse_single(s: &str) -> Result<VersionConstraint, String> { } // Comparison operators + // Use parse_for_constraint so that inline aliases like "1.0.x-dev as 1.0.0" + // resolve to the alias target (right-hand side) when used in constraint context. if let Some(rest) = s.strip_prefix(">=") { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); } if let Some(rest) = s.strip_prefix("<=") { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); } if let Some(rest) = s.strip_prefix("!=") { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); } if let Some(rest) = s.strip_prefix('>') { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); } if let Some(rest) = s.strip_prefix('<') { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::LessThan(v))); } if let Some(rest) = s.strip_prefix('=') { - let v = Version::parse(rest.trim())?; + let v = Version::parse_for_constraint(rest.trim())?; return Ok(VersionConstraint::Single(Constraint::Exact(v))); } @@ -491,8 +524,8 @@ fn parse_single(s: &str) -> Result<VersionConstraint, String> { return parse_wildcard(s); } - // Exact version - let v = Version::parse(s)?; + // Exact version (may carry an inline alias; take the alias target for matching) + let v = Version::parse_for_constraint(s)?; Ok(VersionConstraint::Single(Constraint::Exact(v))) } @@ -590,8 +623,8 @@ fn parse_hyphen_range(s: &str) -> Result<VersionConstraint, String> { return Err(format!("Invalid hyphen range: {s}")); } - let lower_v = Version::parse(parts[0].trim())?; - let upper_v = Version::parse(parts[1].trim())?; + let lower_v = Version::parse_for_constraint(parts[0].trim())?; + let upper_v = Version::parse_for_constraint(parts[1].trim())?; Ok(VersionConstraint::And(vec![ VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), @@ -699,6 +732,38 @@ mod tests { assert!(v.is_dev_branch); } + #[test] + fn test_parse_for_constraint_inline_alias() { + // parse_for_constraint takes the RIGHT side of an inline alias + let v = Version::parse_for_constraint("1.0.x-dev as 1.0.0").unwrap(); + assert!(!v.is_dev_branch); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_for_constraint_no_alias() { + // Without an alias, parse_for_constraint behaves like parse + let v = Version::parse_for_constraint("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_constraint_inline_alias_exact_matches_target() { + // A constraint written as "1.0.x-dev as 1.0.0" should match 1.0.0 (the alias target) + let c = VersionConstraint::parse("1.0.x-dev as 1.0.0").unwrap(); + let target = Version::parse("1.0.0").unwrap(); + assert!(c.matches(&target)); + // But NOT a different version + let other = Version::parse("1.1.0").unwrap(); + assert!(!c.matches(&other)); + } + // ──────────── Version ordering ──────────── #[test] diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs index b589a77..a92eb63 100644 --- a/crates/mozart/src/packagist.rs +++ b/crates/mozart/src/packagist.rs @@ -69,6 +69,41 @@ pub struct PackagistVersion { pub notification_url: Option<String>, } +impl PackagistVersion { + /// Extract the `extra.branch-alias` map from this version's metadata. + /// + /// Composer packages can declare branch aliases in `extra.branch-alias`: + /// ```json + /// { + /// "extra": { + /// "branch-alias": { + /// "dev-master": "2.x-dev" + /// } + /// } + /// } + /// ``` + /// + /// Returns a map from branch name (e.g. `"dev-master"`) to alias target + /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared. + pub fn branch_aliases(&self) -> BTreeMap<String, String> { + let Some(extra) = &self.extra else { + return BTreeMap::new(); + }; + + let Some(branch_alias) = extra.get("branch-alias") else { + return BTreeMap::new(); + }; + + let Some(map) = branch_alias.as_object() else { + return BTreeMap::new(); + }; + + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + } +} + /// Parse a Packagist p2 API JSON response. /// /// The response format is: `{"packages": {"vendor/package": [...]}}`. @@ -179,4 +214,99 @@ mod tests { assert_eq!(versions[0].version, "dev-master"); assert_eq!(versions[1].version, "1.0.0"); } + + // ──────────── branch_aliases() tests ──────────── + + #[test] + fn test_branch_aliases_present() { + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "dev-master", + "version_normalized": "dev-master", + "require": {}, + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + } + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + let aliases = versions[0].branch_aliases(); + assert_eq!(aliases.len(), 1); + assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev"); + } + + #[test] + fn test_branch_aliases_multiple() { + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "dev-master", + "version_normalized": "dev-master", + "require": {}, + "extra": { + "branch-alias": { + "dev-master": "2.x-dev", + "dev-1.x": "1.5.x-dev" + } + } + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + let aliases = versions[0].branch_aliases(); + assert_eq!(aliases.len(), 2); + assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev"); + assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev"); + } + + #[test] + fn test_branch_aliases_no_extra() { + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "dev-master", + "version_normalized": "dev-master", + "require": {} + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + let aliases = versions[0].branch_aliases(); + assert!(aliases.is_empty()); + } + + #[test] + fn test_branch_aliases_extra_without_branch_alias_key() { + let json = r#"{ + "packages": { + "test/pkg": [ + { + "version": "dev-master", + "version_normalized": "dev-master", + "require": {}, + "extra": { + "installer-name": "my-plugin" + } + } + ] + } + }"#; + + let versions = parse_p2_response(json, "test/pkg").unwrap(); + let aliases = versions[0].branch_aliases(); + assert!(aliases.is_empty()); + } } diff --git a/crates/mozart/src/resolver.rs b/crates/mozart/src/resolver.rs index 645039b..ab837cf 100644 --- a/crates/mozart/src/resolver.rs +++ b/crates/mozart/src/resolver.rs @@ -101,6 +101,37 @@ impl fmt::Display for ComposerVersion { } impl ComposerVersion { + /// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a ComposerVersion + /// with dev stability. + /// + /// Used to represent aliased dev branches in the resolver. The version number is taken + /// from the numeric prefix (e.g. "2.x-dev" → major=2, minor=0, patch=0, build=0, stability=dev). + /// This allows constraints like `^2.0` to match `dev-master` when it is aliased to `2.x-dev`. + pub fn from_branch_alias_target(alias_target: &str) -> Option<ComposerVersion> { + let s = alias_target.trim().to_lowercase(); + // Must end with "-dev" or ".x-dev" + if !s.ends_with("-dev") { + return None; + } + // Strip the trailing "-dev" + let base = &s[..s.len() - 4]; + // Strip optional trailing ".x" segments (e.g. "2.x" → "2", "1.0.x" → "1.0") + let base = base.trim_end_matches(".x"); + // Now parse whatever numeric segments remain + let parts: Vec<&str> = base.split('.').collect(); + let major: u16 = parts.first().and_then(|p| p.parse().ok())?; + let minor: u16 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u16 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u16 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + Some(ComposerVersion { + major, + minor, + patch, + build, + stability: STABILITY_DEV, + }) + } + /// Parse from a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1", "1.0.0.0-RC2". /// Returns `None` for dev branches (dev-master, dev-*, *.x-dev). pub fn from_normalized(normalized: &str) -> Option<ComposerVersion> { @@ -604,41 +635,64 @@ impl MozartProvider { // Convert and filter let mut versions = BTreeMap::new(); for pv in &packagist_versions { - let Some(cv) = ComposerVersion::from_normalized(&pv.version_normalized) else { - continue; // Skip dev branches - }; + // Build the dependency metadata once (used for both the normal entry + // and any branch-alias synthetic entry). + let make_deps = + |version_string: String, version_normalized: String| VersionDependencies { + require: pv + .require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + replace: pv + .replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + provide: pv + .provide + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + conflict: pv + .conflict + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + version_string, + version_normalized, + }; - // Apply minimum-stability filter - if !self.passes_stability_filter(package_name, &cv) { - continue; + match ComposerVersion::from_normalized(&pv.version_normalized) { + Some(cv) => { + // Regular (non-dev) version + if self.passes_stability_filter(package_name, &cv) { + let deps = make_deps(pv.version.clone(), pv.version_normalized.clone()); + versions.insert(cv, deps); + } + } + None => { + // Dev branch — check for branch aliases + let aliases = pv.branch_aliases(); + for (branch, alias_target) in &aliases { + // The key in branch-alias is the full branch name, e.g. "dev-master". + // Verify it matches this version. + if branch.to_lowercase() != pv.version.to_lowercase() { + continue; + } + if let Some(alias_cv) = + ComposerVersion::from_branch_alias_target(alias_target) + && self.passes_stability_filter(package_name, &alias_cv) + { + // Use the alias target as the normalized version string so + // that constraint matching works correctly. + let deps = make_deps(pv.version.clone(), alias_target.clone()); + // Only insert if no real release already occupies this slot + versions.entry(alias_cv).or_insert(deps); + } + } + } } - - let deps = VersionDependencies { - require: pv - .require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - replace: pv - .replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - provide: pv - .provide - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - conflict: pv - .conflict - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - version_string: pv.version.clone(), - version_normalized: pv.version_normalized.clone(), - }; - - versions.insert(cv, deps); } let mut cache = self.package_cache.borrow_mut(); @@ -1741,6 +1795,75 @@ mod tests { ); } + // ──────────── Branch alias tests ──────────── + + #[test] + fn test_from_branch_alias_target_x_dev() { + let cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap(); + assert_eq!(cv.major, 2); + assert_eq!(cv.minor, 0); + assert_eq!(cv.patch, 0); + assert_eq!(cv.build, 0); + assert_eq!(cv.stability, STABILITY_DEV); + } + + #[test] + fn test_from_branch_alias_target_minor_x_dev() { + let cv = ComposerVersion::from_branch_alias_target("1.5.x-dev").unwrap(); + assert_eq!(cv.major, 1); + assert_eq!(cv.minor, 5); + assert_eq!(cv.patch, 0); + assert_eq!(cv.stability, STABILITY_DEV); + } + + #[test] + fn test_from_branch_alias_target_patch_x_dev() { + let cv = ComposerVersion::from_branch_alias_target("1.0.2.x-dev").unwrap(); + assert_eq!(cv.major, 1); + assert_eq!(cv.minor, 0); + assert_eq!(cv.patch, 2); + assert_eq!(cv.stability, STABILITY_DEV); + } + + #[test] + fn test_from_branch_alias_target_invalid() { + // Must end with -dev + assert!(ComposerVersion::from_branch_alias_target("dev-master").is_none()); + assert!(ComposerVersion::from_branch_alias_target("2.0.0").is_none()); + assert!(ComposerVersion::from_branch_alias_target("").is_none()); + } + + /// Test that a branch alias entry created from "dev-master" aliased to "2.x-dev" + /// is contained in the ^2.0 constraint range. + #[test] + fn test_branch_alias_in_range() { + // "2.x-dev" alias target → ComposerVersion { major: 2, stability: STABILITY_DEV } + let aliased_cv = ComposerVersion::from_branch_alias_target("2.x-dev").unwrap(); + // ^2.0 → >=2.0.0.0-dev <3.0.0.0-dev + let range = constraint_to_ranges("^2.0").unwrap(); + assert!( + range.contains(&aliased_cv), + "dev-master aliased to 2.x-dev should satisfy ^2.0" + ); + } + + /// Test that a branch alias entry for "1.0.x-dev" satisfies a ^1.0 constraint. + #[test] + fn test_branch_alias_1_x_in_range() { + let aliased_cv = ComposerVersion::from_branch_alias_target("1.0.x-dev").unwrap(); + let range = constraint_to_ranges("^1.0").unwrap(); + assert!( + range.contains(&aliased_cv), + "dev branch aliased to 1.0.x-dev should satisfy ^1.0" + ); + // But should NOT satisfy ^2.0 + let range2 = constraint_to_ranges("^2.0").unwrap(); + assert!( + !range2.contains(&aliased_cv), + "1.0.x-dev alias should not satisfy ^2.0" + ); + } + // ──────────── End-to-end tests (require network, marked #[ignore]) ──────────── #[test] |
