Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.90% covered (warning)
58.90%
344 / 584
40.62% covered (danger)
40.62%
13 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMRangeBuilder
58.90% covered (warning)
58.90%
344 / 584
40.62% covered (danger)
40.62%
13 / 32
3770.97
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
 updateDSRForFirstRangeNode
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 getRangeEndDSR
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRangeId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDOMRange
23.44% covered (danger)
23.44%
15 / 64
0.00% covered (danger)
0.00%
0 / 1
115.98
 getStartConsideringFosteredContent
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
9.83
 stripStartMeta
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 findToplevelEnclosingRange
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 recordTemplateInfo
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
5.93
 introducesCycle
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 rangesOverlap
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 findTopLevelNonOverlappingRanges
47.22% covered (danger)
47.22%
51 / 108
0.00% covered (danger)
0.00%
0 / 1
193.10
 findFirstTemplatedNode
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 isDeletableNode
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
12.00
 ensureElementsInRangeAndAddAboutIds
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 findEncapTarget
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 migrateElements
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 isNewlineWrappingSpan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 encapsulateTemplates
63.81% covered (warning)
63.81%
67 / 105
0.00% covered (danger)
0.00%
0 / 1
111.10
 addNodeRange
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getNodeRanges
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findWrappableMetaRanges
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 findWrappableTemplateRangesRecursive
63.64% covered (warning)
63.64%
28 / 44
0.00% covered (danger)
0.00%
0 / 1
51.70
 matchMetaType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyTplInfoExpectation
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 execute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 findEnclosingRange
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
5.29
 handleRenderingTransparentEltsBetweenBlocks
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 canSwallowRenderingTransparentNodes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 handleFirstRenderingTransparentNode
75.47% covered (warning)
75.47%
40 / 53
0.00% covered (danger)
0.00%
0 / 1
18.32
 shouldStashRenderingTransparentNodes
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 isStashableNode
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\DOM\Processors;
5
6use Error;
7use SplObjectStorage;
8use Wikimedia\Assert\Assert;
9use Wikimedia\Assert\UnreachableException;
10use Wikimedia\Parsoid\Config\Env;
11use Wikimedia\Parsoid\Core\DOMCompat;
12use Wikimedia\Parsoid\Core\DomSourceRange;
13use Wikimedia\Parsoid\Core\ElementRange;
14use Wikimedia\Parsoid\DOM\Comment;
15use Wikimedia\Parsoid\DOM\Document;
16use Wikimedia\Parsoid\DOM\Element;
17use Wikimedia\Parsoid\DOM\Node;
18use Wikimedia\Parsoid\DOM\Text;
19use Wikimedia\Parsoid\NodeData\DataParsoid;
20use Wikimedia\Parsoid\NodeData\TempData;
21use Wikimedia\Parsoid\NodeData\TemplateInfo;
22use Wikimedia\Parsoid\Utils\DOMDataUtils;
23use Wikimedia\Parsoid\Utils\DOMTraverser;
24use Wikimedia\Parsoid\Utils\DOMUtils;
25use Wikimedia\Parsoid\Utils\PHPUtils;
26use Wikimedia\Parsoid\Utils\Utils;
27use Wikimedia\Parsoid\Utils\WTUtils;
28use Wikimedia\Parsoid\Wt2Html\Frame;
29
30/**
31 * Template encapsulation happens in three steps.
32 *
33 * 1. findWrappableTemplateRanges
34 *
35 *    Locate start and end metas. Walk upwards towards the root from both and
36 *    find a common ancestor A. The subtree rooted at A is now effectively the
37 *    scope of the dom template ouput.
38 *
39 * 2. findTopLevelNonOverlappingRanges
40 *
41 *    Mark all nodes in a range and walk up to root from each range start to
42 *    determine overlaps, nesting. Merge overlapping and nested ranges to find
43 *    the subset of top-level non-overlapping ranges which will be wrapped as
44 *    individual units.
45 *
46 * 3. encapsulateTemplates
47 *
48 *    For each non-overlapping range,
49 *    - compute a data-mw according to the DOM spec
50 *    - replace the start / end meta markers with transclusion type and data-mw
51 *      on the first DOM node
52 *    - add about ids on all top-level nodes of the range
53 *
54 * This is a simple high-level overview of the 3 steps to help understand this
55 * code.
56 *
57 * FIXME: At some point, more of the details should be extracted and documented
58 * in pseudo-code as an algorithm.
59 */
60class DOMRangeBuilder {
61
62    private const MAP_TBODY_TR = [
63        'tbody' => true,
64        'tr' => true
65    ];
66
67    private Document $document;
68    private Frame $frame;
69    protected Env $env;
70    protected SplObjectStorage $nodeRanges;
71    /** @var array<string|CompoundTemplateInfo>[] */
72    private array $compoundTpls = [];
73    protected string $traceType;
74
75    public function __construct(
76        Document $document, Frame $frame
77    ) {
78        $this->document = $document;
79        $this->frame = $frame;
80        $this->env = $frame->getEnv();
81        $this->nodeRanges = new SplObjectStorage;
82        $this->traceType = "tplwrap";
83    }
84
85    protected function updateDSRForFirstRangeNode( Element $target, Element $source ): void {
86        $srcDP = DOMDataUtils::getDataParsoid( $source );
87        $tgtDP = DOMDataUtils::getDataParsoid( $target );
88
89        // Since TSRs on template content tokens are cleared by the
90        // template handler, all computed dsr values for template content
91        // is always inferred from top-level content values and is safe.
92        // So, do not overwrite a bigger end-dsr value.
93        if ( isset( $srcDP->dsr ) && $srcDP->dsr->end !== null &&
94            isset( $tgtDP->dsr ) && $tgtDP->dsr->end !== null &&
95            $tgtDP->dsr->end > $srcDP->dsr->end
96        ) {
97            $tgtDP->dsr->start = $srcDP->dsr->start ?? null;
98        } else {
99            $tgtDP->dsr = clone $srcDP->dsr;
100            $tgtDP->src = $srcDP->src ?? null;
101        }
102    }
103
104    /**
105     * Get the DSR of the end of a DOMRange
106     */
107    private static function getRangeEndDSR( DOMRangeInfo $range ): ?DomSourceRange {
108        return DOMDataUtils::getDataParsoid( $range->end )->dsr ?? null;
109    }
110
111    /**
112     * Returns the range ID of a node - in the case of templates, its "about" attribute.
113     */
114    protected function getRangeId( Element $node ): string {
115        $rangeId = DOMCompat::getAttribute( $node, "about" );
116        '@phan-var string $rangeId'; // asserting this is not null
117        return $rangeId;
118    }
119
120    /**
121     * Find the common DOM ancestor of two DOM nodes.
122     */
123    private function getDOMRange(
124        Element $startMeta, Element $endMeta, Element $endElem
125    ): DOMRangeInfo {
126        $range = $this->findEnclosingRange( $startMeta, $endMeta, $endElem );
127        $startsInFosterablePosn = DOMUtils::isFosterablePosition( $range->start );
128        $next = $range->start->nextSibling;
129
130        // Detect empty content and handle them!
131        if ( WTUtils::isTplMarkerMeta( $range->start ) && $next === $endElem ) {
132            Assert::invariant(
133                $range->start === $range->startElem,
134                "Expected startElem to be same as range.start"
135            );
136            if ( $startsInFosterablePosn ) {
137                // Expand range!
138                $range->start = $range->end = $range->start->parentNode;
139            } else {
140                $emptySpan = $this->document->createElement( 'span' );
141                $range->start->parentNode->insertBefore( $emptySpan, $endElem );
142            }
143
144            // Handle unwrappable content in fosterable positions
145            // and expand template range, if required.
146            // NOTE: Template marker meta tags are translated from comments
147            // *after* the DOM has been built which is why they can show up in
148            // fosterable positions in the DOM.
149        } elseif ( $startsInFosterablePosn &&
150            WTUtils::isTplMarkerMeta( $range->start ) &&
151            ( !( $next instanceof Element ) || WTUtils::isTplMarkerMeta( $next ) )
152        ) {
153            $rangeStartParent = $range->start->parentNode;
154
155            // If we are in a table in a foster-element position, then all non-element
156            // nodes will be white-space and comments. Skip over all of them and find
157            // the first table content node.
158            $noWS = true;
159            $nodesToMigrate = [];
160            while ( !( $next instanceof Element ) ) {
161                if ( $next instanceof Text ) {
162                    $noWS = false;
163                }
164                $nodesToMigrate[] = $next;
165                $next = $next->nextSibling;
166            }
167            $newStart = $next;
168
169            // As long as $newStart is a tr/tbody or we don't have whitespace
170            // migrate $nodesToMigrate into $newStart. Pushing whitespace into
171            // th/td/caption can change display semantics.
172            if ( $newStart && ( $noWS || isset( self::MAP_TBODY_TR[DOMUtils::nodeName( $newStart )] ) ) ) {
173                /**
174                 * The point of the above loop is to ensure we're working
175                 * with a Element if there is a $newStart.
176                 *
177                 * @var Element $newStart
178                 */
179                '@phan-var Element $newStart';
180                $insertPosition = $newStart->firstChild;
181                foreach ( $nodesToMigrate as $n ) {
182                    $newStart->insertBefore( $n, $insertPosition );
183                }
184                $range->start = $newStart;
185                // Update dsr to point to original start
186                $this->updateDSRForFirstRangeNode( $range->start, $range->startElem );
187            } else {
188                // If not, we are forced to expand the template range.
189                $range->start = $range->end = $rangeStartParent;
190            }
191        }
192
193        $range->start = $this->getStartConsideringFosteredContent( $range->start );
194
195        // Use the negative test since it doesn't mark the range as flipped
196        // if range.start === range.end
197        if ( !DOMUtils::inSiblingOrder( $range->start, $range->end ) ) {
198            // In foster-parenting situations, the end-meta tag (and hence range.end)
199            // can show up before the range.start which would be the table itself.
200            // So, we record this info for later analysis.
201            $range->flipped = true;
202        }
203
204        $this->env->trace(
205            "{$this->traceType}/findranges",
206            static function () use ( &$range ) {
207                $msg = '';
208                $dp1 = DOMDataUtils::getDataParsoid( $range->start );
209                $dp2 = DOMDataUtils::getDataParsoid( $range->end );
210                $tmp1 = $dp1->tmp;
211                $tmp2 = $dp2->tmp;
212                $dp1->tmp = null;
213                $dp2->tmp = null;
214                $msg .= "\n----------------------------------------------";
215                $msg .= "\nFound range : " . $range->id . '; flipped? ' . ( (string)$range->flipped ) .
216                    '; offset: ' . $range->startOffset;
217                $msg .= "\nstart-elem : " . DOMCompat::getOuterHTML( $range->startElem ) . '; DP: ' .
218                    PHPUtils::jsonEncode( DOMDataUtils::getDataParsoid( $range->startElem ) );
219                $msg .= "\nend-elem : " . DOMCompat::getOuterHTML( $range->endElem ) . '; DP: ' .
220                    PHPUtils::jsonEncode( DOMDataUtils::getDataParsoid( $range->endElem ) );
221                $msg .= "\nstart : [TAG_ID " . ( $tmp1->tagId ?? '?' ) . ']: ' .
222                    DOMCompat::getOuterHTML( $range->start ) .
223                    '; DP: ' . PHPUtils::jsonEncode( $dp1 );
224                $msg .= "\nend : [TAG_ID " . ( $tmp2->tagId ?? '?' ) . ']: ' .
225                    DOMCompat::getOuterHTML( $range->end ) .
226                    '; DP: ' . PHPUtils::jsonEncode( $dp2 );
227                $msg .= "\n----------------------------------------------";
228                $dp1->tmp = $tmp1;
229                $dp2->tmp = $tmp2;
230                return $msg;
231            }
232        );
233
234        return $range;
235    }
236
237    /**
238     * Returns the current node if it's not just after fostered content, the first node
239     * of fostered content otherwise.
240     */
241    protected function getStartConsideringFosteredContent( Element $node ): Element {
242        if ( DOMUtils::nodeName( $node ) === 'table' ) {
243            // If we have any fostered content, include it as well.
244            for ( $previousSibling = $node->previousSibling;
245                $previousSibling instanceof Element &&
246                !empty( DOMDataUtils::getDataParsoid( $previousSibling )->fostered );
247                $previousSibling = $node->previousSibling
248            ) {
249                $node = $previousSibling;
250            }
251        }
252        return $node;
253    }
254
255    private static function stripStartMeta( Element $meta ): void {
256        if ( DOMUtils::nodeName( $meta ) === 'meta' ) {
257            $meta->parentNode->removeChild( $meta );
258        } else {
259            // Remove mw:* from the typeof.
260            $type = DOMCompat::getAttribute( $meta, 'typeof' );
261            if ( $type !== null ) {
262                $type = preg_replace( '/(?:^|\s)mw:[^\/]*(\/\S+|(?=$|\s))/D', '', $type );
263                $meta->setAttribute( 'typeof', $type );
264            }
265        }
266    }
267
268    private static function findToplevelEnclosingRange(
269        array $nestingInfo, string $startId
270    ): ?string {
271        // Walk up the implicit nesting tree to find the
272        // top-level range within which rId is nested.
273        // No cycles can exist since they have been suppressed.
274        $visited = [];
275        $rId = $startId;
276        while ( isset( $nestingInfo[$rId] ) ) {
277            if ( isset( $visited[$rId] ) ) {
278                throw new Error( "Found a cycle in tpl-range nesting where there shouldn't have been one." );
279            }
280            $visited[$rId] = true;
281            $rId = $nestingInfo[$rId];
282        }
283        return $rId === $startId ? null : $rId;
284    }
285
286    /**
287     * Add a template to $this->compoundTpls
288     */
289    private function recordTemplateInfo(
290        string $compoundTplId, DOMRangeInfo $range, TemplateInfo $templateInfo
291    ): void {
292        $this->compoundTpls[$compoundTplId] ??= [];
293
294        // Record template args info along with any intervening wikitext
295        // between templates that are part of the same compound structure.
296        /** @var array $tplArray */
297        $tplArray = &$this->compoundTpls[$compoundTplId];
298        $dp = DOMDataUtils::getDataParsoid( $range->startElem );
299        $dsr = $dp->dsr;
300
301        if ( count( $tplArray ) > 0 ) {
302            $prevTplInfo = PHPUtils::lastItem( $tplArray );
303            if ( $prevTplInfo->dsr->end < $dsr->start ) {
304                $width = $dsr->start - $prevTplInfo->dsr->end;
305                $source = $dsr->source ?? $this->frame->getSource();
306                $tplArray[] = PHPUtils::safeSubstr(
307                    $source->getSrcText(), $prevTplInfo->dsr->end, $width );
308            }
309        }
310
311        if ( !empty( $dp->unwrappedWT ) ) {
312            $tplArray[] = (string)$dp->unwrappedWT;
313        }
314
315        // Get rid of src-offsets since they aren't needed anymore.
316        foreach ( $templateInfo->paramInfos as $pi ) {
317            $pi->srcOffsets = null;
318        }
319        $tplArray[] = new CompoundTemplateInfo(
320            dsr: $dsr,
321            info: $templateInfo,
322            isParam: DOMUtils::hasTypeOf( $range->startElem, 'mw:Param' ),
323            colon: $dp->colon ?? null,
324        );
325    }
326
327    /**
328     * Determine whether adding the given range would introduce a cycle in the
329     * subsumedRanges graph.
330     *
331     * Nesting cycles with multiple ranges can show up because of foster
332     * parenting scenarios if they are not detected and suppressed.
333     *
334     * @param string $start The ID of the new range
335     * @param string $end The ID of the other range
336     * @param string[] $subsumedRanges The subsumed ranges graph, encoded as an
337     *   array in which each element maps one string range ID to another range ID
338     * @return bool
339     */
340    private static function introducesCycle( string $start, string $end, array $subsumedRanges ): bool {
341        $visited = [ $start => true ];
342        $elt = $subsumedRanges[$end] ?? null;
343        while ( $elt ) {
344            if ( !empty( $visited[$elt] ) ) {
345                return true;
346            }
347            $elt = $subsumedRanges[$elt] ?? null;
348        }
349        return false;
350    }
351
352    /**
353     * Determine whether DOM ranges overlap.
354     *
355     * The `inSiblingOrder` check here is sufficient to determine overlaps
356     * because the algorithm in `findWrappableTemplateRanges` will put the
357     * start/end elements for intersecting ranges on the same plane and prev/
358     * curr are in textual order (which translates to dom order).
359     */
360    private static function rangesOverlap( DOMRangeInfo $prev, DOMRangeInfo $curr ): bool {
361        $prevEnd = ( !$prev->flipped ) ? $prev->end : $prev->start;
362        $currStart = ( !$curr->flipped ) ? $curr->start : $curr->end;
363        return DOMUtils::inSiblingOrder( $currStart, $prevEnd );
364    }
365
366    /**
367     * Identify the elements of $tplRanges that are non-overlapping.
368     * Record template info in $this->compoundTpls as we go.
369     *
370     * @param Node $docRoot
371     * @param list<DOMRangeInfo> $tplRanges The potentially overlapping ranges
372     * @return list<DOMRangeInfo> The non-overlapping ranges
373     */
374    public function findTopLevelNonOverlappingRanges( Node $docRoot, array $tplRanges ): array {
375        // For each node, assign an attribute that is a record of all
376        // tpl ranges it belongs to at the top-level.
377        foreach ( $tplRanges as $r ) {
378            $n = !$r->flipped ? $r->start : $r->end;
379            $e = !$r->flipped ? $r->end : $r->start;
380
381            while ( $n ) {
382                if ( $n instanceof Element ) {
383                    $this->addNodeRange( $n, $r );
384                    // Done
385                    if ( $n === $e ) {
386                        break;
387                    }
388                }
389
390                $n = $n->nextSibling;
391            }
392        }
393
394        // In the first pass over `numRanges` below, `subsumedRanges` is used to
395        // record purely the nested ranges.  However, in the second pass, we also
396        // add the relationships between overlapping ranges so that
397        // `findToplevelEnclosingRange` can use that information to add `argInfo`
398        // to the right `compoundTpls`.  This scenario can come up when you have
399        // three ranges, 1 intersecting with 2 but not 3, and 3 nested in 2.
400        $subsumedRanges = [];
401
402        // For each range r:(s, e), walk up from s --> docRoot and if any of
403        // these nodes have tpl-ranges (besides r itself) assigned to them,
404        // then r is nested in those other templates and can be ignored.
405        foreach ( $tplRanges as $r ) {
406            $n = $r->start;
407
408            while ( $n !== $docRoot ) {
409                $ranges = $this->getNodeRanges( $n );
410                if ( $ranges ) {
411                    if ( $n !== $r->start ) {
412                        // 'r' is nested for sure
413                        // Record the outermost range in which 'r' is nested.
414                        $outermostId = null;
415                        $outermostOffset = null;
416                        foreach ( $ranges as $rangeId => $range ) {
417                            if ( $outermostId === null
418                                || $range->startOffset < $outermostOffset
419                            ) {
420                                $outermostId = $rangeId;
421                                $outermostOffset = $range->startOffset;
422                            }
423                        }
424                        $subsumedRanges[$r->id] = (string)$outermostId;
425                        break;
426                    } else {
427                        // n === r.start
428                        //
429                        // We have to make sure this is not an overlap scenario.
430                        // Find the ranges that r.start and r.end belong to and
431                        // compute their intersection. If this intersection has
432                        // another tpl range besides r itself, we have a winner!
433                        //
434                        // The code below does the above check efficiently.
435                        $eTpls = $this->getNodeRanges( $r->end );
436                        $foundNesting = false;
437
438                        foreach ( $ranges as $otherId => $other ) {
439                            // - Don't record nesting cycles.
440                            // - Record the outermost range in which 'r' is nested in.
441                            if ( $otherId !== $r->id &&
442                                !empty( $eTpls[$otherId] ) &&
443                                // When we have identical ranges, pick the range with
444                                // the larger offset to be subsumed.
445                                ( $r->start !== $other->start ||
446                                    $r->end !== $other->end ||
447                                    $other->startOffset < $r->startOffset
448                                ) &&
449                                !self::introducesCycle( $r->id, (string)$otherId, $subsumedRanges )
450                            ) {
451                                $foundNesting = true;
452                                if ( !isset( $subsumedRanges[$r->id] ) ||
453                                    $other->startOffset < $ranges[$subsumedRanges[$r->id]]->startOffset
454                                ) {
455                                    $subsumedRanges[$r->id] = (string)$otherId;
456                                }
457                            }
458                        }
459
460                        if ( $foundNesting ) {
461                            // 'r' is nested
462                            break;
463                        }
464                    }
465                }
466
467                // Move up
468                $n = $n->parentNode;
469            }
470        }
471
472        // Sort by start offset in source wikitext
473        usort( $tplRanges, static function ( $r1, $r2 ) {
474            return $r1->startOffset - $r2->startOffset;
475        } );
476
477        // Since the tpl ranges are sorted in textual order (by start offset),
478        // it is sufficient to only look at the most recent template to see
479        // if the current one overlaps with the previous one.
480        //
481        // This works because we've already identify nested ranges and can ignore them.
482
483        $newRanges = [];
484        $prev = null;
485
486        foreach ( $tplRanges as $r ) {
487            $endTagToRemove = null;
488            $startTagToStrip = null;
489
490            // Extract tplargInfo
491            $tmp = DOMDataUtils::getDataParsoid( $r->startElem )->getTemp();
492            $templateInfo = $tmp->tplarginfo ?? null;
493
494            $this->verifyTplInfoExpectation( $templateInfo, $tmp );
495
496            $this->env->trace( "{$this->traceType}/merge", static function () use ( &$r ) {
497                $msg = '';
498                $dp1 = DOMDataUtils::getDataParsoid( $r->start );
499                $dp2 = DOMDataUtils::getDataParsoid( $r->end );
500                $tmp1 = $dp1->tmp;
501                $tmp2 = $dp2->tmp;
502                $dp1->tmp = null;
503                $dp2->tmp = null;
504                $msg .= "\n##############################################";
505                $msg .= "\nrange " . $r->id . '; r-start-elem: ' . DOMCompat::getOuterHTML( $r->startElem ) .
506                    '; DP: ' . PHPUtils::jsonEncode( DOMDataUtils::getDataParsoid( $r->startElem ) );
507                $msg .= "\nrange " . $r->id . '; r-end-elem: ' . DOMCompat::getOuterHTML( $r->endElem ) .
508                    '; DP: ' . PHPUtils::jsonEncode( DOMDataUtils::getDataParsoid( $r->endElem ) );
509                $msg .= "\nrange " . $r->id . '; r-start: [TAG_ID ' . ( $tmp1->tagId ?? '?' ) . ']: ' .
510                    DOMCompat::getOuterHTML( $r->start ) . '; DP: ' . PHPUtils::jsonEncode( $dp1 );
511                $msg .= "\nrange " . $r->id . '; r-end: [TAG_ID ' . ( $tmp2->tagId ?? '?' ) . ']: ' .
512                    DOMCompat::getOuterHTML( $r->end ) . '; DP: ' . PHPUtils::jsonEncode( $dp2 );
513                $msg .= "\n----------------------------------------------";
514                $dp1->tmp = $tmp1;
515                $dp2->tmp = $tmp2;
516                return $msg;
517            } );
518
519            $enclosingRangeId = self::findToplevelEnclosingRange(
520                $subsumedRanges, $r->id
521            );
522            if ( $enclosingRangeId ) {
523                $this->env->trace( "{$this->traceType}/merge", '--nested in ', $enclosingRangeId, '--' );
524
525                // Nested -- ignore r
526                $startTagToStrip = $r->startElem;
527                $endTagToRemove = $r->endElem;
528                if ( $templateInfo ) {
529                    // 'r' is nested in 'enclosingRange' at the top-level
530                    // So, enclosingRange gets r's argInfo
531                    $this->recordTemplateInfo( $enclosingRangeId, $r, $templateInfo );
532                }
533            } elseif ( $prev && self::rangesOverlap( $prev, $r ) ) {
534                // In the common case, in overlapping scenarios, r.start is
535                // identical to prev.end. However, in fostered content scenarios,
536                // there can true overlap of the ranges.
537                $this->env->trace( "{$this->traceType}/merge", '--overlapped--' );
538
539                // See comment above, where `subsumedRanges` is defined.
540                $subsumedRanges[$r->id] = $prev->id;
541
542                // Overlapping ranges.
543                // r is the regular kind
544                // Merge r with prev
545
546                // Note that if a table comes from a template, a foster box isn't
547                // emitted so the enclosure isn't guaranteed.  In pathological
548                // cases, like where the table end tag isn't emitted, we can still
549                // end up with flipped ranges if the template end marker gets into
550                // a fosterable position (which can still happen despite being
551                // emitted as a comment).
552                Assert::invariant( !$r->flipped,
553                    'Flipped range should have been enclosed.'
554                );
555
556                $startTagToStrip = $r->startElem;
557                $endTagToRemove = $prev->endElem;
558
559                $prev->end = $r->end;
560                $prev->endElem = $r->endElem;
561                if ( WTUtils::isMarkerAnnotation( $r->endElem ) ) {
562                    $endDataMw = DOMDataUtils::getDataMw( $r->endElem );
563                    $endDataMw->rangeId = $r->id;
564                    $prev->extendedByOverlapMerge = true;
565                }
566
567                // Update compoundTplInfo
568                if ( $templateInfo ) {
569                    $this->recordTemplateInfo( $prev->id, $r, $templateInfo );
570                }
571            } else {
572                $this->env->trace( "{$this->traceType}/merge", '--normal--' );
573
574                // Default -- no overlap
575                // Emit the merged range
576                $newRanges[] = $r;
577                $prev = $r;
578
579                // Update compoundTpls
580                if ( $templateInfo ) {
581                    $this->recordTemplateInfo( $r->id, $r, $templateInfo );
582                }
583            }
584
585            if ( $endTagToRemove ) {
586                // Remove start and end meta-tags
587                // Not necessary to remove the start tag, but good to cleanup
588                $endTagToRemove->parentNode->removeChild( $endTagToRemove );
589                self::stripStartMeta( $startTagToStrip );
590            }
591        }
592
593        return $newRanges;
594    }
595
596    /**
597     * Note that the case of nodeName varies with DOM implementation.  This
598     * method currently forces the name nodeName to uppercase.  In the future
599     * we can/should switch to using the "native" case of the DOM
600     * implementation; we do a case-insensitive match (by converting the result
601     * to the "native" case of the DOM implementation) in
602     * EncapsulatedContentHandler when this value is used.
603     * @param DOMRangeInfo $range
604     * @return string|null nodeName with an optional "_$stx" suffix.
605     */
606    private static function findFirstTemplatedNode( DOMRangeInfo $range ): ?string {
607        $firstNode = $range->start;
608
609        // Skip tpl marker meta
610        if ( WTUtils::isTplMarkerMeta( $firstNode ) ) {
611            $firstNode = $firstNode->nextSibling;
612        }
613
614        // Walk past fostered nodes since they came from within a table
615        // Note that this is not foolproof because in some scenarios,
616        // fostered content is not marked up. Ex: when a table is templated,
617        // and content from the table is fostered.
618        $dp = DOMDataUtils::getDataParsoid( $firstNode );
619        while ( !empty( $dp->fostered ) ) {
620            $firstNode = $firstNode->nextSibling;
621            '@phan-var Element $firstNode'; // @var Element $firstNode
622            $dp = DOMDataUtils::getDataParsoid( $firstNode );
623        }
624
625        // FIXME: It is harder to use META as a node name since this is a generic
626        // placeholder for a whole bunch of things each of which has its own
627        // newline constraint requirements. So, for now, I am skipping that
628        // can of worms to prevent confusing the serializer with an overloaded
629        // tag name.
630        if ( DOMUtils::nodeName( $firstNode ) === 'meta' ) {
631            return null;
632        }
633
634        // FIXME spec-compliant values would be upper-case, this is just a workaround
635        // for current PHP DOM implementation and could be removed in the future
636        // See discussion in the method comment above.
637        $nodeName = mb_strtoupper( DOMUtils::nodeName( $firstNode ), "UTF-8" );
638
639        return !empty( $dp->stx ) ? $nodeName . '_' . $dp->stx : $nodeName;
640    }
641
642    private function isDeletableNode( Node $n ): bool {
643        // NOTE: There cannot be any non-IEW text in fosterable position
644        // since the HTML tree builder would already have fostered it out.
645        // So, any non-element node found here is safe to delete since:
646        // (a) this has no rendering output impact, and
647        // (b) data-mw captures template output => we don't need
648        //     to preserve this for html2wt either. Removing this
649        //     lets us preserve DOM range continuity.
650        if ( DOMUtils::isFosterablePosition( $n ) ) {
651            return true;
652        }
653
654        if ( $n instanceof Comment ) {
655            // We only get here for standalone tests. The core preprocessor
656            // strips all comments, so we don't get here.
657            // We could return true here, but that is better as a separate
658            // patch since we will also need to update a bunch of tests.
659            return false;
660        }
661
662        '@phan-var Text $n'; // @var Text $n
663
664        // Bail if the text node is not newline only.
665        // From here on, we only deal with newlines.
666        if ( $n->textContent !== "\n" ) {
667            return false;
668        }
669
670        $prev = $n->previousSibling;
671        $next = $n->nextSibling;
672
673        if ( DOMUtils::isWikitextBlockNode( $prev ) &&
674            // Narrow set of sol-based wikitext constructs
675            in_array( DOMUtils::nodeName( $next ), [ 'ul', 'ol', 'table' ], true ) &&
676            !WTUtils::isLiteralHTMLNode( $next )
677        ) {
678            // This is narrowly targeted hacky fix for T370751.
679            // Whitespace doesn't interfere with next-sibling CSS rules.
680            // But, if we span wrap them as below, those CSS rules break.
681            // Here, we strip such newlines instead of span-wrapping them
682            // in the narrow case where they show up between block tags and
683            // the following block tag is a wikitext list or a table since
684            // the template cannot be edited to strip those newlines - they are
685            // essential for the lists / tables to be rendered as such.
686            return true;
687        }
688
689        if ( WTUtils::isSolTransparentLink( $prev ) && WTUtils::isSolTransparentLink( $next ) ) {
690            // This is a narrowly targeted fix for T407798
691            return true;
692        }
693
694        return false;
695    }
696
697    /**
698     * Encapsulation requires adding about attributes on the top-level
699     * nodes of the range. This requires them to all be Elements.
700     * Since start/end are always Elements, this only needs to examine
701     * and update intermediate nodes between them.
702     */
703    private function ensureElementsInRangeAndAddAboutIds( DOMRangeInfo $range ): void {
704        $n = $range->start;
705        $e = $range->end;
706        $about = DOMCompat::getAttribute( $range->startElem, 'about' );
707        while ( $n ) {
708            $next = $n->nextSibling;
709            if ( $n instanceof Element ) {
710                $n->setAttribute( 'about', $about );
711            } elseif ( self::isDeletableNode( $n ) ) {
712                $n->parentNode->removeChild( $n );
713            } else {
714                // Add a span wrapper to let us add about-ids to represent
715                // the DOM range as a contiguous chain of DOM nodes.
716                $span = $this->document->createElement( 'span' );
717                $span->setAttribute( 'about', $about );
718                $dp = new DataParsoid;
719                $dp->setTempFlag( TempData::WRAPPER );
720                DOMDataUtils::setDataParsoid( $span, $dp );
721                $n->parentNode->replaceChild( $span, $n );
722                $span->appendChild( $n );
723                $n = $span;
724            }
725
726            if ( $n === $e ) {
727                break;
728            }
729
730            $n = $next;
731        }
732    }
733
734    /**
735     * Find the first element to be encapsulated.
736     * Skip past marker metas and non-elements (which will all be IEW
737     * in fosterable positions in a table).
738     */
739    private static function findEncapTarget( DOMRangeInfo $range ): Element {
740        $encapTgt = $range->start;
741        '@phan-var Node $encapTgt'; // @var Node $encapTgt
742
743        // Skip template-marker meta-tags.
744        while ( WTUtils::isTplMarkerMeta( $encapTgt ) ||
745            !( $encapTgt instanceof Element )
746        ) {
747            // Detect unwrappable template and bail out early.
748            if ( $encapTgt === $range->end ||
749                ( !( $encapTgt instanceof Element ) &&
750                    !DOMUtils::isFosterablePosition( $encapTgt )
751                )
752            ) {
753                throw new Error( 'Cannot encapsulate transclusion. Start=' .
754                    DOMCompat::getOuterHTML( $range->startElem ) );
755            }
756            $encapTgt = $encapTgt->nextSibling;
757        }
758
759        '@phan-var Element $encapTgt'; // @var Node $encapTgt
760        return $encapTgt;
761    }
762
763    private function migrateElements(
764        Element $migrationTarget,
765        Element $first,
766        ?Node $last,
767        ?Node $insertPosition
768    ): void {
769        $elt = $first;
770        while ( $elt !== $last ) {
771            // Remove about attribute
772            '@phan-var Element $elt';  /** @var Element $elt */
773            $next = $elt->nextSibling;
774            if ( !( $elt instanceof Element ) || DOMUtils::nodeName( $elt ) === 'span' ) {
775                // Drop the newline span!
776                // Alternatively, we could migrate all the newlines as follows:
777                // DOMUtils::migrateChildren( $elt, $migrationTarget, $insertPosition );
778                $elt->parentNode->removeChild( $elt );
779            } else {
780                $elt->removeAttribute( 'about' );
781                $migrationTarget->insertBefore( $elt, $insertPosition );
782            }
783            $elt = $next;
784        }
785    }
786
787    private function isNewlineWrappingSpan( Node $elt ): bool {
788        return DOMUtils::nodeName( $elt ) === 'span' && preg_match( "/^\n+$/", $elt->textContent );
789    }
790
791    /**
792     * Add markers to the DOM around the non-overlapping ranges.
793     *
794     * @param DOMRangeInfo[] $nonOverlappingRanges
795     */
796    private function encapsulateTemplates( array $nonOverlappingRanges ): void {
797        foreach ( $nonOverlappingRanges as $i => $range ) {
798
799            // We should never have flipped overlapping ranges, and indeed that's
800            // asserted in `findTopLevelNonOverlappingRanges`.  Flipping results
801            // in either completely nested ranges, or non-intersecting ranges.
802            //
803            // If the table causing the fostering is not transcluded, we emit a
804            // foster box and wrap the whole table+fb in metas, producing nested
805            // ranges.  For ex,
806            //
807            // <table>
808            // {{1x|<div>}}
809            //
810            // The tricky part is when the table *is* transcluded, and we omit the
811            // foster box.  The common case (for some definition of common) might
812            // be like,
813            //
814            // {{1x|<table>}}
815            // {{1x|<div>}}
816            //
817            // Here, range-1 leaves a table open and the end meta from range-2 is
818            // fostered, since it gets closed into the div.  The range for range-1
819            // is the entire table, which thankfully contains range-2, so we still
820            // have the expected entire nesting.  Any tricks to extend the range
821            // of range-2 beyond the table (so that we have an overlapping range) will
822            // inevitably result in the end meta not being fostered, and we avoid
823            // this situation altogether.
824            //
825            // The very edgy case is as follows,
826            //
827            // {{1x|<table><div>}}</div>
828            // {{1x|<div>}}
829            //
830            // where both end metas are fostered.  Ignoring that we don't even
831            // roundtrip the first transclusion properly on its own, here we have
832            // a flipped range where, since the end meta for the first range was
833            // also fostered, the ranges still don't overlap.
834
835            // FIXME: The code below needs to be aware of flipped ranges.
836
837            $this->ensureElementsInRangeAndAddAboutIds( $range );
838
839            $tplArray = $this->compoundTpls[$range->id] ?? null;
840            Assert::invariant( (bool)$tplArray, 'No parts for template range!' );
841
842            $encapTgt = self::findEncapTarget( $range );
843            $encapValid = false;
844            $encapDP = DOMDataUtils::getDataParsoid( $encapTgt );
845
846            // Update type-of (always even if tpl-encap below will fail).
847            // This ensures that VE will still "edit-protect" this template
848            // and not allow its content to be edited directly.
849            $startElem = $range->startElem;
850            if ( $startElem !== $encapTgt ) {
851                $t1 = DOMCompat::getAttribute( $startElem, 'typeof' );
852                if ( $t1 !== null ) {
853                    foreach ( array_reverse( explode( ' ', $t1 ) ) as $t ) {
854                        DOMUtils::addTypeOf( $encapTgt, $t, true );
855                    }
856                }
857            }
858
859            /* ----------------------------------------------------------------
860             * We'll attempt to update dp1.dsr to reflect the entire range of
861             * the template.  This relies on a couple observations:
862             *
863             * 1. In the common case, dp2.dsr->end will be > dp1.dsr->end
864             *    If so, new range = dp1.dsr->start, dp2.dsr->end
865             *
866             * 2. But, foster parenting can complicate this when range.end is a table
867             *    and range.start has been fostered out of the table (range.end).
868             *    But, we need to verify this assumption.
869             *
870             *    2a. If dp2.dsr->start is smaller than dp1.dsr->start, this is a
871             *        confirmed case of range.start being fostered out of range.end.
872             *
873             *    2b. If dp2.dsr->start is unknown, we rely on fostered flag on
874             *        range.start, if any.
875             * ---------------------------------------------------------------- */
876            $dp1 = DOMDataUtils::getDataParsoid( $range->start );
877            $dp1DSR = isset( $dp1->dsr ) ? clone $dp1->dsr : null;
878            $dp2DSR = self::getRangeEndDSR( $range );
879
880            if ( $dp1DSR ) {
881                if ( $dp2DSR ) {
882                    // Case 1. above
883                    if ( $dp2DSR->end > $dp1DSR->end ) {
884                        $dp1DSR->end = $dp2DSR->end;
885                    }
886
887                    // Case 2. above
888                    $endDsr = $dp2DSR->start;
889                    if ( DOMUtils::nodeName( $range->end ) === 'table' &&
890                        $endDsr !== null &&
891                        ( $endDsr < $dp1DSR->start || !empty( $dp1->fostered ) )
892                    ) {
893                        $dp1DSR->start = $endDsr;
894                    }
895                }
896
897                // encapsulation possible only if dp1.dsr is valid
898                $encapValid = Utils::isValidDSR( $dp1DSR ) &&
899                    $dp1DSR->end >= $dp1DSR->start;
900            }
901
902            if ( $encapValid ) {
903                // Find transclusion info from the array (skip past a wikitext element)
904                /** @var CompoundTemplateInfo $firstTplInfo */
905                $firstTplInfo = is_string( $tplArray[0] ) ? $tplArray[1] : $tplArray[0];
906
907                // Add any leading wikitext
908                if ( $firstTplInfo->dsr->start > $dp1DSR->start ) {
909                    // This gap in dsr (between the final encapsulated content, and the
910                    // content that actually came from a template) is indicative of this
911                    // being a mixed-template-content-block and/or multi-template-content-block
912                    // scenario.
913                    //
914                    // In this case, record the name of the first node in the encapsulated
915                    // content. During html -> wt serialization, newline constraints for
916                    // this entire block has to be determined relative to this node.
917                    $ftn = self::findFirstTemplatedNode( $range );
918                    if ( $ftn !== null ) {
919                        $encapDP->firstWikitextNode = $ftn;
920                    }
921                    $width = $firstTplInfo->dsr->start - $dp1DSR->start;
922                    $source = $dp1DSR->source ?? $this->frame->getSource();
923                    array_unshift(
924                        $tplArray,
925                        PHPUtils::safeSubstr( $source->getSrcText(), $dp1DSR->start, $width )
926                    );
927                }
928
929                // Add any trailing wikitext
930                /** @var CompoundTemplateInfo $lastTplInfo */
931                $lastTplInfo = PHPUtils::lastItem( $tplArray );
932                if ( $lastTplInfo->dsr->end < $dp1DSR->end ) {
933                    $width = $dp1DSR->end - $lastTplInfo->dsr->end;
934                    $source = $lastTplInfo->dsr->source ?? $this->frame->getSource();
935                    $tplArray[] = PHPUtils::safeSubstr( $source->getSrcText(), $lastTplInfo->dsr->end, $width );
936                }
937
938                // Map the array of { dsr: .. , args: .. } objects to just the args property
939                $infoIndex = 0;
940                $parts = [];
941                $pi = [];
942                foreach ( $tplArray as $a ) {
943                    if ( is_string( $a ) ) {
944                        $parts[] = $a;
945                    } elseif ( $a instanceof CompoundTemplateInfo ) {
946                        // Remember the position of the transclusion relative
947                        // to other transclusions. Should match the index of
948                        // the corresponding private metadata in $templateInfos.
949                        $a->info->i = $infoIndex++;
950                        if ( $a->isParam ) {
951                            $a->info->type = 'templatearg';
952                        } elseif ( $a->info->func ) {
953                            // Type should be initialized already
954                            Assert::invariant(
955                                $a->info->type === 'parserfunction' ||
956                                $a->info->type === 'old-parserfunction',
957                                "parser function type should be initialized already"
958                            );
959                        } else {
960                            $a->info->type = 'template';
961                        }
962                        $parts[] = $a->info;
963                        // FIXME: Except for v3 parser functions, we
964                        // throw away parameter order and rebuild it
965                        // again in WikitextSerializer.  We could add
966                        // 'order' and 'eq' keys to everything.
967                        // T404772
968                        $pi[] = $a->info->paramInfos;
969                    }
970                }
971
972                if ( !is_string( $parts[0] ) && $parts[0]->type === 'parserfunction' ) {
973                    $key = $parts[0]->func;
974                    DOMUtils::addTypeOf( $encapTgt, 'mw:ParserFunction/' . $key, false );
975                }
976                if ( ( $firstTplInfo->colon ?? ':' ) !== ':' ) {
977                    // We only preserve the colon information from the
978                    // first encapsulated item.
979                    $encapDP->colon = $firstTplInfo->colon;
980                }
981
982                // Set up dsr->start, dsr->end, and data-mw on the target node
983                // Avoid clobbering existing (ex: extension) data-mw information (T214241)
984                $encapDataMw = DOMDataUtils::getDataMw( $encapTgt );
985                $encapDataMw->parts = $parts;
986                DOMDataUtils::setDataMw( $encapTgt, $encapDataMw );
987                $encapDP->pi = $pi;
988
989                // Special case when mixed-attribute-and-content templates are
990                // involved. This information is reliable and comes from the
991                // AttributeExpander and gets around the problem of unmarked
992                // fostered content that findFirstTemplatedNode runs into.
993                $firstWikitextNode = DOMDataUtils::getDataParsoid(
994                        $range->startElem
995                    )->firstWikitextNode ?? null;
996                if ( empty( $encapDP->firstWikitextNode ) && $firstWikitextNode ) {
997                    $encapDP->firstWikitextNode = $firstWikitextNode;
998                }
999            } else {
1000                $errors = [ 'Do not have necessary info. to encapsulate Tpl: ' . $i ];
1001                $errors[] = 'Start Elt : ' . DOMCompat::getOuterHTML( $startElem );
1002                $errors[] = 'End Elt   : ' . DOMCompat::getOuterHTML( $range->endElem );
1003                $errors[] = 'Start DSR : ' . PHPUtils::jsonEncode( $dp1DSR ?? 'no-start-dsr' );
1004                $errors[] = 'End   DSR : ' . PHPUtils::jsonEncode( $dp2DSR ?? [] );
1005                $this->env->log( 'error', implode( "\n", $errors ) );
1006            }
1007
1008            // Make DSR range zero-width for fostered templates after
1009            // setting up data-mw. However, since template encapsulation
1010            // sometimes captures both fostered content as well as the table
1011            // from which it was fostered from, in those scenarios, we should
1012            // leave DSR info untouched.
1013            //
1014            // SSS FIXME:
1015            // 1. Should we remove the fostered flag from the entire
1016            // encapsulated block if we dont set dsr width range to zero
1017            // since only part of the block is fostered, not the entire
1018            // encapsulated block?
1019            //
1020            // 2. In both cases, should we mark these uneditable by adding
1021            // mw:Placeholder to the typeof?
1022            if ( !empty( $dp1->fostered ) ) {
1023                $encapDataMw = DOMDataUtils::getDataMw( $encapTgt );
1024                if ( !$encapDataMw ||
1025                    !$encapDataMw->parts ||
1026                    count( $encapDataMw->parts ) === 1
1027                ) {
1028                    $dp1DSR->end = $dp1DSR->start;
1029                }
1030            }
1031
1032            // Update DSR after fostering-related fixes are done.
1033            if ( $encapValid ) {
1034                // encapInfo.dp points to DOMDataUtils.getDataParsoid(encapInfo.target)
1035                // and all updates below update properties in that object tree.
1036                if ( empty( $encapDP->dsr ) ) {
1037                    $encapDP->dsr = $dp1DSR;
1038                } else {
1039                    $encapDP->dsr->start = $dp1DSR->start;
1040                    $encapDP->dsr->end = $dp1DSR->end;
1041                }
1042                $encapDP->src = $encapDP->dsr->substr(
1043                    $this->frame->getSource()
1044                );
1045            }
1046
1047            // Remove startElem (=range.startElem) if a meta.  If a meta,
1048            // it is guaranteed to be a marker meta added to mark the start
1049            // of the template.
1050            if ( WTUtils::isTplMarkerMeta( $startElem ) ) {
1051                if ( $range->start === $startElem ) {
1052                    $range->start = $range->start->nextSibling;
1053                }
1054                $startElem->parentNode->removeChild( $startElem );
1055            }
1056
1057            if ( $range->end === $range->endElem ) {
1058                $range->end = $range->end->previousSibling;
1059            }
1060            $range->endElem->parentNode->removeChild( $range->endElem );
1061
1062            $this->handleRenderingTransparentEltsBetweenBlocks( $range );
1063        }
1064    }
1065
1066    /**
1067     * Attach a range to a node.
1068     */
1069    private function addNodeRange( Element $node, DOMRangeInfo $range ): void {
1070        // With the native DOM extension, normally you assume that DOMNode
1071        // objects are temporary -- you get a new DOMNode every time you
1072        // traverse the DOM. But by retaining a reference in the
1073        // SplObjectStorage, we ensure that the DOMNode object stays live while
1074        // the pass is active. Then its address can be used as an index.
1075        if ( !isset( $this->nodeRanges[$node] ) ) {
1076            // We have to use an object as the data because
1077            // SplObjectStorage::offsetGet() does not provide an lval.
1078            $this->nodeRanges[$node] = new DOMRangeInfoArray;
1079        }
1080        $this->nodeRanges[$node]->ranges[$range->id] = $range;
1081    }
1082
1083    /**
1084     * Get the ranges attached to this node, indexed by range ID.
1085     * @return DOMRangeInfo[]|null
1086     */
1087    private function getNodeRanges( Element $node ): ?array {
1088        return $this->nodeRanges[$node]->ranges ?? null;
1089    }
1090
1091    /**
1092     * Recursively walk the DOM tree. Find wrappable template ranges and return them.
1093     *
1094     * @return list<DOMRangeInfo>
1095     */
1096    protected function findWrappableMetaRanges( Node $rootNode ): array {
1097        $tpls = [];
1098        $tplRanges = [];
1099        $this->findWrappableTemplateRangesRecursive( $rootNode, $tpls, $tplRanges );
1100        return $tplRanges;
1101    }
1102
1103    /**
1104     * Recursive helper for findWrappableTemplateRanges()
1105     *
1106     * @param Node $rootNode
1107     * @param array<string,ElementRange> &$tpls Template start and end elements by ID
1108     * @param list<DOMRangeInfo> &$tplRanges Template range info
1109     */
1110    private function findWrappableTemplateRangesRecursive(
1111        Node $rootNode, array &$tpls, array &$tplRanges
1112    ): void {
1113        $elem = $rootNode->firstChild;
1114
1115        while ( $elem ) {
1116            // get the next sibling before doing anything since
1117            // we may delete elem as part of encapsulation
1118            $nextSibling = $elem->nextSibling;
1119
1120            if ( $elem instanceof Element ) {
1121                $metaType = $this->matchMetaType( $elem );
1122
1123                // Ignore templates without tsr.
1124                //
1125                // These are definitely nested in other templates / extensions
1126                // and need not be wrapped themselves since they
1127                // can never be edited directly.
1128                //
1129                // NOTE: We are only testing for tsr presence on the start-elem
1130                // because wikitext errors can lead to parse failures and no tsr
1131                // on end-meta-tags.
1132                //
1133                // Ex: "<ref>{{1x|bar}}<!--bad-></ref>"
1134                if ( $metaType !== null &&
1135                    ( !empty( DOMDataUtils::getDataParsoid( $elem )->tsr ) ||
1136                        str_ends_with( $metaType, '/End' )
1137                    )
1138                ) {
1139                    $about = $this->getRangeId( $elem );
1140                    $tpl = $tpls[$about] ?? null;
1141                    // Is this a start marker?
1142                    if ( !str_ends_with( $metaType, '/End' ) ) {
1143                        if ( $tpl ) {
1144                            $tpl->startElem = $elem;
1145                            // content or end marker existed already
1146                            if ( $tpl->endElem !== null ) {
1147                                // End marker was foster-parented.
1148                                // Found actual start tag.
1149                                $tplRanges[] = $this->getDOMRange(
1150                                    $elem, $tpl->endElem, $tpl->endElem );
1151                            } else {
1152                                // should not happen!
1153                                throw new UnreachableException( "start found after content for $about." );
1154                            }
1155                        } else {
1156                            $tpl = new ElementRange;
1157                            $tpl->startElem = $elem;
1158                            $tpls[$about] = $tpl;
1159                        }
1160                    } else {
1161                        // elem is the end-meta tag
1162                        if ( $tpl ) {
1163                            /* ------------------------------------------------------------
1164                             * Special case: In some cases, the entire template content can
1165                             * get fostered out of a table, not just the start/end marker.
1166                             *
1167                             * Simplest example:
1168                             *
1169                             *   {|
1170                             *   {{1x|foo}}
1171                             *   |}
1172                             *
1173                             * More complex example:
1174                             *
1175                             *   {|
1176                             *   {{1x|
1177                             *   a
1178                             *    b
1179                             *
1180                             *     c
1181                             *   }}
1182                             *   |}
1183                             *
1184                             * Since meta-tags don't normally get fostered out, this scenario
1185                             * only arises when the entire content including meta-tags was
1186                             * wrapped in p-tags.  So, we look to see if:
1187                             * 1. the end-meta-tag's parent has a table sibling,
1188                             * 2. the start meta's parent is marked as fostered.
1189                             * If so, we recognize this as an adoption scenario and fix up
1190                             * DSR of start-meta-tag's parent to include the table's DSR.
1191                             * ------------------------------------------------------------*/
1192                            $sm = $tpl->startElem;
1193
1194                            // TODO: this should only happen in fairly specific cases of the
1195                            // annotation processing and should eventually be handled properly.
1196                            // In the meantime, we create and log an exception to have an idea
1197                            // of the amplitude of the problem.
1198                            if ( $sm === null ) {
1199                                throw new RangeBuilderException( 'No start tag found for the range' );
1200                            }
1201                            $em = $elem;
1202                            $ee = $em;
1203                            $tbl = $em->parentNode->nextSibling;
1204
1205                            // Dont get distracted by a newline node -- skip over it
1206                            // Unsure why it shows up occasionally
1207                            if ( $tbl && $tbl instanceof Text && $tbl->nodeValue === "\n" ) {
1208                                $tbl = $tbl->nextSibling;
1209                            }
1210
1211                            $dp = !DOMUtils::atTheTop( $sm->parentNode ) ?
1212                                DOMDataUtils::getDataParsoid( $sm->parentNode ) : null;
1213                            if ( $tbl && DOMUtils::nodeName( $tbl ) === 'table' && !empty( $dp->fostered ) ) {
1214                                '@phan-var Element $tbl';  /** @var Element $tbl */
1215                                $tblDP = DOMDataUtils::getDataParsoid( $tbl );
1216                                if ( isset( $dp->tsr ) && $dp->tsr->start !== null && $dp->tsr->start !== null &&
1217                                    isset( $tblDP->dsr ) && $tblDP->dsr->start !== null && $tblDP->dsr->start === null
1218                                ) {
1219                                    $tblDP->dsr->start = $dp->tsr->start;
1220                                }
1221                                $tbl->setAttribute( 'about', $about ); // set about on elem
1222                                $ee = $tbl;
1223                            }
1224                            $tplRanges[] = $this->getDOMRange( $sm, $em, $ee );
1225                        } else {
1226                            // The end tag can appear before the start tag if it is fostered out
1227                            // of the table and the start tag is not.
1228                            // It can even technically happen that both tags are fostered out of
1229                            // a table and that the range is flipped: while the fostered content of
1230                            // single table is fostered in-order, the ordering might change
1231                            // across tables if the tags are not initially fostered by the same
1232                            // table.
1233                            $tpl = new ElementRange;
1234                            $tpl->endElem = $elem;
1235                            $tpls[$about] = $tpl;
1236                        }
1237                    }
1238                } else {
1239                    $this->findWrappableTemplateRangesRecursive( $elem, $tpls, $tplRanges );
1240                }
1241            }
1242
1243            $elem = $nextSibling;
1244        }
1245    }
1246
1247    /**
1248     * Returns the meta type of the element if it exists and matches the type expected by the
1249     * current class, null otherwise
1250     * @param Element $elem the element to check
1251     * @return string|null
1252     */
1253    protected function matchMetaType( Element $elem ): ?string {
1254        // for this class we're interested in the template type
1255        return WTUtils::matchTplType( $elem );
1256    }
1257
1258    protected function verifyTplInfoExpectation( ?TemplateInfo $templateInfo, TempData $tmp ): void {
1259        if ( !$templateInfo ) {
1260            // An assertion here is probably an indication that we're
1261            // mistakenly doing template wrapping in a nested context.
1262            Assert::invariant( $tmp->getFlag( TempData::FROM_FOSTER ), 'Template range without arginfo.' );
1263        }
1264    }
1265
1266    public function execute( Node $root ): void {
1267        $tplRanges = $this->findWrappableMetaRanges( $root );
1268        if ( count( $tplRanges ) > 0 ) {
1269            $nonOverlappingRanges = $this->findTopLevelNonOverlappingRanges( $root, $tplRanges );
1270            $this->encapsulateTemplates( $nonOverlappingRanges );
1271        }
1272    }
1273
1274    /**
1275     * Creates a range that encloses $startMeta and $endMeta
1276     */
1277    protected function findEnclosingRange(
1278        Element $startMeta, Element $endMeta, ?Element $endElem = null
1279    ): DOMRangeInfo {
1280        $range = new DOMRangeInfo(
1281            $this->getRangeId( $startMeta ),
1282            DOMDataUtils::getDataParsoid( $startMeta )->tsr->start,
1283            $startMeta,
1284            $endMeta
1285        );
1286
1287        // Find common ancestor of startMeta and endElem
1288        // NOTE: $startMeta is an Element and all its ancestors
1289        // will be Elements. So, all array entries are Elements.
1290        $startAncestors = DOMUtils::pathToRoot( $startMeta );
1291        $elem = $endElem ?? $endMeta;
1292        $parentNode = $elem->parentNode;
1293        while ( $parentNode && $parentNode->nodeType !== XML_DOCUMENT_NODE ) {
1294            $i = array_search( $parentNode, $startAncestors, true );
1295            if ( $i === 0 ) {
1296                throw new UnreachableException(
1297                    'The startMeta cannot be the common ancestor.'
1298                );
1299            } elseif ( $i > 0 ) {
1300                // @phan-suppress-next-line PhanTypeMismatchPropertyReal
1301                $range->start = $startAncestors[$i - 1];
1302                $range->end = $elem;
1303                break;
1304            }
1305            $elem = $parentNode;
1306            $parentNode = $elem->parentNode;
1307        }
1308
1309        return $range;
1310    }
1311
1312    /**
1313     * This code exists to handle T370751 and T378906. This support is known to not be
1314     * perfect and exists to making the vast majority of existing templates & CSS work
1315     * (primarily navbox styling).
1316     * It wraps rendering transparent tags that have chance of ending up between two divs
1317     * or table into a span, thus giving the option to select them with a CSS that needs to
1318     * account for at most two of these spans (with the class mw-empty-elt).
1319     */
1320    private function handleRenderingTransparentEltsBetweenBlocks( DOMRangeInfo $range ): void {
1321        $traverser = new DOMTraverser( false, false );
1322        $traverser->addHandler( null, fn ( $node ) => $this->handleFirstRenderingTransparentNode( $node, $range ) );
1323
1324        $elt = $range->start;
1325        $end = $range->end->nextSibling;
1326        while ( $elt && $elt !== $end ) {
1327            if ( WTUtils::isRenderingTransparentNode( $elt ) ||
1328                $this->isNewlineWrappingSpan( $elt ) ||
1329                DOMUtils::nodeName( $elt ) === 'style'
1330            ) {
1331                $res = $this->handleFirstRenderingTransparentNode( $elt, $range );
1332                if ( $res instanceof Element ) {
1333                    $elt = $res;
1334                    continue;
1335                }
1336            } else {
1337                $traverser->traverse( null, $elt );
1338            }
1339            $elt = $elt->nextSibling;
1340        }
1341    }
1342
1343    private function canSwallowRenderingTransparentNodes( ?Element $node, Element $wrapper ): bool {
1344        return $node !== null && DOMUtils::nodeName( $node ) === 'div' &&
1345            DOMCompat::getAttribute( $node, 'about' ) == DOMCompat::getAttribute( $wrapper, 'about' ) &&
1346            (
1347                // data-mw will not be transferred to $node if $wrapper has mw:Transclusion
1348                !DOMUtils::hasTypeOf( $wrapper, 'mw:Transclusion' ) ||
1349                // data-mw will be transferred to $node here. Conservatively
1350                // require no existing data-mw $node to guarantee we won't clobber it.
1351                DOMDataUtils::getNodeData( $node )->mw === null
1352            );
1353    }
1354
1355    /**
1356     * Processes a contiguous range of stashable nodes (category links and newline wrapping spans)
1357     * to put them in a mw-empty-elt wrapping span.
1358     *
1359     * For all sequence of contiguous (up to white space) sol-transparent links
1360     * and empty-line wrapping spans that come from the same transclusion range,
1361     * and that are either at the boundary of a transclusion or between two
1362     * elements of type table or div:
1363     * - if they have a valid div before or after that can contain them (and
1364     *   that is part of that same transclusion range), we stash them there
1365     * - if not, we create a wrapping span with class mw-empty-elt where
1366     *   they are and stash them there.
1367     * We actually drop empty-line spans and non-element nodes.
1368     *
1369     * Empty spans get deleted in Wt2Html/DOM/Handlers/CleanUp, unless
1370     * it's containing the template wrapping information (which may have been
1371     * transferred from one of the stashed nodes). This can lead to some
1372     * differences in the generated HTML, for instance:
1373     * - a template that starts with a single empty-line span will be replaced
1374     *   by a span that contains the template information, but not the new line
1375     * - empty lines spans between divs/tables, or empty line spans at the end
1376     *   of a transclusion, will disappear
1377     *
1378     * Additionally, if there's a sequence of sol-transparent links that
1379     * contain both non-transcluded and transcluded elements, the transcluded
1380     * ones will be added to a wrapping span, but the non-transcluded ones
1381     * won't, which may look odd in the resulting HTML.
1382     *
1383     * Traverser used by @see DOMTraverser
1384     *
1385     * @return \DOMNode|true|null
1386     */
1387    public function handleFirstRenderingTransparentNode( Node $node, DOMRangeInfo $range ) {
1388        if ( !$node instanceof Element || !$this->isStashableNode( $node ) ) {
1389            return true;
1390        }
1391
1392        $start = $last = $node;
1393        $rangeExitSentinel = DOMCompat::getNextElementSibling( $range->end );
1394        $next = DOMCompat::getNextElementSibling( $start );
1395        // $next is past the last element of the template, so let's not consider it
1396        if ( $next === $rangeExitSentinel ) {
1397            $next = null;
1398        }
1399        while ( $next !== null && $next !== $rangeExitSentinel && $this->isStashableNode( $next ) ) {
1400            $last = $next;
1401            $next = DOMCompat::getNextElementSibling( $next );
1402        }
1403
1404        $prev = DOMCompat::getPreviousElementSibling( $start );
1405        if ( !$this->shouldStashRenderingTransparentNodes( $prev, $next, $start ) ) {
1406            return true;
1407        }
1408
1409        if ( $this->canSwallowRenderingTransparentNodes( $prev, $start ) ) {
1410            $target = $prev;
1411            $before = null;
1412        } elseif ( $this->canSwallowRenderingTransparentNodes( $next, $start ) ) {
1413            $target = $next;
1414            $before = $target->firstChild;
1415        } else {
1416            $target = $start->ownerDocument->createElement( 'span' );
1417            $target->setAttribute( 'class', 'mw-empty-elt' );
1418            $targetDp = DOMDataUtils::getDataParsoid( $target );
1419            $targetDp->autoInsertedStart = true;
1420            $targetDp->autoInsertedEnd = true;
1421            $before = null;
1422            $start->parentNode->insertBefore( $target, $start );
1423            if ( $start->hasAttribute( 'about' ) ) {
1424                $target->setAttribute( 'about', DOMCompat::getAttribute( $start, 'about' ) );
1425            }
1426        }
1427
1428        if ( DOMUtils::hasTypeOf( $start, 'mw:Transclusion' ) ) {
1429            $newRangeStart = $target;
1430
1431            DOMUtils::removeTypeOf( $start, 'mw:Transclusion' );
1432            $rangeDmw = DOMDataUtils::getDataMw( $start );
1433            $rangeDp = DOMDataUtils::getDataParsoid( $start );
1434
1435            $this->migrateElements( $target, $start, $last->nextSibling, $before );
1436            if ( $range->start === $start ) {
1437                $range->start = $newRangeStart;
1438            }
1439
1440            DOMUtils::addTypeOf( $newRangeStart, 'mw:Transclusion' );
1441
1442            $pfkey = WTUtils::getPFragmentHandlerKey( $start );
1443            if ( $pfkey ) {
1444                DOMUtils::addTypeOf( $newRangeStart, "mw:ParserFunction/$pfkey" );
1445                DOMUtils::removeTypeOf( $start, "mw:ParserFunction/$pfkey" );
1446            }
1447
1448            $newRangeDmw = DOMDataUtils::getDataMw( $newRangeStart );
1449            $newRangeDmw->parts = $rangeDmw->parts;
1450            unset( $rangeDmw->parts );
1451            $newRangeDp = DOMDataUtils::getDataParsoid( $newRangeStart );
1452            $newRangeDp->pi = $rangeDp->pi;
1453            unset( $rangeDp->pi );
1454            $newRangeDp->dsr = $rangeDp->dsr;
1455            unset( $rangeDp->dsr );
1456        } else {
1457            $this->migrateElements( $target, $start, $last->nextSibling, $before );
1458            if ( $range->end === $last ) {
1459                $range->end = $target;
1460            }
1461        }
1462        return $target->nextSibling;
1463    }
1464
1465    private function shouldStashRenderingTransparentNodes( ?Element $prev, ?Element $next, Element $node ): bool {
1466        return (
1467                // start of a template
1468                $prev === null ||
1469                WTUtils::isTplStartMarkerMeta( $prev ) ||
1470                WTUtils::isFirstEncapsulationWrapperNode( $node ) ||
1471                // or after a div or a table
1472                in_array( DOMUtils::nodeName( $prev ), [ 'div', 'table' ], true )
1473            ) &&
1474            (
1475                // end of a template (or start of a new one)
1476                $next === null ||
1477                WTUtils::isTplMarkerMeta( $next ) ||
1478                // or before a div or a table
1479                in_array( DOMUtils::nodeName( $next ), [ 'div', 'table' ], true )
1480             );
1481    }
1482
1483    private function isStashableNode( Node $node ): bool {
1484        return (
1485            (
1486                WTUtils::isRenderingTransparentNode( $node ) &&
1487                // These metas count as rendering transparent, but let's not touch them
1488                // They're probably irrelevant for our case, and require fiddling with
1489                // pre handling more than necessary.
1490                !DOMUtils::hasTypeOf( $node, 'mw:IndentPreWS' )
1491            ) ||
1492            $this->isNewlineWrappingSpan( $node ) ||
1493            DOMUtils::nodeName( $node ) === 'style'
1494        ) &&
1495        // This is conservative because we could restrict it to just
1496        // <style> tags above, but this broader check is easier to
1497        // reason about and verify that there aren't edge cases.
1498        !DOMUtils::isFosterablePosition( $node );
1499    }
1500}