You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
479 lines
8.5 KiB
479 lines
8.5 KiB
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Dom;
|
|
|
|
/**
|
|
* Node in a document.
|
|
*
|
|
* @author Adam Pippin <hello@adampippin.ca>
|
|
*/
|
|
class Node implements \ArrayAccess, \Iterator
|
|
{
|
|
/**
|
|
* Index for Iterator implementation.
|
|
* @var int
|
|
*/
|
|
private $_idx;
|
|
|
|
/**
|
|
* Name of this node, or null if none (e.g., array elements).
|
|
* @var ?string
|
|
*/
|
|
protected $name;
|
|
|
|
/**
|
|
* Parent node of this node, should be set on every node in the
|
|
* document besides the document itself.
|
|
* @var ?Node
|
|
*/
|
|
protected $parent;
|
|
|
|
/**
|
|
* Children nested under this node.
|
|
* @var Node[]
|
|
*/
|
|
protected $children;
|
|
|
|
/**
|
|
* Create a new DOM node.
|
|
*
|
|
* @param ?Node $parent the parent this node is nested under
|
|
* @param ?string $name the name of the node, if one exists
|
|
*/
|
|
public function __construct(?Node $parent, ?string $name)
|
|
{
|
|
$this->parent = $parent;
|
|
$this->children = [];
|
|
$this->name = $name;
|
|
}
|
|
|
|
/**
|
|
* Set the node's name.
|
|
*
|
|
* @param string $name
|
|
* @return void
|
|
*/
|
|
public function setName(string $name): void
|
|
{
|
|
$this->name = $name;
|
|
}
|
|
|
|
/**
|
|
* Get the node's name.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getName(): string
|
|
{
|
|
return $this->name;
|
|
}
|
|
|
|
/**
|
|
* Check whether the node has a name set.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function hasName(): bool
|
|
{
|
|
return isset($this->name);
|
|
}
|
|
|
|
/**
|
|
* Get this node's parent node.
|
|
*
|
|
* @return Node
|
|
*/
|
|
public function getParent(): Node
|
|
{
|
|
if (!isset($this->parent))
|
|
{
|
|
throw new \Exception('Cannot fetch parent -- none set');
|
|
}
|
|
return $this->parent;
|
|
}
|
|
|
|
/**
|
|
* Set this node's parent.
|
|
*
|
|
* @param Node $parent
|
|
* @return void
|
|
*/
|
|
public function setParent(Node $parent): void
|
|
{
|
|
$this->parent = $parent;
|
|
}
|
|
|
|
/**
|
|
* Check whether this node has any child nodes.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function hasChildren(): bool
|
|
{
|
|
return sizeof($this->children) > 0;
|
|
}
|
|
|
|
/**
|
|
* How many children this node has.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function countChildren(): int
|
|
{
|
|
return sizeof($this->children);
|
|
}
|
|
|
|
/**
|
|
* Fetch this node's children.
|
|
*
|
|
* @return Node[]
|
|
*/
|
|
public function getChildren(): array
|
|
{
|
|
return $this->children;
|
|
}
|
|
|
|
/**
|
|
* Replace all of this node's children.
|
|
*
|
|
* @param Node[] $nodes
|
|
* @return void
|
|
*/
|
|
public function setChildren(array $nodes): void
|
|
{
|
|
$this->children = $nodes;
|
|
}
|
|
|
|
/**
|
|
* Attempt to find a child node by name.
|
|
*
|
|
* @param string $name
|
|
* @return ?Node
|
|
*/
|
|
public function getChildByName(string $name): ?Node
|
|
{
|
|
foreach ($this->children as $child)
|
|
{
|
|
if ($child->name == $name)
|
|
{
|
|
return $child;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a child of this node by a dot-separated path.
|
|
*
|
|
* Each element of the path can either be a node name or a numeric
|
|
* index representing the offset into an array
|
|
*
|
|
* @param string $path
|
|
* @return ?Node the requested node, or null if it wasn't found
|
|
*/
|
|
public function getChildByPath(string $path): ?Node
|
|
{
|
|
$path = explode('.', $path);
|
|
$current = $this;
|
|
|
|
while (sizeof($path))
|
|
{
|
|
$next = array_shift($path);
|
|
if (is_numeric($next))
|
|
{
|
|
$current = $current[$next];
|
|
}
|
|
else
|
|
{
|
|
$current = $current->getChildByName($next);
|
|
}
|
|
|
|
if (!isset($current))
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
return $current;
|
|
}
|
|
|
|
/**
|
|
* Add/replace a child of this node by a dot-separated path.
|
|
*
|
|
* Each element of the path can either be a node name or a numeric
|
|
* index representing the offset into an array
|
|
*
|
|
* @todo will explode if you try to write using a numeric index to an array
|
|
* @param string $path
|
|
* @param Node $node
|
|
* @return void
|
|
*/
|
|
public function setChildByPath(string $path, Node $node): void
|
|
{
|
|
$path = explode('.', $path);
|
|
$current = $this;
|
|
|
|
while (sizeof($path))
|
|
{
|
|
$next = array_shift($path);
|
|
if (is_numeric($next))
|
|
{
|
|
$current = $current[$next];
|
|
}
|
|
else
|
|
{
|
|
$next_node = $current->getChildByName($next);
|
|
|
|
if (!isset($next_node))
|
|
{
|
|
$next_node = new Node($current, $next);
|
|
$current->addChild($next_node);
|
|
}
|
|
$current = $next_node;
|
|
}
|
|
|
|
if (!isset($current))
|
|
{
|
|
throw new \Exception('Cannot write to non-existent array index by path');
|
|
}
|
|
}
|
|
|
|
// Replace current with the passed in node
|
|
$current->remove();
|
|
$current->getParent()->addChild($node);
|
|
$node->setParent($current->getParent());
|
|
if ($current->hasName())
|
|
{
|
|
$node->setName($current->getName());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a node to this node's children.
|
|
*
|
|
* @param Node $child
|
|
* @return void
|
|
*/
|
|
public function addChild(Node $child): void
|
|
{
|
|
$this->children[] = $child;
|
|
}
|
|
|
|
/**
|
|
* Remove a child node.
|
|
*
|
|
* @param Node $child
|
|
* @return void
|
|
*/
|
|
public function removeChild(Node $child): void
|
|
{
|
|
for ($i = 0; $i < sizeof($this->children); $i++)
|
|
{
|
|
if ($this->children[$i] === $child)
|
|
{
|
|
array_splice($this->children, $i, 1);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try and determine whether this node is a map (named sub-properties).
|
|
*
|
|
* Checks that this node is not a "FunctionParent" (see isFunctionParent)
|
|
* and whether it contains any children that don't have names.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isMap(): bool
|
|
{
|
|
if ($this->isFunctionParent())
|
|
{
|
|
return false;
|
|
}
|
|
foreach ($this->children as $child)
|
|
{
|
|
if (!$child->hasName())
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Try and determine wether this node is an array.
|
|
*
|
|
* Checks that this node is not a "FunctionParent" (see isFunctionParent)
|
|
* and whether it contains any children that have names.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isArray(): bool
|
|
{
|
|
foreach ($this->children as $child)
|
|
{
|
|
if ($child->hasName())
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check whether this node is a 'function parent', that is, whether it contains
|
|
* a single child node and that node is a function node.
|
|
*
|
|
* When we come across, in YAML, something like:
|
|
* Map:
|
|
* Value: !Func arg
|
|
* That's parsed as:
|
|
* Map (Node):
|
|
* Value (Node): !Func arg (FunctionNode)
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isFunctionParent(): bool
|
|
{
|
|
return sizeof($this->children) == 1 && $this->children[0] instanceof NodeFunction;
|
|
}
|
|
|
|
/**
|
|
* Remove this node from its parent.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function remove(): void
|
|
{
|
|
$this->getParent()->removeChild($this);
|
|
}
|
|
|
|
/**
|
|
* Convert a node tree containing basic data into native PHP data types.
|
|
*
|
|
* @param Node $node
|
|
* @return array|scalar
|
|
*/
|
|
public static function toPhp(Node $node)
|
|
{
|
|
if ($node instanceof NodeValue)
|
|
{
|
|
return $node->getValue();
|
|
}
|
|
elseif ($node->isArray())
|
|
{
|
|
$result = [];
|
|
foreach ($node as $child)
|
|
{
|
|
$result[] = static::toPhp($child);
|
|
}
|
|
return $result;
|
|
}
|
|
elseif ($node->isMap())
|
|
{
|
|
$result = [];
|
|
foreach ($node as $child)
|
|
{
|
|
$result[$child->getName()] = static::toPhp($child);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
throw new \Exception('Cannot convert from DOM node');
|
|
}
|
|
|
|
/**
|
|
* Convert native PHP data types into a node tree.
|
|
*
|
|
* @param array|scalar $value
|
|
* @return Node
|
|
*/
|
|
public static function fromPhp($value): Node
|
|
{
|
|
if (is_scalar($value))
|
|
{
|
|
return new NodeValue(null, null, $value);
|
|
}
|
|
elseif (is_array($value))
|
|
{
|
|
$contains_only_numeric_keys = array_reduce(array_keys($value), static function(bool $carry, string $item): bool {
|
|
return $carry && is_numeric($item);
|
|
}, true);
|
|
|
|
if ($contains_only_numeric_keys)
|
|
{
|
|
$node = new Node(null, null);
|
|
foreach ($value as $item)
|
|
{
|
|
$child = static::fromPhp($item);
|
|
$child->setParent($node);
|
|
$node->addChild($child);
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
$node = new Node(null, null);
|
|
foreach ($value as $key => $item)
|
|
{
|
|
$child = static::fromPhp($item);
|
|
$child->setName($key);
|
|
$child->setParent($node);
|
|
$node->addChild($child);
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
throw new \Exception('Cannot convert passed value into DOM nodes');
|
|
}
|
|
|
|
// ArrayAccess
|
|
|
|
public function offsetExists($offset): bool
|
|
{
|
|
return isset($this->children[$offset]);
|
|
}
|
|
|
|
public function offsetGet($offset)
|
|
{
|
|
return $this->children[$offset];
|
|
}
|
|
|
|
public function offsetSet($offset, $value)
|
|
{
|
|
$this->children[$offset] = $value;
|
|
}
|
|
|
|
public function offsetUnset($offset)
|
|
{
|
|
unset($this->children[$offset]);
|
|
}
|
|
|
|
// Iterator
|
|
|
|
public function current()
|
|
{
|
|
return $this->children[$this->_idx];
|
|
}
|
|
|
|
public function key()
|
|
{
|
|
return $this->_idx;
|
|
}
|
|
|
|
public function next()
|
|
{
|
|
$this->_idx++;
|
|
}
|
|
|
|
public function rewind()
|
|
{
|
|
$this->_idx = 0;
|
|
}
|
|
|
|
public function valid()
|
|
{
|
|
return isset($this->children[$this->_idx]);
|
|
}
|
|
}
|
|
|