Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
54 / 54
89.47% covered (warning)
89.47%
51 / 57
8.49% covered (danger)
8.49%
9 / 106
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Markup
100.00% covered (success)
100.00%
54 / 54
89.47% covered (warning)
89.47%
51 / 57
8.49% covered (danger)
8.49%
9 / 106
100.00% covered (success)
100.00%
5 / 5
358.94
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%
35 / 35
93.55% covered (success)
93.55%
29 / 31
2.44% covered (danger)
2.44%
2 / 82
100.00% covered (success)
100.00%
1 / 1
145.72
 sgr
100.00% covered (success)
100.00%
7 / 7
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%
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
 width
100.00% covered (success)
100.00%
7 / 7
83.33% covered (warning)
83.33%
5 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
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>`. Structural mistakes (a mismatched,
21 * dangling, or unclosed tag) throw a ValueError, also when colors are
22 * disabled, so they surface in tests.
23 *
24 * @internal
25 */
26final class Markup
27{
28    private const string RESET = "\033[0m";
29
30    /** SGR codes by tag name. */
31    private const array TAGS = [
32        'strong' => '1',
33        'em' => '3',
34        'dim' => '2',
35        'u' => '4',
36        'black' => '30',
37        'red' => '31',
38        'green' => '32',
39        'yellow' => '33',
40        'blue' => '34',
41        'magenta' => '35',
42        'cyan' => '36',
43        'white' => '37',
44        'gray' => '90',
45        'bright-black' => '90',
46        'bright-red' => '91',
47        'bright-green' => '92',
48        'bright-yellow' => '93',
49        'bright-blue' => '94',
50        'bright-magenta' => '95',
51        'bright-cyan' => '96',
52        'bright-white' => '97',
53        'bg-black' => '40',
54        'bg-red' => '41',
55        'bg-green' => '42',
56        'bg-yellow' => '43',
57        'bg-blue' => '44',
58        'bg-magenta' => '45',
59        'bg-cyan' => '46',
60        'bg-white' => '47',
61        'bg-gray' => '100',
62        'bg-bright-black' => '100',
63        'bg-bright-red' => '101',
64        'bg-bright-green' => '102',
65        'bg-bright-yellow' => '103',
66        'bg-bright-blue' => '104',
67        'bg-bright-magenta' => '105',
68        'bg-bright-cyan' => '106',
69        'bg-bright-white' => '107',
70    ];
71
72    /** @var non-empty-string */
73    private readonly string $split;
74
75    /** @var non-empty-string */
76    private readonly string $tag;
77
78    public function __construct()
79    {
80        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
81        $this->split = "#(\\\\?</?(?:{$names})>)#";
82        $this->tag = "#^\\\\?</?(?:{$names})>$#";
83    }
84
85    /**
86     * Renders the markup as escape codes, or strips it without `$colors`.
87     */
88    public function render(string $text, bool $colors): string
89    {
90        if (!str_contains($text, '<')) {
91            return $text;
92        }
93
94        /** @var list<string> $parts */
95        $parts = (array) preg_split(
96            $this->split,
97            $text,
98            flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
99        );
100        $out = '';
101        $stack = [];
102
103        foreach ($parts as $part) {
104            if (preg_match($this->tag, $part) !== 1) {
105                $out .= $part;
106
107                continue;
108            }
109
110            if ($part[0] === '\\') {
111                $out .= substr($part, offset: 1);
112
113                continue;
114            }
115
116            if ($part[1] === '/') {
117                $name = substr($part, offset: 2, length: -1);
118                $last = array_pop($stack);
119
120                if ($last === null) {
121                    throw new ValueError("Markup tag '</{$name}>' has no opening tag");
122                }
123
124                if ($last !== $name) {
125                    throw new ValueError("Markup tag '<{$last}>' closed by '</{$name}>'");
126                }
127
128                if ($colors) {
129                    // A blanket reset, then re-apply the enclosing tags.
130                    $out .= self::RESET;
131
132                    foreach ($stack as $open) {
133                        $out .= "\033[" . $this->sgr($open) . 'm';
134                    }
135                }
136
137                continue;
138            }
139
140            $name = substr($part, offset: 1, length: -1);
141            $stack[] = $name;
142
143            if ($colors) {
144                $out .= "\033[" . $this->sgr($name) . 'm';
145            }
146        }
147
148        if ($stack !== []) {
149            throw new ValueError("Unclosed markup tag '<{$stack[array_key_last($stack)]}>'");
150        }
151
152        return $out;
153    }
154
155    /** The SGR code for a tag name: a named lookup or a truecolor hex tag. */
156    private function sgr(string $name): string
157    {
158        if (array_key_exists($name, self::TAGS)) {
159            return self::TAGS[$name];
160        }
161
162        $bg = str_starts_with($name, 'bg-');
163        $hex = substr($name, offset: $bg ? 4 : 1);
164
165        return (
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
167        );
168    }
169
170    /**
171     * Escapes known tags and strips control characters so the text
172     * prints literally.
173     *
174     * Everything C0 except newline and tab is removed, DEL included, so
175     * arbitrary text cannot inject terminal escape sequences (ESC, BEL,
176     * carriage returns, ...).
177     */
178    public function escape(string $text): string
179    {
180        $text = (string) preg_replace('/[\x00-\x08\x0B-\x1F\x7F]/', replacement: '', subject: $text);
181
182        return (string) preg_replace($this->split, replacement: '\\\\$0', subject: $text);
183    }
184
185    /**
186     * The visible width of the text: tags collapse to nothing, an
187     * escaped tag to the tag without its backslash.
188     */
189    public function width(string $text): int
190    {
191        if (str_contains($text, '<')) {
192            $text = (string) preg_replace_callback(
193                $this->split,
194                static fn(array $match): string => $match[0][0] === '\\' ? substr($match[0], offset: 1) : '',
195                $text,
196            );
197        }
198
199        return mb_strwidth($text);
200    }
201}