1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Console\Helper; 13 14use Symfony\Component\Console\Exception\MissingInputException; 15use Symfony\Component\Console\Exception\RuntimeException; 16use Symfony\Component\Console\Formatter\OutputFormatter; 17use Symfony\Component\Console\Formatter\OutputFormatterStyle; 18use Symfony\Component\Console\Input\InputInterface; 19use Symfony\Component\Console\Input\StreamableInputInterface; 20use Symfony\Component\Console\Output\ConsoleOutputInterface; 21use Symfony\Component\Console\Output\ConsoleSectionOutput; 22use Symfony\Component\Console\Output\OutputInterface; 23use Symfony\Component\Console\Question\ChoiceQuestion; 24use Symfony\Component\Console\Question\Question; 25use Symfony\Component\Console\Terminal; 26 27/** 28 * The QuestionHelper class provides helpers to interact with the user. 29 * 30 * @author Fabien Potencier <fabien@symfony.com> 31 */ 32class QuestionHelper extends Helper 33{ 34 private $inputStream; 35 private static $shell; 36 private static $stty = true; 37 private static $stdinIsInteractive; 38 39 /** 40 * Asks a question to the user. 41 * 42 * @return mixed The user answer 43 * 44 * @throws RuntimeException If there is no data to read in the input stream 45 */ 46 public function ask(InputInterface $input, OutputInterface $output, Question $question) 47 { 48 if ($output instanceof ConsoleOutputInterface) { 49 $output = $output->getErrorOutput(); 50 } 51 52 if (!$input->isInteractive()) { 53 return $this->getDefaultAnswer($question); 54 } 55 56 if ($input instanceof StreamableInputInterface && $stream = $input->getStream()) { 57 $this->inputStream = $stream; 58 } 59 60 try { 61 if (!$question->getValidator()) { 62 return $this->doAsk($output, $question); 63 } 64 65 $interviewer = function () use ($output, $question) { 66 return $this->doAsk($output, $question); 67 }; 68 69 return $this->validateAttempts($interviewer, $output, $question); 70 } catch (MissingInputException $exception) { 71 $input->setInteractive(false); 72 73 if (null === $fallbackOutput = $this->getDefaultAnswer($question)) { 74 throw $exception; 75 } 76 77 return $fallbackOutput; 78 } 79 } 80 81 /** 82 * {@inheritdoc} 83 */ 84 public function getName() 85 { 86 return 'question'; 87 } 88 89 /** 90 * Prevents usage of stty. 91 */ 92 public static function disableStty() 93 { 94 self::$stty = false; 95 } 96 97 /** 98 * Asks the question to the user. 99 * 100 * @return mixed 101 * 102 * @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden 103 */ 104 private function doAsk(OutputInterface $output, Question $question) 105 { 106 $this->writePrompt($output, $question); 107 108 $inputStream = $this->inputStream ?: \STDIN; 109 $autocomplete = $question->getAutocompleterCallback(); 110 111 if (null === $autocomplete || !self::$stty || !Terminal::hasSttyAvailable()) { 112 $ret = false; 113 if ($question->isHidden()) { 114 try { 115 $hiddenResponse = $this->getHiddenResponse($output, $inputStream, $question->isTrimmable()); 116 $ret = $question->isTrimmable() ? trim($hiddenResponse) : $hiddenResponse; 117 } catch (RuntimeException $e) { 118 if (!$question->isHiddenFallback()) { 119 throw $e; 120 } 121 } 122 } 123 124 if (false === $ret) { 125 $cp = $this->setIOCodepage(); 126 $ret = fgets($inputStream, 4096); 127 $ret = $this->resetIOCodepage($cp, $ret); 128 if (false === $ret) { 129 throw new MissingInputException('Aborted.'); 130 } 131 if ($question->isTrimmable()) { 132 $ret = trim($ret); 133 } 134 } 135 } else { 136 $autocomplete = $this->autocomplete($output, $question, $inputStream, $autocomplete); 137 $ret = $question->isTrimmable() ? trim($autocomplete) : $autocomplete; 138 } 139 140 if ($output instanceof ConsoleSectionOutput) { 141 $output->addContent($ret); 142 } 143 144 $ret = \strlen($ret) > 0 ? $ret : $question->getDefault(); 145 146 if ($normalizer = $question->getNormalizer()) { 147 return $normalizer($ret); 148 } 149 150 return $ret; 151 } 152 153 /** 154 * @return mixed 155 */ 156 private function getDefaultAnswer(Question $question) 157 { 158 $default = $question->getDefault(); 159 160 if (null === $default) { 161 return $default; 162 } 163 164 if ($validator = $question->getValidator()) { 165 return \call_user_func($question->getValidator(), $default); 166 } elseif ($question instanceof ChoiceQuestion) { 167 $choices = $question->getChoices(); 168 169 if (!$question->isMultiselect()) { 170 return $choices[$default] ?? $default; 171 } 172 173 $default = explode(',', $default); 174 foreach ($default as $k => $v) { 175 $v = $question->isTrimmable() ? trim($v) : $v; 176 $default[$k] = $choices[$v] ?? $v; 177 } 178 } 179 180 return $default; 181 } 182 183 /** 184 * Outputs the question prompt. 185 */ 186 protected function writePrompt(OutputInterface $output, Question $question) 187 { 188 $message = $question->getQuestion(); 189 190 if ($question instanceof ChoiceQuestion) { 191 $output->writeln(array_merge([ 192 $question->getQuestion(), 193 ], $this->formatChoiceQuestionChoices($question, 'info'))); 194 195 $message = $question->getPrompt(); 196 } 197 198 $output->write($message); 199 } 200 201 /** 202 * @param string $tag 203 * 204 * @return string[] 205 */ 206 protected function formatChoiceQuestionChoices(ChoiceQuestion $question, $tag) 207 { 208 $messages = []; 209 210 $maxWidth = max(array_map('self::strlen', array_keys($choices = $question->getChoices()))); 211 212 foreach ($choices as $key => $value) { 213 $padding = str_repeat(' ', $maxWidth - self::strlen($key)); 214 215 $messages[] = sprintf(" [<$tag>%s$padding</$tag>] %s", $key, $value); 216 } 217 218 return $messages; 219 } 220 221 /** 222 * Outputs an error message. 223 */ 224 protected function writeError(OutputInterface $output, \Exception $error) 225 { 226 if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) { 227 $message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'); 228 } else { 229 $message = '<error>'.$error->getMessage().'</error>'; 230 } 231 232 $output->writeln($message); 233 } 234 235 /** 236 * Autocompletes a question. 237 * 238 * @param resource $inputStream 239 */ 240 private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string 241 { 242 $fullChoice = ''; 243 $ret = ''; 244 245 $i = 0; 246 $ofs = -1; 247 $matches = $autocomplete($ret); 248 $numMatches = \count($matches); 249 250 $sttyMode = shell_exec('stty -g'); 251 252 // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) 253 shell_exec('stty -icanon -echo'); 254 255 // Add highlighted text style 256 $output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white')); 257 258 // Read a keypress 259 while (!feof($inputStream)) { 260 $c = fread($inputStream, 1); 261 262 // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. 263 if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) { 264 shell_exec(sprintf('stty %s', $sttyMode)); 265 throw new MissingInputException('Aborted.'); 266 } elseif ("\177" === $c) { // Backspace Character 267 if (0 === $numMatches && 0 !== $i) { 268 --$i; 269 $fullChoice = self::substr($fullChoice, 0, $i); 270 // Move cursor backwards 271 $output->write("\033[1D"); 272 } 273 274 if (0 === $i) { 275 $ofs = -1; 276 $matches = $autocomplete($ret); 277 $numMatches = \count($matches); 278 } else { 279 $numMatches = 0; 280 } 281 282 // Pop the last character off the end of our string 283 $ret = self::substr($ret, 0, $i); 284 } elseif ("\033" === $c) { 285 // Did we read an escape sequence? 286 $c .= fread($inputStream, 2); 287 288 // A = Up Arrow. B = Down Arrow 289 if (isset($c[2]) && ('A' === $c[2] || 'B' === $c[2])) { 290 if ('A' === $c[2] && -1 === $ofs) { 291 $ofs = 0; 292 } 293 294 if (0 === $numMatches) { 295 continue; 296 } 297 298 $ofs += ('A' === $c[2]) ? -1 : 1; 299 $ofs = ($numMatches + $ofs) % $numMatches; 300 } 301 } elseif (\ord($c) < 32) { 302 if ("\t" === $c || "\n" === $c) { 303 if ($numMatches > 0 && -1 !== $ofs) { 304 $ret = (string) $matches[$ofs]; 305 // Echo out remaining chars for current match 306 $remainingCharacters = substr($ret, \strlen(trim($this->mostRecentlyEnteredValue($fullChoice)))); 307 $output->write($remainingCharacters); 308 $fullChoice .= $remainingCharacters; 309 $i = (false === $encoding = mb_detect_encoding($fullChoice, null, true)) ? \strlen($fullChoice) : mb_strlen($fullChoice, $encoding); 310 311 $matches = array_filter( 312 $autocomplete($ret), 313 function ($match) use ($ret) { 314 return '' === $ret || str_starts_with($match, $ret); 315 } 316 ); 317 $numMatches = \count($matches); 318 $ofs = -1; 319 } 320 321 if ("\n" === $c) { 322 $output->write($c); 323 break; 324 } 325 326 $numMatches = 0; 327 } 328 329 continue; 330 } else { 331 if ("\x80" <= $c) { 332 $c .= fread($inputStream, ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3][$c & "\xF0"]); 333 } 334 335 $output->write($c); 336 $ret .= $c; 337 $fullChoice .= $c; 338 ++$i; 339 340 $tempRet = $ret; 341 342 if ($question instanceof ChoiceQuestion && $question->isMultiselect()) { 343 $tempRet = $this->mostRecentlyEnteredValue($fullChoice); 344 } 345 346 $numMatches = 0; 347 $ofs = 0; 348 349 foreach ($autocomplete($ret) as $value) { 350 // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle) 351 if (str_starts_with($value, $tempRet)) { 352 $matches[$numMatches++] = $value; 353 } 354 } 355 } 356 357 // Erase characters from cursor to end of line 358 $output->write("\033[K"); 359 360 if ($numMatches > 0 && -1 !== $ofs) { 361 // Save cursor position 362 $output->write("\0337"); 363 // Write highlighted text, complete the partially entered response 364 $charactersEntered = \strlen(trim($this->mostRecentlyEnteredValue($fullChoice))); 365 $output->write('<hl>'.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).'</hl>'); 366 // Restore cursor position 367 $output->write("\0338"); 368 } 369 } 370 371 // Reset stty so it behaves normally again 372 shell_exec(sprintf('stty %s', $sttyMode)); 373 374 return $fullChoice; 375 } 376 377 private function mostRecentlyEnteredValue(string $entered): string 378 { 379 // Determine the most recent value that the user entered 380 if (!str_contains($entered, ',')) { 381 return $entered; 382 } 383 384 $choices = explode(',', $entered); 385 if ('' !== $lastChoice = trim($choices[\count($choices) - 1])) { 386 return $lastChoice; 387 } 388 389 return $entered; 390 } 391 392 /** 393 * Gets a hidden response from user. 394 * 395 * @param resource $inputStream The handler resource 396 * @param bool $trimmable Is the answer trimmable 397 * 398 * @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden 399 */ 400 private function getHiddenResponse(OutputInterface $output, $inputStream, bool $trimmable = true): string 401 { 402 if ('\\' === \DIRECTORY_SEPARATOR) { 403 $exe = __DIR__.'/../Resources/bin/hiddeninput.exe'; 404 405 // handle code running from a phar 406 if ('phar:' === substr(__FILE__, 0, 5)) { 407 $tmpExe = sys_get_temp_dir().'/hiddeninput.exe'; 408 copy($exe, $tmpExe); 409 $exe = $tmpExe; 410 } 411 412 $sExec = shell_exec('"'.$exe.'"'); 413 $value = $trimmable ? rtrim($sExec) : $sExec; 414 $output->writeln(''); 415 416 if (isset($tmpExe)) { 417 unlink($tmpExe); 418 } 419 420 return $value; 421 } 422 423 if (self::$stty && Terminal::hasSttyAvailable()) { 424 $sttyMode = shell_exec('stty -g'); 425 shell_exec('stty -echo'); 426 } elseif ($this->isInteractiveInput($inputStream)) { 427 throw new RuntimeException('Unable to hide the response.'); 428 } 429 430 $value = fgets($inputStream, 4096); 431 432 if (self::$stty && Terminal::hasSttyAvailable()) { 433 shell_exec(sprintf('stty %s', $sttyMode)); 434 } 435 436 if (false === $value) { 437 throw new MissingInputException('Aborted.'); 438 } 439 if ($trimmable) { 440 $value = trim($value); 441 } 442 $output->writeln(''); 443 444 return $value; 445 } 446 447 /** 448 * Validates an attempt. 449 * 450 * @param callable $interviewer A callable that will ask for a question and return the result 451 * 452 * @return mixed The validated response 453 * 454 * @throws \Exception In case the max number of attempts has been reached and no valid response has been given 455 */ 456 private function validateAttempts(callable $interviewer, OutputInterface $output, Question $question) 457 { 458 $error = null; 459 $attempts = $question->getMaxAttempts(); 460 461 while (null === $attempts || $attempts--) { 462 if (null !== $error) { 463 $this->writeError($output, $error); 464 } 465 466 try { 467 return $question->getValidator()($interviewer()); 468 } catch (RuntimeException $e) { 469 throw $e; 470 } catch (\Exception $error) { 471 } 472 } 473 474 throw $error; 475 } 476 477 private function isInteractiveInput($inputStream): bool 478 { 479 if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) { 480 return false; 481 } 482 483 if (null !== self::$stdinIsInteractive) { 484 return self::$stdinIsInteractive; 485 } 486 487 if (\function_exists('stream_isatty')) { 488 return self::$stdinIsInteractive = stream_isatty(fopen('php://stdin', 'r')); 489 } 490 491 if (\function_exists('posix_isatty')) { 492 return self::$stdinIsInteractive = posix_isatty(fopen('php://stdin', 'r')); 493 } 494 495 if (!\function_exists('exec')) { 496 return self::$stdinIsInteractive = true; 497 } 498 499 exec('stty 2> /dev/null', $output, $status); 500 501 return self::$stdinIsInteractive = 1 !== $status; 502 } 503 504 /** 505 * Sets console I/O to the host code page. 506 * 507 * @return int Previous code page in IBM/EBCDIC format 508 */ 509 private function setIOCodepage(): int 510 { 511 if (\function_exists('sapi_windows_cp_set')) { 512 $cp = sapi_windows_cp_get(); 513 sapi_windows_cp_set(sapi_windows_cp_get('oem')); 514 515 return $cp; 516 } 517 518 return 0; 519 } 520 521 /** 522 * Sets console I/O to the specified code page and converts the user input. 523 * 524 * @param string|false $input 525 * 526 * @return string|false 527 */ 528 private function resetIOCodepage(int $cp, $input) 529 { 530 if (0 !== $cp) { 531 sapi_windows_cp_set($cp); 532 533 if (false !== $input && '' !== $input) { 534 $input = sapi_windows_cp_conv(sapi_windows_cp_get('oem'), $cp, $input); 535 } 536 } 537 538 return $input; 539 } 540} 541