MessageFormatter::parse()   B
last analyzed

Complexity

Conditions 9
Paths 18

Size

Total Lines 51
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 11.2532

Importance

Changes 1
Bugs 1 Features 1
Metric Value
cc 9
eloc 32
c 1
b 1
f 1
nc 18
nop 3
dl 0
loc 51
ccs 23
cts 33
cp 0.6969
crap 11.2532
rs 8.0555

How to fix   Long Method   

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
 * This code is derived from code in the Yii framework which stipulates this notice.
4
 *
5
 * The Yii framework is free software. It is released under the terms of the following BSD License.
6
 *
7
 * Copyright © 2008-2018 by Yii Software LLC, All rights reserved.
8
 *
9
 * Redistribution and use in source and binary forms, with or without modification, are permitted provided
10
 * that the following conditions are met:
11
 *
12
 * - Redistributions of source code must retain the above copyright notice, this list of
13
 *   conditions and the following disclaimer.
14
 * - Redistributions in binary form must reproduce the above copyright notice, this list of
15
 *   conditions and the following disclaimer in the documentation and/or other materials provided
16
 *   with the distribution.
17
 * - Neither the name of Yii Software LLC nor the names of its contributors may be used to endorse
18
 *   or promote products derived from this software without specific prior written permission.
19
 *
20
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
21
 * OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
22
 * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
23
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
26
 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
27
 * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 *
29
 * @link http://www.yiiframework.com/
30
 * @copyright Copyright (c) 2008 Yii Software LLC
31
 * @license http://www.yiiframework.com/license/
32
 */
33
34
namespace Xoops\Locale;
35
36
use Xoops\Core\Exception\NotSupportedException;
37
38
// keep original formatting to help diff against upstream
39
// phpcs:disable Generic.Files.LineLength, PSR2.Classes.PropertyDeclaration.Underscore
40
41
/**
42
 * MessageFormatter allows formatting messages via [ICU message format](http://userguide.icu-project.org/formatparse/messages).
43
 *
44
 * This class enhances the message formatter class provided by the PHP intl extension.
45
 *
46
 * The following enhancements are provided:
47
 *
48
 * - It accepts named arguments and mixed numeric and named arguments.
49
 * - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be
50
 *   substituted.
51
 * - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920).
52
 * - Offers limited support for message formatting in case PHP intl extension is not installed.
53
 *   However it is highly recommended that you install [PHP intl extension](http://php.net/manual/en/book.intl.php) if you want
54
 *   to use MessageFormatter features.
55
 *
56
 *   The fallback implementation only supports the following message formats:
57
 *   - plural formatting for english ('one' and 'other' selectors)
58
 *   - select format
59
 *   - simple parameters
60
 *   - integer number parameters
61
 *
62
 *   The fallback implementation does NOT support the ['apostrophe-friendly' syntax](http://www.php.net/manual/en/messageformatter.formatmessage.php).
63
 *   Also messages that are working with the fallback implementation are not necessarily compatible with the
64
 *   PHP intl MessageFormatter so do not rely on the fallback if you are able to install intl extension somehow.
65
 *
66
 * @property string $errorCode Code of the last error. This property is read-only.
67
 * @property string $errorMessage Description of the last error. This property is read-only.
68
 *
69
 * @author Alexander Makarov <[email protected]>
70
 * @author Carsten Brandt <[email protected]>
71
 * @since 2.0
72
 */
73
class MessageFormatter
74
{
75
    private $_errorCode = 0;
76
    private $_errorMessage = '';
77
78
79
    /**
80
     * Get the error code from the last operation.
81
     * @link http://php.net/manual/en/messageformatter.geterrorcode.php
82
     * @return string Code of the last error.
83
     */
84
    public function getErrorCode()
85
    {
86
        return $this->_errorCode;
87
    }
88
89
    /**
90
     * Get the error text from the last operation.
91
     * @link http://php.net/manual/en/messageformatter.geterrormessage.php
92
     * @return string Description of the last error.
93
     */
94 35
    public function getErrorMessage()
95
    {
96 35
        return $this->_errorMessage;
97
    }
98
99
    /**
100
     * Formats a message via [ICU message format](http://userguide.icu-project.org/formatparse/messages).
101
     *
102
     * It uses the PHP intl extension's [MessageFormatter](http://www.php.net/manual/en/class.messageformatter.php)
103
     * and works around some issues.
104
     * If PHP intl is not installed a fallback will be used that supports a subset of the ICU message format.
105
     *
106
     * @param string $pattern The pattern string to insert parameters into.
107
     * @param array $params The array of name value pairs to insert into the format string.
108
     * @param string $language The locale to use for formatting locale-dependent parts
109
     * @return string|false The formatted pattern string or `false` if an error occurred
110
     */
111 28
    public function format($pattern, $params, $language)
112
    {
113 28
        $this->_errorCode = 0;
114 28
        $this->_errorMessage = '';
115
116 28
        if ($params === []) {
117 4
            return $pattern;
118
        }
119
120 24
        if (!class_exists('MessageFormatter', false)) {
121
            return $this->fallbackFormat($pattern, $params, $language);
122
        }
123
124
        // replace named arguments (https://github.com/yiisoft/yii2/issues/9678)
125 24
        $newParams = [];
126 24
        $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
127 24
        $params = $newParams;
128
129
        try {
130 24
            $formatter = new \MessageFormatter($language, $pattern);
131
132 22
            if ($formatter === null) {
133
                // formatter may be null in PHP 5.x
134
                $this->_errorCode = intl_get_error_code();
135
                $this->_errorMessage = 'Message pattern is invalid: ' . intl_get_error_message();
136 22
                return false;
137
            }
138 2
        } catch (\IntlException $e) {
139
            // IntlException is thrown since PHP 7
140 2
            $this->_errorCode = $e->getCode();
141 2
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
142 2
            return false;
143
        } catch (\Exception $e) {
144
            // Exception is thrown by HHVM
145
            $this->_errorCode = $e->getCode();
146
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
147
            return false;
148
        }
149
150 22
        $result = $formatter->format($params);
151
152 22
        if ($result === false) {
153
            $this->_errorCode = $formatter->getErrorCode();
154
            $this->_errorMessage = $formatter->getErrorMessage();
155
            return false;
156
        }
157
158 22
        return $result;
159
    }
160
161
    /**
162
     * Parses an input string according to an [ICU message format](http://userguide.icu-project.org/formatparse/messages) pattern.
163
     *
164
     * It uses the PHP intl extension's [MessageFormatter::parse()](http://www.php.net/manual/en/messageformatter.parsemessage.php)
165
     * and adds support for named arguments.
166
     * Usage of this method requires PHP intl extension to be installed.
167
     *
168
     * @param string $pattern The pattern to use for parsing the message.
169
     * @param string $message The message to parse, conforming to the pattern.
170
     * @param string $language The locale to use for formatting locale-dependent parts
171
     * @return array|bool An array containing items extracted, or `FALSE` on error.
172
     * @throws \Xoops\Core\Exception\NotSupportedException when PHP intl extension is not installed.
173
     */
174 7
    public function parse($pattern, $message, $language)
175
    {
176 7
        $this->_errorCode = 0;
177 7
        $this->_errorMessage = '';
178
179 7
        if (!class_exists('MessageFormatter', false)) {
180
            throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
181
        }
182
183
        // replace named arguments
184 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...
185
            $this->_errorCode = -1;
186
            $this->_errorMessage = 'Message pattern is invalid.';
187
188
            return false;
189
        }
190 7
        $map = [];
191 7
        foreach ($tokens as $i => $token) {
192 7
            if (is_array($token)) {
193 7
                $param = trim($token[0]);
194 7
                if (!isset($map[$param])) {
195 7
                    $map[$param] = count($map);
196
                }
197 7
                $token[0] = $map[$param];
198 7
                $tokens[$i] = '{' . implode(',', $token) . '}';
199
            }
200
        }
201 7
        $pattern = implode('', $tokens);
202 7
        $map = array_flip($map);
203
204 7
        $formatter = new \MessageFormatter($language, $pattern);
205 7
        if ($formatter === null) {
206
            $this->_errorCode = -1;
207
            $this->_errorMessage = 'Message pattern is invalid.';
208
209
            return false;
210
        }
211 7
        $result = $formatter->parse($message);
212 7
        if ($result === false) {
213
            $this->_errorCode = $formatter->getErrorCode();
214
            $this->_errorMessage = $formatter->getErrorMessage();
215
216
            return false;
217
        }
218
219 7
        $values = [];
220 7
        foreach ($result as $key => $value) {
221 7
            $values[$map[$key]] = $value;
222
        }
223
224 7
        return $values;
225
    }
226
227
    /**
228
     * Replace named placeholders with numeric placeholders and quote unused.
229
     *
230
     * @param string $pattern The pattern string to replace things into.
231
     * @param array $givenParams The array of values to insert into the format string.
232
     * @param array $resultingParams Modified array of parameters.
233
     * @param array $map
234
     * @return string The pattern string with placeholders replaced.
235
     */
236 24
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
237
    {
238 24
        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...
239 1
            return false;
240
        }
241 23
        foreach ($tokens as $i => $token) {
242 23
            if (!is_array($token)) {
243 23
                continue;
244
            }
245 23
            $param = trim($token[0]);
246 23
            if (array_key_exists($param, $givenParams)) {
247
                // if param is given, replace it with a number
248 22
                if (!isset($map[$param])) {
249 22
                    $map[$param] = count($map);
250
                    // make sure only used params are passed to format method
251 22
                    $resultingParams[$map[$param]] = $givenParams[$param];
252
                }
253 22
                $token[0] = $map[$param];
254 22
                $quote = '';
255
            } else {
256
                // quote unused token
257 2
                $quote = "'";
258
            }
259 23
            $type = isset($token[1]) ? trim($token[1]) : 'none';
260
            // replace plural and select format recursively
261 23
            if ($type === 'plural' || $type === 'select') {
262 14
                if (!isset($token[2])) {
263
                    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...
264
                }
265 14
                if (($subtokens = self::tokenizePattern($token[2])) === false) {
266
                    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...
267
                }
268 14
                $c = count($subtokens);
269 14
                for ($k = 0; $k + 1 < $c; $k++) {
270 14
                    if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
271
                        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...
272
                    }
273 14
                    $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
274 14
                    $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
275
                }
276 14
                $token[2] = implode('', $subtokens);
277
            }
278 23
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
279
        }
280
281 23
        return implode('', $tokens);
282
    }
283
284
    /**
285
     * Fallback implementation for MessageFormatter::formatMessage.
286
     * @param string $pattern The pattern string to insert things into.
287
     * @param array $args The array of values to insert into the format string
288
     * @param string $locale The locale to use for formatting locale-dependent parts
289
     * @return false|string The formatted pattern string or `false` if an error occurred
290
     */
291
    protected function fallbackFormat($pattern, $args, $locale)
292
    {
293
        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...
294
            $this->_errorCode = -1;
295
            $this->_errorMessage = 'Message pattern is invalid.';
296
297
            return false;
298
        }
299
        foreach ($tokens as $i => $token) {
300
            if (is_array($token)) {
301
                if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
302
                    $this->_errorCode = -1;
303
                    $this->_errorMessage = 'Message pattern is invalid.';
304
305
                    return false;
306
                }
307
            }
308
        }
309
310
        return implode('', $tokens);
311
    }
312
313
    /**
314
     * Tokenizes a pattern by separating normal text from replaceable patterns.
315
     * @param string $pattern patter to tokenize
316
     * @return array|bool array of tokens or false on failure
317
     */
318 31
    private static function tokenizePattern($pattern)
319
    {
320 31
        $charset = \XoopsLocale::getCharset();
321 31
        $depth = 1;
322 31
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
323 13
            return [$pattern];
324
        }
325 31
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
326 31
        while (true) {
327 31
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
328 31
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
329 31
            if ($open === false && $close === false) {
330 30
                break;
331
            }
332 31
            if ($open === false) {
333 30
                $open = mb_strlen($pattern, $charset);
334
            }
335 31
            if ($close > $open) {
336 22
                $depth++;
337 22
                $pos = $open;
338
            } else {
339 31
                $depth--;
340 31
                $pos = $close;
341
            }
342 31
            if ($depth === 0) {
343 31
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
344 31
                $start = $pos + 1;
345 31
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
346 31
                $start = $open;
347
            }
348
349 31
            if ($depth !== 0 && ($open === false || $close === false)) {
350 1
                break;
351
            }
352
        }
353 31
        if ($depth !== 0) {
354 1
            return false;
355
        }
356
357 30
        return $tokens;
358
    }
359
360
    /**
361
     * Parses a token.
362
     * @param array $token the token to parse
363
     * @param array $args arguments to replace
364
     * @param string $locale the locale
365
     * @return bool|string parsed token or false on failure
366
     * @throws \Xoops\Core\Exception\NotSupportedException when unsupported formatting is used.
367
     */
368
    private function parseToken($token, $args, $locale)
369
    {
370
        // parsing pattern based on ICU grammar:
371
        // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
372
        $charset = \XoopsLocale::getCharset();
373
        $param = trim($token[0]);
374
        if (isset($args[$param])) {
375
            $arg = $args[$param];
376
        } else {
377
            return '{' . implode(',', $token) . '}';
378
        }
379
        $type = isset($token[1]) ? trim($token[1]) : 'none';
380
        switch ($type) {
381
            case 'date':
382
            case 'time':
383
            case 'spellout':
384
            case 'ordinal':
385
            case 'duration':
386
            case 'choice':
387
            case 'selectordinal':
388
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
389
            case 'number':
390
                $format = isset($token[2]) ? trim($token[2]) : null;
391
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
392
                    $number = number_format($arg);
393
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
394
                        // add decimals with unknown length
395
                        $number .= '.' . substr($arg, $pos + 1);
396
                    }
397
398
                    return $number;
399
                }
400
                throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
401
            case 'none':
402
                return $arg;
403
            case 'select':
404
                /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html
405
                selectStyle = (selector '{' message '}')+
406
                */
407
                if (!isset($token[2])) {
408
                    return false;
409
                }
410
                $select = self::tokenizePattern($token[2]);
411
                $c = count($select);
412
                $message = false;
413
                for ($i = 0; $i + 1 < $c; $i++) {
414
                    if (is_array($select[$i]) || !is_array($select[$i + 1])) {
415
                        return false;
416
                    }
417
                    $selector = trim($select[$i++]);
418
                    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...
419
                        $message = implode(',', $select[$i]);
420
                    }
421
                }
422
                if ($message !== false) {
0 ignored issues
show
introduced by
The condition $message !== false is always false.
Loading history...
423
                    return $this->fallbackFormat($message, $args, $locale);
424
                }
425
                break;
426
            case 'plural':
427
                /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html
428
                pluralStyle = [offsetValue] (selector '{' message '}')+
429
                offsetValue = "offset:" number
430
                selector = explicitValue | keyword
431
                explicitValue = '=' number  // adjacent, no white space in between
432
                keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
433
                message: see MessageFormat
434
                */
435
                if (!isset($token[2])) {
436
                    return false;
437
                }
438
                $plural = self::tokenizePattern($token[2]);
439
                $c = count($plural);
440
                $message = false;
441
                $offset = 0;
442
                for ($i = 0; $i + 1 < $c; $i++) {
443
                    if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
444
                        return false;
445
                    }
446
                    $selector = trim($plural[$i++]);
447
448
                    if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
449
                        $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
450
                        $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
451
                    }
452
                    if ($message === false && $selector === 'other' ||
453
                        $selector[0] === '=' && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
454
                        $selector === 'one' && $arg - $offset == 1
455
                    ) {
456
                        $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
457
                    }
458
                }
459
                if ($message !== false) {
460
                    return $this->fallbackFormat($message, $args, $locale);
461
                }
462
                break;
463
        }
464
465
        return false;
466
    }
467
}
468
// phpcs:enable
469