Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
42 / 42
98.00% covered (success)
98.00%
49 / 50
51.02% covered (warning)
51.02%
25 / 49
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Output
100.00% covered (success)
100.00%
42 / 42
98.00% covered (success)
98.00%
49 / 50
51.02% covered (warning)
51.02%
25 / 49
100.00% covered (success)
100.00%
14 / 14
207.67
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echoln
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echoErr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 echolnErr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 styled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 write
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 color
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
11 / 11
29.41% covered (danger)
29.41%
5 / 17
100.00% covered (success)
100.00%
1 / 1
18.66
 indent
100.00% covered (success)
100.00%
6 / 6
87.50% covered (warning)
87.50%
7 / 8
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
5.20
 terminalWidth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
6 / 6
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
4.59
 formatText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
9 / 9
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
8.12
 stdout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stderr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasColorSupport
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Cli;
6
7final class Output
8{
9    private mixed $stream = null;
10    private mixed $errorStream = null;
11    private ?int $width = null;
12    private array $fg = [
13        'black' => [0, 30],
14        'gray' => [1, 30],
15        'grey' => [1, 30],
16        'red' => [0, 31],
17        'lightred' => [1, 31],
18        'green' => [0, 32],
19        'lightgreen' => [1, 32],
20        'brown' => [0, 33],
21        'yellow' => [1, 33],
22        'blue' => [0, 34],
23        'lightblue' => [1, 34],
24        'purple' => [0, 35],
25        'lightpurple' => [1, 35],
26        'magenta' => [0, 35],
27        'lightmagenta' => [1, 35],
28        'cyan' => [0, 36],
29        'lightcyan' => [1, 36],
30        'lightgray' => [0, 37],
31        'lightgrey' => [0, 37],
32        'white' => [1, 37],
33    ];
34    private array $bg = [
35        'black' => 40,
36        'red' => 41,
37        'green' => 42,
38        'yellow' => 43,
39        'blue' => 44,
40        'purple' => 45,
41        'magenta' => 45,
42        'cyan' => 46,
43        'gray' => 47,
44        'grey' => 47,
45        'white' => 47,
46    ];
47
48    public function __construct(
49        protected readonly string $target = 'php://output',
50        protected readonly string $errorTarget = 'php://stderr',
51    ) {}
52
53    public function echo(string $text, string $color = '', string $background = ''): void
54    {
55        $this->write($this->stdout(), $this->styled($text, $color, $background));
56    }
57
58    public function echoln(string $text, string $color = '', string $background = ''): void
59    {
60        $this->write($this->stdout(), $this->styled($text, $color, $background) . PHP_EOL);
61    }
62
63    public function echoErr(string $text, string $color = '', string $background = ''): void
64    {
65        $this->write($this->stderr(), $this->styled($text, $color, $background));
66    }
67
68    public function echolnErr(string $text, string $color = '', string $background = ''): void
69    {
70        $this->write($this->stderr(), $this->styled($text, $color, $background) . PHP_EOL);
71    }
72
73    private function styled(string $text, string $color, string $background): string
74    {
75        return $color || $background ? $this->color($text, $color, $background) : $text;
76    }
77
78    private function write(mixed $stream, string $text): void
79    {
80        fwrite($stream, $text);
81        fflush($stream);
82    }
83
84    public function color(string $text, string $color = '', string $background = ''): string
85    {
86        if (!$this->hasColorSupport()) {
87            return $text;
88        }
89
90        $colorCode = '';
91        $backgroundCode = '';
92
93        if ($color && array_key_exists($color, $this->fg)) {
94            [$first, $second] = $this->fg[$color];
95            $colorCode = "{$first};{$second}";
96        }
97
98        if ($background && array_key_exists($background, $this->bg)) {
99            $backgroundCode = $this->bg[$background];
100        }
101
102        return $this->formatText($text, $colorCode, $backgroundCode);
103    }
104
105    public function indent(
106        string $text,
107        int $indent,
108        ?int $max = null,
109    ): string {
110        $spaces = str_repeat(' ', $indent);
111        $width = $this->terminalWidth() - $indent;
112
113        if ($max !== null && $max < $width) {
114            $width = $max;
115        }
116
117        $lines = explode("\n", wordwrap($text, $width, break: "\n"));
118
119        return implode("\n", array_map(static fn($line) => $spaces . $line, $lines));
120    }
121
122    private function terminalWidth(): int
123    {
124        if ($this->width !== null) {
125            return $this->width;
126        }
127
128        $columns = (int) getenv('COLUMNS');
129
130        if ($columns < 1) {
131            /** @psalm-suppress ForbiddenCode */
132            $columns = (int) shell_exec('tput cols');
133        }
134
135        if ($columns < 1) {
136            // @codeCoverageIgnoreStart
137            $columns = 80;
138
139            // @codeCoverageIgnoreEnd
140        }
141
142        return $this->width = $columns;
143    }
144
145    private function formatText(string $text, string $colorCode, string|int $backgroundCode): string
146    {
147        if ($colorCode && $backgroundCode) {
148            return "\033[{$colorCode};{$backgroundCode}m{$text}\033[0m";
149        }
150
151        if ($colorCode) {
152            return "\033[{$colorCode}m{$text}\033[0m";
153        }
154
155        if ($backgroundCode) {
156            return "\033[{$backgroundCode}m{$text}\033[0m";
157        }
158
159        return $text;
160    }
161
162    private function stdout(): mixed
163    {
164        return $this->stream ??= fopen($this->target, mode: 'w');
165    }
166
167    private function stderr(): mixed
168    {
169        return $this->errorStream ??= fopen($this->errorTarget, mode: 'w');
170    }
171
172    private function hasColorSupport(): bool
173    {
174        if (getenv('NO_COLOR') !== false) {
175            return false;
176        }
177
178        // @codeCoverageIgnoreStart
179        if (getenv('FORCE_COLOR') !== false || getenv('COLORTERM') !== false) {
180            return true;
181        }
182
183        // Windows
184        if (DIRECTORY_SEPARATOR === '\\') {
185            if (function_exists('sapi_windows_vt100_support')) {
186                return sapi_windows_vt100_support(STDOUT);
187            }
188
189            return (
190                getenv('ANSICON') !== false
191                || getenv('ConEmuANSI') === 'ON'
192                || getenv('TERM') === 'xterm'
193            );
194        }
195
196        if (function_exists('stream_isatty')) {
197            return stream_isatty(STDOUT);
198        }
199
200        return false;
201
202        // @codeCoverageIgnoreEnd
203    }
204}