diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6625570009b4..9ba2d0ad45d0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -697,7 +697,7 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode) ->defaultValue(array('en')) ->end() ->booleanNode('logging')->defaultValue(false)->end() - ->scalarNode('formatter')->defaultValue('translator.formatter.default')->end() + ->scalarNode('formatter')->defaultValue(class_exists(\MessageFormatter::class) ? 'translator.formatter.default' : 'translator.formatter.symfony')->end() ->scalarNode('default_path') ->info('The default path used to load translations') ->defaultValue('%kernel.project_dir%/translations') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml index 42434b62d551..c37e556d7fad 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation.xml @@ -29,9 +29,15 @@ - + + + + + + + diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 6397c3c23219..9f9fa5abf374 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Started using ICU parent locales as fallback locales. * deprecated `TranslatorInterface` in favor of `Symfony\Contracts\Translation\TranslatorInterface` * deprecated `MessageSelector`, `Interval` and `PluralizationRules`; use `IdentityTranslator` instead + * Added `IntlMessageFormatter` and `FallbackMessageFormatter` 4.1.0 ----- diff --git a/src/Symfony/Component/Translation/Formatter/FallbackFormatter.php b/src/Symfony/Component/Translation/Formatter/FallbackFormatter.php new file mode 100644 index 000000000000..fac98a4a52de --- /dev/null +++ b/src/Symfony/Component/Translation/Formatter/FallbackFormatter.php @@ -0,0 +1,77 @@ + + * + * 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; +use Symfony\Component\Translation\Exception\LogicException; + +class FallbackFormatter implements MessageFormatterInterface, ChoiceMessageFormatterInterface +{ + /** + * @var MessageFormatterInterface|ChoiceMessageFormatterInterface + */ + private $firstFormatter; + + /** + * @var MessageFormatterInterface|ChoiceMessageFormatterInterface + */ + private $secondFormatter; + + public function __construct(MessageFormatterInterface $firstFormatter, MessageFormatterInterface $secondFormatter) + { + $this->firstFormatter = $firstFormatter; + $this->secondFormatter = $secondFormatter; + } + + public function format($message, $locale, array $parameters = array()) + { + try { + $result = $this->firstFormatter->format($message, $locale, $parameters); + } catch (InvalidArgumentException $e) { + return $this->secondFormatter->format($message, $locale, $parameters); + } + + if ($result === $message) { + $result = $this->secondFormatter->format($message, $locale, $parameters); + } + + return $result; + } + + public function choiceFormat($message, $number, $locale, array $parameters = array()) + { + // If both support ChoiceMessageFormatterInterface + if ($this->firstFormatter instanceof ChoiceMessageFormatterInterface && $this->secondFormatter instanceof ChoiceMessageFormatterInterface) { + try { + $result = $this->firstFormatter->choiceFormat($message, $number, $locale, $parameters); + } catch (InvalidArgumentException $e) { + return $this->secondFormatter->choiceFormat($message, $number, $locale, $parameters); + } + + if ($result === $message) { + $result = $this->secondFormatter->choiceFormat($message, $number, $locale, $parameters); + } + + return $result; + } + + if ($this->firstFormatter instanceof ChoiceMessageFormatterInterface) { + return $this->firstFormatter->choiceFormat($message, $number, $locale, $parameters); + } + + if ($this->secondFormatter instanceof ChoiceMessageFormatterInterface) { + return $this->secondFormatter->choiceFormat($message, $number, $locale, $parameters); + } + + throw new LogicException(sprintf('No formatters support plural translations.')); + } +} diff --git a/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php b/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php new file mode 100644 index 000000000000..8f1ee797a0cc --- /dev/null +++ b/src/Symfony/Component/Translation/Formatter/IntlMessageFormatter.php @@ -0,0 +1,40 @@ + + * + * 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/Tests/Formatter/FallbackFormatterTest.php b/src/Symfony/Component/Translation/Tests/Formatter/FallbackFormatterTest.php new file mode 100644 index 000000000000..7b3cba109afb --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Formatter/FallbackFormatterTest.php @@ -0,0 +1,213 @@ + + * + * 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/IntlMessageFormatterTest.php new file mode 100644 index 000000000000..7b5d89f8353e --- /dev/null +++ b/src/Symfony/Component/Translation/Tests/Formatter/IntlMessageFormatterTest.php @@ -0,0 +1,90 @@ + + * + * 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\Formatter\IntlMessageFormatter; + +class IntlMessageFormatterTest 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))); + } + + public function testInvalidFormat() + { + $this->expectException(InvalidArgumentException::class); + (new IntlMessageFormatter())->format('{foo', 'en', array(2)); + } + + public function testFormatWithNamedArguments() + { + if (version_compare(INTL_ICU_VERSION, '4.8', '<')) { + $this->markTestSkipped('Format with named arguments can only be run with ICU 4.8 or higher and PHP >= 5.5'); + } + + $chooseMessage = <<<'_MSG_' +{gender_of_host, select, + female {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to her party.} + =2 {{host} invites {guest} and one other person to her party.} + other {{host} invites {guest} as one of the # people invited to her party.}}} + male {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to his party.} + =2 {{host} invites {guest} and one other person to his party.} + other {{host} invites {guest} as one of the # people invited to his party.}}} + other {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to their party.} + =2 {{host} invites {guest} and one other person to their party.} + other {{host} invites {guest} as one of the # people invited to their party.}}}} +_MSG_; + + $message = (new IntlMessageFormatter())->format($chooseMessage, 'en', array( + 'gender_of_host' => 'male', + 'num_guests' => 10, + 'host' => 'Fabien', + 'guest' => 'Guilherme', + )); + + $this->assertEquals('Fabien invites Guilherme as one of the 9 people invited to his party.', $message); + } + + public function provideDataForFormat() + { + return array( + array( + 'There is one apple', + 'There is one apple', + array(), + ), + array( + '4,560 monkeys on 123 trees make 37.073 monkeys per tree', + '{0,number,integer} monkeys on {1,number,integer} trees make {2,number} monkeys per tree', + array(4560, 123, 4560 / 123), + ), + ); + } +}