Browse Source

Merge pull request #736 from Seldaek/require-update

Require command & update <package>
Nils Adermann 13 years ago
parent
commit
242323cba4

+ 2 - 0
CHANGELOG.md

@@ -1,6 +1,8 @@
 * 1.0.0-alpha4
 * 1.0.0-alpha4
 
 
   * Schema: Added references for dev versions, requiring `dev-master#abcdef` for example will force the abcdef commit
   * Schema: Added references for dev versions, requiring `dev-master#abcdef` for example will force the abcdef commit
+  * Added `require` command to add a package to your requirements and install it
+  * Added a whitelist to `update`. Calling `composer update foo/bar foo/baz` allows you to update only those packages
   * Added caching of GitHub metadata (faster startup time with custom GitHub VCS repos)
   * Added caching of GitHub metadata (faster startup time with custom GitHub VCS repos)
   * Added support for file:// URLs to GitDriver
   * Added support for file:// URLs to GitDriver
   * Added --dev flag to `create-project` command
   * Added --dev flag to `create-project` command

+ 24 - 0
doc/03-cli.md

@@ -53,12 +53,36 @@ In order to get the latest versions of the dependencies and to update the
 This will resolve all dependencies of the project and write the exact versions
 This will resolve all dependencies of the project and write the exact versions
 into `composer.lock`.
 into `composer.lock`.
 
 
+If you just want to update a few packages and not all, you can list them as such:
+
+    $ php composer.phar update vendor/package vendor/package2
+
 ### Options
 ### Options
 
 
 * **--prefer-source:** Install packages from `source` when available.
 * **--prefer-source:** Install packages from `source` when available.
 * **--dry-run:** Simulate the command without actually doing anything.
 * **--dry-run:** Simulate the command without actually doing anything.
 * **--dev:** Install packages listed in `require-dev`.
 * **--dev:** Install packages listed in `require-dev`.
 
 
+## require
+
+The `require` command adds new packages to the `composer.json` file from 
+the current directory.
+
+    $ php composer.phar require
+
+After adding/changing the requirements, the modified requirements will be
+installed or updated. 
+
+If you do not want to choose requirements interactively, you can just pass them
+to the command.
+
+    $ php composer.phar require vendor/package:2.* vendor/package2:dev-master
+
+### Options
+
+* **--prefer-source:** Install packages from `source` when available.
+* **--dev:** Add packages to `require-dev`.
+
 ## search
 ## search
 
 
 The search command allows you to search through the current project's package
 The search command allows you to search through the current project's package

+ 27 - 6
src/Composer/Command/InitCommand.php

@@ -60,7 +60,8 @@ class InitCommand extends Command
                 new InputOption('author', null, InputOption::VALUE_NONE, 'Author name of package'),
                 new InputOption('author', null, InputOption::VALUE_NONE, 'Author name of package'),
                 // new InputOption('version', null, InputOption::VALUE_NONE, 'Version of package'),
                 // new InputOption('version', null, InputOption::VALUE_NONE, 'Version of package'),
                 new InputOption('homepage', null, InputOption::VALUE_NONE, 'Homepage of package'),
                 new InputOption('homepage', null, InputOption::VALUE_NONE, 'Homepage of package'),
-                new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'An array required packages'),
+                new InputOption('require', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
+                new InputOption('require-dev', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'Package to require for development with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
             ))
             ))
             ->setHelp(<<<EOT
             ->setHelp(<<<EOT
 The <info>init</info> command creates a basic composer.json file
 The <info>init</info> command creates a basic composer.json file
@@ -216,10 +217,15 @@ EOT
         ));
         ));
 
 
         $requirements = array();
         $requirements = array();
-        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dependencies interactively', 'yes', '?'), true)) {
-            $requirements = $this->determineRequirements($input, $output);
+        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dependencies (require) interactively', 'yes', '?'), true)) {
+            $requirements = $this->determineRequirements($input, $output, $input->getOption('require'));
         }
         }
         $input->setOption('require', $requirements);
         $input->setOption('require', $requirements);
+        $devRequirements = array();
+        if ($dialog->askConfirmation($output, $dialog->getQuestion('Would you like to define your dev dependencies (require-dev) interactively', 'yes', '?'), true)) {
+            $devRequirements = $this->determineRequirements($input, $output, $input->getOption('require-dev'));
+        }
+        $input->setOption('require-dev', $devRequirements);
     }
     }
 
 
     protected function findPackages($name)
     protected function findPackages($name)
@@ -246,12 +252,27 @@ EOT
         return $packages;
         return $packages;
     }
     }
 
 
-    protected function determineRequirements(InputInterface $input, OutputInterface $output)
+    protected function determineRequirements(InputInterface $input, OutputInterface $output, $requires = array())
     {
     {
         $dialog = $this->getHelperSet()->get('dialog');
         $dialog = $this->getHelperSet()->get('dialog');
         $prompt = $dialog->getQuestion('Search for a package', false, ':');
         $prompt = $dialog->getQuestion('Search for a package', false, ':');
 
 
-        $requires = $input->getOption('require') ?: array();
+        if ($requires) {
+            foreach ($requires as $key => $requirement) {
+                $requires[$key] = preg_replace('{^([^=: ]+)[=: ](.*)$}', '$1 $2', $requirement);
+                if (false === strpos($requires[$key], ' ') && $input->isInteractive()) {
+                    $question = $dialog->getQuestion('Please provide a version constraint for the '.$requirement.' requirement');
+                    if ($constraint = $dialog->ask($output, $question)) {
+                        $requires[$key] .= ' ' . $constraint;
+                    }
+                }
+                if (false === strpos($requires[$key], ' ')) {
+                    throw new \InvalidArgumentException('The requirement '.$requirement.' must contain a version constraint');
+                }
+            }
+
+            return $requires;
+        }
 
 
         while (null !== $package = $dialog->ask($output, $prompt)) {
         while (null !== $package = $dialog->ask($output, $prompt)) {
             $matches = $this->findPackages($package);
             $matches = $this->findPackages($package);
@@ -287,7 +308,7 @@ EOT
                     return sprintf('%s %s', $package->getName(), $package->getPrettyVersion());
                     return sprintf('%s %s', $package->getName(), $package->getPrettyVersion());
                 };
                 };
 
 
-                $package = $dialog->askAndValidate($output, $dialog->getQuestion('Enter package # to add, or a <package> <version> couple if it is not listed', false, ':'), $validator, 3);
+                $package = $dialog->askAndValidate($output, $dialog->getQuestion('Enter package # to add, or a "[package] [version]" couple if it is not listed', false, ':'), $validator, 3);
 
 
                 if (false !== $package) {
                 if (false !== $package) {
                     $requires[] = $package;
                     $requires[] = $package;

+ 123 - 0
src/Composer/Command/RequireCommand.php

@@ -0,0 +1,123 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Command;
+
+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 Composer\Factory;
+use Composer\Installer;
+use Composer\Json\JsonFile;
+use Composer\Json\JsonManipulator;
+use Composer\Json\JsonValidationException;
+use Composer\Util\RemoteFilesystem;
+
+/**
+ * @author Jérémy Romey <jeremy@free-agent.fr>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class RequireCommand extends InitCommand
+{
+    protected function configure()
+    {
+        $this
+            ->setName('require')
+            ->setDescription('Adds required packages to your composer.json and installs them')
+            ->setDefinition(array(
+                new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Required package with a version constraint, e.g. foo/bar:1.0.0 or foo/bar=1.0.0 or "foo/bar 1.0.0"'),
+                new InputOption('dev', null, InputOption::VALUE_NONE, 'Add requirement to require-dev.'),
+                new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
+            ))
+            ->setHelp(<<<EOT
+The require command adds required packages to your composer.json and installs them
+
+EOT
+            )
+        ;
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $factory = new Factory;
+        $file = $factory->getComposerFile();
+
+        if (!file_exists($file)) {
+            $output->writeln('<error>'.$file.' not found.</error>');
+            return 1;
+        }
+        if (!is_readable($file)) {
+            $output->writeln('<error>'.$file.' is not readable.</error>');
+            return 1;
+        }
+
+        $dialog = $this->getHelperSet()->get('dialog');
+
+        $json = new JsonFile($file);
+        $composer = $json->read();
+
+        $requirements = $this->determineRequirements($input, $output, $input->getArgument('packages'));
+
+        $requireKey = $input->getOption('dev') ? 'require-dev' : 'require';
+        $baseRequirements = array_key_exists($requireKey, $composer) ? $composer[$requireKey] : array();
+        $requirements = $this->formatRequirements($requirements);
+
+        if (!$this->updateFileCleanly($json, $baseRequirements, $requirements, $requireKey)) {
+            foreach ($requirements as $package => $version) {
+                $baseRequirements[$package] = $version;
+            }
+
+            $composer[$requireKey] = $baseRequirements;
+            $json->write($composer);
+        }
+
+        $output->writeln('<info>'.$file.' has been updated</info>');
+
+        // Update packages
+        $composer = $this->getComposer();
+        $io = $this->getIO();
+        $install = Installer::create($io, $composer);
+
+        $install
+            ->setVerbose($input->getOption('verbose'))
+            ->setPreferSource($input->getOption('prefer-source'))
+            ->setDevMode($input->getOption('dev'))
+            ->setUpdate(true)
+            ->setUpdateWhitelist($requirements);
+        ;
+
+        return $install->run() ? 0 : 1;
+    }
+
+    private function updateFileCleanly($json, array $base, array $new, $requireKey)
+    {
+        $contents = file_get_contents($json->getPath());
+
+        $manipulator = new JsonManipulator($contents);
+
+        foreach ($new as $package => $constraint) {
+            if (!$manipulator->addLink($requireKey, $package, $constraint)) {
+                return false;
+            }
+        }
+
+        file_put_contents($json->getPath(), $manipulator->getContents());
+
+        return true;
+    }
+
+    protected function interact(InputInterface $input, OutputInterface $output)
+    {
+        return;
+    }
+}

+ 3 - 0
src/Composer/Command/UpdateCommand.php

@@ -15,6 +15,7 @@ namespace Composer\Command;
 use Composer\Installer;
 use Composer\Installer;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 
 
 /**
 /**
@@ -28,6 +29,7 @@ class UpdateCommand extends Command
             ->setName('update')
             ->setName('update')
             ->setDescription('Updates your dependencies to the latest version, and updates the composer.lock file.')
             ->setDescription('Updates your dependencies to the latest version, and updates the composer.lock file.')
             ->setDefinition(array(
             ->setDefinition(array(
+                new InputArgument('packages', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Packages that should be updated, if not provided all packages are.'),
                 new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
                 new InputOption('prefer-source', null, InputOption::VALUE_NONE, 'Forces installation from package sources when possible, including VCS information.'),
                 new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'),
                 new InputOption('dry-run', null, InputOption::VALUE_NONE, 'Outputs the operations but will not execute anything (implicitly enables --verbose).'),
                 new InputOption('dev', null, InputOption::VALUE_NONE, 'Enables installation of dev-require packages.'),
                 new InputOption('dev', null, InputOption::VALUE_NONE, 'Enables installation of dev-require packages.'),
@@ -58,6 +60,7 @@ EOT
             ->setDevMode($input->getOption('dev'))
             ->setDevMode($input->getOption('dev'))
             ->setRunScripts(!$input->getOption('no-scripts'))
             ->setRunScripts(!$input->getOption('no-scripts'))
             ->setUpdate(true)
             ->setUpdate(true)
+            ->setUpdateWhitelist($input->getArgument('packages'))
         ;
         ;
 
 
         return $install->run() ? 0 : 1;
         return $install->run() ? 0 : 1;

+ 15 - 12
src/Composer/Console/Application.php

@@ -64,7 +64,6 @@ class Application extends BaseApplication
      */
      */
     public function doRun(InputInterface $input, OutputInterface $output)
     public function doRun(InputInterface $input, OutputInterface $output)
     {
     {
-        $this->registerCommands();
         $this->io = new ConsoleIO($input, $output, $this->getHelperSet());
         $this->io = new ConsoleIO($input, $output, $this->getHelperSet());
 
 
         if (version_compare(PHP_VERSION, '5.3.2', '<')) {
         if (version_compare(PHP_VERSION, '5.3.2', '<')) {
@@ -106,21 +105,25 @@ class Application extends BaseApplication
     /**
     /**
      * Initializes all the composer commands
      * Initializes all the composer commands
      */
      */
-    protected function registerCommands()
+    protected function getDefaultCommands()
     {
     {
-        $this->add(new Command\AboutCommand());
-        $this->add(new Command\DependsCommand());
-        $this->add(new Command\InitCommand());
-        $this->add(new Command\InstallCommand());
-        $this->add(new Command\CreateProjectCommand());
-        $this->add(new Command\UpdateCommand());
-        $this->add(new Command\SearchCommand());
-        $this->add(new Command\ValidateCommand());
-        $this->add(new Command\ShowCommand());
+        $commands = parent::getDefaultCommands();
+        $commands[] = new Command\AboutCommand();
+        $commands[] = new Command\DependsCommand();
+        $commands[] = new Command\InitCommand();
+        $commands[] = new Command\InstallCommand();
+        $commands[] = new Command\CreateProjectCommand();
+        $commands[] = new Command\UpdateCommand();
+        $commands[] = new Command\SearchCommand();
+        $commands[] = new Command\ValidateCommand();
+        $commands[] = new Command\ShowCommand();
+        $commands[] = new Command\RequireCommand();
 
 
         if ('phar:' === substr(__FILE__, 0, 5)) {
         if ('phar:' === substr(__FILE__, 0, 5)) {
-            $this->add(new Command\SelfUpdateCommand());
+            $commands[] = new Command\SelfUpdateCommand();
         }
         }
+
+        return $commands;
     }
     }
 
 
     /**
     /**

+ 6 - 1
src/Composer/Factory.php

@@ -62,6 +62,11 @@ class Factory
         return $config;
         return $config;
     }
     }
 
 
+    public function getComposerFile()
+    {
+        return getenv('COMPOSER') ?: 'composer.json';
+    }
+
     /**
     /**
      * Creates a Composer instance
      * Creates a Composer instance
      *
      *
@@ -73,7 +78,7 @@ class Factory
     {
     {
         // load Composer configuration
         // load Composer configuration
         if (null === $localConfig) {
         if (null === $localConfig) {
-            $localConfig = getenv('COMPOSER') ?: 'composer.json';
+            $localConfig = $this->getComposerFile();
         }
         }
 
 
         if (is_string($localConfig)) {
         if (is_string($localConfig)) {

+ 84 - 1
src/Composer/Installer.php

@@ -89,6 +89,7 @@ class Installer
     protected $verbose = false;
     protected $verbose = false;
     protected $update = false;
     protected $update = false;
     protected $runScripts = true;
     protected $runScripts = true;
+    protected $updateWhitelist = null;
 
 
     /**
     /**
      * @var array
      * @var array
@@ -219,6 +220,8 @@ class Installer
             $stabilityFlags = $this->locker->getStabilityFlags();
             $stabilityFlags = $this->locker->getStabilityFlags();
         }
         }
 
 
+        $this->whitelistUpdateDependencies($localRepo, $devMode);
+
         // creating repository pool
         // creating repository pool
         $pool = new Pool($minimumStability, $stabilityFlags);
         $pool = new Pool($minimumStability, $stabilityFlags);
         $pool->addRepository($installedRepo);
         $pool->addRepository($installedRepo);
@@ -275,8 +278,11 @@ class Installer
         // fix the version of all installed packages (+ platform) that are not
         // fix the version of all installed packages (+ platform) that are not
         // in the current local repo to prevent rogue updates (e.g. non-dev
         // in the current local repo to prevent rogue updates (e.g. non-dev
         // updating when in dev)
         // updating when in dev)
+        //
+        // if the updateWhitelist is enabled, packages not in it are also fixed
+        // to their currently installed version
         foreach ($installedRepo->getPackages() as $package) {
         foreach ($installedRepo->getPackages() as $package) {
-            if ($package->getRepository() === $localRepo) {
+            if ($package->getRepository() === $localRepo && (!$this->updateWhitelist || $this->isUpdateable($package))) {
                 continue;
                 continue;
             }
             }
 
 
@@ -331,6 +337,11 @@ class Installer
             } else {
             } else {
                 // force update to latest on update
                 // force update to latest on update
                 if ($this->update) {
                 if ($this->update) {
+                    // skip package if the whitelist is enabled and it is not in it
+                    if ($this->updateWhitelist && !$this->isUpdateable($package)) {
+                        continue;
+                    }
+
                     $newPackage = $this->repositoryManager->findPackage($package->getName(), $package->getVersion());
                     $newPackage = $this->repositoryManager->findPackage($package->getName(), $package->getVersion());
                     if ($newPackage && $newPackage->getSourceReference() !== $package->getSourceReference()) {
                     if ($newPackage && $newPackage->getSourceReference() !== $package->getSourceReference()) {
                         $operations[] = new UpdateOperation($package, $newPackage);
                         $operations[] = new UpdateOperation($package, $newPackage);
@@ -441,6 +452,64 @@ class Installer
         return $aliases;
         return $aliases;
     }
     }
 
 
+    private function isUpdateable(PackageInterface $package)
+    {
+        if (!$this->updateWhitelist) {
+            throw new \LogicException('isUpdateable should only be called when a whitelist is present');
+        }
+
+        return isset($this->updateWhitelist[$package->getName()]);
+    }
+
+    /**
+     * Adds all dependencies of the update whitelist to the whitelist, too.
+     *
+     * @param RepositoryInterface $localRepo
+     * @param boolean $devMode
+     */
+    private function whitelistUpdateDependencies($localRepo, $devMode)
+    {
+        if (!$this->updateWhitelist) {
+            return;
+        }
+
+        $pool = new Pool;
+        $pool->addRepository($localRepo);
+
+        $seen = array();
+
+        foreach ($this->updateWhitelist as $packageName => $void) {
+            $packageQueue = new \SplQueue;
+
+            foreach ($pool->whatProvides($packageName) as $depPackage) {
+                $packageQueue->enqueue($depPackage);
+            }
+
+            while (!$packageQueue->isEmpty()) {
+                $package = $packageQueue->dequeue();
+                if (isset($seen[$package->getId()])) {
+                    continue;
+                }
+
+                $seen[$package->getId()] = true;
+                $this->updateWhitelist[$package->getName()] = true;
+
+                $requires = $package->getRequires();
+                if ($devMode) {
+                    $requires = array_merge($requires, $package->getDevRequires());
+                }
+
+                foreach ($requires as $require) {
+                    $requirePackages = $pool->whatProvides($require->getTarget());
+
+                    foreach ($requirePackages as $requirePackage) {
+                        $packageQueue->enqueue($requirePackage);
+                    }
+                }
+            }
+        }
+    }
+
     /**
     /**
      * Create Installer
      * Create Installer
      *
      *
@@ -551,4 +620,18 @@ class Installer
 
 
         return $this;
         return $this;
     }
     }
+
+    /**
+     * restrict the update operation to a few packages, all other packages
+     * that are already installed will be kept at their current version
+     *
+     * @param  array     $packages
+     * @return Installer
+     */
+    public function setUpdateWhitelist(array $packages)
+    {
+        $this->updateWhitelist = array_flip(array_map('strtolower', $packages));
+
+        return $this;
+    }
 }
 }

+ 121 - 0
src/Composer/Json/JsonManipulator.php

@@ -0,0 +1,121 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Json;
+
+/**
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ */
+class JsonManipulator
+{
+    private $contents;
+    private $newline;
+    private $indent;
+
+    public function __construct($contents)
+    {
+        $contents = trim($contents);
+        if (!preg_match('#^\{(.*)\}$#s', $contents)) {
+            throw new \InvalidArgumentException('The json file must be an object ({})');
+        }
+        $this->newline = false !== strpos("\r\n", $contents) ? "\r\n": "\n";
+        $this->contents = $contents;
+        $this->detectIndenting();
+    }
+
+    public function getContents()
+    {
+        return $this->contents . $this->newline;
+    }
+
+    public function addLink($type, $package, $constraint)
+    {
+        // no link of that type yet
+        if (!preg_match('#"'.$type.'":\s*\{#', $this->contents)) {
+            $this->addMainKey($type, $this->format(array($package => $constraint)));
+
+            return true;
+        }
+
+        $linksRegex = '#("'.$type.'":\s*\{)([^}]+)(\})#s';
+        if (!preg_match($linksRegex, $this->contents, $match)) {
+            return false;
+        }
+
+        $links = $match[2];
+        $packageRegex = str_replace('/', '\\\\?/', preg_quote($package));
+
+        // link exists already
+        if (preg_match('{"'.$packageRegex.'"\s*:}i', $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(
+                '#'.$match[1].'$#',
+                ',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $match[1],
+                $links
+            );
+        } else {
+            // links empty
+            $links = $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $links;
+        }
+
+        $this->contents = preg_replace($linksRegex, '$1'.$links.'$3', $this->contents);
+
+        return true;
+    }
+
+    public function addMainKey($key, $content)
+    {
+        if (preg_match('#[^{\s](\s*)\}$#', $this->contents, $match)) {
+            $this->contents = preg_replace(
+                '#'.$match[1].'\}$#',
+                ',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}',
+                $this->contents
+            );
+        } else {
+            $this->contents = preg_replace(
+                '#\}$#',
+                $this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}',
+                $this->contents
+            );
+        }
+    }
+
+    protected function format($data)
+    {
+        if (is_array($data)) {
+            reset($data);
+
+            if (is_numeric(key($data))) {
+                return '['.implode(', ', $data).']';
+            }
+
+            $out = '{' . $this->newline;
+            foreach ($data as $key => $val) {
+                $elems[] = $this->indent . $this->indent . JsonFile::encode($key). ': '.$this->format($val);
+            }
+            return $out . implode(','.$this->newline, $elems) . $this->newline . $this->indent . '}';
+        }
+
+        return JsonFile::encode($data);
+    }
+
+    protected function detectIndenting()
+    {
+        if (preg_match('{^(\s+)"}', $this->contents, $match)) {
+            $this->indent = $match[1];
+        } else {
+            $this->indent = '    ';
+        }
+    }
+}

+ 3 - 1
tests/Composer/Test/Fixtures/installer/SAMPLE

@@ -10,5 +10,7 @@
 <installed.json file definition>
 <installed.json file definition>
 --INSTALLED:DEV--
 --INSTALLED:DEV--
 <installed_dev.json file definition>
 <installed_dev.json file definition>
---EXPECT-- or --EXPECT:UPDATE-- or --EXPECT:DEV-- or --EXPECT:UPDATE:DEV--
+--RUN--
+install
+--EXPECT--
 <output (stringified operations)>
 <output (stringified operations)>

+ 2 - 0
tests/Composer/Test/Fixtures/installer/aliased-priority-conflicting.test

@@ -42,6 +42,8 @@ Aliases take precedence over default package even if default is selected
         "a/req": "dev-feature-foo as dev-master"
         "a/req": "dev-feature-foo as dev-master"
     }
     }
 }
 }
+--RUN--
+install
 --EXPECT--
 --EXPECT--
 Marking a/req (dev-master feat.f) as installed, alias of a/req (dev-feature-foo feat.f)
 Marking a/req (dev-master feat.f) as installed, alias of a/req (dev-feature-foo feat.f)
 Installing a/req (dev-feature-foo feat.f)
 Installing a/req (dev-feature-foo feat.f)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/aliased-priority.test

@@ -44,6 +44,8 @@ Aliases take precedence over default package
         "a/c": "dev-feature-foo as dev-master"
         "a/c": "dev-feature-foo as dev-master"
     }
     }
 }
 }
+--RUN--
+install
 --EXPECT--
 --EXPECT--
 Installing a/b (dev-master forked)
 Installing a/b (dev-master forked)
 Marking a/b (1.0.x-dev forked) as installed, alias of a/b (dev-master forked)
 Marking a/b (1.0.x-dev forked) as installed, alias of a/b (dev-master forked)

+ 3 - 1
tests/Composer/Test/Fixtures/installer/install-dev.test

@@ -18,6 +18,8 @@ Installs a package in dev env
         "a/b": "1.0.0"
         "a/b": "1.0.0"
     }
     }
 }
 }
---EXPECT:DEV--
+--RUN--
+install --dev
+--EXPECT--
 Installing a/a (1.0.0)
 Installing a/a (1.0.0)
 Installing a/b (1.0.0)
 Installing a/b (1.0.0)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/install-reference.test

@@ -17,5 +17,7 @@ Installs a dev package forcing it's reference
         "a/a": "dev-master#def000"
         "a/a": "dev-master#def000"
     }
     }
 }
 }
+--RUN--
+install
 --EXPECT--
 --EXPECT--
 Installing a/a (dev-master def000)
 Installing a/a (dev-master def000)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/install-simple.test

@@ -14,5 +14,7 @@ Installs a simple package with exact match requirement
         "a/a": "1.0.0"
         "a/a": "1.0.0"
     }
     }
 }
 }
+--RUN--
+install
 --EXPECT--
 --EXPECT--
 Installing a/a (1.0.0)
 Installing a/a (1.0.0)

+ 3 - 1
tests/Composer/Test/Fixtures/installer/update-all.test

@@ -36,6 +36,8 @@ Updates updateable packages
 [
 [
     { "name": "a/b", "version": "1.0.0" }
     { "name": "a/b", "version": "1.0.0" }
 ]
 ]
---EXPECT:UPDATE:DEV--
+--RUN--
+update --dev
+--EXPECT--
 Updating a/a (1.0.0) to a/a (1.0.1)
 Updating a/a (1.0.0) to a/a (1.0.1)
 Updating a/b (1.0.0) to a/b (2.0.0)
 Updating a/b (1.0.0) to a/b (2.0.0)

+ 2 - 0
tests/Composer/Test/Fixtures/installer/update-reference.test

@@ -24,5 +24,7 @@ Updates a dev package forcing it's reference
         "source": { "reference": "abc123", "url": "", "type": "git" }
         "source": { "reference": "abc123", "url": "", "type": "git" }
     }
     }
 ]
 ]
+--RUN--
+install
 --EXPECT--
 --EXPECT--
 Updating a/a (dev-master abc123) to a/a (dev-master def000)
 Updating a/a (dev-master abc123) to a/a (dev-master def000)

+ 40 - 0
tests/Composer/Test/Fixtures/installer/update-whitelist.test

@@ -0,0 +1,40 @@
+--TEST--
+Update with a package whitelist only updates those packages and their dependencies if they are not present in composer.json
+--COMPOSER--
+{
+    "repositories": [
+        {
+            "type": "package",
+            "package": [
+                { "name": "fixed", "version": "1.1.0" },
+                { "name": "fixed", "version": "1.0.0" },
+                { "name": "whitelisted", "version": "1.1.0", "require": { "dependency": "1.1.0" } },
+                { "name": "whitelisted", "version": "1.0.0", "require": { "dependency": "1.0.0" } },
+                { "name": "dependency", "version": "1.1.0" },
+                { "name": "dependency", "version": "1.0.0" },
+                { "name": "unrelated", "version": "1.1.0", "require": { "unrelated-dependency": "1.*" }  },
+                { "name": "unrelated", "version": "1.0.0", "require": { "unrelated-dependency": "1.*" }  },
+                { "name": "unrelated-dependency", "version": "1.1.0" },
+                { "name": "unrelated-dependency", "version": "1.0.0" }
+            ]
+        }
+    ],
+    "require": {
+        "fixed": "1.*",
+        "whitelisted": "1.*",
+        "unrelated": "1.*"
+    }
+}
+--INSTALLED--
+[
+    { "name": "fixed", "version": "1.0.0" },
+    { "name": "whitelisted", "version": "1.0.0", "require": { "dependency": "1.0.0" } },
+    { "name": "dependency", "version": "1.0.0" },
+    { "name": "unrelated", "version": "1.0.0", "require": { "unrelated-dependency": "1.*" } },
+    { "name": "unrelated-dependency", "version": "1.0.0" }
+]
+--RUN--
+update whitelisted
+--EXPECT--
+Updating dependency (1.0.0) to dependency (1.1.0)
+Updating whitelisted (1.0.0) to whitelisted (1.1.0)

+ 31 - 11
tests/Composer/Test/InstallerTest.php

@@ -12,6 +12,7 @@
 namespace Composer\Test;
 namespace Composer\Test;
 
 
 use Composer\Installer;
 use Composer\Installer;
+use Composer\Console\Application;
 use Composer\Config;
 use Composer\Config;
 use Composer\Json\JsonFile;
 use Composer\Json\JsonFile;
 use Composer\Repository\ArrayRepository;
 use Composer\Repository\ArrayRepository;
@@ -24,6 +25,8 @@ use Composer\Test\Mock\FactoryMock;
 use Composer\Test\Mock\InstalledFilesystemRepositoryMock;
 use Composer\Test\Mock\InstalledFilesystemRepositoryMock;
 use Composer\Test\Mock\InstallationManagerMock;
 use Composer\Test\Mock\InstallationManagerMock;
 use Composer\Test\Mock\WritableRepositoryMock;
 use Composer\Test\Mock\WritableRepositoryMock;
+use Symfony\Component\Console\Input\StringInput;
+use Symfony\Component\Console\Output\StreamOutput;
 
 
 class InstallerTest extends TestCase
 class InstallerTest extends TestCase
 {
 {
@@ -121,7 +124,7 @@ class InstallerTest extends TestCase
     /**
     /**
      * @dataProvider getIntegrationTests
      * @dataProvider getIntegrationTests
      */
      */
-    public function testIntegration($file, $message, $condition, $composer, $lock, $installed, $installedDev, $update, $dev, $expect)
+    public function testIntegration($file, $message, $condition, $composer, $lock, $installed, $installedDev, $run, $expect)
     {
     {
         if ($condition) {
         if ($condition) {
             eval('$res = '.$condition.';');
             eval('$res = '.$condition.';');
@@ -177,14 +180,31 @@ class InstallerTest extends TestCase
             $autoloadGenerator
             $autoloadGenerator
         );
         );
 
 
-        $installer->setDevMode($dev)->setUpdate($update);
+        $application = new Application;
+        $application->get('install')->setCode(function ($input, $output) use ($installer) {
+            $installer->setDevMode($input->getOption('dev'));
 
 
-        $result = $installer->run();
-        $this->assertTrue($result, $output);
+            return $installer->run();
+        });
 
 
-        $expectedInstalled   = isset($options['install']) ? $options['install'] : array();
-        $expectedUpdated     = isset($options['update']) ? $options['update'] : array();
-        $expectedUninstalled = isset($options['uninstall']) ? $options['uninstall'] : array();
+        $application->get('update')->setCode(function ($input, $output) use ($installer) {
+            $installer
+                ->setDevMode($input->getOption('dev'))
+                ->setUpdate(true)
+                ->setUpdateWhitelist($input->getArgument('packages'));
+
+            return $installer->run();
+        });
+
+        if (!preg_match('{^(install|update)\b}', $run)) {
+            throw new \UnexpectedValueException('The run command only supports install and update');
+        }
+
+        $application->setAutoExit(false);
+        $appOutput = fopen('php://memory', 'w+');
+        $result = $application->run(new StringInput($run), new StreamOutput($appOutput));
+        fseek($appOutput, 0);
+        $this->assertEquals(0, $result, $output . stream_get_contents($appOutput));
 
 
         $installationManager = $composer->getInstallationManager();
         $installationManager = $composer->getInstallationManager();
         $this->assertSame($expect, implode("\n", $installationManager->getTrace()));
         $this->assertSame($expect, implode("\n", $installationManager->getTrace()));
@@ -210,7 +230,8 @@ class InstallerTest extends TestCase
                 (?:--LOCK--\s*(?P<lock>'.$content.'))?\s*
                 (?:--LOCK--\s*(?P<lock>'.$content.'))?\s*
                 (?:--INSTALLED--\s*(?P<installed>'.$content.'))?\s*
                 (?:--INSTALLED--\s*(?P<installed>'.$content.'))?\s*
                 (?:--INSTALLED:DEV--\s*(?P<installedDev>'.$content.'))?\s*
                 (?:--INSTALLED:DEV--\s*(?P<installedDev>'.$content.'))?\s*
-                --EXPECT(?P<update>:UPDATE)?(?P<dev>:DEV)?--\s*(?P<expect>.*?)\s*
+                --RUN--\s*(?P<run>.*?)\s*
+                --EXPECT--\s*(?P<expect>.*?)\s*
             $}xs';
             $}xs';
 
 
             $installed = array();
             $installed = array();
@@ -231,8 +252,7 @@ class InstallerTest extends TestCase
                     if (!empty($match['installedDev'])) {
                     if (!empty($match['installedDev'])) {
                         $installedDev = JsonFile::parseJson($match['installedDev']);
                         $installedDev = JsonFile::parseJson($match['installedDev']);
                     }
                     }
-                    $update = !empty($match['update']);
-                    $dev = !empty($match['dev']);
+                    $run = $match['run'];
                     $expect = $match['expect'];
                     $expect = $match['expect'];
                 } catch (\Exception $e) {
                 } catch (\Exception $e) {
                     die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file)));
                     die(sprintf('Test "%s" is not valid: '.$e->getMessage(), str_replace($fixturesDir.'/', '', $file)));
@@ -241,7 +261,7 @@ class InstallerTest extends TestCase
                 die(sprintf('Test "%s" is not valid, did not match the expected format.', str_replace($fixturesDir.'/', '', $file)));
                 die(sprintf('Test "%s" is not valid, did not match the expected format.', str_replace($fixturesDir.'/', '', $file)));
             }
             }
 
 
-            $tests[] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $installedDev, $update, $dev, $expect);
+            $tests[] = array(str_replace($fixturesDir.'/', '', $file), $message, $condition, $composer, $lock, $installed, $installedDev, $run, $expect);
         }
         }
 
 
         return $tests;
         return $tests;

+ 134 - 0
tests/Composer/Test/Json/JsonManipulatorTest.php

@@ -0,0 +1,134 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Test\Json;
+
+use Composer\Json\JsonManipulator;
+
+class JsonManipulatorTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @dataProvider linkProvider
+     */
+    public function testAddLink($json, $type, $package, $constraint, $expected)
+    {
+        $manipulator = new JsonManipulator($json);
+        $this->assertTrue($manipulator->addLink($type, $package, $constraint));
+        $this->assertEquals($expected, $manipulator->getContents());
+    }
+
+    public function linkProvider()
+    {
+        return array(
+            array(
+                '{
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "foo": "bar"
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "foo": "bar",
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require": {
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require": {
+        "foo": "bar"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require": {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "baz"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+            array(
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor\/baz": "baz"
+    }
+}',
+                'require',
+                'vendor/baz',
+                'qux',
+                '{
+    "require":
+    {
+        "foo": "bar",
+        "vendor/baz": "qux"
+    }
+}
+'
+            ),
+        );
+    }
+}