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