8000 Allow the nearest locale to be selected instead the default one. · symfony/symfony@ef73505 · GitHub
[go: up one dir, main page]

Skip to content

Commit ef73505

Browse files
Spomkyfabpot
authored andcommitted
Allow the nearest locale to be selected instead the default one.
1 parent 4ce4e5e commit ef73505

File tree

2 files changed

+146
-73
lines changed

2 files changed

+146
-73
lines changed

src/Symfony/Component/HttpFoundation/Request.php

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,24 +1532,25 @@ public function getPreferredLanguage(?array $locales = null): ?string
15321532
return $preferredLanguages[0] ?? null;
15331533
}
15341534

1535+
$locales = array_map($this->formatLocale(...), $locales ?? []);
15351536
if (!$preferredLanguages) {
15361537
return $locales[0];
15371538
}
15381539

1539-
$extendedPreferredLanguages = [];
1540-
foreach ($preferredLanguages as $language) {
1541-
$extendedPreferredLanguages[] = $language;
1542-
if (false !== $position = strpos($language, '_')) {
1543-
$superLanguage = substr($language, 0, $position);
1544-
if (!\in_array($superLanguage, $preferredLanguages, true)) {
1545-
$extendedPreferredLanguages[] = $superLanguage;
1540+
if ($matches = array_intersect($preferredLanguages, $locales)) {
1541+
return current($matches);
1542+
}
1543+
1544+
$combinations = array_merge(...array_map($this->getLanguageCombinations(...), $preferredLanguages));
1545+
foreach ($combinations as $combination) {
1546+
foreach ($locales as $locale) {
1547+
if (str_starts_with($locale, $combination)) {
1548+
return $locale;
15461549
}
15471550
}
15481551
}
15491552

1550-
$preferredLanguages = array_values(array_intersect($extendedPreferredLanguages, $locales));
1551-
1552-
return $preferredLanguages[0] ?? $locales[0];
1553+
return $locales[0];
15531554
}
15541555

15551556
/**
@@ -1567,32 +1568,91 @@ public function getLanguages(): array
15671568
$this->languages = [];
15681569
foreach ($languages as $acceptHeaderItem) {
15691570
$lang = $acceptHeaderItem->getValue();
1570-
if (str_contains($lang, '-')) {
1571-
$codes = explode('-', $lang);
1572-
if ('i' === $codes[0]) {
1573-
// Language not listed in ISO 639 that are not variants
1574-
// of any listed language, which can be registered with the
1575-
// i-prefix, such as i-cherokee
1576-
if (\count($codes) > 1) {
1577-
$lang = $codes[1];
1578-
}
1579-
} else {
1580-
for ($i = 0, $max = \count($codes); $i < $max; ++$i) {
1581-
if (0 === $i) {
1582-
$lang = strtolower($codes[0]);
1583-
} else {
1584-
$lang .= '_'.strtoupper($codes[$i]);
1585-
}
1586-
}
1587-
}
1588-
}
1589-
1590-
$this->languages[] = $lang;
1571+
$this->languages[] = $this->formatLocale($lang);
15911572
}
1573+
$this->languages = array_unique($this->languages);
15921574

15931575
return $this->languages;
15941576
}
15951577

1578+
/**
1579+
* Strips the locale to only keep the canonicalized language value.
1580+
*
1581+
* Depending on the $locale value, this method can return values like :
1582+
* - language_Script_REGION: "fr_Latn_FR", "zh_Hans_TW"
1583+
* - language_Script: "fr_Latn", "zh_Hans"
1584+
* - language_REGION: "fr_FR", "zh_TW"
1585+
* - language: "fr", "zh"
1586+
*
1587+
* Invalid locale values are returned as is.
1588+
*
1589+
* @see https://wikipedia.org/wiki/IETF_language_tag
1590+
* @see https://datatracker.ietf.org/doc/html/rfc5646
1591+
*/
1592+
private static function formatLocale(string $locale): string
1593+
{
1594+
[$language, $script, $region] = self::getLanguageComponents($locale);
1595+
1596+
return implode('_', array_filter([$language, $script, $region]));
1597+
}
1598+
1599+
/**
1600+
* Returns an array of all possible combinations of the language components.
1601+
*
1602+
* For instance, if the locale is "fr_Latn_FR", this method will return:
1603+
* - "fr_Latn_FR"
1604+
* - "fr_Latn"
1605+
* - "fr_FR"
1606+
* - "fr"
1607+
*
1608+
* @return string[]
1609+
*/
1610+
private static function getLanguageCombinations(string $locale): array
1611+
{
1612+
[$language, $script, $region] = self::getLanguageComponents($locale);
1613+
1614+
return array_unique([
1615+
implode('_', array_filter([$language, $script, $region])),
1616+
implode('_', array_filter([$language, $script])),
1617+
implode('_', array_filter([$language, $region])),
1618+
$language,
1619+
]);
1620+
}
1621+
1622+
/**
1623+
* Returns an array with the language components of the locale.
1624+
*
1625+
* For example:
1626+
* - If the locale is "fr_Latn_FR", this method will return "fr", "Latn", "FR"
1627+
* - If the locale is "fr_FR", this method will return "fr", null, "FR"
1628+
* - If the locale is "zh_Hans", this method will return "zh", "Hans", null
1629+
*
1630+
* @see https://wikipedia.org/wiki/IETF_language_tag
1631+
* @see https://datatracker.ietf.org/doc/html/rfc5646
1632+
*
1633+
* @return array{string, string|null, string|null}
1634+
*/
1635+
private static function getLanguageComponents(string $locale): array
1636+
{
1637+
$locale = str_replace('_', '-', strtolower($locale));
1638+
$pattern = '/^([a-zA-Z]{2,3}|i-[a-zA-Z]{5,})(?:-([a-zA-Z]{4}))?(?:-([a-zA-Z]{2}))?(?:-(.+))?$/';
1639+
if (!preg_match($pattern, $locale, $matches)) {
1640+
return [$locale, null, null];
1641+
}
1642+
if (str_starts_with($matches[1], 'i-')) {
1643+
// Language not listed in ISO 639 that are not variants
1644+
// of any listed language, which can be registered with the
1645+
// i-prefix, such as i-cherokee
1646+
$matches[1] = substr($matches[1], 2);
1647+
}
1648+
1649+
return [
1650+
$matches[1],
1651+
isset($matches[2]) ? ucfirst(strtolower($matches[2])) : null,
1652+
isset($matches[3]) ? strtoupper($matches[3]) : null,
1653+
];
1654+
}
1655+
15961656
/**
15971657
* Gets a list of charsets acceptable by the client browser in preferable order.
15981658
*

src/Symfony/Component/HttpFoundation/Tests/RequestTest.php

Lines changed: 55 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,27 +1502,43 @@ public function testGetPreferredLanguage()
15021502
{
15031503
$request = new Request();
15041504
$this->assertNull($request->getPreferredLanguage());
1505-
$this->assertNull($request->getPreferredLanguage([]));
1506-
$this->assertEquals('fr', $request->getPreferredLanguage(['fr']));
1507-
$this->assertEquals('fr', $request->getPreferredLanguage(['fr', 'en']));
1508-
$this->assertEquals('en', $request->getPreferredLanguage(['en', 'fr']));
1509-
$this->assertEquals('fr-ch', $request->getPreferredLanguage(['fr-ch', 'fr-fr']));
1510-
1511-
$request = new Request();
1512-
$request->headers->set('Accept-language', 'zh, en-us; q=0.8, en; q=0.6');
1513-
$this->assertEquals('en', $request->getPreferredLanguage(['en', 'en-us']));
1514-
1515-
$request = new Request();
1516-
$request->headers->set('Accept-language', 'zh, en-us; q=0.8, en; q=0.6');
1517-
$this->assertEquals('en', $request->getPreferredLanguage(['fr', 'en']));
1518-
1519-
$request = new Request();
1520-
$request->headers->set('Accept-language', 'zh, en-us; q=0.8');
1521-
$this->assertEquals('en', $request->getPreferredLanguage(['fr', 'en']));
1505+
}
15221506

1507+
/**
1508+
* @dataProvider providePreferredLanguage
1509+
*/
1510+
public function testPreferredLanguageWithLocales(?string $expectedLocale, ?string $acceptLanguage, array $locales)
1511+
{
15231512
$request = new Request();
1524-
$request->headers->set('Accept-language', 'zh, en-us; q=0.8, fr-fr; q=0.6, fr; q=0.5');
1525-
$this->assertEquals('en', $request->getPreferredLanguage(['fr', 'en']));
1513+
if ($acceptLanguage) {
1514+
$request->headers->set('Accept-language', $acceptLanguage);
1515+
}
1516+
$this->assertSame($expectedLocale, $request->getPreferredLanguage($locales));
1517+
}
1518+
1519+
public static function providePreferredLanguage(): iterable
1520+
{
1521+
yield '"es_PA" is selected as no supported locale is set' => ['es_PA', 'es-pa, en-us; q=0.8, en; q=0.6', []];
1522+
yield 'No supported locales' => [null, null, []];
1523+
yield '"fr" selected as first choice when no header is present' => ['fr', null, ['fr', 'en']];
1524+
yield '"en" selected as first choice when no header is present' => ['en', null, ['en', 'fr']];
1525+
yield '"fr_CH" selected as first choice when no header is present' => ['fr_CH', null, ['fr-ch', 'fr-fr']];
1526+
yield '"en_US" is selected as an exact match is found (1)' => ['en_US', 'zh, en-us; q=0.8, en; q=0.6', ['en', 'en-us']];
1527+
yield '"en_US" is selected as an exact match is found (2)' => ['en_US', 'ja-JP,fr_CA;q=0.7,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']];
1528+
yield '"en" is selected as an exact match is found' => ['en', 'zh, en-us; q=0.8, en; q=0.6', ['fr', 'en']];
1529+
yield '"fr" is selected as an exact match is found' => ['fr', 'zh, en-us; q=0.8, fr-fr; q=0.6, fr; q=0.5', ['fr', 'en']];
1530+
yield '"en" is selected as "en-us" is a similar dialect' => ['en', 'zh, en-us; q=0.8', ['fr', 'en']];
1531+
yield '"fr_FR" is selected as "fr_CA" is a similar dialect (1)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,fr;q=0.5', ['en_US', 'fr_FR']];
1532+
yield '"fr_FR" is selected as "fr_CA" is a similar dialect (2)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7', ['en_US', 'fr_FR']];
1533+
yield '"fr_FR" is selected as "fr" is a similar dialect' => ['fr_FR', 'ja-JP,fr;q=0.5', ['en_US', 'fr_FR']];
1534+
yield '"fr_FR" is selected as "fr_CA" is a similar dialect and has a greater "q" compared to "en_US" (2)' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,ru-ru;q=0.3', ['en_US', 'fr_FR']];
1535+
yield '"en_US" is selected it is an exact match' => ['en_US', 'ja-JP,fr;q=0.5,en_US;q=0.3', ['en_US', 'fr_FR']];
1536+
yield '"fr_FR" is selected as "fr_CA" is a similar dialect and has a greater "q" compared to "en"' => ['fr_FR', 'ja-JP,fr_CA;q=0.7,en;q=0.5', ['en_US', 'fr_FR']];
1537+
yield '"fr_FR" is selected as is is an exact match as well as "en_US", but with a greater "q" parameter' => ['fr_FR', 'en-us;q=0.5,fr-fr', ['en_US', 'fr_FR']];
1538+
yield '"hi_IN" is selected as "hi_Latn_IN" is a similar dialect' => ['hi_IN', 'fr-fr,hi_Latn_IN;q=0.5', ['hi_IN', 'en_US']];
1539+
yield '"hi_Latn_IN" is selected as "hi_IN" is a similar dialect' => ['hi_Latn_IN', 'fr-fr,hi_IN;q=0.5', ['hi_Latn_IN', 'en_US']];
1540+
yield '"en_US" is selected as "en_Latn_US+variants+extensions" is a similar dialect' => ['en_US', 'en-latn-us-fonapi-u-nu-numerical-x-private,fr;q=0.5', ['fr_FR', 'en_US']];
1541+
yield '"zh_Hans" is selected over "zh_TW" as the script as a greater priority over the region' => ['zh_Hans', 'zh-hans-tw, zh-hant-tw', ['zh_Hans', 'zh_tw']];
15261542
}
15271543

15281544
public function testIsXmlHttpRequest()
@@ -1601,30 +1617,28 @@ public function testGetAcceptableContentTypes()
16011617
$this->assertEquals(['application/vnd.wap.wmlscriptc', 'text/vnd.wap.wml', 'application/vnd.wap.xhtml+xml', 'application/xhtml+xml', 'text/html', 'multipart/mixed', '*/*'], $request->getAcceptableContentTypes());
16021618
}
16031619

1604-
public function testGetLanguages()
1620+
/**
1621+
* @dataProvider provideLanguages
1622+
*/
1623+
public function testGetLanguages(array $expectedLocales, ?string $acceptLanguage)
16051624
{
16061625
$request = new Request();
1607-
$this->assertEquals([], $request->getLanguages());
1608-
1609-
$request = new Request();
1610-
$request->headers->set('Accept-language', 'zh, en-us; q=0.8, en; q=0.6');
1611-
$this->assertEquals(['zh', 'en_US', 'en'], $request->getLanguages());
1612-
1613-
$request = new Request();
1614-
$request->headers->set('Accept-language', 'zh, en-us; q=0.6, en; q=0.8');
1615-
$this->assertEquals(['zh', 'en', 'en_US'], $request->getLanguages()); // Test out of order qvalues
1616-
1617-
$request = new Request();
1618-
$request->headers->set('Accept-language', 'zh, en, en-us');
1619-
$this->assertEquals(['zh', 'en', 'en_US'], $request->getLanguages()); // Test equal weighting without qvalues
1620-
1621-
$request = new Request();
1622-
$request->headers->set('Accept-language', 'zh; q=0.6, en, en-us; q=0.6');
1623-
$this->assertEquals(['en', 'zh', 'en_US'], $request->getLanguages()); // Test equal weighting with qvalues
1626+
if ($acceptLanguage) {
1627+
$request->headers->set('Accept-language', $acceptLanguage);
1628+
}
1629+
$this->assertEquals($expectedLocales, $request->getLanguages());
1630+
}
16241631

1625-
$request = new Request();
1626-
$request->headers->set('Accept-language', 'zh, i-cherokee; q=0.6');
1627-
$this->assertEquals(['zh', 'cherokee'], $request->getLanguages());
1632+
public static function provideLanguages(): iterable
1633+
{
1634+
yield 'empty' => [[], null];
1635+
yield [['zh', 'en_US', 'en'], 'zh, en-us; q=0.8, en; q=0.6'];
1636+
yield 'Test out of order qvalues' => [['zh', 'en', 'en_US'], 'zh, en-us; q=0.6, en; q=0.8'];
1637+
yield 'Test equal weighting without qvalues' => [['zh', 'en', 'en_US'], 'zh, en, en-us'];
1638+
yield 'Test equal weighting with qvalues' => [['en', 'zh', 'en_US'], 'zh; q=0.6, en, en-us; q=0.6'];
1639+
yield 'Test irregular locale' => [['zh', 'cherokee'], 'zh, i-cherokee; q=0.6'];
1640+
yield 'Test with variants, unicode extensions and private information' => [['pt_BR', 'hy_Latn_IT', 'zh_Hans_TW'], 'pt-BR-u-ca-gregory-nu-latn, hy-Latn-IT-arevela, zh-Hans-TW-fonapi-u-islamcal-x-AZE-derbend; q=0.6'];
1641+
yield 'Test multiple regions' => [['en_US', 'en_CA', 'en_GB', 'en'], 'en-us, en-ca, en-gb, en'];
16281642
}
16291643

16301644
public function testGetAcceptHeadersReturnString()
@@ -2199,7 +2213,7 @@ public function testFactory()
21992213

22002214
public function testFactoryCallable()
22012215
{
2202-
$requestFactory = new class {
2216+
$requestFactory = new class() {
22032217
public function createRequest(): Request
22042218
{
22052219
return new NewRequest();
@@ -2211,7 +2225,6 @@ public function createRequest(): Request
22112225
$this->assertEquals('foo', Request::create('/')->getFoo());
22122226

22132227
Request::setFactory(null);
2214-
22152228
}
22162229

22172230
/**

0 commit comments

Comments
 (0)
0