blob: 1b86cc591393362bf00fb8c4174f499e453d95fa [file] [log] [blame]
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +02001<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Translation\Extractor;
13
14use Symfony\Component\Finder\Finder;
15use Symfony\Component\Translation\MessageCatalogue;
16
17/**
18 * PhpExtractor extracts translation messages from a PHP template.
19 *
20 * @author Michel Salib <michelsalib@hotmail.com>
21 */
22class PhpExtractor extends AbstractFileExtractor implements ExtractorInterface
23{
24 public const MESSAGE_TOKEN = 300;
25 public const METHOD_ARGUMENTS_TOKEN = 1000;
26 public const DOMAIN_TOKEN = 1001;
27
28 /**
29 * Prefix for new found message.
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020030 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010031 private string $prefix = '';
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020032
33 /**
34 * The sequence that captures translation messages.
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020035 */
36 protected $sequences = [
37 [
38 '->',
39 'trans',
40 '(',
41 self::MESSAGE_TOKEN,
42 ',',
43 self::METHOD_ARGUMENTS_TOKEN,
44 ',',
45 self::DOMAIN_TOKEN,
46 ],
47 [
48 '->',
49 'trans',
50 '(',
51 self::MESSAGE_TOKEN,
52 ],
53 [
54 'new',
55 'TranslatableMessage',
56 '(',
57 self::MESSAGE_TOKEN,
58 ',',
59 self::METHOD_ARGUMENTS_TOKEN,
60 ',',
61 self::DOMAIN_TOKEN,
62 ],
63 [
64 'new',
65 'TranslatableMessage',
66 '(',
67 self::MESSAGE_TOKEN,
68 ],
69 [
70 'new',
71 '\\',
72 'Symfony',
73 '\\',
74 'Component',
75 '\\',
76 'Translation',
77 '\\',
78 'TranslatableMessage',
79 '(',
80 self::MESSAGE_TOKEN,
81 ',',
82 self::METHOD_ARGUMENTS_TOKEN,
83 ',',
84 self::DOMAIN_TOKEN,
85 ],
86 [
87 'new',
88 '\Symfony\Component\Translation\TranslatableMessage',
89 '(',
90 self::MESSAGE_TOKEN,
91 ',',
92 self::METHOD_ARGUMENTS_TOKEN,
93 ',',
94 self::DOMAIN_TOKEN,
95 ],
96 [
97 'new',
98 '\\',
99 'Symfony',
100 '\\',
101 'Component',
102 '\\',
103 'Translation',
104 '\\',
105 'TranslatableMessage',
106 '(',
107 self::MESSAGE_TOKEN,
108 ],
109 [
110 'new',
111 '\Symfony\Component\Translation\TranslatableMessage',
112 '(',
113 self::MESSAGE_TOKEN,
114 ],
115 [
116 't',
117 '(',
118 self::MESSAGE_TOKEN,
119 ',',
120 self::METHOD_ARGUMENTS_TOKEN,
121 ',',
122 self::DOMAIN_TOKEN,
123 ],
124 [
125 't',
126 '(',
127 self::MESSAGE_TOKEN,
128 ],
129 ];
130
131 /**
132 * {@inheritdoc}
133 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100134 public function extract(string|iterable $resource, MessageCatalogue $catalog)
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200135 {
136 $files = $this->extractFiles($resource);
137 foreach ($files as $file) {
138 $this->parseTokens(token_get_all(file_get_contents($file)), $catalog, $file);
139
140 gc_mem_caches();
141 }
142 }
143
144 /**
145 * {@inheritdoc}
146 */
147 public function setPrefix(string $prefix)
148 {
149 $this->prefix = $prefix;
150 }
151
152 /**
153 * Normalizes a token.
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200154 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100155 protected function normalizeToken(mixed $token): ?string
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200156 {
157 if (isset($token[1]) && 'b"' !== $token) {
158 return $token[1];
159 }
160
161 return $token;
162 }
163
164 /**
165 * Seeks to a non-whitespace token.
166 */
167 private function seekToNextRelevantToken(\Iterator $tokenIterator)
168 {
169 for (; $tokenIterator->valid(); $tokenIterator->next()) {
170 $t = $tokenIterator->current();
171 if (\T_WHITESPACE !== $t[0]) {
172 break;
173 }
174 }
175 }
176
177 private function skipMethodArgument(\Iterator $tokenIterator)
178 {
179 $openBraces = 0;
180
181 for (; $tokenIterator->valid(); $tokenIterator->next()) {
182 $t = $tokenIterator->current();
183
184 if ('[' === $t[0] || '(' === $t[0]) {
185 ++$openBraces;
186 }
187
188 if (']' === $t[0] || ')' === $t[0]) {
189 --$openBraces;
190 }
191
192 if ((0 === $openBraces && ',' === $t[0]) || (-1 === $openBraces && ')' === $t[0])) {
193 break;
194 }
195 }
196 }
197
198 /**
199 * Extracts the message from the iterator while the tokens
200 * match allowed message tokens.
201 */
202 private function getValue(\Iterator $tokenIterator)
203 {
204 $message = '';
205 $docToken = '';
206 $docPart = '';
207
208 for (; $tokenIterator->valid(); $tokenIterator->next()) {
209 $t = $tokenIterator->current();
210 if ('.' === $t) {
211 // Concatenate with next token
212 continue;
213 }
214 if (!isset($t[1])) {
215 break;
216 }
217
218 switch ($t[0]) {
219 case \T_START_HEREDOC:
220 $docToken = $t[1];
221 break;
222 case \T_ENCAPSED_AND_WHITESPACE:
223 case \T_CONSTANT_ENCAPSED_STRING:
224 if ('' === $docToken) {
225 $message .= PhpStringTokenParser::parse($t[1]);
226 } else {
227 $docPart = $t[1];
228 }
229 break;
230 case \T_END_HEREDOC:
231 if ($indentation = strspn($t[1], ' ')) {
232 $docPartWithLineBreaks = $docPart;
233 $docPart = '';
234
235 foreach (preg_split('~(\r\n|\n|\r)~', $docPartWithLineBreaks, -1, \PREG_SPLIT_DELIM_CAPTURE) as $str) {
236 if (\in_array($str, ["\r\n", "\n", "\r"], true)) {
237 $docPart .= $str;
238 } else {
239 $docPart .= substr($str, $indentation);
240 }
241 }
242 }
243
244 $message .= PhpStringTokenParser::parseDocString($docToken, $docPart);
245 $docToken = '';
246 $docPart = '';
247 break;
248 case \T_WHITESPACE:
249 break;
250 default:
251 break 2;
252 }
253 }
254
255 return $message;
256 }
257
258 /**
259 * Extracts trans message from PHP tokens.
260 */
261 protected function parseTokens(array $tokens, MessageCatalogue $catalog, string $filename)
262 {
263 $tokenIterator = new \ArrayIterator($tokens);
264
265 for ($key = 0; $key < $tokenIterator->count(); ++$key) {
266 foreach ($this->sequences as $sequence) {
267 $message = '';
268 $domain = 'messages';
269 $tokenIterator->seek($key);
270
271 foreach ($sequence as $sequenceKey => $item) {
272 $this->seekToNextRelevantToken($tokenIterator);
273
274 if ($this->normalizeToken($tokenIterator->current()) === $item) {
275 $tokenIterator->next();
276 continue;
277 } elseif (self::MESSAGE_TOKEN === $item) {
278 $message = $this->getValue($tokenIterator);
279
280 if (\count($sequence) === ($sequenceKey + 1)) {
281 break;
282 }
283 } elseif (self::METHOD_ARGUMENTS_TOKEN === $item) {
284 $this->skipMethodArgument($tokenIterator);
285 } elseif (self::DOMAIN_TOKEN === $item) {
286 $domainToken = $this->getValue($tokenIterator);
287 if ('' !== $domainToken) {
288 $domain = $domainToken;
289 }
290
291 break;
292 } else {
293 break;
294 }
295 }
296
297 if ($message) {
298 $catalog->set($message, $this->prefix.$message, $domain);
299 $metadata = $catalog->getMetadata($message, $domain) ?? [];
300 $normalizedFilename = preg_replace('{[\\\\/]+}', '/', $filename);
301 $metadata['sources'][] = $normalizedFilename.':'.$tokens[$key][2];
302 $catalog->setMetadata($message, $metadata, $domain);
303 break;
304 }
305 }
306 }
307 }
308
309 /**
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200310 * @throws \InvalidArgumentException
311 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100312 protected function canBeExtracted(string $file): bool
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200313 {
314 return $this->isFile($file) && 'php' === pathinfo($file, \PATHINFO_EXTENSION);
315 }
316
317 /**
318 * {@inheritdoc}
319 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100320 protected function extractFromDirectory(string|array $directory): iterable
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200321 {
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100322 if (!class_exists(Finder::class)) {
323 throw new \LogicException(sprintf('You cannot use "%s" as the "symfony/finder" package is not installed. Try running "composer require symfony/finder".', static::class));
324 }
325
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200326 $finder = new Finder();
327
328 return $finder->files()->name('*.php')->in($directory);
329 }
330}