Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.29% covered (warning)
73.29%
118 / 161
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Title
73.29% covered (warning)
73.29%
118 / 161
17.65% covered (danger)
17.65%
3 / 17
170.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newFromText
97.00% covered (success)
97.00%
97 / 100
0.00% covered (danger)
0.00%
0 / 1
42
 getInterwiki
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getKey
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getDBkey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrefixedDBKey
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getPrefixedText
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getFullText
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFullDBKey
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getNamespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNamespaceName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFragment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 equals
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 isSpecialPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fixSpecialName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 createFragmentTarget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 newFromLinkTarget
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\IPUtils;
8use Wikimedia\Parsoid\Config\SiteConfig;
9use Wikimedia\Parsoid\Core\LinkTarget;
10use Wikimedia\Parsoid\Core\LinkTargetTrait;
11
12class Title implements LinkTarget {
13    use LinkTargetTrait;
14
15    /** @var string */
16    private $interwiki;
17
18    /** @var int */
19    private $namespaceId;
20
21    /** @var string */
22    private $namespaceName;
23
24    /** @var string */
25    private $dbkey;
26
27    /** @var string */
28    private $fragment;
29
30    // cached values of prefixed title/key
31    private ?string $prefixedDBKey = null;
32    private ?string $prefixedText = null;
33
34    /**
35     * @param string $interwiki Interwiki prefix, or empty string if none
36     * @param string $key Page DBkey (with underscores, not spaces)
37     * @param int $namespaceId
38     * @param string $namespaceName (with spaces, not underscores)
39     * @param ?string $fragment
40     */
41    private function __construct(
42        string $interwiki, string $key, int $namespaceId, string $namespaceName, ?string $fragment = null
43    ) {
44        $this->interwiki = $interwiki;
45        $this->dbkey = $key;
46        $this->namespaceId = $namespaceId;
47        $this->namespaceName = $namespaceName;
48        $this->fragment = $fragment ?? '';
49    }
50
51    public static function newFromText(
52        string $title, SiteConfig $siteConfig, ?int $defaultNs = null
53    ): Title {
54        if ( $defaultNs === null ) {
55            $defaultNs = 0;
56        }
57        $origTitle = $title;
58
59        if ( !mb_check_encoding( $title, 'UTF-8' ) ) {
60            throw new TitleException( "Bad UTF-8 in title \"$origTitle\"", 'title-invalid-utf8', $origTitle );
61        }
62
63        // Strip Unicode bidi override characters.
64        $title = preg_replace( '/[\x{200E}\x{200F}\x{202A}-\x{202E}]+/u', '', $title );
65        if ( $title === null ) {
66            throw new TitleException( "Bad UTF-8 in title \"$origTitle\"", 'title-invalid-utf8', $origTitle );
67        }
68
69        // Clean up whitespace
70        $title = preg_replace(
71            '/[ _\x{00A0}\x{1680}\x{180E}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}]+/u',
72            '_', $title
73        );
74        // Trim _ from beginning and end
75        $title = trim( $title, '_' );
76
77        if ( str_contains( $title, \UtfNormal\Constants::UTF8_REPLACEMENT ) ) {
78            throw new TitleException( "Bad UTF-8 in title \"$title\"", 'title-invalid-utf8', $title );
79        }
80
81        // Initial colon indicates main namespace rather than specified default
82        // but should not create invalid {ns,title} pairs such as {0,Project:Foo}
83        if ( $title !== '' && $title[0] === ':' ) {
84            $title = ltrim( substr( $title, 1 ), '_' );
85            $defaultNs = 0;
86        }
87
88        if ( $title === '' ) {
89            throw new TitleException( 'Empty title', 'title-invalid-empty', $title );
90        }
91
92        $ns = $defaultNs;
93        $interwiki = null;
94
95        # Namespace or interwiki prefix
96        $prefixRegexp = "/^(.+?)_*:_*(.*)$/S";
97        // MediaWikiTitleCodec::splitTitleString wraps a loop around the
98        // next section, to allow it to repeat this prefix processing if
99        // an interwiki prefix is found which points at the local wiki.
100        $m = [];
101        if ( preg_match( $prefixRegexp, $title, $m ) ) {
102            $p = $m[1];
103            $pLower = mb_strtolower( $p );
104            $nsId = $siteConfig->canonicalNamespaceId( $pLower ) ??
105                $siteConfig->namespaceId( $pLower );
106            if ( $nsId !== null ) {
107                $title = $m[2];
108                $ns = $nsId;
109                # For Talk:X pages, check if X has a "namespace" prefix
110                if (
111                    $nsId === $siteConfig->canonicalNamespaceId( 'talk' ) &&
112                    preg_match( $prefixRegexp, $title, $x )
113                ) {
114                    $xLower = mb_strtolower( $x[1] );
115                    if ( $siteConfig->namespaceId( $xLower ) ) {
116                        // Disallow Talk:File:x type titles.
117                        throw new TitleException(
118                            "Invalid Talk namespace title \"$origTitle\"", 'title-invalid-talk-namespace', $title
119                        );
120                    } elseif ( $siteConfig->interwikiMapNoNamespaces()[$xLower] ?? null ) {
121                        // Disallow Talk:Interwiki:x type titles.
122                        throw new TitleException(
123                            "Invalid Talk namespace title \"$origTitle\"", 'title-invalid-talk-namespace', $title
124                        );
125                    }
126                }
127            } elseif ( $siteConfig->interwikiMapNoNamespaces()[$pLower] ?? null ) {
128                # Interwiki link
129                $title = $m[2];
130                $interwiki = $pLower;
131
132                # We don't check for a redundant interwiki prefix to the
133                # local wiki, like core does here in
134                # MediaWikiTitleCodec::splitTitleString;
135                # core then does a `continue` to repeat the processing
136
137                // If there's an initial colon after the interwiki, that also
138                // resets the default namespace
139                if ( $title !== '' && $title[0] === ':' ) {
140                    $title = trim( substr( $title, 1 ), '_' );
141                    $ns = 0;
142                }
143            }
144            # If there's no recognized interwiki or namespace,
145            # then let the colon expression be part of the title
146        }
147
148        $fragment = null;
149        $fragmentIndex = strpos( $title, '#' );
150        if ( $fragmentIndex !== false ) {
151            $fragment = substr( $title, $fragmentIndex + 1 );
152            $title = rtrim( substr( $title, 0, $fragmentIndex ), '_' );
153        }
154
155        $illegalCharsRe = '/[^' . $siteConfig->legalTitleChars() . ']'
156            // URL percent encoding sequences interfere with the ability
157            // to round-trip titles -- you can't link to them consistently.
158            . '|%[0-9A-Fa-f]{2}'
159            // XML/HTML character references produce similar issues.
160            . '|&[A-Za-z0-9\x80-\xff]+;/S';
161        if ( preg_match( $illegalCharsRe, $title ) ) {
162            throw new TitleException(
163                "Invalid characters in title \"$origTitle\"", 'title-invalid-characters', $title
164            );
165        }
166
167        // Pages with "/./" or "/../" appearing in the URLs will often be
168        // unreachable due to the way web browsers deal with 'relative' URLs.
169        // Also, they conflict with subpage syntax. Forbid them explicitly.
170        if ( str_contains( $title, '.' ) && (
171            $title === '.' || $title === '..' ||
172            str_starts_with( $title, './' ) ||
173            str_starts_with( $title, '../' ) ||
174            str_contains( $title, '/./' ) ||
175            str_contains( $title, '/../' ) ||
176            str_ends_with( $title, '/.' ) ||
177            str_ends_with( $title, '/..' )
178        ) ) {
179            throw new TitleException(
180                "Title \"$origTitle\" contains relative path components", 'title-invalid-relative', $title
181            );
182        }
183
184        // Magic tilde sequences? Nu-uh!
185        if ( str_contains( $title, '~~~' ) ) {
186            throw new TitleException(
187                "Title \"$origTitle\" contains ~~~", 'title-invalid-magic-tilde', $title
188            );
189        }
190
191        $maxLength = $ns === $siteConfig->canonicalNamespaceId( 'special' ) ? 512 : 255;
192        if ( strlen( $title ) > $maxLength ) {
193            throw new TitleException(
194                "Title \"$origTitle\" is too long", 'title-invalid-too-long', $title
195            );
196        }
197
198        if ( $interwiki === null && $siteConfig->namespaceCase( $ns ) === 'first-letter' ) {
199            $title = $siteConfig->ucfirst( $title );
200        }
201
202        # Can't make a link to a namespace alone... "empty" local links can only be
203        # self-links with a fragment identifier.
204        if ( $title === '' && $interwiki === null && $ns !== $siteConfig->canonicalNamespaceId( '' ) ) {
205            throw new TitleException( 'Empty title', 'title-invalid-empty', $title );
206        }
207
208        // This is from MediaWikiTitleCodec::splitTitleString() in core
209        if ( $title !== '' && ( # T329690
210            $ns === $siteConfig->canonicalNamespaceId( 'user' ) ||
211            $ns === $siteConfig->canonicalNamespaceId( 'user_talk' )
212        ) ) {
213            $title = IPUtils::sanitizeIP( $title );
214        }
215
216        // Any remaining initial :s are illegal.
217        if ( $title !== '' && $title[0] == ':' ) {
218            throw new TitleException(
219                'Leading colon title', 'title-invalid-leading-colon', $title
220            );
221        }
222
223        // This is not in core's splitTitleString but matches
224        // mediawiki-title's newFromText.
225        if ( $ns === $siteConfig->canonicalNamespaceId( 'special' ) ) {
226            $title = self::fixSpecialName( $siteConfig, $title );
227        }
228
229        $namespaceName = $siteConfig->namespaceName( $ns );
230        return new self( $interwiki ?? '', $title, $ns, $namespaceName, $fragment );
231    }
232
233    /**
234     * The interwiki component of this LinkTarget.
235     * This is the empty string if there is no interwiki component.
236     *
237     * @return string
238     */
239    public function getInterwiki(): string {
240        return $this->interwiki;
241    }
242
243    /**
244     * Get the DBkey, prefixed with interwiki prefix if any.
245     * This is Parsoid's convention, which differs from core;
246     * use ::getDBkey() for a method compatible with core's
247     * convention.
248     *
249     * @return string
250     * @see ::getDBkey()
251     * @deprecated
252     */
253    public function getKey(): string {
254        if ( $this->interwiki ) {
255            return $this->interwiki . ':' . $this->dbkey;
256        }
257        return $this->dbkey;
258    }
259
260    /**
261     * Get the main part of the link target, in canonical database form.
262     *
263     * The main part is the link target without namespace prefix or hash fragment.
264     * The database form means that spaces become underscores, this is also
265     * used for URLs.
266     *
267     * @return string
268     */
269    public function getDBkey(): string {
270        return $this->dbkey;
271    }
272
273    /**
274     * Get the prefixed DBkey
275     * @return string
276     */
277    public function getPrefixedDBKey(): string {
278        if ( $this->prefixedDBKey === null ) {
279            $this->prefixedDBKey = $this->interwiki === '' ? '' :
280                ( $this->interwiki . ':' );
281            $this->prefixedDBKey .= $this->namespaceName === '' ? '' :
282                ( strtr( $this->namespaceName, ' ', '_' ) . ':' );
283            $this->prefixedDBKey .= $this->getDBkey();
284        }
285        return $this->prefixedDBKey;
286    }
287
288    /**
289     * Get the prefixed text
290     * @return string
291     */
292    public function getPrefixedText(): string {
293        if ( $this->prefixedText === null ) {
294            $this->prefixedText = $this->interwiki === '' ? '' :
295                ( $this->interwiki . ':' );
296            $this->prefixedText .= $this->namespaceName === '' ? '' :
297                ( $this->namespaceName . ':' );
298            $this->prefixedText .= $this->getText();
299        }
300        return $this->prefixedText;
301    }
302
303    /**
304     * Get the prefixed title with spaces, plus any fragment
305     * (part beginning with '#')
306     *
307     * @return string The prefixed title, with spaces and the fragment, including '#'
308     */
309    public function getFullText(): string {
310        $text = $this->getPrefixedText();
311        if ( $this->hasFragment() ) {
312            $text .= '#' . $this->getFragment();
313        }
314        return $text;
315    }
316
317    /**
318     * Get the prefixed title with underscores, plus any fragment
319     * (part beginning with '#')
320     *
321     * @return string The prefixed title, with underscores, and the fragment, including '#'
322     * @note This method is Parsoid-only and doesn't exist in mediawiki-core's
323     *  Title class.
324     */
325    public function getFullDBKey(): string {
326        $dbkey = $this->getPrefixedDBKey();
327        if ( $this->hasFragment() ) {
328            $dbkey .= '#' . $this->getFragment();
329        }
330        return $dbkey;
331    }
332
333    /**
334     * Get the namespace ID
335     * @return int
336     */
337    public function getNamespace(): int {
338        return $this->namespaceId;
339    }
340
341    /**
342     * Get the human-readable name for the namespace
343     * (with spaces, not underscores).
344     * @return string
345     */
346    public function getNamespaceName(): string {
347        return $this->namespaceName;
348    }
349
350    /**
351     * Get the link fragment in text form (i.e. the bit after the hash `#`).
352     *
353     * @return string link fragment
354     */
355    public function getFragment(): string {
356        return $this->fragment ?? '';
357    }
358
359    /**
360     * Compare with another title.
361     *
362     * @param Title $title
363     * @return bool
364     */
365    public function equals( Title $title ) {
366        return $this->getNamespace() === $title->getNamespace() &&
367            $this->getInterwiki() === $title->getInterwiki() &&
368            $this->getDBkey() === $title->getDBkey();
369    }
370
371    /**
372     * Returns true if this is a special page.
373     *
374     * @return bool
375     */
376    public function isSpecialPage() {
377        return $this->getNamespace() === -1; // NS_SPECIAL;
378    }
379
380    /**
381     * Use the default special page alias.
382     *
383     * @param SiteConfig $siteConfig
384     * @param string $title
385     * @return string
386     */
387    public static function fixSpecialName(
388        SiteConfig $siteConfig, string $title
389    ): string {
390        $parts = explode( '/', $title, 2 );
391        $specialName = $siteConfig->specialPageLocalName( $parts[0] );
392        if ( $specialName !== null ) {
393            $parts[0] = $specialName;
394            $title = implode( '/', $parts );
395        }
396        return $title;
397    }
398
399    /**
400     * Create a new LinkTarget with a different fragment on the same page.
401     *
402     * It is expected that the same type of object will be returned, but the
403     * only requirement is that it is a LinkTarget.
404     *
405     * @param string $fragment The fragment override, or "" to remove it.
406     *
407     * @return self
408     */
409    public function createFragmentTarget( string $fragment ) {
410        return new self( $this->interwiki, $this->dbkey, $this->namespaceId, $this->namespaceName, $fragment ?: null );
411    }
412
413    /**
414     * Convert LinkTarget from core (or other implementation) into a
415     * Parsoid Title.
416     *
417     * @param LinkTarget $linkTarget
418     * @return self
419     */
420    public static function newFromLinkTarget(
421        LinkTarget $linkTarget, SiteConfig $siteConfig
422    ) {
423        if ( $linkTarget instanceof Title ) {
424            return $linkTarget;
425        }
426        $ns = $linkTarget->getNamespace();
427        $namespaceName = $siteConfig->namespaceName( $ns );
428        Assert::invariant(
429            $namespaceName !== null,
430            "Badtitle ({$linkTarget}) in unknown namespace ({$ns})"
431        );
432        return new self(
433            $linkTarget->getInterwiki(),
434            $linkTarget->getDBkey(),
435            $linkTarget->getNamespace(),
436            $namespaceName,
437            $linkTarget->getFragment()
438        );
439    }
440}