+ */
+class IntlFormatter implements IntlFormatterInterface
+{
+ private $hasMessageFormatter;
+ private $cache = array();
+
+ /**
+ * {@inheritdoc}
+ */
+ public function formatIntl(string $message, string $locale, array $parameters = array()): string
+ {
+ if (!$formatter = $this->cache[$locale][$message] ?? null) {
+ if (!($this->hasMessageFormatter ?? $this->hasMessageFormatter = class_exists(\MessageFormatter::class))) {
+ throw new LogicException('Cannot parse message translation: please install the "intl" PHP extension or the "symfony/polyfill-intl-messageformatter" package.');
+ }
+ try {
+ $this->cache[$locale][$message] = $formatter = new \MessageFormatter($locale, $message);
+ } catch (\IntlException $e) {
+ throw new InvalidArgumentException(sprintf('Invalid message format (error #%d): %s.', intl_get_error_code(), intl_get_error_message()), 0, $e);
+ }
+ }
+
+ foreach ($parameters as $key => $value) {
+ if (\in_array($key[0] ?? null, array('%', '{'), true)) {
+ unset($parameters[$key]);
+ $parameters[trim($key, '%{ }')] = $value;
+ }
+ }
+
+ if (false === $message = $formatter->format($parameters)) {
+ throw new InvalidArgumentException(sprintf('Unable to format message (error #%s): %s.', $formatter->getErrorCode(), $formatter->getErrorMessage()));
+ }
+
+ return $message;
+ }
+}
diff --git a/src/Symfony/Component/Translation/Formatter/IntlFormatterInterface.php b/src/Symfony/Component/Translation/Formatter/IntlFormatterInterface.php
new file mode 100644
index 0000000000000..eb8590db3d01d
--- /dev/null
+++ b/src/Symfony/Component/Translation/Formatter/IntlFormatterInterface.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Translation\Formatter;
+
+/**
+ * Formats ICU message patterns.
+ *
+ * @author Nicolas Grekas
+ */
+interface IntlFormatterInterface
+{
+ const DOMAIN_SUFFIX = '+intl-icu';
+
+ /**
+ * Formats a localized message using rules defined by ICU MessageFormat.
+ *
+ * @see http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
+ */
+ public function formatIntl(string $message, string $locale, array $parameters = array()): string;
+}
diff --git a/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php b/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php
deleted file mode 100644
index 8f1ee797a0cc7..0000000000000
--- a/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php
+++ /dev/null
@@ -1,40 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\Component\Translation\Formatter;
-
-use Symfony\Component\Translation\Exception\InvalidArgumentException;
-
-/**
- * @author Guilherme Blanco
- * @author Abdellatif Ait boudad
- */
-class IntlMessageFormatter implements MessageFormatterInterface
-{
- /**
- * {@inheritdoc}
- */
- public function format($message, $locale, array $parameters = array())
- {
- try {
- $formatter = new \MessageFormatter($locale, $message);
- } catch (\Throwable $e) {
- throw new InvalidArgumentException(sprintf('Invalid message format (%s, error #%d).', intl_get_error_message(), intl_get_error_code()), 0, $e);
- }
-
- $message = $formatter->format($parameters);
- if (U_ZERO_ERROR !== $formatter->getErrorCode()) {
- throw new InvalidArgumentException(sprintf('Unable to format message ( %s, error #%s).', $formatter->getErrorMessage(), $formatter->getErrorCode()));
- }
-
- return $message;
- }
-}
diff --git a/src/Symfony/Component/Translation/Formatter/MessageFormatter.php b/src/Symfony/Component/Translation/Formatter/MessageFormatter.php
index 1119c356f5afe..11f766c8cd216 100644
--- a/src/Symfony/Component/Translation/Formatter/MessageFormatter.php
+++ b/src/Symfony/Component/Translation/Formatter/MessageFormatter.php
@@ -19,14 +19,15 @@
/**
* @author Abdellatif Ait boudad
*/
-class MessageFormatter implements MessageFormatterInterface, ChoiceMessageFormatterInterface
+class MessageFormatter implements MessageFormatterInterface, IntlFormatterInterface, ChoiceMessageFormatterInterface
{
private $translator;
+ private $intlFormatter;
/**
* @param TranslatorInterface|null $translator An identity translator to use as selector for pluralization
*/
- public function __construct($translator = null)
+ public function __construct($translator = null, IntlFormatterInterface $intlFormatter = null)
{
if ($translator instanceof MessageSelector) {
$translator = new IdentityTranslator($translator);
@@ -35,6 +36,7 @@ public function __construct($translator = null)
}
$this->translator = $translator ?? new IdentityTranslator();
+ $this->intlFormatter = $intlFormatter ?? new IntlFormatter();
}
/**
@@ -49,6 +51,14 @@ public function format($message, $locale, array $parameters = array())
return strtr($message, $parameters);
}
+ /**
+ * {@inheritdoc}
+ */
+ public function formatIntl(string $message, string $locale, array $parameters = array()): string
+ {
+ return $this->intlFormatter->formatIntl($message, $locale, $parameters);
+ }
+
/**
* {@inheritdoc}
*
diff --git a/src/Symfony/Component/Translation/Tests/Formatter/FallbackFormatterTest.php b/src/Symfony/Component/Translation/Tests/Formatter/FallbackFormatterTest.php
deleted file mode 100644
index 7b3cba109afb1..0000000000000
--- a/src/Symfony/Component/Translation/Tests/Formatter/FallbackFormatterTest.php
+++ /dev/null
@@ -1,213 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Symfony\Component\Translation\Tests\Formatter;
-
-use Symfony\Component\Translation\Exception\InvalidArgumentException;
-use Symfony\Component\Translation\Exception\LogicException;
-use Symfony\Component\Translation\Formatter\ChoiceMessageFormatterInterface;
-use Symfony\Component\Translation\Formatter\FallbackFormatter;
-use Symfony\Component\Translation\Formatter\MessageFormatterInterface;
-
-class FallbackFormatterTest extends \PHPUnit\Framework\TestCase
-{
- public function testFormatSame()
- {
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->once())
- ->method('format')
- ->with('foo', 'en', array(2))
- ->willReturn('foo');
-
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->once())
- ->method('format')
- ->with('foo', 'en', array(2))
- ->willReturn('bar');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->format('foo', 'en', array(2)));
- }
-
- public function testFormatDifferent()
- {
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->once())
- ->method('format')
- ->with('foo', 'en', array(2))
- ->willReturn('new value');
-
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->exactly(0))
- ->method('format')
- ->withAnyParameters();
-
- $this->assertEquals('new value', (new FallbackFormatter($first, $second))->format('foo', 'en', array(2)));
- }
-
- public function testFormatException()
- {
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->once())
- ->method('format')
- ->willThrowException(new InvalidArgumentException());
-
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->once())
- ->method('format')
- ->with('foo', 'en', array(2))
- ->willReturn('bar');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->format('foo', 'en', array(2)));
- }
-
- public function testFormatExceptionUnknown()
- {
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->once())
- ->method('format')
- ->willThrowException(new \RuntimeException());
-
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->exactly(0))
- ->method('format');
-
- $this->expectException(\RuntimeException::class);
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->format('foo', 'en', array(2)));
- }
-
- public function testChoiceFormatSame()
- {
- $first = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $first
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('foo');
-
- $second = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $second
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('bar');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-
- public function testChoiceFormatDifferent()
- {
- $first = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $first
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('new value');
-
- $second = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $second
- ->expects($this->exactly(0))
- ->method('choiceFormat')
- ->withAnyParameters()
- ->willReturn('bar');
-
- $this->assertEquals('new value', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-
- public function testChoiceFormatException()
- {
- $first = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $first
- ->expects($this->once())
- ->method('choiceFormat')
- ->willThrowException(new InvalidArgumentException());
-
- $second = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $second
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('bar');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-
- public function testChoiceFormatOnlyFirst()
- {
- // Implements both interfaces
- $first = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $first
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('bar');
-
- // Implements only one interface
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->exactly(0))
- ->method('format')
- ->withAnyParameters()
- ->willReturn('error');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-
- public function testChoiceFormatOnlySecond()
- {
- // Implements only one interface
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->exactly(0))
- ->method('format')
- ->withAnyParameters()
- ->willReturn('error');
-
- // Implements both interfaces
- $second = $this->getMockBuilder(SuperFormatterInterface::class)->setMethods(array('format', 'choiceFormat'))->getMock();
- $second
- ->expects($this->once())
- ->method('choiceFormat')
- ->with('foo', 1, 'en', array(2))
- ->willReturn('bar');
-
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-
- public function testChoiceFormatNoChoiceFormat()
- {
- // Implements only one interface
- $first = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $first
- ->expects($this->exactly(0))
- ->method('format');
-
- // Implements both interfaces
- $second = $this->getMockBuilder(MessageFormatterInterface::class)->setMethods(array('format'))->getMock();
- $second
- ->expects($this->exactly(0))
- ->method('format');
-
- $this->expectException(LogicException::class);
- $this->assertEquals('bar', (new FallbackFormatter($first, $second))->choiceFormat('foo', 1, 'en', array(2)));
- }
-}
-
-interface SuperFormatterInterface extends MessageFormatterInterface, ChoiceMessageFormatterInterface
-{
-}
diff --git a/src/Symfony/Component/Translation/Tests/Formatter/IntlMessageFormatterTest.php b/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php
similarity index 72%
rename from src/Symfony/Component/Translation/Tests/Formatter/IntlMessageFormatterTest.php
rename to src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php
index 7b5d89f8353e1..89eaa18f32b4e 100644
--- a/src/Symfony/Component/Translation/Tests/Formatter/IntlMessageFormatterTest.php
+++ b/src/Symfony/Component/Translation/Tests/Formatter/IntlFormatterTest.php
@@ -12,29 +12,26 @@
namespace Symfony\Component\Translation\Tests\Formatter;
use Symfony\Component\Translation\Exception\InvalidArgumentException;
-use Symfony\Component\Translation\Formatter\IntlMessageFormatter;
+use Symfony\Component\Translation\Formatter\IntlFormatter;
+use Symfony\Component\Translation\Formatter\IntlFormatterInterface;
-class IntlMessageFormatterTest extends \PHPUnit\Framework\TestCase
+/**
+ * @requires extension intl
+ */
+class IntlFormatterTest extends \PHPUnit\Framework\TestCase
{
- protected function setUp()
- {
- if (!\extension_loaded('intl')) {
- $this->markTestSkipped('The Intl extension is not available.');
- }
- }
-
/**
* @dataProvider provideDataForFormat
*/
public function testFormat($expected, $message, $arguments)
{
- $this->assertEquals($expected, trim((new IntlMessageFormatter())->format($message, 'en', $arguments)));
+ $this->assertEquals($expected, trim((new IntlFormatter())->formatIntl($message, 'en', $arguments)));
}
public function testInvalidFormat()
{
$this->expectException(InvalidArgumentException::class);
- (new IntlMessageFormatter())->format('{foo', 'en', array(2));
+ (new IntlFormatter())->formatIntl('{foo', 'en', array(2));
}
public function testFormatWithNamedArguments()
@@ -62,7 +59,7 @@ public function testFormatWithNamedArguments()
other {{host} invites {guest} as one of the # people invited to their party.}}}}
_MSG_;
- $message = (new IntlMessageFormatter())->format($chooseMessage, 'en', array(
+ $message = (new IntlFormatter())->formatIntl($chooseMessage, 'en', array(
'gender_of_host' => 'male',
'num_guests' => 10,
'host' => 'Fabien',
@@ -87,4 +84,13 @@ public function provideDataForFormat()
),
);
}
+
+ public function testPercentsAndBracketsAreTrimmed()
+ {
+ $formatter = new IntlFormatter();
+ $this->assertInstanceof(IntlFormatterInterface::class, $formatter);
+ $this->assertSame('Hello Fab', $formatter->formatIntl('Hello {name}', 'en', array('name' => 'Fab')));
+ $this->assertSame('Hello Fab', $formatter->formatIntl('Hello {name}', 'en', array('%name%' => 'Fab')));
+ $this->assertSame('Hello Fab', $formatter->formatIntl('Hello {name}', 'en', array('{{ name }}' => 'Fab')));
+ }
}
diff --git a/src/Symfony/Component/Translation/Tests/TranslatorTest.php b/src/Symfony/Component/Translation/Tests/TranslatorTest.php
index d630a7491d4bc..bbc8ce2d21acd 100644
--- a/src/Symfony/Component/Translation/Tests/TranslatorTest.php
+++ b/src/Symfony/Component/Translation/Tests/TranslatorTest.php
@@ -540,6 +540,21 @@ public function getValidLocalesTests()
);
}
+ /**
+ * @requires extension intl
+ */
+ public function testIntlFormattedDomain()
+ {
+ $translator = new Translator('en');
+ $translator->addLoader('array', new ArrayLoader());
+
+ $translator->addResource('array', array('some_message' => 'Hello %name%'), 'en');
+ $this->assertSame('Hello Bob', $translator->trans('some_message', array('%name%' => 'Bob')));
+
+ $translator->addResource('array', array('some_message' => 'Hi {name}'), 'en', 'messages+intl-icu');
+ $this->assertSame('Hi Bob', $translator->trans('some_message', array('%name%' => 'Bob')));
+ }
+
/**
* @group legacy
*/
diff --git a/src/Symfony/Component/Translation/Translator.php b/src/Symfony/Component/Translation/Translator.php
index d8d27301f04df..eca5674e6753e 100644
--- a/src/Symfony/Component/Translation/Translator.php
+++ b/src/Symfony/Component/Translation/Translator.php
@@ -19,6 +19,7 @@
use Symfony\Component\Translation\Exception\NotFoundResourceException;
use Symfony\Component\Translation\Exception\RuntimeException;
use Symfony\Component\Translation\Formatter\ChoiceMessageFormatterInterface;
+use Symfony\Component\Translation\Formatter\IntlFormatterInterface;
use Symfony\Component\Translation\Formatter\MessageFormatter;
use Symfony\Component\Translation\Formatter\MessageFormatterInterface;
use Symfony\Component\Translation\Loader\LoaderInterface;
@@ -80,6 +81,8 @@ class Translator implements LegacyTranslatorInterface, TranslatorInterface, Tran
*/
private $parentLocales;
+ private $hasIntlFormatter;
+
/**
* @throws InvalidArgumentException If a locale contains invalid characters
*/
@@ -94,6 +97,7 @@ public function __construct(?string $locale, MessageFormatterInterface $formatte
$this->formatter = $formatter;
$this->cacheDir = $cacheDir;
$this->debug = $debug;
+ $this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface;
}
public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory)
@@ -196,7 +200,26 @@ public function trans($id, array $parameters = array(), $domain = null, $locale
$domain = 'messages';
}
- return $this->formatter->format($this->getCatalogue($locale)->get((string) $id, $domain), $locale, $parameters);
+ $id = (string) $id;
+ $catalogue = $this->getCatalogue($locale);
+ $locale = $catalogue->getLocale();
+ $intlDomain = $this->hasIntlFormatter ? $domain.IntlFormatterInterface::DOMAIN_SUFFIX : null;
+ while (true) {
+ if (null !== $intlDomain && $catalogue->defines($id, $intlDomain)) {
+ return $this->formatter->formatIntl($catalogue->get($id, $intlDomain), $locale, $parameters);
+ }
+ if ($catalogue->defines($id, $domain)) {
+ break;
+ }
+ if ($cat = $catalogue->getFallbackCatalogue()) {
+ $catalogue = $cat;
+ $locale = $catalogue->getLocale();
+ } else {
+ break;
+ }
+ }
+
+ return $this->formatter->format($catalogue->get($id, $domain), $locale, $parameters);
}
/**