Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.13% covered (success)
95.13%
215 / 226
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinkHolderArray
95.56% covered (success)
95.56%
215 / 225
81.82% covered (warning)
81.82%
9 / 11
62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 merge
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isBig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 makeHolder
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 replace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replaceInternal
98.75% covered (success)
98.75%
79 / 80
0.00% covered (danger)
0.00%
0 / 1
18
 replaceInterwiki
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 doVariants
89.53% covered (warning)
89.53%
77 / 86
0.00% covered (danger)
0.00%
0 / 1
28.90
 replaceText
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Holder of replacement pairs for wiki links
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Parser
22 */
23
24namespace MediaWiki\Parser;
25
26use HtmlArmor;
27use MediaWiki\Cache\LinkCache;
28use MediaWiki\HookContainer\HookContainer;
29use MediaWiki\HookContainer\HookRunner;
30use MediaWiki\Language\ILanguageConverter;
31use MediaWiki\Linker\Linker;
32use MediaWiki\MainConfigNames;
33use MediaWiki\MediaWikiServices;
34use MediaWiki\Title\Title;
35
36/**
37 * @internal for using in Parser only.
38 *
39 * @ingroup Parser
40 */
41class LinkHolderArray {
42    /** @var array<int,array<int,array>> Indexed by numeric namespace and link ids, {@see Parser::nextLinkID} */
43    private $internals = [];
44    /** @var array<int,array> Indexed by numeric link id */
45    private $interwikis = [];
46    /** @var int */
47    private $size = 0;
48    /** @var Parser */
49    private $parent;
50    /** @var ILanguageConverter */
51    private $languageConverter;
52    /** @var HookRunner */
53    private $hookRunner;
54
55    /**
56     * @param Parser $parent
57     * @param ILanguageConverter $languageConverter
58     * @param HookContainer $hookContainer
59     */
60    public function __construct( Parser $parent, ILanguageConverter $languageConverter,
61        HookContainer $hookContainer
62    ) {
63        $this->parent = $parent;
64        $this->languageConverter = $languageConverter;
65        $this->hookRunner = new HookRunner( $hookContainer );
66    }
67
68    /**
69     * Reduce memory usage to reduce the impact of circular references
70     */
71    public function __destruct() {
72        // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach
73        foreach ( $this as $name => $_ ) {
74            unset( $this->$name );
75        }
76    }
77
78    /**
79     * Merge another LinkHolderArray into this one
80     * @param LinkHolderArray $other
81     */
82    public function merge( $other ) {
83        foreach ( $other->internals as $ns => $entries ) {
84            $this->size += count( $entries );
85            if ( !isset( $this->internals[$ns] ) ) {
86                $this->internals[$ns] = $entries;
87            } else {
88                $this->internals[$ns] += $entries;
89            }
90        }
91        $this->interwikis += $other->interwikis;
92    }
93
94    /**
95     * Returns true if the memory requirements of this object are getting large
96     * @return bool
97     */
98    public function isBig() {
99        $linkHolderBatchSize = MediaWikiServices::getInstance()->getMainConfig()
100            ->get( MainConfigNames::LinkHolderBatchSize );
101        return $this->size > $linkHolderBatchSize;
102    }
103
104    /**
105     * Clear all stored link holders.
106     * Make sure you don't have any text left using these link holders, before you call this
107     */
108    public function clear() {
109        $this->internals = [];
110        $this->interwikis = [];
111        $this->size = 0;
112    }
113
114    /**
115     * Make a link placeholder. The text returned can be later resolved to a real link with
116     * replaceLinkHolders(). This is done for two reasons: firstly to avoid further
117     * parsing of interwiki links, and secondly to allow all existence checks and
118     * article length checks (for stub links) to be bundled into a single query.
119     *
120     * @param Title $nt
121     * @param string $text
122     * @param string $trail [optional]
123     * @param string $prefix [optional]
124     * @return string
125     */
126    public function makeHolder( Title $nt, $text = '', $trail = '', $prefix = '' ) {
127        # Separate the link trail from the rest of the link
128        [ $inside, $trail ] = Linker::splitTrail( $trail );
129
130        $key = $this->parent->nextLinkID();
131        $entry = [
132            'title' => $nt,
133            'text' => $prefix . $text . $inside,
134            'pdbk' => $nt->getPrefixedDBkey(),
135        ];
136
137        $this->size++;
138        if ( $nt->isExternal() ) {
139            // Use a globally unique ID to keep the objects mergable
140            $this->interwikis[$key] = $entry;
141            return "<!--IWLINK'\" $key-->{$trail}";
142        } else {
143            $ns = $nt->getNamespace();
144            $this->internals[$ns][$key] = $entry;
145            return "<!--LINK'\" $ns:$key-->{$trail}";
146        }
147    }
148
149    /**
150     * Replace <!--LINK--> link placeholders with actual links, in the buffer
151     *
152     * @param string &$text
153     */
154    public function replace( &$text ) {
155        $this->replaceInternal( $text );
156        $this->replaceInterwiki( $text );
157    }
158
159    /**
160     * Replace internal links
161     * @param string &$text
162     */
163    protected function replaceInternal( &$text ) {
164        if ( !$this->internals ) {
165            return;
166        }
167
168        $classes = [];
169        $services = MediaWikiServices::getInstance();
170        $linkCache = $services->getLinkCache();
171        $output = $this->parent->getOutput();
172        $linkRenderer = $this->parent->getLinkRenderer();
173
174        $dbr = $services->getConnectionProvider()->getReplicaDatabase();
175
176        # Sort by namespace
177        ksort( $this->internals );
178
179        $pagemap = [];
180
181        # Generate query
182        $linkBatchFactory = $services->getLinkBatchFactory();
183        $lb = $linkBatchFactory->newLinkBatch();
184        $lb->setCaller( __METHOD__ );
185
186        foreach ( $this->internals as $ns => $entries ) {
187            foreach ( $entries as [ 'title' => $title, 'pdbk' => $pdbk ] ) {
188                /** @var Title $title */
189                # Check if it's a static known link, e.g. interwiki
190                if ( $title->isAlwaysKnown() ) {
191                    $classes[$pdbk] = '';
192                } elseif ( $ns === NS_SPECIAL ) {
193                    $classes[$pdbk] = 'new';
194                } else {
195                    $id = $linkCache->getGoodLinkID( $pdbk );
196                    if ( $id ) {
197                        $classes[$pdbk] = $linkRenderer->getLinkClasses( $title );
198                        $output->addLink( $title, $id );
199                        $pagemap[$id] = $pdbk;
200                    } elseif ( $linkCache->isBadLink( $pdbk ) ) {
201                        $classes[$pdbk] = 'new';
202                    } else {
203                        # Not in the link cache, add it to the query
204                        $lb->addObj( $title );
205                    }
206                }
207            }
208        }
209        if ( !$lb->isEmpty() ) {
210            $res = $dbr->newSelectQueryBuilder()
211                ->select( LinkCache::getSelectFields() )
212                ->from( 'page' )
213                ->where( [ $lb->constructSet( 'page', $dbr ) ] )
214                ->caller( __METHOD__ )
215                ->fetchResultSet();
216
217            # Fetch data and form into an associative array
218            # non-existent = broken
219            foreach ( $res as $s ) {
220                $title = Title::makeTitle( $s->page_namespace, $s->page_title );
221                $pdbk = $title->getPrefixedDBkey();
222                $linkCache->addGoodLinkObjFromRow( $title, $s );
223                $output->addLink( $title, $s->page_id );
224                $classes[$pdbk] = $linkRenderer->getLinkClasses( $title );
225                // add id to the extension todolist
226                $pagemap[$s->page_id] = $pdbk;
227            }
228            unset( $res );
229        }
230        if ( $pagemap !== [] ) {
231            // pass an array of page_ids to an extension
232            $this->hookRunner->onGetLinkColours( $pagemap, $classes, $this->parent->getTitle() );
233        }
234
235        # Do a second query for different language variants of links and categories
236        if ( $this->languageConverter->hasVariants() ) {
237            $this->doVariants( $classes );
238        }
239
240        # Construct search and replace arrays
241        $replacePairs = [];
242        foreach ( $this->internals as $ns => $entries ) {
243            foreach ( $entries as $index => $entry ) {
244                $pdbk = $entry['pdbk'];
245                $title = $entry['title'];
246                $query = $entry['query'] ?? [];
247                $searchkey = "$ns:$index";
248                $displayTextHtml = $entry['text'];
249                if ( isset( $entry['selflink'] ) ) {
250                    $replacePairs[$searchkey] = Linker::makeSelfLinkObj(
251                        $title, $displayTextHtml, $query, '', '',
252                        Sanitizer::escapeIdForLink( $title->getFragment() )
253                    );
254                    continue;
255                }
256
257                $displayText = $displayTextHtml === '' ? null : new HtmlArmor( $displayTextHtml );
258                if ( !isset( $classes[$pdbk] ) ) {
259                    $classes[$pdbk] = 'new';
260                }
261                if ( $classes[$pdbk] === 'new' ) {
262                    $linkCache->addBadLinkObj( $title );
263                    $output->addLink( $title, 0 );
264                    $link = $linkRenderer->makeBrokenLink(
265                        $title, $displayText, [], $query
266                    );
267                } else {
268                    $link = $linkRenderer->makePreloadedLink(
269                        $title, $displayText, $classes[$pdbk], [], $query
270                    );
271                }
272
273                $replacePairs[$searchkey] = $link;
274            }
275        }
276
277        # Do the thing
278        $text = preg_replace_callback(
279            '/<!--LINK\'" (-?[\d:]+)-->/',
280            static function ( array $matches ) use ( $replacePairs ) {
281                return $replacePairs[$matches[1]];
282            },
283            $text
284        );
285    }
286
287    /**
288     * Replace interwiki links
289     * @param string &$text
290     */
291    protected function replaceInterwiki( &$text ) {
292        if ( !$this->interwikis ) {
293            return;
294        }
295
296        # Make interwiki link HTML
297        $output = $this->parent->getOutput();
298        $replacePairs = [];
299        $linkRenderer = $this->parent->getLinkRenderer();
300        foreach ( $this->interwikis as $key => [ 'title' => $title, 'text' => $linkText ] ) {
301            $replacePairs[$key] = $linkRenderer->makeLink( $title, new HtmlArmor( $linkText ) );
302            $output->addInterwikiLink( $title );
303        }
304
305        $text = preg_replace_callback(
306            '/<!--IWLINK\'" (\d+)-->/',
307            static function ( array $matches ) use ( $replacePairs ) {
308                return $replacePairs[$matches[1]];
309            },
310            $text
311        );
312    }
313
314    /**
315     * Modify $this->internals and $classes according to language variant linking rules
316     * @param string[] &$classes
317     */
318    protected function doVariants( &$classes ) {
319        $linkBatchFactory = MediaWikiServices::getInstance()->getLinkBatchFactory();
320        $linkBatch = $linkBatchFactory->newLinkBatch();
321        $variantMap = []; // maps $pdbkey_Variant => $keys (of link holders)
322        $output = $this->parent->getOutput();
323        $titlesToBeConverted = '';
324        $titlesAttrs = [];
325
326        // Concatenate titles to a single string, thus we only need auto convert the
327        // single string to all variants. This would improve parser's performance
328        // significantly.
329        foreach ( $this->internals as $ns => $entries ) {
330            if ( $ns === NS_SPECIAL ) {
331                continue;
332            }
333            foreach ( $entries as $index => [ 'title' => $title, 'pdbk' => $pdbk ] ) {
334                // we only deal with new links (in its first query)
335                if ( !isset( $classes[$pdbk] ) || $classes[$pdbk] === 'new' ) {
336                    $titlesAttrs[] = [ $index, $title ];
337                    // separate titles with \0 because it would never appears
338                    // in a valid title
339                    $titlesToBeConverted .= $title->getText() . "\0";
340                }
341            }
342        }
343
344        // Now do the conversion and explode string to text of titles
345        $titlesAllVariants = $this->languageConverter->
346            autoConvertToAllVariants( rtrim( $titlesToBeConverted, "\0" ) );
347        foreach ( $titlesAllVariants as &$titlesVariant ) {
348            $titlesVariant = explode( "\0", $titlesVariant );
349        }
350
351        // Then add variants of links to link batch
352        $parentTitle = $this->parent->getTitle();
353        foreach ( $titlesAttrs as $i => [ $index, $title ] ) {
354            /** @var Title $title */
355            $ns = $title->getNamespace();
356            $text = $title->getText();
357
358            foreach ( $titlesAllVariants as $textVariants ) {
359                $textVariant = $textVariants[$i];
360                if ( $textVariant === $text ) {
361                    continue;
362                }
363
364                $variantTitle = Title::makeTitle( $ns, $textVariant );
365
366                // Self-link checking for mixed/different variant titles. At this point, we
367                // already know the exact title does not exist, so the link cannot be to a
368                // variant of the current title that exists as a separate page.
369                if ( $variantTitle->equals( $parentTitle ) ) {
370                    $this->internals[$ns][$index]['selflink'] = true;
371                    continue 2;
372                }
373
374                $linkBatch->addObj( $variantTitle );
375                $variantMap[$variantTitle->getPrefixedDBkey()][] = "$ns:$index";
376            }
377        }
378
379        // process categories, check if a category exists in some variant
380        $categoryMap = []; // maps $category_variant => $category (dbkeys)
381        foreach ( $output->getCategoryNames() as $category ) {
382            $categoryTitle = Title::makeTitleSafe( NS_CATEGORY, $category );
383            $linkBatch->addObj( $categoryTitle );
384            $variants = $this->languageConverter->autoConvertToAllVariants( $category );
385            foreach ( $variants as $variant ) {
386                if ( $variant !== $category ) {
387                    $variantTitle = Title::makeTitleSafe( NS_CATEGORY, $variant );
388                    if ( $variantTitle ) {
389                        $linkBatch->addObj( $variantTitle );
390                        $categoryMap[$variant] = [ $category, $categoryTitle ];
391                    }
392                }
393            }
394        }
395
396        if ( $linkBatch->isEmpty() ) {
397            return;
398        }
399
400        // construct query
401        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
402
403        $varRes = $dbr->newSelectQueryBuilder()
404            ->select( LinkCache::getSelectFields() )
405            ->from( 'page' )
406            ->where( [ $linkBatch->constructSet( 'page', $dbr ) ] )
407            ->caller( __METHOD__ )
408            ->fetchResultSet();
409
410        $pagemap = [];
411        $varCategories = [];
412        $linkCache = MediaWikiServices::getInstance()->getLinkCache();
413        $linkRenderer = $this->parent->getLinkRenderer();
414
415        // for each found variants, figure out link holders and replace
416        foreach ( $varRes as $s ) {
417            $variantTitle = Title::makeTitle( $s->page_namespace, $s->page_title );
418            $varPdbk = $variantTitle->getPrefixedDBkey();
419
420            if ( !isset( $variantMap[$varPdbk] ) ) {
421                continue;
422            }
423
424            $linkCache->addGoodLinkObjFromRow( $variantTitle, $s );
425            $output->addLink( $variantTitle, $s->page_id );
426
427            // loop over link holders
428            foreach ( $variantMap[$varPdbk] as $key ) {
429                [ $ns, $index ] = explode( ':', $key, 2 );
430                $entry =& $this->internals[(int)$ns][(int)$index];
431
432                // The selflink we marked above might not have been the first
433                // $textVariants so be sure to skip any entries that have
434                // subsequently been marked.
435                if ( isset( $entry['selflink'] ) ) {
436                    continue;
437                }
438
439                $pdbk = $entry['pdbk'];
440                if ( !isset( $classes[$pdbk] ) || $classes[$pdbk] === 'new' ) {
441                    // found link in some of the variants, replace the link holder data
442                    $entry['title'] = $variantTitle;
443                    $entry['pdbk'] = $varPdbk;
444
445                    // set pdbk and colour if we haven't checked this title yet.
446                    if ( !isset( $classes[$varPdbk] ) ) {
447                        $classes[$varPdbk] = $linkRenderer->getLinkClasses( $variantTitle );
448                        $pagemap[$s->page_id] = $varPdbk;
449                    }
450                }
451            }
452
453            // check if the object is a variant of a category
454            $vardbk = $variantTitle->getDBkey();
455            if ( isset( $categoryMap[$vardbk] ) ) {
456                [ $oldkey, $oldtitle ] = $categoryMap[$vardbk];
457                if ( !isset( $varCategories[$oldkey] ) && !$oldtitle->exists() ) {
458                    $varCategories[$oldkey] = $vardbk;
459                }
460            }
461        }
462        $this->hookRunner->onGetLinkColours( $pagemap, $classes, $this->parent->getTitle() );
463
464        // rebuild the categories in original order (if there are replacements)
465        if ( $varCategories !== [] ) {
466            $newCats = [];
467            foreach ( $output->getCategoryNames() as $cat ) {
468                $sortkey = $output->getCategorySortKey( $cat );
469                // make the replacement
470                $newCats[$varCategories[$cat] ?? $cat] = $sortkey;
471            }
472            $output->setCategories( $newCats );
473        }
474    }
475
476    /**
477     * Replace <!--LINK'" â€¦--> and <!--IWLINK'" â€¦--> link placeholders with plain text of links
478     * (not HTML-formatted).
479     *
480     * @param string $text
481     * @return string
482     */
483    public function replaceText( $text ) {
484        return preg_replace_callback(
485            '/<!--(IW)?LINK\'" (-?[\d:]+)-->/',
486            function ( $matches ) {
487                [ $unchanged, $isInterwiki, $key ] = $matches;
488
489                if ( !$isInterwiki ) {
490                    [ $ns, $index ] = explode( ':', $key, 2 );
491                    return $this->internals[(int)$ns][(int)$index]['text'] ?? $unchanged;
492                } else {
493                    return $this->interwikis[$key]['text'] ?? $unchanged;
494                }
495            },
496            $text
497        );
498    }
499}
500
501/** @deprecated class alias since 1.43 */
502class_alias( LinkHolderArray::class, 'LinkHolderArray' );