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/I18N.php b/framework/yii/i18n/I18N.php index 779d89f..57978a0 100644 --- a/framework/yii/i18n/I18N.php +++ b/framework/yii/i18n/I18N.php @@ -78,7 +78,7 @@ class I18N extends Component return $message; } - if (class_exists('MessageFormatter', false) && preg_match('~{\s*[\d\w]+\s*,~u', $message)) { + if (preg_match('~{\s*[\d\w]+\s*,~u', $message)) { $formatter = new MessageFormatter($language, $message); if ($formatter === null) { Yii::warning("$language message from category $category is invalid. Message is: $message."); diff --git a/framework/yii/i18n/MessageFormatter.php b/framework/yii/i18n/MessageFormatter.php index 93c4563..106bf18 100644 --- a/framework/yii/i18n/MessageFormatter.php +++ b/framework/yii/i18n/MessageFormatter.php @@ -7,6 +7,11 @@ namespace yii\i18n; +if (!class_exists('MessageFormatter', false)) { + 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: * @@ -14,6 +19,9 @@ namespace yii\i18n; * - 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 @@ -34,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); @@ -57,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 diff --git a/tests/unit/framework/i18n/I18NTest.php b/tests/unit/framework/i18n/I18NTest.php index a6d8d9f..924176f 100644 --- a/tests/unit/framework/i18n/I18NTest.php +++ b/tests/unit/framework/i18n/I18NTest.php @@ -51,7 +51,13 @@ class I18NTest extends TestCase $params = ['n' => 42]; $this->assertEquals('His speed is about 42 km/h.', $this->i18n->translate('test', $msg, $params, 'en_US')); $this->assertEquals('Seine Geschwindigkeit beträgt 42 km/h.', $this->i18n->translate('test', $msg, $params, 'de_DE')); + } + public function testTranslateParams2() + { + if (!extension_loaded("intl")) { + $this->markTestSkipped("intl not installed. Skipping."); + } $msg = 'His name is {name} and his speed is about {n, number} km/h.'; $params = [ 'n' => 42,