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}
63
64impl PoolOptimizer {
65    pub fn new() -> PoolOptimizer {
66        PoolOptimizer {
67            expansion_cache: HashMap::new(),
68            irremovable: HashSet::new(),
69            require_constraints: HashMap::new(),
70            conflict_constraints: HashMap::new(),
71            to_remove: HashSet::new(),
72            aliases_per_package: HashMap::new(),
73        }
74    }
75
76    /// `optimize($request, $pool)`: the reduced pool (same arena indices,
77    /// new ids).
78    pub fn optimize(
79        mut self,
80        request: &Request,
81        pool: &Pool,
82        arena: &[Package],
83        policy: &mut DefaultPolicy,
84    ) -> Pool {
85        self.prepare(request, pool, arena);
86        self.optimize_by_identical_dependencies(pool, arena, policy);
87        self.optimize_impossible_packages_away(request, pool, arena);
88        let kept: Vec<usize> = (1..=pool.len())
89            .filter(|id| !self.to_remove.contains(id))
90            .map(|id| pool.package_by_id(id))
91            .collect();
92        pool.with_packages(kept, arena)
93    }
94
95    fn expand_disjunctive(constraint: &Constraint) -> Vec<Constraint> {
96        let compact = intervals::compact_constraint(constraint);
97        match &compact {
98            Constraint::Multi {
99                constraints,
100                conjunctive: false,
101            } => constraints.clone(),
102            _ => vec![compact],
103        }
104    }
105
106    /// `extractRequireConstraintsPerPackage` / `...Conflict...`: `pretty` is
107    /// the memoization key (same text -> same parsed constraint).
108    fn extract(
109        cache: &mut HashMap<String, Vec<(String, Constraint)>>,
110        map: &mut HashMap<String, IndexedMap<Constraint>>,
111        package: &str,
112        pretty: &str,
113        constraint: &Constraint,
114    ) {
115        // `self.version`: same text, different constraint per package.
116        let pretty = if pretty == "self.version" {
117            format!("\u{0}{constraint}")
118        } else {
119            pretty.to_owned()
120        };
121        let expansions = cache.entry(pretty).or_insert_with(|| {
122            Self::expand_disjunctive(constraint)
123                .into_iter()
124                .map(|c| (c.to_string(), c))
125                .collect()
126        });
127        let per_name = map.entry(package.to_owned()).or_default();
128        for (key, expanded) in expansions {
129            // `$map[$package][(string) $expanded] = $expanded`: in-place
130            // rewrite, same textual form -> same constraint.
131            if !per_name.contains(key) {
132                per_name.entry_or_insert_with(key, || expanded.clone());
133            }
134        }
135    }
136
137    /// `prepare`.
138    fn prepare(&mut self, request: &Request, pool: &Pool, arena: &[Package]) {
139        let mut irremovable_groups: OrderedMap<Vec<Constraint>> = OrderedMap::default();
140        for idx in request.fixed_or_locked_packages() {
141            let p = &arena[idx];
142            let c = Constraint::new(Op::Eq, p.version.clone());
143            match irremovable_groups.0.iter_mut().find(|(k, _)| *k == p.name) {
144                Some((_, list)) => list.push(c),
145                None => irremovable_groups.insert(&p.name, vec![c]),
146            }
147        }
148        for (name, constraint) in request.requires.iter() {
149            // Root constraints have no stable text at hand: their display
150            // form serves as the key.
151            let pretty = format!("\u{0}{constraint}");
152            Self::extract(
153                &mut self.expansion_cache,
154                &mut self.require_constraints,
155                name,
156                &pretty,
157                constraint,
158            );
159        }
160        for id in 1..=pool.len() {
161            let p = &arena[pool.package_by_id(id)];
162            for link in p.requires.iter() {
163                Self::extract(
164                    &mut self.expansion_cache,
165                    &mut self.require_constraints,
166                    &link.target,
167                    &link.pretty_constraint,
168                    &link.constraint,
169                );
170            }
171            for link in p.conflicts.iter() {
172                Self::extract(
173                    &mut self.expansion_cache,
174                    &mut self.conflict_constraints,
175                    &link.target,
176                    &link.pretty_constraint,
177                    &link.constraint,
178                );
179            }
180            if let Some(base) = p.alias_of.and_then(|b| pool.id_of(b)) {
181                self.aliases_per_package.entry(base).or_default().push(id);
182            }
183        }
184        let irremovable: HashMap<String, Constraint> = irremovable_groups
185            .0
186            .into_iter()
187            .map(|(name, constraints)| {
188                let c = if constraints.len() == 1 {
189                    constraints.into_iter().next().expect("one")
190                } else {
191                    Constraint::Multi {
192                        constraints,
193                        conjunctive: false,
194                    }
195                };
196                (name, c)
197            })
198            .collect();
199        for id in 1..=pool.len() {
200            let p = &arena[pool.package_by_id(id)];
201            let Some(c) = irremovable.get(&p.name) else {
202                continue;
203            };
204            if c.matches_version(&p.version) {
205                self.mark_irremovable(id, pool, arena);
206            }
207        }
208    }
209
210    fn mark_irremovable(&mut self, id: usize, pool: &Pool, arena: &[Package]) {
211        self.irremovable.insert(id);
212        if let Some(base) = arena[pool.package_by_id(id)]
213            .alias_of
214            .and_then(|b| pool.id_of(b))
215        {
216            self.mark_irremovable(base, pool, arena);
217        }
218        if let Some(aliases) = self.aliases_per_package.get(&id) {
219            for a in aliases.clone() {
220                self.irremovable.insert(a);
221            }
222        }
223    }
224
225    /// `calculateDependencyHash`.
226    fn dependency_hash(p: &Package) -> String {
227        let mut hash = String::new();
228        for (key, links) in [
229            ("requires", &p.requires),
230            ("conflicts", &p.conflicts),
231            ("replaces", &p.replaces),
232            ("provides", &p.provides),
233        ] {
234            if links.is_empty() {
235                continue;
236            }
237            hash.push_str(key);
238            hash.push(':');
239            let mut sub: BTreeMap<Vec<u8>, String> = BTreeMap::new();
240            for link in links.iter() {
241                sub.insert(link.target.as_bytes().to_vec(), link.constraint.to_string());
242            }
243            for (target, constraint) in sub {
244                hash.push_str(&String::from_utf8_lossy(&target));
245                hash.push('@');
246                hash.push_str(&constraint);
247            }
248        }
249        hash
250    }
251
252    /// `optimizeByIdenticalDependencies`.
253    fn optimize_by_identical_dependencies(
254        &mut self,
255        pool: &Pool,
256        arena: &[Package],
257        policy: &mut DefaultPolicy,
258    ) {
259        // name -> groupHash -> dependencyHash -> ids (insertion orders).
260        let mut identical: IndexedMap<IndexedMap<IndexedMap<Vec<usize>>>> = IndexedMap::default();
261        for id in 1..=pool.len() {
262            if self.irremovable.contains(&id) {
263                continue;
264            }
265            self.to_remove.insert(id);
266            let p = &arena[pool.package_by_id(id)];
267            let dependency_hash = Self::dependency_hash(p);
268            // The `replace` pieces do not depend on the constraint under
269            // examination: once per package.
270            let replace_parts: String = p
271                .replaces
272                .iter()
273                .filter(|l| l.constraint.matches_version(&p.version))
274                .map(|l| format!("require:{}", l.constraint))
275                .collect();
276            for name in p.names(false) {
277                let Some(requires) = self.require_constraints.get(&name) else {
278                    continue;
279                };
280                // Same for conflicts: once per name.
281                let conflict_parts: String = match self.conflict_constraints.get(&name) {
282                    Some(conflicts) => conflicts
283                        .iter()
284                        .filter(|(_, c)| c.matches_version(&p.version))
285                        .map(|(key, _)| format!("conflict:{key}"))
286                        .collect(),
287                    None => String::new(),
288                };
289                for (key, require_constraint) in requires.iter() {
290                    let matched = require_constraint.matches_version(&p.version);
291                    if !matched && replace_parts.is_empty() && conflict_parts.is_empty() {
292                        continue;
293                    }
294                    let mut group_hash = String::with_capacity(
295                        key.len() + 8 + replace_parts.len() + conflict_parts.len(),
296                    );
297                    if matched {
298                        group_hash.push_str("require:");
299                        group_hash.push_str(key);
300                    }
301                    group_hash.push_str(&replace_parts);
302                    group_hash.push_str(&conflict_parts);
303                    identical
304                        .entry_or_insert_with(&name, IndexedMap::default)
305                        .entry_or_insert_with(&group_hash, IndexedMap::default)
306                        .entry_or_insert_with(&dependency_hash, Vec::new)
307                        .push(id);
308                }
309            }
310        }
311        for (name, groups) in identical.entries {
312            for (_, group) in groups.entries {
313                for (_, ids) in group.entries {
314                    if ids.len() == 1 {
315                        self.keep_package_in_group(ids[0], pool, arena, &name, &ids);
316                        continue;
317                    }
318                    let literals: Vec<i64> = ids.iter().map(|&i| i as i64).collect();
319                    for preferred in policy.select_preferred_packages(pool, arena, &literals, None)
320                    {
321                        self.keep_package_in_group(preferred as usize, pool, arena, &name, &ids);
322                    }
323                }
324            }
325        }
326    }
327
328    /// `keepPackageInGroup` (without `recordRemovedVersionsForPackage`, which
329    /// only serves the messages).
330    fn keep_package_in_group(
331        &mut self,
332        id: usize,
333        pool: &Pool,
334        arena: &[Package],
335        _name: &str,
336        _ids: &[usize],
337    ) {
338        if !self.to_remove.contains(&id) {
339            return;
340        }
341        self.to_remove.remove(&id);
342        let p = &arena[pool.package_by_id(id)];
343        if let Some(base) = p.alias_of.and_then(|b| pool.id_of(b)) {
344            self.to_remove.remove(&base);
345            if let Some(aliases) = self.aliases_per_package.get(&base) {
346                for a in aliases {
347                    self.to_remove.remove(a);
348                }
349            }
350            return;
351        }
352        if let Some(aliases) = self.aliases_per_package.get(&id) {
353            for a in aliases {
354                self.to_remove.remove(a);
355            }
356        }
357    }
358
359    /// `optimizeImpossiblePackagesAway`.
360    fn optimize_impossible_packages_away(
361        &mut self,
362        request: &Request,
363        pool: &Pool,
364        arena: &[Package],
365    ) {
366        let locked = request.locked_packages_all();
367        if locked.is_empty() {
368            return;
369        }
370        // name -> [(id, arena idx)] (pool order).
371        let mut index: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
372        for id in 1..=pool.len() {
373            if self.irremovable.contains(&id) {
374                continue;
375            }
376            let idx = pool.package_by_id(id);
377            let p = &arena[idx];
378            if self.aliases_per_package.contains_key(&id) || p.is_alias() {
379                continue;
380            }
381            if request.is_fixed_package(idx) || request.is_locked_package(idx) {
382                continue;
383            }
384            index.entry(p.name.clone()).or_default().push((id, idx));
385        }
386        for locked_idx in locked {
387            let p = &arena[locked_idx];
388            let unused = p
389                .names(false)
390                .iter()
391                .all(|n| !self.require_constraints.contains_key(n));
392            if unused {
393                continue;
394            }
395            for link in p.requires.iter() {
396                let Some(candidates) = index.get_mut(&link.target) else {
397                    continue;
398                };
399                let mut kept = Vec::new();
400                for (id, idx) in candidates.iter().copied() {
401                    if !link.constraint.matches_version(&arena[idx].version) {
402                        self.to_remove.insert(id);
403                    } else {
404                        kept.push((id, idx));
405                    }
406                }
407                *candidates = kept;
408            }
409        }
410    }
411}
412
413impl Default for PoolOptimizer {
414    fn default() -> Self {
415        Self::new()
416    }
417}