diff --git a/Application.php b/Application.php
index 09234f5eb..5a2323db6 100644
--- a/Application.php
+++ b/Application.php
@@ -953,6 +953,16 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI
throw new RuntimeException('Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.');
}
+ if (Terminal::hasSttyAvailable()) {
+ $sttyMode = shell_exec('stty -g');
+
+ foreach ([\SIGINT, \SIGTERM] as $signal) {
+ $this->signalRegistry->register($signal, static function () use ($sttyMode) {
+ shell_exec('stty '.$sttyMode);
+ });
+ }
+ }
+
if ($this->dispatcher) {
foreach ($this->signalsToDispatchEvent as $signal) {
$event = new ConsoleSignalEvent($command, $input, $output, $signal);
diff --git a/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php
index c914a1246..7a09956e2 100644
--- a/Formatter/OutputFormatter.php
+++ b/Formatter/OutputFormatter.php
@@ -34,11 +34,11 @@ public function __clone()
}
/**
- * Escapes "<" special char in given text.
+ * Escapes "<" and ">" special chars in given text.
*/
public static function escape(string $text): string
{
- $text = preg_replace('/([^\\\\]?)', '$1\\<', $text);
+ $text = preg_replace('/([^\\\\]|^)([<>])/', '$1\\\\$2', $text);
return self::escapeTrailingBackslash($text);
}
@@ -140,9 +140,10 @@ public function formatAndWrap(?string $message, int $width)
{
$offset = 0;
$output = '';
- $tagRegex = '[a-z][^<>]*+';
+ $openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*';
+ $closeTagRegex = '[a-z][^<>]*+';
$currentLineLength = 0;
- preg_match_all("#<(($tagRegex) | /($tagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE);
+ preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE);
foreach ($matches[0] as $i => $match) {
$pos = $match[1];
$text = $match[0];
@@ -176,11 +177,7 @@ public function formatAndWrap(?string $message, int $width)
$output .= $this->applyCurrentStyle(substr($message, $offset), $output, $width, $currentLineLength);
- if (str_contains($output, "\0")) {
- return strtr($output, ["\0" => '\\', '\\<' => '<']);
- }
-
- return str_replace('\\<', '<', $output);
+ return strtr($output, ["\0" => '\\', '\\<' => '<', '\\>' => '>']);
}
public function getStyleStack(): OutputFormatterStyleStack
@@ -211,7 +208,8 @@ private function createStyleFromString(string $string): ?OutputFormatterStyleInt
} elseif ('bg' == $match[0]) {
$style->setBackground(strtolower($match[1]));
} elseif ('href' === $match[0]) {
- $style->setHref($match[1]);
+ $url = preg_replace('{\\\\([<>])}', '$1', $match[1]);
+ $style->setHref($url);
} elseif ('options' === $match[0]) {
preg_match_all('([^,;]+)', strtolower($match[1]), $options);
$options = array_shift($options);
diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php
index 3266a92df..2e1ccb2ab 100644
--- a/Helper/QuestionHelper.php
+++ b/Helper/QuestionHelper.php
@@ -246,6 +246,9 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
$numMatches = \count($matches);
$sttyMode = shell_exec('stty -g');
+ $isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null);
+ $r = [$inputStream];
+ $w = [];
// Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
shell_exec('stty -icanon -echo');
@@ -255,11 +258,15 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu
// Read a keypress
while (!feof($inputStream)) {
+ while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) {
+ // Give signal handlers a chance to run
+ $r = [$inputStream];
+ }
$c = fread($inputStream, 1);
// as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) {
- shell_exec(sprintf('stty %s', $sttyMode));
+ shell_exec('stty '.$sttyMode);
throw new MissingInputException('Aborted.');
} elseif ("\177" === $c) { // Backspace Character
if (0 === $numMatches && 0 !== $i) {
@@ -364,7 +371,7 @@ function ($match) use ($ret) {
}
// Reset stty so it behaves normally again
- shell_exec(sprintf('stty %s', $sttyMode));
+ shell_exec('stty '.$sttyMode);
return $fullChoice;
}
@@ -425,7 +432,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $
$value = fgets($inputStream, 4096);
if (self::$stty && Terminal::hasSttyAvailable()) {
- shell_exec(sprintf('stty %s', $sttyMode));
+ shell_exec('stty '.$sttyMode);
}
if (false === $value) {
@@ -478,11 +485,11 @@ private function isInteractiveInput($inputStream): bool
}
if (\function_exists('stream_isatty')) {
- return self::$stdinIsInteractive = stream_isatty(fopen('php://stdin', 'r'));
+ return self::$stdinIsInteractive = @stream_isatty(fopen('php://stdin', 'r'));
}
if (\function_exists('posix_isatty')) {
- return self::$stdinIsInteractive = posix_isatty(fopen('php://stdin', 'r'));
+ return self::$stdinIsInteractive = @posix_isatty(fopen('php://stdin', 'r'));
}
if (!\function_exists('exec')) {
diff --git a/Input/StringInput.php b/Input/StringInput.php
index eb5c07fdd..76f1d5030 100644
--- a/Input/StringInput.php
+++ b/Input/StringInput.php
@@ -24,7 +24,7 @@
*/
class StringInput extends ArgvInput
{
- public const REGEX_STRING = '([^\s]+?)(?:\s|(?hasStderrSupport() ? 'php://stderr' : 'php://output', 'w');
+ if (!$this->hasStderrSupport()) {
+ return fopen('php://output', 'w');
+ }
+
+ // Use STDERR when possible to prevent from opening too many file descriptors
+ return \defined('STDERR') ? \STDERR : (@fopen('php://stderr', 'w') ?: fopen('php://output', 'w'));
}
}
diff --git a/Question/ChoiceQuestion.php b/Question/ChoiceQuestion.php
index 97121754c..e449ff683 100644
--- a/Question/ChoiceQuestion.php
+++ b/Question/ChoiceQuestion.php
@@ -119,18 +119,18 @@ private function getDefaultValidator(): callable
return function ($selected) use ($choices, $errorMessage, $multiselect, $isAssoc) {
if ($multiselect) {
// Check for a separated comma values
- if (!preg_match('/^[^,]+(?:,[^,]+)*$/', $selected, $matches)) {
+ if (!preg_match('/^[^,]+(?:,[^,]+)*$/', (string) $selected, $matches)) {
throw new InvalidArgumentException(sprintf($errorMessage, $selected));
}
- $selectedChoices = explode(',', $selected);
+ $selectedChoices = explode(',', (string) $selected);
} else {
$selectedChoices = [$selected];
}
if ($this->isTrimmable()) {
foreach ($selectedChoices as $k => $v) {
- $selectedChoices[$k] = trim($v);
+ $selectedChoices[$k] = trim((string) $v);
}
}
diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php
index 619009efe..c5acd5c86 100644
--- a/Tests/ApplicationTest.php
+++ b/Tests/ApplicationTest.php
@@ -38,9 +38,11 @@
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\StreamOutput;
use Symfony\Component\Console\SignalRegistry\SignalRegistry;
+use Symfony\Component\Console\Terminal;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\EventDispatcher;
+use Symfony\Component\Process\Process;
class ApplicationTest extends TestCase
{
@@ -1882,6 +1884,39 @@ public function testSignalableCommandInterfaceWithoutSignals()
$application->add($command);
$this->assertSame(0, $application->run(new ArrayInput(['signal'])));
}
+
+ /**
+ * @group tty
+ */
+ public function testSignalableRestoresStty()
+ {
+ if (!Terminal::hasSttyAvailable()) {
+ $this->markTestSkipped('stty not available');
+ }
+
+ if (!SignalRegistry::isSupported()) {
+ $this->markTestSkipped('pcntl signals not available');
+ }
+
+ $previousSttyMode = shell_exec('stty -g');
+
+ $p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']);
+ $p->setTty(true);
+ $p->start();
+
+ for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) {
+ usleep(100000);
+ }
+
+ $this->assertNotSame($previousSttyMode, shell_exec('stty -g'));
+ $p->signal(\SIGINT);
+ $p->wait();
+
+ $sttyMode = shell_exec('stty -g');
+ shell_exec('stty '.$previousSttyMode);
+
+ $this->assertSame($previousSttyMode, $sttyMode);
+ }
}
class CustomApplication extends Application
diff --git a/Tests/Fixtures/application_signalable.php b/Tests/Fixtures/application_signalable.php
new file mode 100644
index 000000000..0194703b2
--- /dev/null
+++ b/Tests/Fixtures/application_signalable.php
@@ -0,0 +1,36 @@
+setCode(function(InputInterface $input, OutputInterface $output) {
+ $this->getHelper('question')
+ ->ask($input, $output, new ChoiceQuestion('😊', ['y']));
+
+ return 0;
+ })
+ ->run()
+
+;
diff --git a/Tests/Fixtures/command_2.txt b/Tests/Fixtures/command_2.txt
index 45e7bec4d..fcab77a29 100644
--- a/Tests/Fixtures/command_2.txt
+++ b/Tests/Fixtures/command_2.txt
@@ -2,9 +2,9 @@
command 2 description
Usage:
- descriptor:command2 [options] [--] \
- descriptor:command2 -o|--option_name \
- descriptor:command2 \
+ descriptor:command2 [options] [--] \
+ descriptor:command2 -o|--option_name \
+ descriptor:command2 \
Arguments:
argument_name
diff --git a/Tests/Fixtures/command_mbstring.txt b/Tests/Fixtures/command_mbstring.txt
index 2fd51d057..1fa4e3135 100644
--- a/Tests/Fixtures/command_mbstring.txt
+++ b/Tests/Fixtures/command_mbstring.txt
@@ -2,9 +2,9 @@
command åèä description
Usage:
- descriptor:åèä [options] [--] \
- descriptor:åèä -o|--option_name \
- descriptor:åèä \
+ descriptor:åèä [options] [--] \
+ descriptor:åèä -o|--option_name \
+ descriptor:åèä \
Arguments:
argument_åèä
diff --git a/Tests/Fixtures/input_argument_with_style.txt b/Tests/Fixtures/input_argument_with_style.txt
index 35384a6be..79149ca69 100644
--- a/Tests/Fixtures/input_argument_with_style.txt
+++ b/Tests/Fixtures/input_argument_with_style.txt
@@ -1 +1 @@
- argument_name argument description [default: "\style\>"]
+ argument_name argument description [default: "\style\\>"]
diff --git a/Tests/Fixtures/input_option_with_style.txt b/Tests/Fixtures/input_option_with_style.txt
index 880a53518..4bd30a662 100644
--- a/Tests/Fixtures/input_option_with_style.txt
+++ b/Tests/Fixtures/input_option_with_style.txt
@@ -1 +1 @@
- -o, --option_name=OPTION_NAME option description [default: "\style\>"]
+ -o, --option_name=OPTION_NAME option description [default: "\style\\>"]
diff --git a/Tests/Fixtures/input_option_with_style_array.txt b/Tests/Fixtures/input_option_with_style_array.txt
index 265c18c5a..1fbb05b8a 100644
--- a/Tests/Fixtures/input_option_with_style_array.txt
+++ b/Tests/Fixtures/input_option_with_style_array.txt
@@ -1 +1 @@
- -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed)
+ -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed)
diff --git a/Tests/Formatter/OutputFormatterTest.php b/Tests/Formatter/OutputFormatterTest.php
index 1a6aae23e..3ad18b897 100644
--- a/Tests/Formatter/OutputFormatterTest.php
+++ b/Tests/Formatter/OutputFormatterTest.php
@@ -32,7 +32,10 @@ public function testLGCharEscaping()
$this->assertEquals('foo << bar \\', $formatter->format('foo << bar \\'));
$this->assertEquals("foo << \033[32mbar \\ baz\033[39m \\", $formatter->format('foo << bar \\ baz \\'));
$this->assertEquals('some info', $formatter->format('\\some info\\'));
- $this->assertEquals('\\some info\\', OutputFormatter::escape('some info'));
+ $this->assertEquals('\\some info\\', OutputFormatter::escape('some info'));
+ // every < and > gets escaped if not already escaped, but already escaped ones do not get escaped again
+ // and escaped backslashes remain as such, same with backslashes escaping non-special characters
+ $this->assertEquals('foo \\< bar \\< baz \\\\< foo \\> bar \\> baz \\\\> \\x', OutputFormatter::escape('foo < bar \\< baz \\\\< foo > bar \\> baz \\\\> \\x'));
$this->assertEquals(
"\033[33mSymfony\\Component\\Console does work very well!\033[39m",
@@ -264,6 +267,7 @@ public function provideDecoratedAndNonDecoratedOutput()
['some question', 'some question', "\033[30;46msome question\033[39;49m"],
['some text with inline style>', 'some text with inline style', "\033[31msome text with inline style\033[39m"],
['some URL>', 'some URL', "\033]8;;idea://open/?file=/path/SomeFile.php&line=12\033\\some URL\033]8;;\033\\"],
+ ['>some URL with \>', 'some URL with ', "\033]8;;https://example.com/\033\\some URL with \033]8;;\033\\"],
['some URL>', 'some URL', 'some URL', 'JetBrains-JediTerm'],
];
}
diff --git a/Tests/Helper/FormatterHelperTest.php b/Tests/Helper/FormatterHelperTest.php
index 934e11ac1..c9a3c5e00 100644
--- a/Tests/Helper/FormatterHelperTest.php
+++ b/Tests/Helper/FormatterHelperTest.php
@@ -83,9 +83,9 @@ public function testFormatBlockLGEscaping()
$formatter = new FormatterHelper();
$this->assertEquals(
- ' '."\n".
- ' \some info\ '."\n".
- ' ',
+ ' '."\n".
+ ' \some info\ '."\n".
+ ' ',
$formatter->formatBlock('some info', 'error', true),
'::formatBlock() escapes \'<\' chars'
);
diff --git a/Tests/Input/StringInputTest.php b/Tests/Input/StringInputTest.php
index f781b7ccf..2bd40dec9 100644
--- a/Tests/Input/StringInputTest.php
+++ b/Tests/Input/StringInputTest.php
@@ -71,6 +71,7 @@ public function getTokenizeData()
["--long-option='foo bar''another'", ['--long-option=foo baranother'], '->tokenize() parses long options with a value'],
["--long-option='foo bar'\"another\"", ['--long-option=foo baranother'], '->tokenize() parses long options with a value'],
['foo -a -ffoo --long bar', ['foo', '-a', '-ffoo', '--long', 'bar'], '->tokenize() parses when several arguments and options'],
+ ["--arg=\\\"'Jenny'\''s'\\\"", ["--arg=\"Jenny's\""], '->tokenize() parses quoted quotes'],
];
}
diff --git a/Tests/Question/ChoiceQuestionTest.php b/Tests/Question/ChoiceQuestionTest.php
index 8a48cfcf1..8de26419f 100644
--- a/Tests/Question/ChoiceQuestionTest.php
+++ b/Tests/Question/ChoiceQuestionTest.php
@@ -19,14 +19,15 @@ class ChoiceQuestionTest extends TestCase
/**
* @dataProvider selectUseCases
*/
- public function testSelectUseCases($multiSelect, $answers, $expected, $message)
+ public function testSelectUseCases($multiSelect, $answers, $expected, $message, $default = null)
{
$question = new ChoiceQuestion('A question', [
'First response',
'Second response',
'Third response',
'Fourth response',
- ]);
+ null,
+ ], $default);
$question->setMultiselect($multiSelect);
@@ -59,6 +60,19 @@ public function selectUseCases()
['First response', 'Second response'],
'When passed multiple answers on MultiSelect, the defaultValidator must return these answers as an array',
],
+ [
+ false,
+ [null],
+ null,
+ 'When used null as default single answer on singleSelect, the defaultValidator must return this answer as null',
+ ],
+ [
+ false,
+ ['First response'],
+ 'First response',
+ 'When used a string as default single answer on singleSelect, the defaultValidator must return this answer as a string',
+ 'First response',
+ ],
[
false,
[0],