diff --git a/composer.json b/composer.json index 61534998f..4f9e13819 100644 --- a/composer.json +++ b/composer.json @@ -16,10 +16,10 @@ } ], "require": { - "php": ">=7.1", - "symfony/intl": "~2.3|~3.0|~4.0|~5.0" + "php": ">=7.1" }, "require-dev": { + "symfony/intl": "^4.4|^5.0", "symfony/phpunit-bridge": "^5" }, "replace": { @@ -59,6 +59,7 @@ "src/Mbstring/bootstrap.php" ], "classmap": [ + "src/Intl/Icu/Resources/stubs", "src/Intl/MessageFormatter/Resources/stubs", "src/Intl/Normalizer/Resources/stubs", "src/Php80/Resources/stubs", diff --git a/src/Intl/Icu/Collator.php b/src/Intl/Icu/Collator.php new file mode 100644 index 000000000..9772ab91b --- /dev/null +++ b/src/Intl/Icu/Collator.php @@ -0,0 +1,295 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\Locale; + +/** + * Replacement for PHP's native {@link \Collator} class. + * + * The only methods currently supported in this class are: + * + * - {@link \__construct} + * - {@link create} + * - {@link asort} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * + * @author Igor Wiedler + * @author Bernhard Schussek + * + * @internal + */ +abstract class Collator +{ + /* Attribute constants */ + const FRENCH_COLLATION = 0; + const ALTERNATE_HANDLING = 1; + const CASE_FIRST = 2; + const CASE_LEVEL = 3; + const NORMALIZATION_MODE = 4; + const STRENGTH = 5; + const HIRAGANA_QUATERNARY_MODE = 6; + const NUMERIC_COLLATION = 7; + + /* Attribute constants values */ + const DEFAULT_VALUE = -1; + + const PRIMARY = 0; + const SECONDARY = 1; + const TERTIARY = 2; + const DEFAULT_STRENGTH = 2; + const QUATERNARY = 3; + const IDENTICAL = 15; + + const OFF = 16; + const ON = 17; + + const SHIFTED = 20; + const NON_IGNORABLE = 21; + + const LOWER_FIRST = 24; + const UPPER_FIRST = 25; + + /* Sorting options */ + const SORT_REGULAR = 0; + const SORT_NUMERIC = 2; + const SORT_STRING = 1; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + */ + public function __construct(?string $locale) + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + } + + /** + * 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") + * + * @return static + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + */ + public static function create(?string $locale) + { + return new static($locale); + } + + /** + * Sort array maintaining index association. + * + * @param array &$array Input array + * @param int $sortFlag Flags for sorting, can be one of the following: + * Collator::SORT_REGULAR - compare items normally (don't change types) + * Collator::SORT_NUMERIC - compare items numerically + * Collator::SORT_STRING - compare items as strings + * + * @return bool True on success or false on failure + */ + public function asort(array &$array, int $sortFlag = self::SORT_REGULAR) + { + $intlToPlainFlagMap = [ + self::SORT_REGULAR => \SORT_REGULAR, + self::SORT_NUMERIC => \SORT_NUMERIC, + self::SORT_STRING => \SORT_STRING, + ]; + + $plainSortFlag = isset($intlToPlainFlagMap[$sortFlag]) ? $intlToPlainFlagMap[$sortFlag] : self::SORT_REGULAR; + + return asort($array, $plainSortFlag); + } + + /** + * Not supported. Compare two Unicode strings. + * + * @param string $str1 The first string to compare + * @param string $str2 The second string to compare + * + * @return bool|int Return the comparison result or false on failure: + * 1 if $str1 is greater than $str2 + * 0 if $str1 is equal than $str2 + * -1 if $str1 is less than $str2 + * + * @see https://php.net/collator.compare + * + * @throws MethodNotImplementedException + */ + public function compare(string $str1, string $str2) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Get a value of an integer collator attribute. + * + * @param int $attr An attribute specifier, one of the attribute constants + * + * @return bool|int The attribute value on success or false on error + * + * @see https://php.net/collator.getattribute + * + * @throws MethodNotImplementedException + */ + public function getAttribute(int $attr) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns collator's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int The error code from last collator call + */ + public function getErrorCode() + { + return Icu::U_ZERO_ERROR; + } + + /** + * Returns collator's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string The error message from last collator call + */ + public function getErrorMessage() + { + return 'U_ZERO_ERROR'; + } + + /** + * Returns the collator'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 collator. Currently always + * returns "en". + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Not supported. Get sorting key for a string. + * + * @param string $string The string to produce the key from + * + * @return string The collation key for $string + * + * @see https://php.net/collator.getsortkey + * + * @throws MethodNotImplementedException + */ + public function getSortKey(string $string) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Get current collator's strength. + * + * @return bool|int The current collator's strength or false on failure + * + * @see https://php.net/collator.getstrength + * + * @throws MethodNotImplementedException + */ + public function getStrength() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set a collator's attribute. + * + * @param int $attr An attribute specifier, one of the attribute constants + * @param int $val The attribute value, one of the attribute value constants + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.setattribute + * + * @throws MethodNotImplementedException + */ + public function setAttribute(int $attr, int $val) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set the collator's strength. + * + * @param int $strength Strength to set, possible values: + * Collator::PRIMARY + * Collator::SECONDARY + * Collator::TERTIARY + * Collator::QUATERNARY + * Collator::IDENTICAL + * Collator::DEFAULT + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.setstrength + * + * @throws MethodNotImplementedException + */ + public function setStrength(int $strength) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sort array using specified collator and sort keys. + * + * @param array &$arr Array of strings to sort + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.sortwithsortkeys + * + * @throws MethodNotImplementedException + */ + public function sortWithSortKeys(array &$arr) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sort array using specified collator. + * + * @param array &$arr Array of string to sort + * @param int $sortFlag Optional sorting type, one of the following: + * Collator::SORT_REGULAR + * Collator::SORT_NUMERIC + * Collator::SORT_STRING + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.sort + * + * @throws MethodNotImplementedException + */ + public function sort(array &$arr, int $sortFlag = self::SORT_REGULAR) + { + throw new MethodNotImplementedException(__METHOD__); + } +} diff --git a/src/Intl/Icu/Currencies.php b/src/Intl/Icu/Currencies.php new file mode 100644 index 000000000..90b1efa69 --- /dev/null +++ b/src/Intl/Icu/Currencies.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Currencies +{ + private static $data; + + public static function getSymbol(string $currency): ?string + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][0] ?? $data[strtoupper($currency)][0] ?? null; + } + + public static function getFractionDigits(string $currency): int + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][1] ?? $data[strtoupper($currency)][1] ?? $data['DEFAULT'][1]; + } + + public static function getRoundingIncrement(string $currency): int + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][2] ?? $data[strtoupper($currency)][2] ?? $data['DEFAULT'][2]; + } +} diff --git a/src/Intl/Icu/DateFormat/AmPmTransformer.php b/src/Intl/Icu/DateFormat/AmPmTransformer.php new file mode 100644 index 000000000..931e84496 --- /dev/null +++ b/src/Intl/Icu/DateFormat/AmPmTransformer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for AM/PM markers format. + * + * @author Igor Wiedler + * + * @internal + */ +class AmPmTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + return $dateTime->format('A'); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 'AM|PM'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'marker' => $matched, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/DayOfWeekTransformer.php b/src/Intl/Icu/DateFormat/DayOfWeekTransformer.php new file mode 100644 index 000000000..19c499495 --- /dev/null +++ b/src/Intl/Icu/DateFormat/DayOfWeekTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day of week format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayOfWeekTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $dayOfWeek = $dateTime->format('l'); + switch ($length) { + case 4: + return $dayOfWeek; + case 5: + return $dayOfWeek[0]; + case 6: + return substr($dayOfWeek, 0, 2); + default: + return substr($dayOfWeek, 0, 3); + } + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 4: + return 'Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday'; + case 5: + return '[MTWFS]'; + case 6: + return 'Mo|Tu|We|Th|Fr|Sa|Su'; + default: + return 'Mon|Tue|Wed|Thu|Fri|Sat|Sun'; + } + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/src/Intl/Icu/DateFormat/DayOfYearTransformer.php b/src/Intl/Icu/DateFormat/DayOfYearTransformer.php new file mode 100644 index 000000000..5db0930c8 --- /dev/null +++ b/src/Intl/Icu/DateFormat/DayOfYearTransformer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day of year format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayOfYearTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $dayOfYear = (int) $dateTime->format('z') + 1; + + return $this->padLeft($dayOfYear, $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return '\d{'.$length.'}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/src/Intl/Icu/DateFormat/DayTransformer.php b/src/Intl/Icu/DateFormat/DayTransformer.php new file mode 100644 index 000000000..db4b57bf5 --- /dev/null +++ b/src/Intl/Icu/DateFormat/DayTransformer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('j'), $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{1,'.$length.'}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'day' => (int) $matched, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/FullTransformer.php b/src/Intl/Icu/DateFormat/FullTransformer.php new file mode 100644 index 000000000..792c65106 --- /dev/null +++ b/src/Intl/Icu/DateFormat/FullTransformer.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; + +/** + * Parser and formatter for date formats. + * + * @author Igor Wiedler + * + * @internal + */ +class FullTransformer +{ + private $quoteMatch = "'(?:[^']+|'')*'"; + private $implementedChars = 'MLydQqhDEaHkKmsz'; + private $notImplementedChars = 'GYuwWFgecSAZvVW'; + private $regExp; + + /** + * @var Transformer[] + */ + private $transformers; + + private $pattern; + private $timezone; + + /** + * @param string $pattern The pattern to be used to format and/or parse values + * @param string $timezone The timezone to perform the date/time calculations + */ + public function __construct(string $pattern, string $timezone) + { + $this->pattern = $pattern; + $this->timezone = $timezone; + + $implementedCharsMatch = $this->buildCharsMatch($this->implementedChars); + $notImplementedCharsMatch = $this->buildCharsMatch($this->notImplementedChars); + $this->regExp = "/($this->quoteMatch|$implementedCharsMatch|$notImplementedCharsMatch)/"; + + $this->transformers = [ + 'M' => new MonthTransformer(), + 'L' => new MonthTransformer(), + 'y' => new YearTransformer(), + 'd' => new DayTransformer(), + 'q' => new QuarterTransformer(), + 'Q' => new QuarterTransformer(), + 'h' => new Hour1201Transformer(), + 'D' => new DayOfYearTransformer(), + 'E' => new DayOfWeekTransformer(), + 'a' => new AmPmTransformer(), + 'H' => new Hour2400Transformer(), + 'K' => new Hour1200Transformer(), + 'k' => new Hour2401Transformer(), + 'm' => new MinuteTransformer(), + 's' => new SecondTransformer(), + 'z' => new TimezoneTransformer(), + ]; + } + + /** + * Format a DateTime using ICU dateformat pattern. + * + * @return string The formatted value + */ + public function format(\DateTime $dateTime): string + { + $formatted = preg_replace_callback($this->regExp, function ($matches) use ($dateTime) { + return $this->formatReplace($matches[0], $dateTime); + }, $this->pattern); + + return $formatted; + } + + /** + * Return the formatted ICU value for the matched date characters. + * + * @throws NotImplementedException When it encounters a not implemented date character + */ + private function formatReplace(string $dateChars, \DateTime $dateTime): string + { + $length = \strlen($dateChars); + + if ($this->isQuoteMatch($dateChars)) { + return $this->replaceQuoteMatch($dateChars); + } + + if (isset($this->transformers[$dateChars[0]])) { + $transformer = $this->transformers[$dateChars[0]]; + + return $transformer->format($dateTime, $length); + } + + // handle unimplemented characters + if (false !== strpos($this->notImplementedChars, $dateChars[0])) { + throw new NotImplementedException(sprintf('Unimplemented date character "%s" in format "%s".', $dateChars[0], $this->pattern)); + } + + return ''; + } + + /** + * Parse a pattern based string to a timestamp value. + * + * @param \DateTime $dateTime A configured DateTime object to use to perform the date calculation + * @param string $value String to convert to a time value + * + * @return int|false The corresponding Unix timestamp + * + * @throws \InvalidArgumentException When the value can not be matched with pattern + */ + public function parse(\DateTime $dateTime, string $value) + { + $reverseMatchingRegExp = $this->getReverseMatchingRegExp($this->pattern); + $reverseMatchingRegExp = '/^'.$reverseMatchingRegExp.'$/'; + + $options = []; + + if (preg_match($reverseMatchingRegExp, $value, $matches)) { + $matches = $this->normalizeArray($matches); + + foreach ($this->transformers as $char => $transformer) { + if (isset($matches[$char])) { + $length = \strlen($matches[$char]['pattern']); + $options = array_merge($options, $transformer->extractDateOptions($matches[$char]['value'], $length)); + } + } + + // reset error code and message + Icu::setError(Icu::U_ZERO_ERROR); + + return $this->calculateUnixTimestamp($dateTime, $options); + } + + // behave like the intl extension + Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed'); + + return false; + } + + /** + * Retrieve a regular expression to match with a formatted value. + * + * @return string The reverse matching regular expression with named captures being formed by the + * transformer index in the $transformer array + */ + private function getReverseMatchingRegExp(string $pattern): string + { + $escapedPattern = preg_quote($pattern, '/'); + + // ICU 4.8 recognizes slash ("/") in a value to be parsed as a dash ("-") and vice-versa + // when parsing a date/time value + $escapedPattern = preg_replace('/\\\[\-|\/]/', '[\/\-]', $escapedPattern); + + $reverseMatchingRegExp = preg_replace_callback($this->regExp, function ($matches) { + $length = \strlen($matches[0]); + $transformerIndex = $matches[0][0]; + + $dateChars = $matches[0]; + if ($this->isQuoteMatch($dateChars)) { + return $this->replaceQuoteMatch($dateChars); + } + + if (isset($this->transformers[$transformerIndex])) { + $transformer = $this->transformers[$transformerIndex]; + $captureName = str_repeat($transformerIndex, $length); + + return "(?P<$captureName>".$transformer->getReverseMatchingRegExp($length).')'; + } + + return null; + }, $escapedPattern); + + return $reverseMatchingRegExp; + } + + /** + * Check if the first char of a string is a single quote. + */ + private function isQuoteMatch(string $quoteMatch): bool + { + return "'" === $quoteMatch[0]; + } + + /** + * Replaces single quotes at the start or end of a string with two single quotes. + */ + private function replaceQuoteMatch(string $quoteMatch): string + { + if (preg_match("/^'+$/", $quoteMatch)) { + return str_replace("''", "'", $quoteMatch); + } + + return str_replace("''", "'", substr($quoteMatch, 1, -1)); + } + + /** + * Builds a chars match regular expression. + */ + private function buildCharsMatch(string $specialChars): string + { + $specialCharsArray = str_split($specialChars); + + $specialCharsMatch = implode('|', array_map(function ($char) { + return $char.'+'; + }, $specialCharsArray)); + + return $specialCharsMatch; + } + + /** + * Normalize a preg_replace match array, removing the numeric keys and returning an associative array + * with the value and pattern values for the matched Transformer. + */ + private function normalizeArray(array $data): array + { + $ret = []; + + foreach ($data as $key => $value) { + if (!\is_string($key)) { + continue; + } + + $ret[$key[0]] = [ + 'value' => $value, + 'pattern' => $key, + ]; + } + + return $ret; + } + + /** + * Calculates the Unix timestamp based on the matched values by the reverse matching regular + * expression of parse(). + * + * @return bool|int The calculated timestamp or false if matched date is invalid + */ + private function calculateUnixTimestamp(\DateTime $dateTime, array $options) + { + $options = $this->getDefaultValueForOptions($options); + + $year = $options['year']; + $month = $options['month']; + $day = $options['day']; + $hour = $options['hour']; + $hourInstance = $options['hourInstance']; + $minute = $options['minute']; + $second = $options['second']; + $marker = $options['marker']; + $timezone = $options['timezone']; + + // If month is false, return immediately (intl behavior) + if (false === $month) { + Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed'); + + return false; + } + + // Normalize hour + if ($hourInstance instanceof HourTransformer) { + $hour = $hourInstance->normalizeHour($hour, $marker); + } + + // Set the timezone if different from the default one + if (null !== $timezone && $timezone !== $this->timezone) { + $dateTime->setTimezone(new \DateTimeZone($timezone)); + } + + // Normalize yy year + preg_match_all($this->regExp, $this->pattern, $matches); + if (\in_array('yy', $matches[0])) { + $dateTime->setTimestamp(time()); + $year = $year > (int) $dateTime->format('y') + 20 ? 1900 + $year : 2000 + $year; + } + + $dateTime->setDate($year, $month, $day); + $dateTime->setTime($hour, $minute, $second); + + return $dateTime->getTimestamp(); + } + + /** + * Add sensible default values for missing items in the extracted date/time options array. The values + * are base in the beginning of the Unix era. + */ + private function getDefaultValueForOptions(array $options): array + { + return [ + 'year' => isset($options['year']) ? $options['year'] : 1970, + 'month' => isset($options['month']) ? $options['month'] : 1, + 'day' => isset($options['day']) ? $options['day'] : 1, + 'hour' => isset($options['hour']) ? $options['hour'] : 0, + 'hourInstance' => isset($options['hourInstance']) ? $options['hourInstance'] : null, + 'minute' => isset($options['minute']) ? $options['minute'] : 0, + 'second' => isset($options['second']) ? $options['second'] : 0, + 'marker' => isset($options['marker']) ? $options['marker'] : null, + 'timezone' => isset($options['timezone']) ? $options['timezone'] : null, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/Hour1200Transformer.php b/src/Intl/Icu/DateFormat/Hour1200Transformer.php new file mode 100644 index 000000000..5e7c18fde --- /dev/null +++ b/src/Intl/Icu/DateFormat/Hour1200Transformer.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 12 hour format (0-11). + * + * @author Igor Wiedler + * + * @internal + */ +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/src/Intl/Icu/DateFormat/Hour1201Transformer.php b/src/Intl/Icu/DateFormat/Hour1201Transformer.php new file mode 100644 index 000000000..0606bcfb2 --- /dev/null +++ b/src/Intl/Icu/DateFormat/Hour1201Transformer.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 12 hour format (1-12). + * + * @author Igor Wiedler + * + * @internal + */ +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/src/Intl/Icu/DateFormat/Hour2400Transformer.php b/src/Intl/Icu/DateFormat/Hour2400Transformer.php new file mode 100644 index 000000000..8536587f9 --- /dev/null +++ b/src/Intl/Icu/DateFormat/Hour2400Transformer.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 24 hour format (0-23). + * + * @author Igor Wiedler + * + * @internal + */ +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/src/Intl/Icu/DateFormat/Hour2401Transformer.php b/src/Intl/Icu/DateFormat/Hour2401Transformer.php new file mode 100644 index 000000000..929f11f28 --- /dev/null +++ b/src/Intl/Icu/DateFormat/Hour2401Transformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 24 hour format (1-24). + * + * @author Igor Wiedler + * + * @internal + */ +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/src/Intl/Icu/DateFormat/HourTransformer.php b/src/Intl/Icu/DateFormat/HourTransformer.php new file mode 100644 index 000000000..b042ccf79 --- /dev/null +++ b/src/Intl/Icu/DateFormat/HourTransformer.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Base class for hour transformers. + * + * @author Eriksen Costa + * + * @internal + */ +abstract class HourTransformer extends Transformer +{ + /** + * Returns a normalized hour value suitable for the hour transformer type. + * + * @param int $hour The hour value + * @param string $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/src/Intl/Icu/DateFormat/MinuteTransformer.php b/src/Intl/Icu/DateFormat/MinuteTransformer.php new file mode 100644 index 000000000..59267409a --- /dev/null +++ b/src/Intl/Icu/DateFormat/MinuteTransformer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for minute format. + * + * @author Igor Wiedler + * + * @internal + */ +class MinuteTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $minuteOfHour = (int) $dateTime->format('i'); + + return $this->padLeft($minuteOfHour, $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{'.$length.'}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'minute' => (int) $matched, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/MonthTransformer.php b/src/Intl/Icu/DateFormat/MonthTransformer.php new file mode 100644 index 000000000..d418857c4 --- /dev/null +++ b/src/Intl/Icu/DateFormat/MonthTransformer.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for month format. + * + * @author Igor Wiedler + * + * @internal + */ +class MonthTransformer extends Transformer +{ + protected static $months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + /** + * Short months names (first 3 letters). + */ + protected static $shortMonths = []; + + /** + * Flipped $months array, $name => $index. + */ + protected static $flippedMonths = []; + + /** + * Flipped $shortMonths array, $name => $index. + */ + protected static $flippedShortMonths = []; + + public function __construct() + { + if (0 === \count(self::$shortMonths)) { + self::$shortMonths = array_map(function ($month) { + return substr($month, 0, 3); + }, self::$months); + + self::$flippedMonths = array_flip(self::$months); + self::$flippedShortMonths = array_flip(self::$shortMonths); + } + } + + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $matchLengthMap = [ + 1 => 'n', + 2 => 'm', + 3 => 'M', + 4 => 'F', + ]; + + if (isset($matchLengthMap[$length])) { + return $dateTime->format($matchLengthMap[$length]); + } + + if (5 === $length) { + return substr($dateTime->format('M'), 0, 1); + } + + return $this->padLeft($dateTime->format('m'), $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 1: + $regExp = '\d{1,2}'; + break; + case 3: + $regExp = implode('|', self::$shortMonths); + break; + case 4: + $regExp = implode('|', self::$months); + break; + case 5: + $regExp = '[JFMASOND]'; + break; + default: + $regExp = '\d{1,'.$length.'}'; + break; + } + + return $regExp; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + if (!is_numeric($matched)) { + if (3 === $length) { + $matched = self::$flippedShortMonths[$matched] + 1; + } elseif (4 === $length) { + $matched = self::$flippedMonths[$matched] + 1; + } elseif (5 === $length) { + // IntlDateFormatter::parse() always returns false for MMMMM or LLLLL + $matched = false; + } + } else { + $matched = (int) $matched; + } + + return [ + 'month' => $matched, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/QuarterTransformer.php b/src/Intl/Icu/DateFormat/QuarterTransformer.php new file mode 100644 index 000000000..4291a72b6 --- /dev/null +++ b/src/Intl/Icu/DateFormat/QuarterTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for quarter format. + * + * @author Igor Wiedler + * + * @internal + */ +class QuarterTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $month = (int) $dateTime->format('n'); + $quarter = (int) floor(($month - 1) / 3) + 1; + switch ($length) { + case 1: + case 2: + return $this->padLeft($quarter, $length); + case 3: + return 'Q'.$quarter; + default: + $map = [1 => '1st quarter', 2 => '2nd quarter', 3 => '3rd quarter', 4 => '4th quarter']; + + return $map[$quarter]; + } + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 1: + case 2: + return '\d{'.$length.'}'; + case 3: + return 'Q\d'; + default: + return '(?:1st|2nd|3rd|4th) quarter'; + } + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/src/Intl/Icu/DateFormat/SecondTransformer.php b/src/Intl/Icu/DateFormat/SecondTransformer.php new file mode 100644 index 000000000..456abd3b2 --- /dev/null +++ b/src/Intl/Icu/DateFormat/SecondTransformer.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for the second format. + * + * @author Igor Wiedler + * + * @internal + */ +class SecondTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + $secondOfMinute = (int) $dateTime->format('s'); + + return $this->padLeft($secondOfMinute, $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{'.$length.'}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'second' => (int) $matched, + ]; + } +} diff --git a/src/Intl/Icu/DateFormat/TimezoneTransformer.php b/src/Intl/Icu/DateFormat/TimezoneTransformer.php new file mode 100644 index 000000000..241e84727 --- /dev/null +++ b/src/Intl/Icu/DateFormat/TimezoneTransformer.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; + +/** + * Parser and formatter for time zone format. + * + * @author Igor Wiedler + * + * @internal + */ +class TimezoneTransformer extends Transformer +{ + /** + * {@inheritdoc} + * + * @throws NotImplementedException When time zone is different than UTC or GMT (Etc/GMT) + */ + public function format(\DateTime $dateTime, int $length): string + { + $timeZone = substr($dateTime->getTimezone()->getName(), 0, 3); + + if (!\in_array($timeZone, ['Etc', 'UTC', 'GMT'])) { + throw new NotImplementedException('Time zone different than GMT or UTC is not supported as a formatting output.'); + } + + if ('Etc' === $timeZone) { + // i.e. Etc/GMT+1, Etc/UTC, Etc/Zulu + $timeZone = substr($dateTime->getTimezone()->getName(), 4); + } + + // From ICU >= 59.1 GMT and UTC are no longer unified + if (\in_array($timeZone, ['UTC', 'UCT', 'Universal', 'Zulu'])) { + // offset is not supported with UTC + return $length > 3 ? 'Coordinated Universal Time' : 'UTC'; + } + + $offset = (int) $dateTime->format('O'); + + // From ICU >= 4.8, the zero offset is no more used, example: GMT instead of GMT+00:00 + if (0 === $offset) { + return $length > 3 ? 'Greenwich Mean Time' : 'GMT'; + } + + if ($length > 3) { + return $dateTime->format('\G\M\TP'); + } + + return sprintf('GMT%s%d', ($offset >= 0 ? '+' : ''), $offset / 100); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 'GMT[+-]\d{2}:?\d{2}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'timezone' => self::getEtcTimeZoneId($matched), + ]; + } + + /** + * Get an Etc/GMT timezone identifier for the specified timezone. + * + * The PHP documentation for timezones states to not use the 'Other' time zones because them exists + * "for backwards compatibility". However all Etc/GMT time zones are in the tz database 'etcetera' file, + * which indicates they are not deprecated (neither are old names). + * + * Only GMT, Etc/Universal, Etc/Zulu, Etc/Greenwich, Etc/GMT-0, Etc/GMT+0 and Etc/GMT0 are old names and + * are linked to Etc/GMT or Etc/UTC. + * + * @param string $formattedTimeZone A GMT timezone string (GMT-03:00, e.g.) + * + * @return string A timezone identifier + * + * @see https://php.net/timezones.others + * + * @throws NotImplementedException When the GMT time zone have minutes offset different than zero + * @throws \InvalidArgumentException When the value can not be matched with pattern + */ + public static function getEtcTimeZoneId(string $formattedTimeZone): string + { + if (preg_match('/GMT(?P[+-])(?P\d{2}):?(?P\d{2})/', $formattedTimeZone, $matches)) { + $hours = (int) $matches['hours']; + $minutes = (int) $matches['minutes']; + $signal = '-' === $matches['signal'] ? '+' : '-'; + + if (0 < $minutes) { + throw new NotImplementedException(sprintf('It is not possible to use a GMT time zone with minutes offset different than zero (0). GMT time zone tried: "%s".', $formattedTimeZone)); + } + + return 'Etc/GMT'.(0 !== $hours ? $signal.$hours : ''); + } + + throw new \InvalidArgumentException(sprintf('The GMT time zone "%s" does not match with the supported formats GMT[+-]HH:MM or GMT[+-]HHMM.', $formattedTimeZone)); + } +} diff --git a/src/Intl/Icu/DateFormat/Transformer.php b/src/Intl/Icu/DateFormat/Transformer.php new file mode 100644 index 000000000..7f8bf25b5 --- /dev/null +++ b/src/Intl/Icu/DateFormat/Transformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for date formats. + * + * @author Igor Wiedler + * + * @internal + */ +abstract class Transformer +{ + /** + * Format a value using a configured DateTime as date/time source. + * + * @param \DateTime $dateTime A DateTime object to be used to generate the formatted value + * @param int $length The formatted value string length + * + * @return string The formatted value + */ + abstract public function format(\DateTime $dateTime, int $length): string; + + /** + * Returns a reverse matching regular expression of a string generated by format(). + * + * @param int $length The length of the value to be reverse matched + * + * @return string The reverse matching regular expression + */ + abstract public function getReverseMatchingRegExp(int $length): string; + + /** + * Extract date options from a matched value returned by the processing of the reverse matching + * regular expression. + * + * @param string $matched The matched value + * @param int $length The length of the Transformer pattern string + * + * @return array An associative array + */ + abstract public function extractDateOptions(string $matched, int $length): array; + + /** + * Pad a string with zeros to the left. + * + * @param string $value The string to be padded + * @param int $length The length to pad + * + * @return string The padded string + */ + protected function padLeft(string $value, int $length): string + { + return str_pad($value, $length, '0', \STR_PAD_LEFT); + } +} diff --git a/src/Intl/Icu/DateFormat/YearTransformer.php b/src/Intl/Icu/DateFormat/YearTransformer.php new file mode 100644 index 000000000..3bb6acd04 --- /dev/null +++ b/src/Intl/Icu/DateFormat/YearTransformer.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for year format. + * + * @author Igor Wiedler + * + * @internal + */ +class YearTransformer extends Transformer +{ + /** + * {@inheritdoc} + */ + public function format(\DateTime $dateTime, int $length): string + { + if (2 === $length) { + return $dateTime->format('y'); + } + + return $this->padLeft($dateTime->format('Y'), $length); + } + + /** + * {@inheritdoc} + */ + public function getReverseMatchingRegExp(int $length): string + { + return 2 === $length ? '\d{2}' : '\d{1,4}'; + } + + /** + * {@inheritdoc} + */ + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'year' => (int) $matched, + ]; + } +} diff --git a/src/Intl/Icu/Exception/ExceptionInterface.php b/src/Intl/Icu/Exception/ExceptionInterface.php new file mode 100644 index 000000000..a453b5e2f --- /dev/null +++ b/src/Intl/Icu/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * Base ExceptionInterface for the Intl component. + * + * @author Bernhard Schussek + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Intl/Icu/Exception/MethodArgumentNotImplementedException.php b/src/Intl/Icu/Exception/MethodArgumentNotImplementedException.php new file mode 100644 index 000000000..db120a340 --- /dev/null +++ b/src/Intl/Icu/Exception/MethodArgumentNotImplementedException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodArgumentNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The method name that raised the exception + * @param string $argName The argument name that is not implemented + */ + public function __construct(string $methodName, string $argName) + { + $message = sprintf('The %s() method\'s argument $%s behavior is not implemented.', $methodName, $argName); + parent::__construct($message); + } +} diff --git a/src/Intl/Icu/Exception/MethodArgumentValueNotImplementedException.php b/src/Intl/Icu/Exception/MethodArgumentValueNotImplementedException.php new file mode 100644 index 000000000..bd9204234 --- /dev/null +++ b/src/Intl/Icu/Exception/MethodArgumentValueNotImplementedException.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodArgumentValueNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The method name that raised the exception + * @param string $argName The argument name + * @param mixed $argValue The argument value that is not implemented + * @param string $additionalMessage An optional additional message to append to the exception message + */ + public function __construct(string $methodName, string $argName, $argValue, string $additionalMessage = '') + { + $message = sprintf( + 'The %s() method\'s argument $%s value %s behavior is not implemented.%s', + $methodName, + $argName, + var_export($argValue, true), + '' !== $additionalMessage ? ' '.$additionalMessage.'. ' : '' + ); + + parent::__construct($message); + } +} diff --git a/src/Intl/Icu/Exception/MethodNotImplementedException.php b/src/Intl/Icu/Exception/MethodNotImplementedException.php new file mode 100644 index 000000000..9e1a43985 --- /dev/null +++ b/src/Intl/Icu/Exception/MethodNotImplementedException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The name of the method + */ + public function __construct(string $methodName) + { + parent::__construct(sprintf('The %s() is not implemented.', $methodName)); + } +} diff --git a/src/Intl/Icu/Exception/NotImplementedException.php b/src/Intl/Icu/Exception/NotImplementedException.php new file mode 100644 index 000000000..24062e15a --- /dev/null +++ b/src/Intl/Icu/Exception/NotImplementedException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * Base exception class for not implemented behaviors of the intl extension in the Locale component. + * + * @author Eriksen Costa + */ +class NotImplementedException extends RuntimeException +{ + const INTL_INSTALL_MESSAGE = 'Please install the "intl" extension for full localization capabilities.'; + + /** + * @param string $message The exception message. A note to install the intl extension is appended to this string + */ + public function __construct(string $message) + { + parent::__construct($message.' '.self::INTL_INSTALL_MESSAGE); + } +} diff --git a/src/Intl/Icu/Exception/RuntimeException.php b/src/Intl/Icu/Exception/RuntimeException.php new file mode 100644 index 000000000..ceedffe8e --- /dev/null +++ b/src/Intl/Icu/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * RuntimeException for the Intl component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Intl/Icu/Icu.php b/src/Intl/Icu/Icu.php new file mode 100644 index 000000000..5ec5df116 --- /dev/null +++ b/src/Intl/Icu/Icu.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +/** + * Provides fake static versions of the global functions in the intl extension. + * + * @author Bernhard Schussek + * + * @internal + */ +abstract class Icu +{ + /** + * Indicates that no error occurred. + */ + const U_ZERO_ERROR = 0; + + /** + * Indicates that an invalid argument was passed. + */ + const U_ILLEGAL_ARGUMENT_ERROR = 1; + + /** + * Indicates that the parse() operation failed. + */ + const U_PARSE_ERROR = 9; + + /** + * All known error codes. + */ + private static $errorCodes = [ + self::U_ZERO_ERROR => 'U_ZERO_ERROR', + self::U_ILLEGAL_ARGUMENT_ERROR => 'U_ILLEGAL_ARGUMENT_ERROR', + self::U_PARSE_ERROR => 'U_PARSE_ERROR', + ]; + + /** + * The error code of the last operation. + */ + private static $errorCode = self::U_ZERO_ERROR; + + /** + * The error code of the last operation. + */ + private static $errorMessage = 'U_ZERO_ERROR'; + + /** + * Returns whether the error code indicates a failure. + * + * @param int $errorCode The error code returned by Icu::getErrorCode() + */ + public static function isFailure(int $errorCode): bool + { + return isset(self::$errorCodes[$errorCode]) + && $errorCode > self::U_ZERO_ERROR; + } + + /** + * Returns the error code of the last operation. + * + * Returns Icu::U_ZERO_ERROR if no error occurred. + * + * @return int + */ + public static function getErrorCode() + { + return self::$errorCode; + } + + /** + * Returns the error message of the last operation. + * + * Returns "U_ZERO_ERROR" if no error occurred. + */ + public static function getErrorMessage(): string + { + return self::$errorMessage; + } + + /** + * Returns the symbolic name for a given error code. + * + * @param int $code The error code returned by Icu::getErrorCode() + */ + public static function getErrorName(int $code): string + { + return self::$errorCodes[$code] ?? '[BOGUS UErrorCode]'; + } + + /** + * Sets the current error. + * + * @param int $code One of the error constants in this class + * @param string $message The ICU class error message + * + * @throws \InvalidArgumentException If the code is not one of the error constants in this class + */ + public static function setError(int $code, string $message = '') + { + if (!isset(self::$errorCodes[$code])) { + throw new \InvalidArgumentException(sprintf('No such error code: "%s".', $code)); + } + + self::$errorMessage = $message ? sprintf('%s: %s', $message, self::$errorCodes[$code]) : self::$errorCodes[$code]; + self::$errorCode = $code; + } +} diff --git a/src/Intl/Icu/IntlDateFormatter.php b/src/Intl/Icu/IntlDateFormatter.php new file mode 100644 index 000000000..5d381e7dc --- /dev/null +++ b/src/Intl/Icu/IntlDateFormatter.php @@ -0,0 +1,615 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\DateFormat\FullTransformer; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\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 + */ +abstract class IntlDateFormatter +{ + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = Icu::U_ZERO_ERROR; + + /** + * The error message from the last operation. + * + * @var string + */ + protected $errorMessage = 'U_ZERO_ERROR'; + + /* date/time format types */ + const NONE = -1; + const FULL = 0; + const LONG = 1; + const MEDIUM = 2; + const SHORT = 3; + + /* calendar formats */ + const TRADITIONAL = 0; + 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 $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 = null !== $datetype ? $datetype : self::FULL; + $this->timetype = null !== $timetype ? $timetype : self::FULL; + + $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|\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 integer Unix timestamps and DateTime objects are supported'; + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'timestamp', $timestamp, $message); + } + + // 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) { + Icu::setError(Icu::U_ILLEGAL_ARGUMENT_ERROR, $argumentError); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + + return false; + } + + if ($timestamp instanceof \DateTimeInterface) { + $timestamp = $timestamp->getTimestamp(); + } + + $transformer = new FullTransformer($this->getPattern(), $this->getTimeZoneId()); + $formatted = $transformer->format($this->createDateTime($timestamp)); + + // behave like the intl extension + Icu::setError(Icu::U_ZERO_ERROR); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::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 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 $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 = Icu::getErrorCode(); + $this->errorMessage = Icu::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) + { + if (null === $pattern) { + $pattern = $this->getDefaultPattern(); + } + + $this->pattern = $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 && 0 === strpos($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(int $timestamp) + { + $dateTime = new \DateTime(); + $dateTime->setTimestamp($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/src/Intl/Icu/LICENSE b/src/Intl/Icu/LICENSE index 4cd8bdd30..9e936ec04 100644 --- a/src/Intl/Icu/LICENSE +++ b/src/Intl/Icu/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015-2019 Fabien Potencier +Copyright (c) 2004-2020 Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Intl/Icu/Locale.php b/src/Intl/Icu/Locale.php new file mode 100644 index 000000000..1f29267b5 --- /dev/null +++ b/src/Intl/Icu/Locale.php @@ -0,0 +1,349 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\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 + */ +abstract class Locale +{ + const DEFAULT_LOCALE = null; + + /* Locale method constants */ + const ACTUAL_LOCALE = 0; + const VALID_LOCALE = 1; + + /* Language tags constants */ + const LANG_TAG = 'language'; + const EXTLANG_TAG = 'extlang'; + const SCRIPT_TAG = 'script'; + const REGION_TAG = 'region'; + const VARIANT_TAG = 'variant'; + const GRANDFATHERED_LANG_TAG = 'grandfathered'; + 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 The corresponding locale code + * + * @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 The corresponding locale code + * + * @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 The corresponding locale code + * + * @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 The locale variants + * + * @see https://php.net/locale.getallvariants + * + * @throws MethodNotImplementedException + */ + public static function getAllVariants(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the default locale. + * + * @return string The default locale code. Always returns 'en' + * + * @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 $inLocale Optional format locale code to use to display the language name + * + * @return string The localized language display name + * + * @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 $inLocale Optional format locale code to use to display the locale name + * + * @return string The localized locale display name + * + * @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 $inLocale Optional format locale code to use to display the region name + * + * @return string The localized region display name + * + * @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 $inLocale Optional format locale code to use to display the script name + * + * @return string The localized script display name + * + * @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 $inLocale Optional format locale code to use to display the variant name + * + * @return string The localized variant display name + * + * @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 Associative array with the extracted variants + * + * @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 The extracted language code or null in case of error + * + * @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 The extracted region code or null if not present + * + * @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 The extracted script code or null if not present + * + * @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 $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 Associative array with the extracted subtags + * + * @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 true on success or false on failure + * + * @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/src/Intl/Icu/NumberFormatter.php b/src/Intl/Icu/NumberFormatter.php new file mode 100644 index 000000000..f642e7cf0 --- /dev/null +++ b/src/Intl/Icu/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\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\Currencies; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\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 + */ +abstract class NumberFormatter +{ + /* Format style constants */ + const PATTERN_DECIMAL = 0; + const DECIMAL = 1; + const CURRENCY = 2; + const PERCENT = 3; + const SCIENTIFIC = 4; + const SPELLOUT = 5; + const ORDINAL = 6; + const DURATION = 7; + const PATTERN_RULEBASED = 9; + const IGNORE = 0; + const DEFAULT_STYLE = 1; + + /* Format type constants */ + const TYPE_DEFAULT = 0; + const TYPE_INT32 = 1; + const TYPE_INT64 = 2; + const TYPE_DOUBLE = 3; + const TYPE_CURRENCY = 4; + + /* Numeric attribute constants */ + const PARSE_INT_ONLY = 0; + const GROUPING_USED = 1; + const DECIMAL_ALWAYS_SHOWN = 2; + const MAX_INTEGER_DIGITS = 3; + const MIN_INTEGER_DIGITS = 4; + const INTEGER_DIGITS = 5; + const MAX_FRACTION_DIGITS = 6; + const MIN_FRACTION_DIGITS = 7; + const FRACTION_DIGITS = 8; + const MULTIPLIER = 9; + const GROUPING_SIZE = 10; + const ROUNDING_MODE = 11; + const ROUNDING_INCREMENT = 12; + const FORMAT_WIDTH = 13; + const PADDING_POSITION = 14; + const SECONDARY_GROUPING_SIZE = 15; + const SIGNIFICANT_DIGITS_USED = 16; + const MIN_SIGNIFICANT_DIGITS = 17; + const MAX_SIGNIFICANT_DIGITS = 18; + const LENIENT_PARSE = 19; + + /* Text attribute constants */ + const POSITIVE_PREFIX = 0; + const POSITIVE_SUFFIX = 1; + const NEGATIVE_PREFIX = 2; + const NEGATIVE_SUFFIX = 3; + const PADDING_CHARACTER = 4; + const CURRENCY_CODE = 5; + const DEFAULT_RULESET = 6; + const PUBLIC_RULESETS = 7; + + /* Format symbol constants */ + const DECIMAL_SEPARATOR_SYMBOL = 0; + const GROUPING_SEPARATOR_SYMBOL = 1; + const PATTERN_SEPARATOR_SYMBOL = 2; + const PERCENT_SYMBOL = 3; + const ZERO_DIGIT_SYMBOL = 4; + const DIGIT_SYMBOL = 5; + const MINUS_SIGN_SYMBOL = 6; + const PLUS_SIGN_SYMBOL = 7; + const CURRENCY_SYMBOL = 8; + const INTL_CURRENCY_SYMBOL = 9; + const MONETARY_SEPARATOR_SYMBOL = 10; + const EXPONENTIAL_SYMBOL = 11; + const PERMILL_SYMBOL = 12; + const PAD_ESCAPE_SYMBOL = 13; + const INFINITY_SYMBOL = 14; + const NAN_SYMBOL = 15; + const SIGNIFICANT_DIGIT_SYMBOL = 16; + const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17; + + /* Rounding mode values used by NumberFormatter::setAttribute() with NumberFormatter::ROUNDING_MODE attribute */ + const ROUND_CEILING = 0; + const ROUND_FLOOR = 1; + const ROUND_DOWN = 2; + const ROUND_UP = 3; + const ROUND_HALFEVEN = 4; + const ROUND_HALFDOWN = 5; + const ROUND_HALFUP = 6; + + /* Pad position values used by NumberFormatter::setAttribute() with NumberFormatter::PADDING_POSITION attribute */ + const PAD_BEFORE_PREFIX = 0; + const PAD_AFTER_PREFIX = 1; + const PAD_BEFORE_SUFFIX = 2; + const PAD_AFTER_SUFFIX = 3; + + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = Icu::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 static $supportedStyles = [ + 'CURRENCY' => self::CURRENCY, + 'DECIMAL' => self::DECIMAL, + ]; + + /** + * Supported attributes to the setAttribute() $attr argument. + */ + private static $supportedAttributes = [ + '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 static $roundingModes = [ + '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 static $phpRoundingMap = [ + 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 static $customRoundingList = [ + 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 static $enSymbols = [ + self::DECIMAL => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + self::CURRENCY => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + ]; + + private static $enTextAttributes = [ + 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 $style Style of the formatting, one of the format style constants. + * The only supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string $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::$supportedStyles)) { + $message = sprintf('The available styles are: %s.', implode(', ', array_keys(self::$supportedStyles))); + 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 $style Style of the formatting, one of the format style constants. + * The only currently supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string $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 The formatted currency value + * + * @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); + } + + if (null === $symbol = Currencies::getSymbol($currency)) { + return false; + } + $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 The formatted value or false on error + * + * @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 isset($this->attributes[$attr]) ? $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::$enSymbols) && \array_key_exists($attr, self::$enSymbols[$this->style]) ? self::$enSymbols[$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::$enTextAttributes) && \array_key_exists($attr, self::$enTextAttributes[$this->style]) ? self::$enTextAttributes[$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 $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 = 0 === strpos($value, '-') ? 1 : 0; + } + } else { + $error = true; + $position = 0; + } + + if ($error) { + Icu::setError(Icu::U_PARSE_ERROR, 'Number parsing failed'); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::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::$supportedAttributes)) { + $message = sprintf( + 'The available attributes are: %s', + implode(', ', array_keys(self::$supportedAttributes)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message); + } + + if (self::$supportedAttributes['ROUNDING_MODE'] === $attr && $this->isInvalidRoundingMode($value)) { + $message = sprintf( + 'The supported values for ROUNDING_MODE are: %s', + implode(', ', array_keys(self::$roundingModes)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attr', $value, $message); + } + + if (self::$supportedAttributes['GROUPING_USED'] === $attr) { + $value = $this->normalizeGroupingUsedValue($value); + } + + if (self::$supportedAttributes['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() + { + Icu::setError(Icu::U_ZERO_ERROR); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::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::$phpRoundingMap[$roundingModeAttribute])) { + $value = round($value, $precision, self::$phpRoundingMap[$roundingModeAttribute]); + } elseif (isset(self::$customRoundingList[$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::$roundingModes, 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/src/Intl/Icu/README.md b/src/Intl/Icu/README.md index 5b404dc14..73cf2822b 100644 --- a/src/Intl/Icu/README.md +++ b/src/Intl/Icu/README.md @@ -1,8 +1,7 @@ Symfony Polyfill / Intl: ICU ============================ -This component provides a collection of functions/classes using the -[`symfony/intl`](https://github.com/symfony/intl) package when the +This component provides a fallback implementations when the [Intl](https://php.net/intl) extension is not installed, including: - [`intl_is_failure()`](https://php.net/intl-is-failure) diff --git a/src/Intl/Icu/Resources/currencies.php b/src/Intl/Icu/Resources/currencies.php new file mode 100644 index 000000000..88f112ca2 --- /dev/null +++ b/src/Intl/Icu/Resources/currencies.php @@ -0,0 +1,1311 @@ + + array ( + 0 => 'ADP', + 1 => 0, + 2 => 0, + ), + 'AED' => + array ( + 0 => 'AED', + ), + 'AFA' => + array ( + 0 => 'AFA', + ), + 'AFN' => + array ( + 0 => 'AFN', + 1 => 0, + 2 => 0, + ), + 'ALK' => + array ( + 0 => 'ALK', + ), + 'ALL' => + array ( + 0 => 'ALL', + 1 => 0, + 2 => 0, + ), + 'AMD' => + array ( + 0 => 'AMD', + 1 => 2, + 2 => 0, + ), + 'ANG' => + array ( + 0 => 'ANG', + ), + 'AOA' => + array ( + 0 => 'AOA', + ), + 'AOK' => + array ( + 0 => 'AOK', + ), + 'AON' => + array ( + 0 => 'AON', + ), + 'AOR' => + array ( + 0 => 'AOR', + ), + 'ARA' => + array ( + 0 => 'ARA', + ), + 'ARL' => + array ( + 0 => 'ARL', + ), + 'ARM' => + array ( + 0 => 'ARM', + ), + 'ARP' => + array ( + 0 => 'ARP', + ), + 'ARS' => + array ( + 0 => 'ARS', + ), + 'ATS' => + array ( + 0 => 'ATS', + ), + 'AUD' => + array ( + 0 => 'A$', + ), + 'AWG' => + array ( + 0 => 'AWG', + ), + 'AZM' => + array ( + 0 => 'AZM', + ), + 'AZN' => + array ( + 0 => 'AZN', + ), + 'BAD' => + array ( + 0 => 'BAD', + ), + 'BAM' => + array ( + 0 => 'BAM', + ), + 'BAN' => + array ( + 0 => 'BAN', + ), + 'BBD' => + array ( + 0 => 'BBD', + ), + 'BDT' => + array ( + 0 => 'BDT', + ), + 'BEC' => + array ( + 0 => 'BEC', + ), + 'BEF' => + array ( + 0 => 'BEF', + ), + 'BEL' => + array ( + 0 => 'BEL', + ), + 'BGL' => + array ( + 0 => 'BGL', + ), + 'BGM' => + array ( + 0 => 'BGM', + ), + 'BGN' => + array ( + 0 => 'BGN', + ), + 'BGO' => + array ( + 0 => 'BGO', + ), + 'BHD' => + array ( + 0 => 'BHD', + 1 => 3, + 2 => 0, + ), + 'BIF' => + array ( + 0 => 'BIF', + 1 => 0, + 2 => 0, + ), + 'BMD' => + array ( + 0 => 'BMD', + ), + 'BND' => + array ( + 0 => 'BND', + ), + 'BOB' => + array ( + 0 => 'BOB', + ), + 'BOL' => + array ( + 0 => 'BOL', + ), + 'BOP' => + array ( + 0 => 'BOP', + ), + 'BOV' => + array ( + 0 => 'BOV', + ), + 'BRB' => + array ( + 0 => 'BRB', + ), + 'BRC' => + array ( + 0 => 'BRC', + ), + 'BRE' => + array ( + 0 => 'BRE', + ), + 'BRL' => + array ( + 0 => 'R$', + ), + 'BRN' => + array ( + 0 => 'BRN', + ), + 'BRR' => + array ( + 0 => 'BRR', + ), + 'BRZ' => + array ( + 0 => 'BRZ', + ), + 'BSD' => + array ( + 0 => 'BSD', + ), + 'BTN' => + array ( + 0 => 'BTN', + ), + 'BUK' => + array ( + 0 => 'BUK', + ), + 'BWP' => + array ( + 0 => 'BWP', + ), + 'BYB' => + array ( + 0 => 'BYB', + ), + 'BYN' => + array ( + 0 => 'BYN', + 1 => 2, + 2 => 0, + ), + 'BYR' => + array ( + 0 => 'BYR', + 1 => 0, + 2 => 0, + ), + 'BZD' => + array ( + 0 => 'BZD', + ), + 'CAD' => + array ( + 0 => 'CA$', + 1 => 2, + 2 => 0, + ), + 'CDF' => + array ( + 0 => 'CDF', + ), + 'CHE' => + array ( + 0 => 'CHE', + ), + 'CHF' => + array ( + 0 => 'CHF', + 1 => 2, + 2 => 0, + ), + 'CHW' => + array ( + 0 => 'CHW', + ), + 'CLE' => + array ( + 0 => 'CLE', + ), + 'CLF' => + array ( + 0 => 'CLF', + 1 => 4, + 2 => 0, + ), + 'CLP' => + array ( + 0 => 'CLP', + 1 => 0, + 2 => 0, + ), + 'CNH' => + array ( + 0 => 'CNH', + ), + 'CNX' => + array ( + 0 => 'CNX', + ), + 'CNY' => + array ( + 0 => 'CN¥', + ), + 'COP' => + array ( + 0 => 'COP', + 1 => 2, + 2 => 0, + ), + 'COU' => + array ( + 0 => 'COU', + ), + 'CRC' => + array ( + 0 => 'CRC', + 1 => 2, + 2 => 0, + ), + 'CSD' => + array ( + 0 => 'CSD', + ), + 'CSK' => + array ( + 0 => 'CSK', + ), + 'CUC' => + array ( + 0 => 'CUC', + ), + 'CUP' => + array ( + 0 => 'CUP', + ), + 'CVE' => + array ( + 0 => 'CVE', + ), + 'CYP' => + array ( + 0 => 'CYP', + ), + 'CZK' => + array ( + 0 => 'CZK', + 1 => 2, + 2 => 0, + ), + 'DDM' => + array ( + 0 => 'DDM', + ), + 'DEM' => + array ( + 0 => 'DEM', + ), + 'DJF' => + array ( + 0 => 'DJF', + 1 => 0, + 2 => 0, + ), + 'DKK' => + array ( + 0 => 'DKK', + 1 => 2, + 2 => 0, + ), + 'DOP' => + array ( + 0 => 'DOP', + ), + 'DZD' => + array ( + 0 => 'DZD', + ), + 'ECS' => + array ( + 0 => 'ECS', + ), + 'ECV' => + array ( + 0 => 'ECV', + ), + 'EEK' => + array ( + 0 => 'EEK', + ), + 'EGP' => + array ( + 0 => 'EGP', + ), + 'ERN' => + array ( + 0 => 'ERN', + ), + 'ESA' => + array ( + 0 => 'ESA', + ), + 'ESB' => + array ( + 0 => 'ESB', + ), + 'ESP' => + array ( + 0 => 'ESP', + 1 => 0, + 2 => 0, + ), + 'ETB' => + array ( + 0 => 'ETB', + ), + 'EUR' => + array ( + 0 => '€', + ), + 'FIM' => + array ( + 0 => 'FIM', + ), + 'FJD' => + array ( + 0 => 'FJD', + ), + 'FKP' => + array ( + 0 => 'FKP', + ), + 'FRF' => + array ( + 0 => 'FRF', + ), + 'GBP' => + array ( + 0 => '£', + ), + 'GEK' => + array ( + 0 => 'GEK', + ), + 'GEL' => + array ( + 0 => 'GEL', + ), + 'GHC' => + array ( + 0 => 'GHC', + ), + 'GHS' => + array ( + 0 => 'GHS', + ), + 'GIP' => + array ( + 0 => 'GIP', + ), + 'GMD' => + array ( + 0 => 'GMD', + ), + 'GNF' => + array ( + 0 => 'GNF', + 1 => 0, + 2 => 0, + ), + 'GNS' => + array ( + 0 => 'GNS', + ), + 'GQE' => + array ( + 0 => 'GQE', + ), + 'GRD' => + array ( + 0 => 'GRD', + ), + 'GTQ' => + array ( + 0 => 'GTQ', + ), + 'GWE' => + array ( + 0 => 'GWE', + ), + 'GWP' => + array ( + 0 => 'GWP', + ), + 'GYD' => + array ( + 0 => 'GYD', + 1 => 2, + 2 => 0, + ), + 'HKD' => + array ( + 0 => 'HK$', + ), + 'HNL' => + array ( + 0 => 'HNL', + ), + 'HRD' => + array ( + 0 => 'HRD', + ), + 'HRK' => + array ( + 0 => 'HRK', + ), + 'HTG' => + array ( + 0 => 'HTG', + ), + 'HUF' => + array ( + 0 => 'HUF', + 1 => 2, + 2 => 0, + ), + 'IDR' => + array ( + 0 => 'IDR', + 1 => 2, + 2 => 0, + ), + 'IEP' => + array ( + 0 => 'IEP', + ), + 'ILP' => + array ( + 0 => 'ILP', + ), + 'ILR' => + array ( + 0 => 'ILR', + ), + 'ILS' => + array ( + 0 => '₪', + ), + 'INR' => + array ( + 0 => '₹', + ), + 'IQD' => + array ( + 0 => 'IQD', + 1 => 0, + 2 => 0, + ), + 'IRR' => + array ( + 0 => 'IRR', + 1 => 0, + 2 => 0, + ), + 'ISJ' => + array ( + 0 => 'ISJ', + ), + 'ISK' => + array ( + 0 => 'ISK', + 1 => 0, + 2 => 0, + ), + 'ITL' => + array ( + 0 => 'ITL', + 1 => 0, + 2 => 0, + ), + 'JMD' => + array ( + 0 => 'JMD', + ), + 'JOD' => + array ( + 0 => 'JOD', + 1 => 3, + 2 => 0, + ), + 'JPY' => + array ( + 0 => '¥', + 1 => 0, + 2 => 0, + ), + 'KES' => + array ( + 0 => 'KES', + ), + 'KGS' => + array ( + 0 => 'KGS', + ), + 'KHR' => + array ( + 0 => 'KHR', + ), + 'KMF' => + array ( + 0 => 'KMF', + 1 => 0, + 2 => 0, + ), + 'KPW' => + array ( + 0 => 'KPW', + 1 => 0, + 2 => 0, + ), + 'KRH' => + array ( + 0 => 'KRH', + ), + 'KRO' => + array ( + 0 => 'KRO', + ), + 'KRW' => + array ( + 0 => '₩', + 1 => 0, + 2 => 0, + ), + 'KWD' => + array ( + 0 => 'KWD', + 1 => 3, + 2 => 0, + ), + 'KYD' => + array ( + 0 => 'KYD', + ), + 'KZT' => + array ( + 0 => 'KZT', + ), + 'LAK' => + array ( + 0 => 'LAK', + 1 => 0, + 2 => 0, + ), + 'LBP' => + array ( + 0 => 'LBP', + 1 => 0, + 2 => 0, + ), + 'LKR' => + array ( + 0 => 'LKR', + ), + 'LRD' => + array ( + 0 => 'LRD', + ), + 'LSL' => + array ( + 0 => 'LSL', + ), + 'LTL' => + array ( + 0 => 'LTL', + ), + 'LTT' => + array ( + 0 => 'LTT', + ), + 'LUC' => + array ( + 0 => 'LUC', + ), + 'LUF' => + array ( + 0 => 'LUF', + 1 => 0, + 2 => 0, + ), + 'LUL' => + array ( + 0 => 'LUL', + ), + 'LVL' => + array ( + 0 => 'LVL', + ), + 'LVR' => + array ( + 0 => 'LVR', + ), + 'LYD' => + array ( + 0 => 'LYD', + 1 => 3, + 2 => 0, + ), + 'MAD' => + array ( + 0 => 'MAD', + ), + 'MAF' => + array ( + 0 => 'MAF', + ), + 'MCF' => + array ( + 0 => 'MCF', + ), + 'MDC' => + array ( + 0 => 'MDC', + ), + 'MDL' => + array ( + 0 => 'MDL', + ), + 'MGA' => + array ( + 0 => 'MGA', + 1 => 0, + 2 => 0, + ), + 'MGF' => + array ( + 0 => 'MGF', + 1 => 0, + 2 => 0, + ), + 'MKD' => + array ( + 0 => 'MKD', + ), + 'MKN' => + array ( + 0 => 'MKN', + ), + 'MLF' => + array ( + 0 => 'MLF', + ), + 'MMK' => + array ( + 0 => 'MMK', + 1 => 0, + 2 => 0, + ), + 'MNT' => + array ( + 0 => 'MNT', + 1 => 2, + 2 => 0, + ), + 'MOP' => + array ( + 0 => 'MOP', + ), + 'MRO' => + array ( + 0 => 'MRO', + 1 => 0, + 2 => 0, + ), + 'MRU' => + array ( + 0 => 'MRU', + ), + 'MTL' => + array ( + 0 => 'MTL', + ), + 'MTP' => + array ( + 0 => 'MTP', + ), + 'MUR' => + array ( + 0 => 'MUR', + 1 => 2, + 2 => 0, + ), + 'MVP' => + array ( + 0 => 'MVP', + ), + 'MVR' => + array ( + 0 => 'MVR', + ), + 'MWK' => + array ( + 0 => 'MWK', + ), + 'MXN' => + array ( + 0 => 'MX$', + ), + 'MXP' => + array ( + 0 => 'MXP', + ), + 'MXV' => + array ( + 0 => 'MXV', + ), + 'MYR' => + array ( + 0 => 'MYR', + ), + 'MZE' => + array ( + 0 => 'MZE', + ), + 'MZM' => + array ( + 0 => 'MZM', + ), + 'MZN' => + array ( + 0 => 'MZN', + ), + 'NAD' => + array ( + 0 => 'NAD', + ), + 'NGN' => + array ( + 0 => 'NGN', + ), + 'NIC' => + array ( + 0 => 'NIC', + ), + 'NIO' => + array ( + 0 => 'NIO', + ), + 'NLG' => + array ( + 0 => 'NLG', + ), + 'NOK' => + array ( + 0 => 'NOK', + 1 => 2, + 2 => 0, + ), + 'NPR' => + array ( + 0 => 'NPR', + ), + 'NZD' => + array ( + 0 => 'NZ$', + ), + 'OMR' => + array ( + 0 => 'OMR', + 1 => 3, + 2 => 0, + ), + 'PAB' => + array ( + 0 => 'PAB', + ), + 'PEI' => + array ( + 0 => 'PEI', + ), + 'PEN' => + array ( + 0 => 'PEN', + ), + 'PES' => + array ( + 0 => 'PES', + ), + 'PGK' => + array ( + 0 => 'PGK', + ), + 'PHP' => + array ( + 0 => 'PHP', + ), + 'PKR' => + array ( + 0 => 'PKR', + 1 => 2, + 2 => 0, + ), + 'PLN' => + array ( + 0 => 'PLN', + ), + 'PLZ' => + array ( + 0 => 'PLZ', + ), + 'PTE' => + array ( + 0 => 'PTE', + ), + 'PYG' => + array ( + 0 => 'PYG', + 1 => 0, + 2 => 0, + ), + 'QAR' => + array ( + 0 => 'QAR', + ), + 'RHD' => + array ( + 0 => 'RHD', + ), + 'ROL' => + array ( + 0 => 'ROL', + ), + 'RON' => + array ( + 0 => 'RON', + ), + 'RSD' => + array ( + 0 => 'RSD', + 1 => 0, + 2 => 0, + ), + 'RUB' => + array ( + 0 => 'RUB', + ), + 'RUR' => + array ( + 0 => 'RUR', + ), + 'RWF' => + array ( + 0 => 'RWF', + 1 => 0, + 2 => 0, + ), + 'SAR' => + array ( + 0 => 'SAR', + ), + 'SBD' => + array ( + 0 => 'SBD', + ), + 'SCR' => + array ( + 0 => 'SCR', + ), + 'SDD' => + array ( + 0 => 'SDD', + ), + 'SDG' => + array ( + 0 => 'SDG', + ), + 'SDP' => + array ( + 0 => 'SDP', + ), + 'SEK' => + array ( + 0 => 'SEK', + 1 => 2, + 2 => 0, + ), + 'SGD' => + array ( + 0 => 'SGD', + ), + 'SHP' => + array ( + 0 => 'SHP', + ), + 'SIT' => + array ( + 0 => 'SIT', + ), + 'SKK' => + array ( + 0 => 'SKK', + ), + 'SLL' => + array ( + 0 => 'SLL', + 1 => 0, + 2 => 0, + ), + 'SOS' => + array ( + 0 => 'SOS', + 1 => 0, + 2 => 0, + ), + 'SRD' => + array ( + 0 => 'SRD', + ), + 'SRG' => + array ( + 0 => 'SRG', + ), + 'SSP' => + array ( + 0 => 'SSP', + ), + 'STD' => + array ( + 0 => 'STD', + 1 => 0, + 2 => 0, + ), + 'STN' => + array ( + 0 => 'STN', + ), + 'SUR' => + array ( + 0 => 'SUR', + ), + 'SVC' => + array ( + 0 => 'SVC', + ), + 'SYP' => + array ( + 0 => 'SYP', + 1 => 0, + 2 => 0, + ), + 'SZL' => + array ( + 0 => 'SZL', + ), + 'THB' => + array ( + 0 => 'THB', + ), + 'TJR' => + array ( + 0 => 'TJR', + ), + 'TJS' => + array ( + 0 => 'TJS', + ), + 'TMM' => + array ( + 0 => 'TMM', + 1 => 0, + 2 => 0, + ), + 'TMT' => + array ( + 0 => 'TMT', + ), + 'TND' => + array ( + 0 => 'TND', + 1 => 3, + 2 => 0, + ), + 'TOP' => + array ( + 0 => 'TOP', + ), + 'TPE' => + array ( + 0 => 'TPE', + ), + 'TRL' => + array ( + 0 => 'TRL', + 1 => 0, + 2 => 0, + ), + 'TRY' => + array ( + 0 => 'TRY', + ), + 'TTD' => + array ( + 0 => 'TTD', + ), + 'TWD' => + array ( + 0 => 'NT$', + 1 => 2, + 2 => 0, + ), + 'TZS' => + array ( + 0 => 'TZS', + 1 => 2, + 2 => 0, + ), + 'UAH' => + array ( + 0 => 'UAH', + ), + 'UAK' => + array ( + 0 => 'UAK', + ), + 'UGS' => + array ( + 0 => 'UGS', + ), + 'UGX' => + array ( + 0 => 'UGX', + 1 => 0, + 2 => 0, + ), + 'USD' => + array ( + 0 => '$', + ), + 'USN' => + array ( + 0 => 'USN', + ), + 'USS' => + array ( + 0 => 'USS', + ), + 'UYI' => + array ( + 0 => 'UYI', + 1 => 0, + 2 => 0, + ), + 'UYP' => + array ( + 0 => 'UYP', + ), + 'UYU' => + array ( + 0 => 'UYU', + ), + 'UYW' => + array ( + 0 => 'UYW', + 1 => 4, + 2 => 0, + ), + 'UZS' => + array ( + 0 => 'UZS', + 1 => 2, + 2 => 0, + ), + 'VEB' => + array ( + 0 => 'VEB', + ), + 'VEF' => + array ( + 0 => 'VEF', + 1 => 2, + 2 => 0, + ), + 'VES' => + array ( + 0 => 'VES', + ), + 'VND' => + array ( + 0 => '₫', + 1 => 0, + 2 => 0, + ), + 'VNN' => + array ( + 0 => 'VNN', + ), + 'VUV' => + array ( + 0 => 'VUV', + 1 => 0, + 2 => 0, + ), + 'WST' => + array ( + 0 => 'WST', + ), + 'XAF' => + array ( + 0 => 'FCFA', + 1 => 0, + 2 => 0, + ), + 'XCD' => + array ( + 0 => 'EC$', + ), + 'XEU' => + array ( + 0 => 'XEU', + ), + 'XFO' => + array ( + 0 => 'XFO', + ), + 'XFU' => + array ( + 0 => 'XFU', + ), + 'XOF' => + array ( + 0 => 'CFA', + 1 => 0, + 2 => 0, + ), + 'XPF' => + array ( + 0 => 'CFPF', + 1 => 0, + 2 => 0, + ), + 'XRE' => + array ( + 0 => 'XRE', + ), + 'YDD' => + array ( + 0 => 'YDD', + ), + 'YER' => + array ( + 0 => 'YER', + 1 => 0, + 2 => 0, + ), + 'YUD' => + array ( + 0 => 'YUD', + ), + 'YUM' => + array ( + 0 => 'YUM', + ), + 'YUN' => + array ( + 0 => 'YUN', + ), + 'YUR' => + array ( + 0 => 'YUR', + ), + 'ZAL' => + array ( + 0 => 'ZAL', + ), + 'ZAR' => + array ( + 0 => 'ZAR', + ), + 'ZMK' => + array ( + 0 => 'ZMK', + 1 => 0, + 2 => 0, + ), + 'ZMW' => + array ( + 0 => 'ZMW', + ), + 'ZRN' => + array ( + 0 => 'ZRN', + ), + 'ZRZ' => + array ( + 0 => 'ZRZ', + ), + 'ZWD' => + array ( + 0 => 'ZWD', + 1 => 0, + 2 => 0, + ), + 'ZWL' => + array ( + 0 => 'ZWL', + ), + 'ZWR' => + array ( + 0 => 'ZWR', + ), + 'DEFAULT' => + array ( + 1 => 2, + 2 => 0, + ), +); diff --git a/src/Intl/Icu/Resources/stubs/Collator.php b/src/Intl/Icu/Resources/stubs/Collator.php new file mode 100644 index 000000000..a1efbcb80 --- /dev/null +++ b/src/Intl/Icu/Resources/stubs/Collator.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\Collator as CollatorPolyfill; + +/** + * Stub implementation for the Collator class of the intl extension. + * + * @author Bernhard Schussek + */ +class Collator extends CollatorPolyfill +{ +} diff --git a/src/Intl/Icu/Resources/stubs/IntlDateFormatter.php b/src/Intl/Icu/Resources/stubs/IntlDateFormatter.php new file mode 100644 index 000000000..e7012008e --- /dev/null +++ b/src/Intl/Icu/Resources/stubs/IntlDateFormatter.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter as IntlDateFormatterPolyfill; + +/** + * Stub implementation for the IntlDateFormatter class of the intl extension. + * + * @author Bernhard Schussek + */ +class IntlDateFormatter extends IntlDateFormatterPolyfill +{ +} diff --git a/src/Intl/Icu/Resources/stubs/Locale.php b/src/Intl/Icu/Resources/stubs/Locale.php new file mode 100644 index 000000000..f1b951e13 --- /dev/null +++ b/src/Intl/Icu/Resources/stubs/Locale.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\Locale as LocalePolyfill; + +/** + * Stub implementation for the Locale class of the intl extension. + * + * @author Bernhard Schussek + */ +class Locale extends LocalePolyfill +{ +} diff --git a/src/Intl/Icu/Resources/stubs/NumberFormatter.php b/src/Intl/Icu/Resources/stubs/NumberFormatter.php new file mode 100644 index 000000000..9288b9dd6 --- /dev/null +++ b/src/Intl/Icu/Resources/stubs/NumberFormatter.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\NumberFormatter as NumberFormatterPolyfill; + +/** + * Stub implementation for the NumberFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlNumberFormatter + */ +class NumberFormatter extends NumberFormatterPolyfill +{ +} diff --git a/src/Intl/Icu/bootstrap.php b/src/Intl/Icu/bootstrap.php index c8b0f5e1b..6dac46a2c 100644 --- a/src/Intl/Icu/bootstrap.php +++ b/src/Intl/Icu/bootstrap.php @@ -9,17 +9,17 @@ * file that was distributed with this source code. */ -use Symfony\Component\Intl\Globals\IntlGlobals; +use Symfony\Polyfill\Intl\Icu as p; if (!function_exists('intl_is_failure')) { - function intl_is_failure($error_code) { return IntlGlobals::isFailure($error_code); } + function intl_is_failure($error_code) { return p\Icu::isFailure($error_code); } } if (!function_exists('intl_get_error_code')) { - function intl_get_error_code() { return IntlGlobals::getErrorCode(); } + function intl_get_error_code() { return p\Icu::getErrorCode(); } } if (!function_exists('intl_get_error_message')) { - function intl_get_error_message() { return IntlGlobals::getErrorMessage(); } + function intl_get_error_message() { return p\Icu::getErrorMessage(); } } if (!function_exists('intl_error_name')) { - function intl_error_name($error_code) { return IntlGlobals::getErrorName($error_code); } + function intl_error_name($error_code) { return p\Icu::getErrorName($error_code); } } diff --git a/src/Intl/Icu/composer.json b/src/Intl/Icu/composer.json index de0252091..417fd7184 100644 --- a/src/Intl/Icu/composer.json +++ b/src/Intl/Icu/composer.json @@ -16,14 +16,18 @@ } ], "require": { - "php": ">=7.1", - "symfony/intl": "~2.3|~3.0|~4.0|~5.0" + "php": ">=7.1" }, "autoload": { - "files": [ "bootstrap.php" ] + "files": [ "bootstrap.php" ], + "psr-4": { "Symfony\\Polyfill\\Intl\\Icu\\": "" }, + "classmap": [ "Resources/stubs" ], + "exclude-from-classmap": [ + "/Tests/" + ] }, "suggest": { - "ext-intl": "For best performance" + "ext-intl": "For best performance and support of other locales than \"en\"" }, "minimum-stability": "dev", "extra": { diff --git a/tests/Intl/Icu/AbstractCollatorTest.php b/tests/Intl/Icu/AbstractCollatorTest.php new file mode 100644 index 000000000..f3a66af32 --- /dev/null +++ b/tests/Intl/Icu/AbstractCollatorTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Polyfill\Intl\Icu\Collator; + +/** + * Test case for Collator implementations. + * + * @author Bernhard Schussek + */ +abstract class AbstractCollatorTest extends TestCase +{ + /** + * @dataProvider asortProvider + */ + public function testAsort($array, $sortFlag, $expected) + { + $collator = $this->getCollator('en'); + $collator->asort($array, $sortFlag); + $this->assertSame($expected, $array); + } + + public function asortProvider() + { + return [ + /* array, sortFlag, expected */ + [ + ['a', 'b', 'c'], + Collator::SORT_REGULAR, + ['a', 'b', 'c'], + ], + [ + ['c', 'b', 'a'], + Collator::SORT_REGULAR, + [2 => 'a', 1 => 'b', 0 => 'c'], + ], + [ + ['b', 'c', 'a'], + Collator::SORT_REGULAR, + [2 => 'a', 0 => 'b', 1 => 'c'], + ], + ]; + } + + /** + * @return Collator|\Collator + */ + abstract protected function getCollator(string $locale); +} diff --git a/tests/Intl/Icu/AbstractIcuTest.php b/tests/Intl/Icu/AbstractIcuTest.php new file mode 100644 index 000000000..9f3958888 --- /dev/null +++ b/tests/Intl/Icu/AbstractIcuTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\TestCase; + +/** + * Test case for intl function implementations. + * + * @author Bernhard Schussek + */ +abstract class AbstractIcuTest extends TestCase +{ + public function errorNameProvider() + { + return [ + [-129, '[BOGUS UErrorCode]'], + [0, 'U_ZERO_ERROR'], + [1, 'U_ILLEGAL_ARGUMENT_ERROR'], + [9, 'U_PARSE_ERROR'], + [129, '[BOGUS UErrorCode]'], + ]; + } + + /** + * @dataProvider errorNameProvider + */ + public function testGetErrorName($errorCode, $errorName) + { + $this->assertSame($errorName, $this->getIntlErrorName($errorCode)); + } + + abstract protected function getIntlErrorName($errorCode); +} diff --git a/tests/Intl/Icu/AbstractIntlDateFormatterTest.php b/tests/Intl/Icu/AbstractIntlDateFormatterTest.php new file mode 100644 index 000000000..165a32214 --- /dev/null +++ b/tests/Intl/Icu/AbstractIntlDateFormatterTest.php @@ -0,0 +1,962 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\Intl; + +/** + * Test case for IntlDateFormatter implementations. + * + * @author Bernhard Schussek + */ +abstract class AbstractIntlDateFormatterTest extends TestCase +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + } + + /** + * When a time zone is not specified, it uses the system default however it returns null in the getter method. + */ + public function testConstructorDefaultTimeZone() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); + + $this->assertEquals(date_default_timezone_get(), $formatter->getTimeZoneId()); + + $this->assertEquals( + $this->getDateTime(0, $formatter->getTimeZoneId())->format('M j, Y, g:i A'), + $formatter->format(0) + ); + } + + public function testConstructorWithoutDateType() + { + $formatter = $this->getDateFormatter('en', null, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN); + + $this->assertSame('EEEE, MMMM d, y \'at\' h:mm a', $formatter->getPattern()); + } + + public function testConstructorWithoutTimeType() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::SHORT, null, 'UTC', IntlDateFormatter::GREGORIAN); + + $this->assertSame('M/d/yy, h:mm:ss a zzzz', $formatter->getPattern()); + } + + /** + * @dataProvider formatProvider + */ + public function testFormat($pattern, $timestamp, $expected) + { + $errorCode = Icu::U_ZERO_ERROR; + $errorMessage = 'U_ZERO_ERROR'; + + $formatter = $this->getDefaultDateFormatter($pattern); + $this->assertSame($expected, $formatter->format($timestamp)); + $this->assertIsIntlSuccess($formatter, $errorMessage, $errorCode); + } + + public function formatProvider() + { + $dateTime = new \DateTime('@0'); + $dateTimeImmutable = new \DateTimeImmutable('@0'); + + $formatData = [ + /* general */ + ['y-M-d', 0, '1970-1-1'], + ["EEE, MMM d, ''yy", 0, "Thu, Jan 1, '70"], + ['h:mm a', 0, '12:00 AM'], + ['yyyyy.MMMM.dd hh:mm aaa', 0, '01970.January.01 12:00 AM'], + + /* escaping */ + ["'M'", 0, 'M'], + ["'yy'", 0, 'yy'], + ["'''yy'", 0, "'yy"], + ["''y", 0, "'1970"], + ["''yy", 0, "'70"], + ["H 'o'' clock'", 0, "0 o' clock"], + + /* month */ + ['M', 0, '1'], + ['MM', 0, '01'], + ['MMM', 0, 'Jan'], + ['MMMM', 0, 'January'], + ['MMMMM', 0, 'J'], + ['MMMMMM', 0, '000001'], + + ['L', 0, '1'], + ['LL', 0, '01'], + ['LLL', 0, 'Jan'], + ['LLLL', 0, 'January'], + ['LLLLL', 0, 'J'], + ['LLLLLL', 0, '000001'], + + /* year */ + ['y', 0, '1970'], + ['yy', 0, '70'], + ['yyy', 0, '1970'], + ['yyyy', 0, '1970'], + ['yyyyy', 0, '01970'], + ['yyyyyy', 0, '001970'], + + /* day */ + ['d', 0, '1'], + ['dd', 0, '01'], + ['ddd', 0, '001'], + + /* quarter */ + ['Q', 0, '1'], + ['QQ', 0, '01'], + ['QQQ', 0, 'Q1'], + ['QQQQ', 0, '1st quarter'], + ['QQQQQ', 0, '1st quarter'], + + ['q', 0, '1'], + ['qq', 0, '01'], + ['qqq', 0, 'Q1'], + ['qqqq', 0, '1st quarter'], + ['qqqqq', 0, '1st quarter'], + + // 4 months + ['Q', 7776000, '2'], + ['QQ', 7776000, '02'], + ['QQQ', 7776000, 'Q2'], + ['QQQQ', 7776000, '2nd quarter'], + + // 7 months + ['QQQQ', 15638400, '3rd quarter'], + + // 10 months + ['QQQQ', 23587200, '4th quarter'], + + /* 12-hour (1-12) */ + ['h', 0, '12'], + ['hh', 0, '12'], + ['hhh', 0, '012'], + + ['h', 1, '12'], + ['h', 3600, '1'], + ['h', 43200, '12'], // 12 hours + + /* day of year */ + ['D', 0, '1'], + ['D', 86400, '2'], // 1 day + ['D', 31536000, '1'], // 1 year + ['D', 31622400, '2'], // 1 year + 1 day + + /* day of week */ + ['E', 0, 'Thu'], + ['EE', 0, 'Thu'], + ['EEE', 0, 'Thu'], + ['EEEE', 0, 'Thursday'], + ['EEEEE', 0, 'T'], + ['EEEEEE', 0, 'Th'], + + ['E', 1296540000, 'Tue'], // 2011-02-01 + ['E', 1296950400, 'Sun'], // 2011-02-06 + + /* am/pm marker */ + ['a', 0, 'AM'], + ['aa', 0, 'AM'], + ['aaa', 0, 'AM'], + ['aaaa', 0, 'AM'], + + // 12 hours + ['a', 43200, 'PM'], + ['aa', 43200, 'PM'], + ['aaa', 43200, 'PM'], + ['aaaa', 43200, 'PM'], + + /* 24-hour (0-23) */ + ['H', 0, '0'], + ['HH', 0, '00'], + ['HHH', 0, '000'], + + ['H', 1, '0'], + ['H', 3600, '1'], + ['H', 43200, '12'], + ['H', 46800, '13'], + + /* 24-hour (1-24) */ + ['k', 0, '24'], + ['kk', 0, '24'], + ['kkk', 0, '024'], + + ['k', 1, '24'], + ['k', 3600, '1'], + ['k', 43200, '12'], + ['k', 46800, '13'], + + /* 12-hour (0-11) */ + ['K', 0, '0'], + ['KK', 0, '00'], + ['KKK', 0, '000'], + + ['K', 1, '0'], + ['K', 3600, '1'], + ['K', 43200, '0'], // 12 hours + + /* minute */ + ['m', 0, '0'], + ['mm', 0, '00'], + ['mmm', 0, '000'], + + ['m', 1, '0'], + ['m', 60, '1'], + ['m', 120, '2'], + ['m', 180, '3'], + ['m', 3600, '0'], + ['m', 3660, '1'], + ['m', 43200, '0'], // 12 hours + + /* second */ + ['s', 0, '0'], + ['ss', 0, '00'], + ['sss', 0, '000'], + + ['s', 1, '1'], + ['s', 2, '2'], + ['s', 5, '5'], + ['s', 30, '30'], + ['s', 59, '59'], + ['s', 60, '0'], + ['s', 120, '0'], + ['s', 180, '0'], + ['s', 3600, '0'], + ['s', 3601, '1'], + ['s', 3630, '30'], + ['s', 43200, '0'], // 12 hours + ]; + + /* general, DateTime */ + $formatData[] = ['y-M-d', $dateTime, '1970-1-1']; + $formatData[] = ["EEE, MMM d, ''yy", $dateTime, "Thu, Jan 1, '70"]; + $formatData[] = ['h:mm a', $dateTime, '12:00 AM']; + $formatData[] = ['yyyyy.MMMM.dd hh:mm aaa', $dateTime, '01970.January.01 12:00 AM']; + + /* general, DateTimeImmutable */ + $formatData[] = ['y-M-d', $dateTimeImmutable, '1970-1-1']; + $formatData[] = ["EEE, MMM d, ''yy", $dateTimeImmutable, "Thu, Jan 1, '70"]; + $formatData[] = ['h:mm a', $dateTimeImmutable, '12:00 AM']; + $formatData[] = ['yyyyy.MMMM.dd hh:mm aaa', $dateTimeImmutable, '01970.January.01 12:00 AM']; + + if (!defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '59.1', '>=')) { + // Before ICU 59.1 GMT was used instead of UTC + $formatData[] = ["yyyy.MM.dd 'at' HH:mm:ss zzz", 0, '1970.01.01 at 00:00:00 UTC']; + $formatData[] = ['K:mm a, z', 0, '0:00 AM, UTC']; + $formatData[] = ["yyyy.MM.dd 'at' HH:mm:ss zzz", $dateTime, '1970.01.01 at 00:00:00 UTC']; + $formatData[] = ['K:mm a, z', $dateTime, '0:00 AM, UTC']; + } + + return $formatData; + } + + public function testFormatUtcAndGmtAreSplit() + { + $pattern = "yyyy.MM.dd 'at' HH:mm:ss zzz"; + $gmtFormatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'GMT', IntlDateFormatter::GREGORIAN, $pattern); + $utcFormatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN, $pattern); + + $this->assertSame('1970.01.01 at 00:00:00 GMT', $gmtFormatter->format(new \DateTime('@0'))); + $this->assertSame('1970.01.01 at 00:00:00 UTC', $utcFormatter->format(new \DateTime('@0'))); + $this->assertSame('1970.01.01 at 00:00:00 GMT', $gmtFormatter->format(new \DateTimeImmutable('@0'))); + $this->assertSame('1970.01.01 at 00:00:00 UTC', $utcFormatter->format(new \DateTimeImmutable('@0'))); + } + + /** + * @dataProvider formatErrorProvider + */ + public function testFormatIllegalArgumentError($pattern, $timestamp, $errorMessage) + { + $errorCode = Icu::U_ILLEGAL_ARGUMENT_ERROR; + + $formatter = $this->getDefaultDateFormatter($pattern); + $this->assertFalse($formatter->format($timestamp)); + $this->assertIsIntlFailure($formatter, $errorMessage, $errorCode); + } + + public function formatErrorProvider() + { + return [ + ['y-M-d', 'foobar', 'datefmt_format: string \'foobar\' is not numeric, which would be required for it to be a valid date: U_ILLEGAL_ARGUMENT_ERROR'], + ]; + } + + /** + * @dataProvider formatWithTimezoneProvider + */ + public function testFormatWithTimezone($timestamp, $timezone, $expected) + { + $pattern = 'yyyy-MM-dd HH:mm:ss'; + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, $timezone, IntlDateFormatter::GREGORIAN, $pattern); + $this->assertSame($expected, $formatter->format($timestamp)); + } + + public function formatWithTimezoneProvider() + { + $data = [ + [0, 'UTC', '1970-01-01 00:00:00'], + [0, 'GMT', '1970-01-01 00:00:00'], + [0, 'GMT-03:00', '1969-12-31 21:00:00'], + [0, 'GMT+03:00', '1970-01-01 03:00:00'], + [0, 'Europe/Zurich', '1970-01-01 01:00:00'], + [0, 'Europe/Paris', '1970-01-01 01:00:00'], + [0, 'Africa/Cairo', '1970-01-01 02:00:00'], + [0, 'Africa/Casablanca', '1970-01-01 00:00:00'], + [0, 'Africa/Djibouti', '1970-01-01 03:00:00'], + [0, 'Africa/Johannesburg', '1970-01-01 02:00:00'], + [0, 'America/Antigua', '1969-12-31 20:00:00'], + [0, 'America/Toronto', '1969-12-31 19:00:00'], + [0, 'America/Vancouver', '1969-12-31 16:00:00'], + [0, 'Asia/Aqtau', '1970-01-01 05:00:00'], + [0, 'Asia/Bangkok', '1970-01-01 07:00:00'], + [0, 'Asia/Dubai', '1970-01-01 04:00:00'], + [0, 'Australia/Brisbane', '1970-01-01 10:00:00'], + [0, 'Australia/Eucla', '1970-01-01 08:45:00'], + [0, 'Australia/Melbourne', '1970-01-01 10:00:00'], + [0, 'Europe/Berlin', '1970-01-01 01:00:00'], + [0, 'Europe/Dublin', '1970-01-01 01:00:00'], + [0, 'Europe/Warsaw', '1970-01-01 01:00:00'], + [0, 'Pacific/Fiji', '1970-01-01 12:00:00'], + ]; + + return $data; + } + + /** + * @dataProvider formatTimezoneProvider + */ + public function testFormatTimezone($pattern, $timezone, $expected) + { + $formatter = $this->getDefaultDateFormatter($pattern); + $formatter->setTimeZone(new \DateTimeZone($timezone)); + + $this->assertEquals($expected, $formatter->format(0)); + } + + public function formatTimezoneProvider() + { + return [ + ['z', 'GMT', 'GMT'], + ['zz', 'GMT', 'GMT'], + ['zzz', 'GMT', 'GMT'], + ['zzzz', 'GMT', 'Greenwich Mean Time'], + ['zzzzz', 'GMT', 'Greenwich Mean Time'], + + ['z', 'Etc/GMT', 'GMT'], + ['zz', 'Etc/GMT', 'GMT'], + ['zzz', 'Etc/GMT', 'GMT'], + ['zzzz', 'Etc/GMT', 'Greenwich Mean Time'], + ['zzzzz', 'Etc/GMT', 'Greenwich Mean Time'], + + ['z', 'Etc/GMT+3', 'GMT-3'], + ['zz', 'Etc/GMT+3', 'GMT-3'], + ['zzz', 'Etc/GMT+3', 'GMT-3'], + ['zzzz', 'Etc/GMT+3', 'GMT-03:00'], + ['zzzzz', 'Etc/GMT+3', 'GMT-03:00'], + + ['z', 'UTC', 'UTC'], + ['zz', 'UTC', 'UTC'], + ['zzz', 'UTC', 'UTC'], + ['zzzz', 'UTC', 'Coordinated Universal Time'], + ['zzzzz', 'UTC', 'Coordinated Universal Time'], + + ['z', 'Etc/UTC', 'UTC'], + ['zz', 'Etc/UTC', 'UTC'], + ['zzz', 'Etc/UTC', 'UTC'], + ['zzzz', 'Etc/UTC', 'Coordinated Universal Time'], + ['zzzzz', 'Etc/UTC', 'Coordinated Universal Time'], + + ['z', 'Etc/Universal', 'UTC'], + ['z', 'Etc/Zulu', 'UTC'], + ['z', 'Etc/UCT', 'UTC'], + ['z', 'Etc/Greenwich', 'GMT'], + ['zzzzz', 'Etc/Universal', 'Coordinated Universal Time'], + ['zzzzz', 'Etc/Zulu', 'Coordinated Universal Time'], + ['zzzzz', 'Etc/UCT', 'Coordinated Universal Time'], + ['zzzzz', 'Etc/Greenwich', 'Greenwich Mean Time'], + + ['z', 'GMT+03:00', 'GMT+3'], + ['zz', 'GMT+03:00', 'GMT+3'], + ['zzz', 'GMT+03:00', 'GMT+3'], + ['zzzz', 'GMT+03:00', 'GMT+03:00'], + ['zzzzz', 'GMT+03:00', 'GMT+03:00'], + ]; + } + + public function testFormatWithGmtTimezone() + { + $formatter = $this->getDefaultDateFormatter('zzzz'); + + $formatter->setTimeZone('GMT+03:00'); + + $this->assertEquals('GMT+03:00', $formatter->format(0)); + } + + public function testFormatWithGmtTimeZoneAndMinutesOffset() + { + $formatter = $this->getDefaultDateFormatter('zzzz'); + + $formatter->setTimeZone('GMT+00:30'); + + $this->assertEquals('GMT+00:30', $formatter->format(0)); + } + + public function testFormatWithNonStandardTimezone() + { + $formatter = $this->getDefaultDateFormatter('zzzz'); + + $formatter->setTimeZone('Pacific/Fiji'); + + $this->assertEquals('Fiji Standard Time', $formatter->format(0)); + } + + public function testFormatWithConstructorTimezone() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC'); + $formatter->setPattern('yyyy-MM-dd HH:mm:ss'); + + $this->assertEquals( + $this->getDateTime(0, 'UTC')->format('Y-m-d H:i:s'), + $formatter->format(0) + ); + } + + public function testFormatWithDateTimeZoneGmt() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, new \DateTimeZone('GMT'), IntlDateFormatter::GREGORIAN, 'zzz'); + + $this->assertEquals('GMT', $formatter->format(0)); + } + + public function testFormatWithDateTimeZoneGmtOffset() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, new \DateTimeZone('GMT+03:00'), IntlDateFormatter::GREGORIAN, 'zzzz'); + + $this->assertEquals('GMT+03:00', $formatter->format(0)); + } + + /** + * @requires extension intl + */ + public function testFormatWithIntlTimeZone() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, \IntlTimeZone::createTimeZone('GMT+03:00'), IntlDateFormatter::GREGORIAN, 'zzzz'); + + $this->assertEquals('GMT+03:00', $formatter->format(0)); + } + + public function testFormatWithTimezoneFromPhp() + { + $tz = date_default_timezone_get(); + date_default_timezone_set('Europe/London'); + + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); + $formatter->setPattern('yyyy-MM-dd HH:mm:ss'); + + $this->assertEquals( + $this->getDateTime(0, 'Europe/London')->format('Y-m-d H:i:s'), + $formatter->format(0) + ); + + $this->assertEquals('Europe/London', date_default_timezone_get()); + + // Restores TZ. + date_default_timezone_set($tz); + } + + /** + * @dataProvider dateAndTimeTypeProvider + */ + public function testDateAndTimeType($timestamp, $datetype, $timetype, $expected) + { + $formatter = $this->getDateFormatter('en', $datetype, $timetype, 'UTC'); + $this->assertSame($expected, $formatter->format($timestamp)); + } + + public function dateAndTimeTypeProvider() + { + return [ + [0, IntlDateFormatter::FULL, IntlDateFormatter::NONE, 'Thursday, January 1, 1970'], + [0, IntlDateFormatter::LONG, IntlDateFormatter::NONE, 'January 1, 1970'], + [0, IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE, 'Jan 1, 1970'], + [0, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, '1/1/70'], + [0, IntlDateFormatter::NONE, IntlDateFormatter::FULL, '12:00:00 AM Coordinated Universal Time'], + [0, IntlDateFormatter::NONE, IntlDateFormatter::LONG, '12:00:00 AM UTC'], + [0, IntlDateFormatter::NONE, IntlDateFormatter::MEDIUM, '12:00:00 AM'], + [0, IntlDateFormatter::NONE, IntlDateFormatter::SHORT, '12:00 AM'], + ]; + } + + public function testGetCalendar() + { + $formatter = $this->getDefaultDateFormatter(); + $this->assertEquals(IntlDateFormatter::GREGORIAN, $formatter->getCalendar()); + } + + public function testGetDateType() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::FULL, IntlDateFormatter::NONE); + $this->assertEquals(IntlDateFormatter::FULL, $formatter->getDateType()); + } + + public function testGetLocale() + { + $formatter = $this->getDefaultDateFormatter(); + $this->assertEquals('en', $formatter->getLocale()); + } + + public function testGetPattern() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::FULL, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, 'yyyy-MM-dd'); + $this->assertEquals('yyyy-MM-dd', $formatter->getPattern()); + } + + public function testGetTimeType() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::NONE, IntlDateFormatter::FULL); + $this->assertEquals(IntlDateFormatter::FULL, $formatter->getTimeType()); + } + + /** + * @dataProvider parseProvider + */ + public function testParse($pattern, $value, $expected) + { + $errorCode = Icu::U_ZERO_ERROR; + $errorMessage = 'U_ZERO_ERROR'; + + $formatter = $this->getDefaultDateFormatter($pattern); + $this->assertSame($expected, $formatter->parse($value)); + $this->assertIsIntlSuccess($formatter, $errorMessage, $errorCode); + } + + public function parseProvider() + { + return array_merge( + $this->parseYearProvider(), + $this->parseQuarterProvider(), + $this->parseMonthProvider(), + $this->parseStandaloneMonthProvider(), + $this->parseDayProvider(), + $this->parseDayOfWeekProvider(), + $this->parseDayOfYearProvider(), + $this->parseHour12ClockOneBasedProvider(), + $this->parseHour12ClockZeroBasedProvider(), + $this->parseHour24ClockOneBasedProvider(), + $this->parseHour24ClockZeroBasedProvider(), + $this->parseMinuteProvider(), + $this->parseSecondProvider(), + $this->parseTimezoneProvider(), + $this->parseAmPmProvider(), + $this->parseStandaloneAmPmProvider(), + $this->parseRegexMetaCharsProvider(), + $this->parseQuoteCharsProvider(), + $this->parseDashSlashProvider() + ); + } + + public function parseYearProvider() + { + return [ + ['y-M-d', '1970-1-1', 0], + ['yy-M-d', '70-1-1', 0], + ]; + } + + public function parseQuarterProvider() + { + return [ + ['Q', '1', 0], + ['QQ', '01', 0], + ['QQQ', 'Q1', 0], + ['QQQQ', '1st quarter', 0], + ['QQQQQ', '1st quarter', 0], + + ['Q', '2', 7776000], + ['QQ', '02', 7776000], + ['QQQ', 'Q2', 7776000], + ['QQQQ', '2nd quarter', 7776000], + ['QQQQQ', '2nd quarter', 7776000], + + ['q', '1', 0], + ['qq', '01', 0], + ['qqq', 'Q1', 0], + ['qqqq', '1st quarter', 0], + ['qqqqq', '1st quarter', 0], + ]; + } + + public function parseMonthProvider() + { + return [ + ['y-M-d', '1970-1-1', 0], + ['y-MM-d', '1970-1-1', 0], + ['y-MMM-d', '1970-Jan-1', 0], + ['y-MMMM-d', '1970-January-1', 0], + ]; + } + + public function parseStandaloneMonthProvider() + { + return [ + ['y-L-d', '1970-1-1', 0], + ['y-LLL-d', '1970-Jan-1', 0], + ['y-LLLL-d', '1970-January-1', 0], + ]; + } + + public function parseDayProvider() + { + return [ + ['y-M-d', '1970-1-1', 0], + ['y-M-dd', '1970-1-1', 0], + ['y-M-dd', '1970-1-01', 0], + ['y-M-ddd', '1970-1-001', 0], + ]; + } + + public function parseDayOfWeekProvider() + { + return [ + ['E', 'Thu', 0], + ['EE', 'Thu', 0], + ['EEE', 'Thu', 0], + ['EEEE', 'Thursday', 0], + ['EEEEE', 'T', 432000], + ['EEEEEE', 'Th', 0], + ]; + } + + public function parseDayOfYearProvider() + { + return [ + ['D', '1', 0], + ['D', '2', 86400], + ]; + } + + public function parseHour12ClockOneBasedProvider() + { + return [ + // 12 hours (1-12) + ['y-M-d h', '1970-1-1 1', 3600], + ['y-M-d h', '1970-1-1 10', 36000], + ['y-M-d hh', '1970-1-1 11', 39600], + ['y-M-d hh', '1970-1-1 12', 0], + ['y-M-d hh a', '1970-1-1 0 AM', 0], + ['y-M-d hh a', '1970-1-1 1 AM', 3600], + ['y-M-d hh a', '1970-1-1 10 AM', 36000], + ['y-M-d hh a', '1970-1-1 11 AM', 39600], + ['y-M-d hh a', '1970-1-1 12 AM', 0], + ['y-M-d hh a', '1970-1-1 23 AM', 82800], + ['y-M-d hh a', '1970-1-1 24 AM', 86400], + ['y-M-d hh a', '1970-1-1 0 PM', 43200], + ['y-M-d hh a', '1970-1-1 1 PM', 46800], + ['y-M-d hh a', '1970-1-1 10 PM', 79200], + ['y-M-d hh a', '1970-1-1 11 PM', 82800], + ['y-M-d hh a', '1970-1-1 12 PM', 43200], + ['y-M-d hh a', '1970-1-1 23 PM', 126000], + ['y-M-d hh a', '1970-1-1 24 PM', 129600], + ]; + } + + public function parseHour12ClockZeroBasedProvider() + { + return [ + // 12 hours (0-11) + ['y-M-d K', '1970-1-1 1', 3600], + ['y-M-d K', '1970-1-1 10', 36000], + ['y-M-d KK', '1970-1-1 11', 39600], + ['y-M-d KK', '1970-1-1 12', 43200], + ['y-M-d KK a', '1970-1-1 0 AM', 0], + ['y-M-d KK a', '1970-1-1 1 AM', 3600], + ['y-M-d KK a', '1970-1-1 10 AM', 36000], + ['y-M-d KK a', '1970-1-1 11 AM', 39600], + ['y-M-d KK a', '1970-1-1 12 AM', 43200], + ['y-M-d KK a', '1970-1-1 23 AM', 82800], + ['y-M-d KK a', '1970-1-1 24 AM', 86400], + ['y-M-d KK a', '1970-1-1 0 PM', 43200], + ['y-M-d KK a', '1970-1-1 1 PM', 46800], + ['y-M-d KK a', '1970-1-1 10 PM', 79200], + ['y-M-d KK a', '1970-1-1 11 PM', 82800], + ['y-M-d KK a', '1970-1-1 12 PM', 86400], + ['y-M-d KK a', '1970-1-1 23 PM', 126000], + ['y-M-d KK a', '1970-1-1 24 PM', 129600], + ]; + } + + public function parseHour24ClockOneBasedProvider() + { + return [ + // 24 hours (1-24) + ['y-M-d k', '1970-1-1 1', 3600], + ['y-M-d k', '1970-1-1 10', 36000], + ['y-M-d kk', '1970-1-1 11', 39600], + ['y-M-d kk', '1970-1-1 12', 43200], + ['y-M-d kk', '1970-1-1 23', 82800], + ['y-M-d kk', '1970-1-1 24', 0], + ['y-M-d kk a', '1970-1-1 0 AM', 0], + ['y-M-d kk a', '1970-1-1 1 AM', 0], + ['y-M-d kk a', '1970-1-1 10 AM', 0], + ['y-M-d kk a', '1970-1-1 11 AM', 0], + ['y-M-d kk a', '1970-1-1 12 AM', 0], + ['y-M-d kk a', '1970-1-1 23 AM', 0], + ['y-M-d kk a', '1970-1-1 24 AM', 0], + ['y-M-d kk a', '1970-1-1 0 PM', 43200], + ['y-M-d kk a', '1970-1-1 1 PM', 43200], + ['y-M-d kk a', '1970-1-1 10 PM', 43200], + ['y-M-d kk a', '1970-1-1 11 PM', 43200], + ['y-M-d kk a', '1970-1-1 12 PM', 43200], + ['y-M-d kk a', '1970-1-1 23 PM', 43200], + ['y-M-d kk a', '1970-1-1 24 PM', 43200], + ]; + } + + public function parseHour24ClockZeroBasedProvider() + { + return [ + // 24 hours (0-23) + ['y-M-d H', '1970-1-1 0', 0], + ['y-M-d H', '1970-1-1 1', 3600], + ['y-M-d H', '1970-1-1 10', 36000], + ['y-M-d HH', '1970-1-1 11', 39600], + ['y-M-d HH', '1970-1-1 12', 43200], + ['y-M-d HH', '1970-1-1 23', 82800], + ['y-M-d HH a', '1970-1-1 0 AM', 0], + ['y-M-d HH a', '1970-1-1 1 AM', 0], + ['y-M-d HH a', '1970-1-1 10 AM', 0], + ['y-M-d HH a', '1970-1-1 11 AM', 0], + ['y-M-d HH a', '1970-1-1 12 AM', 0], + ['y-M-d HH a', '1970-1-1 23 AM', 0], + ['y-M-d HH a', '1970-1-1 24 AM', 0], + ['y-M-d HH a', '1970-1-1 0 PM', 43200], + ['y-M-d HH a', '1970-1-1 1 PM', 43200], + ['y-M-d HH a', '1970-1-1 10 PM', 43200], + ['y-M-d HH a', '1970-1-1 11 PM', 43200], + ['y-M-d HH a', '1970-1-1 12 PM', 43200], + ['y-M-d HH a', '1970-1-1 23 PM', 43200], + ['y-M-d HH a', '1970-1-1 24 PM', 43200], + ]; + } + + public function parseMinuteProvider() + { + return [ + ['y-M-d HH:m', '1970-1-1 0:1', 60], + ['y-M-d HH:mm', '1970-1-1 0:10', 600], + ]; + } + + public function parseSecondProvider() + { + return [ + ['y-M-d HH:mm:s', '1970-1-1 00:01:1', 61], + ['y-M-d HH:mm:ss', '1970-1-1 00:01:10', 70], + ]; + } + + public function parseTimezoneProvider() + { + return [ + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT-03:00', 10800], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT-04:00', 14400], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT-00:00', 0], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT+03:00', -10800], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT+04:00', -14400], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT-0300', 10800], + ['y-M-d HH:mm:ss zzzz', '1970-1-1 00:00:00 GMT+0300', -10800], + + // a previous timezone parsing should not change the timezone for the next parsing + ['y-M-d HH:mm:ss', '1970-1-1 00:00:00', 0], + ]; + } + + public function parseAmPmProvider() + { + return [ + // AM/PM (already covered by hours tests) + ['y-M-d HH:mm:ss a', '1970-1-1 00:00:00 AM', 0], + ['y-M-d HH:mm:ss a', '1970-1-1 00:00:00 PM', 43200], + ]; + } + + public function parseStandaloneAmPmProvider() + { + return [ + ['a', 'AM', 0], + ['a', 'PM', 43200], + ]; + } + + public function parseRegexMetaCharsProvider() + { + return [ + // regexp meta chars in the pattern string + ['y[M-d', '1970[1-1', 0], + ['y[M/d', '1970[1/1', 0], + ]; + } + + public function parseQuoteCharsProvider() + { + return [ + ["'M'", 'M', 0], + ["'yy'", 'yy', 0], + ["'''yy'", "'yy", 0], + ["''y", "'1970", 0], + ["H 'o'' clock'", "0 o' clock", 0], + ]; + } + + public function parseDashSlashProvider() + { + return [ + ['y-M-d', '1970/1/1', 0], + ['yy-M-d', '70/1/1', 0], + ['y/M/d', '1970-1-1', 0], + ['yy/M/d', '70-1-1', 0], + ]; + } + + /** + * @dataProvider parseErrorProvider + */ + public function testParseError($pattern, $value) + { + $errorCode = Icu::U_PARSE_ERROR; + $errorMessage = 'Date parsing failed: U_PARSE_ERROR'; + + $formatter = $this->getDefaultDateFormatter($pattern); + $this->assertFalse($formatter->parse($value)); + $this->assertIsIntlFailure($formatter, $errorMessage, $errorCode); + } + + public function parseErrorProvider() + { + return [ + // 1 char month + ['y-MMMMM-d', '1970-J-1'], + ['y-MMMMM-d', '1970-S-1'], + + // standalone 1 char month + ['y-LLLLL-d', '1970-J-1'], + ['y-LLLLL-d', '1970-S-1'], + ]; + } + + /* + * https://github.com/symfony/symfony/issues/4242 + */ + public function testParseAfterError() + { + $this->testParseError('y-MMMMM-d', '1970-J-1'); + $this->testParse('y-M-d', '1970-1-1', 0); + } + + public function testParseWithNullPositionValue() + { + $position = null; + $formatter = $this->getDefaultDateFormatter('y'); + $this->assertSame(0, $formatter->parse('1970', $position)); + // Since $position is not supported by the Symfony implementation, the following won't work. + // The intl implementation works this way since 60.2. + // $this->assertSame(4, $position); + } + + public function testSetPattern() + { + $formatter = $this->getDefaultDateFormatter(); + $formatter->setPattern('yyyy-MM-dd'); + $this->assertEquals('yyyy-MM-dd', $formatter->getPattern()); + } + + /** + * @dataProvider setTimeZoneIdProvider + */ + public function testSetTimeZoneId($timeZoneId, $expectedTimeZoneId) + { + $formatter = $this->getDefaultDateFormatter(); + + $formatter->setTimeZone($timeZoneId); + + $this->assertEquals($expectedTimeZoneId, $formatter->getTimeZoneId()); + } + + public function setTimeZoneIdProvider() + { + return [ + ['UTC', 'UTC'], + ['GMT', 'GMT'], + ['GMT-03:00', 'GMT-03:00'], + ['Europe/Zurich', 'Europe/Zurich'], + [null, date_default_timezone_get()], + ['Foo/Bar', 'UTC'], + ['GMT+00:AA', 'UTC'], + ['GMT+00AA', 'UTC'], + ]; + } + + protected function getDefaultDateFormatter($pattern = null) + { + return $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN, $pattern); + } + + protected function getDateTime($timestamp, $timeZone) + { + $dateTime = new \DateTime(); + $dateTime->setTimestamp(null === $timestamp ? time() : $timestamp); + $dateTime->setTimezone(new \DateTimeZone($timeZone ?: getenv('TZ') ?: 'UTC')); + + return $dateTime; + } + + protected function assertIsIntlFailure($formatter, $errorMessage, $errorCode) + { + $this->assertSame($errorMessage, $this->getIntlErrorMessage()); + $this->assertSame($errorCode, $this->getIntlErrorCode()); + $this->assertTrue($this->isIntlFailure($this->getIntlErrorCode())); + $this->assertSame($errorMessage, $formatter->getErrorMessage()); + $this->assertSame($errorCode, $formatter->getErrorCode()); + $this->assertTrue($this->isIntlFailure($formatter->getErrorCode())); + } + + protected function assertIsIntlSuccess($formatter, $errorMessage, $errorCode) + { + /* @var IntlDateFormatter $formatter */ + $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())); + } + + /** + * @return IntlDateFormatter|\IntlDateFormatter + */ + abstract protected function getDateFormatter($locale, $datetype, $timetype, $timezone = null, $calendar = IntlDateFormatter::GREGORIAN, $pattern = null); + + abstract protected function getIntlErrorMessage(): string; + + abstract protected function getIntlErrorCode(): int; + + /** + * @param int $errorCode + */ + abstract protected function isIntlFailure($errorCode): bool; +} diff --git a/tests/Intl/Icu/AbstractLocaleTest.php b/tests/Intl/Icu/AbstractLocaleTest.php new file mode 100644 index 000000000..ddf86b033 --- /dev/null +++ b/tests/Intl/Icu/AbstractLocaleTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\TestCase; + +/** + * Test case for Locale implementations. + * + * @author Bernhard Schussek + */ +abstract class AbstractLocaleTest extends TestCase +{ + public function testSetDefault() + { + $this->call('setDefault', 'en_GB'); + + $this->assertSame('en_GB', $this->call('getDefault')); + } + + abstract protected function call($methodName); +} diff --git a/tests/Intl/Icu/AbstractNumberFormatterTest.php b/tests/Intl/Icu/AbstractNumberFormatterTest.php new file mode 100644 index 000000000..250f788ea --- /dev/null +++ b/tests/Intl/Icu/AbstractNumberFormatterTest.php @@ -0,0 +1,900 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\Error\Warning; +use PHPUnit\Framework\TestCase; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\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. + */ +abstract class AbstractNumberFormatterTest extends TestCase +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + } + + /** + * @dataProvider formatCurrencyWithDecimalStyleProvider + */ + public function testFormatCurrencyWithDecimalStyle($value, $currency, $expected) + { + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertEquals($expected, $formatter->formatCurrency($value, $currency)); + } + + public 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) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formatter = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals($expected, $formatter->formatCurrency($value, $currency)); + } + + public 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) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formatter = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public 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 = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public 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) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '62.1', '<')) { + $this->markTestSkipped('ICU version 62.1 is required.'); + } + + $formatter = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + $this->assertEquals(sprintf($expected, $symbol), $formatter->formatCurrency($value, $currency)); + } + + public 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 = Icu::U_ZERO_ERROR; + $errorMessage = 'U_ZERO_ERROR'; + + $formatter = $this->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() + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formatter = $this->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 function formatTypeInt32Provider() + { + $formatter = $this->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 = '') + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT32); + $this->assertEquals($expected, $formattedValue, $message); + } + + public function formatTypeInt32WithCurrencyStyleProvider() + { + $formatter = $this->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 function formatTypeInt64Provider() + { + $formatter = $this->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) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_INT64); + $this->assertEquals($expected, $formattedValue); + } + + public function formatTypeInt64WithCurrencyStyleProvider() + { + $formatter = $this->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 function formatTypeDoubleProvider() + { + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + + return [ + [$formatter, 1, '1'], + [$formatter, 1.1, '1.1'], + ]; + } + + /** + * @dataProvider formatTypeDoubleWithCurrencyStyleProvider + */ + public function testFormatTypeDoubleWithCurrencyStyle($formatter, $value, $expected) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $formattedValue = $formatter->format($value, NumberFormatter::TYPE_DOUBLE); + $this->assertEquals($expected, $formattedValue); + } + + public function formatTypeDoubleWithCurrencyStyleProvider() + { + $formatter = $this->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); + } elseif (method_exists($this, 'expectWarning')) { + $this->expectWarning(); + } else { + $this->expectException(Warning::class); + } + + $formatter->format($value, NumberFormatter::TYPE_CURRENCY); + } + + /** + * @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 function formatTypeCurrencyProvider() + { + $df = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $cf = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + + return [ + [$df, 1], + [$cf, 1], + ]; + } + + /** + * @dataProvider formatFractionDigitsProvider + */ + public function testFormatFractionDigits($value, $expected, $fractionDigits = null, $expectedFractionDigits = 1) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '62.1', '<')) { + $this->markTestSkipped('ICU version 62.1 is required.'); + } + + $formatter = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->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 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 = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $this->assertEquals('en', $formatter->getLocale()); + } + + public function testGetSymbol() + { + $decimalFormatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $currencyFormatter = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + + $r = new \ReflectionProperty('Symfony\Polyfill\Intl\Icu\NumberFormatter', 'enSymbols'); + $r->setAccessible(true); + $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() + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '63.1', '<')) { + $this->markTestSkipped('ICU version 63.1 is required.'); + } + + $decimalFormatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $currencyFormatter = $this->getNumberFormatter('en', NumberFormatter::CURRENCY); + + $r = new \ReflectionProperty('Symfony\Polyfill\Intl\Icu\NumberFormatter', 'enTextAttributes'); + $r->setAccessible(true); + $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) + { + if (!\defined('INTL_ICU_VERSION') || version_compare(\INTL_ICU_VERSION, '62.1', '<')) { + $this->markTestSkipped('ICU version 62.1 is required.'); + } + + $position = 0; + $formatter = $this->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 = Icu::U_PARSE_ERROR; + $errorMessage = 'Number parsing failed: U_PARSE_ERROR'; + } else { + $errorCode = Icu::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 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', (float) 1.4, '->parse() parses a number with a string suffix.', 3], + ['1.4suffix', (float) 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', (float) -0.4, '->parse() parses a negative dot float with suffix.', 3], + ['-.4suffix', (float) -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() + { + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } elseif (method_exists($this, 'expectWarning')) { + $this->expectWarning(); + } else { + $this->expectException(Warning::class); + } + + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->parse('1', NumberFormatter::TYPE_DEFAULT); + } + + /** + * @dataProvider parseTypeInt32Provider + */ + public function testParseTypeInt32($value, $expected, $message = '') + { + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $parsedValue = $formatter->parse($value, NumberFormatter::TYPE_INT32); + $this->assertSame($expected, $parsedValue, $message); + } + + public 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() + { + if (4 !== \PHP_INT_SIZE) { + $this->markTestSkipped('PHP 32 bit is required.'); + } + + $formatter = $this->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() + { + if (8 !== \PHP_INT_SIZE) { + $this->markTestSkipped('PHP 64 bit is required.'); + } + + $formatter = $this->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() + { + if (4 !== \PHP_INT_SIZE) { + $this->markTestSkipped('PHP 32 bit is required.'); + } + + $formatter = $this->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() + { + if (8 !== \PHP_INT_SIZE) { + $this->markTestSkipped('PHP 64 bit is required.'); + } + + + $formatter = $this->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 = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $parsedValue = $formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + $this->assertEqualsWithDelta($expectedValue, $parsedValue, 0.001); + } + + public 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() + { + if (\PHP_VERSION_ID >= 80000) { + $this->expectException(\ValueError::class); + } elseif (method_exists($this, 'expectWarning')) { + $this->expectWarning(); + } else { + $this->expectException(Warning::class); + } + + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->parse('1', NumberFormatter::TYPE_CURRENCY); + } + + public function testParseWithNotNullPositionValue() + { + $position = 1; + $formatter = $this->getNumberFormatter('en', NumberFormatter::DECIMAL); + $formatter->parse('123', NumberFormatter::TYPE_DOUBLE, $position); + $this->assertEquals(3, $position); + } + + /** + * @return NumberFormatter|\NumberFormatter + */ + abstract protected 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; +} diff --git a/tests/Intl/Icu/CollatorTest.php b/tests/Intl/Icu/CollatorTest.php new file mode 100644 index 000000000..0cbaf69f6 --- /dev/null +++ b/tests/Intl/Icu/CollatorTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use Symfony\Polyfill\Intl\Icu\Collator; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; + +class CollatorTest extends AbstractCollatorTest +{ + public function testConstructorWithUnsupportedLocale() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $this->getCollator('pt_BR'); + } + + public function testCompare() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->compare('a', 'b'); + } + + public function testGetAttribute() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->getAttribute(Collator::NUMERIC_COLLATION); + } + + public function testGetErrorCode() + { + $collator = $this->getCollator('en'); + $this->assertEquals(Icu::U_ZERO_ERROR, $collator->getErrorCode()); + } + + public function testGetErrorMessage() + { + $collator = $this->getCollator('en'); + $this->assertEquals('U_ZERO_ERROR', $collator->getErrorMessage()); + } + + public function testGetLocale() + { + $collator = $this->getCollator('en'); + $this->assertEquals('en', $collator->getLocale()); + } + + public function testConstructWithoutLocale() + { + $collator = $this->getCollator(null); + $this->assertInstanceOf(Collator::class, $collator); + } + + public function testGetSortKey() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->getSortKey('Hello'); + } + + public function testGetStrength() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->getStrength(); + } + + public function testSetAttribute() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON); + } + + public function testSetStrength() + { + $this->expectException(MethodNotImplementedException::class); + $collator = $this->getCollator('en'); + $collator->setStrength(Collator::PRIMARY); + } + + public function testStaticCreate() + { + $collator = $this->getCollator('en'); + $collator = $collator::create('en'); + $this->assertInstanceOf(Collator::class, $collator); + } + + protected function getCollator(?string $locale): Collator + { + return new class($locale) extends Collator { + }; + } +} diff --git a/tests/Intl/Icu/CurrenciesTest.php b/tests/Intl/Icu/CurrenciesTest.php new file mode 100644 index 000000000..c1938d089 --- /dev/null +++ b/tests/Intl/Icu/CurrenciesTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Polyfill\Intl\Icu\Currencies; + +class CurrenciesTest extends TestCase +{ + public function testMetadata() + { + $en = json_decode(file_get_contents(\dirname(__DIR__, 3).'/vendor/symfony/intl/Resources/data/currencies/en.json'), true); + $meta = json_decode(file_get_contents(\dirname(__DIR__, 3).'/vendor/symfony/intl/Resources/data/currencies/meta.json'), true); + $data = []; + + foreach ($en['Names'] as $code => [$symbol, $name]) { + $data[$code] = [$symbol]; + } + + foreach ($meta['Meta'] as $code => [$fractionDigit, $roundingIncrement]) { + $data[$code] = ($data[$code] ?? []) + [1 => $fractionDigit, $roundingIncrement]; + } + + $data = "assertStringEqualsFile(\dirname(__DIR__, 3).'/src/Intl/Icu/Resources/currencies.php', $data); + } +} diff --git a/tests/Intl/Icu/IcuTest.php b/tests/Intl/Icu/IcuTest.php new file mode 100644 index 000000000..18209ab86 --- /dev/null +++ b/tests/Intl/Icu/IcuTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use Symfony\Polyfill\Intl\Icu\Icu; + +class IcuTest extends AbstractIcuTest +{ + protected function getIntlErrorName($errorCode) + { + return Icu::getErrorName($errorCode); + } +} diff --git a/tests/Intl/Icu/IntlDateFormatterTest.php b/tests/Intl/Icu/IntlDateFormatterTest.php new file mode 100644 index 000000000..c0f2c41a2 --- /dev/null +++ b/tests/Intl/Icu/IntlDateFormatterTest.php @@ -0,0 +1,222 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter; +use Symfony\Polyfill\Intl\Icu\Icu; + +class IntlDateFormatterTest extends AbstractIntlDateFormatterTest +{ + public function testConstructor() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN, 'y-M-d'); + $this->assertEquals('y-M-d', $formatter->getPattern()); + } + + public function testConstructorWithoutLocale() + { + $formatter = $this->getDateFormatter(null, IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN, 'y-M-d'); + $this->assertEquals('y-M-d', $formatter->getPattern()); + } + + public function testConstructorWithoutCalendar() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', null, 'y-M-d'); + $this->assertEquals('y-M-d', $formatter->getPattern()); + } + + public function testConstructorWithUnsupportedLocale() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $this->getDateFormatter('pt_BR', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); + } + + public function testStaticCreate() + { + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); + $formatter = $formatter::create('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT); + $this->assertInstanceOf(IntlDateFormatter::class, $formatter); + } + + public function testFormatWithUnsupportedTimestampArgument() + { + $formatter = $this->getDefaultDateFormatter(); + + $localtime = [ + 'tm_sec' => 59, + 'tm_min' => 3, + 'tm_hour' => 15, + 'tm_mday' => 15, + 'tm_mon' => 3, + 'tm_year' => 112, + 'tm_wday' => 0, + 'tm_yday' => 105, + 'tm_isdst' => 0, + ]; + + try { + $formatter->format($localtime); + } catch (\Exception $e) { + $this->assertInstanceOf(MethodArgumentValueNotImplementedException::class, $e); + $this->assertStringEndsWith('Only integer Unix timestamps and DateTime objects are supported. Please install the "intl" extension for full localization capabilities.', $e->getMessage()); + } + } + + public function testFormatWithUnimplementedChars() + { + $this->expectException(NotImplementedException::class); + $pattern = 'Y'; + $formatter = $this->getDateFormatter('en', IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT, 'UTC', IntlDateFormatter::GREGORIAN, $pattern); + $formatter->format(0); + } + + public function testFormatWithNonIntegerTimestamp() + { + $this->expectException(NotImplementedException::class); + $formatter = $this->getDefaultDateFormatter(); + $formatter->format([]); + } + + public function testGetErrorCode() + { + $formatter = $this->getDefaultDateFormatter(); + $this->assertEquals(Icu::getErrorCode(), $formatter->getErrorCode()); + } + + public function testGetErrorMessage() + { + $formatter = $this->getDefaultDateFormatter(); + $this->assertEquals(Icu::getErrorMessage(), $formatter->getErrorMessage()); + } + + public function testIsLenient() + { + $formatter = $this->getDefaultDateFormatter(); + $this->assertFalse($formatter->isLenient()); + } + + public function testLocaltime() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getDefaultDateFormatter(); + $formatter->localtime('Wednesday, December 31, 1969 4:00:00 PM PT'); + } + + public function testParseWithNotNullPositionValue() + { + $this->expectException(MethodArgumentNotImplementedException::class); + $position = 0; + $formatter = $this->getDefaultDateFormatter('y'); + $this->assertSame(0, $formatter->parse('1970', $position)); + } + + public function testSetCalendar() + { + $this->expectException(MethodNotImplementedException::class); + $formatter = $this->getDefaultDateFormatter(); + $formatter->setCalendar(IntlDateFormatter::GREGORIAN); + } + + public function testSetLenient() + { + $this->expectException(MethodArgumentValueNotImplementedException::class); + $formatter = $this->getDefaultDateFormatter(); + $formatter->setLenient(true); + } + + public function testFormatWithGmtTimeZoneAndMinutesOffset() + { + $this->expectException(NotImplementedException::class); + parent::testFormatWithGmtTimeZoneAndMinutesOffset(); + } + + public function testFormatWithNonStandardTimezone() + { + $this->expectException(NotImplementedException::class); + parent::testFormatWithNonStandardTimezone(); + } + + public function parseStandaloneAmPmProvider() + { + return $this->notImplemented(parent::parseStandaloneAmPmProvider()); + } + + public function parseDayOfWeekProvider() + { + return $this->notImplemented(parent::parseDayOfWeekProvider()); + } + + public function parseDayOfYearProvider() + { + return $this->notImplemented(parent::parseDayOfYearProvider()); + } + + public function parseQuarterProvider() + { + return $this->notImplemented(parent::parseQuarterProvider()); + } + + public function testParseThreeDigitsYears() + { + if (\PHP_INT_SIZE < 8) { + $this->markTestSkipped('Parsing three digits years requires a 64bit PHP.'); + } + + $formatter = $this->getDefaultDateFormatter('yyyy-M-d'); + $this->assertSame(-32157648000, $formatter->parse('950-12-19')); + $this->assertIsIntlSuccess($formatter, 'U_ZERO_ERROR', Icu::U_ZERO_ERROR); + } + + protected function getDateFormatter($locale, $datetype, $timetype, $timezone = null, $calendar = IntlDateFormatter::GREGORIAN, $pattern = null) + { + return new class($locale, $datetype, $timetype, $timezone, $calendar, $pattern) extends IntlDateFormatter { + }; + } + + protected function getIntlErrorMessage(): string + { + return Icu::getErrorMessage(); + } + + protected function getIntlErrorCode(): int + { + return Icu::getErrorCode(); + } + + protected function isIntlFailure($errorCode): bool + { + return Icu::isFailure($errorCode); + } + + /** + * Just to document the differences between the stub and the intl + * implementations. The intl can parse any of the tested formats alone. The + * stub does not implement them as it would be needed to add more + * abstraction, passing more context to the transformers objects. Any of the + * formats are ignored alone or with date/time data (years, months, days, + * hours, minutes and seconds). + * + * Also in intl, format like 'ss E' for '10 2' (2nd day of year + * + 10 seconds) are added, then we have 86,400 seconds (24h * 60min * 60s) + * + 10 seconds + */ + private function notImplemented(array $dataSets): array + { + return array_map(function (array $row) { + return [$row[0], $row[1], 0]; + }, $dataSets); + } +} diff --git a/tests/Intl/Icu/LocaleTest.php b/tests/Intl/Icu/LocaleTest.php new file mode 100644 index 000000000..e4f7e090e --- /dev/null +++ b/tests/Intl/Icu/LocaleTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Locale; + +class LocaleTest extends AbstractLocaleTest +{ + public function testAcceptFromHttp() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('acceptFromHttp', 'pt-br,en-us;q=0.7,en;q=0.5'); + } + + public function testCanonicalize() + { + $this->assertSame('en', $this->call('canonicalize', '')); + $this->assertSame('en', $this->call('canonicalize', '.utf8')); + $this->assertSame('fr_FR', $this->call('canonicalize', 'FR-fr')); + $this->assertSame('fr_FR', $this->call('canonicalize', 'FR-fr.utf8')); + $this->assertSame('uz_Latn', $this->call('canonicalize', 'UZ-lATN')); + $this->assertSame('uz_Cyrl_UZ', $this->call('canonicalize', 'UZ-cYRL-uz')); + $this->assertSame('123', $this->call('canonicalize', 123)); + } + + public function testComposeLocale() + { + $this->expectException(MethodNotImplementedException::class); + $subtags = [ + 'language' => 'pt', + 'script' => 'Latn', + 'region' => 'BR', + ]; + $this->call('composeLocale', $subtags); + } + + public function testFilterMatches() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('filterMatches', 'pt-BR', 'pt-BR'); + } + + public function testGetAllVariants() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getAllVariants', 'pt_BR_Latn'); + } + + public function testGetDisplayLanguage() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getDisplayLanguage', 'pt-Latn-BR', 'en'); + } + + public function testGetDisplayName() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getDisplayName', 'pt-Latn-BR', 'en'); + } + + public function testGetDisplayRegion() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getDisplayRegion', 'pt-Latn-BR', 'en'); + } + + public function testGetDisplayScript() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getDisplayScript', 'pt-Latn-BR', 'en'); + } + + public function testGetDisplayVariant() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getDisplayVariant', 'pt-Latn-BR', 'en'); + } + + public function testGetKeywords() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getKeywords', 'pt-BR@currency=BRL'); + } + + public function testGetPrimaryLanguage() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getPrimaryLanguage', 'pt-Latn-BR'); + } + + public function testGetRegion() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getRegion', 'pt-Latn-BR'); + } + + public function testGetScript() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('getScript', 'pt-Latn-BR'); + } + + public function testLookup() + { + $this->expectException(MethodNotImplementedException::class); + $langtag = [ + 'pt-Latn-BR', + 'pt-BR', + ]; + $this->call('lookup', $langtag, 'pt-BR-x-priv1'); + } + + public function testParseLocale() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('parseLocale', 'pt-Latn-BR'); + } + + public function testSetDefault() + { + $this->expectException(MethodNotImplementedException::class); + $this->call('setDefault', 'pt_BR'); + } + + public function testSetDefaultAcceptsEn() + { + $this->call('setDefault', 'en'); + + $this->assertSame('en', $this->call('getDefault')); + } + + protected function call($methodName) + { + $args = \array_slice(\func_get_args(), 1); + + return Locale::{$methodName}(...$args); + } +} diff --git a/tests/Intl/Icu/NumberFormatterTest.php b/tests/Intl/Icu/NumberFormatterTest.php new file mode 100644 index 000000000..5ceb0570f --- /dev/null +++ b/tests/Intl/Icu/NumberFormatterTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; +use Symfony\Polyfill\Intl\Icu\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. + */ +class NumberFormatterTest extends AbstractNumberFormatterTest +{ + 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'); + 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(Icu::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 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 Icu::getErrorMessage(); + } + + protected function getIntlErrorCode(): int + { + return Icu::getErrorCode(); + } + + protected function isIntlFailure($errorCode): bool + { + return Icu::isFailure($errorCode); + } +} diff --git a/tests/Intl/Icu/Verification/CollatorTest.php b/tests/Intl/Icu/Verification/CollatorTest.php new file mode 100644 index 000000000..f48a95487 --- /dev/null +++ b/tests/Intl/Icu/Verification/CollatorTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests\Verification; + +use Symfony\Polyfill\Intl\Icu\Tests\AbstractCollatorTest; + +/** + * Verifies that {@link AbstractCollatorTest} matches the behavior of the + * {@link \Collator} class in a specific version of ICU. + * + * @author Bernhard Schussek + * + * @requires extension intl + */ +class CollatorTest extends AbstractCollatorTest +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + } + + protected function getCollator(?string $locale): \Collator + { + return new \Collator($locale); + } +} diff --git a/tests/Intl/Icu/Verification/IcuTest.php b/tests/Intl/Icu/Verification/IcuTest.php new file mode 100644 index 000000000..7e684ef62 --- /dev/null +++ b/tests/Intl/Icu/Verification/IcuTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests\Verification; + +use Symfony\Polyfill\Intl\Icu\Tests\AbstractIcuTest; + +/** + * Verifies that {@link AbstractIcuTest} matches the behavior of the + * intl functions with a specific version of ICU. + * + * @author Bernhard Schussek + * + * @requires extension intl + */ +class IcuTest extends AbstractIcuTest +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + } + + protected function getIntlErrorName($errorCode) + { + return intl_error_name($errorCode); + } +} diff --git a/tests/Intl/Icu/Verification/IntlDateFormatterTest.php b/tests/Intl/Icu/Verification/IntlDateFormatterTest.php new file mode 100644 index 000000000..732e0f102 --- /dev/null +++ b/tests/Intl/Icu/Verification/IntlDateFormatterTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests\Verification; + +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter; +use Symfony\Polyfill\Intl\Icu\Tests\AbstractIntlDateFormatterTest; + +/** + * Verifies that {@link AbstractIntlDateFormatterTest} matches the behavior of + * the {@link \IntlDateFormatter} class in a specific version of ICU. + * + * @author Bernhard Schussek + * + * @requires extension intl + */ +class IntlDateFormatterTest extends AbstractIntlDateFormatterTest +{ + /** + * @dataProvider formatProvider + */ + public function testFormat($pattern, $timestamp, $expected) + { + if (\PHP_VERSION_ID < 70105 && $timestamp instanceof \DateTimeImmutable) { + $this->markTestSkipped('PHP >= 7.1.5 required for DateTimeImmutable.'); + } + + parent::testFormat($pattern, $timestamp, $expected); + } + + /** + * @dataProvider formatTimezoneProvider + */ + public function testFormatTimezone($pattern, $timezone, $expected) + { + if (version_compare(\INTL_ICU_VERSION, '59.1', '<')) { + $this->markTestSkipped('ICU version 59.1 is required.'); + } + + parent::testFormatTimezone($pattern, $timezone, $expected); + } + + public function testFormatUtcAndGmtAreSplit() + { + if (version_compare(\INTL_ICU_VERSION, '59.1', '<')) { + $this->markTestSkipped('ICU version 59.1 is required.'); + } + + parent::testFormatUtcAndGmtAreSplit(); + } + + /** + * @dataProvider dateAndTimeTypeProvider + */ + public function testDateAndTimeType($timestamp, $datetype, $timetype, $expected) + { + if (version_compare(\INTL_ICU_VERSION, '59.1', '<')) { + $this->markTestSkipped('ICU version 59.1 is required.'); + } + + parent::testDateAndTimeType($timestamp, $datetype, $timetype, $expected); + } + + protected function getDateFormatter($locale, $datetype, $timetype, $timezone = null, $calendar = IntlDateFormatter::GREGORIAN, $pattern = null) + { + if (version_compare(\INTL_ICU_VERSION, '55.1', '<')) { + $this->markTestSkipped('ICU version 55.1 is required.'); + } + + if (!$formatter = new \IntlDateFormatter($locale, $datetype, $timetype, $timezone, $calendar, $pattern)) { + throw new \InvalidArgumentException(intl_get_error_message()); + } + + return $formatter; + } + + 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/tests/Intl/Icu/Verification/LocaleTest.php b/tests/Intl/Icu/Verification/LocaleTest.php new file mode 100644 index 000000000..fbcb390af --- /dev/null +++ b/tests/Intl/Icu/Verification/LocaleTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Tests\Verification; + +use Symfony\Polyfill\Intl\Icu\Tests\AbstractLocaleTest; + +/** + * Verifies that {@link AbstractLocaleTest} matches the behavior of the + * {@link Locale} class with a specific version of ICU. + * + * @author Bernhard Schussek + * + * @requires extension intl + */ +class LocaleTest extends AbstractLocaleTest +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + } + + protected function call($methodName) + { + $args = \array_slice(\func_get_args(), 1); + + return \Locale::{$methodName}(...$args); + } +} diff --git a/tests/Intl/Icu/Verification/NumberFormatterTest.php b/tests/Intl/Icu/Verification/NumberFormatterTest.php new file mode 100644 index 000000000..0a0dd447d --- /dev/null +++ b/tests/Intl/Icu/Verification/NumberFormatterTest.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\Polyfill\Intl\Icu\Tests\Verification; + +use Symfony\Polyfill\Intl\Icu\Tests\AbstractNumberFormatterTest; + +/** + * Note that there are some values written like -2147483647 - 1. This is the lower 32bit int max and is a known + * behavior of PHP. + * + * @requires extension intl + */ +class NumberFormatterTest extends AbstractNumberFormatterTest +{ + protected function setUp(): void + { + \Locale::setDefault('en'); + + if (version_compare(\INTL_ICU_VERSION, '55.1', '<')) { + $this->markTestSkipped('ICU version 55.1 is required.'); + } + } + + public function testCreate() + { + $this->assertInstanceOf('\NumberFormatter', \NumberFormatter::create('en', \NumberFormatter::DECIMAL)); + } + + public function testGetTextAttribute() + { + if (version_compare(\INTL_ICU_VERSION, '57.1', '<')) { + $this->markTestSkipped('ICU version 57.1 is required.'); + } + + parent::testGetTextAttribute(); + } + + protected 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); + } +}