Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.70% covered (warning)
78.70%
303 / 385
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WrapSectionsState
78.70% covered (warning)
78.70%
303 / 385
31.58% covered (danger)
31.58%
6 / 19
351.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 computeSectionMetadata
77.50% covered (warning)
77.50%
31 / 40
0.00% covered (danger)
0.00%
0 / 1
12.38
 shouldOmitFromTOC
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 createNewSection
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
12.24
 isEmptySpan
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 isHtmlHeading
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
22.94
 isWrappableHeading
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 wrapSectionsInDOM
81.25% covered (warning)
81.25%
65 / 80
0.00% covered (danger)
0.00%
0 / 1
38.75
 isParsoidSection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 findSectionAncestor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDSR
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
24.07
 fillDSRGap
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 collapseWrappers
82.86% covered (warning)
82.86%
29 / 35
0.00% covered (danger)
0.00%
0 / 1
8.32
 resolveTplExtSectionConflicts
81.67% covered (warning)
81.67%
49 / 60
0.00% covered (danger)
0.00%
0 / 1
14.04
 convertTOCOffsets
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 findTOCInsertionPoint
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 insertSyntheticSection
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 addSyntheticTOCMarker
85.00% covered (warning)
85.00%
34 / 40
0.00% covered (danger)
0.00%
0 / 1
16.86
 run
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\DOM\Processors;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\Core\DOMCompat;
10use Wikimedia\Parsoid\Core\DomSourceRange;
11use Wikimedia\Parsoid\Core\InternalException;
12use Wikimedia\Parsoid\Core\Sanitizer;
13use Wikimedia\Parsoid\Core\SectionMetadata;
14use Wikimedia\Parsoid\Core\Source;
15use Wikimedia\Parsoid\DOM\Comment;
16use Wikimedia\Parsoid\DOM\Document;
17use Wikimedia\Parsoid\DOM\DocumentFragment;
18use Wikimedia\Parsoid\DOM\Element;
19use Wikimedia\Parsoid\DOM\Node;
20use Wikimedia\Parsoid\DOM\Text;
21use Wikimedia\Parsoid\NodeData\DataMw;
22use Wikimedia\Parsoid\NodeData\DataParsoid;
23use Wikimedia\Parsoid\NodeData\TemplateInfo;
24use Wikimedia\Parsoid\Utils\DOMDataUtils;
25use Wikimedia\Parsoid\Utils\DOMUtils;
26use Wikimedia\Parsoid\Utils\PHPUtils;
27use Wikimedia\Parsoid\Utils\TokenUtils;
28use Wikimedia\Parsoid\Utils\Utils;
29use Wikimedia\Parsoid\Utils\WTUtils;
30use Wikimedia\Parsoid\Wt2Html\Frame;
31
32class WrapSectionsState {
33    private Env $env;
34    private Frame $frame;
35
36    /** @var Element|DocumentFragment */
37    private $rootNode;
38
39    /**
40     * The next section debug ID
41     */
42    private int $count = 1;
43
44    /**
45     * Pseudo section count is needed to determine TOC rendering
46     */
47    private int $pseudoSectionCount = 0;
48    private Document $doc;
49
50    /**
51     * Map of about ID to first element
52     * @var Element[]
53     */
54    private array $aboutIdMap = [];
55    private int $sectionNumber = 0;
56    private ?WrapSectionsTplInfo $tplInfo = null;
57
58    /** @var WrapSectionsTplInfo[] */
59    private array $tplsAndExtsToExamine = [];
60    private int $oldLevel = 0;
61
62    /**
63     * @param Env $env
64     * @param Frame $frame
65     * @param Element|DocumentFragment $rootNode
66     */
67    public function __construct(
68        Env $env,
69        Frame $frame,
70        Node $rootNode
71    ) {
72        $this->env = $env;
73        $this->frame = $frame;
74        $this->rootNode = $rootNode;
75        $this->doc = $rootNode->ownerDocument;
76    }
77
78    /**
79     * Update section metadata needed to generate TOC.
80     *
81     * @param SectionMetadata $metadata
82     * @param Element $heading
83     * @param int $newLevel
84     */
85    private function computeSectionMetadata(
86        SectionMetadata $metadata, Element $heading, int $newLevel
87    ): void {
88        if ( !$this->env->getPageConfig()->getSuppressTOC() ) {
89            $tocData = $this->env->getTOCData();
90            $tocData->addSection( $metadata );
91            $tocData->processHeading( $this->oldLevel, $newLevel, $metadata );
92        }
93        $this->oldLevel = $newLevel;
94        $dp = DOMDataUtils::getDataParsoid( $heading );
95
96        if (
97            // Literal HTML tags in wikitext don't get section edit links
98            WTUtils::isLiteralHTMLNode( $heading ) ||
99            // Neither do cases where the legacy preprocessor didn't tokenize a heading
100            !isset( $dp->tmp->headingIndex )
101        ) {
102            $metadata->fromTitle = null;
103            $metadata->index = '';
104            $metadata->codepointOffset = null;
105        } elseif ( $this->tplInfo !== null ) {
106            $dmw = DOMDataUtils::getDataMw( $this->tplInfo->first );
107            $metadata->index = ''; // Match legacy parser
108            if ( !isset( $dmw->parts ) ) {
109                // Extension or language-variant
110                // Need to determine what the output should be here
111                $metadata->fromTitle = null;
112            } elseif ( count( $dmw->parts ) > 1 ) {
113                // Multi-part content -- cannot pick a title
114                $metadata->fromTitle = null;
115            } else {
116                $p0 = $dmw->parts[0];
117                if ( !( $p0 instanceof TemplateInfo ) ) {
118                    throw new UnreachableException(
119                        "a single part will always be a TemplateInfo not a string"
120                    );
121                }
122                if ( $p0->type === 'templatearg' ) {
123                    // Since we currently don't process templates in Parsoid,
124                    // this has to be a top-level {{{...}}} and so the content
125                    // comes from the current page. But, legacy parser returns 'false'
126                    // for this, so we'll return null as well instead of current title.
127                    $metadata->fromTitle = null;
128                } elseif ( $p0->href !== null ) {
129                    // Pick template title, but strip leading "./" prefix
130                    $tplHref = Utils::decodeURIComponent( $p0->href );
131                    $metadata->fromTitle = PHPUtils::stripPrefix( $tplHref, './' );
132                    if ( $this->sectionNumber >= 0 ) {
133                        // Legacy parser sets this to '' in some cases
134                        // See "Templated sections (heading from template arg)" parser test
135                        $metadata->index = 'T-' . $this->sectionNumber;
136                    }
137                } else {
138                    // Legacy parser return null here
139                    $metadata->fromTitle = null;
140                }
141            }
142            $metadata->codepointOffset = null;
143        } else {
144            $title = $this->env->getContextTitle();
145            // Use the dbkey (underscores) instead of text (spaces)
146            $metadata->fromTitle = $title->getPrefixedDBKey();
147            $metadata->index = (string)$this->sectionNumber;
148            // Note that our DSR counts *are* byte counts, while this core
149            // interface expects *codepoint* counts.  We are going to convert
150            // these in a batch (for efficiency) in ::convertTOCOffsets() below
151            $metadata->codepointOffset = $dp->dsr->start ?? -1;
152        }
153
154        $metadata->anchor = DOMCompat::getAttribute( $heading, 'id' );
155        $section = $dp->getTemp()->section;
156        $metadata->line = $section['line'];
157        $metadata->linkAnchor = $section['linkAnchor'];
158    }
159
160    /**
161     * Should we omit this heading from TOC?
162     * Yes if $heading is:
163     * - generated by an extension
164     */
165    private function shouldOmitFromTOC( Element $heading ): bool {
166        $node = $heading->parentNode;
167        while ( $node ) {
168            // NOTE: Here, we are making the assumption that extensions never
169            // emit a DOM forest and only ever have a single wrapper node.
170            // While ExtensionHandler doesn't assume that, this seems to be borne out
171            // in reality. But, if this assumption were not true, we would be adding
172            // TOC entries from extension-generated about siblings into the TOC.
173            // In scenarios where templates generated the extension and the extension
174            // is part of the template's wrapper, we cannot reliably determine what
175            // part of the output came from extensions in that case (because the
176            // template wrapping clobbers that information). So, for now, we ignore
177            // this edge case where extensions generate multiple DOM nodes (that also
178            // have headings). Later on, we may enforce a single-wrapper-node
179            // requirement for extensions.
180            if ( WTUtils::isFirstExtensionWrapperNode( $node ) ) {
181                return true;
182            }
183            $node = $node->parentNode;
184        }
185
186        return false;
187    }
188
189    /**
190     * Create a new section element
191     *
192     * @param Element|DocumentFragment $rootNode
193     * @param array<Section> &$sectionStack
194     * @param ?Section $currSection
195     * @param Element $heading the heading node
196     * @param int $newLevel
197     * @param bool $pseudoSection
198     * @return Section
199     */
200    private function createNewSection(
201        Node $rootNode, array &$sectionStack,
202        ?Section $currSection, Element $heading, int $newLevel,
203        bool $pseudoSection
204    ): Section {
205        /* Structure for regular (editable or not) sections
206         *   <section data-mw-section-id="..">
207         *     <h*>..</h*>
208         *     ..
209         *   </section>
210         *
211         * Lead sections and pseudo-sections won't have <h*> or <div> tags
212         */
213        $section = new Section( $newLevel, $this->count++, $this->doc );
214
215        /* Step 1. Get section stack to the right nesting level
216         * 1a. Pop stack till we have a higher-level section.
217         */
218        $stack = &$sectionStack;
219        $sc = count( $stack );
220        while ( $sc > 0 && !( $stack[$sc - 1]->hasNestedLevel( $newLevel ) ) ) {
221            array_pop( $stack );
222            $sc--;
223        }
224
225        /* 1b. Push current section onto stack if it is a higher-level section */
226        if ( $currSection && $currSection->hasNestedLevel( $newLevel ) ) {
227            $stack[] = $currSection;
228            $sc++;
229        }
230
231        /* Step 2: Add new section where it belongs: a parent section OR body */
232        $parentSection = $sc > 0 ? $stack[$sc - 1] : null;
233        if ( $parentSection ) {
234            $parentSection->addSection( $section );
235        } else {
236            $rootNode->insertBefore( $section->container, $heading );
237        }
238
239        /* Step 3: Add <h*> to the <section> */
240        $section->addNode( $heading );
241
242        /* Step 4: Assign data-mw-section-id attribute
243         *
244         * CX wants <section> tags with a distinguishing attribute so that
245         * it can differentiate between its internal use of <section> tags
246         * with what Parsoid adds. So, we will add a data-mw-section-id
247         * attribute always.
248         *
249         * data-mw-section-id = 0 for the lead section
250         * data-mw-section-id = -1 for non-editable sections
251         *     Note that templated content cannot be edited directly.
252         * data-mw-section-id = -2 for pseudo sections
253         * data-mw-section-id > 0 for everything else and this number
254         *     matches PHP parser / MediaWiki's notion of that section.
255         *
256         * The code here handles uneditable sections because of templating.
257         */
258        if ( $pseudoSection ) {
259            $this->pseudoSectionCount++;
260            $section->setId( -2 );
261        } elseif ( $this->tplInfo !== null ) {
262            $section->setId( -1 );
263        } else {
264            $section->setId( $this->sectionNumber );
265        }
266
267        // Sections from extensions shouldn't show up in TOC
268        if ( !$pseudoSection && !$this->shouldOmitFromTOC( $heading ) ) {
269            $this->computeSectionMetadata( $section->metadata, $heading, $newLevel );
270        }
271
272        return $section;
273    }
274
275    private function isEmptySpan( Element $span ): bool {
276        $n = $span->firstChild;
277        while ( $n ) {
278            if ( $n instanceof Element ) {
279                return false;
280            } elseif ( $n instanceof Text && !preg_match( '/^\s*$/D', $n->nodeValue ) ) {
281                return false;
282            }
283            $n = $n->nextSibling;
284        }
285        return true;
286    }
287
288    // Similar to HandleParsoidSectionLinks::isHtmlHeading in OTP
289    private static function isHtmlHeading( Element $h ): bool {
290        // FIXME(T100856): stx info probably shouldn't be in data-parsoid
291        if ( !WTUtils::isLiteralHTMLNode( $h ) ) {
292            return false;
293        }
294
295        foreach ( $h->attributes as $attr ) {
296            // Condition matches DiscussionTool's CommentFormatter::handleHeading
297            if (
298                !in_array( $attr->name, [ 'id', 'data-object-id', 'about', 'typeof' ], true ) &&
299                !Sanitizer::isReservedDataAttribute( $attr->name )
300            ) {
301                return true;
302            }
303        }
304
305        // Id is ignored above since it's a special case, make use of metadata
306        // to determine if it came from wikitext
307        if ( DOMDataUtils::getDataParsoid( $h )->reusedId ?? false ) {
308            return true;
309        }
310
311        return false;
312    }
313
314    private static function isWrappableHeading( Node $node ): bool {
315        return ( $node instanceof Element ) &&
316            DOMUtils::isHeading( $node ) &&
317            !self::isHtmlHeading( $node );
318    }
319
320    /**
321     * Walk the DOM and add <section> wrappers where required.
322     * This is the workhorse code that wrapSections relies on.
323     *
324     * @param ?Section $currSection
325     * @param Element|DocumentFragment $rootNode
326     * @return int
327     */
328    private function wrapSectionsInDOM(
329        ?Section $currSection, Node $rootNode
330    ): int {
331        // Since template wrapping is done and template wrappers are well-nested,
332        // we can reset template state for every subtree.
333        $tplInfo = null;
334        $sectionStack = [];
335        $highestSectionLevel = 7;
336        $node = $rootNode->firstChild;
337        while ( $node ) {
338            $next = $node->nextSibling;
339            $addedNode = false;
340            $expandSectionBoundary = false;
341
342            // Track entry into templated and extension output
343            if ( !$this->tplInfo && WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
344                '@phan-var Element $node'; // @var Element $node
345                $this->tplInfo = $tplInfo = new WrapSectionsTplInfo;
346                $tplInfo->first = $node;
347                $about = DOMCompat::getAttribute( $node, 'about' );
348                $tplInfo->about = $about;
349                if ( $about === null ) {
350                    Assert::invariant(
351                        DOMUtils::hasTypeOf( $node, 'mw:LanguageVariant' ),
352                        "Expected only language variants to be missing about ids."
353                    );
354                    $tplInfo->last = $node;
355                    // No need to map about id since language variants
356                    // aren't forests
357                } else {
358                    $aboutSiblings = WTUtils::getAboutSiblings( $node, $about );
359                    $tplInfo->last = end( $aboutSiblings );
360                    $this->aboutIdMap[$about] = $node;
361                }
362
363                // Collect a sequence of rendering transparent nodes starting at $node.
364                // This could be while ( true ), but being defensive.
365                while ( $node ) {
366                    // If we hit the end of the template, we are done!
367                    // - If this is a heading, we'll process it below.
368                    // - If not, the template never had a heading, so
369                    //   we can continue default section wrapping behavior.
370                    if ( $tplInfo->last === $node ) {
371                        break;
372                    }
373
374                    // If we hit a non-rendering-transparent node or a non-empty span,
375                    // we are done! We cannot expand the section boundary any further.
376                    if ( !WTUtils::isRenderingTransparentNode( $node ) &&
377                        !(
378                            DOMUtils::nodeName( $node ) === 'span' &&
379                            !WTUtils::isLiteralHTMLNode( $node ) &&
380                            $this->isEmptySpan( $node )
381                        )
382                    ) {
383                        break;
384                    }
385
386                    // Accumulate the rendering-transparent node and loop
387                    $tplInfo->rtContentNodes[] = $node;
388                    $node = $node->nextSibling;
389                }
390
391                if ( count( $tplInfo->rtContentNodes ) > 0 && self::isWrappableHeading( $node ) ) {
392                    // In this scenario, we can expand the section boundary to include these nodes
393                    // rather than start with the heading. This eliminates unnecessary conflicts
394                    // between section & template boundaries.
395                    $expandSectionBoundary = true;
396                    $next = $node->nextSibling;
397                } else {
398                    // Reset to normal sectioning behavior!
399                    $node = $tplInfo->first;
400                    $tplInfo->rtContentNodes = [];
401                }
402            }
403
404            if ( self::isWrappableHeading( $node ) ) {
405                '@phan-var Element $node'; // @var Element $node // headings are elements
406                $level = (int)DOMUtils::nodeName( $node )[1];
407
408                $dp = DOMDataUtils::getDataParsoid( $node );
409                if ( WTUtils::isLiteralHTMLNode( $node ) ) {
410                    // HTML <h*> tags get section wrappers, but the sections are uneditable
411                    // via the section editing API.
412                    $this->sectionNumber = -1;
413                } elseif ( isset( $dp->tmp->headingIndex ) ) {
414                    // This could be just `$this->sectionNumber++` without the
415                    // complicated if-guard if T214538 were fixed in core;
416                    // see T213468 where this more-complicated behavior was
417                    // added to match core's eccentricities.
418                    $this->sectionNumber = $dp->tmp->headingIndex;
419                } else {
420                    $this->sectionNumber = -1;
421                }
422                if ( $level < $highestSectionLevel ) {
423                    $highestSectionLevel = $level;
424                }
425                $currSection = $this->createNewSection(
426                    $rootNode, $sectionStack,
427                    $currSection, $node, $level, false
428                );
429                if ( $tplInfo && $expandSectionBoundary ) {
430                    foreach ( $tplInfo->rtContentNodes as $rtn ) {
431                        $currSection->container->insertBefore( $rtn, $node );
432                    }
433                    $tplInfo->firstSection = $currSection;
434                }
435                $addedNode = true;
436            } elseif ( $node instanceof Element ) {
437                $nestedHighestSectionLevel = $this->wrapSectionsInDOM( null, $node );
438                if ( $currSection && !$currSection->hasNestedLevel( $nestedHighestSectionLevel ) ) {
439                    // If we find a higher level nested section,
440                    // (a) Make current section non-editable
441                    // (b) There are 2 options here best illustrated with an example.
442                    //     Consider the wiktiext below.
443                    //       <div>
444                    //       =1=
445                    //       b
446                    //       </div>
447                    //       c
448                    //       =2=
449                    //     1. Create a new pseudo-section to wrap '$node'
450                    //        There will be a <section> around the <div> which includes 'c'.
451                    //     2. Don't create the pseudo-section by setting '$currSection = null'
452                    //        But, this can leave some content outside any top-level section.
453                    //        'c' will not be in any section.
454                    // The code below implements strategy 1.
455                    $currSection->setId( -1 );
456                    $currSection = $this->createNewSection(
457                        $rootNode, $sectionStack,
458                        $currSection, $node, $nestedHighestSectionLevel, true
459                    );
460                    $addedNode = true;
461                }
462            }
463
464            if ( $currSection && !$addedNode ) {
465                $currSection->addNode( $node );
466            }
467
468            if ( $tplInfo && $tplInfo->first === $node ) {
469                $tplInfo->firstSection = $currSection;
470            }
471
472            // Track exit from templated output
473            if ( $tplInfo && $tplInfo->last === $node ) {
474                if ( $currSection !== $tplInfo->firstSection ) {
475                    // The opening $node and closing $node of the template
476                    // are in different sections! This might require resolution.
477                    // While 'firstSection' could be null, if we get here,
478                    // 'lastSection' is guaranteed to always be non-null.
479                    $tplInfo->lastSection = $currSection;
480                    $this->tplsAndExtsToExamine[] = $tplInfo;
481                }
482
483                $this->tplInfo = $tplInfo = null;
484            }
485
486            $node = $next;
487        }
488
489        // The last section embedded in a non-body DOM element
490        // should always be marked non-editable since it will have
491        // the closing tag (ex: </div>) showing up in the source editor
492        // which we cannot support in a visual editing $environment.
493        if ( $currSection && !DOMUtils::atTheTop( $rootNode ) ) {
494            $currSection->setId( -1 );
495        }
496
497        return $highestSectionLevel;
498    }
499
500    /**
501     * Is this a Parsoid-inserted section (vs. a section node generated by
502     * other page-components / content-generators like extensions)?
503     *
504     * @param Element $n
505     * @return bool
506     */
507    private static function isParsoidSection( Element $n ): bool {
508        return DOMUtils::nodeName( $n ) === 'section' && $n->hasAttribute( 'data-mw-section-id' );
509    }
510
511    /**
512     * Find an ancestor that is a Parsoid-inserted section
513     *
514     * @param Node $n
515     * @return Element
516     */
517    private static function findSectionAncestor( Node $n ): Element {
518        do {
519            $n = DOMUtils::findAncestorOfName( $n, 'section' );
520        } while ( $n && !self::isParsoidSection( $n ) );
521
522        Assert::invariant( $n instanceof Element, "Expected to find Parsoid-section ancestor" );
523        '@phan-var Element $n'; // @var Element $n
524        return $n;
525    }
526
527    /**
528     * Get opening/closing DSR offset for the subtree rooted at $node.
529     * This handles scenarios where $node is a section or template wrapper
530     * and if a section, when it has leading/trailing non-element nodes
531     * that don't have recorded DSR values.
532     *
533     * @param Element $node
534     * @param bool $start
535     * @return array{0:?int,1:?Source}
536     */
537    private function getDSR( Element $node, bool $start ): array {
538        if ( !self::isParsoidSection( $node ) ) {
539            $dsr = DOMDataUtils::getDataParsoid( $node )->dsr ?? null;
540            if ( !$dsr ) {
541                Assert::invariant(
542                    $node->hasAttribute( 'about' ),
543                    'Expected an about id'
544                );
545                $about = DOMCompat::getAttribute( $node, 'about' );
546                $dsr = DOMDataUtils::getDataParsoid( $this->aboutIdMap[$about] )->dsr;
547            }
548
549            return [ $start ? $dsr->start : $dsr->end, $dsr->source ];
550        }
551
552        $offset = 0;
553        $c = $start ? $node->firstChild : $node->lastChild;
554        while ( $c ) {
555            if ( $c instanceof Text ) {
556                $offset += strlen( $c->textContent );
557            } elseif ( $c instanceof Comment ) {
558                $offset += WTUtils::decodedCommentLength( $c );
559            } else {
560                '@phan-var Element $c'; // @var Element $c
561                [ $ret, $src ] = $this->getDSR( $c, $start );
562                return [ $ret === null ? null : $ret + ( $start ? -$offset : $offset ), $src ];
563            }
564            $c = $start ? $c->nextSibling : $c->previousSibling;
565        }
566
567        return [ -1, null ];
568    }
569
570    /**
571     * FIXME: Duplicated with TableFixups code.
572     * @param list<string|TemplateInfo> &$parts
573     * @param Source $source
574     * @param ?int $offset1
575     * @param ?int $offset2
576     * @throws InternalException
577     */
578    private function fillDSRGap( array &$parts, Source $source, ?int $offset1, ?int $offset2 ): void {
579        if ( $offset1 === null || $offset2 === null ) {
580            throw new InternalException();
581        }
582        if ( $offset1 < $offset2 ) {
583            $parts[] = PHPUtils::safeSubstr( $source->getSrcText(), $offset1, $offset2 - $offset1 );
584        }
585    }
586
587    /**
588     * FIXME: There is strong overlap with TableFixups code.
589     *
590     * $wrapper will hold tpl/ext encap info for the array of tpls/exts as well as
591     * content before, after and in between them. Right now, this will always be a
592     * <section> node, but not asserting this since code doesn't depend on it being so.
593     *
594     * @param Element $wrapper
595     * @param array $encapWrappers
596     */
597    private function collapseWrappers( Element $wrapper, array $encapWrappers ): void {
598        $wrapperDp = DOMDataUtils::getDataParsoid( $wrapper );
599
600        // Build up $parts, $pi to set up the combined transclusion info on $wrapper
601        $parts = [];
602        $pi = [];
603        $index = 0;
604        $prevDp = null;
605        $haveTemplate = false;
606        try {
607            foreach ( $encapWrappers as $encapNode ) {
608                $dp = DOMDataUtils::getDataParsoid( $encapNode );
609
610                // Plug DSR gaps between encapWrappers
611                $source = $dp->dsr->source ?? $this->frame->getSource();
612                if ( !$prevDp ) {
613                    $this->fillDSRGap( $parts, $source, $wrapperDp->dsr->start, $dp->dsr->start );
614                } else {
615                    $this->fillDSRGap( $parts, $source, $prevDp->dsr->end, $dp->dsr->start );
616                }
617
618                if ( DOMUtils::hasTypeOf( $encapNode, "mw:Transclusion" ) ) {
619                    $haveTemplate = true;
620                    // Assimilate $encapNode's data-mw and data-parsoid pi info
621                    $dmw = DOMDataUtils::getDataMw( $encapNode );
622                    foreach ( $dmw->parts ?? [] as $part ) {
623                        // Template index is relative to other transclusions.
624                        // This index is used to extract whitespace information from
625                        // data-parsoid and that array only includes info for templates.
626                        // So skip over strings here.
627                        if ( !is_string( $part ) ) {
628                            $part = clone $part;
629                            $part->i = $index++;
630                        }
631                        $parts[] = $part;
632                    }
633                    PHPUtils::pushArray( $pi, $dp->pi ?? [ [] ] );
634                } else {
635                    // Where a non-template type is present, we are going to treat that
636                    // segment as a "string" in the parts array. So, we effectively treat
637                    // "mw:Transclusion" as a generic type that covers a single template
638                    // as well as a run of segments where at least one segment comes from
639                    // a template but others may be from other generators (ex: extensions).
640                    $source = $dp->dsr->source ?? $this->frame->getSource();
641                    $this->fillDSRGap( $parts, $source, $dp->dsr->start, $dp->dsr->end );
642                }
643
644                $prevDp = $dp;
645            }
646
647            if ( !$haveTemplate ) {
648                throw new InternalException();
649            }
650
651            DOMUtils::addTypeOf( $wrapper, "mw:Transclusion" );
652            $wrapperDp->pi = $pi;
653            $source = $prevDp->dsr->source ?? $this->frame->getSource();
654            $this->fillDSRGap( $parts, $source, $prevDp->dsr->end, $wrapperDp->dsr->end );
655            $dataMw = new DataMw( [] );
656            $dataMw->parts = $parts;
657            DOMDataUtils::setDataMw( $wrapper, $dataMw );
658        } catch ( InternalException ) {
659            // We don't have accurate template wrapping information.
660            // Set typeof to 'mw:Placeholder' since 'mw:Transclusion'
661            // typeof is not actionable without valid data-mw.
662            //
663            // FIXME:
664            // 1. If we stop stripping section wrappers in the html->wt direction,
665            //    we will need to add a DOMHandler for <section> or mw:Placeholder typeof
666            //    on arbitrary Elements to traverse into children and serialize and
667            //    prevent page corruption.
668            // 2. This may be a good place to collect stats for T191641#6357136
669            // 3. Maybe we need a special error typeof rather than mw:Placeholder
670            $wrapper->setAttribute( 'typeof', 'mw:Placeholder' );
671        }
672    }
673
674    /**
675     * Section wrappers and encapsulation wrappers can conflict because of
676     * partial overlaps. This method identifies those conflicts and fixes up
677     * the encapsulation by expanding those ranges as necessary.
678     */
679    private function resolveTplExtSectionConflicts(): void {
680        $secRanges = [];
681        '@phan-var array[] $secRanges';
682        foreach ( $this->tplsAndExtsToExamine as $tplInfo ) {
683            $s1 = $tplInfo->firstSection->container ??
684                self::findSectionAncestor( $tplInfo->first );
685
686            // guaranteed to be non-null
687            $s2 = $tplInfo->lastSection->container;
688
689            // Find a common ancestor of s1 and s2 (could be s1 or s2)
690            $s2Ancestors = DOMUtils::pathToRoot( $s2 );
691            $s1Ancestors = [];
692            $n = 0;
693            $ancestor = $s1;
694            while ( !in_array( $ancestor, $s2Ancestors, true ) ) {
695                $s1Ancestors[] = $ancestor;
696                $ancestor = $ancestor->parentNode;
697                $n++;
698            }
699
700            // ancestor is now the common ancestor of s1 and s2
701            $s1Ancestors[] = $ancestor;
702            $n++;
703
704            // Set up start/end of the new encapsulation range
705            if ( $ancestor === $s1 || $ancestor === $s2 ) {
706                $start = $ancestor;
707                $end = $ancestor;
708            } else {
709                // While creating a new section (see createNewSection), it only
710                // gets added where its parent is either another section,
711                // or body, so all ancestors are themselves sections, or body.
712                $start = $s1Ancestors[$n - 2];
713                $i = array_search( $ancestor, $s2Ancestors, true );
714                $end = $s2Ancestors[$i - 1];
715            }
716
717            '@phan-var Element $start';  // @var Element $start
718            '@phan-var Element $end';    // @var Element $end
719
720            // Add new OR update existing range
721            if ( $start->hasAttribute( 'about' ) ) {
722                // Overlaps with an existing range.
723                $about = DOMCompat::getAttribute( $start, 'about' );
724                if ( !$end->hasAttribute( 'about' ) ) {
725                    // Extend existing range till $end
726                    $secRanges[$about]['end'] = $end;
727                    $end->setAttribute( 'about', $about );
728                } else {
729                    Assert::invariant( DOMCompat::getAttribute( $end, 'about' ) === $about,
730                        "Expected end-range about id to be $about instead of " .
731                        DOMCompat::getAttribute( $end, 'about' ) . " in the overlap scenario." );
732                }
733            } else {
734                // Check for nesting in another range.  Since $start and $end
735                // are siblings, this is sufficient to know the entire range
736                // is nested
737                $about = null;
738                $n = $start->parentNode;
739                $body = DOMCompat::getBody( $start->ownerDocument );
740                while ( $n !== $body ) {
741                    '@phan-var Element $n';  // @var Element $n
742                    if ( self::isParsoidSection( $n ) && $n->hasAttribute( 'about' ) ) {
743                        $about = DOMCompat::getAttribute( $n, 'about' );
744                        break;
745                    }
746                    $n = $n->parentNode;
747                }
748
749                if ( !$about ) {
750                    // Not overlapping, not nested => new range
751                    $about = $this->env->newAboutId();
752                    $start->setAttribute( 'about', $about );
753                    $end->setAttribute( 'about', $about );
754                    $secRanges[$about] = [ 'start' => $start, 'end' => $end, 'encapWrappers' => [] ];
755                }
756            }
757            $secRanges[$about]['encapWrappers'][] = $tplInfo->first;
758        }
759
760        // Process recorded ranges into new encapsulation information
761        // that spans all content in that range.
762        foreach ( $secRanges as $about => $range ) {
763            // Ensure that all top level nodes of the range have the same about id
764            for ( $n = $range['start']; $n !== $range['end']->nextSibling; $n = $n->nextSibling ) {
765                Assert::invariant( self::isParsoidSection( $n ),
766                    "Encountered non-Parsoid-section node (" .
767                    DOMUtils::nodeName( $n ) .
768                    ") while updating template wrappers" );
769                $n->setAttribute( 'about', $about );
770            }
771
772            [ $dsr1, $src1 ] = $this->getDSR( $range['start'], true ); // Traverses non-tpl content => will succeed
773            [ $dsr2, $src2 ] = $this->getDSR( $range['end'], false );  // Traverses non-tpl content => will succeed
774            Assert::invariant(
775                ( $src1 ?? $src2 ) === ( $src2 ?? $src1 ),
776                "Inconsistent DSR sources"
777            );
778            $dp = new DataParsoid;
779            $dp->dsr = new DomSourceRange( $dsr1, $dsr2, null, null, source: $src1 ?? $src2 );
780            DOMDataUtils::setDataParsoid( $range['start'], $dp );
781
782            $this->collapseWrappers( $range['start'], $range['encapWrappers'] );
783        }
784    }
785
786    private function convertTOCOffsets(): void {
787        // Create reference array from all the codepointOffsets
788        $offsets = [];
789        foreach ( $this->env->getTOCData()->getSections() as $section ) {
790            if ( $section->codepointOffset !== null ) {
791                $offsets[] = &$section->codepointOffset;
792            }
793        }
794        TokenUtils::convertOffsets(
795            $this->env->topFrame->getSource()->getSrcText(),
796            $this->env->getCurrentOffsetType(),
797            'char',
798            $offsets
799        );
800    }
801
802    /**
803     * In core, Parser.php adds a TOC marker before the *first* heading element
804     * independent of how that heading element is nested. In the common case,
805     * that insertion point corresponds to the last element of the lead section
806     * as computed by section wrapping code in this file. In the edge case, when
807     * a <div> wraps the heading, the insertion point lies inside the <div> and
808     * has no relation to the lead section.
809     */
810    private static function findTOCInsertionPoint( Node $elt ): ?Element {
811        while ( $elt ) {
812            // Ignore extension content while finding TOC insertion point
813            if ( WTUtils::isFirstExtensionWrapperNode( $elt ) ) {
814                $elt = WTUtils::skipOverEncapsulatedContent( $elt );
815                continue;
816            }
817            if ( $elt instanceof Element ) {
818                if ( self::isWrappableHeading( $elt ) ) {
819                    return $elt;
820                } elseif ( $elt->firstChild ) {
821                    $tocIP = self::findTOCInsertionPoint( $elt->firstChild );
822                    if ( $tocIP ) {
823                        return $tocIP;
824                    }
825                }
826            }
827            $elt = $elt->nextSibling;
828        }
829        return null;
830    }
831
832    /**
833     * Insert a synthetic section in which to place the TOC
834     */
835    private function insertSyntheticSection(
836        Element $syntheticTocMeta, Element $insertionPoint
837    ): Element {
838        $prev = $insertionPoint->previousSibling;
839
840        // Create a pseudo-section contaning the TOC
841        $syntheticTocSection = $this->doc->createElement( 'section' );
842        $syntheticTocSection->setAttribute( 'data-mw-section-id', '-2' );
843        $insertionPoint->parentNode->insertBefore( $syntheticTocSection, $insertionPoint );
844        $this->pseudoSectionCount++;
845        $syntheticTocSection->appendChild( $syntheticTocMeta );
846
847        // Ensure template continuity is not broken!
848        // If $prev is not an encapsulation wrapper, nothing to do!
849        if ( $prev && WTUtils::isEncapsulationWrapper( $prev ) ) {
850            '@phan-var Element $prev';
851            $prevAbout = DOMCompat::getAttribute( $prev, 'about' );
852
853            // First, handle the case of section-tag-stripping that VE does.
854            // So, find the leftmost non-section-wrapper node since we want
855            // If the about ids are different, $next & $prev belong to
856            // different transclusions and the TOC meta can be left alone.
857            $next = $insertionPoint->firstChild;
858            $nextAbout = $next instanceof Element ? DOMCompat::getAttribute( $next, 'about' ) : null;
859            if ( $prevAbout === $nextAbout ) {
860                $syntheticTocMeta->setAttribute( 'about', $prevAbout );
861            }
862
863            // Now handle case of section-tags not being stripped
864            // NOTE that $syntheticMeta is before $insertipnPoint
865            // If it is not-null, it is known to be a <section>.
866            $next = $insertionPoint;
867            '@phan-var Element $next';
868            $nextAbout = $next ? DOMCompat::getAttribute( $next, 'about' ) : null;
869            if ( $prevAbout === $nextAbout ) {
870                $syntheticTocSection->setAttribute( 'about', $prevAbout );
871            }
872        }
873
874        return $syntheticTocSection;
875    }
876
877    private function addSyntheticTOCMarker(): void {
878        // Add a synthetic TOC at the end of the first section, if necessary
879        $tocBS = $this->env->getBehaviorSwitch( 'toc' );
880        $noTocBS = $this->env->getBehaviorSwitch( 'notoc' );
881        $forceTocBS = $this->env->getBehaviorSwitch( 'forcetoc' );
882
883        $showToc = true;
884        if ( $noTocBS && !$tocBS ) {
885            $showToc = false;
886        }
887        $numHeadings = $this->count - 1 - $this->pseudoSectionCount; // $this->count is initialized to 1
888        $enoughToc = $showToc && ( $numHeadings >= 4 || $tocBS );
889        if ( $forceTocBS ) {
890            $showToc = true;
891            $enoughToc = true;
892        }
893        if ( $numHeadings == 0 ) {
894            $enoughToc = false;
895        }
896
897        if ( !$this->env->getPageConfig()->getSuppressTOC() ) {
898            if ( $enoughToc ) {
899                // ParserOutputFlags::SHOW_TOC
900                $this->env->getMetadata()->setOutputFlag( 'show-toc' );
901                if ( !$tocBS ) {
902                    $syntheticTocMeta = $this->doc->createElement( 'meta' );
903                    $syntheticTocMeta->setAttribute( 'property', 'mw:PageProp/toc' );
904                    $dmw = DOMDataUtils::getDataMw( $syntheticTocMeta );
905                    $dmw->autoGenerated = true;
906                    $tocIP = $this->findTOCInsertionPoint( DOMCompat::getBody( $this->doc ) );
907                    if ( $tocIP === null ) {
908                        // should not happen, but nothing to do here!
909                        return;
910                    }
911
912                    // NOTE: Given how <section>s are computed in this file, headings
913                    // will never have previous siblings. So, we look at $eltSection's
914                    // previous siblings always.
915                    $insertionPoint = self::findSectionAncestor( $tocIP );
916
917                    $insertionContainer = $insertionPoint->previousSibling;
918                    if ( !$insertionContainer || DOMUtils::nodeName( $insertionContainer ) !== 'section' ) {
919                        $insertionContainer = $this->insertSyntheticSection(
920                            $syntheticTocMeta, $insertionPoint
921                        );
922                    }
923                    $insertionContainer->appendChild( $syntheticTocMeta );
924
925                    // Set a synthetic zero-length dsr to suppress noisy warnings
926                    // from the round trip testing script.
927                    $syntheticOffset = DOMDataUtils::getDataParsoid( $tocIP )->dsr->start ?? null;
928                    if ( $syntheticOffset !== null ) {
929                        $dp = DOMDataUtils::getDataParsoid( $syntheticTocMeta );
930                        $dp->dsr = new DomSourceRange(
931                            $syntheticOffset, $syntheticOffset, 0, 0,
932                            source: DOMDataUtils::getDataParsoid( $tocIP )->dsr->source
933                        );
934                    }
935                }
936            }
937            if ( $numHeadings > 0 && !$showToc ) {
938                // ParserOutputFlags::NO_TOC
939                $this->env->getMetadata()->setOutputFlag( 'no-toc' );
940            }
941        }
942    }
943
944    /**
945     * DOM Postprocessor entry function to walk DOM rooted at $root
946     * and add <section> wrappers as necessary.
947     * Implements the algorithm documented @ mw:Parsing/Notes/Section_Wrapping
948     */
949    public function run(): void {
950        // 6 is the lowest possible level since we don't want
951        // any nesting of h-tags in the lead section
952        $leadSection = new Section( 6, 0, $this->doc );
953        $leadSection->setId( 0 );
954
955        $this->wrapSectionsInDOM( $leadSection, $this->rootNode );
956
957        // There will always be a lead section, even if sometimes it only
958        // contains whitespace + comments.
959        $this->rootNode->insertBefore( $leadSection->container, $this->rootNode->firstChild );
960
961        // Resolve template conflicts after all sections have been added to the DOM
962        $this->resolveTplExtSectionConflicts();
963
964        // Convert byte offsets to codepoint offsets in TOCData
965        // (done in a batch to avoid O(N^2) string traversals)
966        $this->convertTOCOffsets();
967
968        $this->addSyntheticTOCMarker();
969    }
970}