From c413f897233a3bf1cd2e5670e5f480aefc6626b0 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 13 Dec 2013 14:43:58 +0100 Subject: [PATCH 1/2] [Console] added a better way to ask questions to the user --- UPGRADE-3.0.md | 2 + src/Symfony/Component/Console/CHANGELOG.md | 4 +- .../Component/Console/Helper/DialogHelper.php | 3 + .../Console/Helper/QuestionHelper.php | 378 ++++++++++++++++++ .../Console/Question/ChoiceQuestion.php | 95 +++++ .../Console/Question/ConfirmationQuestion.php | 44 ++ .../Component/Console/Question/Question.php | 122 ++++++ 7 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Console/Helper/QuestionHelper.php create mode 100644 src/Symfony/Component/Console/Question/ChoiceQuestion.php create mode 100644 src/Symfony/Component/Console/Question/ConfirmationQuestion.php create mode 100644 src/Symfony/Component/Console/Question/Question.php diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md index e9468f9ed1892..9c5c3dc475e71 100644 --- a/UPGRADE-3.0.md +++ b/UPGRADE-3.0.md @@ -20,6 +20,8 @@ UPGRADE FROM 2.x to 3.0 ### Console + * The `dialog` helper has been removed in favor of the `question` helper. + * The methods `isQuiet`, `isVerbose`, `isVeryVerbose` and `isDebug` were added to `Symfony\Component\Console\Output\OutputInterface`. diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 47dcebd31c5d1..9c5741b5e7279 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -4,10 +4,12 @@ CHANGELOG 2.5.0 ----- + * deprecated the dialog helper (use the question helper instead) * deprecated TableHelper in favor of Table * deprecated ProgressHelper in favor of ProgressBar + * added a question helper + * added a way to set the process name of a command * added a way to set a default command instead of `ListCommand` - * added a way to set the process title of a command 2.4.0 ----- diff --git a/src/Symfony/Component/Console/Helper/DialogHelper.php b/src/Symfony/Component/Console/Helper/DialogHelper.php index 7a4686fa11e7c..4ae620a0a0735 100644 --- a/src/Symfony/Component/Console/Helper/DialogHelper.php +++ b/src/Symfony/Component/Console/Helper/DialogHelper.php @@ -18,6 +18,9 @@ * The Dialog class provides helpers to interact with the user. * * @author Fabien Potencier + * + * @deprecated Deprecated since version 2.5, to be removed in 3.0. + * Use the question helper instead. */ class DialogHelper extends InputAwareHelper { diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php new file mode 100644 index 0000000000000..3affb16887213 --- /dev/null +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -0,0 +1,378 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +use Symfony\Component\Console\Helper\Helper; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Dialog\Question; +use Symfony\Component\Console\Dialog\ChoiceQuestion; + +/** + * The Question class provides helpers to interact with the user. + * + * @author Fabien Potencier + */ +class QuestionHelper extends Helper +{ + private $inputStream; + private static $shell; + private static $stty; + + public function __construct() + { + $this->inputStream = STDIN; + } + + /** + * Asks a question to the user. + * + * @param OutputInterface $output An Output instance + * @param Question $question The question to ask + * + * @return string The user answer + * + * @throws \RuntimeException If there is no data to read in the input stream + */ + public function ask(OutputInterface $output, Question $question) + { + $that = $this; + + if (!$question->getValidator()) { + return $that->doAsk($output, $question); + } + + $interviewer = function() use ($output, $question, $that) { + return $that->doAsk($output, $question); + }; + + return $this->validateAttempts($interviewer, $output, $question); + } + + /** + * Sets the input stream to read from when interacting with the user. + * + * This is mainly useful for testing purpose. + * + * @param resource $stream The input stream + */ + public function setInputStream($stream) + { + $this->inputStream = $stream; + } + + /** + * Returns the helper's input stream + * + * @return string + */ + public function getInputStream() + { + return $this->inputStream; + } + + private function doAsk($output, $question) + { + $message = $question->getQuestion(); + if ($question instanceof ChoiceQuestion) { + $width = max(array_map('strlen', array_keys($question->getChoices()))); + + $messages = (array) $question->getQuestion(); + foreach ($question->getChoices() as $key => $value) { + $messages[] = sprintf(" [%-${width}s] %s", $key, $value); + } + + $output->writeln($messages); + + $message = $question->getPrompt(); + } + + $output->write($message); + + $autocomplete = $question->getAutocompleter(); + if (null === $autocomplete || !$this->hasSttyAvailable()) { + $ret = false; + if ($question->isHidden()) { + try { + $ret = trim($this->askHiddenResponse($output, $question)); + } catch (\RuntimeException $e) { + if (!$question->isHiddenFallback()) { + throw $e; + } + } + } + + if (false === $ret) { + $ret = fgets($this->inputStream, 4096); + if (false === $ret) { + throw new \RuntimeException('Aborted'); + } + $ret = trim($ret); + } + } else { + $ret = $this->autocomplete($output, $question); + } + + $ret = strlen($ret) > 0 ? $ret : $question->getDefault(); + + if ($normalizer = $question->getNormalizer()) { + return $normalizer($ret); + } + + return $ret; + } + + private function autocomplete(OutputInterface $output, Question $question) + { + $autocomplete = $question->getAutocompleter(); + $ret = ''; + + $i = 0; + $ofs = -1; + $matches = $autocomplete; + $numMatches = count($matches); + + $sttyMode = shell_exec('stty -g'); + + // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) + shell_exec('stty -icanon -echo'); + + // Add highlighted text style + $output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white')); + + // Read a keypress + while (!feof($this->inputStream)) { + $c = fread($this->inputStream, 1); + + // Backspace Character + if ("\177" === $c) { + if (0 === $numMatches && 0 !== $i) { + $i--; + // Move cursor backwards + $output->write("\033[1D"); + } + + if ($i === 0) { + $ofs = -1; + $matches = $autocomplete; + $numMatches = count($matches); + } else { + $numMatches = 0; + } + + // Pop the last character off the end of our string + $ret = substr($ret, 0, $i); + } elseif ("\033" === $c) { // Did we read an escape sequence? + $c .= fread($this->inputStream, 2); + + // A = Up Arrow. B = Down Arrow + if ('A' === $c[2] || 'B' === $c[2]) { + if ('A' === $c[2] && -1 === $ofs) { + $ofs = 0; + } + + if (0 === $numMatches) { + continue; + } + + $ofs += ('A' === $c[2]) ? -1 : 1; + $ofs = ($numMatches + $ofs) % $numMatches; + } + } elseif (ord($c) < 32) { + if ("\t" === $c || "\n" === $c) { + if ($numMatches > 0 && -1 !== $ofs) { + $ret = $matches[$ofs]; + // Echo out remaining chars for current match + $output->write(substr($ret, $i)); + $i = strlen($ret); + } + + if ("\n" === $c) { + $output->write($c); + break; + } + + $numMatches = 0; + } + + continue; + } else { + $output->write($c); + $ret .= $c; + $i++; + + $numMatches = 0; + $ofs = 0; + + foreach ($autocomplete as $value) { + // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle) + if (0 === strpos($value, $ret) && $i !== strlen($value)) { + $matches[$numMatches++] = $value; + } + } + } + + // Erase characters from cursor to end of line + $output->write("\033[K"); + + if ($numMatches > 0 && -1 !== $ofs) { + // Save cursor position + $output->write("\0337"); + // Write highlighted text + $output->write(''.substr($matches[$ofs], $i).''); + // Restore cursor position + $output->write("\0338"); + } + } + + // Reset stty so it behaves normally again + shell_exec(sprintf('stty %s', $sttyMode)); + + return $ret; + } + + /** + * Asks a question to the user, the response is hidden + * + * @param OutputInterface $output An Output instance + * @param string|array $question The question + * + * @return string The answer + * + * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden + */ + private function askHiddenResponse(OutputInterface $output, Question $question) + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $exe = __DIR__.'/../Resources/bin/hiddeninput.exe'; + + // handle code running from a phar + if ('phar:' === substr(__FILE__, 0, 5)) { + $tmpExe = sys_get_temp_dir().'/hiddeninput.exe'; + copy($exe, $tmpExe); + $exe = $tmpExe; + } + + $value = rtrim(shell_exec($exe)); + $output->writeln(''); + + if (isset($tmpExe)) { + unlink($tmpExe); + } + + return $value; + } + + if ($this->hasSttyAvailable()) { + $sttyMode = shell_exec('stty -g'); + + shell_exec('stty -echo'); + $value = fgets($this->inputStream, 4096); + shell_exec(sprintf('stty %s', $sttyMode)); + + if (false === $value) { + throw new \RuntimeException('Aborted'); + } + + $value = trim($value); + $output->writeln(''); + + return $value; + } + + if (false !== $shell = $this->getShell()) { + $readCmd = $shell === 'csh' ? 'set mypassword = $<' : 'read -r mypassword'; + $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword'", $shell, $readCmd); + $value = rtrim(shell_exec($command)); + $output->writeln(''); + + return $value; + } + + throw new \RuntimeException('Unable to hide the response'); + } + + /** + * Validates an attempt. + * + * @param callable $interviewer A callable that will ask for a question and return the result + * @param OutputInterface $output An Output instance + * @param Question $question A Question instance + * + * @return string The validated response + * + * @throws \Exception In case the max number of attempts has been reached and no valid response has been given + */ + private function validateAttempts($interviewer, OutputInterface $output, Question $question) + { + $error = null; + $attempts = $question->getMaxAttemps(); + while (false === $attempts || $attempts--) { + if (null !== $error) { + $output->writeln($this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error')); + } + + try { + return call_user_func($question->getValidator(), $interviewer()); + } catch (\Exception $error) { + } + } + + throw $error; + } + + /** + * Return a valid unix shell + * + * @return string|Boolean The valid shell name, false in case no valid shell is found + */ + private function getShell() + { + if (null !== self::$shell) { + return self::$shell; + } + + self::$shell = false; + + if (file_exists('/usr/bin/env')) { + // handle other OSs with bash/zsh/ksh/csh if available to hide the answer + $test = "/usr/bin/env %s -c 'echo OK' 2> /dev/null"; + foreach (array('bash', 'zsh', 'ksh', 'csh') as $sh) { + if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) { + self::$shell = $sh; + break; + } + } + } + + return self::$shell; + } + + private function hasSttyAvailable() + { + if (null !== self::$stty) { + return self::$stty; + } + + exec('stty 2>&1', $output, $exitcode); + + return self::$stty = $exitcode === 0; + } + + /** + * {@inheritDoc} + */ + public function getName() + { + return 'question'; + } +} diff --git a/src/Symfony/Component/Console/Question/ChoiceQuestion.php b/src/Symfony/Component/Console/Question/ChoiceQuestion.php new file mode 100644 index 0000000000000..c6589e26fc126 --- /dev/null +++ b/src/Symfony/Component/Console/Question/ChoiceQuestion.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Question; + +/** + * Represents a choice question. + * + * @author Fabien Potencier + */ +class ChoiceQuestion extends Question +{ + private $choices; + private $multiselect = false; + private $prompt = ' > '; + private $errorMessage = 'Value "%s" is invalid'; + + public function __construct($question, array $choices, $default = null) + { + parent::__construct($question, $default); + + $this->choices = $choices; + $this->setValidator($this->getDefaultValidator()); + $this->setAutocompleter(array_keys($choices)); + } + + public function getChoices() + { + return $this->choices; + } + + public function setMultiselect($multiselect) + { + $this->multiselect = $multiselect; + } + + public function getPrompt() + { + return $this->prompt; + } + + public function setPrompt($prompt) + { + $this->prompt = $prompt; + } + + public function setErrorMessage($errorMessage) + { + $this->errorMessage = $errorMessage; + } + + private function getDefaultValidator() + { + $choices = $this->choices; + $errorMessage = $this->errorMessage; + $multiselect = $this->multiselect; + + return function ($selected) use ($choices, $errorMessage, $multiselect) { + // Collapse all spaces. + $selectedChoices = str_replace(' ', '', $selected); + + if ($multiselect) { + // Check for a separated comma values + if (!preg_match('/^[a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*$/', $selectedChoices, $matches)) { + throw new \InvalidArgumentException(sprintf($errorMessage, $selected)); + } + $selectedChoices = explode(',', $selectedChoices); + } else { + $selectedChoices = array($selected); + } + + $multiselectChoices = array(); + foreach ($selectedChoices as $value) { + if (empty($choices[$value])) { + throw new \InvalidArgumentException(sprintf($errorMessage, $value)); + } + array_push($multiselectChoices, $value); + } + + if ($multiselect) { + return $multiselectChoices; + } + + return $selected; + }; + } +} diff --git a/src/Symfony/Component/Console/Question/ConfirmationQuestion.php b/src/Symfony/Component/Console/Question/ConfirmationQuestion.php new file mode 100644 index 0000000000000..bbdd102be7e7c --- /dev/null +++ b/src/Symfony/Component/Console/Question/ConfirmationQuestion.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Question; + +/** + * Represents a yes/no question. + * + * @author Fabien Potencier + */ +class ConfirmationQuestion extends Question +{ + public function __construct($question, $default = false) + { + parent::__construct($question, $default); + + $this->setNormalizer($this->getDefaultNormalizer()); + } + + private function getDefaultNormalizer() + { + $default = $this->getDefault(); + + return function ($answer) use ($default) { + if (is_bool($answer)) { + return $answer; + } + + if (false === $default) { + return $answer && 'y' == strtolower($answer[0]); + } + + return !$answer || 'y' == strtolower($answer[0]); + }; + } +} diff --git a/src/Symfony/Component/Console/Question/Question.php b/src/Symfony/Component/Console/Question/Question.php new file mode 100644 index 0000000000000..ab056a5c41c9c --- /dev/null +++ b/src/Symfony/Component/Console/Question/Question.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Question; + +/** + * Represents a Question. + * + * @author Fabien Potencier + */ +class Question +{ + private $question; + private $attempts = false; + private $hidden = false; + private $hiddenFallback = true; + private $autocompleter; + private $validator; + private $default; + private $normalizer; + + /** + * Constructor. + * + * @param string $question The question to ask to the user + * @param mixed $default The default answer to return if the user enters nothing + */ + public function __construct($question, $default = null) + { + $this->question = $question; + $this->default = $default; + } + + public function getQuestion() + { + return $this->question; + } + + public function getDefault() + { + return $this->default; + } + + public function isHidden() + { + return $this->hidden; + } + + public function setHidden($hidden) + { + if ($this->autocompleter) { + throw new \LogicException('A hidden question cannot use the autocompleter.'); + } + + $this->hidden = (Boolean) $hidden; + } + + public function isHiddenFallback() + { + return $this->fallback; + } + + /** + * Sets whether to fallback on non-hidden question if the response can not be hidden. + */ + public function setHiddenFallback($fallback) + { + $this->fallback = (Boolean) $fallback; + } + + public function getAutocompleter() + { + return $this->autocompleter; + } + + public function setAutocompleter($autocompleter) + { + if ($this->hidden) { + throw new \LogicException('A hidden question cannot use the autocompleter.'); + } + + $this->autocompleter = $autocompleter; + } + + public function setValidator($validator) + { + $this->validator = $validator; + } + + public function getValidator() + { + return $this->validator; + } + + public function setMaxAttemps($attempts) + { + $this->attempts = $attempts; + } + + public function getMaxAttemps() + { + return $this->attempts; + } + + public function setNormalizer($normalizer) + { + $this->normalizer = $normalizer; + } + + public function getNormalizer() + { + return $this->normalizer; + } +} From 336bba2fd816184c2adf9721659b3c6fe36e708d Mon Sep 17 00:00:00 2001 From: Romain Neutron Date: Tue, 1 Apr 2014 14:42:24 +0200 Subject: [PATCH 2/2] [Console] Add docblocks and unit tests to QuestionHelper --- src/Symfony/Component/Console/Application.php | 2 + .../Console/Helper/QuestionHelper.php | 96 ++++--- .../Console/Question/ChoiceQuestion.php | 49 +++- .../Console/Question/ConfirmationQuestion.php | 8 +- .../Component/Console/Question/Question.php | 139 +++++++++- .../Tests/Helper/QuestionHelperTest.php | 238 ++++++++++++++++++ 6 files changed, 483 insertions(+), 49 deletions(-) create mode 100644 src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 44b8a9393b7be..44c719d76a682 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Descriptor\TextDescriptor; use Symfony\Component\Console\Descriptor\XmlDescriptor; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArrayInput; @@ -968,6 +969,7 @@ protected function getDefaultHelperSet() new DialogHelper(), new ProgressHelper(), new TableHelper(), + new QuestionHelper(), )); } diff --git a/src/Symfony/Component/Console/Helper/QuestionHelper.php b/src/Symfony/Component/Console/Helper/QuestionHelper.php index 3affb16887213..99344e9ea728f 100644 --- a/src/Symfony/Component/Console/Helper/QuestionHelper.php +++ b/src/Symfony/Component/Console/Helper/QuestionHelper.php @@ -11,14 +11,14 @@ namespace Symfony\Component\Console\Helper; -use Symfony\Component\Console\Helper\Helper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Formatter\OutputFormatterStyle; -use Symfony\Component\Console\Dialog\Question; -use Symfony\Component\Console\Dialog\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Question\ChoiceQuestion; /** - * The Question class provides helpers to interact with the user. + * The QuestionHelper class provides helpers to interact with the user. * * @author Fabien Potencier */ @@ -36,22 +36,27 @@ public function __construct() /** * Asks a question to the user. * - * @param OutputInterface $output An Output instance + * @param InputInterface $input An InputInterface instance + * @param OutputInterface $output An OutputInterface instance * @param Question $question The question to ask * * @return string The user answer * * @throws \RuntimeException If there is no data to read in the input stream */ - public function ask(OutputInterface $output, Question $question) + public function ask(InputInterface $input, OutputInterface $output, Question $question) { - $that = $this; + if (!$input->isInteractive()) { + return $question->getDefault(); + } if (!$question->getValidator()) { - return $that->doAsk($output, $question); + return $this->doAsk($output, $question); } - $interviewer = function() use ($output, $question, $that) { + $that = $this; + + $interviewer = function () use ($output, $question, $that) { return $that->doAsk($output, $question); }; @@ -64,23 +69,48 @@ public function ask(OutputInterface $output, Question $question) * This is mainly useful for testing purpose. * * @param resource $stream The input stream + * + * @throws \InvalidArgumentException In case the stream is not a resource */ public function setInputStream($stream) { + if (!is_resource($stream)) { + throw new \InvalidArgumentException('Input stream must be a valid resource.'); + } + $this->inputStream = $stream; } /** * Returns the helper's input stream * - * @return string + * @return resource */ public function getInputStream() { return $this->inputStream; } - private function doAsk($output, $question) + /** + * {@inheritdoc} + */ + public function getName() + { + return 'question'; + } + + /** + * Asks the question to the user. + * + * @param OutputInterface $output + * @param Question $question + * + * @return bool|mixed|null|string + * + * @throws \Exception + * @throws \RuntimeException + */ + private function doAsk(OutputInterface $output, Question $question) { $message = $question->getQuestion(); if ($question instanceof ChoiceQuestion) { @@ -98,12 +128,12 @@ private function doAsk($output, $question) $output->write($message); - $autocomplete = $question->getAutocompleter(); + $autocomplete = $question->getAutocompleterValues(); if (null === $autocomplete || !$this->hasSttyAvailable()) { $ret = false; if ($question->isHidden()) { try { - $ret = trim($this->askHiddenResponse($output, $question)); + $ret = trim($this->getHiddenResponse($output)); } catch (\RuntimeException $e) { if (!$question->isHiddenFallback()) { throw $e; @@ -119,7 +149,7 @@ private function doAsk($output, $question) $ret = trim($ret); } } else { - $ret = $this->autocomplete($output, $question); + $ret = trim($this->autocomplete($output, $question)); } $ret = strlen($ret) > 0 ? $ret : $question->getDefault(); @@ -131,9 +161,17 @@ private function doAsk($output, $question) return $ret; } + /** + * Autocompletes a question. + * + * @param OutputInterface $output + * @param Question $question + * + * @return string + */ private function autocomplete(OutputInterface $output, Question $question) { - $autocomplete = $question->getAutocompleter(); + $autocomplete = $question->getAutocompleterValues(); $ret = ''; $i = 0; @@ -241,16 +279,15 @@ private function autocomplete(OutputInterface $output, Question $question) } /** - * Asks a question to the user, the response is hidden + * Gets a hidden response from user. * * @param OutputInterface $output An Output instance - * @param string|array $question The question * * @return string The answer * - * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden + * @throws \RuntimeException In case the fallback is deactivated and the response cannot be hidden */ - private function askHiddenResponse(OutputInterface $output, Question $question) + private function getHiddenResponse(OutputInterface $output) { if (defined('PHP_WINDOWS_VERSION_BUILD')) { $exe = __DIR__.'/../Resources/bin/hiddeninput.exe'; @@ -298,7 +335,7 @@ private function askHiddenResponse(OutputInterface $output, Question $question) return $value; } - throw new \RuntimeException('Unable to hide the response'); + throw new \RuntimeException('Unable to hide the response.'); } /** @@ -315,8 +352,8 @@ private function askHiddenResponse(OutputInterface $output, Question $question) private function validateAttempts($interviewer, OutputInterface $output, Question $question) { $error = null; - $attempts = $question->getMaxAttemps(); - while (false === $attempts || $attempts--) { + $attempts = $question->getMaxAttempts(); + while (null === $attempts || $attempts--) { if (null !== $error) { $output->writeln($this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error')); } @@ -331,7 +368,7 @@ private function validateAttempts($interviewer, OutputInterface $output, Questio } /** - * Return a valid unix shell + * Returns a valid unix shell. * * @return string|Boolean The valid shell name, false in case no valid shell is found */ @@ -357,6 +394,11 @@ private function getShell() return self::$shell; } + /** + * Returns whether Stty is available or not. + * + * @return Boolean + */ private function hasSttyAvailable() { if (null !== self::$stty) { @@ -367,12 +409,4 @@ private function hasSttyAvailable() return self::$stty = $exitcode === 0; } - - /** - * {@inheritDoc} - */ - public function getName() - { - return 'question'; - } } diff --git a/src/Symfony/Component/Console/Question/ChoiceQuestion.php b/src/Symfony/Component/Console/Question/ChoiceQuestion.php index c6589e26fc126..57275d5728cf0 100644 --- a/src/Symfony/Component/Console/Question/ChoiceQuestion.php +++ b/src/Symfony/Component/Console/Question/ChoiceQuestion.php @@ -29,32 +29,75 @@ public function __construct($question, array $choices, $default = null) $this->choices = $choices; $this->setValidator($this->getDefaultValidator()); - $this->setAutocompleter(array_keys($choices)); + $this->setAutocompleterValues(array_keys($choices)); } + /** + * Returns available choices. + * + * @return array + */ public function getChoices() { return $this->choices; } + /** + * Sets multiselect option. + * + * When multiselect is set to true, multiple choices can be answered. + * + * @param Boolean $multiselect + * + * @return ChoiceQuestion The current instance + */ public function setMultiselect($multiselect) { $this->multiselect = $multiselect; + $this->setValidator($this->getDefaultValidator()); + + return $this; } + /** + * Gets the prompt for choices. + * + * @return string + */ public function getPrompt() { return $this->prompt; } + /** + * Sets the prompt for choices. + * + * @param string $prompt + * + * @return ChoiceQuestion The current instance + */ public function setPrompt($prompt) { $this->prompt = $prompt; + + return $this; } + /** + * Sets the error message for invalid values. + * + * The error message has a string placeholder (%s) for the invalid value. + * + * @param string $errorMessage + * + * @return ChoiceQuestion The current instance + */ public function setErrorMessage($errorMessage) { $this->errorMessage = $errorMessage; + $this->setValidator($this->getDefaultValidator()); + + return $this; } private function getDefaultValidator() @@ -82,14 +125,14 @@ private function getDefaultValidator() if (empty($choices[$value])) { throw new \InvalidArgumentException(sprintf($errorMessage, $value)); } - array_push($multiselectChoices, $value); + array_push($multiselectChoices, $choices[$value]); } if ($multiselect) { return $multiselectChoices; } - return $selected; + return $choices[$selected]; }; } } diff --git a/src/Symfony/Component/Console/Question/ConfirmationQuestion.php b/src/Symfony/Component/Console/Question/ConfirmationQuestion.php index bbdd102be7e7c..d14f878521a9e 100644 --- a/src/Symfony/Component/Console/Question/ConfirmationQuestion.php +++ b/src/Symfony/Component/Console/Question/ConfirmationQuestion.php @@ -18,9 +18,9 @@ */ class ConfirmationQuestion extends Question { - public function __construct($question, $default = false) + public function __construct($question, $default = true) { - parent::__construct($question, $default); + parent::__construct($question, (Boolean) $default); $this->setNormalizer($this->getDefaultNormalizer()); } @@ -35,10 +35,10 @@ private function getDefaultNormalizer() } if (false === $default) { - return $answer && 'y' == strtolower($answer[0]); + return $answer && 'y' === strtolower($answer[0]); } - return !$answer || 'y' == strtolower($answer[0]); + return !$answer || 'y' === strtolower($answer[0]); }; } } diff --git a/src/Symfony/Component/Console/Question/Question.php b/src/Symfony/Component/Console/Question/Question.php index ab056a5c41c9c..763a1c02ce3c5 100644 --- a/src/Symfony/Component/Console/Question/Question.php +++ b/src/Symfony/Component/Console/Question/Question.php @@ -10,6 +10,7 @@ */ namespace Symfony\Component\Console\Question; +use Doctrine\Common\Proxy\Exception\InvalidArgumentException; /** * Represents a Question. @@ -19,10 +20,10 @@ class Question { private $question; - private $attempts = false; + private $attempts; private $hidden = false; private $hiddenFallback = true; - private $autocompleter; + private $autocompleterValues; private $validator; private $default; private $normalizer; @@ -39,82 +40,198 @@ public function __construct($question, $default = null) $this->default = $default; } + /** + * Returns the question. + * + * @return string + */ public function getQuestion() { return $this->question; } + /** + * Returns the default answer. + * + * @return mixed + */ public function getDefault() { return $this->default; } + /** + * Returns whether the user response must be hidden. + * + * @return Boolean + */ public function isHidden() { return $this->hidden; } + /** + * Sets whether the user response must be hidden or not. + * + * @param Boolean $hidden + * + * @return Question The current instance + * + * @throws \LogicException In case the autocompleter is also used + */ public function setHidden($hidden) { - if ($this->autocompleter) { + if ($this->autocompleterValues) { throw new \LogicException('A hidden question cannot use the autocompleter.'); } $this->hidden = (Boolean) $hidden; + + return $this; } + /** + * In case the response can not be hidden, whether to fallback on non-hidden question or not. + * + * @return Boolean + */ public function isHiddenFallback() { - return $this->fallback; + return $this->hiddenFallback; } /** * Sets whether to fallback on non-hidden question if the response can not be hidden. + * + * @param Boolean $fallback + * + * @return Question The current instance */ public function setHiddenFallback($fallback) { - $this->fallback = (Boolean) $fallback; + $this->hiddenFallback = (Boolean) $fallback; + + return $this; } - public function getAutocompleter() + /** + * Gets values for the autocompleter. + * + * @return null|array|Traversable + */ + public function getAutocompleterValues() { - return $this->autocompleter; + return $this->autocompleterValues; } - public function setAutocompleter($autocompleter) + /** + * Sets values for the autocompleter. + * + * @param null|array|Traversable $values + * + * @return Question The current instance + * + * @throws \InvalidArgumentException + * @throws \LogicException + */ + public function setAutocompleterValues($values) { + if (null !== $values && !is_array($values)) { + if (!$values instanceof \Traversable || $values instanceof \Countable) { + throw new \InvalidArgumentException('Autocompleter values can be either an array, `null` or an object implementing both `Countable` and `Traversable` interfaces.'); + } + } + if ($this->hidden) { throw new \LogicException('A hidden question cannot use the autocompleter.'); } - $this->autocompleter = $autocompleter; + $this->autocompleterValues = $values; + + return $this; } + /** + * Sets a validator for the question. + * + * @param null|callable $validator + * + * @return Question The current instance + */ public function setValidator($validator) { $this->validator = $validator; + + return $this; } + /** + * Gets the validator for the question + * + * @return null|callable + */ public function getValidator() { return $this->validator; } - public function setMaxAttemps($attempts) + /** + * Sets the maximum number of attempts. + * + * Null means an unlimited number of attempts. + * + * @param null|integer $attempts + * + * @return Question The current instance + * + * @throws InvalidArgumentException In case the number of attempts is invalid. + */ + public function setMaxAttempts($attempts) { + if (null !== $attempts && $attempts < 1) { + throw new \InvalidArgumentException('Maximum number of attempts must be a positive value.'); + } + $this->attempts = $attempts; + + return $this; } - public function getMaxAttemps() + /** + * Gets the maximum number of attempts. + * + * Null means an unlimited number of attempts. + * + * @return null|integer + */ + public function getMaxAttempts() { return $this->attempts; } + /** + * Sets a normalizer for the response. + * + * The normalizer can ba a callable (a string), a closure or a class implementing __invoke. + * + * @param string|Closure $normalizer + * + * @return Question The current instance + */ public function setNormalizer($normalizer) { $this->normalizer = $normalizer; + + return $this; } + /** + * Gets the normalizer for the response. + * + * The normalizer can ba a callable (a string), a closure or a class implementing __invoke. + * + * @return string|Closure + */ public function getNormalizer() { return $this->normalizer; diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php new file mode 100644 index 0000000000000..bba25375dc742 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -0,0 +1,238 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Helper; + +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\FormatterHelper; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +class QuestionHelperTest extends \PHPUnit_Framework_TestCase +{ + public function testAskChoice() + { + $questionHelper = new QuestionHelper(); + + $helperSet = new HelperSet(array(new FormatterHelper())); + $questionHelper->setHelperSet($helperSet); + + $heroes = array('Superman', 'Batman', 'Spiderman'); + + $questionHelper->setInputStream($this->getInputStream("\n1\n 1 \nFabien\n1\nFabien\n1\n0,2\n 0 , 2 \n\n\n")); + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes, '2'); + // first answer is an empty answer, we're supposed to receive the default value + $this->assertEquals('Spiderman', $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes); + $this->assertEquals('Batman', $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('Batman', $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes); + $question->setErrorMessage('Input "%s" is not a superhero!'); + $this->assertEquals('Batman', $questionHelper->ask($this->createInputInterfaceMock(), $output = $this->createOutputInterface(), $question)); + + rewind($output->getStream()); + $stream = stream_get_contents($output->getStream()); + $this->assertContains('Input "Fabien" is not a superhero!', $stream); + + try { + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes, '1'); + $question->setMaxAttempts(1); + $questionHelper->ask($this->createInputInterfaceMock(), $output = $this->createOutputInterface(), $question); + $this->fail(); + } catch (\InvalidArgumentException $e) { + $this->assertEquals('Value "Fabien" is invalid', $e->getMessage()); + } + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes, null); + $question->setMultiselect(true); + + $this->assertEquals(array('Batman'), $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals(array('Superman', 'Spiderman'), $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals(array('Superman', 'Spiderman'), $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes, '0,1'); + $question->setMultiselect(true); + + $this->assertEquals(array('Superman', 'Batman'), $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $question = new ChoiceQuestion('What is your favorite superhero?', $heroes, ' 0 , 1 '); + $question->setMultiselect(true); + + $this->assertEquals(array('Superman', 'Batman'), $questionHelper->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + } + + public function testAsk() + { + $dialog = new QuestionHelper(); + + $dialog->setInputStream($this->getInputStream("\n8AM\n")); + + $question = new Question('What time is it?', '2PM'); + $this->assertEquals('2PM', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $question = new Question('What time is it?', '2PM'); + $this->assertEquals('8AM', $dialog->ask($this->createInputInterfaceMock(), $output = $this->createOutputInterface(), $question)); + + rewind($output->getStream()); + $this->assertEquals('What time is it?', stream_get_contents($output->getStream())); + } + + public function testAskWithAutocomplete() + { + if (!$this->hasSttyAvailable()) { + $this->markTestSkipped('`stty` is required to test autocomplete functionality'); + } + + // Acm + // AcsTest + // + // + // Test + // + // S + // F00oo + $inputStream = $this->getInputStream("Acm\nAc\177\177s\tTest\n\n\033[A\033[A\n\033[A\033[A\033[A\033[A\033[A\tTest\n\033[B\nS\177\177\033[B\033[B\nF00\177\177oo\t\n"); + + $dialog = new QuestionHelper(); + $dialog->setInputStream($inputStream); + $helperSet = new HelperSet(array(new FormatterHelper())); + $dialog->setHelperSet($helperSet); + + $question = new Question('Please select a bundle', 'FrameworkBundle'); + $question->setAutocompleterValues(array('AcmeDemoBundle', 'AsseticBundle', 'SecurityBundle', 'FooBundle')); + + $this->assertEquals('AcmeDemoBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('AsseticBundleTest', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('FrameworkBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('SecurityBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('FooBundleTest', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('AcmeDemoBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('AsseticBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('FooBundle', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + } + + /** + * @group tty + */ + public function testAskHiddenResponse() + { + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $this->markTestSkipped('This test is not supported on Windows'); + } + + $dialog = new QuestionHelper(); + $dialog->setInputStream($this->getInputStream("8AM\n")); + + $question = new Question('What time is it?'); + $question->setHidden(true); + + $this->assertEquals('8AM', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + } + + public function testAskConfirmation() + { + $dialog = new QuestionHelper(); + + $dialog->setInputStream($this->getInputStream("\n\n")); + $question = new ConfirmationQuestion('Do you like French fries?'); + $this->assertTrue($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $question = new ConfirmationQuestion('Do you like French fries?', false); + $this->assertFalse($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $dialog->setInputStream($this->getInputStream("y\nyes\n")); + $question = new ConfirmationQuestion('Do you like French fries?', false); + $this->assertTrue($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $question = new ConfirmationQuestion('Do you like French fries?', false); + $this->assertTrue($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $dialog->setInputStream($this->getInputStream("n\nno\n")); + $question = new ConfirmationQuestion('Do you like French fries?', true); + $this->assertFalse($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $question = new ConfirmationQuestion('Do you like French fries?', true); + $this->assertFalse($dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + } + + public function testAskAndValidate() + { + $dialog = new QuestionHelper(); + $helperSet = new HelperSet(array(new FormatterHelper())); + $dialog->setHelperSet($helperSet); + + $error = 'This is not a color!'; + $validator = function ($color) use ($error) { + if (!in_array($color, array('white', 'black'))) { + throw new \InvalidArgumentException($error); + } + + return $color; + }; + + $question = new Question('What color was the white horse of Henry IV?', 'white'); + $question->setValidator($validator); + $question->setMaxAttempts(2); + + $dialog->setInputStream($this->getInputStream("\nblack\n")); + $this->assertEquals('white', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->assertEquals('black', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + + $dialog->setInputStream($this->getInputStream("green\nyellow\norange\n")); + try { + $this->assertEquals('white', $dialog->ask($this->createInputInterfaceMock(), $this->createOutputInterface(), $question)); + $this->fail(); + } catch (\InvalidArgumentException $e) { + $this->assertEquals($error, $e->getMessage()); + } + } + + public function testNoInteraction() + { + $dialog = new QuestionHelper(); + $question = new Question('Do you have a job?', 'not yet'); + $this->assertEquals('not yet', $dialog->ask($this->createInputInterfaceMock(false), $this->createOutputInterface(), $question)); + } + + protected function getInputStream($input) + { + $stream = fopen('php://memory', 'r+', false); + fputs($stream, $input); + rewind($stream); + + return $stream; + } + + protected function createOutputInterface() + { + return new StreamOutput(fopen('php://memory', 'r+', false)); + } + + protected function createInputInterfaceMock($interactive = true) + { + $mock = $this->getMock('Symfony\Component\Console\Input\InputInterface'); + $mock->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue($interactive)); + + return $mock; + } + + private function hasSttyAvailable() + { + exec('stty 2>&1', $output, $exitcode); + + return $exitcode === 0; + } +}