├── 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 |
--------------------------------------------------------------------------------