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\Exception\InvalidArgumentException;
15use Symfony\Component\Console\Exception\LogicException;
16use Symfony\Component\Console\Output\OutputInterface;
17
18/**
19 * @author Kevin Bond <kevinbond@gmail.com>
20 */
21class ProgressIndicator
22{
23    private $output;
24    private $startTime;
25    private $format;
26    private $message;
27    private $indicatorValues;
28    private $indicatorCurrent;
29    private $indicatorChangeInterval;
30    private $indicatorUpdateTime;
31    private $started = false;
32
33    private static $formatters;
34    private static $formats;
35
36    /**
37     * @param string|null $format                  Indicator format
38     * @param int         $indicatorChangeInterval Change interval in milliseconds
39     * @param array|null  $indicatorValues         Animated indicator characters
40     */
41    public function __construct(OutputInterface $output, string $format = null, int $indicatorChangeInterval = 100, array $indicatorValues = null)
42    {
43        $this->output = $output;
44
45        if (null === $format) {
46            $format = $this->determineBestFormat();
47        }
48
49        if (null === $indicatorValues) {
50            $indicatorValues = ['-', '\\', '|', '/'];
51        }
52
53        $indicatorValues = array_values($indicatorValues);
54
55        if (2 > \count($indicatorValues)) {
56            throw new InvalidArgumentException('Must have at least 2 indicator value characters.');
57        }
58
59        $this->format = self::getFormatDefinition($format);
60        $this->indicatorChangeInterval = $indicatorChangeInterval;
61        $this->indicatorValues = $indicatorValues;
62        $this->startTime = time();
63    }
64
65    /**
66     * Sets the current indicator message.
67     *
68     * @param string|null $message
69     */
70    public function setMessage($message)
71    {
72        $this->message = $message;
73
74        $this->display();
75    }
76
77    /**
78     * Starts the indicator output.
79     *
80     * @param $message
81     */
82    public function start($message)
83    {
84        if ($this->started) {
85            throw new LogicException('Progress indicator already started.');
86        }
87
88        $this->message = $message;
89        $this->started = true;
90        $this->startTime = time();
91        $this->indicatorUpdateTime = $this->getCurrentTimeInMilliseconds() + $this->indicatorChangeInterval;
92        $this->indicatorCurrent = 0;
93
94        $this->display();
95    }
96
97    /**
98     * Advances the indicator.
99     */
100    public function advance()
101    {
102        if (!$this->started) {
103            throw new LogicException('Progress indicator has not yet been started.');
104        }
105
106        if (!$this->output->isDecorated()) {
107            return;
108        }
109
110        $currentTime = $this->getCurrentTimeInMilliseconds();
111
112        if ($currentTime < $this->indicatorUpdateTime) {
113            return;
114        }
115
116        $this->indicatorUpdateTime = $currentTime + $this->indicatorChangeInterval;
117        ++$this->indicatorCurrent;
118
119        $this->display();
120    }
121
122    /**
123     * Finish the indicator with message.
124     *
125     * @param $message
126     */
127    public function finish($message)
128    {
129        if (!$this->started) {
130            throw new LogicException('Progress indicator has not yet been started.');
131        }
132
133        $this->message = $message;
134        $this->display();
135        $this->output->writeln('');
136        $this->started = false;
137    }
138
139    /**
140     * Gets the format for a given name.
141     *
142     * @param string $name The format name
143     *
144     * @return string|null A format string
145     */
146    public static function getFormatDefinition($name)
147    {
148        if (!self::$formats) {
149            self::$formats = self::initFormats();
150        }
151
152        return isset(self::$formats[$name]) ? self::$formats[$name] : null;
153    }
154
155    /**
156     * Sets a placeholder formatter for a given name.
157     *
158     * This method also allow you to override an existing placeholder.
159     *
160     * @param string   $name     The placeholder name (including the delimiter char like %)
161     * @param callable $callable A PHP callable
162     */
163    public static function setPlaceholderFormatterDefinition($name, $callable)
164    {
165        if (!self::$formatters) {
166            self::$formatters = self::initPlaceholderFormatters();
167        }
168
169        self::$formatters[$name] = $callable;
170    }
171
172    /**
173     * Gets the placeholder formatter for a given name.
174     *
175     * @param string $name The placeholder name (including the delimiter char like %)
176     *
177     * @return callable|null A PHP callable
178     */
179    public static function getPlaceholderFormatterDefinition($name)
180    {
181        if (!self::$formatters) {
182            self::$formatters = self::initPlaceholderFormatters();
183        }
184
185        return isset(self::$formatters[$name]) ? self::$formatters[$name] : null;
186    }
187
188    private function display()
189    {
190        if (OutputInterface::VERBOSITY_QUIET === $this->output->getVerbosity()) {
191            return;
192        }
193
194        $this->overwrite(preg_replace_callback("{%([a-z\-_]+)(?:\:([^%]+))?%}i", function ($matches) {
195            if ($formatter = self::getPlaceholderFormatterDefinition($matches[1])) {
196                return $formatter($this);
197            }
198
199            return $matches[0];
200        }, $this->format));
201    }
202
203    private function determineBestFormat(): string
204    {
205        switch ($this->output->getVerbosity()) {
206            // OutputInterface::VERBOSITY_QUIET: display is disabled anyway
207            case OutputInterface::VERBOSITY_VERBOSE:
208                return $this->output->isDecorated() ? 'verbose' : 'verbose_no_ansi';
209            case OutputInterface::VERBOSITY_VERY_VERBOSE:
210            case OutputInterface::VERBOSITY_DEBUG:
211                return $this->output->isDecorated() ? 'very_verbose' : 'very_verbose_no_ansi';
212            default:
213                return $this->output->isDecorated() ? 'normal' : 'normal_no_ansi';
214        }
215    }
216
217    /**
218     * Overwrites a previous message to the output.
219     */
220    private function overwrite(string $message)
221    {
222        if ($this->output->isDecorated()) {
223            $this->output->write("\x0D\x1B[2K");
224            $this->output->write($message);
225        } else {
226            $this->output->writeln($message);
227        }
228    }
229
230    private function getCurrentTimeInMilliseconds(): float
231    {
232        return round(microtime(true) * 1000);
233    }
234
235    private static function initPlaceholderFormatters(): array
236    {
237        return [
238            'indicator' => function (self $indicator) {
239                return $indicator->indicatorValues[$indicator->indicatorCurrent % \count($indicator->indicatorValues)];
240            },
241            'message' => function (self $indicator) {
242                return $indicator->message;
243            },
244            'elapsed' => function (self $indicator) {
245                return Helper::formatTime(time() - $indicator->startTime);
246            },
247            'memory' => function () {
248                return Helper::formatMemory(memory_get_usage(true));
249            },
250        ];
251    }
252
253    private static function initFormats(): array
254    {
255        return [
256            'normal' => ' %indicator% %message%',
257            'normal_no_ansi' => ' %message%',
258
259            'verbose' => ' %indicator% %message% (%elapsed:6s%)',
260            'verbose_no_ansi' => ' %message% (%elapsed:6s%)',
261
262            'very_verbose' => ' %indicator% %message% (%elapsed:6s%, %memory:6s%)',
263            'very_verbose_no_ansi' => ' %message% (%elapsed:6s%, %memory:6s%)',
264        ];
265    }
266}
267