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