blob: 9a639561300cfaf209e271bc666ba7e89cdf7090 [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;
13
14use Symfony\Component\Config\ConfigCacheFactory;
15use Symfony\Component\Config\ConfigCacheFactoryInterface;
16use Symfony\Component\Config\ConfigCacheInterface;
17use Symfony\Component\Translation\Exception\InvalidArgumentException;
18use Symfony\Component\Translation\Exception\NotFoundResourceException;
19use Symfony\Component\Translation\Exception\RuntimeException;
20use Symfony\Component\Translation\Formatter\IntlFormatterInterface;
21use Symfony\Component\Translation\Formatter\MessageFormatter;
22use Symfony\Component\Translation\Formatter\MessageFormatterInterface;
23use Symfony\Component\Translation\Loader\LoaderInterface;
24use Symfony\Contracts\Translation\LocaleAwareInterface;
25use Symfony\Contracts\Translation\TranslatorInterface;
26
27// Help opcache.preload discover always-needed symbols
28class_exists(MessageCatalogue::class);
29
30/**
31 * @author Fabien Potencier <fabien@symfony.com>
32 */
33class Translator implements TranslatorInterface, TranslatorBagInterface, LocaleAwareInterface
34{
35 /**
36 * @var MessageCatalogueInterface[]
37 */
38 protected $catalogues = [];
39
40 /**
41 * @var string
42 */
43 private $locale;
44
45 /**
46 * @var array
47 */
48 private $fallbackLocales = [];
49
50 /**
51 * @var LoaderInterface[]
52 */
53 private $loaders = [];
54
55 /**
56 * @var array
57 */
58 private $resources = [];
59
60 /**
61 * @var MessageFormatterInterface
62 */
63 private $formatter;
64
65 /**
66 * @var string
67 */
68 private $cacheDir;
69
70 /**
71 * @var bool
72 */
73 private $debug;
74
75 private $cacheVary;
76
77 /**
78 * @var ConfigCacheFactoryInterface|null
79 */
80 private $configCacheFactory;
81
82 /**
83 * @var array|null
84 */
85 private $parentLocales;
86
87 private $hasIntlFormatter;
88
89 /**
90 * @throws InvalidArgumentException If a locale contains invalid characters
91 */
92 public function __construct(string $locale, MessageFormatterInterface $formatter = null, string $cacheDir = null, bool $debug = false, array $cacheVary = [])
93 {
94 $this->setLocale($locale);
95
96 if (null === $formatter) {
97 $formatter = new MessageFormatter();
98 }
99
100 $this->formatter = $formatter;
101 $this->cacheDir = $cacheDir;
102 $this->debug = $debug;
103 $this->cacheVary = $cacheVary;
104 $this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface;
105 }
106
107 public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory)
108 {
109 $this->configCacheFactory = $configCacheFactory;
110 }
111
112 /**
113 * Adds a Loader.
114 *
115 * @param string $format The name of the loader (@see addResource())
116 */
117 public function addLoader(string $format, LoaderInterface $loader)
118 {
119 $this->loaders[$format] = $loader;
120 }
121
122 /**
123 * Adds a Resource.
124 *
125 * @param string $format The name of the loader (@see addLoader())
126 * @param mixed $resource The resource name
127 *
128 * @throws InvalidArgumentException If the locale contains invalid characters
129 */
130 public function addResource(string $format, $resource, string $locale, string $domain = null)
131 {
132 if (null === $domain) {
133 $domain = 'messages';
134 }
135
136 $this->assertValidLocale($locale);
137 $locale ?: $locale = class_exists(\Locale::class) ? \Locale::getDefault() : 'en';
138
139 $this->resources[$locale][] = [$format, $resource, $domain];
140
141 if (\in_array($locale, $this->fallbackLocales)) {
142 $this->catalogues = [];
143 } else {
144 unset($this->catalogues[$locale]);
145 }
146 }
147
148 /**
149 * {@inheritdoc}
150 */
151 public function setLocale(string $locale)
152 {
153 $this->assertValidLocale($locale);
154 $this->locale = $locale;
155 }
156
157 /**
158 * {@inheritdoc}
159 */
160 public function getLocale()
161 {
162 return $this->locale ?: (class_exists(\Locale::class) ? \Locale::getDefault() : 'en');
163 }
164
165 /**
166 * Sets the fallback locales.
167 *
168 * @throws InvalidArgumentException If a locale contains invalid characters
169 */
170 public function setFallbackLocales(array $locales)
171 {
172 // needed as the fallback locales are linked to the already loaded catalogues
173 $this->catalogues = [];
174
175 foreach ($locales as $locale) {
176 $this->assertValidLocale($locale);
177 }
178
179 $this->fallbackLocales = $this->cacheVary['fallback_locales'] = $locales;
180 }
181
182 /**
183 * Gets the fallback locales.
184 *
185 * @internal
186 */
187 public function getFallbackLocales(): array
188 {
189 return $this->fallbackLocales;
190 }
191
192 /**
193 * {@inheritdoc}
194 */
195 public function trans(?string $id, array $parameters = [], string $domain = null, string $locale = null)
196 {
197 if (null === $id || '' === $id) {
198 return '';
199 }
200
201 if (null === $domain) {
202 $domain = 'messages';
203 }
204
205 $catalogue = $this->getCatalogue($locale);
206 $locale = $catalogue->getLocale();
207 while (!$catalogue->defines($id, $domain)) {
208 if ($cat = $catalogue->getFallbackCatalogue()) {
209 $catalogue = $cat;
210 $locale = $catalogue->getLocale();
211 } else {
212 break;
213 }
214 }
215
216 $len = \strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX);
217 if ($this->hasIntlFormatter
218 && ($catalogue->defines($id, $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)
219 || (\strlen($domain) > $len && 0 === substr_compare($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX, -$len, $len)))
220 ) {
221 return $this->formatter->formatIntl($catalogue->get($id, $domain), $locale, $parameters);
222 }
223
224 return $this->formatter->format($catalogue->get($id, $domain), $locale, $parameters);
225 }
226
227 /**
228 * {@inheritdoc}
229 */
230 public function getCatalogue(string $locale = null)
231 {
232 if (!$locale) {
233 $locale = $this->getLocale();
234 } else {
235 $this->assertValidLocale($locale);
236 }
237
238 if (!isset($this->catalogues[$locale])) {
239 $this->loadCatalogue($locale);
240 }
241
242 return $this->catalogues[$locale];
243 }
244
245 /**
246 * {@inheritdoc}
247 */
248 public function getCatalogues(): array
249 {
250 return array_values($this->catalogues);
251 }
252
253 /**
254 * Gets the loaders.
255 *
256 * @return array LoaderInterface[]
257 */
258 protected function getLoaders()
259 {
260 return $this->loaders;
261 }
262
263 protected function loadCatalogue(string $locale)
264 {
265 if (null === $this->cacheDir) {
266 $this->initializeCatalogue($locale);
267 } else {
268 $this->initializeCacheCatalogue($locale);
269 }
270 }
271
272 protected function initializeCatalogue(string $locale)
273 {
274 $this->assertValidLocale($locale);
275
276 try {
277 $this->doLoadCatalogue($locale);
278 } catch (NotFoundResourceException $e) {
279 if (!$this->computeFallbackLocales($locale)) {
280 throw $e;
281 }
282 }
283 $this->loadFallbackCatalogues($locale);
284 }
285
286 private function initializeCacheCatalogue(string $locale): void
287 {
288 if (isset($this->catalogues[$locale])) {
289 /* Catalogue already initialized. */
290 return;
291 }
292
293 $this->assertValidLocale($locale);
294 $cache = $this->getConfigCacheFactory()->cache($this->getCatalogueCachePath($locale),
295 function (ConfigCacheInterface $cache) use ($locale) {
296 $this->dumpCatalogue($locale, $cache);
297 }
298 );
299
300 if (isset($this->catalogues[$locale])) {
301 /* Catalogue has been initialized as it was written out to cache. */
302 return;
303 }
304
305 /* Read catalogue from cache. */
306 $this->catalogues[$locale] = include $cache->getPath();
307 }
308
309 private function dumpCatalogue(string $locale, ConfigCacheInterface $cache): void
310 {
311 $this->initializeCatalogue($locale);
312 $fallbackContent = $this->getFallbackContent($this->catalogues[$locale]);
313
314 $content = sprintf(<<<EOF
315<?php
316
317use Symfony\Component\Translation\MessageCatalogue;
318
319\$catalogue = new MessageCatalogue('%s', %s);
320
321%s
322return \$catalogue;
323
324EOF
325 ,
326 $locale,
327 var_export($this->getAllMessages($this->catalogues[$locale]), true),
328 $fallbackContent
329 );
330
331 $cache->write($content, $this->catalogues[$locale]->getResources());
332 }
333
334 private function getFallbackContent(MessageCatalogue $catalogue): string
335 {
336 $fallbackContent = '';
337 $current = '';
338 $replacementPattern = '/[^a-z0-9_]/i';
339 $fallbackCatalogue = $catalogue->getFallbackCatalogue();
340 while ($fallbackCatalogue) {
341 $fallback = $fallbackCatalogue->getLocale();
342 $fallbackSuffix = ucfirst(preg_replace($replacementPattern, '_', $fallback));
343 $currentSuffix = ucfirst(preg_replace($replacementPattern, '_', $current));
344
345 $fallbackContent .= sprintf(<<<'EOF'
346$catalogue%s = new MessageCatalogue('%s', %s);
347$catalogue%s->addFallbackCatalogue($catalogue%s);
348
349EOF
350 ,
351 $fallbackSuffix,
352 $fallback,
353 var_export($this->getAllMessages($fallbackCatalogue), true),
354 $currentSuffix,
355 $fallbackSuffix
356 );
357 $current = $fallbackCatalogue->getLocale();
358 $fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue();
359 }
360
361 return $fallbackContent;
362 }
363
364 private function getCatalogueCachePath(string $locale): string
365 {
366 return $this->cacheDir.'/catalogue.'.$locale.'.'.strtr(substr(base64_encode(hash('sha256', serialize($this->cacheVary), true)), 0, 7), '/', '_').'.php';
367 }
368
369 /**
370 * @internal
371 */
372 protected function doLoadCatalogue(string $locale): void
373 {
374 $this->catalogues[$locale] = new MessageCatalogue($locale);
375
376 if (isset($this->resources[$locale])) {
377 foreach ($this->resources[$locale] as $resource) {
378 if (!isset($this->loaders[$resource[0]])) {
379 if (\is_string($resource[1])) {
380 throw new RuntimeException(sprintf('No loader is registered for the "%s" format when loading the "%s" resource.', $resource[0], $resource[1]));
381 }
382
383 throw new RuntimeException(sprintf('No loader is registered for the "%s" format.', $resource[0]));
384 }
385 $this->catalogues[$locale]->addCatalogue($this->loaders[$resource[0]]->load($resource[1], $locale, $resource[2]));
386 }
387 }
388 }
389
390 private function loadFallbackCatalogues(string $locale): void
391 {
392 $current = $this->catalogues[$locale];
393
394 foreach ($this->computeFallbackLocales($locale) as $fallback) {
395 if (!isset($this->catalogues[$fallback])) {
396 $this->initializeCatalogue($fallback);
397 }
398
399 $fallbackCatalogue = new MessageCatalogue($fallback, $this->getAllMessages($this->catalogues[$fallback]));
400 foreach ($this->catalogues[$fallback]->getResources() as $resource) {
401 $fallbackCatalogue->addResource($resource);
402 }
403 $current->addFallbackCatalogue($fallbackCatalogue);
404 $current = $fallbackCatalogue;
405 }
406 }
407
408 protected function computeFallbackLocales(string $locale)
409 {
410 if (null === $this->parentLocales) {
411 $this->parentLocales = json_decode(file_get_contents(__DIR__.'/Resources/data/parents.json'), true);
412 }
413
414 $locales = [];
415 foreach ($this->fallbackLocales as $fallback) {
416 if ($fallback === $locale) {
417 continue;
418 }
419
420 $locales[] = $fallback;
421 }
422
423 while ($locale) {
424 $parent = $this->parentLocales[$locale] ?? null;
425
426 if ($parent) {
427 $locale = 'root' !== $parent ? $parent : null;
428 } elseif (\function_exists('locale_parse')) {
429 $localeSubTags = locale_parse($locale);
430 $locale = null;
431 if (1 < \count($localeSubTags)) {
432 array_pop($localeSubTags);
433 $locale = locale_compose($localeSubTags) ?: null;
434 }
435 } elseif ($i = strrpos($locale, '_') ?: strrpos($locale, '-')) {
436 $locale = substr($locale, 0, $i);
437 } else {
438 $locale = null;
439 }
440
441 if (null !== $locale) {
442 array_unshift($locales, $locale);
443 }
444 }
445
446 return array_unique($locales);
447 }
448
449 /**
450 * Asserts that the locale is valid, throws an Exception if not.
451 *
452 * @throws InvalidArgumentException If the locale contains invalid characters
453 */
454 protected function assertValidLocale(string $locale)
455 {
456 if (!preg_match('/^[a-z0-9@_\\.\\-]*$/i', (string) $locale)) {
457 throw new InvalidArgumentException(sprintf('Invalid "%s" locale.', $locale));
458 }
459 }
460
461 /**
462 * Provides the ConfigCache factory implementation, falling back to a
463 * default implementation if necessary.
464 */
465 private function getConfigCacheFactory(): ConfigCacheFactoryInterface
466 {
467 if (!$this->configCacheFactory) {
468 $this->configCacheFactory = new ConfigCacheFactory($this->debug);
469 }
470
471 return $this->configCacheFactory;
472 }
473
474 private function getAllMessages(MessageCatalogueInterface $catalogue): array
475 {
476 $allMessages = [];
477
478 foreach ($catalogue->all() as $domain => $messages) {
479 if ($intlMessages = $catalogue->all($domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
480 $allMessages[$domain.MessageCatalogue::INTL_DOMAIN_SUFFIX] = $intlMessages;
481 $messages = array_diff_key($messages, $intlMessages);
482 }
483 if ($messages) {
484 $allMessages[$domain] = $messages;
485 }
486 }
487
488 return $allMessages;
489 }
490}