Skip to main content

vivacity_resolver/
optimizer.rs

1//! Port of `Composer\DependencyResolver\PoolOptimizer`: removes from the
2//! pool the versions whose dependencies are identical to a preferred
3//! version, and those a locked package makes impossible.
4
5use crate::constraint::{Constraint, Op};
6use crate::intervals;
7use crate::package::Package;
8use crate::policy::DefaultPolicy;
9use crate::pool::{OrderedMap, Pool, Request};
10use std::collections::{BTreeMap, HashMap, HashSet};
11
12/// String-keyed PHP array with an index: insertion order and O(1) access.
13#[derive(Debug, Clone)]
14struct IndexedMap<V> {
15    entries: Vec<(String, V)>,
16    index: HashMap<String, usize>,
17}
18
19impl<V> Default for IndexedMap<V> {
20    fn default() -> Self {
21        IndexedMap {
22            entries: Vec::new(),
23            index: HashMap::new(),
24        }
25    }
26}
27
28impl<V> IndexedMap<V> {
29    fn contains(&self, key: &str) -> bool {
30        self.index.contains_key(key)
31    }
32    /// `$map[$key] ??= $default` then a mutable reference.
33    fn entry_or_insert_with(&mut self, key: &str, default: impl FnOnce() -> V) -> &mut V {
34        let i = match self.index.get(key) {
35            Some(&i) => i,
36            None => {
37                self.entries.push((key.to_owned(), default()));
38                let i = self.entries.len() - 1;
39                self.index.insert(key.to_owned(), i);
40                i
41            }
42        };
43        &mut self.entries[i].1
44    }
45    fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
46        self.entries.iter().map(|(k, v)| (k, v))
47    }
48}
49
50pub struct PoolOptimizer {
51    /// Textual form of a constraint -> its disjunctive pieces
52    /// (`expandDisjunctiveMultiConstraints`), memoized: the same link text
53    /// shows up thousands of times in a pool.
54    expansion_cache: HashMap<String, Vec<(String, Constraint)>>,
55    irremovable: HashSet<usize>,
56    /// name -> constraints (deduplicated by textual form, insertion order).
57    require_constraints: HashMap<String, IndexedMap<Constraint>>,
58    conflict_constraints: HashMap<String, IndexedMap<Constraint>>,
59    to_remove: HashSet<usize>,
60    /// base id -> alias ids.
61    aliases_per_package: HashMap<usize, Vec<usize>>,
62    /// `removedVersionsByPackage`, keyed by arena index (PHP: object id).
63    removed_versions_by_package: BTreeMap<usize, BTreeMap<String, String>>,
64}
65
66impl PoolOptimizer {
67    pub fn new() -> PoolOptimizer {
68        PoolOptimizer {
69            expansion_cache: HashMap::new(),
70            irremovable: HashSet::new(),
71            require_constraints: HashMap::new(),
72            conflict_constraints: HashMap::new(),
73            to_remove: HashSet::new(),
74            aliases_per_package: HashMap::new(),
75            removed_versions_by_package: BTreeMap::new(),
76        }
77    }
78
79    /// `optimize($request, $pool)`: the reduced pool (same arena indices,
80    /// new ids).
81    pub fn optimize(
82        mut self,
83        request: &Request,
84        pool: &Pool,
85        arena: &[Package],
86        policy: &mut DefaultPolicy,
87    ) -> Pool {
88        self.prepare(request, pool, arena);
89        self.optimize_by_identical_dependencies(pool, arena, policy);
90        self.optimize_impossible_packages_away(request, pool, arena);
91        // `applyRemovalsToPool`: the dropped versions are remembered for the
92        // messages, per name and per kept package.
93        let mut kept: Vec<usize> = Vec::new();
94        let mut removed_versions: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
95        for id in 1..=pool.len() {
96            let idx = pool.package_by_id(id);
97            if self.to_remove.contains(&id) {
98                let p = &arena[idx];
99                removed_versions
100                    .entry(p.name.clone())
101                    .or_default()
102                    .insert(p.version.clone(), p.pretty_version.clone());
103            } else {
104                kept.push(idx);
105            }
106        }
107        let mut optimized = pool.with_packages(kept, arena);
108        optimized.removed_versions = removed_versions;
109        optimized.removed_versions_by_package = self.removed_versions_by_package;
110        optimized
111    }
112
113    fn expand_disjunctive(constraint: &Constraint) -> Vec<Constraint> {
114        let compact = intervals::compact_constraint(constraint);
115        match &compact {
116            Constraint::Multi {
117                constraints,
118                conjunctive: false,
119            } => constraints.clone(),
120            _ => vec![compact],
121        }
122    }
123
124    /// `extractRequireConstraintsPerPackage` / `...Conflict...`: `pretty` is
125    /// the memoization key (same text -> same parsed constraint).
126    fn extract(
127        cache: &mut HashMap<String, Vec<(String, Constraint)>>,
128        map: &mut HashMap<String, IndexedMap<Constraint>>,
129        package: &str,
130        pretty: &str,
131        constraint: &Constraint,
132    ) {
133        // `self.version`: same text, different constraint per package.
134        let pretty = if pretty == "self.version" {
135            format!("\u{0}{constraint}")
136        } else {
137            pretty.to_owned()
138        };
139        let expansions = cache.entry(pretty).or_insert_with(|| {
140            Self::expand_disjunctive(constraint)
141                .into_iter()
142                .map(|c| (c.to_string(), c))
143                .collect()
144        });
145        let per_name = map.entry(package.to_owned()).or_default();
146        for (key, expanded) in expansions {
147            // `$map[$package][(string) $expanded] = $expanded`: in-place
148            // rewrite, same textual form -> same constraint.
149            if !per_name.contains(key) {
150                per_name.entry_or_insert_with(key, || expanded.clone());
151            }
152        }
153    }
154
155    /// `prepare`.
156    fn prepare(&mut self, request: &Request, pool: &Pool, arena: &[Package]) {
157        let mut irremovable_groups: OrderedMap<Vec<Constraint>> = OrderedMap::default();
158        for idx in request.fixed_or_locked_packages() {
159            let p = &arena[idx];
160            let c = Constraint::new(Op::Eq, p.version.clone());
161            match irremovable_groups.0.iter_mut().find(|(k, _)| *k == p.name) {
162                Some((_, list)) => list.push(c),
163                None => irremovable_groups.insert(&p.name, vec![c]),
164            }
165        }
166        for (name, constraint) in request.requires.iter() {
167            // Root constraints have no stable text at hand: their display
168            // form serves as the key.
169            let pretty = format!("\u{0}{constraint}");
170            Self::extract(
171                &mut self.expansion_cache,
172                &mut self.require_constraints,
173                name,
174                &pretty,
175                constraint,
176            );
177        }
178        for id in 1..=pool.len() {
179            let p = &arena[pool.package_by_id(id)];
180            for link in p.requires.iter() {
181                Self::extract(
182                    &mut self.expansion_cache,
183                    &mut self.require_constraints,
184                    &link.target,
185                    &link.pretty_constraint,
186                    &link.constraint,
187                );
188            }
189            for link in p.conflicts.iter() {
190                Self::extract(
191                    &mut self.expansion_cache,
192                    &mut self.conflict_constraints,
193                    &link.target,
194                    &link.pretty_constraint,
195                    &link.constraint,
196                );
197            }
198            if let Some(base) = p.alias_of.and_then(|b| pool.id_of(b)) {
199                self.aliases_per_package.entry(base).or_default().push(id);
200            }
201        }
202        let irremovable: HashMap<String, Constraint> = irremovable_groups
203            .0
204            .into_iter()
205            .map(|(name, constraints)| {
206                let c = if constraints.len() == 1 {
207                    constraints.into_iter().next().expect("one")
208                } else {
209                    Constraint::Multi {
210                        constraints,
211                        conjunctive: false,
212                    }
213                };
214                (name, c)
215            })
216            .collect();
217        for id in 1..=pool.len() {
218            let p = &arena[pool.package_by_id(id)];
219            let Some(c) = irremovable.get(&p.name) else {
220                continue;
221            };
222            if c.matches_version(&p.version) {
223                self.mark_irremovable(id, pool, arena);
224            }
225        }
226    }
227
228    fn mark_irremovable(&mut self, id: usize, pool: &Pool, arena: &[Package]) {
229        self.irremovable.insert(id);
230        if let Some(base) = arena[pool.package_by_id(id)]
231            .alias_of
232            .and_then(|b| pool.id_of(b))
233        {
234            self.mark_irremovable(base, pool, arena);
235        }
236        if let Some(aliases) = self.aliases_per_package.get(&id) {
237            for a in aliases.clone() {
238                self.irremovable.insert(a);
239            }
240        }
241    }
242
243    /// `calculateDependencyHash`.
244    fn dependency_hash(p: &Package) -> String {
245        let mut hash = String::new();
246        for (key, links) in [
247            ("requires", &p.requires),
248            ("conflicts", &p.conflicts),
249            ("replaces", &p.replaces),
250            ("provides", &p.provides),
251        ] {
252            if links.is_empty() {
253                continue;
254            }
255            hash.push_str(key);
256            hash.push(':');
257            let mut sub: BTreeMap<Vec<u8>, String> = BTreeMap::new();
258            for link in links.iter() {
259                sub.insert(link.target.as_bytes().to_vec(), link.constraint.to_string());
260            }
261            for (target, constraint) in sub {
262                hash.push_str(&String::from_utf8_lossy(&target));
263                hash.push('@');
264                hash.push_str(&constraint);
265            }
266        }
267        hash
268    }
269
270    /// `optimizeByIdenticalDependencies`.
271    fn optimize_by_identical_dependencies(
272        &mut self,
273        pool: &Pool,
274        arena: &[Package],
275        policy: &mut DefaultPolicy,
276    ) {
277        // name -> groupHash -> dependencyHash -> ids (insertion orders).
278        let mut identical: IndexedMap<IndexedMap<IndexedMap<Vec<usize>>>> = IndexedMap::default();
279        for id in 1..=pool.len() {
280            if self.irremovable.contains(&id) {
281                continue;
282            }
283            self.to_remove.insert(id);
284            let p = &arena[pool.package_by_id(id)];
285            let dependency_hash = Self::dependency_hash(p);
286            // The `replace` pieces do not depend on the constraint under
287            // examination: once per package.
288            let replace_parts: String = p
289                .replaces
290                .iter()
291                .filter(|l| l.constraint.matches_version(&p.version))
292                .map(|l| format!("require:{}", l.constraint))
293                .collect();
294            for name in p.names(false) {
295                let Some(requires) = self.require_constraints.get(&name) else {
296                    continue;
297                };
298                // Same for conflicts: once per name.
299                let conflict_parts: String = match self.conflict_constraints.get(&name) {
300                    Some(conflicts) => conflicts
301                        .iter()
302                        .filter(|(_, c)| c.matches_version(&p.version))
303                        .map(|(key, _)| format!("conflict:{key}"))
304                        .collect(),
305                    None => String::new(),
306                };
307                for (key, require_constraint) in requires.iter() {
308                    let matched = require_constraint.matches_version(&p.version);
309                    if !matched && replace_parts.is_empty() && conflict_parts.is_empty() {
310                        continue;
311                    }
312                    let mut group_hash = String::with_capacity(
313                        key.len() + 8 + replace_parts.len() + conflict_parts.len(),
314                    );
315                    if matched {
316                        group_hash.push_str("require:");
317                        group_hash.push_str(key);
318                    }
319                    group_hash.push_str(&replace_parts);
320                    group_hash.push_str(&conflict_parts);
321                    identical
322                        .entry_or_insert_with(&name, IndexedMap::default)
323                        .entry_or_insert_with(&group_hash, IndexedMap::default)
324                        .entry_or_insert_with(&dependency_hash, Vec::new)
325                        .push(id);
326                }
327            }
328        }
329        for (name, groups) in identical.entries {
330            for (_, group) in groups.entries {
331                for (_, ids) in group.entries {
332                    if ids.len() == 1 {
333                        self.keep_package_in_group(ids[0], pool, arena, &name, &ids);
334                        continue;
335                    }
336                    let literals: Vec<i64> = ids.iter().map(|&i| i as i64).collect();
337                    for preferred in policy.select_preferred_packages(pool, arena, &literals, None)
338                    {
339                        self.keep_package_in_group(preferred as usize, pool, arena, &name, &ids);
340                    }
341                }
342            }
343        }
344    }
345
346    /// `keepPackageInGroup`, with `recordRemovedVersionsForPackage` at the
347    /// reference's exact points (the group's versions are recorded on the
348    /// kept package BEFORE the early return, then on its alias/aliased
349    /// packages).
350    fn keep_package_in_group(
351        &mut self,
352        id: usize,
353        pool: &Pool,
354        arena: &[Package],
355        name: &str,
356        ids: &[usize],
357    ) {
358        let mut versions: BTreeMap<String, String> = BTreeMap::new();
359        for &gid in ids {
360            let mut idx = pool.package_by_id(gid);
361            let gp = &arena[idx];
362            if let Some(base) = gp.alias_of.filter(|_| gp.pretty_version == "9999999-dev") {
363                idx = base;
364            }
365            versions.insert(
366                arena[idx].version.clone(),
367                arena[idx].pretty_version.clone(),
368            );
369        }
370        let idx = pool.package_by_id(id);
371        self.record_removed_versions(idx, arena, name, &versions);
372        if !self.to_remove.contains(&id) {
373            return;
374        }
375        self.to_remove.remove(&id);
376        let p = &arena[idx];
377        if let Some(base_idx) = p.alias_of {
378            if let Some(base) = pool.id_of(base_idx) {
379                self.to_remove.remove(&base);
380                self.record_removed_versions(base_idx, arena, name, &versions);
381                if let Some(aliases) = self.aliases_per_package.get(&base).cloned() {
382                    for a in aliases {
383                        self.to_remove.remove(&a);
384                        self.record_removed_versions(pool.package_by_id(a), arena, name, &versions);
385                    }
386                }
387            }
388            return;
389        }
390        if let Some(aliases) = self.aliases_per_package.get(&id).cloned() {
391            for a in aliases {
392                self.to_remove.remove(&a);
393                self.record_removed_versions(pool.package_by_id(a), arena, name, &versions);
394            }
395        }
396    }
397
398    /// `recordRemovedVersionsForPackage`: only when the package carries
399    /// `name` itself (`getNames(false)`: name + replaces).
400    fn record_removed_versions(
401        &mut self,
402        idx: usize,
403        arena: &[Package],
404        name: &str,
405        versions: &BTreeMap<String, String>,
406    ) {
407        if !arena[idx].names(false).iter().any(|n| n == name) {
408            return;
409        }
410        let entry = self.removed_versions_by_package.entry(idx).or_default();
411        for (v, p) in versions {
412            entry.insert(v.clone(), p.clone());
413        }
414    }
415
416    /// `optimizeImpossiblePackagesAway`.
417    fn optimize_impossible_packages_away(
418        &mut self,
419        request: &Request,
420        pool: &Pool,
421        arena: &[Package],
422    ) {
423        let locked = request.locked_packages_all();
424        if locked.is_empty() {
425            return;
426        }
427        // name -> [(id, arena idx)] (pool order).
428        let mut index: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
429        for id in 1..=pool.len() {
430            if self.irremovable.contains(&id) {
431                continue;
432            }
433            let idx = pool.package_by_id(id);
434            let p = &arena[idx];
435            if self.aliases_per_package.contains_key(&id) || p.is_alias() {
436                continue;
437            }
438            if request.is_fixed_package(idx) || request.is_locked_package(idx) {
439                continue;
440            }
441            index.entry(p.name.clone()).or_default().push((id, idx));
442        }
443        for locked_idx in locked {
444            let p = &arena[locked_idx];
445            let unused = p
446                .names(false)
447                .iter()
448                .all(|n| !self.require_constraints.contains_key(n));
449            if unused {
450                continue;
451            }
452            for link in p.requires.iter() {
453                let Some(candidates) = index.get_mut(&link.target) else {
454                    continue;
455                };
456                let mut kept = Vec::new();
457                for (id, idx) in candidates.iter().copied() {
458                    if !link.constraint.matches_version(&arena[idx].version) {
459                        self.to_remove.insert(id);
460                    } else {
461                        kept.push((id, idx));
462                    }
463                }
464                *candidates = kept;
465            }
466        }
467    }
468}
469
470impl Default for PoolOptimizer {
471    fn default() -> Self {
472        Self::new()
473    }
474}