Completed
Pull Request — master (#563)
by Richard
08:33
created

MessageFormatter::format()   C

Complexity

Conditions 7
Paths 12

Size

Total Lines 47
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
cc 7
eloc 30
nc 12
nop 3
dl 0
loc 47
rs 6.7272
c 0
b 0
f 0
ccs 0
cts 29
cp 0
crap 56
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) {
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) {
0 ignored issues
show
introduced by
The condition $result === false can never be true.
Loading history...
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
        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) {
0 ignored issues
show
introduced by
The condition $result === false can never be true.
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $subtokens can also be of type true; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

239
                $c = count(/** @scrutinizer ignore-type */ $subtokens);
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $subtokens can also be of type true; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

247
                $token[2] = implode('', /** @scrutinizer ignore-type */ $subtokens);
Loading history...
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
        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) {
0 ignored issues
show
introduced by
The condition $start = $pos = mb_strpo... 0, $charset) === false can never be true.
Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $select can also be of type boolean; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

377
                $c = count(/** @scrutinizer ignore-type */ $select);
Loading history...
378
                $message = false;
379
                for ($i = 0; $i + 1 < $c; $i++) {
380
                    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
                    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