Completed
Push — develop ( f11ef2...d41b65 )
by David
06:01 queued 11s
created

QuestionHelper::doAsk()   F

Complexity

Conditions 17
Paths 672

Size

Total Lines 51

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 17
nc 672
nop 2
dl 0
loc 51
rs 1.5055
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
use function Symfony\Component\String\s;
28
29
/**
30
 * The QuestionHelper class provides helpers to interact with the user.
31
 *
32
 * @author Fabien Potencier <[email protected]>
33
 */
34
class QuestionHelper extends Helper
35
{
36
    private $inputStream;
37
    private static $shell;
38
    private static $stty = true;
39
    private static $stdinIsInteractive;
40
41
    /**
42
     * Asks a question to the user.
43
     *
44
     * @return mixed The user answer
45
     *
46
     * @throws RuntimeException If there is no data to read in the input stream
47
     */
48
    public function ask(InputInterface $input, OutputInterface $output, Question $question)
49
    {
50
        if ($output instanceof ConsoleOutputInterface) {
51
            $output = $output->getErrorOutput();
52
        }
53
54
        if (!$input->isInteractive()) {
55
            return $this->getDefaultAnswer($question);
56
        }
57
58
        if ($input instanceof StreamableInputInterface && $stream = $input->getStream()) {
59
            $this->inputStream = $stream;
60
        }
61
62
        try {
63
            if (!$question->getValidator()) {
64
                return $this->doAsk($output, $question);
65
            }
66
67
            $interviewer = function () use ($output, $question) {
68
                return $this->doAsk($output, $question);
69
            };
70
71
            return $this->validateAttempts($interviewer, $output, $question);
72
        } catch (MissingInputException $exception) {
73
            $input->setInteractive(false);
74
75
            if (null === $fallbackOutput = $this->getDefaultAnswer($question)) {
76
                throw $exception;
77
            }
78
79
            return $fallbackOutput;
80
        }
81
    }
82
83
    /**
84
     * {@inheritdoc}
85
     */
86
    public function getName()
87
    {
88
        return 'question';
89
    }
90
91
    /**
92
     * Prevents usage of stty.
93
     */
94
    public static function disableStty()
95
    {
96
        self::$stty = false;
97
    }
98
99
    /**
100
     * Asks the question to the user.
101
     *
102
     * @return bool|mixed|string|null
103
     *
104
     * @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
105
     */
106
    private function doAsk(OutputInterface $output, Question $question)
107
    {
108
        $this->writePrompt($output, $question);
109
110
        $inputStream = $this->inputStream ?: STDIN;
111
        $autocomplete = $question->getAutocompleterCallback();
112
113
        if (\function_exists('sapi_windows_cp_set')) {
114
            // Codepage used by cmd.exe on Windows to allow special characters (éàüñ).
115
            sapi_windows_cp_set(1252);
116
        }
117
118
        if (null === $autocomplete || !self::$stty || !Terminal::hasSttyAvailable()) {
119
            $ret = false;
120
            if ($question->isHidden()) {
121
                try {
122
                    $hiddenResponse = $this->getHiddenResponse($output, $inputStream, $question->isTrimmable());
123
                    $ret = $question->isTrimmable() ? trim($hiddenResponse) : $hiddenResponse;
124
                } catch (RuntimeException $e) {
125
                    if (!$question->isHiddenFallback()) {
126
                        throw $e;
127
                    }
128
                }
129
            }
130
131
            if (false === $ret) {
132
                $ret = fgets($inputStream, 4096);
133
                if (false === $ret) {
134
                    throw new MissingInputException('Aborted.');
135
                }
136
                if ($question->isTrimmable()) {
137
                    $ret = trim($ret);
138
                }
139
            }
140
        } else {
141
            $autocomplete = $this->autocomplete($output, $question, $inputStream, $autocomplete);
142
            $ret = $question->isTrimmable() ? trim($autocomplete) : $autocomplete;
143
        }
144
145
        if ($output instanceof ConsoleSectionOutput) {
146
            $output->addContent($ret);
147
        }
148
149
        $ret = \strlen($ret) > 0 ? $ret : $question->getDefault();
150
151
        if ($normalizer = $question->getNormalizer()) {
152
            return $normalizer($ret);
153
        }
154
155
        return $ret;
156
    }
157
158
    /**
159
     * @return mixed
160
     */
161
    private function getDefaultAnswer(Question $question)
162
    {
163
        $default = $question->getDefault();
164
165
        if (null === $default) {
166
            return $default;
167
        }
168
169
        if ($validator = $question->getValidator()) {
0 ignored issues
show
Unused Code introduced by
$validator is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
170
            return \call_user_func($question->getValidator(), $default);
171
        } elseif ($question instanceof ChoiceQuestion) {
172
            $choices = $question->getChoices();
173
174
            if (!$question->isMultiselect()) {
175
                return isset($choices[$default]) ? $choices[$default] : $default;
176
            }
177
178
            $default = explode(',', $default);
179
            foreach ($default as $k => $v) {
180
                $v = $question->isTrimmable() ? trim($v) : $v;
181
                $default[$k] = isset($choices[$v]) ? $choices[$v] : $v;
182
            }
183
        }
184
185
        return $default;
186
    }
187
188
    /**
189
     * Outputs the question prompt.
190
     */
191
    protected function writePrompt(OutputInterface $output, Question $question)
192
    {
193
        $message = $question->getQuestion();
194
195
        if ($question instanceof ChoiceQuestion) {
196
            $output->writeln(array_merge([
0 ignored issues
show
Documentation introduced by
array_merge(array($quest...ces($question, 'info')) is of type array<integer,string,{"0":"string"}>, but the function expects a string|object<Symfony\Co...onsole\Output\iterable>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
197
                $question->getQuestion(),
198
            ], $this->formatChoiceQuestionChoices($question, 'info')));
199
200
            $message = $question->getPrompt();
201
        }
202
203
        $output->write($message);
204
    }
205
206
    /**
207
     * @return string[]
208
     */
209
    protected function formatChoiceQuestionChoices(ChoiceQuestion $question, string $tag)
210
    {
211
        $messages = [];
212
213
        $maxWidth = max(array_map('self::strlen', array_keys($choices = $question->getChoices())));
214
215
        foreach ($choices as $key => $value) {
216
            $padding = str_repeat(' ', $maxWidth - self::strlen($key));
217
218
            $messages[] = sprintf("  [<$tag>%s$padding</$tag>] %s", $key, $value);
219
        }
220
221
        return $messages;
222
    }
223
224
    /**
225
     * Outputs an error message.
226
     */
227
    protected function writeError(OutputInterface $output, \Exception $error)
228
    {
229
        if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) {
230
            $message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error');
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Symfony\Component\Console\Helper\HelperInterface as the method formatBlock() does only exist in the following implementations of said interface: Symfony\Component\Console\Helper\FormatterHelper, Symfony\Component\Console\Helper\FormatterHelper.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
231
        } else {
232
            $message = '<error>'.$error->getMessage().'</error>';
233
        }
234
235
        $output->writeln($message);
236
    }
237
238
    /**
239
     * Autocompletes a question.
240
     *
241
     * @param resource $inputStream
242
     */
243
    private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string
244
    {
245
        $cursor = new Cursor($output, $inputStream);
246
247
        $fullChoice = '';
248
        $ret = '';
249
250
        $i = 0;
251
        $ofs = -1;
252
        $matches = $autocomplete($ret);
253
        $numMatches = \count($matches);
254
255
        $sttyMode = shell_exec('stty -g');
256
257
        // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
258
        shell_exec('stty -icanon -echo');
259
260
        // Add highlighted text style
261
        $output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white'));
262
263
        // Read a keypress
264
        while (!feof($inputStream)) {
265
            $c = fread($inputStream, 1);
266
267
            // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false.
268
            if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) {
269
                shell_exec(sprintf('stty %s', $sttyMode));
270
                throw new MissingInputException('Aborted.');
271
            } elseif ("\177" === $c) { // Backspace Character
272
                if (0 === $numMatches && 0 !== $i) {
273
                    --$i;
274
                    $cursor->moveLeft(s($fullChoice)->slice(-1)->width(false));
275
276
                    $fullChoice = self::substr($fullChoice, 0, $i);
277
                }
278
279
                if (0 === $i) {
280
                    $ofs = -1;
281
                    $matches = $autocomplete($ret);
282
                    $numMatches = \count($matches);
283
                } else {
284
                    $numMatches = 0;
285
                }
286
287
                // Pop the last character off the end of our string
288
                $ret = self::substr($ret, 0, $i);
289
            } elseif ("\033" === $c) {
290
                // Did we read an escape sequence?
291
                $c .= fread($inputStream, 2);
292
293
                // A = Up Arrow. B = Down Arrow
294
                if (isset($c[2]) && ('A' === $c[2] || 'B' === $c[2])) {
295
                    if ('A' === $c[2] && -1 === $ofs) {
296
                        $ofs = 0;
297
                    }
298
299
                    if (0 === $numMatches) {
300
                        continue;
301
                    }
302
303
                    $ofs += ('A' === $c[2]) ? -1 : 1;
304
                    $ofs = ($numMatches + $ofs) % $numMatches;
305
                }
306
            } elseif (\ord($c) < 32) {
307
                if ("\t" === $c || "\n" === $c) {
308
                    if ($numMatches > 0 && -1 !== $ofs) {
309
                        $ret = (string) $matches[$ofs];
310
                        // Echo out remaining chars for current match
311
                        $remainingCharacters = substr($ret, \strlen(trim($this->mostRecentlyEnteredValue($fullChoice))));
312
                        $output->write($remainingCharacters);
313
                        $fullChoice .= $remainingCharacters;
314
                        $i = self::strlen($fullChoice);
315
316
                        $matches = array_filter(
317
                            $autocomplete($ret),
318
                            function ($match) use ($ret) {
319
                                return '' === $ret || 0 === strpos($match, $ret);
320
                            }
321
                        );
322
                        $numMatches = \count($matches);
0 ignored issues
show
Unused Code introduced by
$numMatches is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
323
                        $ofs = -1;
324
                    }
325
326
                    if ("\n" === $c) {
327
                        $output->write($c);
328
                        break;
329
                    }
330
331
                    $numMatches = 0;
332
                }
333
334
                continue;
335
            } else {
336
                if ("\x80" <= $c) {
337
                    $c .= fread($inputStream, ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3][$c & "\xF0"]);
338
                }
339
340
                $output->write($c);
341
                $ret .= $c;
342
                $fullChoice .= $c;
343
                ++$i;
344
345
                $tempRet = $ret;
346
347
                if ($question instanceof ChoiceQuestion && $question->isMultiselect()) {
348
                    $tempRet = $this->mostRecentlyEnteredValue($fullChoice);
349
                }
350
351
                $numMatches = 0;
352
                $ofs = 0;
353
354
                foreach ($autocomplete($ret) as $value) {
355
                    // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
356
                    if (0 === strpos($value, $tempRet)) {
357
                        $matches[$numMatches++] = $value;
358
                    }
359
                }
360
            }
361
362
            $cursor->clearLineAfter();
363
364
            if ($numMatches > 0 && -1 !== $ofs) {
365
                $cursor->savePosition();
366
                // Write highlighted text, complete the partially entered response
367
                $charactersEntered = \strlen(trim($this->mostRecentlyEnteredValue($fullChoice)));
368
                $output->write('<hl>'.OutputFormatter::escapeTrailingBackslash(substr($matches[$ofs], $charactersEntered)).'</hl>');
369
                $cursor->restorePosition();
370
            }
371
        }
372
373
        // Reset stty so it behaves normally again
374
        shell_exec(sprintf('stty %s', $sttyMode));
375
376
        return $fullChoice;
377
    }
378
379
    private function mostRecentlyEnteredValue(string $entered): string
380
    {
381
        // Determine the most recent value that the user entered
382
        if (false === strpos($entered, ',')) {
383
            return $entered;
384
        }
385
386
        $choices = explode(',', $entered);
387
        if (\strlen($lastChoice = trim($choices[\count($choices) - 1])) > 0) {
388
            return $lastChoice;
389
        }
390
391
        return $entered;
392
    }
393
394
    /**
395
     * Gets a hidden response from user.
396
     *
397
     * @param resource $inputStream The handler resource
398
     * @param bool     $trimmable   Is the answer trimmable
399
     *
400
     * @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
401
     */
402
    private function getHiddenResponse(OutputInterface $output, $inputStream, bool $trimmable = true): string
403
    {
404
        if ('\\' === \DIRECTORY_SEPARATOR) {
405
            $exe = __DIR__.'/../Resources/bin/hiddeninput.exe';
406
407
            // handle code running from a phar
408
            if ('phar:' === substr(__FILE__, 0, 5)) {
409
                $tmpExe = sys_get_temp_dir().'/hiddeninput.exe';
410
                copy($exe, $tmpExe);
411
                $exe = $tmpExe;
412
            }
413
414
            $sExec = shell_exec($exe);
415
            $value = $trimmable ? rtrim($sExec) : $sExec;
416
            $output->writeln('');
417
418
            if (isset($tmpExe)) {
419
                unlink($tmpExe);
420
            }
421
422
            return $value;
423
        }
424
425
        if (self::$stty && Terminal::hasSttyAvailable()) {
426
            $sttyMode = shell_exec('stty -g');
427
            shell_exec('stty -echo');
428
        } elseif ($this->isInteractiveInput($inputStream)) {
429
            throw new RuntimeException('Unable to hide the response.');
430
        }
431
432
        $value = fgets($inputStream, 4096);
433
434
        if (self::$stty && Terminal::hasSttyAvailable()) {
435
            shell_exec(sprintf('stty %s', $sttyMode));
0 ignored issues
show
Bug introduced by
The variable $sttyMode does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
436
        }
437
438
        if (false === $value) {
439
            throw new MissingInputException('Aborted.');
440
        }
441
        if ($trimmable) {
442
            $value = trim($value);
443
        }
444
        $output->writeln('');
445
446
        return $value;
447
    }
448
449
    /**
450
     * Validates an attempt.
451
     *
452
     * @param callable $interviewer A callable that will ask for a question and return the result
453
     *
454
     * @return mixed The validated response
455
     *
456
     * @throws \Exception In case the max number of attempts has been reached and no valid response has been given
457
     */
458
    private function validateAttempts(callable $interviewer, OutputInterface $output, Question $question)
459
    {
460
        $error = null;
461
        $attempts = $question->getMaxAttempts();
462
463
        while (null === $attempts || $attempts--) {
464
            if (null !== $error) {
465
                $this->writeError($output, $error);
466
            }
467
468
            try {
469
                return $question->getValidator()($interviewer());
470
            } catch (RuntimeException $e) {
471
                throw $e;
472
            } catch (\Exception $error) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
473
            }
474
        }
475
476
        throw $error;
477
    }
478
479
    private function isInteractiveInput($inputStream): bool
480
    {
481
        if ('php://stdin' !== (stream_get_meta_data($inputStream)['uri'] ?? null)) {
482
            return false;
483
        }
484
485
        if (null !== self::$stdinIsInteractive) {
486
            return self::$stdinIsInteractive;
487
        }
488
489
        if (\function_exists('stream_isatty')) {
490
            return self::$stdinIsInteractive = stream_isatty(fopen('php://stdin', 'r'));
491
        }
492
493
        if (\function_exists('posix_isatty')) {
494
            return self::$stdinIsInteractive = posix_isatty(fopen('php://stdin', 'r'));
495
        }
496
497
        if (!\function_exists('exec')) {
498
            return self::$stdinIsInteractive = true;
499
        }
500
501
        exec('stty 2> /dev/null', $output, $status);
502
503
        return self::$stdinIsInteractive = 1 !== $status;
504
    }
505
}
506