Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.19% covered (warning)
76.19%
112 / 147
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
CLI
76.19% covered (warning)
76.19%
112 / 147
68.75% covered (warning)
68.75%
11 / 16
122.03
0.00% covered (danger)
0.00%
0 / 1
 isWindows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWidth
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 wrap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 strlen
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 style
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 write
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 newLine
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 liveLine
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 beep
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 box
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 error
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 clear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInput
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 prompt
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 secret
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 table
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
16
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework CLI Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\CLI;
11
12use InvalidArgumentException;
13use JetBrains\PhpStorm\Pure;
14
15/**
16 * Class CLI.
17 *
18 * @see https://en.wikipedia.org/wiki/ANSI_escape_code
19 *
20 * @package cli
21 */
22class CLI
23{
24    /**
25     * Background color "black".
26     *
27     * @var string
28     */
29    public const BG_BLACK = 'black';
30    /**
31     * Background color "red".
32     *
33     * @var string
34     */
35    public const BG_RED = 'red';
36    /**
37     * Background color "green".
38     *
39     * @var string
40     */
41    public const BG_GREEN = 'green';
42    /**
43     * Background color "yellow".
44     *
45     * @var string
46     */
47    public const BG_YELLOW = 'yellow';
48    /**
49     * Background color "blue".
50     *
51     * @var string
52     */
53    public const BG_BLUE = 'blue';
54    /**
55     * Background color "magenta".
56     *
57     * @var string
58     */
59    public const BG_MAGENTA = 'magenta';
60    /**
61     * Background color "cyan".
62     *
63     * @var string
64     */
65    public const BG_CYAN = 'cyan';
66    /**
67     * Background color "white".
68     *
69     * @var string
70     */
71    public const BG_WHITE = 'white';
72    /**
73     * Background color "bright black".
74     *
75     * @var string
76     */
77    public const BG_BRIGHT_BLACK = 'bright_black';
78    /**
79     * Background color "bright red".
80     *
81     * @var string
82     */
83    public const BG_BRIGHT_RED = 'bright_red';
84    /**
85     * Background color "bright green".
86     *
87     * @var string
88     */
89    public const BG_BRIGHT_GREEN = 'bright_green';
90    /**
91     * Background color "bright yellow".
92     *
93     * @var string
94     */
95    public const BG_BRIGHT_YELLOW = 'bright_yellow';
96    /**
97     * Background color "bright blue".
98     *
99     * @var string
100     */
101    public const BG_BRIGHT_BLUE = 'bright_blue';
102    /**
103     * Background color "bright magenta".
104     *
105     * @var string
106     */
107    public const BG_BRIGHT_MAGENTA = 'bright_magenta';
108    /**
109     * Background color "bright cyan".
110     *
111     * @var string
112     */
113    public const BG_BRIGHT_CYAN = 'bright_cyan';
114    /**
115     * Foreground color "black".
116     *
117     * @var string
118     */
119    public const FG_BLACK = 'black';
120    /**
121     * Foreground color "red".
122     *
123     * @var string
124     */
125    public const FG_RED = 'red';
126    /**
127     * Foreground color "green".
128     *
129     * @var string
130     */
131    public const FG_GREEN = 'green';
132    /**
133     * Foreground color "yellow".
134     *
135     * @var string
136     */
137    public const FG_YELLOW = 'yellow';
138    /**
139     * Foreground color "blue".
140     *
141     * @var string
142     */
143    public const FG_BLUE = 'blue';
144    /**
145     * Foreground color "magenta".
146     *
147     * @var string
148     */
149    public const FG_MAGENTA = 'magenta';
150    /**
151     * Foreground color "cyan".
152     *
153     * @var string
154     */
155    public const FG_CYAN = 'cyan';
156    /**
157     * Foreground color "white".
158     *
159     * @var string
160     */
161    public const FG_WHITE = 'white';
162    /**
163     * Foreground color "bright black".
164     *
165     * @var string
166     */
167    public const FG_BRIGHT_BLACK = 'bright_black';
168    /**
169     * Foreground color "bright red".
170     *
171     * @var string
172     */
173    public const FG_BRIGHT_RED = 'bright_red';
174    /**
175     * Foreground color "bright green".
176     *
177     * @var string
178     */
179    public const FG_BRIGHT_GREEN = 'bright_green';
180    /**
181     * Foreground color "bright yellow".
182     *
183     * @var string
184     */
185    public const FG_BRIGHT_YELLOW = 'bright_yellow';
186    /**
187     * Foreground color "bright blue".
188     *
189     * @var string
190     */
191    public const FG_BRIGHT_BLUE = 'bright_blue';
192    /**
193     * Foreground color "bright magenta".
194     *
195     * @var string
196     */
197    public const FG_BRIGHT_MAGENTA = 'bright_magenta';
198    /**
199     * Foreground color "bright cyan".
200     *
201     * @var string
202     */
203    public const FG_BRIGHT_CYAN = 'bright_cyan';
204    /**
205     * Foreground color "bright white".
206     *
207     * @var string
208     */
209    public const FG_BRIGHT_WHITE = 'bright_white';
210    /**
211     * SGR format "bold".
212     *
213     * @var string
214     */
215    public const FM_BOLD = 'bold';
216    /**
217     * SGR format "faint".
218     *
219     * @var string
220     */
221    public const FM_FAINT = 'faint';
222    /**
223     * SGR format "italic".
224     *
225     * @var string
226     */
227    public const FM_ITALIC = 'italic';
228    /**
229     * SGR format "underline".
230     *
231     * @var string
232     */
233    public const FM_UNDERLINE = 'underline';
234    /**
235     * SGR format "slow blink".
236     *
237     * @var string
238     */
239    public const FM_SLOW_BLINK = 'slow_blink';
240    /**
241     * SGR format "rapid blink".
242     *
243     * @var string
244     */
245    public const FM_RAPID_BLINK = 'rapid_blink';
246    /**
247     * SGR format "reverse video".
248     *
249     * @var string
250     */
251    public const FM_REVERSE_VIDEO = 'reverse_video';
252    /**
253     * SGR format "conceal".
254     *
255     * @var string
256     */
257    public const FM_CONCEAL = 'conceal';
258    /**
259     * SGR format "crossed out".
260     *
261     * @var string
262     */
263    public const FM_CROSSED_OUT = 'crossed_out';
264    /**
265     * SGR format "primary font".
266     *
267     * @var string
268     */
269    public const FM_PRIMARY_FONT = 'primary_font';
270    /**
271     * SGR format "fraktur".
272     *
273     * @var string
274     */
275    public const FM_FRAKTUR = 'fraktur';
276    /**
277     * SGR format "doubly underline".
278     *
279     * @var string
280     */
281    public const FM_DOUBLY_UNDERLINE = 'doubly_underline';
282    /**
283     * SGR format "encircled".
284     *
285     * @var string
286     */
287    public const FM_ENCIRCLED = 'encircled';
288    /**
289     * @var array<string,string>
290     */
291    protected static array $backgroundColors = [
292        'black' => "\033[40m",
293        'red' => "\033[41m",
294        'green' => "\033[42m",
295        'yellow' => "\033[43m",
296        'blue' => "\033[44m",
297        'magenta' => "\033[45m",
298        'cyan' => "\033[46m",
299        'white' => "\033[47m",
300        'bright_black' => "\033[100m",
301        'bright_red' => "\033[101m",
302        'bright_green' => "\033[102m",
303        'bright_yellow' => "\033[103m",
304        'bright_blue' => "\033[104m",
305        'bright_magenta' => "\033[105m",
306        'bright_cyan' => "\033[106m",
307        'bright_white' => "\033[107m",
308    ];
309    /**
310     * @var array<string,string>
311     */
312    protected static array $foregroundColors = [
313        'black' => "\033[0;30m",
314        'red' => "\033[0;31m",
315        'green' => "\033[0;32m",
316        'yellow' => "\033[0;33m",
317        'blue' => "\033[0;34m",
318        'magenta' => "\033[0;35m",
319        'cyan' => "\033[0;36m",
320        'white' => "\033[0;37m",
321        'bright_black' => "\033[0;90m",
322        'bright_red' => "\033[0;91m",
323        'bright_green' => "\033[0;92m",
324        'bright_yellow' => "\033[0;93m",
325        'bright_blue' => "\033[0;94m",
326        'bright_magenta' => "\033[0;95m",
327        'bright_cyan' => "\033[0;96m",
328        'bright_white' => "\033[0;97m",
329    ];
330    /**
331     * @var array<string,string>
332     */
333    protected static array $formats = [
334        'bold' => "\033[1m",
335        'faint' => "\033[2m",
336        'italic' => "\033[3m",
337        'underline' => "\033[4m",
338        'slow_blink' => "\033[5m",
339        'rapid_blink' => "\033[6m",
340        'reverse_video' => "\033[7m",
341        'conceal' => "\033[8m",
342        'crossed_out' => "\033[9m",
343        'primary_font' => "\033[10m",
344        'fraktur' => "\033[20m",
345        'doubly_underline' => "\033[21m",
346        'encircled' => "\033[52m",
347    ];
348    protected static string $reset = "\033[0m";
349
350    /**
351     * Tells if it is running on a Windows OS.
352     *
353     * @return bool
354     */
355    #[Pure]
356    public static function isWindows() : bool
357    {
358        return \PHP_OS_FAMILY === 'Windows';
359    }
360
361    /**
362     * Get the screen width.
363     *
364     * @param int $default
365     *
366     * @return int
367     */
368    public static function getWidth(int $default = 80) : int
369    {
370        if (static::isWindows()) {
371            return $default;
372        }
373        $width = (int) \shell_exec('tput cols');
374        if ( ! $width) {
375            return $default;
376        }
377        return $width;
378    }
379
380    /**
381     * Displays text wrapped to a certain width.
382     *
383     * @param string $text
384     * @param int|null $width
385     *
386     * @return string Returns the wrapped text
387     */
388    public static function wrap(string $text, int $width = null) : string
389    {
390        $width ??= static::getWidth();
391        return \wordwrap($text, $width, \PHP_EOL, true);
392    }
393
394    /**
395     * Calculate the multibyte length of a text without style characters.
396     *
397     * @param string $text The text being checked for length
398     *
399     * @return int
400     */
401    public static function strlen(string $text) : int
402    {
403        $codes = [];
404        foreach (static::$foregroundColors as $color) {
405            $codes[] = $color;
406        }
407        foreach (static::$backgroundColors as $background) {
408            $codes[] = $background;
409        }
410        foreach (static::$formats as $format) {
411            $codes[] = $format;
412        }
413        $codes[] = static::$reset;
414        $text = \str_replace($codes, '', $text);
415        return \mb_strlen($text);
416    }
417
418    /**
419     * Applies styles to a text.
420     *
421     * @param string $text The text to be styled
422     * @param string|null $color Foreground color. One of the FG_* constants
423     * @param string|null $background Background color. One of the BG_* constants
424     * @param array<int,string> $formats The text format. A list of FM_* constants
425     *
426     * @throws InvalidArgumentException For invalid color, background or format
427     *
428     * @return string Returns the styled text
429     */
430    public static function style(
431        string $text,
432        string $color = null,
433        string $background = null,
434        array $formats = []
435    ) : string {
436        $string = '';
437        if ($color !== null) {
438            if (empty(static::$foregroundColors[$color])) {
439                throw new InvalidArgumentException('Invalid color: ' . $color);
440            }
441            $string = static::$foregroundColors[$color];
442        }
443        if ($background !== null) {
444            if (empty(static::$backgroundColors[$background])) {
445                throw new InvalidArgumentException('Invalid background color: ' . $background);
446            }
447            $string .= static::$backgroundColors[$background];
448        }
449        if ($formats) {
450            foreach ($formats as $format) {
451                if (empty(static::$formats[$format])) {
452                    throw new InvalidArgumentException('Invalid format: ' . $format);
453                }
454                $string .= static::$formats[$format];
455            }
456        }
457        $string .= $text . static::$reset;
458        return $string;
459    }
460
461    /**
462     * Write a text in the output.
463     *
464     * Optionally with styles and width wrapping.
465     *
466     * @param string $text The text to be written
467     * @param string|null $color Foreground color. One of the FG_* constants
468     * @param string|null $background Background color. One of the BG_* constants
469     * @param int|null $width Width to wrap the text. Null to do not wrap.
470     */
471    public static function write(
472        string $text,
473        string $color = null,
474        string $background = null,
475        int $width = null
476    ) : void {
477        if ($width !== null) {
478            $text = static::wrap($text, $width);
479        }
480        if ($color !== null || $background !== null) {
481            $text = static::style($text, $color, $background);
482        }
483        \fwrite(\STDOUT, $text . \PHP_EOL);
484    }
485
486    /**
487     * Prints a new line in the output.
488     *
489     * @param int $lines Number of lines to be printed
490     */
491    public static function newLine(int $lines = 1) : void
492    {
493        for ($i = 0; $i < $lines; $i++) {
494            \fwrite(\STDOUT, \PHP_EOL);
495        }
496    }
497
498    /**
499     * Creates a "live line".
500     *
501     * Erase the current line, move the cursor to the beginning of the line and
502     * writes a text.
503     *
504     * @param string $text The text to be written
505     * @param bool $finalize If true the "live line" activity ends, creating a
506     * new line after the text
507     */
508    public static function liveLine(string $text, bool $finalize = false) : void
509    {
510        // See: https://stackoverflow.com/a/35190285
511        $string = '';
512        if ( ! static::isWindows()) {
513            $string .= "\33[2K";
514        }
515        $string .= "\r";
516        $string .= $text;
517        if ($finalize) {
518            $string .= \PHP_EOL;
519        }
520        \fwrite(\STDOUT, $string);
521    }
522
523    /**
524     * Performs audible beep alarms.
525     *
526     * @param int $times How many times should the beep be played
527     * @param int $usleep Interval in microseconds
528     */
529    public static function beep(int $times = 1, int $usleep = 0) : void
530    {
531        for ($i = 0; $i < $times; $i++) {
532            \fwrite(\STDOUT, "\x07");
533            \usleep($usleep);
534        }
535    }
536
537    /**
538     * Writes a message box.
539     *
540     * @param array<int,string>|string $lines One line as string or multi-lines as array
541     * @param string $background Background color. One of the BG_* constants
542     * @param string $color Foreground color. One of the FG_* constants
543     */
544    public static function box(
545        array | string $lines,
546        string $background = CLI::BG_BLACK,
547        string $color = CLI::FG_WHITE
548    ) : void {
549        $width = static::getWidth();
550        $width -= 2;
551        if ( ! \is_array($lines)) {
552            $lines = [
553                $lines,
554            ];
555        }
556        $allLines = [];
557        foreach ($lines as &$line) {
558            $length = static::strlen($line);
559            if ($length > $width) {
560                $line = static::wrap($line, $width);
561            }
562            foreach (\explode(\PHP_EOL, $line) as $subLine) {
563                $allLines[] = $subLine;
564            }
565        }
566        unset($line);
567        $blankLine = \str_repeat(' ', $width + 2);
568        $text = static::style($blankLine, $color, $background);
569        foreach ($allLines as $line) {
570            $end = \str_repeat(' ', $width - static::strlen($line)) . ' ';
571            $end = static::style($end, $color, $background);
572            $text .= static::style(' ' . $line . $end, $color, $background);
573        }
574        $text .= static::style($blankLine, $color, $background);
575        static::write($text);
576    }
577
578    /**
579     * Writes a message to STDERR and optionally exit with a custom code.
580     *
581     * @param string $message The error message
582     * @param int|null $exitCode Set null to do not exit
583     */
584    public static function error(string $message, ?int $exitCode = 1) : void
585    {
586        static::beep();
587        \fwrite(\STDERR, static::style($message, static::FG_RED) . \PHP_EOL);
588        if ($exitCode !== null) {
589            exit($exitCode);
590        }
591    }
592
593    /**
594     * Clear the terminal screen.
595     */
596    public static function clear() : void
597    {
598        \fwrite(\STDOUT, "\e[H\e[2J");
599    }
600
601    /**
602     * Get user input.
603     *
604     * NOTE: It is possible pass multiple lines ending each line with a backslash.
605     *
606     * @param string $prepend Text prepended in the input. Used internally to
607     * allow multiple lines
608     *
609     * @return string Returns the user input
610     */
611    public static function getInput(string $prepend = '') : string
612    {
613        $input = \fgets(\STDIN);
614        $input = $input === false ? '' : \trim($input);
615        $prepend .= $input;
616        $eolPos = false;
617        if ($prepend) {
618            $eolPos = \strrpos($prepend, '\\', -1);
619        }
620        if ($eolPos !== false) {
621            $prepend = \substr_replace($prepend, \PHP_EOL, $eolPos);
622            $prepend = static::getInput($prepend);
623        }
624        return $prepend;
625    }
626
627    /**
628     * Prompt a question.
629     *
630     * @param string $question The question to prompt
631     * @param array<int,string>|string|null $options Answer options. If an array
632     * is set, the default answer is the first value. If is a string, it will
633     * be the default.
634     *
635     * @return string The answer
636     */
637    public static function prompt(string $question, array | string $options = null) : string
638    {
639        if ($options !== null) {
640            $options = \is_array($options)
641                ? \array_values($options)
642                : [$options];
643        }
644        if ($options) {
645            $opt = $options;
646            $opt[0] = static::style($opt[0], null, null, [static::FM_BOLD]);
647            $optionsText = isset($opt[1])
648                ? \implode(', ', $opt)
649                : $opt[0];
650            $question .= ' [' . $optionsText . ']';
651        }
652        $question .= ': ';
653        \fwrite(\STDOUT, $question);
654        $answer = static::getInput();
655        if ($answer === '' && isset($options[0])) {
656            $answer = $options[0];
657        }
658        return $answer;
659    }
660
661    /**
662     * Prompt a question with secret answer.
663     *
664     * @param string $question The question to prompt
665     *
666     * @see https://dev.to/mykeels/reading-passwords-from-stdin-in-php-1np9
667     *
668     * @return string The secret answer
669     */
670    public static function secret(string $question) : string
671    {
672        $question .= ': ';
673        \fwrite(\STDOUT, $question);
674        \exec('stty -echo');
675        $secret = \trim((string) \fgets(\STDIN));
676        \exec('stty echo');
677        return $secret;
678    }
679
680    /**
681     * Creates a well formatted table.
682     *
683     * @param array<array<scalar|\Stringable>> $tbody Table body rows
684     * @param array<scalar|\Stringable> $thead Table head fields
685     */
686    public static function table(array $tbody, array $thead = []) : void
687    {
688        // All the rows in the table will be here until the end
689        $tableRows = [];
690        // We need only indexes and not keys
691        if ( ! empty($thead)) {
692            $tableRows[] = \array_values($thead);
693        }
694        foreach ($tbody as $tr) {
695            // cast tr to array if is not - (objects...)
696            $tableRows[] = \array_values((array) $tr);
697        }
698        // Yes, it really is necessary to know this count
699        $totalRows = \count($tableRows);
700        // Store all columns lengths
701        // $allColsLengths[row][column] = length
702        $allColsLengths = [];
703        // Store maximum lengths by column
704        // $maxColsLengths[column] = length
705        $maxColsLengths = [];
706        // Read row by row and define the longest columns
707        for ($row = 0; $row < $totalRows; $row++) {
708            $column = 0; // Current column index
709            foreach ($tableRows[$row] as $col) {
710                // Sets the size of this column in the current row
711                $allColsLengths[$row][$column] = static::strlen((string) $col);
712                // If the current column does not have a value among the larger ones
713                // or the value of this is greater than the existing one
714                // then, now, this assumes the maximum length
715                if ( ! isset($maxColsLengths[$column])
716                    || $allColsLengths[$row][$column] > $maxColsLengths[$column]) {
717                    $maxColsLengths[$column] = $allColsLengths[$row][$column];
718                }
719                // We can go check the size of the next column...
720                $column++;
721            }
722        }
723        // Read row by row and add spaces at the end of the columns
724        // to match the exact column length
725        for ($row = 0; $row < $totalRows; $row++) {
726            $column = 0;
727            foreach ($tableRows[$row] as $col => $value) {
728                $diff = $maxColsLengths[$column] - $allColsLengths[$row][$col];
729                if ($diff) {
730                    $tableRows[$row][$column] .= \str_repeat(' ', $diff);
731                }
732                $column++;
733            }
734        }
735        $table = $line = '';
736        // Joins columns and append the well formatted rows to the table
737        foreach ($tableRows as $row => $value) {
738            // Set the table border-top
739            if ($row === 0) {
740                $line = '+';
741                foreach (\array_keys($value) as $col) {
742                    $line .= \str_repeat('-', $maxColsLengths[$col] + 2) . '+';
743                }
744                $table .= $line . \PHP_EOL;
745            }
746            // Set the vertical borders
747            $table .= '| ' . \implode(' | ', $value) . ' |' . \PHP_EOL;
748            // Set the thead and table borders-bottom
749            if (($row === 0 && ! empty($thead)) || $row + 1 === $totalRows) {
750                $table .= $line . \PHP_EOL;
751            }
752        }
753        \fwrite(\STDOUT, $table);
754    }
755}