Passed
Push — master ( 8e4c14...93bf08 )
by smiley
06:58
created

Document::recursivelyFind()   B

Complexity

Conditions 7
Paths 3

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 7

Importance

Changes 0
Metric Value
eloc 8
c 0
b 0
f 0
dl 0
loc 18
ccs 7
cts 7
cp 1
rs 8.8333
cc 7
nc 3
nop 5
crap 7
1
<?php
2
/**
3
 * Class Document
4
 *
5
 * @filesource   Document.php
6
 * @created      05.05.2017
7
 * @package      chillerlan\PrototypeDOM
8
 * @author       Smiley <[email protected]>
9
 * @copyright    2017 Smiley
10
 * @license      MIT
11
 */
12
13
namespace chillerlan\PrototypeDOM;
14
15
use chillerlan\PrototypeDOM\Node\{
16
	Attr, CdataSection, CharacterData, Comment, DocumentFragment, DocumentType, Element, Entity,
17
	EntityReference, Node, Notation, ProcessingInstruction, PrototypeHTMLElement, PrototypeNode, Text,
18
};
19
use DOMDocument, DOMException, DOMNode, DOMNodeList, DOMXPath;
20
use Symfony\Component\CssSelector\CssSelectorConverter;
21
22
use function count, in_array, is_file, is_iterable, is_readable, is_string;
23
24
use const LIBXML_COMPACT, LIBXML_HTML_NODEFDTD, LIBXML_HTML_NOIMPLIED, LIBXML_NOERROR, LIBXML_NONET, XML_ELEMENT_NODE;
25
26
/**
27
 *
28
 */
29
class Document extends DOMDocument{
30
31
	protected const NODE_CLASSES = [
32
		'DOMAttr'                  => Attr::class,
33
		'DOMCdataSection'          => CdataSection::class,
34
		'DOMCharacterData'         => CharacterData::class,
35
		'DOMComment'               => Comment::class,
36
		'DOMDocumentFragment'      => DocumentFragment::class,
37
		'DOMDocumentType'          => DocumentType::class,
38
		'DOMElement'               => Element::class,
39
		'DOMEntity'                => Entity::class,
40
		'DOMEntityReference'       => EntityReference::class,
41
		'DOMNode'                  => Node::class,
42
		'DOMNotation'              => Notation::class,
43
		'DOMProcessingInstruction' => ProcessingInstruction::class,
44 42
		'DOMText'                  => Text::class,
45 42
	];
46
47 42
	protected const LOAD_OPTIONS = LIBXML_COMPACT | LIBXML_NONET | LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED | LIBXML_NOERROR;
48 42
49
	protected CssSelectorConverter $cssSelectorConverter;
50
51 42
	/**
52 2
	 * Document constructor.
53
	 */
54
	public function __construct($content = null, bool $xml = null, string $version = null, string $encoding = null){
55 42
		parent::__construct($version ?? '1.0', $encoding ?? 'UTF-8');
56
57
		foreach($this::NODE_CLASSES as $baseClass => $extendedClass){
58
			$this->registerNodeClass($baseClass, $extendedClass);
59
		}
60
61
		if($content !== null){
62 1
			$this->loadDocument($content, $xml);
63 1
		}
64
65
		$this->cssSelectorConverter = new CssSelectorConverter;
66 1
	}
67 1
68
	/*********
69 1
	 * magic *
70 1
	 *********/
71
72
	/**
73 1
	 * @return string|null
74 1
	 */
75
	public function getTitle():?string{
76 1
		return $this->select(['head > title'])->offsetGet(0)->nodeValue ?? null;
77
	}
78
79
	/**
80 1
	 * @throws \DOMException
81
	 */
82 1
	public function setTitle(string $title):void{
83
		$currentTitle = $this->select(['head > title'])->offsetGet(0);
84
85
		if($currentTitle instanceof Element){
86
			$currentTitle->update($title);
87
88
			return;
89
		}
90 2
91
		$head         = $this->select(['head'])->offsetGet(0);
92
		$currentTitle = $this->newElement('title')->update($title);
93 2
94 1
		if(!$head){
95 1
			$html = $this->select(['html'])->first();
96
97
			if(!$html instanceof PrototypeHTMLElement){
98
				throw new DOMException('html header missing');
99
			}
100 1
101 1
			$head = $this->newElement('head');
102
			$html->insert_top($head);
103 1
		}
104 1
105 1
		$head->insert($currentTitle);
0 ignored issues
show
Bug introduced by
The method insert() does not exist on DOMNode. Did you maybe mean insertBefore()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

105
		$head->/** @scrutinizer ignore-call */ 
106
         insert($currentTitle);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
106
	}
107 1
108
	/**
109
	 * @throws \DOMException
110
	 */
111
112
	public function loadDocument($content, bool $xml = null):Document{
113
114
		if($content instanceof NodeList){
115
			return $this->insertNodeList($content);
116 8
		}
117
118
		if($content instanceof DOMNodeList){
119 8
			return $this->insertNodeList(new NodeList($content));
120 8
		}
121 7
122 7
		if(!is_string($content)){
123 7
			throw new DOMException('invalid document content');
124
		}
125
126
		if(is_file($content) && is_readable($content)){
127
			return $this->loadDocumentFile($content, $xml);
128
		}
129
130
		return $this->loadDocumentString($content, $xml);
131
	}
132
133
	/**
134
	 * @throws \DOMException
135 8
	 */
136 8
	public function loadDocumentFile(string $file, bool $xml = null, int $options = null):Document{
137 8
		$options = $options ?? $this::LOAD_OPTIONS;
138
139 8
		$result = $xml === true
140
			? $this->load($file, $options)
141
			: $this->loadHTMLFile($file, $options);
142
143
		if($result === false){
144
			throw new DOMException('failed to load document from file: '.$file); // @codeCoverageIgnore
145
		}
146
147 3
		return $this;
148 3
	}
149
150 3
	/**
151 3
	 * @throws \DOMException
152
	 */
153
	public function loadDocumentString(string $documentSource, bool $xml = null, int $options = null):Document{
154 3
		$options = $options ?? $this::LOAD_OPTIONS;
155
156
		$result = $xml === true
157
			? $this->loadXML($documentSource, $options)
158
			: $this->loadHTML($documentSource, $options);
159
160
		if($result === false){
161
			throw new DOMException('failed to load document from string'); // @codeCoverageIgnore
162
		}
163
164
		return $this;
165
	}
166
167
	/**
168 41
	 * @throws \DOMException
169 41
	 */
170
	public function toNodeList($content):NodeList{
171
172
		if($content instanceof NodeList){
173
			return $content;
174
		}
175
176
		if($content instanceof DOMNode || $content instanceof PrototypeNode){
177
			return new NodeList([$content]);
178 41
		}
179 41
180
		if($content instanceof DOMNodeList || is_iterable($content)){
181
			return new NodeList($content);
182
		}
183
184
		if(is_string($content)){
185
			$document = new self;
186
			$document->loadHTML('<html lang="en"><body id="-import-content">'.$content.'</body></html>');
187
188
			return $document->toNodeList($document->getElementById('-import-content')->childNodes);
189 41
		}
190 41
191
		throw new DOMException('invalid content');
192
	}
193
194
	/***********
195
	 * generic *
196
	 ***********/
197
198
	/**
199
	 *
200 1
	 */
201 1
	public function selector2xpath(string $selector, string $axis = null):string{
202
		return $this->cssSelectorConverter->toXPath($selector, $axis ?? '//');
203 1
	}
204
205 1
	/**
206 1
	 *
207
	 */
208
	public function query(string $xpath, DOMNode $contextNode = null):?NodeList{
209
		$q = (new DOMXPath($this))->query($xpath, $contextNode);
210
211 1
		return $q !== false ? new NodeList($q) : null;
212
	}
213
214
	/**
215
	 *
216
	 */
217
	public function querySelectorAll(string $selector, DOMNode $contextNode = null, string $axis = null):?NodeList{
218
		return $this->query($this->cssSelectorConverter->toXPath($selector, $axis ?? 'descendant-or-self::'), $contextNode);
219 2
	}
220
221
	/**
222 2
	 *
223 2
	 */
224
	public function removeElementsBySelector(array $selectors, DOMNode $contextNode = null, string $axis = null):Document{
225
		$nodes = $this->select($selectors, $contextNode, $axis ?? 'descendant-or-self::');
226 2
227
		if(count($nodes) > 0){
228
229
			foreach($nodes as $node){
230
				$node->remove();
0 ignored issues
show
Bug introduced by
The method remove() does not exist on DOMNode. Did you maybe mean removeChild()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

230
				$node->/** @scrutinizer ignore-call */ 
231
           remove();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method remove() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

230
				$node->/** @scrutinizer ignore-call */ 
231
           remove();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
231
			}
232
233
		}
234
235
		return $this;
236
	}
237
238
	/**
239
	 *
240
	 */
241 8
	public function insertNodeList(NodeList $nodeList):Document{
242 8
243 2
		foreach($nodeList as $node){
244 8
			$this->appendChild($this->importNode($node->cloneNode(true), true));
245
		}
246
247
		return $this;
248
	}
249
250
	/*************
251
	 * prototype *
252
	 *************/
253
254
	/**
255
	 * @link http://api.prototypejs.org/dom/Element/inspect/
256
	 */
257 41
	public function inspect(DOMNode $context = null, bool $xml = null):string{
258
259 41
		if($xml === true){
260 41
			return $this->saveXML($context);
261
		}
262
263 41
		return $this->saveHTML($context);
264 2
	}
265
266
	/**
267 41
	 * @link http://api.prototypejs.org/dom/Element/select/
268
	 * @see https://secure.php.net/manual/dom.constants.php
269 41
	 */
270
	public function select(array $selectors = null, DOMNode $contextNode = null, string $axis = null, int $nodeType = null):NodeList{
271 41
		$nodeType = $nodeType ?? XML_ELEMENT_NODE;
272
		$elements = new NodeList;
273 41
274 41
		foreach($selectors ?? ['*'] as $selector){
275
276
			if(!is_string($selector)){
277
				continue;
278
			}
279
280
			foreach($this->querySelectorAll($selector, $contextNode, $axis ?? 'descendant-or-self::') as $element){
281 41
282
				if($element->nodeType === $nodeType){
283
					$elements[] = $element;
284
				}
285
286
			}
287
288
		}
289
290
		return $elements;
291
	}
292
293
	/**
294 3
	 * @see http://api.prototypejs.org/dom/Element/recursivelyCollect/
295 3
	 * @see https://secure.php.net/manual/dom.constants.php
296
	 */
297 3
	public function recursivelyCollect(DOMNode $element, string $property, int $maxLength = null, int $nodeType = null):NodeList{
298
		$nodeType  = $nodeType ?? XML_ELEMENT_NODE;
299 3
		$maxLength = $maxLength ?? -1;
300
		$nodes     = new NodeList;
301 3
302 3
		if(in_array($property, ['parentNode', 'previousSibling', 'nextSibling'])){
303
304
			while($element = $element->{$property}){
305 3
306 2
				if($element->nodeType === $nodeType){
307
					$nodes[] = $element;
308
				}
309
310
				if(count($nodes) === $maxLength){
311
					break;
312
				}
313 3
314
			}
315
316
		}
317
318
		return $nodes;
319
	}
320
321
	/**
322
	 * @see https://secure.php.net/manual/dom.constants.php
323
	 */
324
	public function recursivelyFind(PrototypeNode $element, string $property = null, string $selector = null, int $index = null, int $nodeType = null):?DOMNode{
325 4
		$nodeType = $nodeType ?? XML_ELEMENT_NODE;
326
		$index    = $index ?? 0;
327 4
328
		if(in_array($property, ['parentNode', 'previousSibling', 'nextSibling'])){
329
330 4
			while($element = $element->{$property}){
331
332 4
				if($element->nodeType !== $nodeType || $selector !== null && !$element->match($selector) || --$index >= 0){
333 4
					continue;
334
				}
335
336 4
				return $element;
337
			}
338
339
		}
340
341 4
		return null;
342
	}
343
344
	/**
345
	 * @link http://api.prototypejs.org/dom/Element/match/
346
	 */
347
	public function match(DOMNode $element, string $selector):bool{
348
349
		foreach($this->select([$selector]) as $match){
350
351
			if($element->isSameNode($match)){
0 ignored issues
show
Bug introduced by
It seems like $match can also be of type null; however, parameter $node of DOMNode::isSameNode() does only seem to accept DOMNode, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

351
			if($element->isSameNode(/** @scrutinizer ignore-type */ $match)){
Loading history...
352 4
				return true;
353
			}
354 4
355
		}
356 4
357 4
		return false;
358
	}
359
360
	/**
361
	 * @link http://api.prototypejs.org/dom/Element/new/
362 4
	 */
363
	public function newElement(string $tag, array $attributes = null):PrototypeHTMLElement{
364
		/** @var \chillerlan\PrototypeDOM\Node\PrototypeHTMLElement $element */
365
		$element = $this->createElement($tag);
366
367
		if($attributes !== null){
368
			$element->setAttributes($attributes);
0 ignored issues
show
Bug introduced by
The method setAttributes() does not exist on chillerlan\PrototypeDOM\Node\PrototypeHTMLElement. Since it exists in all sub-types, consider adding an abstract or default implementation to chillerlan\PrototypeDOM\Node\PrototypeHTMLElement. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

368
			$element->/** @scrutinizer ignore-call */ 
369
             setAttributes($attributes);
Loading history...
369
		}
370
371
		return $element;
372
	}
373 6
374
	/**
375 6
	 * @inheritDoc
376
	 *
377 6
	 * @return \chillerlan\PrototypeDOM\Node\PrototypeHTMLElement|\DOMNode|null
378 4
	 * @throws \DOMException
379
	 */
380
	public function getElementById($elementId):?DOMNode{
381 6
382
		if(!is_string($elementId)){
383
			throw new DOMException('invalid element id');
384
		}
385
386
		return $this->select(['#'.$elementId])[0] ?? null;
387
	}
388
389
}
390