Fix IntlExtension inheriting values derived by ICU from a date formatter prototype

This commit is contained in:
Fabien Potencier
2026-09-18 18:13:48 +01:00
parent d722e8af93
commit 6671288259
3 changed files with 90 additions and 2 deletions
+2
View File
@@ -1,5 +1,7 @@
# 3.30.0 (2026-XX-XX)
* Fix `IntlExtension` letting the pattern derived from a date formatter prototype override an explicit locale
* Fix `IntlExtension` not honoring the locale of a date formatter prototype configured with no date and time styles
* Report a clear error when `random`, `reverse`, `shuffle`, and `split` receive a string that is not valid UTF-8
* Fix the deprecation about omitting parentheses when calling a macro being reported twice for the same call
* Add the macro name to the deprecation about omitting parentheses when calling a macro
+47 -2
View File
@@ -186,6 +186,7 @@ final class IntlExtension extends AbstractExtension
private $listFormatters = [];
private $dateFormatterPrototype;
private $numberFormatterPrototype;
private array $prototypeDerivedPatterns = [];
public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null)
{
@@ -473,7 +474,7 @@ final class IntlExtension extends AbstractExtension
if (null === $locale) {
if ($this->dateFormatterPrototype) {
$locale = $this->dateFormatterPrototype->getLocale();
$locale = $this->prototypeLocale();
}
$locale = $locale ?: \Locale::getDefault();
}
@@ -500,7 +501,7 @@ final class IntlExtension extends AbstractExtension
// fall back to the prototype's pattern only when nothing else was given, else it would override the explicit date/time formats;
// a pattern describes a full datetime rendering, so it cannot be honored by format_date/format_time, which pass 'none' for the other part
if ('' === $pattern && null === $dateFormat && null === $timeFormat) {
$pattern = $this->dateFormatterPrototype->getPattern();
$pattern = $this->prototypePattern();
}
}
@@ -527,6 +528,50 @@ final class IntlExtension extends AbstractExtension
return $this->dateFormatters[$hash];
}
/**
* ICU reports "root" for a prototype with no date and no time style, whatever its pattern, and rejects
* it as an input locale, so the configured locale is recovered from the calendar ICU opened for it.
*/
private function prototypeLocale(): ?string
{
$locale = $this->dateFormatterPrototype->getLocale();
if (\is_string($locale) && '' !== $locale && 'root' !== $locale) {
return $locale;
}
$calendar = $this->dateFormatterPrototype->getCalendarObject();
$locale = $calendar ? $calendar->getLocale(\Locale::VALID_LOCALE) : false;
return \is_string($locale) && '' !== $locale && 'root' !== $locale ? $locale : null;
}
/**
* ICU derives a pattern from the locale and the date/time types when none was configured;
* a pattern configured to the very value ICU derives is indistinguishable from a derived one.
*/
private function prototypePattern(): string
{
$pattern = $this->dateFormatterPrototype->getPattern();
$dateType = $this->dateFormatterPrototype->getDateType();
$timeType = $this->dateFormatterPrototype->getTimeType();
if (!\is_string($pattern) || '' === $pattern || false === $dateType || false === $timeType) {
return \is_string($pattern) ? $pattern : '';
}
$locale = $this->prototypeLocale() ?: \Locale::getDefault();
$calendar = $this->dateFormatterPrototype->getCalendarObject();
// ICU derives the pattern from the calendar keyword it adds to the locale, which it does not do for a calendar object
if ($calendar && \is_int($this->dateFormatterPrototype->getCalendar())) {
$locale .= '@calendar='.$calendar->getType();
}
$key = $locale.'|'.$dateType.'|'.$timeType;
$this->prototypeDerivedPatterns[$key] ??= (new \IntlDateFormatter($locale, $dateType, $timeType))->getPattern();
return $pattern === $this->prototypeDerivedPatterns[$key] ? '' : $pattern;
}
private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter
{
if (!isset(self::NUMBER_STYLES[$style])) {
@@ -194,6 +194,47 @@ class IntlExtensionTest extends TestCase
$this->assertSame('22:22:00', $ext->formatTime($env, $date));
}
public function testFormatterProtoPatternDoesNotOverrideAnExplicitLocale(): void
{
$env = new Environment(new ArrayLoader());
$date = new \DateTime('2019-08-07T23:39:12+00:00');
// this prototype has no configured pattern, but ICU still reports one
$derivedProto = new \IntlDateFormatter('fr_FR', \IntlDateFormatter::LONG, \IntlDateFormatter::SHORT, 'UTC', \IntlDateFormatter::GREGORIAN);
$expected = (new \IntlDateFormatter('en_US', \IntlDateFormatter::LONG, \IntlDateFormatter::SHORT, 'UTC', \IntlDateFormatter::GREGORIAN))->format($date);
$this->assertSame($expected, (new IntlExtension($derivedProto))->formatDateTime($env, $date, timezone: 'UTC', locale: 'en_US'));
$patternProto = new \IntlDateFormatter('fr_FR', \IntlDateFormatter::LONG, \IntlDateFormatter::SHORT, 'UTC', \IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd');
$this->assertSame('2019-08-07', (new IntlExtension($patternProto))->formatDateTime($env, $date, timezone: 'UTC', locale: 'en_US'));
// ICU derives the pattern from the calendar of the prototype as well
$calendarProto = new \IntlDateFormatter('ja_JP@calendar=japanese', \IntlDateFormatter::LONG, \IntlDateFormatter::SHORT, 'UTC', \IntlDateFormatter::TRADITIONAL);
$this->assertSame($expected, (new IntlExtension($calendarProto))->formatDateTime($env, $date, timezone: 'UTC', locale: 'en_US'));
}
public function testFormatterProtoWithoutDateAndTimeStylesKeepsItsLocale(): void
{
$env = new Environment(new ArrayLoader());
$date = new \DateTime('2019-08-07T23:39:12+00:00');
// ICU reports the "root" locale for a prototype with no date and no time style, and rejects it as an input locale
$proto = new \IntlDateFormatter('fr_FR', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', \IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd');
$this->assertSame('2019-08-07', (new IntlExtension($proto))->formatDateTime($env, $date, timezone: 'UTC'));
$localeDependentProto = new \IntlDateFormatter('fr_FR', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', \IntlDateFormatter::GREGORIAN, 'EEEE d MMMM y');
$default = \Locale::getDefault();
\Locale::setDefault('en_US');
try {
$this->assertSame('mercredi 7 août 2019', (new IntlExtension($localeDependentProto))->formatDateTime($env, $date, timezone: 'UTC'));
} finally {
\Locale::setDefault($default);
}
}
public function testDateFormatterCacheIsBounded(): void
{
$ext = new IntlExtension();