Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.90% covered (danger)
12.90%
8 / 62
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PluralRules
12.90% covered (danger)
12.90%
8 / 62
20.00% covered (danger)
20.00%
2 / 10
584.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPluralRuleIndexNumber
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCompiledPluralRules
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 compileRulesFor
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 loadPluralFiles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 loadPluralFile
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 compileRulesFromArray
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getPluralRules
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getPluralRuleType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getPluralRuleTypes
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace Wikimedia\Leximorph\Provider;
8
9use CLDRPluralRuleParser\Error as CLDRPluralRuleError;
10use CLDRPluralRuleParser\Evaluator;
11use Psr\Log\LoggerInterface;
12use RuntimeException;
13use Wikimedia\Leximorph\Provider;
14use Wikimedia\Leximorph\Util\XmlLoader;
15
16/**
17 * PluralRules
18 *
19 * Provides functionality to load, cache, and compile pluralization rules from XML files.
20 *
21 * @since     1.45
22 * @author    Doğu Abaris (abaris@null.net)
23 * @license   https://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
24 */
25class PluralRules {
26
27    /**
28     * The paths to the plural rules XML files.
29     */
30    public const PLURAL_FILES = [
31        // Load CLDR plural rules
32        __DIR__ . '/../data/plurals.xml',
33        // Override or extend with MW-specific rules
34        __DIR__ . '/../data/plurals-mediawiki.xml',
35    ];
36
37    /**
38     * Associative array of cached plural data.
39     *
40     * The key is the language code, and the value is an array of plural rules.
41     * Each plural rule is an associative array with the keys:
42     *  - 'type': the plural rule type (e.g., 'one', 'few', etc.)
43     *  - 'rule': the plural rule as a string.
44     *
45     * @var array<string, list<array{type: string, rule: string}>>|null
46     */
47    private static ?array $pluralData = null;
48
49    /**
50     * Initializes the PluralRules.
51     *
52     * @param string $langCode The language code for which plural rules will be evaluated.
53     * @param Evaluator $evaluator The evaluator used for compiling and evaluating plural rules.
54     * @param Provider $provider The provider used to access fallback and related resources.
55     * @param LoggerInterface $logger The logger instance used for reporting errors.
56     * @param XmlLoader $xmlLoader The xml loader to load data
57     *
58     * @since 1.45
59     */
60    public function __construct(
61        private readonly string $langCode,
62        private readonly Evaluator $evaluator,
63        private readonly Provider $provider,
64        private readonly LoggerInterface $logger,
65        private readonly XmlLoader $xmlLoader,
66    ) {
67    }
68
69    /**
70     * Finds the index number of the plural rule appropriate for the given number.
71     *
72     * @param float $number
73     *
74     * @since 1.45
75     * @return int The index number of the plural rule.
76     */
77    public function getPluralRuleIndexNumber( float $number ): int {
78        $compiledRules = $this->getCompiledPluralRules();
79        $evaluatedNumber = ( $number == (int)$number ) ? (int)$number : (string)$number;
80
81        return $this->evaluator->evaluateCompiled( $evaluatedNumber, $compiledRules );
82    }
83
84    /**
85     * Returns the compiled plural rules for the current language.
86     *
87     * It uses the cached raw rules from the XML files and compiles them.
88     *
89     * @since 1.45
90     * @return array<int, string> The compiled plural rules.
91     */
92    public function getCompiledPluralRules(): array {
93        $rules = $this->compileRulesFor( $this->langCode );
94        if ( count( $rules ) === 0 ) {
95            foreach ( $this->provider->getLanguageFallbacksProvider()->getFallbacks() as $fallbackCode ) {
96                $rules = $this->compileRulesFor( $fallbackCode );
97                if ( count( $rules ) > 0 ) {
98                    break;
99                }
100            }
101        }
102
103        return $rules;
104    }
105
106    /**
107     * Compiles the plural rules for the specified language code.
108     *
109     * It uses the cached data and returns a compiled version via the CLDR Evaluator.
110     * Returns an empty array if the rules are unavailable or if a compilation error occurs.
111     *
112     * @param string $code The language code.
113     *
114     * @since 1.45
115     * @return array<int, string> The compiled plural rules.
116     */
117    public function compileRulesFor( string $code ): array {
118        if ( self::$pluralData === null ) {
119            self::$pluralData = self::loadPluralFiles();
120        }
121        $data = self::$pluralData[$code] ?? null;
122        $rules = $data ? array_column( $data, 'rule' ) : null;
123
124        return $this->compileRulesFromArray( $rules );
125    }
126
127    /**
128     * Loads the plural XML files.
129     *
130     * @since 1.45
131     * @return array<string, list<array{type: string, rule: string}>>
132     */
133    private function loadPluralFiles(): array {
134        $pluralData = [];
135        foreach ( self::PLURAL_FILES as $fileName ) {
136            $pluralData = array_merge( $pluralData, $this->loadPluralFile( $fileName ) );
137        }
138
139        return $pluralData;
140    }
141
142    /**
143     * Loads a plural XML file and extracts the plural data.
144     *
145     * @param string $fileName The path to the XML file.
146     *
147     * @since 1.45
148     * @return array<string, list<array{type: string, rule: string}>>
149     * @throws RuntimeException if the file cannot be read.
150     */
151    private function loadPluralFile( string $fileName ): array {
152        $data = [];
153
154        $doc = $this->xmlLoader->load( $fileName, 'PluralRules' );
155        if ( $doc === null ) {
156            return $data;
157        }
158
159        $rulesets = $doc->getElementsByTagName( "pluralRules" );
160        foreach ( $rulesets as $ruleset ) {
161            $codes = $ruleset->getAttribute( 'locales' );
162            $rules = [];
163            $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
164            foreach ( $ruleElements as $elt ) {
165                $ruleType = $elt->getAttribute( 'count' );
166                if ( $ruleType === 'other' ) {
167                    // Skip "other" rules, which have an empty condition.
168                    continue;
169                }
170                $rules[] = [
171                    'type' => $ruleType,
172                    'rule' => (string)$elt->nodeValue,
173                ];
174            }
175            foreach ( explode( ' ', $codes ) as $code ) {
176                $data[$code] = $rules;
177            }
178        }
179
180        return $data;
181    }
182
183    /**
184     * Helper method that compiles an array of rules.
185     *
186     * If the rules array is empty, returns an empty array.
187     * Otherwise, attempts to compile using the CLDR Evaluator.
188     * On error, logs the message and returns an empty array.
189     *
190     * @param array<int, string>|null $rules The raw plural rules.
191     *
192     * @since 1.45
193     * @return array<int, string> The compiled plural rules.
194     */
195    private function compileRulesFromArray( ?array $rules ): array {
196        if ( $rules === null ) {
197            return [];
198        }
199        try {
200            /** @var array<int|string, string> $compiled */
201            $compiled = $this->evaluator->compile( $rules );
202
203            return array_values( $compiled );
204        } catch ( CLDRPluralRuleError $e ) {
205            $this->logger->debug( 'Unable to compile rules', [ 'exception' => $e ] );
206
207            return [];
208        }
209    }
210
211    /**
212     * Returns the raw plural rules for the current language from the XML files.
213     *
214     * The data is loaded from the cache if available; otherwise, the XML files are read.
215     *
216     * @since 1.45
217     * @return array<int, string>|null The plural rules, or null if they are not available.
218     */
219    public function getPluralRules(): ?array {
220        self::$pluralData ??= self::loadPluralFiles();
221        $data = self::$pluralData[$this->langCode] ?? null;
222
223        return $data ? array_column( $data, 'rule' ) : null;
224    }
225
226    /**
227     * Finds the plural rule type corresponding to the given number.
228     * For example, if the language is set to Arabic, getPluralRuleType(5) should return 'few'.
229     *
230     * @param float $number
231     *
232     * @since 1.45
233     * @return string The name of the plural rule type (e.g., one, two, few, many).
234     */
235    public function getPluralRuleType( float $number ): string {
236        $index = $this->getPluralRuleIndexNumber( $number );
237        $types = $this->getPluralRuleTypes();
238
239        return $types[$index] ?? 'other';
240    }
241
242    /**
243     * Returns the plural rule types for the current language from the XML files.
244     *
245     * The data is loaded from the cache if available; otherwise, the XML files are read.
246     *
247     * @since 1.45
248     * @return array<int, string> The plural rule types.
249     */
250    public function getPluralRuleTypes(): array {
251        if ( self::$pluralData === null ) {
252            self::$pluralData = self::loadPluralFiles();
253        }
254        $data = self::$pluralData[$this->langCode] ?? [];
255        if ( count( $data ) === 0 ) {
256            foreach ( $this->provider->getLanguageFallbacksProvider()->getFallbacks() as $fallbackCode ) {
257                $data = self::$pluralData[$fallbackCode] ?? [];
258                if ( count( $data ) > 0 ) {
259                    break;
260                }
261            }
262        }
263
264        return array_column( $data, 'type' );
265    }
266}