Skip to main content

vivacity_resolver/
problem.rs

1//! Composer's explanations of an unsolvable set: port of
2//! `SolverProblemsException::getPrettyString`, `Problem::getPrettyString`
3//! (with `getMissingPackageReason`, `getPackageList`,
4//! `formatDeduplicatedRules`…) and `Rule::getPrettyString`
5//! (docs/reference/resolver/). The text is Composer's `--no-ansi`
6//! rendering: the output styles (`<error>`, `<warning>`, `<info>`,
7//! `<comment>`, `<href=…>`) are stripped, any other angle-bracket text is
8//! kept verbatim.
9//!
10//! The repository lookups of the reference (`RepositorySet::findPackages`,
11//! `getProviders`) load metadata, hence the mutable arena; the pool must
12//! be the one the solver ran on (removed versions, policy removals).
13
14use crate::constraint::{Constraint, Op};
15use crate::package::{Origin, Package};
16use crate::phpver::version_compare;
17use crate::platform::is_platform_package;
18use crate::pool::{Pool, Repository, RepositorySet, Request};
19use crate::repository::{is_package_acceptable, Advisory};
20use crate::rule::{Reason, Rule};
21use crate::solver::SolvedProblem;
22use std::cmp::Ordering;
23use std::collections::{BTreeMap, BTreeSet, HashSet};
24
25/// Everything the messages read.
26pub struct MessageContext<'a> {
27    pub arena: &'a mut Vec<Package>,
28    pub set: &'a RepositorySet,
29    pub pool: &'a Pool,
30    pub request: &'a Request,
31    pub is_verbose: bool,
32    /// `IniHelper::getAll()` of the PHP in use.
33    pub ini_files: &'a [String],
34    /// `ext-*` the PHP in use has loaded (`extension_loaded`).
35    pub loaded_extensions: &'a BTreeSet<String>,
36}
37
38/// `RepositorySet::ALLOW_UNACCEPTABLE_STABILITIES`.
39const ALLOW_UNACCEPTABLE_STABILITIES: u8 = 4;
40/// `RepositorySet::ALLOW_SHADOWED_REPOSITORIES`.
41const ALLOW_SHADOWED_REPOSITORIES: u8 = 8;
42
43/// A constraint with its pretty string (`getPrettyString`).
44#[derive(Clone, Copy)]
45pub struct PrettyConstraint<'a> {
46    pub constraint: &'a Constraint,
47    pub pretty: &'a str,
48}
49
50// ---------------------------------------------------------------------------
51// SolverProblemsException
52
53/// `SolverProblemsException::getPrettyString(..., $isDevExtraction)`: the
54/// numbered problems, deduplicated, then the hints.
55pub fn pretty_string(
56    ctx: &mut MessageContext<'_>,
57    problems: &[SolvedProblem],
58    is_dev_extraction: bool,
59) -> String {
60    let installed_map = present_map(ctx);
61    let mut missing_extensions: Vec<String> = Vec::new();
62    let mut caused_by_lock = false;
63    let mut texts: Vec<String> = Vec::new();
64    for problem in problems {
65        texts.push(format!(
66            "{}\n",
67            problem_pretty_string(ctx, problem, &installed_map)
68        ));
69        // `getExtensionProblems($problem->getReasons())`: sections in their
70        // original order.
71        for rule in problem.sections.iter().flatten() {
72            if let Some(required) = required_package(ctx.arena, rule) {
73                if required.starts_with("ext-") && !missing_extensions.contains(&required) {
74                    missing_extensions.push(required);
75                }
76            }
77        }
78        caused_by_lock = caused_by_lock
79            || problem
80                .reasons()
81                .iter()
82                .any(|rule| is_caused_by_lock(ctx, rule));
83    }
84    let mut text = String::from("\n");
85    let mut seen: Vec<&String> = Vec::new();
86    let mut i = 1;
87    for t in &texts {
88        if seen.contains(&t) {
89            continue;
90        }
91        seen.push(t);
92        text.push_str(&format!("  Problem {i}{t}"));
93        i += 1;
94    }
95    let mut hints: Vec<String> = Vec::new();
96    if !is_dev_extraction
97        && (text.contains("could not be found") || text.contains("no matching package found"))
98    {
99        hints.push("Potential causes:\n - A typo in the package name\n - The package is not available in a stable-enough version according to your minimum-stability setting\n   see <https://getcomposer.org/doc/04-schema.md#minimum-stability> for more details.\n - It's a private package and you forgot to add a custom repository to find it\n\nRead <https://getcomposer.org/doc/articles/troubleshooting.md> for further common problems.".to_owned());
100    }
101    if !missing_extensions.is_empty() {
102        hints.push(extension_hint(ctx.ini_files, &missing_extensions));
103    }
104    if caused_by_lock
105        && !is_dev_extraction
106        && !ctx.request.update_allow_transitive_root_dependencies()
107    {
108        hints.push("Use the option --with-all-dependencies (-W) to allow upgrades, downgrades and removals for packages currently locked to specific versions.".to_owned());
109    }
110    if text.contains("found composer-plugin-api[2.0.0] but it does not match")
111        && text.contains("- ocramius/package-versions")
112    {
113        hints.push("<warning>ocramius/package-versions only provides support for Composer 2 in 1.8+, which requires PHP 7.4.</warning>\nIf you can not upgrade PHP you can require <info>composer/package-versions-deprecated</info> to resolve this with PHP 7.0+.".to_owned());
114    }
115    if text.contains("found composer-plugin-api[2.0.0] but it does not match") {
116        hints.push("You are using Composer 2, which some of your plugins seem to be incompatible with. Make sure you update your plugins or report a plugin-issue to ask them to support Composer 2.".to_owned());
117    }
118    if !hints.is_empty() {
119        text.push('\n');
120        text.push_str(&hints.join("\n\n"));
121    }
122    strip_output_styles(&text)
123}
124
125/// `createExtensionHint`.
126fn extension_hint(ini_files: &[String], missing: &[String]) -> String {
127    let mut paths: Vec<&String> = ini_files.iter().collect();
128    if paths.first().is_some_and(|p| p.is_empty()) {
129        if paths.len() == 1 {
130            return String::new();
131        }
132        paths.remove(0);
133    }
134    let args: Vec<String> = missing
135        .iter()
136        .map(|e| format!("--ignore-platform-req={e}"))
137        .collect();
138    let mut text = String::from(
139        "To enable extensions, verify that they are enabled in your .ini files:\n    - ",
140    );
141    text.push_str(
142        &paths
143            .iter()
144            .map(|p| p.as_str())
145            .collect::<Vec<_>>()
146            .join("\n    - "),
147    );
148    text.push_str(
149        "\nYou can also run `php --ini` in a terminal to see which files are used by PHP in CLI mode.",
150    );
151    text.push_str(&format!(
152        "\nAlternatively, you can run Composer with `{}` to temporarily ignore these required extensions.",
153        args.join(" ")
154    ));
155    text
156}
157
158/// `Rule::getRequiredPackage`.
159fn required_package(arena: &[Package], rule: &Rule) -> Option<String> {
160    match &rule.reason {
161        Reason::RootRequire { package_name, .. } => Some(package_name.clone()),
162        Reason::PackageRequires(link) => Some(link.target.clone()),
163        Reason::Fixed { package } | Reason::LockedFilterListRemoved { package } => {
164            Some(arena[*package].name.clone())
165        }
166        _ => None,
167    }
168}
169
170/// `Request::getPresentMap(true)`: pool ids of the locked repository's
171/// packages and of the fixed packages.
172fn present_map(ctx: &MessageContext<'_>) -> HashSet<usize> {
173    let mut map = HashSet::new();
174    if let Some(locked) = &ctx.request.locked_repository {
175        for &idx in locked {
176            if let Some(id) = ctx.pool.id_of(idx) {
177                map.insert(id);
178            }
179        }
180    }
181    for &idx in &ctx.request.fixed_packages {
182        if let Some(id) = ctx.pool.id_of(idx) {
183            map.insert(id);
184        }
185    }
186    map
187}
188
189/// Symfony's `OutputFormatter` without decoration: the known styles and
190/// `<href=…>` disappear, everything else (`<https://…>`) is printed as is.
191pub fn strip_output_styles(text: &str) -> String {
192    let mut out = String::with_capacity(text.len());
193    let mut rest = text;
194    while let Some(start) = rest.find('<') {
195        out.push_str(&rest[..start]);
196        let after = &rest[start + 1..];
197        let Some(end) = after.find('>') else {
198            out.push_str(rest);
199            return out;
200        };
201        let tag = &after[..end];
202        let name = tag.trim_start_matches('/');
203        let known = matches!(
204            name,
205            "error" | "warning" | "info" | "comment" | "question" | "highlight" | ""
206        ) || name.starts_with("href=")
207            || name.starts_with("fg=")
208            || name.starts_with("bg=")
209            || name.starts_with("options=");
210        if known && !tag.contains('\n') {
211            rest = &after[end + 1..];
212        } else {
213            out.push('<');
214            rest = after;
215        }
216    }
217    out.push_str(rest);
218    out
219}
220
221// ---------------------------------------------------------------------------
222// Problem
223
224/// `Problem::getPrettyString`.
225fn problem_pretty_string(
226    ctx: &mut MessageContext<'_>,
227    problem: &SolvedProblem,
228    installed_map: &HashSet<usize>,
229) -> String {
230    let reasons: Vec<Rule> = problem.reasons().into_iter().cloned().collect();
231    if reasons.len() == 1 {
232        let rule = &reasons[0];
233        match &rule.reason {
234            Reason::RootRequire {
235                package_name,
236                constraint,
237                pretty,
238            } if ctx
239                .pool
240                .what_provides(ctx.arena, package_name, Some(constraint))
241                .is_empty() =>
242            {
243                let (a, b) = missing_package_reason(
244                    ctx,
245                    package_name,
246                    Some(PrettyConstraint { constraint, pretty }),
247                );
248                return format!("\n    {a}{b}");
249            }
250            Reason::LockedFilterListRemoved { package } => {
251                let (a, b) = missing_locked_package_reason(ctx, *package);
252                return format!("\n    {a}{b}");
253            }
254            _ => {}
255        }
256    }
257    let mut sorted = reasons;
258    sorted.sort_by(|r1, r2| {
259        let p1 = rule_priority(r1);
260        let p2 = rule_priority(r2);
261        if p1 != p2 {
262            return p2.cmp(&p1);
263        }
264        php_string_cmp(&sortable_string(ctx, r1), &sortable_string(ctx, r2))
265    });
266    format_deduplicated_rules(ctx, &sorted, "    ", installed_map)
267}
268
269/// `getRulePriority`.
270fn rule_priority(rule: &Rule) -> i32 {
271    match &rule.reason {
272        Reason::Fixed { .. } | Reason::LockedFilterListRemoved { .. } => 3,
273        Reason::RootRequire { .. } => 2,
274        Reason::PackageConflict(_) | Reason::PackageRequires(_) => 1,
275        Reason::PackageSameName(_)
276        | Reason::Learned(_)
277        | Reason::PackageAlias { .. }
278        | Reason::PackageInverseAlias { .. } => 0,
279    }
280}
281
282/// `getSortableString`.
283fn sortable_string(ctx: &MessageContext<'_>, rule: &Rule) -> String {
284    match &rule.reason {
285        Reason::RootRequire { package_name, .. } => package_name.clone(),
286        Reason::Fixed { package } | Reason::LockedFilterListRemoved { package } => {
287            package_to_string(ctx, *package)
288        }
289        Reason::PackageConflict(link) | Reason::PackageRequires(link) => {
290            let source = source_package(ctx, rule);
291            format!(
292                "{}//{}",
293                package_to_string(ctx, source),
294                link_pretty_string(&ctx.arena[source], link)
295            )
296        }
297        Reason::PackageSameName(name) => name.clone(),
298        Reason::PackageAlias { alias } => package_to_string(ctx, *alias),
299        Reason::PackageInverseAlias { package } => package_to_string(ctx, *package),
300        Reason::Learned(_) => rule
301            .literals
302            .iter()
303            .map(|l| l.to_string())
304            .collect::<Vec<_>>()
305            .join("-"),
306    }
307}
308
309/// `BasePackage::__toString` (`getUniqueName`), with `AliasPackage`'s
310/// ` (alias of <version>)` / ` (root alias of <version>)` suffix.
311fn package_to_string(ctx: &MessageContext<'_>, idx: usize) -> String {
312    let p = &ctx.arena[idx];
313    match p.alias_of {
314        Some(base) => format!(
315            "{} ({}alias of {})",
316            p.unique_name(),
317            if p.root_package_alias { "root " } else { "" },
318            ctx.arena[base].version
319        ),
320        None => p.unique_name(),
321    }
322}
323
324/// PHP 8 `<=>` on strings: two numeric strings compare as numbers.
325pub fn php_string_cmp(a: &str, b: &str) -> Ordering {
326    match (php_numeric(a), php_numeric(b)) {
327        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
328        _ => a.as_bytes().cmp(b.as_bytes()),
329    }
330}
331
332/// `is_numeric` (leading whitespace allowed, trailing whitespace allowed
333/// since PHP 8), as a float.
334fn php_numeric(s: &str) -> Option<f64> {
335    let t = s.trim_matches([' ', '\t', '\n', '\r', '\x0B', '\x0C']);
336    if t.is_empty() {
337        return None;
338    }
339    let body = t.strip_prefix(['+', '-']).unwrap_or(t);
340    let (mantissa, exponent) = match body.split_once(['e', 'E']) {
341        Some((m, e)) => (m, Some(e)),
342        None => (body, None),
343    };
344    let (int_part, frac) = match mantissa.split_once('.') {
345        Some((i, f)) => (i, Some(f)),
346        None => (mantissa, None),
347    };
348    let digits = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit());
349    let mantissa_ok = match frac {
350        Some(f) => {
351            (digits(int_part) || int_part.is_empty())
352                && (digits(f) || f.is_empty())
353                && !(int_part.is_empty() && f.is_empty())
354        }
355        None => digits(int_part),
356    };
357    if !mantissa_ok {
358        return None;
359    }
360    if let Some(e) = exponent {
361        let e = e.strip_prefix(['+', '-']).unwrap_or(e);
362        if !digits(e) {
363            return None;
364        }
365    }
366    t.parse::<f64>().ok()
367}
368
369/// `Rule::getSourcePackage` (conflict or requires rules only).
370fn source_package(ctx: &MessageContext<'_>, rule: &Rule) -> usize {
371    match &rule.reason {
372        Reason::PackageConflict(link) => {
373            let p1 = dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(rule.literals[0]));
374            let p2 = dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(rule.literals[1]));
375            if link.source == ctx.arena[p1].name {
376                p1
377            } else {
378                p2
379            }
380        }
381        _ => dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(rule.literals[0])),
382    }
383}
384
385/// `deduplicateDefaultBranchAlias`.
386fn dedup_default_branch_alias(ctx: &MessageContext<'_>, idx: usize) -> usize {
387    let p = &ctx.arena[idx];
388    match p.alias_of {
389        Some(base) if p.pretty_version == "9999999-dev" => base,
390        _ => idx,
391    }
392}
393
394/// `Link::getPrettyString($sourcePackage)`: the CONSTRAINT's pretty string
395/// (`$this->constraint->getPrettyString()`), not `getPrettyConstraint()`.
396fn link_pretty_string(source: &Package, link: &crate::package::Link) -> String {
397    format!(
398        "{} {} {} {}",
399        source.pretty_string(),
400        link.kind.description(),
401        link.target,
402        link_constraint_pretty(source, link)
403    )
404}
405
406/// `$link->getConstraint()->getPrettyString()`: `ArrayLoader::createLink`
407/// parses `self.version` as the source's pretty version, so that is the
408/// constraint's own pretty string; every other link keeps its text.
409fn link_constraint_pretty(source: &Package, link: &crate::package::Link) -> String {
410    if link.pretty_constraint == "self.version" {
411        source.pretty_version.clone()
412    } else {
413        link.pretty_constraint.clone()
414    }
415}
416
417/// Package pretty name -> normalized version -> pretty version, in a
418/// deduplication template.
419type TemplatePackages = Vec<(String, BTreeMap<String, String>)>;
420
421/// `formatDeduplicatedRules`.
422fn format_deduplicated_rules(
423    ctx: &mut MessageContext<'_>,
424    rules: &[Rule],
425    indent: &str,
426    installed_map: &HashSet<usize>,
427) -> String {
428    // template -> package pretty name -> normalized version -> pretty
429    let mut messages: Vec<String> = Vec::new();
430    let mut templates: Vec<(String, TemplatePackages)> = Vec::new();
431    for rule in rules {
432        let message = rule_pretty_string(ctx, rule, installed_map);
433        let deduplicatable = matches!(
434            rule.reason,
435            Reason::PackageRequires(_) | Reason::PackageConflict(_)
436        );
437        let parsed = deduplicatable.then(|| parse_dedup_head(&message)).flatten();
438        if let Some((package, version, _)) = parsed {
439            let escaped = message.replace('%', "%%");
440            let head_len = escaped
441                .find(' ')
442                .and_then(|i| escaped[i + 1..].find(' ').map(|j| i + 1 + j + 1));
443            let template = match head_len {
444                Some(n) => format!("%s%s {}", &escaped[n..]),
445                None => escaped.clone(),
446            };
447            messages.push(template.clone());
448            let normalized = crate::version::normalize(&version, None).unwrap_or(version.clone());
449            let source = source_package(ctx, rule);
450            let entry = match templates.iter_mut().find(|(t, _)| *t == template) {
451                Some((_, e)) => e,
452                None => {
453                    templates.push((template.clone(), Vec::new()));
454                    &mut templates.last_mut().expect("just pushed").1
455                }
456            };
457            let versions = match entry.iter_mut().find(|(p, _)| *p == package) {
458                Some((_, v)) => v,
459                None => {
460                    entry.push((package.clone(), BTreeMap::new()));
461                    &mut entry.last_mut().expect("just pushed").1
462                }
463            };
464            versions.insert(normalized, version);
465            for (v, pretty) in ctx.pool.removed_versions_by_package(source) {
466                versions.insert(v, pretty);
467            }
468        } else if !message.is_empty() {
469            messages.push(message);
470        }
471    }
472    let mut result: Vec<String> = Vec::new();
473    let mut seen: Vec<&String> = Vec::new();
474    for message in &messages {
475        if seen.contains(&message) {
476            continue;
477        }
478        seen.push(message);
479        if let Some((_, packages)) = templates.iter().find(|(t, _)| t == message) {
480            for (package, versions) in packages {
481                let mut list: Vec<(&String, &String)> = versions.iter().collect();
482                list.sort_by(|a, b| version_compare(a.0, b.0));
483                let mut pretty: Vec<String> = list.into_iter().map(|(_, p)| p.clone()).collect();
484                if !ctx.is_verbose {
485                    pretty = condense_version_list(&versions_in_order(versions), 1, 16);
486                }
487                let rest = message.replacen("%s%s ", "", 1).replace("%%", "%");
488                if pretty.len() > 1 {
489                    // `^(%s%s (?:require|conflict))s` -> plural verb.
490                    let rest = if let Some(r) = rest.strip_prefix("requires") {
491                        format!("require{r}")
492                    } else if let Some(r) = rest.strip_prefix("conflicts") {
493                        format!("conflict{r}")
494                    } else {
495                        rest
496                    };
497                    result.push(format!("{package}[{}] {rest}", pretty.join(", ")));
498                } else {
499                    result.push(format!("{package} {} {rest}", pretty[0]));
500                }
501            }
502        } else {
503            result.push(message.clone());
504        }
505    }
506    format!("\n{indent}- {}", result.join(&format!("\n{indent}- ")))
507}
508
509/// `{^(?P<package>\S+) (?P<version>\S+) (?P<type>requires|conflicts)}`.
510fn parse_dedup_head(message: &str) -> Option<(String, String, String)> {
511    let mut parts = message.splitn(3, ' ');
512    let package = parts.next()?;
513    let version = parts.next()?;
514    let rest = parts.next()?;
515    if package.is_empty() || version.is_empty() {
516        return None;
517    }
518    let kind = if rest.starts_with("requires") {
519        "requires"
520    } else if rest.starts_with("conflicts") {
521        "conflicts"
522    } else {
523        return None;
524    };
525    Some((package.to_owned(), version.to_owned(), kind.to_owned()))
526}
527
528/// The versions of a normalized->pretty map in `version_compare` order.
529fn versions_in_order(versions: &BTreeMap<String, String>) -> Vec<(String, String)> {
530    let mut list: Vec<(String, String)> = versions
531        .iter()
532        .map(|(v, p)| (v.clone(), p.clone()))
533        .collect();
534    list.sort_by(|a, b| version_compare(&a.0, &b.0));
535    list
536}
537
538/// `condenseVersionList($versions, $max, $maxDev)`: `versions` already in
539/// `version_compare` order, keyed by normalized version.
540fn condense_version_list(versions: &[(String, String)], max: usize, max_dev: usize) -> Vec<String> {
541    if versions.len() <= max {
542        return versions.iter().map(|(_, p)| p.clone()).collect();
543    }
544    let mut by_major: Vec<(String, Vec<String>)> = Vec::new();
545    for (version, pretty) in versions {
546        let major = if version.to_lowercase().starts_with("dev-") {
547            "dev".to_owned()
548        } else {
549            let digits: String = version.chars().take_while(|c| c.is_ascii_digit()).collect();
550            if !digits.is_empty() && version[digits.len()..].starts_with('.') {
551                digits
552            } else {
553                version.clone()
554            }
555        };
556        match by_major.iter_mut().find(|(m, _)| *m == major) {
557            Some((_, list)) => list.push(pretty.clone()),
558            None => by_major.push((major, vec![pretty.clone()])),
559        }
560    }
561    let mut filtered: Vec<String> = Vec::new();
562    for (major, list) in by_major {
563        let cap = if major == "dev" { max_dev } else { max };
564        if list.len() > cap {
565            filtered.push(list[0].clone());
566            filtered.push("...".to_owned());
567            filtered.push(list[list.len() - 1].clone());
568        } else {
569            filtered.extend(list);
570        }
571    }
572    filtered
573}
574
575/// `Problem::isCausedByLock` for one rule (`Rule::isCausedByLock`).
576fn is_caused_by_lock(ctx: &MessageContext<'_>, rule: &Rule) -> bool {
577    let (target, constraint) = match &rule.reason {
578        Reason::PackageRequires(link) => (link.target.as_str(), &link.constraint),
579        Reason::RootRequire {
580            package_name,
581            constraint,
582            ..
583        } => (package_name.as_str(), constraint),
584        _ => return false,
585    };
586    if is_platform_package(target) {
587        return false;
588    }
589    let Some(locked) = &ctx.request.locked_repository else {
590        return false;
591    };
592    for &idx in locked {
593        let p = &ctx.arena[idx];
594        if p.name != target {
595            continue;
596        }
597        if ctx.pool.is_unacceptable_fixed_or_locked(idx) {
598            return true;
599        }
600        if !constraint.matches(&Constraint::new(Op::Eq, p.version.clone())) {
601            return true;
602        }
603        if matches!(rule.reason, Reason::PackageRequires(_)) && !ctx.request.is_locked_package(idx)
604        {
605            return true;
606        }
607        break;
608    }
609    false
610}
611
612// ---------------------------------------------------------------------------
613// Rule
614
615/// `Rule::getPrettyString`.
616fn rule_pretty_string(
617    ctx: &mut MessageContext<'_>,
618    rule: &Rule,
619    installed_map: &HashSet<usize>,
620) -> String {
621    let literals = &rule.literals;
622    match &rule.reason {
623        Reason::RootRequire {
624            package_name,
625            constraint,
626            pretty,
627        } => {
628            let packages = ctx
629                .pool
630                .what_provides(ctx.arena, package_name, Some(constraint));
631            if packages.is_empty() {
632                return format!(
633                    "No package found to satisfy root composer.json require {package_name} {pretty}"
634                );
635            }
636            let non_alias: Vec<usize> = packages
637                .iter()
638                .map(|&id| ctx.pool.package_by_id(id))
639                .filter(|&idx| ctx.arena[idx].alias_of.is_none())
640                .collect();
641            if non_alias.len() == 1 && ctx.request.is_locked_package(non_alias[0]) {
642                let p = &ctx.arena[non_alias[0]];
643                return format!(
644                    "{} is locked to version {} and an update of this package was not requested.",
645                    p.pretty_name, p.pretty_version
646                );
647            }
648            let idxs: Vec<usize> = packages
649                .iter()
650                .map(|&id| ctx.pool.package_by_id(id))
651                .collect();
652            format!(
653                "Root composer.json requires {package_name} {pretty} -> satisfiable by {}.",
654                package_list(ctx, &idxs, Some(constraint), false)
655            )
656        }
657        Reason::Fixed { package } => {
658            let idx = dedup_default_branch_alias(ctx, *package);
659            let p = &ctx.arena[idx];
660            if ctx.request.is_locked_package(idx) {
661                return format!(
662                    "{} is locked to version {} and an update of this package was not requested.",
663                    p.pretty_name, p.pretty_version
664                );
665            }
666            format!(
667                "{} is present at version {} and cannot be modified by Composer",
668                p.pretty_name, p.pretty_version
669            )
670        }
671        Reason::LockedFilterListRemoved { package } => {
672            let idx = dedup_default_branch_alias(ctx, *package);
673            let p = &ctx.arena[idx];
674            format!(
675                "{} {} was removed by a dependency policy (e.g. malware) and cannot be installed.",
676                p.pretty_name, p.pretty_version
677            )
678        }
679        Reason::PackageConflict(link) => {
680            let mut package1 =
681                dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(literals[0]));
682            let mut package2 =
683                dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(literals[1]));
684            let mut conflict_target = ctx.arena[package1].pretty_string();
685            if link.source == ctx.arena[package1].name {
686                std::mem::swap(&mut package1, &mut package2);
687                conflict_target = format!(
688                    "{} {}",
689                    ctx.arena[package1].pretty_name, link.pretty_constraint
690                );
691            }
692            if link.target != ctx.arena[package1].name {
693                let p1 = &ctx.arena[package1];
694                // Both loops run in the reference: a `replaces` link wins
695                // over a `provides` one for the same target.
696                let mut provided: Option<(&str, &str)> = None;
697                for l in p1.provides.iter() {
698                    if l.target == link.target {
699                        provided = Some(("provides", &l.pretty_constraint));
700                        break;
701                    }
702                }
703                for l in p1.replaces.iter() {
704                    if l.target == link.target {
705                        provided = Some(("replaces", &l.pretty_constraint));
706                        break;
707                    }
708                }
709                if let Some((kind, constraint)) = provided {
710                    conflict_target = format!(
711                        "{} {} ({} {} {} {})",
712                        link.target,
713                        link.pretty_constraint,
714                        p1.pretty_string(),
715                        kind,
716                        link.target,
717                        constraint
718                    );
719                }
720            }
721            format!(
722                "{} conflicts with {}.",
723                ctx.arena[package2].pretty_string(),
724                conflict_target
725            )
726        }
727        Reason::PackageRequires(link) => {
728            let source = dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(literals[0]));
729            let requires: Vec<usize> = literals[1..]
730                .iter()
731                .map(|&l| ctx.pool.literal_to_package(l))
732                .collect();
733            let text = link_pretty_string(&ctx.arena[source], link);
734            if !requires.is_empty() {
735                return format!(
736                    "{text} -> satisfiable by {}.",
737                    package_list(ctx, &requires, Some(&link.constraint), false)
738                );
739            }
740            let pretty = link_constraint_pretty(&ctx.arena[source], link);
741            let (_, reason) = missing_package_reason(
742                ctx,
743                &link.target,
744                Some(PrettyConstraint {
745                    constraint: &link.constraint,
746                    pretty: &pretty,
747                }),
748            );
749            format!("{text} -> {reason}")
750        }
751        Reason::PackageSameName(replaced_name) => {
752            let mut package_names: Vec<String> = Vec::new();
753            for &l in literals {
754                let name = ctx.arena[ctx.pool.literal_to_package(l)].name.clone();
755                if !package_names.contains(&name) {
756                    package_names.push(name);
757                }
758            }
759            let all: Vec<usize> = literals
760                .iter()
761                .map(|&l| ctx.pool.literal_to_package(l))
762                .collect();
763            if package_names.len() > 1 {
764                let reason = if !package_names.contains(replaced_name) {
765                    format!(
766                        "They {} replace {replaced_name} and thus cannot coexist.",
767                        if literals.len() == 2 { "both" } else { "all" }
768                    )
769                } else {
770                    let replacers: Vec<&String> = package_names
771                        .iter()
772                        .filter(|n| *n != replaced_name)
773                        .collect();
774                    let head = if replacers.len() == 1 {
775                        format!("{} replaces ", replacers[0])
776                    } else {
777                        format!(
778                            "[{}] replace ",
779                            replacers
780                                .iter()
781                                .map(|s| s.as_str())
782                                .collect::<Vec<_>>()
783                                .join(", ")
784                        )
785                    };
786                    format!("{head}{replaced_name} and thus cannot coexist with it.")
787                };
788                let mut installed: Vec<usize> = Vec::new();
789                let mut removable: Vec<usize> = Vec::new();
790                for &l in literals {
791                    let idx = ctx.pool.literal_to_package(l);
792                    if installed_map.contains(&(l.unsigned_abs() as usize)) {
793                        installed.push(idx);
794                    } else {
795                        removable.push(idx);
796                    }
797                }
798                if !installed.is_empty() && !removable.is_empty() {
799                    return format!(
800                        "{} cannot be installed as that would require removing {}. {reason}",
801                        package_list(ctx, &removable, None, true),
802                        package_list(ctx, &installed, None, true)
803                    );
804                }
805                return format!(
806                    "Only one of these can be installed: {}. {reason}",
807                    package_list(ctx, &all, None, true)
808                );
809            }
810            format!(
811                "You can only install one version of a package, so only one of these can be installed: {}.",
812                package_list(ctx, &all, None, true)
813            )
814        }
815        Reason::Learned(_) => {
816            let rule_text = if literals.len() == 1 {
817                literal_to_pretty_string(ctx, literals[0], installed_map)
818            } else {
819                let mut groups: Vec<(&str, Vec<usize>)> = Vec::new();
820                for &l in literals {
821                    let idx = ctx.pool.literal_to_package(l);
822                    let id = l.unsigned_abs() as usize;
823                    let group = if installed_map.contains(&id) {
824                        if l > 0 {
825                            "keep"
826                        } else {
827                            "remove"
828                        }
829                    } else if l > 0 {
830                        "install"
831                    } else {
832                        "don't install"
833                    };
834                    let dedup = dedup_default_branch_alias(ctx, idx);
835                    match groups.iter_mut().find(|(g, _)| *g == group) {
836                        Some((_, list)) => list.push(dedup),
837                        None => groups.push((group, vec![dedup])),
838                    }
839                }
840                let mut texts: Vec<String> = Vec::new();
841                for (group, packages) in groups {
842                    texts.push(format!(
843                        "{group}{} {}",
844                        if packages.len() > 1 { " one of" } else { "" },
845                        package_list(ctx, &packages, None, false)
846                    ));
847                }
848                texts.join(" | ")
849            };
850            format!("Conclusion: {rule_text} (conflict analysis result)")
851        }
852        Reason::PackageAlias { .. } => {
853            let alias = ctx.pool.literal_to_package(literals[0]);
854            if ctx.arena[alias].version == "9999999-dev" {
855                return String::new();
856            }
857            let package = dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(literals[1]));
858            format!(
859                "{} is an alias of {} and thus requires it to be installed too.",
860                ctx.arena[alias].pretty_string(),
861                ctx.arena[package].pretty_string()
862            )
863        }
864        Reason::PackageInverseAlias { .. } => {
865            let alias = ctx.pool.literal_to_package(literals[1]);
866            if ctx.arena[alias].version == "9999999-dev" {
867                return String::new();
868            }
869            let package = dedup_default_branch_alias(ctx, ctx.pool.literal_to_package(literals[0]));
870            format!(
871                "{} is an alias of {} and must be installed with it.",
872                ctx.arena[alias].pretty_string(),
873                ctx.arena[package].pretty_string()
874            )
875        }
876    }
877}
878
879/// `Pool::literalToPrettyString`.
880fn literal_to_pretty_string(
881    ctx: &MessageContext<'_>,
882    literal: i64,
883    installed_map: &HashSet<usize>,
884) -> String {
885    let idx = ctx.pool.literal_to_package(literal);
886    let prefix = if installed_map.contains(&(literal.unsigned_abs() as usize)) {
887        if literal > 0 {
888            "keep"
889        } else {
890            "remove"
891        }
892    } else if literal > 0 {
893        "install"
894    } else {
895        "don't install"
896    };
897    format!("{prefix} {}", ctx.arena[idx].pretty_string())
898}
899
900// ---------------------------------------------------------------------------
901// Package lists
902
903/// `Problem::getPackageList` on arena indices.
904fn package_list(
905    ctx: &MessageContext<'_>,
906    packages: &[usize],
907    constraint: Option<&Constraint>,
908    use_removed_version_group: bool,
909) -> String {
910    // name -> (pretty name, normalized -> pretty version)
911    let mut prepared: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
912    let mut has_default_branch: HashSet<String> = HashSet::new();
913    for &idx in packages {
914        let p = &ctx.arena[idx];
915        let slot = match prepared.iter_mut().position(|(n, _, _)| *n == p.name) {
916            Some(i) => i,
917            None => {
918                prepared.push((p.name.clone(), p.pretty_name.clone(), BTreeMap::new()));
919                prepared.len() - 1
920            }
921        };
922        prepared[slot].1 = p.pretty_name.clone();
923        let pretty = match p.alias_of {
924            Some(base) => format!(
925                "{} (alias of {})",
926                p.pretty_version, ctx.arena[base].pretty_version
927            ),
928            None => p.pretty_version.clone(),
929        };
930        prepared[slot].2.insert(p.version.clone(), pretty);
931        if let Some(c) = constraint {
932            for (v, pretty) in ctx.pool.removed_versions(&p.name, c) {
933                prepared[slot].2.insert(v, pretty);
934            }
935        }
936        if use_removed_version_group {
937            for (v, pretty) in ctx.pool.removed_versions_by_package(idx) {
938                prepared[slot].2.insert(v, pretty);
939            }
940        }
941        if p.is_default_branch {
942            has_default_branch.insert(p.name.clone());
943        }
944    }
945    let mut strings: Vec<String> = Vec::new();
946    for (name, pretty_name, mut versions) in prepared {
947        if has_default_branch.contains(&name) {
948            versions.remove("9999999-dev");
949        }
950        let ordered = versions_in_order(&versions);
951        let list: Vec<String> = if ctx.is_verbose {
952            ordered.into_iter().map(|(_, p)| p).collect()
953        } else {
954            condense_version_list(&ordered, 4, 16)
955        };
956        strings.push(format!("{pretty_name}[{}]", list.join(", ")));
957    }
958    strings.join(", ")
959}
960
961/// `hasMultipleNames`.
962fn has_multiple_names(ctx: &MessageContext<'_>, packages: &[usize]) -> bool {
963    let mut name: Option<&str> = None;
964    for &idx in packages {
965        let n = ctx.arena[idx].name.as_str();
966        match name {
967            None => name = Some(n),
968            Some(current) if current == n => {}
969            Some(_) => return true,
970        }
971    }
972    false
973}
974
975/// `constraintToText`.
976fn constraint_to_text(constraint: Option<PrettyConstraint<'_>>) -> String {
977    let Some(pc) = constraint else {
978        return String::new();
979    };
980    if let Constraint::Single {
981        op: Op::Eq,
982        version,
983    } = pc.constraint
984    {
985        if !version.starts_with("dev-") {
986            let plain = !pc.pretty.is_empty()
987                && pc
988                    .pretty
989                    .split('.')
990                    .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()));
991            if !plain {
992                return format!(" {} (exact version match)", pc.pretty);
993            }
994            let mut versions = vec![pc.pretty.to_owned()];
995            let dots = pc.pretty.matches('.').count();
996            let mut i = 3i64 - dots as i64;
997            while i > 0 {
998                let last = versions.last().expect("non-empty").clone();
999                versions.push(format!("{last}.0"));
1000                i -= 1;
1001            }
1002            let text = if versions.len() > 1 {
1003                format!(
1004                    "{} or {}",
1005                    versions[..versions.len() - 1].join(", "),
1006                    versions[versions.len() - 1]
1007                )
1008            } else {
1009                versions[0].clone()
1010            };
1011            return format!(" {} (exact version match: {text})", pc.pretty);
1012        }
1013    }
1014    format!(" {}", pc.pretty)
1015}
1016
1017// ---------------------------------------------------------------------------
1018// Repository set lookups
1019
1020/// `getRepoName` of a package's repository.
1021fn repo_name(ctx: &MessageContext<'_>, origin: Origin) -> String {
1022    match origin {
1023        Origin::Root => "root package repo".to_owned(),
1024        Origin::Platform => "platform repo".to_owned(),
1025        Origin::Locked => "lock repo".to_owned(),
1026        Origin::Repository(i) => match ctx.set.repositories.get(i) {
1027            Some(Repository::Composer(repo)) => repo.repo_name(),
1028            _ => "unknown repo".to_owned(),
1029        },
1030        Origin::Detached | Origin::Result => "unknown repo".to_owned(),
1031    }
1032}
1033
1034/// `RepositorySet::findPackages($name, $constraint, $flags)`: arena
1035/// indices, repository order.
1036fn find_packages(
1037    ctx: &mut MessageContext<'_>,
1038    name: &str,
1039    constraint: Option<&Constraint>,
1040    flags: u8,
1041) -> Vec<usize> {
1042    let ignore_stability = flags & ALLOW_UNACCEPTABLE_STABILITIES != 0;
1043    let from_all_repos = flags & ALLOW_SHADOWED_REPOSITORIES != 0;
1044    let all_stabilities: BTreeMap<String, i32> = ["stable", "RC", "beta", "alpha", "dev"]
1045        .iter()
1046        .map(|s| (s.to_string(), crate::version::stability_rank(s)))
1047        .collect();
1048    let empty_flags = BTreeMap::new();
1049    let already = BTreeMap::new();
1050    let map = vec![(
1051        name.to_owned(),
1052        constraint.cloned().unwrap_or(Constraint::MatchAll),
1053    )];
1054    let mut found: Vec<usize> = Vec::new();
1055    for (i, repository) in ctx.set.repositories.iter().enumerate() {
1056        let members: Option<&Vec<usize>> = match repository {
1057            Repository::Root(m) | Repository::Platform(m) | Repository::Locked(m) => Some(m),
1058            Repository::Composer(_) => None,
1059        };
1060        if from_all_repos {
1061            // `$repository->findPackages($name, $constraint)`: no stability
1062            // filter at this stage.
1063            match repository {
1064                Repository::Composer(repo) => {
1065                    if let Ok((_, ids)) = repo.load_packages(
1066                        &map,
1067                        &all_stabilities,
1068                        &empty_flags,
1069                        &already,
1070                        Origin::Repository(i),
1071                        ctx.arena,
1072                    ) {
1073                        found.extend(ids);
1074                    }
1075                }
1076                _ => {
1077                    for &idx in members.expect("array repository") {
1078                        let p = &ctx.arena[idx];
1079                        if p.name == name
1080                            && constraint.is_none_or(|c| {
1081                                c.matches(&Constraint::new(Op::Eq, p.version.clone()))
1082                            })
1083                        {
1084                            found.push(idx);
1085                        }
1086                    }
1087                }
1088            }
1089            continue;
1090        }
1091        let (acceptable, stability_flags) = if ignore_stability {
1092            (&all_stabilities, &empty_flags)
1093        } else {
1094            (&ctx.set.acceptable_stabilities, &ctx.set.stability_flags)
1095        };
1096        let (names_found, ids) = match repository {
1097            Repository::Composer(repo) => repo
1098                .load_packages(
1099                    &map,
1100                    acceptable,
1101                    stability_flags,
1102                    &already,
1103                    Origin::Repository(i),
1104                    ctx.arena,
1105                )
1106                .unwrap_or_default(),
1107            _ => crate::pool::array_repository_load_packages(
1108                members.expect("array repository"),
1109                &map,
1110                acceptable,
1111                stability_flags,
1112                &already,
1113                ctx.arena,
1114            ),
1115        };
1116        found.extend(ids);
1117        if names_found.iter().any(|n| n == name) {
1118            break;
1119        }
1120    }
1121    if ignore_stability || !from_all_repos {
1122        return found;
1123    }
1124    found
1125        .into_iter()
1126        .filter(|&idx| {
1127            let p = &ctx.arena[idx];
1128            is_package_acceptable(
1129                &ctx.set.acceptable_stabilities,
1130                &ctx.set.stability_flags,
1131                &p.names(true),
1132                p.stability,
1133            )
1134        })
1135        .collect()
1136}
1137
1138/// `RepositorySet::getProviders($name)` without the providers API: every
1139/// repository's loaded packages whose `provide` links target the name
1140/// (`ArrayRepository::getProviders`), merged by name.
1141fn providers(ctx: &mut MessageContext<'_>, name: &str) -> Vec<(String, Option<String>)> {
1142    let mut result: Vec<(String, Option<String>)> = Vec::new();
1143    for (i, repository) in ctx.set.repositories.iter().enumerate() {
1144        let candidates: Vec<usize> = match repository {
1145            Repository::Root(m) | Repository::Platform(m) | Repository::Locked(m) => m.clone(),
1146            Repository::Composer(repo) => {
1147                // `ComposerRepository::getProviders`: the providers API
1148                // answers alone when the repository declares one.
1149                if let Ok(Some(api)) = repo.providers_api(name) {
1150                    for (n, d) in api {
1151                        match result.iter_mut().find(|(existing, _)| *existing == n) {
1152                            Some(slot) => slot.1 = d,
1153                            None => result.push((n, d)),
1154                        }
1155                    }
1156                    continue;
1157                }
1158                repo.provider_candidates(Origin::Repository(i), ctx.arena)
1159                    .unwrap_or_default()
1160            }
1161        };
1162        let mut repo_result: Vec<(String, Option<String>)> = Vec::new();
1163        for idx in candidates {
1164            let p = &ctx.arena[idx];
1165            if repo_result.iter().any(|(n, _)| *n == p.name) {
1166                continue;
1167            }
1168            if p.provides.iter().any(|l| l.target == name) {
1169                let description = p
1170                    .raw
1171                    .get("description")
1172                    .and_then(serde_json::Value::as_str)
1173                    .map(str::to_owned);
1174                repo_result.push((p.name.clone(), description));
1175            }
1176        }
1177        // `array_merge` on string keys: first position kept, value replaced.
1178        for (n, d) in repo_result {
1179            match result.iter_mut().find(|(existing, _)| *existing == n) {
1180                Some(slot) => slot.1 = d,
1181                None => result.push((n, d)),
1182            }
1183        }
1184    }
1185    result
1186}
1187
1188/// `getProvidersList`.
1189fn providers_list(ctx: &mut MessageContext<'_>, name: &str, max: usize) -> Option<String> {
1190    let list = providers(ctx, name);
1191    if list.is_empty() {
1192        return None;
1193    }
1194    let shown: &[(String, Option<String>)] = if list.len() > max + 1 {
1195        &list[..max]
1196    } else {
1197        &list
1198    };
1199    let mut text = String::new();
1200    for (n, description) in shown {
1201        let d = match description {
1202            Some(d) if !d.is_empty() => format!(" {}", php_substr(d, 100)),
1203            _ => String::new(),
1204        };
1205        text.push_str(&format!("      - {n}{d}\n"));
1206    }
1207    if list.len() > max + 1 {
1208        text.push_str(&format!("      ... and {} more.\n", list.len() - max));
1209    }
1210    Some(text)
1211}
1212
1213/// `substr($s, 0, $n)` on bytes (a UTF-8 cut is kept as PHP makes it).
1214fn php_substr(s: &str, n: usize) -> String {
1215    let bytes = s.as_bytes();
1216    if bytes.len() <= n {
1217        return s.to_owned();
1218    }
1219    String::from_utf8_lossy(&bytes[..n]).into_owned()
1220}
1221
1222/// `getPlatformPackageVersion`.
1223fn platform_package_version(ctx: &MessageContext<'_>, name: &str) -> Option<String> {
1224    let available = ctx.pool.what_provides(ctx.arena, name, None);
1225    if available.is_empty() {
1226        return None;
1227    }
1228    let idxs: Vec<usize> = available
1229        .iter()
1230        .map(|&id| ctx.pool.package_by_id(id))
1231        .collect();
1232    let selected = idxs
1233        .iter()
1234        .copied()
1235        .find(|&idx| ctx.arena[idx].origin == Origin::Platform)
1236        .unwrap_or(idxs[0]);
1237    let p = &ctx.arena[selected];
1238    if p.name != name {
1239        for link in p.provides.iter().chain(p.replaces.iter()) {
1240            if link.target == name {
1241                let description = link.kind.description();
1242                return Some(format!(
1243                    "{} {}d by {}",
1244                    link.pretty_constraint,
1245                    &description[..description.len() - 1],
1246                    p.pretty_string()
1247                ));
1248            }
1249        }
1250    }
1251    let mut version = p.pretty_version.clone();
1252    let overridden = p
1253        .raw
1254        .get("extra")
1255        .and_then(|e| e.get("config.platform"))
1256        .and_then(serde_json::Value::as_bool)
1257        == Some(true);
1258    if overridden {
1259        let description = p
1260            .raw
1261            .get("description")
1262            .and_then(serde_json::Value::as_str)
1263            .unwrap_or("");
1264        version.push_str(&format!("; {}", description.replace("Package ", "")));
1265    }
1266    Some(version)
1267}
1268
1269// ---------------------------------------------------------------------------
1270// Missing package reasons
1271
1272/// `Problem::getMissingPackageReason`: `[prefix, reason]`.
1273fn missing_package_reason(
1274    ctx: &mut MessageContext<'_>,
1275    package_name: &str,
1276    constraint: Option<PrettyConstraint<'_>>,
1277) -> (String, String) {
1278    let ctt = constraint_to_text(constraint);
1279    let c = constraint.map(|pc| pc.constraint);
1280    if is_platform_package(package_name) {
1281        if package_name.to_lowercase().starts_with("php") || package_name == "hhvm" {
1282            let version = platform_package_version(ctx, package_name);
1283            let msg = format!("- Root composer.json requires {package_name}{ctt} but ");
1284            if package_name == "hhvm" {
1285                if !ctx
1286                    .pool
1287                    .what_provides(ctx.arena, package_name, None)
1288                    .is_empty()
1289                {
1290                    return (
1291                        msg,
1292                        "your HHVM version does not satisfy that requirement.".to_owned(),
1293                    );
1294                }
1295                return (
1296                    msg,
1297                    "HHVM was not detected on this machine, make sure it is in your PATH."
1298                        .to_owned(),
1299                );
1300            }
1301            return match version {
1302                None => (
1303                    msg,
1304                    format!("the {package_name} package is disabled by your platform config. Enable it again with \"composer config platform.{package_name} --unset\"."),
1305                ),
1306                Some(v) => (
1307                    msg,
1308                    format!("your {package_name} version ({v}) does not satisfy that requirement."),
1309                ),
1310            };
1311        }
1312        if package_name.to_lowercase().starts_with("ext-") {
1313            if package_name.contains(' ') {
1314                return (
1315                    "- ".to_owned(),
1316                    format!(
1317                        "PHP extension {package_name} should be required as {}.",
1318                        package_name.replace(' ', "-")
1319                    ),
1320                );
1321            }
1322            let ext = &package_name[4..];
1323            let msg =
1324                format!("- Root composer.json requires PHP extension {package_name}{ctt} but ");
1325            let version = platform_package_version(ctx, package_name);
1326            if version.is_none() {
1327                let providers_str = providers_list(ctx, package_name, 5).map(|s| {
1328                    format!("\n\n      Alternatively you can require one of these packages that provide the extension (or parts of it):\n      <warning>Keep in mind that the suggestions are automated and may not be valid or safe to use</warning>\n{s}")
1329                }).unwrap_or_default();
1330                if ctx.loaded_extensions.contains(&package_name.to_lowercase()) {
1331                    return (
1332                        msg,
1333                        format!("the {package_name} package is disabled by your platform config. Enable it again with \"composer config platform.{package_name} --unset\".{providers_str}"),
1334                    );
1335                }
1336                return (
1337                    msg,
1338                    format!("it is missing from your system. Install or enable PHP's {ext} extension.{providers_str}"),
1339                );
1340            }
1341            return (
1342                msg,
1343                format!(
1344                    "it has the wrong version installed ({}).",
1345                    version.expect("checked")
1346                ),
1347            );
1348        }
1349        if package_name.to_lowercase().starts_with("lib-") {
1350            if package_name.to_lowercase() == "lib-icu" {
1351                let error = if ctx.loaded_extensions.contains("ext-intl") {
1352                    "it has the wrong version installed, try upgrading the intl extension."
1353                } else {
1354                    "it is missing from your system, make sure the intl extension is loaded."
1355                };
1356                return (
1357                    format!(
1358                        "- Root composer.json requires linked library {package_name}{ctt} but "
1359                    ),
1360                    error.to_owned(),
1361                );
1362            }
1363            let providers_str = providers_list(ctx, package_name, 5).map(|s| {
1364                format!("\n\n      Alternatively you can require one of these packages that provide the library (or parts of it):\n      <warning>Keep in mind that the suggestions are automated and may not be valid or safe to use</warning>\n{s}")
1365            }).unwrap_or_default();
1366            return (
1367                format!("- Root composer.json requires linked library {package_name}{ctt} but "),
1368                format!("it has the wrong version installed or is missing from your system, make sure to load the extension providing it.{providers_str}"),
1369            );
1370        }
1371    }
1372
1373    let mut locked_package: Option<usize> = None;
1374    for idx in ctx.request.locked_packages_all() {
1375        if ctx.arena[idx].name == package_name {
1376            locked_package = Some(idx);
1377            if ctx.pool.is_unacceptable_fixed_or_locked(idx) {
1378                let p = &ctx.arena[idx];
1379                return (
1380                    "- ".to_owned(),
1381                    format!("{} is fixed to {} (lock file version) by a partial update but that version is rejected by your minimum-stability. Make sure you list it as an argument for the update command.", p.pretty_name, p.pretty_version),
1382                );
1383            }
1384            break;
1385        }
1386    }
1387
1388    if let Some(pc) = constraint {
1389        if let Constraint::Single { op: Op::Eq, .. } = pc.constraint {
1390            if pc.pretty.starts_with("dev-") && pc.pretty.contains('#') {
1391                let new_constraint = strip_as_suffix(pc.pretty);
1392                let alt = new_constraint.replace('#', "+");
1393                let multi = Constraint::Multi {
1394                    constraints: vec![
1395                        Constraint::new(Op::Eq, new_constraint.clone()),
1396                        Constraint::new(Op::Eq, alt),
1397                    ],
1398                    conjunctive: false,
1399                };
1400                let packages = find_packages(ctx, package_name, Some(&multi), 0);
1401                if !packages.is_empty() {
1402                    return (
1403                        format!("- Root composer.json requires {package_name}{ctt}, "),
1404                        format!(
1405                            "found {}. The # character in branch names is replaced by a + character. Make sure to require it as \"{}\".",
1406                            package_list(ctx, &packages, c, false),
1407                            pc.pretty.replace('#', "+")
1408                        ),
1409                    );
1410                }
1411            }
1412        }
1413    }
1414
1415    let packages = find_packages(ctx, package_name, c, 0);
1416    if !packages.is_empty() {
1417        if let Some(root_req) = ctx.set.root_requires.get(package_name) {
1418            let root_pretty = ctx
1419                .request
1420                .pretty_requires
1421                .get(package_name)
1422                .cloned()
1423                .unwrap_or_else(|| root_req.to_string());
1424            if !packages.iter().any(|&idx| {
1425                root_req.matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone()))
1426            }) {
1427                return (
1428                    format!("- Root composer.json requires {package_name}{ctt}, "),
1429                    format!(
1430                        "found {} but {} with your root composer.json require ({root_pretty}).",
1431                        package_list(ctx, &packages, c, false),
1432                        if has_multiple_names(ctx, &packages) {
1433                            "these conflict"
1434                        } else {
1435                            "it conflicts"
1436                        }
1437                    ),
1438                );
1439            }
1440        }
1441        let first_names = ctx.arena[packages[0]].names(true);
1442        for name in first_names {
1443            if let Some(temp) = ctx.set.temporary_constraints.get(&name) {
1444                if !packages.iter().any(|&idx| {
1445                    temp.matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone()))
1446                }) {
1447                    return (
1448                        format!("- Root composer.json requires {name}{ctt}, "),
1449                        format!(
1450                            "found {} but {} with your temporary update constraint ({name}:{temp}).",
1451                            package_list(ctx, &packages, c, false),
1452                            if has_multiple_names(ctx, &packages) {
1453                                "these conflict"
1454                            } else {
1455                                "it conflicts"
1456                            }
1457                        ),
1458                    );
1459                }
1460            }
1461        }
1462        if let Some(locked) = locked_package {
1463            let fixed = Constraint::new(Op::Eq, ctx.arena[locked].version.clone());
1464            if !packages
1465                .iter()
1466                .any(|&idx| fixed.matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone())))
1467            {
1468                return (
1469                    format!("- Root composer.json requires {package_name}{ctt}, "),
1470                    format!(
1471                        "found {} but the package is fixed to {} (lock file version) by a partial update and that version does not match. Make sure you list it as an argument for the update command.",
1472                        package_list(ctx, &packages, c, false),
1473                        ctx.arena[locked].pretty_version
1474                    ),
1475                );
1476            }
1477        }
1478        if let Some(cst) = c {
1479            if ctx.pool.is_abandoned_removed(package_name, cst) {
1480                return (
1481                    format!("- Root composer.json requires {package_name}{ctt}, "),
1482                    format!(
1483                        "found {} but these were not loaded, because they are abandoned and you configured \"policy.abandoned.block\" to true.",
1484                        package_list(ctx, &packages, c, false)
1485                    ),
1486                );
1487            }
1488            if ctx.pool.is_security_removed(package_name, cst) {
1489                let complete = matching_security_advisories(ctx, &packages, package_name);
1490                let (advisories_list, advisory_ids): (Vec<String>, Vec<String>) = match complete {
1491                    Some(list) if !list.is_empty() => (
1492                        list.iter()
1493                            .map(|a| {
1494                                let link = a.complete.as_ref().and_then(|c| c.link.clone());
1495                                match link {
1496                                    Some(l) if !l.is_empty() => {
1497                                        format!("<href={}>{}</>", escape_output(&l), a.advisory_id)
1498                                    }
1499                                    _ if a.advisory_id.starts_with("PKSA-") => format!(
1500                                        "<href={}>{}</>",
1501                                        escape_output(&format!(
1502                                            "https://packagist.org/security-advisories/{}",
1503                                            a.advisory_id
1504                                        )),
1505                                        a.advisory_id
1506                                    ),
1507                                    _ => a.advisory_id.clone(),
1508                                }
1509                            })
1510                            .collect(),
1511                        list.iter().map(|a| a.advisory_id.clone()).collect(),
1512                    ),
1513                    _ => {
1514                        let ids = ctx.pool.security_advisory_ids(package_name, cst);
1515                        (
1516                            ids.iter()
1517                                .map(|id| {
1518                                    if id.starts_with("PKSA-") {
1519                                        format!(
1520                                            "<href={}>{id}</>",
1521                                            escape_output(&format!(
1522                                                "https://packagist.org/security-advisories/{id}"
1523                                            ))
1524                                        )
1525                                    } else {
1526                                        id.clone()
1527                                    }
1528                                })
1529                                .collect(),
1530                            ids,
1531                        )
1532                    }
1533                };
1534                let has_packagist = advisory_ids.iter().all(|id| id.starts_with("PKSA-"));
1535                let details_hint = if has_packagist {
1536                    " Go to https://packagist.org/security-advisories/ to find advisory details."
1537                } else {
1538                    " Review the advisory details above for more information."
1539                };
1540                return (
1541                    format!("- Root composer.json requires {package_name}{ctt}, "),
1542                    format!(
1543                        "found {} but these were not loaded, because they are affected by security advisories (\"{}\").{details_hint} To ignore the advisories, add their IDs to the \"policy.advisories.ignore-id\" config or add the package to \"policy.advisories.ignore\". To turn the feature off entirely, you can set \"policy.advisories.block\" to false.",
1544                        package_list(ctx, &packages, c, false),
1545                        advisories_list.join("\", \"")
1546                    ),
1547                );
1548            }
1549            if ctx.pool.is_filter_list_removed_version(package_name, cst) {
1550                let filters = ctx.pool.filter_list_entries_text(package_name, cst);
1551                let ignore_paths = filters
1552                    .iter()
1553                    .map(|(l, _)| format!("\"policy.{l}.ignore\""))
1554                    .collect::<Vec<_>>()
1555                    .join(" and ");
1556                let off_paths = filters
1557                    .iter()
1558                    .map(|(l, _)| format!("\"policy.{l}.block\""))
1559                    .collect::<Vec<_>>()
1560                    .join(" and ");
1561                return (
1562                    format!("- Root composer.json requires {package_name}{ctt}, "),
1563                    format!(
1564                        "found {} but these were not loaded, because they were {}. To ignore filters for this package, add the package to the {ignore_paths} config. To turn the feature off entirely, you can set {off_paths} to false.",
1565                        package_list(ctx, &packages, c, false),
1566                        filters.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>().join(", ")
1567                    ),
1568                );
1569            }
1570        }
1571        if packages
1572            .iter()
1573            .all(|&idx| ctx.arena[idx].origin == Origin::Locked)
1574        {
1575            return (
1576                format!("- Root composer.json requires {package_name}{ctt}, "),
1577                format!(
1578                    "found {} in the lock file but not in remote repositories, make sure you avoid updating this package to keep the one from the lock file.",
1579                    package_list(ctx, &packages, c, false)
1580                ),
1581            );
1582        }
1583        return (
1584            format!("- Root composer.json requires {package_name}{ctt}, "),
1585            format!(
1586                "found {} but these were not loaded, likely because {} with another require.",
1587                package_list(ctx, &packages, c, false),
1588                if has_multiple_names(ctx, &packages) {
1589                    "they conflict"
1590                } else {
1591                    "it conflicts"
1592                }
1593            ),
1594        );
1595    }
1596
1597    let packages = find_packages(ctx, package_name, c, ALLOW_UNACCEPTABLE_STABILITIES);
1598    if !packages.is_empty() {
1599        let all_repos = find_packages(ctx, package_name, c, ALLOW_SHADOWED_REPOSITORIES);
1600        if !all_repos.is_empty() {
1601            return check_for_lower_prio_repo(
1602                ctx,
1603                package_name,
1604                &packages,
1605                &all_repos,
1606                "minimum-stability",
1607                constraint,
1608            );
1609        }
1610        return (
1611            format!("- Root composer.json requires {package_name}{ctt}, "),
1612            format!(
1613                "found {} but {} not match your minimum-stability.",
1614                package_list(ctx, &packages, c, false),
1615                if has_multiple_names(ctx, &packages) {
1616                    "these do"
1617                } else {
1618                    "it does"
1619                }
1620            ),
1621        );
1622    }
1623
1624    let packages = find_packages(ctx, package_name, None, ALLOW_UNACCEPTABLE_STABILITIES);
1625    if !packages.is_empty() {
1626        let all_repos = find_packages(ctx, package_name, c, ALLOW_SHADOWED_REPOSITORIES);
1627        if !all_repos.is_empty() {
1628            return check_for_lower_prio_repo(
1629                ctx,
1630                package_name,
1631                &packages,
1632                &all_repos,
1633                "constraint",
1634                constraint,
1635            );
1636        }
1637        let mut suffix = String::new();
1638        if let Some(pc) = constraint {
1639            if let Constraint::Single { version, .. } = pc.constraint {
1640                if version == "dev-master" {
1641                    for &idx in &packages {
1642                        let v = &ctx.arena[idx].version;
1643                        if v == "dev-default" || v == "dev-main" {
1644                            suffix = format!(
1645                                " Perhaps dev-master was renamed to {}?",
1646                                ctx.arena[idx].pretty_version
1647                            );
1648                            break;
1649                        }
1650                    }
1651                }
1652            }
1653        }
1654        if ctx.arena[packages[0]].origin == Origin::Root {
1655            suffix =
1656                " See https://getcomposer.org/dep-on-root for details and assistance.".to_owned();
1657        }
1658        return (
1659            format!("- Root composer.json requires {package_name}{ctt}, "),
1660            format!(
1661                "found {} but {} not match the constraint.{suffix}",
1662                package_list(ctx, &packages, c, false),
1663                if has_multiple_names(ctx, &packages) {
1664                    "these do"
1665                } else {
1666                    "it does"
1667                }
1668            ),
1669        );
1670    }
1671
1672    let legal = |ch: char| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '/' | '-');
1673    if !package_name.chars().all(legal) {
1674        let illegal: String = package_name.chars().filter(|c| !legal(*c)).collect();
1675        return (
1676            format!("- Root composer.json requires {package_name}, it "),
1677            format!(
1678                "could not be found, it looks like its name is invalid, \"{illegal}\" is not allowed in package names."
1679            ),
1680        );
1681    }
1682
1683    if let Some(providers_str) = providers_list(ctx, package_name, 15) {
1684        return (
1685            format!("- Root composer.json requires {package_name}{ctt}, it "),
1686            format!(
1687                "could not be found in any version, but the following packages provide it:\n{providers_str}      Consider requiring one of these to satisfy the {package_name} requirement."
1688            ),
1689        );
1690    }
1691
1692    (
1693        format!("- Root composer.json requires {package_name}, it "),
1694        "could not be found in any version, there may be a typo in the package name.".to_owned(),
1695    )
1696}
1697
1698/// `Preg::replace('{ +as +([^,\s|]+)$}', '', $pretty)`.
1699fn strip_as_suffix(pretty: &str) -> String {
1700    let trimmed = pretty.trim_end();
1701    if let Some(pos) = trimmed.rfind(" as ") {
1702        let after = &trimmed[pos + 4..];
1703        let after = after.trim_start();
1704        if !after.is_empty() && !after.contains([',', ' ', '|']) {
1705            return trimmed[..pos].trim_end().to_owned();
1706        }
1707    }
1708    pretty.to_owned()
1709}
1710
1711/// `OutputFormatter::escape` for `<href=…>`: a backslash before `<`.
1712fn escape_output(text: &str) -> String {
1713    text.replace('<', "\\<")
1714}
1715
1716/// `RepositorySet::getMatchingSecurityAdvisories($packages, false, true)`
1717/// for one name: the complete advisories, `None` when a repository fails
1718/// (the reference would throw; the ids recorded by the filter are used).
1719fn matching_security_advisories(
1720    ctx: &mut MessageContext<'_>,
1721    packages: &[usize],
1722    package_name: &str,
1723) -> Option<Vec<Advisory>> {
1724    let map = crate::pool_filters::constraints_by_name(packages, ctx.arena);
1725    let mut unreachable = Vec::new();
1726    let all = crate::pool_filters::security_advisories_for_constraints(
1727        &ctx.set.repositories,
1728        &map,
1729        false,
1730        true,
1731        &mut unreachable,
1732    )
1733    .ok()?;
1734    all.into_iter()
1735        .find(|(n, _)| n == package_name)
1736        .map(|(_, list)| list)
1737}
1738
1739/// `computeCheckForLowerPrioRepo`.
1740fn check_for_lower_prio_repo(
1741    ctx: &mut MessageContext<'_>,
1742    package_name: &str,
1743    higher: &[usize],
1744    all_repos: &[usize],
1745    reason: &str,
1746    constraint: Option<PrettyConstraint<'_>>,
1747) -> (String, String) {
1748    let ctt = constraint_to_text(constraint);
1749    let c = constraint.map(|pc| pc.constraint);
1750    let mut next_repo: Option<Origin> = None;
1751    let mut next_repo_packages: Vec<usize> = Vec::new();
1752    for &idx in all_repos {
1753        let origin = ctx.arena[idx].origin;
1754        match next_repo {
1755            None => {
1756                next_repo = Some(origin);
1757                next_repo_packages.push(idx);
1758            }
1759            Some(o) if o == origin => next_repo_packages.push(idx),
1760            Some(_) => break,
1761        }
1762    }
1763    let next_repo = next_repo.expect("non-empty");
1764    if let Some(&top) = higher.first() {
1765        if ctx.arena[top].origin == Origin::Root {
1766            let p = &ctx.arena[top];
1767            return (
1768                format!("- Root composer.json requires {package_name}{ctt}, it is "),
1769                format!(
1770                    "satisfiable by {} from {} but {} {} is the root package and cannot be modified. See https://getcomposer.org/dep-on-root for details and assistance.",
1771                    package_list(ctx, &next_repo_packages, c, false),
1772                    repo_name(ctx, next_repo),
1773                    p.pretty_name,
1774                    p.pretty_version
1775                ),
1776            );
1777        }
1778    }
1779    if next_repo == Origin::Locked {
1780        let singular = higher.len() == 1;
1781        let mut suggestion = format!(
1782            "Make sure you either fix the {reason} or avoid updating this package to keep the one present in the lock file ({}).",
1783            package_list(ctx, &next_repo_packages, c, false)
1784        );
1785        let first = &ctx.arena[next_repo_packages[0]];
1786        if first.dist.as_ref().is_some_and(|d| d.kind == "path") {
1787            let symlink_off = first
1788                .raw
1789                .get("transport-options")
1790                .and_then(|t| t.get("symlink"))
1791                .and_then(serde_json::Value::as_bool)
1792                == Some(false);
1793            if !symlink_off {
1794                suggestion = format!("Make sure you fix the {reason} as packages installed from symlinked path repos are updated even in partial updates and the one from the lock file can thus not be used.");
1795            }
1796        }
1797        return (
1798            format!("- Root composer.json requires {package_name}{ctt}, "),
1799            format!(
1800                "found {} but {} not match your {reason} and {} therefore not installable. {suggestion}",
1801                package_list(ctx, higher, c, false),
1802                if singular { "it does" } else { "these do" },
1803                if singular { "is" } else { "are" }
1804            ),
1805        );
1806    }
1807    let higher_repo = repo_name(ctx, ctx.arena[higher[0]].origin);
1808    (
1809        format!("- Root composer.json requires {package_name}{ctt}, it is "),
1810        format!(
1811            "satisfiable by {} from {} but {} from {higher_repo} has higher repository priority. The packages from the higher priority repository do not match your {reason} and are therefore not installable. That repository is canonical so the lower priority repo's packages are not installable. See https://getcomposer.org/repoprio for details and assistance.",
1812            package_list(ctx, &next_repo_packages, c, false),
1813            repo_name(ctx, next_repo),
1814            package_list(ctx, higher, c, false)
1815        ),
1816    )
1817}
1818
1819/// `getMissingLockedPackageReason`.
1820fn missing_locked_package_reason(ctx: &MessageContext<'_>, idx: usize) -> (String, String) {
1821    let p = &ctx.arena[idx];
1822    let constraint = Constraint::new(Op::Eq, p.version.clone());
1823    let prefix = format!(
1824        "- Package {} {} (in the lock file) ",
1825        p.name, p.pretty_version
1826    );
1827    if ctx
1828        .pool
1829        .is_filter_list_removed_version(&p.name, &constraint)
1830    {
1831        let filters = ctx.pool.filter_list_entries_text(&p.name, &constraint);
1832        let ignore_paths = filters
1833            .iter()
1834            .map(|(l, _)| format!("\"policy.{l}.ignore\""))
1835            .collect::<Vec<_>>()
1836            .join(" and ");
1837        let off_paths = filters
1838            .iter()
1839            .map(|(l, _)| format!("\"policy.{l}.block\""))
1840            .collect::<Vec<_>>()
1841            .join(" and ");
1842        return (
1843            prefix,
1844            format!(
1845                "was not loaded, because it was {}. To ignore filters for this package, add the package to the {ignore_paths} config. To turn the feature off entirely, you can set {off_paths} to false.",
1846                filters.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>().join(", ")
1847            ),
1848        );
1849    }
1850    (
1851        prefix,
1852        "was not loaded (filter list removed locked package must have version removed from pool)."
1853            .to_owned(),
1854    )
1855}
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860
1861    #[test]
1862    fn numeric_strings_compare_as_numbers() {
1863        assert_eq!(php_string_cmp("1234", "98"), Ordering::Greater);
1864        assert_eq!(php_string_cmp("-12", "3"), Ordering::Less);
1865        assert_eq!(php_string_cmp("12-34", "98"), Ordering::Less); // strcmp
1866        assert_eq!(
1867            php_string_cmp("acme/a-1.0.0.0", "acme/b-1.0.0.0"),
1868            Ordering::Less
1869        );
1870        assert_eq!(php_string_cmp("1e3", "999"), Ordering::Greater);
1871    }
1872
1873    #[test]
1874    fn output_styles_are_stripped_but_urls_kept() {
1875        assert_eq!(
1876            strip_output_styles("see <https://getcomposer.org/doc/04-schema.md#minimum-stability> for <info>x</info> <warning>y</warning> <href=https://a>PKSA-1</>"),
1877            "see <https://getcomposer.org/doc/04-schema.md#minimum-stability> for x y PKSA-1"
1878        );
1879    }
1880
1881    #[test]
1882    fn version_lists_condense_per_major() {
1883        let versions: Vec<(String, String)> = [
1884            "1.0.0.0", "1.1.0.0", "1.2.0.0", "1.3.0.0", "1.4.0.0", "2.0.0.0",
1885        ]
1886        .iter()
1887        .map(|v| (v.to_string(), v.trim_end_matches(".0").to_string()))
1888        .collect();
1889        assert_eq!(
1890            condense_version_list(&versions, 4, 16),
1891            vec!["1", "...", "1.4", "2"]
1892        );
1893        assert_eq!(
1894            condense_version_list(&versions[..4], 4, 16),
1895            vec!["1", "1.1", "1.2", "1.3"]
1896        );
1897    }
1898
1899    #[test]
1900    fn exact_constraints_get_their_expansions() {
1901        let c = Constraint::new(Op::Eq, "1.2.0.0");
1902        assert_eq!(
1903            constraint_to_text(Some(PrettyConstraint {
1904                constraint: &c,
1905                pretty: "1.2"
1906            })),
1907            " 1.2 (exact version match: 1.2, 1.2.0 or 1.2.0.0)"
1908        );
1909        assert_eq!(
1910            constraint_to_text(Some(PrettyConstraint {
1911                constraint: &c,
1912                pretty: "v1.2"
1913            })),
1914            " v1.2 (exact version match)"
1915        );
1916        let caret = Constraint::new(Op::Ge, "1.0.0.0-dev");
1917        assert_eq!(
1918            constraint_to_text(Some(PrettyConstraint {
1919                constraint: &caret,
1920                pretty: "^1.0"
1921            })),
1922            " ^1.0"
1923        );
1924    }
1925}