MessageFormatter::parseToken()   D
last analyzed

Complexity

Conditions 41
Paths 47

Size

Total Lines 98
Code Lines 63

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 1722

Importance

Changes 1
Bugs 1 Features 1
Metric Value
cc 41
eloc 63
c 1
b 1
f 1
nc 47
nop 3
dl 0
loc 98
ccs 0
cts 63
cp 0
crap 1722
rs 4.1666

How to fix   Long Method    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
 * 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