Completed
Pull Request — master (#18)
by Paulo Rodrigues
03:15
created

QuestionHelper::writePrompt()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 19
rs 9.4286
cc 3
eloc 10
nc 2
nop 2
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 Rj\FrontendBundle\Command\Options\Legacy;
13
14
use Symfony\Component\Console\Helper\Helper;
15
use Symfony\Component\Console\Exception\InvalidArgumentException;
16
use Symfony\Component\Console\Exception\RuntimeException;
17
use Symfony\Component\Console\Input\InputInterface;
18
use Symfony\Component\Console\Output\ConsoleOutputInterface;
19
use Symfony\Component\Console\Output\OutputInterface;
20
use Symfony\Component\Console\Formatter\OutputFormatterStyle;
21
22
/**
23
 * The QuestionHelper class provides helpers to interact with the user.
24
 *
25
 * @author Fabien Potencier <[email protected]>
26
 */
27
class QuestionHelper extends Helper
28
{
29
    private $inputStream;
30
    private static $shell;
31
    private static $stty;
32
33
    /**
34
     * Asks a question to the user.
35
     *
36
     * @param InputInterface  $input    An InputInterface instance
37
     * @param OutputInterface $output   An OutputInterface instance
38
     * @param Question        $question The question to ask
39
     *
40
     * @return string The user answer
41
     *
42
     * @throws RuntimeException If there is no data to read in the input stream
43
     */
44
    public function ask(InputInterface $input, OutputInterface $output, Question $question)
45
    {
46
        if ($output instanceof ConsoleOutputInterface) {
47
            $output = $output->getErrorOutput();
48
        }
49
50
        if (!$input->isInteractive()) {
51
            return $question->getDefault();
52
        }
53
54
        if (!$question->getValidator()) {
55
            return $this->doAsk($output, $question);
56
        }
57
58
        $that = $this;
59
60
        $interviewer = function () use ($output, $question, $that) {
61
            return $that->doAsk($output, $question);
62
        };
63
64
        return $this->validateAttempts($interviewer, $output, $question);
65
    }
66
67
    /**
68
     * Sets the input stream to read from when interacting with the user.
69
     *
70
     * This is mainly useful for testing purpose.
71
     *
72
     * @param resource $stream The input stream
73
     *
74
     * @throws InvalidArgumentException In case the stream is not a resource
75
     */
76
    public function setInputStream($stream)
77
    {
78
        if (!is_resource($stream)) {
79
            throw new InvalidArgumentException('Input stream must be a valid resource.');
80
        }
81
82
        $this->inputStream = $stream;
83
    }
84
85
    /**
86
     * Returns the helper's input stream.
87
     *
88
     * @return resource
89
     */
90
    public function getInputStream()
91
    {
92
        return $this->inputStream;
93
    }
94
95
    /**
96
     * {@inheritdoc}
97
     */
98
    public function getName()
99
    {
100
        return 'question';
101
    }
102
103
    /**
104
     * Asks the question to the user.
105
     *
106
     * This method is public for PHP 5.3 compatibility, it should be private.
107
     *
108
     * @param OutputInterface $output
109
     * @param Question        $question
110
     *
111
     * @return bool|mixed|null|string
112
     *
113
     * @throws \Exception
114
     * @throws \RuntimeException
115
     */
116
    public function doAsk(OutputInterface $output, Question $question)
117
    {
118
        $this->writePrompt($output, $question);
119
120
        $inputStream = $this->inputStream ?: STDIN;
121
        $autocomplete = $question->getAutocompleterValues();
122
123
        if (null === $autocomplete || !$this->hasSttyAvailable()) {
124
            $ret = false;
125
            if ($question->isHidden()) {
126
                try {
127
                    $ret = trim($this->getHiddenResponse($output, $inputStream));
128
                } catch (\RuntimeException $e) {
129
                    if (!$question->isHiddenFallback()) {
130
                        throw $e;
131
                    }
132
                }
133
            }
134
135
            if (false === $ret) {
136
                $ret = $this->readFromInput($inputStream);
1 ignored issue
show
Bug introduced by
It seems like $inputStream defined by $this->inputStream ?: STDIN on line 120 can also be of type string; however, Rj\FrontendBundle\Comman...Helper::readFromInput() does only seem to accept resource, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
137
            }
138
        } else {
139
            $ret = trim($this->autocomplete($output, $question, $inputStream));
140
        }
141
142
        $ret = strlen($ret) > 0 ? $ret : $question->getDefault();
143
144
        if ($normalizer = $question->getNormalizer()) {
145
            return $normalizer($ret);
146
        }
147
148
        return $ret;
149
    }
150
151
    /**
152
     * Outputs the question prompt.
153
     *
154
     * @param OutputInterface $output
155
     * @param Question        $question
156
     */
157
    protected function writePrompt(OutputInterface $output, Question $question)
158
    {
159
        $message = $question->getQuestion();
160
161
        if ($question instanceof ChoiceQuestion) {
162
            $width = max(array_map('strlen', array_keys($question->getChoices())));
163
164
            $messages = (array) $question->getQuestion();
165
            foreach ($question->getChoices() as $key => $value) {
166
                $messages[] = sprintf("  [<info>%-${width}s</info>] %s", $key, $value);
167
            }
168
169
            $output->writeln($messages);
170
171
            $message = $question->getPrompt();
172
        }
173
174
        $output->write($message);
175
    }
176
177
    /**
178
     * Outputs an error message.
179
     *
180
     * @param OutputInterface $output
181
     * @param \Exception      $error
182
     */
183
    protected function writeError(OutputInterface $output, \Exception $error)
184
    {
185
        if (null !== $this->getHelperSet() && $this->getHelperSet()->has('formatter')) {
186
            $message = $this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error');
1 ignored issue
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.

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...
187
        } else {
188
            $message = '<error>'.$error->getMessage().'</error>';
189
        }
190
191
        $output->writeln($message);
192
    }
193
194
    /**
195
     * Autocompletes a question.
196
     *
197
     * @param OutputInterface $output
198
     * @param Question        $question
199
     *
200
     * @return string
201
     */
202
    private function autocomplete(OutputInterface $output, Question $question, $inputStream)
203
    {
204
        $autocomplete = $question->getAutocompleterValues();
205
        $ret = '';
206
207
        $i = 0;
208
        $ofs = -1;
209
        $matches = $autocomplete;
210
        $numMatches = count($matches);
211
212
        $sttyMode = shell_exec('stty -g');
213
214
        // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
215
        shell_exec('stty -icanon -echo');
216
217
        // Add highlighted text style
218
        $output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white'));
219
220
        // Read a keypress
221
        while (!feof($inputStream)) {
222
            $c = fread($inputStream, 1);
223
224
            // Backspace Character
225
            if ("\177" === $c) {
1 ignored issue
show
Coding Style Comprehensibility introduced by
The string literal \177 does not require double quotes, as per coding-style, please use single quotes.

PHP provides two ways to mark string literals. Either with single quotes 'literal' or with double quotes "literal". The difference between these is that string literals in double quotes may contain variables with are evaluated at run-time as well as escape sequences.

String literals in single quotes on the other hand are evaluated very literally and the only two characters that needs escaping in the literal are the single quote itself (\') and the backslash (\\). Every other character is displayed as is.

Double quoted string literals may contain other variables or more complex escape sequences.

<?php

$singleQuoted = 'Value';
$doubleQuoted = "\tSingle is $singleQuoted";

print $doubleQuoted;

will print an indented: Single is Value

If your string literal does not contain variables or escape sequences, it should be defined using single quotes to make that fact clear.

For more information on PHP string literals and available escape sequences see the PHP core documentation.

Loading history...
226
                if (0 === $numMatches && 0 !== $i) {
227
                    --$i;
228
                    // Move cursor backwards
229
                    $output->write("\033[1D");
230
                }
231
232
                if ($i === 0) {
233
                    $ofs = -1;
234
                    $matches = $autocomplete;
235
                    $numMatches = count($matches);
236
                } else {
237
                    $numMatches = 0;
238
                }
239
240
                // Pop the last character off the end of our string
241
                $ret = substr($ret, 0, $i);
242
            } elseif ("\033" === $c) {
243
                // Did we read an escape sequence?
244
                $c .= fread($inputStream, 2);
245
246
                // A = Up Arrow. B = Down Arrow
247
                if (isset($c[2]) && ('A' === $c[2] || 'B' === $c[2])) {
248
                    if ('A' === $c[2] && -1 === $ofs) {
249
                        $ofs = 0;
250
                    }
251
252
                    if (0 === $numMatches) {
253
                        continue;
254
                    }
255
256
                    $ofs += ('A' === $c[2]) ? -1 : 1;
257
                    $ofs = ($numMatches + $ofs) % $numMatches;
258
                }
259
            } elseif (ord($c) < 32) {
260
                if ("\t" === $c || "\n" === $c) {
261
                    if ($numMatches > 0 && -1 !== $ofs) {
262
                        $ret = $matches[$ofs];
263
                        // Echo out remaining chars for current match
264
                        $output->write(substr($ret, $i));
265
                        $i = strlen($ret);
266
                    }
267
268
                    if ("\n" === $c) {
269
                        $output->write($c);
270
                        break;
271
                    }
272
273
                    $numMatches = 0;
274
                }
275
276
                continue;
277
            } else {
278
                $output->write($c);
279
                $ret .= $c;
280
                ++$i;
281
282
                $numMatches = 0;
283
                $ofs = 0;
284
285
                foreach ($autocomplete as $value) {
1 ignored issue
show
Bug introduced by
The expression $autocomplete of type null|array|object<Traversable> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
286
                    // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
287
                    if (0 === strpos($value, $ret) && $i !== strlen($value)) {
288
                        $matches[$numMatches++] = $value;
289
                    }
290
                }
291
            }
292
293
            // Erase characters from cursor to end of line
294
            $output->write("\033[K");
295
296
            if ($numMatches > 0 && -1 !== $ofs) {
297
                // Save cursor position
298
                $output->write("\0337");
299
                // Write highlighted text
300
                $output->write('<hl>'.substr($matches[$ofs], $i).'</hl>');
301
                // Restore cursor position
302
                $output->write("\0338");
303
            }
304
        }
305
306
        // Reset stty so it behaves normally again
307
        shell_exec(sprintf('stty %s', $sttyMode));
308
309
        return $ret;
310
    }
311
312
    /**
313
     * Gets a hidden response from user.
314
     *
315
     * @param OutputInterface $output An Output instance
316
     *
317
     * @return string The answer
318
     *
319
     * @throws RuntimeException In case the fallback is deactivated and the response cannot be hidden
320
     */
321
    private function getHiddenResponse(OutputInterface $output, $inputStream)
322
    {
323
        if ('\\' === DIRECTORY_SEPARATOR) {
324
            $exe = __DIR__.'/../Resources/bin/hiddeninput.exe';
325
326
            // handle code running from a phar
327
            if ('phar:' === substr(__FILE__, 0, 5)) {
328
                $tmpExe = sys_get_temp_dir().'/hiddeninput.exe';
329
                copy($exe, $tmpExe);
330
                $exe = $tmpExe;
331
            }
332
333
            $value = rtrim(shell_exec($exe));
334
            $output->writeln('');
335
336
            if (isset($tmpExe)) {
337
                unlink($tmpExe);
338
            }
339
340
            return $value;
341
        }
342
343
        if ($this->hasSttyAvailable()) {
344
            $sttyMode = shell_exec('stty -g');
345
346
            shell_exec('stty -echo');
347
            $value = fgets($inputStream, 4096);
348
            shell_exec(sprintf('stty %s', $sttyMode));
349
350
            if (false === $value) {
351
                throw new RuntimeException('Aborted');
352
            }
353
354
            $value = trim($value);
355
            $output->writeln('');
356
357
            return $value;
358
        }
359
360
        if (false !== $shell = $this->getShell()) {
361
            $readCmd = $shell === 'csh' ? 'set mypassword = $<' : 'read -r mypassword';
362
            $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword'", $shell, $readCmd);
363
            $value = rtrim(shell_exec($command));
364
            $output->writeln('');
365
366
            return $value;
367
        }
368
369
        throw new RuntimeException('Unable to hide the response.');
370
    }
371
372
    /**
373
     * Validates an attempt.
374
     *
375
     * @param callable        $interviewer A callable that will ask for a question and return the result
376
     * @param OutputInterface $output      An Output instance
377
     * @param Question        $question    A Question instance
378
     *
379
     * @return string The validated response
380
     *
381
     * @throws \Exception In case the max number of attempts has been reached and no valid response has been given
382
     */
383
    private function validateAttempts($interviewer, OutputInterface $output, Question $question)
384
    {
385
        $error = null;
386
        $attempts = $question->getMaxAttempts();
387
        while (null === $attempts || $attempts--) {
388
            if (null !== $error) {
389
                $this->writeError($output, $error);
390
            }
391
392
            try {
393
                return call_user_func($question->getValidator(), $interviewer());
394
            } catch (\Exception $error) {
1 ignored issue
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
395
            }
396
        }
397
398
        throw $error;
399
    }
400
401
    /**
402
     * Returns a valid unix shell.
403
     *
404
     * @return string|bool The valid shell name, false in case no valid shell is found
405
     */
406
    private function getShell()
407
    {
408
        if (null !== self::$shell) {
409
            return self::$shell;
410
        }
411
412
        self::$shell = false;
413
414
        if (file_exists('/usr/bin/env')) {
415
            // handle other OSs with bash/zsh/ksh/csh if available to hide the answer
416
            $test = "/usr/bin/env %s -c 'echo OK' 2> /dev/null";
417
            foreach (array('bash', 'zsh', 'ksh', 'csh') as $sh) {
418
                if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) {
419
                    self::$shell = $sh;
420
                    break;
421
                }
422
            }
423
        }
424
425
        return self::$shell;
426
    }
427
428
    /**
429
     * Reads user input.
430
     *
431
     * @param resource $stream The input stream
432
     *
433
     * @return string User input
434
     *
435
     * @throws RuntimeException
436
     */
437
    private function readFromInput($stream)
438
    {
439
        if (STDIN === $stream && function_exists('readline')) {
440
            $ret = readline();
441
        } else {
442
            $ret = fgets($stream, 4096);
443
        }
444
445
        if (false === $ret) {
446
            throw new RuntimeException('Aborted');
447
        }
448
449
        return trim($ret);
450
    }
451
452
    /**
453
     * Returns whether Stty is available or not.
454
     *
455
     * @return bool
456
     */
457
    private function hasSttyAvailable()
458
    {
459
        if (null !== self::$stty) {
460
            return self::$stty;
461
        }
462
463
        exec('stty 2>&1', $output, $exitcode);
464
465
        return self::$stty = $exitcode === 0;
466
    }
467
}
468