Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.73% covered (warning)
51.73%
209 / 404
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImmutableRange
51.73% covered (warning)
51.73%
209 / 404
40.00% covered (danger)
40.00%
6 / 15
2509.26
0.00% covered (danger)
0.00%
0 / 1
 findCommonAncestorContainer
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
 getRootNode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 __get
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
10.02
 setStart
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStartOrEnd
53.85% covered (warning)
53.85%
14 / 26
0.00% covered (danger)
0.00%
0 / 1
14.29
 isPartiallyContainedNode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isFullyContainedNode
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 extractContents
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
812
 cloneContents
74.07% covered (warning)
74.07%
80 / 108
0.00% covered (danger)
0.00%
0 / 1
54.14
 insertNode
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
110
 surroundContents
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 computePosition
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
19
 compareBoundaryPoints
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
11.17
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use DOMException;
6use RuntimeException;
7use Wikimedia\Assert\Assert;
8use Wikimedia\Parsoid\DOM\CharacterData;
9use Wikimedia\Parsoid\DOM\Comment;
10use Wikimedia\Parsoid\DOM\Document;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\DOM\DocumentType;
13use Wikimedia\Parsoid\DOM\Node;
14use Wikimedia\Parsoid\DOM\ProcessingInstruction;
15use Wikimedia\Parsoid\DOM\Text;
16
17/**
18 * ImmutableRange has a similar API to the DOM Range class.
19 *
20 * start/endContainer and offsets can be accessed, as can commonAncestorContainer
21 * which is lazy evaluated.
22 *
23 * setStart and setEnd are still available but return a cloned range.
24 *
25 * @property bool $collapsed
26 * @property Node $commonAncestorContainer
27 * @property Node $endContainer
28 * @property int $endOffset
29 * @property Node $startContainer
30 * @property int $startOffset
31 */
32class ImmutableRange {
33    private ?Node $mCommonAncestorContainer = null;
34    private Node $mEndContainer;
35    private int $mEndOffset;
36    private Node $mStartContainer;
37    private int $mStartOffset;
38
39    /**
40     * Find the common ancestor container of two nodes
41     *
42     * @return Node Common ancestor container
43     */
44    private static function findCommonAncestorContainer( Node $a, Node $b ): Node {
45        $ancestorsA = [];
46        $ancestorsB = [];
47
48        $parent = $a;
49        do {
50            // While walking up the parents of $a we found $b is a parent of $a or even identical
51            if ( $parent === $b ) {
52                return $b;
53            }
54            $ancestorsA[] = $parent;
55        } while ( $parent = $parent->parentNode );
56
57        $parent = $b;
58        do {
59            // While walking up the parents of $b we found $a is a parent of $b or even identical
60            if ( $parent === $a ) {
61                return $a;
62            }
63            $ancestorsB[] = $parent;
64        } while ( $parent = $parent->parentNode );
65
66        $node = null;
67        // Start with the top-most (hopefully) identical root node, walk down, skip everything
68        // that's identical, and stop at the first mismatch
69        $indexA = count( $ancestorsA );
70        $indexB = count( $ancestorsB );
71        while ( $indexA-- && $indexB-- && $ancestorsA[$indexA] === $ancestorsB[$indexB] ) {
72            // Remember the last match closest to $a and $b
73            $node = $ancestorsA[$indexA];
74        }
75
76        if ( !$node ) {
77            throw new DOMException( 'Nodes are not in the same document' );
78        }
79
80        return $node;
81    }
82
83    /**
84     * Get the root ancestor of a node
85     */
86    private static function getRootNode( Node $node ): Node {
87        while ( $node->parentNode ) {
88            $node = $node->parentNode;
89            '@phan-var Node $node';
90        }
91
92        return $node;
93    }
94
95    public function __construct(
96        Node $startNode, int $startOffset, Node $endNode, int $endOffset
97    ) {
98        $this->mStartContainer = $startNode;
99        $this->mStartOffset = $startOffset;
100        $this->mEndContainer = $endNode;
101        $this->mEndOffset = $endOffset;
102    }
103
104    /**
105     * @param string $field Field name
106     * @return mixed
107     */
108    public function __get( string $field ) {
109        switch ( $field ) {
110            case 'collapsed':
111                return $this->mStartContainer === $this->mEndContainer &&
112                    $this->mStartOffset === $this->mEndOffset;
113            case 'commonAncestorContainer':
114                if ( !$this->mCommonAncestorContainer ) {
115                    $this->mCommonAncestorContainer =
116                        static::findCommonAncestorContainer( $this->mStartContainer, $this->mEndContainer );
117                }
118                return $this->mCommonAncestorContainer;
119            case 'endContainer':
120                return $this->mEndContainer;
121            case 'endOffset':
122                return $this->mEndOffset;
123            case 'startContainer':
124                return $this->mStartContainer;
125            case 'startOffset':
126                return $this->mStartOffset;
127            default:
128                throw new RuntimeException( 'Invalid property: ' . $field );
129        }
130    }
131
132    /**
133     * Clone range with a new start position
134     */
135    public function setStart( Node $startNode, int $startOffset ): self {
136        return $this->setStartOrEnd( 'start', $startNode, $startOffset );
137    }
138
139    /**
140     * Clone range with a new end position
141     */
142    public function setEnd( Node $endNode, int $endOffset ): self {
143        return $this->setStartOrEnd( 'end', $endNode, $endOffset );
144    }
145
146    /**
147     * Sets the start or end boundary point for the Range.
148     *
149     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
150     * @see https://dom.spec.whatwg.org/#concept-range-bp-set
151     *
152     * @param string $type Which boundary point should be set. Valid values are start or end.
153     * @param Node $node The Node that will become the boundary.
154     * @param int $offset The offset within the given Node that will be the boundary.
155     */
156    private function setStartOrEnd( string $type, Node $node, int $offset ): self {
157        if ( $node instanceof DocumentType ) {
158            throw new DOMException();
159        }
160
161        switch ( $type ) {
162            case 'start':
163                $endContainer = $this->mEndContainer;
164                $endOffset = $this->mEndOffset;
165                if (
166                    self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) ||
167                    $this->computePosition(
168                        $node, $offset, $this->mEndContainer, $this->mEndOffset
169                    ) === 'after'
170                ) {
171                    $endContainer = $node;
172                    $endOffset = $offset;
173                }
174
175                return new self(
176                    $node, $offset, $endContainer, $endOffset
177                );
178
179            case 'end':
180                $startContainer = $this->mStartContainer;
181                $startOffset = $this->mStartOffset;
182                if (
183                    self::getRootNode( $this->mStartContainer ) !== self::getRootNode( $node ) ||
184                    $this->computePosition(
185                        $node, $offset, $this->mStartContainer, $this->mStartOffset
186                    ) === 'before'
187                ) {
188                    $startContainer = $node;
189                    $startOffset = $offset;
190                }
191
192                return new self(
193                    $startContainer, $startOffset, $node, $offset
194                );
195        }
196    }
197
198    /**
199     * Returns true if only a portion of the Node is contained within the Range.
200     *
201     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
202     * @see https://dom.spec.whatwg.org/#partially-contained
203     *
204     * @param Node $node The Node to check against.
205     */
206    private function isPartiallyContainedNode( Node $node ): bool {
207        return CommentUtils::contains( $node, $this->mStartContainer ) xor
208            CommentUtils::contains( $node, $this->mEndContainer );
209    }
210
211    /**
212     * Returns true if the entire Node is within the Range, otherwise false.
213     *
214     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
215     * @see https://dom.spec.whatwg.org/#contained
216     *
217     * @param Node $node The Node to check against.
218     */
219    private function isFullyContainedNode( Node $node ): bool {
220        return static::getRootNode( $node ) === static::getRootNode( $this->mStartContainer )
221            && $this->computePosition( $node, 0, $this->mStartContainer, $this->mStartOffset ) === 'after'
222            && $this->computePosition(
223                // @phan-suppress-next-line PhanUndeclaredProperty
224                $node, $node->length ?? $node->childNodes->length,
225                $this->mEndContainer, $this->mEndOffset
226            ) === 'before';
227    }
228
229    /**
230     * Extracts the content of the Range from the node tree and places it in a
231     * DocumentFragment.
232     *
233     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
234     * @see https://dom.spec.whatwg.org/#dom-range-extractcontents
235     */
236    public function extractContents(): DocumentFragment {
237        $fragment = $this->mStartContainer->ownerDocument->createDocumentFragment();
238
239        if (
240            $this->mStartContainer === $this->mEndContainer
241            && $this->mStartOffset === $this->mEndOffset
242        ) {
243            return $fragment;
244        }
245
246        $originalStartNode = $this->mStartContainer;
247        $originalStartOffset = $this->mStartOffset;
248        $originalEndNode = $this->mEndContainer;
249        $originalEndOffset = $this->mEndOffset;
250
251        if (
252            $originalStartNode === $originalEndNode
253            && ( $originalStartNode instanceof Text
254                || $originalStartNode instanceof ProcessingInstruction
255                || $originalStartNode instanceof Comment )
256        ) {
257            $clone = $originalStartNode->cloneNode();
258            Assert::precondition( $clone instanceof CharacterData, 'TODO' );
259            $clone->data = $originalStartNode->substringData(
260                $originalStartOffset,
261                $originalEndOffset - $originalStartOffset
262            );
263            $fragment->appendChild( $clone );
264            $originalStartNode->replaceData(
265                $originalStartOffset,
266                $originalEndOffset - $originalStartOffset,
267                ''
268            );
269
270            return $fragment;
271        }
272
273        $commonAncestor = $this->commonAncestorContainer;
274        // It should be impossible for common ancestor to be null here since both nodes should be
275        // in the same tree.
276        Assert::precondition( $commonAncestor !== null, 'TODO' );
277        $firstPartiallyContainedChild = null;
278
279        if ( !CommentUtils::contains( $originalStartNode, $originalEndNode ) ) {
280            foreach ( $commonAncestor->childNodes as $node ) {
281                if ( $this->isPartiallyContainedNode( $node ) ) {
282                    $firstPartiallyContainedChild = $node;
283
284                    break;
285                }
286            }
287        }
288
289        $lastPartiallyContainedChild = null;
290
291        if ( !CommentUtils::contains( $originalEndNode, $originalStartNode ) ) {
292            $node = $commonAncestor->lastChild;
293
294            while ( $node ) {
295                if ( $this->isPartiallyContainedNode( $node ) ) {
296                    $lastPartiallyContainedChild = $node;
297
298                    break;
299                }
300
301                $node = $node->previousSibling;
302            }
303        }
304
305        $containedChildren = [];
306
307        foreach ( $commonAncestor->childNodes as $childNode ) {
308            if ( $this->isFullyContainedNode( $childNode ) ) {
309                if ( $childNode instanceof DocumentType ) {
310                    throw new DOMException();
311                }
312
313                $containedChildren[] = $childNode;
314            }
315        }
316
317        if ( CommentUtils::contains( $originalStartNode, $originalEndNode ) ) {
318            $newNode = $originalStartNode;
319            $newOffset = $originalStartOffset;
320        } else {
321            $referenceNode = $originalStartNode;
322            $parent = $referenceNode->parentNode;
323
324            while ( $parent && !CommentUtils::contains( $parent, $originalEndNode ) ) {
325                $referenceNode = $parent;
326                $parent = $referenceNode->parentNode;
327            }
328
329            // Note: If reference node’s parent is null, it would be the root of range, so would be an inclusive
330            // ancestor of original end node, and we could not reach this point.
331            Assert::precondition( $parent !== null, 'TODO' );
332            $newNode = $parent;
333            $newOffset = CommentUtils::childIndexOf( $referenceNode ) + 1;
334        }
335
336        if (
337            $firstPartiallyContainedChild instanceof Text
338            || $firstPartiallyContainedChild instanceof ProcessingInstruction
339            || $firstPartiallyContainedChild instanceof Comment
340        ) {
341            // Note: In this case, first partially contained child is original start node.
342            Assert::precondition( $originalStartNode instanceof CharacterData, 'TODO' );
343            $clone = $originalStartNode->cloneNode();
344            Assert::precondition( $clone instanceof CharacterData, 'TODO' );
345            $clone->data = $originalStartNode->substringData(
346                $originalStartOffset,
347                $originalStartNode->length - $originalStartOffset
348            );
349            $fragment->appendChild( $clone );
350            $originalStartNode->replaceData(
351                $originalStartOffset,
352                $originalStartNode->length - $originalStartOffset,
353                ''
354            );
355        } elseif ( $firstPartiallyContainedChild ) {
356            $clone = $firstPartiallyContainedChild->cloneNode();
357            $fragment->appendChild( $clone );
358            $subrange = clone $this;
359            $subrange->mStartContainer = $originalStartNode;
360            $subrange->mStartOffset = $originalStartOffset;
361            $subrange->mEndContainer = $firstPartiallyContainedChild;
362            $subrange->mEndOffset = count( $firstPartiallyContainedChild->childNodes );
363            $subfragment = $subrange->extractContents();
364            $clone->appendChild( $subfragment );
365        }
366
367        foreach ( $containedChildren as $child ) {
368            $fragment->appendChild( $child );
369        }
370
371        if (
372            $lastPartiallyContainedChild instanceof Text
373            || $lastPartiallyContainedChild instanceof ProcessingInstruction
374            || $lastPartiallyContainedChild instanceof Comment
375        ) {
376            // Note: In this case, last partially contained child is original end node.
377            Assert::precondition( $originalEndNode instanceof CharacterData, 'TODO' );
378            $clone = $originalEndNode->cloneNode();
379            Assert::precondition( $clone instanceof CharacterData, 'TODO' );
380            $clone->data = $originalEndNode->substringData( 0, $originalEndOffset );
381            $fragment->appendChild( $clone );
382            $originalEndNode->replaceData( 0, $originalEndOffset, '' );
383        } elseif ( $lastPartiallyContainedChild ) {
384            $clone = $lastPartiallyContainedChild->cloneNode();
385            $fragment->appendChild( $clone );
386            $subrange = clone $this;
387            $subrange->mStartContainer = $lastPartiallyContainedChild;
388            $subrange->mStartOffset = 0;
389            $subrange->mEndContainer = $originalEndNode;
390            $subrange->mEndOffset = $originalEndOffset;
391            $subfragment = $subrange->extractContents();
392            $clone->appendChild( $subfragment );
393        }
394
395        $this->mStartContainer = $newNode;
396        $this->mStartOffset = $newOffset;
397        $this->mEndContainer = $newNode;
398        $this->mEndOffset = $newOffset;
399
400        return $fragment;
401    }
402
403    /**
404     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
405     * @see https://dom.spec.whatwg.org/#dom-range-clonecontents
406     */
407    public function cloneContents(): DocumentFragment {
408        $ownerDocument = $this->mStartContainer->ownerDocument;
409        $fragment = $ownerDocument->createDocumentFragment();
410
411        if ( $this->mStartContainer === $this->mEndContainer
412            && $this->mStartOffset === $this->mEndOffset
413        ) {
414            return $fragment;
415        }
416
417        $originalStartContainer = $this->mStartContainer;
418        $originalStartOffset = $this->mStartOffset;
419        $originalEndContainer = $this->mEndContainer;
420        $originalEndOffset = $this->mEndOffset;
421
422        if ( $originalStartContainer === $originalEndContainer
423            && ( $originalStartContainer instanceof Text
424                || $originalStartContainer instanceof ProcessingInstruction
425                || $originalStartContainer instanceof Comment )
426        ) {
427            $clone = $originalStartContainer->cloneNode();
428            $clone->nodeValue = $originalStartContainer->substringData(
429                $originalStartOffset,
430                $originalEndOffset - $originalStartOffset
431            );
432            $fragment->appendChild( $clone );
433
434            return $fragment;
435        }
436
437        $commonAncestor = static::findCommonAncestorContainer(
438            $originalStartContainer,
439            $originalEndContainer
440        );
441        $firstPartiallyContainedChild = null;
442
443        if ( !CommentUtils::contains( $originalStartContainer, $originalEndContainer ) ) {
444            foreach ( $commonAncestor->childNodes as $node ) {
445                if ( $this->isPartiallyContainedNode( $node ) ) {
446                    $firstPartiallyContainedChild = $node;
447                    break;
448                }
449            }
450        }
451
452        $lastPartiallyContainedChild = null;
453
454        // Upstream uses lastChild then iterates over previousSibling, however this
455        // is much slower that copying all the nodes to an array, at least when using
456        // a native DOMNode, presumably because previousSibling is lazy-evaluated.
457        if ( !CommentUtils::contains( $originalEndContainer, $originalStartContainer ) ) {
458            $childNodes = iterator_to_array( $commonAncestor->childNodes );
459
460            foreach ( array_reverse( $childNodes ) as $node ) {
461                if ( $this->isPartiallyContainedNode( $node ) ) {
462                    $lastPartiallyContainedChild = $node;
463                    break;
464                }
465            }
466        }
467
468        $containedChildrenStart = null;
469        $containedChildrenEnd = null;
470
471        $child = $firstPartiallyContainedChild ?: $commonAncestor->firstChild;
472        for ( ; $child; $child = $child->nextSibling ) {
473            if ( $this->isFullyContainedNode( $child ) ) {
474                $containedChildrenStart = $child;
475                break;
476            }
477        }
478
479        $child = $lastPartiallyContainedChild ?: $commonAncestor->lastChild;
480        for ( ; $child !== $containedChildrenStart; $child = $child->previousSibling ) {
481            if ( $this->isFullyContainedNode( $child ) ) {
482                $containedChildrenEnd = $child;
483                break;
484            }
485        }
486        if ( !$containedChildrenEnd ) {
487            $containedChildrenEnd = $containedChildrenStart;
488        }
489
490        // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly
491        for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) {
492            if ( $child instanceof DocumentType ) {
493                throw new DOMException();
494            }
495        }
496
497        if ( $firstPartiallyContainedChild instanceof Text
498            || $firstPartiallyContainedChild instanceof ProcessingInstruction
499            || $firstPartiallyContainedChild instanceof Comment
500        ) {
501            $clone = $originalStartContainer->cloneNode();
502            Assert::precondition(
503                $firstPartiallyContainedChild === $originalStartContainer,
504                'Only possible when the node is the startContainer'
505            );
506            $clone->nodeValue = $firstPartiallyContainedChild->substringData(
507                $originalStartOffset,
508                $firstPartiallyContainedChild->length - $originalStartOffset
509            );
510            $fragment->appendChild( $clone );
511        } elseif ( $firstPartiallyContainedChild ) {
512            $clone = $firstPartiallyContainedChild->cloneNode();
513            $fragment->appendChild( $clone );
514            $subrange = new self(
515                $originalStartContainer, $originalStartOffset,
516                $firstPartiallyContainedChild,
517                // @phan-suppress-next-line PhanUndeclaredProperty
518                $firstPartiallyContainedChild->length ?? $firstPartiallyContainedChild->childNodes->length
519            );
520            $subfragment = $subrange->cloneContents();
521            if ( $subfragment->hasChildNodes() ) {
522                $clone->appendChild( $subfragment );
523            }
524        }
525
526        // $containedChildrenStart and $containedChildrenEnd may be null here, but this loop still works correctly
527        for ( $child = $containedChildrenStart; $child !== $containedChildrenEnd; $child = $child->nextSibling ) {
528            $clone = $child->cloneNode( true );
529            $fragment->appendChild( $clone );
530        }
531        // If not null, this node wasn't processed by the loop
532        if ( $containedChildrenEnd ) {
533            $clone = $containedChildrenEnd->cloneNode( true );
534            $fragment->appendChild( $clone );
535        }
536
537        if ( $lastPartiallyContainedChild instanceof Text
538            || $lastPartiallyContainedChild instanceof ProcessingInstruction
539            || $lastPartiallyContainedChild instanceof Comment
540        ) {
541            Assert::precondition(
542                $lastPartiallyContainedChild === $originalEndContainer,
543                'Only possible when the node is the endContainer'
544            );
545            $clone = $lastPartiallyContainedChild->cloneNode();
546            $clone->nodeValue = $lastPartiallyContainedChild->substringData(
547                0,
548                $originalEndOffset
549            );
550            $fragment->appendChild( $clone );
551        } elseif ( $lastPartiallyContainedChild ) {
552            $clone = $lastPartiallyContainedChild->cloneNode();
553            $fragment->appendChild( $clone );
554            $subrange = new self(
555                $lastPartiallyContainedChild, 0,
556                $originalEndContainer, $originalEndOffset
557            );
558            $subfragment = $subrange->cloneContents();
559            if ( $subfragment->hasChildNodes() ) {
560                $clone->appendChild( $subfragment );
561            }
562        }
563
564        return $fragment;
565    }
566
567    /**
568     * Inserts a new Node into at the start of the Range.
569     *
570     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
571     *
572     * @see https://dom.spec.whatwg.org/#dom-range-insertnode
573     *
574     * @param Node $node The Node to be inserted.
575     */
576    public function insertNode( Node $node ): void {
577        if ( ( $this->mStartContainer instanceof ProcessingInstruction
578                || $this->mStartContainer instanceof Comment )
579            || ( $this->mStartContainer instanceof Text
580                && $this->mStartContainer->parentNode === null )
581        ) {
582            throw new DOMException();
583        }
584
585        $referenceNode = null;
586
587        if ( $this->mStartContainer instanceof Text ) {
588            $referenceNode = $this->mStartContainer;
589        } else {
590            $referenceNode = $this
591                ->mStartContainer
592                ->childNodes
593                ->item( $this->mStartOffset );
594        }
595
596        $parent = !$referenceNode
597            ? $this->mStartContainer
598            : $referenceNode->parentNode;
599        // TODO: Restore this validation check?
600        // $parent->ensurePreinsertionValidity( $node, $referenceNode );
601
602        if ( $this->mStartContainer instanceof Text ) {
603            $referenceNode = $this->mStartContainer->splitText( $this->mStartOffset );
604        }
605
606        if ( $node === $referenceNode ) {
607            $referenceNode = $referenceNode->nextSibling;
608        }
609
610        if ( $node->parentNode ) {
611            $node->parentNode->removeChild( $node );
612        }
613
614        // TODO: Restore this validation check?
615        // $parent->preinsertNode( $node, $referenceNode );
616
617        // $referenceNode may be null, this is okay
618        $parent->insertBefore( $node, $referenceNode );
619    }
620
621    /**
622     * Wraps the content of Range in a new Node and inserts it in to the Document.
623     *
624     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
625     *
626     * @see https://dom.spec.whatwg.org/#dom-range-surroundcontents
627     *
628     * @param Node $newParent New parent node for contents
629     */
630    public function surroundContents( Node $newParent ): void {
631        $commonAncestor = $this->commonAncestorContainer;
632
633        if ( $commonAncestor ) {
634            $tw = new TreeWalker( $commonAncestor );
635            $node = $tw->nextNode();
636
637            while ( $node ) {
638                if ( !$node instanceof Text && $this->isPartiallyContainedNode( $node ) ) {
639                    throw new DOMException();
640                }
641
642                $node = $tw->nextNode();
643            }
644        }
645
646        if (
647            $newParent instanceof Document
648            || $newParent instanceof DocumentType
649            || $newParent instanceof DocumentFragment
650        ) {
651            throw new DOMException();
652        }
653
654        $fragment = $this->extractContents();
655
656        while ( $newParent->firstChild ) {
657            $newParent->removeChild( $newParent->firstChild );
658        }
659
660        $this->insertNode( $newParent );
661        $newParent->appendChild( $fragment );
662        // TODO: Return new range?
663    }
664
665    /**
666     * Compares the position of two boundary points.
667     *
668     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
669     * @internal
670     *
671     * @see https://dom.spec.whatwg.org/#concept-range-bp-position
672     *
673     * @return string 'before'|'after'|'equal'
674     */
675    private function computePosition(
676        Node $nodeA, int $offsetA, Node $nodeB, int $offsetB
677    ): string {
678        // 1. Assert: nodeA and nodeB have the same root.
679        // Removed, not necessary for our usage
680
681        // 2. If nodeA is nodeB, then return equal if offsetA is offsetB, before if offsetA is less than offsetB, and
682        // after if offsetA is greater than offsetB.
683        if ( $nodeA === $nodeB ) {
684            if ( $offsetA === $offsetB ) {
685                return 'equal';
686            } elseif ( $offsetA < $offsetB ) {
687                return 'before';
688            } else {
689                return 'after';
690            }
691        }
692
693        $commonAncestor = $this->findCommonAncestorContainer( $nodeB, $nodeA );
694        if ( $commonAncestor === $nodeA ) {
695            $AFollowsB = false;
696        } elseif ( $commonAncestor === $nodeB ) {
697            $AFollowsB = true;
698        } else {
699            // A was not found inside B. Traverse both A & B up to the nodes
700            // before their common ancestor, then see if A is in the nextSibling
701            // chain of B.
702            $b = $nodeB;
703            while ( $b->parentNode !== $commonAncestor ) {
704                $b = $b->parentNode;
705            }
706            $a = $nodeA;
707            while ( $a->parentNode !== $commonAncestor ) {
708                $a = $a->parentNode;
709            }
710            $AFollowsB = false;
711            while ( $b ) {
712                if ( $a === $b ) {
713                    $AFollowsB = true;
714                    break;
715                }
716                $b = $b->nextSibling;
717            }
718        }
719
720        if ( $AFollowsB ) {
721            // Swap variables
722            [ $nodeB, $nodeA ] = [ $nodeA, $nodeB ];
723            [ $offsetB, $offsetA ] = [ $offsetA, $offsetB ];
724        }
725
726        $ancestor = $nodeB->parentNode;
727
728        while ( $ancestor ) {
729            if ( $ancestor === $nodeA ) {
730                break;
731            }
732
733            $ancestor = $ancestor->parentNode;
734        }
735
736        if ( $ancestor ) {
737            $child = $nodeB;
738
739            while ( $child ) {
740                if ( $child->parentNode === $nodeA ) {
741                    break;
742                }
743
744                $child = $child->parentNode;
745            }
746
747            // Phan complains that $child may be null here, but that can't happen, because at this point
748            // we know that $nodeA is an ancestor of $nodeB, so the loop above will stop before the root.
749            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
750            if ( CommentUtils::childIndexOf( $child ) < $offsetA ) {
751                return $AFollowsB ? 'before' : 'after';
752            }
753        }
754
755        return $AFollowsB ? 'after' : 'before';
756    }
757
758    public const START_TO_START = 0;
759    public const START_TO_END = 1;
760    public const END_TO_END = 2;
761    public const END_TO_START = 3;
762
763    /**
764     * Compares the boundary points of this Range with another Range.
765     *
766     * Ported from https://github.com/TRowbotham/PHPDOM (MIT)
767     *
768     * @see https://dom.spec.whatwg.org/#dom-range-compareboundarypoints
769     *
770     * @param int $how One of ImmutableRange::END_TO_END, ImmutableRange::END_TO_START,
771     *     ImmutableRange::START_TO_END, ImmutableRange::START_TO_START
772     * @param ImmutableRange $sourceRange A Range whose boundary points are to be compared.
773     * @return int -1, 0, or 1
774     */
775    public function compareBoundaryPoints( int $how, self $sourceRange ): int {
776        if ( static::getRootNode( $this->mStartContainer ) !== static::getRootNode( $sourceRange->startContainer ) ) {
777            throw new DOMException();
778        }
779
780        switch ( $how ) {
781            case static::START_TO_START:
782                $thisPoint = [ $this->mStartContainer, $this->mStartOffset ];
783                $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ];
784                break;
785
786            case static::START_TO_END:
787                $thisPoint = [ $this->mEndContainer, $this->mEndOffset ];
788                $otherPoint = [ $sourceRange->startContainer, $sourceRange->startOffset ];
789                break;
790
791            case static::END_TO_END:
792                $thisPoint = [ $this->mEndContainer, $this->mEndOffset ];
793                $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ];
794                break;
795
796            case static::END_TO_START:
797                $thisPoint = [ $this->mStartContainer, $this->mStartOffset ];
798                $otherPoint = [ $sourceRange->endContainer, $sourceRange->endOffset ];
799                break;
800
801            default:
802                throw new DOMException();
803        }
804
805        switch ( $this->computePosition( ...$thisPoint, ...$otherPoint ) ) {
806            case 'before':
807                return -1;
808
809            case 'equal':
810                return 0;
811
812            case 'after':
813                return 1;
814
815            default:
816                throw new DOMException();
817        }
818    }
819}