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