aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 14:19:42 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 14:19:42 +0900
commit2d46dc9091c4fa1b68361425c561dad773a343b4 (patch)
treee924acfb4ccb62a86136e78e8b27c1a8b862e90a /crates
parent294bd3dd425a374eda13a52b925a2cd0c4db7f0a (diff)
downloadphp-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.rs85
-rw-r--r--crates/mozart/src/packagist.rs130
-rw-r--r--crates/mozart/src/resolver.rs189
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]