Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
JavascriptEscape
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
6 / 6
32
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 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 already hold a full codepoint; fixed-width escapes use JSON for surrogates.
88        $entity = '&#x' . $hex . ';';
89        $decoded = html_entity_decode($entity, ENT_QUOTES | ENT_HTML5, 'UTF-8');
90
91        return $decoded === $entity ? '' : $decoded;
92    }
93
94    private static function highSurrogate(string $hex): bool
95    {
96        $code = hexdec($hex);
97
98        return $code >= 0xD800 && $code <= 0xDBFF;
99    }
100
101    private static function lowSurrogate(string $hex): bool
102    {
103        $code = hexdec($hex);
104
105        return $code >= 0xDC00 && $code <= 0xDFFF;
106    }
107
108    private static function hex(string $raw, int &$offset): string
109    {
110        $hex = substr($raw, $offset + 1, 2);
111
112        if (strlen($hex) !== 2 || !ctype_xdigit($hex)) {
113            return 'x';
114        }
115
116        $offset += 2;
117
118        return chr(hexdec($hex));
119    }
120}