Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.70% covered (success)
94.70%
447 / 472
60.00% covered (warning)
60.00%
12 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommentParser
94.70% covered (success)
94.70%
447 / 472
60.00% covered (warning)
60.00%
12 / 20
187.98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 nextInterestingLeafNode
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 regexpAlternateGroup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMessages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTimestampRegexp
84.62% covered (warning)
84.62%
66 / 78
0.00% covered (danger)
0.00%
0 / 1
29.65
 getTimestampParser
93.58% covered (success)
93.58%
102 / 109
0.00% covered (danger)
0.00%
0 / 1
46.56
 getLocalTimestampRegexps
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getLocalTimestampParsers
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getUsernameFromLink
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 findSignature
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
14
 acceptOnlyNodesAllowingComments
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 findTimestamp
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 adjustSigRange
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 buildThreadItems
98.51% covered (success)
98.51%
66 / 67
0.00% covered (danger)
0.00%
0 / 1
20
 truncateForId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 computeId
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 computeName
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 buildThreads
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 computeIdsAndNames
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use Config;
6use DateInterval;
7use DateTime;
8use DateTimeImmutable;
9use DateTimeZone;
10use Language;
11use MalformedTitleException;
12use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
13use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentHeadingItem;
14use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentThreadItem;
15use MediaWiki\Languages\LanguageConverterFactory;
16use MWException;
17use TitleParser;
18use TitleValue;
19use Wikimedia\Assert\Assert;
20use Wikimedia\IPUtils;
21use Wikimedia\Parsoid\DOM\Element;
22use Wikimedia\Parsoid\DOM\Node;
23use Wikimedia\Parsoid\DOM\Text;
24use Wikimedia\Parsoid\Utils\DOMCompat;
25
26// TODO consider making timestamp parsing not a returned function
27
28class CommentParser {
29
30    /**
31     * How far backwards we look for a signature associated with a timestamp before giving up.
32     * Note that this is not a hard limit on the length of signatures we detect.
33     */
34    private const SIGNATURE_SCAN_LIMIT = 100;
35
36    private Config $config;
37    private Language $language;
38    private LanguageConverterFactory $languageConverterFactory;
39    private TitleParser $titleParser;
40
41    private $dateFormat;
42    private $digits;
43    /** @var string[][] */
44    private $contLangMessages;
45    private $localTimezone;
46    private $timezones;
47    private $specialContributionsName;
48
49    private Element $rootNode;
50    private TitleValue $title;
51
52    /**
53     * @param Config $config
54     * @param Language $language Content language
55     * @param LanguageConverterFactory $languageConverterFactory
56     * @param LanguageData $languageData
57     * @param TitleParser $titleParser
58     */
59    public function __construct(
60        Config $config,
61        Language $language,
62        LanguageConverterFactory $languageConverterFactory,
63        LanguageData $languageData,
64        TitleParser $titleParser
65    ) {
66        $this->config = $config;
67        $this->language = $language;
68        $this->languageConverterFactory = $languageConverterFactory;
69        $this->titleParser = $titleParser;
70
71        $data = $languageData->getLocalData();
72        $this->dateFormat = $data['dateFormat'];
73        $this->digits = $data['digits'];
74        $this->contLangMessages = $data['contLangMessages'];
75        $this->localTimezone = $data['localTimezone'];
76        $this->timezones = $data['timezones'];
77        $this->specialContributionsName = $data['specialContributionsName'];
78    }
79
80    /**
81     * Parse a discussion page.
82     *
83     * @param Element $rootNode Root node of content to parse
84     * @param TitleValue $title Title of the page being parsed
85     * @return ContentThreadItemSet
86     */
87    public function parse( Element $rootNode, TitleValue $title ): ContentThreadItemSet {
88        $this->rootNode = $rootNode;
89        $this->title = $title;
90
91        $result = $this->buildThreadItems();
92        $this->buildThreads( $result );
93        $this->computeIdsAndNames( $result );
94
95        return $result;
96    }
97
98    /**
99     * Return the next leaf node in the tree order that is likely a part of a discussion comment,
100     * rather than some boring "separator" element.
101     *
102     * Currently, this can return a Text node with content other than whitespace, or an Element node
103     * that is a "void element" or "text element", except some special cases that we treat as comment
104     * separators (isCommentSeparator()).
105     *
106     * @param Node $node Node to start searching at. This node's children are ignored.
107     * @return Node
108     */
109    private function nextInterestingLeafNode( Node $node ): Node {
110        $rootNode = $this->rootNode;
111        $treeWalker = new TreeWalker(
112            $rootNode,
113            NodeFilter::SHOW_ELEMENT | NodeFilter::SHOW_TEXT,
114            static function ( $n ) use ( $node, $rootNode ) {
115                // Ignore this node and its descendants
116                // (unless it's the root node, this is a special case for "fakeHeading" handling)
117                if ( $node !== $rootNode && ( $n === $node || $n->parentNode === $node ) ) {
118                    return NodeFilter::FILTER_REJECT;
119                }
120                // Ignore some elements usually used as separators or headers (and their descendants)
121                if ( CommentUtils::isCommentSeparator( $n ) ) {
122                    return NodeFilter::FILTER_REJECT;
123                }
124                // Ignore nodes with no rendering that mess up our indentation detection
125                if ( CommentUtils::isRenderingTransparentNode( $n ) ) {
126                    return NodeFilter::FILTER_REJECT;
127                }
128                if ( CommentUtils::isCommentContent( $n ) ) {
129                    return NodeFilter::FILTER_ACCEPT;
130                }
131                return NodeFilter::FILTER_SKIP;
132            }
133        );
134        $treeWalker->currentNode = $node;
135        $treeWalker->nextNode();
136        if ( !$treeWalker->currentNode ) {
137            throw new MWException( 'nextInterestingLeafNode not found' );
138        }
139        return $treeWalker->currentNode;
140    }
141
142    /**
143     * @param string[] $values Values to match
144     * @return string Regular expression
145     */
146    private static function regexpAlternateGroup( array $values ): string {
147        return '(' . implode( '|', array_map( static function ( string $x ) {
148            return preg_quote( $x, '/' );
149        }, $values ) ) . ')';
150    }
151
152    /**
153     * Get text of localisation messages in content language.
154     *
155     * @param string $contLangVariant Content language variant
156     * @param string[] $messages Message keys
157     * @return string[] Message values
158     */
159    private function getMessages( string $contLangVariant, array $messages ): array {
160        return array_map( function ( string $key ) use ( $contLangVariant ) {
161            return $this->contLangMessages[$contLangVariant][$key];
162        }, $messages );
163    }
164
165    /**
166     * Get a regexp that matches timestamps generated using the given date format.
167     *
168     * This only supports format characters that are used by the default date format in any of
169     * MediaWiki's languages, namely: D, d, F, G, H, i, j, l, M, n, Y, xg, xkY (and escape characters),
170     * and only dates when MediaWiki existed, let's say 2000 onwards (Thai dates before 1941 are
171     * complicated).
172     *
173     * @param string $contLangVariant Content language variant
174     * @param string $format Date format
175     * @param string $digitsRegexp Regular expression matching a single localised digit, e.g. '[0-9]'
176     * @param array $tzAbbrs Associative array mapping localised timezone abbreviations to
177     *   IANA abbreviations, for the local timezone, e.g. [ 'EDT' => 'EDT', 'EST' => 'EST' ]
178     * @return string Regular expression
179     */
180    private function getTimestampRegexp(
181        string $contLangVariant, string $format, string $digitsRegexp, array $tzAbbrs
182    ): string {
183        $formatLength = strlen( $format );
184        $s = '';
185        // Adapted from Language::sprintfDate()
186        for ( $p = 0; $p < $formatLength; $p++ ) {
187            $num = false;
188            $code = $format[ $p ];
189            if ( $code === 'x' && $p < $formatLength - 1 ) {
190                $code .= $format[++$p];
191            }
192            if ( $code === 'xk' && $p < $formatLength - 1 ) {
193                $code .= $format[++$p];
194            }
195
196            switch ( $code ) {
197                case 'xx':
198                    $s .= 'x';
199                    break;
200                case 'xg':
201                    $s .= static::regexpAlternateGroup(
202                        $this->getMessages( $contLangVariant, Language::MONTH_GENITIVE_MESSAGES )
203                    );
204                    break;
205                case 'd':
206                    $num = '2';
207                    break;
208                case 'D':
209                    $s .= static::regexpAlternateGroup(
210                        $this->getMessages( $contLangVariant, Language::WEEKDAY_ABBREVIATED_MESSAGES )
211                    );
212                    break;
213                case 'j':
214                    $num = '1,2';
215                    break;
216                case 'l':
217                    $s .= static::regexpAlternateGroup(
218                        $this->getMessages( $contLangVariant, Language::WEEKDAY_MESSAGES )
219                    );
220                    break;
221                case 'F':
222                    $s .= static::regexpAlternateGroup(
223                        $this->getMessages( $contLangVariant, Language::MONTH_MESSAGES )
224                    );
225                    break;
226                case 'M':
227                    $s .= static::regexpAlternateGroup(
228                        $this->getMessages( $contLangVariant, Language::MONTH_ABBREVIATED_MESSAGES )
229                    );
230                    break;
231                case 'n':
232                    $num = '1,2';
233                    break;
234                case 'Y':
235                    $num = '4';
236                    break;
237                case 'xkY':
238                    $num = '4';
239                    break;
240                case 'G':
241                    $num = '1,2';
242                    break;
243                case 'H':
244                    $num = '2';
245                    break;
246                case 'i':
247                    $num = '2';
248                    break;
249                case '\\':
250                    // Backslash escaping
251                    if ( $p < $formatLength - 1 ) {
252                        $s .= preg_quote( $format[++$p], '/' );
253                    } else {
254                        $s .= preg_quote( '\\', '/' );
255                    }
256                    break;
257                case '"':
258                    // Quoted literal
259                    if ( $p < $formatLength - 1 ) {
260                        $endQuote = strpos( $format, '"', $p + 1 );
261                        if ( $endQuote === false ) {
262                            // No terminating quote, assume literal "
263                            $s .= '"';
264                        } else {
265                            $s .= preg_quote( substr( $format, $p + 1, $endQuote - $p - 1 ), '/' );
266                            $p = $endQuote;
267                        }
268                    } else {
269                        // Quote at end of string, assume literal "
270                        $s .= '"';
271                    }
272                    break;
273                default:
274                    // Copy whole characters together, instead of single bytes
275                    $char = mb_substr( mb_strcut( $format, $p, 4 ), 0, 1 );
276                    $s .= preg_quote( $char, '/' );
277                    $p += strlen( $char ) - 1;
278            }
279            if ( $num !== false ) {
280                $s .= '(' . $digitsRegexp . '{' . $num . '})';
281            }
282            // Ignore some invisible Unicode characters that often sneak into copy-pasted timestamps (T308448)
283            $s .= '[\\x{200E}\\x{200F}]?';
284        }
285
286        $tzRegexp = static::regexpAlternateGroup( array_keys( $tzAbbrs ) );
287
288        // Hard-coded parentheses and space like in Parser::pstPass2
289        // Ignore some invisible Unicode characters that often sneak into copy-pasted timestamps (T245784)
290        // \uNNNN syntax can only be used from PHP 7.3
291        return '/' . $s . ' [\\x{200E}\\x{200F}]?\\(' . $tzRegexp . '\\)/u';
292    }
293
294    /**
295     * Get a function that parses timestamps generated using the given date format, based on the result
296     * of matching the regexp returned by getTimestampRegexp()
297     *
298     * @param string $contLangVariant Content language variant
299     * @param string $format Date format, as used by MediaWiki
300     * @param string[]|null $digits Localised digits from 0 to 9, e.g. `[ '0', '1', ..., '9' ]`
301     * @param string $localTimezone Local timezone IANA name, e.g. `America/New_York`
302     * @param array $tzAbbrs Map of localised timezone abbreviations to IANA abbreviations
303     *   for the local timezone, e.g. [ 'EDT' => 'EDT', 'EST' => 'EST' ]
304     * @return callable Parser function
305     */
306    private function getTimestampParser(
307        string $contLangVariant, string $format, ?array $digits, string $localTimezone, array $tzAbbrs
308    ): callable {
309        $untransformDigits = static function ( string $text ) use ( $digits ) {
310            if ( !$digits ) {
311                return $text;
312            }
313            return preg_replace_callback(
314                '/[' . implode( '', $digits ) . ']/u',
315                static function ( array $m ) use ( $digits ) {
316                    return (string)array_search( $m[0], $digits );
317                },
318                $text
319            );
320        };
321
322        $formatLength = strlen( $format );
323        $matchingGroups = [];
324        for ( $p = 0; $p < $formatLength; $p++ ) {
325            $code = $format[$p];
326            if ( $code === 'x' && $p < $formatLength - 1 ) {
327                $code .= $format[++$p];
328            }
329            if ( $code === 'xk' && $p < $formatLength - 1 ) {
330                $code .= $format[++$p];
331            }
332
333            switch ( $code ) {
334                case 'xx':
335                    break;
336                case 'xg':
337                case 'd':
338                case 'j':
339                case 'D':
340                case 'l':
341                case 'F':
342                case 'M':
343                case 'n':
344                case 'Y':
345                case 'xkY':
346                case 'G':
347                case 'H':
348                case 'i':
349                    $matchingGroups[] = $code;
350                    break;
351                case '\\':
352                    // Backslash escaping
353                    if ( $p < $formatLength - 1 ) {
354                        $p++;
355                    }
356                    break;
357                case '"':
358                    // Quoted literal
359                    if ( $p < $formatLength - 1 ) {
360                        $endQuote = strpos( $format, '"', $p + 1 );
361                        if ( $endQuote !== false ) {
362                            $p = $endQuote;
363                        }
364                    }
365                    break;
366                default:
367                    break;
368            }
369        }
370
371        return function ( array $match ) use (
372            $matchingGroups, $untransformDigits, $localTimezone, $tzAbbrs, $contLangVariant
373        ) {
374            if ( is_array( $match[0] ) ) {
375                // Strip PREG_OFFSET_CAPTURE data
376                unset( $match['offset'] );
377                $match = array_map( static function ( array $tuple ) {
378                    return $tuple[0];
379                }, $match );
380            }
381            $year = 0;
382            $monthIdx = 0;
383            $day = 0;
384            $hour = 0;
385            $minute = 0;
386            foreach ( $matchingGroups as $i => $code ) {
387                $text = $match[$i + 1];
388                switch ( $code ) {
389                    case 'xg':
390                        $monthIdx = array_search(
391                            $text,
392                            $this->getMessages( $contLangVariant, Language::MONTH_GENITIVE_MESSAGES )
393                        );
394                        break;
395                    case 'd':
396                    case 'j':
397                        $day = intval( $untransformDigits( $text ) );
398                        break;
399                    case 'D':
400                    case 'l':
401                        // Day of the week - unused
402                        break;
403                    case 'F':
404                        $monthIdx = array_search(
405                            $text,
406                            $this->getMessages( $contLangVariant, Language::MONTH_MESSAGES )
407                        );
408                        break;
409                    case 'M':
410                        $monthIdx = array_search(
411                            $text,
412                            $this->getMessages( $contLangVariant, Language::MONTH_ABBREVIATED_MESSAGES )
413                        );
414                        break;
415                    case 'n':
416                        $monthIdx = intval( $untransformDigits( $text ) ) - 1;
417                        break;
418                    case 'Y':
419                        $year = intval( $untransformDigits( $text ) );
420                        break;
421                    case 'xkY':
422                        // Thai year
423                        $year = intval( $untransformDigits( $text ) ) - 543;
424                        break;