From a25492d1b9890b39f2b33de0d1c05bd7f36c0f50 Mon Sep 17 00:00:00 2001 From: Giorgio Premi Date: Mon, 9 Nov 2015 11:35:48 +0100 Subject: [PATCH 1/2] Allow composer script call with @ syntax --- doc/articles/scripts.md | 15 +++++ .../EventDispatcher/EventDispatcher.php | 19 ++++++ .../EventDispatcher/EventDispatcherTest.php | 59 +++++++++++++++++++ 3 files changed, 93 insertions(+) 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..cfab0e8a9 100644 --- a/src/Composer/EventDispatcher/EventDispatcher.php +++ b/src/Composer/EventDispatcher/EventDispatcher.php @@ -150,6 +150,14 @@ class EventDispatcher 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); @@ -362,4 +370,15 @@ 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); + } } diff --git a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php index 925772d42..0fde8f48b 100644 --- a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php +++ b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php @@ -142,6 +142,65 @@ 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)); + } + private function getDispatcherStubForListenersTest($listeners, $io) { $dispatcher = $this->getMockBuilder('Composer\EventDispatcher\EventDispatcher') From fd0026b5425bfc21ffa3903c1b1f276c5c533236 Mon Sep 17 00:00:00 2001 From: Giorgio Premi Date: Mon, 9 Nov 2015 13:05:16 +0100 Subject: [PATCH 2/2] Detect infinite script call recursion --- .../EventDispatcher/EventDispatcher.php | 33 +++++++++++++++++++ .../EventDispatcher/EventDispatcherTest.php | 32 ++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/Composer/EventDispatcher/EventDispatcher.php b/src/Composer/EventDispatcher/EventDispatcher.php index cfab0e8a9..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,6 +147,8 @@ class EventDispatcher { $listeners = $this->getListeners($event); + $this->pushEvent($event); + $return = 0; foreach ($listeners as $callable) { if (!is_string($callable) && is_callable($callable)) { @@ -198,6 +202,8 @@ class EventDispatcher } } + $this->popEvent(); + return $return; } @@ -381,4 +387,31 @@ class EventDispatcher { 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 0fde8f48b..afe4715d5 100644 --- a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php +++ b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php @@ -201,6 +201,38 @@ class EventDispatcherTest extends TestCase $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')