1 | <?php declare(strict_types=1); |
||||||
2 | |||||||
3 | namespace DOMWrap\Traits; |
||||||
4 | |||||||
5 | use DOMWrap\{ |
||||||
6 | Element, |
||||||
7 | NodeList |
||||||
8 | }; |
||||||
9 | use Symfony\Component\CssSelector\CssSelectorConverter; |
||||||
10 | |||||||
11 | /** |
||||||
12 | * Traversal Trait |
||||||
13 | * |
||||||
14 | * @package DOMWrap\Traits |
||||||
15 | * @license http://opensource.org/licenses/BSD-3-Clause BSD 3 Clause |
||||||
16 | */ |
||||||
17 | trait TraversalTrait |
||||||
18 | { |
||||||
19 | /** |
||||||
20 | * @param iterable $nodes |
||||||
21 | * |
||||||
22 | * @return NodeList |
||||||
23 | */ |
||||||
24 | 138 | public function newNodeList(iterable $nodes = null): NodeList { |
|||||
25 | |||||||
26 | 138 | if (!is_iterable($nodes)) { |
|||||
27 | 134 | if (!is_null($nodes)) { |
|||||
28 | $nodes = [$nodes]; |
||||||
29 | } else { |
||||||
30 | 134 | $nodes = []; |
|||||
31 | } |
||||||
32 | } |
||||||
33 | |||||||
34 | 138 | return new NodeList($this->document(), $nodes); |
|||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||||
35 | } |
||||||
36 | |||||||
37 | /** |
||||||
38 | * @param string $selector |
||||||
39 | * @param string $prefix |
||||||
40 | * |
||||||
41 | * @return NodeList |
||||||
42 | */ |
||||||
43 | 130 | public function find(string $selector, string $prefix = 'descendant::'): NodeList { |
|||||
44 | 130 | $converter = new CssSelectorConverter(); |
|||||
45 | |||||||
46 | 130 | return $this->findXPath($converter->toXPath($selector, $prefix)); |
|||||
47 | } |
||||||
48 | |||||||
49 | /** |
||||||
50 | * @param string $xpath |
||||||
51 | * |
||||||
52 | * @return NodeList |
||||||
53 | */ |
||||||
54 | 130 | public function findXPath(string $xpath): NodeList { |
|||||
55 | 130 | $results = $this->newNodeList(); |
|||||
56 | |||||||
57 | 130 | if ($this->isRemoved()) { |
|||||
0 ignored issues
–
show
It seems like
isRemoved() must be provided by classes using this trait. How about adding it as abstract method to this trait?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
58 | return $results; |
||||||
59 | } |
||||||
60 | |||||||
61 | 130 | $domxpath = new \DOMXPath($this->document()); |
|||||
62 | |||||||
63 | 130 | foreach ($this->collection() as $node) { |
|||||
0 ignored issues
–
show
It seems like
collection() must be provided by classes using this trait. How about adding it as abstract method to this trait?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
64 | 130 | $results = $results->merge( |
|||||
65 | 130 | $node->newNodeList($domxpath->query($xpath, $node)) |
|||||
66 | ); |
||||||
67 | } |
||||||
68 | |||||||
69 | 130 | return $results; |
|||||
70 | } |
||||||
71 | |||||||
72 | /** |
||||||
73 | * @param string|NodeList|\DOMNode|callable $input |
||||||
74 | * @param bool $matchType |
||||||
75 | * |
||||||
76 | * @return NodeList |
||||||
77 | */ |
||||||
78 | 18 | protected function getNodesMatchingInput($input, bool $matchType = true): NodeList { |
|||||
79 | 18 | if ($input instanceof NodeList || $input instanceof \DOMNode) { |
|||||
80 | 8 | $inputNodes = $this->inputAsNodeList($input, false); |
|||||
0 ignored issues
–
show
It seems like
inputAsNodeList() must be provided by classes using this trait. How about adding it as abstract method to this trait?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
81 | |||||||
82 | 8 | $fn = function($node) use ($inputNodes) { |
|||||
83 | 8 | return $inputNodes->exists($node); |
|||||
84 | 8 | }; |
|||||
85 | |||||||
86 | |||||||
87 | 11 | } elseif (is_callable($input)) { |
|||||
88 | // Since we're at the behest of the input callable, the 'matched' |
||||||
89 | // return value is always true. |
||||||
90 | 2 | $matchType = true; |
|||||
91 | |||||||
92 | 2 | $fn = $input; |
|||||
93 | |||||||
94 | 10 | } elseif (is_string($input)) { |
|||||
95 | 10 | $fn = function($node) use ($input) { |
|||||
96 | 10 | return $node->find($input, 'self::')->count() != 0; |
|||||
97 | 10 | }; |
|||||
98 | |||||||
99 | } else { |
||||||
100 | throw new \InvalidArgumentException('Unexpected input value of type "' . gettype($input) . '"'); |
||||||
101 | } |
||||||
102 | |||||||
103 | // Build a list of matching nodes. |
||||||
104 | 18 | return $this->collection()->map(function($node) use ($fn, $matchType) { |
|||||
105 | 18 | if ($fn($node) !== $matchType) { |
|||||
106 | 17 | return null; |
|||||
107 | } |
||||||
108 | |||||||
109 | 18 | return $node; |
|||||
110 | 18 | }); |
|||||
111 | } |
||||||
112 | |||||||
113 | /** |
||||||
114 | * @param string|NodeList|\DOMNode|callable $input |
||||||
115 | * |
||||||
116 | * @return bool |
||||||
117 | */ |
||||||
118 | 15 | public function is($input): bool { |
|||||
119 | 15 | return $this->getNodesMatchingInput($input)->count() != 0; |
|||||
120 | } |
||||||
121 | |||||||
122 | /** |
||||||
123 | * @param string|NodeList|\DOMNode|callable $input |
||||||
124 | * |
||||||
125 | * @return NodeList |
||||||
126 | */ |
||||||
127 | 1 | public function not($input): NodeList { |
|||||
128 | 1 | return $this->getNodesMatchingInput($input, false); |
|||||
129 | } |
||||||
130 | |||||||
131 | /** |
||||||
132 | * @param string|NodeList|\DOMNode|callable $input |
||||||
133 | * |
||||||
134 | * @return NodeList |
||||||
135 | */ |
||||||
136 | 1 | public function filter($input): NodeList { |
|||||
137 | 1 | return $this->getNodesMatchingInput($input); |
|||||
138 | } |
||||||
139 | |||||||
140 | /** |
||||||
141 | * @param string|NodeList|\DOMNode|callable $input |
||||||
142 | * |
||||||
143 | * @return NodeList |
||||||
144 | */ |
||||||
145 | 1 | public function has($input): NodeList { |
|||||
146 | 1 | if ($input instanceof NodeList || $input instanceof \DOMNode) { |
|||||
147 | 1 | $inputNodes = $this->inputAsNodeList($input, false); |
|||||
148 | |||||||
149 | 1 | $fn = function($node) use ($inputNodes) { |
|||||
150 | 1 | $descendantNodes = $node->find('*', 'descendant::'); |
|||||
151 | |||||||
152 | // Determine if we have a descendant match. |
||||||
153 | 1 | return $inputNodes->reduce(function($carry, $inputNode) use ($descendantNodes) { |
|||||
154 | // Match descendant nodes against input nodes. |
||||||
155 | 1 | if ($descendantNodes->exists($inputNode)) { |
|||||
156 | 1 | return true; |
|||||
157 | } |
||||||
158 | |||||||
159 | return $carry; |
||||||
160 | 1 | }, false); |
|||||
161 | 1 | }; |
|||||
162 | |||||||
163 | 1 | } elseif (is_string($input)) { |
|||||
164 | 1 | $fn = function($node) use ($input) { |
|||||
165 | 1 | return $node->find($input, 'descendant::')->count() != 0; |
|||||
166 | 1 | }; |
|||||
167 | |||||||
168 | } elseif (is_callable($input)) { |
||||||
169 | $fn = $input; |
||||||
170 | |||||||
171 | } else { |
||||||
172 | throw new \InvalidArgumentException('Unexpected input value of type "' . gettype($input) . '"'); |
||||||
173 | } |
||||||
174 | |||||||
175 | 1 | return $this->getNodesMatchingInput($fn); |
|||||
176 | } |
||||||
177 | |||||||
178 | /** |
||||||
179 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
180 | * |
||||||
181 | * @return \DOMNode|null |
||||||
182 | */ |
||||||
183 | public function preceding($selector = null): ?\DOMNode { |
||||||
184 | return $this->precedingUntil(null, $selector)->first(); |
||||||
185 | } |
||||||
186 | |||||||
187 | /** |
||||||
188 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
189 | * |
||||||
190 | * @return NodeList |
||||||
191 | */ |
||||||
192 | 21 | public function precedingAll($selector = null): NodeList { |
|||||
193 | 21 | return $this->precedingUntil(null, $selector); |
|||||
194 | } |
||||||
195 | |||||||
196 | /** |
||||||
197 | * @param string|NodeList|\DOMNode|callable $input |
||||||
198 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
199 | * |
||||||
200 | * @return NodeList |
||||||
201 | */ |
||||||
202 | 32 | public function precedingUntil($input = null, $selector = null): NodeList { |
|||||
203 | 32 | return $this->_walkPathUntil('previousSibling', $input, $selector); |
|||||
204 | } |
||||||
205 | |||||||
206 | /** |
||||||
207 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
208 | * |
||||||
209 | * @return \DOMNode|null |
||||||
210 | */ |
||||||
211 | 12 | public function following($selector = null): ?\DOMNode { |
|||||
212 | 12 | return $this->followingUntil(null, $selector)->first(); |
|||||
213 | } |
||||||
214 | |||||||
215 | /** |
||||||
216 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
217 | * |
||||||
218 | * @return NodeList |
||||||
219 | */ |
||||||
220 | 21 | public function followingAll($selector = null): NodeList { |
|||||
221 | 21 | return $this->followingUntil(null, $selector); |
|||||
222 | } |
||||||
223 | |||||||
224 | /** |
||||||
225 | * @param string|NodeList|\DOMNode|callable $input |
||||||
226 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
227 | * |
||||||
228 | * @return NodeList |
||||||
229 | */ |
||||||
230 | 25 | public function followingUntil($input = null, $selector = null): NodeList { |
|||||
231 | 25 | return $this->_walkPathUntil('nextSibling', $input, $selector); |
|||||
232 | } |
||||||
233 | |||||||
234 | /** |
||||||
235 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
236 | * |
||||||
237 | * @return NodeList |
||||||
238 | */ |
||||||
239 | public function siblings($selector = null): NodeList { |
||||||
240 | 21 | $results = $this->collection()->reduce(function($carry, $node) use ($selector) { |
|||||
241 | 21 | return $carry->merge( |
|||||
242 | 21 | $node->precedingAll($selector)->merge( |
|||||
243 | 21 | $node->followingAll($selector) |
|||||
244 | ) |
||||||
245 | ); |
||||||
246 | 21 | }, $this->newNodeList()); |
|||||
247 | |||||||
248 | 21 | return $results; |
|||||
249 | } |
||||||
250 | |||||||
251 | /** |
||||||
252 | * NodeList is only array like. Removing items using foreach() has undesired results. |
||||||
253 | * |
||||||
254 | * @return NodeList |
||||||
255 | */ |
||||||
256 | public function children(): NodeList { |
||||||
257 | 93 | $results = $this->collection()->reduce(function($carry, $node) { |
|||||
258 | 93 | return $carry->merge( |
|||||
259 | 93 | $node->findXPath('child::*') |
|||||
260 | ); |
||||||
261 | 93 | }, $this->newNodeList()); |
|||||
262 | |||||||
263 | 93 | return $results; |
|||||
264 | } |
||||||
265 | |||||||
266 | /** |
||||||
267 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
268 | * |
||||||
269 | * @return Element|NodeList|null |
||||||
270 | */ |
||||||
271 | 51 | public function parent($selector = null) { |
|||||
272 | 51 | $results = $this->_walkPathUntil('parentNode', null, $selector, self::$MATCH_TYPE_FIRST); |
|||||
273 | |||||||
274 | 51 | return $this->result($results); |
|||||
0 ignored issues
–
show
It seems like
result() must be provided by classes using this trait. How about adding it as abstract method to this trait?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
275 | } |
||||||
276 | |||||||
277 | /** |
||||||
278 | * @param int $index |
||||||
279 | * |
||||||
280 | * @return \DOMNode|null |
||||||
281 | */ |
||||||
282 | 2 | public function eq(int $index): ?\DOMNode { |
|||||
283 | 2 | if ($index < 0) { |
|||||
284 | 1 | $index = $this->collection()->count() + $index; |
|||||
285 | } |
||||||
286 | |||||||
287 | 2 | return $this->collection()->offsetGet($index); |
|||||
288 | } |
||||||
289 | |||||||
290 | /** |
||||||
291 | * @param string $selector |
||||||
292 | * |
||||||
293 | * @return NodeList |
||||||
294 | */ |
||||||
295 | public function parents(string $selector = null): NodeList { |
||||||
296 | return $this->parentsUntil(null, $selector); |
||||||
297 | } |
||||||
298 | |||||||
299 | /** |
||||||
300 | * @param string|NodeList|\DOMNode|callable $input |
||||||
301 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
302 | * |
||||||
303 | * @return NodeList |
||||||
304 | */ |
||||||
305 | public function parentsUntil($input = null, $selector = null): NodeList { |
||||||
306 | return $this->_walkPathUntil('parentNode', $input, $selector); |
||||||
307 | } |
||||||
308 | |||||||
309 | /** |
||||||
310 | * @return \DOMNode |
||||||
311 | */ |
||||||
312 | public function intersect(): \DOMNode { |
||||||
313 | if ($this->collection()->count() < 2) { |
||||||
314 | return $this->collection()->first(); |
||||||
315 | } |
||||||
316 | |||||||
317 | $nodeParents = []; |
||||||
318 | |||||||
319 | // Build a multi-dimensional array of the collection nodes parent elements |
||||||
320 | $this->collection()->each(function($node) use(&$nodeParents) { |
||||||
321 | $nodeParents[] = $node->parents()->unshift($node)->toArray(); |
||||||
322 | }); |
||||||
323 | |||||||
324 | // Find the common parent |
||||||
325 | $diff = call_user_func_array('array_uintersect', array_merge($nodeParents, [function($a, $b) { |
||||||
326 | return strcmp(spl_object_hash($a), spl_object_hash($b)); |
||||||
327 | }])); |
||||||
328 | |||||||
329 | return array_shift($diff); |
||||||
330 | } |
||||||
331 | |||||||
332 | /** |
||||||
333 | * @param string|NodeList|\DOMNode|callable $input |
||||||
334 | * |
||||||
335 | * @return Element|NodeList|null |
||||||
336 | */ |
||||||
337 | 3 | public function closest($input) { |
|||||
338 | 3 | $results = $this->_walkPathUntil('parentNode', $input, null, self::$MATCH_TYPE_LAST); |
|||||
339 | |||||||
340 | 3 | return $this->result($results); |
|||||
341 | } |
||||||
342 | |||||||
343 | /** |
||||||
344 | * NodeList is only array like. Removing items using foreach() has undesired results. |
||||||
345 | * |
||||||
346 | * @return NodeList |
||||||
347 | */ |
||||||
348 | public function contents(): NodeList { |
||||||
349 | 26 | $results = $this->collection()->reduce(function($carry, $node) { |
|||||
350 | 26 | if ($node->isRemoved()) { |
|||||
351 | return $carry; |
||||||
352 | 26 | } |
|||||
353 | 26 | ||||||
354 | return $carry->merge( |
||||||
355 | 26 | $node->newNodeList($node->childNodes) |
|||||
356 | ); |
||||||
357 | 26 | }, $this->newNodeList()); |
|||||
358 | |||||||
359 | return $results; |
||||||
360 | } |
||||||
361 | |||||||
362 | /** |
||||||
363 | * @param string|NodeList|\DOMNode $input |
||||||
364 | * |
||||||
365 | * @return NodeList |
||||||
366 | */ |
||||||
367 | public function add($input): NodeList { |
||||||
368 | $nodes = $this->inputAsNodeList($input); |
||||||
369 | |||||||
370 | $results = $this->collection()->merge( |
||||||
371 | $nodes |
||||||
372 | ); |
||||||
373 | |||||||
374 | return $results; |
||||||
375 | } |
||||||
376 | |||||||
377 | /** @var int */ |
||||||
378 | private static $MATCH_TYPE_FIRST = 1; |
||||||
379 | |||||||
380 | /** @var int */ |
||||||
381 | private static $MATCH_TYPE_LAST = 2; |
||||||
382 | |||||||
383 | /** |
||||||
384 | * @param \DOMNode $baseNode |
||||||
385 | * @param string $property |
||||||
386 | * @param string|NodeList|\DOMNode|callable $input |
||||||
387 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
388 | * @param int $matchType |
||||||
389 | * |
||||||
390 | 65 | * @return NodeList |
|||||
391 | 65 | */ |
|||||
392 | protected function _buildNodeListUntil(\DOMNode $baseNode, string $property, $input = null, $selector = null, int $matchType = null): NodeList { |
||||||
393 | $resultNodes = $this->newNodeList(); |
||||||
394 | 65 | ||||||
395 | // Get our first node |
||||||
396 | $node = $baseNode->$property; |
||||||
397 | 65 | ||||||
398 | // Keep looping until we are out of nodes. |
||||||
399 | 64 | // Allow either FIRST to reach \DOMDocument. Others that return multiple should ignore it. |
|||||
400 | 61 | while ($node instanceof \DOMNode && ($matchType === self::$MATCH_TYPE_FIRST || !($node instanceof \DOMDocument))) { |
|||||
401 | // Filter nodes if not matching last |
||||||
402 | if ($matchType != self::$MATCH_TYPE_LAST && (is_null($selector) || $node->is($selector))) { |
||||||
403 | $resultNodes[] = $node; |
||||||
404 | 64 | } |
|||||
405 | |||||||
406 | 60 | // 'Until' check or first match only |
|||||
407 | 2 | if ($matchType == self::$MATCH_TYPE_FIRST || (!is_null($input) && $node->is($input))) { |
|||||
408 | // Set last match |
||||||
409 | if ($matchType == self::$MATCH_TYPE_LAST) { |
||||||
410 | 60 | $resultNodes[] = $node; |
|||||
411 | } |
||||||
412 | |||||||
413 | break; |
||||||
414 | 21 | } |
|||||
415 | |||||||
416 | // Find the next node |
||||||
417 | 65 | $node = $node->{$property}; |
|||||
418 | } |
||||||
419 | |||||||
420 | return $resultNodes; |
||||||
421 | } |
||||||
422 | |||||||
423 | /** |
||||||
424 | * @param iterable $nodeLists |
||||||
425 | 65 | * |
|||||
426 | 65 | * @return NodeList |
|||||
427 | */ |
||||||
428 | protected function _uniqueNodes(iterable $nodeLists): NodeList { |
||||||
429 | 65 | $resultNodes = $this->newNodeList(); |
|||||
430 | |||||||
431 | 65 | // Loop through our array of NodeLists |
|||||
432 | foreach ($nodeLists as $nodeList) { |
||||||
433 | 63 | // Each node in the NodeList |
|||||
434 | 65 | foreach ($nodeList as $node) { |
|||||
435 | // We're only interested in unique nodes |
||||||
436 | if (!$resultNodes->exists($node)) { |
||||||
437 | $resultNodes[] = $node; |
||||||
438 | } |
||||||
439 | } |
||||||
440 | 65 | } |
|||||
441 | |||||||
442 | // Sort resulting NodeList: outer-most => inner-most. |
||||||
443 | return $resultNodes->reverse(); |
||||||
444 | } |
||||||
445 | |||||||
446 | /** |
||||||
447 | * @param string $property |
||||||
448 | * @param string|NodeList|\DOMNode|callable $input |
||||||
449 | * @param string|NodeList|\DOMNode|callable $selector |
||||||
450 | * @param int $matchType |
||||||
451 | 65 | * |
|||||
452 | 65 | * @return NodeList |
|||||
453 | */ |
||||||
454 | 65 | protected function _walkPathUntil(string $property, $input = null, $selector = null, int $matchType = null): NodeList { |
|||||
455 | 65 | $nodeLists = []; |
|||||
456 | 65 | ||||||
457 | $this->collection()->each(function($node) use($property, $input, $selector, $matchType, &$nodeLists) { |
||||||
458 | 65 | $nodeLists[] = $this->_buildNodeListUntil($node, $property, $input, $selector, $matchType); |
|||||
459 | }); |
||||||
460 | |||||||
461 | return $this->_uniqueNodes($nodeLists); |
||||||
462 | } |
||||||
463 | } |