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 array 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     * @since 1.45
51     */
52    public function __construct(
53        private readonly string $langCode,
54        private readonly Evaluator $evaluator,
55        private readonly Provider $provider,
56        private readonly LoggerInterface $logger,
57        private readonly XmlLoader $xmlLoader,
58    ) {
59    }
60
61    /**
62     * Finds the index number of the plural rule appropriate for the given number.
63     *
64     * @since 1.45
65     * @return int The index number of the plural rule.
66     */
67    public function getPluralRuleIndexNumber( float $number ): int {
68        $compiledRules = $this->getCompiledPluralRules();
69        $evaluatedNumber = ( $number == (int)$number ) ? (int)$number : (string)$number;
70
71        return $this->evaluator->evaluateCompiled( $evaluatedNumber, $compiledRules );
72    }
73
74    /**
75     * Returns the compiled plural rules for the current language.
76     *
77     * It uses the cached raw rules from the XML files and compiles them.
78     *
79     * @since 1.45
80     * @return array<int, string> The compiled plural rules.
81     */
82    public function getCompiledPluralRules(): array {
83        $rules = $this->compileRulesFor( $this->langCode );
84        if ( count( $rules ) === 0 ) {
85            foreach ( $this->provider->getLanguageFallbacksProvider()->getFallbacks() as $fallbackCode ) {
86                $rules = $this->compileRulesFor( $fallbackCode );
87                if ( count( $rules ) > 0 ) {
88                    break;
89                }
90            }
91        }
92
93        return $rules;
94    }
95
96    /**
97     * Compiles the plural rules for the specified language code.
98     *
99     * It uses the cached data and returns a compiled version via the CLDR Evaluator.
100     * Returns an empty array if the rules are unavailable or if a compilation error occurs.
101     *
102     * @param string $code The language code.
103     *
104     * @since 1.45
105     * @return array<int, string> The compiled plural rules.
106     */
107    public function compileRulesFor( string $code ): array {
108        if ( self::$pluralData === null ) {
109            self::$pluralData = self::loadPluralFiles();
110        }
111        $data = self::$pluralData[$code] ?? null;
112        $rules = $data ? array_column( $data, 'rule' ) : null;
113
114        return $this->compileRulesFromArray( $rules );
115    }
116
117    /**
118     * Loads the plural XML files.
119     *
120     * @since 1.45
121     * @return array<string, list<array{type: string, rule: string}>>
122     */
123    private function loadPluralFiles(): array {
124        $pluralData = [];
125        foreach ( self::PLURAL_FILES as $fileName ) {
126            $pluralData = array_merge( $pluralData, $this->loadPluralFile( $fileName ) );
127        }
128
129        return $pluralData;
130    }
131
132    /**
133     * Loads a plural XML file and extracts the plural data.
134     *
135     * @param string $fileName The path to the XML file.
136     *
137     * @since 1.45
138     * @return array<string, list<array{type: string, rule: string}>>
139     * @throws RuntimeException if the file cannot be read.
140     */
141    private function loadPluralFile( string $fileName ): array {
142        $data = [];
143
144        $doc = $this->xmlLoader->load( $fileName, 'PluralRules' );
145        if ( $doc === null ) {
146            return $data;
147        }
148
149        $rulesets = $doc->getElementsByTagName( "pluralRules" );
150        foreach ( $rulesets as $ruleset ) {
151            $codes = $ruleset->getAttribute( 'locales' );
152            $rules = [];
153            $ruleElements = $ruleset->getElementsByTagName( "pluralRule" );
154            foreach ( $ruleElements as $elt ) {
155                $ruleType = $elt->getAttribute( 'count' );
156                if ( $ruleType === 'other' ) {
157                    // Skip "other" rules, which have an empty condition.
158                    continue;
159                }
160                $rules[] = [
161                    'type' => $ruleType,
162                    'rule' => (string)$elt->nodeValue,
163                ];
164            }
165            foreach ( explode( ' ', $codes ) as $code ) {
166                $data[$code] = $rules;
167            }
168        }
169
170        return $data;
171    }
172
173    /**
174     * Helper method that compiles an array of rules.
175     *
176     * If the rules array is empty, returns an empty array.
177     * Otherwise, attempts to compile using the CLDR Evaluator.
178     * On error, logs the message and returns an empty array.
179     *
180     * @param array<int, string>|null $rules The raw plural rules.
181     *
182     * @since 1.45
183     * @return array<int, string> The compiled plural rules.
184     */
185    private function compileRulesFromArray( ?array $rules ): array {
186        if ( $rules === null ) {
187            return [];
188        }
189        try {
190            /** @var array<int|string, string> $compiled */
191            $compiled = $this->evaluator->compile( $rules );
192
193            return array_values( $compiled );
194        } catch ( CLDRPluralRuleError $e ) {
195            $this->logger->debug( 'Unable to compile rules', [ 'exception' => $e ] );
196
197            return [];
198        }
199    }
200
201    /**
202     * Returns the raw plural rules for the current language from the XML files.
203     *
204     * The data is loaded from the cache if available; otherwise, the XML files are read.
205     *
206     * @since 1.45
207     * @return array<int, string>|null The plural rules, or null if they are not available.
208     */
209    public function getPluralRules(): ?array {
210        self::$pluralData ??= self::loadPluralFiles();
211        $data = self::$pluralData[$this->langCode] ?? null;
212
213        return $data ? array_column( $data, 'rule' ) : null;
214    }
215
216    /**
217     * Finds the plural rule type corresponding to the given number.
218     * For example, if the language is set to Arabic, getPluralRuleType(5) should return 'few'.
219     *
220     * @since 1.45
221     * @return string The name of the plural rule type (e.g., one, two, few, many).
222     */
223    public function getPluralRuleType( float $number ): string {
224        $index = $this->getPluralRuleIndexNumber( $number );
225        $types = $this->getPluralRuleTypes();
226
227        return $types[$index] ?? 'other';
228    }
229
230    /**
231     * Returns the plural rule types for the current language from the XML files.
232     *
233     * The data is loaded from the cache if available; otherwise, the XML files are read.
234     *
235     * @since 1.45
236     * @return array<int, string> The plural rule types.
237     */
238    public function getPluralRuleTypes(): array {
239        if ( self::$pluralData === null ) {
240            self::$pluralData = self::loadPluralFiles();
241        }
242        $data = self::$pluralData[$this->langCode] ?? [];
243        if ( count( $data ) === 0 ) {
244            foreach ( $this->provider->getLanguageFallbacksProvider()->getFallbacks() as $fallbackCode ) {
245                $data = self::$pluralData[$fallbackCode] ?? [];
246                if ( count( $data ) > 0 ) {
247                    break;
248                }
249            }
250        }
251
252        return array_column( $data, 'type' );
253    }
254}