Skip to main content

vivacity_resolver/
flex_filter.rs

1//! Port of `Symfony\Flex\PackageFilter` (symfony/flex 2.11): Flex's
2//! `PRE_POOL_CREATE` listener (`Flex::truncatePackages`) that removes from
3//! the pool, before it is created, the versions of the packages listed in
4//! `symfony/symfony` that do not match `extra.symfony.require`
5//! (`SYMFONY_REQUIRE` wins; a trailing `.x` becomes `.x-dev`). The data
6//! comes from the Flex endpoints' `index.json`, key `versions`
7//! (`splits`: package → versions it ships in; `next`: the branch `.x`
8//! resolves to), merged first endpoint wins. Fetching and caching are the
9//! command layer's business (`vivacity::flex`); this module is pure.
10//!
11//! `ignorePreleases` (Composer < 2.9) is not ported: on 2.9+ Flex sets
12//! `COMPOSER_PREFER_DEV_OVER_PRERELEASE` instead, which the session applies
13//! to the policy.
14
15use crate::constraint::{Constraint, Op};
16use crate::intervals;
17use crate::package::Package;
18use crate::version;
19use serde_json::Value;
20use std::collections::{BTreeMap, HashMap};
21
22/// The `versions` entry of the Flex index.
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct FlexVersions {
25    /// `splits`: package name → the `<major>.<minor>` (or `<major>.x`)
26    /// versions it is split from (insertion order kept for the pruning).
27    pub splits: Vec<(String, Vec<String>)>,
28    /// `next`: what `.x` stands for.
29    pub next: String,
30}
31
32impl FlexVersions {
33    /// From the merged `versions` object (`splits` required — Flex throws
34    /// `The Flex index is missing a "splits" entry` otherwise).
35    pub fn from_value(v: &Value) -> Result<FlexVersions, String> {
36        let Some(splits) = v.get("splits").and_then(Value::as_object) else {
37            return Err("The Flex index is missing a \"splits\" entry. Did you forget to add \"flex://defaults\" in the \"extra.symfony.endpoint\" array of your composer.json?".to_owned());
38        };
39        let mut out = Vec::new();
40        for (name, vers) in splits {
41            let list: Vec<String> = vers
42                .as_array()
43                .map(|a| {
44                    a.iter()
45                        .filter_map(Value::as_str)
46                        .map(str::to_owned)
47                        .collect()
48                })
49                .unwrap_or_default();
50            out.push((name.clone(), list));
51        }
52        Ok(FlexVersions {
53            splits: out,
54            next: v
55                .get("next")
56                .and_then(Value::as_str)
57                .unwrap_or("")
58                .to_owned(),
59        })
60    }
61
62    /// `Downloader::initialize`'s `self::$versions += $config['versions']`
63    /// over the endpoints in order: the first endpoint's keys win.
64    pub fn merge_endpoints(indexes: &[Value]) -> Value {
65        let mut merged = serde_json::Map::new();
66        for index in indexes {
67            if let Some(v) = index.get("versions").and_then(Value::as_object) {
68                for (k, val) in v {
69                    merged.entry(k.clone()).or_insert_with(|| val.clone());
70                }
71            }
72        }
73        Value::Object(merged)
74    }
75
76    /// `PackageFilter::getVersions`: the splits pruned to the versions
77    /// whose `<v>.N.0` (`.x` → `next`) matches the constraint, up to 60
78    /// patch levels; an entry whose list is empty or unchanged is dropped
79    /// — a package absent from `splits` is then kept by the filter.
80    pub fn pruned(&self, symfony: &Constraint) -> FlexVersions {
81        let mut ok: HashMap<&str, bool> = HashMap::new();
82        let mut splits = Vec::new();
83        for (name, vers) in &self.splits {
84            let mut kept: Vec<String> = Vec::new();
85            for v in vers {
86                let is_ok = *ok.entry(v.as_str()).or_insert_with(|| {
87                    let w = if v.ends_with(".x") {
88                        self.next.as_str()
89                    } else {
90                        v.as_str()
91                    };
92                    (0..60).any(|j| symfony.matches_version(&norm_or_raw(&format!("{w}.{j}.0"))))
93                });
94                if is_ok {
95                    kept.push(v.clone());
96                }
97            }
98            if !kept.is_empty() && &kept != vers {
99                splits.push((name.clone(), kept));
100            }
101        }
102        FlexVersions {
103            splits,
104            next: self.next.clone(),
105        }
106    }
107
108    fn has(&self, name: &str) -> bool {
109        self.splits.iter().any(|(n, _)| n == name)
110    }
111}
112
113fn norm_or_raw(v: &str) -> String {
114    version::normalize(v, None).unwrap_or_else(|_| v.to_owned())
115}
116
117/// `Flex::activate`: the requirement, `SYMFONY_REQUIRE` over
118/// `extra.symfony.require`, `.x` → `.x-dev`. `None` when empty (no filter).
119pub fn symfony_require(manifest: &Value) -> Option<String> {
120    let from_env = std::env::var("SYMFONY_REQUIRE")
121        .ok()
122        .filter(|s| !s.is_empty());
123    let raw = from_env.or_else(|| {
124        manifest
125            .get("extra")?
126            .get("symfony")?
127            .get("require")?
128            .as_str()
129            .map(str::to_owned)
130    })?;
131    if raw.is_empty() {
132        return None;
133    }
134    Some(match raw.strip_suffix(".x") {
135        Some(base) => format!("{base}.x-dev"),
136        None => raw,
137    })
138}
139
140/// `PackageFilter::removeLegacyPackages`. `packages` are pool candidates
141/// (arena indices, aliases included); `root_constraints` is
142/// `getRequires() + getDevRequires()` (require wins on a name in both);
143/// `locked` the versions of `getFixedOrLockedPackages()` by name (an
144/// alias contributes the aliased version too); `versions` the index as
145/// fetched — pruned here (`getVersions`), so a package whose every split
146/// version matches the requirement, or none does, is not filtered.
147/// Returns the kept indices in order, and whether the `Restricting
148/// packages…` notice is due (once, when a package other than
149/// `symfony/symfony` loses a version to the constraint).
150pub fn remove_legacy_packages(
151    packages: &[usize],
152    arena: &[Package],
153    root_constraints: &BTreeMap<String, Constraint>,
154    locked: &BTreeMap<String, Vec<String>>,
155    symfony: &Constraint,
156    versions: &FlexVersions,
157) -> (Vec<usize>, bool) {
158    let versions = versions.pruned(symfony);
159    let mut kept: Vec<usize> = Vec::new();
160    let mut symfony_packages: Vec<usize> = Vec::new();
161    let mut one_symfony = false;
162    let mut restricting = false;
163    for &idx in packages {
164        let p = &arena[idx];
165        let name = p.name.as_str();
166        let mut candidate_versions: Vec<String> = vec![p.version.clone()];
167        if let Some(base) = p.alias_of {
168            candidate_versions.push(arena[base].version.clone());
169        }
170        let locked_hit = locked
171            .get(name)
172            .is_some_and(|l| candidate_versions.iter().any(|v| l.contains(v)));
173        let root_disjoint = root_constraints
174            .get(name)
175            .is_some_and(|c| !intervals::have_intersections(symfony, c));
176        let bridge =
177            name == "symfony/psr-http-message-bridge" && php_float(&candidate_versions[0]) < 6.4;
178        if name != "symfony/symfony"
179            && (locked_hit || !versions.has(name) || root_disjoint || bridge)
180        {
181            kept.push(idx);
182            continue;
183        }
184        // `$package->getExtra()['branch-alias'][$package->getVersion()]`
185        if let Some(alias) = p
186            .raw
187            .get("extra")
188            .and_then(|e| e.get("branch-alias"))
189            .and_then(|b| b.get(&p.version))
190            .and_then(Value::as_str)
191        {
192            candidate_versions.push(norm_or_raw(alias));
193        }
194        if candidate_versions.iter().any(|v| {
195            symfony.matches(&Constraint::Single {
196                op: Op::Eq,
197                version: v.clone(),
198            })
199        }) {
200            kept.push(idx);
201            one_symfony = one_symfony || name == "symfony/symfony";
202            continue;
203        }
204        if name == "symfony/symfony" {
205            symfony_packages.push(idx);
206        } else {
207            restricting = true;
208        }
209    }
210    if !symfony_packages.is_empty() && !one_symfony {
211        kept.extend(symfony_packages);
212    }
213    (kept, restricting)
214}
215
216/// PHP's `6.4 > $versions[0]`: the normalized version string cast to
217/// float (`"7.1.0.0"` → 7.1, `"dev-main"` → 0).
218fn php_float(version: &str) -> f64 {
219    let mut end = 0;
220    let bytes = version.as_bytes();
221    let mut seen_dot = false;
222    while end < bytes.len() {
223        let b = bytes[end];
224        if b.is_ascii_digit() {
225            end += 1;
226        } else if b == b'.' && !seen_dot {
227            seen_dot = true;
228            end += 1;
229        } else {
230            break;
231        }
232    }
233    version[..end].parse::<f64>().unwrap_or(0.0)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::constraint::parse_constraints;
240    use crate::package::Origin;
241    use serde_json::json;
242
243    fn cons(s: &str) -> Constraint {
244        parse_constraints(s).expect("constraint").constraint
245    }
246
247    fn pkg(name: &str, version: &str) -> Package {
248        let normalized = norm_or_raw(version);
249        Package::new(name, &normalized, version, Origin::Repository(0))
250    }
251
252    #[test]
253    fn symfony_require_env_wins_and_x_becomes_x_dev() {
254        let m = json!({"extra": {"symfony": {"require": "7.1.*"}}});
255        std::env::remove_var("SYMFONY_REQUIRE");
256        assert_eq!(symfony_require(&m).as_deref(), Some("7.1.*"));
257        let m = json!({"extra": {"symfony": {"require": "7.x"}}});
258        assert_eq!(symfony_require(&m).as_deref(), Some("7.x-dev"));
259        assert_eq!(symfony_require(&json!({})), None);
260    }
261
262    #[test]
263    fn pruning_keeps_the_matching_splits_and_drops_unchanged_entries() {
264        let v = FlexVersions {
265            splits: vec![
266                (
267                    "symfony/console".into(),
268                    vec!["6.4".into(), "7.0".into(), "7.1".into()],
269                ),
270                ("symfony/only-old".into(), vec!["5.4".into()]),
271                ("symfony/next".into(), vec!["7.x".into(), "7.1".into()]),
272                ("symfony/all-ok".into(), vec!["7.1".into()]),
273            ],
274            next: "7.2".into(),
275        };
276        let pruned = v.pruned(&cons("7.1.*"));
277        // console: 7.1 kept only; only-old: nothing → dropped; next: 7.x →
278        // 7.2 no, 7.1 yes → kept as [7.1]; all-ok: unchanged → dropped.
279        assert_eq!(
280            pruned.splits,
281            vec![
282                ("symfony/console".to_string(), vec!["7.1".to_string()]),
283                ("symfony/next".to_string(), vec!["7.1".to_string()]),
284            ]
285        );
286    }
287
288    #[test]
289    fn legacy_packages_are_removed_and_the_notice_is_due() {
290        let arena = vec![
291            pkg("symfony/console", "v7.0.3"),
292            pkg("symfony/console", "v7.1.0"),
293            pkg("symfony/not-split", "v1.0.0"),
294            pkg("symfony/locked", "v6.4.0"),
295            pkg("symfony/symfony", "v7.0.0"),
296            pkg("symfony/psr-http-message-bridge", "v2.3.1"),
297            pkg("symfony/all-in-range", "v6.0.0"),
298        ];
299        let packages: Vec<usize> = (0..arena.len()).collect();
300        let versions = FlexVersions {
301            splits: vec![
302                (
303                    "symfony/console".into(),
304                    vec!["6.4".into(), "7.0".into(), "7.1".into()],
305                ),
306                ("symfony/locked".into(), vec!["6.4".into(), "7.1".into()]),
307                (
308                    "symfony/psr-http-message-bridge".into(),
309                    vec!["6.4".into(), "7.1".into()],
310                ),
311                // Every split version matches: pruned away, never filtered.
312                ("symfony/all-in-range".into(), vec!["7.1".into()]),
313            ],
314            next: "7.2".into(),
315        };
316        let mut locked = BTreeMap::new();
317        locked.insert("symfony/locked".to_string(), vec!["6.4.0.0".to_string()]);
318        let (kept, notice) = remove_legacy_packages(
319            &packages,
320            &arena,
321            &BTreeMap::new(),
322            &locked,
323            &cons("7.1.*"),
324            &versions,
325        );
326        // console 7.0.3 dropped; 7.1.0 kept; not-split kept (absent from
327        // splits); locked kept (locked version); bridge < 6.4 kept;
328        // all-in-range kept (pruned away); symfony/symfony 7.0.0 appended
329        // because no symfony/symfony matched.
330        assert_eq!(kept, vec![1, 2, 3, 5, 6, 4]);
331        assert!(notice);
332    }
333
334    #[test]
335    fn root_constraint_disjoint_from_symfony_require_keeps_the_package() {
336        let arena = vec![pkg("symfony/console", "v6.4.0")];
337        let versions = FlexVersions {
338            splits: vec![("symfony/console".into(), vec!["6.4".into(), "7.1".into()])],
339            next: "7.2".into(),
340        };
341        let mut root = BTreeMap::new();
342        root.insert("symfony/console".to_string(), cons("^6.4"));
343        let (kept, notice) = remove_legacy_packages(
344            &[0],
345            &arena,
346            &root,
347            &BTreeMap::new(),
348            &cons("7.1.*"),
349            &versions,
350        );
351        assert_eq!(kept, vec![0]);
352        assert!(!notice);
353    }
354
355    #[test]
356    fn merge_endpoints_first_wins() {
357        let a = json!({"versions": {"next": "7.2", "splits": {"x": ["7.1"]}}});
358        let b = json!({"versions": {"next": "9.9", "lts": "6.4"}});
359        let m = FlexVersions::merge_endpoints(&[a, b]);
360        assert_eq!(m["next"], "7.2");
361        assert_eq!(m["lts"], "6.4");
362        assert!(FlexVersions::from_value(&m).is_ok());
363        assert!(FlexVersions::from_value(&json!({"next": "7.2"})).is_err());
364    }
365
366    #[test]
367    fn php_float_cast() {
368        assert_eq!(php_float("7.1.0.0"), 7.1);
369        assert_eq!(php_float("2.3.1.0"), 2.3);
370        assert_eq!(php_float("dev-main"), 0.0);
371        assert_eq!(php_float("9999999-dev"), 9999999.0);
372    }
373}