TraversalTrait::_uniqueNodes()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 16
ccs 5
cts 5
cp 1
rs 10
c 0
b 0
f 0
cc 4
nc 4
nop 1
crap 4
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
It seems like document() 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 ignore-call  annotation

34
        return new NodeList($this->/** @scrutinizer ignore-call */ document(), $nodes);
Loading history...
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
Bug introduced by
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 ignore-call  annotation

57
        if ($this->/** @scrutinizer ignore-call */ isRemoved()) {
Loading history...
58
            return $results;
59
        }
60
61 130
        $domxpath = new \DOMXPath($this->document());
62
63 130
        foreach ($this->collection() as $node) {
0 ignored issues
show
Bug introduced by
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 ignore-call  annotation

63
        foreach ($this->/** @scrutinizer ignore-call */ collection() as $node) {
Loading history...
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
Bug introduced by
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 ignore-call  annotation

80
            /** @scrutinizer ignore-call */ 
81
            $inputNodes = $this->inputAsNodeList($input, false);
Loading history...
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
Bug introduced by
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 ignore-call  annotation

274
        return $this->/** @scrutinizer ignore-call */ result($results);
Loading history...
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
}