From d5bee7d0fd7afee4d991df71537cb01eab4b120d Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Wed, 19 Jan 2022 15:45:13 -0600 Subject: [PATCH 1/2] feat: add formatters for dates and times --- CHANGELOG.md | 2 + .../UnableToFormatDateTimeException.php | 30 + src/FormatPHP.php | 79 ++- src/FormatterInterface.php | 42 +- src/Intl/DateTimeFormat.php | 331 +++++++++++ src/Intl/DateTimeFormatInterface.php | 46 ++ src/Intl/DateTimeFormatOptions.php | 3 +- src/Intl/Locale.php | 114 +++- src/Intl/LocaleInterface.php | 74 ++- src/Intl/LocaleOptions.php | 26 +- tests/Console/Command/ExtractCommandTest.php | 2 +- tests/FormatPHPTest.php | 151 +++++ tests/Intl/DateTimeFormatOptionsTest.php | 4 - tests/Intl/DateTimeFormatTest.php | 540 ++++++++++++++++++ tests/Intl/LocaleTest.php | 111 ++++ 15 files changed, 1530 insertions(+), 25 deletions(-) create mode 100644 src/Exception/UnableToFormatDateTimeException.php create mode 100644 src/Intl/DateTimeFormat.php create mode 100644 src/Intl/DateTimeFormatInterface.php create mode 100644 tests/Intl/DateTimeFormatTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 721f153..b686c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add `Intl\NumberFormatOptions` to allow users to configure number string formatting. - Add `Intl\DateTimeFormatOptions` to allow users to configure date and time string formatting. +- Provide functionality for formatting dates and times through `Intl\DateTimeFormat`, as well as `FormatPHP::formatDate()` and `FormatPHP::formatTime()` convenience methods. - Add `UnableToFormatStringException` from which other formatting exceptions will descend. +- Add `UnableToFormatDateTimeException` thrown when we're unable to format a date or time string. ### Changed diff --git a/src/Exception/UnableToFormatDateTimeException.php b/src/Exception/UnableToFormatDateTimeException.php new file mode 100644 index 0000000..55d9f4e --- /dev/null +++ b/src/Exception/UnableToFormatDateTimeException.php @@ -0,0 +1,30 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Exception; + +/** + * Thrown when we are unable to format a date/time + */ +class UnableToFormatDateTimeException extends UnableToFormatStringException +{ +} diff --git a/src/FormatPHP.php b/src/FormatPHP.php index 0ef8bdb..3815cad 100644 --- a/src/FormatPHP.php +++ b/src/FormatPHP.php @@ -22,17 +22,25 @@ namespace FormatPHP; -use FormatPHP\Exception\InvalidArgumentException; -use FormatPHP\Exception\UnableToGenerateMessageIdException; +use DateTimeImmutable as PhpDateTimeImmutable; +use DateTimeInterface as PhpDateTimeInterface; +use Exception as PhpException; +use FormatPHP\Intl\DateTimeFormat; +use FormatPHP\Intl\DateTimeFormatOptions; use FormatPHP\Intl\MessageFormat; use FormatPHP\Util\MessageCleaner; use FormatPHP\Util\MessageRetriever; use function array_merge; +use function gettype; use function is_int; +use function is_string; +use function sprintf; /** * FormatPHP internationalization and localization + * + * @psalm-import-type DateTimeType from FormatterInterface */ class FormatPHP implements FormatterInterface { @@ -76,10 +84,10 @@ public function formatMessage(array $descriptor, array $values = []): string $descriptor['description'] ?? null, ), ); - } catch (UnableToGenerateMessageIdException $exception) { - throw new InvalidArgumentException( + } catch (Exception\UnableToGenerateMessageIdException $exception) { + throw new Exception\InvalidArgumentException( 'The message descriptor must have an ID or default message', - is_int($exception->getCode()) ? $exception->getCode() : 0, // @phpstan-ignore-line + (int) $exception->getCode(), $exception, ); } @@ -87,8 +95,69 @@ public function formatMessage(array $descriptor, array $values = []): string return $this->messageFormat->format($this->cleanMessage($messagePattern), $values); } + /** + * @throws Exception\InvalidArgumentException + * @throws Exception\UnableToFormatDateTimeException + * + * @inheritdoc + */ + public function formatDate($date = null, ?DateTimeFormatOptions $options = null): string + { + $formatter = new DateTimeFormat($this->config->getLocale(), $options); + + return $formatter->format($this->convertToDateTime($date)); + } + + /** + * @throws Exception\InvalidArgumentException + * @throws Exception\UnableToFormatDateTimeException + * + * @inheritdoc + */ + public function formatTime($date = null, ?DateTimeFormatOptions $options = null): string + { + $options = $options ?? new DateTimeFormatOptions(); + + if ($options->dateStyle === null && $options->timeStyle === null) { + $options->hour = $options->hour ?? 'numeric'; + $options->minute = $options->minute ?? 'numeric'; + } + + return $this->formatDate($date, $options); + } + protected function getConfig(): ConfigInterface { return $this->config; } + + /** + * @param DateTimeType | mixed $date + * + * @throws Exception\InvalidArgumentException + * @throws PhpException + */ + private function convertToDateTime($date): PhpDateTimeInterface + { + if ($date === null) { + return new PhpDateTimeImmutable(); + } + + if ($date instanceof PhpDateTimeInterface) { + return $date; + } + + if (is_string($date)) { + return new PhpDateTimeImmutable($date); + } + + if (is_int($date)) { + return new PhpDateTimeImmutable('@' . $date); + } + + throw new Exception\InvalidArgumentException(sprintf( + 'Value must be a string, integer, or instance of DateTimeInterface; received \'%s\'', + gettype($date), + )); + } } diff --git a/src/FormatterInterface.php b/src/FormatterInterface.php index 26cc296..dcebe83 100644 --- a/src/FormatterInterface.php +++ b/src/FormatterInterface.php @@ -22,8 +22,15 @@ namespace FormatPHP; +use DateTimeInterface as PhpDateTimeInterface; +use FormatPHP\Intl\DateTimeFormatOptions; + /** * FormatPHP formatter methods + * + * @psalm-type MessageDescriptorType = array{id?: string, defaultMessage?: string, description?: string} + * @psalm-type MessageValuesType = array + * @psalm-type DateTimeType = PhpDateTimeInterface | string | int */ interface FormatterInterface { @@ -36,8 +43,39 @@ interface FormatterInterface * If we cannot find the given ID in the configured messages, we will use * the descriptor's defaultMessage, if provided. * - * @param array{id?: string, defaultMessage?: string, description?: string} $descriptor - * @param array $values + * @throws Exception\InvalidArgumentException + * @throws Exception\UnableToFormatMessageException + * + * @psalm-param MessageDescriptorType $descriptor + * @psalm-param MessageValuesType $values */ public function formatMessage(array $descriptor, array $values = []): string; + + /** + * Returns a date string formatted according to the locale of this formatter + * + * Additional options may be provided to configure how the date should be + * formatted. + * + * @param DateTimeType | null $date + * + * @throws Exception\InvalidArgumentException + * @throws Exception\UnableToFormatDateTimeException + */ + public function formatDate($date = null, ?DateTimeFormatOptions $options = null): string; + + /** + * Returns a date string formatted according to the locale of this formatter, + * but it differs from `formatDate()` by using "numeric" as the default value + * for the `hour` and `minute` options + * + * Additional options may be provided to configure how the date should be + * formatted. + * + * @param DateTimeType | null $date + * + * @throws Exception\InvalidArgumentException + * @throws Exception\UnableToFormatDateTimeException + */ + public function formatTime($date = null, ?DateTimeFormatOptions $options = null): string; } diff --git a/src/Intl/DateTimeFormat.php b/src/Intl/DateTimeFormat.php new file mode 100644 index 0000000..c89ec1d --- /dev/null +++ b/src/Intl/DateTimeFormat.php @@ -0,0 +1,331 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Intl; + +use DateTimeInterface as PhpDateTimeInterface; +use FormatPHP\Exception\InvalidArgumentException; +use FormatPHP\Exception\UnableToFormatDateTimeException; +use IntlDateFormatter as PhpIntlDateFormatter; +use IntlException as PhpIntlException; +use Locale as PhpLocale; +use MessageFormatter as PhpMessageFormatter; +use Throwable; + +use function date_default_timezone_get; +use function date_default_timezone_set; +use function is_int; +use function preg_match; +use function sprintf; + +/** + * Formats a date/time for a given locale + */ +class DateTimeFormat implements DateTimeFormatInterface +{ + private const HOUR_PATTERN = '/(hh?|HH?|kk?|KK?)/'; + + private const STYLE_MAP = [ + 'full' => PhpIntlDateFormatter::FULL, + 'long' => PhpIntlDateFormatter::LONG, + 'medium' => PhpIntlDateFormatter::MEDIUM, + 'short' => PhpIntlDateFormatter::SHORT, + 'none' => PhpIntlDateFormatter::NONE, + ]; + + /** + * These style properties may not be combined with `dateStyle` or `timeStyle` + */ + private const STYLE_PROPERTIES = [ + 'era', + 'year', + 'month', + 'weekday', + 'day', + 'hour', + 'minute', + 'second', + ]; + + private const SYMBOLS_ERA = [ + 'narrow' => 'GGGGG', + 'short' => 'G', + 'long' => 'GGGG', + ]; + + private const SYMBOLS_YEAR = [ + 'numeric' => 'yyyy', + '2-digit' => 'yy', + ]; + + private const SYMBOLS_MONTH = [ + 'numeric' => 'M', + '2-digit' => 'MM', + 'short' => 'MMM', + 'long' => 'MMMM', + 'narrow' => 'MMMMM', + ]; + + private const SYMBOLS_DAY = [ + 'numeric' => 'd', + '2-digit' => 'dd', + ]; + + private const SYMBOLS_WEEKDAY = [ + 'narrow' => 'EEEEE', + 'short' => 'E', + 'long' => 'EEEE', + ]; + + private const SYMBOLS_HOUR = [ + 'h12' => [ + 'numeric' => 'h', + '2-digit' => 'hh', + ], + 'h23' => [ + 'numeric' => 'H', + '2-digit' => 'HH', + ], + 'h24' => [ + 'numeric' => 'k', + '2-digit' => 'kk', + ], + 'h11' => [ + 'numeric' => 'K', + '2-digit' => 'KK', + ], + ]; + + private const SYMBOLS_MINUTE = [ + 'numeric' => 'm', + '2-digit' => 'mm', + ]; + + private const SYMBOLS_SECOND = [ + 'numeric' => 's', + '2-digit' => 'ss', + ]; + + private const SYMBOLS_TIME_ZONE = [ + 'short' => 'z', + 'long' => 'zzzz', + 'shortOffset' => 'Z', + 'longOffset' => 'ZZZZ', + 'shortGeneric' => 'v', + 'longGeneric' => 'vvvv', + ]; + + private const HOUR_CYCLE_MAP = [ + 'h' => 'h12', + 'hh' => 'h12', + 'H' => 'h23', + 'HH' => 'h23', + 'k' => 'h24', + 'kk' => 'h24', + 'K' => 'h11', + 'KK' => 'h11', + ]; + + private string $originalLocaleName; + private string $localeName; + private int $dateType; + private int $timeType; + private ?string $pattern; + private ?string $timeZone; + + /** + * @throws InvalidArgumentException + */ + public function __construct(?LocaleInterface $locale = null, ?DateTimeFormatOptions $options = null) + { + $locale = $locale ?? new Locale(PhpLocale::getDefault()); + $this->originalLocaleName = $locale->toString(); + $options = $options ? clone $options : new DateTimeFormatOptions(); + + $this->checkDateTimeStyle($options); + + $locale = $this->combineLocaleWithOptions($locale, $options); + + $this->localeName = $locale->toString(); + $this->dateType = $this->getDateStyleFallback($options); + $this->timeType = self::STYLE_MAP[$options->timeStyle] ?? PhpIntlDateFormatter::NONE; + $this->pattern = $this->buildPattern($options, $locale, $this->dateType, $this->timeType); + $this->timeZone = $options->timeZone; + } + + /** + * @throws UnableToFormatDateTimeException + */ + public function format(PhpDateTimeInterface $date): string + { + try { + return $this->doFormat($date); + } catch (Throwable $exception) { + throw new UnableToFormatDateTimeException( + sprintf( + 'Unable to format date "%s" for locale "%s"', + $date->format('r'), + $this->originalLocaleName, + ), + is_int($exception->getCode()) ? $exception->getCode() : 0, + $exception, + ); + } + } + + /** + * @throws PhpIntlException + */ + private function doFormat(PhpDateTimeInterface $date): string + { + if ($this->pattern === null) { + $formatter = new PhpIntlDateFormatter($this->localeName, $this->dateType, $this->timeType, $this->timeZone); + + return (string) $formatter->format($date); + } + + // This is a hack, since PHP's MessageFormatter, unlike its + // IntlDateFormatter, has no way to set the timezone it should use when + // formatting dates/times. + $defaultTZ = date_default_timezone_get(); + date_default_timezone_set($this->timeZone ?? $defaultTZ); + + // PHP's `IntlDateFormatter::setPattern()` method leaves much to be desired, + // so we will use the PHP `MessageFormatter` class, instead. + $formatter = new PhpMessageFormatter($this->localeName, $this->pattern); + + $formattedDate = (string) $formatter->format([$date]); + + // Restore the system timezone. + date_default_timezone_set($defaultTZ); + + return $formattedDate; + } + + /** + * @throws InvalidArgumentException + */ + private function checkDateTimeStyle(DateTimeFormatOptions $options): void + { + if ($options->dateStyle === null && $options->timeStyle === null) { + return; + } + + foreach (self::STYLE_PROPERTIES as $property) { + if ($options->{$property} !== null) { + throw new InvalidArgumentException( + 'dateStyle and timeStyle may not be used with other DateTimeFormat options', + ); + } + } + } + + private function combineLocaleWithOptions(LocaleInterface $locale, DateTimeFormatOptions $options): LocaleInterface + { + if ($options->calendar !== null) { + $locale = $locale->withCalendar($options->calendar); + } + + if ($options->numberingSystem !== null) { + $locale = $locale->withNumberingSystem($options->numberingSystem); + } + + return $this->withHourCycleFallback($locale, $options); + } + + /** + * If `dateStyle` is not set, this returns an appropriate fallback style, + * depending on whether other style properties are set + */ + private function getDateStyleFallback(DateTimeFormatOptions $options): int + { + foreach (self::STYLE_PROPERTIES as $property) { + if ($options->{$property} !== null) { + return PhpIntlDateFormatter::NONE; + } + } + + if ($options->timeStyle === null) { + // If everything else is `null`, then default to the "short" style, + // as is the practice in FormatJS. + return self::STYLE_MAP[$options->dateStyle] ?? PhpIntlDateFormatter::SHORT; + } + + return self::STYLE_MAP[$options->dateStyle] ?? PhpIntlDateFormatter::NONE; + } + + private function withHourCycleFallback(LocaleInterface $locale, DateTimeFormatOptions $options): LocaleInterface + { + if ($options->hourCycle !== null) { + return $locale->withHourCycle($options->hourCycle); + } + + // The `hour12` property overrides the `hourCycle` property, in case + // both are present. + if ($options->hour12 !== null) { + return $locale->withHourCycle($options->hour12 ? 'h12' : 'h23'); + } + + if ($locale->hourCycle() !== null) { + return $locale; + } + + // If neither `hourCycle` nor `hour12` are set, we will use PHP's + // IntlDateFormatter class to determine the default hour cycle for + // the locale. + $dateFormatter = new PhpIntlDateFormatter( + $locale->toString(), + PhpIntlDateFormatter::FULL, + PhpIntlDateFormatter::FULL, + ); + + preg_match(self::HOUR_PATTERN, $dateFormatter->getPattern(), $matches); + + // Fallback to h12, if we can't determine the hour cycle from this locale. + return $locale->withHourCycle(self::HOUR_CYCLE_MAP[$matches[1] ?? 'h']); + } + + private function buildPattern( + DateTimeFormatOptions $options, + LocaleInterface $locale, + int $dateType, + int $timeType + ): ?string { + if ($dateType !== PhpIntlDateFormatter::NONE || $timeType !== PhpIntlDateFormatter::NONE) { + return null; + } + + $hourCycle = $locale->hourCycle() ?? ''; + + $pattern = self::SYMBOLS_ERA[$options->era] ?? ''; + $pattern .= self::SYMBOLS_YEAR[$options->year] ?? ''; + $pattern .= self::SYMBOLS_MONTH[$options->month] ?? ''; + $pattern .= self::SYMBOLS_DAY[$options->day] ?? ''; + $pattern .= self::SYMBOLS_WEEKDAY[$options->weekday] ?? ''; + $pattern .= self::SYMBOLS_HOUR[$hourCycle][$options->hour] ?? ''; + $pattern .= self::SYMBOLS_MINUTE[$options->minute] ?? ''; + $pattern .= self::SYMBOLS_SECOND[$options->second] ?? ''; + $pattern .= self::SYMBOLS_TIME_ZONE[$options->timeZoneName] ?? ''; + + return "{0, date, ::$pattern}"; + } +} diff --git a/src/Intl/DateTimeFormatInterface.php b/src/Intl/DateTimeFormatInterface.php new file mode 100644 index 0000000..80919e0 --- /dev/null +++ b/src/Intl/DateTimeFormatInterface.php @@ -0,0 +1,46 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +declare(strict_types=1); + +namespace FormatPHP\Intl; + +use DateTimeInterface; +use FormatPHP\Exception\UnableToFormatDateTimeException; + +/** + * A date formatter designed to fit within the style and function of + * ECMA-402 formatters + * + * @link https://unicode-org.github.io/icu/userguide/format_parse/datetime/ + * @link https://www.php.net/IntlDateFormatter + * @link https://tc39.es/ecma402/#datetimeformat-objects + * @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat + */ +interface DateTimeFormatInterface +{ + /** + * Formats a date or time, using a locale configured with the date/time + * format instance + * + * @throws UnableToFormatDateTimeException + */ + public function format(DateTimeInterface $date): string; +} diff --git a/src/Intl/DateTimeFormatOptions.php b/src/Intl/DateTimeFormatOptions.php index e414090..b4b8630 100644 --- a/src/Intl/DateTimeFormatOptions.php +++ b/src/Intl/DateTimeFormatOptions.php @@ -31,7 +31,7 @@ * @psalm-type FractionDigitsType = 0 | 1 | 2 | 3 * @psalm-type HourType = "h11" | "h12" | "h23" | "h24" * @psalm-type PeriodType = "narrow" | "short" | "long" - * @psalm-type StyleType = "full" | "long" | "medium" | "short" | "none" + * @psalm-type StyleType = "full" | "long" | "medium" | "short" * @psalm-type TimeZoneNameType = "long" | "short" | "shortOffset" | "longOffset" | "shortGeneric" | "longGeneric" * @psalm-type TimeZoneType = non-empty-string * @psalm-type WidthType = "numeric" | "2-digit" @@ -45,7 +45,6 @@ class DateTimeFormatOptions implements JsonSerializable public const STYLE_LONG = 'long'; public const STYLE_MEDIUM = 'medium'; public const STYLE_SHORT = 'short'; - public const STYLE_NONE = 'none'; public const PERIOD_NARROW = 'narrow'; public const PERIOD_SHORT = 'short'; diff --git a/src/Intl/Locale.php b/src/Intl/Locale.php index 764f1d2..18c1ca3 100644 --- a/src/Intl/Locale.php +++ b/src/Intl/Locale.php @@ -37,6 +37,12 @@ /** * An implementation of an ECMA-402 locale identifier + * + * @psalm-import-type CalendarType from DateTimeFormatOptions + * @psalm-import-type HourType from DateTimeFormatOptions + * @psalm-import-type NumeralType from NumberFormatOptions + * @psalm-import-type CaseFirstType from LocaleOptions + * @psalm-import-type CollationType from LocaleOptions */ class Locale implements LocaleInterface { @@ -144,21 +150,53 @@ public function baseName(): ?string return implode('-', array_filter($parts)); } + /** + * @return CalendarType | null + */ public function calendar(): ?string { + /** @var non-empty-string | null $calendar */ $calendar = $this->parsedLocale['keywords']['calendar'] ?? null; return self::CALENDAR_MAP[$calendar] ?? $calendar; } + /** + * @param CalendarType $calendar + */ + public function withCalendar(string $calendar): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['calendar'] = $calendar; + + return $locale; + } + + /** + * @return CaseFirstType | null + */ public function caseFirst(): ?string { $colcasefirst = $this->parsedLocale['keywords']['colcasefirst'] ?? null; - /** @var "false" | "upper" | "lower" | null */ + /** @var CaseFirstType | null */ return self::CASE_FIRST_MAP[$colcasefirst] ?? $colcasefirst; } + /** + * @param CaseFirstType $caseFirst + */ + public function withCaseFirst(string $caseFirst): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['colcasefirst'] = $caseFirst; + + return $locale; + } + + /** + * @return CollationType | null + */ public function collation(): ?string { $collation = $this->parsedLocale['keywords']['collation'] ?? null; @@ -166,17 +204,50 @@ public function collation(): ?string return self::COLLATION_MAP[$collation] ?? $collation; } + /** + * @param CollationType $collation + */ + public function withCollation(string $collation): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['collation'] = $collation; + + return $locale; + } + + /** + * @return HourType | null + */ public function hourCycle(): ?string { - /** @var "h11" | "h12" | "h23" | "h24" | null */ + /** @var HourType | null */ return $this->parsedLocale['keywords']['hours'] ?? null; } + /** + * @param HourType $hourCycle + */ + public function withHourCycle(string $hourCycle): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['hours'] = $hourCycle; + + return $locale; + } + public function language(): ?string { return $this->parsedLocale['language'] ?? null; } + public function withLanguage(string $language): self + { + $locale = clone $this; + $locale->parsedLocale['language'] = $language; + + return $locale; + } + /** * @return no-return * @@ -197,28 +268,67 @@ public function minimize(): LocaleInterface throw new BadMethodCallException('Method not implemented'); } + /** + * @return NumeralType | null + */ public function numberingSystem(): ?string { + /** @var non-empty-string | null $numbers */ $numbers = $this->parsedLocale['keywords']['numbers'] ?? null; return self::NUMBERING_SYSTEM_MAP[$numbers] ?? $numbers; } + /** + * @param NumeralType $numberingSystem + */ + public function withNumberingSystem(string $numberingSystem): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['numbers'] = $numberingSystem; + + return $locale; + } + public function numeric(): bool { return ($this->parsedLocale['keywords']['colnumeric'] ?? null) === 'yes'; } + public function withNumeric(bool $numeric): self + { + $locale = clone $this; + $locale->parsedLocale['keywords']['colnumeric'] = $numeric ? 'yes' : 'no'; + + return $locale; + } + public function region(): ?string { return $this->parsedLocale['region'] ?? null; } + public function withRegion(string $region): self + { + $locale = clone $this; + $locale->parsedLocale['region'] = $region; + + return $locale; + } + public function script(): ?string { return $this->parsedLocale['script'] ?? null; } + public function withScript(string $script): self + { + $locale = clone $this; + $locale->parsedLocale['script'] = $script; + + return $locale; + } + public function toString(): string { $locale = (string) $this->baseName(); diff --git a/src/Intl/LocaleInterface.php b/src/Intl/LocaleInterface.php index f71eeea..7a95158 100644 --- a/src/Intl/LocaleInterface.php +++ b/src/Intl/LocaleInterface.php @@ -29,6 +29,12 @@ * ECMAScript 2022 Internationalization API Specification (ECMA-402 9th Edition). * * @link https://tc39.es/ecma402/#locale-objects + * + * @psalm-import-type CalendarType from DateTimeFormatOptions + * @psalm-import-type HourType from DateTimeFormatOptions + * @psalm-import-type NumeralType from NumberFormatOptions + * @psalm-import-type CaseFirstType from LocaleOptions + * @psalm-import-type CollationType from LocaleOptions */ interface LocaleInterface { @@ -39,33 +45,71 @@ public function baseName(): ?string; /** * Returns this locale's calendar era + * + * @return CalendarType | null */ public function calendar(): ?string; + /** + * Returns a new instance of the locale, combined with the given calendar + * + * @param CalendarType $calendar + */ + public function withCalendar(string $calendar): self; + /** * Returns whether case is accounted for in this locale's collation rules * - * @psalm-return "upper" | "lower" | "false" | null + * @psalm-return CaseFirstType | null */ public function caseFirst(): ?string; + /** + * Returns a new instance of the locale, combined with the given case + * collation + * + * @param CaseFirstType $caseFirst + */ + public function withCaseFirst(string $caseFirst): self; + /** * Returns this locale's collation type + * + * @return CollationType | null */ public function collation(): ?string; + /** + * Returns a new instance of the locale, combined with the given collation + * + * @param CollationType $collation + */ + public function withCollation(string $collation): self; + /** * Returns this locale's time-keeping convention * - * @psalm-return "h11" | "h12" | "h23" | "h24" | null + * @psalm-return HourType | null */ public function hourCycle(): ?string; + /** + * Returns a new instance of the locale, combined with the given hour cycle + * + * @param HourType $hourCycle + */ + public function withHourCycle(string $hourCycle): self; + /** * Returns this locale's language */ public function language(): ?string; + /** + * Returns a new instance of the locale, combined with the given language + */ + public function withLanguage(string $language): self; + /** * Using the existing values set on this locale instance, returns the most * likely values that can be determined for language, script, and region @@ -80,25 +124,51 @@ public function minimize(): LocaleInterface; /** * Returns this locale's numeral system + * + * @return NumeralType | null */ public function numberingSystem(): ?string; + /** + * Returns a new instance of the locale, combined with the given numbering + * system + * + * @param NumeralType $numberingSystem + */ + public function withNumberingSystem(string $numberingSystem): self; + /** * Returns whether this locale has special collation handling for * numeric strings */ public function numeric(): bool; + /** + * Returns a new instance of the locale, with the numeric collation handling + * toggled on or off + */ + public function withNumeric(bool $numeric): self; + /** * Returns this locale's region */ public function region(): ?string; + /** + * Returns a new instance of the locale, combined with the given region + */ + public function withRegion(string $region): self; + /** * Returns this locale's script used for writing */ public function script(): ?string; + /** + * Returns a new instance of the locale, combined with the given script + */ + public function withScript(string $script): self; + /** * Returns the full string identifier for this locale */ diff --git a/src/Intl/LocaleOptions.php b/src/Intl/LocaleOptions.php index ec2fcca..6b931c4 100644 --- a/src/Intl/LocaleOptions.php +++ b/src/Intl/LocaleOptions.php @@ -24,11 +24,19 @@ /** * Configuration options for the locale identifier + * + * @psalm-import-type CalendarType from DateTimeFormatOptions + * @psalm-import-type HourType from DateTimeFormatOptions + * @psalm-import-type NumeralType from NumberFormatOptions + * @psalm-type CaseFirstType = "upper" | "lower" | "false" + * @psalm-type CollationType = "big5han" | "compat" | "dict" | "direct" | "ducet" | "emoji" | "eor" | "gb2312" | "phonebk" | "phonetic" | "pinyin" | "reformed" | "search" | "searchjl" | "standard" | "stroke" | "trad" | "unihan" | "zhuyin" | string */ class LocaleOptions { /** * The locale's calendar era + * + * @psalm-var CalendarType | null */ public ?string $calendar = null; @@ -36,12 +44,14 @@ class LocaleOptions * Whether case should be accounted for in the locale's collation rules * (i.e. `"upper"`, `"lower"`, or `"false"`) * - * @psalm-var "upper" | "lower" | "false" + * @psalm-var CaseFirstType | null */ public ?string $caseFirst = null; /** * The locale's collation type + * + * @psalm-var CollationType | null */ public ?string $collation = null; @@ -49,7 +59,7 @@ class LocaleOptions * The locale's time-keeping convention (i.e., `"h11"`, `"h12"`, `"h23"`, * or `"h24"`) * - * @psalm-var "h11" | "h12" | "h23" | "h24" | null + * @psalm-var HourType | null */ public ?string $hourCycle = null; @@ -60,6 +70,8 @@ class LocaleOptions /** * The locale's numeral system + * + * @psalm-var NumeralType | null */ public ?string $numberingSystem = null; @@ -79,14 +91,14 @@ class LocaleOptions public ?string $script = null; /** - * @param string | null $calendar The locale's calendar era - * @param string | null $caseFirst Whether case should be accounted for in + * @param CalendarType | null $calendar The locale's calendar era + * @param CaseFirstType | null $caseFirst Whether case should be accounted for in * the locale's collation rules (i.e. `"upper"`, `"lower"`, or `"false"`) - * @param string | null $collation The locale's collation type - * @param string | null $hourCycle The locale's time-keeping convention + * @param CollationType | null $collation The locale's collation type + * @param HourType | null $hourCycle The locale's time-keeping convention * (i.e., `"h11"`, `"h12"`, `"h23"`, or `"h24"`) * @param string | null $language The locale's language - * @param string | null $numberingSystem The locale's numeral system + * @param NumeralType | null $numberingSystem The locale's numeral system * @param bool | null $numeric Whether the locale has special collation * handling for numeric strings * @param string | null $region The locale's region diff --git a/tests/Console/Command/ExtractCommandTest.php b/tests/Console/Command/ExtractCommandTest.php index dd0096c..4f75de1 100644 --- a/tests/Console/Command/ExtractCommandTest.php +++ b/tests/Console/Command/ExtractCommandTest.php @@ -68,7 +68,7 @@ public function testExecuteWithValidation(): void $errorOutput, ); $this->assertStringContainsString( - '86 Descriptor argument must have at least one of id, defaultMessage, or', + '90 Descriptor argument must have at least one of id, defaultMessage, or', $errorOutput, ); $this->assertStringContainsString( diff --git a/tests/FormatPHPTest.php b/tests/FormatPHPTest.php index 5beb9eb..97398c4 100644 --- a/tests/FormatPHPTest.php +++ b/tests/FormatPHPTest.php @@ -4,13 +4,17 @@ namespace FormatPHP\Test; +use DateTimeImmutable; use FormatPHP\Config; use FormatPHP\Exception\InvalidArgumentException; use FormatPHP\FormatPHP; +use FormatPHP\Intl\DateTimeFormatOptions; use FormatPHP\Intl\Locale; use FormatPHP\Message; use FormatPHP\MessageCollection; +use function date; + class FormatPHPTest extends TestCase { public function testFormatMessage(): void @@ -112,4 +116,151 @@ public function testFormatMessageUsesDefaultRichTextElements(): void ), ); } + + public function testFormatDateUsesCurrentDateWhenNoValuePassed(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $this->assertSame(date('n/j/y'), $formatphp->formatDate()); + } + + public function testFormatDateWithUnixTimestamp(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $this->assertSame( + '10/25/21', + $formatphp->formatDate(1635204852), + ); + } + + public function testFormatDateWithOptions(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $this->assertSame( + 'Monday, October 25, 2021 at 11:34:12 PM UTC', + $formatphp->formatDate(1635204852, new DateTimeFormatOptions([ + 'dateStyle' => 'full', + 'timeStyle' => 'long', + ])), + ); + } + + public function testFormatDateWithDateTimeInstance(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $date = new DateTimeImmutable('@' . 1635204852); + + $this->assertSame( + '10/25/21', + $formatphp->formatDate($date), + ); + } + + public function testFormatDateWithString(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $this->assertSame( + '10/25/21', + $formatphp->formatDate('Mon, 25 Oct 2021 23:34:12 +0000'), + ); + } + + public function testFormatDateThrowsExceptionForInvalidArgument(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Value must be a string, integer, or instance of DateTimeInterface; received \'boolean\'', + ); + + // @phpstan-ignore-next-line + $formatphp->formatDate(false); + } + + public function testFormatTime(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $this->assertSame( + '11:34 PM', + $formatphp->formatTime('Mon, 25 Oct 2021 23:34:12 +0000'), + ); + } + + public function testFormatTimeWithOptions(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $this->assertSame( + '11:34:12 PM', + $formatphp->formatTime(1635204852, new DateTimeFormatOptions([ + 'second' => 'numeric', + ])), + ); + } + + public function testFormatTimeWithTimeStyle(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $this->assertSame( + '11:34:12 PM Coordinated Universal Time', + $formatphp->formatTime(1635204852, new DateTimeFormatOptions([ + 'timeStyle' => 'full', + ])), + ); + } + + public function testFormatTimeWithDateStyle(): void + { + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + // Mon, 25 Oct 2021 23:34:12 +0000 + $this->assertSame( + 'Monday, October 25, 2021', + $formatphp->formatTime(1635204852, new DateTimeFormatOptions([ + 'dateStyle' => 'full', + ])), + ); + } } diff --git a/tests/Intl/DateTimeFormatOptionsTest.php b/tests/Intl/DateTimeFormatOptionsTest.php index 9177865..811d5f8 100644 --- a/tests/Intl/DateTimeFormatOptionsTest.php +++ b/tests/Intl/DateTimeFormatOptionsTest.php @@ -49,10 +49,6 @@ public function publicConstantsProvider(): array 'constantName' => "$class::STYLE_SHORT", 'expectedValue' => 'short', ], - [ - 'constantName' => "$class::STYLE_NONE", - 'expectedValue' => 'none', - ], [ 'constantName' => "$class::PERIOD_NARROW", 'expectedValue' => 'narrow', diff --git a/tests/Intl/DateTimeFormatTest.php b/tests/Intl/DateTimeFormatTest.php new file mode 100644 index 0000000..4b96292 --- /dev/null +++ b/tests/Intl/DateTimeFormatTest.php @@ -0,0 +1,540 @@ +defaultTimezone = date_default_timezone_get(); + date_default_timezone_set(self::TEST_TIMEZONE); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->defaultTimezone ?? 'UTC'); + } + + /** + * @psalm-param OptionsType $options + * @dataProvider formatProvider + */ + public function testFormat(array $options, string $ko, string $en): void + { + $koLocale = new Locale('ko'); + $enLocale = new Locale('en'); + $formatOptions = new DateTimeFormatOptions($options); + + $koFormatter = new DateTimeFormat($koLocale, $formatOptions); + $enFormatter = new DateTimeFormat($enLocale, $formatOptions); + $date = new DateTimeImmutable('@' . self::TS); + + $this->assertSame($en, $enFormatter->format($date)); + $this->assertSame($ko, $koFormatter->format($date)); + + // We change the default timezone within the SUT, so let's assert + // that is changed back to the value we set in this test's setUp(). + $this->assertSame(self::TEST_TIMEZONE, date_default_timezone_get()); + } + + public function testFormatThrowsException(): void + { + $formatter = new DateTimeFormat(new Locale('en'), new DateTimeFormatOptions([ + 'timeZone' => 'America/Foobar', + ])); + + $this->expectException(UnableToFormatDateTimeException::class); + $this->expectExceptionMessage( + 'Unable to format date "Fri, 07 Jan 2022 21:36:52 +0000" for locale "en"', + ); + + $formatter->format(new DateTimeImmutable('@' . 1641591412)); + } + + /** + * @psalm-param OptionsType $additionalOptions + * @dataProvider formatThrowsExceptionWhenDateStyleOrTimeStyleMixedWithStylePropertyProvider + */ + public function testFormatThrowsExceptionWhenDateStyleOrTimeStyleMixedWithStyleProperty( + ?string $dateStyle, + ?string $timeStyle, + array $additionalOptions + ): void { + /** @var OptionsType $combinedOptions */ + $combinedOptions = array_merge( + [ + 'dateStyle' => $dateStyle, + 'timeStyle' => $timeStyle, + ], + $additionalOptions, + ); + + $options = new DateTimeFormatOptions($combinedOptions); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('dateStyle and timeStyle may not be used with other DateTimeFormat options'); + + new DateTimeFormat(null, $options); + } + + public function testHour12OverridesHourCycle(): void + { + $enLocale = new Locale('en'); + $formatOptions = new DateTimeFormatOptions([ + 'dateStyle' => 'full', + 'timeStyle' => 'full', + // Specify both hourCycle and hour12 to show that hour12 overrides hourCycle. + 'hourCycle' => 'h23', + 'hour12' => true, + 'timeZone' => 'America/Denver', + ]); + + $enFormatter = new DateTimeFormat($enLocale, $formatOptions); + $date = new DateTimeImmutable('@' . self::TS); + + $this->assertSame( + 'Monday, June 15, 2020 at 10:48:20 PM Mountain Daylight Time', + $enFormatter->format($date), + ); + } + + public function testUseHourCycleFromLocale(): void + { + $enLocale = new Locale('en'); + $enLocale = $enLocale->withHourCycle('h23'); + + $formatOptions = new DateTimeFormatOptions([ + 'dateStyle' => 'full', + 'timeStyle' => 'full', + 'timeZone' => 'America/Denver', + ]); + + $enFormatter = new DateTimeFormat($enLocale, $formatOptions); + $date = new DateTimeImmutable('@' . self::TS); + + // This should be 22 instead of 10 (because of the "h23" hourCycle, + // but PHP's MessageFormatter (perhaps through extension of icu4c's + // u_formatMessage) always takes the locale's formatting into + // consideration and renders this without the 24-hour time. + $this->assertSame( + 'Monday, June 15, 2020 at 10:48:20 PM Mountain Daylight Time', + $enFormatter->format($date), + ); + } + + /** + * Tests taken from FormatJS tests for DateTimeFormat + * + * @link https://github.com/formatjs/formatjs/blob/da104e8421dcc5480e38aeec4a32891f8941332f/packages/intl-datetimeformat/tests/format.test.ts#L15-L275 + * + * @return array + */ + public function formatProvider(): array + { + return [ + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => 'numeric', + 'month' => 'numeric', + 'day' => 'numeric', + 'hour' => 'numeric', + 'minute' => 'numeric', + 'second' => 'numeric', + 'hour12' => true, + 'timeZone' => 'UTC', + 'timeZoneName' => 'long', + ], + 'ko' => '서기 2020년 6 16일 화요일 오전 4시 48분 20초 협정 세계시', + 'en' => 'Tuesday, 6 16, 2020 Anno Domini, 4:48:20 AM Coordinated Universal Time', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => 'numeric', + 'month' => 'numeric', + 'day' => 'numeric', + 'hour' => 'numeric', + 'minute' => 'numeric', + 'second' => 'numeric', + 'hour12' => true, + 'timeZone' => 'America/New_York', + 'timeZoneName' => 'short', + ], + 'ko' => '서기 2020년 6 16일 화요일 오전 12시 48분 20초 GMT-4', + 'en' => 'Tuesday, 6 16, 2020 Anno Domini, 12:48:20 AM EDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => 'numeric', + 'month' => 'numeric', + 'day' => '2-digit', + 'hour' => 'numeric', + 'minute' => 'numeric', + 'second' => 'numeric', + 'hour12' => true, + 'timeZone' => 'America/New_York', + 'timeZoneName' => 'short', + ], + 'ko' => '서기 2020년 6 16일 화요일 오전 12시 48분 20초 GMT-4', + 'en' => 'Tuesday, 6 16, 2020 Anno Domini, 12:48:20 AM EDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => 'numeric', + 'month' => 'numeric', + 'day' => '2-digit', + 'hour' => 'numeric', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/New_York', + 'timeZoneName' => 'short', + ], + 'ko' => '서기 2020년 6 16일 화요일 오전 12시 48분 20초 GMT-4', + 'en' => 'Tuesday, 6 16, 2020 Anno Domini, 12:48:20 AM EDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => 'numeric', + 'month' => 'numeric', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => '서기 2020년 6 15일 월요일 오후 9시 48분 20초 GMT-7', + 'en' => 'Monday, 6 15, 2020 Anno Domini, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => '2-digit', + 'month' => 'long', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => '서기 20년 6월 15일 월요일 오후 9시 48분 20초 GMT-7', + 'en' => 'Monday, June 15, 20 Anno Domini, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => '2-digit', + 'month' => 'short', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => '서기 20년 6월 15일 월요일 오후 9시 48분 20초 GMT-7', + 'en' => 'Monday, Jun 15, 20 Anno Domini, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'long', + 'year' => '2-digit', + 'month' => 'narrow', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => '서기 20년 6월 15일 월요일 오후 9시 48분 20초 GMT-7', + 'en' => 'Monday, J 15, 20 Anno Domini, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'long', + 'era' => 'short', + 'year' => '2-digit', + 'month' => 'narrow', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => 'AD 20년 6월 15일 월요일 오후 9시 48분 20초 GMT-7', + 'en' => 'Monday, J 15, 20 AD, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'narrow', + 'era' => 'short', + 'year' => '2-digit', + 'month' => 'narrow', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => 'AD 20년 6월 15일 (월) 오후 9시 48분 20초 GMT-7', + 'en' => 'M, J 15, 20 AD, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'weekday' => 'short', + 'era' => 'short', + 'year' => '2-digit', + 'month' => 'narrow', + 'day' => '2-digit', + 'hour' => '2-digit', + 'minute' => 'numeric', + 'second' => 'numeric', + 'timeZone' => 'America/Los_Angeles', + 'timeZoneName' => 'short', + ], + // This should be 09 instead of 9, but PHP's MessageFormatter + // (perhaps through extension of icu4c's u_formatMessage) always + // takes the locale's formatting into consideration and renders + // this without the zero padding. + 'ko' => 'AD 20년 6월 15일 (월) 오후 9시 48분 20초 GMT-7', + 'en' => 'Mon, J 15, 20 AD, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'dateStyle' => 'full', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020년 6월 15일 월요일', + 'en' => 'Monday, June 15, 2020', + ], + [ + 'options' => [ + 'dateStyle' => 'long', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020년 6월 15일', + 'en' => 'June 15, 2020', + ], + [ + 'options' => [ + 'dateStyle' => 'medium', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020. 6. 15.', + 'en' => 'Jun 15, 2020', + ], + [ + 'options' => [ + 'dateStyle' => 'short', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '20. 6. 15.', + 'en' => '6/15/20', + ], + [ + 'options' => [ + 'timeStyle' => 'full', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '오후 9시 48분 20초 미 태평양 하계 표준시', + 'en' => '9:48:20 PM Pacific Daylight Time', + ], + [ + 'options' => [ + 'timeStyle' => 'long', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '오후 9시 48분 20초 GMT-7', + 'en' => '9:48:20 PM PDT', + ], + [ + 'options' => [ + 'timeStyle' => 'medium', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '오후 9:48:20', + 'en' => '9:48:20 PM', + ], + [ + 'options' => [ + 'timeStyle' => 'short', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '오후 9:48', + 'en' => '9:48 PM', + ], + [ + 'options' => [ + 'dateStyle' => 'long', + 'timeStyle' => 'full', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020년 6월 15일 오후 9시 48분 20초 미 태평양 하계 표준시', + 'en' => 'June 15, 2020 at 9:48:20 PM Pacific Daylight Time', + ], + [ + 'options' => [ + 'dateStyle' => 'medium', + 'timeStyle' => 'long', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020. 6. 15. 오후 9시 48분 20초 GMT-7', + 'en' => 'Jun 15, 2020, 9:48:20 PM PDT', + ], + [ + 'options' => [ + 'dateStyle' => 'short', + 'timeStyle' => 'medium', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '20. 6. 15. 오후 9:48:20', + 'en' => '6/15/20, 9:48:20 PM', + ], + [ + 'options' => [ + 'dateStyle' => 'full', + 'timeStyle' => 'short', + 'timeZone' => 'America/Los_Angeles', + ], + 'ko' => '2020년 6월 15일 월요일 오후 9:48', + 'en' => 'Monday, June 15, 2020 at 9:48 PM', + ], + [ + 'options' => [ + 'dateStyle' => 'full', + 'timeStyle' => 'full', + 'calendar' => 'buddhist', + 'timeZone' => 'America/Denver', + ], + 'ko' => 'AD 2020년 6월 15일 월요일 오후 10시 48분 20초 미 산지 하계 표준시', + 'en' => 'Monday, June 15, 2020 AD at 10:48:20 PM Mountain Daylight Time', + ], + [ + 'options' => [ + 'dateStyle' => 'full', + 'timeStyle' => 'full', + 'numberingSystem' => 'hant', + 'timeZone' => 'America/Denver', + ], + 'ko' => '二千零二十년 六월 十五일 월요일 오후 十시 四十八분 二十초 미 산지 하계 표준시', + 'en' => 'Monday, June 十五, 二千零二十 at 十:四十八:二十 PM Mountain Daylight Time', + ], + [ + 'options' => [ + 'dateStyle' => 'full', + 'timeStyle' => 'full', + 'hourCycle' => 'h23', + 'timeZone' => 'America/Denver', + ], + // This should be 22 instead of 10 (because of the "h23" hourCycle, + // but PHP's MessageFormatter (perhaps through extension of icu4c's + // u_formatMessage) always takes the locale's formatting into + // consideration and renders this without the 24-hour time. + 'ko' => '2020년 6월 15일 월요일 오후 10시 48분 20초 미 산지 하계 표준시', + 'en' => 'Monday, June 15, 2020 at 10:48:20 PM Mountain Daylight Time', + ], + ]; + } + + /** + * @return array + */ + public function formatThrowsExceptionWhenDateStyleOrTimeStyleMixedWithStylePropertyProvider(): array + { + $styleProperties = [ + 'era' => 'short', + 'year' => 'numeric', + 'month' => 'numeric', + 'weekday' => 'long', + 'day' => 'numeric', + 'hour' => '2-digit', + 'minute' => '2-digit', + 'second' => '2-digit', + ]; + + $tests = []; + foreach ($styleProperties as $property => $value) { + $tests[] = [ + 'dateStyle' => 'full', + 'timeStyle' => null, + 'additionalOptions' => [$property => $value], + ]; + $tests[] = [ + 'dateStyle' => null, + 'timeStyle' => 'full', + 'additionalOptions' => [$property => $value], + ]; + } + + /** @var array */ + return $tests; + } +} diff --git a/tests/Intl/LocaleTest.php b/tests/Intl/LocaleTest.php index 0976723..e59257b 100644 --- a/tests/Intl/LocaleTest.php +++ b/tests/Intl/LocaleTest.php @@ -210,4 +210,115 @@ public function testMinimizeThrowsException(): void $locale->minimize(); } + + public function testWithCalendar(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withCalendar('buddhist'); + + $this->assertNull($locale1->calendar()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('buddhist', $locale2->calendar()); + $this->assertSame('en-US-u-ca-buddhist', $locale2->toString()); + } + + public function testWithCaseFirst(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withCaseFirst('upper'); + + $this->assertNotSame($locale1, $locale2); + $this->assertNull($locale1->caseFirst()); + $this->assertSame('upper', $locale2->caseFirst()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-US-u-kf-upper', $locale2->toString()); + } + + public function testWithCollation(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withCollation('big5han'); + + $this->assertNotSame($locale1, $locale2); + $this->assertNull($locale1->collation()); + $this->assertSame('big5han', $locale2->collation()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-US-u-co-big5han', $locale2->toString()); + } + + public function testWithHourCycle(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withHourCycle('h23'); + + $this->assertNotSame($locale1, $locale2); + $this->assertNull($locale1->hourCycle()); + $this->assertSame('h23', $locale2->hourCycle()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-US-u-hc-h23', $locale2->toString()); + } + + public function testWithLanguage(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withLanguage('es'); + + $this->assertNotSame($locale1, $locale2); + $this->assertSame('en', $locale1->language()); + $this->assertSame('es', $locale2->language()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('es-US', $locale2->toString()); + } + + public function testWithNumberingSystem(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withNumberingSystem('arab'); + + $this->assertNotSame($locale1, $locale2); + $this->assertNull($locale1->numberingSystem()); + $this->assertSame('arab', $locale2->numberingSystem()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-US-u-nu-arab', $locale2->toString()); + } + + public function testWithNumeric(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withNumeric(true); + $locale3 = $locale1->withNumeric(false); + + $this->assertNotSame($locale1, $locale2); + $this->assertNotSame($locale2, $locale3); + $this->assertFalse($locale1->numeric()); + $this->assertTrue($locale2->numeric()); + $this->assertFalse($locale3->numeric()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-US-u-kn-true', $locale2->toString()); + $this->assertSame('en-US-u-kn-false', $locale3->toString()); + } + + public function testWithRegion(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withRegion('CA'); + + $this->assertNotSame($locale1, $locale2); + $this->assertSame('US', $locale1->region()); + $this->assertSame('CA', $locale2->region()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-CA', $locale2->toString()); + } + + public function testWithScript(): void + { + $locale1 = new Locale('en-US'); + $locale2 = $locale1->withScript('Latn'); + + $this->assertNotSame($locale1, $locale2); + $this->assertNull($locale1->script()); + $this->assertSame('Latn', $locale2->script()); + $this->assertSame('en-US', $locale1->toString()); + $this->assertSame('en-Latn-US', $locale2->toString()); + } } From ec3eacfee27dd3121d325da377140e983c4df3e8 Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Thu, 20 Jan 2022 20:21:59 -0600 Subject: [PATCH 2/2] refactor: clone options so we don't change the instance passed --- src/FormatPHP.php | 2 +- tests/FormatPHPTest.php | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/FormatPHP.php b/src/FormatPHP.php index 3815cad..1f5be69 100644 --- a/src/FormatPHP.php +++ b/src/FormatPHP.php @@ -116,7 +116,7 @@ public function formatDate($date = null, ?DateTimeFormatOptions $options = null) */ public function formatTime($date = null, ?DateTimeFormatOptions $options = null): string { - $options = $options ?? new DateTimeFormatOptions(); + $options = $options ? clone $options : new DateTimeFormatOptions(); if ($options->dateStyle === null && $options->timeStyle === null) { $options->hour = $options->hour ?? 'numeric'; diff --git a/tests/FormatPHPTest.php b/tests/FormatPHPTest.php index 97398c4..9e31433 100644 --- a/tests/FormatPHPTest.php +++ b/tests/FormatPHPTest.php @@ -263,4 +263,45 @@ public function testFormatTimeWithDateStyle(): void ])), ); } + + public function testFormatTimeDoesNotModifyPassedDateTimeFormatOptionsInstance(): void + { + $time = 1642708984; // Thu, 20 Jan 2022 20:03:04 +0000 + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $options = new DateTimeFormatOptions(); + + // These should be null before passing them to formatTime(). + $this->assertNull($options->hour); + $this->assertNull($options->minute); + + $this->assertSame('8:03 PM', $formatphp->formatTime($time, $options)); + + // These should still be null after passing them to formatTime(). + $this->assertNull($options->hour); + $this->assertNull($options->minute); + } + + public function testFormatTimeUsesProvidedHourMinuteOptions(): void + { + $time = 1642708984; // Thu, 20 Jan 2022 20:03:04 +0000 + $locale = new Locale('en'); + $config = new Config($locale); + $messageCollection = new MessageCollection(); + $formatphp = new FormatPHP($config, $messageCollection); + + $options = new DateTimeFormatOptions([ + 'hour' => '2-digit', + 'minute' => '2-digit', + ]); + + $this->assertSame('8:03 PM', $formatphp->formatTime($time, $options)); + + // These should not change after being passed to formatTime(). + $this->assertSame('2-digit', $options->hour); + $this->assertSame('2-digit', $options->minute); + } }