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