diff --git a/Application.php b/Application.php index 9ea549580..0c3aa4781 100644 --- a/Application.php +++ b/Application.php @@ -736,15 +736,14 @@ public function find(string $name): Command $message = \sprintf('Command "%s" is not defined.', $name); if ($alternatives = $this->findAlternatives($name, $allCommands)) { - // remove hidden commands - $alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden()); + $wantHelps = $this->wantHelps; + $this->wantHelps = false; - if (1 == \count($alternatives)) { - $message .= "\n\nDid you mean this?\n "; - } else { - $message .= "\n\nDid you mean one of these?\n "; + // remove hidden commands + if ($alternatives = array_filter($alternatives, fn ($name) => !$this->get($name)->isHidden())) { + $message .= \sprintf("\n\nDid you mean %s?\n %s", 1 === \count($alternatives) ? 'this' : 'one of these', implode("\n ", $alternatives)); } - $message .= implode("\n ", $alternatives); + $this->wantHelps = $wantHelps; } throw new CommandNotFoundException($message, array_values($alternatives)); @@ -792,9 +791,9 @@ public function find(string $name): Command } } - $command = $this->get(reset($commands)); + $command = $commands ? $this->get(reset($commands)) : null; - if ($command->isHidden()) { + if (!$command || $command->isHidden()) { throw new CommandNotFoundException(\sprintf('The command "%s" does not exist.', $name)); } diff --git a/Command/Command.php b/Command/Command.php index c0d6bbcf3..bda5d08bf 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -84,6 +84,12 @@ public function __construct(?string $name = null, ?callable $code = null) $name = array_shift($aliases); } + // we must not overwrite existing aliases, combine new ones with existing ones + $aliases = array_unique([ + ...$this->aliases, + ...$aliases, + ]); + $this->setAliases($aliases); } diff --git a/Command/TraceableCommand.php b/Command/TraceableCommand.php index ed11cc29f..96d2b33bf 100644 --- a/Command/TraceableCommand.php +++ b/Command/TraceableCommand.php @@ -169,9 +169,9 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti public function setCode(callable $code): static { if ($code instanceof InvokableCommand) { - $r = new \ReflectionFunction(\Closure::bind(function () { - return $this->code; - }, $code, InvokableCommand::class)()); + $r = \Closure::bind(function () { + return $this->invokable; + }, $code, InvokableCommand::class)(); $this->invokableCommandInfo = [ 'class' => $r->getClosureScopeClass()->name, diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 82d185c8d..1f0cc1ca3 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -428,9 +428,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ throw new RuntimeException('Unable to hide the response.'); } - $inputHelper?->waitForInput(); - - $value = fgets($inputStream, 4096); + $value = $this->doReadInput($inputStream, helper: $inputHelper); if (4095 === \strlen($value)) { $errOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; @@ -440,9 +438,6 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ // Restore the terminal so it behaves normally again $inputHelper?->finish(); - if (false === $value) { - throw new MissingInputException('Aborted.'); - } if ($trimmable) { $value = trim($value); } @@ -514,7 +509,7 @@ private function readInput($inputStream, Question $question): string|false if (!$question->isMultiline()) { $cp = $this->setIOCodepage(); - $ret = fgets($inputStream, 4096); + $ret = $this->doReadInput($inputStream); return $this->resetIOCodepage($cp, $ret); } @@ -524,14 +519,8 @@ private function readInput($inputStream, Question $question): string|false return false; } - $ret = ''; $cp = $this->setIOCodepage(); - while (false !== ($char = fgetc($multiLineStreamReader))) { - if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") { - break; - } - $ret .= $char; - } + $ret = $this->doReadInput($multiLineStreamReader, "\x4"); if (stream_get_meta_data($inputStream)['seekable']) { fseek($inputStream, ftell($multiLineStreamReader)); @@ -601,4 +590,35 @@ private function cloneInputStream($inputStream) return $cloneStream; } + + /** + * @param resource $inputStream + */ + private function doReadInput($inputStream, ?string $exitChar = null, ?TerminalInputHelper $helper = null): string + { + $ret = ''; + $helper ??= new TerminalInputHelper($inputStream, false); + + while (!feof($inputStream)) { + $helper->waitForInput(); + $char = fread($inputStream, 1); + + // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. + if (false === $char || ('' === $ret && '' === $char)) { + throw new MissingInputException('Aborted.'); + } + + if (\PHP_EOL === "{$ret}{$char}" || $exitChar === $char) { + break; + } + + $ret .= $char; + + if (null === $exitChar && "\n" === $char) { + break; + } + } + + return $ret; + } } diff --git a/Helper/TerminalInputHelper.php b/Helper/TerminalInputHelper.php index 750229a8f..d6f07db8b 100644 --- a/Helper/TerminalInputHelper.php +++ b/Helper/TerminalInputHelper.php @@ -37,29 +37,36 @@ final class TerminalInputHelper /** @var resource */ private $inputStream; private bool $isStdin; - private string $initialState; + private string $initialState = ''; private int $signalToKill = 0; private array $signalHandlers = []; private array $targetSignals = []; + private bool $withStty; /** * @param resource $inputStream * * @throws \RuntimeException If unable to read terminal settings */ - public function __construct($inputStream) + public function __construct($inputStream, bool $withStty = true) { - if (!\is_string($state = shell_exec('stty -g'))) { - throw new \RuntimeException('Unable to read the terminal settings.'); - } $this->inputStream = $inputStream; - $this->initialState = $state; $this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri']; - $this->createSignalHandlers(); + $this->withStty = $withStty; + + if ($withStty) { + if (!\is_string($state = shell_exec('stty -g'))) { + throw new \RuntimeException('Unable to read the terminal settings.'); + } + + $this->initialState = $state; + + $this->createSignalHandlers(); + } } /** - * Waits for input and terminates if sent a default signal. + * Waits for input. */ public function waitForInput(): void { @@ -67,14 +74,15 @@ public function waitForInput(): void $r = [$this->inputStream]; $w = []; - // Allow signal handlers to run, either before Enter is pressed - // when icanon is enabled, or a single character is entered when - // icanon is disabled + // Allow signal handlers to run while (0 === @stream_select($r, $w, $w, 0, 100)) { $r = [$this->inputStream]; } } - $this->checkForKillSignal(); + + if ($this->withStty) { + $this->checkForKillSignal(); + } } /** @@ -82,6 +90,10 @@ public function waitForInput(): void */ public function finish(): void { + if (!$this->withStty) { + return; + } + // Safeguard in case an unhandled kill signal exists $this->checkForKillSignal(); shell_exec('stty '.$this->initialState); diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 8c14d126c..b45a061af 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2627,6 +2627,53 @@ public function testOriginalHandlerRestoredAfterPop() $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler must remain SIG_DFL after a second run.'); } + public function testFindAmbiguousHiddenCommands() + { + $application = new Application(); + + $application->addCommand(new Command('test:foo')); + $application->addCommand(new Command('test:foobar')); + $application->get('test:foo')->setHidden(true); + $application->get('test:foobar')->setHidden(true); + + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('The command "t:f" does not exist.'); + + $application->find('t:f'); + } + + public function testDoesNotFindHiddenCommandAsAlternativeIfHelpOptionIsPresent() + { + $application = new Application(); + $application->setAutoExit(false); + $application->addCommand(new \FooHiddenCommand()); + + $tester = new ApplicationTester($application); + $tester->setInputs(['yes']); + $tester->run(['command' => 'foohidden', '--help' => true]); + + $this->assertStringContainsString('Command "foohidden" is not defined.', $tester->getDisplay(true)); + $this->assertStringNotContainsString('Did you mean', $tester->getDisplay(true)); + $this->assertStringNotContainsString('Do you want to run', $tester->getDisplay(true)); + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + } + + public function testsPreservedHelpOptionWhenItsAnAlternative() + { + $application = new Application(); + $application->setAutoExit(false); + $application->addCommand(new \FoobarCommand()); + + $tester = new ApplicationTester($application); + $tester->setInputs(['yes']); + $tester->run(['command' => 'foobarfoo', '--help' => true]); + + $this->assertStringContainsString('Command "foobarfoo" is not defined.', $tester->getDisplay(true)); + $this->assertStringContainsString('Do you want to run "foobar:foo" instead?', $tester->getDisplay(true)); + $this->assertStringContainsString('The foobar:foo command', $tester->getDisplay(true)); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + } + #[RequiresPhpExtension('pcntl')] #[TestWith([false])] #[TestWith([4])] diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index 5ba81881d..07abfa918 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\TestWithJson; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; @@ -196,14 +196,27 @@ public function testGetProcessedHelp() public function testGetSetAliases() { $command = new \TestCommand(); - $this->assertEquals(['name'], $command->getAliases(), '->getAliases() returns the aliases'); $ret = $command->setAliases(['name1']); $this->assertEquals($command, $ret, '->setAliases() implements a fluent interface'); $this->assertEquals(['name1'], $command->getAliases(), '->setAliases() sets the aliases'); } - #[TestWithJson('["name|alias1|alias2", "name", ["alias1", "alias2"], false]')] - #[TestWithJson('["|alias1|alias2", "alias1", ["alias2"], true]')] + public function testAliasesSetBeforeParentConstructorArePreserved() + { + $command = new class extends Command { + public function __construct() + { + // set aliases before calling parent constructor + $this->setAliases(['existingalias']); + parent::__construct('foo|newalias'); + } + }; + + $this->assertSame(['existingalias', 'newalias'], $command->getAliases(), 'Aliases set before parent::__construct() must be preserved.'); + } + + #[TestWith(['name|alias1|alias2', 'name', ['alias1', 'alias2'], false])] + #[TestWith(['|alias1|alias2', 'alias1', ['alias2'], true])] public function testSetAliasesAndHiddenViaName(string $name, string $expectedName, array $expectedAliases, bool $expectedHidden) { $command = new Command($name); diff --git a/Tests/Command/TraceableCommandTest.php b/Tests/Command/TraceableCommandTest.php index eab84c549..4878b6ba7 100644 --- a/Tests/Command/TraceableCommandTest.php +++ b/Tests/Command/TraceableCommandTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\TraceableCommand; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; use Symfony\Component\Console\Tests\Fixtures\LoopExampleCommand; use Symfony\Component\Stopwatch\Stopwatch; @@ -26,6 +27,7 @@ protected function setUp(): void { $this->application = new Application(); $this->application->addCommand(new LoopExampleCommand()); + $this->application->addCommand(new InvokableTestCommand()); } public function testRunIsOverriddenWithoutProfile() @@ -57,6 +59,16 @@ public function testRunIsNotOverriddenWithProfile() $this->assertLoopOutputCorrectness($output); } + public function testRunOnInvokableCommand() + { + $command = $this->application->find('invokable:test'); + $traceableCommand = new TraceableCommand($command, new Stopwatch()); + + $commandTester = new CommandTester($traceableCommand); + $commandTester->execute([]); + $commandTester->assertCommandIsSuccessful(); + } + public function assertLoopOutputCorrectness(string $output) { $completeChar = '\\' !== \DIRECTORY_SEPARATOR ? '▓' : '='; diff --git a/Tests/Fixtures/application_test_sigint.php b/Tests/Fixtures/application_test_sigint.php new file mode 100644 index 000000000..4a3d4eab0 --- /dev/null +++ b/Tests/Fixtures/application_test_sigint.php @@ -0,0 +1,46 @@ +addArgument('mode', InputArgument::OPTIONAL, default: 'single'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $mode = $input->getArgument('mode'); + + $question = new Question('Enter text: '); + $question->setMultiline($mode !== 'single'); + + $helper = new QuestionHelper(); + + pcntl_async_signals(true); + pcntl_signal(\SIGALRM, function () { + posix_kill(posix_getpid(), \SIGINT); + pcntl_signal_dispatch(); + }); + pcntl_alarm(1); + + $helper->ask($input, $output, $question); + + return Command::SUCCESS; + } +}) + ->run(new ArgvInput($argv), new ConsoleOutput()) +; diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index be109d9fe..8d492c564 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\TestWith; use Symfony\Component\Console\Application; use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\MissingInputException; @@ -28,6 +29,8 @@ use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Process; #[Group('tty')] class QuestionHelperTest extends AbstractQuestionHelperTestCase @@ -958,6 +961,26 @@ public function testAutocompleteMoveCursorBackwards() $this->assertStringEndsWith("\033[1D\033[K\033[2D\033[K\033[1D\033[K", stream_get_contents($stream)); } + #[TestWith(['single'])] + #[TestWith(['multi'])] + public function testExitCommandOnInputSIGINT(string $mode) + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pcntl signals not available'); + } + + $p = new Process( + ['php', dirname(__DIR__).'/Fixtures/application_test_sigint.php', $mode], + timeout: 2, // the process will auto shutdown if not killed by SIGINT, to prevent blocking + ); + $p->setPty(true); + $p->start(); + + $this->expectException(ProcessSignaledException::class); + $this->expectExceptionMessage('The process has been signaled with signal "2".'); + $p->wait(); + } + protected function getInputStream($input) { $stream = fopen('php://memory', 'r+', false);