diff --git a/doc/articles/scripts.md b/doc/articles/scripts.md index 26b8113ab..5ed3bf6c3 100644 --- a/doc/articles/scripts.md +++ b/doc/articles/scripts.md @@ -410,6 +410,38 @@ JSON array of commands. You can also call a shell/bash script, which will have the path to the PHP executable available in it as a `PHP_BINARY` env var. +## Controlling additional arguments + +When running scripts like `composer script-name arg arg2` or `composer script-name -- --option`, +Composer will by default append `arg`, `arg2` and `--option` to the script's command. + +If you do not want these args in a given command, you can put `@no_additional_args` +anywhere in it, that will remove the default behavior and that flag will be removed +as well before running the command. + +If you want the args to be added somewhere else than at the very end, then you can put +`@additional_args` to be able to choose exactly where they go. + +For example running `composer run-commands ARG` with the below config: + +```json +{ + "scripts": { + "run-commands": [ + "echo hello @no_additional_args", + "command-with-args @additional_args && do-something-without-args --here" + ] + } +} +``` + +Would end up executing these commands: + +``` +echo hello +command-with-args ARG && do-something-without-args --here +``` + ## Setting environment variables To set an environment variable in a cross-platform way, you can use `@putenv`: diff --git a/src/Composer/EventDispatcher/EventDispatcher.php b/src/Composer/EventDispatcher/EventDispatcher.php index d10ca53b2..2d8af9e07 100644 --- a/src/Composer/EventDispatcher/EventDispatcher.php +++ b/src/Composer/EventDispatcher/EventDispatcher.php @@ -202,7 +202,12 @@ class EventDispatcher $return = 0; $this->ensureBinDirIsInPath(); - $formattedEventNameWithArgs = $event->getName() . ($event->getArguments() !== [] ? ' (' . implode(', ', $event->getArguments()) . ')' : ''); + $additionalArgs = $event->getArguments(); + if (is_string($callable) && str_contains($callable, '@no_additional_args')) { + $callable = Preg::replace('{ ?@no_additional_args}', '', $callable); + $additionalArgs = []; + } + $formattedEventNameWithArgs = $event->getName() . ($additionalArgs !== [] ? ' (' . implode(', ', $additionalArgs) . ')' : ''); if (!is_string($callable)) { if (!is_callable($callable)) { $className = is_object($callable[0]) ? get_class($callable[0]) : $callable[0]; @@ -220,7 +225,12 @@ class EventDispatcher $scriptName = $script[0]; unset($script[0]); - $args = array_merge($script, $event->getArguments()); + $index = array_search('@additional_args', $script, true); + if ($index !== false) { + $args = array_splice($script, $index, 0, $additionalArgs); + } else { + $args = array_merge($script, $additionalArgs); + } $flags = $event->getFlags(); if (isset($flags['script-alias-input'])) { $argsString = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $script)); @@ -294,7 +304,7 @@ class EventDispatcher $app->add($cmd); $app->setDefaultCommand((string) $cmd->getName(), true); try { - $args = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $event->getArguments())); + $args = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $additionalArgs)); // reusing the output from $this->io is mostly needed for tests, but generally speaking // it does not hurt to keep the same stream as the current Application if ($this->io instanceof ConsoleIO) { @@ -313,13 +323,17 @@ class EventDispatcher throw $e; } } else { - $args = implode(' ', array_map(['Composer\Util\ProcessExecutor', 'escape'], $event->getArguments())); + $args = implode(' ', array_map(['Composer\Util\ProcessExecutor', 'escape'], $additionalArgs)); // @putenv does not receive arguments if (strpos($callable, '@putenv ') === 0) { $exec = $callable; } else { - $exec = $callable . ($args === '' ? '' : ' '.$args); + if (str_contains($callable, '@additional_args')) { + $exec = str_replace('@additional_args', $args, $callable); + } else { + $exec = $callable . ($args === '' ? '' : ' '.$args); + } } if ($this->io->isVerbose()) { diff --git a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php index 233317c03..1439c9f87 100644 --- a/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php +++ b/tests/Composer/Test/EventDispatcher/EventDispatcherTest.php @@ -311,6 +311,51 @@ class EventDispatcherTest extends TestCase } } + public function testDispatcherSupportForAdditionalArgs(): void + { + $process = $this->getProcessExecutorMock(); + $dispatcher = $this->getMockBuilder('Composer\EventDispatcher\EventDispatcher') + ->setConstructorArgs([ + $this->createComposerInstance(), + $io = new BufferIO('', OutputInterface::VERBOSITY_VERBOSE), + $process, + ]) + ->onlyMethods([ + 'getListeners', + ]) + ->getMock(); + + $reflMethod = new \ReflectionMethod($dispatcher, 'getPhpExecCommand'); + if (PHP_VERSION_ID < 80100) { + $reflMethod->setAccessible(true); + } + $phpCmd = $reflMethod->invoke($dispatcher); + + $args = ProcessExecutor::escape('ARG').' '.ProcessExecutor::escape('ARG2').' '.ProcessExecutor::escape('--arg'); + $process->expects([ + 'echo -n foo', + $phpCmd.' foo.php '.$args.' then the rest', + 'echo -n bar '.$args, + ], true); + + $listeners = [ + 'echo -n foo @no_additional_args', + '@php foo.php @additional_args then the rest', + 'echo -n bar', + ]; + + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnValue($listeners)); + + $dispatcher->dispatchScript(ScriptEvents::POST_INSTALL_CMD, false, ['ARG', 'ARG2', '--arg']); + + $expected = '> post-install-cmd: echo -n foo'.PHP_EOL. + '> post-install-cmd: @php foo.php '.$args.' then the rest'.PHP_EOL. + '> post-install-cmd: echo -n bar '.$args.PHP_EOL; + self::assertEquals($expected, $io->getOutput()); + } + public static function createsVendorBinFolderChecksEnvDoesNotContainsBin(): void { mkdir(__DIR__ . '/vendor/bin', 0700, true);