From c413f897233a3bf1cd2e5670e5f480aefc6626b0 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 13 Dec 2013 14:43:58 +0100 Subject: [PATCH] [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 e9468f9ed1..9c5c3dc475 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 47dcebd31c..9c5741b5e7 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 7a4686fa11..4ae620a0a0 100644 --- a/src/Symfony/Component/Console/Helper/DialogHelper.php +++ b/src/Symfony/Component/Console/Helper/DialogHelper.php @@ -18,6 +18,9 @@ use Symfony\Component\Console\Formatter\OutputFormatterStyle; * 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 0000000000..3affb16887 --- /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 0000000000..c6589e26fc --- /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 0000000000..bbdd102be7 --- /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 0000000000..ab056a5c41 --- /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; + } +}