Passed
Pull Request — master (#41)
by Def
02:07
created

ContentParser::tokensEqual()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 7
ccs 4
cts 4
cp 1
rs 10
cc 3
nc 3
nop 2
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Translator\Extractor;
6
7
/**
8
 * Extracts translation keys from a string given.
9
 */
10
final class ContentParser
11
{
12
    private string $translator = '->translate';
13
14
    /** @var array<string|array{0: int, 1: string, 2: int}> */
15
    private array $translatorTokens = [];
16
17
    private int $sizeOfTranslator = 0;
18
19
    private string $defaultCategory = '';
20
21
    private static array $brackets = [
22
        ')' => '(',
23
        ']' => '[',
24
        '}' => '{',
25
    ];
26
27
    /**
28
     * @psalm-var array<array<string|array{0: int, 1: string, 2: int}>>
29
     */
30
    private array $skippedLines = [];
31
32 12
    public function __construct(?string $defaultCategory = null, ?string $translator = null)
33
    {
34 12
        $this->defaultCategory = $defaultCategory ?? $this->defaultCategory;
35 12
        $this->setTranslator($translator ?? $this->translator);
36 11
    }
37
38 12
    private function setTranslator(string $translator): void
39
    {
40 12
        $this->translator = $translator;
41 12
        $translatorTokens = token_get_all('<?php ' . $this->translator);
42 12
        array_shift($translatorTokens);
43 12
        $this->translatorTokens = $translatorTokens;
44 12
        $this->sizeOfTranslator = count($this->translatorTokens);
45
46 12
        if ($this->sizeOfTranslator < 2) {
47 1
            throw new \RuntimeException('Translator tokens cannot be shorttest 2 tokens.');
48
        }
49 11
    }
50
51
    /**
52
     * @param string $content
53
     *
54
     * @psalm-return array<array-key|string, mixed|non-empty-list<string>>
55
     *
56
     * @return array[]
57
     */
58 11
    public function extract(string $content): array
59
    {
60 11
        $this->skippedLines = [];
61 11
        $tokens = token_get_all($content);
62
63 11
        return $this->extractMessagesFromTokens($tokens);
64
    }
65
66
    /**
67
     * @psalm-param array<string|array{0: int, 1: string, 2: int}> $tokens
68
     *
69
     * @param array $tokens
70
     *
71
     * @psalm-return array<array-key|string, mixed|non-empty-list<string>>
72
     *
73
     * @return array
74
     */
75 11
    private function extractMessagesFromTokens(array $tokens): array
76
    {
77 11
        $messages = $buffer = [];
78 11
        $matchedTokensCount = $pendingParenthesisCount = 0;
79 11
        $isStartedTranslator = false;
80
81 11
        foreach ($tokens as $token) {
82 11
            if (in_array($token[0], [T_WHITESPACE, T_COMMENT], true)) {
83 11
                continue;
84
            }
85
86 11
            if ($isStartedTranslator) {
87 11
                if ($this->tokensEqual($token, ')')) {
88 11
                    if ($pendingParenthesisCount === 0) {
89 11
                        $messages = array_merge_recursive($messages, $this->extractParametersFromTokens($buffer));
90 11
                        $isStartedTranslator = false;
91 11
                        $pendingParenthesisCount = 0;
92 11
                        $buffer = [];
93 11
                        continue;
94
                    }
95 11
                    $pendingParenthesisCount--;
96 11
                } elseif ($this->tokensEqual($token, '(')) {
97 11
                    $pendingParenthesisCount++;
98
                }
99 11
                $buffer[] = $token;
100
            } else {
101 11
                if ($matchedTokensCount === $this->sizeOfTranslator) {
102 11
                    if ($this->tokensEqual($token, '(')) {
103 11
                        $isStartedTranslator = true;
104 11
                        continue;
105
                    }
106 11
                    $matchedTokensCount = 0;
107
                }
108
109 11
                if ($this->tokensEqual($token, $this->translatorTokens[$matchedTokensCount])) {
110 11
                    $matchedTokensCount++;
111
                } else {
112 11
                    $matchedTokensCount = 0;
113
                }
114
            }
115
        }
116
117 11
        return $messages;
118
    }
119
120
    /**
121
     * @param array $tokens
122
     * @psalm-param array<string|array{0: int, 1: string, 2: int}> $tokens
123
     *
124
     * @return array
125
     * @psalm-return array<array-key|string, mixed|non-empty-list<string>>
126
     */
127 11
    private function extractParametersFromTokens(array $tokens): array
128
    {
129 11
        $parameters = $this->splitTokensAsParams($tokens);
130
131 11
        if (!isset($parameters['id'])) {
132 6
            $this->skippedLines[] = $tokens;
133 6
            return [];
134
        }
135
136 8
        $messages = [$parameters['category'] ?? $this->defaultCategory => [$parameters['id']]];
137
138
        // Get translation messages from parameters
139 8
        if (isset($parameters['parameters'])) {
140 6
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($parameters['parameters']));
141
        }
142
143 8
        return $messages;
144
    }
145
146
    /**
147
     * @psalm-param array<string|array{0: int, 1: string, 2: int}> $tokens
148
     *
149
     * @psalm-return array{category?: null|string, id?: null|string, parameters?: null|list<array{0: int, 1: string, 2: int}|string>}
150
     */
151 11
    private function splitTokensAsParams(array $tokens): array
152
    {
153 11
        $parameters = [];
154 11
        $parameterIndex = 0;
155 11
        $commaStack = [];
156
157 11
        foreach ($tokens as $token) {
158 11
            if (empty($commaStack) && $token === ',') {
159 9
                $parameterIndex++;
160 9
                continue;
161
            }
162 11
            if (is_string($token)) {
163 9
                if (in_array($token, self::$brackets, true)) {
164 9
                    $commaStack[] = $token;
165 9
                } elseif (isset(self::$brackets[$token]) && array_pop($commaStack) !== self::$brackets[$token]) {
166 4
                    return [];
167
                }
168
            }
169 11
            $parameters[$parameterIndex][] = $token;
170
        }
171
172
        return [
173 11
            'id' => $this->getMessageStringFromTokens($parameters[0] ?? []),
174 11
            'parameters' => $parameters[1] ?? null,
175 11
            'category' => $this->getMessageStringFromTokens($parameters[2] ?? []),
176
        ];
177
    }
178
179
    /**
180
     * @psalm-param array<string|array{0: int, 1: string, 2: int}> $tokens
181
     *
182
     * @return string|null
183
     */
184 11
    private function getMessageStringFromTokens(array $tokens): ?string
185
    {
186 11
        if (empty($tokens) || $tokens[0][0] !== T_CONSTANT_ENCAPSED_STRING) {
187 10
            return null;
188
        }
189
190 11
        $fullMessage = substr($tokens[0][1], 1, -1);
191
192 11
        $i = 1;
193 11
        $countTokens = count($tokens);
194 11
        while ($i < $countTokens && $tokens[$i] === '.') {
195 9
            if ($tokens[$i + 1][0] === T_CONSTANT_ENCAPSED_STRING) {
196 9
                $fullMessage .= substr($tokens[$i + 1][1], 1, -1);
197 6
            } elseif (in_array($tokens[$i + 1][0], [T_LNUMBER, T_DNUMBER])) {
198 6
                $fullMessage .= $tokens[$i + 1][1];
199
            } else {
200 4
                return null;
201
            }
202
203 9
            $i += 2;
204
        }
205
206 8
        return stripcslashes($fullMessage);
207
    }
208
209
    /**
210
     * Finds out if two PHP tokens are equal.
211
     *
212
     * @param array{0: int, 1: string, 2: int}|string $a
213
     * @param array{0: int, 1: string, 2: int}|string $b
214
     *
215
     * @return bool
216
     */
217 11
    private function tokensEqual($a, $b): bool
218
    {
219 11
        if (is_string($a)) {
220 11
            return $a === $b;
221
        }
222
223 11
        return $a[0] === $b[0] && $a[1] === $b[1];
224
    }
225
226 2
    public function setDefaultCategory(string $defaultCategory): void
227
    {
228 2
        $this->defaultCategory = $defaultCategory;
229 2
    }
230
231 10
    public function hasSkippedLines(): bool
232
    {
233 10
        return !empty($this->skippedLines);
234
    }
235
236
    /**
237
     * @psalm-return array<array<string|array{0: int, 1: string, 2: int}>>
238
     */
239 7
    public function getSkippedLines(): array
240
    {
241 7
        return $this->skippedLines;
242
    }
243
}
244