Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.16% covered (success)
95.16%
59 / 62
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Node
95.16% covered (success)
95.16%
59 / 62
72.73% covered (warning)
72.73%
8 / 11
31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nodeType
n/a
0 / 0
n/a
0 / 0
0
 nodeName
n/a
0 / 0
n/a
0 / 0
0
 childNodes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasChildNodes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 textContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setTextContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendChild
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insertBefore
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 removeChild
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 replaceChild
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 cloneNode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 shallowClone
n/a
0 / 0
n/a
0 / 0
0
 isAncestorOf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html\Dom;
6
7/**
8 * Abstract DOM node. Concrete subclasses: Document, DocumentFragment,
9 * DocumentType, Element, Text, Comment.
10 *
11 * Mutation methods (appendChild, insertBefore, removeChild, replaceChild)
12 * maintain the parent / sibling pointer invariants. They're public — per the
13 * Q1 contract decision, post-parse mutation is a supported feature (server-
14 * side rewriting, sanitization, etc.) rather than an internal-only operation.
15 */
16abstract class Node
17{
18    /** Set in constructor; never reassigned. */
19    protected readonly ?Document $documentRef;
20
21    public ?Node $parentNode { get => $this->parentRef; }
22    private ?Node $parentRef = null;
23
24    public ?Node $previousSibling { get => $this->prevRef; }
25    private ?Node $prevRef = null;
26
27    public ?Node $nextSibling { get => $this->nextRef; }
28    private ?Node $nextRef = null;
29
30    public ?Node $firstChild { get => $this->firstRef; }
31    private ?Node $firstRef = null;
32
33    public ?Node $lastChild { get => $this->lastRef; }
34    private ?Node $lastRef = null;
35
36    public ?Element $parentElement {
37        get => $this->parentRef instanceof Element ? $this->parentRef : null;
38    }
39
40    public Document $ownerDocument {
41        get => $this instanceof Document ? $this : ($this->documentRef ?? throw new \LogicException(
42            sprintf('Node of type %s has no owner document', static::class),
43        ));
44    }
45
46    public function __construct(?Document $ownerDocument)
47    {
48        $this->documentRef = $ownerDocument;
49    }
50
51    abstract public function nodeType(): NodeType;
52
53    /**
54     * Per WHATWG: uppercase tag name for HTML elements, "#text", "#comment",
55     * "#document", "#document-fragment", or the doctype name.
56     */
57    abstract public function nodeName(): string;
58
59    /** @return list<Node> snapshot of direct children at the time of the call */
60    public function childNodes(): array
61    {
62        $out = [];
63        for ($n = $this->firstRef; $n !== null; $n = $n->nextRef) {
64            $out[] = $n;
65        }
66        return $out;
67    }
68
69    public function hasChildNodes(): bool
70    {
71        return $this->firstRef !== null;
72    }
73
74    /**
75     * Concatenation of descendant Text node data. Per WHATWG: for Document,
76     * DocumentType, Comment, ProcessingInstruction this is implementation-
77     * defined; for our purposes, Document/DocumentFragment/Element return the
78     * concatenation, Text returns its data, Comment returns its data,
79     * DocumentType returns ''.
80     */
81    public function textContent(): string
82    {
83        $out = '';
84        for ($n = $this->firstRef; $n !== null; $n = $n->nextRef) {
85            $out .= $n->textContent();
86        }
87        return $out;
88    }
89
90    /**
91     * Replace all children with a single Text node containing $text.
92     * Empty string clears all children with no replacement.
93     */
94    public function setTextContent(string $text): void
95    {
96        while ($this->firstRef !== null) {
97            $this->removeChild($this->firstRef);
98        }
99        if ($text !== '') {
100            $this->appendChild(new Text($this->ownerDocument, $text));
101        }
102    }
103
104    public function appendChild(Node $child): Node
105    {
106        return $this->insertBefore($child, null);
107    }
108
109    public function insertBefore(Node $child, ?Node $reference): Node
110    {
111        if ($reference !== null && $reference->parentRef !== $this) {
112            throw new \InvalidArgumentException('Reference node is not a child of this parent');
113        }
114        if ($child === $this || $child->isAncestorOf($this)) {
115            throw new \InvalidArgumentException('Cannot insert a node into one of its own descendants');
116        }
117
118        // Detach from previous parent if any.
119        if ($child->parentRef !== null) {
120            $child->parentRef->removeChild($child);
121        }
122
123        $child->parentRef = $this;
124
125        if ($reference === null) {
126            // Append.
127            $child->prevRef = $this->lastRef;
128            $child->nextRef = null;
129            if ($this->lastRef !== null) {
130                $this->lastRef->nextRef = $child;
131            } else {
132                $this->firstRef = $child;
133            }
134            $this->lastRef = $child;
135        } else {
136            // Insert before reference.
137            $child->prevRef = $reference->prevRef;
138            $child->nextRef = $reference;
139            if ($reference->prevRef !== null) {
140                $reference->prevRef->nextRef = $child;
141            } else {
142                $this->firstRef = $child;
143            }
144            $reference->prevRef = $child;
145        }
146
147        return $child;
148    }
149
150    public function removeChild(Node $child): Node
151    {
152        if ($child->parentRef !== $this) {
153            throw new \InvalidArgumentException('Node is not a child of this parent');
154        }
155        if ($child->prevRef !== null) {
156            $child->prevRef->nextRef = $child->nextRef;
157        } else {
158            $this->firstRef = $child->nextRef;
159        }
160        if ($child->nextRef !== null) {
161            $child->nextRef->prevRef = $child->prevRef;
162        } else {
163            $this->lastRef = $child->prevRef;
164        }
165        $child->parentRef = null;
166        $child->prevRef = null;
167        $child->nextRef = null;
168        return $child;
169    }
170
171    public function replaceChild(Node $newChild, Node $oldChild): Node
172    {
173        if ($oldChild->parentRef !== $this) {
174            throw new \InvalidArgumentException('Node to be replaced is not a child of this parent');
175        }
176        $this->insertBefore($newChild, $oldChild);
177        $this->removeChild($oldChild);
178        return $oldChild;
179    }
180
181    /**
182     * Deep- or shallow-clone the node. Per Q1, mutation post-parse is a
183     * feature; deep clones produce an independent subtree with no shared
184     * parent linkage.
185     */
186    public function cloneNode(bool $deep = true): static
187    {
188        $copy = $this->shallowClone();
189        if ($deep) {
190            for ($n = $this->firstRef; $n !== null; $n = $n->nextRef) {
191                $copy->appendChild($n->cloneNode(true));
192            }
193        }
194        /** @var static $copy */
195        return $copy;
196    }
197
198    /** Subclass-specific construction of a child-less copy. */
199    abstract protected function shallowClone(): static;
200
201    protected function isAncestorOf(Node $other): bool
202    {
203        for ($n = $other->parentRef; $n !== null; $n = $n->parentRef) {
204            if ($n === $this) {
205                return true;
206            }
207        }
208        return false;
209    }
210}