Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
77 / 77
92.55% covered (success)
92.55%
87 / 94
16.50% covered (danger)
16.50%
33 / 200
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Io
100.00% covered (success)
100.00%
77 / 77
92.55% covered (success)
92.55%
87 / 94
16.50% covered (danger)
16.50%
33 / 200
100.00% covered (success)
100.00%
19 / 19
1171.11
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
 line
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
 write
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
 stderr
100.00% covered (success)
100.00%
3 / 3
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
 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
90.00% covered (success)
90.00%
9 / 10
33.33% covered (danger)
33.33%
4 / 12
100.00% covered (success)
100.00%
1 / 1
8.74
 ask
100.00% covered (success)
100.00%
6 / 6
88.89% covered (warning)
88.89%
8 / 9
37.50% covered (danger)
37.50%
3 / 8
100.00% covered (success)
100.00%
1 / 1
5.20
 secret
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
 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%
16 / 16
90.48% covered (success)
90.48%
19 / 21
7.14% covered (danger)
7.14%
4 / 56
100.00% covered (success)
100.00%
1 / 1
73.85
 interactive
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
 width
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
 output
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 indent
100.00% covered (success)
100.00%
11 / 11
90.00% covered (success)
90.00%
18 / 20
4.17% covered (danger)
4.17%
4 / 96
100.00% covered (success)
100.00%
1 / 1
37.68
 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
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use Stringable;
8use ValueError;
9
10/**
11 * The output methods take a template and its arguments: the template is
12 * markup, e.g. `<green>done</green>` or `<strong>%d</strong>`, the
13 * arguments are data. They fill the template's sprintf() conversions
14 * and print as plain text, so no escaping is needed:
15 *
16 *     $io->error('Cannot read <strong>%s</strong>', $path);
17 *
18 * Without arguments the template is not formatted, so `%` needs no
19 * doubling. Markup never fails: a tag without its partner prints
20 * literally. See Markup for the tag set.
21 *
22 * The terminal decides where the text goes and what it supports: the
23 * process streams by default, a Buffer in tests.
24 *
25 * @api
26 */
27final class Io
28{
29    private readonly Markup $markup;
30    private bool $error = false;
31
32    public function __construct(
33        private readonly Terminal $terminal = new Stdio(),
34    ) {
35        $this->markup = new Markup();
36    }
37
38    public function line(string $template = '', float|int|string|Stringable ...$args): void
39    {
40        $this->output($template, $args, PHP_EOL);
41    }
42
43    /**
44     * Like line(), without the line break.
45     */
46    public function write(string $template, float|int|string|Stringable ...$args): void
47    {
48        $this->output($template, $args, '');
49    }
50
51    public function success(string $template, float|int|string|Stringable ...$args): void
52    {
53        $this->output($template, $args, PHP_EOL, style: 'green');
54    }
55
56    /**
57     * Writes a yellow line to the error output.
58     */
59    public function warn(string $template, float|int|string|Stringable ...$args): void
60    {
61        $this->output($template, $args, PHP_EOL, error: true, style: 'yellow');
62    }
63
64    /**
65     * Writes a red line to the error output.
66     */
67    public function error(string $template, float|int|string|Stringable ...$args): void
68    {
69        $this->output($template, $args, PHP_EOL, error: true, style: 'red');
70    }
71
72    /**
73     * An Io for the error output: whatever it writes goes there, plain
74     * lines and the prompts' questions included.
75     *
76     *     $io->stderr()->line('  - %s', $path);
77     */
78    public function stderr(): self
79    {
80        $io = clone $this;
81        $io->error = true;
82
83        return $io;
84    }
85
86    /**
87     * Escapes markup tags and strips control characters (keeping
88     * newlines and tabs) so the text prints literally where it is
89     * concatenated into a template; arguments need no escaping.
90     *
91     * The result is meant for the output methods: it carries invisible
92     * markers that keep a `%` from reading as a sprintf() conversion and
93     * a tag after a trailing backslash from reading as escaped.
94     */
95    public function escape(string $text): string
96    {
97        return $this->markup->escape($text);
98    }
99
100    /**
101     * Pads the text with spaces to the visible width `$width`; markup
102     * tags and multibyte characters don't count, wider text is
103     * returned unchanged.
104     */
105    public function pad(string $text, int $width, Align $align = Align::Left): string
106    {
107        return $this->markup->pad($text, $width, $align);
108    }
109
110    /**
111     * Writes a horizontal rule: the char repeated across the terminal.
112     *
113     * `$max` caps the width like in `indent()`; a negative one counts as
114     * 0. The char may be a multi-char pattern and may carry markup â€” the
115     * repeat count uses its visible width: `$io->rule('<dim>─</dim>')`
116     * draws a dim line.
117     */
118    public function rule(string $char = '─', ?int $max = null): void
119    {
120        $width = $this->terminal->width();
121
122        if ($max !== null && $max < $width) {
123            $width = max(0, $max);
124        }
125
126        $unit = $this->markup->width($char);
127
128        if ($unit < 1) {
129            throw new ValueError("Rule char '{$char}' has no visible width");
130        }
131
132        $this->line(str_repeat($char, intdiv($width, $unit)));
133    }
134
135    /**
136     * Prints the question, and the default if there is one, and reads one
137     * line. A trimmed empty answer, or the end of input, yields the
138     * default.
139     */
140    public function ask(string $question, string $default = ''): string
141    {
142        $this->write($question);
143
144        if ($default !== '') {
145            $this->write(' [%s]', $default);
146        }
147
148        $this->write(' ');
149        $answer = trim($this->terminal->read() ?? '');
150
151        return $answer === '' ? $default : $answer;
152    }
153
154    /**
155     * Asks for input that must not show while typing, such as a password.
156     *
157     * The answer keeps its whitespace. A terminal that fails to hide the
158     * input throws rather than reading visibly; Stdio has no way to hide
159     * it on Windows and reads visibly there, see Stdio::read().
160     */
161    public function secret(string $question): string
162    {
163        $this->write($question . ' ');
164
165        return $this->terminal->read(hidden: true) ?? '';
166    }
167
168    /**
169     * Asks a yes/no question and returns the answer as bool.
170     *
171     * An empty answer yields the default; an answer starting with `y` or
172     * `Y` means yes, anything else no.
173     */
174    public function confirm(string $question, bool $default = false): bool
175    {
176        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
177
178        if ($answer === '') {
179            return $default;
180        }
181
182        return str_starts_with($answer, 'y');
183    }
184
185    /**
186     * Asks to pick one of the options and returns its key.
187     *
188     * The labels are listed numbered from 1, and the prompt shows the
189     * default's number: `[1]`. An empty answer, or the end of input,
190     * yields the default, the first option unless given; any other answer
191     * must be a listed number â€” an invalid one asks again.
192     *
193     * @template K of array-key
194     *
195     * @param array<K, string> $options
196     * @param K|null $default
197     *
198     * @return K
199     */
200    public function choice(string $question, array $options, int|string|null $default = null): int|string
201    {
202        if ($options === []) {
203            throw new ValueError('Choice needs options');
204        }
205
206        $default ??= array_key_first($options);
207
208        if (!array_key_exists($default, $options)) {
209            throw new ValueError("Choice default '{$default}' is not an option");
210        }
211
212        $keys = array_keys($options);
213        $number = array_flip($keys)[$default] + 1;
214        $this->line($question);
215
216        foreach (array_values($options) as $i => $label) {
217            $this->line('  %d) %s', $i + 1, $label);
218        }
219
220        while (true) {
221            $answer = $this->ask("[{$number}]");
222
223            if ($answer === '') {
224                return $keys[$number - 1];
225            }
226
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
228                return $keys[(int) $answer - 1];
229            }
230        }
231    }
232
233    /**
234     * Whether someone can see the prompts and answer them.
235     */
236    public function interactive(): bool
237    {
238        return $this->terminal->interactive();
239    }
240
241    /**
242     * The terminal width in columns.
243     */
244    public function width(): int
245    {
246        return $this->terminal->width();
247    }
248
249    /** @param array<float|int|string|Stringable> $args */
250    private function output(string $template, array $args, string $end, bool $error = false, string $style = ''): void
251    {
252        $error = $error || $this->error;
253        $text = $this->markup->render($template, array_values($args), $this->terminal->colors($error), $style);
254        $this->terminal->write($text . $end, $error);
255    }
256
257    /**
258     * Indents the text and wraps it on its visible width; markup tags
259     * and multibyte characters don't count. `$max` caps the total line
260     * width: the text wraps as if the terminal were at most that wide.
261     * A negative `$indent` counts as 0.
262     */
263    public function indent(
264        string $text,
265        int $indent,
266        ?int $max = null,
267    ): string {
268        $indent = max(0, $indent);
269        $spaces = str_repeat(' ', $indent);
270        $terminal = $this->terminal->width();
271
272        if ($max !== null && $max < $terminal) {
273            $terminal = $max;
274        }
275
276        $width = $terminal - $indent;
277
278        $lines = [];
279
280        foreach (explode("\n", $text) as $line) {
281            foreach ($this->wrap($line, $width) as $wrapped) {
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
283            }
284        }
285
286        return implode("\n", $lines);
287    }
288
289    /**
290     * Wraps one line at spaces; a word longer than the width overflows.
291     *
292     * @return list<string>
293     */
294    private function wrap(string $line, int $width): array
295    {
296        $lines = [];
297        $current = null;
298        $currentWidth = 0;
299
300        foreach (explode(' ', $line) as $word) {
301            $wordWidth = $this->markup->width($word);
302
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
304                $current .= ' ' . $word;
305                $currentWidth += 1 + $wordWidth;
306
307                continue;
308            }
309
310            if ($current !== null) {
311                $lines[] = $current;
312            }
313
314            $current = $word;
315            $currentWidth = $wordWidth;
316        }
317
318        $lines[] = (string) $current;
319
320        return $lines;
321    }
322}

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
33        private readonly Terminal $terminal = new Stdio(),
34    ) {
35        $this->markup = new Markup();
36    }
Io->ask
140    public function ask(string $question, string $default = ''): string
141    {
142        $this->write($question);
143
144        if ($default !== '') {
145            $this->write(' [%s]', $default);
146        }
147
148        $this->write(' ');
148        $this->write(' ');
149        $answer = trim($this->terminal->read() ?? '');
149        $answer = trim($this->terminal->read() ?? '');
149        $answer = trim($this->terminal->read() ?? '');
149        $answer = trim($this->terminal->read() ?? '');
150
151        return $answer === '' ? $default : $answer;
151        return $answer === '' ? $default : $answer;
151        return $answer === '' ? $default : $answer;
151        return $answer === '' ? $default : $answer;
152    }
Io->choice
200    public function choice(string $question, array $options, int|string|null $default = null): int|string
201    {
202        if ($options === []) {
203            throw new ValueError('Choice needs options');
206        $default ??= array_key_first($options);
207
208        if (!array_key_exists($default, $options)) {
209            throw new ValueError("Choice default '{$default}' is not an option");
212        $keys = array_keys($options);
213        $number = array_flip($keys)[$default] + 1;
214        $this->line($question);
215
216        foreach (array_values($options) as $i => $label) {
216        foreach (array_values($options) as $i => $label) {
216        foreach (array_values($options) as $i => $label) {
216        foreach (array_values($options) as $i => $label) {
217            $this->line('  %d) %s', $i + 1, $label);
218        }
219
220        while (true) {
221            $answer = $this->ask("[{$number}]");
222
223            if ($answer === '') {
224                return $keys[$number - 1];
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
227            if (preg_match('/^\d+\z/', $answer) === 1 && (int) $answer >= 1 && (int) $answer <= count($keys)) {
228                return $keys[(int) $answer - 1];
220        while (true) {
231    }
Io->confirm
174    public function confirm(string $question, bool $default = false): bool
175    {
176        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
176        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
176        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
176        $answer = strtolower($this->ask($question . ($default ? ' [Y/n]' : ' [y/N]')));
177
178        if ($answer === '') {
179            return $default;
182        return str_starts_with($answer, 'y');
182        return str_starts_with($answer, 'y');
182        return str_starts_with($answer, 'y');
182        return str_starts_with($answer, 'y');
183    }
Io->error
67    public function error(string $template, float|int|string|Stringable ...$args): void
68    {
69        $this->output($template, $args, PHP_EOL, error: true, style: 'red');
70    }
Io->escape
95    public function escape(string $text): string
96    {
97        return $this->markup->escape($text);
98    }
Io->indent
264        string $text,
265        int $indent,
266        ?int $max = null,
267    ): string {
268        $indent = max(0, $indent);
268        $indent = max(0, $indent);
268        $indent = max(0, $indent);
268        $indent = max(0, $indent);
269        $spaces = str_repeat(' ', $indent);
270        $terminal = $this->terminal->width();
271
272        if ($max !== null && $max < $terminal) {
272        if ($max !== null && $max < $terminal) {
272        if ($max !== null && $max < $terminal) {
273            $terminal = $max;
274        }
275
276        $width = $terminal - $indent;
276        $width = $terminal - $indent;
277
278        $lines = [];
279
280        foreach (explode("\n", $text) as $line) {
280        foreach (explode("\n", $text) as $line) {
281            foreach ($this->wrap($line, $width) as $wrapped) {
281            foreach ($this->wrap($line, $width) as $wrapped) {
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
281            foreach ($this->wrap($line, $width) as $wrapped) {
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
280        foreach (explode("\n", $text) as $line) {
281            foreach ($this->wrap($line, $width) as $wrapped) {
280        foreach (explode("\n", $text) as $line) {
281            foreach ($this->wrap($line, $width) as $wrapped) {
282                $lines[] = $wrapped === '' ? '' : $spaces . $wrapped;
283            }
284        }
285
286        return implode("\n", $lines);
286        return implode("\n", $lines);
286        return implode("\n", $lines);
286        return implode("\n", $lines);
287    }
Io->interactive
238        return $this->terminal->interactive();
239    }
Io->line
38    public function line(string $template = '', float|int|string|Stringable ...$args): void
39    {
40        $this->output($template, $args, PHP_EOL);
41    }
Io->output
250    private function output(string $template, array $args, string $end, bool $error = false, string $style = ''): void
251    {
252        $error = $error || $this->error;
252        $error = $error || $this->error;
252        $error = $error || $this->error;
253        $text = $this->markup->render($template, array_values($args), $this->terminal->colors($error), $style);
254        $this->terminal->write($text . $end, $error);
255    }
Io->pad
105    public function pad(string $text, int $width, Align $align = Align::Left): string
106    {
107        return $this->markup->pad($text, $width, $align);
108    }
Io->rule
118    public function rule(string $char = '─', ?int $max = null): void
119    {
120        $width = $this->terminal->width();
121
122        if ($max !== null && $max < $width) {
122        if ($max !== null && $max < $width) {
122        if ($max !== null && $max < $width) {
123            $width = max(0, $max);
123            $width = max(0, $max);
123            $width = max(0, $max);
123            $width = max(0, $max);
124        }
125
126        $unit = $this->markup->width($char);
126        $unit = $this->markup->width($char);
127
128        if ($unit < 1) {
129            throw new ValueError("Rule char '{$char}' has no visible width");
132        $this->line(str_repeat($char, intdiv($width, $unit)));
133    }
Io->secret
161    public function secret(string $question): string
162    {
163        $this->write($question . ' ');
164
165        return $this->terminal->read(hidden: true) ?? '';
166    }
Io->stderr
80        $io = clone $this;
81        $io->error = true;
82
83        return $io;
84    }
Io->success
51    public function success(string $template, float|int|string|Stringable ...$args): void
52    {
53        $this->output($template, $args, PHP_EOL, style: 'green');
54    }
Io->warn
59    public function warn(string $template, float|int|string|Stringable ...$args): void
60    {
61        $this->output($template, $args, PHP_EOL, error: true, style: 'yellow');
62    }
Io->width
246        return $this->terminal->width();
247    }
Io->wrap
294    private function wrap(string $line, int $width): array
295    {
296        $lines = [];
297        $current = null;
298        $currentWidth = 0;
299
300        foreach (explode(' ', $line) as $word) {
300        foreach (explode(' ', $line) as $word) {
301            $wordWidth = $this->markup->width($word);
302
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
304                $current .= ' ' . $word;
305                $currentWidth += 1 + $wordWidth;
306
307                continue;
310            if ($current !== null) {
311                $lines[] = $current;
312            }
313
314            $current = $word;
300        foreach (explode(' ', $line) as $word) {
301            $wordWidth = $this->markup->width($word);
302
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
304                $current .= ' ' . $word;
305                $currentWidth += 1 + $wordWidth;
306
307                continue;
308            }
309
310            if ($current !== null) {
311                $lines[] = $current;
312            }
313
314            $current = $word;
300        foreach (explode(' ', $line) as $word) {
301            $wordWidth = $this->markup->width($word);
302
303            if ($current !== null && ($currentWidth + 1 + $wordWidth) <= $width) {
304                $current .= ' ' . $word;
305                $currentWidth += 1 + $wordWidth;
306
307                continue;
308            }
309
310            if ($current !== null) {
311                $lines[] = $current;
312            }
313
314            $current = $word;
315            $currentWidth = $wordWidth;
316        }
317
318        $lines[] = (string) $current;
319
320        return $lines;
321    }
Io->write
46    public function write(string $template, float|int|string|Stringable ...$args): void
47    {
48        $this->output($template, $args, '');
49    }