Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.45% covered (success)
96.45%
190 / 197
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentParser
96.45% covered (success)
96.45%
190 / 197
50.00% covered (danger)
50.00%
6 / 12
53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 preprocess
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 preprocessUnsafe
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 finalize
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 preprocessInternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 doSectionLinks
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
8
 makeSectionLink
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 doWikiLinks
98.46% covered (success)
98.46%
64 / 65
0.00% covered (danger)
0.00%
0 / 1
17
 addLinkMarker
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 addPageLink
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 addFileLink
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 flushLinkBatches
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\CommentFormatter;
4
5use MediaWiki\Cache\LinkBatch;
6use MediaWiki\Cache\LinkBatchFactory;
7use MediaWiki\Cache\LinkCache;
8use MediaWiki\FileRepo\File\File;
9use MediaWiki\FileRepo\RepoGroup;
10use MediaWiki\HookContainer\HookContainer;
11use MediaWiki\HookContainer\HookRunner;
12use MediaWiki\Html\Html;
13use MediaWiki\Language\Language;
14use MediaWiki\Linker\Linker;
15use MediaWiki\Linker\LinkRenderer;
16use MediaWiki\Linker\LinkTarget;
17use MediaWiki\Parser\Parser;
18use MediaWiki\Parser\Sanitizer;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\Title\MalformedTitleException;
21use MediaWiki\Title\NamespaceInfo;
22use MediaWiki\Title\Title;
23use MediaWiki\Title\TitleParser;
24use MediaWiki\Title\TitleValue;
25use MediaWiki\WikiMap\WikiMap;
26use Wikimedia\HtmlArmor\HtmlArmor;
27use Wikimedia\StringUtils\StringUtils;
28
29/**
30 * The text processing backend for CommentFormatter.
31 *
32 * CommentParser objects should be discarded after the comment batch is
33 * complete, in order to reduce memory usage.
34 *
35 * @internal
36 */
37class CommentParser {
38    /** @var LinkRenderer */
39    private $linkRenderer;
40    /** @var LinkBatchFactory */
41    private $linkBatchFactory;
42    /** @var RepoGroup */
43    private $repoGroup;
44    /** @var Language */
45    private $userLang;
46    /** @var Language */
47    private $contLang;
48    /** @var TitleParser */
49    private $titleParser;
50    /** @var NamespaceInfo */
51    private $namespaceInfo;
52    /** @var HookRunner */
53    private $hookRunner;
54    /** @var LinkCache */
55    private $linkCache;
56
57    /** @var callable[] */
58    private $links = [];
59    /** @var LinkBatch|null */
60    private $linkBatch;
61
62    /** @var array Input to RepoGroup::findFiles() */
63    private $fileBatch;
64    /** @var File[] Resolved File objects indexed by DB key */
65    private $files = [];
66
67    /** @var int The maximum number of digits in a marker ID */
68    private const MAX_ID_SIZE = 7;
69    /** @var string Prefix for marker. ' and " included to break attributes (T355538) */
70    private const MARKER_PREFIX = "\x1B\"'";
71
72    /**
73     * @param LinkRenderer $linkRenderer
74     * @param LinkBatchFactory $linkBatchFactory
75     * @param LinkCache $linkCache
76     * @param RepoGroup $repoGroup
77     * @param Language $userLang
78     * @param Language $contLang
79     * @param TitleParser $titleParser
80     * @param NamespaceInfo $namespaceInfo
81     * @param HookContainer $hookContainer
82     */
83    public function __construct(
84        LinkRenderer $linkRenderer,
85        LinkBatchFactory $linkBatchFactory,
86        LinkCache $linkCache,
87        RepoGroup $repoGroup,
88        Language $userLang,
89        Language $contLang,
90        TitleParser $titleParser,
91        NamespaceInfo $namespaceInfo,
92        HookContainer $hookContainer
93    ) {
94        $this->linkRenderer = $linkRenderer;
95        $this->linkBatchFactory = $linkBatchFactory;
96        $this->linkCache = $linkCache;
97        $this->repoGroup = $repoGroup;
98        $this->userLang = $userLang;
99        $this->contLang = $contLang;
100        $this->titleParser = $titleParser;
101        $this->namespaceInfo = $namespaceInfo;
102        $this->hookRunner = new HookRunner( $hookContainer );
103    }
104
105    /**
106     * Convert a comment to HTML, but replace links with markers which are
107     * resolved later.
108     *
109     * @param string $comment
110     * @param LinkTarget|null $selfLinkTarget
111     * @param bool $samePage
112     * @param string|false|null $wikiId
113     * @param bool $enableSectionLinks
114     * @return string
115     */
116    public function preprocess( string $comment, ?LinkTarget $selfLinkTarget = null,
117        $samePage = false, $wikiId = false, $enableSectionLinks = true
118    ) {
119        return $this->preprocessInternal( $comment, false, $selfLinkTarget,
120            $samePage, $wikiId, $enableSectionLinks );
121    }
122
123    /**
124     * Convert a comment in pseudo-HTML format to HTML, replacing links with markers.
125     *
126     * @param string $comment
127     * @param LinkTarget|null $selfLinkTarget
128     * @param bool $samePage
129     * @param string|false|null $wikiId
130     * @param bool $enableSectionLinks
131     * @return string
132     */
133    public function preprocessUnsafe( $comment, ?LinkTarget $selfLinkTarget = null,
134        $samePage = false, $wikiId = false, $enableSectionLinks = true
135    ) {
136        return $this->preprocessInternal( $comment, true, $selfLinkTarget,
137            $samePage, $wikiId, $enableSectionLinks );
138    }
139
140    /**
141     * Execute pending batch queries and replace markers in the specified
142     * string(s) with actual links.
143     *
144     * @param string|string[] $comments
145     * @return string|string[]
146     */
147    public function finalize( $comments ) {
148        $this->flushLinkBatches();
149        return preg_replace_callback(
150            '/' . self::MARKER_PREFIX . '([0-9]{' . self::MAX_ID_SIZE . '})/',
151            function ( $m ) {
152                $callback = $this->links[(int)$m[1]] ?? null;
153                if ( $callback ) {
154                    return $callback();
155                } else {
156                    return '<!-- MISSING -->';
157                }
158            },
159            $comments
160        );
161    }
162
163    /**
164     * @param string $comment
165     * @param bool $unsafe
166     * @param LinkTarget|null $selfLinkTarget
167     * @param bool $samePage
168     * @param string|false|null $wikiId
169     * @param bool $enableSectionLinks
170     * @return string
171     */
172    private function preprocessInternal( $comment, $unsafe, $selfLinkTarget, $samePage, $wikiId,
173        $enableSectionLinks
174    ) {
175        // Sanitize text a bit
176        // \x1b needs to be stripped because it is used for link markers
177        $comment = strtr( $comment, "\n\x1b", "  " );
178        // Allow HTML entities (for T15815)
179        if ( !$unsafe ) {
180            $comment = Sanitizer::escapeHtmlAllowEntities( $comment );
181        }
182        if ( $enableSectionLinks ) {
183            $comment = $this->doSectionLinks( $comment, $selfLinkTarget, $samePage, $wikiId );
184        }
185        return $this->doWikiLinks( $comment, $selfLinkTarget, $samePage, $wikiId );
186    }
187
188    /**
189     * Converts C-style comments in edit summaries into section links.
190     *
191     * Too many things are called "comments", so these are mostly now called
192     * section links rather than autocomments.
193     *
194     * We look for all comments, match any text before and after the comment,
195     * add a separator where needed and format the comment itself with CSS.
196     *
197     * @param string $comment Comment text
198     * @param LinkTarget|null $selfLinkTarget An optional LinkTarget object used to links to sections
199     * @param bool $samePage Whether section links should refer to local page
200     * @param string|false|null $wikiId Id of the wiki to link to (if not the local wiki),
201     *  as used by WikiMap.
202     * @return string Preprocessed comment
203     */
204    private function doSectionLinks(
205        $comment,
206        $selfLinkTarget = null,
207        $samePage = false,
208        $wikiId = false
209    ) {
210        $comment = preg_replace_callback(
211            // To detect the presence of content before or after the
212            // auto-comment, we use capturing groups inside optional zero-width
213            // assertions. But older versions of PCRE can not directly make
214            // zero-width assertions optional, so wrap them in a non-capturing
215            // group.
216            '!(?:(?<=(.)))?/\*\s*(.*?)\s*\*/(?:(?=(.)))?!',
217            function ( $match ) use ( $selfLinkTarget, $samePage, $wikiId ) {
218                $pre = ( $match[1] ?? '' ) !== '';
219                $section = ( $match[2] ?? '' );
220                $post = ( $match[3] ?? '' ) !== '';
221                $comment = null;
222
223                $this->hookRunner->onFormatAutocomments(
224                    $comment, $pre, $section, $post,
225                    Title::castFromLinkTarget( $selfLinkTarget ),
226                    $samePage,
227                    $wikiId );
228                if ( $comment !== null ) {
229                    return $comment;
230                }
231
232                // HTML has already been escaped in preprocessInternal(), so treat this as HTML from this point
233                $parsedSection = $section;
234
235                if ( $selfLinkTarget ) {
236                    $decodedSection = substr( Parser::guessSectionNameFromStrippedText(
237                        // Remove links that a user may have manually put in the autosummary
238                        // This could be improved by copying as much of Parser::stripSectionName as desired.
239                        str_replace( [ '[[:', '[[', ']]' ], '', $section )
240                    ), 1 );
241                    if ( $decodedSection !== '' ) {
242                        if ( $samePage ) {
243                            $targetWithSection = new TitleValue( NS_MAIN, '', $decodedSection );
244                        } else {
245                            $targetWithSection = $selfLinkTarget->createFragmentTarget( $decodedSection );
246                        }
247                        $parsedSection = $this->makeSectionLink(
248                            $targetWithSection,
249                            $this->userLang->getArrow() .
250                                Html::rawElement( 'bdi', [ 'dir' => $this->userLang->getDir() ], $parsedSection ),
251                            $wikiId,
252                            $selfLinkTarget
253                        );
254                    }
255                }
256                if ( $post ) {
257                    # autocomment $postsep written summary (/* section */ summary)
258                    $parsedSection .= wfMessage( 'colon-separator' )->inContentLanguage()->escaped();
259                }
260                if ( $parsedSection ) {
261                    $parsedSection = Html::rawElement( 'span', [ 'class' => 'autocomment' ], $parsedSection );
262                }
263                if ( $pre ) {
264                    # written summary $presep autocomment (summary /* section */)
265                    $parsedSection = wfMessage( 'autocomment-prefix' )->inContentLanguage()->escaped()
266                        . $parsedSection;
267                }
268
269                // Make sure any brackets (which the user could have input in the edit summary)
270                // in the generated autocomment HTML don't trigger additional link processing (T406664).
271                return str_replace( [ '[', ']' ], [ '&#91;', '&#93;' ], $parsedSection );
272            },
273            $comment
274        );
275        return $comment;
276    }
277
278    /**
279     * Make a section link. These don't need to go into the LinkBatch, since
280     * the link class does not depend on whether the link is known.
281     *
282     * @param LinkTarget $target
283     * @param string $text
284     * @param string|false|null $wikiId Id of the wiki to link to (if not the local wiki),
285     *  as used by WikiMap.
286     * @param LinkTarget $contextTitle
287     *
288     * @return string HTML link
289     */
290    private function makeSectionLink(
291        LinkTarget $target, $text, $wikiId, LinkTarget $contextTitle
292    ) {
293        if ( $wikiId !== null && $wikiId !== false && !$target->isExternal() ) {
294            return $this->linkRenderer->makeExternalLink(
295                WikiMap::getForeignURL(
296                    $wikiId,
297                    $target->getNamespace() === 0
298                        ? $target->getDBkey()
299                        : $this->namespaceInfo->getCanonicalName( $target->getNamespace() ) .
300                        ':' . $target->getDBkey(),
301                    $target->getFragment()
302                ),
303                new HtmlArmor( $text ), // Already escaped
304                $contextTitle
305            );
306        }
307        return $this->linkRenderer->makePreloadedLink( $target, new HtmlArmor( $text ), '' );
308    }
309
310    /**
311     * Formats wiki links and media links in text; all other wiki formatting
312     * is ignored
313     *
314     * @todo FIXME: Doesn't handle sub-links as in image thumb texts like the main parser
315     *
316     * @param string $comment Text to format links in. WARNING! Since the output of this
317     *   function is html, $comment must be sanitized for use as html. You probably want
318     *   to pass $comment through Sanitizer::escapeHtmlAllowEntities() before calling
319     *   this function.
320     *   as used by WikiMap.
321     * @param LinkTarget|null $selfLinkTarget An optional LinkTarget object used to links to sections
322     * @param bool $samePage Whether section links should refer to local page
323     * @param string|false|null $wikiId Id of the wiki to link to (if not the local wiki),
324     *   as used by WikiMap.
325     *
326     * @return string HTML
327     */
328    private function doWikiLinks( $comment, $selfLinkTarget = null, $samePage = false, $wikiId = false ) {
329        return preg_replace_callback(
330            '/
331                \[\[
332                \s*+ # ignore leading whitespace, the *+ quantifier disallows backtracking
333                :? # ignore optional leading colon
334                ([^[\]|]+) # 1. link target; page names cannot include [, ] or |
335                (?:\|
336                    # 2. link text
337                    # Stop matching at ]] without relying on backtracking.
338                    ((?:]?[^\]])*+)
339                )?
340                \]\]
341                ([^[]*) # 3. link trail (the text up until the next link)
342            /x',
343            function ( $match ) use ( $selfLinkTarget, $samePage, $wikiId ) {
344                $medians = '(?:';
345                $medians .= preg_quote(
346                    $this->namespaceInfo->getCanonicalName( NS_MEDIA ), '/' );
347                $medians .= '|';
348                $medians .= preg_quote(
349                        $this->contLang->getNsText( NS_MEDIA ),
350                        '/'
351                    ) . '):';
352
353                $comment = $match[0];
354
355                // Fix up urlencoded title texts (copied from Parser::replaceInternalLinks)
356                if ( str_contains( $match[1], '%' ) ) {
357                    $match[1] = strtr(
358                        rawurldecode( $match[1] ),
359                        [ '<' => '&lt;', '>' => '&gt;' ]
360                    );
361                }
362
363                // Handle link renaming [[foo|text]] will show link as "text"
364                if ( $match[2] != "" ) {
365                    $text = $match[2];
366                } else {
367                    $text = $match[1];
368                }
369                $submatch = [];
370                $linkMarker = null;
371                if ( preg_match( '/^' . $medians . '(.*)$/i', $match[1], $submatch ) ) {
372                    // Media link; trail not supported.
373                    $linkRegexp = '/\[\[(.*?)\]\]/';
374                    $linkTarget = $this->titleParser->makeTitleValueSafe( NS_FILE, $submatch[1] );
375                    if ( $linkTarget ) {
376                        $linkMarker = $this->addFileLink( $linkTarget, $text );
377                    }
378                } else {
379                    // Other kind of link
380                    // Make sure its target is non-empty
381                    if ( isset( $match[1][0] ) && $match[1][0] == ':' ) {
382                        $match[1] = substr( $match[1], 1 );
383                    }
384                    if ( $match[1] !== false && $match[1] !== null && $match[1] !== '' ) {
385                        if ( preg_match(
386                            $this->contLang->linkTrail(),
387                            $match[3],
388                            $submatch
389                        ) ) {
390                            $trail = $submatch[1];
391                        } else {
392                            $trail = "";
393                        }
394                        $linkRegexp = '/\[\[(.*?)\]\]' . preg_quote( $trail, '/' ) . '/';
395                        [ $inside, $trail ] = Linker::splitTrail( $trail );
396
397                        $linkText = $text;
398                        $linkTarget = Linker::normalizeSubpageLink( $selfLinkTarget, $match[1], $linkText );
399
400                        try {
401                            $target = $this->titleParser->parseTitle( $linkTarget );
402
403                            if ( $target->getText() == '' && !$target->isExternal()
404                                && !$samePage && $selfLinkTarget
405                            ) {
406                                $target = $selfLinkTarget->createFragmentTarget( $target->getFragment() );
407                            }
408
409                            // We should deprecate `null` as a valid value for
410                            // $selfLinkTarget to ensure that we can use it as
411                            // the title context for the external link.
412                            // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgTitle
413                            global $wgTitle;
414                            $linkMarker = $this->addPageLink(
415                                $target,
416                                $linkText . $inside,
417                                $wikiId,
418                                $selfLinkTarget ?? $wgTitle ?? SpecialPage::getTitleFor( 'Badtitle' )
419                            );
420                            $linkMarker .= $trail;
421                        } catch ( MalformedTitleException ) {
422                            // Fall through
423                        }
424                    }
425                }
426                if ( $linkMarker ) {
427                    // If the link is still valid, go ahead and replace it in!
428                    $comment = preg_replace(
429                        // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable linkRegexp set when used
430                        // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal linkRegexp set when used
431                        $linkRegexp,
432                        StringUtils::escapeRegexReplacement( $linkMarker ),
433                        $comment,
434                        1
435                    );
436                }
437
438                return $comment;
439            },
440            $comment
441        );
442    }
443
444    /**
445     * Add a deferred link to the list and return its marker.
446     *
447     * @param callable $callback
448     * @return string
449     */
450    private function addLinkMarker( $callback ) {
451        $nextId = count( $this->links );
452        if ( strlen( (string)$nextId ) > self::MAX_ID_SIZE ) {
453            throw new \RuntimeException( 'Too many links in comment batch' );
454        }
455        $this->links[] = $callback;
456        return sprintf( self::MARKER_PREFIX . "%0" . self::MAX_ID_SIZE . 'd', $nextId );
457    }
458
459    /**
460     * Link to a LinkTarget. Return either HTML or a marker depending on whether
461     * existence checks are deferred.
462     *
463     * @param LinkTarget $target
464     * @param string $text
465     * @param string|false|null $wikiId
466     * @param LinkTarget $contextTitle
467     * @return string
468     */
469    private function addPageLink( LinkTarget $target, $text, $wikiId, LinkTarget $contextTitle ) {
470        if ( $wikiId !== null && $wikiId !== false && !$target->isExternal() ) {
471            // Handle links from a foreign wiki ID
472            return $this->linkRenderer->makeExternalLink(
473                WikiMap::getForeignURL(
474                    $wikiId,
475                    $target->getNamespace() === 0
476                        ? $target->getDBkey()
477                        : $this->namespaceInfo->getCanonicalName( $target->getNamespace() ) .
478                        ':' . $target->getDBkey(),
479                    $target->getFragment()
480                ),
481                new HtmlArmor( $text ), // Already escaped
482                $contextTitle
483            );
484        } elseif ( $this->linkCache->getGoodLinkID( $target ) ||
485            Title::newFromLinkTarget( $target )->isAlwaysKnown()
486        ) {
487            // Already known
488            return $this->linkRenderer->makeKnownLink( $target, new HtmlArmor( $text ) );
489        } elseif ( $this->linkCache->isBadLink( $target ) ) {
490            // Already cached as unknown
491            return $this->linkRenderer->makeBrokenLink( $target, new HtmlArmor( $text ) );
492        }
493
494        // Defer page link
495        if ( !$this->linkBatch ) {
496            $this->linkBatch = $this->linkBatchFactory->newLinkBatch();
497            $this->linkBatch->setCaller( __METHOD__ );
498        }
499        $this->linkBatch->addObj( $target );
500        return $this->addLinkMarker( function () use ( $target, $text ) {
501            return $this->linkRenderer->makeLink( $target, new HtmlArmor( $text ) );
502        } );
503    }
504
505    /**
506     * Link to a file, returning a marker.
507     *
508     * @param LinkTarget $target The name of the file.
509     * @param string $html The inner HTML of the link
510     * @return string
511     */
512    private function addFileLink( LinkTarget $target, $html ) {
513        $this->fileBatch[] = [
514            'title' => $target
515        ];
516        return $this->addLinkMarker( function () use ( $target, $html ) {
517            return Linker::makeMediaLinkFile(
518                $target,
519                $this->files[$target->getDBkey()] ?? false,
520                $html
521            );
522        } );
523    }
524
525    /**
526     * Execute any pending link batch or file batch
527     */
528    private function flushLinkBatches() {
529        if ( $this->linkBatch ) {
530            $this->linkBatch->execute();
531            $this->linkBatch = null;
532        }
533        if ( $this->fileBatch ) {
534            $this->files += $this->repoGroup->findFiles( $this->fileBatch );
535            $this->fileBatch = [];
536        }
537    }
538
539}