You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
447 lines
14 KiB
447 lines
14 KiB
<?php |
|
|
|
/* |
|
* This file is part of the Symfony package. |
|
* |
|
* (c) Fabien Potencier <fabien@symfony.com> |
|
* |
|
* 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\Exception\RuntimeException; |
|
use Symfony\Component\Console\Formatter\OutputFormatter; |
|
use Symfony\Component\Console\Formatter\OutputFormatterStyle; |
|
use Symfony\Component\Console\Input\InputInterface; |
|
use Symfony\Component\Console\Input\StreamableInputInterface; |
|
use Symfony\Component\Console\Output\ConsoleOutputInterface; |
|
use Symfony\Component\Console\Output\ConsoleSectionOutput; |
|
use Symfony\Component\Console\Output\OutputInterface; |
|
use Symfony\Component\Console\Question\ChoiceQuestion; |
|
use Symfony\Component\Console\Question\Question; |
|
|
|
/** |
|
* The QuestionHelper class provides helpers to interact with the user. |
|
* |
|
* @author Fabien Potencier <fabien@symfony.com> |
|
*/ |
|
class QuestionHelper extends Helper |
|
{ |
|
private $inputStream; |
|
private static $shell; |
|
private static $stty; |
|
|
|
/** |
|
* Asks a question to the user. |
|
* |
|
* @return mixed The user answer |
|
* |
|
* @throws RuntimeException If there is no data to read in the input stream |
|
*/ |
|
public function ask(InputInterface $input, OutputInterface $output, Question $question) |
|
{ |
|
if ($output instanceof ConsoleOutputInterface) { |
|
$output = $output->getErrorOutput(); |
|
} |
|
|
|
if (!$input->isInteractive()) { |
|
$default = $question->getDefault(); |
|
|
|
if (null === $default) { |
|
return $default; |
|
} |
|
|
|
if ($validator = $question->getValidator()) { |
|
return \call_user_func($question->getValidator(), $default); |
|
} elseif ($question instanceof ChoiceQuestion) { |
|
$choices = $question->getChoices(); |
|
|
|
if (!$question->isMultiselect()) { |
|
return isset($choices[$default]) ? $choices[$default] : $default; |
|
} |
|
|
|
$default = explode(',', $default); |
|
foreach ($default as $k => $v) { |
|
$v = trim($v); |
|
$default[$k] = isset($choices[$v]) ? $choices[$v] : $v; |
|
} |
|
} |
|
|
|
return $default; |
|
} |
|
|
|
if ($input instanceof StreamableInputInterface && $stream = $input->getStream()) { |
|
$this->inputStream = $stream; |
|
} |
|
|
|
if (!$question->getValidator()) { |
|
return $this->doAsk($output, $question); |
|
} |
|
|
|
$interviewer = function () use ($output, $question) { |
|
return $this->doAsk($output, $question); |
|
}; |
|
|
|
return $this->validateAttempts($interviewer, $output, $question); |
|
} |
|
|
|
/** |
|
* {@inheritdoc} |
|
*/ |
|
public function getName() |
|
{ |
|
return 'question'; |
|
} |
|
|
|
/** |
|
* Prevents usage of stty. |
|
*/ |
|
public static function disableStty() |
|
{ |
|
self::$stty = false; |
|
} |
|
|
|
/** |
|
* Asks the question to the user. |
|
* |
|
* @return bool|mixed|string|null |
|
* |
|
* @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden |
|
*/ |
|
private function doAsk(OutputInterface $output, Question $question) |
|
{ |
|
$this->writePrompt($output, $question); |
|
|
|
$inputStream = $this->inputStream ?: STDIN; |
|
$autocomplete = $question->getAutocompleterValues(); |
|
|
|
if (null === $autocomplete || !$this->hasSttyAvailable()) { |
|
$ret = false; |
|
if ($question->isHidden()) { |
|
try { |
|
$ret = trim($this->getHiddenResponse($output, $inputStream)); |
|
} catch (RuntimeException $e) { |
|
if (!$question->isHiddenFallback()) { |
|
throw $e; |
|
} |
|
} |
|
} |
|
|
|
if (false === $ret) { |
|
$ret = fgets($inputStream, 4096); |
|
if (false === $ret) { |
|
throw new RuntimeException('Aborted.'); |
|
} |
|
$ret = trim($ret); |
|
} |
|
} else { |
|
$ret = trim($this->autocomplete($output, $question, $inputStream, \is_array($autocomplete) ? $autocomplete : iterator_to_array($autocomplete, false))); |
|
} |
|
|
|
if ($output instanceof ConsoleSectionOutput) { |
|
$output->addContent($ret); |
|
} |
|
|
|
$ret = \strlen($ret) > 0 ? $ret : $question->getDefault(); |
|
|
|
if ($normalizer = $question->getNormalizer()) { |
|
return $normalizer($ret); |
|
} |
|
|
|
return $ret; |
|
} |
|
|
|
/** |
|
* Outputs the question prompt. |
|
*/ |
|
protected function writePrompt(OutputInterface $output, Question $question) |
|
{ |
|
$message = $question->getQuestion(); |
|
|
|
if ($question instanceof ChoiceQuestion) { |
|
$maxWidth = max(array_map([$this, 'strlen'], array_keys($question->getChoices()))); |
|
|
|
$messages = (array) $question->getQuestion(); |
|
foreach ($question->getChoices() as $key => $value) { |
|
$width = $maxWidth - $this->strlen($key); |
|
$messages[] = ' [<info>'.$key.str_repeat(' ', $width).'</info>] '.$value; |
|
} |
|
|
|
$output->writeln($messages); |
|
|
|
$message = $question->getPrompt(); |
|
} |
|
|
|
$output->write($message); |
|
} |
|
|
|
/** |
|
* Outputs an error message. |
|
*/ |
|
protected function writeError(OutputInterface $output, \Exception $error) |
|
{ |
|
if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) { |
|
$message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'); |
|
} else { |
|
$message = '<error>'.$error->getMessage().'</error>'; |
|
} |
|
|
|
$output->writeln($message); |
|
} |
|
|
|
/** |
|
* Autocompletes a question. |
|
* |
|
* @param OutputInterface $output |
|
* @param Question $question |
|
* @param resource $inputStream |
|
*/ |
|
private function autocomplete(OutputInterface $output, Question $question, $inputStream, array $autocomplete): string |
|
{ |
|
$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($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)); |
|
throw new RuntimeException('Aborted.'); |
|
} elseif ("\177" === $c) { // Backspace Character |
|
if (0 === $numMatches && 0 !== $i) { |
|
--$i; |
|
// Move cursor backwards |
|
$output->write("\033[1D"); |
|
} |
|
|
|
if (0 === $i) { |
|
$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($inputStream, 2); |
|
|
|
// A = Up Arrow. B = Down Arrow |
|
if (isset($c[2]) && ('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 { |
|
if ("\x80" <= $c) { |
|
$c .= fread($inputStream, ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3][$c & "\xF0"]); |
|
} |
|
|
|
$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)) { |
|
$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('<hl>'.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $i)).'</hl>'); |
|
// Restore cursor position |
|
$output->write("\0338"); |
|
} |
|
} |
|
|
|
// Reset stty so it behaves normally again |
|
shell_exec(sprintf('stty %s', $sttyMode)); |
|
|
|
return $ret; |
|
} |
|
|
|
/** |
|
* Gets a hidden response from user. |
|
* |
|
* @param OutputInterface $output An Output instance |
|
* @param resource $inputStream The handler resource |
|
* |
|
* @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden |
|
*/ |
|
private function getHiddenResponse(OutputInterface $output, $inputStream): string |
|
{ |
|
if ('\\' === \DIRECTORY_SEPARATOR) { |
|
$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($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 = 'csh' === $shell ? '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 mixed 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(callable $interviewer, OutputInterface $output, Question $question) |
|
{ |
|
$error = null; |
|
$attempts = $question->getMaxAttempts(); |
|
while (null === $attempts || $attempts--) { |
|
if (null !== $error) { |
|
$this->writeError($output, $error); |
|
} |
|
|
|
try { |
|
return $question->getValidator()($interviewer()); |
|
} catch (RuntimeException $e) { |
|
throw $e; |
|
} catch (\Exception $error) { |
|
} |
|
} |
|
|
|
throw $error; |
|
} |
|
|
|
/** |
|
* Returns a valid unix shell. |
|
* |
|
* @return string|bool 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 (['bash', 'zsh', 'ksh', 'csh'] as $sh) { |
|
if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) { |
|
self::$shell = $sh; |
|
break; |
|
} |
|
} |
|
} |
|
|
|
return self::$shell; |
|
} |
|
|
|
/** |
|
* Returns whether Stty is available or not. |
|
*/ |
|
private function hasSttyAvailable(): bool |
|
{ |
|
if (null !== self::$stty) { |
|
return self::$stty; |
|
} |
|
|
|
exec('stty 2>&1', $output, $exitcode); |
|
|
|
return self::$stty = 0 === $exitcode; |
|
} |
|
}
|
|
|