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            Some(Repository::Path(repo)) => repo.repo_name(),
1029            _ => "unknown repo".to_owned(),
1030        },
1031        Origin::Detached | Origin::Result => "unknown repo".to_owned(),
1032    }
1033}
1034
1035/// `RepositorySet::findPackages($name, $constraint, $flags)`: arena
1036/// indices, repository order.
1037fn find_packages(
1038    ctx: &mut MessageContext<'_>,
1039    name: &str,
1040    constraint: Option<&Constraint>,
1041    flags: u8,
1042) -> Vec<usize> {
1043    let ignore_stability = flags & ALLOW_UNACCEPTABLE_STABILITIES != 0;
1044    let from_all_repos = flags & ALLOW_SHADOWED_REPOSITORIES != 0;
1045    let all_stabilities: BTreeMap<String, i32> = ["stable", "RC", "beta", "alpha", "dev"]
1046        .iter()
1047        .map(|s| (s.to_string(), crate::version::stability_rank(s)))
1048        .collect();
1049    let empty_flags = BTreeMap::new();
1050    let already = BTreeMap::new();
1051    let map = vec![(
1052        name.to_owned(),
1053        constraint.cloned().unwrap_or(Constraint::MatchAll),
1054    )];
1055    let mut found: Vec<usize> = Vec::new();
1056    for (i, repository) in ctx.set.repositories.iter().enumerate() {
1057        let members: Option<&Vec<usize>> = match repository {
1058            Repository::Root(m) | Repository::Platform(m) | Repository::Locked(m) => Some(m),
1059            Repository::Path(repo) => Some(&repo.members),
1060            Repository::Composer(_) => None,
1061        };
1062        if from_all_repos {
1063            // `$repository->findPackages($name, $constraint)`: no stability
1064            // filter at this stage.
1065            match repository {
1066                Repository::Composer(repo) => {
1067                    if let Ok((_, ids)) = repo.load_packages(
1068                        &map,
1069                        &all_stabilities,
1070                        &empty_flags,
1071                        &already,
1072                        Origin::Repository(i),
1073                        ctx.arena,
1074                    ) {
1075                        found.extend(ids);
1076                    }
1077                }
1078                _ => {
1079                    for &idx in members.expect("array repository") {
1080                        let p = &ctx.arena[idx];
1081                        if p.name == name
1082                            && constraint.is_none_or(|c| {
1083                                c.matches(&Constraint::new(Op::Eq, p.version.clone()))
1084                            })
1085                        {
1086                            found.push(idx);
1087                        }
1088                    }
1089                }
1090            }
1091            continue;
1092        }
1093        let (acceptable, stability_flags) = if ignore_stability {
1094            (&all_stabilities, &empty_flags)
1095        } else {
1096            (&ctx.set.acceptable_stabilities, &ctx.set.stability_flags)
1097        };
1098        let (names_found, ids) = match repository {
1099            Repository::Composer(repo) => repo
1100                .load_packages(
1101                    &map,
1102                    acceptable,
1103                    stability_flags,
1104                    &already,
1105                    Origin::Repository(i),
1106                    ctx.arena,
1107                )
1108                .unwrap_or_default(),
1109            _ => crate::pool::array_repository_load_packages(
1110                members.expect("array repository"),
1111                &map,
1112                acceptable,
1113                stability_flags,
1114                &already,
1115                ctx.arena,
1116            ),
1117        };
1118        found.extend(ids);
1119        if names_found.iter().any(|n| n == name) {
1120            break;
1121        }
1122    }
1123    if ignore_stability || !from_all_repos {
1124        return found;
1125    }
1126    found
1127        .into_iter()
1128        .filter(|&idx| {
1129            let p = &ctx.arena[idx];
1130            is_package_acceptable(
1131                &ctx.set.acceptable_stabilities,
1132                &ctx.set.stability_flags,
1133                &p.names(true),
1134                p.stability,
1135            )
1136        })
1137        .collect()
1138}
1139
1140/// `RepositorySet::getProviders($name)` without the providers API: every
1141/// repository's loaded packages whose `provide` links target the name
1142/// (`ArrayRepository::getProviders`), merged by name.
1143fn providers(ctx: &mut MessageContext<'_>, name: &str) -> Vec<(String, Option<String>)> {
1144    let mut result: Vec<(String, Option<String>)> = Vec::new();
1145    for (i, repository) in ctx.set.repositories.iter().enumerate() {
1146        let candidates: Vec<usize> = match repository {
1147            Repository::Root(m) | Repository::Platform(m) | Repository::Locked(m) => m.clone(),
1148            Repository::Path(repo) => repo.members.clone(),
1149            Repository::Composer(repo) => {
1150                // `ComposerRepository::getProviders`: the providers API
1151                // answers alone when the repository declares one.
1152                if let Ok(Some(api)) = repo.providers_api(name) {
1153                    for (n, d) in api {
1154                        match result.iter_mut().find(|(existing, _)| *existing == n) {
1155                            Some(slot) => slot.1 = d,
1156                            None => result.push((n, d)),
1157                        }
1158                    }
1159                    continue;
1160                }
1161                repo.provider_candidates(Origin::Repository(i), ctx.arena)
1162                    .unwrap_or_default()
1163            }
1164        };
1165        let mut repo_result: Vec<(String, Option<String>)> = Vec::new();
1166        for idx in candidates {
1167            let p = &ctx.arena[idx];
1168            if repo_result.iter().any(|(n, _)| *n == p.name) {
1169                continue;
1170            }
1171            if p.provides.iter().any(|l| l.target == name) {
1172                let description = p
1173                    .raw
1174                    .get("description")
1175                    .and_then(serde_json::Value::as_str)
1176                    .map(str::to_owned);
1177                repo_result.push((p.name.clone(), description));
1178            }
1179        }
1180        // `array_merge` on string keys: first position kept, value replaced.
1181        for (n, d) in repo_result {
1182            match result.iter_mut().find(|(existing, _)| *existing == n) {
1183                Some(slot) => slot.1 = d,
1184                None => result.push((n, d)),
1185            }
1186        }
1187    }
1188    result
1189}
1190
1191/// `getProvidersList`.
1192fn providers_list(ctx: &mut MessageContext<'_>, name: &str, max: usize) -> Option<String> {
1193    let list = providers(ctx, name);
1194    if list.is_empty() {
1195        return None;
1196    }
1197    let shown: &[(String, Option<String>)] = if list.len() > max + 1 {
1198        &list[..max]
1199    } else {
1200        &list
1201    };
1202    let mut text = String::new();
1203    for (n, description) in shown {
1204        let d = match description {
1205            Some(d) if !d.is_empty() => format!(" {}", php_substr(d, 100)),
1206            _ => String::new(),
1207        };
1208        text.push_str(&format!("      - {n}{d}\n"));
1209    }
1210    if list.len() > max + 1 {
1211        text.push_str(&format!("      ... and {} more.\n", list.len() - max));
1212    }
1213    Some(text)
1214}
1215
1216/// `substr($s, 0, $n)` on bytes (a UTF-8 cut is kept as PHP makes it).
1217fn php_substr(s: &str, n: usize) -> String {
1218    let bytes = s.as_bytes();
1219    if bytes.len() <= n {
1220        return s.to_owned();
1221    }
1222    String::from_utf8_lossy(&bytes[..n]).into_owned()
1223}
1224
1225/// `getPlatformPackageVersion`.
1226fn platform_package_version(ctx: &MessageContext<'_>, name: &str) -> Option<String> {
1227    let available = ctx.pool.what_provides(ctx.arena, name, None);
1228    if available.is_empty() {
1229        return None;
1230    }
1231    let idxs: Vec<usize> = available
1232        .iter()
1233        .map(|&id| ctx.pool.package_by_id(id))
1234        .collect();
1235    let selected = idxs
1236        .iter()
1237        .copied()
1238        .find(|&idx| ctx.arena[idx].origin == Origin::Platform)
1239        .unwrap_or(idxs[0]);
1240    let p = &ctx.arena[selected];
1241    if p.name != name {
1242        for link in p.provides.iter().chain(p.replaces.iter()) {
1243            if link.target == name {
1244                let description = link.kind.description();
1245                return Some(format!(
1246                    "{} {}d by {}",
1247                    link.pretty_constraint,
1248                    &description[..description.len() - 1],
1249                    p.pretty_string()
1250                ));
1251            }
1252        }
1253    }
1254    let mut version = p.pretty_version.clone();
1255    let overridden = p
1256        .raw
1257        .get("extra")
1258        .and_then(|e| e.get("config.platform"))
1259        .and_then(serde_json::Value::as_bool)
1260        == Some(true);
1261    if overridden {
1262        let description = p
1263            .raw
1264            .get("description")
1265            .and_then(serde_json::Value::as_str)
1266            .unwrap_or("");
1267        version.push_str(&format!("; {}", description.replace("Package ", "")));
1268    }
1269    Some(version)
1270}
1271
1272// ---------------------------------------------------------------------------
1273// Missing package reasons
1274
1275/// `Problem::getMissingPackageReason`: `[prefix, reason]`.
1276fn missing_package_reason(
1277    ctx: &mut MessageContext<'_>,
1278    package_name: &str,
1279    constraint: Option<PrettyConstraint<'_>>,
1280) -> (String, String) {
1281    let ctt = constraint_to_text(constraint);
1282    let c = constraint.map(|pc| pc.constraint);
1283    if is_platform_package(package_name) {
1284        if package_name.to_lowercase().starts_with("php") || package_name == "hhvm" {
1285            let version = platform_package_version(ctx, package_name);
1286            let msg = format!("- Root composer.json requires {package_name}{ctt} but ");
1287            if package_name == "hhvm" {
1288                if !ctx
1289                    .pool
1290                    .what_provides(ctx.arena, package_name, None)
1291                    .is_empty()
1292                {
1293                    return (
1294                        msg,
1295                        "your HHVM version does not satisfy that requirement.".to_owned(),
1296                    );
1297                }
1298                return (
1299                    msg,
1300                    "HHVM was not detected on this machine, make sure it is in your PATH."
1301                        .to_owned(),
1302                );
1303            }
1304            return match version {
1305                None => (
1306                    msg,
1307                    format!("the {package_name} package is disabled by your platform config. Enable it again with \"composer config platform.{package_name} --unset\"."),
1308                ),
1309                Some(v) => (
1310                    msg,
1311                    format!("your {package_name} version ({v}) does not satisfy that requirement."),
1312                ),
1313            };
1314        }
1315        if package_name.to_lowercase().starts_with("ext-") {
1316            if package_name.contains(' ') {
1317                return (
1318                    "- ".to_owned(),
1319                    format!(
1320                        "PHP extension {package_name} should be required as {}.",
1321                        package_name.replace(' ', "-")
1322                    ),
1323                );
1324            }
1325            let ext = &package_name[4..];
1326            let msg =
1327                format!("- Root composer.json requires PHP extension {package_name}{ctt} but ");
1328            let version = platform_package_version(ctx, package_name);
1329            if version.is_none() {
1330                let providers_str = providers_list(ctx, package_name, 5).map(|s| {
1331                    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}")
1332                }).unwrap_or_default();
1333                if ctx.loaded_extensions.contains(&package_name.to_lowercase()) {
1334                    return (
1335                        msg,
1336                        format!("the {package_name} package is disabled by your platform config. Enable it again with \"composer config platform.{package_name} --unset\".{providers_str}"),
1337                    );
1338                }
1339                return (
1340                    msg,
1341                    format!("it is missing from your system. Install or enable PHP's {ext} extension.{providers_str}"),
1342                );
1343            }
1344            return (
1345                msg,
1346                format!(
1347                    "it has the wrong version installed ({}).",
1348                    version.expect("checked")
1349                ),
1350            );
1351        }
1352        if package_name.to_lowercase().starts_with("lib-") {
1353            if package_name.to_lowercase() == "lib-icu" {
1354                let error = if ctx.loaded_extensions.contains("ext-intl") {
1355                    "it has the wrong version installed, try upgrading the intl extension."
1356                } else {
1357                    "it is missing from your system, make sure the intl extension is loaded."
1358                };
1359                return (
1360                    format!(
1361                        "- Root composer.json requires linked library {package_name}{ctt} but "
1362                    ),
1363                    error.to_owned(),
1364                );
1365            }
1366            let providers_str = providers_list(ctx, package_name, 5).map(|s| {
1367                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}")
1368            }).unwrap_or_default();
1369            return (
1370                format!("- Root composer.json requires linked library {package_name}{ctt} but "),
1371                format!("it has the wrong version installed or is missing from your system, make sure to load the extension providing it.{providers_str}"),
1372            );
1373        }
1374    }
1375
1376    let mut locked_package: Option<usize> = None;
1377    for idx in ctx.request.locked_packages_all() {
1378        if ctx.arena[idx].name == package_name {
1379            locked_package = Some(idx);
1380            if ctx.pool.is_unacceptable_fixed_or_locked(idx) {
1381                let p = &ctx.arena[idx];
1382                return (
1383                    "- ".to_owned(),
1384                    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),
1385                );
1386            }
1387            break;
1388        }
1389    }
1390
1391    if let Some(pc) = constraint {
1392        if let Constraint::Single { op: Op::Eq, .. } = pc.constraint {
1393            if pc.pretty.starts_with("dev-") && pc.pretty.contains('#') {
1394                let new_constraint = strip_as_suffix(pc.pretty);
1395                let alt = new_constraint.replace('#', "+");
1396                let multi = Constraint::Multi {
1397                    constraints: vec![
1398                        Constraint::new(Op::Eq, new_constraint.clone()),
1399                        Constraint::new(Op::Eq, alt),
1400                    ],
1401                    conjunctive: false,
1402                };
1403                let packages = find_packages(ctx, package_name, Some(&multi), 0);
1404                if !packages.is_empty() {
1405                    return (
1406                        format!("- Root composer.json requires {package_name}{ctt}, "),
1407                        format!(
1408                            "found {}. The # character in branch names is replaced by a + character. Make sure to require it as \"{}\".",
1409                            package_list(ctx, &packages, c, false),
1410                            pc.pretty.replace('#', "+")
1411                        ),
1412                    );
1413                }
1414            }
1415        }
1416    }
1417
1418    let packages = find_packages(ctx, package_name, c, 0);
1419    if !packages.is_empty() {
1420        if let Some(root_req) = ctx.set.root_requires.get(package_name) {
1421            let root_pretty = ctx
1422                .request
1423                .pretty_requires
1424                .get(package_name)
1425                .cloned()
1426                .unwrap_or_else(|| root_req.to_string());
1427            if !packages.iter().any(|&idx| {
1428                root_req.matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone()))
1429            }) {
1430                return (
1431                    format!("- Root composer.json requires {package_name}{ctt}, "),
1432                    format!(
1433                        "found {} but {} with your root composer.json require ({root_pretty}).",
1434                        package_list(ctx, &packages, c, false),
1435                        if has_multiple_names(ctx, &packages) {
1436                            "these conflict"
1437                        } else {
1438                            "it conflicts"
1439                        }
1440                    ),
1441                );
1442            }
1443        }
1444        let first_names = ctx.arena[packages[0]].names(true);
1445        for name in first_names {
1446            if let Some(temp) = ctx.set.temporary_constraints.get(&name) {
1447                if !packages.iter().any(|&idx| {
1448                    temp.constraint
1449                        .matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone()))
1450                }) {
1451                    return (
1452                        format!("- Root composer.json requires {name}{ctt}, "),
1453                        format!(
1454                            "found {} but {} with your temporary update constraint ({name}:{temp}).",
1455                            package_list(ctx, &packages, c, false),
1456                            if has_multiple_names(ctx, &packages) {
1457                                "these conflict"
1458                            } else {
1459                                "it conflicts"
1460                            }
1461                        ),
1462                    );
1463                }
1464            }
1465        }
1466        if let Some(locked) = locked_package {
1467            let fixed = Constraint::new(Op::Eq, ctx.arena[locked].version.clone());
1468            if !packages
1469                .iter()
1470                .any(|&idx| fixed.matches(&Constraint::new(Op::Eq, ctx.arena[idx].version.clone())))
1471            {
1472                return (
1473                    format!("- Root composer.json requires {package_name}{ctt}, "),
1474                    format!(
1475                        "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.",
1476                        package_list(ctx, &packages, c, false),
1477                        ctx.arena[locked].pretty_version
1478                    ),
1479                );
1480            }
1481        }
1482        if let Some(cst) = c {
1483            if ctx.pool.is_abandoned_removed(package_name, cst) {
1484                return (
1485                    format!("- Root composer.json requires {package_name}{ctt}, "),
1486                    format!(
1487                        "found {} but these were not loaded, because they are abandoned and you configured \"policy.abandoned.block\" to true.",
1488                        package_list(ctx, &packages, c, false)
1489                    ),
1490                );
1491            }
1492            if ctx.pool.is_security_removed(package_name, cst) {
1493                let complete = matching_security_advisories(ctx, &packages, package_name);
1494                let (advisories_list, advisory_ids): (Vec<String>, Vec<String>) = match complete {
1495                    Some(list) if !list.is_empty() => (
1496                        list.iter()
1497                            .map(|a| {
1498                                let link = a.complete.as_ref().and_then(|c| c.link.clone());
1499                                match link {
1500                                    Some(l) if !l.is_empty() => {
1501                                        format!("<href={}>{}</>", escape_output(&l), a.advisory_id)
1502                                    }
1503                                    _ if a.advisory_id.starts_with("PKSA-") => format!(
1504                                        "<href={}>{}</>",
1505                                        escape_output(&format!(
1506                                            "https://packagist.org/security-advisories/{}",
1507                                            a.advisory_id
1508                                        )),
1509                                        a.advisory_id
1510                                    ),
1511                                    _ => a.advisory_id.clone(),
1512                                }
1513                            })
1514                            .collect(),
1515                        list.iter().map(|a| a.advisory_id.clone()).collect(),
1516                    ),
1517                    _ => {
1518                        let ids = ctx.pool.security_advisory_ids(package_name, cst);
1519                        (
1520                            ids.iter()
1521                                .map(|id| {
1522                                    if id.starts_with("PKSA-") {
1523                                        format!(
1524                                            "<href={}>{id}</>",
1525                                            escape_output(&format!(
1526                                                "https://packagist.org/security-advisories/{id}"
1527                                            ))
1528                                        )
1529                                    } else {
1530                                        id.clone()
1531                                    }
1532                                })
1533                                .collect(),
1534                            ids,
1535                        )
1536                    }
1537                };
1538                let has_packagist = advisory_ids.iter().all(|id| id.starts_with("PKSA-"));
1539                let details_hint = if has_packagist {
1540                    " Go to https://packagist.org/security-advisories/ to find advisory details."
1541                } else {
1542                    " Review the advisory details above for more information."
1543                };
1544                return (
1545                    format!("- Root composer.json requires {package_name}{ctt}, "),
1546                    format!(
1547                        "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.",
1548                        package_list(ctx, &packages, c, false),
1549                        advisories_list.join("\", \"")
1550                    ),
1551                );
1552            }
1553            if ctx.pool.is_filter_list_removed_version(package_name, cst) {
1554                let filters = ctx.pool.filter_list_entries_text(package_name, cst);
1555                let ignore_paths = filters
1556                    .iter()
1557                    .map(|(l, _)| format!("\"policy.{l}.ignore\""))
1558                    .collect::<Vec<_>>()
1559                    .join(" and ");
1560                let off_paths = filters
1561                    .iter()
1562                    .map(|(l, _)| format!("\"policy.{l}.block\""))
1563                    .collect::<Vec<_>>()
1564                    .join(" and ");
1565                return (
1566                    format!("- Root composer.json requires {package_name}{ctt}, "),
1567                    format!(
1568                        "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.",
1569                        package_list(ctx, &packages, c, false),
1570                        filters.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>().join(", ")
1571                    ),
1572                );
1573            }
1574        }
1575        if packages
1576            .iter()
1577            .all(|&idx| ctx.arena[idx].origin == Origin::Locked)
1578        {
1579            return (
1580                format!("- Root composer.json requires {package_name}{ctt}, "),
1581                format!(
1582                    "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.",
1583                    package_list(ctx, &packages, c, false)
1584                ),
1585            );
1586        }
1587        return (
1588            format!("- Root composer.json requires {package_name}{ctt}, "),
1589            format!(
1590                "found {} but these were not loaded, likely because {} with another require.",
1591                package_list(ctx, &packages, c, false),
1592                if has_multiple_names(ctx, &packages) {
1593                    "they conflict"
1594                } else {
1595                    "it conflicts"
1596                }
1597            ),
1598        );
1599    }
1600
1601    let packages = find_packages(ctx, package_name, c, ALLOW_UNACCEPTABLE_STABILITIES);
1602    if !packages.is_empty() {
1603        let all_repos = find_packages(ctx, package_name, c, ALLOW_SHADOWED_REPOSITORIES);
1604        if !all_repos.is_empty() {
1605            return check_for_lower_prio_repo(
1606                ctx,
1607                package_name,
1608                &packages,
1609                &all_repos,
1610                "minimum-stability",
1611                constraint,
1612            );
1613        }
1614        return (
1615            format!("- Root composer.json requires {package_name}{ctt}, "),
1616            format!(
1617                "found {} but {} not match your minimum-stability.",
1618                package_list(ctx, &packages, c, false),
1619                if has_multiple_names(ctx, &packages) {
1620                    "these do"
1621                } else {
1622                    "it does"
1623                }
1624            ),
1625        );
1626    }
1627
1628    let packages = find_packages(ctx, package_name, None, ALLOW_UNACCEPTABLE_STABILITIES);
1629    if !packages.is_empty() {
1630        let all_repos = find_packages(ctx, package_name, c, ALLOW_SHADOWED_REPOSITORIES);
1631        if !all_repos.is_empty() {
1632            return check_for_lower_prio_repo(
1633                ctx,
1634                package_name,
1635                &packages,
1636                &all_repos,
1637                "constraint",
1638                constraint,
1639            );
1640        }
1641        let mut suffix = String::new();
1642        if let Some(pc) = constraint {
1643            if let Constraint::Single { version, .. } = pc.constraint {
1644                if version == "dev-master" {
1645                    for &idx in &packages {
1646                        let v = &ctx.arena[idx].version;
1647                        if v == "dev-default" || v == "dev-main" {
1648                            suffix = format!(
1649                                " Perhaps dev-master was renamed to {}?",
1650                                ctx.arena[idx].pretty_version
1651                            );
1652                            break;
1653                        }
1654                    }
1655                }
1656            }
1657        }
1658        if ctx.arena[packages[0]].origin == Origin::Root {
1659            suffix =
1660                " See https://getcomposer.org/dep-on-root for details and assistance.".to_owned();
1661        }
1662        return (
1663            format!("- Root composer.json requires {package_name}{ctt}, "),
1664            format!(
1665                "found {} but {} not match the constraint.{suffix}",
1666                package_list(ctx, &packages, c, false),
1667                if has_multiple_names(ctx, &packages) {
1668                    "these do"
1669                } else {
1670                    "it does"
1671                }
1672            ),
1673        );
1674    }
1675
1676    let legal = |ch: char| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '/' | '-');
1677    if !package_name.chars().all(legal) {
1678        let illegal: String = package_name.chars().filter(|c| !legal(*c)).collect();
1679        return (
1680            format!("- Root composer.json requires {package_name}, it "),
1681            format!(
1682                "could not be found, it looks like its name is invalid, \"{illegal}\" is not allowed in package names."
1683            ),
1684        );
1685    }
1686
1687    if let Some(providers_str) = providers_list(ctx, package_name, 15) {
1688        return (
1689            format!("- Root composer.json requires {package_name}{ctt}, it "),
1690            format!(
1691                "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."
1692            ),
1693        );
1694    }
1695
1696    (
1697        format!("- Root composer.json requires {package_name}, it "),
1698        "could not be found in any version, there may be a typo in the package name.".to_owned(),
1699    )
1700}
1701
1702/// `Preg::replace('{ +as +([^,\s|]+)$}', '', $pretty)`.
1703fn strip_as_suffix(pretty: &str) -> String {
1704    let trimmed = pretty.trim_end();
1705    if let Some(pos) = trimmed.rfind(" as ") {
1706        let after = &trimmed[pos + 4..];
1707        let after = after.trim_start();
1708        if !after.is_empty() && !after.contains([',', ' ', '|']) {
1709            return trimmed[..pos].trim_end().to_owned();
1710        }
1711    }
1712    pretty.to_owned()
1713}
1714
1715/// `OutputFormatter::escape` for `<href=…>`: a backslash before `<`.
1716fn escape_output(text: &str) -> String {
1717    text.replace('<', "\\<")
1718}
1719
1720/// `RepositorySet::getMatchingSecurityAdvisories($packages, false, true)`
1721/// for one name: the complete advisories, `None` when a repository fails
1722/// (the reference would throw; the ids recorded by the filter are used).
1723fn matching_security_advisories(
1724    ctx: &mut MessageContext<'_>,
1725    packages: &[usize],
1726    package_name: &str,
1727) -> Option<Vec<Advisory>> {
1728    let map = crate::pool_filters::constraints_by_name(packages, ctx.arena);
1729    let mut unreachable = Vec::new();
1730    let all = crate::pool_filters::security_advisories_for_constraints(
1731        &ctx.set.repositories,
1732        &map,
1733        false,
1734        true,
1735        &mut unreachable,
1736    )
1737    .ok()?;
1738    all.into_iter()
1739        .find(|(n, _)| n == package_name)
1740        .map(|(_, list)| list)
1741}
1742
1743/// `computeCheckForLowerPrioRepo`.
1744fn check_for_lower_prio_repo(
1745    ctx: &mut MessageContext<'_>,
1746    package_name: &str,
1747    higher: &[usize],
1748    all_repos: &[usize],
1749    reason: &str,
1750    constraint: Option<PrettyConstraint<'_>>,
1751) -> (String, String) {
1752    let ctt = constraint_to_text(constraint);
1753    let c = constraint.map(|pc| pc.constraint);
1754    let mut next_repo: Option<Origin> = None;
1755    let mut next_repo_packages: Vec<usize> = Vec::new();
1756    for &idx in all_repos {
1757        let origin = ctx.arena[idx].origin;
1758        match next_repo {
1759            None => {
1760                next_repo = Some(origin);
1761                next_repo_packages.push(idx);
1762            }
1763            Some(o) if o == origin => next_repo_packages.push(idx),
1764            Some(_) => break,
1765        }
1766    }
1767    let next_repo = next_repo.expect("non-empty");
1768    if let Some(&top) = higher.first() {
1769        if ctx.arena[top].origin == Origin::Root {
1770            let p = &ctx.arena[top];
1771            return (
1772                format!("- Root composer.json requires {package_name}{ctt}, it is "),
1773                format!(
1774                    "satisfiable by {} from {} but {} {} is the root package and cannot be modified. See https://getcomposer.org/dep-on-root for details and assistance.",
1775                    package_list(ctx, &next_repo_packages, c, false),
1776                    repo_name(ctx, next_repo),
1777                    p.pretty_name,
1778                    p.pretty_version
1779                ),
1780            );
1781        }
1782    }
1783    if next_repo == Origin::Locked {
1784        let singular = higher.len() == 1;
1785        let mut suggestion = format!(
1786            "Make sure you either fix the {reason} or avoid updating this package to keep the one present in the lock file ({}).",
1787            package_list(ctx, &next_repo_packages, c, false)
1788        );
1789        let first = &ctx.arena[next_repo_packages[0]];
1790        if first.dist.as_ref().is_some_and(|d| d.kind == "path") {
1791            let symlink_off = first
1792                .raw
1793                .get("transport-options")
1794                .and_then(|t| t.get("symlink"))
1795                .and_then(serde_json::Value::as_bool)
1796                == Some(false);
1797            if !symlink_off {
1798                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.");
1799            }
1800        }
1801        return (
1802            format!("- Root composer.json requires {package_name}{ctt}, "),
1803            format!(
1804                "found {} but {} not match your {reason} and {} therefore not installable. {suggestion}",
1805                package_list(ctx, higher, c, false),
1806                if singular { "it does" } else { "these do" },
1807                if singular { "is" } else { "are" }
1808            ),
1809        );
1810    }
1811    let higher_repo = repo_name(ctx, ctx.arena[higher[0]].origin);
1812    (
1813        format!("- Root composer.json requires {package_name}{ctt}, it is "),
1814        format!(
1815            "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.",
1816            package_list(ctx, &next_repo_packages, c, false),
1817            repo_name(ctx, next_repo),
1818            package_list(ctx, higher, c, false)
1819        ),
1820    )
1821}
1822
1823/// `getMissingLockedPackageReason`.
1824fn missing_locked_package_reason(ctx: &MessageContext<'_>, idx: usize) -> (String, String) {
1825    let p = &ctx.arena[idx];
1826    let constraint = Constraint::new(Op::Eq, p.version.clone());
1827    let prefix = format!(
1828        "- Package {} {} (in the lock file) ",
1829        p.name, p.pretty_version
1830    );
1831    if ctx
1832        .pool
1833        .is_filter_list_removed_version(&p.name, &constraint)
1834    {
1835        let filters = ctx.pool.filter_list_entries_text(&p.name, &constraint);
1836        let ignore_paths = filters
1837            .iter()
1838            .map(|(l, _)| format!("\"policy.{l}.ignore\""))
1839            .collect::<Vec<_>>()
1840            .join(" and ");
1841        let off_paths = filters
1842            .iter()
1843            .map(|(l, _)| format!("\"policy.{l}.block\""))
1844            .collect::<Vec<_>>()
1845            .join(" and ");
1846        return (
1847            prefix,
1848            format!(
1849                "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.",
1850                filters.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>().join(", ")
1851            ),
1852        );
1853    }
1854    (
1855        prefix,
1856        "was not loaded (filter list removed locked package must have version removed from pool)."
1857            .to_owned(),
1858    )
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864
1865    #[test]
1866    fn numeric_strings_compare_as_numbers() {
1867        assert_eq!(php_string_cmp("1234", "98"), Ordering::Greater);
1868        assert_eq!(php_string_cmp("-12", "3"), Ordering::Less);
1869        assert_eq!(php_string_cmp("12-34", "98"), Ordering::Less); // strcmp
1870        assert_eq!(
1871            php_string_cmp("acme/a-1.0.0.0", "acme/b-1.0.0.0"),
1872            Ordering::Less
1873        );
1874        assert_eq!(php_string_cmp("1e3", "999"), Ordering::Greater);
1875    }
1876
1877    #[test]
1878    fn output_styles_are_stripped_but_urls_kept() {
1879        assert_eq!(
1880            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</>"),
1881            "see <https://getcomposer.org/doc/04-schema.md#minimum-stability> for x y PKSA-1"
1882        );
1883    }
1884
1885    #[test]
1886    fn version_lists_condense_per_major() {
1887        let versions: Vec<(String, String)> = [
1888            "1.0.0.0", "1.1.0.0", "1.2.0.0", "1.3.0.0", "1.4.0.0", "2.0.0.0",
1889        ]
1890        .iter()
1891        .map(|v| (v.to_string(), v.trim_end_matches(".0").to_string()))
1892        .collect();
1893        assert_eq!(
1894            condense_version_list(&versions, 4, 16),
1895            vec!["1", "...", "1.4", "2"]
1896        );
1897        assert_eq!(
1898            condense_version_list(&versions[..4], 4, 16),
1899            vec!["1", "1.1", "1.2", "1.3"]
1900        );
1901    }
1902
1903    #[test]
1904    fn exact_constraints_get_their_expansions() {
1905        let c = Constraint::new(Op::Eq, "1.2.0.0");
1906        assert_eq!(
1907            constraint_to_text(Some(PrettyConstraint {
1908                constraint: &c,
1909                pretty: "1.2"
1910            })),
1911            " 1.2 (exact version match: 1.2, 1.2.0 or 1.2.0.0)"
1912        );
1913        assert_eq!(
1914            constraint_to_text(Some(PrettyConstraint {
1915                constraint: &c,
1916                pretty: "v1.2"
1917            })),
1918            " v1.2 (exact version match)"
1919        );
1920        let caret = Constraint::new(Op::Ge, "1.0.0.0-dev");
1921        assert_eq!(
1922            constraint_to_text(Some(PrettyConstraint {
1923                constraint: &caret,
1924                pretty: "^1.0"
1925            })),
1926            " ^1.0"
1927        );
1928    }
1929}