MessageFormatter   F
last analyzed

Complexity

Total Complexity 92

Size/Duplication

Total Lines 394
Duplicated Lines 0 %

Test Coverage

Coverage 80.2%

Importance

Changes 0
Metric Value
eloc 198
dl 0
loc 394
ccs 158
cts 197
cp 0.802
rs 2
c 0
b 0
f 0
wmc 92

8 Methods

Rating   Name   Duplication   Size   Complexity  
A getErrorCode() 0 3 1
C replaceNamedArguments() 0 46 14
B parse() 0 51 9
B format() 0 48 7
A getErrorMessage() 0 3 1
C tokenizePattern() 0 40 13
A fallbackFormat() 0 20 5
D parseToken() 0 99 42

How to fix   Complexity   

Complex Class

Complex classes like MessageFormatter often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MessageFormatter, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://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](https://unicode-org.github.io/icu/userguide/format_parse/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](https://www.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](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.
38
 *
39
 * @property-read string $errorCode Code of the last error.
40
 * @property-read string $errorMessage Description of the last error.
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 https://www.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 https://www.php.net/manual/en/messageformatter.geterrormessage.php
65
     * @return string Description of the last error.
66
     */
67 49
    public function getErrorMessage()
68
    {
69 49
        return $this->_errorMessage;
70
    }
71
72
    /**
73
     * Formats a message via [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/).
74
     *
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.
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 151
    public function format($pattern, $params, $language)
85
    {
86 151
        $this->_errorCode = 0;
87 151
        $this->_errorMessage = '';
88
89 151
        if ($params === []) {
90 4
            return $pattern;
91
        }
92
93 147
        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 147
        $newParams = [];
99 147
        $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
100 147
        $params = $newParams;
101
102
        try {
103 147
            $formatter = new \MessageFormatter($language, $pattern);
104
105 145
            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 145
                return false;
110
            }
111 2
        } catch (\IntlException $e) {
112
            // IntlException is thrown since PHP 7
113 2
            $this->_errorCode = $e->getCode();
114 2
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
115 2
            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 145
        $result = $formatter->format($params);
124
125 145
        if ($result === false) {
126
            $this->_errorCode = $formatter->getErrorCode();
127
            $this->_errorMessage = $formatter->getErrorMessage();
128
            return false;
129
        }
130
131 145
        return $result;
132
    }
133
134
    /**
135
     * Parses an input string according to an [ICU message format](https://unicode-org.github.io/icu/userguide/format_parse/messages/) pattern.
136
     *
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.
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) {
0 ignored issues
show
introduced by
The condition $tokens = self::tokenize...ern($pattern) === false is always false.
Loading history...
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 147
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
210
    {
211 147
        if (($tokens = self::tokenizePattern($pattern)) === false) {
0 ignored issues
show
introduced by
The condition $tokens = self::tokenize...ern($pattern) === false is always false.
Loading history...
212 1
            return false;
213
        }
214 146
        foreach ($tokens as $i => $token) {
215 146
            if (!is_array($token)) {
216 146
                continue;
217
            }
218 146
            $param = trim($token[0]);
219 146
            if (array_key_exists($param, $givenParams)) {
220
                // if param is given, replace it with a number
221 144
                if (!isset($map[$param])) {
222 144
                    $map[$param] = count($map);
223
                    // make sure only used params are passed to format method
224 144
                    $resultingParams[$map[$param]] = $givenParams[$param];
225
                }
226 144
                $token[0] = $map[$param];
227 144
                $quote = '';
228
            } else {
229
                // quote unused token
230 3
                $quote = "'";
231
            }
232 146
            $type = isset($token[1]) ? trim($token[1]) : 'none';
233
            // replace plural and select format recursively
234 146
            if ($type === 'plural' || $type === 'select') {
235 134
                if (!isset($token[2])) {
236
                    return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
237
                }
238 134
                if (($subtokens = self::tokenizePattern($token[2])) === false) {
239
                    return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
240
                }
241 134
                $c = count($subtokens);
242 134
                for ($k = 0; $k + 1 < $c; $k++) {
243 134
                    if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
244
                        return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
245
                    }
246 134
                    $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
247 134
                    $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
248
                }
249 134
                $token[2] = implode('', $subtokens);
250
            }
251 146
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
252
        }
253
254 146
        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) {
0 ignored issues
show
introduced by
The condition $tokens = self::tokenize...ern($pattern) === false is always false.
Loading history...
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
                    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 172
    private static function tokenizePattern($pattern)
292
    {
293 172
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
294 172
        $depth = 1;
295 172
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
296 136
            return [$pattern];
297
        }
298 172
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
299 172
        while (true) {
300 172
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
301 172
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
302 172
            if ($open === false && $close === false) {
303 171
                break;
304
            }
305 172
            if ($open === false) {
306 171
                $open = mb_strlen($pattern, $charset);
307
            }
308 172
            if ($close > $open) {
309 155
                $depth++;
310 155
                $pos = $open;
311
            } else {
312 172
                $depth--;
313 172
                $pos = $close;
314
            }
315 172
            if ($depth === 0) {
316 172
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
317 172
                $start = $pos + 1;
318 172
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
319 172
                $start = $open;
320
            }
321
322 172
            if ($depth !== 0 && ($open === false || $close === false)) {
323 1
                break;
324
            }
325
        }
326 172
        if ($depth !== 0) {
327 1
            return false;
328
        }
329
330 171
        return $tokens;
331
    }
332
333
    /**
334
     * Parses a token.
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.
340
     */
341 18
    private function parseToken($token, $args, $locale)
342
    {
343
        // parsing pattern based on ICU grammar:
344
        // https://unicode-org.github.io/icu-docs/#/icu4c/classMessageFormat.html
345 18
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
346 18
        $param = trim($token[0]);
347 18
        if (isset($args[$param])) {
348 16
            $arg = $args[$param];
349
        } else {
350 3
            return '{' . implode(',', $token) . '}';
351
        }
352 16
        $type = isset($token[1]) ? trim($token[1]) : 'none';
353
        switch ($type) {
354 16
            case 'date':
355 16
            case 'time':
356 16
            case 'spellout':
357 16
            case 'ordinal':
358 16
            case 'duration':
359 16
            case 'choice':
360 16
            case 'selectordinal':
361
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
362 16
            case 'number':
363 9
                $format = isset($token[2]) ? trim($token[2]) : null;
364 9
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
365 7
                    $number = number_format($arg);
366 7
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
367
                        // add decimals with unknown length
368 1
                        $number .= '.' . substr($arg, $pos + 1);
369
                    }
370
371 7
                    return $number;
372
                }
373 2
                throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
374 10
            case 'none':
375 9
                return $arg;
376 6
            case 'select':
377
                /* https://unicode-org.github.io/icu-docs/#/icu4c/classicu_1_1SelectFormat.html
378
                selectStyle = (selector '{' message '}')+
379
                */
380 5
                if (!isset($token[2])) {
381
                    return false;
382
                }
383 5
                $select = self::tokenizePattern($token[2]);
384 5
                $c = count($select);
385 5
                $message = false;
386 5
                for ($i = 0; $i + 1 < $c; $i++) {
387 5
                    if (is_array($select[$i]) || !is_array($select[$i + 1])) {
388
                        return false;
389
                    }
390 5
                    $selector = trim($select[$i++]);
391 5
                    if ($message === false && $selector === 'other' || $selector == $arg) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($message === false && $...') || $selector == $arg, Probably Intended Meaning: $message === false && ($...' || $selector == $arg)
Loading history...
392 5
                        $message = implode(',', $select[$i]);
393
                    }
394
                }
395 5
                if ($message !== false) {
0 ignored issues
show
introduced by
The condition $message !== false is always false.
Loading history...
396 5
                    return $this->fallbackFormat($message, $args, $locale);
397
                }
398
                break;
399 2
            case 'plural':
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
407
                */
408 2
                if (!isset($token[2])) {
409
                    return false;
410
                }
411 2
                $plural = self::tokenizePattern($token[2]);
412 2
                $c = count($plural);
413 2
                $message = false;
414 2
                $offset = 0;
415 2
                for ($i = 0; $i + 1 < $c; $i++) {
416 2
                    if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
417
                        return false;
418
                    }
419 2
                    $selector = trim($plural[$i++]);
420
421 2
                    if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
422 1
                        $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
423 1
                        $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
424
                    }
425
                    if (
426 2
                        $message === false && $selector === 'other' ||
427 2
                        strncmp($selector, '=', 1) === 0 && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
428 2
                        $selector === 'one' && $arg - $offset == 1
429
                    ) {
430 2
                        $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
431
                    }
432
                }
433 2
                if ($message !== false) {
434 2
                    return $this->fallbackFormat($message, $args, $locale);
435
                }
436
                break;
437
        }
438
439
        return false;
440
    }
441
}
442