From 135783299af0281db918c103cceb2b202ae154f2 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Wed, 27 Apr 2016 10:48:21 +0100 Subject: [PATCH] Add support for editing top level properties and extra values, replaces #2415, fixes #1411, fixes #2384 --- doc/03-cli.md | 23 ++- src/Composer/Command/ConfigCommand.php | 148 +++++++++++++----- src/Composer/Config/ConfigSourceInterface.php | 15 ++ src/Composer/Config/JsonConfigSource.php | 47 ++++++ src/Composer/Json/JsonManipulator.php | 53 ++++++- .../Test/Json/JsonManipulatorTest.php | 67 +++++++- 6 files changed, 309 insertions(+), 44 deletions(-) diff --git a/doc/03-cli.md b/doc/03-cli.md index 0f8d1d8f4..951406efb 100644 --- a/doc/03-cli.md +++ b/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 diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 363708adf..65d37da11 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/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 @@ -74,8 +76,10 @@ class ConfigCommand extends BaseCommand new InputArgument('setting-value', InputArgument::IS_ARRAY, 'Setting value'), )) ->setHelp(<<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 * diff --git a/src/Composer/Config/ConfigSourceInterface.php b/src/Composer/Config/ConfigSourceInterface.php index edd3dff8a..0d56fc0ed 100644 --- a/src/Composer/Config/ConfigSourceInterface.php +++ b/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 * diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 2d9d6e792..34a47e26d 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/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} */ diff --git a/src/Composer/Json/JsonManipulator.php b/src/Composer/Json/JsonManipulator.php index 2611e536d..3011dbab5 100644 --- a/src/Composer/Json/JsonManipulator.php +++ b/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)) { diff --git a/tests/Composer/Test/Json/JsonManipulatorTest.php b/tests/Composer/Test/Json/JsonManipulatorTest.php index afb53774e..7ee9eec73 100644 --- a/tests/Composer/Test/Json/JsonManipulatorTest.php +++ b/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('{