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