ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
PluralForms.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
23use InvalidArgumentException;
24
41final class PluralForms
42{
46 public const string GERMANIC_HEADER = 'nplurals=2; plural=(n != 1);';
47
48 private const int MAX_COUNT = 10;
49 private const int MAX_FORMULA_LENGTH = 512;
50 private const int MAX_DEPTH = 64;
51 private const int MAX_INTEGER_DIGITS = 9;
52
59 private const array EXTRA_SAMPLES = [1000, 1001, 1011, 100000, 1000000, 1000001, 2000000];
60 private const int SAMPLED_UP_TO = 200;
61
65 private const array BINARY_LEVELS = [
66 ['||'],
67 ['&&'],
68 ['==', '!='],
69 ['<', '<=', '>', '>='],
70 ['%'],
71 ];
72
74 private static array $parsed = [];
75
79 private function __construct(
80 private readonly int $count,
81 private readonly string $formula,
82 private readonly array $tree
83 ) {
84 }
85
90 public static function fromHeader(string $header): self
91 {
92 if (isset(self::$parsed[$header])) {
93 return self::$parsed[$header];
94 }
95 // limited before any regular expression runs on it; possessive quantifiers, no backtracking
96 if (strlen($header) > self::MAX_FORMULA_LENGTH + 64) {
97 throw new InvalidArgumentException('The Plural-Forms header is too long.');
98 }
99 if (preg_match('/\A\s*+nplurals\s*+=\s*+(\d{1,2}+)\s*+;\s*+plural\s*+=([^;]++);?\s*+\z/', $header, $matches) !== 1) {
100 throw new InvalidArgumentException('The Plural-Forms header does not have the form "nplurals=<count>; plural=<expression>;".');
101 }
102 $count = (int) $matches[1];
103 $formula = trim($matches[2]);
104 if ($count < 1 || $count > self::MAX_COUNT) {
105 throw new InvalidArgumentException(sprintf('nplurals must be between 1 and %d.', self::MAX_COUNT));
106 }
107 if (strlen($formula) > self::MAX_FORMULA_LENGTH) {
108 throw new InvalidArgumentException('The plural expression is too long.');
109 }
110
111 $forms = new self($count, $formula, self::parse($formula));
112 foreach ([...range(0, self::SAMPLED_UP_TO), ...self::EXTRA_SAMPLES] as $n) {
113 $index = $forms->evaluate($n);
114 if ($index === null || $index < 0 || $index >= $count) {
115 throw new InvalidArgumentException(sprintf(
116 'The plural expression yields no valid form for n = %d (nplurals=%d).',
117 $n,
118 $count
119 ));
120 }
121 }
122 if (count(self::$parsed) >= 64) {
123 self::$parsed = [];
124 }
125
126 return self::$parsed[$header] = $forms;
127 }
128
135 public static function fromHeaderOrGermanic(?string $header, ?callable $on_invalid = null): self
136 {
137 if ($header !== null && trim($header) !== '') {
138 try {
139 return self::fromHeader($header);
140 } catch (InvalidArgumentException $e) {
141 $message = sprintf('Invalid Plural-Forms header "%s" (%s) - using "%s".', $header, $e->getMessage(), self::GERMANIC_HEADER);
142 }
143 } else {
144 $message = sprintf('Missing Plural-Forms header - using "%s".', self::GERMANIC_HEADER);
145 }
146 if ($on_invalid !== null) {
147 $on_invalid(PlainLogText::of($message));
148 }
149
150 return self::germanic();
151 }
152
153 public static function germanic(): self
154 {
155 return self::fromHeader(self::GERMANIC_HEADER);
156 }
157
158 public function getCount(): int
159 {
160 return $this->count;
161 }
162
163 public function getHeader(): string
164 {
165 return sprintf('nplurals=%d; plural=%s;', $this->count, $this->formula);
166 }
167
172 public function formIndexFor(int $n): int
173 {
174 $n = $n === PHP_INT_MIN ? PHP_INT_MAX : abs($n);
175 $index = $this->evaluate($n);
176
177 return $index !== null && $index >= 0 && $index < $this->count ? $index : $this->defaultFormIndex();
178 }
179
184 public function defaultFormIndex(): int
185 {
186 return self::defaultFormIndexForCount($this->count);
187 }
188
189 public static function defaultFormIndexForCount(int $count): int
190 {
191 return max(0, $count - 1);
192 }
193
200 public function defaultValueOf(array $forms): string
201 {
202 return self::defaultValueForCount($forms, $this->count);
203 }
204
206 public static function defaultValueForCount(array $forms, int $count): string
207 {
208 for ($form = self::defaultFormIndexForCount($count); $form >= 0; $form--) {
209 $value = (string) ($forms[$form] ?? '');
210 if ($value !== '') {
211 return $value;
212 }
213 }
214
215 return '';
216 }
217
222 public function selectsSameFormsAs(PluralForms $other): bool
223 {
224 if ($other->count !== $this->count) {
225 return false;
226 }
227 foreach ([...range(0, self::SAMPLED_UP_TO), ...self::EXTRA_SAMPLES] as $n) {
228 if ($other->formIndexFor($n) !== $this->formIndexFor($n)) {
229 return false;
230 }
231 }
232
233 return true;
234 }
235
243 public function previousFormsOf(PluralForms $previous): array
244 {
245 $previous_forms = array_fill(0, $this->count, []);
246 foreach ([...range(0, self::SAMPLED_UP_TO), ...self::EXTRA_SAMPLES] as $n) {
247 $previous_forms[$this->formIndexFor($n)][$previous->formIndexFor($n)] = true;
248 }
249
250 return array_map(static function (array $forms): array {
251 $forms = array_keys($forms);
252 sort($forms);
253 return $forms;
254 }, $previous_forms);
255 }
256
260 public function isOneSingularOtherPlural(): bool
261 {
262 if ($this->count !== 2) {
263 return false;
264 }
265 foreach ([...range(0, self::SAMPLED_UP_TO), ...self::EXTRA_SAMPLES] as $n) {
266 if ($this->formIndexFor($n) !== ($n === 1 ? 0 : 1)) {
267 return false;
268 }
269 }
270
271 return true;
272 }
273
274 private function evaluate(int $n): ?int
275 {
276 try {
277 return self::evaluateNode($this->tree, $n);
278 } catch (\ArithmeticError) {
279 return null;
280 }
281 }
282
286 private static function evaluateNode(array $node, int $n): int
287 {
288 switch ($node[0]) {
289 case 'n':
290 return $n;
291 case 'int':
292 return $node[1];
293 case '!':
294 return self::evaluateNode($node[1], $n) === 0 ? 1 : 0;
295 case '?':
296 return self::evaluateNode($node[1], $n) !== 0
297 ? self::evaluateNode($node[2], $n)
298 : self::evaluateNode($node[3], $n);
299 case '||':
300 return self::evaluateNode($node[1], $n) !== 0 || self::evaluateNode($node[2], $n) !== 0 ? 1 : 0;
301 case '&&':
302 return self::evaluateNode($node[1], $n) !== 0 && self::evaluateNode($node[2], $n) !== 0 ? 1 : 0;
303 }
304
305 $left = self::evaluateNode($node[1], $n);
306 $right = self::evaluateNode($node[2], $n);
307
308 return match ($node[0]) {
309 '==' => (int) ($left === $right),
310 '!=' => (int) ($left !== $right),
311 '<' => (int) ($left < $right),
312 '<=' => (int) ($left <= $right),
313 '>' => (int) ($left > $right),
314 '>=' => (int) ($left >= $right),
315 // intdiv()'s sibling: throws DivisionByZeroError (an ArithmeticError) for 0
316 '%' => $left % $right,
317 // parse() creates no other node
318 default => throw new \LogicException(sprintf('Unknown node "%s" of a plural expression.', (string) $node[0])),
319 };
320 }
321
326 private static function parse(string $formula): array
327 {
328 $tokens = self::tokenize($formula);
329 $position = 0;
330 $tree = self::parseTernary($tokens, $position, 0);
331 if ($position !== count($tokens)) {
332 throw new InvalidArgumentException(sprintf('Unexpected "%s" in the plural expression.', $tokens[$position]));
333 }
334
335 return $tree;
336 }
337
342 private static function tokenize(string $formula): array
343 {
344 $matched = preg_match_all(
345 '/\s*(?:(\d+)|(n)|(\|\||&&|==|!=|<=|>=|[<>!?:%()]))\s*/A',
346 $formula,
347 $matches,
348 PREG_SET_ORDER
349 );
350 $consumed = 0;
351 $tokens = [];
352 foreach ($matches ?: [] as $match) {
353 $consumed += strlen($match[0]);
354 $token = trim($match[0]);
355 if (ctype_digit($token) && strlen(ltrim($token, '0')) > self::MAX_INTEGER_DIGITS) {
356 throw new InvalidArgumentException('An integer of the plural expression is too large.');
357 }
358 $tokens[] = $token;
359 }
360 if ($matched === false || $consumed !== strlen($formula) || $tokens === []) {
361 throw new InvalidArgumentException('The plural expression contains something other than n, integers, ?:, ||, &&, !, comparisons, % and parentheses.');
362 }
363
364 return $tokens;
365 }
366
373 private static function parseTernary(array $tokens, int &$position, int $depth): array
374 {
375 self::assertDepth($depth);
376 $condition = self::parseBinary($tokens, $position, $depth, 0);
377 if (($tokens[$position] ?? null) !== '?') {
378 return $condition;
379 }
380 $position++;
381 $then = self::parseTernary($tokens, $position, $depth + 1);
382 self::expect($tokens, $position, ':');
383 $else = self::parseTernary($tokens, $position, $depth + 1);
384
385 return ['?', $condition, $then, $else];
386 }
387
392 private static function parseBinary(array $tokens, int &$position, int $depth, int $level): array
393 {
394 // the levels are a fixed, small number - only parentheses, "!" and "?:" nest (see assertDepth())
395 if ($level === count(self::BINARY_LEVELS)) {
396 return self::parseUnary($tokens, $position, $depth);
397 }
398 $left = self::parseBinary($tokens, $position, $depth, $level + 1);
399 while (in_array($tokens[$position] ?? null, self::BINARY_LEVELS[$level], true)) {
400 $operator = $tokens[$position++];
401 $left = [$operator, $left, self::parseBinary($tokens, $position, $depth, $level + 1)];
402 }
403
404 return $left;
405 }
406
413 private static function parseUnary(array $tokens, int &$position, int $depth): array
414 {
415 self::assertDepth($depth);
416 $token = $tokens[$position] ?? null;
417 if ($token === null) {
418 throw new InvalidArgumentException('The plural expression ends unexpectedly.');
419 }
420 $position++;
421 if ($token === '!') {
422 return ['!', self::parseUnary($tokens, $position, $depth + 1)];
423 }
424 if ($token === 'n') {
425 return ['n'];
426 }
427 if (ctype_digit($token)) {
428 return ['int', (int) $token];
429 }
430 if ($token === '(') {
431 $inner = self::parseTernary($tokens, $position, $depth + 1);
432 self::expect($tokens, $position, ')');
433 return $inner;
434 }
435
436 throw new InvalidArgumentException(sprintf('Unexpected "%s" in the plural expression.', $token));
437 }
438
442 private static function expect(array $tokens, int &$position, string $token): void
443 {
444 if (($tokens[$position] ?? null) !== $token) {
445 throw new InvalidArgumentException(sprintf('"%s" expected in the plural expression.', $token));
446 }
447 $position++;
448 }
449
450 private static function assertDepth(int $depth): void
451 {
452 if ($depth > self::MAX_DEPTH) {
453 throw new InvalidArgumentException('The plural expression is nested too deeply.');
454 }
455 }
456}
The plural rule of a language, as declared by the "Plural-Forms" header of a gettext catalog (nplural...
Definition: PluralForms.php:42
static parseBinary(array $tokens, int &$position, int $depth, int $level)
const array BINARY_LEVELS
The binary operators by precedence, lowest first (C rules; all left-associative).
Definition: PluralForms.php:65
static parseTernary(array $tokens, int &$position, int $depth)
ternary := or ( "?" ternary ":" ternary )?
static fromHeaderOrGermanic(?string $header, ?callable $on_invalid=null)
fromHeader(), or the Germanic rule (GERMANIC_HEADER) if $header is missing or invalid - reported thro...
static defaultValueForCount(array $forms, int $count)
formIndexFor(int $n)
The index of the form for the quantity $n.
const string GERMANIC_HEADER
The fallback for a missing or invalid header: the rule of the Germanic languages.
Definition: PluralForms.php:46
static parseUnary(array $tokens, int &$position, int $depth)
unary := "!" unary | "n" | integer | "(" ternary ")"
selectsSameFormsAs(PluralForms $other)
Whether $other has as many forms and selects the same form for every sampled n, e....
previousFormsOf(PluralForms $previous)
For every form of this rule, the forms of $previous serving the same quantities: $previous->formIndex...
__construct(private readonly int $count, private readonly string $formula, private readonly array $tree)
Definition: PluralForms.php:79
defaultValueOf(array $forms)
The value of the default form (see defaultFormIndex()) of a message with $forms (msgstr[0],...
isOneSingularOtherPlural()
Whether the rule is "n == 1 -> form 0, every other n -> form 1" (English, German) for every sampled n...
static expect(array $tokens, int &$position, string $token)
defaultFormIndex()
The form serving a plural message where only one value can be used: the last one, msgstr[nplurals - 1...
$token
Definition: xapitoken.php:69