diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 632e869df..9928692eb 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -21,6 +21,7 @@ use Composer\IO\IOInterface; use Composer\Package\BasePackage; use Composer\DependencyResolver\Pool; use Composer\DependencyResolver\Operation\InstallOperation; +use Composer\Package\Version\VersionSelector; use Composer\Repository\ComposerRepository; use Composer\Repository\CompositeRepository; use Composer\Repository\FilesystemRepository; @@ -242,7 +243,6 @@ EOT } $parser = new VersionParser(); - $candidates = array(); $requirements = $parser->parseNameVersionPairs(array($packageName)); $name = strtolower($requirements[0]['name']); if (!$packageVersion && isset($requirements[0]['version'])) { @@ -266,15 +266,11 @@ EOT $pool = new Pool($stability); $pool->addRepository($sourceRepo); - $constraint = $packageVersion ? $parser->parseConstraints($packageVersion) : null; - $candidates = $pool->whatProvides($name, $constraint); - foreach ($candidates as $key => $candidate) { - if ($candidate->getName() !== $name) { - unset($candidates[$key]); - } - } + // find the latest version if there are multiple + $versionSelector = new VersionSelector($pool); + $package = $versionSelector->findBestCandidate($name, $packageVersion); - if (!$candidates) { + if (!$package) { throw new \InvalidArgumentException("Could not find package $name" . ($packageVersion ? " with version $packageVersion." : " with stability $stability.")); } @@ -283,15 +279,6 @@ EOT $directory = getcwd() . DIRECTORY_SEPARATOR . array_pop($parts); } - // select highest version if we have many - $package = reset($candidates); - foreach ($candidates as $candidate) { - if (version_compare($package->getVersion(), $candidate->getVersion(), '<')) { - $package = $candidate; - } - } - unset($candidates); - $io->write('Installing ' . $package->getName() . ' (' . VersionParser::formatVersion($package, false) . ')'); if ($disablePlugins) { diff --git a/src/Composer/Command/InitCommand.php b/src/Composer/Command/InitCommand.php index a44546b73..37c4d3029 100644 --- a/src/Composer/Command/InitCommand.php +++ b/src/Composer/Command/InitCommand.php @@ -12,9 +12,11 @@ namespace Composer\Command; +use Composer\DependencyResolver\Pool; use Composer\Json\JsonFile; use Composer\Factory; use Composer\Package\BasePackage; +use Composer\Package\Version\VersionSelector; use Composer\Repository\CompositeRepository; use Composer\Repository\PlatformRepository; use Composer\Package\Version\VersionParser; @@ -32,6 +34,7 @@ class InitCommand extends Command { private $gitConfig; private $repos; + private $pool; public function parseAuthorString($author) { @@ -284,9 +287,11 @@ EOT protected function findPackages($name) { - $packages = array(); + return $this->getRepos()->search($name); + } - // init repos + protected function getRepos() + { if (!$this->repos) { $this->repos = new CompositeRepository(array_merge( array(new PlatformRepository), @@ -294,7 +299,17 @@ EOT )); } - return $this->repos->search($name); + return $this->repos; + } + + protected function getPool() + { + if (!$this->pool) { + $this->pool = new Pool($this->getComposer()->getPackage()->getMinimumStability()); + $this->pool->addRepository($this->getRepos()); + } + + return $this->pool; } protected function determineRequirements(InputInterface $input, OutputInterface $output, $requires = array()) @@ -306,15 +321,18 @@ EOT $requires = $this->normalizeRequirements($requires); $result = array(); - foreach ($requires as $key => $requirement) { - if (!isset($requirement['version']) && $input->isInteractive()) { - $question = $dialog->getQuestion('Please provide a version constraint for the '.$requirement['name'].' requirement'); - if ($constraint = $dialog->ask($output, $question)) { - $requirement['version'] = $constraint; - } - } + foreach ($requires as $requirement) { if (!isset($requirement['version'])) { - throw new \InvalidArgumentException('The requirement '.$requirement['name'].' must contain a version constraint'); + + // determine the best version automatically + $version = $this->findBestVersionForPackage($requirement['name']); + $requirement['version'] = $version; + + $output->writeln(sprintf( + 'Using version %s for %s', + $requirement['version'], + $requirement['name'] + )); } $result[] = $requirement['name'] . ' ' . $requirement['version']; @@ -369,7 +387,7 @@ EOT $package = $dialog->askAndValidate($output, $dialog->getQuestion('Enter package # to add, or the complete package name if it is not listed', false, ':'), $validator, 3); } - // no constraint yet, prompt user + // no constraint yet, determine the best version automatically if (false !== $package && false === strpos($package, ' ')) { $validator = function ($input) { $input = trim($input); @@ -377,9 +395,20 @@ EOT return $input ?: false; }; - $constraint = $dialog->askAndValidate($output, $dialog->getQuestion('Enter the version constraint to require', false, ':'), $validator, 3); + $constraint = $dialog->askAndValidate( + $output, + $dialog->getQuestion('Enter the version constraint to require (or leave blank to use the latest version)', false, ':'), + $validator, + 3) + ; if (false === $constraint) { - continue; + $constraint = $this->findBestVersionForPackage($package); + + $output->writeln(sprintf( + 'Using version %s for %s', + $constraint, + $package + )); } $package .= ' '.$constraint; @@ -504,4 +533,30 @@ EOT return false !== filter_var($email, FILTER_VALIDATE_EMAIL); } + + /** + * Given a package name, this determines the best version to use in the require key. + * + * This returns a version with the ~ operator prefixed when possible. + * + * @param string $name + * @return string + * @throws \InvalidArgumentException + */ + protected function findBestVersionForPackage($name) + { + // find the latest version allowed in this pool + $versionSelector = new VersionSelector($this->getPool()); + $package = $versionSelector->findBestCandidate($name); + + if (!$package) { + throw new \InvalidArgumentException(sprintf( + 'Could not find package %s at any version for your minimum-stability (%s). Check the package spelling or your minimum-stability', + $name, + $this->getComposer()->getPackage()->getMinimumStability() + )); + } + + return $versionSelector->findRecommendedRequireVersion($package); + } } diff --git a/src/Composer/DependencyResolver/Pool.php b/src/Composer/DependencyResolver/Pool.php index 636a78c0a..d0c660f82 100644 --- a/src/Composer/DependencyResolver/Pool.php +++ b/src/Composer/DependencyResolver/Pool.php @@ -15,7 +15,6 @@ namespace Composer\DependencyResolver; use Composer\Package\BasePackage; use Composer\Package\AliasPackage; use Composer\Package\Version\VersionParser; -use Composer\Package\Link; use Composer\Package\LinkConstraint\LinkConstraintInterface; use Composer\Package\LinkConstraint\VersionConstraint; use Composer\Package\LinkConstraint\EmptyConstraint; @@ -25,6 +24,7 @@ use Composer\Repository\ComposerRepository; use Composer\Repository\InstalledRepositoryInterface; use Composer\Repository\StreamableRepositoryInterface; use Composer\Repository\PlatformRepository; +use Composer\Package\PackageInterface; /** * A package pool contains repositories that provide packages. @@ -232,7 +232,7 @@ class Pool * packages must match or null to return all * @param bool $mustMatchName Whether the name of returned packages * must match the given name - * @return array A set of packages + * @return PackageInterface[] A set of packages */ public function whatProvides($name, LinkConstraintInterface $constraint = null, $mustMatchName = false) { diff --git a/src/Composer/Package/Version/VersionSelector.php b/src/Composer/Package/Version/VersionSelector.php new file mode 100644 index 000000000..c00c4254d --- /dev/null +++ b/src/Composer/Package/Version/VersionSelector.php @@ -0,0 +1,113 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Package\Version; + +use Composer\DependencyResolver\Pool; +use Composer\Package\PackageInterface; + +/** + * Selects the best possible version for a package + * + * @author Ryan Weaver + */ +class VersionSelector +{ + private $pool; + + private $parser; + + public function __construct(Pool $pool) + { + $this->pool = $pool; + } + + /** + * Given a package name and optional version, returns the latest PackageInterface + * that matches. + * + * @param string $packageName + * @param string $targetPackageVersion + * @return PackageInterface|bool + */ + public function findBestCandidate($packageName, $targetPackageVersion = null) + { + $constraint = $targetPackageVersion ? $this->getParser()->parseConstraints($targetPackageVersion) : null; + $candidates = $this->pool->whatProvides($packageName, $constraint, true); + + if (!$candidates) { + return false; + } + + // select highest version if we have many + // logic is repeated in InitCommand + $package = reset($candidates); + foreach ($candidates as $candidate) { + if (version_compare($package->getVersion(), $candidate->getVersion(), '<')) { + $package = $candidate; + } + } + + return $package; + } + + /** + * Given a concrete version, this returns a ~ constraint (when possible) + * that should be used, for example, in composer.json. + * + * For example: + * * 1.2.1 -> ~1.2 + * * 1.2 -> ~1.2 + * * v3.2.1 -> ~3.2 + * * 2.0-beta.1 -> ~2.0-beta.1 + * * dev-master -> dev-master (dev versions are untouched) + * + * @param PackageInterface $package + * @return string + */ + public function findRecommendedRequireVersion(PackageInterface $package) + { + $version = $package->getPrettyVersion(); + if (!$package->isDev()) { + // remove the v prefix if there is one + if (substr($version, 0, 1) == 'v') { + $version = substr($version, 1); + } + + // for stable packages only, we try to transform 2.1.1 to 2.1 + // this allows you to upgrade through minor versions + if ($package->getStability() == 'stable') { + $semanticVersionParts = explode('.', $version); + // check to see if we have a normal 1.2.6 semantic version + if (count($semanticVersionParts) == 3) { + // remove the last part (i.e. the patch version number) + unset($semanticVersionParts[2]); + $version = implode('.', $semanticVersionParts); + } + } + + // 2.1 -> ~2.1 + $version = '~'.$version; + } + + return $version; + } + + private function getParser() + { + if ($this->parser === null) { + $this->parser = new VersionParser(); + } + + return $this->parser; + } +} diff --git a/tests/Composer/Test/Package/Version/VersionSelectorTest.php b/tests/Composer/Test/Package/Version/VersionSelectorTest.php new file mode 100644 index 000000000..fc4d6d61b --- /dev/null +++ b/tests/Composer/Test/Package/Version/VersionSelectorTest.php @@ -0,0 +1,115 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Package\Version; + +use Composer\Package\Version\VersionSelector; + +class VersionSelectorTest extends \PHPUnit_Framework_TestCase +{ + // A) multiple versions, get the latest one + // B) targetPackageVersion will pass to pool + // C) No results, throw exception + + public function testLatestVersionIsReturned() + { + $packageName = 'foobar'; + + $package1 = $this->createMockPackage('1.2.1'); + $package2 = $this->createMockPackage('1.2.2'); + $package3 = $this->createMockPackage('1.2.0'); + $packages = array($package1, $package2, $package3); + + $pool = $this->createMockPool(); + $pool->expects($this->once()) + ->method('whatProvides') + ->with($packageName, null, true) + ->will($this->returnValue($packages)); + + $versionSelector = new VersionSelector($pool); + $best = $versionSelector->findBestCandidate($packageName); + + // 1.2.2 should be returned because it's the latest of the returned versions + $this->assertEquals($package2, $best, 'Latest version should be 1.2.2'); + } + + public function testFalseReturnedOnNoPackages() + { + $pool = $this->createMockPool(); + $pool->expects($this->once()) + ->method('whatProvides') + ->will($this->returnValue(array())); + + $versionSelector = new VersionSelector($pool); + $best = $versionSelector->findBestCandidate('foobaz'); + $this->assertFalse($best, 'No versions are available returns false'); + } + + /** + * @dataProvider getRecommendedRequireVersionPackages + */ + public function testFindRecommendedRequireVersion($prettyVersion, $isDev, $stability, $expectedVersion) + { + $pool = $this->createMockPool(); + $versionSelector = new VersionSelector($pool); + + $package = $this->getMock('\Composer\Package\PackageInterface'); + $package->expects($this->any()) + ->method('getPrettyVersion') + ->will($this->returnValue($prettyVersion)); + $package->expects($this->any()) + ->method('isDev') + ->will($this->returnValue($isDev)); + $package->expects($this->any()) + ->method('getStability') + ->will($this->returnValue($stability)); + + $recommended = $versionSelector->findRecommendedRequireVersion($package); + + // assert that the recommended version is what we expect + $this->assertEquals($expectedVersion, $recommended); + } + + public function getRecommendedRequireVersionPackages() + { + return array( + // real version, is dev package, stability, expected recommendation + array('1.2.1', false, 'stable', '~1.2'), + array('1.2', false, 'stable', '~1.2'), + array('v1.2.1', false, 'stable', '~1.2'), + array('3.1.2-pl2', false, 'stable', '~3.1'), + array('3.1.2-patch', false, 'stable', '~3.1'), + // for non-stable versions, we add ~, but don't try the (1.2.1 -> 1.2) transformation + array('2.0-beta.1', false, 'beta', '~2.0-beta.1'), + array('3.1.2-alpha5', false, 'alpha', '~3.1.2-alpha5'), + array('3.0-RC2', false, 'RC', '~3.0-RC2'), + // dev packages are not touched at all + array('dev-master', true, 'dev', 'dev-master'), + array('3.1.2-dev', true, 'dev', '3.1.2-dev'), + ); + } + + private function createMockPackage($version) + { + $package = $this->getMock('\Composer\Package\PackageInterface'); + $package->expects($this->any()) + ->method('getVersion') + ->will($this->returnValue($version)); + + return $package; + } + + private function createMockPool() + { + return $this->getMock('Composer\DependencyResolver\Pool', array(), array(), '', true); + } +}