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\Style;
13
14use Symfony\Component\Console\Exception\InvalidArgumentException;
15use Symfony\Component\Console\Exception\RuntimeException;
16use Symfony\Component\Console\Formatter\OutputFormatter;
17use Symfony\Component\Console\Helper\Helper;
18use Symfony\Component\Console\Helper\ProgressBar;
19use Symfony\Component\Console\Helper\SymfonyQuestionHelper;
20use Symfony\Component\Console\Helper\Table;
21use Symfony\Component\Console\Helper\TableCell;
22use Symfony\Component\Console\Helper\TableSeparator;
23use Symfony\Component\Console\Input\InputInterface;
24use Symfony\Component\Console\Output\OutputInterface;
25use Symfony\Component\Console\Output\TrimmedBufferOutput;
26use Symfony\Component\Console\Question\ChoiceQuestion;
27use Symfony\Component\Console\Question\ConfirmationQuestion;
28use Symfony\Component\Console\Question\Question;
29use Symfony\Component\Console\Terminal;
30
31/**
32 * Output decorator helpers for the Symfony Style Guide.
33 *
34 * @author Kevin Bond <kevinbond@gmail.com>
35 */
36class SymfonyStyle extends OutputStyle
37{
38    public const MAX_LINE_LENGTH = 120;
39
40    private $input;
41    private $questionHelper;
42    private $progressBar;
43    private $lineLength;
44    private $bufferedOutput;
45
46    public function __construct(InputInterface $input, OutputInterface $output)
47    {
48        $this->input = $input;
49        $this->bufferedOutput = new TrimmedBufferOutput(\DIRECTORY_SEPARATOR === '\\' ? 4 : 2, $output->getVerbosity(), false, clone $output->getFormatter());
50        // Windows cmd wraps lines as soon as the terminal width is reached, whether there are following chars or not.
51        $width = (new Terminal())->getWidth() ?: self::MAX_LINE_LENGTH;
52        $this->lineLength = min($width - (int) (\DIRECTORY_SEPARATOR === '\\'), self::MAX_LINE_LENGTH);
53
54        parent::__construct($output);
55    }
56
57    /**
58     * Formats a message as a block of text.
59     *
60     * @param string|array $messages The message to write in the block
61     * @param string|null  $type     The block type (added in [] on first line)
62     * @param string|null  $style    The style to apply to the whole block
63     * @param string       $prefix   The prefix for the block
64     * @param bool         $padding  Whether to add vertical padding
65     * @param bool         $escape   Whether to escape the message
66     */
67    public function block($messages, $type = null, $style = null, $prefix = ' ', $padding = false, $escape = true)
68    {
69        $messages = \is_array($messages) ? array_values($messages) : [$messages];
70
71        $this->autoPrependBlock();
72        $this->writeln($this->createBlock($messages, $type, $style, $prefix, $padding, $escape));
73        $this->newLine();
74    }
75
76    /**
77     * {@inheritdoc}
78     */
79    public function title($message)
80    {
81        $this->autoPrependBlock();
82        $this->writeln([
83            sprintf('<comment>%s</>', OutputFormatter::escapeTrailingBackslash($message)),
84            sprintf('<comment>%s</>', str_repeat('=', Helper::strlenWithoutDecoration($this->getFormatter(), $message))),
85        ]);
86        $this->newLine();
87    }
88
89    /**
90     * {@inheritdoc}
91     */
92    public function section($message)
93    {
94        $this->autoPrependBlock();
95        $this->writeln([
96            sprintf('<comment>%s</>', OutputFormatter::escapeTrailingBackslash($message)),
97            sprintf('<comment>%s</>', str_repeat('-', Helper::strlenWithoutDecoration($this->getFormatter(), $message))),
98        ]);
99        $this->newLine();
100    }
101
102    /**
103     * {@inheritdoc}
104     */
105    public function listing(array $elements)
106    {
107        $this->autoPrependText();
108        $elements = array_map(function ($element) {
109            return sprintf(' * %s', $element);
110        }, $elements);
111
112        $this->writeln($elements);
113        $this->newLine();
114    }
115
116    /**
117     * {@inheritdoc}
118     */
119    public function text($message)
120    {
121        $this->autoPrependText();
122
123        $messages = \is_array($message) ? array_values($message) : [$message];
124        foreach ($messages as $message) {
125            $this->writeln(sprintf(' %s', $message));
126        }
127    }
128
129    /**
130     * Formats a command comment.
131     *
132     * @param string|array $message
133     */
134    public function comment($message)
135    {
136        $this->block($message, null, null, '<fg=default;bg=default> // </>', false, false);
137    }
138
139    /**
140     * {@inheritdoc}
141     */
142    public function success($message)
143    {
144        $this->block($message, 'OK', 'fg=black;bg=green', ' ', true);
145    }
146
147    /**
148     * {@inheritdoc}
149     */
150    public function error($message)
151    {
152        $this->block($message, 'ERROR', 'fg=white;bg=red', ' ', true);
153    }
154
155    /**
156     * {@inheritdoc}
157     */
158    public function warning($message)
159    {
160        $this->block($message, 'WARNING', 'fg=black;bg=yellow', ' ', true);
161    }
162
163    /**
164     * {@inheritdoc}
165     */
166    public function note($message)
167    {
168        $this->block($message, 'NOTE', 'fg=yellow', ' ! ');
169    }
170
171    /**
172     * {@inheritdoc}
173     */
174    public function caution($message)
175    {
176        $this->block($message, 'CAUTION', 'fg=white;bg=red', ' ! ', true);
177    }
178
179    /**
180     * {@inheritdoc}
181     */
182    public function table(array $headers, array $rows)
183    {
184        $style = clone Table::getStyleDefinition('symfony-style-guide');
185        $style->setCellHeaderFormat('<info>%s</info>');
186
187        $table = new Table($this);
188        $table->setHeaders($headers);
189        $table->setRows($rows);
190        $table->setStyle($style);
191
192        $table->render();
193        $this->newLine();
194    }
195
196    /**
197     * Formats a horizontal table.
198     */
199    public function horizontalTable(array $headers, array $rows)
200    {
201        $style = clone Table::getStyleDefinition('symfony-style-guide');
202        $style->setCellHeaderFormat('<info>%s</info>');
203
204        $table = new Table($this);
205        $table->setHeaders($headers);
206        $table->setRows($rows);
207        $table->setStyle($style);
208        $table->setHorizontal(true);
209
210        $table->render();
211        $this->newLine();
212    }
213
214    /**
215     * Formats a list of key/value horizontally.
216     *
217     * Each row can be one of:
218     * * 'A title'
219     * * ['key' => 'value']
220     * * new TableSeparator()
221     *
222     * @param string|array|TableSeparator ...$list
223     */
224    public function definitionList(...$list)
225    {
226        $style = clone Table::getStyleDefinition('symfony-style-guide');
227        $style->setCellHeaderFormat('<info>%s</info>');
228
229        $table = new Table($this);
230        $headers = [];
231        $row = [];
232        foreach ($list as $value) {
233            if ($value instanceof TableSeparator) {
234                $headers[] = $value;
235                $row[] = $value;
236                continue;
237            }
238            if (\is_string($value)) {
239                $headers[] = new TableCell($value, ['colspan' => 2]);
240                $row[] = null;
241                continue;
242            }
243            if (!\is_array($value)) {
244                throw new InvalidArgumentException('Value should be an array, string, or an instance of TableSeparator.');
245            }
246            $headers[] = key($value);
247            $row[] = current($value);
248        }
249
250        $table->setHeaders($headers);
251        $table->setRows([$row]);
252        $table->setHorizontal();
253        $table->setStyle($style);
254
255        $table->render();
256        $this->newLine();
257    }
258
259    /**
260     * {@inheritdoc}
261     */
262    public function ask($question, $default = null, $validator = null)
263    {
264        $question = new Question($question, $default);
265        $question->setValidator($validator);
266
267        return $this->askQuestion($question);
268    }
269
270    /**
271     * {@inheritdoc}
272     */
273    public function askHidden($question, $validator = null)
274    {
275        $question = new Question($question);
276
277        $question->setHidden(true);
278        $question->setValidator($validator);
279
280        return $this->askQuestion($question);
281    }
282
283    /**
284     * {@inheritdoc}
285     */
286    public function confirm($question, $default = true)
287    {
288        return $this->askQuestion(new ConfirmationQuestion($question, $default));
289    }
290
291    /**
292     * {@inheritdoc}
293     */
294    public function choice($question, array $choices, $default = null)
295    {
296        if (null !== $default) {
297            $values = array_flip($choices);
298            $default = $values[$default] ?? $default;
299        }
300
301        return $this->askQuestion(new ChoiceQuestion($question, $choices, $default));
302    }
303
304    /**
305     * {@inheritdoc}
306     */
307    public function progressStart($max = 0)
308    {
309        $this->progressBar = $this->createProgressBar($max);
310        $this->progressBar->start();
311    }
312
313    /**
314     * {@inheritdoc}
315     */
316    public function progressAdvance($step = 1)
317    {
318        $this->getProgressBar()->advance($step);
319    }
320
321    /**
322     * {@inheritdoc}
323     */
324    public function progressFinish()
325    {
326        $this->getProgressBar()->finish();
327        $this->newLine(2);
328        $this->progressBar = null;
329    }
330
331    /**
332     * {@inheritdoc}
333     */
334    public function createProgressBar($max = 0)
335    {
336        $progressBar = parent::createProgressBar($max);
337
338        if ('\\' !== \DIRECTORY_SEPARATOR || 'Hyper' === getenv('TERM_PROGRAM')) {
339            $progressBar->setEmptyBarCharacter('░'); // light shade character \u2591
340            $progressBar->setProgressCharacter('');
341            $progressBar->setBarCharacter('▓'); // dark shade character \u2593
342        }
343
344        return $progressBar;
345    }
346
347    /**
348     * @return mixed
349     */
350    public function askQuestion(Question $question)
351    {
352        if ($this->input->isInteractive()) {
353            $this->autoPrependBlock();
354        }
355
356        if (!$this->questionHelper) {
357            $this->questionHelper = new SymfonyQuestionHelper();
358        }
359
360        $answer = $this->questionHelper->ask($this->input, $this, $question);
361
362        if ($this->input->isInteractive()) {
363            $this->newLine();
364            $this->bufferedOutput->write("\n");
365        }
366
367        return $answer;
368    }
369
370    /**
371     * {@inheritdoc}
372     */
373    public function writeln($messages, $type = self::OUTPUT_NORMAL)
374    {
375        if (!is_iterable($messages)) {
376            $messages = [$messages];
377        }
378
379        foreach ($messages as $message) {
380            parent::writeln($message, $type);
381            $this->writeBuffer($message, true, $type);
382        }
383    }
384
385    /**
386     * {@inheritdoc}
387     */
388    public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
389    {
390        if (!is_iterable($messages)) {
391            $messages = [$messages];
392        }
393
394        foreach ($messages as $message) {
395            parent::write($message, $newline, $type);
396            $this->writeBuffer($message, $newline, $type);
397        }
398    }
399
400    /**
401     * {@inheritdoc}
402     */
403    public function newLine($count = 1)
404    {
405        parent::newLine($count);
406        $this->bufferedOutput->write(str_repeat("\n", $count));
407    }
408
409    /**
410     * Returns a new instance which makes use of stderr if available.
411     *
412     * @return self
413     */
414    public function getErrorStyle()
415    {
416        return new self($this->input, $this->getErrorOutput());
417    }
418
419    private function getProgressBar(): ProgressBar
420    {
421        if (!$this->progressBar) {
422            throw new RuntimeException('The ProgressBar is not started.');
423        }
424
425        return $this->progressBar;
426    }
427
428    private function autoPrependBlock(): void
429    {
430        $chars = substr(str_replace(\PHP_EOL, "\n", $this->bufferedOutput->fetch()), -2);
431
432        if (!isset($chars[0])) {
433            $this->newLine(); //empty history, so we should start with a new line.
434
435            return;
436        }
437        //Prepend new line for each non LF chars (This means no blank line was output before)
438        $this->newLine(2 - substr_count($chars, "\n"));
439    }
440
441    private function autoPrependText(): void
442    {
443        $fetched = $this->bufferedOutput->fetch();
444        //Prepend new line if last char isn't EOL:
445        if ("\n" !== substr($fetched, -1)) {
446            $this->newLine();
447        }
448    }
449
450    private function writeBuffer(string $message, bool $newLine, int $type): void
451    {
452        // We need to know if the last chars are PHP_EOL
453        $this->bufferedOutput->write($message, $newLine, $type);
454    }
455
456    private function createBlock(iterable $messages, string $type = null, string $style = null, string $prefix = ' ', bool $padding = false, bool $escape = false): array
457    {
458        $indentLength = 0;
459        $prefixLength = Helper::strlenWithoutDecoration($this->getFormatter(), $prefix);
460        $lines = [];
461
462        if (null !== $type) {
463            $type = sprintf('[%s] ', $type);
464            $indentLength = \strlen($type);
465            $lineIndentation = str_repeat(' ', $indentLength);
466        }
467
468        // wrap and add newlines for each element
469        foreach ($messages as $key => $message) {
470            if ($escape) {
471                $message = OutputFormatter::escape($message);
472            }
473
474            $decorationLength = Helper::strlen($message) - Helper::strlenWithoutDecoration($this->getFormatter(), $message);
475            $messageLineLength = min($this->lineLength - $prefixLength - $indentLength + $decorationLength, $this->lineLength);
476            $messageLines = explode(\PHP_EOL, wordwrap($message, $messageLineLength, \PHP_EOL, true));
477            foreach ($messageLines as $messageLine) {
478                $lines[] = $messageLine;
479            }
480
481            if (\count($messages) > 1 && $key < \count($messages) - 1) {
482                $lines[] = '';
483            }
484        }
485
486        $firstLineIndex = 0;
487        if ($padding && $this->isDecorated()) {
488            $firstLineIndex = 1;
489            array_unshift($lines, '');
490            $lines[] = '';
491        }
492
493        foreach ($lines as $i => &$line) {
494            if (null !== $type) {
495                $line = $firstLineIndex === $i ? $type.$line : $lineIndentation.$line;
496            }
497
498            $line = $prefix.$line;
499            $line .= str_repeat(' ', max($this->lineLength - Helper::strlenWithoutDecoration($this->getFormatter(), $line), 0));
500
501            if ($style) {
502                $line = sprintf('<%s>%s</>', $style, $line);
503            }
504        }
505
506        return $lines;
507    }
508}
509