Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
92 / 92
90.38% covered (success)
90.38%
94 / 104
10.12% covered (danger)
10.12%
26 / 257
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Markup
100.00% covered (success)
100.00%
92 / 92
90.38% covered (success)
90.38%
94 / 104
10.12% covered (danger)
10.12%
26 / 257
100.00% covered (success)
100.00%
10 / 10
1261.69
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 render
100.00% covered (success)
100.00%
12 / 12
88.89% covered (warning)
88.89%
8 / 9
16.67% covered (danger)
16.67%
2 / 12
100.00% covered (success)
100.00%
1 / 1
8.21
 format
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 markup
100.00% covered (success)
100.00%
23 / 23
97.14% covered (success)
97.14%
34 / 35
4.15% covered (danger)
4.15%
8 / 193
100.00% covered (success)
100.00%
1 / 1
161.84
 paired
100.00% covered (success)
100.00%
14 / 14
94.12% covered (success)
94.12%
16 / 17
15.00% covered (danger)
15.00%
3 / 20
100.00% covered (success)
100.00%
1 / 1
37.09
 codes
100.00% covered (success)
100.00%
1 / 1
75.00% covered (warning)
75.00%
3 / 4
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
1.12
 sgr
100.00% covered (success)
100.00%
5 / 5
86.67% covered (warning)
86.67%
13 / 15
17.65% covered (danger)
17.65%
3 / 17
100.00% covered (success)
100.00%
1 / 1
12.94
 escape
100.00% covered (success)
100.00%
5 / 5
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 pad
100.00% covered (success)
100.00%
8 / 8
81.82% covered (warning)
81.82%
9 / 11
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
5.20
 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
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7use ValueError;
8
9/**
10 * Renders the inline console markup to ANSI escape codes.
11 *
12 * Style tags are `<strong>`, `<em>`, `<dim>`, and `<u>`; color tags are
13 * the ANSI names — `<red>`, `<bright-red>`, `<gray>` — and the same set
14 * with a `bg-` prefix for backgrounds. Truecolor hex tags take a
15 * lowercase six-digit code: `<#ff7313>`, `<bg-#ff7313>`. Tags compose
16 * by nesting, the innermost tag wins on conflict.
17 *
18 * Only exact known tags are parsed; everything else — `<info@example.com>`,
19 * generics, unknown names — passes through untouched. A backslash renders
20 * a known tag literally: `\<green>`. A tag without its partner — dangling,
21 * mismatched, or unclosed — prints literally too: rendering never fails.
22 *
23 * Arguments fill the template's sprintf() conversions and print as plain
24 * text, never as markup. Control characters other than newlines and tabs
25 * are dropped from both, so no text can inject terminal escape sequences;
26 * only templates keep carriage returns, which redraw a progress line.
27 *
28 * @internal
29 */
30final class Markup
31{
32    private const string RESET = "\033[0m";
33
34    /**
35     * Follows a trailing backslash of escaped text, so that a tag placed
36     * right after it stays a tag: without it, `C:\` + `</red>` would read
37     * as an escaped `</red>`. escape() strips control characters from its
38     * input, so untrusted text cannot forge it; render() drops it again.
39     */
40    private const string BOUNDARY = "\x1F";
41
42    /**
43     * Stands in for a `%` of escaped text, which a template with
44     * arguments would read as a conversion. Like BOUNDARY, it cannot be
45     * forged and render() turns it back.
46     */
47    private const string PERCENT = "\x1E";
48
49    /** Stands in for a formatted argument while the markup is parsed. */
50    private const string SLOT = "\x00";
51
52    private const string CONTROLS = '/[\x00-\x08\x0B-\x1F\x7F]/';
53
54    /** Like CONTROLS, but keeps carriage returns and the markers of escaped text. */
55    private const string TEMPLATE_CONTROLS = '/[\x00-\x08\x0B\x0C\x0E-\x1D\x7F]/';
56
57    /**
58     * The C1 controls U+0080 to U+009F as UTF-8: U+009B opens an escape
59     * sequence like `ESC [`. A whole run matches, so that no removal
60     * joins the bytes around it to another one; the lookbehind keeps a
61     * long run from being scanned at each of its bytes.
62     */
63    private const string C1 = '/(?<!\xC2)\xC2+[\x80-\x9F]+/';
64
65    /** A sprintf() conversion, `%1$s` and `%%` included. */
66    private const string CONVERSION = '/%(?:%|(?:(\d+)\$)?((?:[-+ 0]|\'.)*\d*(?:\.\d+)?[bcdeEfFgGhHosuxX]))/';
67
68    /** SGR codes by tag name. */
69    private const array TAGS = [
70        'strong' => '1',
71        'em' => '3',
72        'dim' => '2',
73        'u' => '4',
74        'black' => '30',
75        'red' => '31',
76        'green' => '32',
77        'yellow' => '33',
78        'blue' => '34',
79        'magenta' => '35',
80        'cyan' => '36',
81        'white' => '37',
82        'gray' => '90',
83        'bright-black' => '90',
84        'bright-red' => '91',
85        'bright-green' => '92',
86        'bright-yellow' => '93',
87        'bright-blue' => '94',
88        'bright-magenta' => '95',
89        'bright-cyan' => '96',
90        'bright-white' => '97',
91        'bg-black' => '40',
92        'bg-red' => '41',
93        'bg-green' => '42',
94        'bg-yellow' => '43',
95        'bg-blue' => '44',
96        'bg-magenta' => '45',
97        'bg-cyan' => '46',
98        'bg-white' => '47',
99        'bg-gray' => '100',
100        'bg-bright-black' => '100',
101        'bg-bright-red' => '101',
102        'bg-bright-green' => '102',
103        'bg-bright-yellow' => '103',
104        'bg-bright-blue' => '104',
105        'bg-bright-magenta' => '105',
106        'bg-bright-cyan' => '106',
107        'bg-bright-white' => '107',
108    ];
109
110    /** @var non-empty-string */
111    private readonly string $split;
112
113    /** @var non-empty-string */
114    private readonly string $tag;
115
116    public function __construct()
117    {
118        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
119        $this->split = "#(\\\\?</?(?:{$names})>)#";
120        $this->tag = "#^\\\\?</?(?:{$names})>$#";
121    }
122
123    /**
124     * Renders the template as escape codes, or strips the markup without
125     * `$colors`. A `$style` tag encloses the whole text.
126     *
127     * @param list<float|int|string|\Stringable> $args
128     */
129    public function render(string $template, array $args = [], bool $colors = false, string $style = ''): string
130    {
131        $text = (string) preg_replace(self::TEMPLATE_CONTROLS, replacement: '', subject: $template);
132        $values = [];
133
134        if ($args !== []) {
135            [$text, $values] = $this->format($text, $args);
136        }
137
138        $parts = explode(
139            self::SLOT,
140            str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)),
141        );
142        $out = array_shift($parts);
143
144        foreach ($parts as $i => $part) {
145            $out .= $values[$i] . $part;
146        }
147
148        // On the finished text: its parts could join to a C1 control.
149        return (string) preg_replace(self::C1, replacement: '', subject: $out);
150    }
151
152    /**
153     * Replaces the conversions with slots and formats their arguments.
154     *
155     * @param list<float|int|string|\Stringable> $args
156     *
157     * @return array{string, list<string>}
158     */
159    private function format(string $template, array $args): array
160    {
161        $values = [];
162        $next = 0;
163        $text = (string) preg_replace_callback(
164            self::CONVERSION,
165            static function (array $match) use ($args, &$values, &$next): string {
166                if ($match[0] === '%%') {
167                    return '%';
168                }
169
170                $index = $match[1] === '' ? $next++ : (int) $match[1] - 1;
171
172                if (!array_key_exists($index, $args)) {
173                    throw new ValueError('Missing argument ' . ($index + 1) . " for '{$match[0]}'");
174                }
175
176                $values[] = (string) preg_replace(
177                    self::CONTROLS,
178                    replacement: '',
179                    subject: sprintf('%' . $match[2], $args[$index]),
180                );
181
182                return self::SLOT;
183            },
184            $template,
185        );
186
187        return [$text, $values];
188    }
189
190    private function markup(string $text, bool $colors, string $style): string
191    {
192        $parts = preg_split(
193            $this->split,
194            $text,
195            flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
196        );
197
198        // Like a failed preg_replace() in render(), a failed split drops the text.
199        if ($parts === false) {
200            return '';
201        }
202
203        $paired = $this->paired($parts);
204        $stack = $style === '' ? [] : [$style];
205        $out = $colors ? $this->codes($stack) : '';
206
207        foreach ($parts as $i => $part) {
208            if (!in_array($i, $paired, strict: true)) {
209                $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1;
210                $out .= $escaped ? substr($part, offset: 1) : $part;
211
212                continue;
213            }
214
215            if ($part[1] === '/') {
216                array_pop($stack);
217
218                // A blanket reset, then re-apply the enclosing tags.
219                $out .= $colors ? self::RESET . $this->codes($stack) : '';
220
221                continue;
222            }
223
224            $name = substr($part, offset: 1, length: -1);
225            $stack[] = $name;
226            $out .= $colors ? $this->codes([$name]) : '';
227        }
228
229        return $colors && $style !== '' ? $out . self::RESET : $out;
230    }
231
232    /**
233     * The indexes of the tags that have a partner: a closing tag pairs
234     * with the innermost open tag if that has its name, else with none.
235     *
236     * @param list<string> $parts
237     *
238     * @return list<int>
239     */
240    private function paired(array $parts): array
241    {
242        $paired = [];
243        $open = [];
244
245        foreach ($parts as $i => $part) {
246            if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) {
247                continue;
248            }
249
250            if ($part[1] !== '/') {
251                $open[] = [$i, substr($part, offset: 1, length: -1)];
252
253                continue;
254            }
255
256            $last = end($open);
257
258            if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) {
259                continue;
260            }
261
262            array_pop($open);
263            array_push($paired, $last[0], $i);
264        }
265
266        return $paired;
267    }
268
269    /** @param list<string> $names */
270    private function codes(array $names): string
271    {
272        return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names));
273    }
274
275    /** The SGR code for a tag name: a named lookup or a truecolor hex tag. */
276    private function sgr(string $name): string
277    {
278        if (array_key_exists($name, self::TAGS)) {
279            return self::TAGS[$name];
280        }
281
282        $bg = str_starts_with($name, 'bg-');
283        $hex = substr($name, offset: $bg ? 4 : 1);
284
285        return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)));
286    }
287
288    /**
289     * Escapes known tags and `%` and strips control characters so the
290     * text prints literally.
291     *
292     * Everything C0 except newline and tab is removed, DEL and the C1
293     * controls included, so arbitrary text cannot inject terminal escape
294     * sequences (ESC, BEL, carriage returns, ...).
295     */
296    public function escape(string $text): string
297    {
298        $text = (string) preg_replace(self::CONTROLS, replacement: '', subject: $text);
299        $text = (string) preg_replace(self::C1, replacement: '', subject: $text);
300        $text = (string) preg_replace($this->split, replacement: '\\\\$0', subject: $text);
301        $text = str_replace('%', self::PERCENT, $text);
302
303        return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text;
304    }
305
306    /**
307     * Pads the text with spaces to the visible width `$width`; wider
308     * text is returned unchanged. An uneven center split leans left.
309     */
310    public function pad(string $text, int $width, Align $align): string
311    {
312        $missing = $width - $this->width($text);
313
314        if ($missing <= 0) {
315            return $text;
316        }
317
318        return match ($align) {
319            Align::Left => $text . str_repeat(' ', $missing),
320            Align::Right => str_repeat(' ', $missing) . $text,
321            Align::Center => str_repeat(' ', intdiv($missing, 2))
322                . $text
323                . str_repeat(' ', $missing - intdiv($missing, 2)),
324        };
325    }
326
327    /**
328     * The visible width of the text as rendered: tags collapse to
329     * nothing, unpaired and escaped ones print.
330     */
331    public function width(string $text): int
332    {
333        return mb_strwidth($this->render($text));
334    }
335}