diff --git a/doc/articles/plugins.md b/doc/articles/plugins.md index 9da3badad..367c74c83 100644 --- a/doc/articles/plugins.md +++ b/doc/articles/plugins.md @@ -16,7 +16,7 @@ specific logic. ## Creating a Plugin -A plugin is a regular composer package which ships its code as part of the +A plugin is a regular Composer package which ships its code as part of the package and may also depend on further packages. ### Plugin Package @@ -24,23 +24,36 @@ package and may also depend on further packages. The package file is the same as any other package file but with the following requirements: -1. the [type][1] attribute must be `composer-plugin`. -2. the [extra][2] attribute must contain an element `class` defining the +1. The [type][1] attribute must be `composer-plugin`. +2. The [extra][2] attribute must contain an element `class` defining the class name of the plugin (including namespace). If a package contains - multiple plugins this can be array of class names. + multiple plugins, this can be array of class names. +3. You must require the special package called `composer-plugin-api` + to define which Plugin API versions your plugin is compatible with. -Additionally you must require the special package called `composer-plugin-api` -to define which composer API versions your plugin is compatible with. The -current composer plugin API version is 1.0.0. +The required version of the `composer-plugin-api` follows the same [rules][7] +as a normal package's, except for the `1.0`, `1.0.0` and `1.0.0.0` _exact_ +values. In only these three cases, Composer will assume your plugin +actually meant `^1.0` instead. This was introduced to maintain BC with +the old style of declaring the Plugin API version. + +In other words, `"require": { "composer-plugin-api": "1.0.0" }` means +`"require": { "composer-plugin-api": "^1.0" }`. -For example +The current composer plugin API version is 1.0.0. + +An example of a valid plugin `composer.json` file (with the autoloading +part omitted): ```json { "name": "my/plugin-package", "type": "composer-plugin", "require": { - "composer-plugin-api": "1.0.0" + "composer-plugin-api": "~1.0" + }, + "extra": { + "class": "My\\Plugin" } } ``` @@ -149,3 +162,4 @@ local project plugins are loaded. [4]: https://github.com/composer/composer/blob/master/src/Composer/Composer.php [5]: https://github.com/composer/composer/blob/master/src/Composer/IO/IOInterface.php [6]: https://github.com/composer/composer/blob/master/src/Composer/EventDispatcher/EventSubscriberInterface.php +[7]: ../01-basic-usage.md#package-versions diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 828b98e75..ba8e2bdc7 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -13,6 +13,7 @@ namespace Composer; use Composer\Config\ConfigSourceInterface; +use Composer\Plugin\PluginInterface; /** * @author Jordi Boggiano diff --git a/src/Composer/Package/Version/VersionParser.php b/src/Composer/Package/Version/VersionParser.php index 3a214f150..41f9a72a0 100644 --- a/src/Composer/Package/Version/VersionParser.php +++ b/src/Composer/Package/Version/VersionParser.php @@ -227,12 +227,28 @@ class VersionParser } else { $parsedConstraint = $this->parseConstraints($constraint); } + + // if the required Plugin API version is exactly "1.0.0", convert it to "^1.0", to keep BC + if ('composer-plugin-api' === strtolower($target) && $this->isOldStylePluginApiVersion($constraint)) { + $parsedConstraint = $this->parseConstraints('^1.0'); + } + $res[strtolower($target)] = new Link($source, $target, $parsedConstraint, $description, $constraint); } return $res; } + /** + * @param string $requiredPluginApiVersion + * @return bool + */ + private function isOldStylePluginApiVersion($requiredPluginApiVersion) + { + // catch "1.0", "1.0.0", "1.0.0.0" etc. + return (bool) preg_match('#^1(\.0)++$#', trim($requiredPluginApiVersion)); + } + /** * Parses as constraint string into LinkConstraint objects * diff --git a/src/Composer/Plugin/PluginManager.php b/src/Composer/Plugin/PluginManager.php index 14021dc45..833b3e29b 100644 --- a/src/Composer/Plugin/PluginManager.php +++ b/src/Composer/Plugin/PluginManager.php @@ -122,6 +122,7 @@ class PluginManager foreach ($package->getRequires() as $link) { /** @var Link $link */ if ('composer-plugin-api' === $link->getTarget()) { $requiresComposer = $link->getConstraint(); + break; } } @@ -129,14 +130,18 @@ class PluginManager throw new \RuntimeException("Plugin ".$package->getName()." is missing a require statement for a version of the composer-plugin-api package."); } - if (!$requiresComposer->matches(new VersionConstraint('==', $this->versionParser->normalize(PluginInterface::PLUGIN_API_VERSION)))) { - $this->io->writeError("The plugin ".$package->getName()." requires a version of composer-plugin-api that does not match your composer installation. You may need to run composer update with the '--no-plugins' option."); + $currentPluginApiVersion = $this->getPluginApiVersion(); + $currentPluginApiConstraint = new VersionConstraint('==', $this->versionParser->normalize($currentPluginApiVersion)); + + if (!$requiresComposer->matches($currentPluginApiConstraint)) { + $this->io->writeError('The "' . $package->getName() . '" plugin was skipped because it requires a Plugin API version ("' . $requiresComposer->getPrettyString() . '") that does not match your Composer installation ("' . $currentPluginApiVersion . '"). You may need to run composer update with the "--no-plugins" option.'); + continue; } $this->registerPackage($package); - } + // Backward compatibility - if ('composer-installer' === $package->getType()) { + } elseif ('composer-installer' === $package->getType()) { $this->registerPackage($package); } } @@ -272,4 +277,14 @@ class PluginManager return $this->globalComposer->getInstallationManager()->getInstallPath($package); } + + /** + * Returns the version of the internal composer-plugin-api package. + * + * @return string + */ + protected function getPluginApiVersion() + { + return PluginInterface::PLUGIN_API_VERSION; + } } diff --git a/src/Composer/Repository/PlatformRepository.php b/src/Composer/Repository/PlatformRepository.php index 4afd3d4e9..7ef11786b 100644 --- a/src/Composer/Repository/PlatformRepository.php +++ b/src/Composer/Repository/PlatformRepository.php @@ -12,6 +12,7 @@ namespace Composer\Repository; +use Composer\Config; use Composer\Package\PackageInterface; use Composer\Package\CompletePackage; use Composer\Package\Version\VersionParser; diff --git a/tests/Composer/Test/Package/Version/VersionParserTest.php b/tests/Composer/Test/Package/Version/VersionParserTest.php index ceb959051..87416e7eb 100644 --- a/tests/Composer/Test/Package/Version/VersionParserTest.php +++ b/tests/Composer/Test/Package/Version/VersionParserTest.php @@ -12,6 +12,7 @@ namespace Composer\Test\Package\Version; +use Composer\Package\Link; use Composer\Package\Version\VersionParser; use Composer\Package\LinkConstraint\MultiConstraint; use Composer\Package\LinkConstraint\VersionConstraint; @@ -513,4 +514,70 @@ class VersionParserTest extends \PHPUnit_Framework_TestCase array('RC', '2.0.0rc1') ); } + + public function oldStylePluginApiVersions() + { + return array( + array('1.0'), + array('1.0.0'), + array('1.0.0.0'), + ); + } + + public function newStylePluginApiVersions() + { + return array( + array('1'), + array('=1.0.0'), + array('==1.0'), + array('~1.0.0'), + array('*'), + array('3.0.*'), + array('@stable'), + array('1.0.0@stable'), + array('^5.1'), + array('>=1.0.0 <2.5'), + array('x'), + array('1.0.0-dev'), + ); + } + + /** + * @dataProvider oldStylePluginApiVersions + */ + public function testOldStylePluginApiVersionGetsConvertedIntoAnotherConstraintToKeepBc($apiVersion) + { + $parser = new VersionParser; + + /** @var Link[] $links */ + $links = $parser->parseLinks('Plugin', '9.9.9', '', array('composer-plugin-api' => $apiVersion)); + + $this->assertArrayHasKey('composer-plugin-api', $links); + $this->assertSame('^1.0', $links['composer-plugin-api']->getConstraint()->getPrettyString()); + } + + /** + * @dataProvider newStylePluginApiVersions + */ + public function testNewStylePluginApiVersionAreKeptAsDeclared($apiVersion) + { + $parser = new VersionParser; + + /** @var Link[] $links */ + $links = $parser->parseLinks('Plugin', '9.9.9', '', array('composer-plugin-api' => $apiVersion)); + + $this->assertArrayHasKey('composer-plugin-api', $links); + $this->assertSame($apiVersion, $links['composer-plugin-api']->getConstraint()->getPrettyString()); + } + + public function testPluginApiVersionDoesSupportSelfVersion() + { + $parser = new VersionParser; + + /** @var Link[] $links */ + $links = $parser->parseLinks('Plugin', '6.6.6', '', array('composer-plugin-api' => 'self.version')); + + $this->assertArrayHasKey('composer-plugin-api', $links); + $this->assertSame('6.6.6', $links['composer-plugin-api']->getConstraint()->getPrettyString()); + } } diff --git a/tests/Composer/Test/Plugin/Fixtures/plugin-v5/Installer/Plugin5.php b/tests/Composer/Test/Plugin/Fixtures/plugin-v5/Installer/Plugin5.php new file mode 100644 index 000000000..a2ac37bc5 --- /dev/null +++ b/tests/Composer/Test/Plugin/Fixtures/plugin-v5/Installer/Plugin5.php @@ -0,0 +1,14 @@ +=3.0.0 <5.5" + } +} diff --git a/tests/Composer/Test/Plugin/PluginInstallerTest.php b/tests/Composer/Test/Plugin/PluginInstallerTest.php index a2090082f..8bebe7d28 100644 --- a/tests/Composer/Test/Plugin/PluginInstallerTest.php +++ b/tests/Composer/Test/Plugin/PluginInstallerTest.php @@ -15,29 +15,62 @@ namespace Composer\Test\Installer; use Composer\Composer; use Composer\Config; use Composer\Installer\PluginInstaller; +use Composer\Package\CompletePackage; use Composer\Package\Loader\JsonLoader; use Composer\Package\Loader\ArrayLoader; use Composer\Plugin\PluginManager; use Composer\Autoload\AutoloadGenerator; +use Composer\TestCase; use Composer\Util\Filesystem; -class PluginInstallerTest extends \PHPUnit_Framework_TestCase +class PluginInstallerTest extends TestCase { + /** + * @var Composer + */ protected $composer; - protected $packages; - protected $im; + + /** + * @var PluginManager + */ protected $pm; - protected $repository; - protected $io; + + /** + * @var AutoloadGenerator + */ protected $autoloadGenerator; + + /** + * @var CompletePackage[] + */ + protected $packages; + + /** + * @var string + */ protected $directory; + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $im; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $repository; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject + */ + protected $io; + protected function setUp() { $loader = new JsonLoader(new ArrayLoader()); $this->packages = array(); $this->directory = sys_get_temp_dir() . '/' . uniqid(); - for ($i = 1; $i <= 4; $i++) { + for ($i = 1; $i <= 7; $i++) { $filename = '/Fixtures/plugin-v'.$i.'/composer.json'; mkdir(dirname($this->directory . $filename), 0777, true); $this->packages[] = $loader->load(__DIR__ . $filename); @@ -181,4 +214,104 @@ class PluginInstallerTest extends \PHPUnit_Framework_TestCase $this->assertCount(1, $plugins); $this->assertEquals('installer-v1', $plugins[0]->version); } + + /** + * @param string $newPluginApiVersion + * @param CompletePackage[] $plugins + */ + private function setPluginApiVersionWithPlugins($newPluginApiVersion, array $plugins = array()) + { + // reset the plugin manager's installed plugins + $this->pm = $this->getMockBuilder('Composer\Plugin\PluginManager') + ->setMethods(array('getPluginApiVersion')) + ->setConstructorArgs(array($this->io, $this->composer)) + ->getMock(); + + // mock the Plugin API version + $this->pm->expects($this->any()) + ->method('getPluginApiVersion') + ->will($this->returnValue($newPluginApiVersion)); + + $plugApiInternalPackage = $this->getPackage( + 'composer-plugin-api', + $newPluginApiVersion, + 'Composer\Package\CompletePackage' + ); + + // Add the plugins to the repo along with the internal Plugin package on which they all rely. + $this->repository + ->expects($this->any()) + ->method('getPackages') + ->will($this->returnCallback(function() use($plugApiInternalPackage, $plugins) { + return array_merge(array($plugApiInternalPackage), $plugins); + })); + + $this->pm->loadInstalledPlugins(); + } + + public function testOldPluginVersionStyleWorksWithAPIUntil199() + { + $pluginsWithOldStyleAPIVersions = array( + $this->packages[0], + $this->packages[1], + $this->packages[2], + ); + + $this->setPluginApiVersionWithPlugins('1.0.0', $pluginsWithOldStyleAPIVersions); + $this->assertCount(3, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('1.9.9', $pluginsWithOldStyleAPIVersions); + $this->assertCount(3, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('2.0.0-dev', $pluginsWithOldStyleAPIVersions); + $this->assertCount(0, $this->pm->getPlugins()); + } + + public function testStarPluginVersionWorksWithAnyAPIVersion() + { + $starVersionPlugin = array($this->packages[4]); + + $this->setPluginApiVersionWithPlugins('1.0.0', $starVersionPlugin); + $this->assertCount(1, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('1.9.9', $starVersionPlugin); + $this->assertCount(1, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('2.0.0-dev', $starVersionPlugin); + $this->assertCount(1, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('100.0.0-stable', $starVersionPlugin); + $this->assertCount(1, $this->pm->getPlugins()); + } + + public function testPluginConstraintWorksOnlyWithCertainAPIVersion() + { + $pluginWithApiConstraint = array($this->packages[5]); + + $this->setPluginApiVersionWithPlugins('1.0.0', $pluginWithApiConstraint); + $this->assertCount(0, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('1.1.9', $pluginWithApiConstraint); + $this->assertCount(0, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('1.2.0', $pluginWithApiConstraint); + $this->assertCount(1, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('1.9.9', $pluginWithApiConstraint); + $this->assertCount(1, $this->pm->getPlugins()); + } + + public function testPluginRangeConstraintsWorkOnlyWithCertainAPIVersion() + { + $pluginWithApiConstraint = array($this->packages[6]); + + $this->setPluginApiVersionWithPlugins('1.0.0', $pluginWithApiConstraint); + $this->assertCount(0, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('3.0.0', $pluginWithApiConstraint); + $this->assertCount(1, $this->pm->getPlugins()); + + $this->setPluginApiVersionWithPlugins('5.5.0', $pluginWithApiConstraint); + $this->assertCount(0, $this->pm->getPlugins()); + } }