blob: 05e84d0cc049f549696ce500d34da6e85ea27f37 [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
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010040 private string $locale;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020041
42 /**
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010043 * @var string[]
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020044 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010045 private array $fallbackLocales = [];
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020046
47 /**
48 * @var LoaderInterface[]
49 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010050 private array $loaders = [];
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020051
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010052 private array $resources = [];
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020053
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020054 private $formatter;
55
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010056 private ?string $cacheDir;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020057
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010058 private bool $debug;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020059
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010060 private array $cacheVary;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020061
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020062 private $configCacheFactory;
63
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010064 private array $parentLocales;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020065
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +010066 private bool $hasIntlFormatter;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +020067
68 /**
69 * @throws InvalidArgumentException If a locale contains invalid characters
70 */
71 public function __construct(string $locale, MessageFormatterInterface $formatter = null, string $cacheDir = null, bool $debug = false, array $cacheVary = [])
72 {
73 $this->setLocale($locale);
74
75 if (null === $formatter) {
76 $formatter = new MessageFormatter();
77 }
78
79 $this->formatter = $formatter;
80 $this->cacheDir = $cacheDir;
81 $this->debug = $debug;
82 $this->cacheVary = $cacheVary;
83 $this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface;
84 }
85
86 public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory)
87 {
88 $this->configCacheFactory = $configCacheFactory;
89 }
90
91 /**
92 * Adds a Loader.
93 *
94 * @param string $format The name of the loader (@see addResource())
95 */
96 public function addLoader(string $format, LoaderInterface $loader)
97 {
98 $this->loaders[$format] = $loader;
99 }
100
101 /**
102 * Adds a Resource.
103 *
104 * @param string $format The name of the loader (@see addLoader())
105 * @param mixed $resource The resource name
106 *
107 * @throws InvalidArgumentException If the locale contains invalid characters
108 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100109 public function addResource(string $format, mixed $resource, string $locale, string $domain = null)
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200110 {
111 if (null === $domain) {
112 $domain = 'messages';
113 }
114
115 $this->assertValidLocale($locale);
116 $locale ?: $locale = class_exists(\Locale::class) ? \Locale::getDefault() : 'en';
117
118 $this->resources[$locale][] = [$format, $resource, $domain];
119
120 if (\in_array($locale, $this->fallbackLocales)) {
121 $this->catalogues = [];
122 } else {
123 unset($this->catalogues[$locale]);
124 }
125 }
126
127 /**
128 * {@inheritdoc}
129 */
130 public function setLocale(string $locale)
131 {
132 $this->assertValidLocale($locale);
133 $this->locale = $locale;
134 }
135
136 /**
137 * {@inheritdoc}
138 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100139 public function getLocale(): string
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200140 {
141 return $this->locale ?: (class_exists(\Locale::class) ? \Locale::getDefault() : 'en');
142 }
143
144 /**
145 * Sets the fallback locales.
146 *
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100147 * @param string[] $locales
148 *
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200149 * @throws InvalidArgumentException If a locale contains invalid characters
150 */
151 public function setFallbackLocales(array $locales)
152 {
153 // needed as the fallback locales are linked to the already loaded catalogues
154 $this->catalogues = [];
155
156 foreach ($locales as $locale) {
157 $this->assertValidLocale($locale);
158 }
159
160 $this->fallbackLocales = $this->cacheVary['fallback_locales'] = $locales;
161 }
162
163 /**
164 * Gets the fallback locales.
165 *
166 * @internal
167 */
168 public function getFallbackLocales(): array
169 {
170 return $this->fallbackLocales;
171 }
172
173 /**
174 * {@inheritdoc}
175 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100176 public function trans(?string $id, array $parameters = [], string $domain = null, string $locale = null): string
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200177 {
178 if (null === $id || '' === $id) {
179 return '';
180 }
181
182 if (null === $domain) {
183 $domain = 'messages';
184 }
185
186 $catalogue = $this->getCatalogue($locale);
187 $locale = $catalogue->getLocale();
188 while (!$catalogue->defines($id, $domain)) {
189 if ($cat = $catalogue->getFallbackCatalogue()) {
190 $catalogue = $cat;
191 $locale = $catalogue->getLocale();
192 } else {
193 break;
194 }
195 }
196
197 $len = \strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX);
198 if ($this->hasIntlFormatter
199 && ($catalogue->defines($id, $domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)
200 || (\strlen($domain) > $len && 0 === substr_compare($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX, -$len, $len)))
201 ) {
202 return $this->formatter->formatIntl($catalogue->get($id, $domain), $locale, $parameters);
203 }
204
205 return $this->formatter->format($catalogue->get($id, $domain), $locale, $parameters);
206 }
207
208 /**
209 * {@inheritdoc}
210 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100211 public function getCatalogue(string $locale = null): MessageCatalogueInterface
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200212 {
213 if (!$locale) {
214 $locale = $this->getLocale();
215 } else {
216 $this->assertValidLocale($locale);
217 }
218
219 if (!isset($this->catalogues[$locale])) {
220 $this->loadCatalogue($locale);
221 }
222
223 return $this->catalogues[$locale];
224 }
225
226 /**
227 * {@inheritdoc}
228 */
229 public function getCatalogues(): array
230 {
231 return array_values($this->catalogues);
232 }
233
234 /**
235 * Gets the loaders.
236 *
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100237 * @return LoaderInterface[]
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200238 */
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100239 protected function getLoaders(): array
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200240 {
241 return $this->loaders;
242 }
243
244 protected function loadCatalogue(string $locale)
245 {
246 if (null === $this->cacheDir) {
247 $this->initializeCatalogue($locale);
248 } else {
249 $this->initializeCacheCatalogue($locale);
250 }
251 }
252
253 protected function initializeCatalogue(string $locale)
254 {
255 $this->assertValidLocale($locale);
256
257 try {
258 $this->doLoadCatalogue($locale);
259 } catch (NotFoundResourceException $e) {
260 if (!$this->computeFallbackLocales($locale)) {
261 throw $e;
262 }
263 }
264 $this->loadFallbackCatalogues($locale);
265 }
266
267 private function initializeCacheCatalogue(string $locale): void
268 {
269 if (isset($this->catalogues[$locale])) {
270 /* Catalogue already initialized. */
271 return;
272 }
273
274 $this->assertValidLocale($locale);
275 $cache = $this->getConfigCacheFactory()->cache($this->getCatalogueCachePath($locale),
276 function (ConfigCacheInterface $cache) use ($locale) {
277 $this->dumpCatalogue($locale, $cache);
278 }
279 );
280
281 if (isset($this->catalogues[$locale])) {
282 /* Catalogue has been initialized as it was written out to cache. */
283 return;
284 }
285
286 /* Read catalogue from cache. */
287 $this->catalogues[$locale] = include $cache->getPath();
288 }
289
290 private function dumpCatalogue(string $locale, ConfigCacheInterface $cache): void
291 {
292 $this->initializeCatalogue($locale);
293 $fallbackContent = $this->getFallbackContent($this->catalogues[$locale]);
294
295 $content = sprintf(<<<EOF
296<?php
297
298use Symfony\Component\Translation\MessageCatalogue;
299
300\$catalogue = new MessageCatalogue('%s', %s);
301
302%s
303return \$catalogue;
304
305EOF
306 ,
307 $locale,
308 var_export($this->getAllMessages($this->catalogues[$locale]), true),
309 $fallbackContent
310 );
311
312 $cache->write($content, $this->catalogues[$locale]->getResources());
313 }
314
315 private function getFallbackContent(MessageCatalogue $catalogue): string
316 {
317 $fallbackContent = '';
318 $current = '';
319 $replacementPattern = '/[^a-z0-9_]/i';
320 $fallbackCatalogue = $catalogue->getFallbackCatalogue();
321 while ($fallbackCatalogue) {
322 $fallback = $fallbackCatalogue->getLocale();
323 $fallbackSuffix = ucfirst(preg_replace($replacementPattern, '_', $fallback));
324 $currentSuffix = ucfirst(preg_replace($replacementPattern, '_', $current));
325
326 $fallbackContent .= sprintf(<<<'EOF'
327$catalogue%s = new MessageCatalogue('%s', %s);
328$catalogue%s->addFallbackCatalogue($catalogue%s);
329
330EOF
331 ,
332 $fallbackSuffix,
333 $fallback,
334 var_export($this->getAllMessages($fallbackCatalogue), true),
335 $currentSuffix,
336 $fallbackSuffix
337 );
338 $current = $fallbackCatalogue->getLocale();
339 $fallbackCatalogue = $fallbackCatalogue->getFallbackCatalogue();
340 }
341
342 return $fallbackContent;
343 }
344
345 private function getCatalogueCachePath(string $locale): string
346 {
347 return $this->cacheDir.'/catalogue.'.$locale.'.'.strtr(substr(base64_encode(hash('sha256', serialize($this->cacheVary), true)), 0, 7), '/', '_').'.php';
348 }
349
350 /**
351 * @internal
352 */
353 protected function doLoadCatalogue(string $locale): void
354 {
355 $this->catalogues[$locale] = new MessageCatalogue($locale);
356
357 if (isset($this->resources[$locale])) {
358 foreach ($this->resources[$locale] as $resource) {
359 if (!isset($this->loaders[$resource[0]])) {
360 if (\is_string($resource[1])) {
361 throw new RuntimeException(sprintf('No loader is registered for the "%s" format when loading the "%s" resource.', $resource[0], $resource[1]));
362 }
363
364 throw new RuntimeException(sprintf('No loader is registered for the "%s" format.', $resource[0]));
365 }
366 $this->catalogues[$locale]->addCatalogue($this->loaders[$resource[0]]->load($resource[1], $locale, $resource[2]));
367 }
368 }
369 }
370
371 private function loadFallbackCatalogues(string $locale): void
372 {
373 $current = $this->catalogues[$locale];
374
375 foreach ($this->computeFallbackLocales($locale) as $fallback) {
376 if (!isset($this->catalogues[$fallback])) {
377 $this->initializeCatalogue($fallback);
378 }
379
380 $fallbackCatalogue = new MessageCatalogue($fallback, $this->getAllMessages($this->catalogues[$fallback]));
381 foreach ($this->catalogues[$fallback]->getResources() as $resource) {
382 $fallbackCatalogue->addResource($resource);
383 }
384 $current->addFallbackCatalogue($fallbackCatalogue);
385 $current = $fallbackCatalogue;
386 }
387 }
388
389 protected function computeFallbackLocales(string $locale)
390 {
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100391 $this->parentLocales ??= json_decode(file_get_contents(__DIR__.'/Resources/data/parents.json'), true);
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200392
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100393 $originLocale = $locale;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200394 $locales = [];
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200395
396 while ($locale) {
397 $parent = $this->parentLocales[$locale] ?? null;
398
399 if ($parent) {
400 $locale = 'root' !== $parent ? $parent : null;
401 } elseif (\function_exists('locale_parse')) {
402 $localeSubTags = locale_parse($locale);
403 $locale = null;
404 if (1 < \count($localeSubTags)) {
405 array_pop($localeSubTags);
406 $locale = locale_compose($localeSubTags) ?: null;
407 }
408 } elseif ($i = strrpos($locale, '_') ?: strrpos($locale, '-')) {
409 $locale = substr($locale, 0, $i);
410 } else {
411 $locale = null;
412 }
413
414 if (null !== $locale) {
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100415 $locales[] = $locale;
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200416 }
417 }
418
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100419 foreach ($this->fallbackLocales as $fallback) {
420 if ($fallback === $originLocale) {
421 continue;
422 }
423
424 $locales[] = $fallback;
425 }
426
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200427 return array_unique($locales);
428 }
429
430 /**
431 * Asserts that the locale is valid, throws an Exception if not.
432 *
433 * @throws InvalidArgumentException If the locale contains invalid characters
434 */
435 protected function assertValidLocale(string $locale)
436 {
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100437 if (!preg_match('/^[a-z0-9@_\\.\\-]*$/i', $locale)) {
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200438 throw new InvalidArgumentException(sprintf('Invalid "%s" locale.', $locale));
439 }
440 }
441
442 /**
443 * Provides the ConfigCache factory implementation, falling back to a
444 * default implementation if necessary.
445 */
446 private function getConfigCacheFactory(): ConfigCacheFactoryInterface
447 {
Matthias Andreas Benkard1ba53812022-12-27 17:32:58 +0100448 $this->configCacheFactory ??= new ConfigCacheFactory($this->debug);
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +0200449
450 return $this->configCacheFactory;
451 }
452
453 private function getAllMessages(MessageCatalogueInterface $catalogue): array
454 {
455 $allMessages = [];
456
457 foreach ($catalogue->all() as $domain => $messages) {
458 if ($intlMessages = $catalogue->all($domain.MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
459 $allMessages[$domain.MessageCatalogue::INTL_DOMAIN_SUFFIX] = $intlMessages;
460 $messages = array_diff_key($messages, $intlMessages);
461 }
462 if ($messages) {
463 $allMessages[$domain] = $messages;
464 }
465 }
466
467 return $allMessages;
468 }
469}