diff --git a/Application.php b/Application.php index 6fdb5e68c..bb8ff2359 100644 --- a/Application.php +++ b/Application.php @@ -774,17 +774,16 @@ protected function doRenderThrowable(\Throwable $e, OutputInterface $output): vo do { $message = trim($e->getMessage()); if ('' === $message || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { - $class = \get_class($e); - $class = 'c' === $class[0] && 0 === strpos($class, "class@anonymous\0") ? get_parent_class($class).'@anonymous' : $class; + $class = get_debug_type($e); $title = sprintf(' [%s%s] ', $class, 0 !== ($code = $e->getCode()) ? ' ('.$code.')' : ''); $len = Helper::strlen($title); } else { $len = 0; } - if (false !== strpos($message, "class@anonymous\0")) { - $message = preg_replace_callback('/class@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/', function ($m) { - return class_exists($m[0], false) ? get_parent_class($m[0]).'@anonymous' : $m[0]; + if (false !== strpos($message, "@anonymous\0")) { + $message = preg_replace_callback('/[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/', function ($m) { + return class_exists($m[0], false) ? (get_parent_class($m[0]) ?: key(class_implements($m[0])) ?: 'class').'@anonymous' : $m[0]; }, $message); } diff --git a/Helper/ProcessHelper.php b/Helper/ProcessHelper.php index 944c59395..40842799c 100644 --- a/Helper/ProcessHelper.php +++ b/Helper/ProcessHelper.php @@ -36,6 +36,10 @@ class ProcessHelper extends Helper */ public function run(OutputInterface $output, $cmd, string $error = null, callable $callback = null, int $verbosity = OutputInterface::VERBOSITY_VERY_VERBOSE): Process { + if (!class_exists(Process::class)) { + throw new \LogicException('The ProcessHelper cannot be run as the Process component is not installed. Try running "compose require symfony/process".'); + } + if ($output instanceof ConsoleOutputInterface) { $output = $output->getErrorOutput(); } diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index b45c9db54..87437250a 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -33,7 +33,7 @@ class QuestionHelper extends Helper { private $inputStream; private static $shell; - private static $stty; + private static $stty = true; /** * Asks a question to the user. @@ -107,7 +107,7 @@ private function doAsk(OutputInterface $output, Question $question) $inputStream = $this->inputStream ?: STDIN; $autocomplete = $question->getAutocompleterCallback(); - if (null === $autocomplete || !Terminal::hasSttyAvailable()) { + if (null === $autocomplete || !self::$stty || !Terminal::hasSttyAvailable()) { $ret = false; if ($question->isHidden()) { try { @@ -415,7 +415,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ return $value; } - if (Terminal::hasSttyAvailable()) { + if (self::$stty && Terminal::hasSttyAvailable()) { $sttyMode = shell_exec('stty -g'); shell_exec('stty -echo'); @@ -435,7 +435,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ if (false !== $shell = $this->getShell()) { $readCmd = 'csh' === $shell ? 'set mypassword = $<' : 'read -r mypassword'; - $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword'", $shell, $readCmd); + $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword' 2> /dev/null", $shell, $readCmd); $sCommand = shell_exec($command); $value = $trimmable ? rtrim($sCommand) : $sCommand; $output->writeln(''); @@ -459,6 +459,7 @@ private function validateAttempts(callable $interviewer, OutputInterface $output { $error = null; $attempts = $question->getMaxAttempts(); + while (null === $attempts || $attempts--) { if (null !== $error) { $this->writeError($output, $error); @@ -470,6 +471,8 @@ private function validateAttempts(callable $interviewer, OutputInterface $output throw $e; } catch (\Exception $error) { } + + $attempts = $attempts ?? -(int) $this->isTty(); } throw $error; @@ -501,4 +504,19 @@ private function getShell() return self::$shell; } + + private function isTty(): bool + { + $inputStream = !$this->inputStream && \defined('STDIN') ? STDIN : $this->inputStream; + + if (\function_exists('stream_isatty')) { + return stream_isatty($inputStream); + } + + if (\function_exists('posix_isatty')) { + return posix_isatty($inputStream); + } + + return true; + } } diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index cd8904ea7..a37031f4b 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -883,7 +883,8 @@ public function testRenderAnonymousException() $application = new Application(); $application->setAutoExit(false); $application->register('foo')->setCode(function () { - throw new class('') extends \InvalidArgumentException { }; + throw new class('') extends \InvalidArgumentException { + }; }); $tester = new ApplicationTester($application); @@ -893,12 +894,13 @@ public function testRenderAnonymousException() $application = new Application(); $application->setAutoExit(false); $application->register('foo')->setCode(function () { - throw new \InvalidArgumentException(sprintf('Dummy type "%s" is invalid.', \get_class(new class() { }))); + throw new \InvalidArgumentException(sprintf('Dummy type "%s" is invalid.', \get_class(new class() { + }))); }); $tester = new ApplicationTester($application); $tester->run(['command' => 'foo'], ['decorated' => false]); - $this->assertStringContainsString('Dummy type "@anonymous" is invalid.', $tester->getDisplay(true)); + $this->assertStringContainsString('Dummy type "class@anonymous" is invalid.', $tester->getDisplay(true)); } public function testRenderExceptionStackTraceContainsRootException() @@ -906,7 +908,8 @@ public function testRenderExceptionStackTraceContainsRootException() $application = new Application(); $application->setAutoExit(false); $application->register('foo')->setCode(function () { - throw new class('') extends \InvalidArgumentException { }; + throw new class('') extends \InvalidArgumentException { + }; }); $tester = new ApplicationTester($application); @@ -916,12 +919,13 @@ public function testRenderExceptionStackTraceContainsRootException() $application = new Application(); $application->setAutoExit(false); $application->register('foo')->setCode(function () { - throw new \InvalidArgumentException(sprintf('Dummy type "%s" is invalid.', \get_class(new class() { }))); + throw new \InvalidArgumentException(sprintf('Dummy type "%s" is invalid.', \get_class(new class() { + }))); }); $tester = new ApplicationTester($application); $tester->run(['command' => 'foo'], ['decorated' => false]); - $this->assertStringContainsString('Dummy type "@anonymous" is invalid.', $tester->getDisplay(true)); + $this->assertStringContainsString('Dummy type "class@anonymous" is invalid.', $tester->getDisplay(true)); } public function testRun() diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 139aa7290..ddb0c90e1 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Tests\Helper; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\HelperSet; @@ -726,6 +727,23 @@ public function testAskThrowsExceptionOnMissingInputWithValidator() $dialog->ask($this->createStreamableInputInterfaceMock($this->getInputStream('')), $this->createOutputInterface(), $question); } + public function testAskThrowsExceptionFromValidatorEarlyWhenTtyIsMissing() + { + $this->expectException('Exception'); + $this->expectExceptionMessage('Bar, not Foo'); + + $output = $this->getMockBuilder('\Symfony\Component\Console\Output\OutputInterface')->getMock(); + $output->expects($this->once())->method('writeln'); + + (new QuestionHelper())->ask( + $this->createStreamableInputInterfaceMock($this->getInputStream('Foo'), true), + $output, + (new Question('Q?'))->setHidden(true)->setValidator(function ($input) { + throw new \Exception("Bar, not $input"); + }) + ); + } + public function testEmptyChoices() { $this->expectException('LogicException'); @@ -766,6 +784,35 @@ public function testTraversableAutocomplete() $this->assertEquals('FooBundle', $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question)); } + public function testDisableSttby() + { + if (!Terminal::hasSttyAvailable()) { + $this->markTestSkipped('`stty` is required to test autocomplete functionality'); + } + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid'); + + QuestionHelper::disableStty(); + $dialog = new QuestionHelper(); + $dialog->setHelperSet(new HelperSet([new FormatterHelper()])); + + $question = new ChoiceQuestion('Please select a bundle', [1 => 'AcmeDemoBundle', 4 => 'AsseticBundle']); + $question->setMaxAttempts(1); + + // + // Gives `AcmeDemoBundle` with stty + $inputStream = $this->getInputStream("\033[A\033[A\n\033[B\033[B\n"); + + try { + $dialog->ask($this->createStreamableInputInterfaceMock($inputStream), $this->createOutputInterface(), $question); + } finally { + $reflection = new \ReflectionProperty(QuestionHelper::class, 'stty'); + $reflection->setAccessible(true); + $reflection->setValue(null, true); + } + } + public function testTraversableMultiselectAutocomplete() { // diff --git a/Tests/TerminalTest.php b/Tests/TerminalTest.php index f135edf8b..c2d36fe0a 100644 --- a/Tests/TerminalTest.php +++ b/Tests/TerminalTest.php @@ -60,7 +60,7 @@ public function test() $this->assertSame(60, $terminal->getHeight()); } - public function test_zero_values() + public function testZeroValues() { putenv('COLUMNS=0'); putenv('LINES=0'); diff --git a/composer.json b/composer.json index 8948071d5..381c2f6d4 100644 --- a/composer.json +++ b/composer.json @@ -16,9 +16,10 @@ } ], "require": { - "php": "^7.2.5", + "php": ">=7.2.5", "symfony/polyfill-mbstring": "~1.0", "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php80": "^1.15", "symfony/service-contracts": "^1.1|^2" }, "require-dev": {