Browse Source

Add support for editing top level properties and extra values, replaces #2415, fixes #1411, fixes #2384

Jordi Boggiano 9 years ago
parent
commit
135783299a

+ 21 - 2
doc/03-cli.md

@@ -499,8 +499,10 @@ sudo -H composer self-update
 
 ## config
 
-The `config` command allows you to edit some basic Composer settings in either
-the local `composer.json` file or the global `config.json` file.
+The `config` command allows you to edit composer config settings and repositories
+in either the local `composer.json` file or the global `config.json` file.
+
+Additionally it lets you edit most properties in the local `composer.json`.
 
 ```sh
 php composer.phar config --list
@@ -514,6 +516,11 @@ php composer.phar config --list
 configuration value.  For settings that can take an array of values (like
 `github-protocols`), more than one setting-value arguments are allowed.
 
+You can also edit the values of the following properties:
+
+`description`, `homepage`, `keywords`, `license`, `minimum-stability`,
+`name`, `prefer-stable`, `type` and `version`.
+
 See the [Config](06-config.md) chapter for valid configuration options.
 
 ### Options
@@ -547,6 +554,18 @@ If your repository requires more configuration options, you can instead pass its
 php composer.phar config repositories.foo '{"type": "vcs", "url": "http://svn.example.org/my-project/", "trunk-path": "master"}'
 ```
 
+### Modifying Extra Values
+
+In addition to modifying the config section, the `config` command also supports making
+changes to the extra section by using it the following way:
+
+```sh
+php composer.phar config extra.foo.bar value
+```
+
+The dots indicate array nesting, a max depth of 3 levels is allowed though. The above
+would set `"extra": { "foo": { "bar": "value" } }`.
+
 ## create-project
 
 You can use Composer to create new projects from an existing package. This is

+ 111 - 37
src/Composer/Command/ConfigCommand.php

@@ -22,6 +22,8 @@ use Composer\Config;
 use Composer\Config\JsonConfigSource;
 use Composer\Factory;
 use Composer\Json\JsonFile;
+use Composer\Semver\VersionParser;
+use Composer\Package\BasePackage;
 
 /**
  * @author Joshua Estes <Joshua.Estes@iostudio.com>
@@ -74,8 +76,10 @@ class ConfigCommand extends BaseCommand
                 new InputArgument('setting-value', InputArgument::IS_ARRAY, 'Setting value'),
             ))
             ->setHelp(<<<EOT
-This command allows you to edit some basic composer settings in either the
-local composer.json file or the global config.json file.
+This command allows you to edit composer config settings and repositories
+in either the local composer.json file or the global config.json file.
+
+Additionally it lets you edit most properties in the local composer.json.
 
 To set a config setting:
 
@@ -227,6 +231,8 @@ EOT
 
         // show the value if no value is provided
         if (array() === $input->getArgument('setting-value') && !$input->getOption('unset')) {
+            $properties = array('name', 'type', 'description', 'homepage', 'version', 'minimum-stability', 'prefer-stable', 'keywords', 'license', 'extra');
+            $rawData = $this->configFile->read();
             $data = $this->config->all();
             if (preg_match('/^repos?(?:itories)?(?:\.(.+))?/', $settingKey, $matches)) {
                 if (empty($matches[1])) {
@@ -240,7 +246,11 @@ EOT
                 }
             } elseif (strpos($settingKey, '.')) {
                 $bits = explode('.', $settingKey);
-                $data = $data['config'];
+                if ($bits[0] === 'extra') {
+                    $data = $rawData;
+                } else {
+                    $data = $data['config'];
+                }
                 $match = false;
                 foreach ($bits as $bit) {
                     $key = isset($key) ? $key.'.'.$bit : $bit;
@@ -259,6 +269,8 @@ EOT
                 $value = $data;
             } elseif (isset($data['config'][$settingKey])) {
                 $value = $this->config->get($settingKey, $input->getOption('absolute') ? 0 : Config::RELATIVE_PATHS);
+            } elseif (in_array($settingKey, $properties, true) && isset($rawData[$settingKey])) {
+                $value = $rawData[$settingKey];
             } else {
                 throw new \RuntimeException($settingKey.' is not defined');
             }
@@ -387,44 +399,67 @@ EOT
             ),
         );
 
-        foreach ($uniqueConfigValues as $name => $callbacks) {
-            if ($settingKey === $name) {
-                if ($input->getOption('unset')) {
-                    return $this->configSource->removeConfigSetting($settingKey);
-                }
-
-                list($validator, $normalizer) = $callbacks;
-                if (1 !== count($values)) {
-                    throw new \RuntimeException('You can only pass one value. Example: php composer.phar config process-timeout 300');
-                }
-
-                if (true !== $validation = $validator($values[0])) {
-                    throw new \RuntimeException(sprintf(
-                        '"%s" is an invalid value'.($validation ? ' ('.$validation.')' : ''),
-                        $values[0]
-                    ));
-                }
-
-                return $this->configSource->addConfigSetting($settingKey, $normalizer($values[0]));
-            }
+        if ($input->getOption('unset') && (isset($uniqueConfigValues[$settingKey]) || isset($multiConfigValues[$settingKey]))) {
+            return $this->configSource->removeConfigSetting($settingKey);
+        }
+        if (isset($uniqueConfigValues[$settingKey])) {
+            return $this->handleSingleValue($settingKey, $uniqueConfigValues[$settingKey], $values, 'addConfigSetting');
+        }
+        if (isset($multiConfigValues[$settingKey])) {
+            return $this->handleMultiValue($settingKey, $multiConfigValues[$settingKey], $values, 'addConfigSetting');
         }
 
-        foreach ($multiConfigValues as $name => $callbacks) {
-            if ($settingKey === $name) {
-                if ($input->getOption('unset')) {
-                    return $this->configSource->removeConfigSetting($settingKey);
-                }
+        // handle properties
+        $uniqueProps = array(
+            'name' => array('is_string', function ($val) { return $val; }),
+            'type' => array('is_string', function ($val) { return $val; }),
+            'description' => array('is_string', function ($val) { return $val; }),
+            'homepage' => array('is_string', function ($val) { return $val; }),
+            'version' => array('is_string', function ($val) { return $val; }),
+            'minimum-stability' => array(
+                function ($val) { return isset(BasePackage::$stabilities[VersionParser::normalizeStability($val)]); },
+                function ($val) { return VersionParser::normalizeStability($val); }
+            ),
+            'prefer-stable' => array($booleanValidator, $booleanNormalizer),
+        );
+        $multiProps = array(
+            'keywords' => array(
+                function ($vals) {
+                    if (!is_array($vals)) {
+                        return 'array expected';
+                    }
 
-                list($validator, $normalizer) = $callbacks;
-                if (true !== $validation = $validator($values)) {
-                    throw new \RuntimeException(sprintf(
-                        '%s is an invalid value'.($validation ? ' ('.$validation.')' : ''),
-                        json_encode($values)
-                    ));
-                }
+                    return true;
+                },
+                function ($vals) {
+                    return $vals;
+                },
+            ),
+            'license' => array(
+                function ($vals) {
+                    if (!is_array($vals)) {
+                        return 'array expected';
+                    }
 
-                return $this->configSource->addConfigSetting($settingKey, $normalizer($values));
-            }
+                    return true;
+                },
+                function ($vals) {
+                    return $vals;
+                },
+            ),
+        );
+
+        if ($input->getOption('global') && (isset($uniqueProps[$settingKey]) || isset($multiProps[$settingKey]) || substr($settingKey, 0, 6) === 'extra.')) {
+            throw new \InvalidArgumentException('The '.$settingKey.' property can not be set in the global config.json file. Use `composer global config` to apply changes to the global composer.json');
+        }
+        if ($input->getOption('unset') && (isset($uniqueProps[$settingKey]) || isset($multiProps[$settingKey]))) {
+            return $this->configSource->removeProperty($settingKey);
+        }
+        if (isset($uniqueProps[$settingKey])) {
+            return $this->handleSingleValue($settingKey, $uniqueProps[$settingKey], $values, 'addProperty');
+        }
+        if (isset($multiProps[$settingKey])) {
+            return $this->handleMultiValue($settingKey, $multiProps[$settingKey], $values, 'addProperty');
         }
 
         // handle repositories
@@ -456,6 +491,15 @@ EOT
             throw new \RuntimeException('You must pass the type and a url. Example: php composer.phar config repositories.foo vcs https://bar.com');
         }
 
+        // handle extra
+        if (preg_match('/^extra\.(.+)/', $settingKey, $matches)) {
+            if ($input->getOption('unset')) {
+                return $this->configSource->removeProperty($settingKey);
+            }
+
+            return $this->configSource->addProperty($settingKey, $values[0]);
+        }
+
         // handle platform
         if (preg_match('/^platform\.(.+)/', $settingKey, $matches)) {
             if ($input->getOption('unset')) {
@@ -500,6 +544,36 @@ EOT
         throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command');
     }
 
+    protected function handleSingleValue($key, array $callbacks, array $values, $method)
+    {
+        list($validator, $normalizer) = $callbacks;
+        if (1 !== count($values)) {
+            throw new \RuntimeException('You can only pass one value. Example: php composer.phar config process-timeout 300');
+        }
+
+        if (true !== $validation = $validator($values[0])) {
+            throw new \RuntimeException(sprintf(
+                '"%s" is an invalid value'.($validation ? ' ('.$validation.')' : ''),
+                $values[0]
+            ));
+        }
+
+        return call_user_func(array($this->configSource, $method), $key, $normalizer($values[0]));
+    }
+
+    protected function handleMultiValue($key, array $callbacks, array $values, $method)
+    {
+        list($validator, $normalizer) = $callbacks;
+        if (true !== $validation = $validator($values)) {
+            throw new \RuntimeException(sprintf(
+                '%s is an invalid value'.($validation ? ' ('.$validation.')' : ''),
+                json_encode($values)
+            ));
+        }
+
+        return call_user_func(array($this->configSource, $method), $key, $normalizer($values));
+    }
+
     /**
      * Display the contents of the file in a pretty formatted way
      *

+ 15 - 0
src/Composer/Config/ConfigSourceInterface.php

@@ -50,6 +50,21 @@ interface ConfigSourceInterface
      */
     public function removeConfigSetting($name);
 
+    /**
+     * Add a property
+     *
+     * @param string $name  Name
+     * @param string $value Value
+     */
+    public function addProperty($name, $value);
+
+    /**
+     * Remove a property
+     *
+     * @param string $name
+     */
+    public function removeProperty($name);
+
     /**
      * Add a package link
      *

+ 47 - 0
src/Composer/Config/JsonConfigSource.php

@@ -114,6 +114,53 @@ class JsonConfigSource implements ConfigSourceInterface
         });
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    public function addProperty($name, $value)
+    {
+        $this->manipulateJson('addProperty', $name, $value, function (&$config, $key, $val) {
+            if (substr($key, 0, 6) === 'extra.') {
+                $bits = explode('.', $key);
+                $last = array_pop($bits);
+                $arr =& $config['extra'];
+                foreach ($bits as $bit) {
+                    if (!isset($arr[$bit])) {
+                        $arr[$bit] = array();
+                    }
+                    $arr =& $arr[$bit];
+                }
+                $arr[$last] = $val;
+            } else {
+                $config[$key] = $val;
+            }
+        });
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function removeProperty($name)
+    {
+        $authConfig = $this->authConfig;
+        $this->manipulateJson('removeProperty', $name, function (&$config, $key) {
+            if (substr($key, 0, 6) === 'extra.') {
+                $bits = explode('.', $key);
+                $last = array_pop($bits);
+                $arr =& $config['extra'];
+                foreach ($bits as $bit) {
+                    if (!isset($arr[$bit])) {
+                        return;
+                    }
+                    $arr =& $arr[$bit];
+                }
+                unset($arr[$last]);
+            } else {
+                unset($config[$key]);
+            }
+        });
+    }
+
     /**
      * {@inheritdoc}
      */

+ 51 - 2
src/Composer/Json/JsonManipulator.php

@@ -163,12 +163,30 @@ class JsonManipulator
         return $this->removeSubNode('config', $name);
     }
 
+    public function addProperty($name, $value)
+    {
+        if (substr($name, 0, 6) === 'extra.') {
+            return $this->addSubNode('extra', substr($name, 6), $value);
+        }
+
+        return $this->addMainKey($name, $value);
+    }
+
+    public function removeProperty($name)
+    {
+        if (substr($name, 0, 6) === 'extra.') {
+            return $this->removeSubNode('extra', substr($name, 6));
+        }
+
+        return $this->removeMainKey($name);
+    }
+
     public function addSubNode($mainNode, $name, $value)
     {
         $decoded = JsonFile::parseJson($this->contents);
 
         $subName = null;
-        if (in_array($mainNode, array('config', 'repositories')) && false !== strpos($name, '.')) {
+        if (in_array($mainNode, array('config', 'repositories', 'extra')) && false !== strpos($name, '.')) {
             list($name, $subName) = explode('.', $name, 2);
         }
 
@@ -211,6 +229,9 @@ class JsonManipulator
             $children = preg_replace_callback('{("'.preg_quote($name).'"\s*:\s*)('.self::$JSON_VALUE.')(,?)}', function ($matches) use ($name, $subName, $value, $that) {
                 if ($subName !== null) {
                     $curVal = json_decode($matches[2], true);
+                    if (!is_array($curVal)) {
+                        $curVal = array();
+                    }
                     $curVal[$subName] = $value;
                     $value = $curVal;
                 }
@@ -275,7 +296,7 @@ class JsonManipulator
         }
 
         $subName = null;
-        if (in_array($mainNode, array('config', 'repositories')) && false !== strpos($name, '.')) {
+        if (in_array($mainNode, array('config', 'repositories', 'extra')) && false !== strpos($name, '.')) {
             list($name, $subName) = explode('.', $name, 2);
         }
 
@@ -374,6 +395,34 @@ class JsonManipulator
         return true;
     }
 
+    public function removeMainKey($key)
+    {
+        $decoded = JsonFile::parseJson($this->contents);
+
+        if (!isset($decoded[$key])) {
+            return true;
+        }
+
+        // key exists already
+        $regex = '{^(\s*\{\s*(?:'.self::$JSON_STRING.'\s*:\s*'.self::$JSON_VALUE.'\s*,\s*)*?)'.
+            '('.preg_quote(JsonFile::encode($key)).'\s*:\s*'.self::$JSON_VALUE.')\s*,?\s*(.*)}s';
+        if ($this->pregMatch($regex, $this->contents, $matches)) {
+            // invalid match due to un-regexable content, abort
+            if (!@json_decode('{'.$matches[2].'}')) {
+                return false;
+            }
+
+            $this->contents = $matches[1] . $matches[3];
+            if (preg_match('#^\{\s*\}\s*$#', $this->contents)) {
+                $this->contents = "{\n}";
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+
     public function format($data, $depth = 0)
     {
         if (is_array($data)) {

+ 64 - 3
tests/Composer/Test/Json/JsonManipulatorTest.php

@@ -2050,7 +2050,7 @@ class JsonManipulatorTest extends \PHPUnit_Framework_TestCase
         $manipulator = new JsonManipulator('{
     "foo": "bar"
 }');
-        
+
         $this->assertTrue($manipulator->addMainKey('bar', '$1baz'));
         $this->assertEquals('{
     "foo": "bar",
@@ -2069,7 +2069,7 @@ class JsonManipulatorTest extends \PHPUnit_Framework_TestCase
 }
 ', $manipulator->getContents());
     }
-    
+
     public function testUpdateMainKey()
     {
         $manipulator = new JsonManipulator('{
@@ -2142,7 +2142,68 @@ class JsonManipulatorTest extends \PHPUnit_Framework_TestCase
 }
 ', $manipulator->getContents());
     }
-    
+
+    public function testRemoveMainKey()
+    {
+        $manipulator = new JsonManipulator('{
+    "repositories": [
+        {
+            "package": {
+                "require": {
+                    "this/should-not-end-up-in-root-require": "~2.0"
+                },
+                "require-dev": {
+                    "this/should-not-end-up-in-root-require-dev": "~2.0"
+                }
+            }
+        }
+    ],
+    "require": {
+        "package/a": "*",
+        "package/b": "*",
+        "package/c": "*"
+    },
+    "foo": "bar",
+    "require-dev": {
+        "package/d": "*"
+    }
+}');
+
+        $this->assertTrue($manipulator->removeMainKey('repositories'));
+        $this->assertEquals('{
+    "require": {
+        "package/a": "*",
+        "package/b": "*",
+        "package/c": "*"
+    },
+    "foo": "bar",
+    "require-dev": {
+        "package/d": "*"
+    }
+}
+', $manipulator->getContents());
+
+        $this->assertTrue($manipulator->removeMainKey('foo'));
+        $this->assertEquals('{
+    "require": {
+        "package/a": "*",
+        "package/b": "*",
+        "package/c": "*"
+    },
+    "require-dev": {
+        "package/d": "*"
+    }
+}
+', $manipulator->getContents());
+
+        $this->assertTrue($manipulator->removeMainKey('require'));
+        $this->assertTrue($manipulator->removeMainKey('require-dev'));
+        $this->assertEquals('{
+}
+', $manipulator->getContents());
+
+    }
+
     public function testIndentDetection()
     {
         $manipulator = new JsonManipulator('{