Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.08% covered (warning)
78.08%
114 / 146
33.33% covered (danger)
33.33%
5 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Title
78.08% covered (warning)
78.08%
114 / 146
33.33% covered (danger)
33.33%
5 / 15
109.49
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
96.94% covered (success)
96.94%
95 / 98
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
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getPrefixedText
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 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 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 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            $nsId = $siteConfig->canonicalNamespaceId( $p ) ??
104                $siteConfig->namespaceId( $p );
105            if ( $nsId !== null ) {
106                $title = $m[2];
107                $ns = $nsId;
108                # For Talk:X pages, check if X has a "namespace" prefix
109                if (
110                    $nsId === $siteConfig->canonicalNamespaceId( 'talk' ) &&
111                    preg_match( $prefixRegexp, $title, $x )
112                ) {
113                    if ( $siteConfig->namespaceId( $x[1] ) ) {
114                        // Disallow Talk:File:x type titles.
115                        throw new TitleException(
116                            "Invalid Talk namespace title \"$origTitle\"", 'title-invalid-talk-namespace', $title
117                        );
118                    } elseif ( $siteConfig->interwikiMapNoNamespaces()[$x[1]] ?? null ) {
119                        // Disallow Talk:Interwiki:x type titles.
120                        throw new TitleException(
121                            "Invalid Talk namespace title \"$origTitle\"", 'title-invalid-talk-namespace', $title
122                        );
123                    }
124                }
125            } elseif ( $siteConfig->interwikiMapNoNamespaces()[$p] ?? null ) {
126                # Interwiki link
127                $title = $m[2];
128                $interwiki = strtolower( $p );
129
130                # We don't check for a redundant interwiki prefix to the
131                # local wiki, like core does here in
132                # MediaWikiTitleCodec::splitTitleString;
133                # core then does a `continue` to repeat the processing
134
135                // If there's an initial colon after the interwiki, that also
136                // resets the default namespace
137                if ( $title !== '' && $title[0] === ':' ) {
138                    $title = trim( substr( $title, 1 ), '_' );
139                    $ns = 0;
140                }
141            }
142            # If there's no recognized interwiki or namespace,
143            # then let the colon expression be part of the title
144        }
145
146        $fragment = null;
147        $fragmentIndex = strpos( $title, '#' );
148        if ( $fragmentIndex !== false ) {
149            $fragment = substr( $title, $fragmentIndex + 1 );
150            $title = rtrim( substr( $title, 0, $fragmentIndex ), '_' );
151        }
152
153        $illegalCharsRe = '/[^' . $siteConfig->legalTitleChars() . ']'
154            // URL percent encoding sequences interfere with the ability
155            // to round-trip titles -- you can't link to them consistently.
156            . '|%[0-9A-Fa-f]{2}'
157            // XML/HTML character references produce similar issues.
158            . '|&[A-Za-z0-9\x80-\xff]+;/S';
159        if ( preg_match( $illegalCharsRe, $title ) ) {
160            throw new TitleException(
161                "Invalid characters in title \"$origTitle\"", 'title-invalid-characters', $title
162            );
163        }
164
165        // Pages with "/./" or "/../" appearing in the URLs will often be
166        // unreachable due to the way web browsers deal with 'relative' URLs.
167        // Also, they conflict with subpage syntax. Forbid them explicitly.
168        if ( str_contains( $title, '.' ) && (
169            $title === '.' || $title === '..' ||
170            str_starts_with( $title, './' ) ||
171            str_starts_with( $title, '../' ) ||
172            str_contains( $title, '/./' ) ||
173            str_contains( $title, '/../' ) ||
174            str_ends_with( $title, '/.' ) ||
175            str_ends_with( $title, '/..' )
176        ) ) {
177            throw new TitleException(
178                "Title \"$origTitle\" contains relative path components", 'title-invalid-relative', $title
179            );
180        }
181
182        // Magic tilde sequences? Nu-uh!
183        if ( str_contains( $title, '~~~' ) ) {
184            throw new TitleException(
185                "Title \"$origTitle\" contains ~~~", 'title-invalid-magic-tilde', $title
186            );
187        }
188
189        $maxLength = $ns === $siteConfig->canonicalNamespaceId( 'special' ) ? 512 : 255;
190        if ( strlen( $title ) > $maxLength ) {
191            throw new TitleException(
192                "Title \"$origTitle\" is too long", 'title-invalid-too-long', $title
193            );
194        }
195
196        if ( $interwiki === null && $siteConfig->namespaceCase( $ns ) === 'first-letter' ) {
197            $title = $siteConfig->ucfirst( $title );
198        }
199
200        # Can't make a link to a namespace alone... "empty" local links can only be
201        # self-links with a fragment identifier.
202        if ( $title === '' && $interwiki === null && $ns !== $siteConfig->canonicalNamespaceId( '' ) ) {
203            throw new TitleException( 'Empty title', 'title-invalid-empty', $title );
204        }
205
206        // This is from MediaWikiTitleCodec::splitTitleString() in core
207        if ( $title !== '' && ( # T329690
208            $ns === $siteConfig->canonicalNamespaceId( 'user' ) ||
209            $ns === $siteConfig->canonicalNamespaceId( 'user_talk' )
210        ) ) {
211            $title = IPUtils::sanitizeIP( $title );
212        }
213
214        // Any remaining initial :s are illegal.
215        if ( $title !== '' && $title[0] == ':' ) {
216            throw new TitleException(
217                'Leading colon title', 'title-invalid-leading-colon', $title
218            );
219        }
220
221        // This is not in core's splitTitleString but matches
222        // mediawiki-title's newFromText.
223        if ( $ns === $siteConfig->canonicalNamespaceId( 'special' ) ) {
224            $title = self::fixSpecialName( $siteConfig, $title );
225        }
226
227        $namespaceName = $siteConfig->namespaceName( $ns );
228        return new self( $interwiki ?? '', $title, $ns, $namespaceName, $fragment );
229    }
230
231    /**
232     * The interwiki component of this LinkTarget.
233     * This is the empty string if there is no interwiki component.
234     *
235     * @return string
236     */
237    public function getInterwiki(): string {
238        return $this->interwiki;
239    }
240
241    /**
242     * Get the DBkey, prefixed with interwiki prefix if any.
243     * This is Parsoid's convention, which differs from core;
244     * use ::getDBkey() for a method compatible with core's
245     * convention.
246     *
247     * @return string
248     * @see ::getDBkey()
249     */
250    public function getKey(): string {
251        if ( $this->interwiki ) {
252            return $this->interwiki . ':' . $this->dbkey;
253        }
254        return $this->dbkey;
255    }
256
257    /**
258     * Get the main part of the link target, in canonical database form.
259     *
260     * The main part is the link target without namespace prefix or hash fragment.
261     * The database form means that spaces become underscores, this is also
262     * used for URLs.
263     *
264     * @return string
265     */
266    public function getDBkey(): string {
267        return $this->dbkey;
268    }
269
270    /**
271     * Get the prefixed DBkey
272     * @return string
273     */
274    public function getPrefixedDBKey(): string {
275        if ( $this->prefixedDBKey === null ) {
276            $this->prefixedDBKey = $this->namespaceName === '' ? '' :
277                ( strtr( $this->namespaceName, ' ', '_' ) . ':' );
278            $this->prefixedDBKey .= $this->getKey();
279        }
280        return $this->prefixedDBKey;
281    }
282
283    /**
284     * Get the prefixed text
285     * @return string
286     */
287    public function getPrefixedText(): string {
288        if ( $this->prefixedText === null ) {
289            $this->prefixedText = $this->namespaceName === '' ? '' :
290                ( $this->namespaceName . ':' );
291            $this->prefixedText .= strtr( $this->getKey(), '_', ' ' );
292        }
293        return $this->prefixedText;
294    }
295
296    /**
297     * Get the namespace ID
298     * @return int
299     */
300    public function getNamespace(): int {
301        return $this->namespaceId;
302    }
303
304    /**
305     * Get the human-readable name for the namespace
306     * (with spaces, not underscores).
307     * @return string
308     */
309    public function getNamespaceName(): string {
310        return $this->namespaceName;
311    }
312
313    /**
314     * Get the link fragment in text form (i.e. the bit after the hash `#`).
315     *
316     * @return string link fragment
317     */
318    public function getFragment(): string {
319        return $this->fragment ?? '';
320    }
321
322    /**
323     * Compare with another title.
324     *
325     * @param Title $title
326     * @return bool
327     */
328    public function equals( Title $title ) {
329        return $this->getNamespace() === $title->getNamespace() &&
330            $this->getKey() === $title->getKey();
331    }
332
333    /**
334     * Returns true if this is a special page.
335     *
336     * @return bool
337     */
338    public function isSpecialPage() {
339        return $this->getNamespace() === -1; // NS_SPECIAL;
340    }
341
342    /**
343     * Use the default special page alias.
344     *
345     * @param SiteConfig $siteConfig
346     * @param string $title
347     * @return string
348     */
349    public static function fixSpecialName(
350        SiteConfig $siteConfig, string $title
351    ): string {
352        $parts = explode( '/', $title, 2 );
353        $specialName = $siteConfig->specialPageLocalName( $parts[0] );
354        if ( $specialName !== null ) {
355            $parts[0] = $specialName;
356            $title = implode( '/', $parts );
357        }
358        return $title;
359    }
360
361    /**
362     * Create a new LinkTarget with a different fragment on the same page.
363     *
364     * It is expected that the same type of object will be returned, but the
365     * only requirement is that it is a LinkTarget.
366     *
367     * @param string $fragment The fragment override, or "" to remove it.
368     *
369     * @return self
370     */
371    public function createFragmentTarget( string $fragment ) {
372        return new self( $this->interwiki, $this->dbkey, $this->namespaceId, $this->namespaceName, $fragment ?: null );
373    }
374
375    /**
376     * Convert LinkTarget from core (or other implementation) into a
377     * Parsoid Title.
378     *
379     * @param LinkTarget $linkTarget
380     * @return self
381     */
382    public static function newFromLinkTarget(
383        LinkTarget $linkTarget, SiteConfig $siteConfig
384    ) {
385        if ( $linkTarget instanceof Title ) {
386            return $linkTarget;
387        }
388        $ns = $linkTarget->getNamespace();
389        $namespaceName = $siteConfig->namespaceName( $ns );
390        Assert::invariant(
391            $namespaceName !== null,
392            "Badtitle ({$linkTarget}) in unknown namespace ({$ns})"
393        );
394        return new self(
395            $linkTarget->getInterwiki(),
396            $linkTarget->getDBkey(),
397            $linkTarget->getNamespace(),
398            $namespaceName,
399            $linkTarget->getFragment()
400        );
401    }
402}