diff --git a/doc/articles/scripts.md b/doc/articles/scripts.md index 14e5b5a63..92f9c5bdb 100644 --- a/doc/articles/scripts.md +++ b/doc/articles/scripts.md @@ -200,3 +200,18 @@ simply running `composer test`: > **Note:** Composer's bin-dir is pushed on top of the PATH so that binaries > of dependencies are easily accessible as CLI commands when writing scripts. + +Composer script can also called from other scripts, by prefixing the command name +by `@`. For example the following syntax is valid: + +```json +{ + "scripts": { + "test": [ + "@clearCache", + "phpunit" + ], + "clearCache": "rm -rf cache/*" + } +} +``` diff --git a/src/Composer/EventDispatcher/EventDispatcher.php b/src/Composer/EventDispatcher/EventDispatcher.php index 721e26a3f..46f22400c 100644 --- a/src/Composer/EventDispatcher/EventDispatcher.php +++ b/src/Composer/EventDispatcher/EventDispatcher.php @@ -45,6 +45,7 @@ class EventDispatcher protected $loader; protected $process; protected $listeners; + private $eventStack; /** * Constructor. @@ -58,6 +59,7 @@ class EventDispatcher $this->composer = $composer; $this->io = $io; $this->process = $process ?: new ProcessExecutor($io); + $this->eventStack = array(); } /** @@ -145,11 +147,21 @@ class EventDispatcher { $listeners = $this->getListeners($event); + $this->pushEvent($event); + $return = 0; foreach ($listeners as $callable) { if (!is_string($callable) && is_callable($callable)) { $event = $this->checkListenerExpectedEvent($callable, $event); $return = false === call_user_func($callable, $event) ? 1 : 0; + } elseif ($this->isComposerScript($callable)) { + if ($this->io->isVerbose()) { + $this->io->writeError(sprintf('> %s: %s', $event->getName(), $callable)); + } else { + $this->io->writeError(sprintf('> %s', $callable)); + } + $scriptName = substr($callable, 1); + $return = $this->dispatch($scriptName, new Script\Event($scriptName, $event->getComposer(), $event->getIO(), $event->isDevMode())); } elseif ($this->isPhpScript($callable)) { $className = substr($callable, 0, strpos($callable, '::')); $methodName = substr($callable, strpos($callable, '::') + 2); @@ -190,6 +202,8 @@ class EventDispatcher } } + $this->popEvent(); + return $return; } @@ -362,4 +376,42 @@ class EventDispatcher { 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); + } + + /** + * 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("Recursive call to '%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); + } } diff --git a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php index 925772d42..afe4715d5 100644 --- a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php +++ b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php @@ -142,6 +142,97 @@ class EventDispatcherTest extends TestCase $dispatcher->dispatchScript(ScriptEvents::POST_INSTALL_CMD, false); } + public function testDispatcherCanExecuteComposerScriptGroups() + { + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\EventDispatcher\EventDispatcher') + ->setConstructorArgs(array( + $composer = $this->getMock('Composer\Composer'), + $io = $this->getMock('Composer\IO\IOInterface'), + $process, + )) + ->setMethods(array( + 'getListeners', + )) + ->getMock(); + + $process->expects($this->exactly(3)) + ->method('execute') + ->will($this->returnValue(0)); + + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnCallback(function (Event $event) { + if ($event->getName() === 'root') { + return array('@group'); + } elseif ($event->getName() === 'group') { + return array('echo -n foo', '@subgroup', 'echo -n bar'); + } elseif ($event->getName() === 'subgroup') { + return array('echo -n baz'); + } + + return array(); + })); + + $io->expects($this->any()) + ->method('isVerbose') + ->willReturn(1); + + $io->expects($this->at(1)) + ->method('writeError') + ->with($this->equalTo('> root: @group')); + + $io->expects($this->at(3)) + ->method('writeError') + ->with($this->equalTo('> group: echo -n foo')); + + $io->expects($this->at(5)) + ->method('writeError') + ->with($this->equalTo('> group: @subgroup')); + + $io->expects($this->at(7)) + ->method('writeError') + ->with($this->equalTo('> subgroup: echo -n baz')); + + $io->expects($this->at(9)) + ->method('writeError') + ->with($this->equalTo('> group: echo -n bar')); + + $dispatcher->dispatch('root', new CommandEvent('root', $composer, $io)); + } + + /** + * @expectedException RuntimeException + */ + public function testDispatcherDetectInfiniteRecursion() + { + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\EventDispatcher\EventDispatcher') + ->setConstructorArgs(array( + $composer = $this->getMock('Composer\Composer'), + $io = $this->getMock('Composer\IO\IOInterface'), + $process, + )) + ->setMethods(array( + 'getListeners', + )) + ->getMock(); + + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnCallback(function (Event $event) { + if ($event->getName() === 'root') { + return array('@recurse'); + } elseif ($event->getName() === 'recurse') { + return array('@root'); + } + + return array(); + })); + + $dispatcher->dispatch('root', new CommandEvent('root', $composer, $io)); + } + private function getDispatcherStubForListenersTest($listeners, $io) { $dispatcher = $this->getMockBuilder('Composer\EventDispatcher\EventDispatcher')