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