3
* @link https://www.yiiframework.com/
4
* @copyright Copyright (c) 2008 Yii Software LLC
5
* @license https://www.yiiframework.com/license/
11
use yii\base\Component;
12
use yii\base\NotSupportedException;
15
* MessageFormatter allows formatting messages via [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
17
* This class enhances the message formatter class provided by the PHP intl extension.
19
* The following enhancements are provided:
21
* - It accepts named arguments and mixed numeric and named arguments.
22
* - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be
24
* - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920).
25
* - Offers limited support for message formatting in case PHP intl extension is not installed.
26
* However it is highly recommended that you install [PHP intl extension](https://www.php.net/manual/en/book.intl.php) if you want
27
* to use MessageFormatter features.
29
* The fallback implementation only supports the following message formats:
30
* - plural formatting for english ('one' and 'other' selectors)
33
* - integer number parameters
35
* The fallback implementation does NOT support the ['apostrophe-friendly' syntax](https://www.php.net/manual/en/messageformatter.formatmessage.php).
36
* Also messages that are working with the fallback implementation are not necessarily compatible with the
37
* PHP intl MessageFormatter so do not rely on the fallback if you are able to install intl extension somehow.
39
* @property-read string $errorCode Code of the last error.
40
* @property-read string $errorMessage Description of the last error.
42
* @author Alexander Makarov <sam@rmcreative.ru>
43
* @author Carsten Brandt <mail@cebe.cc>
46
class MessageFormatter extends Component
48
private $_errorCode = 0;
49
private $_errorMessage = '';
53
* Get the error code from the last operation.
54
* @link https://www.php.net/manual/en/messageformatter.geterrorcode.php
55
* @return string Code of the last error.
57
public function getErrorCode()
59
return $this->_errorCode;
63
* Get the error text from the last operation.
64
* @link https://www.php.net/manual/en/messageformatter.geterrormessage.php
65
* @return string Description of the last error.
67
public function getErrorMessage()
69
return $this->_errorMessage;
73
* Formats a message via [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
75
* It uses the PHP intl extension's [MessageFormatter](https://www.php.net/manual/en/class.messageformatter.php)
76
* and works around some issues.
77
* If PHP intl is not installed a fallback will be used that supports a subset of the ICU message format.
79
* @param string $pattern The pattern string to insert parameters into.
80
* @param array $params The array of name value pairs to insert into the format string.
81
* @param string $language The locale to use for formatting locale-dependent parts
82
* @return string|false The formatted pattern string or `false` if an error occurred
84
public function format($pattern, $params, $language)
86
$this->_errorCode = 0;
87
$this->_errorMessage = '';
93
if (!class_exists('MessageFormatter', false)) {
94
return $this->fallbackFormat($pattern, $params, $language);
97
// replace named arguments (https://github.com/yiisoft/yii2/issues/9678)
99
$pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
100
$params = $newParams;
103
$formatter = new \MessageFormatter($language, $pattern);
105
if ($formatter === null) {
106
// formatter may be null in PHP 5.x
107
$this->_errorCode = intl_get_error_code();
108
$this->_errorMessage = 'Message pattern is invalid: ' . intl_get_error_message();
111
} catch (\IntlException $e) {
112
// IntlException is thrown since PHP 7
113
$this->_errorCode = $e->getCode();
114
$this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
116
} catch (\Exception $e) {
117
// Exception is thrown by HHVM
118
$this->_errorCode = $e->getCode();
119
$this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
123
$result = $formatter->format($params);
125
if ($result === false) {
126
$this->_errorCode = $formatter->getErrorCode();
127
$this->_errorMessage = $formatter->getErrorMessage();
135
* Parses an input string according to an [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) pattern.
137
* It uses the PHP intl extension's [MessageFormatter::parse()](https://www.php.net/manual/en/messageformatter.parsemessage.php)
138
* and adds support for named arguments.
139
* Usage of this method requires PHP intl extension to be installed.
141
* @param string $pattern The pattern to use for parsing the message.
142
* @param string $message The message to parse, conforming to the pattern.
143
* @param string $language The locale to use for formatting locale-dependent parts
144
* @return array|bool An array containing items extracted, or `FALSE` on error.
145
* @throws \yii\base\NotSupportedException when PHP intl extension is not installed.
147
public function parse($pattern, $message, $language)
149
$this->_errorCode = 0;
150
$this->_errorMessage = '';
152
if (!class_exists('MessageFormatter', false)) {
153
throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
156
// replace named arguments
157
if (($tokens = self::tokenizePattern($pattern)) === false) {
158
$this->_errorCode = -1;
159
$this->_errorMessage = 'Message pattern is invalid.';
164
foreach ($tokens as $i => $token) {
165
if (is_array($token)) {
166
$param = trim($token[0]);
167
if (!isset($map[$param])) {
168
$map[$param] = count($map);
170
$token[0] = $map[$param];
171
$tokens[$i] = '{' . implode(',', $token) . '}';
174
$pattern = implode('', $tokens);
175
$map = array_flip($map);
177
$formatter = new \MessageFormatter($language, $pattern);
178
if ($formatter === null) {
179
$this->_errorCode = -1;
180
$this->_errorMessage = 'Message pattern is invalid.';
184
$result = $formatter->parse($message);
185
if ($result === false) {
186
$this->_errorCode = $formatter->getErrorCode();
187
$this->_errorMessage = $formatter->getErrorMessage();
193
foreach ($result as $key => $value) {
194
$values[$map[$key]] = $value;
201
* Replace named placeholders with numeric placeholders and quote unused.
203
* @param string $pattern The pattern string to replace things into.
204
* @param array $givenParams The array of values to insert into the format string.
205
* @param array $resultingParams Modified array of parameters.
207
* @return string The pattern string with placeholders replaced.
209
private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
211
if (($tokens = self::tokenizePattern($pattern)) === false) {
214
foreach ($tokens as $i => $token) {
215
if (!is_array($token)) {
218
$param = trim($token[0]);
219
if (array_key_exists($param, $givenParams)) {
220
// if param is given, replace it with a number
221
if (!isset($map[$param])) {
222
$map[$param] = count($map);
223
// make sure only used params are passed to format method
224
$resultingParams[$map[$param]] = $givenParams[$param];
226
$token[0] = $map[$param];
229
// quote unused token
232
$type = isset($token[1]) ? trim($token[1]) : 'none';
233
// replace plural and select format recursively
234
if ($type === 'plural' || $type === 'select') {
235
if (!isset($token[2])) {
238
if (($subtokens = self::tokenizePattern($token[2])) === false) {
241
$c = count($subtokens);
242
for ($k = 0; $k + 1 < $c; $k++) {
243
if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
246
$subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
247
$subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
249
$token[2] = implode('', $subtokens);
251
$tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
254
return implode('', $tokens);
258
* Fallback implementation for MessageFormatter::formatMessage.
259
* @param string $pattern The pattern string to insert things into.
260
* @param array $args The array of values to insert into the format string
261
* @param string $locale The locale to use for formatting locale-dependent parts
262
* @return false|string The formatted pattern string or `false` if an error occurred
264
protected function fallbackFormat($pattern, $args, $locale)
266
if (($tokens = self::tokenizePattern($pattern)) === false) {
267
$this->_errorCode = -1;
268
$this->_errorMessage = 'Message pattern is invalid.';
272
foreach ($tokens as $i => $token) {
273
if (is_array($token)) {
274
if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
275
$this->_errorCode = -1;
276
$this->_errorMessage = 'Message pattern is invalid.';
283
return implode('', $tokens);
287
* Tokenizes a pattern by separating normal text from replaceable patterns.
288
* @param string $pattern patter to tokenize
289
* @return array|bool array of tokens or false on failure
291
private static function tokenizePattern($pattern)
293
$charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
295
if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
298
$tokens = [mb_substr($pattern, 0, $pos, $charset)];
300
$open = mb_strpos($pattern, '{', $pos + 1, $charset);
301
$close = mb_strpos($pattern, '}', $pos + 1, $charset);
302
if ($open === false && $close === false) {
305
if ($open === false) {
306
$open = mb_strlen($pattern, $charset);
308
if ($close > $open) {
316
$tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
318
$tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
322
if ($depth !== 0 && ($open === false || $close === false)) {
335
* @param array $token the token to parse
336
* @param array $args arguments to replace
337
* @param string $locale the locale
338
* @return bool|string parsed token or false on failure
339
* @throws \yii\base\NotSupportedException when unsupported formatting is used.
341
private function parseToken($token, $args, $locale)
343
// parsing pattern based on ICU grammar:
344
// https://unicode-org.github.io/icu-docs/#/icu4c/classMessageFormat.html
345
$charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
346
$param = trim($token[0]);
347
if (isset($args[$param])) {
348
$arg = $args[$param];
350
return '{' . implode(',', $token) . '}';
352
$type = isset($token[1]) ? trim($token[1]) : 'none';
360
case 'selectordinal':
361
throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
363
$format = isset($token[2]) ? trim($token[2]) : null;
364
if (is_numeric($arg) && ($format === null || $format === 'integer')) {
365
$number = number_format($arg);
366
if ($format === null && ($pos = strpos($arg, '.')) !== false) {
367
// add decimals with unknown length
368
$number .= '.' . substr($arg, $pos + 1);
373
throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
377
/* https://unicode-org.github.io/icu-docs/#/icu4c/classicu_1_1SelectFormat.html
378
selectStyle = (selector '{' message '}')+
380
if (!isset($token[2])) {
383
$select = self::tokenizePattern($token[2]);
386
for ($i = 0; $i + 1 < $c; $i++) {
387
if (is_array($select[$i]) || !is_array($select[$i + 1])) {
390
$selector = trim($select[$i++]);
391
if ($message === false && $selector === 'other' || $selector == $arg) {
392
$message = implode(',', $select[$i]);
395
if ($message !== false) {
396
return $this->fallbackFormat($message, $args, $locale);
400
/* https://unicode-org.github.io/icu-docs/#/icu4c/classicu_1_1PluralFormat.html
401
pluralStyle = [offsetValue] (selector '{' message '}')+
402
offsetValue = "offset:" number
403
selector = explicitValue | keyword
404
explicitValue = '=' number // adjacent, no white space in between
405
keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
406
message: see MessageFormat
408
if (!isset($token[2])) {
411
$plural = self::tokenizePattern($token[2]);
415
for ($i = 0; $i + 1 < $c; $i++) {
416
if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
419
$selector = trim($plural[$i++]);
421
if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
422
$offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
423
$selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
425
if ($message === false && $selector === 'other' ||
426
strncmp($selector, '=', 1) === 0 && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
427
$selector === 'one' && $arg - $offset == 1
429
$message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
432
if ($message !== false) {
433
return $this->fallbackFormat($message, $args, $locale);