Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.14% covered (success)
97.14%
68 / 70
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
TitleMatcher
97.14% covered (success)
97.14%
68 / 70
75.00% covered (warning)
75.00%
3 / 4
35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getNearMatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getNearMatchResultSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNearMatchInternal
96.49% covered (success)
96.49%
55 / 57
0.00% covered (danger)
0.00%
0 / 1
32
1<?php
2namespace MediaWiki\Search;
3
4use ILanguageConverter;
5use ISearchResultSet;
6use Language;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\HookContainer\HookContainer;
9use MediaWiki\HookContainer\HookRunner;
10use MediaWiki\Languages\LanguageConverterFactory;
11use MediaWiki\MainConfigNames;
12use MediaWiki\Page\WikiPageFactory;
13use MediaWiki\SpecialPage\SpecialPage;
14use MediaWiki\Title\Title;
15use MediaWiki\Title\TitleFactory;
16use MediaWiki\User\UserNameUtils;
17use RepoGroup;
18use SearchNearMatchResultSet;
19
20/**
21 * Service implementation of near match title search.
22 */
23class TitleMatcher {
24    /**
25     * @internal For use by ServiceWiring.
26     * @var string[]
27     */
28    public const CONSTRUCTOR_OPTIONS = [
29        MainConfigNames::EnableSearchContributorsByIP,
30    ];
31
32    /**
33     * @var ServiceOptions
34     */
35    private $options;
36
37    /**
38     * Current language
39     * @var Language
40     */
41    private $language;
42
43    /**
44     * Current language converter
45     * @var ILanguageConverter
46     */
47    private $languageConverter;
48
49    /**
50     * @var HookRunner
51     */
52    private $hookRunner;
53
54    /**
55     * @var WikiPageFactory
56     */
57    private $wikiPageFactory;
58
59    /**
60     * @var UserNameUtils
61     */
62    private $userNameUtils;
63
64    /**
65     * @var RepoGroup
66     */
67    private $repoGroup;
68
69    private TitleFactory $titleFactory;
70
71    /**
72     * @param ServiceOptions $options
73     * @param Language $contentLanguage
74     * @param LanguageConverterFactory $languageConverterFactory
75     * @param HookContainer $hookContainer
76     * @param WikiPageFactory $wikiPageFactory
77     * @param UserNameUtils $userNameUtils
78     * @param RepoGroup $repoGroup
79     * @param TitleFactory $titleFactory
80     */
81    public function __construct(
82        ServiceOptions $options,
83        Language $contentLanguage,
84        LanguageConverterFactory $languageConverterFactory,
85        HookContainer $hookContainer,
86        WikiPageFactory $wikiPageFactory,
87        UserNameUtils $userNameUtils,
88        RepoGroup $repoGroup,
89        TitleFactory $titleFactory
90    ) {
91        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
92        $this->options = $options;
93
94        $this->language = $contentLanguage;
95        $this->languageConverter = $languageConverterFactory->getLanguageConverter( $contentLanguage );
96        $this->hookRunner = new HookRunner( $hookContainer );
97        $this->wikiPageFactory = $wikiPageFactory;
98        $this->userNameUtils = $userNameUtils;
99        $this->repoGroup = $repoGroup;
100        $this->titleFactory = $titleFactory;
101    }
102
103    /**
104     * If an exact title match can be found, or a very slightly close match,
105     * return the title. If no match, returns NULL.
106     *
107     * @param string $searchterm
108     * @return Title
109     */
110    public function getNearMatch( $searchterm ) {
111        $title = $this->getNearMatchInternal( $searchterm );
112
113        $this->hookRunner->onSearchGetNearMatchComplete( $searchterm, $title );
114        return $title;
115    }
116
117    /**
118     * Do a near match (see SearchEngine::getNearMatch) and wrap it into a
119     * ISearchResultSet.
120     *
121     * @param string $searchterm
122     * @return ISearchResultSet
123     */
124    public function getNearMatchResultSet( $searchterm ) {
125        return new SearchNearMatchResultSet( $this->getNearMatch( $searchterm ) );
126    }
127
128    /**
129     * Really find the title match.
130     * @param string $searchterm
131     * @return null|Title
132     */
133    protected function getNearMatchInternal( $searchterm ) {
134        $allSearchTerms = [ $searchterm ];
135
136        if ( $this->languageConverter->hasVariants() ) {
137            $allSearchTerms = array_unique( array_merge(
138                $allSearchTerms,
139                $this->languageConverter->autoConvertToAllVariants( $searchterm )
140            ) );
141        }
142
143        $titleResult = null;
144        if ( !$this->hookRunner->onSearchGetNearMatchBefore( $allSearchTerms, $titleResult ) ) {
145            return $titleResult;
146        }
147
148        // Most of our handling here deals with finding a valid title for the search term,
149        // but almost anything starting with '#' is "valid" and points to Main_Page#searchterm.
150        // Rather than doing something completely wrong, do nothing.
151        if ( $searchterm === '' || $searchterm[0] === '#' ) {
152            return null;
153        }
154
155        foreach ( $allSearchTerms as $term ) {
156            # Exact match? No need to look further.
157            $title = $this->titleFactory->newFromText( $term );
158            if ( $title === null ) {
159                return null;
160            }
161
162            # Try files if searching in the Media: namespace
163            if ( $title->getNamespace() === NS_MEDIA ) {
164                $title = Title::makeTitle( NS_FILE, $title->getText() );
165            }
166
167            if ( $title->isSpecialPage() || $title->isExternal() || $title->exists() ) {
168                return $title;
169            }
170
171            # See if it still otherwise has content is some sensible sense
172            if ( $title->canExist() ) {
173                $page = $this->wikiPageFactory->newFromTitle( $title );
174                if ( $page->hasViewableContent() ) {
175                    return $title;
176                }
177            }
178
179            if ( !$this->hookRunner->onSearchAfterNoDirectMatch( $term, $title ) ) {
180                return $title;
181            }
182
183            # Now try all lower case (i.e. first letter capitalized)
184            $title = $this->titleFactory->newFromText( $this->language->lc( $term ) );
185            if ( $title && $title->exists() ) {
186                return $title;
187            }
188
189            # Now try capitalized string
190            $title = $this->titleFactory->newFromText( $this->language->ucwords( $term ) );
191            if ( $title && $title->exists() ) {
192                return $title;
193            }
194
195            # Now try all upper case
196            $title = $this->titleFactory->newFromText( $this->language->uc( $term ) );
197            if ( $title && $title->exists() ) {
198                return $title;
199            }
200
201            # Now try Word-Caps-Breaking-At-Word-Breaks, for hyphenated names etc
202            $title = $this->titleFactory->newFromText( $this->language->ucwordbreaks( $term ) );
203            if ( $title && $title->exists() ) {
204                return $title;
205            }
206
207            // Give hooks a chance at better match variants
208            $title = null;
209            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
210            if ( !$this->hookRunner->onSearchGetNearMatch( $term, $title ) ) {
211                return $title;
212            }
213        }
214
215        $title = $this->titleFactory->newFromTextThrow( $searchterm );
216
217        # Entering an IP address goes to the contributions page
218        if ( $this->options->get( MainConfigNames::EnableSearchContributorsByIP ) ) {
219            if ( ( $title->getNamespace() === NS_USER && $this->userNameUtils->isIP( $title->getText() ) )
220                || $this->userNameUtils->isIP( trim( $searchterm ) ) ) {
221                return SpecialPage::getTitleFor( 'Contributions', $title->getDBkey() );
222            }
223        }
224
225        # Entering a user goes to the user page whether it's there or not
226        if ( $title->getNamespace() === NS_USER ) {
227            return $title;
228        }
229
230        # Go to images that exist even if there's no local page.
231        # There may have been a funny upload, or it may be on a shared
232        # file repository such as Wikimedia Commons.
233        if ( $title->getNamespace() === NS_FILE ) {
234            $image = $this->repoGroup->findFile( $title );
235            if ( $image ) {
236                return $title;
237            }
238        }
239
240        # MediaWiki namespace? Page may be "implied" if not customized.
241        # Just return it, with caps forced as the message system likes it.
242        if ( $title->getNamespace() === NS_MEDIAWIKI ) {
243            return Title::makeTitle( NS_MEDIAWIKI, $this->language->ucfirst( $title->getText() ) );
244        }
245
246        # Quoted term? Try without the quotes...
247        $matches = [];
248        if ( preg_match( '/^"([^"]+)"$/', $searchterm, $matches ) ) {
249            return $this->getNearMatch( $matches[1] );
250        }
251
252        return null;
253    }
254}