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