feature #27399 [Translation] Added intl message formatter. (aitboudad, Nyholm)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[Translation] Added intl message formatter.

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | replaces #20007
| License       | MIT
| Doc PR        |

This PR will replace #20007 and continue the work from the original author.

I've have tried to address all comments made in the original PR.

Commits
-------

2a90931e52 cs
fb30c77659 Be more specific with what exception we catch
b1aa0047fd Only use the default translator if intl extension is loaded
f88153fa22 Updates according to feedback
597a15d7f7 Use FallbackFormatter instead of support for multiple formatters
2aa7181e15 Fixes according to feedback
a325a443ed Allow config for different domain specific formatters
b43fe21997 Add support for multiple formatters
c2b3dc0a90 [Translation] Added intl message formatter.
19e8e69979 use error
940d440e87 Make it a warning
This commit is contained in:
Fabien Potencier 2018-09-04 09:03:37 +02:00
commit baad3321f4
7 changed files with 429 additions and 2 deletions

View File

@ -697,7 +697,7 @@ class Configuration implements ConfigurationInterface
->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')

View File

@ -29,9 +29,15 @@
<tag name="monolog.logger" channel="translation" />
</service>
<service id="translator.formatter.default" class="Symfony\Component\Translation\Formatter\MessageFormatter">
<service id="translator.formatter.symfony" class="Symfony\Component\Translation\Formatter\MessageFormatter">
<argument type="service" id="identity_translator" />
</service>
<service id="translator.formatter.intl" class="Symfony\Component\Translation\Formatter\IntlMessageFormatter" public="false" />
<service id="translator.formatter.fallback" class="Symfony\Component\Translation\Formatter\FallbackFormatter" public="false">
<argument type="service" id="translator.formatter.intl" />
<argument type="service" id="translator.formatter.symfony" />
</service>
<service id="translator.formatter.default" alias="translator.formatter.fallback" />
<service id="translation.loader.php" class="Symfony\Component\Translation\Loader\PhpFileLoader">
<tag name="translation.loader" alias="php" />

View File

@ -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
-----

View File

@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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.'));
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <guilhermeblanco@hotmail.com>
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
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;
}
}

View File

@ -0,0 +1,213 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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
{
}

View File

@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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),
),
);
}
}