<commit_before><?php

namespace Rct567\DomQuery;

/**
 * Class DomQuery
 *
 * @property \DOMXPath $dom_xpath
 * @property string $tagName
 * @property string $nodeName
 * @property string $nodeValue
 *
 * @method string getAttribute(string $name)
 *
 * @package Rct567\DomQuery
 */
class DomQuery implements \IteratorAggregate, \Countable, \ArrayAccess
{
    /**
     * Instance of DOMDocument
     *
     * @var \DOMDocument
     */
    private $document;

    /**
     * All nodes as instances of DOMNode
     *
     * @var \DOMNode[]
     */
    private $nodes = array();

    /**
     * Number of nodes
     *
     * @var integer
     */
    public $length = 0;

    /**
     * Load and return as xml
     *
     * @var boolean
     */
    public $xml_mode;

    /**
     * Return xml with pi node (XML declaration)
     *
     * @var boolean
     */
    public $xml_print_pi = false;

    /**
     * Xpath expression used to create the result of this instance
     *
     * @var string
     */
    private $xpath_query;

    /**
     * Root instance who began the chain
     *
     * @var self
     */
    private $root_instance;

    /**
     * Css selector given to create result of this instance
     *
     * @var string
     */
    private $css_query;

    /**
     * Jquery style property; css selector given to create result of this instance
     *
     * @var string
     */
    public $selector;

    /**
     * Preserve no newlines (prevent creating newlines in html result)
     */
    public $preserve_no_newlines;

    /**
     * LibXMl options used to load html for DOMDocument
     *
     * @var mixed
     */
    public $libxml_options =
        LIBXML_HTML_NOIMPLIED // turns off the automatic adding of implied html/body
      | LIBXML_HTML_NODEFDTD; // prevents a default doctype being added when one is not found

    /**
     * Constructor
     *
     * @throws \InvalidArgumentException
     */
    public function __construct()
    {
        foreach (func_get_args() as $arg) {
            if ($arg instanceof \DOMDocument) {
                $this->setDomDocument($arg);
            } elseif ($arg instanceof \DOMNodeList) {
                $this->loadDomNodeList($arg);
            } elseif ($arg instanceof \DOMNode) {
                $this->addDomNode($arg);
            } elseif ($arg instanceof \DOMXPath) {
                $this->dom_xpath = $arg;
            } elseif (is_string($arg) && strpos($arg, '>') !== false) {
                $this->loadContent($arg);
            } elseif (is_object($arg)) {
                throw new \InvalidArgumentException('Unknown object '.get_class($arg).' given as argument');
            } else {
                throw new \InvalidArgumentException('Unknown argument '.gettype($arg));
            }
        }
    }

    /**
     * Create new instance
     *
     * @return self
     * @throws \InvalidArgumentException
     */
    public static function create()
    {
        return new self(...func_get_args());
    }

    /**
     * Set dom document
     *
     * @param \DOMDocument $document
     *
     * @return void
     */
    public function setDomDocument(\DOMDocument $document)
    {
        if (isset($this->document) && $this->document !== $document) {
            throw new \Exception('Other DOMDocument already set!');
        }

        $this->document = $document;
    }

    /**
     * Load nodes from dom list
     *
     * @param \DOMNodeList $dom_node_list
     *
     * @return void
     */
    public function loadDomNodeList(\DOMNodeList $dom_node_list)
    {
        if (!isset($this->document)) {
            throw new \Exception('DOMDocument is missing!');
        }

        foreach ($dom_node_list as $node) {
            $this->addDomNode($node);
        }
    }

    /**
     * Add node to result set
     *
     * @param \DOMNode $dom_node
     *
     * @return void
     */
    public function addDomNode(\DOMNode $dom_node)
    {
        $this->nodes[] = $dom_node;
        $this->length = count($this->nodes);
        $this->setDomDocument($dom_node->ownerDocument);
    }

    /**
     * Load html or xml content
     *
     * @param string $content
     * @param string $encoding
     *
     * @return void
     */
    public function loadContent(string $content, $encoding='UTF-8')
    {
        $this->preserve_no_newlines = (strpos($content, '<') !== false && strpos($content, "\n") === false);
        
        if (!is_bool($this->xml_mode)) {
            $this->xml_mode = (stripos($content, '<?xml') === 0);
        }

        $this->xml_print_pi = (stripos($content, '<?xml') === 0);

        $xml_pi_node_added = false;
        if (!$this->xml_mode && $encoding && stripos($content, '<?xml') === false) { // add pi node to make libxml use the correct encoding
            $content = '<?xml encoding="'.$encoding.'">'.$content;
            $xml_pi_node_added = true;
        }

        libxml_disable_entity_loader(true);
        libxml_use_internal_errors(true);

        $dom_document = new \DOMDocument('1.0', $encoding);
        $dom_document->strictErrorChecking = false;
        $dom_document->validateOnParse = false;
        $dom_document->recover = true;

        if ($this->xml_mode) {
            $dom_document->loadXML($content, $this->libxml_options);
        } else {
            $dom_document->loadHTML($content, $this->libxml_options);
        }

        $this->setDomDocument($dom_document);

        if ($xml_pi_node_added) { // pi node added, now remove it
            foreach ($dom_document->childNodes as $node) {
                if ($node->nodeType == XML_PI_NODE) {
                    $dom_document->removeChild($node);
                    break;
                }
            }
        }

        foreach ($dom_document->childNodes as $node) {
            $this->nodes[] = $node;
        }

        $this->length = count($this->nodes);
    }

    /**
     * Use xpath and return new DomQuery with resulting nodes
     *
     * @param string $xpath_query
     *
     * @return self|void
     */
    public function xpath(string $xpath_query)
    {
        if (isset($this->document)) {
            $result = new self($this->document, $this->dom_xpath);
            if (isset($this->root_instance)) {
                $result->root_instance = $this->root_instance;
            } else {
                $result->root_instance = $this;
            }
            $result->xpath_query = $xpath_query;
            $result->xml_mode = $this->xml_mode;

            if ($this->length > 0 && isset($this->xpath_query)) {  // all nodes as context
                foreach ($this->nodes as $node) {
                    if ($result_node_list = $this->xpathQuery('.'.$xpath_query, $node)) {
                        $result->loadDomNodeList($result_node_list);
                    }
                }
            } else { // whole document
                if ($result_node_list = $this->xpathQuery($xpath_query)) {
                    $result->loadDomNodeList($result_node_list);
                }
            }

            return $result;
        }
    }

    /**
     * Use css expression and return new DomQuery with results
     *
     * @param string $css_expression
     *
     * @return self|void
     */
    public function find(string $css_expression)
    {
        if (isset($this->document)) {
            $xpath_expression = self::cssToXpath($css_expression);
            $result = $this->xpath($xpath_expression);

            $result->css_query = $css_expression;
            $result->selector = $css_expression; // jquery style

            return $result;
        }
    }

    /**
     * Get the combined text contents of each element in the set of matched elements, including their descendants,
     * or set the text contents of the matched elements.
     *
     * @param string|null $val
     *
     * @return $this|string|void
     */
    public function text($val=null)
    {
        if (!is_null($val)) { // set node value for all nodes
            foreach ($this->nodes as $node) {
                $node->nodeValue = $val;
            }

            return $this;
        } else { // get value for first node
            if ($node = $this->getFirstElmNode()) {
                return $node->nodeValue;
            }
        }
    }

    /**
     * Get the HTML contents of the first element in the set of matched elements
     *
     * @param string|null $html_string
     *
     * @return $this|string|void
     */
    public function html($html_string=null)
    {
        if (!is_null($html_string)) { // set html for all nodes
            foreach ($this as $node) {
                $node->get(0)->nodeValue = '';
                $node->append($html_string);
            }

            if (strpos($html_string, "\n") !== false) {
                $this->preserve_no_newlines = false;
            }

            return $this;
        } else { // get html for first node
            if ($node = $this->getFirstElmNode()) {
                $html = '';
                $document = $node->ownerDocument;

                foreach ($node->childNodes as $node) {
                    if ($this->xml_mode) {
                        $html .= $document->saveXML($node);
                    } else {
                        $html .= $document->saveHTML($node);
                    }
                }

                if ($this->preserve_no_newlines) {
                    $html = str_replace("\n", '', $html);
                }

                return $html;
            }
        }
    }

    /**
     * Get the value of an attribute for the first element in the set of matched elements
     * or set one or more attributes for every matched element.
     *
     * @param string $name
     * @param string $val
     *
     * @return $this|string
     */
    public function attr(string $name, $val=null)
    {
        if (!is_null($val)) { // set attribute for all nodes
            foreach ($this->nodes as $node) {
                if ($node instanceof \DOMElement) {
                    $node->setAttribute($name, $val);
                }
            }
            return $this;
        } else { // get attribute value for first element
            if ($node = $this->getFirstElmNode()) {
                return $node->getAttribute($name);
            }
        }
    }

    /**
     * Remove an attribute from each element in the set of matched elements
     *
     * @param string $name
     *
     * @return $this
     */
    public function removeAttr($name)
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                $node->removeAttribute($name);
            }
        }

        return $this;
    }

    /**
     * Adds the specified class(es) to each element in the set of matched elements.
     *
     * @param string $class_name class name(s)
     *
     * @return $this
     */
    public function addClass($class_name)
    {
        $add_names = preg_split('#\s+#s', $class_name);

        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                $node_classes = array();
                if ($node_class_attr = $node->getAttribute('class')) {
                    $node_classes = preg_split('#\s+#s', $node_class_attr);
                }
                foreach ($add_names as $add_name) {
                    if (!in_array($add_name, $node_classes)) {
                        $node_classes[] = $add_name;
                    }
                }
                if (count($node_classes) > 0) {
                    $node->setAttribute('class', implode(' ', $node_classes));
                }
            }
        }

        return $this;
    }

    /**
     * Determine whether any of the matched elements are assigned the given class.
     *
     * @param string $class_name
     *
     * @return boolean
     */
    public function hasClass($class_name)
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                if ($node_class_attr = $node->getAttribute('class')) {
                    $node_classes = preg_split('#\s+#s', $node_class_attr);
                    if (in_array($class_name, $node_classes)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * Remove a single class, multiple classes, or all classes from each element in the set of matched elements.
     *
     * @param string $class_name
     *
     * @return $this
     */
    public function removeClass($class_name='')
    {
        $remove_names = preg_split('#\s+#s', $class_name);
        
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement && $node->hasAttribute('class')) {
                $node_classes = preg_split('#\s+#s', $node->getAttribute('class'));
                $class_removed = false;
            
                if ($class_name === '') { // remove all
                    $node_classes = array();
                    $class_removed = true;
                } else {
                    foreach ($remove_names as $remove_name) {
                        $key = array_search($remove_name, $node_classes);
                        if ($key !== false) {
                            unset($node_classes[$key]);
                            $class_removed = true;
                        }
                    }
                }
                if ($class_removed) {
                    $node->setAttribute('class', implode(' ', $node_classes));
                }
            }
        }

        return $this;
    }

    /**
     * Get the value of a property for the first element in the set of matched elements
     * or set one or more properties for every matched element.
     *
     * @param string $name
     * @param string $val
     *
     * @return $this|mixed
     */
    public function prop(string $name, $val=null)
    {
        if (!is_null($val)) { // set attribute for all nodes
            foreach ($this->nodes as $node) {
                $node->$name = $val;
            }

            return $this;
        } else { // get property value for first element
            if ($name == 'outerHTML') {
                return $this->getOuterHtml();
            } elseif ($node = $this->getFirstElmNode()) {
                if (isset($node->$name)) {
                    return $node->$name;
                }
            }
        }
    }

    /**
     * Get the children of each element in the set of matched elements, optionally filtered by a selector.
     *
     * @param string $selector
     *
     * @return self|void
     */
    public function children(string $selector='*')
    {
        if (strpos($selector, ',') !== false) {
            $selectors = explode(',', $selector);
        } else {
            $selectors = array($selector);
        }

        // make all selectors for direct children
        foreach ($selectors as &$single_selector) {
            $single_selector = '> '.ltrim($single_selector, ' >');
        }

        $direct_children_selector = implode(', ', $selectors);

        return $this->find($direct_children_selector);
    }

    /**
     * Get the parent of each element in the current set of matched elements, optionally filtered by a selector
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function parent(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) {
                if (!is_null($node->parentNode)) {
                    $result->addDomNode($node->parentNode);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Reduce the set of matched elements to those that match the selector
     *
     * @param string $selector
     *
     * @return self
     */
    public function not(string $selector)
    {
        $result = new self($this->document, $this->dom_xpath);

        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $selector_result_node_list = $this->xpathQuery($xpath_query);

            $result->xpath_query = $xpath_query;

            if ($selector_result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    $matched = false;
                    foreach ($selector_result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            $matched = true;
                            break 1;
                        }
                    }
                    if (!$matched) {
                        $result->addDomNode($node);
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Reduce the set of matched elements to those that match the selector
     *
     * @param string $selector
     *
     * @return self
     */
    public function filter(string $selector)
    {
        $result = new self($this->document, $this->dom_xpath);

        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $selector_result_node_list = $this->xpathQuery($xpath_query);

            $result->xpath_query = $xpath_query;

            if ($selector_result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    foreach ($selector_result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            $result->addDomNode($node);
                            break 1;
                        }
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Check if any node matches the selector
     *
     * @param string $selector
     *
     * @return boolean
     */
    public function is(string $selector)
    {
        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $result_node_list = $this->xpathQuery($xpath_query);

            if ($result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    foreach ($result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            return true;
                        }
                    }
                }
            }
        }

        return false;
    }

    /**
     * Grants access to the DOM nodes of this instance
     *
     * @param int $index
     *
     * @return \DOMNode|null
     */
    public function get($index)
    {
        $result = array_slice($this->nodes, $index, 1); // note: index can be negative
        if (count($result) > 0) {
            return $result[0];
        } else {
            return null; // return null if nu result for key
        }
    }

    /**
     * Returns DomQuery with first node
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function first(string $selector=null)
    {
        if (isset($this[0])) {
            $result = $this[0];
            if ($selector) {
                $result = $result->filter($selector);
            }
            return $result;
        }
    }

    /**
     * Returns DomQuery with last node
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function last(string $selector=null)
    {
        if ($this->length > 0 && isset($this[$this->length-1])) {
            $result = $this[$this->length-1];
            if ($selector) {
                $result = $result->filter($selector);
            }
            return $result;
        }
    }

    /**
     * Returns DomQuery with immediately following sibling of all nodes
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function next(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) {
                if (!is_null($node->nextSibling)) {
                    $result->addDomNode($node->nextSibling);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Returns DomQuery with immediately preceding sibling of all nodes
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function prev(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) { // get all previous sibling of all nodes
                if (!is_null($node->previousSibling)) {
                    $result->addDomNode($node->previousSibling);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Iterate over result set and executing a callback for each node
     *
     * @param callable $callback
     *
     * @return $this
     */
    public function each(callable $callback)
    {
        foreach ($this->nodes as $index => $node) {
            $return_value = call_user_func($callback, $node, $index);

            if ($return_value === false) {
                break;
            }
        }

        return $this;
    }

    /**
     * Remove the set of matched elements
     *
     * @param string|null $selector expression that filters the set of matched elements to be removed
     *
     * @return self
     */
    public function remove(string $selector=null)
    {
        $result = $this;
        if ($selector) {
            $result = $result->filter($selector);
        }
        foreach ($result->nodes as $node) {
            if ($node->parentNode) {
                $node->parentNode->removeChild($node);
            }
        }

        $result->nodes = array();
        $result->length = 0;

        return $result;
    }

    /**
     * Import nodes and insert or append them via callback function
     *
     * @param string|self|array $content
     * @param callable $import_function
     *
     * @return void
     */
    private function importNodes($content, callable $import_function)
    {
        if (is_array($content)) {
            foreach ($content as $item) {
                $this->importNodes($item, $import_function);
            }
        } else {
            if (is_string($content) && strpos($content, "\n") !== false) {
                $this->preserve_no_newlines = false;
                if (isset($this->root_instance)) {
                    $this->root_instance->preserve_no_newlines = false;
                }
            }

            if (!($content instanceof DomQuery)) {
                $content = new self($content);
            }

            foreach ($this->nodes as $node) {
                foreach ($content->getNodes() as $content_node) {
                    $imported_node = $this->document->importNode($content_node, true);
                    $import_function($node, $imported_node);
                }
            }
        }
    }

    /**
     * Insert content to the end of each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function append()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            $node->appendChild($imported_node);
        });

        return $this;
    }

    /**
     * Insert content to the beginning of each element in the set of matched elements
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function prepend()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            $node->insertBefore($imported_node, $node->childNodes->item(0));
        });

        return $this;
    }

    /**
     * Insert content before each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function before()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            if ($node->parentNode instanceof \DOMDocument) {
                throw new \Exception('Can not set before root element '.$node->tagName.' of document');
            } else {
                $node->parentNode->insertBefore($imported_node, $node);
            }
        });

        return $this;
    }

    /**
     * Insert content after each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function after()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            if ($node->nextSibling) {
                $node->parentNode->insertBefore($imported_node, $node->nextSibling);
            } else { // node is last, so there is no next sibling to insert before
                $node->parentNode->appendChild($imported_node);
            }
        });

        return $this;
    }

    /**
     * Return array with nodes
     *
     * @return \DOMNode[]
     */
    private function getNodes()
    {
        return $this->nodes;
    }

    /**
     * Return first DOMElement
     *
     * @return \DOMElement|void
     */
    private function getFirstElmNode()
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                return $node;
            }
        }
    }

    /**
     * Call method on first DOMElement
     *
     * @param string $name
     * @param $arguments
     *
     * @return mixed
     * @throws \Exception
     */
    public function __call($name, $arguments)
    {
        if (method_exists($this->getFirstElmNode(), $name)) {
            return call_user_func_array(array($this->getFirstElmNode(), $name), $arguments);
        } else {
            throw new \Exception('Unknown call '.$name);
        }
    }

    /**
     * Perform query via xpath expression (using DOMXPath::query)
     *
     * @param string $expression
     * @param \DOMNode|null $context_node
     *
     * @return \DOMNodeList|false
     * @throws \Exception
     */
    public function xpathQuery(string $expression, \DOMNode $context_node=null)
    {
        if ($this->dom_xpath) {
            $node_list = $this->dom_xpath->query($expression, $context_node);

            if ($node_list instanceof \DOMNodeList) {
                return $node_list;
            } elseif ($node_list === false && !is_null($context_node)) {
                throw new \Exception('Expression '.$expression.' is malformed or contextnode is invalid.');
            } elseif ($node_list === false) {
                throw new \Exception('Expression '.$expression.' is malformed.');
            }
        }

        return false;
    }


    /**
     * Create dom xpath instance
     *
     * @return \DOMXPath
     */
    private function createDomXpath()
    {
        $xpath = new \DOMXPath($this->document);

        if ($this->xml_mode) { // register all name spaces
            foreach ($xpath->query('namespace::*') as $node) {
                if ($node->prefix !== 'xml') {
                    $xpath->registerNamespace($node->prefix, $node->namespaceURI);
                }
            }
        }

        return $xpath;
    }

    /**
     * Access xpath or ... DOMNode properties (nodeName, parentNode etc) or get attribute value of first node
     *
     * @param string $name
     *
     * @return \DOMXPath|\DOMNode|string|null
     */
    public function __get($name)
    {
        if ($name === 'dom_xpath') {
            return $this->createDomXpath();
        } elseif ($name === 'outerHTML') {
            return $this->getOuterHtml();
        }

        if ($node = $this->getFirstElmNode()) {
            if (isset($node->$name)) {
                return $node->{$name};
            } elseif ($node instanceof \DOMElement && $node->hasAttribute($name)) {
                return $node->getAttribute($name);
            }
        }

        return null;
    }

    /**
     * Check if property exist for this instance
     *
     * @param string $name
     *
     * @return boolean
     */
    public function __isset($name)
    {
        return $this->__get($name) != null;
    }

    /**
     * Return html of all nodes (HTML fragment describing all the elements, including their descendants)
     *
     * @return string
     */
    public function getOuterHtml()
    {
        $outer_html = '';

        if ($this->xml_mode && $this->xml_print_pi) {
            $outer_html .= '<?xml version="'.$this->document->xmlVersion.'" encoding="'.$this->document->xmlEncoding.'"?>';
            $outer_html .= "\n\n";
        }

        foreach ($this->nodes as $node) {
            if (isset($this->document)) {
                if ($this->xml_mode) {
                    $outer_html .= $this->document->saveXML($node);
                } else {
                    $outer_html .= $this->document->saveHTML($node);
                }
            }
        }

        if ($this->preserve_no_newlines) {
            $outer_html = str_replace("\n", '', $outer_html);
        }

        return $outer_html;
    }

    /**
     * Return html of first domnode
     *
     * @return string
     */
    public function __toString()
    {
        return $this->getOuterHtml();
    }

    /**
     * Replace char with null bytes inside (optionally specified) enclosure
     *
     * @param string $str
     * @param string $search_char
     * @param string $enclosure_open
     * @param string $enclosure_close
     *
     * @return string $str
     */
    private static function replaceCharInsideEnclosure($str, $search_char, $enclosure_open='(', $enclosure_close=')')
    {
        if ($str == '') {
            return $str;
        }

        for ($i = 0; $i < strlen($str); $i++) {
            if ($str[$i] === $search_char && $i > 0) {
                if (substr_count($str, $enclosure_open, 0, $i) != substr_count($str, $enclosure_close, 0, $i)) { // count char before position
                    $str[$i] = "\0";
                }
            }
        }

        return $str;
    }

    /**
     * Transform CSS selector expression to XPath
     *
     * @param string $path css selector expression
     *
     * @return string xpath expression
     */
    public static function cssToXpath(string $path)
    {
        $tmp_path = self::replaceCharInsideEnclosure($path, ',');
        if (strpos($tmp_path, ',') !== false) {
            $paths = explode(',', $tmp_path);
            $expressions = array();

            foreach ($paths as $path) {
                $path = str_replace("\0", ',', $path); // restore commas
                $xpath = static::cssToXpath(trim($path));
                $expressions[] = $xpath;
            }

            return implode('|', $expressions);
        }

        // replace spaces inside (), to correctly create tokens (restore later)

        $path = self::replaceCharInsideEnclosure($path, ' ');

        // create tokens and analyze to create segments

        $tokens = preg_split('/\s+/', $path);

        $segments = array();

        foreach ($tokens as $key => $token) {
            $token = str_replace("\0", ' ', $token); // restore spaces
                
            if ($segment = self::getSegmentFromToken($token, $key, $tokens)) {
                $segments[] = $segment;
            }
        }

        // use segments to create array with transformed tokens

        $new_path_tokens = self::transformCssSegments($segments);

        return implode('', $new_path_tokens);
    }

    /**
     * Get segment data from token (css selector delimited by space and commas)
     *
     * @param string $token
     * @param integer $key
     * @param array $tokens
     *
     * @return object|boolean $segment
     */
    private static function getSegmentFromToken($token, $key, array $tokens)
    {
        $relation_tokens = array('>', '~', '+');

        if (in_array($token, $relation_tokens)) { // not a segment
            return false;
        }

        $segment = (object) array(
            'selector' => '',
            'relation_token' => false,
            'attribute_filters' => array(),
            'pseudo_filters' => array()
        );

        if (isset($tokens[$key-1]) && in_array($tokens[$key-1], $relation_tokens)) { // get relationship token
            $segment->relation_token = $tokens[$key-1];
        }

        $char_tmp_replaced = false;
        if (strpos($token, '\\') !== false) {
            $token = preg_replace_callback( // temporary replace escaped characters<commit_msg>segmentfromtoken optimizations<commit_after><?php

namespace Rct567\DomQuery;

/**
 * Class DomQuery
 *
 * @property \DOMXPath $dom_xpath
 * @property string $tagName
 * @property string $nodeName
 * @property string $nodeValue
 *
 * @method string getAttribute(string $name)
 *
 * @package Rct567\DomQuery
 */
class DomQuery implements \IteratorAggregate, \Countable, \ArrayAccess
{
    /**
     * Instance of DOMDocument
     *
     * @var \DOMDocument
     */
    private $document;

    /**
     * All nodes as instances of DOMNode
     *
     * @var \DOMNode[]
     */
    private $nodes = array();

    /**
     * Number of nodes
     *
     * @var integer
     */
    public $length = 0;

    /**
     * Load and return as xml
     *
     * @var boolean
     */
    public $xml_mode;

    /**
     * Return xml with pi node (XML declaration)
     *
     * @var boolean
     */
    public $xml_print_pi = false;

    /**
     * Xpath expression used to create the result of this instance
     *
     * @var string
     */
    private $xpath_query;

    /**
     * Root instance who began the chain
     *
     * @var self
     */
    private $root_instance;

    /**
     * Css selector given to create result of this instance
     *
     * @var string
     */
    private $css_query;

    /**
     * Jquery style property; css selector given to create result of this instance
     *
     * @var string
     */
    public $selector;

    /**
     * Preserve no newlines (prevent creating newlines in html result)
     */
    public $preserve_no_newlines;

    /**
     * LibXMl options used to load html for DOMDocument
     *
     * @var mixed
     */
    public $libxml_options =
        LIBXML_HTML_NOIMPLIED // turns off the automatic adding of implied html/body
      | LIBXML_HTML_NODEFDTD; // prevents a default doctype being added when one is not found

    /**
     * Constructor
     *
     * @throws \InvalidArgumentException
     */
    public function __construct()
    {
        foreach (func_get_args() as $arg) {
            if ($arg instanceof \DOMDocument) {
                $this->setDomDocument($arg);
            } elseif ($arg instanceof \DOMNodeList) {
                $this->loadDomNodeList($arg);
            } elseif ($arg instanceof \DOMNode) {
                $this->addDomNode($arg);
            } elseif ($arg instanceof \DOMXPath) {
                $this->dom_xpath = $arg;
            } elseif (is_string($arg) && strpos($arg, '>') !== false) {
                $this->loadContent($arg);
            } elseif (is_object($arg)) {
                throw new \InvalidArgumentException('Unknown object '.get_class($arg).' given as argument');
            } else {
                throw new \InvalidArgumentException('Unknown argument '.gettype($arg));
            }
        }
    }

    /**
     * Create new instance
     *
     * @return self
     * @throws \InvalidArgumentException
     */
    public static function create()
    {
        return new self(...func_get_args());
    }

    /**
     * Set dom document
     *
     * @param \DOMDocument $document
     *
     * @return void
     */
    public function setDomDocument(\DOMDocument $document)
    {
        if (isset($this->document) && $this->document !== $document) {
            throw new \Exception('Other DOMDocument already set!');
        }

        $this->document = $document;
    }

    /**
     * Load nodes from dom list
     *
     * @param \DOMNodeList $dom_node_list
     *
     * @return void
     */
    public function loadDomNodeList(\DOMNodeList $dom_node_list)
    {
        if (!isset($this->document)) {
            throw new \Exception('DOMDocument is missing!');
        }

        foreach ($dom_node_list as $node) {
            $this->addDomNode($node);
        }
    }

    /**
     * Add node to result set
     *
     * @param \DOMNode $dom_node
     *
     * @return void
     */
    public function addDomNode(\DOMNode $dom_node)
    {
        $this->nodes[] = $dom_node;
        $this->length = count($this->nodes);
        $this->setDomDocument($dom_node->ownerDocument);
    }

    /**
     * Load html or xml content
     *
     * @param string $content
     * @param string $encoding
     *
     * @return void
     */
    public function loadContent(string $content, $encoding='UTF-8')
    {
        $this->preserve_no_newlines = (strpos($content, '<') !== false && strpos($content, "\n") === false);
        
        if (!is_bool($this->xml_mode)) {
            $this->xml_mode = (stripos($content, '<?xml') === 0);
        }

        $this->xml_print_pi = (stripos($content, '<?xml') === 0);

        $xml_pi_node_added = false;
        if (!$this->xml_mode && $encoding && stripos($content, '<?xml') === false) { // add pi node to make libxml use the correct encoding
            $content = '<?xml encoding="'.$encoding.'">'.$content;
            $xml_pi_node_added = true;
        }

        libxml_disable_entity_loader(true);
        libxml_use_internal_errors(true);

        $dom_document = new \DOMDocument('1.0', $encoding);
        $dom_document->strictErrorChecking = false;
        $dom_document->validateOnParse = false;
        $dom_document->recover = true;

        if ($this->xml_mode) {
            $dom_document->loadXML($content, $this->libxml_options);
        } else {
            $dom_document->loadHTML($content, $this->libxml_options);
        }

        $this->setDomDocument($dom_document);

        if ($xml_pi_node_added) { // pi node added, now remove it
            foreach ($dom_document->childNodes as $node) {
                if ($node->nodeType == XML_PI_NODE) {
                    $dom_document->removeChild($node);
                    break;
                }
            }
        }

        foreach ($dom_document->childNodes as $node) {
            $this->nodes[] = $node;
        }

        $this->length = count($this->nodes);
    }

    /**
     * Use xpath and return new DomQuery with resulting nodes
     *
     * @param string $xpath_query
     *
     * @return self|void
     */
    public function xpath(string $xpath_query)
    {
        if (isset($this->document)) {
            $result = new self($this->document, $this->dom_xpath);
            if (isset($this->root_instance)) {
                $result->root_instance = $this->root_instance;
            } else {
                $result->root_instance = $this;
            }
            $result->xpath_query = $xpath_query;
            $result->xml_mode = $this->xml_mode;

            if ($this->length > 0 && isset($this->xpath_query)) {  // all nodes as context
                foreach ($this->nodes as $node) {
                    if ($result_node_list = $this->xpathQuery('.'.$xpath_query, $node)) {
                        $result->loadDomNodeList($result_node_list);
                    }
                }
            } else { // whole document
                if ($result_node_list = $this->xpathQuery($xpath_query)) {
                    $result->loadDomNodeList($result_node_list);
                }
            }

            return $result;
        }
    }

    /**
     * Use css expression and return new DomQuery with results
     *
     * @param string $css_expression
     *
     * @return self|void
     */
    public function find(string $css_expression)
    {
        if (isset($this->document)) {
            $xpath_expression = self::cssToXpath($css_expression);
            $result = $this->xpath($xpath_expression);

            $result->css_query = $css_expression;
            $result->selector = $css_expression; // jquery style

            return $result;
        }
    }

    /**
     * Get the combined text contents of each element in the set of matched elements, including their descendants,
     * or set the text contents of the matched elements.
     *
     * @param string|null $val
     *
     * @return $this|string|void
     */
    public function text($val=null)
    {
        if (!is_null($val)) { // set node value for all nodes
            foreach ($this->nodes as $node) {
                $node->nodeValue = $val;
            }

            return $this;
        } else { // get value for first node
            if ($node = $this->getFirstElmNode()) {
                return $node->nodeValue;
            }
        }
    }

    /**
     * Get the HTML contents of the first element in the set of matched elements
     *
     * @param string|null $html_string
     *
     * @return $this|string|void
     */
    public function html($html_string=null)
    {
        if (!is_null($html_string)) { // set html for all nodes
            foreach ($this as $node) {
                $node->get(0)->nodeValue = '';
                $node->append($html_string);
            }

            if (strpos($html_string, "\n") !== false) {
                $this->preserve_no_newlines = false;
            }

            return $this;
        } else { // get html for first node
            if ($node = $this->getFirstElmNode()) {
                $html = '';
                $document = $node->ownerDocument;

                foreach ($node->childNodes as $node) {
                    if ($this->xml_mode) {
                        $html .= $document->saveXML($node);
                    } else {
                        $html .= $document->saveHTML($node);
                    }
                }

                if ($this->preserve_no_newlines) {
                    $html = str_replace("\n", '', $html);
                }

                return $html;
            }
        }
    }

    /**
     * Get the value of an attribute for the first element in the set of matched elements
     * or set one or more attributes for every matched element.
     *
     * @param string $name
     * @param string $val
     *
     * @return $this|string
     */
    public function attr(string $name, $val=null)
    {
        if (!is_null($val)) { // set attribute for all nodes
            foreach ($this->nodes as $node) {
                if ($node instanceof \DOMElement) {
                    $node->setAttribute($name, $val);
                }
            }
            return $this;
        } else { // get attribute value for first element
            if ($node = $this->getFirstElmNode()) {
                return $node->getAttribute($name);
            }
        }
    }

    /**
     * Remove an attribute from each element in the set of matched elements
     *
     * @param string $name
     *
     * @return $this
     */
    public function removeAttr($name)
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                $node->removeAttribute($name);
            }
        }

        return $this;
    }

    /**
     * Adds the specified class(es) to each element in the set of matched elements.
     *
     * @param string $class_name class name(s)
     *
     * @return $this
     */
    public function addClass($class_name)
    {
        $add_names = preg_split('#\s+#s', $class_name);

        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                $node_classes = array();
                if ($node_class_attr = $node->getAttribute('class')) {
                    $node_classes = preg_split('#\s+#s', $node_class_attr);
                }
                foreach ($add_names as $add_name) {
                    if (!in_array($add_name, $node_classes)) {
                        $node_classes[] = $add_name;
                    }
                }
                if (count($node_classes) > 0) {
                    $node->setAttribute('class', implode(' ', $node_classes));
                }
            }
        }

        return $this;
    }

    /**
     * Determine whether any of the matched elements are assigned the given class.
     *
     * @param string $class_name
     *
     * @return boolean
     */
    public function hasClass($class_name)
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                if ($node_class_attr = $node->getAttribute('class')) {
                    $node_classes = preg_split('#\s+#s', $node_class_attr);
                    if (in_array($class_name, $node_classes)) {
                        return true;
                    }
                }
            }
        }

        return false;
    }

    /**
     * Remove a single class, multiple classes, or all classes from each element in the set of matched elements.
     *
     * @param string $class_name
     *
     * @return $this
     */
    public function removeClass($class_name='')
    {
        $remove_names = preg_split('#\s+#s', $class_name);
        
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement && $node->hasAttribute('class')) {
                $node_classes = preg_split('#\s+#s', $node->getAttribute('class'));
                $class_removed = false;
            
                if ($class_name === '') { // remove all
                    $node_classes = array();
                    $class_removed = true;
                } else {
                    foreach ($remove_names as $remove_name) {
                        $key = array_search($remove_name, $node_classes);
                        if ($key !== false) {
                            unset($node_classes[$key]);
                            $class_removed = true;
                        }
                    }
                }
                if ($class_removed) {
                    $node->setAttribute('class', implode(' ', $node_classes));
                }
            }
        }

        return $this;
    }

    /**
     * Get the value of a property for the first element in the set of matched elements
     * or set one or more properties for every matched element.
     *
     * @param string $name
     * @param string $val
     *
     * @return $this|mixed
     */
    public function prop(string $name, $val=null)
    {
        if (!is_null($val)) { // set attribute for all nodes
            foreach ($this->nodes as $node) {
                $node->$name = $val;
            }

            return $this;
        } else { // get property value for first element
            if ($name == 'outerHTML') {
                return $this->getOuterHtml();
            } elseif ($node = $this->getFirstElmNode()) {
                if (isset($node->$name)) {
                    return $node->$name;
                }
            }
        }
    }

    /**
     * Get the children of each element in the set of matched elements, optionally filtered by a selector.
     *
     * @param string $selector
     *
     * @return self|void
     */
    public function children(string $selector='*')
    {
        if (strpos($selector, ',') !== false) {
            $selectors = explode(',', $selector);
        } else {
            $selectors = array($selector);
        }

        // make all selectors for direct children
        foreach ($selectors as &$single_selector) {
            $single_selector = '> '.ltrim($single_selector, ' >');
        }

        $direct_children_selector = implode(', ', $selectors);

        return $this->find($direct_children_selector);
    }

    /**
     * Get the parent of each element in the current set of matched elements, optionally filtered by a selector
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function parent(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) {
                if (!is_null($node->parentNode)) {
                    $result->addDomNode($node->parentNode);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Reduce the set of matched elements to those that match the selector
     *
     * @param string $selector
     *
     * @return self
     */
    public function not(string $selector)
    {
        $result = new self($this->document, $this->dom_xpath);

        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $selector_result_node_list = $this->xpathQuery($xpath_query);

            $result->xpath_query = $xpath_query;

            if ($selector_result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    $matched = false;
                    foreach ($selector_result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            $matched = true;
                            break 1;
                        }
                    }
                    if (!$matched) {
                        $result->addDomNode($node);
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Reduce the set of matched elements to those that match the selector
     *
     * @param string $selector
     *
     * @return self
     */
    public function filter(string $selector)
    {
        $result = new self($this->document, $this->dom_xpath);

        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $selector_result_node_list = $this->xpathQuery($xpath_query);

            $result->xpath_query = $xpath_query;

            if ($selector_result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    foreach ($selector_result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            $result->addDomNode($node);
                            break 1;
                        }
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Check if any node matches the selector
     *
     * @param string $selector
     *
     * @return boolean
     */
    public function is(string $selector)
    {
        if ($this->length > 0) {
            $xpath_query = self::cssToXpath($selector);
            $result_node_list = $this->xpathQuery($xpath_query);

            if ($result_node_list->length > 0) {
                foreach ($this->nodes as $node) {
                    foreach ($result_node_list as $result_node) {
                        if ($result_node->isSameNode($node)) {
                            return true;
                        }
                    }
                }
            }
        }

        return false;
    }

    /**
     * Grants access to the DOM nodes of this instance
     *
     * @param int $index
     *
     * @return \DOMNode|null
     */
    public function get($index)
    {
        $result = array_slice($this->nodes, $index, 1); // note: index can be negative
        if (count($result) > 0) {
            return $result[0];
        } else {
            return null; // return null if nu result for key
        }
    }

    /**
     * Returns DomQuery with first node
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function first(string $selector=null)
    {
        if (isset($this[0])) {
            $result = $this[0];
            if ($selector) {
                $result = $result->filter($selector);
            }
            return $result;
        }
    }

    /**
     * Returns DomQuery with last node
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function last(string $selector=null)
    {
        if ($this->length > 0 && isset($this[$this->length-1])) {
            $result = $this[$this->length-1];
            if ($selector) {
                $result = $result->filter($selector);
            }
            return $result;
        }
    }

    /**
     * Returns DomQuery with immediately following sibling of all nodes
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function next(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) {
                if (!is_null($node->nextSibling)) {
                    $result->addDomNode($node->nextSibling);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Returns DomQuery with immediately preceding sibling of all nodes
     *
     * @param string|null $selector expression that filters the set of matched elements
     *
     * @return self|void
     */
    public function prev(string $selector=null)
    {
        if (isset($this->document) && $this->length > 0) {
            $result = new self($this->document);

            foreach ($this->nodes as $node) { // get all previous sibling of all nodes
                if (!is_null($node->previousSibling)) {
                    $result->addDomNode($node->previousSibling);
                }
            }

            if ($selector) {
                $result = $result->filter($selector);
            }

            return $result;
        }
    }

    /**
     * Iterate over result set and executing a callback for each node
     *
     * @param callable $callback
     *
     * @return $this
     */
    public function each(callable $callback)
    {
        foreach ($this->nodes as $index => $node) {
            $return_value = call_user_func($callback, $node, $index);

            if ($return_value === false) {
                break;
            }
        }

        return $this;
    }

    /**
     * Remove the set of matched elements
     *
     * @param string|null $selector expression that filters the set of matched elements to be removed
     *
     * @return self
     */
    public function remove(string $selector=null)
    {
        $result = $this;
        if ($selector) {
            $result = $result->filter($selector);
        }
        foreach ($result->nodes as $node) {
            if ($node->parentNode) {
                $node->parentNode->removeChild($node);
            }
        }

        $result->nodes = array();
        $result->length = 0;

        return $result;
    }

    /**
     * Import nodes and insert or append them via callback function
     *
     * @param string|self|array $content
     * @param callable $import_function
     *
     * @return void
     */
    private function importNodes($content, callable $import_function)
    {
        if (is_array($content)) {
            foreach ($content as $item) {
                $this->importNodes($item, $import_function);
            }
        } else {
            if (is_string($content) && strpos($content, "\n") !== false) {
                $this->preserve_no_newlines = false;
                if (isset($this->root_instance)) {
                    $this->root_instance->preserve_no_newlines = false;
                }
            }

            if (!($content instanceof DomQuery)) {
                $content = new self($content);
            }

            foreach ($this->nodes as $node) {
                foreach ($content->getNodes() as $content_node) {
                    $imported_node = $this->document->importNode($content_node, true);
                    $import_function($node, $imported_node);
                }
            }
        }
    }

    /**
     * Insert content to the end of each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function append()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            $node->appendChild($imported_node);
        });

        return $this;
    }

    /**
     * Insert content to the beginning of each element in the set of matched elements
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function prepend()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            $node->insertBefore($imported_node, $node->childNodes->item(0));
        });

        return $this;
    }

    /**
     * Insert content before each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function before()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            if ($node->parentNode instanceof \DOMDocument) {
                throw new \Exception('Can not set before root element '.$node->tagName.' of document');
            } else {
                $node->parentNode->insertBefore($imported_node, $node);
            }
        });

        return $this;
    }

    /**
     * Insert content after each element in the set of matched elements.
     *
     * @param string|self $content,...
     *
     * @return $this
     */
    public function after()
    {
        $this->importNodes(func_get_args(), function ($node, $imported_node) {
            if ($node->nextSibling) {
                $node->parentNode->insertBefore($imported_node, $node->nextSibling);
            } else { // node is last, so there is no next sibling to insert before
                $node->parentNode->appendChild($imported_node);
            }
        });

        return $this;
    }

    /**
     * Return array with nodes
     *
     * @return \DOMNode[]
     */
    private function getNodes()
    {
        return $this->nodes;
    }

    /**
     * Return first DOMElement
     *
     * @return \DOMElement|void
     */
    private function getFirstElmNode()
    {
        foreach ($this->nodes as $node) {
            if ($node instanceof \DOMElement) {
                return $node;
            }
        }
    }

    /**
     * Call method on first DOMElement
     *
     * @param string $name
     * @param $arguments
     *
     * @return mixed
     * @throws \Exception
     */
    public function __call($name, $arguments)
    {
        if (method_exists($this->getFirstElmNode(), $name)) {
            return call_user_func_array(array($this->getFirstElmNode(), $name), $arguments);
        } else {
            throw new \Exception('Unknown call '.$name);
        }
    }

    /**
     * Perform query via xpath expression (using DOMXPath::query)
     *
     * @param string $expression
     * @param \DOMNode|null $context_node
     *
     * @return \DOMNodeList|false
     * @throws \Exception
     */
    public function xpathQuery(string $expression, \DOMNode $context_node=null)
    {
        if ($this->dom_xpath) {
            $node_list = $this->dom_xpath->query($expression, $context_node);

            if ($node_list instanceof \DOMNodeList) {
                return $node_list;
            } elseif ($node_list === false && !is_null($context_node)) {
                throw new \Exception('Expression '.$expression.' is malformed or contextnode is invalid.');
            } elseif ($node_list === false) {
                throw new \Exception('Expression '.$expression.' is malformed.');
            }
        }

        return false;
    }


    /**
     * Create dom xpath instance
     *
     * @return \DOMXPath
     */
    private function createDomXpath()
    {
        $xpath = new \DOMXPath($this->document);

        if ($this->xml_mode) { // register all name spaces
            foreach ($xpath->query('namespace::*') as $node) {
                if ($node->prefix !== 'xml') {
                    $xpath->registerNamespace($node->prefix, $node->namespaceURI);
                }
            }
        }

        return $xpath;
    }

    /**
     * Access xpath or ... DOMNode properties (nodeName, parentNode etc) or get attribute value of first node
     *
     * @param string $name
     *
     * @return \DOMXPath|\DOMNode|string|null
     */
    public function __get($name)
    {
        if ($name === 'dom_xpath') {
            return $this->createDomXpath();
        } elseif ($name === 'outerHTML') {
            return $this->getOuterHtml();
        }

        if ($node = $this->getFirstElmNode()) {
            if (isset($node->$name)) {
                return $node->{$name};
            } elseif ($node instanceof \DOMElement && $node->hasAttribute($name)) {
                return $node->getAttribute($name);
            }
        }

        return null;
    }

    /**
     * Check if property exist for this instance
     *
     * @param string $name
     *
     * @return boolean
     */
    public function __isset($name)
    {
        return $this->__get($name) != null;
    }

    /**
     * Return html of all nodes (HTML fragment describing all the elements, including their descendants)
     *
     * @return string
     */
    public function getOuterHtml()
    {
        $outer_html = '';

        if ($this->xml_mode && $this->xml_print_pi) {
            $outer_html .= '<?xml version="'.$this->document->xmlVersion.'" encoding="'.$this->document->xmlEncoding.'"?>';
            $outer_html .= "\n\n";
        }

        foreach ($this->nodes as $node) {
            if (isset($this->document)) {
                if ($this->xml_mode) {
                    $outer_html .= $this->document->saveXML($node);
                } else {
                    $outer_html .= $this->document->saveHTML($node);
                }
            }
        }

        if ($this->preserve_no_newlines) {
            $outer_html = str_replace("\n", '', $outer_html);
        }

        return $outer_html;
    }

    /**
     * Return html of first domnode
     *
     * @return string
     */
    public function __toString()
    {
        return $this->getOuterHtml();
    }

    /**
     * Replace char with null bytes inside (optionally specified) enclosure
     *
     * @param string $str
     * @param string $search_char
     * @param string $enclosure_open
     * @param string $enclosure_close
     *
     * @return string $str
     */
    private static function replaceCharInsideEnclosure($str, $search_char, $enclosure_open='(', $enclosure_close=')')
    {
        if ($str == '' || strpos($str, $search_char) === false || strpos($str, $enclosure_open) === false) {
            return $str;
        }

        for ($i = 0; $i < strlen($str); $i++) {
            if ($str[$i] === $search_char && $i > 0) {
                if (substr_count($str, $enclosure_open, 0, $i) != substr_count($str, $enclosure_close, 0, $i)) { // count char before position
                    $str[$i] = "\0";
                }
            }
        }

        return $str;
    }

    /**
     * Transform CSS selector expression to XPath
     *
     * @param string $path css selector expression
     *
     * @return string xpath expression
     */
    public static function cssToXpath(string $path)
    {
        $tmp_path = self::replaceCharInsideEnclosure($path, ',');
        if (strpos($tmp_path, ',') !== false) {
            $paths = explode(',', $tmp_path);
            $expressions = array();

            foreach ($paths as $path) {
                $path = str_replace("\0", ',', $path); // restore commas
                $xpath = static::cssToXpath(trim($path));
                $expressions[] = $xpath;
            }

            return implode('|', $expressions);
        }

        // replace spaces inside (), to correctly create tokens (restore later)

        $path = self::replaceCharInsideEnclosure($path, ' ');

        // create tokens and analyze to create segments

        $tokens = preg_split('/\s+/', $path);

        $segments = array();

        foreach ($tokens as $key => $token) {
            $token = str_replace("\0", ' ', $token); // restore spaces
                
            if ($segment = self::getSegmentFromToken($token, $key, $tokens)) {
                $segments[] = $segment;
            }
        }

        // use segments to create array with transformed tokens

        $new_path_tokens = self::transformCssSegments($segments);

        return implode('', $new_path_tokens);
    }

    /**
     * Get segment data from token (css selector delimited by space and commas)
     *
     * @param string $token
     * @param integer $key
     * @param array $tokens
     *
     * @return object|boolean $segment
     */
    private static function getSegmentFromToken($token, $key, array $tokens)
    {
        $relation_tokens = array('>', '~', '+');

        if (in_array($token, $relation_tokens)) { // not a segment
            return false;
        }

        $segment = (object) array(
            'selector' => '',
            'relation_token' => false,
            'attribute_filters' => array(),
            'pseudo_filters' => array()
        );

        if (isset($tokens[$key-1]) && in_array($tokens[$key-1], $relation_tokens)) { // get relationship token
            $segment->relation_token = $tokens[$key-1];
        }

        if (ctype_alpha($token)) { // simple element selector
            $segment->selector = $token;
            return $segment;
        }

        $char_tmp_replaced = false;
        if (strpos($token, '\\') !== false) {
            $token = preg_replace_callback( // temporary replace escaped characters
{'fraction_non_alphanumeric': 0.12687381368724684, 'fraction_numerical': 0.00137500186820906, 'mean_word_length': 1.5923056061369183, 'pattern_counts': {'":': 0, '<': 19, '<?xml version=': 2, '>': 496, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 68}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '104950', 'n_tokens_mistral': 18707, 'n_tokens_neox': 18048, 'n_words': 4503}