Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
LiFixups
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 3
1560
0.00% covered (danger)
0.00%
0 / 1
 getMigrationInfo
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 findLastMigratableNode
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 migrateTrailingSolTransparentLinks
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
462
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\DOM\Handlers;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Parsoid\DOM\Comment;
8use Wikimedia\Parsoid\DOM\Element;
9use Wikimedia\Parsoid\DOM\Node;
10use Wikimedia\Parsoid\DOM\Text;
11use Wikimedia\Parsoid\Utils\DiffDOMUtils;
12use Wikimedia\Parsoid\Utils\DOMDataUtils;
13use Wikimedia\Parsoid\Utils\DOMUtils;
14use Wikimedia\Parsoid\Utils\DTState;
15use Wikimedia\Parsoid\Utils\WTUtils;
16
17class LiFixups {
18
19    /**
20     * @return array{tplRoot: ?Element, migratable: bool}
21     */
22    private static function getMigrationInfo( Node $c ): array {
23        $tplRoot = WTUtils::findFirstEncapsulationWrapperNode( $c );
24        if ( $tplRoot !== null ) {
25            // Check if everything between tplRoot and c is migratable.
26            $prev = $tplRoot->previousSibling;
27            while ( $c !== $prev ) {
28                if ( !WTUtils::isSolTransparentLink( $c ) &&
29                    ( $c instanceof Element && !DOMUtils::hasClass( $c, 'mw-empty-elt' ) ) &&
30                    !( DOMUtils::nodeName( $c ) === 'span' && preg_match( '/^\s*$/D', $c->textContent ) )
31                ) {
32                    return [ 'tplRoot' => $tplRoot, 'migratable' => false ];
33                }
34
35                $c = $c->previousSibling;
36            }
37        }
38
39        return [ 'tplRoot' => $tplRoot, 'migratable' => true ];
40    }
41
42    /**
43     * @return Comment|Text|null
44     */
45    private static function findLastMigratableNode( Node $li ): ?Node {
46        $sentinel = null;
47        $c = DiffDOMUtils::lastNonSepChild( $li );
48        // c is known to be a sol-transparent link (ex: category)
49        // fail fast in parser tests if something changes.
50        Assert::invariant( WTUtils::isSolTransparentLink( $c ), 'c is known to be a sol-transparent link' );
51        while ( $c ) {
52            // Handle template units first
53            $info = self::getMigrationInfo( $c );
54            if ( !$info['migratable'] ) {
55                break;
56            } elseif ( $info['tplRoot'] !== null ) {
57                $c = $info['tplRoot'];
58            }
59
60            if ( $c instanceof Text ) {
61                // Update sentinel if we hit a newline.
62                // We want to migrate these newlines and
63                // everything following them out of 'li'.
64                if ( preg_match( '/\n\s*$/D', $c->nodeValue ) ) {
65                    $sentinel = $c;
66                }
67
68                // If we didn't hit pure whitespace, we are done!
69                if ( !preg_match( '/^\s*$/D', $c->nodeValue ) ) {
70                    break;
71                }
72            } elseif ( $c instanceof Comment ) {
73                $sentinel = $c;
74            } elseif ( !WTUtils::isSolTransparentLink( $c ) && !DOMUtils::hasClass( $c, 'mw-empty-elt' ) ) {
75                // We are done if we hit anything but text
76                // or sol-transparent links.
77                break;
78            }
79
80            $c = $c->previousSibling;
81        }
82
83        return $sentinel;
84    }
85
86    /**
87     * Earlier in the parsing pipeline, we suppress all newlines and
88     * other whitespace before sol-transparent links which causes them
89     * to be swallowed into preceding paragraphs and list items.
90     *
91     * However, with wikitext like this: `*a\n\n[[Category:Foo]]`, this
92     * could prevent proper roundtripping (because we suppress newlines
93     * when serializing list items). This needs addressing because
94     * this pattern is extremely common (some list at the end of the page
95     * followed by a list of categories for the page).
96     */
97    public static function migrateTrailingSolTransparentLinks( Element $li, DTState $state ): bool {
98        // * Don't bother fixing up template content when processing the full page
99        if ( $state->tplInfo ?? null ) {
100            return true;
101        }
102
103        // If there is migratable content inside a list item
104        // (such as categories preceded by newlines),
105        // * migrate it out of the outermost list
106        // * and fix up the DSR of list items and list along the rightmost path.
107        if ( $li->nextSibling === null && DOMUtils::isList( $li->parentNode ) &&
108            WTUtils::isSolTransparentLink( DiffDOMUtils::lastNonSepChild( $li ) )
109        ) {
110
111            // Find the outermost list -- content will be moved after it
112            $outerList = $li->parentNode;
113            // Expected that $outerList is a list container (ul/dl/ol),
114            // and we're checking whether its parent is a list *item* (li/dd/etc)
115            while ( DOMUtils::isListItem( $outerList->parentNode ) ) {
116                // if so, $p will be another list item...
117                $p = $outerList->parentNode;
118                // Bail if we find ourself on a path that is not the rightmost path.
119                if ( $p->nextSibling !== null ) {
120                    return true;
121                }
122                // ...and we expect that $p->parentNode will be a list
123                // container. (Bail if it's not)
124                if ( !DOMUtils::isList( $p->parentNode ) ) {
125                    break;
126                }
127                $outerList = $p->parentNode;
128                // Now $outerList->parentNode might be a list item again.
129            }
130
131            // Find last migratable node
132            $sentinel = self::findLastMigratableNode( $li );
133            if ( !$sentinel ) {
134                return true;
135            }
136
137            // Migrate (and update DSR)
138            $c = $li->lastChild;
139            $liDsr = DOMDataUtils::getDataParsoid( $li )->dsr ?? null;
140            $newEndDsr = -1; // dummy to eliminate useless null checks
141            while ( true ) {
142                if ( $c instanceof Element ) {
143                    $dsr = DOMDataUtils::getDataParsoid( $c )->dsr ?? null;
144                    $newEndDsr = $dsr->start ?? -1;
145                    $outerList->parentNode->insertBefore( $c, $outerList->nextSibling );
146                } elseif ( $c instanceof Text ) {
147                    if ( preg_match( '/^\s*$/D', $c->nodeValue ) ) {
148                        $newEndDsr -= strlen( $c->nodeValue );
149                        $outerList->parentNode->insertBefore( $c, $outerList->nextSibling );
150                    } else {
151                        // Split off the newlines into its own node and migrate it
152                        $nls = $c->nodeValue;
153                        $c->nodeValue = preg_replace( '/\s+$/D', '', $c->nodeValue, 1 );
154                        $nls = substr( $nls, strlen( $c->nodeValue ) );
155                        $nlNode = $c->ownerDocument->createTextNode( $nls );
156                        $outerList->parentNode->insertBefore( $nlNode, $outerList->nextSibling );
157                        $newEndDsr -= strlen( $nls );
158                    }
159                } elseif ( $c instanceof Comment ) {
160                    $newEndDsr -= WTUtils::decodedCommentLength( $c );
161                    $outerList->parentNode->insertBefore( $c, $outerList->nextSibling );
162                }
163
164                if ( $c === $sentinel ) {
165                    break;
166                }
167
168                $c = $li->lastChild;
169            }
170
171            // Update DSR of all listitem & list nodes till
172            // we hit the outermost list we started with.
173            $delta = null;
174            if ( $liDsr && $newEndDsr >= 0 ) {
175                $delta = $liDsr->end - $newEndDsr;
176            }
177
178            // If there is no delta to adjust dsr by, we are done
179            if ( !$delta ) {
180                return true;
181            }
182
183            // Fix DSR along the rightmost path to outerList
184            $list = null;
185            while ( $outerList !== $list ) {
186                $list = $li->parentNode;
187                '@phan-var Element $list'; // @var Element $list
188
189                $liDp = DOMDataUtils::getDataParsoid( $li );
190                if ( !empty( $liDp->dsr ) ) {
191                    $liDp->dsr->end -= $delta;
192                }
193
194                $listDp = DOMDataUtils::getDataParsoid( $list );
195                if ( !empty( $listDp->dsr ) ) {
196                    $listDp->dsr->end -= $delta;
197                }
198                $li = $list->parentNode;
199            }
200        }
201
202        return true;
203    }
204}