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}