Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
JavascriptEscape
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
6 / 6
33
100.00% covered (success)
100.00%
1 / 1
 decode
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 unicode
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
13
 codepoint
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 highSurrogate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 lowSurrogate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hex
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Verba\Tool;
6
7/**
8 * Decodes escape sequences from JavaScript string literals.
9 *
10 * @internal
11 */
12final class JavascriptEscape
13{
14    /**
15     * Decodes the escape at $offset and advances it to the final consumed byte.
16     */
17    public static function decode(string $raw, int &$offset): string
18    {
19        $offset++;
20        $char = $raw[$offset] ?? '';
21
22        return match ($char) {
23            'b' => "\b",
24            'f' => "\f",
25            'n' => "\n",
26            'r' => "\r",
27            't' => "\t",
28            'v' => "\v",
29            'u' => self::unicode($raw, $offset),
30            'x' => self::hex($raw, $offset),
31            default => $char,
32        };
33    }
34
35    private static function unicode(string $raw, int &$offset): string
36    {
37        if (($raw[$offset + 1] ?? '') === '{') {
38            $end = strpos($raw, '}', $offset + 2);
39
40            if ($end === false) {
41                return 'u';
42            }
43
44            $hex = substr($raw, $offset + 2, $end - $offset - 2);
45
46            if ($hex === '' || !ctype_xdigit($hex)) {
47                return 'u';
48            }
49
50            $offset = $end;
51
52            return self::codepoint($hex);
53        }
54
55        $hex = substr($raw, $offset + 1, 4);
56
57        if (strlen($hex) !== 4 || !ctype_xdigit($hex)) {
58            return 'u';
59        }
60
61        $escape = '\\u' . $hex;
62        $offset += 4;
63
64        if (self::highSurrogate($hex)) {
65            $low = substr($raw, $offset + 3, 4);
66
67            if (
68                ($raw[$offset + 1] ?? '') === '\\'
69                && ($raw[$offset + 2] ?? '') === 'u'
70                && strlen($low) === 4
71                && ctype_xdigit($low)
72                && self::lowSurrogate($low)
73            ) {
74                $escape .= '\\u' . $low;
75                $offset += 6;
76            }
77        }
78
79        /** @var string|null $decoded */
80        $decoded = json_decode('"' . $escape . '"');
81
82        return $decoded ?? '';
83    }
84
85    private static function codepoint(string $hex): string
86    {
87        // Braced escapes hold a full codepoint (any number of leading zeros);
88        // fixed-width escapes use JSON for surrogates. Like JavaScript, keep
89        // controls and noncharacters; drop lone surrogates and values above
90        // U+10FFFF, which have no UTF-8 encoding.
91        $hex = ltrim($hex, '0');
92
93        if (strlen($hex) > 6) {
94            return '';
95        }
96
97        $char = mb_chr((int) hexdec($hex), 'UTF-8');
98
99        return $char === false ? '' : $char;
100    }
101
102    private static function highSurrogate(string $hex): bool
103    {
104        $code = hexdec($hex);
105
106        return $code >= 0xD800 && $code <= 0xDBFF;
107    }
108
109    private static function lowSurrogate(string $hex): bool
110    {
111        $code = hexdec($hex);
112
113        return $code >= 0xDC00 && $code <= 0xDFFF;
114    }
115
116    private static function hex(string $raw, int &$offset): string
117    {
118        $hex = substr($raw, $offset + 1, 2);
119
120        if (strlen($hex) !== 2 || !ctype_xdigit($hex)) {
121            return 'x';
122        }
123
124        $offset += 2;
125
126        return chr(hexdec($hex));
127    }
128}