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}

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
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);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
124        $answer = $hidden ? rtrim($line, characters: "\r\n") : trim($line);
125
126        return $answer === '' ? $default : $answer;
126        return $answer === '' ? $default : $answer;
126        return $answer === '' ? $default : $answer;
126        return $answer === '' ? $default : $answer;
127    }
Io->confirm
135    public function confirm(string $question, bool $default = false): bool
136    {
137        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
137        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
137        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
137        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
138
139        if ($answer === '') {
140            return $default;
143        return str_starts_with($answer, 'y');
143        return str_starts_with($answer, 'y');
143        return str_starts_with($answer, 'y');
143        return str_starts_with($answer, 'y');
144    }
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
304    protected function hasColorSupport(mixed $stream): bool
305    {
306        $noColor = getenv('NO_COLOR');
307
308        if ($noColor !== false && $noColor !== '') {
308        if ($noColor !== false && $noColor !== '') {
308        if ($noColor !== false && $noColor !== '') {
309            return false;
312        $force = getenv('FORCE_COLOR');
313
314        if ($force !== false) {
315            return $force !== '0' && strtolower($force) !== 'false';
315            return $force !== '0' && strtolower($force) !== 'false';
315            return $force !== '0' && strtolower($force) !== 'false';
329        return $terminal;
330    }
Io->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) {
194        if ($max !== null && $max < $terminal) {
194        if ($max !== null && $max < $terminal) {
195            $terminal = $max;
196        }
197
198        $width = $terminal - $indent;
198        $width = $terminal - $indent;
199
200        $lines = [];
201
202        foreach (explode("\n", $text) as $line) {
202        foreach (explode("\n", $text) as $line) {
203            foreach ($this->wrap($line, $width) as $wrapped) {
203            foreach ($this->wrap($line, $width) as $wrapped) {
204                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
204                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
204                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
203            foreach ($this->wrap($line, $width) as $wrapped) {
204                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
202        foreach (explode("\n", $text) as $line) {
203            foreach ($this->wrap($line, $width) as $wrapped) {
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);
208        return implode("\n", $lines);
208        return implode("\n", $lines);
208        return implode("\n", $lines);
209    }
Io->info
67    public function info(string $message): void
68    {
69        $this->echoln($this->escape($message));
70    }
Io->open
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 {
293        } finally {
294            restore_error_handler();
297        if ($stream === false) {
298            throw new RuntimeException("Could not open stream '{$target}'");
301        return $stream;
302    }
Io->readline
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)) {
151        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
151        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
151        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
151        if ($hidden && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($stream)) {
172        return (string) fgets($stream);
173    }
Io->rule
94    public function rule(string $char = '─', ?int $max = null): void
95    {
96        $width = $this->terminalWidth();
97
98        if ($max !== null && $max < $width) {
98        if ($max !== null && $max < $width) {
98        if ($max !== null && $max < $width) {
99            $width = $max;
100        }
101
102        $unit = $this->markup->width($char);
102        $unit = $this->markup->width($char);
103
104        if ($unit < 1) {
105            throw new ValueError("Rule char '{$char}' has no visible width");
108        $this->echoln(str_repeat($char, intdiv($width, $unit)));
109    }
Io->stderr
279        return $this->errorStream ??= $this->open($this->errorTarget, 'w');
280    }
Io->stdin
284        return $this->inputStream ??= $this->open($this->inputTarget, 'r');
285    }
Io->stdout
274        return $this->stream ??= $this->open($this->target, 'w');
275    }
Io->success
72    public function success(string $message): void
73    {
74        $this->echoln('<green>' . $this->escape($message) . '</green>');
75    }
Io->terminalWidth
247        if ($this->width !== null) {
248            return $this->width;
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())) {
254        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
254        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
254        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
254        if ($columns < 1 && DIRECTORY_SEPARATOR !== '\\' && stream_isatty($this->stdout())) {
262        if ($columns < 1) {
269        return $this->width = $columns;
270    }
Io->warn
77    public function warn(string $message): void
78    {
79        $this->echolnErr('<yellow>' . $this->escape($message) . '</yellow>');
80    }
Io->wrap
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) {
222        foreach (explode(' ', $line) as $word) {
223            $wordWidth = $this->markup->width($word);
224
225            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
225            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
225            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
226                $current .= ' ' . $word;
227                $currentWidth += 1 + $wordWidth;
228
229                continue;
232            if ($current !== null) {
233                $lines[] = $current;
234            }
235
236            $current = $word;
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;
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    }
Io->write
175    private function write(mixed $stream, string $text): void
176    {
177        fwrite($stream, $text);
178        fflush($stream);
179    }
{closure:/workspace/celema/console/src/Io.php:289-289}
289        set_error_handler(static fn(): bool => true);