Completed
Push — php7-travis-apcu ( 9bbcee...fd63c3 )
by Alexander
14:47
created

MessageFormatter::format()   C

Complexity

Conditions 7
Paths 11

Size

Total Lines 49
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 9.0086

Importance

Changes 0
Metric Value
dl 0
loc 49
ccs 19
cts 29
cp 0.6552
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 30
nc 11
nop 3
crap 9.0086
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) {
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
        } 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