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.