Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.70% covered (warning)
84.70%
238 / 281
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComputeDSR
84.70% covered (warning)
84.70%
238 / 281
28.57% covered (danger)
28.57%
2 / 7
228.55
0.00% covered (danger)
0.00%
0 / 1
 tsrSpansTagDOM
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 acceptableInconsistency
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 computeListEltWidth
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
10.98
 computeATagWidth
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
10.29
 computeTagWidths
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
15.31
 computeNodeDSR
83.08% covered (warning)
83.08%
162 / 195
0.00% covered (danger)
0.00%
0 / 1
152.42
 run
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\DOM\Processors;
5
6use Wikimedia\Parsoid\Config\Env;
7use Wikimedia\Parsoid\Core\DOMCompat;
8use Wikimedia\Parsoid\Core\DomSourceRange;
9use Wikimedia\Parsoid\Core\Source;
10use Wikimedia\Parsoid\Core\SourceRange;
11use Wikimedia\Parsoid\DOM\Comment;
12use Wikimedia\Parsoid\DOM\Element;
13use Wikimedia\Parsoid\DOM\Node;
14use Wikimedia\Parsoid\DOM\Text;
15use Wikimedia\Parsoid\NodeData\DataParsoid;
16use Wikimedia\Parsoid\Utils\DOMDataUtils;
17use Wikimedia\Parsoid\Utils\DOMUtils;
18use Wikimedia\Parsoid\Utils\PHPUtils;
19use Wikimedia\Parsoid\Utils\Utils;
20use Wikimedia\Parsoid\Utils\WTUtils;
21use Wikimedia\Parsoid\Wikitext\Consts;
22use Wikimedia\Parsoid\Wt2Html\TT\PreHandler;
23use Wikimedia\Parsoid\Wt2Html\Wt2HtmlDOMProcessor;
24
25class ComputeDSR implements Wt2HtmlDOMProcessor {
26    /**
27     * For an explanation of what TSR is, see ComputeDSR::computeNodeDSR()
28     *
29     * TSR info on all these tags are only valid for the opening tag.
30     *
31     * On other tags, a, hr, br, meta-marker tags, the tsr spans
32     * the entire DOM, not just the tag.
33     *
34     * This code is not in Wikitext\Consts.php because this
35     * information is Parsoid-implementation-specific.
36     */
37    private const WT_TAGS_WITH_LIMITED_TSR = [
38        "b"  => true,
39        "i"  => true,
40        "h1" => true,
41        "h2" => true,
42        "h3" => true,
43        "h4" => true,
44        "h5" => true,
45        "h6" => true,
46        "ul" => true,
47        "ol" => true,
48        "dl" => true,
49        "li" => true,
50        "dt" => true,
51        "dd" => true,
52        "table" => true,
53        "caption" => true,
54        "tr" => true,
55        "td" => true,
56        "th" => true,
57        "hr" => true, // void element
58        "br" => true, // void element
59        "pre" => true,
60    ];
61
62    /**
63     * Do $parsoidData->tsr values span the entire DOM subtree rooted at $n?
64     *
65     * @param Element $n
66     * @param DataParsoid $parsoidData
67     * @return bool
68     */
69    private function tsrSpansTagDOM( Element $n, DataParsoid $parsoidData ): bool {
70        // - tags known to have tag-specific tsr
71        // - html tags with 'stx' set
72        // - tags with certain typeof properties (Parsoid-generated
73        //   constructs: placeholders, lang variants)
74        $name = DOMUtils::nodeName( $n );
75        return !(
76            isset( self::WT_TAGS_WITH_LIMITED_TSR[$name] ) ||
77            DOMUtils::matchTypeOf(
78                $n,
79                '/^mw:(Placeholder|LanguageVariant)$/D'
80            ) ||
81            WTUtils::hasLiteralHTMLMarker( $parsoidData )
82        );
83    }
84
85    /**
86     * Is the inconsistency between two different ways of computing
87     * start offset ($cs, $s) explainable and acceptable?
88     * If so, we can suppress warnings.
89     *
90     * @param array $opts
91     * @param Node $node
92     * @param int $cs
93     * @param int $s
94     * @return bool
95     */
96    private function acceptableInconsistency( array $opts, Node $node, int $cs, int $s ): bool {
97        /**
98         * 1. For wikitext URL links, suppress cs-s diff warnings because
99         *    the diffs can come about because of various reasons since the
100         *    canonicalized/decoded href will become the a-link text whose width
101         *    will not match the tsr width of source wikitext
102         *
103         *    (a) urls with encoded chars (ex: 'http://example.com/?foo&#61;bar')
104         *    (b) non-canonical spaces (ex: 'RFC  123' instead of 'RFC 123')
105         *
106         * 2. We currently don't have source offsets for attributes.
107         *    So, we get a lot of spurious complaints about cs/s mismatch
108         *    when DSR computation hit the <body> tag on this attribute.
109         *    $opts['attrExpansion'] tell us when we are processing an attribute
110         *    and let us suppress the mismatch warning on the <body> tag.
111         *
112         * 3. Other scenarios .. to be added
113         */
114        if ( $node instanceof Element && (
115                WTUtils::isATagFromURLLinkSyntax( $node ) ||
116                WTUtils::isATagFromMagicLinkSyntax( $node )
117        ) ) {
118            return true;
119        } elseif ( isset( $opts['attrExpansion'] ) && DOMUtils::atTheTop( $node ) ) {
120            return true;
121        } else {
122            return false;
123        }
124    }
125
126    /**
127     * Compute wikitext string length that contributes to this
128     * list item's open tag. Closing tag width is always 0 for lists.
129     *
130     * @param Element $li
131     * @return int
132     */
133    private function computeListEltWidth( Element $li ): int {
134        if ( !$li->previousSibling && $li->firstChild ) {
135            if ( DOMUtils::isList( $li->firstChild ) ) {
136                // Special case!!
137                // First child of a list that is on a chain
138                // of nested lists doesn't get a width.
139                return 0;
140            }
141        }
142
143        // count nest listing depth and assign
144        // that to the opening tag width.
145        $depth = 0;
146
147        // This is the crux of the algorithm in DOMHandler::getListBullets()
148        while ( !DOMUtils::atTheTop( $li ) ) {
149            $dp = DOMDataUtils::getDataParsoid( $li );
150            if ( DOMUtils::isListOrListItem( $li ) ) {
151                if ( DOMUtils::isListItem( $li ) ) {
152                    $depth++;
153                }
154            } elseif (
155                !WTUtils::isLiteralHTMLNode( $li ) ||
156                empty( $dp->autoInsertedStart ) || empty( $dp->autoInsertedEnd )
157            ) {
158                break;
159            }
160            $li = $li->parentNode;
161        }
162
163        return $depth;
164    }
165
166    /**
167     * Compute wikitext string lengths that contribute to this
168     * anchor's opening (<a>) and closing (</a>) tags.
169     *
170     * @param Element $node
171     * @param ?DataParsoid $dp
172     * @return int[]|null
173     */
174    private function computeATagWidth(
175        Element $node, ?DataParsoid $dp
176    ): ?array {
177        /* -------------------------------------------------------------
178         * Tag widths are computed as per this logic here:
179         *
180         * 1. [[Foo|bar]] <-- piped mw:WikiLink
181         *     -> start-tag: "[[Foo|"
182         *     -> content  : "bar"
183         *     -> end-tag  : "]]"
184         *
185         * 2. [[Foo]] <-- non-piped mw:WikiLink
186         *     -> start-tag: "[["
187         *     -> content  : "Foo"
188         *     -> end-tag  : "]]"
189         *
190         * 3. [[{{1x|Foo}}|Foo]] <-- tpl-attr mw:WikiLink
191         *    Don't bother setting tag widths since dp->sa['href'] will be
192         *    the expanded target and won't correspond to original source.
193         *
194         * 4. [http://wp.org foo] <-- mw:ExtLink
195         *     -> start-tag: "[http://wp.org "
196         *     -> content  : "foo"
197         *     -> end-tag  : "]"
198         * -------------------------------------------------------------- */
199        if ( !$dp ) {
200            return null;
201        } else {
202            if ( WTUtils::isATagFromWikiLinkSyntax( $node ) && !WTUtils::hasExpandedAttrsType( $node ) ) {
203                if ( isset( $dp->stx ) && $dp->stx === "piped" ) {
204                    $pipeLen = strlen( $dp->firstPipeSrc ?? '|' );
205                    $href = $dp->sa['href'];
206                    return [ 2 + strlen( $href ) + $pipeLen, 2 ];
207                } else {
208                    return [ 2, 2 ];
209                }
210            } elseif ( isset( $dp->tsr ) && WTUtils::isATagFromExtLinkSyntax( $node ) ) {
211                return [ $dp->tmp->extLinkContentOffsets->start - $dp->tsr->start, 1 ];
212            } elseif ( WTUtils::isATagFromURLLinkSyntax( $node ) ||
213                WTUtils::isATagFromMagicLinkSyntax( $node )
214            ) {
215                return [ 0, 0 ];
216            } else {
217                return null;
218            }
219        }
220    }
221
222    /**
223     * Compute wikitext string lengths that contribute to this
224     * node's opening and closing tags.
225     *
226     * @param int|null $stWidth Start tag width
227     * @param int|null $etWidth End tag width
228     * @param Element $node
229     * @param DataParsoid $dp
230     * @return (int|null)[] Start and end tag widths
231     */
232    private function computeTagWidths( $stWidth, $etWidth, Element $node, DataParsoid $dp ): array {
233        if ( isset( $dp->extTagOffsets ) ) {
234            return [
235                $dp->extTagOffsets->openWidth,
236                $dp->extTagOffsets->closeWidth
237            ];
238        }
239
240        if ( WTUtils::hasLiteralHTMLMarker( $dp ) ) {
241            if ( !empty( $dp->selfClose ) ) {
242                $etWidth = 0;
243            }
244        } elseif ( DOMUtils::hasTypeOf( $node, 'mw:LanguageVariant' ) ) {
245            $stWidth = 2; // -{
246            $etWidth = 2; // }-
247        } else {
248            $nodeName = DOMUtils::nodeName( $node );
249            // 'tr' tags not in the original source have zero width
250            if ( $nodeName === 'tr' && !isset( $dp->startTagSrc ) ) {
251                $stWidth = 0;
252                $etWidth = 0;
253            } else {
254                $wtTagWidth = Consts::$WtTagWidths[$nodeName] ?? null;
255                if ( $stWidth === null ) {
256                    // we didn't have a tsr to tell us how wide this tag was.
257                    if ( $nodeName === 'a' ) {
258                        $wtTagWidth = $this->computeATagWidth( $node, $dp );
259                        $stWidth = $wtTagWidth ? $wtTagWidth[0] : null;
260                    } elseif ( $nodeName === 'li' || $nodeName === 'dd' ) {
261                        $stWidth = $this->computeListEltWidth( $node );
262                    } elseif ( $wtTagWidth ) {
263                        $stWidth = $wtTagWidth[0];
264                    }
265                }
266
267                if ( $etWidth === null && $wtTagWidth ) {
268                    $etWidth = $wtTagWidth[1];
269                }
270            }
271        }
272
273        return [ $stWidth, $etWidth ];
274    }
275
276    /**
277     * TSR = "Tag Source Range".  Start and end offsets giving the location
278     * where the tag showed up in the original source.
279     *
280     * DSR = "DOM Source Range".  dsr->start and dsr->end are open and end,
281     * dsr->openWidth and dsr->closeWidth are widths of the container tag.
282     *
283     * TSR is set by the tokenizer. In most cases, it only applies to the
284     * specific tag (opening or closing).  However, for self-closing
285     * tags that the tokenizer generates, the TSR values applies to the entire
286     * DOM subtree (opening tag + content + closing tag).
287     *
288     * Ex: So [[foo]] will get tokenized to a SelfclosingTagTk(...) with a TSR
289     * value of [0,7].  The DSR algorithm will then use that info and assign
290     * the a-tag rooted at the <a href='...'>foo</a> DOM subtree a DSR value of
291     * [0,7,2,2], where 2 and 2 refer to the opening and closing tag widths.
292     *
293     * [s,e) -- if defined, start/end position of wikitext source that generated
294     *          node's subtree
295     *
296     * @param Env $env
297     * @param Source $source
298     * @param Node $node node to process
299     * @param ?int $s start position, inclusive
300     * @param ?int $e end position, exclusive
301     * @param int $dsrCorrection
302     * @param array $opts
303     *
304     * @return list{?int, ?int}
305     */
306    private function computeNodeDSR(
307        Env $env, Source $source, Node $node, ?int $s, ?int $e, int $dsrCorrection,
308        array $opts
309    ): array {
310        if ( $e === null && !$node->hasChildNodes() ) {
311            $e = $s;
312        }
313
314        $env->trace( "dsr", "BEG: ", DOMUtils::nodeName( $node ), "with [s, e]=", [ $s, $e ] );
315
316        /** @var int|null $ce Child end */
317        $ce = $e;
318        // Initialize $cs to $ce to handle the zero-children case properly
319        // if this $node has no child content, then the start and end for
320        // the child dom are indeed identical.  Alternatively, we could
321        // explicitly code this check before everything and bypass this.
322        /** @var int|null $cs Child start */
323        $cs = $ce;
324
325        $child = $node->lastChild;
326        while ( $child !== null ) {
327            $prevChild = $child->previousSibling;
328            $origCE = $ce;
329            $cType = $child->nodeType;
330            $fosteredNode = false;
331            $cs = null;
332
333            if ( $child instanceof Element ) {
334                $dp = DOMDataUtils::getDataParsoid( $child );
335                $endTSR = $dp->tmp->endTSR ?? null;
336                if ( $endTSR ) {
337                    $ce = $endTSR->end;
338                }
339            } else {
340                $endTSR = null;
341            }
342
343            // StrippedTag marker tags will be removed and won't
344            // be around to fill in the missing gap.  So, absorb its width into
345            // the DSR of its previous sibling.  Currently, this fix is only for
346            // B and I tags where the fix is clear-cut and obvious.
347            $next = $child->nextSibling;
348            if ( $next instanceof Element ) {
349                $ndp = DOMDataUtils::getDataParsoid( $next );
350                if (
351                    isset( $ndp->src ) &&
352                    DOMUtils::hasTypeOf( $next, 'mw:Placeholder/StrippedTag' ) &&
353                    // NOTE: This inlist check matches the case in CleanUp where
354                    // the placeholders are not removed from the DOM.  We don't want
355                    // to move the width into the sibling here and then leave around a
356                    // a zero width placeholder because serializeDOMNode only handles
357                    // a few cases of zero width nodes, so we'll end up duplicating
358                    // it from ->src.
359                    !DOMUtils::isNestedInListItem( $next )
360                ) {
361                    if ( isset( Consts::$WTQuoteTags[$ndp->name] ) &&
362                        isset( Consts::$WTQuoteTags[DOMUtils::nodeName( $child )] ) ) {
363                        $correction = strlen( $ndp->src );
364                        $ce += $correction;
365                        $dsrCorrection = $correction;
366                        if ( Utils::isValidDSR( $ndp->dsr ?? null ) ) {
367                            // Record original DSR for the meta tag
368                            // since it will now get corrected to zero width
369                            // since child acquires its width->
370                            $ndp->getTemp()->origDSR = new DomSourceRange(
371                                $ndp->dsr->start, $ndp->dsr->end, null, null,
372                                source: $ndp->dsr->source
373                            );
374                        }
375                    }
376                }
377            }
378
379            $env->trace( "dsr", static function () use ( $child, $cs, $ce ) {
380                // slow, for debugging only
381                $i = 0;
382                foreach ( DOMUtils::childNodes( $child->parentNode ) as $x ) {
383                    if ( $x === $child ) {
384                        break;
385                    }
386                    $i++;
387                }
388                return "     CHILD: <" . DOMUtils::nodeName( $child->parentNode ) . ":" . $i .
389                    ">=" .
390                    ( $child instanceof Element ? '' : ( $child instanceof Text ? '#' : '!' ) ) .
391                    ( ( $child instanceof Element ) ?
392                        ( DOMUtils::nodeName( $child ) === 'meta' ?
393                            DOMCompat::getOuterHTML( $child ) : DOMUtils::nodeName( $child ) ) :
394                            PHPUtils::jsonEncode( $child->nodeValue ) ) .
395                    " with " . PHPUtils::jsonEncode( [ $cs, $ce ] );
396            } );
397
398            if ( $cType === XML_TEXT_NODE ) {
399                if ( $ce !== null ) {
400                    $cs = $ce - strlen( $child->textContent );
401                }
402            } elseif ( $cType === XML_COMMENT_NODE ) {
403                '@phan-var Comment $child'; // @var Comment $child
404                if ( $ce !== null ) {
405                    // Decode HTML entities & re-encode as wikitext to find length
406                    $cs = $ce - WTUtils::decodedCommentLength( $child );
407                }
408            } elseif ( $cType === XML_ELEMENT_NODE ) {
409                '@phan-var Element $child'; // @var Element $child
410                $dp = DOMDataUtils::getDataParsoid( $child );
411                $tsr = $dp->tsr ?? null;
412                $oldCE = $tsr ? $tsr->end : null;
413                $propagateRight = false;
414                $stWidth = null;
415                $etWidth = null;
416
417                $fosteredNode = $dp->fostered ?? false;
418
419                // We are making dsr corrections to account for
420                // stripped tags (end tags usually). When stripping happens,
421                // in most common use cases, a corresponding end tag is added
422                // back elsewhere in the DOM.
423                //
424                // So, when an autoInsertedEnd tag is encountered and a matching
425                // dsr-correction is found, make a 1-time correction in the
426                // other direction.
427                //
428                // Currently, this fix is only for
429                // B and I tags where the fix is clear-cut and obvious.
430                if ( $ce !== null && !empty( $dp->autoInsertedEnd ) &&
431                    DOMUtils::isQuoteElt( $child )
432                ) {
433                    $correction = 3 + strlen( DOMUtils::nodeName( $child ) );
434                    if ( $correction === $dsrCorrection ) {
435                        $ce -= $correction;
436                        $dsrCorrection = 0;
437                    }
438                }
439
440                if ( DOMUtils::nodeName( $child ) === "meta" ) {
441                    if ( $tsr ) {
442                        if ( WTUtils::isTplMarkerMeta( $child ) ) {
443                            // If this is a meta-marker tag (for templates, extensions),
444                            // we have a new valid '$cs'. This marker also effectively resets tsr
445                            // back to the top-level wikitext source range from nested template
446                            // source range.
447                            $cs = $tsr->start;
448                            $ce = $tsr->end;
449                            $propagateRight = true;
450                        } else {
451                            // All other meta-tags: <includeonly>, <noinclude>, etc.
452                            $cs = $tsr->start;
453                            $ce = $tsr->end;
454                        }
455                    } elseif ( PreHandler::isIndentPreWS( $child ) ) {
456                        // Adjust start DSR; see PreHandler::newIndentPreWS()
457                        $cs = $ce - 1;
458                    } elseif ( DOMUtils::matchTypeOf( $child, '#^mw:Placeholder(/\w*)?$#D' ) &&
459                        $ce !== null && $dp->src
460                    ) {
461                        $cs = $ce - strlen( $dp->src );
462                    }
463                    if ( isset( $dp->extTagOffsets ) ) {
464                        $stWidth = $dp->extTagOffsets->openWidth;
465                        $etWidth = $dp->extTagOffsets->closeWidth;
466                        unset( $dp->extTagOffsets );
467                    }
468                } elseif ( DOMUtils::hasTypeOf( $child, "mw:Entity" ) && $ce !== null && $dp->src ) {
469                    $cs = $ce - strlen( $dp->src );
470                } else {
471                    if ( DOMUtils::matchTypeOf( $child, '#^mw:Placeholder(/\w*)?$#D' ) &&
472                        $ce !== null && $dp->src
473                    ) {
474                        $cs = $ce - strlen( $dp->src );
475                    } else {
476                        // Non-meta tags
477                        if ( $endTSR ) {
478                            $etWidth = $endTSR->length();
479                        }
480                        if ( $tsr && empty( $dp->autoInsertedStart ) ) {
481                            $cs = $tsr->start;
482                            if ( $this->tsrSpansTagDOM( $child, $dp ) ) {
483                                if ( $tsr->end !== null && $tsr->end > 0 ) {
484                                    $ce = $tsr->end;
485                                    $propagateRight = true;
486                                }
487                            } else {
488                                $stWidth = $tsr->end - $tsr->start;
489                            }
490
491                            $env->trace( "dsr", "     TSR: ", $tsr, "; cs: ", $cs, "; ce: ", $ce );
492                        } elseif ( $s && $child->previousSibling === null ) {
493                            $cs = $s;
494                        }
495                    }
496
497                    // Compute width of opening/closing tags for this dom $node
498                    [ $stWidth, $etWidth ] =
499                        $this->computeTagWidths( $stWidth, $etWidth, $child, $dp );
500
501                    if ( !empty( $dp->autoInsertedStart ) ) {
502                        $stWidth = 0;
503                    }
504                    if ( !empty( $dp->autoInsertedEnd ) ) {
505                        $etWidth = 0;
506                    }
507
508                    $ccs = $cs !== null && $stWidth !== null ? $cs + $stWidth : null;
509                    $cce = $ce !== null && $etWidth !== null ? $ce - $etWidth : null;
510
511                    /* -----------------------------------------------------------------
512                     * Process DOM rooted at '$child'.
513                     *
514                     * NOTE: You might wonder why we are not checking for the zero-$children
515                     * case. It is strictly not necessary and you can set newDsr directly.
516                     *
517                     * But, you have 2 options: [$ccs, $ccs] or [$cce, $cce]. Setting it to
518                     * [$cce, $cce] would be consistent with the RTL approach. We should
519                     * then compare $ccs and $cce and verify that they are identical.
520                     *
521                     * But, if we handled the zero-child case like the other scenarios,
522                     * we don't have to worry about the above decisions and checks.
523                     * ----------------------------------------------------------------- */
524
525                    if ( WTUtils::isDOMFragmentWrapper( $child ) ||
526                         DOMUtils::hasTypeOf( $child, 'mw:LanguageVariant' )
527                    ) {
528                        // Eliminate artificial $cs/s mismatch warnings since this is
529                        // just a wrapper token with the right DSR but without any
530                        // nested subtree that could account for the DSR span.
531                        $newDsr = [ $ccs, $cce ];
532                    } elseif ( $child instanceof Element
533                        && WTUtils::isATagFromWikiLinkSyntax( $child )
534                        && ( !isset( $dp->stx ) || $dp->stx !== "piped" ) ) {
535                        /* -------------------------------------------------------------
536                         * This check here eliminates artificial DSR mismatches on content
537                         * text of the A-node because of entity expansion, etc.
538                         *
539                         * Ex: [[7%25 solution]] will be rendered as:
540                         *    <a href=....>7% solution</a>
541                         * If we descend into the text for the a-node, we'll have a 2-char
542                         * DSR mismatch which will trigger artificial error warnings.
543                         *
544                         * In the non-piped link scenario, all dsr info is already present
545                         * in the link target and so we get nothing new by processing
546                         * content.
547                         * ------------------------------------------------------------- */
548                        $newDsr = [ $ccs, $cce ];
549                    } else {
550                        $env->trace( "dsr", static function () use (
551                            $cs, $ce, $stWidth, $etWidth, $ccs, $cce
552                        ) {
553                            return "     before-recursing:" .
554                                "[cs,ce]=" . PHPUtils::jsonEncode( [ $cs, $ce ] ) .
555                                "; [sw,ew]=" . PHPUtils::jsonEncode( [ $stWidth, $etWidth ] ) .
556                                "; subtree-[cs,ce]=" . PHPUtils::jsonEncode( [ $ccs, $cce ] );
557                        } );
558
559                        $env->trace( "dsr", "<recursion>" );
560                        $newDsr = $this->computeNodeDSR( $env, $source, $child, $ccs, $cce, $dsrCorrection, $opts );
561                        $env->trace( "dsr", "</recursion>" );
562                    }
563
564                    // $cs = min($child-dom-tree dsr->start - tag-width, current dsr->start)
565                    if ( $stWidth !== null && $newDsr[0] !== null ) {
566                        $newCs = $newDsr[0] - $stWidth;
567                        if ( $cs === null || ( !$tsr && $newCs < $cs ) ) {
568                            $cs = $newCs;
569                        }
570                    }
571
572                    // $ce = max($child-dom-tree dsr->end + tag-width, current dsr->end)
573                    if ( $etWidth !== null && $newDsr[1] !== null ) {
574                        $newCe = $newDsr[1] + $etWidth;
575                        if ( $newCe > $ce ) {
576                            $ce = $newCe;
577                        }
578                    }
579                }
580
581                if ( $cs !== null || $ce !== null ) {
582                    if ( $ce < 0 ) {
583                        if ( !$fosteredNode ) {
584                            $env->trace( "dsr/negative",
585                                "Negative DSR for node: " . DOMUtils::nodeName( $node ) . "; resetting to zero" );
586                        }
587                        $ce = 0;
588                    }
589
590                    // Fostered $nodes get a zero-dsr width range.
591                    if ( $fosteredNode ) {
592                        // Reset to 0, if necessary.
593                        // This is critical to avoid duplication of fostered content in selser mode.
594                        if ( $origCE < 0 ) {
595                            $origCE = 0;
596                        }
597                        $dp->dsr = new DomSourceRange( $origCE, $origCE, null, null, source: $source );
598                    } else {
599                        $dp->dsr = new DomSourceRange( $cs, $ce, $stWidth, $etWidth, source: $source );
600                    }
601
602                    $env->trace( "dsr", static function () use ( $child, $cs, $ce ) {
603                        return "     UPDATING " . DOMUtils::nodeName( $child ) .
604                            " with " . PHPUtils::jsonEncode( [ $cs, $ce ] ) .
605                            "; typeof: " . ( DOMCompat::getAttribute( $child, "typeof" ) ?? '' );
606                    } );
607                }
608
609                // Propagate any required changes to the right
610                // taking care not to cross-over into template content
611                if ( $ce !== null &&
612                    ( $propagateRight || $oldCE !== $ce || $e === null ) &&
613                    !WTUtils::isTplStartMarkerMeta( $child )
614                ) {
615                    $sibling = $child->nextSibling;
616                    $newCE = $ce;
617                    while ( $newCE !== null && $sibling && !WTUtils::isTplStartMarkerMeta( $sibling ) ) {
618                        $nType = $sibling->nodeType;
619                        if ( $nType === XML_TEXT_NODE ) {
620                            $newCE += strlen( $sibling->textContent );
621                        } elseif ( $nType === XML_COMMENT_NODE ) {
622                            '@phan-var Comment $sibling'; // @var Comment $sibling
623                            $newCE += WTUtils::decodedCommentLength( $sibling );
624                        } elseif ( $nType === XML_ELEMENT_NODE ) {
625                            '@phan-var Element $sibling'; // @var Element $sibling
626                            $siblingDP = DOMDataUtils::getDataParsoid( $sibling );
627                            $siblingDP->dsr ??= new DomSourceRange( null, null, null, null, source: $source );
628                            $sdsrStart = $siblingDP->dsr->start;
629                            if ( !empty( $siblingDP->fostered ) ||
630                                ( $sdsrStart !== null && $sdsrStart === $newCE ) ||
631                                ( $sdsrStart !== null && $sdsrStart < $newCE && isset( $siblingDP->tsr ) )
632                            ) {
633                                // $sibling is fostered
634                                // => nothing to propagate past it
635                                // $sibling's dsr->start matches what we might propagate
636                                // => nothing will change
637                                // $sibling's dsr value came from tsr and it is not outside expected range
638                                // => stop propagation so you don't overwrite it
639                                break;
640                            }
641
642                            // Update and move right
643                            $env->trace( "dsr", static function () use ( $newCE, $sibling, $siblingDP ) {
644                                return "     CHANGING ce.start of " . DOMUtils::nodeName( $sibling ) .
645                                    " from " . $siblingDP->dsr->start . " to " . $newCE;
646                            } );
647
648                            $siblingDP->dsr->start = $newCE;
649                            // If we have a dsr->end as well and since we updated
650                            // dsr->start, we have to ensure that the two values don't
651                            // introduce an inconsistency where dsr->start > dsr->end.
652                            // Since we are in a LTR pass and are pushing updates
653                            // forward, we are resolving it by updating dsr->end as
654                            // well. There could be scenarios where this would be
655                            // incorrect, but there is no universal fix here.
656                            if ( $siblingDP->dsr->end !== null && $newCE > $siblingDP->dsr->end ) {
657                                $siblingDP->dsr->end = $newCE;
658                            }
659                            $newCE = $siblingDP->dsr->end;
660
661                        } else {
662                            break;
663                        }
664                        $sibling = $sibling->nextSibling;
665                    }
666
667                    // Propagate new end information
668                    if ( !$sibling ) {
669                        $e = $newCE;
670                    }
671                }
672            }
673
674            // Don't change state if we processed a fostered $node
675            if ( $fosteredNode ) {
676                $ce = $origCE;
677            } else {
678                // $ce for next $child = $cs of current $child
679                $ce = $cs;
680            }
681
682            $child = $prevChild;
683        }
684
685        if ( $cs === null ) {
686            $cs = $s;
687        }
688
689        // Detect errors
690        if ( $s !== null && $cs !== $s && !$this->acceptableInconsistency( $opts, $node, $cs, $s ) ) {
691            $env->trace( "dsr/inconsistent", "DSR inconsistency: cs/s mismatch for node:",
692                DOMUtils::nodeName( $node ), "s:", $s, "; cs:", $cs );
693        }
694
695        $env->trace( "dsr", "END: ", DOMUtils::nodeName( $node ), "returning: ", $cs, ", ", $e );
696
697        return [ $cs, $e ];
698    }
699
700    /**
701     * Computes DSR ranges for every node of a DOM tree.
702     * This pass is only invoked on the top-level page.
703     *
704     * @param Env $env The environment/context for the parse pipeline
705     * @param Node $root The root of the tree for which DSR has to be computed
706     * @param array $options Options governing DSR computation
707     * - srcOffsets   : [start, end] source offset. If missing, this defaults to
708     *                  [0, strlen($frame->getSrcText())]
709     * - attrExpansion: Is this an attribute expansion pipeline?
710     * @param bool $atTopLevel Are we running this on the top level?
711     */
712    public function run(
713        Env $env, Node $root, array $options = [], bool $atTopLevel = false
714    ): void {
715        // Don't run this in template content
716        if ( $options['inTemplate'] ) {
717            return;
718        }
719
720        $frame = $options['frame'] ?? $env->topFrame;
721        $srcOffsets = $options['srcOffsets'] ??
722            SourceRange::fromSource( $frame->getSource() );
723        $startOffset = $srcOffsets->start;
724        $endOffset = $srcOffsets->end;
725        $source = $srcOffsets->source ?? $frame->getSource();
726        $env->trace( "dsr", "------- tracing DSR computation -------" );
727
728        // The actual computation buried in trace/debug stmts.
729        $opts = [ 'attrExpansion' => $options['attrExpansion'] ?? false ];
730        $this->computeNodeDSR( $env, $source, $root, $startOffset, $endOffset, 0, $opts );
731
732        if ( $root instanceof Element ) {
733            $dp = DOMDataUtils::getDataParsoid( $root );
734            $dp->dsr = new DomSourceRange( $startOffset, $endOffset, 0, 0, source: $source );
735        }
736        $env->trace( "dsr", "------- done tracing computation -------" );
737    }
738}