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
12namespace Symfony\Component\Console\Helper;
13
14use Symfony\Component\Console\Output\OutputInterface;
15use Symfony\Component\Console\Formatter\OutputFormatterStyle;
16
17/**
18 * The Dialog class provides helpers to interact with the user.
19 *
20 * @author Fabien Potencier <fabien@symfony.com>
21 */
22class DialogHelper extends InputAwareHelper
23{
24    private $inputStream;
25    private static $shell;
26    private static $stty;
27
28    /**
29     * Asks the user to select a value.
30     *
31     * @param OutputInterface $output       An Output instance
32     * @param string|array    $question     The question to ask
33     * @param array           $choices      List of choices to pick from
34     * @param bool|string     $default      The default answer if the user enters nothing
35     * @param bool|int        $attempts Max number of times to ask before giving up (false by default, which means infinite)
36     * @param string          $errorMessage Message which will be shown if invalid value from choice list would be picked
37     * @param bool            $multiselect  Select more than one value separated by comma
38     *
39     * @return int|string|array     The selected value or values (the key of the choices array)
40     *
41     * @throws \InvalidArgumentException
42     */
43    public function select(OutputInterface $output, $question, $choices, $default = null, $attempts = false, $errorMessage = 'Value "%s" is invalid', $multiselect = false)
44    {
45        $width = max(array_map('strlen', array_keys($choices)));
46
47        $messages = (array) $question;
48        foreach ($choices as $key => $value) {
49            $messages[] = sprintf("  [<info>%-${width}s</info>] %s", $key, $value);
50        }
51
52        $output->writeln($messages);
53
54        $result = $this->askAndValidate($output, '> ', function ($picked) use ($choices, $errorMessage, $multiselect) {
55            // Collapse all spaces.
56            $selectedChoices = str_replace(" ", "", $picked);
57
58            if ($multiselect) {
59                // Check for a separated comma values
60                if (!preg_match('/^[a-zA-Z0-9_-]+(?:,[a-zA-Z0-9_-]+)*$/', $selectedChoices, $matches)) {
61                    throw new \InvalidArgumentException(sprintf($errorMessage, $picked));
62                }
63                $selectedChoices = explode(",", $selectedChoices);
64            } else {
65                $selectedChoices = array($picked);
66            }
67
68            $multiselectChoices = array();
69
70            foreach ($selectedChoices as $value) {
71                if (empty($choices[$value])) {
72                    throw new \InvalidArgumentException(sprintf($errorMessage, $value));
73                }
74                array_push($multiselectChoices, $value);
75            }
76
77            if ($multiselect) {
78                return $multiselectChoices;
79            }
80
81            return $picked;
82        }, $attempts, $default);
83
84        return $result;
85    }
86
87    /**
88     * Asks a question to the user.
89     *
90     * @param OutputInterface $output       An Output instance
91     * @param string|array    $question     The question to ask
92     * @param string          $default      The default answer if none is given by the user
93     * @param array           $autocomplete List of values to autocomplete
94     *
95     * @return string The user answer
96     *
97     * @throws \RuntimeException If there is no data to read in the input stream
98     */
99    public function ask(OutputInterface $output, $question, $default = null, array $autocomplete = null)
100    {
101        if ($this->input && !$this->input->isInteractive()) {
102            return $default;
103        }
104
105        $output->write($question);
106
107        $inputStream = $this->inputStream ?: STDIN;
108
109        if (null === $autocomplete || !$this->hasSttyAvailable()) {
110            $ret = fgets($inputStream, 4096);
111            if (false === $ret) {
112                throw new \RuntimeException('Aborted');
113            }
114            $ret = trim($ret);
115        } else {
116            $ret = '';
117
118            $i = 0;
119            $ofs = -1;
120            $matches = $autocomplete;
121            $numMatches = count($matches);
122
123            $sttyMode = shell_exec('stty -g');
124
125            // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead)
126            shell_exec('stty -icanon -echo');
127
128            // Add highlighted text style
129            $output->getFormatter()->setStyle('hl', new OutputFormatterStyle('black', 'white'));
130
131            // Read a keypress
132            while (!feof($inputStream)) {
133                $c = fread($inputStream, 1);
134
135                // Backspace Character
136                if ("\177" === $c) {
137                    if (0 === $numMatches && 0 !== $i) {
138                        $i--;
139                        // Move cursor backwards
140                        $output->write("\033[1D");
141                    }
142
143                    if ($i === 0) {
144                        $ofs = -1;
145                        $matches = $autocomplete;
146                        $numMatches = count($matches);
147                    } else {
148                        $numMatches = 0;
149                    }
150
151                    // Pop the last character off the end of our string
152                    $ret = substr($ret, 0, $i);
153                } elseif ("\033" === $c) { // Did we read an escape sequence?
154                    $c .= fread($inputStream, 2);
155
156                    // A = Up Arrow. B = Down Arrow
157                    if ('A' === $c[2] || 'B' === $c[2]) {
158                        if ('A' === $c[2] && -1 === $ofs) {
159                            $ofs = 0;
160                        }
161
162                        if (0 === $numMatches) {
163                            continue;
164                        }
165
166                        $ofs += ('A' === $c[2]) ? -1 : 1;
167                        $ofs = ($numMatches + $ofs) % $numMatches;
168                    }
169                } elseif (ord($c) < 32) {
170                    if ("\t" === $c || "\n" === $c) {
171                        if ($numMatches > 0 && -1 !== $ofs) {
172                            $ret = $matches[$ofs];
173                            // Echo out remaining chars for current match
174                            $output->write(substr($ret, $i));
175                            $i = strlen($ret);
176                        }
177
178                        if ("\n" === $c) {
179                            $output->write($c);
180                            break;
181                        }
182
183                        $numMatches = 0;
184                    }
185
186                    continue;
187                } else {
188                    $output->write($c);
189                    $ret .= $c;
190                    $i++;
191
192                    $numMatches = 0;
193                    $ofs = 0;
194
195                    foreach ($autocomplete as $value) {
196                        // If typed characters match the beginning chunk of value (e.g. [AcmeDe]moBundle)
197                        if (0 === strpos($value, $ret) && $i !== strlen($value)) {
198                            $matches[$numMatches++] = $value;
199                        }
200                    }
201                }
202
203                // Erase characters from cursor to end of line
204                $output->write("\033[K");
205
206                if ($numMatches > 0 && -1 !== $ofs) {
207                    // Save cursor position
208                    $output->write("\0337");
209                    // Write highlighted text
210                    $output->write('<hl>'.substr($matches[$ofs], $i).'</hl>');
211                    // Restore cursor position
212                    $output->write("\0338");
213                }
214            }
215
216            // Reset stty so it behaves normally again
217            shell_exec(sprintf('stty %s', $sttyMode));
218        }
219
220        return strlen($ret) > 0 ? $ret : $default;
221    }
222
223    /**
224     * Asks a confirmation to the user.
225     *
226     * The question will be asked until the user answers by nothing, yes, or no.
227     *
228     * @param OutputInterface $output   An Output instance
229     * @param string|array    $question The question to ask
230     * @param bool            $default  The default answer if the user enters nothing
231     *
232     * @return bool    true if the user has confirmed, false otherwise
233     */
234    public function askConfirmation(OutputInterface $output, $question, $default = true)
235    {
236        $answer = 'z';
237        while ($answer && !in_array(strtolower($answer[0]), array('y', 'n'))) {
238            $answer = $this->ask($output, $question);
239        }
240
241        if (false === $default) {
242            return $answer && 'y' == strtolower($answer[0]);
243        }
244
245        return !$answer || 'y' == strtolower($answer[0]);
246    }
247
248    /**
249     * Asks a question to the user, the response is hidden
250     *
251     * @param OutputInterface $output   An Output instance
252     * @param string|array    $question The question
253     * @param bool            $fallback In case the response can not be hidden, whether to fallback on non-hidden question or not
254     *
255     * @return string         The answer
256     *
257     * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden
258     */
259    public function askHiddenResponse(OutputInterface $output, $question, $fallback = true)
260    {
261        if (defined('PHP_WINDOWS_VERSION_BUILD')) {
262            $exe = __DIR__.'/../Resources/bin/hiddeninput.exe';
263
264            // handle code running from a phar
265            if ('phar:' === substr(__FILE__, 0, 5)) {
266                $tmpExe = sys_get_temp_dir().'/hiddeninput.exe';
267                copy($exe, $tmpExe);
268                $exe = $tmpExe;
269            }
270
271            $output->write($question);
272            $value = rtrim(shell_exec($exe));
273            $output->writeln('');
274
275            if (isset($tmpExe)) {
276                unlink($tmpExe);
277            }
278
279            return $value;
280        }
281
282        if ($this->hasSttyAvailable()) {
283            $output->write($question);
284
285            $sttyMode = shell_exec('stty -g');
286
287            shell_exec('stty -echo');
288            $value = fgets($this->inputStream ?: STDIN, 4096);
289            shell_exec(sprintf('stty %s', $sttyMode));
290
291            if (false === $value) {
292                throw new \RuntimeException('Aborted');
293            }
294
295            $value = trim($value);
296            $output->writeln('');
297
298            return $value;
299        }
300
301        if (false !== $shell = $this->getShell()) {
302            $output->write($question);
303            $readCmd = $shell === 'csh' ? 'set mypassword = $<' : 'read -r mypassword';
304            $command = sprintf("/usr/bin/env %s -c 'stty -echo; %s; stty echo; echo \$mypassword'", $shell, $readCmd);
305            $value = rtrim(shell_exec($command));
306            $output->writeln('');
307
308            return $value;
309        }
310
311        if ($fallback) {
312            return $this->ask($output, $question);
313        }
314
315        throw new \RuntimeException('Unable to hide the response');
316    }
317
318    /**
319     * Asks for a value and validates the response.
320     *
321     * The validator receives the data to validate. It must return the
322     * validated data when the data is valid and throw an exception
323     * otherwise.
324     *
325     * @param OutputInterface $output       An Output instance
326     * @param string|array    $question     The question to ask
327     * @param callable        $validator    A PHP callback
328     * @param int             $attempts     Max number of times to ask before giving up (false by default, which means infinite)
329     * @param string          $default      The default answer if none is given by the user
330     * @param array           $autocomplete List of values to autocomplete
331     *
332     * @return mixed
333     *
334     * @throws \Exception When any of the validators return an error
335     */
336    public function askAndValidate(OutputInterface $output, $question, $validator, $attempts = false, $default = null, array $autocomplete = null)
337    {
338        $that = $this;
339
340        $interviewer = function () use ($output, $question, $default, $autocomplete, $that) {
341            return $that->ask($output, $question, $default, $autocomplete);
342        };
343
344        return $this->validateAttempts($interviewer, $output, $validator, $attempts);
345    }
346
347    /**
348     * Asks for a value, hide and validates the response.
349     *
350     * The validator receives the data to validate. It must return the
351     * validated data when the data is valid and throw an exception
352     * otherwise.
353     *
354     * @param OutputInterface $output    An Output instance
355     * @param string|array    $question  The question to ask
356     * @param callable        $validator A PHP callback
357     * @param int             $attempts  Max number of times to ask before giving up (false by default, which means infinite)
358     * @param bool            $fallback  In case the response can not be hidden, whether to fallback on non-hidden question or not
359     *
360     * @return string         The response
361     *
362     * @throws \Exception        When any of the validators return an error
363     * @throws \RuntimeException In case the fallback is deactivated and the response can not be hidden
364     *
365     */
366    public function askHiddenResponseAndValidate(OutputInterface $output, $question, $validator, $attempts = false, $fallback = true)
367    {
368        $that = $this;
369
370        $interviewer = function () use ($output, $question, $fallback, $that) {
371            return $that->askHiddenResponse($output, $question, $fallback);
372        };
373
374        return $this->validateAttempts($interviewer, $output, $validator, $attempts);
375    }
376
377    /**
378     * Sets the input stream to read from when interacting with the user.
379     *
380     * This is mainly useful for testing purpose.
381     *
382     * @param resource $stream The input stream
383     */
384    public function setInputStream($stream)
385    {
386        $this->inputStream = $stream;
387    }
388
389    /**
390     * Returns the helper's input stream
391     *
392     * @return string
393     */
394    public function getInputStream()
395    {
396        return $this->inputStream;
397    }
398
399    /**
400     * {@inheritdoc}
401     */
402    public function getName()
403    {
404        return 'dialog';
405    }
406
407    /**
408     * Return a valid Unix shell
409     *
410     * @return string|bool     The valid shell name, false in case no valid shell is found
411     */
412    private function getShell()
413    {
414        if (null !== self::$shell) {
415            return self::$shell;
416        }
417
418        self::$shell = false;
419
420        if (file_exists('/usr/bin/env')) {
421            // handle other OSs with bash/zsh/ksh/csh if available to hide the answer
422            $test = "/usr/bin/env %s -c 'echo OK' 2> /dev/null";
423            foreach (array('bash', 'zsh', 'ksh', 'csh') as $sh) {
424                if ('OK' === rtrim(shell_exec(sprintf($test, $sh)))) {
425                    self::$shell = $sh;
426                    break;
427                }
428            }
429        }
430
431        return self::$shell;
432    }
433
434    private function hasSttyAvailable()
435    {
436        if (null !== self::$stty) {
437            return self::$stty;
438        }
439
440        exec('stty 2>&1', $output, $exitcode);
441
442        return self::$stty = $exitcode === 0;
443    }
444
445    /**
446     * Validate an attempt
447     *
448     * @param callable         $interviewer  A callable that will ask for a question and return the result
449     * @param OutputInterface  $output       An Output instance
450     * @param callable         $validator    A PHP callback
451     * @param int              $attempts     Max number of times to ask before giving up ; false will ask infinitely
452     *
453     * @return string   The validated response
454     *
455     * @throws \Exception In case the max number of attempts has been reached and no valid response has been given
456     */
457    private function validateAttempts($interviewer, OutputInterface $output, $validator, $attempts)
458    {
459        $error = null;
460        while (false === $attempts || $attempts--) {
461            if (null !== $error) {
462                $output->writeln($this->getHelperSet()->get('formatter')->formatBlock($error->getMessage(), 'error'));
463            }
464
465            try {
466                return call_user_func($validator, $interviewer());
467            } catch (\Exception $error) {
468            }
469        }
470
471        throw $error;
472    }
473}
474