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}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Io->__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    }
Io->ask
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);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
134        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
135
136        return $answer === '' ? $default : $answer;
136        return $answer === '' ? $default : $answer;
136        return $answer === '' ? $default : $answer;
136        return $answer === '' ? $default : $answer;
137    }
Io->choice
166    public function choice(string $question, array $options, int $default = 1): string
167    {
168        if ($options === []) {
169            throw new ValueError('Choice needs options');
172        if ($default < 1 || $default > count($options)) {
172        if ($default < 1 || $default > count($options)) {
172        if ($default < 1 || $default > count($options)) {
173            throw new ValueError("Choice default {$default} is out of range");
176        $this->echoln($question);
177
178        foreach ($options as $i => $option) {
178        foreach ($options as $i => $option) {
178        foreach ($options as $i => $option) {
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];
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
189            if (ctype_digit($answer) && (int) $answer >= 1 && (int) $answer <= count($options)) {
190                return $options[(int) $answer - 1];
182        while (true) {
193    }
Io->confirm
145    public function confirm(string $question, bool $default = false): bool
146    {
147        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
147        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
147        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
147        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
148
149        if ($answer === '') {
150            return $default;
153        return str_starts_with($answer, 'y');
153        return str_starts_with($answer, 'y');
153        return str_starts_with($answer, 'y');
153        return str_starts_with($answer, 'y');
154    }
Io->echo
34    public function echo(string $text): void
35    {
36        $stream = $this->stdout();
37        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)));
38    }
Io->echoErr
46    public function echoErr(string $text): void
47    {
48        $stream = $this->stderr();
49        $this->write($stream, $this->markup->render($text, $this->hasColorSupport($stream)));
50    }
Io->echoln
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    }
Io->echolnErr
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    }
Io->error
82    public function error(string $message): void
83    {
84        $this->echolnErr('<red>' . $this->escape($message) . '</red>');
85    }
Io->escape
62    public function escape(string $text): string
63    {
64        return $this->markup->escape($text);
65    }
Io->hasColorSupport
353    protected function hasColorSupport(mixed $stream): bool
354    {
355        $noColor = getenv('NO_COLOR');
356
357        if ($noColor !== false && $noColor !== '') {
357        if ($noColor !== false && $noColor !== '') {
357        if ($noColor !== false && $noColor !== '') {
358            return false;
361        $force = getenv('FORCE_COLOR');
362
363        if ($force !== false) {
364            return $force !== '0' && strtolower($force) !== 'false';
364            return $force !== '0' && strtolower($force) !== 'false';
364            return $force !== '0' && strtolower($force) !== 'false';
378        return $terminal;
379    }
Io->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) {
243        if ($max !== null && $max < $terminal) {
243        if ($max !== null && $max < $terminal) {
244            $terminal = $max;
245        }
246
247        $width = $terminal - $indent;
247        $width = $terminal - $indent;
248
249        $lines = [];
250
251        foreach (explode("\n", $text) as $line) {
251        foreach (explode("\n", $text) as $line) {
252            foreach ($this->wrap($line, $width) as $wrapped) {
252            foreach ($this->wrap($line, $width) as $wrapped) {
253                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
253                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
253                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
252            foreach ($this->wrap($line, $width) as $wrapped) {
253                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
251        foreach (explode("\n", $text) as $line) {
252            foreach ($this->wrap($line, $width) as $wrapped) {
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);
257        return implode("\n", $lines);
257        return implode("\n", $lines);
257        return implode("\n", $lines);
258    }
Io->info
67    public function info(string $message): void
68    {
69        $this->echoln($this->escape($message));
70    }
Io->open
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 {
342        } finally {
343            restore_error_handler();
346        if ($stream === false) {
347            throw new RuntimeException("Could not open stream '{$target}'");
350        return $stream;
351    }
Io->pad
92    public function pad(string $text, int $width, Align $align = Align::Left): string
93    {
94        return $this->markup->pad($text, $width, $align);
95    }
Io->readline
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)) {
200        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
200        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
200        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
200        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
221        return (string) fgets($stream);
222    }
Io->rule
104    public function rule(string $char = '─', ?int $max = null): void
105    {
106        $width = $this->terminalWidth();
107
108        if ($max !== null && $max < $width) {
108        if ($max !== null && $max < $width) {
108        if ($max !== null && $max < $width) {
109            $width = $max;
110        }
111
112        $unit = $this->markup->width($char);
112        $unit = $this->markup->width($char);
113
114        if ($unit < 1) {
115            throw new ValueError("Rule char '{$char}' has no visible width");
118        $this->echoln(str_repeat($char, intdiv($width, $unit)));
119    }
Io->stderr
328        return $this->errorStream ??= $this->open($this->errorTarget, 'w');
329    }
Io->stdin
333        return $this->inputStream ??= $this->open($this->inputTarget, 'r');
334    }
Io->stdout
323        return $this->stream ??= $this->open($this->target, 'w');
324    }
Io->success
72    public function success(string $message): void
73    {
74        $this->echoln('<green>' . $this->escape($message) . '</green>');
75    }
Io->terminalWidth
296        if ($this->width !== null) {
297            return $this->width;
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())) {
303        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
303        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
303        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
303        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
311        if ($columns < 1) {
318        return $this->width = $columns;
319    }
Io->warn
77    public function warn(string $message): void
78    {
79        $this->echolnErr('<yellow>' . $this->escape($message) . '</yellow>');
80    }
Io->wrap
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) {
271        foreach (explode(' ', $line) as $word) {
272            $wordWidth = $this->markup->width($word);
273
274            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
274            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
274            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
275                $current .= ' ' . $word;
276                $currentWidth += 1 + $wordWidth;
277
278                continue;
281            if ($current !== null) {
282                $lines[] = $current;
283            }
284
285            $current = $word;
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;
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    }
Io->write
224    private function write(mixed $stream, string $text): void
225    {
226        fwrite($stream, $text);
227        fflush($stream);
228    }
{closure:/workspace/celema/console/src/Io.php:338-338}
338        set_error_handler(static fn(): bool => true);