Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
96 / 96
96.61% covered (success)
96.61%
114 / 118
24.42% covered (danger)
24.42%
42 / 172
84.00% covered (warning)
84.00%
21 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
Io
100.00% covered (success)
100.00%
96 / 96
96.61% covered (success)
96.61%
114 / 118
24.42% covered (danger)
24.42%
42 / 172
100.00% covered (success)
100.00%
25 / 25
1946.76
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
 pad
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
 choice
100.00% covered (success)
100.00%
13 / 13
95.00% covered (success)
95.00%
19 / 20
6.35% covered (danger)
6.35%
4 / 63
100.00% covered (success)
100.00%
1 / 1
92.14
 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     * Pads the text with spaces to the visible width `$width`; markup
89     * tags and multibyte characters don't count, wider text is
90     * returned unchanged.
91     */
92    public function pad(string $text, int $width, Align $align = Align::Left): string
93    {
94        return $this->markup->pad($text, $width, $align);
95    }
96
97    /**
98     * Writes a horizontal rule: the char repeated across the terminal.
99     *
100     * `$max` caps the width like in `indent()`. The char may be a
101     * multi-char pattern and may carry markup â€” the repeat count uses
102     * its visible width: `$io->rule('<dim>─</dim>')` draws a dim line.
103     */
104    public function rule(string $char = '─', ?int $max = null): void
105    {
106        $width = $this->terminalWidth();
107
108        if ($max !== null && $max < $width) {
109            $width = $max;
110        }
111
112        $unit = $this->markup->width($char);
113
114        if ($unit < 1) {
115            throw new ValueError("Rule char '{$char}' has no visible width");
116        }
117
118        $this->echoln(str_repeat($char, intdiv($width, $unit)));
119    }
120
121    /**
122     * Prints the question and reads one line from the input stream.
123     *
124     * A trimmed empty answer (or end of input) yields the default. With
125     * `hidden` the terminal echo is switched off while typing, for example
126     * for passwords, and the answer keeps its whitespace; only the trailing
127     * newline is stripped. On Windows, or without a terminal, the input is
128     * simply read as is, visibly.
129     */
130    public function ask(string $question, string $default = '', bool $hidden = false): string
131    {
132        $this->echo($question . ' ');
133        $line = $this->readline($hidden);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
135
136        return $answer === '' ? $default : $answer;
137    }
138
139    /**
140     * Asks a yes/no question and returns the answer as bool.
141     *
142     * An empty answer yields the default; an answer starting with `y` or
143     * `Y` means yes, anything else no.
144     */
145    public function confirm(string $question, bool $default = false): bool
146    {
147        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
148
149        if ($answer === '') {
150            return $default;
151        }
152
153        return str_starts_with($answer, 'y');
154    }
155
156    /**
157     * Asks to pick from a numbered list and returns the chosen option.
158     *
159     * The options render one per line, numbered from 1, then the
160     * prompt shows the default number: `[1]`. An empty answer (or end
161     * of input) yields the default's option; anything else must be a
162     * listed number â€” an invalid answer asks again.
163     *
164     * @param list<string> $options
165     */
166    public function choice(string $question, array $options, int $default = 1): string
167    {
168        if ($options === []) {
169            throw new ValueError('Choice needs options');
170        }
171
172        if ($default < 1 || $default > count($options)) {
173            throw new ValueError("Choice default {$default} is out of range");
174        }
175
176        $this->echoln($question);
177
178        foreach ($options as $i => $option) {
179            $this->echoln('  ' . ($i + 1) . ') ' . $option);
180        }
181
182        while (true) {
183            $answer = $this->ask("[{$default}]");
184
185            if ($answer === '') {
186                return $options[$default - 1];
187            }
188
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
190                return $options[(int) $answer - 1];
191            }
192        }
193    }
194
195    private function readline(bool $hidden): string
196    {
197        $stream = $this->stdin();
198
199        // No stty on Windows; shell_exec would leak its error output.
200        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
201            // @codeCoverageIgnoreStart
202            /** @psalm-suppress ForbiddenCode */
203            $previous = trim((string) shell_exec('stty -g'));
204
205            /** @psalm-suppress ForbiddenCode */
206            shell_exec('stty -echo');
207
208            try {
209                return (string) fgets($stream);
210            } finally {
211                // Restore the saved terminal state rather than assuming
212                // echo was on, even when reading throws.
213                /** @psalm-suppress ForbiddenCode */
214                shell_exec($previous === '' ? 'stty echo' : 'stty ' . escapeshellarg($previous));
215                $this->echo(PHP_EOL);
216            }
217
218            // @codeCoverageIgnoreEnd
219        }
220
221        return (string) fgets($stream);
222    }
223
224    private function write(mixed $stream, string $text): void
225    {
226        fwrite($stream, $text);
227        fflush($stream);
228    }
229
230    /**
231     * Indents the text and wraps it on its visible width; markup tags
232     * and multibyte characters don't count. `$max` caps the total line
233     * width: the text wraps as if the terminal were at most that wide.
234     */
235    public function indent(
236        string $text,
237        int $indent,
238        ?int $max = null,
239    ): string {
240        $spaces = str_repeat(' ', $indent);
241        $terminal = $this->terminalWidth();
242
243        if ($max !== null && $max < $terminal) {
244            $terminal = $max;
245        }
246
247        $width = $terminal - $indent;
248
249        $lines = [];
250
251        foreach (explode("\n", $text) as $line) {
252            foreach ($this->wrap($line, $width) as $wrapped) {
253                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
254            }
255        }
256
257        return implode("\n", $lines);
258    }
259
260    /**
261     * Wraps one line at spaces; a word longer than the width overflows.
262     *
263     * @return list<string>
264     */
265    private function wrap(string $line, int $width): array
266    {
267        $lines = [];
268        $current = null;
269        $currentWidth = 0;
270
271        foreach (explode(' ', $line) as $word) {
272            $wordWidth = $this->markup->width($word);
273
274            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
275                $current .= ' ' . $word;
276                $currentWidth += 1 + $wordWidth;
277
278                continue;
279            }
280
281            if ($current !== null) {
282                $lines[] = $current;
283            }
284
285            $current = $word;
286            $currentWidth = $wordWidth;
287        }
288
289        $lines[] = (string) $current;
290
291        return $lines;
292    }
293
294    private function terminalWidth(): int
295    {
296        if ($this->width !== null) {
297            return $this->width;
298        }
299
300        $columns = (int) getenv('COLUMNS');
301
302        // No tput on Windows; shell_exec would leak its error output.
303        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
304            // @codeCoverageIgnoreStart
305            /** @psalm-suppress ForbiddenCode */
306            $columns = (int) shell_exec('tput cols');
307
308            // @codeCoverageIgnoreEnd
309        }
310
311        if ($columns < 1) {
312            // @codeCoverageIgnoreStart
313            $columns = 80;
314
315            // @codeCoverageIgnoreEnd
316        }
317
318        return $this->width = $columns;
319    }
320
321    protected function stdout(): mixed
322    {
323        return $this->stream ??= $this->open($this->target, 'w');
324    }
325
326    protected function stderr(): mixed
327    {
328        return $this->errorStream ??= $this->open($this->errorTarget, 'w');
329    }
330
331    protected function stdin(): mixed
332    {
333        return $this->inputStream ??= $this->open($this->inputTarget, 'r');
334    }
335
336    private function open(string $target, string $mode): mixed
337    {
338        set_error_handler(static fn(): bool => true);
339
340        try {
341            $stream = fopen($target, $mode);
342        } finally {
343            restore_error_handler();
344        }
345
346        if ($stream === false) {
347            throw new RuntimeException("Could not open stream '{$target}'");
348        }
349
350        return $stream;
351    }
352
353    protected function hasColorSupport(mixed $stream): bool
354    {
355        $noColor = getenv('NO_COLOR');
356
357        if ($noColor !== false && $noColor !== '') {
358            return false;
359        }
360
361        $force = getenv('FORCE_COLOR');
362
363        if ($force !== false) {
364            return $force !== '0' && strtolower($force) !== 'false';
365        }
366
367        $terminal = stream_isatty($stream);
368
369        // @codeCoverageIgnoreStart
370        if (DIRECTORY_SEPARATOR === '\\' && $terminal) {
371            // VT100 processing is off by default in cmd/PowerShell;
372            // enabling it reports whether the console supports it.
373            return sapi_windows_vt100_support($stream, enable: true);
374        }
375
376        // @codeCoverageIgnoreEnd
377
378        return $terminal;
379    }
380}