diff --git a/bin/composer b/bin/composer index 401efb5b0..ed4979f7b 100755 --- a/bin/composer +++ b/bin/composer @@ -7,10 +7,19 @@ if (PHP_SAPI !== 'cli') { require __DIR__.'/../src/bootstrap.php'; +use Composer\Factory; +use Composer\XdebugHandler; use Composer\Console\Application; error_reporting(-1); +// Create output for XdebugHandler and Application +$output = Factory::createOutput(); + +$xdebug = new XdebugHandler($output); +$xdebug->check(); +unset($xdebug); + if (function_exists('ini_set')) { @ini_set('display_errors', 1); @@ -41,4 +50,4 @@ if (function_exists('ini_set')) { // run the command application $application = new Application(); -$application->run(); +$application->run(null, $output); diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index 252ee16b7..a2165ea76 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -18,8 +18,6 @@ use Symfony\Component\Console\Application as BaseApplication; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Output\ConsoleOutput; -use Symfony\Component\Console\Formatter\OutputFormatter; use Composer\Command; use Composer\Composer; use Composer\Factory; @@ -96,9 +94,7 @@ class Application extends BaseApplication public function run(InputInterface $input = null, OutputInterface $output = null) { if (null === $output) { - $styles = Factory::createAdditionalStyles(); - $formatter = new OutputFormatter(null, $styles); - $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, null, $formatter); + $output = Factory::createOutput(); } return parent::run($input, $output); diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 4bfae989a..21ac5ac08 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -28,7 +28,9 @@ use Composer\Util\Silencer; use Composer\Plugin\PluginEvents; use Composer\EventDispatcher\Event; use Seld\JsonLint\DuplicateKeyException; +use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Output\ConsoleOutput; use Composer\EventDispatcher\EventDispatcher; use Composer\Autoload\AutoloadGenerator; use Composer\Package\Version\VersionParser; @@ -225,6 +227,19 @@ class Factory ); } + /** + * Creates a ConsoleOutput instance + * + * @return ConsoleOutput + */ + public static function createOutput() + { + $styles = self::createAdditionalStyles(); + $formatter = new OutputFormatter(null, $styles); + + return new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, null, $formatter); + } + /** * @deprecated Use Composer\Repository\RepositoryFactory::defaultRepos instead */ diff --git a/src/Composer/XdebugHandler.php b/src/Composer/XdebugHandler.php new file mode 100644 index 000000000..cdd7e68ec --- /dev/null +++ b/src/Composer/XdebugHandler.php @@ -0,0 +1,306 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @author John Stevenson + */ +class XdebugHandler +{ + const ENV_ALLOW = 'COMPOSER_ALLOW_XDEBUG'; + + private $output; + private $loaded; + private $tmpIni; + private $scanDir; + + /** + * Constructor + */ + public function __construct(OutputInterface $output) + { + $this->output = $output; + $this->loaded = extension_loaded('xdebug'); + $tmp = sys_get_temp_dir(); + $this->tmpIni = $tmp.'/composer-php.ini'; + $this->scanDir = $tmp.'/composer-php-empty'; + } + + /** + * Checks if xdebug is loaded and composer needs to be restarted + * + * If so, then a tmp ini is created with the xdebug ini entry commented out. + * If additional inis have been loaded, these are combined into the tmp ini + * and PHP_INI_SCAN_DIR is set to an empty directory. An environment + * variable is set so that the new process is created only once. + */ + public function check() + { + if (!$this->needsRestart()) { + return; + } + + if ($this->prepareRestart($command)) { + $this->restart($command); + } + } + + /** + * Executes the restarted command + * + * @param string $command + */ + protected function restart($command) + { + passthru($command, $exitCode); + exit($exitCode); + } + + /** + * Returns true if a restart is needed + * + * @return bool + */ + private function needsRestart() + { + if (PHP_SAPI !== 'cli' || !defined('PHP_BINARY')) { + return false; + } + + return !getenv(self::ENV_ALLOW) && $this->loaded; + } + + /** + * Returns true if everything was written for the restart + * + * If any of the following fails (however unlikely) we must return false to + * stop potential recursion: + * - tmp ini file creation + * - environment variable creation + * - tmp scan dir creation + * + * @param null|string $command The command to run, set by method + * + * @return bool + */ + private function prepareRestart(&$command) + { + $iniFiles = array(); + if ($loadedIni = php_ini_loaded_file()) { + $iniFiles[] = $loadedIni; + } + + $additional = $this->getAdditionalInis($iniFiles, $replace); + if ($this->writeTmpIni($iniFiles, $replace)) { + $command = $this->getCommand($additional); + } + + return !empty($command) && putenv(self::ENV_ALLOW.'=1'); + } + + /** + * Writes the temporary ini file, or clears its name if no ini + * + * If there are no ini files, the tmp ini name is cleared so that + * an empty value is passed with the -c option. + * + * @param array $iniFiles The php.ini locations + * @param bool $replace Whether we need to modify the files + * + * @return bool False if the tmp ini could not be created + */ + private function writeTmpIni(array $iniFiles, $replace) + { + if (empty($iniFiles)) { + // Unlikely, maybe xdebug was loaded through the -d option. + $this->tmpIni = ''; + return true; + } + + $content = $this->getIniHeader($iniFiles); + foreach ($iniFiles as $file) { + $content .= $this->getIniData($file, $replace); + } + + return @file_put_contents($this->tmpIni, $content); + } + + /** + * Returns true if additional inis were loaded + * + * @param array $iniFiles Populated by method + * @param bool $replace Whether we need to modify the files + * + * @return bool + */ + private function getAdditionalInis(array &$iniFiles, &$replace) + { + $replace = true; + + if ($scanned = php_ini_scanned_files()) { + $list = explode(',', $scanned); + + foreach ($list as $file) { + $file = trim($file); + if (preg_match('/xdebug.ini$/', $file)) { + // Skip the file, no need for regex replacing + $replace = false; + } else { + $iniFiles[] = $file; + } + } + } + + return !empty($scanned); + } + + /** + * Returns formatted ini file data + * + * @param string $iniFile The location of the ini file + * @param bool $replace Whether to regex replace content + * + * @return string The ini data + */ + private function getIniData($iniFile, $replace) + { + $data = str_repeat(PHP_EOL, 3); + $data .= sprintf('; %s%s', $iniFile, PHP_EOL); + $contents = file_get_contents($iniFile); + + if ($replace) { + // Comment out xdebug config + $regex = '/^\s*(zend_extension\s*=.*xdebug.*)$/mi'; + $data .= preg_replace($regex, ';$1', $contents); + } else { + $data .= $contents; + } + + return $data; + } + + /** + * Creates the required environment and returns the restart command line + * + * @param bool $additional Whether additional inis were loaded + * + * @return string|null The command line or null on failure + */ + private function getCommand($additional) + { + if ($additional) { + if (!file_exists($this->scanDir) && !@mkdir($this->scanDir, 0777)) { + return; + } + if (!putenv('PHP_INI_SCAN_DIR='.$this->scanDir)) { + return; + } + } + + $phpArgs = array(PHP_BINARY, '-c', $this->tmpIni); + $params = array_merge($phpArgs, $this->getScriptArgs($_SERVER['argv'])); + + return implode(' ', array_map(array($this, 'escape'), $params)); + } + + /** + * Returns the restart script arguments, adding --ansi if required + * + * If we are a terminal with color support we must ensure that the --ansi + * option is set, because the restarted output is piped. + * + * @param array $args The argv array + * + * @return array + */ + private function getScriptArgs(array $args) + { + if (in_array('--no-ansi', $args) || in_array('--ansi', $args)) { + return $args; + } + + if ($this->output->isDecorated()) { + $offset = count($args) > 1 ? 2: 1; + array_splice($args, $offset, 0, '--ansi'); + } + + return $args; + } + + /** + * Escapes a string to be used as a shell argument. + * + * From https://github.com/johnstevenson/winbox-args + * MIT Licensed (c) John Stevenson + * + * @param string $arg The argument to be escaped + * @param bool $meta Additionally escape cmd.exe meta characters + * + * @return string The escaped argument + */ + private function escape($arg, $meta = true) + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + return escapeshellarg($arg); + } + + $quote = strpbrk($arg, " \t") !== false || $arg === ''; + $arg = preg_replace('/(\\\\*)"/', '$1$1\\"', $arg, -1, $dquotes); + + if ($meta) { + $meta = $dquotes || preg_match('/%[^%]+%/', $arg); + + if (!$meta && !$quote) { + $quote = strpbrk($arg, '^&|<>()') !== false; + } + } + + if ($quote) { + $arg = preg_replace('/(\\\\*)$/', '$1$1', $arg); + $arg = '"'.$arg.'"'; + } + + if ($meta) { + $arg = preg_replace('/(["^&|<>()%])/', '^$1', $arg); + } + + return $arg; + } + + /** + * Returns the location of the original ini data used. + * + * @param array $iniFiles loaded php.ini locations + * + * @return string + */ + private function getIniHeader($iniFiles) + { + $ini = implode(PHP_EOL.'; ', $iniFiles); + $header = << + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Mock; + +use Composer\Factory; +use Composer\XdebugHandler; + +class XdebugHandlerMock extends XdebugHandler +{ + public $restarted; + public $output; + + public function __construct($loaded = null) + { + $this->output = Factory::createOutput(); + parent::__construct($this->output); + + $loaded = null === $loaded ? true: $loaded; + $class = new \ReflectionClass(get_parent_class($this)); + $prop = $class->getProperty('loaded'); + $prop->setAccessible(true); + $prop->setValue($this, $loaded); + + $this->restarted = false; + } + + protected function restart($command) + { + $this->restarted = true; + } +} diff --git a/tests/Composer/Test/XdebugHandlerTest.php b/tests/Composer/Test/XdebugHandlerTest.php new file mode 100644 index 000000000..57222fa5a --- /dev/null +++ b/tests/Composer/Test/XdebugHandlerTest.php @@ -0,0 +1,70 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test; + +use Composer\Test\Mock\XdebugHandlerMock; + +/** + * @author John Stevenson + */ +class XdebugHandlerTest extends \PHPUnit_Framework_TestCase +{ + public static $envAllow; + + public function testRestartWhenLoaded() + { + $loaded = true; + + $xdebug = new XdebugHandlerMock($loaded); + $xdebug->check(); + $this->assertTrue($xdebug->restarted || !defined('PHP_BINARY')); + } + + public function testNoRestartWhenNotLoaded() + { + $loaded = false; + + $xdebug = new XdebugHandlerMock($loaded); + $xdebug->check(); + $this->assertFalse($xdebug->restarted); + } + + public function testNoRestartWhenLoadedAndAllowed() + { + $loaded = true; + putenv(XdebugHandlerMock::ENV_ALLOW.'=1'); + + $xdebug = new XdebugHandlerMock($loaded); + $xdebug->check(); + $this->assertFalse($xdebug->restarted); + } + + public static function setUpBeforeClass() + { + self::$envAllow = (bool) getenv(XdebugHandlerMock::ENV_ALLOW); + } + + public static function tearDownAfterClass() + { + if (self::$envAllow) { + putenv(XdebugHandlerMock::ENV_ALLOW.'=1'); + } else { + putenv(XdebugHandlerMock::ENV_ALLOW.'=0'); + } + } + + protected function setUp() + { + putenv(XdebugHandlerMock::ENV_ALLOW.'=0'); + } +}