Translator::extractPluralsForms()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 13
ccs 6
cts 6
cp 1
rs 10
cc 3
nc 3
nop 1
crap 3
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 PhpMyAdmin\MoTranslator\Cache\InMemoryCache;
32
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
33
use Throwable;
34
35
use function array_key_exists;
36
use function count;
37
use function explode;
38
use function is_numeric;
39
use function ltrim;
40
use function preg_replace;
41
use function rtrim;
42
use function sprintf;
43
use function str_contains;
44
use function str_starts_with;
45
use function stripos;
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
    /**
65
     * File does not exist.
66
     */
67
    public const ERROR_DOES_NOT_EXIST = 1;
68
69
    /**
70
     * File has bad magic number.
71
     */
72
    public const ERROR_BAD_MAGIC = 2;
73
74
    /**
75
     * Error while reading file, probably too short.
76
     */
77
    public const ERROR_READING = 3;
78
79
    /**
80
     * Big endian mo file magic bytes.
81
     */
82
    public const MAGIC_BE = "\x95\x04\x12\xde";
83
84
    /**
85
     * Little endian mo file magic bytes.
86
     */
87
    public const MAGIC_LE = "\xde\x12\x04\x95";
88
89
    /**
90
     * Parse error code (0 if no error).
91
     */
92
    public int $error = self::ERROR_NONE;
93
94
    /**
95
     * Cache header field for plural forms.
96
     */
97
    private string|null $pluralEquation = null;
98
99
    /**
100
     * Evaluator for plurals
101
     */
102
    private ExpressionLanguage|null $pluralExpression = null;
103
104
    /**
105
     * number of plurals
106
     */
107
    private int|null $pluralCount = null;
108
109
    private CacheInterface $cache;
110
111
    /** @param CacheInterface|string|null $cache Mo file to load (null for no file) or a CacheInterface implementation */
112 826
    public function __construct(CacheInterface|string|null $cache)
113
    {
114 826
        if (! $cache instanceof CacheInterface) {
115 28
            $cache = new InMemoryCache(new MoParser($cache));
116
        }
117
118 826
        $this->cache = $cache;
119 118
    }
120
121
    /**
122
     * Translates a string.
123
     *
124
     * @param string $msgid String to be translated
125
     *
126
     * @return string translated string (or original, if not found)
127
     */
128 476
    public function gettext(string $msgid): string
129
    {
130 476
        return $this->cache->get($msgid);
131
    }
132
133
    /**
134
     * Check if a string is translated.
135
     *
136
     * @param string $msgid String to be checked
137
     */
138 84
    public function exists(string $msgid): bool
139
    {
140 84
        return $this->cache->has($msgid);
141
    }
142
143
    /**
144
     * Sanitize plural form expression for use in ExpressionLanguage.
145
     *
146
     * @param string $expr Expression to sanitize
147
     *
148
     * @return string sanitized plural form expression
149
     */
150 322
    public static function sanitizePluralExpression(string $expr): string
151
    {
152
        // Parse equation
153 322
        $expr = explode(';', $expr);
154 322
        $expr = count($expr) >= 2 ? $expr[1] : $expr[0];
155 280
156
        $expr = trim(strtolower($expr));
157 42
        // Strip plural prefix
158
        if (str_starts_with($expr, 'plural')) {
159
            $expr = ltrim(substr($expr, 6));
160 322
        }
161
162 322
        // Strip equals
163 294
        if (str_starts_with($expr, '=')) {
164
            $expr = ltrim(substr($expr, 1));
165
        }
166
167 322
        // Cleanup from unwanted chars
168 294
        $expr = preg_replace('@[^n0-9:\(\)\?=!<>/%&| ]@', '', $expr);
169
170
        return (string) $expr;
171
    }
172 322
173
    /**
174 322
     * Extracts number of plurals from plurals form expression.
175
     *
176
     * @param string $expr Expression to process
177
     *
178
     * @return int Total number of plurals
179
     */
180
    public static function extractPluralCount(string $expr): int
181
    {
182
        $parts = explode(';', $expr, 2);
183
        $nplurals = explode('=', trim($parts[0]), 2);
184 308
        if (strtolower(rtrim($nplurals[0])) !== 'nplurals') {
185
            return 1;
186 308
        }
187 308
188 308
        if (count($nplurals) === 1) {
189 28
            return 1;
190
        }
191
192 280
        return (int) $nplurals[1];
193 14
    }
194
195
    /**
196 266
     * Parse full PO header and extract only plural forms line.
197
     *
198
     * @param string $header Gettext header
199
     *
200
     * @return string verbatim plural form header field
201
     */
202
    public static function extractPluralsForms(string $header): string
203
    {
204
        $headers = explode("\n", $header);
205
        $expr = 'nplurals=2; plural=n == 1 ? 0 : 1;';
206 280
        foreach ($headers as $header) {
207
            if (stripos($header, 'Plural-Forms:') !== 0) {
208 280
                continue;
209 280
            }
210 280
211 280
            $expr = substr($header, 13);
212 280
        }
213
214
        return $expr;
215 210
    }
216
217
    /**
218 280
     * Get possible plural forms from MO header.
219
     *
220
     * @return string plural form header
221
     */
222
    private function getPluralForms(): string
223
    {
224
        // lets assume message number 0 is header
225
        // this is true, right?
226 238
227
        // cache header field for plural forms
228
        if ($this->pluralEquation === null) {
229
            $header = $this->cache->get('');
230
231
            $expr = self::extractPluralsForms($header);
232 238
            $this->pluralEquation = self::sanitizePluralExpression($expr);
233 224
            $this->pluralCount = self::extractPluralCount($expr);
234
        }
235 224
236 224
        return $this->pluralEquation;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->pluralEquation could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
237 224
    }
238
239
    /**
240 238
     * Detects which plural form to take.
241
     *
242
     * @param int $n count of objects
243
     *
244
     * @return int array index of the right plural form
245
     */
246
    private function selectString(int $n): int
247
    {
248
        if ($this->pluralExpression === null) {
249
            $this->pluralExpression = new ExpressionLanguage();
250 238
        }
251
252 238
        try {
253 224
            $evaluatedPlural = $this->pluralExpression->evaluate($this->getPluralForms(), ['n' => $n]);
0 ignored issues
show
Bug introduced by
The method evaluate() does not exist on null. ( Ignorable by Annotation )

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

253
            /** @scrutinizer ignore-call */ 
254
            $evaluatedPlural = $this->pluralExpression->evaluate($this->getPluralForms(), ['n' => $n]);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
254
            $plural = is_numeric($evaluatedPlural) ? (int) $evaluatedPlural : 0;
255
        } catch (Throwable) {
256
            $plural = 0;
257 238
        }
258 238
259 34
        if ($plural >= $this->pluralCount) {
260
            $plural = $this->pluralCount - 1;
261 14
        }
262 14
263
        return $plural;
264
    }
265 238
266 14
    /**
267
     * Plural version of gettext.
268
     *
269 238
     * @param string $msgid       Single form
270
     * @param string $msgidPlural Plural form
271
     * @param int    $number      Number of objects
272
     *
273
     * @return string translated plural form
274
     */
275
    public function ngettext(string $msgid, string $msgidPlural, int $number): string
276
    {
277
        // this should contains all strings separated by NULLs
278
        $key = $msgid . "\u{0}" . $msgidPlural;
279
        if (! $this->cache->has($key)) {
280
            return $number !== 1 ? $msgidPlural : $msgid;
281 378
        }
282
283
        $result = $this->cache->get($key);
284 378
285 378
        // find out the appropriate form
286 252
        $select = $this->selectString($number);
287
288
        $list = explode("\u{0}", $result);
289 238
290
        if (array_key_exists($select, $list)) {
291
            return $list[$select];
292 238
        }
293
294 238
        return $list[0];
295
    }
296
297
    /**
298
     * Translate with context.
299
     *
300
     * @param string $msgctxt Context
301
     * @param string $msgid   String to be translated
302
     *
303
     * @return string translated plural form
304 238
     */
305 14
    public function pgettext(string $msgctxt, string $msgid): string
306
    {
307
        $key = $msgctxt . "\u{4}" . $msgid;
308 238
        $ret = $this->gettext($key);
309
        if ($ret === $key) {
310
            return $msgid;
311
        }
312
313
        return $ret;
314
    }
315
316
    /**
317
     * Plural version of pgettext.
318
     *
319 196
     * @param string $msgctxt     Context
320
     * @param string $msgid       Single form
321 196
     * @param string $msgidPlural Plural form
322 196
     * @param int    $number      Number of objects
323 196
     *
324 84
     * @return string translated plural form
325
     */
326
    public function npgettext(string $msgctxt, string $msgid, string $msgidPlural, int $number): string
327 112
    {
328
        $key = $msgctxt . "\u{4}" . $msgid;
329
        $ret = $this->ngettext($key, $msgidPlural, $number);
330
        if (str_contains($ret, "\u{4}")) {
331
            return $msgid;
332
        }
333
334
        return $ret;
335
    }
336
337
    /**
338
     * Set translation in place
339
     *
340 56
     * @param string $msgid  String to be set
341
     * @param string $msgstr Translation
342 56
     */
343 56
    public function setTranslation(string $msgid, string $msgstr): void
344 56
    {
345 14
        $this->cache->set($msgid, $msgstr);
346
    }
347
348 42
    /**
349
     * Set the translations
350
     *
351
     * @param array<string,string> $translations The translations "key => value" array
352
     */
353
    public function setTranslations(array $translations): void
354
    {
355
        $this->cache->setAll($translations);
356
    }
357 140
358
    /**
359 140
     * Get the translations
360 20
     *
361
     * @return array<string,string> The translations "key => value" array
362
     */
363
    public function getTranslations(): array
364
    {
365
        if ($this->cache instanceof GetAllInterface) {
366
            return $this->cache->getAll();
0 ignored issues
show
Bug introduced by
The method getAll() does not exist on PhpMyAdmin\MoTranslator\Cache\CacheInterface. It seems like you code against a sub-type of PhpMyAdmin\MoTranslator\Cache\CacheInterface such as PhpMyAdmin\MoTranslator\Cache\InMemoryCache. ( Ignorable by Annotation )

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

366
            return $this->cache->/** @scrutinizer ignore-call */ getAll();
Loading history...
367 14
        }
368
369 14
        throw new CacheException(sprintf(
370 2
            "Cache '%s' does not support getting translations",
371
            $this->cache::class,
372
        ));
373
    }
374
}
375