Completed
Push — remove-hhvm ( 3f0295 )
by Alexander
13:56 queued 10:07
created

MessageFormatter::fallbackFormat()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 8.125

Importance

Changes 0
Metric Value
dl 0
loc 21
ccs 6
cts 12
cp 0.5
rs 8.7624
c 0
b 0
f 0
cc 5
eloc 12
nc 5
nop 3
crap 8.125
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
                return false;
110
            }
111 1
        } catch (\IntlException $e) {
0 ignored issues
show
Bug introduced by
The class IntlException does not exist. Did you forget a USE statement, or did you not list all dependencies?

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.

Loading history...
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
        }
117
118 147
        $result = $formatter->format($params);
119
120 147
        if ($result === false) {
121
            $this->_errorCode = $formatter->getErrorCode();
122
            $this->_errorMessage = $formatter->getErrorMessage();
123
            return false;
124
        } else {
125 147
            return $result;
126
        }
127
    }
128
129
    /**
130
     * Parses an input string according to an [ICU message format](http://userguide.icu-project.org/formatparse/messages) pattern.
131
     *
132
     * It uses the PHP intl extension's [MessageFormatter::parse()](http://www.php.net/manual/en/messageformatter.parsemessage.php)
133
     * and adds support for named arguments.
134
     * Usage of this method requires PHP intl extension to be installed.
135
     *
136
     * @param string $pattern The pattern to use for parsing the message.
137
     * @param string $message The message to parse, conforming to the pattern.
138
     * @param string $language The locale to use for formatting locale-dependent parts
139
     * @return array|bool An array containing items extracted, or `FALSE` on error.
140
     * @throws \yii\base\NotSupportedException when PHP intl extension is not installed.
141
     */
142 7
    public function parse($pattern, $message, $language)
143
    {
144 7
        $this->_errorCode = 0;
145 7
        $this->_errorMessage = '';
146
147 7
        if (!class_exists('MessageFormatter', false)) {
148
            throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
149
        }
150
151
        // replace named arguments
152 7
        if (($tokens = self::tokenizePattern($pattern)) === false) {
153
            $this->_errorCode = -1;
154
            $this->_errorMessage = 'Message pattern is invalid.';
155
156
            return false;
157
        }
158 7
        $map = [];
159 7
        foreach ($tokens as $i => $token) {
160 7
            if (is_array($token)) {
161 7
                $param = trim($token[0]);
162 7
                if (!isset($map[$param])) {
163 7
                    $map[$param] = count($map);
164
                }
165 7
                $token[0] = $map[$param];
166 7
                $tokens[$i] = '{' . implode(',', $token) . '}';
167
            }
168
        }
169 7
        $pattern = implode('', $tokens);
170 7
        $map = array_flip($map);
171
172 7
        $formatter = new \MessageFormatter($language, $pattern);
173 7
        if ($formatter === null) {
174
            $this->_errorCode = -1;
175
            $this->_errorMessage = 'Message pattern is invalid.';
176
177
            return false;
178
        }
179 7
        $result = $formatter->parse($message);
180 7
        if ($result === false) {
181
            $this->_errorCode = $formatter->getErrorCode();
182
            $this->_errorMessage = $formatter->getErrorMessage();
183
184
            return false;
185
        } else {
186 7
            $values = [];
187 7
            foreach ($result as $key => $value) {
188 7
                $values[$map[$key]] = $value;
189
            }
190
191 7
            return $values;
192
        }
193
    }
194
195
    /**
196
     * Replace named placeholders with numeric placeholders and quote unused.
197
     *
198
     * @param string $pattern The pattern string to replace things into.
199
     * @param array $givenParams The array of values to insert into the format string.
200
     * @param array $resultingParams Modified array of parameters.
201
     * @param array $map
202
     * @return string The pattern string with placeholders replaced.
203
     */
204 148
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
205
    {
206 148
        if (($tokens = self::tokenizePattern($pattern)) === false) {
207
            return false;
208
        }
209 148
        foreach ($tokens as $i => $token) {
210 148
            if (!is_array($token)) {
211 148
                continue;
212
            }
213 148
            $param = trim($token[0]);
214 148
            if (isset($givenParams[$param])) {
215
                // if param is given, replace it with a number
216 145
                if (!isset($map[$param])) {
217 145
                    $map[$param] = count($map);
218
                    // make sure only used params are passed to format method
219 145
                    $resultingParams[$map[$param]] = $givenParams[$param];
220
                }
221 145
                $token[0] = $map[$param];
222 145
                $quote = '';
223
            } else {
224
                // quote unused token
225 4
                $quote = "'";
226
            }
227 148
            $type = isset($token[1]) ? trim($token[1]) : 'none';
228
            // replace plural and select format recursively
229 148
            if ($type === 'plural' || $type === 'select') {
230 135
                if (!isset($token[2])) {
231
                    return false;
232
                }
233 135
                if (($subtokens = self::tokenizePattern($token[2])) === false) {
234
                    return false;
235
                }
236 135
                $c = count($subtokens);
237 135
                for ($k = 0; $k + 1 < $c; $k++) {
238 135
                    if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
239
                        return false;
240
                    }
241 135
                    $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
242 135
                    $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
243
                }
244 135
                $token[2] = implode('', $subtokens);
245
            }
246 148
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
247
        }
248
249 148
        return implode('', $tokens);
250
    }
251
252
    /**
253
     * Fallback implementation for MessageFormatter::formatMessage
254
     * @param string $pattern The pattern string to insert things into.
255
     * @param array $args The array of values to insert into the format string
256
     * @param string $locale The locale to use for formatting locale-dependent parts
257
     * @return false|string The formatted pattern string or `false` if an error occurred
258
     */
259 18
    protected function fallbackFormat($pattern, $args, $locale)
260
    {
261 18
        if (($tokens = self::tokenizePattern($pattern)) === false) {
262
            $this->_errorCode = -1;
263
            $this->_errorMessage = 'Message pattern is invalid.';
264
265
            return false;
266
        }
267 18
        foreach ($tokens as $i => $token) {
268 18
            if (is_array($token)) {
269 18
                if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
270
                    $this->_errorCode = -1;
271
                    $this->_errorMessage = 'Message pattern is invalid.';
272
273
                    return false;
274
                }
275
            }
276
        }
277
278 16
        return implode('', $tokens);
279
    }
280
281
    /**
282
     * Tokenizes a pattern by separating normal text from replaceable patterns
283
     * @param string $pattern patter to tokenize
284
     * @return array|bool array of tokens or false on failure
285
     */
286 173
    private static function tokenizePattern($pattern)
287
    {
288 173
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
289 173
        $depth = 1;
290 173
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
291 137
            return [$pattern];
292
        }
293 173
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
294 173
        while (true) {
295 173
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
296 173
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
297 173
            if ($open === false && $close === false) {
298 173
                break;
299
            }
300 173
            if ($open === false) {
301 173
                $open = mb_strlen($pattern, $charset);
302
            }
303 173
            if ($close > $open) {
304 158
                $depth++;
305 158
                $pos = $open;
306
            } else {
307 173
                $depth--;
308 173
                $pos = $close;
309
            }
310 173
            if ($depth === 0) {
311 173
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
312 173
                $start = $pos + 1;
313 173
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
314 173
                $start = $open;
315
            }
316
        }
317 173
        if ($depth !== 0) {
318
            return false;
319
        }
320
321 173
        return $tokens;
322
    }
323
324
    /**
325
     * Parses a token
326
     * @param array $token the token to parse
327
     * @param array $args arguments to replace
328
     * @param string $locale the locale
329
     * @return bool|string parsed token or false on failure
330
     * @throws \yii\base\NotSupportedException when unsupported formatting is used.
331
     */
332 18
    private function parseToken($token, $args, $locale)
333
    {
334
        // parsing pattern based on ICU grammar:
335
        // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
336 18
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
337 18
        $param = trim($token[0]);
338 18
        if (isset($args[$param])) {
339 16
            $arg = $args[$param];
340
        } else {
341 3
            return '{' . implode(',', $token) . '}';
342
        }
343 16
        $type = isset($token[1]) ? trim($token[1]) : 'none';
344
        switch ($type) {
345 16
            case 'date':
346 16
            case 'time':
347 16
            case 'spellout':
348 16
            case 'ordinal':
349 16
            case 'duration':
350 16
            case 'choice':
351 16
            case 'selectordinal':
352
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
353 16
            case 'number':
354 9
                $format = isset($token[2]) ? trim($token[2]) : null;
355 9
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
356 7
                    $number = number_format($arg);
357 7
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
358
                        // add decimals with unknown length
359 1
                        $number .= '.' . substr($arg, $pos + 1);
360
                    }
361 7
                    return $number;
362
                }
363 2
                throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
364 10
            case 'none':
365 9
                return $arg;
366 6
            case 'select':
367
                /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html
368
                selectStyle = (selector '{' message '}')+
369
                */
370 5
                if (!isset($token[2])) {
371
                    return false;
372
                }
373 5
                $select = self::tokenizePattern($token[2]);
374 5
                $c = count($select);
375 5
                $message = false;
376 5
                for ($i = 0; $i + 1 < $c; $i++) {
377 5
                    if (is_array($select[$i]) || !is_array($select[$i + 1])) {
378
                        return false;
379
                    }
380 5
                    $selector = trim($select[$i++]);
381 5
                    if ($message === false && $selector === 'other' || $selector == $arg) {
382 5
                        $message = implode(',', $select[$i]);
383
                    }
384
                }
385 5
                if ($message !== false) {
386 5
                    return $this->fallbackFormat($message, $args, $locale);
387
                }
388
                break;
389 2
            case 'plural':
390
                /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html
391
                pluralStyle = [offsetValue] (selector '{' message '}')+
392
                offsetValue = "offset:" number
393
                selector = explicitValue | keyword
394
                explicitValue = '=' number  // adjacent, no white space in between
395
                keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
396
                message: see MessageFormat
397
                */
398 2
                if (!isset($token[2])) {
399
                    return false;
400
                }
401 2
                $plural = self::tokenizePattern($token[2]);
402 2
                $c = count($plural);
403 2
                $message = false;
404 2
                $offset = 0;
405 2
                for ($i = 0; $i + 1 < $c; $i++) {
406 2
                    if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
407
                        return false;
408
                    }
409 2
                    $selector = trim($plural[$i++]);
410
411 2
                    if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
412 1
                        $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
413 1
                        $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
414
                    }
415 2
                    if ($message === false && $selector === 'other' ||
416 2
                        $selector[0] === '=' && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
417 2
                        $selector === 'one' && $arg - $offset == 1
418
                    ) {
419 2
                        $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
420
                    }
421
                }
422 2
                if ($message !== false) {
423 2
                    return $this->fallbackFormat($message, $args, $locale);
424
                }
425
                break;
426
        }
427
428
        return false;
429
    }
430
}
431