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\Cursor;
15use Symfony\Component\Console\Exception\LogicException;
16use Symfony\Component\Console\Output\ConsoleOutputInterface;
17use Symfony\Component\Console\Output\ConsoleSectionOutput;
18use Symfony\Component\Console\Output\OutputInterface;
19use Symfony\Component\Console\Terminal;
20
21/**
22 * The ProgressBar provides helpers to display progress output.
23 *
24 * @author Fabien Potencier <fabien@symfony.com>
25 * @author Chris Jones <leeked@gmail.com>
26 */
27final class ProgressBar
28{
29    private $barWidth = 28;
30    private $barChar;
31    private $emptyBarChar = '-';
32    private $progressChar = '>';
33    private $format;
34    private $internalFormat;
35    private $redrawFreq = 1;
36    private $writeCount;
37    private $lastWriteTime;
38    private $minSecondsBetweenRedraws = 0;
39    private $maxSecondsBetweenRedraws = 1;
40    private $output;
41    private $step = 0;
42    private $max;
43    private $startTime;
44    private $stepWidth;
45    private $percent = 0.0;
46    private $formatLineCount;
47    private $messages = [];
48    private $overwrite = true;
49    private $terminal;
50    private $previousMessage;
51    private $cursor;
52
53    private static $formatters;
54    private static $formats;
55
56    /**
57     * @param int $max Maximum steps (0 if unknown)
58     */
59    public function __construct(OutputInterface $output, int $max = 0, float $minSecondsBetweenRedraws = 1 / 25)
60    {
61        if ($output instanceof ConsoleOutputInterface) {
62            $output = $output->getErrorOutput();
63        }
64
65        $this->output = $output;
66        $this->setMaxSteps($max);
67        $this->terminal = new Terminal();
68
69        if (0 < $minSecondsBetweenRedraws) {
70            $this->redrawFreq = null;
71            $this->minSecondsBetweenRedraws = $minSecondsBetweenRedraws;
72        }
73
74        if (!$this->output->isDecorated()) {
75            // disable overwrite when output does not support ANSI codes.
76            $this->overwrite = false;
77
78            // set a reasonable redraw frequency so output isn't flooded
79            $this->redrawFreq = null;
80        }
81
82        $this->startTime = time();
83        $this->cursor = new Cursor($output);
84    }
85
86    /**
87     * Sets a placeholder formatter for a given name.
88     *
89     * This method also allow you to override an existing placeholder.
90     *
91     * @param string   $name     The placeholder name (including the delimiter char like %)
92     * @param callable $callable A PHP callable
93     */
94    public static function setPlaceholderFormatterDefinition(string $name, callable $callable): void
95    {
96        if (!self::$formatters) {
97            self::$formatters = self::initPlaceholderFormatters();
98        }
99
100        self::$formatters[$name] = $callable;
101    }
102
103    /**
104     * Gets the placeholder formatter for a given name.
105     *
106     * @param string $name The placeholder name (including the delimiter char like %)
107     *
108     * @return callable|null A PHP callable
109     */
110    public static function getPlaceholderFormatterDefinition(string $name): ?callable
111    {
112        if (!self::$formatters) {
113            self::$formatters = self::initPlaceholderFormatters();
114        }
115
116        return self::$formatters[$name] ?? null;
117    }
118
119    /**
120     * Sets a format for a given name.
121     *
122     * This method also allow you to override an existing format.
123     *
124     * @param string $name   The format name
125     * @param string $format A format string
126     */
127    public static function setFormatDefinition(string $name, string $format): void
128    {
129        if (!self::$formats) {
130            self::$formats = self::initFormats();
131        }
132
133        self::$formats[$name] = $format;
134    }
135
136    /**
137     * Gets the format for a given name.
138     *
139     * @param string $name The format name
140     *
141     * @return string|null A format string
142     */
143    public static function getFormatDefinition(string $name): ?string
144    {
145        if (!self::$formats) {
146            self::$formats = self::initFormats();
147        }
148
149        return self::$formats[$name] ?? null;
150    }
151
152    /**
153     * Associates a text with a named placeholder.
154     *
155     * The text is displayed when the progress bar is rendered but only
156     * when the corresponding placeholder is part of the custom format line
157     * (by wrapping the name with %).
158     *
159     * @param string $message The text to associate with the placeholder
160     * @param string $name    The name of the placeholder
161     */
162    public function setMessage(string $message, string $name = 'message')
163    {
164        $this->messages[$name] = $message;
165    }
166
167    public function getMessage(string $name = 'message')
168    {
169        return $this->messages[$name];
170    }
171
172    public function getStartTime(): int
173    {
174        return $this->startTime;
175    }
176
177    public function getMaxSteps(): int
178    {
179        return $this->max;
180    }
181
182    public function getProgress(): int
183    {
184        return $this->step;
185    }
186
187    private function getStepWidth(): int
188    {
189        return $this->stepWidth;
190    }
191
192    public function getProgressPercent(): float
193    {
194        return $this->percent;
195    }
196
197    public function getBarOffset(): float
198    {
199        return floor($this->max ? $this->percent * $this->barWidth : (null === $this->redrawFreq ? min(5, $this->barWidth / 15) * $this->writeCount : $this->step) % $this->barWidth);
200    }
201
202    public function getEstimated(): float
203    {
204        if (!$this->step) {
205            return 0;
206        }
207
208        return round((time() - $this->startTime) / $this->step * $this->max);
209    }
210
211    public function getRemaining(): float
212    {
213        if (!$this->step) {
214            return 0;
215        }
216
217        return round((time() - $this->startTime) / $this->step * ($this->max - $this->step));
218    }
219
220    public function setBarWidth(int $size)
221    {
222        $this->barWidth = max(1, $size);
223    }
224
225    public function getBarWidth(): int
226    {
227        return $this->barWidth;
228    }
229
230    public function setBarCharacter(string $char)
231    {
232        $this->barChar = $char;
233    }
234
235    public function getBarCharacter(): string
236    {
237        if (null === $this->barChar) {
238            return $this->max ? '=' : $this->emptyBarChar;
239        }
240
241        return $this->barChar;
242    }
243
244    public function setEmptyBarCharacter(string $char)
245    {
246        $this->emptyBarChar = $char;
247    }
248
249    public function getEmptyBarCharacter(): string
250    {
251        return $this->emptyBarChar;
252    }
253
254    public function setProgressCharacter(string $char)
255    {
256        $this->progressChar = $char;
257    }
258
259    public function getProgressCharacter(): string
260    {
261        return $this->progressChar;
262    }
263
264    public function setFormat(string $format)
265    {
266        $this->format = null;
267        $this->internalFormat = $format;
268    }
269
270    /**
271     * Sets the redraw frequency.
272     *
273     * @param int|float $freq The frequency in steps
274     */
275    public function setRedrawFrequency(?int $freq)
276    {
277        $this->redrawFreq = null !== $freq ? max(1, $freq) : null;
278    }
279
280    public function minSecondsBetweenRedraws(float $seconds): void
281    {
282        $this->minSecondsBetweenRedraws = $seconds;
283    }
284
285    public function maxSecondsBetweenRedraws(float $seconds): void
286    {
287        $this->maxSecondsBetweenRedraws = $seconds;
288    }
289
290    /**
291     * Returns an iterator that will automatically update the progress bar when iterated.
292     *
293     * @param int|null $max Number of steps to complete the bar (0 if indeterminate), if null it will be inferred from $iterable
294     */
295    public function iterate(iterable $iterable, int $max = null): iterable
296    {
297        $this->start($max ?? (is_countable($iterable) ? \count($iterable) : 0));
298
299        foreach ($iterable as $key => $value) {
300            yield $key => $value;
301
302            $this->advance();
303        }
304
305        $this->finish();
306    }
307
308    /**
309     * Starts the progress output.
310     *
311     * @param int|null $max Number of steps to complete the bar (0 if indeterminate), null to leave unchanged
312     */
313    public function start(int $max = null)
314    {
315        $this->startTime = time();
316        $this->step = 0;
317        $this->percent = 0.0;
318
319        if (null !== $max) {
320            $this->setMaxSteps($max);
321        }
322
323        $this->display();
324    }
325
326    /**
327     * Advances the progress output X steps.
328     *
329     * @param int $step Number of steps to advance
330     */
331    public function advance(int $step = 1)
332    {
333        $this->setProgress($this->step + $step);
334    }
335
336    /**
337     * Sets whether to overwrite the progressbar, false for new line.
338     */
339    public function setOverwrite(bool $overwrite)
340    {
341        $this->overwrite = $overwrite;
342    }
343
344    public function setProgress(int $step)
345    {
346        if ($this->max && $step > $this->max) {
347            $this->max = $step;
348        } elseif ($step < 0) {
349            $step = 0;
350        }
351
352        $redrawFreq = $this->redrawFreq ?? (($this->max ?: 10) / 10);
353        $prevPeriod = (int) ($this->step / $redrawFreq);
354        $currPeriod = (int) ($step / $redrawFreq);
355        $this->step = $step;
356        $this->percent = $this->max ? (float) $this->step / $this->max : 0;
357        $timeInterval = microtime(true) - $this->lastWriteTime;
358
359        // Draw regardless of other limits
360        if ($this->max === $step) {
361            $this->display();
362
363            return;
364        }
365
366        // Throttling
367        if ($timeInterval < $this->minSecondsBetweenRedraws) {
368            return;
369        }
370
371        // Draw each step period, but not too late
372        if ($prevPeriod !== $currPeriod || $timeInterval >= $this->maxSecondsBetweenRedraws) {
373            $this->display();
374        }
375    }
376
377    public function setMaxSteps(int $max)
378    {
379        $this->format = null;
380        $this->max = max(0, $max);
381        $this->stepWidth = $this->max ? Helper::strlen((string) $this->max) : 4;
382    }
383
384    /**
385     * Finishes the progress output.
386     */
387    public function finish(): void
388    {
389        if (!$this->max) {
390            $this->max = $this->step;
391        }
392
393        if ($this->step === $this->max && !$this->overwrite) {
394            // prevent double 100% output
395            return;
396        }
397
398        $this->setProgress($this->max);
399    }
400
401    /**
402     * Outputs the current progress string.
403     */
404    public function display(): void
405    {
406        if (OutputInterface::VERBOSITY_QUIET === $this->output->getVerbosity()) {
407            return;
408        }
409
410        if (null === $this->format) {
411            $this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
412        }
413
414        $this->overwrite($this->buildLine());
415    }
416
417    /**
418     * Removes the progress bar from the current line.
419     *
420     * This is useful if you wish to write some output
421     * while a progress bar is running.
422     * Call display() to show the progress bar again.
423     */
424    public function clear(): void
425    {
426        if (!$this->overwrite) {
427            return;
428        }
429
430        if (null === $this->format) {
431            $this->setRealFormat($this->internalFormat ?: $this->determineBestFormat());
432        }
433
434        $this->overwrite('');
435    }
436
437    private function setRealFormat(string $format)
438    {
439        // try to use the _nomax variant if available
440        if (!$this->max && null !== self::getFormatDefinition($format.'_nomax')) {
441            $this->format = self::getFormatDefinition($format.'_nomax');
442        } elseif (null !== self::getFormatDefinition($format)) {
443            $this->format = self::getFormatDefinition($format);
444        } else {
445            $this->format = $format;
446        }
447
448        $this->formatLineCount = substr_count($this->format, "\n");
449    }
450
451    /**
452     * Overwrites a previous message to the output.
453     */
454    private function overwrite(string $message): void
455    {
456        if ($this->previousMessage === $message) {
457            return;
458        }
459
460        $originalMessage = $message;
461
462        if ($this->overwrite) {
463            if (null !== $this->previousMessage) {
464                if ($this->output instanceof ConsoleSectionOutput) {
465                    $messageLines = explode("\n", $message);
466                    $lineCount = \count($messageLines);
467                    foreach ($messageLines as $messageLine) {
468                        $messageLineLength = Helper::strlenWithoutDecoration($this->output->getFormatter(), $messageLine);
469                        if ($messageLineLength > $this->terminal->getWidth()) {
470                            $lineCount += floor($messageLineLength / $this->terminal->getWidth());
471                        }
472                    }
473                    $this->output->clear($lineCount);
474                } else {
475                    if ($this->formatLineCount > 0) {
476                        $this->cursor->moveUp($this->formatLineCount);
477                    }
478
479                    $this->cursor->moveToColumn(1);
480                    $this->cursor->clearLine();
481                }
482            }
483        } elseif ($this->step > 0) {
484            $message = \PHP_EOL.$message;
485        }
486
487        $this->previousMessage = $originalMessage;
488        $this->lastWriteTime = microtime(true);
489
490        $this->output->write($message);
491        ++$this->writeCount;
492    }
493
494    private function determineBestFormat(): string
495    {
496        switch ($this->output->getVerbosity()) {
497            // OutputInterface::VERBOSITY_QUIET: display is disabled anyway
498            case OutputInterface::VERBOSITY_VERBOSE:
499                return $this->max ? 'verbose' : 'verbose_nomax';
500            case OutputInterface::VERBOSITY_VERY_VERBOSE:
501                return $this->max ? 'very_verbose' : 'very_verbose_nomax';
502            case OutputInterface::VERBOSITY_DEBUG:
503                return $this->max ? 'debug' : 'debug_nomax';
504            default:
505                return $this->max ? 'normal' : 'normal_nomax';
506        }
507    }
508
509    private static function initPlaceholderFormatters(): array
510    {
511        return [
512            'bar' => function (self $bar, OutputInterface $output) {
513                $completeBars = $bar->getBarOffset();
514                $display = str_repeat($bar->getBarCharacter(), $completeBars);
515                if ($completeBars < $bar->getBarWidth()) {
516                    $emptyBars = $bar->getBarWidth() - $completeBars - Helper::strlenWithoutDecoration($output->getFormatter(), $bar->getProgressCharacter());
517                    $display .= $bar->getProgressCharacter().str_repeat($bar->getEmptyBarCharacter(), $emptyBars);
518                }
519
520                return $display;
521            },
522            'elapsed' => function (self $bar) {
523                return Helper::formatTime(time() - $bar->getStartTime());
524            },
525            'remaining' => function (self $bar) {
526                if (!$bar->getMaxSteps()) {
527                    throw new LogicException('Unable to display the remaining time if the maximum number of steps is not set.');
528                }
529
530                return Helper::formatTime($bar->getRemaining());
531            },
532            'estimated' => function (self $bar) {
533                if (!$bar->getMaxSteps()) {
534                    throw new LogicException('Unable to display the estimated time if the maximum number of steps is not set.');
535                }
536
537                return Helper::formatTime($bar->getEstimated());
538            },
539            'memory' => function (self $bar) {
540                return Helper::formatMemory(memory_get_usage(true));
541            },
542            'current' => function (self $bar) {
543                return str_pad($bar->getProgress(), $bar->getStepWidth(), ' ', \STR_PAD_LEFT);
544            },
545            'max' => function (self $bar) {
546                return $bar->getMaxSteps();
547            },
548            'percent' => function (self $bar) {
549                return floor($bar->getProgressPercent() * 100);
550            },
551        ];
552    }
553
554    private static function initFormats(): array
555    {
556        return [
557            'normal' => ' %current%/%max% [%bar%] %percent:3s%%',
558            'normal_nomax' => ' %current% [%bar%]',
559
560            'verbose' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%',
561            'verbose_nomax' => ' %current% [%bar%] %elapsed:6s%',
562
563            'very_verbose' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%',
564            'very_verbose_nomax' => ' %current% [%bar%] %elapsed:6s%',
565
566            'debug' => ' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%',
567            'debug_nomax' => ' %current% [%bar%] %elapsed:6s% %memory:6s%',
568        ];
569    }
570
571    private function buildLine(): string
572    {
573        $regex = "{%([a-z\-_]+)(?:\:([^%]+))?%}i";
574        $callback = function ($matches) {
575            if ($formatter = $this::getPlaceholderFormatterDefinition($matches[1])) {
576                $text = $formatter($this, $this->output);
577            } elseif (isset($this->messages[$matches[1]])) {
578                $text = $this->messages[$matches[1]];
579            } else {
580                return $matches[0];
581            }
582
583            if (isset($matches[2])) {
584                $text = sprintf('%'.$matches[2], $text);
585            }
586
587            return $text;
588        };
589        $line = preg_replace_callback($regex, $callback, $this->format);
590
591        // gets string length for each sub line with multiline format
592        $linesLength = array_map(function ($subLine) {
593            return Helper::strlenWithoutDecoration($this->output->getFormatter(), rtrim($subLine, "\r"));
594        }, explode("\n", $line));
595
596        $linesWidth = max($linesLength);
597
598        $terminalWidth = $this->terminal->getWidth();
599        if ($linesWidth <= $terminalWidth) {
600            return $line;
601        }
602
603        $this->setBarWidth($this->barWidth - $linesWidth + $terminalWidth);
604
605        return preg_replace_callback($regex, $callback, $this->format);
606    }
607}
608