Completed
Push — master ( a47eb4...e4e632 )
by Richard
09:13
created

MessageFormatter::fallbackFormat()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 21
Code Lines 12

Duplication

Lines 6
Ratio 28.57 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 5
nop 3
dl 6
loc 21
rs 8.7624
c 0
b 0
f 0
ccs 0
cts 18
cp 0
crap 30
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 Xoops\Locale;
9
10
use Xoops\Core\Exception\NotSupportedException;
11
12
/**
13
 * MessageFormatter allows formatting messages via [ICU message format](http://userguide.icu-project.org/formatparse/messages)
14
 *
15
 * This class enhances the message formatter class provided by the PHP intl extension.
16
 *
17
 * The following enhancements are provided:
18
 *
19
 * - It accepts named arguments and mixed numeric and named arguments.
20
 * - Issues no error when an insufficient number of arguments have been provided. Instead, the placeholders will not be
21
 *   substituted.
22
 * - Fixes PHP 5.5 weird placeholder replacement in case no arguments are provided at all (https://bugs.php.net/bug.php?id=65920).
23
 * - Offers limited support for message formatting in case PHP intl extension is not installed.
24
 *   However it is highly recommended that you install [PHP intl extension](http://php.net/manual/en/book.intl.php) if you want
25
 *   to use MessageFormatter features.
26
 *
27
 *   The fallback implementation only supports the following message formats:
28
 *   - plural formatting for english ('one' and 'other' selectors)
29
 *   - select format
30
 *   - simple parameters
31
 *   - integer number parameters
32
 *
33
 *   The fallback implementation does NOT support the ['apostrophe-friendly' syntax](http://www.php.net/manual/en/messageformatter.formatmessage.php).
34
 *   Also messages that are working with the fallback implementation are not necessarily compatible with the
35
 *   PHP intl MessageFormatter so do not rely on the fallback if you are able to install intl extension somehow.
36
 *
37
 * @property string $errorCode Code of the last error. This property is read-only.
38
 * @property string $errorMessage Description of the last error. This property is read-only.
39
 *
40
 * @author Alexander Makarov <[email protected]>
41
 * @author Carsten Brandt <[email protected]>
42
 * @since 2.0
43
 */
44
class MessageFormatter
45
{
46
    private $_errorCode = 0;
47
    private $_errorMessage = '';
48
49
50
    /**
51
     * Get the error code from the last operation
52
     * @link http://php.net/manual/en/messageformatter.geterrorcode.php
53
     * @return string Code of the last error.
54
     */
55
    public function getErrorCode()
56
    {
57
        return $this->_errorCode;
58
    }
59
60
    /**
61
     * Get the error text from the last operation
62
     * @link http://php.net/manual/en/messageformatter.geterrormessage.php
63
     * @return string Description of the last error.
64
     */
65
    public function getErrorMessage()
66
    {
67
        return $this->_errorMessage;
68
    }
69
70
    /**
71
     * Formats a message via [ICU message format](http://userguide.icu-project.org/formatparse/messages)
72
     *
73
     * It uses the PHP intl extension's [MessageFormatter](http://www.php.net/manual/en/class.messageformatter.php)
74
     * and works around some issues.
75
     * If PHP intl is not installed a fallback will be used that supports a subset of the ICU message format.
76
     *
77
     * @param string $pattern The pattern string to insert parameters into.
78
     * @param array $params The array of name value pairs to insert into the format string.
79
     * @param string $language The locale to use for formatting locale-dependent parts
80
     * @return string|false The formatted pattern string or `false` if an error occurred
81
     */
82
    public function format($pattern, $params, $language)
83
    {
84
        $this->_errorCode = 0;
85
        $this->_errorMessage = '';
86
87
        if ($params === []) {
88
            return $pattern;
89
        }
90
91
        if (!class_exists('MessageFormatter', false)) {
92
            return $this->fallbackFormat($pattern, $params, $language);
93
        }
94
95
        // replace named arguments (https://github.com/yiisoft/yii2/issues/9678)
96
        $newParams = [];
97
        $pattern = $this->replaceNamedArguments($pattern, $params, $newParams);
98
        $params = $newParams;
99
100
        try {
101
            $formatter = new \MessageFormatter($language, $pattern);
102
103
            if ($formatter === null) {
104
                // formatter may be null in PHP 5.x
105
                $this->_errorCode = intl_get_error_code();
106
                $this->_errorMessage = 'Message pattern is invalid: ' . intl_get_error_message();
107
                return false;
108
            }
109
        } 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...
110
            // IntlException is thrown since PHP 7
111
            $this->_errorCode = $e->getCode();
112
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
113
            return false;
114
        } catch (\Exception $e) {
115
            // Exception is thrown by HHVM
116
            $this->_errorCode = $e->getCode();
117
            $this->_errorMessage = 'Message pattern is invalid: ' . $e->getMessage();
118
            return false;
119
        }
120
121
        $result = $formatter->format($params);
122
123
        if ($result === false) {
124
            $this->_errorCode = $formatter->getErrorCode();
125
            $this->_errorMessage = $formatter->getErrorMessage();
126
            return false;
127
        } else {
128
            return $result;
129
        }
130
    }
131
132
    /**
133
     * Parses an input string according to an [ICU message format](http://userguide.icu-project.org/formatparse/messages) pattern.
134
     *
135
     * It uses the PHP intl extension's [MessageFormatter::parse()](http://www.php.net/manual/en/messageformatter.parsemessage.php)
136
     * and adds support for named arguments.
137
     * Usage of this method requires PHP intl extension to be installed.
138
     *
139
     * @param string $pattern The pattern to use for parsing the message.
140
     * @param string $message The message to parse, conforming to the pattern.
141
     * @param string $language The locale to use for formatting locale-dependent parts
142
     * @return array|bool An array containing items extracted, or `FALSE` on error.
143
     * @throws \Xoops\Core\Exception\NotSupportedException when PHP intl extension is not installed.
144
     */
145
    public function parse($pattern, $message, $language)
146
    {
147
        $this->_errorCode = 0;
148
        $this->_errorMessage = '';
149
150
        if (!class_exists('MessageFormatter', false)) {
151
            throw new NotSupportedException('You have to install PHP intl extension to use this feature.');
152
        }
153
154
        // replace named arguments
155 View Code Duplication
        if (($tokens = self::tokenizePattern($pattern)) === false) {
156
            $this->_errorCode = -1;
157
            $this->_errorMessage = 'Message pattern is invalid.';
158
159
            return false;
160
        }
161
        $map = [];
162
        foreach ($tokens as $i => $token) {
163
            if (is_array($token)) {
164
                $param = trim($token[0]);
165
                if (!isset($map[$param])) {
166
                    $map[$param] = count($map);
167
                }
168
                $token[0] = $map[$param];
169
                $tokens[$i] = '{' . implode(',', $token) . '}';
170
            }
171
        }
172
        $pattern = implode('', $tokens);
173
        $map = array_flip($map);
174
175
        $formatter = new \MessageFormatter($language, $pattern);
176
        if ($formatter === null) {
177
            $this->_errorCode = -1;
178
            $this->_errorMessage = 'Message pattern is invalid.';
179
180
            return false;
181
        }
182
        $result = $formatter->parse($message);
183
        if ($result === false) {
184
            $this->_errorCode = $formatter->getErrorCode();
185
            $this->_errorMessage = $formatter->getErrorMessage();
186
187
            return false;
188
        } else {
189
            $values = [];
190
            foreach ($result as $key => $value) {
191
                $values[$map[$key]] = $value;
192
            }
193
194
            return $values;
195
        }
196
    }
197
198
    /**
199
     * Replace named placeholders with numeric placeholders and quote unused.
200
     *
201
     * @param string $pattern The pattern string to replace things into.
202
     * @param array $givenParams The array of values to insert into the format string.
203
     * @param array $resultingParams Modified array of parameters.
204
     * @param array $map
205
     * @return string The pattern string with placeholders replaced.
206
     */
207
    private function replaceNamedArguments($pattern, $givenParams, &$resultingParams = [], &$map = [])
208
    {
209
        if (($tokens = self::tokenizePattern($pattern)) === false) {
210
            return false;
211
        }
212
        foreach ($tokens as $i => $token) {
213
            if (!is_array($token)) {
214
                continue;
215
            }
216
            $param = trim($token[0]);
217
            if (isset($givenParams[$param])) {
218
                // if param is given, replace it with a number
219
                if (!isset($map[$param])) {
220
                    $map[$param] = count($map);
221
                    // make sure only used params are passed to format method
222
                    $resultingParams[$map[$param]] = $givenParams[$param];
223
                }
224
                $token[0] = $map[$param];
225
                $quote = '';
226
            } else {
227
                // quote unused token
228
                $quote = "'";
229
            }
230
            $type = isset($token[1]) ? trim($token[1]) : 'none';
231
            // replace plural and select format recursively
232
            if ($type === 'plural' || $type === 'select') {
233
                if (!isset($token[2])) {
234
                    return false;
235
                }
236
                if (($subtokens = self::tokenizePattern($token[2])) === false) {
237
                    return false;
238
                }
239
                $c = count($subtokens);
240
                for ($k = 0; $k + 1 < $c; $k++) {
241
                    if (is_array($subtokens[$k]) || !is_array($subtokens[++$k])) {
242
                        return false;
243
                    }
244
                    $subpattern = $this->replaceNamedArguments(implode(',', $subtokens[$k]), $givenParams, $resultingParams, $map);
245
                    $subtokens[$k] = $quote . '{' . $quote . $subpattern . $quote . '}' . $quote;
246
                }
247
                $token[2] = implode('', $subtokens);
248
            }
249
            $tokens[$i] = $quote . '{' . $quote . implode(',', $token) . $quote . '}' . $quote;
250
        }
251
252
        return implode('', $tokens);
253
    }
254
255
    /**
256
     * Fallback implementation for MessageFormatter::formatMessage
257
     * @param string $pattern The pattern string to insert things into.
258
     * @param array $args The array of values to insert into the format string
259
     * @param string $locale The locale to use for formatting locale-dependent parts
260
     * @return false|string The formatted pattern string or `false` if an error occurred
261
     */
262
    protected function fallbackFormat($pattern, $args, $locale)
263
    {
264 View Code Duplication
        if (($tokens = self::tokenizePattern($pattern)) === false) {
265
            $this->_errorCode = -1;
266
            $this->_errorMessage = 'Message pattern is invalid.';
267
268
            return false;
269
        }
270
        foreach ($tokens as $i => $token) {
271
            if (is_array($token)) {
272
                if (($tokens[$i] = $this->parseToken($token, $args, $locale)) === false) {
273
                    $this->_errorCode = -1;
274
                    $this->_errorMessage = 'Message pattern is invalid.';
275
276
                    return false;
277
                }
278
            }
279
        }
280
281
        return implode('', $tokens);
282
    }
283
284
    /**
285
     * Tokenizes a pattern by separating normal text from replaceable patterns
286
     * @param string $pattern patter to tokenize
287
     * @return array|bool array of tokens or false on failure
288
     */
289
    private static function tokenizePattern($pattern)
290
    {
291
        $charset = \XoopsLocale::getCharset();
292
        $depth = 1;
293
        if (($start = $pos = mb_strpos($pattern, '{', 0, $charset)) === false) {
294
            return [$pattern];
295
        }
296
        $tokens = [mb_substr($pattern, 0, $pos, $charset)];
297
        while (true) {
298
            $open = mb_strpos($pattern, '{', $pos + 1, $charset);
299
            $close = mb_strpos($pattern, '}', $pos + 1, $charset);
300
            if ($open === false && $close === false) {
301
                break;
302
            }
303
            if ($open === false) {
304
                $open = mb_strlen($pattern, $charset);
305
            }
306
            if ($close > $open) {
307
                $depth++;
308
                $pos = $open;
309
            } else {
310
                $depth--;
311
                $pos = $close;
312
            }
313
            if ($depth === 0) {
314
                $tokens[] = explode(',', mb_substr($pattern, $start + 1, $pos - $start - 1, $charset), 3);
315
                $start = $pos + 1;
316
                $tokens[] = mb_substr($pattern, $start, $open - $start, $charset);
317
                $start = $open;
318
            }
319
        }
320
        if ($depth !== 0) {
321
            return false;
322
        }
323
324
        return $tokens;
325
    }
326
327
    /**
328
     * Parses a token
329
     * @param array $token the token to parse
330
     * @param array $args arguments to replace
331
     * @param string $locale the locale
332
     * @return bool|string parsed token or false on failure
333
     * @throws \Xoops\Core\Exception\NotSupportedException when unsupported formatting is used.
334
     */
335
    private function parseToken($token, $args, $locale)
336
    {
337
        // parsing pattern based on ICU grammar:
338
        // http://icu-project.org/apiref/icu4c/classMessageFormat.html#details
339
        $charset = \XoopsLocale::getCharset();
340
        $param = trim($token[0]);
341
        if (isset($args[$param])) {
342
            $arg = $args[$param];
343
        } else {
344
            return '{' . implode(',', $token) . '}';
345
        }
346
        $type = isset($token[1]) ? trim($token[1]) : 'none';
347
        switch ($type) {
348
            case 'date':
349
            case 'time':
350
            case 'spellout':
351
            case 'ordinal':
352
            case 'duration':
353
            case 'choice':
354
            case 'selectordinal':
355
                throw new NotSupportedException("Message format '$type' is not supported. You have to install PHP intl extension to use this feature.");
356
            case 'number':
357
                $format = isset($token[2]) ? trim($token[2]) : null;
358
                if (is_numeric($arg) && ($format === null || $format === 'integer')) {
359
                    $number = number_format($arg);
360
                    if ($format === null && ($pos = strpos($arg, '.')) !== false) {
361
                        // add decimals with unknown length
362
                        $number .= '.' . substr($arg, $pos + 1);
363
                    }
364
                    return $number;
365
                }
366
                throw new NotSupportedException("Message format 'number' is only supported for integer values. You have to install PHP intl extension to use this feature.");
367
            case 'none':
368
                return $arg;
369
            case 'select':
370
                /* http://icu-project.org/apiref/icu4c/classicu_1_1SelectFormat.html
371
                selectStyle = (selector '{' message '}')+
372
                */
373
                if (!isset($token[2])) {
374
                    return false;
375
                }
376
                $select = self::tokenizePattern($token[2]);
377
                $c = count($select);
378
                $message = false;
379
                for ($i = 0; $i + 1 < $c; $i++) {
380 View Code Duplication
                    if (is_array($select[$i]) || !is_array($select[$i + 1])) {
381
                        return false;
382
                    }
383
                    $selector = trim($select[$i++]);
384
                    if ($message === false && $selector === 'other' || $selector == $arg) {
385
                        $message = implode(',', $select[$i]);
386
                    }
387
                }
388
                if ($message !== false) {
389
                    return $this->fallbackFormat($message, $args, $locale);
390
                }
391
                break;
392
            case 'plural':
393
                /* http://icu-project.org/apiref/icu4c/classicu_1_1PluralFormat.html
394
                pluralStyle = [offsetValue] (selector '{' message '}')+
395
                offsetValue = "offset:" number
396
                selector = explicitValue | keyword
397
                explicitValue = '=' number  // adjacent, no white space in between
398
                keyword = [^[[:Pattern_Syntax:][:Pattern_White_Space:]]]+
399
                message: see MessageFormat
400
                */
401
                if (!isset($token[2])) {
402
                    return false;
403
                }
404
                $plural = self::tokenizePattern($token[2]);
405
                $c = count($plural);
406
                $message = false;
407
                $offset = 0;
408
                for ($i = 0; $i + 1 < $c; $i++) {
409 View Code Duplication
                    if (is_array($plural[$i]) || !is_array($plural[$i + 1])) {
410
                        return false;
411
                    }
412
                    $selector = trim($plural[$i++]);
413
414
                    if ($i == 1 && strncmp($selector, 'offset:', 7) === 0) {
415
                        $offset = (int) trim(mb_substr($selector, 7, ($pos = mb_strpos(str_replace(["\n", "\r", "\t"], ' ', $selector), ' ', 7, $charset)) - 7, $charset));
416
                        $selector = trim(mb_substr($selector, $pos + 1, mb_strlen($selector, $charset), $charset));
417
                    }
418
                    if ($message === false && $selector === 'other' ||
419
                        $selector[0] === '=' && (int) mb_substr($selector, 1, mb_strlen($selector, $charset), $charset) === $arg ||
420
                        $selector === 'one' && $arg - $offset == 1
421
                    ) {
422
                        $message = implode(',', str_replace('#', $arg - $offset, $plural[$i]));
423
                    }
424
                }
425
                if ($message !== false) {
426
                    return $this->fallbackFormat($message, $args, $locale);
427
                }
428
                break;
429
        }
430
431
        return false;
432
    }
433
}
434