Browse Source

Merge pull request #4089 from nevvermind/plugin-api-versions

Make plugins have actual constraints instead of fixed versions
Jordi Boggiano 9 years ago
parent
commit
17c2a8019e

+ 23 - 9
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

+ 1 - 0
src/Composer/Config.php

@@ -13,6 +13,7 @@
 namespace Composer;
 
 use Composer\Config\ConfigSourceInterface;
+use Composer\Plugin\PluginInterface;
 
 /**
  * @author Jordi Boggiano <j.boggiano@seld.be>

+ 16 - 0
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
      *

+ 19 - 4
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("<warning>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.</warning>");
+                $currentPluginApiVersion = $this->getPluginApiVersion();
+                $currentPluginApiConstraint = new VersionConstraint('==', $this->versionParser->normalize($currentPluginApiVersion));
+
+                if (!$requiresComposer->matches($currentPluginApiConstraint)) {
+                    $this->io->writeError('<warning>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.</warning>');
+                    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;
+    }
 }

+ 1 - 0
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;

+ 67 - 0
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());
+    }
 }

+ 14 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v5/Installer/Plugin5.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Installer;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+
+class Plugin5 implements PluginInterface
+{
+    public function activate(Composer $composer, IOInterface $io)
+    {
+    }
+}

+ 12 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v5/composer.json

@@ -0,0 +1,12 @@
+{
+    "name": "plugin-v5",
+    "version": "1.0.0",
+    "type": "composer-plugin",
+    "autoload": { "psr-0": { "Installer": "" } },
+    "extra": {
+        "class": "Installer\\Plugin5"
+    },
+    "require": {
+        "composer-plugin-api": "*"
+    }
+}

+ 14 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v6/Installer/Plugin6.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Installer;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+
+class Plugin6 implements PluginInterface
+{
+    public function activate(Composer $composer, IOInterface $io)
+    {
+    }
+}

+ 12 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v6/composer.json

@@ -0,0 +1,12 @@
+{
+    "name": "plugin-v6",
+    "version": "1.0.0",
+    "type": "composer-plugin",
+    "autoload": { "psr-0": { "Installer": "" } },
+    "extra": {
+        "class": "Installer\\Plugin6"
+    },
+    "require": {
+        "composer-plugin-api": "~1.2"
+    }
+}

+ 14 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v7/Installer/Plugin7.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Installer;
+
+use Composer\Composer;
+use Composer\IO\IOInterface;
+use Composer\Plugin\PluginInterface;
+
+class Plugin7 implements PluginInterface
+{
+    public function activate(Composer $composer, IOInterface $io)
+    {
+    }
+}

+ 12 - 0
tests/Composer/Test/Plugin/Fixtures/plugin-v7/composer.json

@@ -0,0 +1,12 @@
+{
+    "name": "plugin-v7",
+    "version": "1.0.0",
+    "type": "composer-plugin",
+    "autoload": { "psr-0": { "Installer": "" } },
+    "extra": {
+        "class": "Installer\\Plugin7"
+    },
+    "require": {
+        "composer-plugin-api": ">=3.0.0 <5.5"
+    }
+}

+ 138 - 5
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;
+
+    /**
+     * @var PluginManager
+     */
+    protected $pm;
+
+    /**
+     * @var AutoloadGenerator
+     */
+    protected $autoloadGenerator;
+
+    /**
+     * @var CompletePackage[]
+     */
     protected $packages;
+
+    /**
+     * @var string
+     */
+    protected $directory;
+
+    /**
+     * @var \PHPUnit_Framework_MockObject_MockObject
+     */
     protected $im;
-    protected $pm;
+
+    /**
+     * @var \PHPUnit_Framework_MockObject_MockObject
+     */
     protected $repository;
+
+    /**
+     * @var \PHPUnit_Framework_MockObject_MockObject
+     */
     protected $io;
-    protected $autoloadGenerator;
-    protected $directory;
 
     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());
+    }
 }