diff --git a/doc/articles/scripts.md b/doc/articles/scripts.md index 3efaccc32..359020cd5 100644 --- a/doc/articles/scripts.md +++ b/doc/articles/scripts.md @@ -6,19 +6,24 @@ ## What is a script? -A script is a callback (defined as a static method) that will be called -when the event it listens on is triggered. +A script, in Composer's terms, can either be a PHP callback (defined as a +static method) or any command-line executable command. Scripts are useful +for executing a package's custom code or package-specific commands during +the Composer execution process. -**Scripts are only executed on the root package, not on the dependencies -that are installed.** +**NOTE: Only scripts defined in the root package's `composer.json` are +executed. If a dependency of the root package specifies its own scripts, +Composer does not execute those additional scripts.** -## Event types +## Event names -- **pre-install-cmd**: occurs before the install command is executed. -- **post-install-cmd**: occurs after the install command is executed. -- **pre-update-cmd**: occurs before the update command is executed. -- **post-update-cmd**: occurs after the update command is executed. +Composer fires the following named events during its execution process: + +- **pre-install-cmd**: occurs before the `install` command is executed. +- **post-install-cmd**: occurs after the `install` command is executed. +- **pre-update-cmd**: occurs before the `update` command is executed. +- **post-update-cmd**: occurs after the `update` command is executed. - **pre-package-install**: occurs before a package is installed. - **post-package-install**: occurs after a package is installed. - **pre-package-update**: occurs before a package is updated. @@ -29,12 +34,18 @@ that are installed.** ## Defining scripts -Scripts are defined by adding the `scripts` key to a project's `composer.json`. +The root JSON object in `composer.json` should have a member called `"scripts"`, +which contains pairs of named events and each event's corresponding +scripts. An event's scripts can be defined as either as a string (only for +a single script) or an array (for single or multiple scripts.) -They are specified as an array of classes and static method names. +For any given event: -The classes used as scripts must be autoloadable via Composer's autoload -functionality. +- Scripts execute in the order defined when their corresponding event is fired. +- An array of scripts wired to a single event can contain both PHP callbacks +and command-line executables commands. +- PHP classes containing defined callbacks must be autoloadable via Composer's +autoload functionality. Script definition example: @@ -44,14 +55,15 @@ Script definition example: "post-package-install": [ "MyVendor\\MyClass::postPackageInstall" ] + "post-install-cmd": [ + "MyVendor\\MyClass::warmCache", + "phpunit -c app/" + ] } } -The event handler receives a `Composer\Script\Event` object as an argument, -which gives you access to the `Composer\Composer` instance through the -`getComposer` method. - -Using the previous example, here's an event listener example : +Using the previous definition example, here's the class `MyVendor\MyClass` +that might be used to execute the PHP callbacks: getOperation()->getPackage(); // do stuff } + + public static function warmCache(Event $event) + { + // make cache toasty + } } + +When an event is fired, Composer's internal event handler receives a +`Composer\Script\Event` object, which is passed as the first argument to your +PHP callback. This `Event` object has getters for other contextual objects: + +- `getComposer()`: returns the current instance of `Composer\Composer` +- `getName()`: returns the name of the event being fired as a string +- `getIO()`: returns the current input/output stream which implements +`Composer\IO\IOInterface` for writing to the console diff --git a/src/Composer/Script/EventDispatcher.php b/src/Composer/Script/EventDispatcher.php index b4537ed62..31589bf7b 100644 --- a/src/Composer/Script/EventDispatcher.php +++ b/src/Composer/Script/EventDispatcher.php @@ -16,6 +16,7 @@ use Composer\Autoload\AutoloadGenerator; use Composer\IO\IOInterface; use Composer\Composer; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\Util\ProcessExecutor; /** * The Event Dispatcher. @@ -34,6 +35,7 @@ class EventDispatcher protected $composer; protected $io; protected $loader; + protected $process; /** * Constructor. @@ -41,10 +43,11 @@ class EventDispatcher * @param Composer $composer The composer instance * @param IOInterface $io The IOInterface instance */ - public function __construct(Composer $composer, IOInterface $io) + public function __construct(Composer $composer, IOInterface $io, ProcessExecutor $process = null) { $this->composer = $composer; $this->io = $io; + $this->process = $process ?: new ProcessExecutor(); } /** @@ -78,28 +81,51 @@ class EventDispatcher $listeners = $this->getListeners($event); foreach ($listeners as $callable) { - $className = substr($callable, 0, strpos($callable, '::')); - $methodName = substr($callable, strpos($callable, '::') + 2); - - if (!class_exists($className)) { - $this->io->write('Class '.$className.' is not autoloadable, can not call '.$event->getName().' script'); - continue; - } - if (!is_callable($callable)) { - $this->io->write('Method '.$callable.' is not callable, can not call '.$event->getName().' script'); - continue; - } - - try { - $className::$methodName($event); - } catch (\Exception $e) { - $message = "Script %s handling the %s event terminated with an exception"; - $this->io->write(''.sprintf($message, $callable, $event->getName()).''); - throw $e; + if ($this->isPhpScript($callable)) { + $className = substr($callable, 0, strpos($callable, '::')); + $methodName = substr($callable, strpos($callable, '::') + 2); + + if (!class_exists($className)) { + $this->io->write('Class '.$className.' is not autoloadable, can not call '.$event->getName().' script'); + continue; + } + if (!is_callable($callable)) { + $this->io->write('Method '.$callable.' is not callable, can not call '.$event->getName().' script'); + continue; + } + + try { + $this->executeEventPhpScript($className, $methodName, $event); + } catch (\Exception $e) { + $message = "Script %s handling the %s event terminated with an exception"; + $this->io->write(''.sprintf($message, $callable, $event->getName()).''); + throw $e; + } + } else { + $callback = function ($type, $buffer) use ($event, $callable) { + $io = $event->getIO(); + if ('err' === $type) { + $message = 'Script %s handling the %s event returned an error: %s'; + $io->write(sprintf(''.$message.'', $callable, $event->getName(), $buffer)); + } else { + $io->write($buffer, false); + } + }; + $this->process->execute($callable, $callback); } } } + /** + * @param string $className + * @param string $methodName + * @param Event $event Event invoking the PHP callable + */ + protected function executeEventPhpScript($className, $methodName, Event $event) + { + $className::$methodName($event); + } + /** * @param Event $event Event object * @return array Listeners @@ -126,4 +152,15 @@ class EventDispatcher return $scripts[$event->getName()]; } + + /** + * Checks if string given references a class path and method + * + * @param string $callable + * @return boolean + */ + protected function isPhpScript($callable) + { + return false === strpos($callable, ' ') && false !== strpos($callable, '::'); + } } diff --git a/tests/Composer/Test/Script/EventDispatcherTest.php b/tests/Composer/Test/Script/EventDispatcherTest.php index e23dccf8a..15a7ff172 100644 --- a/tests/Composer/Test/Script/EventDispatcherTest.php +++ b/tests/Composer/Test/Script/EventDispatcherTest.php @@ -35,6 +35,69 @@ class EventDispatcherTest extends TestCase $dispatcher->dispatchCommandEvent("post-install-cmd"); } + /** + * @dataProvider getValidCommands + * @param string $command + */ + public function testDispatcherCanExecuteSingleCommandLineScript($command) + { + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') + ->setConstructorArgs(array( + $this->getMock('Composer\Composer'), + $this->getMock('Composer\IO\IOInterface'), + $process, + )) + ->setMethods(array('getListeners')) + ->getMock(); + + $listener = array($command); + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnValue($listener)); + + $process->expects($this->once()) + ->method('execute') + ->with($command); + + $dispatcher->dispatchCommandEvent("post-install-cmd"); + } + + public function testDispatcherCanExecuteCliAndPhpInSameEventScriptStack() + { + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') + ->setConstructorArgs(array( + $this->getMock('Composer\Composer'), + $this->getMock('Composer\IO\IOInterface'), + $process, + )) + ->setMethods(array( + 'getListeners', + 'executeEventPhpScript', + )) + ->getMock(); + + $process->expects($this->exactly(2)) + ->method('execute'); + + $listeners = array( + 'echo -n foo', + 'Composer\\Test\\Script\\EventDispatcherTest::someMethod', + 'echo -n bar', + ); + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnValue($listeners)); + + $dispatcher->expects($this->once()) + ->method('executeEventPhpScript') + ->with('Composer\Test\Script\EventDispatcherTest', 'someMethod') + ->will($this->returnValue(true)); + + $dispatcher->dispatchCommandEvent("post-install-cmd"); + } + private function getDispatcherStubForListenersTest($listeners, $io) { $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') @@ -52,8 +115,22 @@ class EventDispatcherTest extends TestCase return $dispatcher; } + public function getValidCommands() + { + return array( + array('phpunit'), + array('echo foo'), + array('echo -n foo'), + ); + } + public static function call() { throw new \RuntimeException(); } + + public static function someMethod() + { + return true; + } }