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