From 5b9b97276e7e8a5c59aeb63e4e6314e78e86cd8e Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Wed, 23 Oct 2013 19:13:04 +0200 Subject: [PATCH] implemented plural and select for fallback intl message formatter --- framework/yii/i18n/BaseMessageFormatter.php | 168 ----------- framework/yii/i18n/FallbackMessageFormatter.php | 306 +++++++++++++++++++++ framework/yii/i18n/MessageFormatter.php | 10 +- .../i18n/FallbackMessageFormatterTest.php | 155 +++++++++++ 4 files changed, 468 insertions(+), 171 deletions(-) delete mode 100644 framework/yii/i18n/BaseMessageFormatter.php create mode 100644 framework/yii/i18n/FallbackMessageFormatter.php create mode 100644 tests/unit/framework/i18n/FallbackMessageFormatterTest.php diff --git a/framework/yii/i18n/BaseMessageFormatter.php b/framework/yii/i18n/BaseMessageFormatter.php deleted file mode 100644 index 73968c0..0000000 --- a/framework/yii/i18n/BaseMessageFormatter.php +++ /dev/null @@ -1,168 +0,0 @@ - - * @since 2.0 - */ -class BaseMessageFormatter -{ - private $_locale; - private $_pattern; - - /** - * Constructs a new Message Formatter - * @link http://php.net/manual/en/messageformatter.create.php - * @param string $locale The locale to use when formatting arguments - * @param string $pattern The pattern string to stick arguments into. - * The pattern uses an 'apostrophe-friendly' syntax; it is run through - * umsg_autoQuoteApostrophe before being interpreted. - */ - public function __construct($locale, $pattern) - { - $this->_locale = $locale; - $this->_pattern = $pattern; - } - - /** - * Constructs a new Message Formatter - * @link http://php.net/manual/en/messageformatter.create.php - * @param string $locale The locale to use when formatting arguments - * @param string $pattern The pattern string to stick arguments into. - * The pattern uses an 'apostrophe-friendly' syntax; it is run through - * umsg_autoQuoteApostrophe before being interpreted. - * @return MessageFormatter The formatter object - */ - public static function create($locale, $pattern) - { - return new static($locale, $pattern); - } - - /** - * Format the message - * @link http://php.net/manual/en/messageformatter.format.php - * @param array $args Arguments to insert into the format string - * @return string The formatted string, or FALSE if an error occurred - */ - public function format(array $args) - { - return static::formatMessage($this->_locale, $this->_pattern, $args); - } - - /** - * Quick format message - * @link http://php.net/manual/en/messageformatter.formatmessage.php - * @param string $locale The locale to use for formatting locale-dependent parts - * @param string $pattern The pattern string to insert things into. - * The pattern uses an 'apostrophe-friendly' syntax; it is run through - * umsg_autoQuoteApostrophe before being interpreted. - * @param array $args The array of values to insert into the format string - * @return string The formatted pattern string or FALSE if an error occurred - */ - public static function formatMessage($locale, $pattern, array $args) - { - // TODO implement plural format - - $a = []; - foreach($args as $name => $value) { - $a['{' . $name . '}'] = $value; - } - return strtr($pattern, $a); - } - - /** - * Parse input string according to pattern - * @link http://php.net/manual/en/messageformatter.parse.php - * @param string $value The string to parse - * @return array An array containing the items extracted, or FALSE on error - */ - public function parse ($value) - { - throw new NotSupportedException('You have to install PHP intl extension to use this feature.'); - } - - /** - * Quick parse input string - * @link http://php.net/manual/en/messageformatter.parsemessage.php - * @param string $locale The locale to use for parsing locale-dependent parts - * @param string $pattern The pattern with which to parse the value. - * @param string $source The string to parse, conforming to the pattern. - * @return array An array containing items extracted, or FALSE on error - */ - public static function parseMessage ($locale, $pattern, $source) - { - throw new NotSupportedException('You have to install PHP intl extension to use this feature.'); - } - - /** - * Set the pattern used by the formatter - * @link http://php.net/manual/en/messageformatter.setpattern.php - * @param string $pattern The pattern string to use in this message formatter. - * The pattern uses an 'apostrophe-friendly' syntax; it is run through - * umsg_autoQuoteApostrophe before being interpreted. - * @return bool TRUE on success or FALSE on failure. - */ - public function setPattern ($pattern) - { - $this->_pattern = $pattern; - return true; - } - - /** - * Get the pattern used by the formatter - * @link http://php.net/manual/en/messageformatter.getpattern.php - * @return string The pattern string for this message formatter - */ - public function getPattern() - { - return $this->_pattern; - } - - /** - * Get the locale for which the formatter was created. - * @link http://php.net/manual/en/messageformatter.getlocale.php - * @return string The locale name - */ - public function getLocale() - { - return $this->_locale; - } - - /** - * Get the error code from last operation - * @link http://php.net/manual/en/messageformatter.geterrorcode.php - * @return int The error code, one of UErrorCode values. Initial value is U_ZERO_ERROR. - */ - public function getErrorCode() - { - return 0; - } - - /** - * Get the error text from the last operation - * @link http://php.net/manual/en/messageformatter.geterrormessage.php - * @return string Description of the last error. - */ - public function getErrorMessage() - { - return ''; - } -} - -if (!class_exists('MessageFormatter', false)) { - class_alias('yii\\i18n\\BaseMessageFormatter', 'MessageFormatter'); -} - diff --git a/framework/yii/i18n/FallbackMessageFormatter.php b/framework/yii/i18n/FallbackMessageFormatter.php new file mode 100644 index 0000000..16d38f5 --- /dev/null +++ b/framework/yii/i18n/FallbackMessageFormatter.php @@ -0,0 +1,306 @@ + + * @since 2.0 + */ +class FallbackMessageFormatter +{ + private $_locale; + private $_pattern; + private $_errorMessage = ''; + private $_errorCode = 0; + + /** + * Constructs a new Message Formatter + * @link http://php.net/manual/en/messageformatter.create.php + * @param string $locale The locale to use when formatting arguments + * @param string $pattern The pattern string to stick arguments into. + */ + public function __construct($locale, $pattern) + { + $this->_locale = $locale; + $this->_pattern = $pattern; + } + + /** + * Constructs a new Message Formatter + * @link http://php.net/manual/en/messageformatter.create.php + * @param string $locale The locale to use when formatting arguments + * @param string $pattern The pattern string to stick arguments into. + * @return MessageFormatter The formatter object + */ + public static function create($locale, $pattern) + { + return new static($locale, $pattern); + } + + /** + * Format the message + * @link http://php.net/manual/en/messageformatter.format.php + * @param array $args Arguments to insert into the format string + * @return string The formatted string, or `FALSE` if an error occurred + */ + public function format(array $args) + { + return static::formatMessage($this->_locale, $this->_pattern, $args); + } + + /** + * Quick format message + * @link http://php.net/manual/en/messageformatter.formatmessage.php + * @param string $locale The locale to use for formatting locale-dependent parts + * @param string $pattern The pattern string to insert things into. + * @param array $args The array of values to insert into the format string + * @return string The formatted pattern string or `FALSE` if an error occurred + */ + public static function formatMessage($locale, $pattern, array $args) + { + if (($tokens = static::tokenizePattern($pattern)) === false) { + return false; + } + foreach($tokens as $i => $token) { + if (is_array($token)) { + if (($tokens[$i] = static::parseToken($token, $args, $locale)) === false) { + return false; + } + } + } + return implode('', $tokens); + } + + /** + * Tokenizes a pattern by separating normal text from replaceable patterns + * @param string $pattern patter to tokenize + * @return array|bool array of tokens or false on failure + */ + private static function tokenizePattern($pattern) + { + $depth = 1; + if (($start = $pos = mb_strpos($pattern, '{')) === false) { + return [$pattern]; + } + $tokens = [mb_substr($pattern, 0, $pos)]; + while(true) { + $open = mb_strpos($pattern, '{', $pos + 1); + $close = mb_strpos($pattern, '}', $pos + 1); + if ($open === false && $close === false) { + break; + } + if ($open === false) { + $open = mb_strlen($pattern); + } + if ($close > $open) { + $depth++; + $pos = $open; + } else { + $depth--; + $pos = $close; + } + if ($depth == 0) { + $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1), 3); + $start = $pos + 1; + $tokens[] = mb_substr($pattern, $start, $open - $start); + $start = $open; + } + } + if ($depth != 0) { + return false; + } + return $tokens; + } + + /** + * Parses a token + * @param array $token the token to parse + * @param array $args arguments to replace + * @param string $locale the locale + * @return bool|string parsed token or false on failure + * @throws \yii\base\NotSupportedException when unsupported formatting is used. + */ + private static function parseToken($token, $args, $locale) + { + $param = trim($token[0]); + if (isset($args[$param])) { + $arg = $args[$param]; + } else { + return '{' . implode(',', $token) . '}'; + } + $type = isset($token[1]) ? trim($token[1]) : 'none'; + switch($type) + { + case 'number': + case 'date': + case 'time': + case 'spellout': + case 'ordinal': + case 'duration': + case 'choice': + case 'selectordinal': + throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature."); + case 'none': return $arg; + case 'select': + /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html + selectStyle = (selector '{' message '}')+ + */ + $select = static::tokenizePattern($token[2]); + $c = count($select); + $message = false; + for($i = 0; $i + 1 < $c; $i++) { + if (is_array($select[$i]) || !is_array($select[$i + 1])) { + return false; + } + $selector = trim($select[$i++]); + if ($message === false && $selector == 'other' || $selector == $arg) { + $message = implode(',', $select[$i]); + } + } + if ($message !== false) { + return static::formatMessage($locale, $message, $args); + } + break; + case 'plural': + /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html + pluralStyle = [offsetValue] (selector '{' message '}')+ + offsetValue = "offset:" number + selector = explicitValue | keyword + explicitValue = '=' number // adjacent, no white space in between + keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+ + message: see MessageFormat + */ + $plural = static::tokenizePattern($token[2]); + $c = count($plural); + $message = false; + $offset = 0; + for($i = 0; $i + 1 < $c; $i++) { + if (is_array($plural[$i]) || !is_array($plural[$i + 1])) { + return false; + } + $selector = trim($plural[$i++]); + if ($i == 1 && substr($selector, 0, 7) == 'offset:') { + $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7)) - 7)); + $selector = trim(mb_substr($selector, $pos + 1)); + } + if ($message === false && $selector == 'other' || + $selector[0] == '=' && (int) mb_substr($selector, 1) == $arg || + $selector == 'zero' && $arg - $offset == 0 || + $selector == 'one' && $arg - $offset == 1 || + $selector == 'two' && $arg - $offset == 2 + ) { + $message = implode(',', str_replace('#', $arg - $offset, $plural[$i])); + } + } + if ($message !== false) { + return static::formatMessage($locale, $message, $args); + } + break; + } + return false; + } + + /** + * Parse input string according to pattern + * @link http://php.net/manual/en/messageformatter.parse.php + * @param string $value The string to parse + * @return array An array containing the items extracted, or `FALSE` on error + */ + public function parse($value) + { + throw new NotSupportedException('You have to install PHP intl extension to use this feature.'); + } + + /** + * Quick parse input string + * @link http://php.net/manual/en/messageformatter.parsemessage.php + * @param string $locale The locale to use for parsing locale-dependent parts + * @param string $pattern The pattern with which to parse the `value`. + * @param string $source The string to parse, conforming to the `pattern`. + * @return array An array containing items extracted, or `FALSE` on error + */ + public static function parseMessage($locale, $pattern, $source) + { + throw new NotSupportedException('You have to install PHP intl extension to use this feature.'); + } + + /** + * Set the pattern used by the formatter + * @link http://php.net/manual/en/messageformatter.setpattern.php + * @param string $pattern The pattern string to use in this message formatter. + * @return bool `TRUE` on success or `FALSE` on failure. + */ + public function setPattern($pattern) + { + $this->_pattern = $pattern; + return true; + } + + /** + * Get the pattern used by the formatter + * @link http://php.net/manual/en/messageformatter.getpattern.php + * @return string The pattern string for this message formatter + */ + public function getPattern() + { + return $this->_pattern; + } + + /** + * Get the locale for which the formatter was created. + * @link http://php.net/manual/en/messageformatter.getlocale.php + * @return string The locale name + */ + public function getLocale() + { + return $this->_locale; + } + + /** + * Get the error code from last operation + * @link http://php.net/manual/en/messageformatter.geterrorcode.php + * @return int The error code, one of UErrorCode values. Initial value is U_ZERO_ERROR. + */ + public function getErrorCode() + { + return $this->_errorCode; + } + + /** + * Get the error text from the last operation + * @link http://php.net/manual/en/messageformatter.geterrormessage.php + * @return string Description of the last error. + */ + public function getErrorMessage() + { + return $this->_errorMessage; + } +} + +if (!class_exists('MessageFormatter', false)) { + class_alias('yii\\i18n\\FallbackMessageFormatter', 'MessageFormatter'); + define('YII_INTL_MESSAGE_FALLBACK', true); +} diff --git a/framework/yii/i18n/MessageFormatter.php b/framework/yii/i18n/MessageFormatter.php index 6521d65..106bf18 100644 --- a/framework/yii/i18n/MessageFormatter.php +++ b/framework/yii/i18n/MessageFormatter.php @@ -8,8 +8,9 @@ namespace yii\i18n; if (!class_exists('MessageFormatter', false)) { - require_once(__DIR__ . '/BaseMessageFormatter.php'); + require_once(__DIR__ . '/FallbackMessageFormatter.php'); } +defined('YII_INTL_MESSAGE_FALLBACK') || define('YII_INTL_MESSAGE_FALLBACK', false); /** * MessageFormatter is an enhanced version of PHP intl class that no matter which PHP and ICU versions are used: @@ -18,6 +19,9 @@ if (!class_exists('MessageFormatter', false)) { * - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be * substituted. * - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920). + * - Offers limited support for message formatting in case PHP intl extension is not installed. + * However it is highly recommended that you install [PHP intl extension](http://php.net/manual/en/book.intl.php) if you want + * to use MessageFormatter features. * * @author Alexander Makarov * @author Carsten Brandt @@ -38,7 +42,7 @@ class MessageFormatter extends \MessageFormatter return $this->getPattern(); } - if (version_compare(PHP_VERSION, '5.5.0', '<')) { + if (version_compare(PHP_VERSION, '5.5.0', '<') && !YII_INTL_MESSAGE_FALLBACK) { $pattern = self::replaceNamedArguments($this->getPattern(), $args); $this->setPattern($pattern); $args = array_values($args); @@ -61,7 +65,7 @@ class MessageFormatter extends \MessageFormatter return $pattern; } - if (version_compare(PHP_VERSION, '5.5.0', '<')) { + if (version_compare(PHP_VERSION, '5.5.0', '<') && !YII_INTL_MESSAGE_FALLBACK) { $pattern = self::replaceNamedArguments($pattern, $args); $args = array_values($args); } diff --git a/tests/unit/framework/i18n/FallbackMessageFormatterTest.php b/tests/unit/framework/i18n/FallbackMessageFormatterTest.php new file mode 100644 index 0000000..407ec7f --- /dev/null +++ b/tests/unit/framework/i18n/FallbackMessageFormatterTest.php @@ -0,0 +1,155 @@ + + * @since 2.0 + * @group i18n + */ +class FallbackMessageFormatterTest extends TestCase +{ + const N = 'n'; + const N_VALUE = 42; + const SUBJECT = 'сабж'; + const SUBJECT_VALUE = 'Answer to the Ultimate Question of Life, the Universe, and Everything'; + + public function patterns() + { + return [ + [ + '{'.self::SUBJECT.'} is {'.self::N.'}', // pattern + self::SUBJECT_VALUE.' is '.self::N_VALUE, // expected + [ // params + self::N => self::N_VALUE, + self::SUBJECT => self::SUBJECT_VALUE, + ] + ], + + // This one was provided by Aura.Intl. Thanks! + [<<<_MSG_ +{gender_of_host, select, + female {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to her party.} + =2 {{host} invites {guest} and one other person to her party.} + other {{host} invites {guest} and # other people to her party.}}} + male {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to his party.} + =2 {{host} invites {guest} and one other person to his party.} + other {{host} invites {guest} and # other people to his party.}}} + other {{num_guests, plural, offset:1 + =0 {{host} does not give a party.} + =1 {{host} invites {guest} to their party.} + =2 {{host} invites {guest} and one other person to their party.} + other {{host} invites {guest} and # other people to their party.}}}} +_MSG_ + , + 'ralph invites beep and 3 other people to his party.', + [ + 'gender_of_host' => 'male', + 'num_guests' => 4, + 'host' => 'ralph', + 'guest' => 'beep' + ] + ], + + [ + '{name} is {gender} and {gender, select, female{she} male{he} other{it}} loves Yii!', + 'Alexander is male and he loves Yii!', + [ + 'name' => 'Alexander', + 'gender' => 'male', + ], + ], + + // verify pattern in select does not get replaced + [ + '{name} is {gender} and {gender, select, female{she} male{he} other{it}} loves Yii!', + 'Alexander is male and he loves Yii!', + [ + 'name' => 'Alexander', + 'gender' => 'male', + // following should not be replaced + 'he' => 'wtf', + 'she' => 'wtf', + 'it' => 'wtf', + ] + ], + + // verify pattern in select message gets replaced + [ + '{name} is {gender} and {gender, select, female{she} male{{he}} other{it}} loves Yii!', + 'Alexander is male and wtf loves Yii!', + [ + 'name' => 'Alexander', + 'gender' => 'male', + 'he' => 'wtf', + 'she' => 'wtf', + ], + ], + + // some parser specific verifications + [ + '{gender} and {gender, select, female{she} male{{he}} other{it}} loves {nr} is {gender}!', + 'male and wtf loves 42 is male!', + [ + 'nr' => 42, + 'gender' => 'male', + 'he' => 'wtf', + 'she' => 'wtf', + ], + ], + ]; + } + + /** + * @dataProvider patterns + */ + public function testNamedArgumentsStatic($pattern, $expected, $args) + { + $result = FallbackMessageFormatter::formatMessage('en_US', $pattern, $args); + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider patterns + */ + public function testNamedArgumentsObject($pattern, $expected, $args) + { + $formatter = new FallbackMessageFormatter('en_US', $pattern); + $result = $formatter->format($args); + $this->assertEquals($expected, $result, $formatter->getErrorMessage()); + } + + public function testInsufficientArguments() + { + $expected = '{'.self::SUBJECT.'} is '.self::N_VALUE; + + $result = FallbackMessageFormatter::formatMessage('en_US', '{'.self::SUBJECT.'} is {'.self::N.'}', [ + self::N => self::N_VALUE, + ]); + + $this->assertEquals($expected, $result); + } + + public function testNoParams() + { + $pattern = '{'.self::SUBJECT.'} is '.self::N; + $result = FallbackMessageFormatter::formatMessage('en_US', $pattern, []); + $this->assertEquals($pattern, $result); + + $formatter = new FallbackMessageFormatter('en_US', $pattern); + $result = $formatter->format([]); + $this->assertEquals($pattern, $result, $formatter->getErrorMessage()); + } +} \ No newline at end of file