From 5cb9a6ead77a42dfd6da4684882123ed95b9bc14 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Sun, 7 Oct 2012 19:18:22 +0200 Subject: [PATCH] Write in the json directly without reformatting the whole file - skip validation since that is not really the job of the config command --- src/Composer/Command/ConfigCommand.php | 279 +++++++--------- src/Composer/Json/JsonManipulator.php | 123 ++++++- .../Test/Json/JsonManipulatorTest.php | 315 ++++++++++++++++++ 3 files changed, 551 insertions(+), 166 deletions(-) diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index bd099976d..965c7e3cf 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -16,11 +16,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use JsonSchema\Validator; use Composer\Config; use Composer\Factory; use Composer\Json\JsonFile; -use Composer\Json\JsonValidationException; +use Composer\Json\JsonManipulator; /** * @author Joshua Estes @@ -146,118 +145,133 @@ EOT throw new \RuntimeException('You must include a setting value or pass --unset to clear the value'); } - /** - * The user needs the ability to add a repository with one command. - * For example "config -g repository.foo 'vcs http://example.com' - */ - $configSettings = $this->configFile->read(); // what is current in the config - $values = $input->getArgument('setting-value'); // what the user is trying to add/change + $values = $input->getArgument('setting-value'); // what the user is trying to add/change // handle repositories if (preg_match('/^repos?(?:itories)?\.(.+)/', $input->getArgument('setting-key'), $matches)) { if ($input->getOption('unset')) { - unset($configSettings['repositories'][$matches[1]]); - } else { - $settingKey = 'repositories.'.$matches[1]; - if (2 !== count($values)) { - throw new \RuntimeException('You must pass the type and a url. Example: php composer.phar config repositories.foo vcs http://bar.com'); - } - $setting = $this->parseSetting($settingKey, array( + return $this->manipulateJson('removeRepository', $matches[1], function (&$config, $repo) { + unset($config['repositories'][$repo]); + }); + } + + if (2 !== count($values)) { + throw new \RuntimeException('You must pass the type and a url. Example: php composer.phar config repositories.foo vcs http://bar.com'); + } + + return $this->manipulateJson( + 'addRepository', + $matches[1], + array( 'type' => $values[0], 'url' => $values[1], - )); + ), function (&$config, $repo, $repoConfig) { + $config['repositories'][$repo] = $repoConfig; + } + ); + } - // Could there be a better way to do this? - $configSettings = array_merge_recursive($configSettings, $setting); - $this->validateSchema($configSettings); + // handle config values + $uniqueConfigValues = array( + 'process-timeout' => array('is_numeric', 'intval'), + 'vendor-dir' => array('is_string', function ($val) { return $val; }), + 'bin-dir' => array('is_string', function ($val) { return $val; }), + 'notify-on-install' => array( + function ($val) { return true; }, + function ($val) { return $val !== 'false' && (bool) $val; } + ), + ); + $multiConfigValues = array( + 'github-protocols' => array( + function ($vals) { + if (!is_array($vals)) { + return 'array expected'; + } + + foreach ($vals as $val) { + if (!in_array($val, array('git', 'https', 'http'))) { + return 'valid protocols include: git, https, http'; + } + } + + return true; + }, + function ($vals) { + return $vals; + } + ), + ); + + $settingKey = $input->getArgument('setting-key'); + foreach ($uniqueConfigValues as $name => $callbacks) { + if ($settingKey === $name) { + if ($input->getOption('unset')) { + return $this->manipulateJson('removeConfigSetting', $settingKey, function (&$config, $key) { + unset($config['config'][$key]); + }); + } + + 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->manipulateJson('addConfigSetting', $settingKey, $normalizer($values[0]), function (&$config, $key, $val) { + $config['config'][$key] = $val; + }); } + } + + foreach ($multiConfigValues as $name => $callbacks) { + if ($settingKey === $name) { + if ($input->getOption('unset')) { + return $this->manipulateJson('removeConfigSetting', $settingKey, function (&$config, $key) { + unset($config['config'][$key]); + }); + } + + list($validator, $normalizer) = $callbacks; + if (true !== $validation = $validator($values)) { + throw new \RuntimeException(sprintf( + '%s is an invalid value'.($validation ? ' ('.$validation.')' : ''), + json_encode($values) + )); + } + + return $this->manipulateJson('addConfigSetting', $settingKey, $normalizer($values), function (&$config, $key, $val) { + $config['config'][$key] = $val; + }); + } + } + } + + protected function manipulateJson($method, $args, $fallback) + { + $args = func_get_args(); + // remove method & fallback + array_shift($args); + $fallback = array_pop($args); + + $contents = file_get_contents($this->configFile->getPath()); + $manipulator = new JsonManipulator($contents); + + // try to update cleanly + if (call_user_func_array(array($manipulator, $method), $args)) { + file_put_contents($this->configFile->getPath(), $manipulator->getContents()); } else { - // handle config values - $uniqueConfigValues = array( - 'process-timeout' => array('is_numeric', 'intval'), - 'vendor-dir' => array('is_string', function ($val) { return $val; }), - 'bin-dir' => array('is_string', function ($val) { return $val; }), - 'notify-on-install' => array( - function ($val) { return true; }, - function ($val) { return $val !== 'false' && (bool) $val; } - ), - ); - $multiConfigValues = array( - 'github-protocols' => array( - function ($vals) { - if (!is_array($vals)) { - return 'array expected'; - } - - foreach ($vals as $val) { - if (!in_array($val, array('git', 'https', 'http'))) { - return 'valid protocols include: git, https, http'; - } - } - - return true; - }, - function ($vals) { - return $vals; - } - ), - ); - - $settingKey = $input->getArgument('setting-key'); - foreach ($uniqueConfigValues as $name => $callbacks) { - if ($settingKey === $name) { - list($validator, $normalizer) = $callbacks; - if ($input->getOption('unset')) { - unset($configSettings['config'][$settingKey]); - } else { - 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] - )); - } - - $setting = $this->parseSetting('config.'.$settingKey, $normalizer($values[0])); - $configSettings = array_merge($configSettings, $setting); - $this->validateSchema($configSettings); - } - } - } - - foreach ($multiConfigValues as $name => $callbacks) { - if ($settingKey === $name) { - list($validator, $normalizer) = $callbacks; - if ($input->getOption('unset')) { - unset($configSettings['config'][$settingKey]); - } else { - if (true !== $validation = $validator($values)) { - throw new \RuntimeException(sprintf( - '%s is an invalid value'.($validation ? ' ('.$validation.')' : ''), - json_encode($values) - )); - } - - $setting = $this->parseSetting('config.'.$settingKey, $normalizer($values)); - $configSettings = array_merge($configSettings, $setting); - $this->validateSchema($configSettings); - } - } - } + // on failed clean update, call the fallback and rewrite the whole file + $config = $this->configFile->read(); + array_unshift($args, $config); + call_user_func_array($fallback, $args); + $this->configFile->write($config); } - - // clean up empty sections - if (empty($configSettings['repositories'])) { - unset($configSettings['repositories']); - } - if (empty($configSettings['config'])) { - unset($configSettings['config']); - } - - $this->configFile->write($configSettings); } /** @@ -291,61 +305,4 @@ EOT $output->writeln('[' . $k . $key . '] ' . $value . ''); } } - - /** - * This function will take a setting key (a.b.c) and return an - * array that matches this - * - * @param string $key - * @param string $value - * @return array - */ - protected function parseSetting($key, $value) - { - $parts = array_reverse(explode('.', $key)); - $tmp = array(); - for ($i = 0; $i < count($parts); $i++) { - $tmp[$parts[$i]] = (0 === $i) ? $value : $tmp; - if (0 < $i) { - unset($tmp[$parts[$i - 1]]); - } - } - - return $tmp; - } - - /** - * After the command sets a new config value, this will parse it writes - * it to disk to make sure that it is valid according the the composer.json - * schema. - * - * @param array $data - * @throws JsonValidationException - * @return boolean - */ - protected function validateSchema(array $data) - { - // TODO Figure out what should be excluded from the validation check - // TODO validation should vary based on if it's global or local - $schemaFile = __DIR__ . '/../../../res/composer-schema.json'; - $schemaData = json_decode(file_get_contents($schemaFile)); - - unset( - $schemaData->properties->name, - $schemaData->properties->description - ); - - $validator = new Validator(); - $validator->check(json_decode(json_encode($data)), $schemaData); - - if (!$validator->isValid()) { - $errors = array(); - foreach ((array) $validator->getErrors() as $error) { - $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message']; - } - throw new JsonValidationException('"'.$this->configFile->getPath().'" does not match the expected JSON schema'."\n". implode("\n",$errors)); - } - - return true; - } } diff --git a/src/Composer/Json/JsonManipulator.php b/src/Composer/Json/JsonManipulator.php index 8dc9e398c..fb2752ac8 100644 --- a/src/Composer/Json/JsonManipulator.php +++ b/src/Composer/Json/JsonManipulator.php @@ -17,6 +17,8 @@ namespace Composer\Json; */ class JsonManipulator { + private static $RECURSE_BLOCKS = '(?:[^{}]*|\{(?:[^{}]*|\{(?:[^{}]*|\{(?:[^{}]*|\{[^{}]*\})*\})*\})*\})*'; + private $contents; private $newline; private $indent; @@ -56,7 +58,7 @@ class JsonManipulator // link exists already if (preg_match('{"'.$packageRegex.'"\s*:}i', $links)) { - $links = preg_replace('{"'.$packageRegex.'"(\s*:\s*)"[^"]+"}i', JsonFile::encode($package).'$1"'.$constraint.'"', $links); + $links = preg_replace('{"'.$packageRegex.'"(\s*:\s*)"[^"]+"}i', JsonFile::encode($package).'${1}"'.$constraint.'"', $links); } elseif (preg_match('#[^\s](\s*)$#', $links, $match)) { // link missing but non empty links $links = preg_replace( @@ -69,7 +71,118 @@ class JsonManipulator $links = $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $links; } - $this->contents = preg_replace($linksRegex, '$1'.$links.'$3', $this->contents); + $this->contents = preg_replace($linksRegex, '${1}'.$links.'$3', $this->contents); + + return true; + } + + public function addRepository($name, $config) + { + return $this->addSubNode('repositories', $name, $config); + } + + public function removeRepository($name) + { + return $this->removeSubNode('repositories', $name); + } + + public function addConfigSetting($name, $value) + { + return $this->addSubNode('config', $name, $value); + } + + public function removeConfigSetting($name) + { + return $this->removeSubNode('config', $name); + } + + public function addSubNode($mainNode, $name, $value) + { + // no main node yet + if (!preg_match('#"'.$mainNode.'":\s*\{#', $this->contents)) { + $this->addMainKey(''.$mainNode.'', $this->format(array($name => $value))); + + return true; + } + + // main node content not match-able + $nodeRegex = '#("'.$mainNode.'":\s*\{)('.self::$RECURSE_BLOCKS.')(\})#s'; + if (!preg_match($nodeRegex, $this->contents, $match)) { + return false; + } + + $children = $match[2]; + + // invalid match due to un-regexable content, abort + if (!json_decode('{'.$children.'}')) { + return false; + } + + // child exists + if (preg_match('{("'.preg_quote($name).'"\s*:\s*)([0-9.]+|null|true|false|"[^"]+"|\{'.self::$RECURSE_BLOCKS.'\})(,?)}', $children, $matches)) { + $children = preg_replace('{("'.preg_quote($name).'"\s*:\s*)([0-9.]+|null|true|false|"[^"]+"|\{'.self::$RECURSE_BLOCKS.'\})(,?)}', '${1}'.$this->format($value, 1).'$3', $children); + } elseif (preg_match('#[^\s](\s*)$#', $children, $match)) { + // child missing but non empty children + $children = preg_replace( + '#'.$match[1].'$#', + ',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $match[1], + $children + ); + } else { + // children present but empty + $children = $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $children; + } + + $this->contents = preg_replace($nodeRegex, '${1}'.$children.'$3', $this->contents); + + return true; + } + + public function removeSubNode($mainNode, $name) + { + // no node + if (!preg_match('#"'.$mainNode.'":\s*\{#', $this->contents)) { + return true; + } + + // empty node + if (preg_match('#"'.$mainNode.'":\s*\{\s*\}#s', $this->contents)) { + return true; + } + + // no node content match-able + $nodeRegex = '#("'.$mainNode.'":\s*\{)('.self::$RECURSE_BLOCKS.')(\})#s'; + if (!preg_match($nodeRegex, $this->contents, $match)) { + return false; + } + + $children = $match[2]; + + // invalid match due to un-regexable content, abort + if (!json_decode('{'.$children.'}')) { + return false; + } + + if (preg_match('{"'.preg_quote($name).'"\s*:}i', $children)) { + if (preg_match('{"'.preg_quote($name).'"\s*:\s*(?:[0-9.]+|null|true|false|"[^"]+"|\{'.self::$RECURSE_BLOCKS.'\})}', $children, $matches)) { + $children = preg_replace('{,\s*'.preg_quote($matches[0]).'}i', '', $children, -1, $count); + if (1 !== $count) { + $children = preg_replace('{'.preg_quote($matches[0]).'\s*,?\s*}i', '', $children, -1, $count); + if (1 !== $count) { + return false; + } + } + } + + } + + if (!trim($children)) { + $this->contents = preg_replace($nodeRegex, '$1'.$this->newline.$this->indent.'}', $this->contents); + + return true; + } + + $this->contents = preg_replace($nodeRegex, '${1}'.$children.'$3', $this->contents); return true; } @@ -91,7 +204,7 @@ class JsonManipulator } } - protected function format($data) + protected function format($data, $depth = 0) { if (is_array($data)) { reset($data); @@ -102,10 +215,10 @@ class JsonManipulator $out = '{' . $this->newline; foreach ($data as $key => $val) { - $elems[] = $this->indent . $this->indent . JsonFile::encode($key). ': '.$this->format($val); + $elems[] = str_repeat($this->indent, $depth + 2) . JsonFile::encode($key). ': '.$this->format($val, $depth + 1); } - return $out . implode(','.$this->newline, $elems) . $this->newline . $this->indent . '}'; + return $out . implode(','.$this->newline, $elems) . $this->newline . str_repeat($this->indent, $depth + 1) . '}'; } return JsonFile::encode($data); diff --git a/tests/Composer/Test/Json/JsonManipulatorTest.php b/tests/Composer/Test/Json/JsonManipulatorTest.php index e6718b56a..b0971ce51 100644 --- a/tests/Composer/Test/Json/JsonManipulatorTest.php +++ b/tests/Composer/Test/Json/JsonManipulatorTest.php @@ -131,4 +131,319 @@ class JsonManipulatorTest extends \PHPUnit_Framework_TestCase ), ); } + + /** + * @dataProvider removeSubNodeProvider + */ + public function testRemoveSubNode($json, $name, $expected, $expectedContent = null) + { + $manipulator = new JsonManipulator($json); + + $this->assertEquals($expected, $manipulator->removeSubNode('repositories', $name)); + if (null !== $expectedContent) { + $this->assertEquals($expectedContent, $manipulator->getContents()); + } + } + + public function removeSubNodeProvider() + { + return array( + 'works on simple ones first' => array( + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + }, + "bar": { + "foo": "bar", + "bar": "baz" + } + } +}', + 'foo', + true, + '{ + "repositories": { + "bar": { + "foo": "bar", + "bar": "baz" + } + } +} +' + ), + 'works on simple ones last' => array( + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + }, + "bar": { + "foo": "bar", + "bar": "baz" + } + } +}', + 'bar', + true, + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + } + } +} +' + ), + 'works on simple ones unique' => array( + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + } + } +}', + 'foo', + true, + '{ + "repositories": { + } +} +' + ), + 'works on simple ones middle' => array( + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + }, + "bar": { + "foo": "bar", + "bar": "baz" + }, + "baz": { + "foo": "bar", + "bar": "baz" + } + } +}', + 'bar', + true, + '{ + "repositories": { + "foo": { + "foo": "bar", + "bar": "baz" + }, + "baz": { + "foo": "bar", + "bar": "baz" + } + } +} +' + ), + 'works on empty repos' => array( + '{ + "repositories": { + } +}', + 'bar', + true + ), + 'works on empty repos2' => array( + '{ + "repositories": {} +}', + 'bar', + true + ), + 'works on missing repos' => array( + "{\n}", + 'bar', + true + ), + 'works on deep repos' => array( + '{ + "repositories": { + "foo": { + "package": { "bar": "baz" } + } + } +}', + 'foo', + true, + '{ + "repositories": { + } +} +' + ), + 'fails on deep repos with borked texts' => array( + '{ + "repositories": { + "foo": { + "package": { "bar": "ba{z" } + } + } +}', + 'bar', + false + ), + 'fails on deep repos with borked texts2' => array( + '{ + "repositories": { + "foo": { + "package": { "bar": "ba}z" } + } + } +}', + 'bar', + false + ), + ); + } + + public function testAddRepositoryCanInitializeEmptyRepositories() + { + $manipulator = new JsonManipulator('{ + "repositories": { + } +}'); + + $this->assertTrue($manipulator->addRepository('bar', array('type' => 'composer'))); + $this->assertEquals('{ + "repositories": { + "bar": { + "type": "composer" + } + } +} +', $manipulator->getContents()); + } + + public function testAddRepositoryCanInitializeFromScratch() + { + $manipulator = new JsonManipulator('{ + "a": "b" +}'); + + $this->assertTrue($manipulator->addRepository('bar2', array('type' => 'composer'))); + $this->assertEquals('{ + "a": "b", + "repositories": { + "bar2": { + "type": "composer" + } + } +} +', $manipulator->getContents()); + } + + public function testAddRepositoryCanAdd() + { + $manipulator = new JsonManipulator('{ + "repositories": { + "foo": { + "type": "vcs", + "url": "lala" + } + } +}'); + + $this->assertTrue($manipulator->addRepository('bar', array('type' => 'composer'))); + $this->assertEquals('{ + "repositories": { + "foo": { + "type": "vcs", + "url": "lala" + }, + "bar": { + "type": "composer" + } + } +} +', $manipulator->getContents()); + } + + public function testAddRepositoryCanOverrideDeepRepos() + { + $manipulator = new JsonManipulator('{ + "repositories": { + "baz": { + "type": "package", + "package": {} + } + } +}'); + + $this->assertTrue($manipulator->addRepository('baz', array('type' => 'composer'))); + $this->assertEquals('{ + "repositories": { + "baz": { + "type": "composer" + } + } +} +', $manipulator->getContents()); + } + + public function testAddConfigSettingCanAdd() + { + $manipulator = new JsonManipulator('{ + "config": { + "foo": "bar" + } +}'); + + $this->assertTrue($manipulator->addConfigSetting('bar', 'baz')); + $this->assertEquals('{ + "config": { + "foo": "bar", + "bar": "baz" + } +} +', $manipulator->getContents()); + } + + public function testAddConfigSettingCanOverwrite() + { + $manipulator = new JsonManipulator('{ + "config": { + "foo": "bar", + "bar": "baz" + } +}'); + + $this->assertTrue($manipulator->addConfigSetting('foo', 'zomg')); + $this->assertEquals('{ + "config": { + "foo": "zomg", + "bar": "baz" + } +} +', $manipulator->getContents()); + } + + public function testAddConfigSettingCanOverwriteNumbers() + { + $manipulator = new JsonManipulator('{ + "config": { + "foo": 500 + } +}'); + + $this->assertTrue($manipulator->addConfigSetting('foo', 50)); + $this->assertEquals('{ + "config": { + "foo": 50 + } +} +', $manipulator->getContents()); + } }