GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — master ( cc39b3...2b121f )
by Anton
04:25 queued 01:05
created

QuestionHelper::mostRecentlyEnteredValue()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 2
b 0
f 0
nc 3
nop 1
dl 0
loc 13
rs 10
1
<?php
2
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <[email protected]>
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 <[email protected]>
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);
0 ignored issues
show
Bug introduced by
It seems like $default can also be of type boolean; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

174
            $default = explode(',', /** @scrutinizer ignore-type */ $default);
Loading history...
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');
0 ignored issues
show
Bug introduced by
The method formatBlock() does not exist on Symfony\Component\Console\Helper\HelperInterface. It seems like you code against a sub-type of Symfony\Component\Console\Helper\HelperInterface such as Symfony\Component\Console\Helper\FormatterHelper. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

226
            $message = $this->getHelperSet()->get('formatter')->/** @scrutinizer ignore-call */ formatBlock($error->getMessage(), 'error');
Loading history...
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);
0 ignored issues
show
Unused Code introduced by
The assignment to $numMatches is dead and can be removed.
Loading history...
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"]);
0 ignored issues
show
Bug introduced by
Are you sure you want to use the bitwise & or did you mean &&?
Loading history...
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;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
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');
0 ignored issues
show
Bug introduced by
The method warning() does not exist on Symfony\Component\Console\Output\OutputInterface. It seems like you code against a sub-type of Symfony\Component\Console\Output\OutputInterface such as Symfony\Component\Console\Style\OutputStyle. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

437
            $errOutput->/** @scrutinizer ignore-call */ 
438
                        warning('The value was possibly truncated by your shell or terminal emulator');
Loading history...
438
        }
439
440
        if (self::$stty && Terminal::hasSttyAvailable()) {
441
            shell_exec('stty '.$sttyMode);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $sttyMode does not seem to be defined for all execution paths leading up to this point.
Loading history...
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);
0 ignored issues
show
Bug introduced by
$error of type void is incompatible with the type Exception expected by parameter $error of Symfony\Component\Consol...ionHelper::writeError(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

469
                $this->writeError($output, /** @scrutinizer ignore-type */ $error);
Loading history...
470
            }
471
472
            try {
473
                return $question->getValidator()($interviewer());
474
            } catch (RuntimeException $e) {
475
                throw $e;
476
            } catch (\Exception $error) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
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();
0 ignored issues
show
Bug introduced by
The call to sapi_windows_cp_get() has too few arguments starting with kind. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

531
            $cp = /** @scrutinizer ignore-call */ sapi_windows_cp_get();

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
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
}
590