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