Skip to main content

vivacity_resolver/
version_selector.rs

1//! Port of `Composer\Package\Version\VersionSelector`
2//! (docs/reference/VersionSelector.php): the best candidate for a name
3//! (preferred stability, then descending version, filtered by platform
4//! requirements) and the recommended constraint for `require` (`^x.y`,
5//! `@stability`, branch alias).
6//!
7//! Collecting the candidates (`RepositorySet::findPackages`) is left to the
8//! caller: `find_best_candidate` receives already loaded indices.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use pcre2::bytes::Regex;
13use serde_json::{Map, Value};
14
15use crate::constraint::{Constraint, Op};
16use crate::package::Package;
17use crate::phpver::version_compare;
18use crate::platform::is_platform_package;
19use crate::platform_filter::PlatformRequirementFilter;
20use crate::version::{stability_rank, DEFAULT_BRANCH_ALIAS};
21
22/// `$this->platformConstraints`: name -> `[Constraint('==', version)]` of
23/// the `PlatformRepository` packages.
24pub fn platform_constraints(
25    platform: &[usize],
26    arena: &[Package],
27) -> BTreeMap<String, Vec<Constraint>> {
28    let mut out: BTreeMap<String, Vec<Constraint>> = BTreeMap::new();
29    for &idx in platform {
30        let p = &arena[idx];
31        out.entry(p.name.clone())
32            .or_default()
33            .push(Constraint::new(Op::Eq, &p.version));
34    }
35    out
36}
37
38/// A `Cannot use ...` warning; `verbose` means `IOInterface::VERBOSE`
39/// (a repeat for the same package/target pair, hidden in normal mode).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Warning {
42    pub message: String,
43    pub verbose: bool,
44}
45
46/// `findBestCandidate` on already found candidates: sort by preferred
47/// stability then version, platform filter, a `9999999-dev` alias returned
48/// as its base package. Warnings are returned in order.
49pub fn find_best_candidate(
50    candidates: &[usize],
51    arena: &[Package],
52    preferred_stability: &str,
53    filter: &PlatformRequirementFilter,
54    platform: &BTreeMap<String, Vec<Constraint>>,
55    warnings: &mut Vec<Warning>,
56) -> Option<usize> {
57    let min_priority = stability_rank(preferred_stability);
58    let mut sorted: Vec<usize> = candidates.to_vec();
59    // `usort`: accepted stabilities first (descending version), then the
60    // others by ascending stability; a total order, hence independent of
61    // the sorting algorithm (stable on both sides).
62    sorted.sort_by(|&a, &b| {
63        let (pa, pb) = (&arena[a], &arena[b]);
64        let (ra, rb) = (stability_rank(pa.stability), stability_rank(pb.stability));
65        if min_priority < ra && rb < ra {
66            return std::cmp::Ordering::Greater;
67        }
68        if min_priority < ra && ra < rb {
69            return std::cmp::Ordering::Less;
70        }
71        if min_priority >= ra && min_priority < rb {
72            return std::cmp::Ordering::Less;
73        }
74        version_compare(&pb.version, &pa.version)
75    });
76
77    let mut chosen = None;
78    if !platform.is_empty() && !matches!(filter, PlatformRequirementFilter::IgnoreAll) {
79        let mut already_warned: BTreeSet<String> = BTreeSet::new();
80        let mut already_seen: BTreeSet<String> = BTreeSet::new();
81        'candidates: for &idx in &sorted {
82            let pkg = &arena[idx];
83            let mut skip = false;
84            'links: for link in pkg.requires.iter() {
85                let name = link.key.as_deref().unwrap_or(&link.target);
86                if !is_platform_package(name) || filter.is_ignored(name) {
87                    continue;
88                }
89                let reason = if let Some(provided) = platform.get(name) {
90                    for provided_constraint in provided {
91                        if link.constraint.matches(provided_constraint) {
92                            continue 'links;
93                        }
94                        if filter.is_upper_bound_ignored(name)
95                            && filter
96                                .filter_constraint(name, &link.constraint, true)
97                                .matches(provided_constraint)
98                        {
99                            continue 'links;
100                        }
101                    }
102                    "is not satisfied by your platform"
103                } else {
104                    "is missing from your platform"
105                };
106                let is_latest = !already_seen.contains(&pkg.name);
107                already_seen.insert(pkg.name.clone());
108                let key = format!("{}/{}", pkg.name, link.target);
109                let first = !already_warned.contains(&key);
110                already_warned.insert(key);
111                let latest = if is_latest { "'s latest version" } else { "" };
112                warnings.push(Warning {
113                    message: format!(
114                        "Cannot use {}{latest} {} as it {} {} {} which {reason}.",
115                        pkg.pretty_name,
116                        pkg.pretty_version,
117                        link.kind.description(),
118                        link.target,
119                        link.pretty_constraint
120                    ),
121                    verbose: !first,
122                });
123                skip = true;
124            }
125            if skip {
126                continue 'candidates;
127            }
128            chosen = Some(idx);
129            break;
130        }
131    } else {
132        chosen = sorted.first().copied();
133    }
134
135    let idx = chosen?;
136    let pkg = &arena[idx];
137    if let Some(base) = pkg.alias_of {
138        if pkg.version == DEFAULT_BRANCH_ALIAS {
139            return Some(base);
140        }
141    }
142    Some(idx)
143}
144
145/// `findRecommendedRequireVersion`; `php_version` is the local PHP's
146/// `PHP_MAJOR.MINOR.RELEASE` (for the `ext-*` rule).
147pub fn find_recommended_require_version(
148    pkg: &Package,
149    arena: &[Package],
150    php_version: &str,
151) -> String {
152    if pkg.name.starts_with("ext-") {
153        let ext_version: Vec<&str> = pkg.version.split('.').take(3).collect();
154        if php_version == ext_version.join(".") {
155            return "*".to_owned();
156        }
157    }
158    if !pkg.is_dev() {
159        return transform_version(&pkg.version, &pkg.pretty_version, pkg.stability);
160    }
161    // `$loader->getBranchAlias($dumper->dump($package))`: the package's
162    // pretty version, `extra` and `default-branch` (for an alias: those of
163    // the aliased package, except the version).
164    let base = pkg.alias_of.map(|b| &arena[b]).unwrap_or(pkg);
165    let mut dumped = Map::new();
166    dumped.insert("version".into(), Value::String(pkg.pretty_version.clone()));
167    if let Some(extra) = base.raw.get("extra") {
168        dumped.insert("extra".into(), extra.clone());
169    }
170    if base.is_default_branch {
171        dumped.insert("default-branch".into(), Value::Bool(true));
172    }
173    if let Some(extra) = crate::loader::branch_alias(&dumped) {
174        if extra != DEFAULT_BRANCH_ALIAS {
175            static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
176            let re = RE.get_or_init(|| {
177                Regex::new(r"^(\d+\.\d+\.\d+)(\.9999999)-dev$").unwrap_or_else(|e| panic!("{e}"))
178            });
179            if let Ok(Some(m)) = re.captures(extra.as_bytes()) {
180                let head = m
181                    .get(1)
182                    .and_then(|g| std::str::from_utf8(g.as_bytes()).ok())
183                    .unwrap_or("");
184                let replaced = format!("{head}.0").replace(".9999999", ".0");
185                return transform_version(&replaced, &replaced, "dev");
186            }
187        }
188    }
189    pkg.pretty_version.clone()
190}
191
192/// `transformVersion`: `x.y.z.w` -> `^x.y` (`^0.y.z` below 1.0), suffix
193/// `@stability` when not stable; otherwise the pretty version as is.
194fn transform_version(version: &str, pretty_version: &str, stability: &str) -> String {
195    let parts: Vec<&str> = version.split('.').collect();
196    let fourth_ok = parts.len() == 4 && parts[3].starts_with(|c: char| c.is_ascii_digit());
197    if !fourth_ok {
198        return pretty_version.to_owned();
199    }
200    let kept: &[&str] = if parts[0] == "0" {
201        &parts[..3]
202    } else {
203        &parts[..2]
204    };
205    let mut version = kept.join(".");
206    if stability != "stable" {
207        version.push('@');
208        version.push_str(stability);
209    }
210    format!("^{version}")
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn transform_versions() {
219        assert_eq!(transform_version("1.2.3.0", "1.2.3", "stable"), "^1.2");
220        assert_eq!(transform_version("0.2.3.0", "0.2.3", "stable"), "^0.2.3");
221        assert_eq!(
222            transform_version("1.0.0.0-RC1", "1.0.0-RC1", "RC"),
223            "^1.0@RC"
224        );
225        assert_eq!(
226            transform_version("2.0.0.0-beta2", "v2.0.0-beta2", "beta"),
227            "^2.0@beta"
228        );
229        assert_eq!(transform_version("dev-main", "dev-main", "dev"), "dev-main");
230        assert_eq!(
231            transform_version("20240101", "20240101", "stable"),
232            "20240101"
233        );
234    }
235}