Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
104 / 107
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageNameSearch
97.20% covered (success)
97.20%
104 / 107
71.43% covered (warning)
71.43%
5 / 7
38
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
 search
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 doSearch
96.36% covered (success)
96.36%
53 / 55
0.00% covered (danger)
0.00%
0 / 1
17
 matchNames
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 getIndex
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCodepoint
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 levenshteinDistance
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2
3namespace MediaWiki\Language;
4
5use MediaWiki\MediaWikiServices;
6use Wikimedia\LanguageData\LanguageUtil;
7
8/**
9 * Cross-Language Language name search
10 *
11 * FIXME: This class can be marked as readonly once AutoLoaderStructureTest can
12 * parse "readonly" annotations.
13 *
14 * Copyright (C) 2012 Alolita Sharma, Amir Aharoni, Arun Ganesh, Brandon Harris,
15 * Niklas Laxström, Pau Giner, Santhosh Thottingal, Siebrand Mazeland and other
16 * contributors.
17 *
18 * @license GPL-2.0-or-later
19 */
20class LanguageNameSearch {
21    private LanguageNameUtils $languageNameUtils;
22
23    public function __construct( LanguageNameUtils $languageNameUtils ) {
24        $this->languageNameUtils = $languageNameUtils;
25    }
26
27    /**
28     * Find languages with fuzzy matching.
29     * The order of results is following:
30     * 1: exact language code match
31     * 2: exact language name match in any language
32     * 3: prefix language name match in any language
33     * 4: infix language name match in any language
34     *
35     * The returned language name for autocompletion is the first one that
36     * matches in this list:
37     * 1: exact match in [user, autonym, any other language]
38     * 2: prefix match in [user, autonym, any other language]
39     * 3: inline match in [user, autonym, any other language]
40     *
41     * @param string $searchKey
42     * @param int $typos
43     * @param string|null $userLanguage Language tag.
44     * @return array
45     */
46    public static function search( string $searchKey, int $typos = 0, ?string $userLanguage = null ): array {
47        $services = MediaWikiServices::getInstance();
48        $instance = $services->getLanguageNameSearch();
49        return $instance->doSearch( $searchKey, $typos, $userLanguage );
50    }
51
52    /**
53     * Find languages with fuzzy matching.
54     * The order of results is following:
55     * 1: exact language code match
56     * 2: exact language name match in any language
57     * 3: remaining languages matche by autonym and script group
58     *
59     * The returned language name for autocompletion is the first one that
60     * matches in this list:
61     * 1: exact match in [user, autonym, any other language]
62     * 2: prefix match in [user, autonym, any other language]
63     * 3: inline match in [user, autonym, any other language]
64     *
65     * @param string $searchKey
66     * @param int $typos
67     * @param string|null $userLanguage Language tag.
68     * @return array
69     */
70    public function doSearch( string $searchKey, int $typos = 0, ?string $userLanguage = null ): array {
71        $exactMatches = [];
72        $otherMatches = [];
73        $searchKey = mb_strtolower( $searchKey );
74
75        if ( mb_strlen( $searchKey ) > 100 ) {
76            // Searching with long search keys for language names is not useful. So, return early.
77            return [];
78        }
79
80        $languageNameUtils = $this->languageNameUtils;
81
82        // Always prefer exact language code match
83        if ( $languageNameUtils->isKnownLanguageTag( $searchKey ) ) {
84            $name = mb_strtolower( $languageNameUtils->getLanguageName( $searchKey, $userLanguage ) );
85            // Check if language code is a prefix of the name
86            if ( str_starts_with( $name, $searchKey ) ) {
87                $exactMatches[$searchKey] = $name;
88            } else {
89                $exactMatches[$searchKey] = "$searchKey â€“ $name";
90            }
91        }
92
93        $index = self::getIndex( $searchKey );
94        static $buckets = null;
95        if ( $buckets === null ) {
96            $data = json_decode(
97                file_get_contents( __DIR__ . '/../../languages/data/LanguageNameSearchData.json' ),
98                true
99            );
100            $buckets = $data['buckets'] ?? [];
101        }
102        $bucketsForIndex = $buckets[$index] ?? [];
103
104        // types are 'prefix', 'infix' (in this order!)
105        foreach ( $bucketsForIndex as $bucket ) {
106            foreach ( $bucket as $name => $code ) {
107                // We can skip checking languages we already have in either match list
108                if ( isset( $exactMatches[$code] ) || isset( $otherMatches[$code] ) ) {
109                    continue;
110                }
111
112                // Apply fuzzy search
113                if ( !$this->matchNames( $name, $searchKey, $typos ) ) {
114                    continue;
115                }
116
117                // Once we find a match, figure out the best name to display to the user
118                // If $userLanguage is not provided (null), it is the same as autonym
119                $candidates = [
120                    mb_strtolower( $languageNameUtils->getLanguageName( $code, $userLanguage ) ),
121                    mb_strtolower( $languageNameUtils->getLanguageName( $code, LanguageNameUtils::AUTONYMS ) ),
122                    $name
123                ];
124
125                // First check for exact name match
126                foreach ( $candidates as $candidate ) {
127                    if ( $searchKey === $candidate ) {
128                        $exactMatches[$code] = $candidate;
129                        continue 2;
130                    }
131                }
132
133                // Otherwise, check for prefix/infix match
134                foreach ( $candidates as $candidate ) {
135                    if ( $this->matchNames( $candidate, $searchKey, $typos ) ) {
136                        $otherMatches[$code] = $candidate;
137                        continue 2;
138                    }
139                }
140            }
141        }
142
143        // Sort the remaining matches by autonym and script group
144        $languageUtil = LanguageUtil::get();
145        $otherCodes = array_keys( $otherMatches );
146
147        $knownCodes = [];
148        $unknownCodes = [];
149        foreach ( $otherCodes as $code ) {
150            if ( $languageUtil->isKnown( $code ) ) {
151                $knownCodes[] = $code;
152            } else {
153                $unknownCodes[] = $code;
154            }
155        }
156
157        $knownCodes = $languageUtil->sortByAutonym( $knownCodes );
158        $knownCodes = $languageUtil->sortByScriptGroup( $knownCodes );
159
160        asort( $unknownCodes, SORT_STRING | SORT_FLAG_CASE );
161
162        $sortedOtherCodes = array_merge( $knownCodes, $unknownCodes );
163
164        // Merge exact matches (retaining priority order at the top) and sorted matches
165        $sortedResults = $exactMatches;
166        foreach ( $sortedOtherCodes as $code ) {
167            $sortedResults[$code] = $otherMatches[$code];
168        }
169
170        return $sortedResults;
171    }
172
173    private function matchNames( string $name, string $searchKey, int $typos ): bool {
174        return strrpos( $name, $searchKey, -strlen( $name ) ) !== false
175            || ( $typos > 0 && $this->levenshteinDistance( $name, $searchKey ) <= $typos );
176    }
177
178    public static function getIndex( string $name ): int {
179        $codepoint = self::getCodepoint( $name );
180
181        if ( $codepoint < 4000 ) {
182            // For latin etc. we need smaller buckets for speed
183            return $codepoint;
184        }
185
186        // Try to group names of same script together
187        return $codepoint - ( $codepoint % 1000 );
188    }
189
190    /**
191     * Get the code point of first letter of string
192     *
193     * @param string $str
194     * @return int Code point of first letter of string
195     */
196    private static function getCodepoint( string $str ): int {
197        $values = [];
198        $lookingFor = 1;
199        $strLen = strlen( $str );
200        $number = 0;
201
202        for ( $i = 0; $i < $strLen; $i++ ) {
203            $thisValue = ord( $str[$i] );
204            if ( $thisValue < 128 ) {
205                $number = $thisValue;
206
207                break;
208            }
209
210            // Codepoints larger than 127 are represented by multi-byte sequences
211            if ( $values === [] ) {
212                // 224 is the lowest non-overlong-encoded codepoint.
213                $lookingFor = ( $thisValue < 224 ) ? 2 : 3;
214            }
215
216            $values[] = $thisValue;
217            if ( count( $values ) === $lookingFor ) {
218                // Refer http://en.wikipedia.org/wiki/UTF-8#Description
219                if ( $lookingFor === 3 ) {
220                    $number = ( $values[0] % 16 ) * 4096;
221                    $number += ( $values[1] % 64 ) * 64;
222                    $number += $values[2] % 64;
223                } else {
224                    $number = ( $values[0] % 32 ) * 64;
225                    $number += $values[1] % 64;
226                }
227
228                break;
229            }
230        }
231
232        return $number;
233    }
234
235    /**
236     * Calculate the Levenshtein distance between two strings
237     */
238    private function levenshteinDistance( string $str1, string $str2 ): int {
239        if ( $str1 === $str2 ) {
240            return 0;
241        }
242        $length1 = mb_strlen( $str1, 'UTF-8' );
243        $length2 = mb_strlen( $str2, 'UTF-8' );
244        if ( $length1 === 0 ) {
245            return $length2;
246        }
247        if ( $length1 < $length2 ) {
248            return $this->levenshteinDistance( $str2, $str1 );
249        }
250        $prevRow = range( 0, $length2 );
251        for ( $i = 0; $i < $length1; $i++ ) {
252            $currentRow = [];
253            $currentRow[0] = $i + 1;
254            $c1 = mb_substr( $str1, $i, 1, 'UTF-8' );
255            for ( $j = 0; $j < $length2; $j++ ) {
256                $c2 = mb_substr( $str2, $j, 1, 'UTF-8' );
257                $insertions = $prevRow[$j + 1] + 1;
258                $deletions = $currentRow[$j] + 1;
259                $substitutions = $prevRow[$j] + ( ( $c1 !== $c2 ) ? 1 : 0 );
260                $currentRow[] = min( $insertions, $deletions, $substitutions );
261            }
262            $prevRow = $currentRow;
263        }
264
265        return $prevRow[$length2];
266    }
267}