Passed
Pull Request — master (#42)
by
unknown
12:48
created

Translator::getPluralForms()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 6
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 15
rs 10
ccs 6
cts 6
cp 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
    Copyright (c) 2003, 2009 Danilo Segan <[email protected]>.
7
    Copyright (c) 2005 Nico Kaiser <[email protected]>
8
    Copyright (c) 2016 Michal Čihař <[email protected]>
9
10
    This file is part of MoTranslator.
11
12
    This program is free software; you can redistribute it and/or modify
13
    it under the terms of the GNU General Public License as published by
14
    the Free Software Foundation; either version 2 of the License, or
15
    (at your option) any later version.
16
17
    This program is distributed in the hope that it will be useful,
18
    but WITHOUT ANY WARRANTY; without even the implied warranty of
19
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20
    GNU General Public License for more details.
21
22
    You should have received a copy of the GNU General Public License along
23
    with this program; if not, write to the Free Software Foundation, Inc.,
24
    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
25
*/
26
27
namespace PhpMyAdmin\MoTranslator;
28
29
use PhpMyAdmin\MoTranslator\Cache\CacheInterface;
30
use PhpMyAdmin\MoTranslator\Cache\GetAllInterface;
31
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
32
use Throwable;
33
34
use function chr;
35
use function count;
36
use function explode;
37
use function get_class;
38
use function implode;
39
use function intval;
40
use function ltrim;
41
use function preg_replace;
42
use function rtrim;
43
use function sprintf;
44
use function stripos;
45
use function strpos;
46
use function strtolower;
47
use function substr;
48
use function trim;
49
50
/**
51
 * Provides a simple gettext replacement that works independently from
52
 * the system's gettext abilities.
53
 * It can read MO files and use them for translating strings.
54
 *
55
 * It caches ll strings and translations to speed up the string lookup.
56
 */
57
class Translator
58
{
59
    /**
60
     * None error.
61
     */
62
    public const ERROR_NONE = 0;
63
    /**
64
     * File does not exist.
65
     */
66
    public const ERROR_DOES_NOT_EXIST = 1;
67
    /**
68
     * File has bad magic number.
69
     */
70
    public const ERROR_BAD_MAGIC = 2;
71
    /**
72
     * Error while reading file, probably too short.
73
     */
74
    public const ERROR_READING = 3;
75
76
    /**
77
     * Big endian mo file magic bytes.
78
     */
79
    public const MAGIC_BE = "\x95\x04\x12\xde";
80
    /**
81
     * Little endian mo file magic bytes.
82
     */
83
    public const MAGIC_LE = "\xde\x12\x04\x95";
84
85
    /**
86
     * Parse error code (0 if no error).
87
     *
88
     * @var int
89
     */
90
    public $error = self::ERROR_NONE;
91
92
    /**
93
     * Cache header field for plural forms.
94
     *
95
     * @var string|null
96
     */
97
    private $pluralEquation = null;
98
99
    /** @var ExpressionLanguage|null Evaluator for plurals */
100
    private $pluralExpression = null;
101
102
    /** @var int|null number of plurals */
103
    private $pluralCount = null;
104
105
    /** @var CacheInterface */
106
    private $cache;
107
108
    public function __construct(CacheInterface $cache)
109
    {
110
        $this->cache = $cache;
111
    }
112
113
    /**
114 220
     * Translates a string.
115
     *
116
     * @param string $msgid String to be translated
117 220
     *
118 4
     * @return string translated string (or original, if not found)
119
     */
120
    public function gettext(string $msgid): string
121 220
    {
122 55
        return $this->cache->get($msgid);
123
    }
124
125
    /**
126
     * Check if a string is translated.
127
     *
128
     * @param string $msgid String to be checked
129 220
     */
130
    public function exists(string $msgid): bool
131 220
    {
132 64
        return $this->cache->has($msgid);
133
    }
134 64
135
    /**
136
     * Sanitize plural form expression for use in ExpressionLanguage.
137 156
     *
138
     * @param string $expr Expression to sanitize
139
     *
140 156
     * @return string sanitized plural form expression
141 152
     */
142 120
    public static function sanitizePluralExpression(string $expr): string
143 32
    {
144 28
        // Parse equation
145
        $expr = explode(';', $expr);
146 4
        if (count($expr) >= 2) {
147
            $expr = $expr[1];
148 4
        } else {
149
            $expr = $expr[0];
150
        }
151
152 148
        $expr = trim(strtolower($expr));
153 148
        // Strip plural prefix
154 136
        if (substr($expr, 0, 6) === 'plural') {
155
            $expr = ltrim(substr($expr, 6));
156
        }
157 136
158 136
        // Strip equals
159 132
        if (substr($expr, 0, 1) === '=') {
160
            $expr = ltrim(substr($expr, 1));
161
        }
162 132
163 132
        // Cleanup from unwanted chars
164 132
        $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr);
165 132
166 132
        return (string) $expr;
167 132
    }
168 132
169
    /**
170 20
     * Extracts number of plurals from plurals form expression.
171 20
     *
172
     * @param string $expr Expression to process
173 20
     *
174
     * @return int Total number of plurals
175 33
     */
176
    public static function extractPluralCount(string $expr): int
177
    {
178
        $parts = explode(';', $expr, 2);
179
        $nplurals = explode('=', trim($parts[0]), 2);
180
        if (strtolower(rtrim($nplurals[0])) !== 'nplurals') {
181
            return 1;
182
        }
183
184 124
        if (count($nplurals) === 1) {
185
            return 1;
186 124
        }
187 88
188
        return intval($nplurals[1]);
189
    }
190 60
191
    /**
192
     * Parse full PO header and extract only plural forms line.
193
     *
194
     * @param string $header Gettext header
195
     *
196
     * @return string verbatim plural form header field
197
     */
198 24
    public static function extractPluralsForms(string $header): string
199
    {
200 24
        $headers = explode("\n", $header);
201
        $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;';
202
        foreach ($headers as $header) {
203
            if (stripos($header, 'Plural-Forms:') !== 0) {
204
                continue;
205
            }
206
207
            $expr = substr($header, 13);
208
        }
209
210 92
        return $expr;
211
    }
212
213 92
    /**
214 92
     * Get possible plural forms from MO header.
215 80
     *
216
     * @return string plural form header
217 12
     */
218
    private function getPluralForms(): string
219
    {
220 92
        // lets assume message number 0 is header
221
        // this is true, right?
222 92
223 84
        // cache header field for plural forms
224
        if ($this->pluralEquation === null) {
225
            $header = $this->cache->get('');
226
227 92
            $expr = $this->extractPluralsForms($header);
228 84
            $this->pluralEquation = $this->sanitizePluralExpression($expr);
229
            $this->pluralCount = $this->extractPluralCount($expr);
230
        }
231
232 92
        return $this->pluralEquation;
233
    }
234 92
235
    /**
236
     * Detects which plural form to take.
237
     *
238
     * @param int $n count of objects
239
     *
240
     * @return int array index of the right plural form
241
     */
242
    private function selectString(int $n): int
243
    {
244 88
        if ($this->pluralExpression === null) {
245
            $this->pluralExpression = new ExpressionLanguage();
246 88
        }
247 88
248 88
        try {
249 8
            $plural = (int) $this->pluralExpression->evaluate(
250
                $this->getPluralForms(),
251
                ['n' => $n]
252 80
            );
253 4
        } catch (Throwable $e) {
254
            $plural = 0;
255
        }
256 76
257
        if ($plural >= $this->pluralCount) {
258
            $plural = $this->pluralCount - 1;
259
        }
260
261
        return $plural;
262
    }
263
264
    /**
265
     * Plural version of gettext.
266 80
     *
267
     * @param string $msgid       Single form
268 80
     * @param string $msgidPlural Plural form
269 80
     * @param int    $number      Number of objects
270 80
     *
271 80
     * @return string translated plural form
272 80
     */
273
    public function ngettext(string $msgid, string $msgidPlural, int $number): string
274
    {
275 60
        // this should contains all strings separated by NULLs
276
        $key = implode(chr(0), [$msgid, $msgidPlural]);
277
        if (! $this->cache->has($key)) {
278 80
            return $number !== 1 ? $msgidPlural : $msgid;
279
        }
280
281
        $result = $this->cache->get($key);
282
283
        // find out the appropriate form
284
        $select = $this->selectString($number);
285
286 68
        $list = explode(chr(0), $result);
287
        // @codeCoverageIgnoreStart
288
        if ($list === false) {
289
            // This was added in 3ff2c63bcf85f81b3a205ce7222de11b33e2bf56 for phpstan
290
            // But according to the php manual it should never happen
291
            return '';
292 68
        }
293 64
294 52
        // @codeCoverageIgnoreEnd
295
296 12
        if (! isset($list[$select])) {
297
            return $list[0];
298
        }
299 64
300 64
        return $list[$select];
301 64
    }
302
303
    /**
304 68
     * Translate with context.
305
     *
306
     * @param string $msgctxt Context
307
     * @param string $msgid   String to be translated
308
     *
309
     * @return string translated plural form
310
     */
311
    public function pgettext(string $msgctxt, string $msgid): string
312
    {
313
        $key = implode(chr(4), [$msgctxt, $msgid]);
314 68
        $ret = $this->gettext($key);
315
        if (strpos($ret, chr(4)) !== false) {
316 68
            return $msgid;
317 64
        }
318
319
        return $ret;
320
    }
321 68
322 68
    /**
323 17
     * Plural version of pgettext.
324
     *
325 4
     * @param string $msgctxt     Context
326 4
     * @param string $msgid       Single form
327
     * @param string $msgidPlural Plural form
328
     * @param int    $number      Number of objects
329 68
     *
330 4
     * @return string translated plural form
331
     */
332
    public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string
333 68
    {
334
        $key = implode(chr(4), [$msgctxt, $msgid]);
335
        $ret = $this->ngettext($key, $msgidPlural, $number);
336
        if (strpos($ret, chr(4)) !== false) {
337
            return $msgid;
338
        }
339
340
        return $ret;
341
    }
342
343
    /**
344
     * Set translation in place
345 108
     *
346
     * @param string $msgid  String to be set
347
     * @param string $msgstr Translation
348 108
     */
349 108
    public function setTranslation(string $msgid, string $msgstr): void
350 72
    {
351
        $this->cache->set($msgid, $msgstr);
352
    }
353
354 68
    /**
355
     * Set the translations
356 68
     *
357 68
     * @param array<string,string> $translations The translations "key => value" array
358
     */
359
    public function setTranslations(array $translations): void
360
    {
361
        $this->cache->setAll($translations);
362
    }
363
364
    /**
365
     * Get the translations
366
     *
367 68
     * @return array<string,string> The translations "key => value" array
368 4
     */
369
    public function getTranslations(): array
370
    {
371 68
        if ($this->cache instanceof GetAllInterface) {
372
            return $this->cache->getAll();
373
        }
374
375
        throw new CacheException(sprintf(
376
            "Cache '%s' does not support getting translations",
377
            get_class($this->cache)
378
        ));
379
    }
380
}
381