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