├── CHANGELOG.md ├── README.md ├── TranslatableInterface.php ├── LocaleAwareInterface.php ├── LICENSE ├── composer.json ├── TranslatorInterface.php ├── TranslatorTrait.php └── Test └── TranslatorTest.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Symfony Translation Contracts 2 | ============================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /TranslatableInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | */ 17 | interface TranslatableInterface 18 | { 19 | public function trans(TranslatorInterface $translator, ?string $locale = null): string; 20 | } 21 | -------------------------------------------------------------------------------- /LocaleAwareInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | interface LocaleAwareInterface 15 | { 16 | /** 17 | * Sets the current locale. 18 | * 19 | * @return void 20 | * 21 | * @throws \InvalidArgumentException If the locale contains invalid characters 22 | */ 23 | public function setLocale(string $locale); 24 | 25 | /** 26 | * Returns the current locale. 27 | */ 28 | public function getLocale(): string; 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/translation-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to translation", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1" 20 | }, 21 | "autoload": { 22 | "psr-4": { "Symfony\\Contracts\\Translation\\": "" }, 23 | "exclude-from-classmap": [ 24 | "/Test/" 25 | ] 26 | }, 27 | "minimum-stability": "dev", 28 | "extra": { 29 | "branch-alias": { 30 | "dev-main": "3.6-dev" 31 | }, 32 | "thanks": { 33 | "name": "symfony/contracts", 34 | "url": "https://github.com/symfony/contracts" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /TranslatorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | interface TranslatorInterface 18 | { 19 | /** 20 | * Translates the given message. 21 | * 22 | * When a number is provided as a parameter named "%count%", the message is parsed for plural 23 | * forms and a translation is chosen according to this number using the following rules: 24 | * 25 | * Given a message with different plural translations separated by a 26 | * pipe (|), this method returns the correct portion of the message based 27 | * on the given number, locale and the pluralization rules in the message 28 | * itself. 29 | * 30 | * The message supports two different types of pluralization rules: 31 | * 32 | * interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples 33 | * indexed: There is one apple|There are %count% apples 34 | * 35 | * The indexed solution can also contain labels (e.g. one: There is one apple). 36 | * This is purely for making the translations more clear - it does not 37 | * affect the functionality. 38 | * 39 | * The two methods can also be mixed: 40 | * {0} There are no apples|one: There is one apple|more: There are %count% apples 41 | * 42 | * An interval can represent a finite set of numbers: 43 | * {1,2,3,4} 44 | * 45 | * An interval can represent numbers between two numbers: 46 | * [1, +Inf] 47 | * ]-1,2[ 48 | * 49 | * The left delimiter can be [ (inclusive) or ] (exclusive). 50 | * The right delimiter can be [ (exclusive) or ] (inclusive). 51 | * Beside numbers, you can use -Inf and +Inf for the infinite. 52 | * 53 | * @see https://en.wikipedia.org/wiki/ISO_31-11 54 | * 55 | * @param string $id The message id (may also be an object that can be cast to string) 56 | * @param array $parameters An array of parameters for the message 57 | * @param string|null $domain The domain for the message or null to use the default 58 | * @param string|null $locale The locale or null to use the default 59 | * 60 | * @throws \InvalidArgumentException If the locale contains invalid characters 61 | */ 62 | public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string; 63 | 64 | /** 65 | * Returns the default locale. 66 | */ 67 | public function getLocale(): string; 68 | } 69 | -------------------------------------------------------------------------------- /TranslatorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * A trait to help implement TranslatorInterface and LocaleAwareInterface. 18 | * 19 | * @author Fabien Potencier 20 | */ 21 | trait TranslatorTrait 22 | { 23 | private ?string $locale = null; 24 | 25 | /** 26 | * @return void 27 | */ 28 | public function setLocale(string $locale) 29 | { 30 | $this->locale = $locale; 31 | } 32 | 33 | public function getLocale(): string 34 | { 35 | return $this->locale ?: (class_exists(\Locale::class) ? \Locale::getDefault() : 'en'); 36 | } 37 | 38 | public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string 39 | { 40 | if (null === $id || '' === $id) { 41 | return ''; 42 | } 43 | 44 | foreach ($parameters as $k => $v) { 45 | if ($v instanceof TranslatableInterface) { 46 | $parameters[$k] = $v->trans($this, $locale); 47 | } 48 | } 49 | 50 | if (!isset($parameters['%count%']) || !is_numeric($parameters['%count%'])) { 51 | return strtr($id, $parameters); 52 | } 53 | 54 | $number = (float) $parameters['%count%']; 55 | $locale = $locale ?: $this->getLocale(); 56 | 57 | $parts = []; 58 | if (preg_match('/^\|++$/', $id)) { 59 | $parts = explode('|', $id); 60 | } elseif (preg_match_all('/(?:\|\||[^\|])++/', $id, $matches)) { 61 | $parts = $matches[0]; 62 | } 63 | 64 | $intervalRegexp = <<<'EOF' 65 | /^(?P 66 | ({\s* 67 | (\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*) 68 | \s*}) 69 | 70 | | 71 | 72 | (?P[\[\]]) 73 | \s* 74 | (?P-Inf|\-?\d+(\.\d+)?) 75 | \s*,\s* 76 | (?P\+?Inf|\-?\d+(\.\d+)?) 77 | \s* 78 | (?P[\[\]]) 79 | )\s*(?P.*?)$/xs 80 | EOF; 81 | 82 | $standardRules = []; 83 | foreach ($parts as $part) { 84 | $part = trim(str_replace('||', '|', $part)); 85 | 86 | // try to match an explicit rule, then fallback to the standard ones 87 | if (preg_match($intervalRegexp, $part, $matches)) { 88 | if ($matches[2]) { 89 | foreach (explode(',', $matches[3]) as $n) { 90 | if ($number == $n) { 91 | return strtr($matches['message'], $parameters); 92 | } 93 | } 94 | } else { 95 | $leftNumber = '-Inf' === $matches['left'] ? -\INF : (float) $matches['left']; 96 | $rightNumber = is_numeric($matches['right']) ? (float) $matches['right'] : \INF; 97 | 98 | if (('[' === $matches['left_delimiter'] ? $number >= $leftNumber : $number > $leftNumber) 99 | && (']' === $matches['right_delimiter'] ? $number <= $rightNumber : $number < $rightNumber) 100 | ) { 101 | return strtr($matches['message'], $parameters); 102 | } 103 | } 104 | } elseif (preg_match('/^\w+\:\s*(.*?)$/', $part, $matches)) { 105 | $standardRules[] = $matches[1]; 106 | } else { 107 | $standardRules[] = $part; 108 | } 109 | } 110 | 111 | $position = $this->getPluralizationRule($number, $locale); 112 | 113 | if (!isset($standardRules[$position])) { 114 | // when there's exactly one rule given, and that rule is a standard 115 | // rule, use this rule 116 | if (1 === \count($parts) && isset($standardRules[0])) { 117 | return strtr($standardRules[0], $parameters); 118 | } 119 | 120 | $message = \sprintf('Unable to choose a translation for "%s" with locale "%s" for value "%d". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %%count%% apples").', $id, $locale, $number); 121 | 122 | if (class_exists(InvalidArgumentException::class)) { 123 | throw new InvalidArgumentException($message); 124 | } 125 | 126 | throw new \InvalidArgumentException($message); 127 | } 128 | 129 | return strtr($standardRules[$position], $parameters); 130 | } 131 | 132 | /** 133 | * Returns the plural position to use for the given locale and number. 134 | * 135 | * The plural rules are derived from code of the Zend Framework (2010-09-25), 136 | * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd). 137 | * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) 138 | */ 139 | private function getPluralizationRule(float $number, string $locale): int 140 | { 141 | $number = abs($number); 142 | 143 | return match ('pt_BR' !== $locale && 'en_US_POSIX' !== $locale && \strlen($locale) > 3 ? substr($locale, 0, strrpos($locale, '_')) : $locale) { 144 | 'af', 145 | 'bn', 146 | 'bg', 147 | 'ca', 148 | 'da', 149 | 'de', 150 | 'el', 151 | 'en', 152 | 'en_US_POSIX', 153 | 'eo', 154 | 'es', 155 | 'et', 156 | 'eu', 157 | 'fa', 158 | 'fi', 159 | 'fo', 160 | 'fur', 161 | 'fy', 162 | 'gl', 163 | 'gu', 164 | 'ha', 165 | 'he', 166 | 'hu', 167 | 'is', 168 | 'it', 169 | 'ku', 170 | 'lb', 171 | 'ml', 172 | 'mn', 173 | 'mr', 174 | 'nah', 175 | 'nb', 176 | 'ne', 177 | 'nl', 178 | 'nn', 179 | 'no', 180 | 'oc', 181 | 'om', 182 | 'or', 183 | 'pa', 184 | 'pap', 185 | 'ps', 186 | 'pt', 187 | 'so', 188 | 'sq', 189 | 'sv', 190 | 'sw', 191 | 'ta', 192 | 'te', 193 | 'tk', 194 | 'ur', 195 | 'zu' => (1 == $number) ? 0 : 1, 196 | 'am', 197 | 'bh', 198 | 'fil', 199 | 'fr', 200 | 'gun', 201 | 'hi', 202 | 'hy', 203 | 'ln', 204 | 'mg', 205 | 'nso', 206 | 'pt_BR', 207 | 'ti', 208 | 'wa' => ($number < 2) ? 0 : 1, 209 | 'be', 210 | 'bs', 211 | 'hr', 212 | 'ru', 213 | 'sh', 214 | 'sr', 215 | 'uk' => ((1 == $number % 10) && (11 != $number % 100)) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2), 216 | 'cs', 217 | 'sk' => (1 == $number) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2), 218 | 'ga' => (1 == $number) ? 0 : ((2 == $number) ? 1 : 2), 219 | 'lt' => ((1 == $number % 10) && (11 != $number % 100)) ? 0 : ((($number % 10 >= 2) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2), 220 | 'sl' => (1 == $number % 100) ? 0 : ((2 == $number % 100) ? 1 : (((3 == $number % 100) || (4 == $number % 100)) ? 2 : 3)), 221 | 'mk' => (1 == $number % 10) ? 0 : 1, 222 | 'mt' => (1 == $number) ? 0 : (((0 == $number) || (($number % 100 > 1) && ($number % 100 < 11))) ? 1 : ((($number % 100 > 10) && ($number % 100 < 20)) ? 2 : 3)), 223 | 'lv' => (0 == $number) ? 0 : (((1 == $number % 10) && (11 != $number % 100)) ? 1 : 2), 224 | 'pl' => (1 == $number) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 12) || ($number % 100 > 14))) ? 1 : 2), 225 | 'cy' => (1 == $number) ? 0 : ((2 == $number) ? 1 : (((8 == $number) || (11 == $number)) ? 2 : 3)), 226 | 'ro' => (1 == $number) ? 0 : (((0 == $number) || (($number % 100 > 0) && ($number % 100 < 20))) ? 1 : 2), 227 | 'ar' => (0 == $number) ? 0 : ((1 == $number) ? 1 : ((2 == $number) ? 2 : ((($number % 100 >= 3) && ($number % 100 <= 10)) ? 3 : ((($number % 100 >= 11) && ($number % 100 <= 99)) ? 4 : 5)))), 228 | default => 0, 229 | }; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Test/TranslatorTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use PHPUnit\Framework\Attributes\RequiresPhpExtension; 16 | use PHPUnit\Framework\TestCase; 17 | use Symfony\Component\Translation\TranslatableMessage; 18 | use Symfony\Contracts\Translation\TranslatorInterface; 19 | use Symfony\Contracts\Translation\TranslatorTrait; 20 | 21 | /** 22 | * Test should cover all languages mentioned on http://translate.sourceforge.net/wiki/l10n/pluralforms 23 | * and Plural forms mentioned on http://www.gnu.org/software/gettext/manual/gettext.html#Plural-forms. 24 | * 25 | * See also https://developer.mozilla.org/en/Localization_and_Plurals which mentions 15 rules having a maximum of 6 forms. 26 | * The mozilla code is also interesting to check for. 27 | * 28 | * As mentioned by chx http://drupal.org/node/1273968 we can cover all by testing number from 0 to 199 29 | * 30 | * The goal to cover all languages is to far fetched so this test case is smaller. 31 | * 32 | * @author Clemens Tolboom clemens@build2be.nl 33 | */ 34 | class TranslatorTest extends TestCase 35 | { 36 | private string $defaultLocale; 37 | 38 | protected function setUp(): void 39 | { 40 | $this->defaultLocale = \Locale::getDefault(); 41 | \Locale::setDefault('en'); 42 | } 43 | 44 | protected function tearDown(): void 45 | { 46 | \Locale::setDefault($this->defaultLocale); 47 | } 48 | 49 | public function getTranslator(): TranslatorInterface 50 | { 51 | return new class implements TranslatorInterface { 52 | use TranslatorTrait; 53 | }; 54 | } 55 | 56 | /** 57 | * @dataProvider getTransTests 58 | */ 59 | #[DataProvider('getTransTests')] 60 | public function testTrans($expected, $id, $parameters) 61 | { 62 | $translator = $this->getTranslator(); 63 | 64 | $this->assertEquals($expected, $translator->trans($id, $parameters)); 65 | } 66 | 67 | /** 68 | * @dataProvider getTransChoiceTests 69 | */ 70 | #[DataProvider('getTransChoiceTests')] 71 | public function testTransChoiceWithExplicitLocale($expected, $id, $number) 72 | { 73 | $translator = $this->getTranslator(); 74 | 75 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 76 | } 77 | 78 | /** 79 | * @requires extension intl 80 | * 81 | * @dataProvider getTransChoiceTests 82 | */ 83 | #[DataProvider('getTransChoiceTests')] 84 | #[RequiresPhpExtension('intl')] 85 | public function testTransChoiceWithDefaultLocale($expected, $id, $number) 86 | { 87 | $translator = $this->getTranslator(); 88 | 89 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 90 | } 91 | 92 | /** 93 | * @dataProvider getTransChoiceTests 94 | */ 95 | #[DataProvider('getTransChoiceTests')] 96 | public function testTransChoiceWithEnUsPosix($expected, $id, $number) 97 | { 98 | $translator = $this->getTranslator(); 99 | $translator->setLocale('en_US_POSIX'); 100 | 101 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 102 | } 103 | 104 | public function testGetSetLocale() 105 | { 106 | $translator = $this->getTranslator(); 107 | 108 | $this->assertEquals('en', $translator->getLocale()); 109 | } 110 | 111 | /** 112 | * @requires extension intl 113 | */ 114 | #[RequiresPhpExtension('intl')] 115 | public function testGetLocaleReturnsDefaultLocaleIfNotSet() 116 | { 117 | $translator = $this->getTranslator(); 118 | 119 | \Locale::setDefault('pt_BR'); 120 | $this->assertEquals('pt_BR', $translator->getLocale()); 121 | 122 | \Locale::setDefault('en'); 123 | $this->assertEquals('en', $translator->getLocale()); 124 | } 125 | 126 | public static function getTransTests() 127 | { 128 | yield ['Symfony is great!', 'Symfony is great!', []]; 129 | yield ['Symfony is awesome!', 'Symfony is %what%!', ['%what%' => 'awesome']]; 130 | 131 | if (class_exists(TranslatableMessage::class)) { 132 | yield ['He said "Symfony is awesome!".', 'He said "%what%".', ['%what%' => new TranslatableMessage('Symfony is %what%!', ['%what%' => 'awesome'])]]; 133 | } 134 | } 135 | 136 | public static function getTransChoiceTests() 137 | { 138 | return [ 139 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 140 | ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], 141 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 142 | ['There are 0 apples', 'There is 1 apple|There are %count% apples', 0], 143 | ['There is 1 apple', 'There is 1 apple|There are %count% apples', 1], 144 | ['There are 10 apples', 'There is 1 apple|There are %count% apples', 10], 145 | // custom validation messages may be coded with a fixed value 146 | ['There are 2 apples', 'There are 2 apples', 2], 147 | ]; 148 | } 149 | 150 | /** 151 | * @dataProvider getInterval 152 | */ 153 | #[DataProvider('getInterval')] 154 | public function testInterval($expected, $number, $interval) 155 | { 156 | $translator = $this->getTranslator(); 157 | 158 | $this->assertEquals($expected, $translator->trans($interval.' foo|[1,Inf[ bar', ['%count%' => $number])); 159 | } 160 | 161 | public static function getInterval() 162 | { 163 | return [ 164 | ['foo', 3, '{1,2, 3 ,4}'], 165 | ['bar', 10, '{1,2, 3 ,4}'], 166 | ['bar', 3, '[1,2]'], 167 | ['foo', 1, '[1,2]'], 168 | ['foo', 2, '[1,2]'], 169 | ['bar', 1, ']1,2['], 170 | ['bar', 2, ']1,2['], 171 | ['foo', log(0), '[-Inf,2['], 172 | ['foo', -log(0), '[-2,+Inf]'], 173 | ]; 174 | } 175 | 176 | /** 177 | * @dataProvider getChooseTests 178 | */ 179 | #[DataProvider('getChooseTests')] 180 | public function testChoose($expected, $id, $number, $locale = null) 181 | { 182 | $translator = $this->getTranslator(); 183 | 184 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number], null, $locale)); 185 | } 186 | 187 | public function testReturnMessageIfExactlyOneStandardRuleIsGiven() 188 | { 189 | $translator = $this->getTranslator(); 190 | 191 | $this->assertEquals('There are two apples', $translator->trans('There are two apples', ['%count%' => 2])); 192 | } 193 | 194 | /** 195 | * @dataProvider getNonMatchingMessages 196 | */ 197 | #[DataProvider('getNonMatchingMessages')] 198 | public function testThrowExceptionIfMatchingMessageCannotBeFound($id, $number) 199 | { 200 | $translator = $this->getTranslator(); 201 | 202 | $this->expectException(\InvalidArgumentException::class); 203 | 204 | $translator->trans($id, ['%count%' => $number]); 205 | } 206 | 207 | public static function getNonMatchingMessages() 208 | { 209 | return [ 210 | ['{0} There are no apples|{1} There is one apple', 2], 211 | ['{1} There is one apple|]1,Inf] There are %count% apples', 0], 212 | ['{1} There is one apple|]2,Inf] There are %count% apples', 2], 213 | ['{0} There are no apples|There is one apple', 2], 214 | ]; 215 | } 216 | 217 | public static function getChooseTests() 218 | { 219 | return [ 220 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 221 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 222 | ['There are no apples', '{0}There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 223 | 224 | ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], 225 | 226 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 227 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf]There are %count% apples', 10], 228 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 229 | 230 | ['There are 0 apples', 'There is one apple|There are %count% apples', 0], 231 | ['There is one apple', 'There is one apple|There are %count% apples', 1], 232 | ['There are 10 apples', 'There is one apple|There are %count% apples', 10], 233 | 234 | ['There are 0 apples', 'one: There is one apple|more: There are %count% apples', 0], 235 | ['There is one apple', 'one: There is one apple|more: There are %count% apples', 1], 236 | ['There are 10 apples', 'one: There is one apple|more: There are %count% apples', 10], 237 | 238 | ['There are no apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 0], 239 | ['There is one apple', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 1], 240 | ['There are 10 apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 10], 241 | 242 | ['', '{0}|{1} There is one apple|]1,Inf] There are %count% apples', 0], 243 | ['', '{0} There are no apples|{1}|]1,Inf] There are %count% apples', 1], 244 | 245 | // Indexed only tests which are Gettext PoFile* compatible strings. 246 | ['There are 0 apples', 'There is one apple|There are %count% apples', 0], 247 | ['There is one apple', 'There is one apple|There are %count% apples', 1], 248 | ['There are 2 apples', 'There is one apple|There are %count% apples', 2], 249 | 250 | // Tests for float numbers 251 | ['There is almost one apple', '{0} There are no apples|]0,1[ There is almost one apple|{1} There is one apple|[1,Inf] There is more than one apple', 0.7], 252 | ['There is one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1], 253 | ['There is more than one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1.7], 254 | ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], 255 | ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0.0], 256 | ['There are no apples', '{0.0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], 257 | 258 | // Test texts with new-lines 259 | // with double-quotes and \n in id & double-quotes and actual newlines in text 260 | ["This is a text with a\n new-line in it. Selector = 0.", '{0}This is a text with a 261 | new-line in it. Selector = 0.|{1}This is a text with a 262 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 263 | new-line in it. Selector > 1.', 0], 264 | // with double-quotes and \n in id and single-quotes and actual newlines in text 265 | ["This is a text with a\n new-line in it. Selector = 1.", '{0}This is a text with a 266 | new-line in it. Selector = 0.|{1}This is a text with a 267 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 268 | new-line in it. Selector > 1.', 1], 269 | ["This is a text with a\n new-line in it. Selector > 1.", '{0}This is a text with a 270 | new-line in it. Selector = 0.|{1}This is a text with a 271 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 272 | new-line in it. Selector > 1.', 5], 273 | // with double-quotes and id split across lines 274 | ['This is a text with a 275 | new-line in it. Selector = 1.', '{0}This is a text with a 276 | new-line in it. Selector = 0.|{1}This is a text with a 277 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 278 | new-line in it. Selector > 1.', 1], 279 | // with single-quotes and id split across lines 280 | ['This is a text with a 281 | new-line in it. Selector > 1.', '{0}This is a text with a 282 | new-line in it. Selector = 0.|{1}This is a text with a 283 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 284 | new-line in it. Selector > 1.', 5], 285 | // with single-quotes and \n in text 286 | ['This is a text with a\nnew-line in it. Selector = 0.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 0], 287 | // with double-quotes and id split across lines 288 | ["This is a text with a\nnew-line in it. Selector = 1.", "{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.", 1], 289 | // escape pipe 290 | ['This is a text with | in it. Selector = 0.', '{0}This is a text with || in it. Selector = 0.|{1}This is a text with || in it. Selector = 1.', 0], 291 | // Empty plural set (2 plural forms) from a .PO file 292 | ['', '|', 1], 293 | // Empty plural set (3 plural forms) from a .PO file 294 | ['', '||', 1], 295 | 296 | // Floating values 297 | ['1.5 liters', '%count% liter|%count% liters', 1.5], 298 | ['1.5 litre', '%count% litre|%count% litres', 1.5, 'fr'], 299 | 300 | // Negative values 301 | ['-1 degree', '%count% degree|%count% degrees', -1], 302 | ['-1 degré', '%count% degré|%count% degrés', -1], 303 | ['-1.5 degrees', '%count% degree|%count% degrees', -1.5], 304 | ['-1.5 degré', '%count% degré|%count% degrés', -1.5, 'fr'], 305 | ['-2 degrees', '%count% degree|%count% degrees', -2], 306 | ['-2 degrés', '%count% degré|%count% degrés', -2], 307 | ]; 308 | } 309 | 310 | /** 311 | * @dataProvider failingLangcodes 312 | */ 313 | #[DataProvider('failingLangcodes')] 314 | public function testFailedLangcodes($nplural, $langCodes) 315 | { 316 | $matrix = $this->generateTestData($langCodes); 317 | $this->validateMatrix($nplural, $matrix, false); 318 | } 319 | 320 | /** 321 | * @dataProvider successLangcodes 322 | */ 323 | #[DataProvider('successLangcodes')] 324 | public function testLangcodes($nplural, $langCodes) 325 | { 326 | $matrix = $this->generateTestData($langCodes); 327 | $this->validateMatrix($nplural, $matrix); 328 | } 329 | 330 | /** 331 | * This array should contain all currently known langcodes. 332 | * 333 | * As it is impossible to have this ever complete we should try as hard as possible to have it almost complete. 334 | */ 335 | public static function successLangcodes(): array 336 | { 337 | return [ 338 | ['1', ['ay', 'bo', 'cgg', 'dz', 'id', 'ja', 'jbo', 'ka', 'kk', 'km', 'ko', 'ky']], 339 | ['2', ['nl', 'fr', 'en', 'de', 'de_GE', 'hy', 'hy_AM', 'en_US_POSIX']], 340 | ['3', ['be', 'bs', 'cs', 'hr']], 341 | ['4', ['cy', 'mt', 'sl']], 342 | ['6', ['ar']], 343 | ]; 344 | } 345 | 346 | /** 347 | * This array should be at least empty within the near future. 348 | * 349 | * This both depends on a complete list trying to add above as understanding 350 | * the plural rules of the current failing languages. 351 | * 352 | * @return array with nplural together with langcodes 353 | */ 354 | public static function failingLangcodes(): array 355 | { 356 | return [ 357 | ['1', ['fa']], 358 | ['2', ['jbo']], 359 | ['3', ['cbs']], 360 | ['4', ['gd', 'kw']], 361 | ['5', ['ga']], 362 | ]; 363 | } 364 | 365 | /** 366 | * We validate only on the plural coverage. Thus the real rules is not tested. 367 | * 368 | * @param string $nplural Plural expected 369 | * @param array $matrix Containing langcodes and their plural index values 370 | */ 371 | protected function validateMatrix(string $nplural, array $matrix, bool $expectSuccess = true) 372 | { 373 | foreach ($matrix as $langCode => $data) { 374 | $indexes = array_flip($data); 375 | if ($expectSuccess) { 376 | $this->assertCount($nplural, $indexes, "Langcode '$langCode' has '$nplural' plural forms."); 377 | } else { 378 | $this->assertNotCount($nplural, $indexes, "Langcode '$langCode' has '$nplural' plural forms."); 379 | } 380 | } 381 | } 382 | 383 | protected function generateTestData($langCodes) 384 | { 385 | $translator = new class { 386 | use TranslatorTrait { 387 | getPluralizationRule as public; 388 | } 389 | }; 390 | 391 | $matrix = []; 392 | foreach ($langCodes as $langCode) { 393 | for ($count = 0; $count < 200; ++$count) { 394 | $plural = $translator->getPluralizationRule($count, $langCode); 395 | $matrix[$langCode][$count] = $plural; 396 | } 397 | } 398 | 399 | return $matrix; 400 | } 401 | } 402 | --------------------------------------------------------------------------------