Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
82 / 82
96.91% covered (success)
96.91%
94 / 97
34.26% covered (danger)
34.26%
37 / 108
86.96% covered (warning)
86.96%
20 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Io
100.00% covered (success)
100.00%
82 / 82
96.91% covered (success)
96.91%
94 / 97
34.26% covered (danger)
34.26%
37 / 108
100.00% covered (success)
100.00%
23 / 23
914.47
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%
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
 echoln
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
 echoErr
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
 echolnErr
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
 escape
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
 info
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
 success
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
 warn
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
 error
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
 rule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
7 / 7
50.00% covered (danger)
50.00%
4 / 8
100.00% covered (success)
100.00%
1 / 1
6.00
 ask
100.00% covered (success)
100.00%
4 / 4
90.00% covered (success)
90.00%
9 / 10
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
4.12
 confirm
100.00% covered (success)
100.00%
4 / 4
88.89% covered (warning)
88.89%
8 / 9
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
4.12
 readline
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
8.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
 indent
100.00% covered (success)
100.00%
10 / 10
94.12% covered (success)
94.12%
16 / 17
8.33% covered (danger)
8.33%
4 / 48
100.00% covered (success)
100.00%
1 / 1
33.73
 wrap
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
10 / 10
12.50% covered (danger)
12.50%
1 / 8
100.00% covered (success)
100.00%
1 / 1
21.75
 terminalWidth
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
9 / 9
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
13.78
 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
 stdin
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
 open
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
0.00% covered (danger)
0.00%
0 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasColorSupport
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
9 / 9
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
8.81
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use RuntimeException;
8use ValueError;
9
10/**
11 * The echo methods render the inline console markup, e.g.
12 * `<green>done</green>` or `<strong>1</strong>`; see Markup for the tag
13 * set and passthrough rules. Use `escape()` for text that must print
14 * literally. The message helpers treat their input as plain text.
15 *
16 * @api
17 */
18class Io
19{
20    private readonly Markup $markup;
21    private mixed $stream = null;
22    private mixed $errorStream = null;
23    private mixed $inputStream = null;
24    private ?int $width = null;
25
26    public function __construct(
27        protected readonly string $target = 'php://stdout',
28        protected readonly string $errorTarget = 'php://stderr',
29        protected readonly string $inputTarget = 'php://stdin',
30    ) {
31        $this->markup = new Markup();
32    }
33
34    public function echo(string $text): void
35    {
36        $stream = $this->stdout();
37        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)));
38    }
39
40    public function echoln(string $text): void
41    {
42        $stream = $this->stdout();
43        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)) . PHP_EOL);
44    }
45
46    public function echoErr(string $text): void
47    {
48        $stream = $this->stderr();
49        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)));
50    }
51
52    public function echolnErr(string $text): void
53    {
54        $stream = $this->stderr();
55        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)) . PHP_EOL);
56    }
57
58    /**
59     * Escapes markup tags and strips control characters (keeping
60     * newlines and tabs) so the text prints literally.
61     */
62    public function escape(string $text): string
63    {
64        return $this->markup->escape($text);
65    }
66
67    public function info(string $message): void
68    {
69        $this->echoln($this->escape($message));
70    }
71
72    public function success(string $message): void
73    {
74        $this->echoln('<green>' . $this->escape($message) . '</green>');
75    }
76
77    public function warn(string $message): void
78    {
79        $this->echolnErr('<yellow>' . $this->escape($message) . '</yellow>');
80    }
81
82    public function error(string $message): void
83    {
84        $this->echolnErr('<red>' . $this->escape($message) . '</red>');
85    }
86
87    /**
88     * Writes a horizontal rule: the char repeated across the terminal.
89     *
90     * `$max` caps the width like in `indent()`. The char may be a
91     * multi-char pattern and may carry markup â€” the repeat count uses
92     * its visible width: `$io->rule('<dim>─</dim>')` draws a dim line.
93     */
94    public function rule(string $char = '─', ?int $max = null): void
95    {
96        $width = $this->terminalWidth();
97
98        if ($max !== null && $max < $width) {
99            $width = $max;
100        }
101
102        $unit = $this->markup->width($char);
103
104        if ($unit < 1) {
105            throw new ValueError("Rule char '{$char}' has no visible width");
106        }
107
108        $this->echoln(str_repeat($char, intdiv($width, $unit)));
109    }
110
111    /**
112     * Prints the question and reads one line from the input stream.
113     *
114     * A trimmed empty answer (or end of input) yields the default. With
115     * `hidden` the terminal echo is switched off while typing, for example
116     * for passwords, and the answer keeps its whitespace; only the trailing
117     * newline is stripped. On Windows, or without a terminal, the input is
118     * simply read as is, visibly.
119     */
120    public function ask(string $question, string $default = '', bool $hidden = false): string
121    {
122        $this->echo($question . ' ');
123        $line = $this->readline($hidden);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
125
126        return $answer === '' ? $default : $answer;
127    }
128
129    /**
130     * Asks a yes/no question and returns the answer as bool.
131     *
132     * An empty answer yields the default; an answer starting with `y` or
133     * `Y` means yes, anything else no.
134     */
135    public function confirm(string $question, bool $default = false): bool
136    {
137        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
138
139        if ($answer === '') {
140            return $default;
141        }
142
143        return str_starts_with($answer, 'y');
144    }
145
146    private function readline(bool $hidden): string
147    {
148        $stream = $this->stdin();
149
150        // No stty on Windows; shell_exec would leak its error output.
151        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
152            // @codeCoverageIgnoreStart
153            /** @psalm-suppress ForbiddenCode */
154            $previous = trim((string) shell_exec('stty -g'));
155
156            /** @psalm-suppress ForbiddenCode */
157            shell_exec('stty -echo');
158
159            try {
160                return (string) fgets($stream);
161            } finally {
162                // Restore the saved terminal state rather than assuming
163                // echo was on, even when reading throws.
164                /** @psalm-suppress ForbiddenCode */
165                shell_exec($previous === '' ? 'stty echo' : 'stty ' . escapeshellarg($previous));
166                $this->echo(PHP_EOL);
167            }
168
169            // @codeCoverageIgnoreEnd
170        }
171
172        return (string) fgets($stream);
173    }
174
175    private function write(mixed $stream, string $text): void
176    {
177        fwrite($stream, $text);
178        fflush($stream);
179    }
180
181    /**
182     * Indents the text and wraps it on its visible width; markup tags
183     * and multibyte characters don't count. `$max` caps the total line
184     * width: the text wraps as if the terminal were at most that wide.
185     */
186    public function indent(
187        string $text,
188        int $indent,
189        ?int $max = null,
190    ): string {
191        $spaces = str_repeat(' ', $indent);
192        $terminal = $this->terminalWidth();
193
194        if ($max !== null && $max < $terminal) {
195            $terminal = $max;
196        }
197
198        $width = $terminal - $indent;
199
200        $lines = [];
201
202        foreach (explode("\n", $text) as $line) {
203            foreach ($this->wrap($line, $width) as $wrapped) {
204                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
205            }
206        }
207
208        return implode("\n", $lines);
209    }
210
211    /**
212     * Wraps one line at spaces; a word longer than the width overflows.
213     *
214     * @return list<string>
215     */
216    private function wrap(string $line, int $width): array
217    {
218        $lines = [];
219        $current = null;
220        $currentWidth = 0;
221
222        foreach (explode(' ', $line) as $word) {
223            $wordWidth = $this->markup->width($word);
224
225            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
226                $current .= ' ' . $word;
227                $currentWidth += 1 + $wordWidth;
228
229                continue;
230            }
231
232            if ($current !== null) {
233                $lines[] = $current;
234            }
235
236            $current = $word;
237            $currentWidth = $wordWidth;
238        }
239
240        $lines[] = (string) $current;
241
242        return $lines;
243    }
244
245    private function terminalWidth(): int
246    {
247        if ($this->width !== null) {
248            return $this->width;
249        }
250
251        $columns = (int) getenv('COLUMNS');
252
253        // No tput on Windows; shell_exec would leak its error output.
254        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
255            // @codeCoverageIgnoreStart
256            /** @psalm-suppress ForbiddenCode */
257            $columns = (int) shell_exec('tput cols');
258
259            // @codeCoverageIgnoreEnd
260        }
261
262        if ($columns < 1) {
263            // @codeCoverageIgnoreStart
264            $columns = 80;
265
266            // @codeCoverageIgnoreEnd
267        }
268
269        return $this->width = $columns;
270    }
271
272    protected function stdout(): mixed
273    {
274        return $this->stream ??= $this->open($this->target, 'w');
275    }
276
277    protected function stderr(): mixed
278    {
279        return $this->errorStream ??= $this->open($this->errorTarget, 'w');
280    }
281
282    protected function stdin(): mixed
283    {
284        return $this->inputStream ??= $this->open($this->inputTarget, 'r');
285    }
286
287    private function open(string $target, string $mode): mixed
288    {
289        set_error_handler(static fn(): bool => true);
290
291        try {
292            $stream = fopen($target, $mode);
293        } finally {
294            restore_error_handler();
295        }
296
297        if ($stream === false) {
298            throw new RuntimeException("Could not open stream '{$target}'");
299        }
300
301        return $stream;
302    }
303
304    protected function hasColorSupport(mixed $stream): bool
305    {
306        $noColor = getenv('NO_COLOR');
307
308        if ($noColor !== false && $noColor !== '') {
309            return false;
310        }
311
312        $force = getenv('FORCE_COLOR');
313
314        if ($force !== false) {
315            return $force !== '0' && strtolower($force) !== 'false';
316        }
317
318        $terminal = stream_isatty($stream);
319
320        // @codeCoverageIgnoreStart
321        if (DIRECTORY_SEPARATOR === '\\' && $terminal) {
322            // VT100 processing is off by default in cmd/PowerShell;
323            // enabling it reports whether the console supports it.
324            return sapi_windows_vt100_support($stream, enable: true);
325        }
326
327        // @codeCoverageIgnoreEnd
328
329        return $terminal;
330    }
331}