<?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\EventDispatcher;
use Composer\DependencyResolver\PolicyInterface;
use Composer\DependencyResolver\Pool;
use Composer\DependencyResolver\Request;
use Composer\Installer\InstallerEvent;
use Composer\IO\IOInterface;
use Composer\Composer;
use Composer\DependencyResolver\Operation\OperationInterface;
use Composer\Repository\CompositeRepository;
use Composer\Script;
use Composer\Installer\PackageEvent;
use Composer\Installer\BinaryInstaller;
use Composer\Util\ProcessExecutor;
use Composer\Script\Event as ScriptEvent;
use Symfony\Component\Process\PhpExecutableFinder;
/**
* The Event Dispatcher.
*
* Example in command:
* $dispatcher = new EventDispatcher($this->getComposer(), $this->getApplication()->getIO());
* // ...
* $dispatcher->dispatch(ScriptEvents::POST_INSTALL_CMD);
* // ...
*
* @author François Pluchino <francois.pluchino@opendisplay.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @author Nils Adermann <naderman@naderman.de>
*/
class EventDispatcher
{
protected $composer;
protected $io;
protected $loader;
protected $process;
protected $listeners;
private $eventStack;
/**
* Constructor.
*
* @param Composer $composer The composer instance
* @param IOInterface $io The IOInterface instance
* @param ProcessExecutor $process
*/
public function __construct(Composer $composer, IOInterface $io, ProcessExecutor $process = null)
{
$this->composer = $composer;
$this->io = $io;
$this->process = $process ?: new ProcessExecutor($io);
$this->eventStack = array();
}
/**
* Dispatch an event
*
* @param string $eventName An event name
* @param Event $event
* @return int return code of the executed script if any, for php scripts a false return
* value is changed to 1, anything else to 0
*/
public function dispatch($eventName, Event $event = null)
{
if (null === $event) {
$event = new Event($eventName);
}
return $this->doDispatch($event);
}
/**
* Dispatch a script event.
*
* @param string $eventName The constant in ScriptEvents
* @param bool $devMode
* @param array $additionalArgs Arguments passed by the user
* @param array $flags Optional flags to pass data not as argument
* @return int return code of the executed script if any, for php scripts a false return
* value is changed to 1, anything else to 0
*/
public function dispatchScript($eventName, $devMode = false, $additionalArgs = array(), $flags = array())
{
return $this->doDispatch(new Script\Event($eventName, $this->composer, $this->io, $devMode, $additionalArgs, $flags));
}
/**
* Dispatch a package event.
*
* @param string $eventName The constant in PackageEvents
* @param bool $devMode Whether or not we are in dev mode
* @param PolicyInterface $policy The policy
* @param Pool $pool The pool
* @param CompositeRepository $installedRepo The installed repository
* @param Request $request The request
* @param array $operations The list of operations
* @param OperationInterface $operation The package being installed/updated/removed
*
* @return int return code of the executed script if any, for php scripts a false return
* value is changed to 1, anything else to 0
*/
public function dispatchPackageEvent($eventName, $devMode, PolicyInterface $policy, Pool $pool, CompositeRepository $installedRepo, Request $request, array $operations, OperationInterface $operation)
{
return $this->doDispatch(new PackageEvent($eventName, $this->composer, $this->io, $devMode, $policy, $pool, $installedRepo, $request, $operations, $operation));
}
/**
* Dispatch a installer event.
*
* @param string $eventName The constant in InstallerEvents
* @param bool $devMode Whether or not we are in dev mode
* @param PolicyInterface $policy The policy
* @param Pool $pool The pool
* @param CompositeRepository $installedRepo The installed repository
* @param Request $request The request
* @param array $operations The list of operations
*
* @return int return code of the executed script if any, for php scripts a false return
* value is changed to 1, anything else to 0
*/
public function dispatchInstallerEvent($eventName, $devMode, PolicyInterface $policy, Pool $pool, CompositeRepository $installedRepo, Request $request, array $operations = array())
{
return $this->doDispatch(new InstallerEvent($eventName, $this->composer, $this->io, $devMode, $policy, $pool, $installedRepo, $request, $operations));
}
/**
* Triggers the listeners of an event.
*
* @param Event $event The event object to pass to the event handlers/listeners.
* @throws \RuntimeException|\Exception
* @return int return code of the executed script if any, for php scripts a false return
* value is changed to 1, anything else to 0
*/
protected function doDispatch(Event $event)
{
$listeners = $this->getListeners($event);
$this->pushEvent($event);
$return = 0;
foreach ($listeners as $callable) {
$this->ensureBinDirIsInPath();
if (!is_string($callable)) {
if (!is_callable($callable)) {
$className = is_object($callable[0]) ? get_class($callable[0]) : $callable[0];
throw new \RuntimeException('Subscriber '.$className.'::'.$callable[1].' for event '.$event->getName().' is not callable, make sure the function is defined and public');
}
$event = $this->checkListenerExpectedEvent($callable, $event);
$return = false === call_user_func($callable, $event) ? 1 : 0;
} elseif ($this->isComposerScript($callable)) {
$this->io->writeError(sprintf('> %s: %s', $event->getName(), $callable), true, IOInterface::VERBOSE);
$script = explode(' ', substr($callable, 1));
$scriptName = $script[0];
unset($script[0]);
$args = array_merge($script, $event->getArguments());
$flags = $event->getFlags();
if (substr($callable, 0, 10) === '@composer ') {
$exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(getenv('COMPOSER_BINARY')) . substr($callable, 9);
if (0 !== ($exitCode = $this->process->execute($exec))) {
$this->io->writeError(sprintf('<error>Script %s handling the %s event returned with error code '.$exitCode.'</error>', $callable, $event->getName()), true, IOInterface::QUIET);
throw new ScriptExecutionException('Error Output: '.$this->process->getErrorOutput(), $exitCode);
}
} else {
if (!$this->getListeners(new Event($scriptName))) {
$this->io->writeError(sprintf('<warning>You made a reference to a non-existent script %s</warning>', $callable), true, IOInterface::QUIET);
}
try {
$scriptEvent = new Script\Event($scriptName, $event->getComposer(), $event->getIO(), $event->isDevMode(), $args, $flags);
$scriptEvent->setOriginatingEvent($event);
$return = $this->dispatch($scriptName, $scriptEvent);
} catch (ScriptExecutionException $e) {
$this->io->writeError(sprintf('<error>Script %s was called via %s</error>', $callable, $event->getName()), true, IOInterface::QUIET);
throw $e;
}
}
} elseif ($this->isPhpScript($callable)) {
$className = substr($callable, 0, strpos($callable, '::'));
$methodName = substr($callable, strpos($callable, '::') + 2);
if (!class_exists($className)) {
$this->io->writeError('<warning>Class '.$className.' is not autoloadable, can not call '.$event->getName().' script</warning>', true, IOInterface::QUIET);
continue;
}
if (!is_callable($callable)) {
$this->io->writeError('<warning>Method '.$callable.' is not callable, can not call '.$event->getName().' script</warning>', true, IOInterface::QUIET);
continue;
}
try {
$return = false === $this->executeEventPhpScript($className, $methodName, $event) ? 1 : 0;
} catch (\Exception $e) {
$message = "Script %s handling the %s event terminated with an exception";
$this->io->writeError('<error>'.sprintf($message, $callable, $event->getName()).'</error>', true, IOInterface::QUIET);
throw $e;
}
} else {
$args = implode(' ', array_map(array('Composer\Util\ProcessExecutor', 'escape'), $event->getArguments()));
$exec = $callable . ($args === '' ? '' : ' '.$args);
if ($this->io->isVerbose()) {
$this->io->writeError(sprintf('> %s: %s', $event->getName(), $exec));
} else {
$this->io->writeError(sprintf('> %s', $exec));
}
$possibleLocalBinaries = $this->composer->getPackage()->getBinaries();
if ($possibleLocalBinaries) {
foreach ($possibleLocalBinaries as $localExec) {
if (preg_match('{\b'.preg_quote($callable).'$}', $localExec)) {
$caller = BinaryInstaller::determineBinaryCaller($localExec);
$exec = preg_replace('{^'.preg_quote($callable).'}', $caller . ' ' . $localExec, $exec);
break;
}
}
}
if (substr($exec, 0, 8) === '@putenv ') {
putenv(substr($exec, 8));
continue;
} elseif (substr($exec, 0, 5) === '@php ') {
$exec = $this->getPhpExecCommand() . ' ' . substr($exec, 5);
} else {
$finder = new PhpExecutableFinder();
$phpPath = $finder->find(false);
if ($phpPath) {
$_SERVER['PHP_BINARY'] = $phpPath;
putenv('PHP_BINARY=' . $_SERVER['PHP_BINARY']);
}
}
// if composer is being executed, make sure it runs the expected composer from current path
// resolution, even if bin-dir contains composer too because the project requires composer/composer
// see https://github.com/composer/composer/issues/8748
if (substr($exec, 0, 9) === 'composer ') {
$exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(getenv('COMPOSER_BINARY')) . substr($exec, 8);
}
if (0 !== ($exitCode = $this->process->execute($exec))) {
$this->io->writeError(sprintf('<error>Script %s handling the %s event returned with error code '.$exitCode.'</error>', $callable, $event->getName()), true, IOInterface::QUIET);
throw new ScriptExecutionException('Error Output: '.$this->process->getErrorOutput(), $exitCode);
}
}
if ($event->isPropagationStopped()) {
break;
}
}
$this->popEvent();
return $return;
}
protected function getPhpExecCommand()
{
$finder = new PhpExecutableFinder();
$phpPath = $finder->find(false);
if (!$phpPath) {
throw new \RuntimeException('Failed to locate PHP binary to execute '.$phpPath);
}
$phpArgs = $finder->findArguments();
$phpArgs = $phpArgs ? ' ' . implode(' ', $phpArgs) : '';
$allowUrlFOpenFlag = ' -d allow_url_fopen=' . ProcessExecutor::escape(ini_get('allow_url_fopen'));
$disableFunctionsFlag = ' -d disable_functions=' . ProcessExecutor::escape(ini_get('disable_functions'));
$memoryLimitFlag = ' -d memory_limit=' . ProcessExecutor::escape(ini_get('memory_limit'));
return ProcessExecutor::escape($phpPath) . $phpArgs . $allowUrlFOpenFlag . $disableFunctionsFlag . $memoryLimitFlag;
}
/**
* @param string $className
* @param string $methodName
* @param Event $event Event invoking the PHP callable
*/
protected function executeEventPhpScript($className, $methodName, Event $event)
{
$event = $this->checkListenerExpectedEvent(array($className, $methodName), $event);
if ($this->io->isVerbose()) {
$this->io->writeError(sprintf('> %s: %s::%s', $event->getName(), $className, $methodName));
} else {
$this->io->writeError(sprintf('> %s::%s', $className, $methodName));
}
return $className::$methodName($event);
}
/**
* @param mixed $target
* @param Event $event
* @return Event
*/
protected function checkListenerExpectedEvent($target, Event $event)
{
if (in_array($event->getName(), array(
'init',
'command',
'pre-file-download',
), true)) {
return $event;
}
try {
$reflected = new \ReflectionParameter($target, 0);
} catch (\Exception $e) {
return $event;
}
$expected = null;
$isClass = false;
if (\PHP_VERSION_ID >= 70000) {
$reflectionType = $reflected->getType();
if ($reflectionType) {
$expected = $reflectionType instanceof \ReflectionNamedType ? $reflectionType->getName() : (string)$reflectionType;
$isClass = !$reflectionType->isBuiltin();
}
} else {
$expected = $reflected->getClass() ? $reflected->getClass()->getName() : null;
$isClass = null !== $expected;
}
if (!$isClass) {
return $event;
}
// BC support
if (!$event instanceof $expected && $expected === 'Composer\Script\CommandEvent') {
trigger_error('The callback '.$this->serializeCallback($target).' declared at '.$reflected->getDeclaringFunction()->getFileName().' accepts a '.$expected.' but '.$event->getName().' events use a '.get_class($event).' instance. Please adjust your type hint accordingly, see https://getcomposer.org/doc/articles/scripts.md#event-classes', E_USER_DEPRECATED);
$event = new \Composer\Script\CommandEvent(
$event->getName(),
$event->getComposer(),
$event->getIO(),
$event->isDevMode(),
$event->getArguments()
);
}
if (!$event instanceof $expected && $expected === 'Composer\Script\PackageEvent') {
trigger_error('The callback '.$this->serializeCallback($target).' declared at '.$reflected->getDeclaringFunction()->getFileName().' accepts a '.$expected.' but '.$event->getName().' events use a '.get_class($event).' instance. Please adjust your type hint accordingly, see https://getcomposer.org/doc/articles/scripts.md#event-classes', E_USER_DEPRECATED);
$event = new \Composer\Script\PackageEvent(
$event->getName(),
$event->getComposer(),
$event->getIO(),
$event->isDevMode(),
$event->getPolicy(),
$event->getPool(),
$event->getInstalledRepo(),
$event->getRequest(),
$event->getOperations(),
$event->getOperation()
);
}
if (!$event instanceof $expected && $expected === 'Composer\Script\Event') {
trigger_error('The callback '.$this->serializeCallback($target).' declared at '.$reflected->getDeclaringFunction()->getFileName().' accepts a '.$expected.' but '.$event->getName().' events use a '.get_class($event).' instance. Please adjust your type hint accordingly, see https://getcomposer.org/doc/articles/scripts.md#event-classes', E_USER_DEPRECATED);
$event = new \Composer\Script\Event(
$event->getName(),
$event->getComposer(),
$event->getIO(),
$event->isDevMode(),
$event->getArguments(),
$event->getFlags()
);
}
return $event;
}
private function serializeCallback($cb)
{
if (is_array($cb) && count($cb) === 2) {
if (is_object($cb[0])) {
$cb[0] = get_class($cb[0]);
}
if (is_string($cb[0]) && is_string($cb[1])) {
$cb = implode('::', $cb);
}
}
if (is_string($cb)) {
return $cb;
}
return var_export($cb, true);
}
/**
* Add a listener for a particular event
*
* @param string $eventName The event name - typically a constant
* @param callable $listener A callable expecting an event argument
* @param int $priority A higher value represents a higher priority
*/
public function addListener($eventName, $listener, $priority = 0)
{
$this->listeners[$eventName][$priority][] = $listener;
}
/**
* Adds object methods as listeners for the events in getSubscribedEvents
*
* @see EventSubscriberInterface
*
* @param EventSubscriberInterface $subscriber
*/
public function addSubscriber(EventSubscriberInterface $subscriber)
{
foreach ($subscriber->getSubscribedEvents() as $eventName => $params) {
if (is_string($params)) {
$this->addListener($eventName, array($subscriber, $params));
} elseif (is_string($params[0])) {
$this->addListener($eventName, array($subscriber, $params[0]), isset($params[1]) ? $params[1] : 0);
} else {
foreach ($params as $listener) {
$this->addListener($eventName, array($subscriber, $listener[0]), isset($listener[1]) ? $listener[1] : 0);
}
}
}
}
/**
* Retrieves all listeners for a given event
*
* @param Event $event
* @return array All listeners: callables and scripts
*/
protected function getListeners(Event $event)
{
$scriptListeners = $this->getScriptListeners($event);
if (!isset($this->listeners[$event->getName()][0])) {
$this->listeners[$event->getName()][0] = array();
}
krsort($this->listeners[$event->getName()]);
$listeners = $this->listeners;
$listeners[$event->getName()][0] = array_merge($listeners[$event->getName()][0], $scriptListeners);
return call_user_func_array('array_merge', $listeners[$event->getName()]);
}
/**
* Checks if an event has listeners registered
*
* @param Event $event
* @return bool
*/
public function hasEventListeners(Event $event)
{
$listeners = $this->getListeners($event);
return count($listeners) > 0;
}
/**
* Finds all listeners defined as scripts in the package
*
* @param Event $event Event object
* @return array Listeners
*/
protected function getScriptListeners(Event $event)
{
$package = $this->composer->getPackage();
$scripts = $package->getScripts();
if (empty($scripts[$event->getName()])) {
return array();
}
if ($this->loader) {
$this->loader->unregister();
}
$generator = $this->composer->getAutoloadGenerator();
if ($event instanceof ScriptEvent) {
$generator->setDevMode($event->isDevMode());
}
$packages = $this->composer->getRepositoryManager()->getLocalRepository()->getCanonicalPackages();
$packageMap = $generator->buildPackageMap($this->composer->getInstallationManager(), $package, $packages);
$map = $generator->parseAutoloads($packageMap, $package);
$this->loader = $generator->createLoader($map);
$this->loader->register();
return $scripts[$event->getName()];
}
/**
* Checks if string given references a class path and method
*
* @param string $callable
* @return bool
*/
protected function isPhpScript($callable)
{
return false === strpos($callable, ' ') && false !== strpos($callable, '::');
}
/**
* Checks if string given references a composer run-script
*
* @param string $callable
* @return bool
*/
protected function isComposerScript($callable)
{
return '@' === substr($callable, 0, 1) && '@php ' !== substr($callable, 0, 5) && '@putenv ' !== substr($callable, 0, 8);
}
/**
* Push an event to the stack of active event
*
* @param Event $event
* @throws \RuntimeException
* @return number
*/
protected function pushEvent(Event $event)
{
$eventName = $event->getName();
if (in_array($eventName, $this->eventStack)) {
throw new \RuntimeException(sprintf("Circular call to script handler '%s' detected", $eventName));
}
return array_push($this->eventStack, $eventName);
}
/**
* Pops the active event from the stack
*
* @return mixed
*/
protected function popEvent()
{
return array_pop($this->eventStack);
}
private function ensureBinDirIsInPath()
{
$pathStr = 'PATH';
if (!isset($_SERVER[$pathStr]) && isset($_SERVER['Path'])) {
$pathStr = 'Path';
}
// add the bin dir to the PATH to make local binaries of deps usable in scripts
$binDir = $this->composer->getConfig()->get('bin-dir');
if (is_dir($binDir)) {
$binDir = realpath($binDir);
if (isset($_SERVER[$pathStr]) && !preg_match('{(^|'.PATH_SEPARATOR.')'.preg_quote($binDir).'($|'.PATH_SEPARATOR.')}', $_SERVER[$pathStr])) {
$_SERVER[$pathStr] = $binDir.PATH_SEPARATOR.getenv($pathStr);
putenv($pathStr.'='.$_SERVER[$pathStr]);
}
}
}
}