From 18c5fd1168a388a42e79f3ab77334733b2895fb9 Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Sun, 23 Nov 2025 22:25:19 +0100 Subject: [PATCH 1/6] [Console] Fix exception message when abbreviation matches multiple hidden commands --- Application.php | 4 ++-- Tests/ApplicationTest.php | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Application.php b/Application.php index 98baa9322..96d7a82f8 100644 --- a/Application.php +++ b/Application.php @@ -788,9 +788,9 @@ public function find(string $name) } } - $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/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 639527f2d..359671088 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2424,6 +2424,21 @@ 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->add(new Command('test:foo')); + $application->add(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'); + } + /** * Reads the private "signalHandlers" property of the SignalRegistry for assertions. */ From 245d678a1f19846c113b02fffe50ce183631758a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jib=C3=A9=20Barth?= Date: Sat, 29 Nov 2025 12:55:56 +0100 Subject: [PATCH 2/6] [Console] Fix profile invokable command --- Command/TraceableCommand.php | 6 +++--- Tests/Command/TraceableCommandTest.php | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) 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/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 ? '▓' : '='; From ac543cf9535111ea557a24f7473f5d6f4a8ab1cc Mon Sep 17 00:00:00 2001 From: henderkes Date: Sat, 29 Nov 2025 11:26:34 +0100 Subject: [PATCH 3/6] [Console] don't discard existing aliases when constructing Command --- Command/Command.php | 6 ++++++ Tests/Command/CommandTest.php | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Command/Command.php b/Command/Command.php index 6f687d38c..d09c8876a 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -120,6 +120,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/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index 3a75ea7e4..e56ddfbab 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -199,12 +199,25 @@ 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'); } + 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) From 5a92ed298d951f99139b6a5cb5cc74a6248df943 Mon Sep 17 00:00:00 2001 From: Santiago San Martin Date: Tue, 2 Dec 2025 20:10:15 -0300 Subject: [PATCH 4/6] [Console] Preserve `--help` option when a command is not found --- Application.php | 13 ++++++------- Tests/ApplicationTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/Application.php b/Application.php index 96d7a82f8..f61761df5 100644 --- a/Application.php +++ b/Application.php @@ -732,15 +732,14 @@ public function find(string $name) $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)); diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 359671088..5482fa238 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2439,6 +2439,38 @@ public function testFindAmbiguousHiddenCommands() $application->find('t:f'); } + public function testDoesNotFindHiddenCommandAsAlternativeIfHelpOptionIsPresent() + { + $application = new Application(); + $application->setAutoExit(false); + $application->add(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->add(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()); + } + /** * Reads the private "signalHandlers" property of the SignalRegistry for assertions. */ From 1b2813049506b39eb3d7e64aff033fd5ca26c97e Mon Sep 17 00:00:00 2001 From: Valentin PONS Date: Sun, 5 Oct 2025 20:08:26 +0200 Subject: [PATCH 5/6] Handle signals on text input --- Helper/QuestionHelper.php | 48 +++++++++++++++------- Helper/TerminalInputHelper.php | 36 ++++++++++------ Tests/Fixtures/application_test_sigint.php | 46 +++++++++++++++++++++ Tests/Helper/QuestionHelperTest.php | 24 +++++++++++ 4 files changed, 128 insertions(+), 26 deletions(-) create mode 100644 Tests/Fixtures/application_test_sigint.php diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 8eb5ec681..23d8522ad 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -437,9 +437,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; @@ -449,9 +447,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); } @@ -511,7 +506,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); } @@ -521,14 +516,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)); @@ -598,4 +587,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/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 651ae5f10..4a89829d7 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -26,6 +26,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 @@ -929,6 +931,28 @@ public function testAutocompleteMoveCursorBackwards() $this->assertStringEndsWith("\033[1D\033[K\033[2D\033[K\033[1D\033[K", stream_get_contents($stream)); } + /** + * @testWith ["single"] + * ["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); From 6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 5 Dec 2025 16:23:39 +0100 Subject: [PATCH 6/6] Fix merge --- Tests/ApplicationTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 864e54d15..b45a061af 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2646,7 +2646,7 @@ public function testDoesNotFindHiddenCommandAsAlternativeIfHelpOptionIsPresent() { $application = new Application(); $application->setAutoExit(false); - $application->add(new \FooHiddenCommand()); + $application->addCommand(new \FooHiddenCommand()); $tester = new ApplicationTester($application); $tester->setInputs(['yes']); @@ -2662,7 +2662,7 @@ public function testsPreservedHelpOptionWhenItsAnAlternative() { $application = new Application(); $application->setAutoExit(false); - $application->add(new \FoobarCommand()); + $application->addCommand(new \FoobarCommand()); $tester = new ApplicationTester($application); $tester->setInputs(['yes']);