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}

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.

Markup->__construct
80        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
80        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
80        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
80        $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}';
81        $this->split = "#(\\\\?</?(?:{$names})>)#";
82        $this->tag = "#^\\\\?</?(?:{$names})>$#";
83    }
Markup->escape
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    }
Markup->render
88    public function render(string $text, bool $colors): string
89    {
90        if (!str_contains($text, '<')) {
90        if (!str_contains($text, '<')) {
90        if (!str_contains($text, '<')) {
90        if (!str_contains($text, '<')) {
91            return $text;
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) {
103        foreach ($parts as $part) {
104            if (preg_match($this->tag, $part) !== 1) {
104            if (preg_match($this->tag, $part) !== 1) {
104            if (preg_match($this->tag, $part) !== 1) {
104            if (preg_match($this->tag, $part) !== 1) {
105                $out .= $part;
106
107                continue;
110            if ($part[0] === '\\') {
111                $out .= substr($part, offset: 1);
112
113                continue;
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");
124                if ($last !== $name) {
125                    throw new ValueError("Markup tag '<{$last}>' closed by '</{$name}>'");
128                if ($colors) {
130                    $out .= self::RESET;
131
132                    foreach ($stack as $open) {
132                    foreach ($stack as $open) {
132                    foreach ($stack as $open) {
133                        $out .= "\033[" . $this->sgr($open) . 'm';
132                    foreach ($stack as $open) {
133                        $out .= "\033[" . $this->sgr($open) . 'm';
134                    }
135                }
136
137                continue;
137                continue;
140            $name = substr($part, offset: 1, length: -1);
141            $stack[] = $name;
142
143            if ($colors) {
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';
103        foreach ($parts as $part) {
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)]}>'");
152        return $out;
153    }
Markup->sgr
156    private function sgr(string $name): string
157    {
158        if (array_key_exists($name, self::TAGS)) {
159            return self::TAGS[$name];
162        $bg = str_starts_with($name, 'bg-');
162        $bg = str_starts_with($name, 'bg-');
162        $bg = str_starts_with($name, 'bg-');
162        $bg = str_starts_with($name, 'bg-');
163        $hex = substr($name, offset: $bg ? 4 : 1);
163        $hex = substr($name, offset: $bg ? 4 : 1);
163        $hex = substr($name, offset: $bg ? 4 : 1);
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)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
166            ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2)))
167        );
168    }
Markup->width
189    public function width(string $text): int
190    {
191        if (str_contains($text, '<')) {
191        if (str_contains($text, '<')) {
191        if (str_contains($text, '<')) {
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);
199        return mb_strwidth($text);
200    }
{closure:/workspace/celema/console/src/Markup.php:194-194}
194                static fn(array $match): string => $match[0][0] === '\\' ? substr($match[0], offset: 1) : '',
194                static fn(array $match): string => $match[0][0] === '\\' ? substr($match[0], offset: 1) : '',
194                static fn(array $match): string => $match[0][0] === '\\' ? substr($match[0], offset: 1) : '',
194                static fn(array $match): string => $match[0][0] === '\\' ? substr($match[0], offset: 1) : '',