diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index d73a185..8d4edf3 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -21,10 +21,11 @@ jobs: - "8.2" - "8.3" - "8.4" + - "8.5" experimental: - false include: - - php-version: "8.5" + - php-version: "8.6" composer-options: "--ignore-platform-reqs" experimental: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d23fe89..3e5d146 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,12 +1,14 @@ - - - - ./tests - - - - - ./src - - - \ No newline at end of file + + + + + ./src + + + + + ./tests + + + diff --git a/src/DateTimeFormatter.php b/src/DateTimeFormatter.php index a9deb24..157d3ca 100644 --- a/src/DateTimeFormatter.php +++ b/src/DateTimeFormatter.php @@ -2,7 +2,7 @@ namespace Sunkan\Dictus; -final class DateTimeFormatter implements Formatter, MutableFormatter +final class DateTimeFormatter implements FormatterDateTime, MutableFormatter { public function __construct( private string $format, @@ -17,4 +17,14 @@ public function setFormat(string $format): void { $this->format = $format; } + + public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string + { + return $timestamp->format($format); + } + + public function formatDate(string $format, \DateTimeInterface $date): string + { + return $date->format($format); + } } diff --git a/src/Formatter.php b/src/Formatter.php index 9dc5d1e..5649a54 100644 --- a/src/Formatter.php +++ b/src/Formatter.php @@ -5,4 +5,9 @@ interface Formatter { public function format(\DateTimeInterface $date): string; + + /** + * @deprecated use FormatterDateTime::formatDate + */ + public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string; } diff --git a/src/FormatterDateTime.php b/src/FormatterDateTime.php new file mode 100644 index 0000000..3c3b04e --- /dev/null +++ b/src/FormatterDateTime.php @@ -0,0 +1,8 @@ + 'H:i', 'LTS' => 'H:i:s', 'L' => 'd/m/Y', 'LL' => 'j F Y', - 'LLL' => 'j F H:i', - 'LLLL' => 'l, j F Y H:i', + 'll' => 'j M Y', + 'LLL' => 'j F Y [at] H:i', + 'lll' => 'j M Y, H:i', + 'LLLL' => 'l, j F Y [at] H:i', + 'llll' => 'D, j M Y, H:i', default => null, }; } public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string { + if ($char === 'M' && $dateTime->format('n') === '9') { + return 'Sept'; + } return null; } } diff --git a/src/Locales/EnUs.php b/src/Locales/EnUs.php new file mode 100644 index 0000000..84c3bac --- /dev/null +++ b/src/Locales/EnUs.php @@ -0,0 +1,29 @@ + 'g:i A', + 'LTS' => 'g:i:s A', + 'L' => 'n/j/y', + 'LL' => 'F j, Y', + 'll' => 'M j, Y', + 'LLL' => 'F j, Y [at] g:i A', + 'lll' => 'M j, Y, g:i A', + 'LLLL' => 'l, F j, Y [at] g:i A', + 'llll' => 'D, M j, Y, g:i A', + default => null, + }; + } + + public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string + { + return null; + } +} diff --git a/src/Locales/IsIs.php b/src/Locales/IsIs.php index 2c6cf39..711ea5e 100644 --- a/src/Locales/IsIs.php +++ b/src/Locales/IsIs.php @@ -7,13 +7,13 @@ final class IsIs implements LocaleFormat { private const DAYS_SHORT = [ - 1 => 'mán', - 2 => 'þri', - 3 => 'mið', - 4 => 'fim', - 5 => 'fös', - 6 => 'lau', - 7 => 'sun', + 1 => 'mán.', + 2 => 'þri.', + 3 => 'mið.', + 4 => 'fim.', + 5 => 'fös.', + 6 => 'lau.', + 7 => 'sun.', ]; private const DAYS_LONG = [ @@ -27,18 +27,18 @@ final class IsIs implements LocaleFormat ]; private const MONTHS_SHORT = [ - 1 => 'jan', - 2 => 'feb', - 3 => 'mar', - 4 => 'apr', + 1 => 'jan.', + 2 => 'feb.', + 3 => 'mar.', + 4 => 'apr.', 5 => 'maí', - 6 => 'jún', - 7 => 'júl', - 8 => 'ágú', - 9 => 'sep', - 10 => 'okt', - 11 => 'nóv', - 12 => 'des', + 6 => 'jún.', + 7 => 'júl.', + 8 => 'ágú.', + 9 => 'sep.', + 10 => 'okt.', + 11 => 'nóv.', + 12 => 'des.', ]; private const MONTHS_LONG = [ @@ -72,10 +72,13 @@ public function resolveFormat(string $format): ?string return match($format) { 'LT' => 'H:i', 'LTS' => 'H:i:s', - 'L' => 'd.m.Y', + 'L' => 'j.n.Y', 'LL' => 'j. F Y', - 'LLL' => 'j. F [kl.] H:i', - 'LLLL' => 'l j. F Y [kl.] H:i', + 'll' => 'j. M Y', + 'LLL' => 'j. F Y [kl.] H:i', + 'lll' => 'j. M Y, H:i', + 'LLLL' => 'l, j. F Y [kl.] H:i', + 'llll' => 'D j. M Y, H:i', default => null, }; } diff --git a/src/Locales/SvSe.php b/src/Locales/SvSe.php index 80391b1..c6dd9ce 100644 --- a/src/Locales/SvSe.php +++ b/src/Locales/SvSe.php @@ -7,53 +7,53 @@ final class SvSe implements LocaleFormat { private const MONTHS_SHORT = [ - 1 => 'Jan', - 2 => 'Feb', - 3 => 'Mar', - 4 => 'Apr', - 5 => 'Maj', - 6 => 'Jun', - 7 => 'Jul', - 8 => 'Aug', - 9 => 'Sep', - 10 => 'Okt', - 11 => 'Nov', - 12 => 'Dec', + 1 => 'jan.', + 2 => 'feb.', + 3 => 'mars', + 4 => 'apr.', + 5 => 'maj', + 6 => 'juni', + 7 => 'juli', + 8 => 'aug.', + 9 => 'sep.', + 10 => 'okt.', + 11 => 'nov.', + 12 => 'dec.', ]; private const MONTHS_LONG = [ - 1 => 'Januari', - 2 => 'Februari', - 3 => 'Mars', - 4 => 'April', - 5 => 'Maj', - 6 => 'Juni', - 7 => 'Juli', - 8 => 'Augusti', - 9 => 'September', - 10 => 'Oktober', - 11 => 'November', - 12 => 'December', + 1 => 'januari', + 2 => 'februari', + 3 => 'mars', + 4 => 'april', + 5 => 'maj', + 6 => 'juni', + 7 => 'juli', + 8 => 'augusti', + 9 => 'september', + 10 => 'oktober', + 11 => 'november', + 12 => 'december', ]; private const DAYS_SHORT = [ 1 => 'mån', 2 => 'tis', 3 => 'ons', - 4 => 'tor', + 4 => 'tors', 5 => 'fre', 6 => 'lör', 7 => 'sön', ]; private const DAYS_LONG = [ - 1 => 'Måndag', - 2 => 'Tisdag', - 3 => 'Onsdag', - 4 => 'Torsdag', - 5 => 'Fredag', - 6 => 'Lördag', - 7 => 'Söndag', + 1 => 'måndag', + 2 => 'tisdag', + 3 => 'onsdag', + 4 => 'torsdag', + 5 => 'fredag', + 6 => 'lördag', + 7 => 'söndag', ]; public function formatChar(string $char, \DateTimeImmutable $dateTime): ?string @@ -72,10 +72,13 @@ public function resolveFormat(string $format): ?string return match($format) { 'LT' => 'H:i', 'LTS' => 'H:i:s', - 'L' => 'd.m.Y', + 'L' => 'Y-m-d', 'LL' => 'j F Y', - 'LLL' => 'j F [kl.] H:i', + 'll' => 'j M Y', + 'LLL' => 'j F Y [kl.] H:i', + 'lll' => 'j M Y H:i', 'LLLL' => 'l j F Y [kl.] H:i', + 'llll' => 'D j M Y H:i', default => null, }; } diff --git a/src/LocalizedDateTimeFormatter.php b/src/LocalizedDateTimeFormatter.php index 962dc41..94d4cbe 100644 --- a/src/LocalizedDateTimeFormatter.php +++ b/src/LocalizedDateTimeFormatter.php @@ -2,8 +2,20 @@ namespace Sunkan\Dictus; -final class LocalizedDateTimeFormatter implements LocalizedFormatter, MutableFormatter +final class LocalizedDateTimeFormatter implements LocalizedFormatter, MutableFormatter, FormatterDateTime { + private const LOCALIZED_SHORT_FORMATS = [ + // Sorting this list correctly is important because of how we replace from it + 'LTS', + 'LT', + 'LLLL', + 'llll', + 'LLL', + 'lll', + 'LL', + 'll', + 'L', + ]; /** @var array */ private static array $localFormats = []; @@ -34,11 +46,27 @@ public function setLocale(string $locale): void public function format(\DateTimeInterface $date): string { - return $this->formatTimestamp($this->format, \DateTimeImmutable::createFromInterface($date), $this->locale); + return $this->formatDate($this->format, $date); } - public function formatTimestamp(string $format, \DateTimeImmutable $timestamp, string $locale): string + public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string { + return $this->formatDate($format, $timestamp); + } + + public function formatDate(string $format, \DateTimeInterface $date): string + { + $timestamp = \DateTimeImmutable::createFromInterface($date); + foreach (self::LOCALIZED_SHORT_FORMATS as $localizedFormat) { + if (!str_contains($format, $localizedFormat)) { + continue; + } + $tmpFormat = $this->localeFormat->resolveFormat($localizedFormat); + if ($tmpFormat !== null) { + $format = str_replace($localizedFormat, $tmpFormat, $format); + } + } + $result = ''; $length = mb_strlen($format); $inEscaped = false; @@ -70,25 +98,11 @@ public function formatTimestamp(string $format, \DateTimeImmutable $timestamp, s continue; } $localResult = $this->localeFormat->formatChar($char, $timestamp); - if ($localResult) { + if ($localResult) { $result .= $localResult; continue; } - $input = mb_substr($format, $i); - if ($char === 'L' && preg_match('/^(LTS|LT|L{1,4})/', $input, $match)) { - $code = $match[0]; - $newFormat = $this->localeFormat->resolveFormat($code); - if ($newFormat) { - $result .= $this->formatTimestamp($newFormat, $timestamp, $locale); - } - else { - $result .= $code; - } - $i += mb_strlen($code) - 1; - continue; - } - $result .= $timestamp->format($char); } return $result; diff --git a/src/LocalizedStrftimeFormatter.php b/src/LocalizedStrftimeFormatter.php index ccdeb98..6598e63 100644 --- a/src/LocalizedStrftimeFormatter.php +++ b/src/LocalizedStrftimeFormatter.php @@ -5,7 +5,7 @@ use DateTimeImmutable; use IntlDateFormatter; -final class LocalizedStrftimeFormatter implements LocalizedFormatter, MutableFormatter +final class LocalizedStrftimeFormatter implements LocalizedFormatter, MutableFormatter, FormatterDateTime { private const INTL_FORMATS = [ '%a' => 'EEE', // An abbreviated textual representation of the day Sun through Sat @@ -23,9 +23,9 @@ public function __construct( private string $format, ) {} - public function setLocale(string $local): void + public function setLocale(string $locale): void { - $this->locale = $local; + $this->locale = $locale; } public function setFormat(string $format): void @@ -39,6 +39,16 @@ public function format(\DateTimeInterface $date): string return $this->strftime($this->format, $date, $this->locale); } + public function formatTimestamp(string $format, \DateTimeImmutable $timestamp): string + { + return $this->strftime($format, $timestamp, $this->locale); + } + + public function formatDate(string $format, \DateTimeInterface $date): string + { + return $this->strftime($format, DateTimeImmutable::createFromInterface($date), $this->locale); + } + private function strftime(string $format, DateTimeImmutable $timestamp, string $local): string { // remove trailing part not supported by ext-intl locale diff --git a/tests/IntlComparisonTest.php b/tests/IntlComparisonTest.php new file mode 100644 index 0000000..6ad26a5 --- /dev/null +++ b/tests/IntlComparisonTest.php @@ -0,0 +1,267 @@ + [IntlDateFormatter::NONE, IntlDateFormatter::SHORT], + 'LTS' => [IntlDateFormatter::NONE, IntlDateFormatter::MEDIUM], + 'L' => [IntlDateFormatter::SHORT, IntlDateFormatter::NONE], + 'LL' => [IntlDateFormatter::LONG, IntlDateFormatter::NONE], + 'll' => [IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE], + 'LLL' => [IntlDateFormatter::LONG, IntlDateFormatter::SHORT], + 'lll' => [IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT], + 'LLLL' => [IntlDateFormatter::FULL, IntlDateFormatter::SHORT], + // This is not available via intl formats without setting a pattern + // 'llll' => [], + ]; + + /** + * @return array + */ + public static function provideLocales(): array + { + return [ + 'en' => ['en_US', new EnUs()], + 'uk' => ['en_GB', new EnGb()], + 'is' => ['is_IS', new IsIs()], + 'se' => ['sv_SE', new SvSe()], + ]; + } + + /** + * @return array + */ + public static function provideMonths(): array + { + $ret = []; + foreach (self::provideLocales() as $key => $args) { + for ($month = 1; $month <= 12; $month++) { + $args[2] = $month; + $ret["$key-$month"] = $args; + } + } + return $ret; + } + + /** + * @return array + */ + public static function provideWeekdays(): array + { + $ret = []; + foreach (self::provideLocales() as $key => $args) { + for ($wd = 1; $wd <= 7; $wd++) { + $args[2] = $wd; + $ret["$key-$wd"] = $args; + } + } + return $ret; + } + + /** + * @return array + */ + public static function provideFormats(): array + { + $ret = []; + foreach (self::provideLocales() as $key => $args) { + foreach (array_keys(self::INTL_FORMAT_MAP) as $k) { + $args[2] = $k; + $ret["$key-$k"] = $args; + } + } + return $ret; + } + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::checkIcuDataVersion(); + } + + /** + * @dataProvider provideMonths + */ + public function testFullMonth(string $locale, LocaleFormat $localeFormat, int $month): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, 'F'); + $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'MMMM'); + + $m = $this->pad($month); + $date = new DateTimeImmutable("2023-$m-03 11:11:11"); + + $this->assertSame( + $intlFormatter->format($date), + $formatter->format($date), + ); + } + + /** + * @dataProvider provideMonths + */ + public function testAbbrMonth(string $locale, LocaleFormat $localeFormat, int $month): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, 'M'); + $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'MMM'); + + $m = $this->pad($month); + $date = new DateTimeImmutable("2023-$m-03 11:11:11"); + + $this->assertSame( + $intlFormatter->format($date), + $formatter->format($date), + ); + } + + /** + * @dataProvider provideWeekdays + */ + public function testFullWeekday(string $locale, LocaleFormat $localeFormat, int $weekday): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, 'l'); + $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'EEEE'); + + $w = $this->pad($weekday); + $date = new DateTimeImmutable("2025-12-$w 11:11:11"); + + $this->assertSame( + $intlFormatter->format($date), + $formatter->format($date), + ); + } + + /** + * @dataProvider provideWeekdays + */ + public function testAbbrWeekday(string $locale, LocaleFormat $localeFormat, int $weekday): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, 'D'); + $intlFormatter = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::SHORT, null, null, 'EEE'); + + $w = $this->pad($weekday); + $date = new DateTimeImmutable("2025-12-$w 11:11:11"); + + $this->assertSame( + $intlFormatter->format($date), + $formatter->format($date), + ); + } + + /** + * @dataProvider provideFormats + */ + public function testFormats(string $locale, LocaleFormat $localeFormat, string $format): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, $format); + [$dateType, $timeType] = self::INTL_FORMAT_MAP[$format]; + + $intlFormatter = new IntlDateFormatter( + $locale, + $dateType, + $timeType, + ); + + foreach ($this->createDates() as $date) { + $this->assertSame( + $intlFormatter->format($date), + $formatter->format($date), + ); + } + } + + /** + * @dataProvider provideLocales + */ + public function testSpecialLocalFormat(string $locale, LocaleFormat $localeFormat): void + { + LocalizedDateTimeFormatter::addLocaleFormat($locale, $localeFormat); + $formatter = new LocalizedDateTimeFormatter($locale, 'llll'); + + $intlFormatter = new IntlDateFormatter( + $locale, + IntlDateFormatter::MEDIUM, + IntlDateFormatter::SHORT, + ); + + $dates = $this->createDates(); + foreach ($dates as $date) { + // This should just contain the same as "lll" + $this->assertStringContainsString( + (string)$intlFormatter->format($date), + $formatter->format($date), + ); + } + $intlFormatter->setPattern('eee'); + foreach ($dates as $date) { + $this->assertStringContainsString( + (string)$intlFormatter->format($date), + $formatter->format($date), + ); + } + } + + private static function checkIcuDataVersion(): void + { + try { + $reflector = new \ReflectionExtension('intl'); + ob_start(); + $reflector->info(); + $output = strip_tags((string)ob_get_clean()); + preg_match('/^ICU Data version (?:=>)?(.*)$/m', $output, $matches); + $icuDataVersion = trim($matches[1]); + } catch (\ReflectionException) { + $icuDataVersion = ''; + } + if ((int)$icuDataVersion < 76) { + self::markTestSkipped(sprintf( + 'Intl comparison tests expect ICU Data version to be at least 76. Current is %s', + $icuDataVersion, + )); + } + } + + /** + * @return list + */ + private function createDates(): array + { + $date = new DateTimeImmutable('2025-01-03 09:11:11'); + $dates = [ + $date, + $date->add(new \DateInterval('PT12H')), + ]; + + for ($i = 1; $i <= 7; $i++) { + $dates[] = $date = $date->add(new \DateInterval('P1D')); + } + for ($i = 1; $i <= 12; $i++) { + $dates[] = $date = $date->add(new \DateInterval('P1M')); + } + return $dates; + } + + private function pad(int $month): string + { + return str_pad((string)$month, 2, '0', STR_PAD_LEFT); + } +} diff --git a/tests/LocalizedDateTimeFormatterTest.php b/tests/LocalizedDateTimeFormatterTest.php index ddbbd1e..882352f 100644 --- a/tests/LocalizedDateTimeFormatterTest.php +++ b/tests/LocalizedDateTimeFormatterTest.php @@ -1,7 +1,6 @@ assertSame('mánudagur 21. ágúst kl. 16:26 Test', $formatter->format($date)); + $this->assertSame('mánudagur 21. ágúst 2023 kl. 16:26 Test', $formatter->format($date)); } public function testEnglishFormat(): void @@ -23,6 +22,6 @@ public function testEnglishFormat(): void $date = new DateTimeImmutable('2023-08-21 16:26:14'); - $this->assertSame('Monday 21 August 16:26 Test', $formatter->format($date)); + $this->assertSame('Monday 21 August 2023 at 16:26 Test', $formatter->format($date)); } }