diff --git a/Countries.php b/Countries.php index 6a4ef2c1..a50e7624 100644 --- a/Countries.php +++ b/Countries.php @@ -89,7 +89,7 @@ public static function alpha3CodeExists(string $alpha3Code): bool * * @throws MissingResourceException if the country code does not exist */ - public static function getName(string $country, string $displayLocale = null): string + public static function getName(string $country, ?string $displayLocale = null): string { return self::readEntry(['Names', $country], $displayLocale); } @@ -99,7 +99,7 @@ public static function getName(string $country, string $displayLocale = null): s * * @throws MissingResourceException if the country code does not exist */ - public static function getAlpha3Name(string $alpha3Code, string $displayLocale = null): string + public static function getAlpha3Name(string $alpha3Code, ?string $displayLocale = null): string { return self::getName(self::getAlpha2Code($alpha3Code), $displayLocale); } @@ -109,7 +109,7 @@ public static function getAlpha3Name(string $alpha3Code, string $displayLocale = * * @return array */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { return self::asort(self::readEntry(['Names'], $displayLocale), $displayLocale); } @@ -121,7 +121,7 @@ public static function getNames(string $displayLocale = null): array * * @return array */ - public static function getAlpha3Names(string $displayLocale = null): array + public static function getAlpha3Names(?string $displayLocale = null): array { $alpha2Names = self::getNames($displayLocale); $alpha3Names = []; diff --git a/Currencies.php b/Currencies.php index 2f52576d..ce7fe5e2 100644 --- a/Currencies.php +++ b/Currencies.php @@ -50,7 +50,7 @@ public static function exists(string $currency): bool /** * @throws MissingResourceException if the currency code does not exist */ - public static function getName(string $currency, string $displayLocale = null): string + public static function getName(string $currency, ?string $displayLocale = null): string { return self::readEntry(['Names', $currency, self::INDEX_NAME], $displayLocale); } @@ -58,7 +58,7 @@ public static function getName(string $currency, string $displayLocale = null): /** * @return string[] */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { // ==================================================================== // For reference: It is NOT possible to return names indexed by @@ -82,7 +82,7 @@ public static function getNames(string $displayLocale = null): array /** * @throws MissingResourceException if the currency code does not exist */ - public static function getSymbol(string $currency, string $displayLocale = null): string + public static function getSymbol(string $currency, ?string $displayLocale = null): string { return self::readEntry(['Names', $currency, self::INDEX_SYMBOL], $displayLocale); } diff --git a/Data/Generator/TimezoneDataGenerator.php b/Data/Generator/TimezoneDataGenerator.php index a0573e36..9de6a0cd 100644 --- a/Data/Generator/TimezoneDataGenerator.php +++ b/Data/Generator/TimezoneDataGenerator.php @@ -159,7 +159,7 @@ private function generateZones(BundleEntryReaderInterface $reader, string $tempD $regionFormat = $reader->readEntry($tempDir, $locale, ['zoneStrings', 'regionFormat']); $fallbackFormat = $reader->readEntry($tempDir, $locale, ['zoneStrings', 'fallbackFormat']); - $resolveName = function (string $id, string $city = null) use ($reader, $tempDir, $locale, $regionFormat, $fallbackFormat): ?string { + $resolveName = function (string $id, ?string $city = null) use ($reader, $tempDir, $locale, $regionFormat, $fallbackFormat): ?string { // Resolve default name as described per http://cldr.unicode.org/translation/timezones if (isset($this->zoneToCountryMapping[$id])) { try { diff --git a/DateFormatter/DateFormat/Hour1200Transformer.php b/DateFormatter/DateFormat/Hour1200Transformer.php new file mode 100644 index 00000000..da407332 --- /dev/null +++ b/DateFormatter/DateFormat/Hour1200Transformer.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter\DateFormat; + +/** + * Parser and formatter for 12 hour format (0-11). + * + * @author Igor Wiedler + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +class Hour1200Transformer extends HourTransformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $hourOfDay = $dateTime->format('g'); + $hourOfDay = '12' === $hourOfDay ? '0' : $hourOfDay; + + return $this->padLeft($hourOfDay, $length); + } + + /** + * {@inheritdoc} + */ + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('PM' === $marker) { + $hour += 12; + } + + return $hour; + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/DateFormatter/DateFormat/Hour1201Transformer.php b/DateFormatter/DateFormat/Hour1201Transformer.php new file mode 100644 index 00000000..67e612dd --- /dev/null +++ b/DateFormatter/DateFormat/Hour1201Transformer.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter\DateFormat; + +/** + * Parser and formatter for 12 hour format (1-12). + * + * @author Igor Wiedler + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +class Hour1201Transformer extends HourTransformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('g'), $length); + } + + /** + * {@inheritdoc} + */ + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('PM' !== $marker && 12 === $hour) { + $hour = 0; + } elseif ('PM' === $marker && 12 !== $hour) { + // If PM and hour is not 12 (1-12), sum 12 hour + $hour += 12; + } + + return $hour; + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/DateFormatter/DateFormat/Hour2400Transformer.php b/DateFormatter/DateFormat/Hour2400Transformer.php new file mode 100644 index 00000000..b9771141 --- /dev/null +++ b/DateFormatter/DateFormat/Hour2400Transformer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter\DateFormat; + +/** + * Parser and formatter for 24 hour format (0-23). + * + * @author Igor Wiedler + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +class Hour2400Transformer extends HourTransformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('G'), $length); + } + + /** + * {@inheritdoc} + */ + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('AM' === $marker) { + $hour = 0; + } elseif ('PM' === $marker) { + $hour = 12; + } + + return $hour; + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/DateFormatter/DateFormat/Hour2401Transformer.php b/DateFormatter/DateFormat/Hour2401Transformer.php new file mode 100644 index 00000000..4a26acaa --- /dev/null +++ b/DateFormatter/DateFormat/Hour2401Transformer.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter\DateFormat; + +/** + * Parser and formatter for 24 hour format (1-24). + * + * @author Igor Wiedler + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +class Hour2401Transformer extends HourTransformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $hourOfDay = $dateTime->format('G'); + $hourOfDay = '0' === $hourOfDay ? '24' : $hourOfDay; + + return $this->padLeft($hourOfDay, $length); + } + + /** + * {@inheritdoc} + */ + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ((null === $marker && 24 === $hour) || 'AM' === $marker) { + $hour = 0; + } elseif ('PM' === $marker) { + $hour = 12; + } + + return $hour; + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/DateFormatter/DateFormat/HourTransformer.php b/DateFormatter/DateFormat/HourTransformer.php new file mode 100644 index 00000000..54c105be --- /dev/null +++ b/DateFormatter/DateFormat/HourTransformer.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter\DateFormat; + +/** + * Base class for hour transformers. + * + * @author Eriksen Costa + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +abstract class HourTransformer extends Transformer +{ + /** + * Returns a normalized hour value suitable for the hour transformer type. + * + * @param int $hour The hour value + * @param string|null $marker An optional AM/PM marker + * + * @return int The normalized hour value + */ + abstract public function normalizeHour(int $hour, ?string $marker = null): int; +} diff --git a/DateFormatter/IntlDateFormatter.php b/DateFormatter/IntlDateFormatter.php new file mode 100644 index 00000000..31a67580 --- /dev/null +++ b/DateFormatter/IntlDateFormatter.php @@ -0,0 +1,620 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\DateFormatter; + +use Symfony\Component\Intl\DateFormatter\DateFormat\FullTransformer; +use Symfony\Component\Intl\Exception\MethodArgumentNotImplementedException; +use Symfony\Component\Intl\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Component\Intl\Exception\MethodNotImplementedException; +use Symfony\Component\Intl\Globals\IntlGlobals; +use Symfony\Component\Intl\Locale\Locale; + +/** + * Replacement for PHP's native {@link \IntlDateFormatter} class. + * + * The only methods currently supported in this class are: + * + * - {@link __construct} + * - {@link create} + * - {@link format} + * - {@link getCalendar} + * - {@link getDateType} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * - {@link getPattern} + * - {@link getTimeType} + * - {@link getTimeZoneId} + * - {@link isLenient} + * - {@link parse} + * - {@link setLenient} + * - {@link setPattern} + * - {@link setTimeZoneId} + * - {@link setTimeZone} + * + * @author Igor Wiedler + * @author Bernhard Schussek + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +abstract class IntlDateFormatter +{ + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = IntlGlobals::U_ZERO_ERROR; + + /** + * The error message from the last operation. + * + * @var string + */ + protected $errorMessage = 'U_ZERO_ERROR'; + + /* date/time format types */ + public const NONE = -1; + public const FULL = 0; + public const LONG = 1; + public const MEDIUM = 2; + public const SHORT = 3; + + /* calendar formats */ + public const TRADITIONAL = 0; + public const GREGORIAN = 1; + + /** + * Patterns used to format the date when no pattern is provided. + */ + private $defaultDateFormats = [ + self::NONE => '', + self::FULL => 'EEEE, MMMM d, y', + self::LONG => 'MMMM d, y', + self::MEDIUM => 'MMM d, y', + self::SHORT => 'M/d/yy', + ]; + + /** + * Patterns used to format the time when no pattern is provided. + */ + private $defaultTimeFormats = [ + self::FULL => 'h:mm:ss a zzzz', + self::LONG => 'h:mm:ss a z', + self::MEDIUM => 'h:mm:ss a', + self::SHORT => 'h:mm a', + ]; + + private $datetype; + private $timetype; + + /** + * @var string + */ + private $pattern; + + /** + * @var \DateTimeZone + */ + private $dateTimeZone; + + /** + * @var bool + */ + private $uninitializedTimeZoneId = false; + + /** + * @var string + */ + private $timeZoneId; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param int|null $datetype Type of date formatting, one of the format type constants + * @param int|null $timetype Type of time formatting, one of the format type constants + * @param \IntlTimeZone|\DateTimeZone|string|null $timezone Timezone identifier + * @param int|null $calendar Calendar to use for formatting or parsing. The only currently + * supported value is IntlDateFormatter::GREGORIAN (or null using the default calendar, i.e. "GREGORIAN") + * @param string|null $pattern Optional pattern to use when formatting + * + * @see https://php.net/intldateformatter.create + * @see http://userguide.icu-project.org/formatparse/datetime + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When $calendar different than GREGORIAN is passed + */ + public function __construct(?string $locale, ?int $datetype, ?int $timetype, $timezone = null, ?int $calendar = self::GREGORIAN, ?string $pattern = null) + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + + if (self::GREGORIAN !== $calendar && null !== $calendar) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'calendar', $calendar, 'Only the GREGORIAN calendar is supported'); + } + + $this->datetype = $datetype ?? self::FULL; + $this->timetype = $timetype ?? self::FULL; + + if ('' === ($pattern ?? '')) { + $pattern = $this->getDefaultPattern(); + } + + $this->setPattern($pattern); + $this->setTimeZone($timezone); + } + + /** + * Static constructor. + * + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param int|null $datetype Type of date formatting, one of the format type constants + * @param int|null $timetype Type of time formatting, one of the format type constants + * @param \IntlTimeZone|\DateTimeZone|string|null $timezone Timezone identifier + * @param int $calendar Calendar to use for formatting or parsing; default is Gregorian + * One of the calendar constants + * @param string|null $pattern Optional pattern to use when formatting + * + * @return static + * + * @see https://php.net/intldateformatter.create + * @see http://userguide.icu-project.org/formatparse/datetime + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When $calendar different than GREGORIAN is passed + */ + public static function create(?string $locale, ?int $datetype, ?int $timetype, $timezone = null, int $calendar = self::GREGORIAN, ?string $pattern = null) + { + return new static($locale, $datetype, $timetype, $timezone, $calendar, $pattern); + } + + /** + * Format the date/time value (timestamp) as a string. + * + * @param int|string|\DateTimeInterface $timestamp The timestamp to format + * + * @return string|bool The formatted value or false if formatting failed + * + * @see https://php.net/intldateformatter.format + * + * @throws MethodArgumentValueNotImplementedException If one of the formatting characters is not implemented + */ + public function format($timestamp) + { + // intl allows timestamps to be passed as arrays - we don't + if (\is_array($timestamp)) { + $message = 'Only Unix timestamps and DateTime objects are supported'; + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'timestamp', $timestamp, $message); + } + + if (\is_string($timestamp) && $dt = \DateTime::createFromFormat('U', $timestamp)) { + $timestamp = $dt; + } + + // behave like the intl extension + $argumentError = null; + if (!\is_int($timestamp) && !$timestamp instanceof \DateTimeInterface) { + $argumentError = sprintf('datefmt_format: string \'%s\' is not numeric, which would be required for it to be a valid date', $timestamp); + } + + if (null !== $argumentError) { + IntlGlobals::setError(IntlGlobals::U_ILLEGAL_ARGUMENT_ERROR, $argumentError); + $this->errorCode = IntlGlobals::getErrorCode(); + $this->errorMessage = IntlGlobals::getErrorMessage(); + + return false; + } + + if ($timestamp instanceof \DateTimeInterface) { + $timestamp = $timestamp->format('U'); + } + + $transformer = new FullTransformer($this->getPattern(), $this->getTimeZoneId()); + $formatted = $transformer->format($this->createDateTime($timestamp)); + + // behave like the intl extension + IntlGlobals::setError(IntlGlobals::U_ZERO_ERROR); + $this->errorCode = IntlGlobals::getErrorCode(); + $this->errorMessage = IntlGlobals::getErrorMessage(); + + return $formatted; + } + + /** + * Not supported. Formats an object. + * + * @param mixed $format + * @param string $locale + * + * @return string The formatted value + * + * @see https://php.net/intldateformatter.formatobject + * + * @throws MethodNotImplementedException + */ + public static function formatObject(object $object, $format = null, ?string $locale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the formatter's calendar. + * + * @return int The calendar being used by the formatter. Currently always returns + * IntlDateFormatter::GREGORIAN. + * + * @see https://php.net/intldateformatter.getcalendar + */ + public function getCalendar() + { + return self::GREGORIAN; + } + + /** + * Not supported. Returns the formatter's calendar object. + * + * @return object The calendar's object being used by the formatter + * + * @see https://php.net/intldateformatter.getcalendarobject + * + * @throws MethodNotImplementedException + */ + public function getCalendarObject() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the formatter's datetype. + * + * @return int The current value of the formatter + * + * @see https://php.net/intldateformatter.getdatetype + */ + public function getDateType() + { + return $this->datetype; + } + + /** + * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int The error code from last formatter call + * + * @see https://php.net/intldateformatter.geterrorcode + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string The error message from last formatter call + * + * @see https://php.net/intldateformatter.geterrormessage + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Returns the formatter's locale. + * + * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE) + * + * @return string The locale used to create the formatter. Currently always + * returns "en". + * + * @see https://php.net/intldateformatter.getlocale + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Returns the formatter's pattern. + * + * @return string The pattern string used by the formatter + * + * @see https://php.net/intldateformatter.getpattern + */ + public function getPattern() + { + return $this->pattern; + } + + /** + * Returns the formatter's time type. + * + * @return int The time type used by the formatter + * + * @see https://php.net/intldateformatter.gettimetype + */ + public function getTimeType() + { + return $this->timetype; + } + + /** + * Returns the formatter's timezone identifier. + * + * @return string The timezone identifier used by the formatter + * + * @see https://php.net/intldateformatter.gettimezoneid + */ + public function getTimeZoneId() + { + if (!$this->uninitializedTimeZoneId) { + return $this->timeZoneId; + } + + return date_default_timezone_get(); + } + + /** + * Not supported. Returns the formatter's timezone. + * + * @return mixed The timezone used by the formatter + * + * @see https://php.net/intldateformatter.gettimezone + * + * @throws MethodNotImplementedException + */ + public function getTimeZone() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns whether the formatter is lenient. + * + * @return bool Currently always returns false + * + * @see https://php.net/intldateformatter.islenient + * + * @throws MethodNotImplementedException + */ + public function isLenient() + { + return false; + } + + /** + * Not supported. Parse string to a field-based time value. + * + * @param string $value String to convert to a time value + * @param int $position Position at which to start the parsing in $value (zero-based) + * If no error occurs before $value is consumed, $parse_pos will + * contain -1 otherwise it will contain the position at which parsing + * ended. If $parse_pos > strlen($value), the parse fails immediately. + * + * @return string Localtime compatible array of integers: contains 24 hour clock value in tm_hour field + * + * @see https://php.net/intldateformatter.localtime + * + * @throws MethodNotImplementedException + */ + public function localtime(string $value, int &$position = 0) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Parse string to a timestamp value. + * + * @param string $value String to convert to a time value + * @param int|null $position Not supported. Position at which to start the parsing in $value (zero-based) + * If no error occurs before $value is consumed, $parse_pos will + * contain -1 otherwise it will contain the position at which parsing + * ended. If $parse_pos > strlen($value), the parse fails immediately. + * + * @return int|false Parsed value as a timestamp + * + * @see https://php.net/intldateformatter.parse + * + * @throws MethodArgumentNotImplementedException When $position different than null, behavior not implemented + */ + public function parse(string $value, ?int &$position = null) + { + // We don't calculate the position when parsing the value + if (null !== $position) { + throw new MethodArgumentNotImplementedException(__METHOD__, 'position'); + } + + $dateTime = $this->createDateTime(0); + $transformer = new FullTransformer($this->getPattern(), $this->getTimeZoneId()); + + $timestamp = $transformer->parse($dateTime, $value); + + // behave like the intl extension. FullTransformer::parse() set the proper error + $this->errorCode = IntlGlobals::getErrorCode(); + $this->errorMessage = IntlGlobals::getErrorMessage(); + + return $timestamp; + } + + /** + * Not supported. Set the formatter's calendar. + * + * @param string $calendar The calendar to use. Default is IntlDateFormatter::GREGORIAN + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setcalendar + * + * @throws MethodNotImplementedException + */ + public function setCalendar(string $calendar) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Set the leniency of the parser. + * + * Define if the parser is strict or lenient in interpreting inputs that do not match the pattern + * exactly. Enabling lenient parsing allows the parser to accept otherwise flawed date or time + * patterns, parsing as much as possible to obtain a value. Extra space, unrecognized tokens, or + * invalid values ("February 30th") are not accepted. + * + * @param bool $lenient Sets whether the parser is lenient or not. Currently + * only false (strict) is supported. + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setlenient + * + * @throws MethodArgumentValueNotImplementedException When $lenient is true + */ + public function setLenient(bool $lenient) + { + if ($lenient) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'lenient', $lenient, 'Only the strict parser is supported'); + } + + return true; + } + + /** + * Set the formatter's pattern. + * + * @param string|null $pattern A pattern string in conformance with the ICU IntlDateFormatter documentation + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setpattern + * @see http://userguide.icu-project.org/formatparse/datetime + */ + public function setPattern(?string $pattern) + { + $this->pattern = (string) $pattern; + + return true; + } + + /** + * Set the formatter's timezone identifier. + * + * @param string|null $timeZoneId The time zone ID string of the time zone to use. + * If NULL or the empty string, the default time zone for the + * runtime is used. + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.settimezoneid + */ + public function setTimeZoneId(?string $timeZoneId) + { + if (null === $timeZoneId) { + $timeZoneId = date_default_timezone_get(); + + $this->uninitializedTimeZoneId = true; + } + + // Backup original passed time zone + $timeZone = $timeZoneId; + + // Get an Etc/GMT time zone that is accepted for \DateTimeZone + if ('GMT' !== $timeZoneId && str_starts_with($timeZoneId, 'GMT')) { + try { + $timeZoneId = DateFormat\TimezoneTransformer::getEtcTimeZoneId($timeZoneId); + } catch (\InvalidArgumentException $e) { + // Does nothing, will fallback to UTC + } + } + + try { + $this->dateTimeZone = new \DateTimeZone($timeZoneId); + if ('GMT' !== $timeZoneId && $this->dateTimeZone->getName() !== $timeZoneId) { + $timeZone = $this->getTimeZoneId(); + } + } catch (\Exception $e) { + $timeZoneId = $timeZone = $this->getTimeZoneId(); + $this->dateTimeZone = new \DateTimeZone($timeZoneId); + } + + $this->timeZoneId = $timeZone; + + return true; + } + + /** + * This method was added in PHP 5.5 as replacement for `setTimeZoneId()`. + * + * @param \IntlTimeZone|\DateTimeZone|string|null $timeZone + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.settimezone + */ + public function setTimeZone($timeZone) + { + if ($timeZone instanceof \IntlTimeZone) { + $timeZone = $timeZone->getID(); + } + + if ($timeZone instanceof \DateTimeZone) { + $timeZone = $timeZone->getName(); + + // DateTimeZone returns the GMT offset timezones without the leading GMT, while our parsing requires it. + if (!empty($timeZone) && ('+' === $timeZone[0] || '-' === $timeZone[0])) { + $timeZone = 'GMT'.$timeZone; + } + } + + return $this->setTimeZoneId($timeZone); + } + + /** + * Create and returns a DateTime object with the specified timestamp and with the + * current time zone. + * + * @return \DateTime + */ + protected function createDateTime(string $timestamp) + { + $dateTime = \DateTime::createFromFormat('U', $timestamp); + $dateTime->setTimezone($this->dateTimeZone); + + return $dateTime; + } + + /** + * Returns a pattern string based in the datetype and timetype values. + * + * @return string + */ + protected function getDefaultPattern() + { + $pattern = ''; + if (self::NONE !== $this->datetype) { + $pattern = $this->defaultDateFormats[$this->datetype]; + } + if (self::NONE !== $this->timetype) { + if (self::FULL === $this->datetype || self::LONG === $this->datetype) { + $pattern .= ' \'at\' '; + } elseif (self::NONE !== $this->datetype) { + $pattern .= ', '; + } + $pattern .= $this->defaultTimeFormats[$this->timetype]; + } + + return $pattern; + } +} diff --git a/Languages.php b/Languages.php index e00b89fa..bff314b9 100644 --- a/Languages.php +++ b/Languages.php @@ -56,7 +56,7 @@ public static function exists(string $language): bool * * @throws MissingResourceException if the language code does not exist */ - public static function getName(string $language, string $displayLocale = null): string + public static function getName(string $language, ?string $displayLocale = null): string { try { return self::readEntry(['Names', $language], $displayLocale); @@ -78,7 +78,7 @@ public static function getName(string $language, string $displayLocale = null): * * @return array */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { return self::asort(self::readEntry(['Names'], $displayLocale), $displayLocale); } @@ -137,7 +137,7 @@ public static function alpha3CodeExists(string $language): bool * * @throws MissingResourceException if the country code does not exists */ - public static function getAlpha3Name(string $language, string $displayLocale = null): string + public static function getAlpha3Name(string $language, ?string $displayLocale = null): string { try { return self::getName(self::getAlpha2Code($language), $displayLocale); @@ -157,7 +157,7 @@ public static function getAlpha3Name(string $language, string $displayLocale = n * * @return array */ - public static function getAlpha3Names(string $displayLocale = null): array + public static function getAlpha3Names(?string $displayLocale = null): array { $alpha2Names = self::getNames($displayLocale); $alpha3Names = []; diff --git a/Locale/Locale.php b/Locale/Locale.php new file mode 100644 index 00000000..c2924b32 --- /dev/null +++ b/Locale/Locale.php @@ -0,0 +1,351 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\Locale; + +use Symfony\Component\Intl\Exception\MethodNotImplementedException; + +/** + * Replacement for PHP's native {@link \Locale} class. + * + * The only methods supported in this class are `getDefault` and `canonicalize`. + * All other methods will throw an exception when used. + * + * @author Eriksen Costa + * @author Bernhard Schussek + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +abstract class Locale +{ + public const DEFAULT_LOCALE = null; + + /* Locale method constants */ + public const ACTUAL_LOCALE = 0; + public const VALID_LOCALE = 1; + + /* Language tags constants */ + public const LANG_TAG = 'language'; + public const EXTLANG_TAG = 'extlang'; + public const SCRIPT_TAG = 'script'; + public const REGION_TAG = 'region'; + public const VARIANT_TAG = 'variant'; + public const GRANDFATHERED_LANG_TAG = 'grandfathered'; + public const PRIVATE_TAG = 'private'; + + /** + * Not supported. Returns the best available locale based on HTTP "Accept-Language" header according to RFC 2616. + * + * @param string $header The string containing the "Accept-Language" header value + * + * @return string + * + * @see https://php.net/locale.acceptfromhttp + * + * @throws MethodNotImplementedException + */ + public static function acceptFromHttp(string $header) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns a canonicalized locale string. + * + * This polyfill doesn't implement the full-spec algorithm. It only + * canonicalizes locale strings handled by the `LocaleBundle` class. + * + * @return string + */ + public static function canonicalize(string $locale) + { + if ('' === $locale || '.' === $locale[0]) { + return self::getDefault(); + } + + if (!preg_match('/^([a-z]{2})[-_]([a-z]{2})(?:([a-z]{2})(?:[-_]([a-z]{2}))?)?(?:\..*)?$/i', $locale, $m)) { + return $locale; + } + + if (!empty($m[4])) { + return strtolower($m[1]).'_'.ucfirst(strtolower($m[2].$m[3])).'_'.strtoupper($m[4]); + } + + if (!empty($m[3])) { + return strtolower($m[1]).'_'.ucfirst(strtolower($m[2].$m[3])); + } + + return strtolower($m[1]).'_'.strtoupper($m[2]); + } + + /** + * Not supported. Returns a correctly ordered and delimited locale code. + * + * @param array $subtags A keyed array where the keys identify the particular locale code subtag + * + * @return string + * + * @see https://php.net/locale.composelocale + * + * @throws MethodNotImplementedException + */ + public static function composeLocale(array $subtags) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Checks if a language tag filter matches with locale. + * + * @param string $langtag The language tag to check + * @param string $locale The language range to check against + * + * @return string + * + * @see https://php.net/locale.filtermatches + * + * @throws MethodNotImplementedException + */ + public static function filterMatches(string $langtag, string $locale, bool $canonicalize = false) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the variants for the input locale. + * + * @param string $locale The locale to extract the variants from + * + * @return array + * + * @see https://php.net/locale.getallvariants + * + * @throws MethodNotImplementedException + */ + public static function getAllVariants(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the default locale, which is always "en". + * + * @return string + * + * @see https://php.net/locale.getdefault + */ + public static function getDefault() + { + return 'en'; + } + + /** + * Not supported. Returns the localized display name for the locale language. + * + * @param string $locale The locale code to return the display language from + * @param string|null $inLocale Optional format locale code to use to display the language name + * + * @return string + * + * @see https://php.net/locale.getdisplaylanguage + * + * @throws MethodNotImplementedException + */ + public static function getDisplayLanguage(string $locale, ?string $inLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale. + * + * @param string $locale The locale code to return the display locale name from + * @param string|null $inLocale Optional format locale code to use to display the locale name + * + * @return string + * + * @see https://php.net/locale.getdisplayname + * + * @throws MethodNotImplementedException + */ + public static function getDisplayName(string $locale, ?string $inLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale region. + * + * @param string $locale The locale code to return the display region from + * @param string|null $inLocale Optional format locale code to use to display the region name + * + * @return string + * + * @see https://php.net/locale.getdisplayregion + * + * @throws MethodNotImplementedException + */ + public static function getDisplayRegion(string $locale, ?string $inLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale script. + * + * @param string $locale The locale code to return the display script from + * @param string|null $inLocale Optional format locale code to use to display the script name + * + * @return string + * + * @see https://php.net/locale.getdisplayscript + * + * @throws MethodNotImplementedException + */ + public static function getDisplayScript(string $locale, ?string $inLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale variant. + * + * @param string $locale The locale code to return the display variant from + * @param string|null $inLocale Optional format locale code to use to display the variant name + * + * @return string + * + * @see https://php.net/locale.getdisplayvariant + * + * @throws MethodNotImplementedException + */ + public static function getDisplayVariant(string $locale, ?string $inLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the keywords for the locale. + * + * @param string $locale The locale code to extract the keywords from + * + * @return array + * + * @see https://php.net/locale.getkeywords + * + * @throws MethodNotImplementedException + */ + public static function getKeywords(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the primary language for the locale. + * + * @param string $locale The locale code to extract the language code from + * + * @return string|null + * + * @see https://php.net/locale.getprimarylanguage + * + * @throws MethodNotImplementedException + */ + public static function getPrimaryLanguage(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the region for the locale. + * + * @param string $locale The locale code to extract the region code from + * + * @return string|null + * + * @see https://php.net/locale.getregion + * + * @throws MethodNotImplementedException + */ + public static function getRegion(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the script for the locale. + * + * @param string $locale The locale code to extract the script code from + * + * @return string|null + * + * @see https://php.net/locale.getscript + * + * @throws MethodNotImplementedException + */ + public static function getScript(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the closest language tag for the locale. + * + * @param array $langtag A list of the language tags to compare to locale + * @param string $locale The locale to use as the language range when matching + * @param bool $canonicalize If true, the arguments will be converted to canonical form before matching + * @param string|null $default The locale to use if no match is found + * + * @see https://php.net/locale.lookup + * + * @throws MethodNotImplementedException + */ + public static function lookup(array $langtag, string $locale, bool $canonicalize = false, ?string $default = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns an associative array of locale identifier subtags. + * + * @param string $locale The locale code to extract the subtag array from + * + * @return array + * + * @see https://php.net/locale.parselocale + * + * @throws MethodNotImplementedException + */ + public static function parseLocale(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sets the default runtime locale. + * + * @return bool + * + * @see https://php.net/locale.setdefault + * + * @throws MethodNotImplementedException + */ + public static function setDefault(string $locale) + { + if ('en' !== $locale) { + throw new MethodNotImplementedException(__METHOD__); + } + + return true; + } +} diff --git a/Locales.php b/Locales.php index 10d3dcef..ea4f50a7 100644 --- a/Locales.php +++ b/Locales.php @@ -51,7 +51,7 @@ public static function exists(string $locale): bool /** * @throws MissingResourceException if the locale does not exist */ - public static function getName(string $locale, string $displayLocale = null): string + public static function getName(string $locale, ?string $displayLocale = null): string { try { return self::readEntry(['Names', $locale], $displayLocale); @@ -67,7 +67,7 @@ public static function getName(string $locale, string $displayLocale = null): st /** * @return string[] */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { return self::asort(self::readEntry(['Names'], $displayLocale), $displayLocale); } diff --git a/NumberFormatter/NumberFormatter.php b/NumberFormatter/NumberFormatter.php new file mode 100644 index 00000000..376384f6 --- /dev/null +++ b/NumberFormatter/NumberFormatter.php @@ -0,0 +1,864 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\NumberFormatter; + +use Symfony\Component\Intl\Currencies; +use Symfony\Component\Intl\Exception\MethodArgumentNotImplementedException; +use Symfony\Component\Intl\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Component\Intl\Exception\MethodNotImplementedException; +use Symfony\Component\Intl\Exception\NotImplementedException; +use Symfony\Component\Intl\Globals\IntlGlobals; +use Symfony\Component\Intl\Locale\Locale; + +/** + * Replacement for PHP's native {@link \NumberFormatter} class. + * + * The only methods currently supported in this class are: + * + * - {@link __construct} + * - {@link create} + * - {@link formatCurrency} + * - {@link format} + * - {@link getAttribute} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * - {@link parse} + * - {@link setAttribute} + * + * @author Eriksen Costa + * @author Bernhard Schussek + * + * @internal + * + * @deprecated since Symfony 5.3, use symfony/polyfill-intl-icu ^1.21 instead + */ +abstract class NumberFormatter +{ + /* Format style constants */ + public const PATTERN_DECIMAL = 0; + public const DECIMAL = 1; + public const CURRENCY = 2; + public const PERCENT = 3; + public const SCIENTIFIC = 4; + public const SPELLOUT = 5; + public const ORDINAL = 6; + public const DURATION = 7; + public const PATTERN_RULEBASED = 9; + public const IGNORE = 0; + public const DEFAULT_STYLE = 1; + + /* Format type constants */ + public const TYPE_DEFAULT = 0; + public const TYPE_INT32 = 1; + public const TYPE_INT64 = 2; + public const TYPE_DOUBLE = 3; + public const TYPE_CURRENCY = 4; + + /* Numeric attribute constants */ + public const PARSE_INT_ONLY = 0; + public const GROUPING_USED = 1; + public const DECIMAL_ALWAYS_SHOWN = 2; + public const MAX_INTEGER_DIGITS = 3; + public const MIN_INTEGER_DIGITS = 4; + public const INTEGER_DIGITS = 5; + public const MAX_FRACTION_DIGITS = 6; + public const MIN_FRACTION_DIGITS = 7; + public const FRACTION_DIGITS = 8; + public const MULTIPLIER = 9; + public const GROUPING_SIZE = 10; + public const ROUNDING_MODE = 11; + public const ROUNDING_INCREMENT = 12; + public const FORMAT_WIDTH = 13; + public const PADDING_POSITION = 14; + public const SECONDARY_GROUPING_SIZE = 15; + public const SIGNIFICANT_DIGITS_USED = 16; + public const MIN_SIGNIFICANT_DIGITS = 17; + public const MAX_SIGNIFICANT_DIGITS = 18; + public const LENIENT_PARSE = 19; + + /* Text attribute constants */ + public const POSITIVE_PREFIX = 0; + public const POSITIVE_SUFFIX = 1; + public const NEGATIVE_PREFIX = 2; + public const NEGATIVE_SUFFIX = 3; + public const PADDING_CHARACTER = 4; + public const CURRENCY_CODE = 5; + public const DEFAULT_RULESET = 6; + public const PUBLIC_RULESETS = 7; + + /* Format symbol constants */ + public const DECIMAL_SEPARATOR_SYMBOL = 0; + public const GROUPING_SEPARATOR_SYMBOL = 1; + public const PATTERN_SEPARATOR_SYMBOL = 2; + public const PERCENT_SYMBOL = 3; + public const ZERO_DIGIT_SYMBOL = 4; + public const DIGIT_SYMBOL = 5; + public const MINUS_SIGN_SYMBOL = 6; + public const PLUS_SIGN_SYMBOL = 7; + public const CURRENCY_SYMBOL = 8; + public const INTL_CURRENCY_SYMBOL = 9; + public const MONETARY_SEPARATOR_SYMBOL = 10; + public const EXPONENTIAL_SYMBOL = 11; + public const PERMILL_SYMBOL = 12; + public const PAD_ESCAPE_SYMBOL = 13; + public const INFINITY_SYMBOL = 14; + public const NAN_SYMBOL = 15; + public const SIGNIFICANT_DIGIT_SYMBOL = 16; + public const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17; + + /* Rounding mode values used by NumberFormatter::setAttribute() with NumberFormatter::ROUNDING_MODE attribute */ + public const ROUND_CEILING = 0; + public const ROUND_FLOOR = 1; + public const ROUND_DOWN = 2; + public const ROUND_UP = 3; + public const ROUND_HALFEVEN = 4; + public const ROUND_HALFDOWN = 5; + public const ROUND_HALFUP = 6; + + /* Pad position values used by NumberFormatter::setAttribute() with NumberFormatter::PADDING_POSITION attribute */ + public const PAD_BEFORE_PREFIX = 0; + public const PAD_AFTER_PREFIX = 1; + public const PAD_BEFORE_SUFFIX = 2; + public const PAD_AFTER_SUFFIX = 3; + + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = IntlGlobals::U_ZERO_ERROR; + + /** + * The error message from the last operation. + * + * @var string + */ + protected $errorMessage = 'U_ZERO_ERROR'; + + /** + * @var int + */ + private $style; + + /** + * Default values for the en locale. + */ + private $attributes = [ + self::FRACTION_DIGITS => 0, + self::GROUPING_USED => 1, + self::ROUNDING_MODE => self::ROUND_HALFEVEN, + ]; + + /** + * Holds the initialized attributes code. + */ + private $initializedAttributes = []; + + /** + * The supported styles to the constructor $styles argument. + */ + private const SUPPORTED_STYLES = [ + 'CURRENCY' => self::CURRENCY, + 'DECIMAL' => self::DECIMAL, + ]; + + /** + * Supported attributes to the setAttribute() $attr argument. + */ + private const SUPPORTED_ATTRIBUTES = [ + 'FRACTION_DIGITS' => self::FRACTION_DIGITS, + 'GROUPING_USED' => self::GROUPING_USED, + 'ROUNDING_MODE' => self::ROUNDING_MODE, + ]; + + /** + * The available rounding modes for setAttribute() usage with + * NumberFormatter::ROUNDING_MODE. NumberFormatter::ROUND_DOWN + * and NumberFormatter::ROUND_UP does not have a PHP only equivalent. + */ + private const ROUNDING_MODES = [ + 'ROUND_HALFEVEN' => self::ROUND_HALFEVEN, + 'ROUND_HALFDOWN' => self::ROUND_HALFDOWN, + 'ROUND_HALFUP' => self::ROUND_HALFUP, + 'ROUND_CEILING' => self::ROUND_CEILING, + 'ROUND_FLOOR' => self::ROUND_FLOOR, + 'ROUND_DOWN' => self::ROUND_DOWN, + 'ROUND_UP' => self::ROUND_UP, + ]; + + /** + * The mapping between NumberFormatter rounding modes to the available + * modes in PHP's round() function. + * + * @see https://php.net/round + */ + private const PHP_ROUNDING_MAP = [ + self::ROUND_HALFDOWN => \PHP_ROUND_HALF_DOWN, + self::ROUND_HALFEVEN => \PHP_ROUND_HALF_EVEN, + self::ROUND_HALFUP => \PHP_ROUND_HALF_UP, + ]; + + /** + * The list of supported rounding modes which aren't available modes in + * PHP's round() function, but there's an equivalent. Keys are rounding + * modes, values does not matter. + */ + private const CUSTOM_ROUNDING_LIST = [ + self::ROUND_CEILING => true, + self::ROUND_FLOOR => true, + self::ROUND_DOWN => true, + self::ROUND_UP => true, + ]; + + /** + * The maximum value of the integer type in 32 bit platforms. + */ + private static $int32Max = 2147483647; + + /** + * The maximum value of the integer type in 64 bit platforms. + * + * @var int|float + */ + private static $int64Max = 9223372036854775807; + + private const EN_SYMBOLS = [ + self::DECIMAL => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + self::CURRENCY => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + ]; + + private const EN_TEXT_ATTRIBUTES = [ + self::DECIMAL => ['', '', '-', '', ' ', 'XXX', ''], + self::CURRENCY => ['¤', '', '-¤', '', ' ', 'XXX'], + ]; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param int|null $style Style of the formatting, one of the format style constants. + * The only supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string|null $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or + * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax + * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation + * + * @see https://php.net/numberformatter.create + * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1DecimalFormat.html#details + * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1RuleBasedNumberFormat.html#details + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When the $style is not supported + * @throws MethodArgumentNotImplementedException When the pattern value is different than null + */ + public function __construct(?string $locale = 'en', ?int $style = null, ?string $pattern = null) + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + + if (!\in_array($style, self::SUPPORTED_STYLES)) { + $message = sprintf('The available styles are: %s.', implode(', ', array_keys(self::SUPPORTED_STYLES))); + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'style', $style, $message); + } + + if (null !== $pattern) { + throw new MethodArgumentNotImplementedException(__METHOD__, 'pattern'); + } + + $this->style = $style; + } + + /** + * Static constructor. + * + * @param string|null $locale The locale code. The only supported locale is "en" (or null using the default locale, i.e. "en") + * @param int|null $style Style of the formatting, one of the format style constants. + * The only currently supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string|null $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or + * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax + * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation + * + * @return static + * + * @see https://php.net/numberformatter.create + * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details + * @see http://www.icu-project.org/apiref/icu4c/classRuleBasedNumberFormat.html#_details + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When the $style is not supported + * @throws MethodArgumentNotImplementedException When the pattern value is different than null + */ + public static function create(?string $locale = 'en', ?int $style = null, ?string $pattern = null) + { + return new static($locale, $style, $pattern); + } + + /** + * Format a currency value. + * + * @param string $currency The 3-letter ISO 4217 currency code indicating the currency to use + * + * @return string + * + * @see https://php.net/numberformatter.formatcurrency + * @see https://en.wikipedia.org/wiki/ISO_4217#Active_codes + */ + public function formatCurrency(float $value, string $currency) + { + if (self::DECIMAL === $this->style) { + return $this->format($value); + } + + $symbol = Currencies::getSymbol($currency, 'en'); + $fractionDigits = Currencies::getFractionDigits($currency); + + $value = $this->roundCurrency($value, $currency); + + $negative = false; + if (0 > $value) { + $negative = true; + $value *= -1; + } + + $value = $this->formatNumber($value, $fractionDigits); + + // There's a non-breaking space after the currency code (i.e. CRC 100), but not if the currency has a symbol (i.e. £100). + $ret = $symbol.(mb_strlen($symbol, 'UTF-8') > 2 ? "\xc2\xa0" : '').$value; + + return $negative ? '-'.$ret : $ret; + } + + /** + * Format a number. + * + * @param int|float $value The value to format + * @param int $type Type of the formatting, one of the format type constants. + * Only type NumberFormatter::TYPE_DEFAULT is currently supported. + * + * @return bool|string + * + * @see https://php.net/numberformatter.format + * + * @throws NotImplementedException If the method is called with the class $style 'CURRENCY' + * @throws MethodArgumentValueNotImplementedException If the $type is different than TYPE_DEFAULT + */ + public function format($value, int $type = self::TYPE_DEFAULT) + { + // The original NumberFormatter does not support this format type + if (self::TYPE_CURRENCY === $type) { + if (\PHP_VERSION_ID >= 80000) { + throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%s given).', $type)); + } + + trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); + + return false; + } + + if (self::CURRENCY === $this->style) { + throw new NotImplementedException(sprintf('"%s()" method does not support the formatting of currencies (instance with CURRENCY style). "%s".', __METHOD__, NotImplementedException::INTL_INSTALL_MESSAGE)); + } + + // Only the default type is supported. + if (self::TYPE_DEFAULT !== $type) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'type', $type, 'Only TYPE_DEFAULT is supported'); + } + + $fractionDigits = $this->getAttribute(self::FRACTION_DIGITS); + + $value = $this->round($value, $fractionDigits); + $value = $this->formatNumber($value, $fractionDigits); + + // behave like the intl extension + $this->resetError(); + + return $value; + } + + /** + * Returns an attribute value. + * + * @param int $attr An attribute specifier, one of the numeric attribute constants + * + * @return int|false The attribute value on success or false on error + * + * @see https://php.net/numberformatter.getattribute + */ + public function getAttribute(int $attr) + { + return $this->attributes[$attr] ?? null; + } + + /** + * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int The error code from last formatter call + * + * @see https://php.net/numberformatter.geterrorcode + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string The error message from last formatter call + * + * @see https://php.net/numberformatter.geterrormessage + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Returns the formatter's locale. + * + * The parameter $type is currently ignored. + * + * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE) + * + * @return string The locale used to create the formatter. Currently always + * returns "en". + * + * @see https://php.net/numberformatter.getlocale + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Not supported. Returns the formatter's pattern. + * + * @return string|false The pattern string used by the formatter or false on error + * + * @see https://php.net/numberformatter.getpattern + * + * @throws MethodNotImplementedException + */ + public function getPattern() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns a formatter symbol value. + * + * @param int $attr A symbol specifier, one of the format symbol constants + * + * @return string|false The symbol value or false on error + * + * @see https://php.net/numberformatter.getsymbol + */ + public function getSymbol(int $attr) + { + return \array_key_exists($this->style, self::EN_SYMBOLS) && \array_key_exists($attr, self::EN_SYMBOLS[$this->style]) ? self::EN_SYMBOLS[$this->style][$attr] : false; + } + + /** + * Not supported. Returns a formatter text attribute value. + * + * @param int $attr An attribute specifier, one of the text attribute constants + * + * @return string|false The attribute value or false on error + * + * @see https://php.net/numberformatter.gettextattribute + */ + public function getTextAttribute(int $attr) + { + return \array_key_exists($this->style, self::EN_TEXT_ATTRIBUTES) && \array_key_exists($attr, self::EN_TEXT_ATTRIBUTES[$this->style]) ? self::EN_TEXT_ATTRIBUTES[$this->style][$attr] : false; + } + + /** + * Not supported. Parse a currency number. + * + * @param string $value The value to parse + * @param string $currency Parameter to receive the currency name (reference) + * @param int|null $position Offset to begin the parsing on return this value will hold the offset at which the parsing ended + * + * @return float|false The parsed numeric value or false on error + * + * @see https://php.net/numberformatter.parsecurrency + * + * @throws MethodNotImplementedException + */ + public function parseCurrency(string $value, string &$currency, ?int &$position = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Parse a number. + * + * @param string $value The value to parse + * @param int $type Type of the formatting, one of the format type constants. NumberFormatter::TYPE_DOUBLE by default. + * @param int $position Offset to begin the parsing on return this value will hold the offset at which the parsing ended + * + * @return int|float|false The parsed value or false on error + * + * @see https://php.net/numberformatter.parse + */ + public function parse(string $value, int $type = self::TYPE_DOUBLE, int &$position = 0) + { + if (self::TYPE_DEFAULT === $type || self::TYPE_CURRENCY === $type) { + if (\PHP_VERSION_ID >= 80000) { + throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%d given).', $type)); + } + + trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); + + return false; + } + + // Any invalid number at the end of the string is removed. + // Only numbers and the fraction separator is expected in the string. + // If grouping is used, grouping separator also becomes a valid character. + $groupingMatch = $this->getAttribute(self::GROUPING_USED) ? '|(?P\d++(,{1}\d+)++(\.\d*+)?)' : ''; + if (preg_match("/^-?(?:\.\d++{$groupingMatch}|\d++(\.\d*+)?)/", $value, $matches)) { + $value = $matches[0]; + $position = \strlen($value); + // value is not valid if grouping is used, but digits are not grouped in groups of three + if ($error = isset($matches['grouping']) && !preg_match('/^-?(?:\d{1,3}+)?(?:(?:,\d{3})++|\d*+)(?:\.\d*+)?$/', $value)) { + // the position on error is 0 for positive and 1 for negative numbers + $position = str_starts_with($value, '-') ? 1 : 0; + } + } else { + $error = true; + $position = 0; + } + + if ($error) { + IntlGlobals::setError(IntlGlobals::U_PARSE_ERROR, 'Number parsing failed'); + $this->errorCode = IntlGlobals::getErrorCode(); + $this->errorMessage = IntlGlobals::getErrorMessage(); + + return false; + } + + $value = str_replace(',', '', $value); + $value = $this->convertValueDataType($value, $type); + + // behave like the intl extension + $this->resetError(); + + return $value; + } + + /** + * Set an attribute. + * + * @param int $attr An attribute specifier, one of the numeric attribute constants. + * The only currently supported attributes are NumberFormatter::FRACTION_DIGITS, + * NumberFormatter::GROUPING_USED and NumberFormatter::ROUNDING_MODE. + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setattribute + * + * @throws MethodArgumentValueNotImplementedException When the $attr is not supported + * @throws MethodArgumentValueNotImplementedException When the $value is not supported + */ + public function setAttribute(int $attr, int $value) + { + if (!\in_array($attr, self::SUPPORTED_ATTRIBUTES)) { + $message = sprintf( + 'The available attributes are: %s', + implode(', ', array_keys(self::SUPPORTED_ATTRIBUTES)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message); + } + + if (self::SUPPORTED_ATTRIBUTES['ROUNDING_MODE'] === $attr && $this->isInvalidRoundingMode($value)) { + $message = sprintf( + 'The supported values for ROUNDING_MODE are: %s', + implode(', ', array_keys(self::ROUNDING_MODES)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message); + } + + if (self::SUPPORTED_ATTRIBUTES['GROUPING_USED'] === $attr) { + $value = $this->normalizeGroupingUsedValue($value); + } + + if (self::SUPPORTED_ATTRIBUTES['FRACTION_DIGITS'] === $attr) { + $value = $this->normalizeFractionDigitsValue($value); + if ($value < 0) { + // ignore negative values but do not raise an error + return true; + } + } + + $this->attributes[$attr] = $value; + $this->initializedAttributes[$attr] = true; + + return true; + } + + /** + * Not supported. Set the formatter's pattern. + * + * @param string $pattern A pattern string in conformance with the ICU DecimalFormat documentation + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setpattern + * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details + * + * @throws MethodNotImplementedException + */ + public function setPattern(string $pattern) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set the formatter's symbol. + * + * @param int $attr A symbol specifier, one of the format symbol constants + * @param string $value The value for the symbol + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setsymbol + * + * @throws MethodNotImplementedException + */ + public function setSymbol(int $attr, string $value) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set a text attribute. + * + * @param int $attr An attribute specifier, one of the text attribute constants + * @param string $value The attribute value + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.settextattribute + * + * @throws MethodNotImplementedException + */ + public function setTextAttribute(int $attr, string $value) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Set the error to the default U_ZERO_ERROR. + */ + protected function resetError() + { + IntlGlobals::setError(IntlGlobals::U_ZERO_ERROR); + $this->errorCode = IntlGlobals::getErrorCode(); + $this->errorMessage = IntlGlobals::getErrorMessage(); + } + + /** + * Rounds a currency value, applying increment rounding if applicable. + * + * When a currency have a rounding increment, an extra round is made after the first one. The rounding factor is + * determined in the ICU data and is explained as of: + * + * "the rounding increment is given in units of 10^(-fraction_digits)" + * + * The only actual rounding data as of this writing, is CHF. + * + * @see http://en.wikipedia.org/wiki/Swedish_rounding + * @see http://www.docjar.com/html/api/com/ibm/icu/util/Currency.java.html#1007 + */ + private function roundCurrency(float $value, string $currency): float + { + $fractionDigits = Currencies::getFractionDigits($currency); + $roundingIncrement = Currencies::getRoundingIncrement($currency); + + // Round with the formatter rounding mode + $value = $this->round($value, $fractionDigits); + + // Swiss rounding + if (0 < $roundingIncrement && 0 < $fractionDigits) { + $roundingFactor = $roundingIncrement / 10 ** $fractionDigits; + $value = round($value / $roundingFactor) * $roundingFactor; + } + + return $value; + } + + /** + * Rounds a value. + * + * @param int|float $value The value to round + * + * @return int|float The rounded value + */ + private function round($value, int $precision) + { + $precision = $this->getUninitializedPrecision($value, $precision); + + $roundingModeAttribute = $this->getAttribute(self::ROUNDING_MODE); + if (isset(self::PHP_ROUNDING_MAP[$roundingModeAttribute])) { + $value = round($value, $precision, self::PHP_ROUNDING_MAP[$roundingModeAttribute]); + } elseif (isset(self::CUSTOM_ROUNDING_LIST[$roundingModeAttribute])) { + $roundingCoef = 10 ** $precision; + $value *= $roundingCoef; + $value = (float) (string) $value; + + switch ($roundingModeAttribute) { + case self::ROUND_CEILING: + $value = ceil($value); + break; + case self::ROUND_FLOOR: + $value = floor($value); + break; + case self::ROUND_UP: + $value = $value > 0 ? ceil($value) : floor($value); + break; + case self::ROUND_DOWN: + $value = $value > 0 ? floor($value) : ceil($value); + break; + } + + $value /= $roundingCoef; + } + + return $value; + } + + /** + * Formats a number. + * + * @param int|float $value The numeric value to format + */ + private function formatNumber($value, int $precision): string + { + $precision = $this->getUninitializedPrecision($value, $precision); + + return number_format($value, $precision, '.', $this->getAttribute(self::GROUPING_USED) ? ',' : ''); + } + + /** + * Returns the precision value if the DECIMAL style is being used and the FRACTION_DIGITS attribute is uninitialized. + * + * @param int|float $value The value to get the precision from if the FRACTION_DIGITS attribute is uninitialized + */ + private function getUninitializedPrecision($value, int $precision): int + { + if (self::CURRENCY === $this->style) { + return $precision; + } + + if (!$this->isInitializedAttribute(self::FRACTION_DIGITS)) { + preg_match('/.*\.(.*)/', (string) $value, $digits); + if (isset($digits[1])) { + $precision = \strlen($digits[1]); + } + } + + return $precision; + } + + /** + * Check if the attribute is initialized (value set by client code). + */ + private function isInitializedAttribute(string $attr): bool + { + return isset($this->initializedAttributes[$attr]); + } + + /** + * Returns the numeric value using the $type to convert to the right data type. + * + * @param mixed $value The value to be converted + * + * @return int|float|false The converted value + */ + private function convertValueDataType($value, int $type) + { + if (self::TYPE_DOUBLE === $type) { + $value = (float) $value; + } elseif (self::TYPE_INT32 === $type) { + $value = $this->getInt32Value($value); + } elseif (self::TYPE_INT64 === $type) { + $value = $this->getInt64Value($value); + } + + return $value; + } + + /** + * Convert the value data type to int or returns false if the value is out of the integer value range. + * + * @return int|false The converted value + */ + private function getInt32Value($value) + { + if ($value > self::$int32Max || $value < -self::$int32Max - 1) { + return false; + } + + return (int) $value; + } + + /** + * Convert the value data type to int or returns false if the value is out of the integer value range. + * + * @return int|float|false The converted value + */ + private function getInt64Value($value) + { + if ($value > self::$int64Max || $value < -self::$int64Max - 1) { + return false; + } + + if (\PHP_INT_SIZE !== 8 && ($value > self::$int32Max || $value < -self::$int32Max - 1)) { + return (float) $value; + } + + return (int) $value; + } + + /** + * Check if the rounding mode is invalid. + */ + private function isInvalidRoundingMode(int $value): bool + { + if (\in_array($value, self::ROUNDING_MODES, true)) { + return false; + } + + return true; + } + + /** + * Returns the normalized value for the GROUPING_USED attribute. Any value that can be converted to int will be + * cast to Boolean and then to int again. This way, negative values are converted to 1 and string values to 0. + */ + private function normalizeGroupingUsedValue($value): int + { + return (int) (bool) (int) $value; + } + + /** + * Returns the normalized value for the FRACTION_DIGITS attribute. + */ + private function normalizeFractionDigitsValue($value): int + { + return (int) $value; + } +} diff --git a/ResourceBundle.php b/ResourceBundle.php index f1bc4825..be5017ce 100644 --- a/ResourceBundle.php +++ b/ResourceBundle.php @@ -43,7 +43,7 @@ abstract protected static function getPath(): string; * @return mixed returns an array or {@link \ArrayAccess} instance for * complex data and a scalar value for simple data */ - final protected static function readEntry(array $indices, string $locale = null, bool $fallback = true): mixed + final protected static function readEntry(array $indices, ?string $locale = null, bool $fallback = true): mixed { if (!isset(self::$entryReader)) { self::$entryReader = new BundleEntryReader(new BufferedBundleReader( @@ -58,7 +58,7 @@ final protected static function readEntry(array $indices, string $locale = null, return self::$entryReader->readEntry(static::getPath(), $locale ?? \Locale::getDefault(), $indices, $fallback); } - final protected static function asort(iterable $list, string $locale = null): array + final protected static function asort(iterable $list, ?string $locale = null): array { if ($list instanceof \Traversable) { $list = iterator_to_array($list); diff --git a/Scripts.php b/Scripts.php index d21ed6fa..27e46067 100644 --- a/Scripts.php +++ b/Scripts.php @@ -43,7 +43,7 @@ public static function exists(string $script): bool /** * @throws MissingResourceException if the script code does not exist */ - public static function getName(string $script, string $displayLocale = null): string + public static function getName(string $script, ?string $displayLocale = null): string { return self::readEntry(['Names', $script], $displayLocale); } @@ -51,7 +51,7 @@ public static function getName(string $script, string $displayLocale = null): st /** * @return string[] */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { return self::asort(self::readEntry(['Names'], $displayLocale), $displayLocale); } diff --git a/Tests/NumberFormatter/AbstractNumberFormatterTestCase.php b/Tests/NumberFormatter/AbstractNumberFormatterTestCase.php new file mode 100644 index 00000000..f874aab2 --- /dev/null +++ b/Tests/NumberFormatter/AbstractNumberFormatterTestCase.php @@ -0,0 +1,889 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\Tests\NumberFormatter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Intl\Globals\IntlGlobals; +use Symfony\Component\Intl\NumberFormatter\NumberFormatter; +use Symfony\Component\Intl\Util\IntlTestHelper; + +/** + * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known + * behavior of PHP. + * + * @group legacy + */ +abstract class AbstractNumberFormatterTestCase extends TestCase +{ + /** + * @dataProvider formatCurrencyWithDecimalStyleProvider + */ + public function testFormatCurrencyWithDecimalStyle($value, $currency, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertEquals($expected, $formatter->formatCurrency($value, $currency)); + } + + public static function formatCurrencyWithDecimalStyleProvider() + { + return [ + [100, 'ALL', '100'], + [100, 'BRL', '100'], + [100, 'CRC', '100'], + [100, 'JPY', '100'], + [100, 'CHF', '100'], + [-100, 'ALL', '-100'], + [-100, 'BRL', '-100'], + [-100, 'CRC', '-100'], + [-100, 'JPY', '-100'], + [-100, 'CHF', '-100'], + [1000.12, 'ALL', '1,000.12'], + [1000.12, 'BRL', '1,000.12'], + [1000.12, 'CRC', '1,000.12'], + [1000.12, 'JPY', '1,000.12'], + [1000.12, 'CHF', '1,000.12'], + ]; + } + + /** + * @dataProvider formatCurrencyWithCurrencyStyleProvider + */ + public function testFormatCurrencyWithCurrencyStyle($value, $currency, $expected) + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals($expected, $formatter->formatCurrency($value, $currency)); + } + + public static function formatCurrencyWithCurrencyStyleProvider() + { + return [ + [100, 'ALL', "ALL\xc2\xa0100"], + [-100, 'ALL', "-ALL\xc2\xa0100"], + [1000.12, 'ALL', "ALL\xc2\xa01,000"], + + [100, 'JPY', '¥100'], + [-100, 'JPY', '-¥100'], + [1000.12, 'JPY', '¥1,000'], + + [100, 'EUR', '€100.00'], + [-100, 'EUR', '-€100.00'], + [1000.12, 'EUR', '€1,000.12'], + ]; + } + + /** + * @dataProvider formatCurrencyWithCurrencyStyleCostaRicanColonsRoundingProvider + */ + public function testFormatCurrencyWithCurrencyStyleCostaRicanColonsRounding($value, $currency, $symbol, $expected) + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public static function formatCurrencyWithCurrencyStyleCostaRicanColonsRoundingProvider() + { + return [ + [100, 'CRC', 'CRC', "%s\xc2\xa0100.00"], + [-100, 'CRC', 'CRC', "-%s\xc2\xa0100.00"], + [1000.12, 'CRC', 'CRC', "%s\xc2\xa01,000.12"], + ]; + } + + /** + * @dataProvider formatCurrencyWithCurrencyStyleBrazilianRealRoundingProvider + */ + public function testFormatCurrencyWithCurrencyStyleBrazilianRealRounding($value, $currency, $symbol, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public static function formatCurrencyWithCurrencyStyleBrazilianRealRoundingProvider() + { + return [ + [100, 'BRL', 'R', '%s$100.00'], + [-100, 'BRL', 'R', '-%s$100.00'], + [1000.12, 'BRL', 'R', '%s$1,000.12'], + + // Rounding checks + [1000.121, 'BRL', 'R', '%s$1,000.12'], + [1000.123, 'BRL', 'R', '%s$1,000.12'], + [1000.125, 'BRL', 'R', '%s$1,000.12'], + [1000.127, 'BRL', 'R', '%s$1,000.13'], + [1000.129, 'BRL', 'R', '%s$1,000.13'], + [11.50999, 'BRL', 'R', '%s$11.51'], + [11.9999464, 'BRL', 'R', '%s$12.00'], + ]; + } + + /** + * @dataProvider formatCurrencyWithCurrencyStyleSwissRoundingProvider + */ + public function testFormatCurrencyWithCurrencyStyleSwissRounding($value, $currency, $symbol, $expected) + { + IntlTestHelper::requireIntl($this, '62.1'); + + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public static function formatCurrencyWithCurrencyStyleSwissRoundingProvider() + { + return [ + [100, 'CHF', 'CHF', "%s\xc2\xa0100.00"], + [-100, 'CHF', 'CHF', "-%s\xc2\xa0100.00"], + [1000.12, 'CHF', 'CHF', "%s\xc2\xa01,000.12"], + ['1000.12', 'CHF', 'CHF', "%s\xc2\xa01,000.12"], + + // Rounding checks + [1000.121, 'CHF', 'CHF', "%s\xc2\xa01,000.12"], + [1000.123, 'CHF', 'CHF', "%s\xc2\xa01,000.12"], + [1000.125, 'CHF', 'CHF', "%s\xc2\xa01,000.12"], + [1000.127, 'CHF', 'CHF', "%s\xc2\xa01,000.13"], + [1000.129, 'CHF', 'CHF', "%s\xc2\xa01,000.13"], + + [1200000.00, 'CHF', 'CHF', "%s\xc2\xa01,200,000.00"], + [1200000.1, 'CHF', 'CHF', "%s\xc2\xa01,200,000.10"], + [1200000.10, 'CHF', 'CHF', "%s\xc2\xa01,200,000.10"], + [1200000.101, 'CHF', 'CHF', "%s\xc2\xa01,200,000.10"], + ]; + } + + public function testFormat() + { + $errorCode = IntlGlobals::U_ZERO_ERROR; + $errorMessage = 'U_ZERO_ERROR'; + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertSame('9.555', $formatter->format(9.555)); + + $this->assertSame($errorMessage, $this->getIntlErrorMessage()); + $this->assertSame($errorCode, $this->getIntlErrorCode()); + $this->assertFalse($this->isIntlFailure($this->getIntlErrorCode())); + $this->assertSame($errorMessage, $formatter->getErrorMessage()); + $this->assertSame($errorCode, $formatter->getErrorCode()); + $this->assertFalse($this->isIntlFailure($formatter->getErrorCode())); + } + + public function testFormatWithCurrencyStyle() + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals('¤1.00', $formatter->format(1)); + } + + /** + * @dataProvider formatTypeInt32Provider + */ + public function testFormatTypeInt32($formatter, $value, $expected, $message = '') + { + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT32); + $this->assertEquals($expected, $formattedValue, $message); + } + + public static function formatTypeInt32Provider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $message = '->format() TYPE_INT32 formats inconsistently an integer if out of the 32 bit range.'; + + return [ + [$formatter, 1, '1'], + [$formatter, 1.1, '1'], + [$formatter, 2147483648, '-2,147,483,648', $message], + [$formatter, -2147483649, '2,147,483,647', $message], + ]; + } + + /** + * @dataProvider formatTypeInt32WithCurrencyStyleProvider + */ + public function testFormatTypeInt32WithCurrencyStyle($formatter, $value, $expected, $message = '') + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT32); + $this->assertEquals($expected, $formattedValue, $message); + } + + public static function formatTypeInt32WithCurrencyStyleProvider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + $message = '->format() TYPE_INT32 formats inconsistently an integer if out of the 32 bit range.'; + + return [ + [$formatter, 1, '¤1.00'], + [$formatter, 1.1, '¤1.00'], + [$formatter, 2147483648, '-¤2,147,483,648.00', $message], + [$formatter, -2147483649, '¤2,147,483,647.00', $message], + ]; + } + + /** + * The parse() method works differently with integer out of the 32 bit range. format() works fine. + * + * @dataProvider formatTypeInt64Provider + */ + public function testFormatTypeInt64($formatter, $value, $expected) + { + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT64); + $this->assertEquals($expected, $formattedValue); + } + + public static function formatTypeInt64Provider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + return [ + [$formatter, 1, '1'], + [$formatter, 1.1, '1'], + [$formatter, 2147483648, '2,147,483,648'], + [$formatter, -2147483649, '-2,147,483,649'], + ]; + } + + /** + * @dataProvider formatTypeInt64WithCurrencyStyleProvider + */ + public function testFormatTypeInt64WithCurrencyStyle($formatter, $value, $expected) + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT64); + $this->assertEquals($expected, $formattedValue); + } + + public static function formatTypeInt64WithCurrencyStyleProvider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + return [ + [$formatter, 1, '¤1.00'], + [$formatter, 1.1, '¤1.00'], + [$formatter, 2147483648, '¤2,147,483,648.00'], + [$formatter, -2147483649, '-¤2,147,483,649.00'], + ]; + } + + /** + * @dataProvider formatTypeDoubleProvider + */ + public function testFormatTypeDouble($formatter, $value, $expected) + { + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_DOUBLE); + $this->assertEquals($expected, $formattedValue); + } + + public static function formatTypeDoubleProvider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + return [ + [$formatter, 1, '1'], + [$formatter, 1.1, '1.1'], + ]; + } + + /** + * @dataProvider formatTypeDoubleWithCurrencyStyleProvider + */ + public function testFormatTypeDoubleWithCurrencyStyle($formatter, $value, $expected) + { + IntlTestHelper::requireIntl($this, '63.1'); + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_DOUBLE); + $this->assertEquals($expected, $formattedValue); + } + + public static function formatTypeDoubleWithCurrencyStyleProvider() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + return [ + [$formatter, 1, '¤1.00'], + [$formatter, 1.1, '¤1.10'], + ]; + } + + /** + * @dataProvider formatTypeCurrencyProvider + */ + public function testFormatTypeCurrency($formatter, $value) + { + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } else { + $this->expectException(\ErrorException::class); + } + + set_error_handler([self::class, 'throwOnWarning']); + + try { + $formatter->format($value, NumberFormatter::TYPE_CURRENCY); + } finally { + restore_error_handler(); + } + } + + /** + * @dataProvider formatTypeCurrencyProvider + */ + public function testFormatTypeCurrencyReturn($formatter, $value) + { + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } + + $this->assertFalse(@$formatter->format($value, NumberFormatter::TYPE_CURRENCY)); + } + + public static function formatTypeCurrencyProvider() + { + $df = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $cf = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + return [ + [$df, 1], + [$cf, 1], + ]; + } + + /** + * @dataProvider formatFractionDigitsProvider + */ + public function testFormatFractionDigits($value, $expected, $fractionDigits = null, $expectedFractionDigits = 1) + { + IntlTestHelper::requireIntl($this, '62.1'); + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $attributeRet = null; + if (null !== $fractionDigits) { + $attributeRet = $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $fractionDigits); + } + + $formattedValue = $formatter->format($value); + $this->assertSame($expected, $formattedValue); + $this->assertSame($expectedFractionDigits, $formatter->getAttribute(NumberFormatter::FRACTION_DIGITS)); + + if (null !== $attributeRet) { + $this->assertTrue($attributeRet); + } + } + + public static function formatFractionDigitsProvider() + { + yield [1.123, '1.123', null, 0]; + yield [1.123, '1', 0, 0]; + yield [1.123, '1.1', 1, 1]; + yield [1.123, '1.12', 2, 2]; + yield [1.123, '1.123', -1, 0]; + } + + /** + * @dataProvider formatGroupingUsedProvider + */ + public function testFormatGroupingUsed($value, $expected, $groupingUsed = null, $expectedGroupingUsed = 1) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $attributeRet = null; + if (null !== $groupingUsed) { + $attributeRet = $formatter->setAttribute(NumberFormatter::GROUPING_USED, $groupingUsed); + } + + $formattedValue = $formatter->format($value); + $this->assertSame($expected, $formattedValue); + $this->assertSame($expectedGroupingUsed, $formatter->getAttribute(NumberFormatter::GROUPING_USED)); + + if (null !== $attributeRet) { + $this->assertTrue($attributeRet); + } + } + + public static function formatGroupingUsedProvider() + { + yield [1000, '1,000', null, 1]; + yield [1000, '1000', 0, 0]; + yield [1000, '1,000', 1, 1]; + yield [1000, '1,000', 2, 1]; + yield [1000, '1,000', -1, 1]; + } + + /** + * @dataProvider formatRoundingModeRoundHalfUpProvider + */ + public function testFormatRoundingModeHalfUp($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFUP); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_HALFUP rounding mode.'); + } + + public static function formatRoundingModeRoundHalfUpProvider() + { + // The commented value is differently rounded by intl's NumberFormatter in 32 and 64 bit architectures + return [ + [1.121, '1.12'], + [1.123, '1.12'], + // [1.125, '1.13'], + [1.127, '1.13'], + [1.129, '1.13'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundHalfDownProvider + */ + public function testFormatRoundingModeHalfDown($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFDOWN); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_HALFDOWN rounding mode.'); + } + + public static function formatRoundingModeRoundHalfDownProvider() + { + return [ + [1.121, '1.12'], + [1.123, '1.12'], + [1.125, '1.12'], + [1.127, '1.13'], + [1.129, '1.13'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundHalfEvenProvider + */ + public function testFormatRoundingModeHalfEven($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFEVEN); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_HALFEVEN rounding mode.'); + } + + public static function formatRoundingModeRoundHalfEvenProvider() + { + return [ + [1.121, '1.12'], + [1.123, '1.12'], + [1.125, '1.12'], + [1.127, '1.13'], + [1.129, '1.13'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundCeilingProvider + */ + public function testFormatRoundingModeCeiling($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_CEILING); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_CEILING rounding mode.'); + } + + public static function formatRoundingModeRoundCeilingProvider() + { + return [ + [1.123, '1.13'], + [1.125, '1.13'], + [1.127, '1.13'], + [-1.123, '-1.12'], + [-1.125, '-1.12'], + [-1.127, '-1.12'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundFloorProvider + */ + public function testFormatRoundingModeFloor($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_FLOOR); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_FLOOR rounding mode.'); + } + + public static function formatRoundingModeRoundFloorProvider() + { + return [ + [1.123, '1.12'], + [1.125, '1.12'], + [1.127, '1.12'], + [-1.123, '-1.13'], + [-1.125, '-1.13'], + [-1.127, '-1.13'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundDownProvider + */ + public function testFormatRoundingModeDown($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_DOWN); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_DOWN rounding mode.'); + } + + public static function formatRoundingModeRoundDownProvider() + { + return [ + [1.123, '1.12'], + [1.125, '1.12'], + [1.127, '1.12'], + [-1.123, '-1.12'], + [-1.125, '-1.12'], + [-1.127, '-1.12'], + [1020 / 100, '10.20'], + ]; + } + + /** + * @dataProvider formatRoundingModeRoundUpProvider + */ + public function testFormatRoundingModeUp($value, $expected) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); + + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_UP); + $this->assertSame($expected, $formatter->format($value), '->format() with ROUND_UP rounding mode.'); + } + + public static function formatRoundingModeRoundUpProvider() + { + return [ + [1.123, '1.13'], + [1.125, '1.13'], + [1.127, '1.13'], + [-1.123, '-1.13'], + [-1.125, '-1.13'], + [-1.127, '-1.13'], + [1020 / 100, '10.20'], + ]; + } + + public function testGetLocale() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertEquals('en', $formatter->getLocale()); + } + + public function testGetSymbol() + { + $decimalFormatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $currencyFormatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + $r = new \ReflectionClassConstant(NumberFormatter::class, 'EN_SYMBOLS'); + $expected = $r->getValue(); + + for ($i = 0; $i <= 17; ++$i) { + $this->assertSame($expected[1][$i], $decimalFormatter->getSymbol($i)); + $this->assertSame($expected[2][$i], $currencyFormatter->getSymbol($i)); + } + } + + public function testGetTextAttribute() + { + IntlTestHelper::requireIntl($this, '63.1'); + + $decimalFormatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $currencyFormatter = static::getNumberFormatter('en', NumberFormatter::CURRENCY); + + $r = new \ReflectionClassConstant(NumberFormatter::class, 'EN_TEXT_ATTRIBUTES'); + $expected = $r->getValue(); + + for ($i = 0; $i <= 5; ++$i) { + $this->assertSame($expected[1][$i], $decimalFormatter->getTextAttribute($i)); + $this->assertSame($expected[2][$i], $currencyFormatter->getTextAttribute($i)); + } + } + + /** + * @dataProvider parseProvider + */ + public function testParse($value, $expected, $message, $expectedPosition, $groupingUsed = true) + { + IntlTestHelper::requireIntl($this, '62.1'); + + $position = 0; + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::GROUPING_USED, $groupingUsed); + $parsedValue = $formatter->parse($value, NumberFormatter::TYPE_DOUBLE, $position); + $this->assertSame($expected, $parsedValue, $message); + $this->assertSame($expectedPosition, $position, $message); + + if (false === $expected) { + $errorCode = IntlGlobals::U_PARSE_ERROR; + $errorMessage = 'Number parsing failed: U_PARSE_ERROR'; + } else { + $errorCode = IntlGlobals::U_ZERO_ERROR; + $errorMessage = 'U_ZERO_ERROR'; + } + + $this->assertSame($errorMessage, $this->getIntlErrorMessage()); + $this->assertSame($errorCode, $this->getIntlErrorCode()); + $this->assertSame(0 !== $errorCode, $this->isIntlFailure($this->getIntlErrorCode())); + $this->assertSame($errorMessage, $formatter->getErrorMessage()); + $this->assertSame($errorCode, $formatter->getErrorCode()); + $this->assertSame(0 !== $errorCode, $this->isIntlFailure($formatter->getErrorCode())); + } + + public static function parseProvider() + { + return [ + ['prefix1', false, '->parse() does not parse a number with a string prefix.', 0], + ['prefix1', false, '->parse() does not parse a number with a string prefix.', 0, false], + ['1.4suffix', 1.4, '->parse() parses a number with a string suffix.', 3], + ['1.4suffix', 1.4, '->parse() parses a number with a string suffix.', 3, false], + ['1,234.4suffix', 1234.4, '->parse() parses a number with a string suffix.', 7], + ['1,234.4suffix', 1.0, '->parse() parses a number with a string suffix.', 1, false], + ['-.4suffix', -0.4, '->parse() parses a negative dot float with suffix.', 3], + ['-.4suffix', -0.4, '->parse() parses a negative dot float with suffix.', 3, false], + [',4', false, '->parse() does not parse when invalid grouping used.', 0], + [',4', false, '->parse() does not parse when invalid grouping used.', 0, false], + ['123,4', false, '->parse() does not parse when invalid grouping used.', 0], + ['123,4', 123.0, '->parse() truncates invalid grouping when grouping is disabled.', 3, false], + ['123,a4', 123.0, '->parse() truncates a string suffix.', 3], + ['123,a4', 123.0, '->parse() truncates a string suffix.', 3, false], + ['-123,4', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123,4', -123.0, '->parse() truncates invalid grouping when grouping is disabled.', 4, false], + ['-123,4567', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123,4567', -123.0, '->parse() truncates invalid grouping when grouping is disabled.', 4, false], + ['-123,456,789', -123456789.0, '->parse() parses a number with grouping.', 12], + ['-123,456,789', -123.0, '->parse() truncates a group if grouping is disabled.', 4, false], + ['-123,456,789.66', -123456789.66, '->parse() parses a number with grouping.', 15], + ['-123,456,789.66', -123.00, '->parse() truncates a group if grouping is disabled.', 4, false], + ['-123,456789.66', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123,456789.66', -123.00, '->parse() truncates a group if grouping is disabled.', 4, false], + ['-123456,789.66', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123456,789.66', -123456.00, '->parse() truncates a group if grouping is disabled.', 7, false], + ['-123,456,78', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123,456,78', -123.0, '->parse() truncates a group if grouping is disabled.', 4, false], + ['-123,45,789', false, '->parse() does not parse when invalid grouping used.', 1], + ['-123,45,789', -123.0, '->parse() truncates a group if grouping is disabled.', 4, false], + ['-123,,456', -123.0, '->parse() parses when grouping is duplicated.', 4], + ['-123,,456', -123.0, '->parse() parses when grouping is disabled.', 4, false], + ['-123,,4', -123.0, '->parse() parses when grouping is duplicated.', 4], + ['-123,,4', -123.0, '->parse() parses when grouping is duplicated.', 4, false], + ['239.', 239.0, '->parse() parses when string ends with decimal separator.', 4], + ['239.', 239.0, '->parse() parses when string ends with decimal separator.', 4, false], + ]; + } + + public function testParseTypeDefault() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } else { + $this->expectException(\ErrorException::class); + } + + set_error_handler([self::class, 'throwOnWarning']); + + try { + $formatter->parse('1', NumberFormatter::TYPE_DEFAULT); + } finally { + restore_error_handler(); + } + } + + /** + * @dataProvider parseTypeInt32Provider + */ + public function testParseTypeInt32($value, $expected, $message = '') + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $parsedValue = $formatter->parse($value, NumberFormatter::TYPE_INT32); + $this->assertSame($expected, $parsedValue, $message); + } + + public static function parseTypeInt32Provider() + { + return [ + ['1', 1], + ['1.1', 1], + ['.1', 0], + ['2,147,483,647', 2147483647], + ['-2,147,483,648', -2147483647 - 1], + ['2,147,483,648', false, '->parse() TYPE_INT32 returns false when the number is greater than the integer positive range.'], + ['-2,147,483,649', false, '->parse() TYPE_INT32 returns false when the number is greater than the integer negative range.'], + ]; + } + + public function testParseTypeInt64With32BitIntegerInPhp32Bit() + { + IntlTestHelper::require32Bit($this); + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $parsedValue = $formatter->parse('2,147,483,647', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + $this->assertEquals(2147483647, $parsedValue); + + $parsedValue = $formatter->parse('-2,147,483,648', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + $this->assertEquals(-2147483648, $parsedValue); + } + + public function testParseTypeInt64With32BitIntegerInPhp64Bit() + { + IntlTestHelper::require64Bit($this); + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $parsedValue = $formatter->parse('2,147,483,647', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + $this->assertEquals(2147483647, $parsedValue); + + $parsedValue = $formatter->parse('-2,147,483,648', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + $this->assertEquals(-2147483647 - 1, $parsedValue); + } + + /** + * If PHP is compiled in 32bit mode, the returned value for a 64bit integer are float numbers. + */ + public function testParseTypeInt64With64BitIntegerInPhp32Bit() + { + IntlTestHelper::require32Bit($this); + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + // int 64 using only 32 bit range strangeness + $parsedValue = $formatter->parse('2,147,483,648', NumberFormatter::TYPE_INT64); + $this->assertIsFloat($parsedValue); + $this->assertEquals(2147483648, $parsedValue, '->parse() TYPE_INT64 does not use true 64 bit integers, using only the 32 bit range.'); + + $parsedValue = $formatter->parse('-2,147,483,649', NumberFormatter::TYPE_INT64); + $this->assertIsFloat($parsedValue); + $this->assertEquals(-2147483649, $parsedValue, '->parse() TYPE_INT64 does not use true 64 bit integers, using only the 32 bit range.'); + } + + /** + * If PHP is compiled in 64bit mode, the returned value for a 64bit integer are 32bit integer numbers. + */ + public function testParseTypeInt64With64BitIntegerInPhp64Bit() + { + IntlTestHelper::require64Bit($this); + + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + $parsedValue = $formatter->parse('2,147,483,648', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + + $this->assertEquals(2147483648, $parsedValue, '->parse() TYPE_INT64 uses true 64 bit integers (PHP >= 5.3.14 and PHP >= 5.4.4).'); + + $parsedValue = $formatter->parse('-2,147,483,649', NumberFormatter::TYPE_INT64); + $this->assertIsInt($parsedValue); + + $this->assertEquals(-2147483649, $parsedValue, '->parse() TYPE_INT64 uses true 64 bit integers (PHP >= 5.3.14 and PHP >= 5.4.4).'); + } + + /** + * @dataProvider parseTypeDoubleProvider + */ + public function testParseTypeDouble($value, $expectedValue) + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $parsedValue = $formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + $this->assertEqualsWithDelta($expectedValue, $parsedValue, 0.001); + } + + public static function parseTypeDoubleProvider() + { + return [ + ['1', (float) 1], + ['1.1', 1.1], + ['9,223,372,036,854,775,808', 9223372036854775808], + ['-9,223,372,036,854,775,809', -9223372036854775809], + ]; + } + + public function testParseTypeCurrency() + { + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } else { + $this->expectException(\ErrorException::class); + } + + set_error_handler([self::class, 'throwOnWarning']); + + try { + $formatter->parse('1', NumberFormatter::TYPE_CURRENCY); + } finally { + restore_error_handler(); + } + } + + public function testParseWithNotNullPositionValue() + { + $position = 1; + $formatter = static::getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->parse('123', NumberFormatter::TYPE_DOUBLE, $position); + $this->assertEquals(3, $position); + } + + /** + * @return NumberFormatter|\NumberFormatter + */ + abstract protected static function getNumberFormatter(string $locale = 'en', ?string $style = null, ?string $pattern = null); + + abstract protected function getIntlErrorMessage(): string; + + abstract protected function getIntlErrorCode(): int; + + /** + * @param int $errorCode + */ + abstract protected function isIntlFailure($errorCode): bool; + + public static function throwOnWarning(int $errno, string $errstr, ?string $errfile = null, ?int $errline = null): bool + { + if ($errno & (\E_WARNING | \E_USER_WARNING)) { + throw new \ErrorException($errstr, 0, $errno, $errfile ?? __FILE__, $errline ?? __LINE__); + } + + return false; + } +} diff --git a/Tests/NumberFormatter/NumberFormatterTest.php b/Tests/NumberFormatter/NumberFormatterTest.php new file mode 100644 index 00000000..649cc834 --- /dev/null +++ b/Tests/NumberFormatter/NumberFormatterTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\Tests\NumberFormatter; + +use Symfony\Component\Intl\Exception\MethodArgumentNotImplementedException; +use Symfony\Component\Intl\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Component\Intl\Exception\MethodNotImplementedException; +use Symfony\Component\Intl\Exception\NotImplementedException; +use Symfony\Component\Intl\Globals\IntlGlobals; +use Symfony\Component\Intl\NumberFormatter\NumberFormatter; + +/** + * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known + * behavior of PHP. + * + * @group legacy + */ +class NumberFormatterTest extends AbstractNumberFormatterTestCase +{ + public function testConstructorWithUnsupportedLocale() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $this->getNumberFormatter('pt_BR'); + } + + public function testConstructorWithUnsupportedStyle() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $this->getNumberFormatter('en', NumberFormatter::PATTERN_DECIMAL); + } + + public function testConstructorWithPatternDifferentThanNull() + { + $this->expectException(MethodArgumentNotImplementedException::class); + $this->getNumberFormatter('en', NumberFormatter::DECIMAL, ''); + } + + public function testSetAttributeWithUnsupportedAttribute() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::LENIENT_PARSE, 100); + } + + public function testSetAttributeInvalidRoundingMode() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, -1); + } + + public function testConstructWithoutLocale() + { + $this->assertInstanceOf(NumberFormatter::class, $this->getNumberFormatter(null, NumberFormatter::DECIMAL)); + } + + public function testCreate() + { + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertInstanceOf(NumberFormatter::class, $formatter::create('en', NumberFormatter::DECIMAL)); + } + + public function testFormatWithCurrencyStyle() + { + $this->expectException(\RuntimeException::class); + parent::testFormatWithCurrencyStyle(); + } + + /** + * @dataProvider formatTypeInt32Provider + */ + public function testFormatTypeInt32($formatter, $value, $expected, $message = '') + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + parent::testFormatTypeInt32($formatter, $value, $expected, $message); + } + + /** + * @dataProvider formatTypeInt32WithCurrencyStyleProvider + */ + public function testFormatTypeInt32WithCurrencyStyle($formatter, $value, $expected, $message = '') + { + $this->expectException(NotImplementedException::class); + parent::testFormatTypeInt32WithCurrencyStyle($formatter, $value, $expected, $message); + } + + /** + * @dataProvider formatTypeInt64Provider + */ + public function testFormatTypeInt64($formatter, $value, $expected) + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + parent::testFormatTypeInt64($formatter, $value, $expected); + } + + /** + * @dataProvider formatTypeInt64WithCurrencyStyleProvider + */ + public function testFormatTypeInt64WithCurrencyStyle($formatter, $value, $expected) + { + $this->expectException(NotImplementedException::class); + parent::testFormatTypeInt64WithCurrencyStyle($formatter, $value, $expected); + } + + /** + * @dataProvider formatTypeDoubleProvider + */ + public function testFormatTypeDouble($formatter, $value, $expected) + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + parent::testFormatTypeDouble($formatter, $value, $expected); + } + + /** + * @dataProvider formatTypeDoubleWithCurrencyStyleProvider + */ + public function testFormatTypeDoubleWithCurrencyStyle($formatter, $value, $expected) + { + $this->expectException(NotImplementedException::class); + parent::testFormatTypeDoubleWithCurrencyStyle($formatter, $value, $expected); + } + + public function testGetPattern() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->getPattern(); + } + + public function testGetErrorCode() + { + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertEquals(IntlGlobals::U_ZERO_ERROR, $formatter->getErrorCode()); + } + + public function testParseCurrency() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $currency = 'USD'; + $formatter->parseCurrency(3, $currency); + } + + public function testSetPattern() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setPattern('#0'); + } + + public function testSetSymbol() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setSymbol(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '*'); + } + + public function testSetTextAttribute() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->setTextAttribute(NumberFormatter::NEGATIVE_PREFIX, '-'); + } + + protected static function getNumberFormatter(?string $locale = 'en', ?string $style = null, ?string $pattern = null): NumberFormatter + { + return new class($locale, $style, $pattern) extends NumberFormatter { + }; + } + + protected function getIntlErrorMessage(): string + { + return IntlGlobals::getErrorMessage(); + } + + protected function getIntlErrorCode(): int + { + return IntlGlobals::getErrorCode(); + } + + protected function isIntlFailure($errorCode): bool + { + return IntlGlobals::isFailure($errorCode); + } +} diff --git a/Tests/NumberFormatter/Verification/NumberFormatterTest.php b/Tests/NumberFormatter/Verification/NumberFormatterTest.php new file mode 100644 index 00000000..0a326cb8 --- /dev/null +++ b/Tests/NumberFormatter/Verification/NumberFormatterTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Intl\Tests\NumberFormatter\Verification; + +use Symfony\Component\Intl\Tests\NumberFormatter\AbstractNumberFormatterTestCase; +use Symfony\Component\Intl\Util\IntlTestHelper; + +/** + * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known + * behavior of PHP. + */ +class NumberFormatterTest extends AbstractNumberFormatterTestCase +{ + protected function setUp(): void + { + IntlTestHelper::requireFullIntl($this, '55.1'); + + parent::setUp(); + } + + public function testCreate() + { + $this->assertInstanceOf(\NumberFormatter::class, \NumberFormatter::create('en', \NumberFormatter::DECIMAL)); + } + + public function testGetTextAttribute() + { + IntlTestHelper::requireFullIntl($this, '57.1'); + + parent::testGetTextAttribute(); + } + + protected static function getNumberFormatter(?string $locale = 'en', ?string $style = null, ?string $pattern = null): \NumberFormatter + { + return new \NumberFormatter($locale, $style, $pattern); + } + + protected function getIntlErrorMessage(): string + { + return intl_get_error_message(); + } + + protected function getIntlErrorCode(): int + { + return intl_get_error_code(); + } + + protected function isIntlFailure($errorCode): bool + { + return intl_is_failure($errorCode); + } +} diff --git a/Timezones.php b/Timezones.php index 0f9f4e3d..53d054f2 100644 --- a/Timezones.php +++ b/Timezones.php @@ -49,7 +49,7 @@ public static function exists(string $timezone): bool /** * @throws MissingResourceException if the timezone identifier does not exist or is an alias */ - public static function getName(string $timezone, string $displayLocale = null): string + public static function getName(string $timezone, ?string $displayLocale = null): string { return self::readEntry(['Names', $timezone], $displayLocale); } @@ -57,7 +57,7 @@ public static function getName(string $timezone, string $displayLocale = null): /** * @return string[] */ - public static function getNames(string $displayLocale = null): array + public static function getNames(?string $displayLocale = null): array { return self::asort(self::readEntry(['Names'], $displayLocale), $displayLocale); } @@ -66,14 +66,14 @@ public static function getNames(string $displayLocale = null): array * @throws \Exception if the timezone identifier does not exist * @throws RuntimeException if there's no timezone DST transition information available */ - public static function getRawOffset(string $timezone, int $timestamp = null): int + public static function getRawOffset(string $timezone, ?int $timestamp = null): int { $dateTimeImmutable = new \DateTimeImmutable(date('Y-m-d H:i:s', $timestamp ?? time()), new \DateTimeZone($timezone)); return $dateTimeImmutable->getOffset(); } - public static function getGmtOffset(string $timezone, int $timestamp = null, string $displayLocale = null): string + public static function getGmtOffset(string $timezone, ?int $timestamp = null, ?string $displayLocale = null): string { $offset = self::getRawOffset($timezone, $timestamp); $abs = abs($offset); diff --git a/Util/GitRepository.php b/Util/GitRepository.php index acbe8c49..1f8870cc 100644 --- a/Util/GitRepository.php +++ b/Util/GitRepository.php @@ -69,7 +69,7 @@ public function getLastAuthoredDate(): \DateTimeImmutable return new \DateTimeImmutable($this->getLastLine($this->execInPath('git log -1 --format="%ai"'))); } - public function getLastTag(callable $filter = null): string + public function getLastTag(?callable $filter = null): string { $tags = $this->execInPath('git tag -l --sort=v:refname'); @@ -90,7 +90,7 @@ private function execInPath(string $command): array return self::exec(sprintf('cd %s && %s', escapeshellarg($this->path), $command)); } - private static function exec(string $command, string $customErrorMessage = null): array + private static function exec(string $command, ?string $customErrorMessage = null): array { exec(sprintf('%s 2>&1', $command), $output, $result); diff --git a/Util/IcuVersion.php b/Util/IcuVersion.php index b75987f7..22c15ae2 100644 --- a/Util/IcuVersion.php +++ b/Util/IcuVersion.php @@ -48,7 +48,7 @@ class IcuVersion * * @see normalize() */ - public static function compare(string $version1, string $version2, string $operator, int $precision = null): bool + public static function compare(string $version1, string $version2, string $operator, ?int $precision = null): bool { $version1 = self::normalize($version1, $precision); $version2 = self::normalize($version2, $precision); diff --git a/Util/IntlTestHelper.php b/Util/IntlTestHelper.php index d22f2e69..86d188d7 100644 --- a/Util/IntlTestHelper.php +++ b/Util/IntlTestHelper.php @@ -32,7 +32,7 @@ class IntlTestHelper * * @return void */ - public static function requireIntl(TestCase $testCase, string $minimumIcuVersion = null) + public static function requireIntl(TestCase $testCase, ?string $minimumIcuVersion = null) { $minimumIcuVersion ??= Intl::getIcuStubVersion(); @@ -66,7 +66,7 @@ public static function requireIntl(TestCase $testCase, string $minimumIcuVersion * * @return void */ - public static function requireFullIntl(TestCase $testCase, string $minimumIcuVersion = null) + public static function requireFullIntl(TestCase $testCase, ?string $minimumIcuVersion = null) { // We only run tests if the intl extension is loaded... if (!Intl::isExtensionLoaded()) { diff --git a/Util/Version.php b/Util/Version.php index 8d72c3af..f30709ec 100644 --- a/Util/Version.php +++ b/Util/Version.php @@ -38,7 +38,7 @@ class Version * * @see normalize() */ - public static function compare(string $version1, string $version2, string $operator, int $precision = null): bool + public static function compare(string $version1, string $version2, string $operator, ?int $precision = null): bool { $version1 = self::normalize($version1, $precision); $version2 = self::normalize($version2, $precision);