Completed
Push — 2.1 ( 21da0e...a39d12 )
by
unknown
10:45
created

MessageFormatter::replaceNamedArguments()   C

Complexity

Conditions 14
Paths 33

Size

Total Lines 47
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 14.2166

Importance

Changes 0
Metric Value
dl 0
loc 47
ccs 26
cts 29
cp 0.8966
rs 5.0622
c 0
b 0
f 0
cc 14
eloc 30
nc 33
nop 4
crap 14.2166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 49
    public function getErrorMessage()
68
    {
69 49
        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 155
    public function format($pattern, $params, $language)
85
    {
86 155
        $this->_errorCode = 0;
87 155
        $this->_errorMessage = '';
88
89 155
        if ($params === []) {
90 4
            return $pattern;
91
        }
92
93 151
        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 151
        $newParams = [];
99 151
        $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
100 151
        $params = $newParams;
101
102
        try {
103 151
            $formatter = new \MessageFormatter($language, $pattern);
104
105 149
            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 149
                return false;
110
            }
111 2
        } 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 2
            $this->_errorCode = $e->getCode();
114 2
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
115 2
            return false;
116
        }
117
118 149
        $result = $formatter->format($params);
119
120 149
        if ($result === false) {
121
            $this->_errorCode = $formatter->getErrorCode();
122
            $this->_errorMessage = $formatter->getErrorMessage();
123
            return false;
124
        }
125
126 149
        return $result;
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
        }
186
187 7
        $values = [];
188 7
        foreach ($result as $key => $value) {
189 7
            $values[$map[$key]] = $value;
190
        }
191
192 7
        return $values;
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 151
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
205
    {
206 151
        if (($tokens = self::tokenizePattern($pattern)) === false) {
207 1
            return false;
208
        }
209 150
        foreach ($tokens as $i => $token) {
210 150
            if (!is_array($token)) {
211 150
                continue;
212
            }
213 150
            $param = trim($token[0]);
214 150
            if (array_key_exists($param, $givenParams)) {
215
                // if param is given, replace it with a number
216 147
                if (!isset($map[$param])) {
217 147
                    $map[$param] = count($map);
218
                    // make sure only used params are passed to format method
219 147
                    $resultingParams[$map[$param]] = $givenParams[$param];
220
                }
221 147
                $token[0] = $map[$param];
222 147
                $quote = '';
223
            } else {
224
                // quote unused token
225 4
                $quote = "'";
226
            }
227 150
            $type = isset($token[1]) ? trim($token[1]) : 'none';
228
            // replace plural and select format recursively
229 150
            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 150
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
247
        }
248
249 150
        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 18
                    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 176
    private static function tokenizePattern($pattern)
287
    {
288 176
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
289 176
        $depth = 1;
290 176
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
291 137
            return [$pattern];
292
        }
293 176
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
294 176
        while (true) {
295 176
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
296 176
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
297 176
            if ($open === false && $close === false) {
298 175
                break;
299
            }
300 176
            if ($open === false) {
301 175
                $open = mb_strlen($pattern, $charset);
302
            }
303 176
            if ($close > $open) {
304 158
                $depth++;
305 158
                $pos = $open;
306
            } else {
307 176
                $depth--;
308 176
                $pos = $close;
309
            }
310 176
            if ($depth === 0) {
311 176
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
312 176
                $start = $pos + 1;
313 176
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
314 176
                $start = $open;
315
            }
316
317 176
            if ($depth !== 0 && ($open === false || $close === false)) {
318 1
                break;
319
            }
320
        }
321 176
        if ($depth !== 0) {
322 1
            return false;
323
        }
324
325 175
        return $tokens;
326
    }
327
328
    /**
329
     * Parses a token.
330
     * @param array $token the token to parse
331
     * @param array $args arguments to replace
332
     * @param string $locale the locale
333
     * @return bool|string parsed token or false on failure
334
     * @throws \yii\base\NotSupportedException when unsupported formatting is used.
335
     */
336 18
    private function parseToken($token, $args, $locale)
337
    {
338
        // parsing pattern based on ICU grammar:
339
        // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
340 18
        $charset = Yii::$app ? Yii::$app->charset : 'UTF-8';
341 18
        $param = trim($token[0]);
342 18
        if (isset($args[$param])) {
343 16
            $arg = $args[$param];
344
        } else {
345 3
            return '{' . implode(',', $token) . '}';
346
        }
347 16
        $type = isset($token[1]) ? trim($token[1]) : 'none';
348
        switch ($type) {
349 16
            case 'date':
350 16
            case 'time':
351 16
            case 'spellout':
352 16
            case 'ordinal':
353 16
            case 'duration':
354 16
            case 'choice':
355 16
            case 'selectordinal':
356
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
357 16
            case 'number':
358 9
                $format = isset($token[2]) ? trim($token[2]) : null;
359 9
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
360 7
                    $number = number_format($arg);
361 7
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
362
                        // add decimals with unknown length
363 1
                        $number .= '.' . substr($arg, $pos + 1);
364
                    }
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