1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* @link http://www.yiiframework.com/ |
4
|
|
|
* @copyright Copyright (c) 2008 Yii Software LLC |
5
|
|
|
* @license http://www.yiiframework.com/license/ |
6
|
|
|
*/ |
7
|
|
|
|
8
|
|
|
namespace yii\i18n; |
9
|
|
|
|
10
|
|
|
use Yii; |
11
|
|
|
use yii\base\Component; |
12
|
|
|
use yii\base\NotSupportedException; |
13
|
|
|
|
14
|
|
|
/** |
15
|
|
|
* MessageFormatter allows formatting messages via [ICU message format](http://userguide.icu-project.org/formatparse/messages) |
16
|
|
|
* |
17
|
|
|
* This class enhances the message formatter class provided by the PHP intl extension. |
18
|
|
|
* |
19
|
|
|
* The following enhancements are provided: |
20
|
|
|
* |
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 |
23
|
|
|
* substituted. |
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](http://php.net/manual/en/book.intl.php) if you want |
27
|
|
|
* to use MessageFormatter features. |
28
|
|
|
* |
29
|
|
|
* The fallback implementation only supports the following message formats: |
30
|
|
|
* - plural formatting for english ('one' and 'other' selectors) |
31
|
|
|
* - select format |
32
|
|
|
* - simple parameters |
33
|
|
|
* - integer number parameters |
34
|
|
|
* |
35
|
|
|
* The fallback implementation does NOT support the ['apostrophe-friendly' syntax](http://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. |
38
|
|
|
* |
39
|
|
|
* @property string $errorCode Code of the last error. This property is read-only. |
40
|
|
|
* @property string $errorMessage Description of the last error. This property is read-only. |
41
|
|
|
* |
42
|
|
|
* @author Alexander Makarov <[email protected]> |
43
|
|
|
* @author Carsten Brandt <[email protected]> |
44
|
|
|
* @since 2.0 |
45
|
|
|
*/ |
46
|
|
|
class MessageFormatter extends Component |
47
|
|
|
{ |
48
|
|
|
private $_errorCode = 0; |
49
|
|
|
private $_errorMessage = ''; |
50
|
|
|
|
51
|
|
|
|
52
|
|
|
/** |
53
|
|
|
* Get the error code from the last operation |
54
|
|
|
* @link http://php.net/manual/en/messageformatter.geterrorcode.php |
55
|
|
|
* @return string Code of the last error. |
56
|
|
|
*/ |
57
|
|
|
public function getErrorCode() |
58
|
|
|
{ |
59
|
|
|
return $this->_errorCode; |
60
|
|
|
} |
61
|
|
|
|
62
|
|
|
/** |
63
|
|
|
* Get the error text from the last operation |
64
|
|
|
* @link http://php.net/manual/en/messageformatter.geterrormessage.php |
65
|
|
|
* @return string Description of the last error. |
66
|
|
|
*/ |
67
|
47 |
|
public function getErrorMessage() |
68
|
|
|
{ |
69
|
47 |
|
return $this->_errorMessage; |
70
|
|
|
} |
71
|
|
|
|
72
|
|
|
/** |
73
|
|
|
* Formats a message via [ICU message format](http://userguide.icu-project.org/formatparse/messages) |
74
|
|
|
* |
75
|
|
|
* It uses the PHP intl extension's [MessageFormatter](http://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. |
78
|
|
|
* |
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 |
83
|
|
|
*/ |
84
|
152 |
|
public function format($pattern, $params, $language) |
85
|
|
|
{ |
86
|
152 |
|
$this->_errorCode = 0; |
87
|
152 |
|
$this->_errorMessage = ''; |
88
|
|
|
|
89
|
152 |
|
if ($params === []) { |
90
|
4 |
|
return $pattern; |
91
|
|
|
} |
92
|
|
|
|
93
|
148 |
|
if (!class_exists('MessageFormatter', false)) { |
94
|
|
|
return $this->fallbackFormat($pattern, $params, $language); |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
// replace named arguments (https://github.com/yiisoft/yii2/issues/9678) |
98
|
148 |
|
$newParams = []; |
99
|
148 |
|
$pattern = $this->replaceNamedArguments($pattern, $params, $newParams); |
100
|
148 |
|
$params = $newParams; |
101
|
|
|
|
102
|
|
|
try { |
103
|
148 |
|
$formatter = new \MessageFormatter($language, $pattern); |
104
|
|
|
|
105
|
147 |
|
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(); |
109
|
147 |
|
return false; |
110
|
|
|
} |
111
|
1 |
|
} catch (\IntlException $e) { |
|
|
|
|
112
|
|
|
// IntlException is thrown since PHP 7 |
113
|
1 |
|
$this->_errorCode = $e->getCode(); |
114
|
1 |
|
$this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage(); |
115
|
1 |
|
return false; |
116
|
|
|
} catch (\Exception $e) { |
117
|
|
|
// Exception is thrown by HHVM |
118
|
|
|
$this->_errorCode = $e->getCode(); |
119
|
|
|
$this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage(); |
120
|
|
|
return false; |
121
|
|
|
} |
122
|
|
|
|
123
|
147 |
|
$result = $formatter->format($params); |
124
|
|
|
|
125
|
147 |
|
if ($result === false) { |
126
|
|
|
$this->_errorCode = $formatter->getErrorCode(); |
127
|
|
|
$this->_errorMessage = $formatter->getErrorMessage(); |
128
|
|
|
return false; |
129
|
|
|
} |
130
|
|
|
|
131
|
147 |
|
return $result; |
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
/** |
135
|
|
|
* Parses an input string according to an [ICU message format](http://userguide.icu-project.org/formatparse/messages) pattern. |
136
|
|
|
* |
137
|
|
|
* It uses the PHP intl extension's [MessageFormatter::parse()](http://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. |
140
|
|
|
* |
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. |
146
|
|
|
*/ |
147
|
7 |
|
public function parse($pattern, $message, $language) |
148
|
|
|
{ |
149
|
7 |
|
$this->_errorCode = 0; |
150
|
7 |
|
$this->_errorMessage = ''; |
151
|
|
|
|
152
|
7 |
|
if (!class_exists('MessageFormatter', false)) { |
153
|
|
|
throw new NotSupportedException('You have to install PHP intl extension to use this feature.'); |
154
|
|
|
} |
155
|
|
|
|
156
|
|
|
// replace named arguments |
157
|
7 |
|
if (($tokens = self::tokenizePattern($pattern)) === false) { |
158
|
|
|
$this->_errorCode = -1; |
159
|
|
|
$this->_errorMessage = 'Message pattern is invalid.'; |
160
|
|
|
|
161
|
|
|
return false; |
162
|
|
|
} |
163
|
7 |
|
$map = []; |
164
|
7 |
|
foreach ($tokens as $i => $token) { |
165
|
7 |
|
if (is_array($token)) { |
166
|
7 |
|
$param = trim($token[0]); |
167
|
7 |
|
if (!isset($map[$param])) { |
168
|
7 |
|
$map[$param] = count($map); |
169
|
|
|
} |
170
|
7 |
|
$token[0] = $map[$param]; |
171
|
7 |
|
$tokens[$i] = '{' . implode(',', $token) . '}'; |
172
|
|
|
} |
173
|
|
|
} |
174
|
7 |
|
$pattern = implode('', $tokens); |
175
|
7 |
|
$map = array_flip($map); |
176
|
|
|
|
177
|
7 |
|
$formatter = new \MessageFormatter($language, $pattern); |
178
|
7 |
|
if ($formatter === null) { |
179
|
|
|
$this->_errorCode = -1; |
180
|
|
|
$this->_errorMessage = 'Message pattern is invalid.'; |
181
|
|
|
|
182
|
|
|
return false; |
183
|
|
|
} |
184
|
7 |
|
$result = $formatter->parse($message); |
185
|
7 |
|
if ($result === false) { |
186
|
|
|
$this->_errorCode = $formatter->getErrorCode(); |
187
|
|
|
$this->_errorMessage = $formatter->getErrorMessage(); |
188
|
|
|
|
189
|
|
|
return false; |
190
|
|
|
} |
191
|
|
|
|
192
|
7 |
|
$values = []; |
193
|
7 |
|
foreach ($result as $key => $value) { |
194
|
7 |
|
$values[$map[$key]] = $value; |
195
|
|
|
} |
196
|
|
|
|
197
|
7 |
|
return $values; |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* Replace named placeholders with numeric placeholders and quote unused. |
202
|
|
|
* |
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. |
206
|
|
|
* @param array $map |
207
|
|
|
* @return string The pattern string with placeholders replaced. |
208
|
|
|
*/ |
209
|
148 |
|
private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = []) |
210
|
|
|
{ |
211
|
148 |
|
if (($tokens = self::tokenizePattern($pattern)) === false) { |
212
|
|
|
return false; |
213
|
|
|
} |
214
|
148 |
|
foreach ($tokens as $i => $token) { |
215
|
148 |
|
if (!is_array($token)) { |
216
|
148 |
|
continue; |
217
|
|
|
} |
218
|
148 |
|
$param = trim($token[0]); |
219
|
148 |
|
if (isset($givenParams[$param])) { |
220
|
|
|
// if param is given, replace it with a number |
221
|
145 |
|
if (!isset($map[$param])) { |
222
|
145 |
|
$map[$param] = count($map); |
223
|
|
|
// make sure only used params are passed to format method |
224
|
145 |
|
$resultingParams[$map[$param]] = $givenParams[$param]; |
225
|
|
|
} |
226
|
145 |
|
$token[0] = $map[$param]; |
227
|
145 |
|
$quote = ''; |
228
|
|
|
} else { |
229
|
|
|
// quote unused token |
230
|
4 |
|
$quote = "'"; |
231
|
|
|
} |
232
|
148 |
|
$type = isset($token[1]) ? trim($token[1]) : 'none'; |
233
|
|
|
// replace plural and select format recursively |
234
|
148 |
|
if ($type === 'plural' || $type === 'select') { |
235
|
135 |
|
if (!isset($token[2])) { |
236
|
|
|
return false; |
237
|
|
|
} |
238
|
135 |
|
if (($subtokens = self::tokenizePattern($token[2])) === false) { |
239
|
|
|
return false; |
240
|
|
|
} |
241
|
135 |
|
$c = count($subtokens); |
242
|
135 |
|
for ($k = 0; $k + 1 < $c; $k++) { |
243
|
135 |
|
if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) { |
244
|
|
|
return false; |
245
|
|
|
} |
246
|
135 |
|
$subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map); |
247
|
135 |
|
$subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote; |
248
|
|
|
} |
249
|
135 |
|
$token[2] = implode('', $subtokens); |
250
|
|
|
} |
251
|
148 |
|
$tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote; |
252
|
|
|
} |
253
|
|
|
|
254
|
148 |
|
return implode('', $tokens); |
255
|
|
|
} |
256
|
|
|
|
257
|
|
|
/** |
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 |
263
|
|
|
*/ |
264
|
18 |
|
protected function fallbackFormat($pattern, $args, $locale) |
265
|
|
|
{ |
266
|
18 |
|
if (($tokens = self::tokenizePattern($pattern)) === false) { |
267
|
|
|
$this->_errorCode = -1; |
268
|
|
|
$this->_errorMessage = 'Message pattern is invalid.'; |
269
|
|
|
|
270
|
|
|
return false; |
271
|
|
|
} |
272
|
18 |
|
foreach ($tokens as $i => $token) { |
273
|
18 |
|
if (is_array($token)) { |
274
|
18 |
|
if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) { |
275
|
|
|
$this->_errorCode = -1; |
276
|
|
|
$this->_errorMessage = 'Message pattern is invalid.'; |
277
|
|
|
|
278
|
18 |
|
return false; |
279
|
|
|
} |
280
|
|
|
} |
281
|
|
|
} |
282
|
|
|
|
283
|
16 |
|
return implode('', $tokens); |
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
/** |
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 |
290
|
|
|
*/ |
291
|
173 |
|
private static function tokenizePattern($pattern) |
292
|
|
|
{ |
293
|
173 |
|
$charset = Yii::$app ? Yii::$app->charset : 'UTF-8'; |
294
|
173 |
|
$depth = 1; |
295
|
173 |
|
if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) { |
296
|
137 |
|
return [$pattern]; |
297
|
|
|
} |
298
|
173 |
|
$tokens = [mb_substr($pattern, 0, $pos, $charset)]; |
299
|
173 |
|
while (true) { |
300
|
173 |
|
$open = mb_strpos($pattern, '{', $pos + 1, $charset); |
301
|
173 |
|
$close = mb_strpos($pattern, '}', $pos + 1, $charset); |
302
|
173 |
|
if ($open === false && $close === false) { |
303
|
173 |
|
break; |
304
|
|
|
} |
305
|
173 |
|
if ($open === false) { |
306
|
173 |
|
$open = mb_strlen($pattern, $charset); |
307
|
|
|
} |
308
|
173 |
|
if ($close > $open) { |
309
|
158 |
|
$depth++; |
310
|
158 |
|
$pos = $open; |
311
|
|
|
} else { |
312
|
173 |
|
$depth--; |
313
|
173 |
|
$pos = $close; |
314
|
|
|
} |
315
|
173 |
|
if ($depth === 0) { |
316
|
173 |
|
$tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3); |
317
|
173 |
|
$start = $pos + 1; |
318
|
173 |
|
$tokens[] = mb_substr($pattern, $start, $open - $start, $charset); |
319
|
173 |
|
$start = $open; |
320
|
|
|
} |
321
|
|
|
} |
322
|
173 |
|
if ($depth !== 0) { |
323
|
|
|
return false; |
324
|
|
|
} |
325
|
|
|
|
326
|
173 |
|
return $tokens; |
327
|
|
|
} |
328
|
|
|
|
329
|
|
|
/** |
330
|
|
|
* Parses a token |
331
|
|
|
* @param array $token the token to parse |
332
|
|
|
* @param array $args arguments to replace |
333
|
|
|
* @param string $locale the locale |
334
|
|
|
* @return bool|string parsed token or false on failure |
335
|
|
|
* @throws \yii\base\NotSupportedException when unsupported formatting is used. |
336
|
|
|
*/ |
337
|
18 |
|
private function parseToken($token, $args, $locale) |
338
|
|
|
{ |
339
|
|
|
// parsing pattern based on ICU grammar: |
340
|
|
|
// http://icu-project.org/apiref/icu4c/classMessageFormat.html#details |
341
|
18 |
|
$charset = Yii::$app ? Yii::$app->charset : 'UTF-8'; |
342
|
18 |
|
$param = trim($token[0]); |
343
|
18 |
|
if (isset($args[$param])) { |
344
|
16 |
|
$arg = $args[$param]; |
345
|
|
|
} else { |
346
|
3 |
|
return '{' . implode(',', $token) . '}'; |
347
|
|
|
} |
348
|
16 |
|
$type = isset($token[1]) ? trim($token[1]) : 'none'; |
349
|
|
|
switch ($type) { |
350
|
16 |
|
case 'date': |
351
|
16 |
|
case 'time': |
352
|
16 |
|
case 'spellout': |
353
|
16 |
|
case 'ordinal': |
354
|
16 |
|
case 'duration': |
355
|
16 |
|
case 'choice': |
356
|
16 |
|
case 'selectordinal': |
357
|
|
|
throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature."); |
358
|
16 |
|
case 'number': |
359
|
9 |
|
$format = isset($token[2]) ? trim($token[2]) : null; |
360
|
9 |
|
if (is_numeric($arg) && ($format === null || $format === 'integer')) { |
361
|
7 |
|
$number = number_format($arg); |
362
|
7 |
|
if ($format === null && ($pos = strpos($arg, '.')) !== false) { |
363
|
|
|
// add decimals with unknown length |
364
|
1 |
|
$number .= '.' . substr($arg, $pos + 1); |
365
|
|
|
} |
366
|
7 |
|
return $number; |
367
|
|
|
} |
368
|
2 |
|
throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature."); |
369
|
10 |
|
case 'none': |
370
|
9 |
|
return $arg; |
371
|
6 |
|
case 'select': |
372
|
|
|
/* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html |
373
|
|
|
selectStyle = (selector '{' message '}')+ |
374
|
|
|
*/ |
375
|
5 |
|
if (!isset($token[2])) { |
376
|
|
|
return false; |
377
|
|
|
} |
378
|
5 |
|
$select = self::tokenizePattern($token[2]); |
379
|
5 |
|
$c = count($select); |
380
|
5 |
|
$message = false; |
381
|
5 |
|
for ($i = 0; $i + 1 < $c; $i++) { |
382
|
5 |
|
if (is_array($select[$i]) || !is_array($select[$i + 1])) { |
383
|
|
|
return false; |
384
|
|
|
} |
385
|
5 |
|
$selector = trim($select[$i++]); |
386
|
5 |
|
if ($message === false && $selector === 'other' || $selector == $arg) { |
387
|
5 |
|
$message = implode(',', $select[$i]); |
388
|
|
|
} |
389
|
|
|
} |
390
|
5 |
|
if ($message !== false) { |
391
|
5 |
|
return $this->fallbackFormat($message, $args, $locale); |
392
|
|
|
} |
393
|
|
|
break; |
394
|
2 |
|
case 'plural': |
395
|
|
|
/* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html |
396
|
|
|
pluralStyle = [offsetValue] (selector '{' message '}')+ |
397
|
|
|
offsetValue = "offset:" number |
398
|
|
|
selector = explicitValue | keyword |
399
|
|
|
explicitValue = '=' number // adjacent, no white space in between |
400
|
|
|
keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+ |
401
|
|
|
message: see MessageFormat |
402
|
|
|
*/ |
403
|
2 |
|
if (!isset($token[2])) { |
404
|
|
|
return false; |
405
|
|
|
} |
406
|
2 |
|
$plural = self::tokenizePattern($token[2]); |
407
|
2 |
|
$c = count($plural); |
408
|
2 |
|
$message = false; |
409
|
2 |
|
$offset = 0; |
410
|
2 |
|
for ($i = 0; $i + 1 < $c; $i++) { |
411
|
2 |
|
if (is_array($plural[$i]) || !is_array($plural[$i + 1])) { |
412
|
|
|
return false; |
413
|
|
|
} |
414
|
2 |
|
$selector = trim($plural[$i++]); |
415
|
|
|
|
416
|
2 |
|
if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) { |
417
|
1 |
|
$offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset)); |
418
|
1 |
|
$selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset)); |
419
|
|
|
} |
420
|
2 |
|
if ($message === false && $selector === 'other' || |
421
|
2 |
|
$selector[0] === '=' && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg || |
422
|
2 |
|
$selector === 'one' && $arg - $offset == 1 |
423
|
|
|
) { |
424
|
2 |
|
$message = implode(',', str_replace('#', $arg - $offset, $plural[$i])); |
425
|
|
|
} |
426
|
|
|
} |
427
|
2 |
|
if ($message !== false) { |
428
|
2 |
|
return $this->fallbackFormat($message, $args, $locale); |
429
|
|
|
} |
430
|
|
|
break; |
431
|
|
|
} |
432
|
|
|
|
433
|
|
|
return false; |
434
|
|
|
} |
435
|
|
|
} |
436
|
|
|
|
Scrutinizer analyzes your
composer.json
/composer.lock
file if available to determine the classes, and functions that are defined by your dependencies.It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.