diff --git a/src/Composer/DependencyResolver/Problem.php b/src/Composer/DependencyResolver/Problem.php new file mode 100644 index 000000000..c40367d4c --- /dev/null +++ b/src/Composer/DependencyResolver/Problem.php @@ -0,0 +1,144 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\DependencyResolver; + +/** + * Represents a problem detected while solving dependencies + * + * @author Nils Adermann + */ +class Problem +{ + /** + * A set of reasons for the problem, each is a rule or a job and a rule + * @var array + */ + protected $reasons; + + /** + * Add a job as a reason + * + * @param array $job A job descriptor which is a reason for this problem + * @param Rule $rule An optional rule associated with the job + */ + public function addJobRule($job, Rule $rule = null) + { + $this->addReason(serialize($job), array( + 'rule' => $rule, + 'job' => $job, + )); + } + + /** + * Add a rule as a reason + * + * @param Rule $rule A rule which is a reason for this problem + */ + public function addRule(Rule $rule) + { + $this->addReason($rule->getId(), array( + 'rule' => $rule, + 'job' => null, + )); + } + + /** + * Retrieve all reasons for this problem + * + * @return array The problem's reasons + */ + public function getReasons() + { + return $this->reasons; + } + + /** + * A human readable textual representation of the problem's reasons + */ + public function __toString() + { + if (count($this->reasons) === 1) { + reset($this->reasons); + $reason = current($this->reasons); + + $rule = $reason['rule']; + $job = $reason['job']; + + if ($job && $job['cmd'] === 'install' && empty($job['packages'])) { + return 'The requested package "'.$job['packageName'].'" '.$this->constraintToText($job['constraint']).'could not be found.'; + } + } + + $messages = array("Problem caused by:"); + + foreach ($this->reasons as $reason) { + + $rule = $reason['rule']; + $job = $reason['job']; + + if ($job) { + $messages[] = $this->jobToText($job); + } elseif ($rule) { + if ($rule instanceof Rule) { + $messages[] = $rule->toHumanReadableString(); + } + } + } + + return implode("\n\t\t\t- ", $messages); + } + + /** + * Store a reason descriptor but ignore duplicates + * + * @param string $id A canonical identifier for the reason + * @param string $reason The reason descriptor + */ + protected function addReason($id, $reason) + { + if (!isset($this->reasons[$id])) { + $this->reasons[$id] = $reason; + } + } + + /** + * Turns a job into a human readable description + * + * @param array $job + * @return string + */ + protected function jobToText($job) + { + switch ($job['cmd']) { + case 'install': + return 'Installation of package "'.$job['packageName'].'" '.$this->constraintToText($job['constraint']).'was requested. Satisfiable by packages ['.implode(', ', $job['packages']).'].'; + case 'update': + return 'Update of package "'.$job['packageName'].'" '.$this->constraintToText($job['constraint']).'was requested.'; + case 'remove': + return 'Removal of package "'.$job['packageName'].'" '.$this->constraintToText($job['constraint']).'was requested.'; + } + + return 'Job(cmd='.$job['cmd'].', target='.$job['packageName'].', packages=['.implode(', ', $job['packages']).'])'; + } + + /** + * Turns a constraint into text usable in a sentence describing a job + * + * @param LinkConstraint $constraint + * @return string + */ + protected function constraintToText($constraint) + { + return ($constraint) ? 'with constraint '.$constraint.' ' : ''; + } +} diff --git a/src/Composer/DependencyResolver/Request.php b/src/Composer/DependencyResolver/Request.php index 3d1b28448..92c8aa175 100644 --- a/src/Composer/DependencyResolver/Request.php +++ b/src/Composer/DependencyResolver/Request.php @@ -52,6 +52,7 @@ class Request 'packages' => $packages, 'cmd' => $cmd, 'packageName' => $packageName, + 'constraint' => $constraint, ); } diff --git a/src/Composer/DependencyResolver/Rule.php b/src/Composer/DependencyResolver/Rule.php index 8af24094e..cd674ef0f 100644 --- a/src/Composer/DependencyResolver/Rule.php +++ b/src/Composer/DependencyResolver/Rule.php @@ -17,6 +17,19 @@ namespace Composer\DependencyResolver; */ class Rule { + const RULE_INTERNAL_ALLOW_UPDATE = 1; + const RULE_JOB_INSTALL = 2; + const RULE_JOB_REMOVE = 3; + const RULE_JOB_LOCK = 4; + const RULE_NOT_INSTALLABLE = 5; + const RULE_PACKAGE_CONFLICT = 6; + const RULE_PACKAGE_REQUIRES = 7; + const RULE_PACKAGE_OBSOLETES = 8; + const RULE_INSTALLED_PACKAGE_OBSOLETES = 9; + const RULE_PACKAGE_SAME_NAME = 10; + const RULE_PACKAGE_IMPLICIT_OBSOLETES = 11; + const RULE_LEARNED = 12; + protected $disabled; protected $literals; protected $type; @@ -163,6 +176,68 @@ class Rule } } + public function toHumanReadableString() + { + $ruleText = ''; + foreach ($this->literals as $i => $literal) { + if ($i != 0) { + $ruleText .= '|'; + } + $ruleText .= $literal; + } + + switch ($this->reason) { + case self::RULE_INTERNAL_ALLOW_UPDATE: + return $ruleText; + + case self::RULE_JOB_INSTALL: + return "Install command rule ($ruleText)"; + + case self::RULE_JOB_REMOVE: + return "Remove command rule ($ruleText)"; + + case self::RULE_JOB_LOCK: + return "Lock command rule ($ruleText)"; + + case self::RULE_NOT_INSTALLABLE: + return $ruleText; + + case self::RULE_PACKAGE_CONFLICT: + $package1 = $this->literals[0]->getPackage(); + $package2 = $this->literals[1]->getPackage(); + return 'Package "'.$package1.'" conflicts with "'.$package2.'"'; + + case self::RULE_PACKAGE_REQUIRES: + $literals = $this->literals; + $sourceLiteral = array_shift($literals); + $sourcePackage = $sourceLiteral->getPackage(); + + $requires = array(); + foreach ($literals as $literal) { + $requires[] = $literal->getPackage(); + } + + $text = 'Package "'.$sourcePackage.'" contains the rule '.$this->reasonData.'. '; + if ($requires) { + $text .= 'Any of these packages satisfy the dependency: '.implode(', ', $requires).'.'; + } else { + $text .= 'No package satisfies this dependency.'; + } + return $text; + + case self::RULE_PACKAGE_OBSOLETES: + return $ruleText; + case self::RULE_INSTALLED_PACKAGE_OBSOLETES: + return $ruleText; + case self::RULE_PACKAGE_SAME_NAME: + return $ruleText; + case self::RULE_PACKAGE_IMPLICIT_OBSOLETES: + return $ruleText; + case self::RULE_LEARNED: + return 'learned: '.$ruleText; + } + } + /** * Formats a rule as a string of the format (Literal1|Literal2|...) * diff --git a/src/Composer/DependencyResolver/Solver.php b/src/Composer/DependencyResolver/Solver.php index aa832b113..ef9c786c0 100644 --- a/src/Composer/DependencyResolver/Solver.php +++ b/src/Composer/DependencyResolver/Solver.php @@ -21,21 +21,6 @@ use Composer\DependencyResolver\Operation; */ class Solver { - const RULE_INTERNAL_ALLOW_UPDATE = 1; - const RULE_JOB_INSTALL = 2; - const RULE_JOB_REMOVE = 3; - const RULE_JOB_LOCK = 4; - const RULE_NOT_INSTALLABLE = 5; - const RULE_NOTHING_PROVIDES_DEP = 6; - const RULE_PACKAGE_CONFLICT = 7; - const RULE_PACKAGE_NOT_EXIST = 8; - const RULE_PACKAGE_REQUIRES = 9; - const RULE_PACKAGE_OBSOLETES = 10; - const RULE_INSTALLED_PACKAGE_OBSOLETES = 11; - const RULE_PACKAGE_SAME_NAME = 12; - const RULE_PACKAGE_IMPLICIT_OBSOLETES = 13; - const RULE_LEARNED = 14; - protected $policy; protected $pool; protected $installed; @@ -235,7 +220,7 @@ class Solver } if (!$dontFix && !$this->policy->installable($this, $this->pool, $this->installedMap, $package)) { - $this->addRule(RuleSet::TYPE_PACKAGE, $this->createRemoveRule($package, self::RULE_NOT_INSTALLABLE, (string) $package)); + $this->addRule(RuleSet::TYPE_PACKAGE, $this->createRemoveRule($package, Rule::RULE_NOT_INSTALLABLE, (string) $package)); continue; } @@ -261,7 +246,7 @@ class Solver } } - $this->addRule(RuleSet::TYPE_PACKAGE, $rule = $this->createRequireRule($package, $possibleRequires, self::RULE_PACKAGE_REQUIRES, (string) $link)); + $this->addRule(RuleSet::TYPE_PACKAGE, $rule = $this->createRequireRule($package, $possibleRequires, Rule::RULE_PACKAGE_REQUIRES, (string) $link)); foreach ($possibleRequires as $require) { $workQueue->enqueue($require); @@ -276,7 +261,7 @@ class Solver continue; } - $this->addRule(RuleSet::TYPE_PACKAGE, $this->createConflictRule($package, $conflict, self::RULE_PACKAGE_CONFLICT, (string) $link)); + $this->addRule(RuleSet::TYPE_PACKAGE, $this->createConflictRule($package, $conflict, Rule::RULE_PACKAGE_CONFLICT, (string) $link)); } } @@ -301,7 +286,7 @@ class Solver continue; // don't repair installed/installed problems } - $reason = ($isInstalled) ? self::RULE_INSTALLED_PACKAGE_OBSOLETES : self::RULE_PACKAGE_OBSOLETES; + $reason = ($isInstalled) ? Rule::RULE_INSTALLED_PACKAGE_OBSOLETES : Rule::RULE_PACKAGE_OBSOLETES; $this->addRule(RuleSet::TYPE_PACKAGE, $this->createConflictRule($package, $provider, $reason, (string) $link)); } } @@ -327,7 +312,7 @@ class Solver continue; } - $reason = ($package->getName() == $provider->getName()) ? self::RULE_PACKAGE_SAME_NAME : self::RULE_PACKAGE_IMPLICIT_OBSOLETES; + $reason = ($package->getName() == $provider->getName()) ? Rule::RULE_PACKAGE_SAME_NAME : Rule::RULE_PACKAGE_IMPLICIT_OBSOLETES; $this->addRule(RuleSet::TYPE_PACKAGE, $rule = $this->createConflictRule($package, $provider, $reason, (string) $package)); } } @@ -466,24 +451,29 @@ class Solver $conflict = $this->findDecisionRule($literal->getPackage()); /** TODO: handle conflict with systemsolvable? */ - $this->learnedPool[] = array($rule, $conflict); - if ($conflict && RuleSet::TYPE_PACKAGE === $conflict->getType()) { - if ($rule->getType() == RuleSet::TYPE_JOB) { - $why = $this->ruleToJob[$rule->getId()]; - } else { - $why = $rule; - } - $this->problems[] = array($why); + $problem = new Problem; - $this->disableProblem($why); + if ($rule->getType() == RuleSet::TYPE_JOB) { + $job = $this->ruleToJob[$rule->getId()]; + + $problem->addJobRule($job, $rule); + $problem->addRule($conflict); + $this->disableProblem($job); + } else { + $problem->addRule($rule); + $problem->addRule($conflict); + $this->disableProblem($rule); + } + $this->problems[] = $problem; continue; } // conflict with another job or update/feature rule - - $this->problems[] = array(); + $problem = new Problem; + $problem->addRule($rule); + $problem->addRule($conflict); // push all of our rules (can only be feature or job rules) // asserting this literal on the problem stack @@ -500,14 +490,16 @@ class Solver } if ($assertRule->getType() === RuleSet::TYPE_JOB) { - $why = $this->ruleToJob[$assertRule->getId()]; - } else { - $why = $assertRule; - } - $this->problems[count($this->problems) - 1][] = $why; + $job = $this->ruleToJob[$assertRule->getId()]; - $this->disableProblem($why); + $problem->addJobRule($job, $assertRule); + $this->disableProblem($job); + } else { + $problem->addRule($assertRule); + $this->disableProblem($assertRule); + } } + $this->problems[] = $problem; // start over while (count($this->decisionQueue) > $decisionStart) { @@ -966,7 +958,7 @@ class Solver foreach ($installedPackages as $package) { $updates = $this->policy->findUpdatePackages($this, $this->pool, $this->installedMap, $package); - $rule = $this->createUpdateRule($package, $updates, self::RULE_INTERNAL_ALLOW_UPDATE, (string) $package); + $rule = $this->createUpdateRule($package, $updates, Rule::RULE_INTERNAL_ALLOW_UPDATE, (string) $package); $rule->setWeak(true); $this->addRule(RuleSet::TYPE_FEATURE, $rule); @@ -977,9 +969,11 @@ class Solver switch ($job['cmd']) { case 'install': if (empty($job['packages'])) { - $this->problems[] = array($job); + $problem = new Problem(); + $problem->addJobRule($job); + $this->problems[] = $problem; } else { - $rule = $this->createInstallOneOfRule($job['packages'], self::RULE_JOB_INSTALL, $job['packageName']); + $rule = $this->createInstallOneOfRule($job['packages'], Rule::RULE_JOB_INSTALL, $job['packageName']); $this->addRule(RuleSet::TYPE_JOB, $rule); $this->ruleToJob[$rule->getId()] = $job; } @@ -990,7 +984,7 @@ class Solver // todo: cleandeps foreach ($job['packages'] as $package) { - $rule = $this->createRemoveRule($package, self::RULE_JOB_REMOVE); + $rule = $this->createRemoveRule($package, Rule::RULE_JOB_REMOVE); $this->addRule(RuleSet::TYPE_JOB, $rule); $this->ruleToJob[$rule->getId()] = $job; } @@ -998,9 +992,9 @@ class Solver case 'lock': foreach ($job['packages'] as $package) { if (isset($this->installedMap[$package->getId()])) { - $rule = $this->createInstallRule($package, self::RULE_JOB_LOCK); + $rule = $this->createInstallRule($package, Rule::RULE_JOB_LOCK); } else { - $rule = $this->createRemoveRule($package, self::RULE_JOB_LOCK); + $rule = $this->createRemoveRule($package, Rule::RULE_JOB_LOCK); } $this->addRule(RuleSet::TYPE_JOB, $rule); $this->ruleToJob[$rule->getId()] = $job; @@ -1028,7 +1022,7 @@ class Solver //solver_prepare_solutions(solv); if ($this->problems) { - throw new SolverProblemsException($this->problems, $this->learnedPool); + throw new SolverProblemsException($this->problems); } return $this->createTransaction(); @@ -1487,22 +1481,21 @@ class Solver $why = count($this->learnedPool) - 1; assert($learnedLiterals[0] !== null); - $newRule = new Rule($learnedLiterals, self::RULE_LEARNED, $why); + $newRule = new Rule($learnedLiterals, Rule::RULE_LEARNED, $why); return array($ruleLevel, $newRule, $why); } - private function analyzeUnsolvableRule($conflictRule, &$lastWeakWhy) + private function analyzeUnsolvableRule($problem, $conflictRule, &$lastWeakWhy) { $why = $conflictRule->getId(); if ($conflictRule->getType() == RuleSet::TYPE_LEARNED) { - $learnedWhy = $this->learnedWhy[$why]; - $problem = $this->learnedPool[$learnedWhy]; + $problemRules = $this->learnedPool[$learnedWhy]; - foreach ($problem as $problemRule) { - $this->analyzeUnsolvableRule($problemRule, $lastWeakWhy); + foreach ($problemRules as $problemRule) { + $this->analyzeUnsolvableRule($problem, $problemRule, $lastWeakWhy); } return; } @@ -1520,24 +1513,22 @@ class Solver } if ($conflictRule->getType() == RuleSet::TYPE_JOB) { - $why = $this->ruleToJob[$conflictRule->getId()]; + $job = $this->ruleToJob[$conflictRule->getId()]; + $problem->addJobRule($job, $conflictRule); + } else { + $problem->addRule($conflictRule); } - - // if this problem was already found skip it - if (in_array($why, $this->problems[count($this->problems) - 1], true)) { - return; - } - - $this->problems[count($this->problems) - 1][] = $why; } private function analyzeUnsolvable($conflictRule, $disableRules) { $lastWeakWhy = null; - $this->problems[] = array(); - $this->learnedPool[] = array($conflictRule); + $problem = new Problem; + $problem->addRule($conflictRule); - $this->analyzeUnsolvableRule($conflictRule, $lastWeakWhy); + $this->analyzeUnsolvableRule($problem, $conflictRule, $lastWeakWhy); + + $this->problems[] = $problem; $seen = array(); $literals = $conflictRule->getLiterals(); @@ -1569,9 +1560,9 @@ class Solver } $why = $this->decisionQueueWhy[$decisionId]; - $this->learnedPool[count($this->learnedPool) - 1][] = $why; + $problem->addRule($why); - $this->analyzeUnsolvableRule($why, $lastWeakWhy); + $this->analyzeUnsolvableRule($problem, $why, $lastWeakWhy); $literals = $why->getLiterals(); /* unnecessary because unlike rule.d, watch2 == 2nd literal, unless watch2 changed @@ -1591,7 +1582,6 @@ class Solver if ($lastWeakWhy) { array_pop($this->problems); - array_pop($this->learnedPool); if ($lastWeakWhy->getType() === RuleSet::TYPE_JOB) { $why = $this->ruleToJob[$lastWeakWhy]; @@ -1616,8 +1606,12 @@ class Solver } if ($disableRules) { - foreach ($this->problems[count($this->problems) - 1] as $why) { - $this->disableProblem($why); + foreach ($this->problems[count($this->problems) - 1] as $reason) { + if ($reason['job']) { + $this->disableProblem($reason['job']); + } else { + $this->disableProblem($reason['rule']); + } } $this->resetSolver(); @@ -1670,10 +1664,10 @@ class Solver { foreach ($this->rules->getIteratorFor(RuleSet::TYPE_LEARNED) as $rule) { $why = $this->learnedWhy[$rule->getId()]; - $problem = $this->learnedPool[$why]; + $problemRules = $this->learnedPool[$why]; $foundDisabled = false; - foreach ($problem as $problemRule) { + foreach ($problemRules as $problemRule) { if ($problemRule->disabled()) { $foundDisabled = true; break; diff --git a/src/Composer/DependencyResolver/SolverProblemsException.php b/src/Composer/DependencyResolver/SolverProblemsException.php index cbc4fd571..d558aa122 100644 --- a/src/Composer/DependencyResolver/SolverProblemsException.php +++ b/src/Composer/DependencyResolver/SolverProblemsException.php @@ -19,47 +19,26 @@ class SolverProblemsException extends \RuntimeException { protected $problems; - public function __construct(array $problems, array $learnedPool) + public function __construct(array $problems) { - $message = ''; - foreach ($problems as $i => $problem) { - $message .= '['; - foreach ($problem as $why) { + $this->problems = $problems; - if (is_int($why) && isset($learnedPool[$why])) { - $rules = $learnedPool[$why]; - } else { - $rules = $why; - } + parent::__construct($this->createMessage()); + } - if (isset($rules['packages'])) { - $message .= $this->jobToText($rules); - } else { - $message .= '('; - foreach ($rules as $rule) { - if ($rule instanceof Rule) { - if ($rule->getType() == RuleSet::TYPE_LEARNED) { - $message .= 'learned: '; - } - $message .= $rule . ', '; - } else { - $message .= 'String(' . $rule . '), '; - } - } - $message .= ')'; - } - $message .= ', '; - } - $message .= "]\n"; + protected function createMessage() + { + $messages = array(); + + foreach ($this->problems as $problem) { + $messages[] = (string) $problem; } - parent::__construct($message); + return "\n\tProblems:\n\t\t- ".implode("\n\t\t- ", $messages); } - public function jobToText($job) + public function getProblems() { - //$output = serialize($job); - $output = 'Job(cmd='.$job['cmd'].', target='.$job['packageName'].', packages=['.implode(', ', $job['packages']).'])'; - return $output; + return $this->problems; } } diff --git a/tests/Composer/Test/DependencyResolver/RequestTest.php b/tests/Composer/Test/DependencyResolver/RequestTest.php index d11c3c427..969240860 100644 --- a/tests/Composer/Test/DependencyResolver/RequestTest.php +++ b/tests/Composer/Test/DependencyResolver/RequestTest.php @@ -40,9 +40,9 @@ class RequestTest extends TestCase $this->assertEquals( array( - array('packages' => array($foo), 'cmd' => 'install', 'packageName' => 'foo'), - array('packages' => array($bar), 'cmd' => 'install', 'packageName' => 'bar'), - array('packages' => array($foobar), 'cmd' => 'remove', 'packageName' => 'foobar'), + array('packages' => array($foo), 'cmd' => 'install', 'packageName' => 'foo', 'constraint' => null), + array('packages' => array($bar), 'cmd' => 'install', 'packageName' => 'bar', 'constraint' => null), + array('packages' => array($foobar), 'cmd' => 'remove', 'packageName' => 'foobar', 'constraint' => null), ), $request->getJobs()); } @@ -63,11 +63,11 @@ class RequestTest extends TestCase $pool->addRepository($repo2); $request = new Request($pool); - $request->install('foo', $this->getVersionConstraint('=', '1')); + $request->install('foo', $constraint = $this->getVersionConstraint('=', '1')); $this->assertEquals( array( - array('packages' => array($foo1, $foo2), 'cmd' => 'install', 'packageName' => 'foo'), + array('packages' => array($foo1, $foo2), 'cmd' => 'install', 'packageName' => 'foo', 'constraint' => $constraint), ), $request->getJobs() ); diff --git a/tests/Composer/Test/DependencyResolver/SolverTest.php b/tests/Composer/Test/DependencyResolver/SolverTest.php index fc7dce7a1..9132fe4e5 100644 --- a/tests/Composer/Test/DependencyResolver/SolverTest.php +++ b/tests/Composer/Test/DependencyResolver/SolverTest.php @@ -59,13 +59,15 @@ class SolverTest extends TestCase $this->repo->addPackage($this->getPackage('A', '1.0')); $this->reposComplete(); - $this->request->install('B'); + $this->request->install('B', $this->getVersionConstraint('=', '1')); try { $transaction = $this->solver->solve($this->request); - $this->fail('Unsolvable conflict did not resolve in exception.'); + $this->fail('Unsolvable conflict did not result in exception.'); } catch (SolverProblemsException $e) { - // TODO assert problem properties + $problems = $e->getProblems(); + $this->assertEquals(1, count($problems)); + $this->assertEquals('The requested package "b" with constraint == 1.0.0.0 could not be found.', (string) $problems[0]); } } @@ -589,8 +591,10 @@ class SolverTest extends TestCase try { $transaction = $this->solver->solve($this->request); - $this->fail('Unsolvable conflict did not resolve in exception.'); + $this->fail('Unsolvable conflict did not result in exception.'); } catch (SolverProblemsException $e) { + $problems = $e->getProblems(); + $this->assertEquals(1, count($problems)); // TODO assert problem properties } } @@ -610,8 +614,10 @@ class SolverTest extends TestCase try { $transaction = $this->solver->solve($this->request); - $this->fail('Unsolvable conflict did not resolve in exception.'); + $this->fail('Unsolvable conflict did not result in exception.'); } catch (SolverProblemsException $e) { + $problems = $e->getProblems(); + $this->assertEquals(1, count($problems)); // TODO assert problem properties } }