Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.27% covered (warning)
52.27%
115 / 220
15.91% covered (danger)
15.91%
7 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchEngine
52.27% covered (warning)
52.27%
115 / 220
15.91% covered (danger)
15.91%
7 / 44
1076.18
0.00% covered (danger)
0.00%
0 / 1
 searchText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 doSearchText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 searchArchiveTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSearchArchiveTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 searchTitle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 doSearchTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybePaginate
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 supports
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 setFeatureData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFeatureData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNearMatcher
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 defaultNearMatcher
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 legalSearchChars
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLimitOffset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setNamespaces
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 setShowSuggestion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getValidSorts
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSort
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 replacePrefixes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseNamespacePrefixes
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
11
 userHighlightPrefs
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 update
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 updateTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTextFromContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 textAlreadyUpdatedForIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeNamespaces
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 completionSearchBackendOverfetch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 completionSearchBackend
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 completionSearch
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 completionSearchWithVariants
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 extractTitles
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 processCompletionResults
78.79% covered (warning)
78.79%
26 / 33
0.00% covered (danger)
0.00%
0 / 1
7.47
 defaultPrefixSearch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 simplePrefixSearch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getProfiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeSearchFieldMapping
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSearchIndexFields
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
7.29
 augmentSearchResults
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 setHookContainer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHookContainer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHookRunner
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2/**
3 * Basic search engine
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Search
22 */
23
24/**
25 * @defgroup Search Search
26 */
27
28use MediaWiki\Config\Config;
29use MediaWiki\HookContainer\HookContainer;
30use MediaWiki\HookContainer\HookRunner;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Search\TitleMatcher;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\Title;
35use MediaWiki\User\User;
36
37/**
38 * Contain a class for special pages
39 * @stable to extend
40 * @ingroup Search
41 */
42abstract class SearchEngine {
43    public const DEFAULT_SORT = 'relevance';
44
45    /** @var string */
46    public $prefix = '';
47
48    /** @var int[]|null */
49    public $namespaces = [ NS_MAIN ];
50
51    /** @var int */
52    protected $limit = 10;
53
54    /** @var int */
55    protected $offset = 0;
56
57    /**
58     * @var string[]
59     * @deprecated since 1.34
60     */
61    protected $searchTerms = [];
62
63    /** @var bool */
64    protected $showSuggestion = true;
65    private $sort = self::DEFAULT_SORT;
66
67    /** @var array Feature values */
68    protected $features = [];
69
70    /** @var HookContainer */
71    private $hookContainer;
72
73    /** @var HookRunner */
74    private $hookRunner;
75
76    /** Profile type for completionSearch */
77    public const COMPLETION_PROFILE_TYPE = 'completionSearchProfile';
78
79    /** Profile type for query independent ranking features */
80    public const FT_QUERY_INDEP_PROFILE_TYPE = 'fulltextQueryIndepProfile';
81
82    /** Integer flag for legalSearchChars: includes all chars allowed in a search query */
83    protected const CHARS_ALL = 1;
84
85    /** Integer flag for legalSearchChars: includes all chars allowed in a search term */
86    protected const CHARS_NO_SYNTAX = 2;
87
88    /**
89     * Perform a full text search query and return a result set.
90     * If full text searches are not supported or disabled, return null.
91     *
92     * @note As of 1.32 overriding this function is deprecated. It will
93     * be converted to final in 1.34. Override self::doSearchText().
94     *
95     * @param string $term Raw search term
96     * @return ISearchResultSet|Status|null
97     */
98    public function searchText( $term ) {
99        return $this->maybePaginate( function () use ( $term ) {
100            return $this->doSearchText( $term );
101        } );
102    }
103
104    /**
105     * Perform a full text search query and return a result set.
106     *
107     * @stable to override
108     *
109     * @param string $term Raw search term
110     * @return ISearchResultSet|Status|null
111     * @since 1.32
112     */
113    protected function doSearchText( $term ) {
114        return null;
115    }
116
117    /**
118     * Perform a title search in the article archive.
119     * NOTE: these results still should be filtered by
120     * matching against PageArchive, permissions checks etc
121     * The results returned by this methods are only suggestions and
122     * may not end up being shown to the user.
123     *
124     * @note As of 1.32 overriding this function is deprecated. It will
125     * be converted to final in 1.34. Override self::doSearchArchiveTitle().
126     *
127     * @param string $term Raw search term
128     * @return Status
129     * @since 1.29
130     */
131    public function searchArchiveTitle( $term ) {
132        return $this->doSearchArchiveTitle( $term );
133    }
134
135    /**
136     * Perform a title search in the article archive.
137     *
138     * @stable to override
139     *
140     * @param string $term Raw search term
141     * @return Status
142     * @since 1.32
143     */
144    protected function doSearchArchiveTitle( $term ) {
145        return Status::newGood( [] );
146    }
147
148    /**
149     * Perform a title-only search query and return a result set.
150     * If title searches are not supported or disabled, return null.
151     * STUB
152     *
153     * @note As of 1.32 overriding this function is deprecated. It will
154     * be converted to final in 1.34. Override self::doSearchTitle().
155     *
156     * @param string $term Raw search term
157     * @return ISearchResultSet|null
158     */
159    public function searchTitle( $term ) {
160        return $this->maybePaginate( function () use ( $term ) {
161            return $this->doSearchTitle( $term );
162        } );
163    }
164
165    /**
166     * Perform a title-only search query and return a result set.
167     *
168     * @stable to override
169     *
170     * @param string $term Raw search term
171     * @return ISearchResultSet|null
172     * @since 1.32
173     */
174    protected function doSearchTitle( $term ) {
175        return null;
176    }
177
178    /**
179     * Performs an overfetch and shrink operation to determine if
180     * the next page is available for search engines that do not
181     * explicitly implement their own pagination.
182     *
183     * @param Closure $fn Takes no arguments
184     * @return ISearchResultSet|Status<ISearchResultSet>|null Result of calling $fn
185     */
186    private function maybePaginate( Closure $fn ) {
187        if ( $this instanceof PaginatingSearchEngine ) {
188            return $fn();
189        }
190        $this->limit++;
191        try {
192            $resultSetOrStatus = $fn();
193        } finally {
194            $this->limit--;
195        }
196
197        $resultSet = null;
198        if ( $resultSetOrStatus instanceof ISearchResultSet ) {
199            $resultSet = $resultSetOrStatus;
200        } elseif ( $resultSetOrStatus instanceof Status &&
201            $resultSetOrStatus->getValue() instanceof ISearchResultSet
202        ) {
203            $resultSet = $resultSetOrStatus->getValue();
204        }
205        if ( $resultSet ) {
206            $resultSet->shrink( $this->limit );
207        }
208
209        return $resultSetOrStatus;
210    }
211
212    /**
213     * @since 1.18
214     * @stable to override
215     *
216     * @param string $feature
217     * @return bool
218     */
219    public function supports( $feature ) {
220        switch ( $feature ) {
221            case 'search-update':
222                return true;
223            case 'title-suffix-filter':
224            default:
225                return false;
226        }
227    }
228
229    /**
230     * Way to pass custom data for engines
231     * @since 1.18
232     * @param string $feature
233     * @param mixed $data
234     */
235    public function setFeatureData( $feature, $data ) {
236        $this->features[$feature] = $data;
237    }
238
239    /**
240     * Way to retrieve custom data set by setFeatureData
241     * or by the engine itself.
242     * @since 1.29
243     * @param string $feature feature name
244     * @return mixed the feature value or null if unset
245     */
246    public function getFeatureData( $feature ) {
247        return $this->features[$feature] ?? null;
248    }
249
250    /**
251     * When overridden in derived class, performs database-specific conversions
252     * on text to be used for searching or updating search index.
253     * Default implementation does nothing (simply returns $string).
254     *
255     * @param string $string String to process
256     * @return string
257     */
258    public function normalizeText( $string ) {
259        // Some languages such as Chinese require word segmentation
260        return MediaWikiServices::getInstance()->getContentLanguage()->segmentByWord( $string );
261    }
262
263    /**
264     * Get service class to finding near matches.
265     *
266     * @return TitleMatcher
267     * @deprecated since 1.40, use MediaWikiServices::getInstance()->getTitleMatcher()
268     */
269    public function getNearMatcher( Config $config ) {
270        return MediaWikiServices::getInstance()->getTitleMatcher();
271    }
272
273    /**
274     * Get near matcher for default SearchEngine.
275     *
276     * @return TitleMatcher
277     * @deprecated since 1.40, MediaWikiServices::getInstance()->getTitleMatcher()
278     */
279    protected static function defaultNearMatcher() {
280        wfDeprecated( __METHOD__, '1.40' );
281        return MediaWikiServices::getInstance()->getTitleMatcher();
282    }
283
284    /**
285     * Get chars legal for search
286     * @param int $type type of search chars (see self::CHARS_ALL
287     * and self::CHARS_NO_SYNTAX). Defaults to CHARS_ALL
288     * @return string
289     */
290    public function legalSearchChars( $type = self::CHARS_ALL ) {
291        return "A-Za-z_'.0-9\\x80-\\xFF\\-";
292    }
293
294    /**
295     * Set the maximum number of results to return
296     * and how many to skip before returning the first.
297     *
298     * @param int $limit
299     * @param int $offset
300     */
301    public function setLimitOffset( $limit, $offset = 0 ) {
302        $this->limit = intval( $limit );
303        $this->offset = intval( $offset );
304    }
305
306    /**
307     * Set which namespaces the search should include.
308     * Give an array of namespace index numbers.
309     *
310     * @param int[]|null $namespaces
311     */
312    public function setNamespaces( $namespaces ) {
313        if ( $namespaces ) {
314            // Filter namespaces to only keep valid ones
315            $validNs = MediaWikiServices::getInstance()->getSearchEngineConfig()->searchableNamespaces();
316            $namespaces = array_filter( $namespaces, static function ( $ns ) use( $validNs ) {
317                return $ns < 0 || isset( $validNs[$ns] );
318            } );
319        } else {
320            $namespaces = [];
321        }
322        $this->namespaces = $namespaces;
323    }
324
325    /**
326     * Set whether the searcher should try to build a suggestion.  Note: some searchers
327     * don't support building a suggestion in the first place and others don't respect
328     * this flag.
329     *
330     * @param bool $showSuggestion Should the searcher try to build suggestions
331     */
332    public function setShowSuggestion( $showSuggestion ) {
333        $this->showSuggestion = $showSuggestion;
334    }
335
336    /**
337     * Get the valid sort directions.  All search engines support 'relevance' but others
338     * might support more. The default in all implementations must be 'relevance.'
339     *
340     * @since 1.25
341     * @stable to override
342     *
343     * @return string[] the valid sort directions for setSort
344     */
345    public function getValidSorts() {
346        return [ self::DEFAULT_SORT ];
347    }
348
349    /**
350     * Set the sort direction of the search results. Must be one returned by
351     * SearchEngine::getValidSorts()
352     *
353     * @since 1.25
354     * @throws InvalidArgumentException
355     * @param string $sort sort direction for query result
356     */
357    public function setSort( $sort ) {
358        if ( !in_array( $sort, $this->getValidSorts() ) ) {
359            throw new InvalidArgumentException( "Invalid sort: $sort" .
360                "Must be one of: " . implode( ', ', $this->getValidSorts() ) );
361        }
362        $this->sort = $sort;
363    }
364
365    /**
366     * Get the sort direction of the search results
367     *
368     * @since 1.25
369     * @return string
370     */
371    public function getSort() {
372        return $this->sort;
373    }
374
375    /**
376     * Parse some common prefixes: all (search everything)
377     * or namespace names and set the list of namespaces
378     * of this class accordingly.
379     *
380     * @deprecated since 1.32; should be handled internally by the search engine
381     * @param string $query
382     * @return string
383     */
384    public function replacePrefixes( $query ) {
385        return $query;
386    }
387
388    /**
389     * Parse some common prefixes: all (search everything)
390     * or namespace names
391     *
392     * @param string $query
393     * @param bool $withAllKeyword activate support of the "all:" keyword and its
394     * translations to activate searching on all namespaces.
395     * @param bool $withPrefixSearchExtractNamespaceHook call the PrefixSearchExtractNamespace hook
396     *  if classic namespace identification did not match.
397     * @return false|array false if no namespace was extracted, an array
398     * with the parsed query at index 0 and an array of namespaces at index
399     * 1 (or null for all namespaces).
400     */
401    public static function parseNamespacePrefixes(
402        $query,
403        $withAllKeyword = true,
404        $withPrefixSearchExtractNamespaceHook = false
405    ) {
406        $parsed = $query;
407        if ( strpos( $query, ':' ) === false ) { // nothing to do
408            return false;
409        }
410        $extractedNamespace = null;
411
412        $allQuery = false;
413        if ( $withAllKeyword ) {
414            $allkeywords = [];
415
416            $allkeywords[] = wfMessage( 'searchall' )->inContentLanguage()->text() . ":";
417            // force all: so that we have a common syntax for all the wikis
418            if ( !in_array( 'all:', $allkeywords ) ) {
419                $allkeywords[] = 'all:';
420            }
421
422            foreach ( $allkeywords as $kw ) {
423                if ( str_starts_with( $query, $kw ) ) {
424                    $parsed = substr( $query, strlen( $kw ) );
425                    $allQuery = true;
426                    break;
427                }
428            }
429        }
430
431        if ( !$allQuery && strpos( $query, ':' ) !== false ) {
432            $prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) );
433            $services = MediaWikiServices::getInstance();
434            $index = $services->getContentLanguage()->getNsIndex( $prefix );
435            if ( $index !== false ) {
436                $extractedNamespace = [ $index ];
437                $parsed = substr( $query, strlen( $prefix ) + 1 );
438            } elseif ( $withPrefixSearchExtractNamespaceHook ) {
439                $hookNamespaces = [ NS_MAIN ];
440                $hookQuery = $query;
441                ( new HookRunner( $services->getHookContainer() ) )
442                    ->onPrefixSearchExtractNamespace( $hookNamespaces, $hookQuery );
443                if ( $hookQuery !== $query ) {
444                    $parsed = $hookQuery;
445                    $extractedNamespace = $hookNamespaces;
446                } else {
447                    return false;
448                }
449            } else {
450                return false;
451            }
452        }
453
454        return [ $parsed, $extractedNamespace ];
455    }
456
457    /**
458     * Find snippet highlight settings for all users
459     * @return array Contextlines, contextchars
460     * @deprecated since 1.34; use the SearchHighlighter constants directly
461     * @see SearchHighlighter::DEFAULT_CONTEXT_CHARS
462     * @see SearchHighlighter::DEFAULT_CONTEXT_LINES
463     */
464    public static function userHighlightPrefs() {
465        $contextlines = SearchHighlighter::DEFAULT_CONTEXT_LINES;
466        $contextchars = SearchHighlighter::DEFAULT_CONTEXT_CHARS;
467        return [ $contextlines, $contextchars ];
468    }
469
470    /**
471     * Create or update the search index record for the given page.
472     * Title and text should be pre-processed.
473     * STUB
474     *
475     * @param int $id
476     * @param string $title
477     * @param string $text
478     */
479    public function update( $id, $title, $text ) {
480        // no-op
481    }
482
483    /**
484     * Update a search index record's title only.
485     * Title should be pre-processed.
486     * STUB
487     *
488     * @param int $id
489     * @param string $title
490     */
491    public function updateTitle( $id, $title ) {
492        // no-op
493    }
494
495    /**
496     * Delete an indexed page
497     * Title should be pre-processed.
498     * STUB
499     *
500     * @param int $id Page id that was deleted
501     * @param string $title Title of page that was deleted
502     */
503    public function delete( $id, $title ) {
504        // no-op
505    }
506
507    /**
508     * Get the raw text for updating the index from a content object
509     * Nicer search backends could possibly do something cooler than
510     * just returning raw text
511     *
512     * @todo This isn't ideal, we'd really like to have content-specific handling here
513     * @param Title $t Title we're indexing
514     * @param Content|null $c Content of the page to index
515     * @return string
516     * @deprecated since 1.34 use Content::getTextForSearchIndex directly
517     */
518    public function getTextFromContent( Title $t, Content $c = null ) {
519        return $c ? $c->getTextForSearchIndex() : '';
520    }
521
522    /**
523     * If an implementation of SearchEngine handles all of its own text processing
524     * in getTextFromContent() and doesn't require SearchUpdate::updateText()'s
525     * rather silly handling, it should return true here instead.
526     *
527     * @return bool
528     * @deprecated since 1.34 no longer needed since getTextFromContent is being deprecated
529     */
530    public function textAlreadyUpdatedForIndex() {
531        return false;
532    }
533
534    /**
535     * Makes search simple string if it was namespaced.
536     * Sets namespaces of the search to namespaces extracted from string.
537     * @param string $search
538     * @return string Simplified search string
539     */
540    protected function normalizeNamespaces( $search ) {
541        $queryAndNs = self::parseNamespacePrefixes( $search, false, true );
542        if ( $queryAndNs !== false ) {
543            $this->setNamespaces( $queryAndNs[1] );
544            return $queryAndNs[0];
545        }
546        return $search;
547    }
548
549    /**
550     * Perform an overfetch of completion search results. This allows
551     * determining if another page of results is available.
552     *
553     * @param string $search
554     * @return SearchSuggestionSet
555     */
556    protected function completionSearchBackendOverfetch( $search ) {
557        $this->limit++;
558        try {
559            return $this->completionSearchBackend( $search );
560        } finally {
561            $this->limit--;
562        }
563    }
564
565    /**
566     * Perform a completion search.
567     * Does not resolve namespaces and does not check variants.
568     * Search engine implementations may want to override this function.
569     *
570     * @stable to override
571     *
572     * @param string $search
573     * @return SearchSuggestionSet
574     */
575    protected function completionSearchBackend( $search ) {
576        $results = [];
577
578        $search = trim( $search );
579
580        if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
581            !$this->getHookRunner()->onPrefixSearchBackend(
582                $this->namespaces, $search, $this->limit, $results, $this->offset )
583        ) {
584            // False means hook worked.
585            // FIXME: Yes, the API is weird. That's why it is going to be deprecated.
586
587            return SearchSuggestionSet::fromStrings( $results );
588        } else {
589            // Hook did not do the job, use default simple search
590            $results = $this->simplePrefixSearch( $search );
591            return SearchSuggestionSet::fromTitles( $results );
592        }
593    }
594
595    /**
596     * Perform a completion search.
597     * @param string $search
598     * @return SearchSuggestionSet
599     */
600    public function completionSearch( $search ) {
601        if ( trim( $search ) === '' ) {
602            return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
603        }
604        $search = $this->normalizeNamespaces( $search );
605        $suggestions = $this->completionSearchBackendOverfetch( $search );
606        return $this->processCompletionResults( $search, $suggestions );
607    }
608
609    /**
610     * Perform a completion search with variants.
611     * @stable to override
612     *
613     * @param string $search
614     * @return SearchSuggestionSet
615     */
616    public function completionSearchWithVariants( $search ) {
617        if ( trim( $search ) === '' ) {
618            return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
619        }
620        $search = $this->normalizeNamespaces( $search );
621
622        $results = $this->completionSearchBackendOverfetch( $search );
623        $fallbackLimit = 1 + $this->limit - $results->getSize();
624        if ( $fallbackLimit > 0 ) {
625            $services = MediaWikiServices::getInstance();
626            $fallbackSearches = $services->getLanguageConverterFactory()
627                ->getLanguageConverter( $services->getContentLanguage() )
628                ->autoConvertToAllVariants( $search );
629            $fallbackSearches = array_diff( array_unique( $fallbackSearches ), [ $search ] );
630
631            foreach ( $fallbackSearches as $fbs ) {
632                $this->setLimitOffset( $fallbackLimit );
633                $fallbackSearchResult = $this->completionSearch( $fbs );
634                $results->appendAll( $fallbackSearchResult );
635                $fallbackLimit -= $fallbackSearchResult->getSize();
636                if ( $fallbackLimit <= 0 ) {
637                    break;
638                }
639            }
640        }
641        return $this->processCompletionResults( $search, $results );
642    }
643
644    /**
645     * Extract titles from completion results
646     * @param SearchSuggestionSet $completionResults
647     * @return Title[]
648     */
649    public function extractTitles( SearchSuggestionSet $completionResults ) {
650        return $completionResults->map( static function ( SearchSuggestion $sugg ) {
651            return $sugg->getSuggestedTitle();
652        } );
653    }
654
655    /**
656     * Process completion search results.
657     * Resolves the titles and rescores.
658     * @param string $search
659     * @param SearchSuggestionSet $suggestions
660     * @return SearchSuggestionSet
661     */
662    protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
663        // We over-fetched to determine pagination. Shrink back down if we have extra results
664        // and mark if pagination is possible
665        $suggestions->shrink( $this->limit );
666
667        $search = trim( $search );
668        // preload the titles with LinkBatch
669        $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
670        $lb = $linkBatchFactory->newLinkBatch( $suggestions->map( static function ( SearchSuggestion $sugg ) {
671            return $sugg->getSuggestedTitle();
672        } ) );
673        $lb->setCaller( __METHOD__ );
674        $lb->execute();
675
676        $diff = $suggestions->filter( static function ( SearchSuggestion $sugg ) {
677            return $sugg->getSuggestedTitle()->isKnown();
678        } );
679        if ( $diff > 0 ) {
680            MediaWikiServices::getInstance()->getStatsdDataFactory()
681                ->updateCount( 'search.completion.missing', $diff );
682        }
683
684        // SearchExactMatchRescorer should probably be refactored to work directly on top of a SearchSuggestionSet
685        // instead of converting it to array and trying to infer if it has re-scored anything by inspected the head
686        // of the returned array.
687        $results = $suggestions->map( static function ( SearchSuggestion $sugg ) {
688            return $sugg->getSuggestedTitle()->getPrefixedText();
689        } );
690
691        $rescorer = new SearchExactMatchRescorer();
692        if ( $this->offset === 0 ) {
693            // Rescore results with an exact title match
694            // NOTE: in some cases like cross-namespace redirects
695            // (frequently used as shortcuts e.g. WP:WP on huwiki) some
696            // backends like Cirrus will return no results. We should still
697            // try an exact title match to workaround this limitation
698            $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
699        } else {
700            // No need to rescore if offset is not 0
701            // The exact match must have been returned at position 0
702            // if it existed.
703            $rescoredResults = $results;
704        }
705
706        if ( count( $rescoredResults ) > 0 ) {
707            $found = array_search( $rescoredResults[0], $results );
708            if ( $found === false ) {
709                // If the first result is not in the previous array it
710                // means that we found a new exact match
711                $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
712                $suggestions->prepend( $exactMatch );
713                if ( $rescorer->getReplacedRedirect() !== null ) {
714                    // the exact match rescorer replaced one of the suggestion found by the search engine
715                    // let's remove it from our suggestions set to avoid showing duplicates
716                    $suggestions->remove( SearchSuggestion::fromTitle( 0,
717                        Title::newFromText( $rescorer->getReplacedRedirect() ) ) );
718                }
719                $suggestions->shrink( $this->limit );
720            } else {
721                // if the first result is not the same we need to rescore
722                if ( $found > 0 ) {
723                    $suggestions->rescore( $found );
724                }
725            }
726        }
727
728        return $suggestions;
729    }
730
731    /**
732     * Simple prefix search for subpages.
733     * @param string $search
734     * @return Title[]
735     */
736    public function defaultPrefixSearch( $search ) {
737        if ( trim( $search ) === '' ) {
738            return [];
739        }
740
741        $search = $this->normalizeNamespaces( $search );
742        return $this->simplePrefixSearch( $search );
743    }
744
745    /**
746     * Call out to simple search backend.
747     * Defaults to TitlePrefixSearch.
748     * @param string $search
749     * @return Title[]
750     */
751    protected function simplePrefixSearch( $search ) {
752        // Use default database prefix search
753        $backend = new TitlePrefixSearch;
754        return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
755    }
756
757    /**
758     * Get a list of supported profiles.
759     * Some search engine implementations may expose specific profiles to fine-tune
760     * its behaviors.
761     * The profile can be passed as a feature data with setFeatureData( $profileType, $profileName )
762     * The array returned by this function contains the following keys:
763     * - name: the profile name to use with setFeatureData
764     * - desc-message: the i18n description
765     * - default: set to true if this profile is the default
766     *
767     * @since 1.28
768     * @stable to override
769     *
770     * @param string $profileType the type of profiles
771     * @param User|null $user the user requesting the list of profiles
772     * @return array|null the list of profiles or null if none available
773     * @phan-return null|array{name:string,desc-message:string,default?:bool}
774     */
775    public function getProfiles( $profileType, User $user = null ) {
776        return null;
777    }
778
779    /**
780     * Create a search field definition.
781     * Specific search engines should override this method to create search fields.
782     * @stable to override
783     *
784     * @param string $name
785     * @param string $type One of the types in SearchIndexField::INDEX_TYPE_*
786     * @return SearchIndexField
787     * @since 1.28
788     */
789    public function makeSearchFieldMapping( $name, $type ) {
790        return new NullIndexField();
791    }
792
793    /**
794     * Get fields for search index
795     * @since 1.28
796     * @return SearchIndexField[] Index field definitions for all content handlers
797     */
798    public function getSearchIndexFields() {
799        $models = MediaWikiServices::getInstance()->getContentHandlerFactory()->getContentModels();
800        $fields = [];
801        $seenHandlers = new SplObjectStorage();
802        foreach ( $models as $model ) {
803            try {
804                $handler = MediaWikiServices::getInstance()
805                    ->getContentHandlerFactory()
806                    ->getContentHandler( $model );
807            } catch ( MWUnknownContentModelException $e ) {
808                // If we can find no handler, ignore it
809                continue;
810            }
811            // Several models can have the same handler, so avoid processing it repeatedly
812            if ( $seenHandlers->contains( $handler ) ) {
813                // We already did this one
814                continue;
815            }
816            $seenHandlers->attach( $handler );
817            $handlerFields = $handler->getFieldsForSearchIndex( $this );
818            foreach ( $handlerFields as $fieldName => $fieldData ) {
819                if ( empty( $fields[$fieldName] ) ) {
820                    $fields[$fieldName] = $fieldData;
821                } else {
822                    // TODO: do we allow some clashes with the same type or reject all of them?
823                    $mergeDef = $fields[$fieldName]->merge( $fieldData );
824                    if ( !$mergeDef ) {
825                        throw new InvalidArgumentException( "Duplicate field $fieldName for model $model" );
826                    }
827                    $fields[$fieldName] = $mergeDef;
828                }
829            }
830        }
831        // Hook to allow extensions to produce search mapping fields
832        $this->getHookRunner()->onSearchIndexFields( $fields, $this );
833        return $fields;
834    }
835
836    /**
837     * Augment search results with extra data.
838     *
839     * @param ISearchResultSet $resultSet
840     */
841    public function augmentSearchResults( ISearchResultSet $resultSet ) {
842        $setAugmentors = [];
843        $rowAugmentors = [];
844        $this->getHookRunner()->onSearchResultsAugment( $setAugmentors, $rowAugmentors );
845        if ( !$setAugmentors && !$rowAugmentors ) {
846            // We're done here
847            return;
848        }
849
850        // Convert row augmentors to set augmentor
851        foreach ( $rowAugmentors as $name => $row ) {
852            if ( isset( $setAugmentors[$name] ) ) {
853                throw new InvalidArgumentException( "Both row and set augmentors are defined for $name" );
854            }
855            $setAugmentors[$name] = new PerRowAugmentor( $row );
856        }
857
858        /**
859         * @var string $name
860         * @var ResultSetAugmentor $augmentor
861         */
862        foreach ( $setAugmentors as $name => $augmentor ) {
863            $data = $augmentor->augmentAll( $resultSet );
864            if ( $data ) {
865                $resultSet->setAugmentedData( $name, $data );
866            }
867        }
868    }
869
870    /**
871     * @since 1.35
872     * @internal
873     * @param HookContainer $hookContainer
874     */
875    public function setHookContainer( HookContainer $hookContainer ) {
876        $this->hookContainer = $hookContainer;
877        $this->hookRunner = new HookRunner( $hookContainer );
878    }
879
880    /**
881     * Get a HookContainer, for running extension hooks or for hook metadata.
882     *
883     * @since 1.35
884     * @return HookContainer
885     */
886    protected function getHookContainer(): HookContainer {
887        if ( !$this->hookContainer ) {
888            // This shouldn't be hit in core, but it is needed for CirrusSearch
889            // which commonly creates a CirrusSearch object without cirrus being
890            // configured in $wgSearchType/$wgSearchTypeAlternatives.
891            $this->hookContainer = MediaWikiServices::getInstance()->getHookContainer();
892        }
893        return $this->hookContainer;
894    }
895
896    /**
897     * Get a HookRunner for running core hooks.
898     *
899     * @internal This is for use by core only. Hook interfaces may be removed
900     *   without notice.
901     * @since 1.35
902     * @return HookRunner
903     */
904    protected function getHookRunner(): HookRunner {
905        if ( !$this->hookRunner ) {
906            $this->hookRunner = new HookRunner( $this->getHookContainer() );
907        }
908        return $this->hookRunner;
909    }
910
911}