diff --git a/CHANGELOG b/CHANGELOG index 5e7b28009..c4798b911 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/extra/intl-extra/IntlExtension.php b/extra/intl-extra/IntlExtension.php index 901e18ea2..4bb3b343f 100644 --- a/extra/intl-extra/IntlExtension.php +++ b/extra/intl-extra/IntlExtension.php @@ -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])) { diff --git a/extra/intl-extra/Tests/IntlExtensionTest.php b/extra/intl-extra/Tests/IntlExtensionTest.php index 0fef4b817..9d24fe9b0 100644 --- a/extra/intl-extra/Tests/IntlExtensionTest.php +++ b/extra/intl-extra/Tests/IntlExtensionTest.php @@ -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();