ContentParser::setTranslator()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 7
c 1
b 0
f 0
dl 0
loc 10
ccs 8
cts 8
cp 1
rs 10
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Translator\Extractor;
6
7
use RuntimeException;
8
9
use function array_slice;
10
use function count;
11
use function in_array;
12
use function is_array;
13
use function is_string;
14
15
/**
16
 * Extracts translation keys from a string given.
17
 *
18
 * @psalm-type TranslatorToken = string|array{0: int, 1: string, 2: int}
19
 */
20
final class ContentParser
21
{
22
    private string $translatorCall = '->translate';
23
24
    /**
25
     * @psalm-var TranslatorToken[]
26
     */
27
    private array $translatorTokens = [];
28
29
    private int $translatorTokenCount = 0;
30
31
    private static array $brackets = [
32
        ')' => '(',
33
        ']' => '[',
34
        '}' => '{',
35
    ];
36
37
    private array $skippedLines = [];
38
39
    /**
40
     * @param string $defaultCategory Name of the category to use when no category is specified.
41
     * @param string|null $translator A string containing a method call that translates the message. If not specified,
42
     * "->translate" is assumed.
43
     */
44 12
    public function __construct(private string $defaultCategory, ?string $translator = null)
45
    {
46 12
        $this->setTranslator($translator ?? $this->translatorCall);
47
    }
48
49
    /**
50
     * @param string $content Code to extract translation keys from.
51
     *
52
     * @return array Extracted messages.
53
     */
54 11
    public function extract(string $content): array
55
    {
56 11
        $this->skippedLines = [];
57 11
        $tokens = token_get_all($content);
58
59 11
        return $this->extractMessagesFromTokens($tokens);
60
    }
61
62
    /**
63
     * @param string $defaultCategory Name of the category to use when no category is specified.
64
     */
65 2
    public function setDefaultCategory(string $defaultCategory): void
66
    {
67 2
        $this->defaultCategory = $defaultCategory;
68
    }
69
70
    /**
71
     * @return bool Whether there are skipped lines.
72
     */
73 10
    public function hasSkippedLines(): bool
74
    {
75 10
        return !empty($this->skippedLines);
76
    }
77
78
    /**
79
     * @return array Lines that were skipped during parsing.
80
     *
81
     * The format is:
82
     *
83
     * ```php
84
     * return [
85
     *     'fileName' => [
86
     *         [
87
     *             int $numberOfLine,
88
     *             string $incorrectLine,
89
     *         ],
90
     *     ],
91
     * ]
92
     * ```
93
     */
94 7
    public function getSkippedLines(): array
95
    {
96 7
        return $this->skippedLines;
97
    }
98
99 12
    private function setTranslator(string $translatorCall): void
100
    {
101 12
        $this->translatorCall = $translatorCall;
102 12
        $translatorTokens = token_get_all('<?php ' . $this->translatorCall);
103 12
        array_shift($translatorTokens);
104 12
        $this->translatorTokens = $translatorTokens;
105 12
        $this->translatorTokenCount = count($this->translatorTokens);
106
107 12
        if ($this->translatorTokenCount < 2) {
108 1
            throw new RuntimeException('Translator call cannot contain less than 2 tokens.');
109
        }
110
    }
111
112
    /**
113
     * @psalm-param array<integer, TranslatorToken> $tokens
114
     *
115
     * @psalm-suppress UnusedVariable See https://github.com/vimeo/psalm/issues/9080
116
     */
117 11
    private function extractMessagesFromTokens(array $tokens): array
118
    {
119 11
        $messages = $buffer = [];
120 11
        $matchedTokensCount = $pendingParenthesisCount = 0;
121 11
        $startTranslatorTokenIndex = 0;
122
123 11
        foreach ($tokens as $indexToken => $token) {
124 11
            if (in_array($token[0], [T_WHITESPACE, T_COMMENT], true)) {
125 11
                continue;
126
            }
127
128 11
            if ($startTranslatorTokenIndex) {
129 11
                if ($this->tokensEqual($token, ')')) {
130 11
                    if ($pendingParenthesisCount === 0) {
131 11
                        $result = $this->extractParametersFromTokens($buffer);
132 11
                        if ($result === null) {
133 6
                            $skippedTokens = array_slice($tokens, $startTranslatorTokenIndex, $indexToken - $startTranslatorTokenIndex + 1);
134 6
                            $this->skippedLines[] = $this->getLinesData($skippedTokens);
135
                        } else {
136 8
                            $messages = array_merge_recursive($messages, $result);
137
                        }
138 11
                        $startTranslatorTokenIndex = 0;
139 11
                        $pendingParenthesisCount = 0;
140 11
                        $buffer = [];
141 11
                        continue;
142
                    }
143 9
                    $pendingParenthesisCount--;
144 11
                } elseif ($this->tokensEqual($token, '(')) {
145 9
                    $pendingParenthesisCount++;
146
                }
147 11
                $buffer[] = $token;
148
            } else {
149 11
                if ($matchedTokensCount === $this->translatorTokenCount) {
150 11
                    if ($this->tokensEqual($token, '(')) {
151 11
                        $startTranslatorTokenIndex = $indexToken - $this->translatorTokenCount;
152 11
                        continue;
153
                    }
154 11
                    $matchedTokensCount = 0;
155
                }
156
157 11
                if ($this->tokensEqual($token, $this->translatorTokens[$matchedTokensCount])) {
158 11
                    $matchedTokensCount++;
159
                } else {
160 11
                    $matchedTokensCount = 0;
161
                }
162
            }
163
        }
164
165 11
        return $messages;
166
    }
167
168
    /**
169
     * @psalm-param TranslatorToken[] $tokens
170
     */
171 11
    private function extractParametersFromTokens(array $tokens): ?array
172
    {
173 11
        $parameters = $this->splitTokensAsParams($tokens);
174
175 11
        if (!isset($parameters['id'])) {
176 6
            return null;
177
        }
178
179 8
        $messages = [$parameters['category'] ?? $this->defaultCategory => [$parameters['id']]];
180
181
        // Get translation messages from parameters
182 8
        if (isset($parameters['parameters'])) {
183 6
            $messages = array_merge_recursive($messages, $this->extractMessagesFromTokens($parameters['parameters']));
184
        }
185
186 8
        return $messages;
187
    }
188
189
    /**
190
     * @psalm-param TranslatorToken[] $tokens
191
     *
192
     * @psalm-return array{
193
     *     category?: null|string,
194
     *     id?: null|string,
195
     *     parameters?: null|list<TranslatorToken>
196
     * }
197
     */
198 11
    private function splitTokensAsParams(array $tokens): array
199
    {
200 11
        $parameters = [];
201 11
        $parameterIndex = 0;
202 11
        $commaStack = [];
203
204 11
        foreach ($tokens as $token) {
205 11
            if (empty($commaStack) && $token === ',') {
206 9
                $parameterIndex++;
207 9
                continue;
208
            }
209 11
            if (is_string($token)) {
210 9
                if (in_array($token, self::$brackets, true)) {
211 9
                    $commaStack[] = $token;
212 9
                } elseif (isset(self::$brackets[$token]) && array_pop($commaStack) !== self::$brackets[$token]) {
213 4
                    return [];
214
                }
215
            }
216 11
            $parameters[$parameterIndex][] = $token;
217
        }
218
219 11
        return [
220 11
            'id' => $this->getMessageStringFromTokens($parameters[0] ?? []),
221 11
            'parameters' => $parameters[1] ?? null,
222 11
            'category' => $this->getMessageStringFromTokens($parameters[2] ?? []),
223 11
        ];
224
    }
225
226
    /**
227
     * @psalm-param TranslatorToken[] $tokens
228
     */
229 11
    private function getMessageStringFromTokens(array $tokens): ?string
230
    {
231 11
        if (empty($tokens) || $tokens[0][0] !== T_CONSTANT_ENCAPSED_STRING) {
232 10
            return null;
233
        }
234
235 11
        $fullMessage = substr($tokens[0][1], 1, -1);
236
237 11
        $i = 1;
238 11
        $countTokens = count($tokens);
239 11
        while ($i < $countTokens && $tokens[$i] === '.') {
240 9
            if ($tokens[$i + 1][0] === T_CONSTANT_ENCAPSED_STRING) {
241 9
                $fullMessage .= substr($tokens[$i + 1][1], 1, -1);
242 6
            } elseif (in_array($tokens[$i + 1][0], [T_LNUMBER, T_DNUMBER], true)) {
243 6
                $fullMessage .= $tokens[$i + 1][1];
244
            } else {
245 4
                return null;
246
            }
247
248 9
            $i += 2;
249
        }
250
251 8
        return stripcslashes($fullMessage);
252
    }
253
254
    /**
255
     * Finds out if two PHP tokens are equal.
256
     *
257
     * @psalm-param TranslatorToken $a
258
     * @psalm-param TranslatorToken $b
259
     */
260 11
    private function tokensEqual(array|string $a, array|string $b): bool
261
    {
262 11
        if (is_string($a)) {
0 ignored issues
show
introduced by
The condition is_string($a) is always false.
Loading history...
263 11
            return $a === $b;
264
        }
265
266 11
        return $a[0] === $b[0] && $a[1] === $b[1];
267
    }
268
269
    /**
270
     * @psalm-param TranslatorToken[] $tokens
271
     */
272 6
    private function getLinesData(array $tokens): array
273
    {
274 6
        $startLine = null;
275 6
        $codeLines = '';
276 6
        foreach ($tokens as $token) {
277 6
            if (is_array($token)) {
278 6
                if ($startLine === null) {
279 6
                    $startLine = $token[2];
280
                }
281 6
                $codeLines .= $token[1];
282
            } else {
283 6
                $codeLines .= $token;
284
            }
285
        }
286 6
        return [$startLine, $codeLines];
287
    }
288
}
289