[Translation] allow using the ICU message format using domains with the "+intl-icu" suffix

This commit is contained in:
Nicolas Grekas 2018-10-22 19:14:16 +02:00
parent 8c24c35fe8
commit d95cc4d4c6
13 changed files with 158 additions and 355 deletions

View File

@ -699,7 +699,7 @@ class Configuration implements ConfigurationInterface
->defaultValue(array('en'))
->end()
->booleanNode('logging')->defaultValue(false)->end()
->scalarNode('formatter')->defaultValue(class_exists(\MessageFormatter::class) ? 'translator.formatter.default' : 'translator.formatter.symfony')->end()
->scalarNode('formatter')->defaultValue('translator.formatter.default')->end()
->scalarNode('default_path')
->info('The default path used to load translations')
->defaultValue('%kernel.project_dir%/translations')

View File

@ -29,15 +29,9 @@
<tag name="monolog.logger" channel="translation" />
</service>
<service id="translator.formatter.symfony" class="Symfony\Component\Translation\Formatter\MessageFormatter">
<service id="translator.formatter.default" 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

@ -189,7 +189,7 @@ class ConfigurationTest extends TestCase
'enabled' => !class_exists(FullStack::class),
'fallbacks' => array('en'),
'logging' => false,
'formatter' => \class_exists('MessageFormatter') ? 'translator.formatter.default' : 'translator.formatter.symfony',
'formatter' => 'translator.formatter.default',
'paths' => array(),
'default_path' => '%kernel.project_dir%/translations',
),

View File

@ -5,10 +5,11 @@ CHANGELOG
-----
* Started using ICU parent locales as fallback locales.
* allow using the ICU message format using domains with the "+intl-icu" suffix
* deprecated `Translator::transChoice()` in favor of using `Translator::trans()` with a `%count%` parameter
* deprecated `TranslatorInterface` in favor of `Symfony\Contracts\Translation\TranslatorInterface`
* deprecated `MessageSelector`, `Interval` and `PluralizationRules`; use `IdentityTranslator` instead
* Added `IntlMessageFormatter` and `FallbackMessageFormatter`
* Added `IntlFormatter` and `IntlFormatterInterface`
* added support for multiple files and directories in `XliffLintCommand`
* Marked `Translator::getFallbackLocales()` and `TranslationDataCollector::getFallbackLocales()` as internal

View File

@ -1,77 +0,0 @@
<?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,55 @@
<?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;
/**
* @author Guilherme Blanco <guilhermeblanco@hotmail.com>
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
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;
}
}

View File

@ -0,0 +1,29 @@
<?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;
/**
* Formats ICU message patterns.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
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;
}

View File

@ -1,40 +0,0 @@
<?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

@ -19,14 +19,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @author Abdellatif Ait boudad <a.aitboudad@gmail.com>
*/
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 @@ class MessageFormatter implements MessageFormatterInterface, ChoiceMessageFormat
}
$this->translator = $translator ?? new IdentityTranslator();
$this->intlFormatter = $intlFormatter ?? new IntlFormatter();
}
/**
@ -49,6 +51,14 @@ class MessageFormatter implements MessageFormatterInterface, ChoiceMessageFormat
return strtr($message, $parameters);
}
/**
* {@inheritdoc}
*/
public function formatIntl(string $message, string $locale, array $parameters = array()): string
{
return $this->intlFormatter->formatIntl($message, $locale, $parameters);
}
/**
* {@inheritdoc}
*

View File

@ -1,213 +0,0 @@
<?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

@ -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 @@ class IntlMessageFormatterTest extends \PHPUnit\Framework\TestCase
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 @@ _MSG_;
),
);
}
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')));
}
}

View File

@ -540,6 +540,21 @@ class TranslatorTest extends TestCase
);
}
/**
* @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
*/

View File

@ -19,6 +19,7 @@ use Symfony\Component\Translation\Exception\LogicException;
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 @@ class Translator implements LegacyTranslatorInterface, TranslatorInterface, Tran
$this->formatter = $formatter;
$this->cacheDir = $cacheDir;
$this->debug = $debug;
$this->hasIntlFormatter = $formatter instanceof IntlFormatterInterface;
}
public function setConfigCacheFactory(ConfigCacheFactoryInterface $configCacheFactory)
@ -196,7 +200,26 @@ class Translator implements LegacyTranslatorInterface, TranslatorInterface, Tran
$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);
}
/**