diff --git a/README.md b/README.md index 23155ea..7c3f36f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ The primary aim of this library is accuracy. However, due either to limitations 11. All of the `Range` APIs will also not be implemented due to the sheer complexity of creating them in userland and how it adds undue difficulty to node manipulation in the "core" DOM. Numerous operations reference in excrutiating detail what to do with Ranges when manipulating nodes and would have to be added here to be compliant or mostly so -- slowing everything else down in the process on an already extremely front-heavy library. 12. The `DOMParser` and `XMLSerializer` APIs will not be implemented because they are ridiculous and limited in their scope. For instance, `DOMParser::parseFromString` won't set a document's character set to anything but UTF-8. This library needs to be able to print to other encodings due to the nature of how it is used. `Document::__construct` will accept optional `$source` and `$charset` arguments, and there are both `Document::load` and `Document::loadFile` methods for loading DOM from a string or a file respectively. 13. Aside from `HTMLElement`, `HTMLPreElement`, `HTMLTemplateElement`, `HTMLUnknownElement`, `MathMLElement`, and `SVGElement` none of the specific derived element classes (such as `HTMLAnchorElement` or `SVGSVGElement`) are implemented. The ones listed before are required for the element interface algorithm. The focus on this library will be on the core DOM before moving onto those -- if ever. -14. This class is meant to be used with HTML, but it will -MOSTLY- as needed work with XML. Loading of XML uses PHP DOM's XML parser which does not conform to the XML specification. Writing an actual conforming XML parser is outside of the scope of this library. +14. This class is meant to be used with HTML, but it will -MOSTLY- as needed work with XML. Loading of XML uses PHP DOM's XML parser which does not completely conform to the XML specification. Writing an actual conforming XML parser is outside of the scope of this library. 15. While there is implementation of much of the XPath extensions, there will only be support for XPath 1.0 because that is all PHP DOM's XPath supports. -16. The XPath DOM specification allows for the use of the `XPathNSResolver` to automatically resolve namespaces for prefixes. To polyfill this behavior for use with PHP's XPath implementation would require writing at least partially a XPath 1.0 parser to grab any prefixes and then use `DOMXPath::registerNamespace` to associate namespaces. This might be something done at a later date. In the meantime this implementation instead exposes this ability to assocate namespaces with prefixes through the `Document::registerXPathNamespace` and `XPathEvaluator::registerXPathNamespace` methods. However, to eliminate common uses of namespace association the `xmlns` namespace is automatically associated. \ No newline at end of file +16. This library's XPath API is -- like the rest of the library itself -- a wrapper that wraps PHP's implementation but instead works like the specification, so there is no need to manually register namespaces. Namespaces that are associated with prefixes will be looked up when evaluating the expression if a `XPathNSResolver` is specified. However, access to registering PHP functions for use within XPath isn't in the specification but is available through `Document::registerXPathFunctions` and `XPathEvaluator::registerXPathFunctions`. +17. `XPathEvaluatorBase::evaluate` has a `result` argument where one provides it with an existing result object to use. I can't find any usable documentation on what this is supposed to do, and the specifications on it are vague. So, at present it does nothing until what it needs to do can be deduced. +18. At present XPath expressions cannot select elements or attributes which use any valid non-ascii character. This is because those nodes are coerced internally to work within PHP's DOM which doesn't support those characters. This can be worked around by coercing names in XPath queries, but that can only be reliably accomplished through an XPath parser. Writing an entire XPath parser for what amounts to an edge case isn't desirable. \ No newline at end of file diff --git a/lib/Document.php b/lib/Document.php index 969ecfb..7a61921 100644 --- a/lib/Document.php +++ b/lib/Document.php @@ -738,10 +738,6 @@ class Document extends Node implements \ArrayAccess { $this->xpathRegisterPhpFunctions($this, $restrict); } - public function registerXPathNamespace(string $prefix, string $namespace): bool { - return $this->xpathRegisterNamespace($this, $prefix, $namespace); - } - public function serialize(?Node $node = null, array $config = []): string { $node = $node ?? $this; if ($node !== $this) { diff --git a/lib/XPathEvaluate.php b/lib/XPathEvaluate.php index 63dbd83..749ce19 100644 --- a/lib/XPathEvaluate.php +++ b/lib/XPathEvaluate.php @@ -11,21 +11,34 @@ use MensBeam\HTML\DOM\Inner\Reflection; trait XPathEvaluate { - protected function xpathEvaluate(string $expression, Node $contextNode, int $type = XPathResult::ANY_TYPE, ?XPathResult $result = null): XPathResult { + protected function xpathErrorHandler(int $errno, string $errstr, string $errfile, int $errline) { + $lowerErrstr = strtolower($errstr); + if (str_contains(needle: 'invalid expression', haystack: $lowerErrstr)) { + throw new XPathException(XPathException::INVALID_EXPRESSION); + } + + if (str_contains(needle: 'undefined namespace prefix', haystack: $lowerErrstr)) { + throw new XPathException(XPathException::UNRESOLVABLE_NAMESPACE_PREFIX); + } + } // @codeCoverageIgnore + + protected function xpathEvaluate(string $expression, Node $contextNode, ?XPathNSResolver $resolver = null, int $type = XPathResult::ANY_TYPE, ?XPathResult $result = null): XPathResult { $innerContextNode = Reflection::getProtectedProperty($contextNode, 'innerNode'); $doc = ($innerContextNode instanceof \DOMDocument) ? $innerContextNode : $innerContextNode->ownerDocument; - set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline) { - $lowerErrstr = strtolower($errstr); - - if (str_contains(needle: 'invalid expression', haystack: $lowerErrstr)) { - throw new XPathException(XPathException::INVALID_EXPRESSION); + if ($resolver !== null && preg_match_all('/([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]+):([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]+)/u', $expression, $m, \PREG_SET_ORDER)) { + foreach ($m as $prefix) { + $prefix = $prefix[1]; + if ($namespace = $contextNode->lookupNamespaceURI($prefix)) { + $doc->xpath->registerNamespace($prefix, $namespace); + } } + } - if (str_contains(needle: 'undefined namespace prefix', haystack: $lowerErrstr)) { - throw new XPathException(XPathException::UNDEFINED_NAMESPACE_PREFIX); - } - }); + // PHP's DOM XPath incorrectly issues a warnings rather than exceptions when + // expressions are incorrect, so we must use a custom error handler here to + // "catch" it and throw an exception in its place. + set_error_handler([ $this, 'xpathErrorHandler' ]); $result = $doc->xpath->evaluate($expression, $innerContextNode); restore_error_handler(); @@ -129,8 +142,4 @@ trait XPathEvaluate { protected function xpathRegisterPhpFunctions(Document $document, string|array|null $restrict = null): void { Reflection::getProtectedProperty($document, 'innerNode')->xpath->registerPhpFunctions($restrict); } - - protected function xpathRegisterNamespace(Document $document, string $prefix, string $namespace): bool { - return Reflection::getProtectedProperty($document, 'innerNode')->xpath->registerNamespace($prefix, $namespace); - } } \ No newline at end of file diff --git a/lib/XPathEvaluator.php b/lib/XPathEvaluator.php index 5d023d9..2214774 100644 --- a/lib/XPathEvaluator.php +++ b/lib/XPathEvaluator.php @@ -16,8 +16,4 @@ class XPathEvaluator { public function registerXPathFunctions(Document $document, string|array|null $restrict = null): void { $this->xpathRegisterPhpFunctions($document, $restrict); } - - public function registerXPathNamespace(Document $document, string $prefix, string $namespace): bool { - return $this->xpathRegisterNamespace($document, $prefix, $namespace); - } } \ No newline at end of file diff --git a/lib/XPathEvaluatorBase.php b/lib/XPathEvaluatorBase.php index 1248296..1d7910d 100644 --- a/lib/XPathEvaluatorBase.php +++ b/lib/XPathEvaluatorBase.php @@ -16,10 +16,15 @@ trait XPathEvaluatorBase { public function createExpression(string $expression, ?XPathNSResolver $resolver = null): XPathExpression { // XPathExpression cannot be created from their constructors normally. - return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\XPathExpression', $expression); + return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\XPathExpression', $expression, $resolver); + } + + public function createNSResolver(Node $nodeResolver): XPathNSResolver { + // XPathNSResolver cannot be created from their constructors normally. + return Reflection::createFromProtectedConstructor(__NAMESPACE__ . '\\XPathNSResolver', $nodeResolver); } public function evaluate(string $expression, Node $contextNode, ?XPathNSResolver $resolver = null, int $type = XPathResult::ANY_TYPE, ?XPathResult $result = null): XPathResult { - return $this->xpathEvaluate($expression, $contextNode, $type, $result); + return $this->xpathEvaluate($expression, $contextNode, $resolver, $type, $result); } } \ No newline at end of file diff --git a/lib/XPathException.php b/lib/XPathException.php index 8c10194..51eea46 100644 --- a/lib/XPathException.php +++ b/lib/XPathException.php @@ -13,13 +13,13 @@ use MensBeam\Framework\Exception; class XPathException extends Exception { public const INVALID_EXPRESSION = 51; public const TYPE_ERROR = 52; - public const UNDEFINED_NAMESPACE_PREFIX = 53; + public const UNRESOLVABLE_NAMESPACE_PREFIX = 53; public function __construct(int $code, ...$args) { self::$messages = array_replace(parent::$messages, [ 51 => 'Invalid expression error', 52 => 'Expression cannot be converted to the specified type', - 53 => 'Undefined namespace prefix' + 53 => 'Unresolvable namespace prefix' ]); parent::__construct($code, ...$args); diff --git a/lib/XPathExpression.php b/lib/XPathExpression.php index 38e09f1..d91c19b 100644 --- a/lib/XPathExpression.php +++ b/lib/XPathExpression.php @@ -7,40 +7,52 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; +use MensBeam\HTML\DOM\Inner\Reflection; class XPathExpression { use XPathEvaluate; protected string $expression; - - - protected function __construct(string $expression) { - // Test the expression by attempting to run it on an empty document. PHP's DOM - // XPath incorrectly issues a warning on an invalid expression rather than an - // exception, so we must use a custom error handler here to "catch" it and throw - // an exception in its place. - set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline) { - $lowerErrstr = strtolower($errstr); - - if (str_contains(needle: 'invalid expression', haystack: $lowerErrstr)) { - throw new XPathException(XPathException::INVALID_EXPRESSION); + protected ?XPathNSResolver $resolver; + + + protected function __construct(string $expression, ?XPathNSResolver $resolver) { + if ($resolver !== null && preg_match_all('/([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]+):([A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}][A-Z_a-z\x{C0}-\x{D6}\x{D8}-\x{F6}\x{F8}-\x{2FF}\x{370}-\x{37D}\x{37F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}-\.0-9\x{B7}\x{0300}-\x{036F}\x{203F}-\x{2040}]+)/u', $expression, $m, \PREG_SET_ORDER)) { + // This part is especially nasty because of egregious use of reflection to get + // protected properties, but neither should be exposed publicly; this is a crazy + // polyfill hack that wouldn't normally be necessary otherwise. + $nodeResolver = Reflection::getProtectedProperty($resolver, 'nodeResolver'); + $innerNodeResolver = Reflection::getProtectedProperty($nodeResolver, 'innerNode'); + $doc = ($innerNodeResolver instanceof \DOMDocument) ? $innerNodeResolver : $innerNodeResolver->ownerDocument; + + foreach ($m as $prefix) { + $prefix = $prefix[1]; + if ($namespace = $resolver->lookupNamespaceURI($prefix)) { + $doc->xpath->registerNamespace($prefix, $namespace); + } } - // Ignore undefined namespace prefix warnings here because there's no way to - // register namespace prefixes before the expression is created. - }); - - $xpath = new \DOMXPath(new \DOMDocument()); - $xpath->evaluate($expression); - - restore_error_handler(); + set_error_handler([ $this, 'xpathErrorHandler' ]); + $doc->xpath->evaluate($expression); + restore_error_handler(); + } else { + // Test the expression by attempting to run it on an empty document. PHP's DOM + // XPath incorrectly issues a warnings rather than exceptions when expressions + // are incorrect, so we must use a custom error handler here to "catch" it and + // throw an exception in its place. + set_error_handler([ $this, 'xpathErrorHandler' ]); + $xpath = new \DOMXPath(new \DOMDocument()); + $xpath->evaluate($expression); + restore_error_handler(); + } $this->expression = $expression; + $this->resolver = $resolver; } - protected function evaluate(Node $contextNode, int $type = XPathResult::ANY_TYPE, ?XPathResult $result = null): XPathResult { - return $this->xpathEvaluate($this->expression, $contextNode, $type, $result); + public function evaluate(Node $contextNode, int $type = XPathResult::ANY_TYPE, ?XPathResult $result = null): XPathResult { + return $this->xpathEvaluate($this->expression, $contextNode, $this->resolver, $type, $result); } } \ No newline at end of file diff --git a/lib/XPathNSResolver.php b/lib/XPathNSResolver.php index 93bec3a..d930707 100644 --- a/lib/XPathNSResolver.php +++ b/lib/XPathNSResolver.php @@ -9,4 +9,16 @@ declare(strict_types=1); namespace MensBeam\HTML\DOM; -interface XPathNSResolver {} \ No newline at end of file +class XPathNSResolver { + protected Node $nodeResolver; + + + protected function __construct(Node $nodeResolver) { + $this->nodeResolver = $nodeResolver; + } + + + public function lookupNamespaceURI(?string $prefix): ?string { + return $this->nodeResolver->lookupNamespaceURI($prefix); + } +} \ No newline at end of file diff --git a/lib/XPathResult.php b/lib/XPathResult.php index 68f139d..3b4c6d3 100644 --- a/lib/XPathResult.php +++ b/lib/XPathResult.php @@ -63,12 +63,12 @@ class XPathResult implements \ArrayAccess, \Countable, \Iterator { return $node->ownerDocument->getWrapperNode($node); } - protected function __get_snapshotLength(): bool { + protected function __get_snapshotLength(): int { if (!in_array($this->_resultType, [ self::ORDERED_NODE_SNAPSHOT_TYPE, self::UNORDERED_NODE_SNAPSHOT_TYPE ])) { throw new XPathException(XPathException::TYPE_ERROR); } - return $this->count; + return $this->count(); } protected function __get_stringValue(): string { @@ -102,6 +102,10 @@ class XPathResult implements \ArrayAccess, \Countable, \Iterator { throw new XPathException(XPathException::TYPE_ERROR); } + if ($this->position + 1 > $this->count()) { + return null; + } + $node = $this->storage[$this->position++]; return $node->ownerDocument->getWrapperNode($node); } diff --git a/tests/cases/TestXPathEvaluate.php b/tests/cases/TestXPathEvaluate.php index ed0d25f..075de10 100644 --- a/tests/cases/TestXPathEvaluate.php +++ b/tests/cases/TestXPathEvaluate.php @@ -22,18 +22,40 @@ class TestXPathEvaluate extends \PHPUnit\Framework\TestCase { /** * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathEvaluate * + * @covers \MensBeam\HTML\DOM\Attr::__get_localName + * @covers \MensBeam\HTML\DOM\Attr::__get_namespaceURI + * @covers \MensBeam\HTML\DOM\Attr::__get_ownerElement + * @covers \MensBeam\HTML\DOM\Attr::__set_value * @covers \MensBeam\HTML\DOM\Document::__construct * @covers \MensBeam\HTML\DOM\Document::__get_body + * @covers \MensBeam\HTML\DOM\Document::__get_documentElement + * @covers \MensBeam\HTML\DOM\Document::createAttributeNS + * @covers \MensBeam\HTML\DOM\Document::createElementNS * @covers \MensBeam\HTML\DOM\Document::load * @covers \MensBeam\HTML\DOM\Document::registerXPathFunctions + * @covers \MensBeam\HTML\DOM\DocumentOrElement::validateAndExtract * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct * @covers \MensBeam\HTML\DOM\Element::__construct + * @covers \MensBeam\HTML\DOM\Element::getAttributeNodeNS + * @covers \MensBeam\HTML\DOM\Element::setAttributeNode + * @covers \MensBeam\HTML\DOM\Element::setAttributeNodeNS + * @covers \MensBeam\HTML\DOM\Element::setAttributeNS * @covers \MensBeam\HTML\DOM\Node::__construct + * @covers \MensBeam\HTML\DOM\Node::__get_ownerDocument + * @covers \MensBeam\HTML\DOM\Node::appendChild * @covers \MensBeam\HTML\DOM\Node::getInnerDocument + * @covers \MensBeam\HTML\DOM\Node::getInnerNode * @covers \MensBeam\HTML\DOM\Node::hasChildNodes + * @covers \MensBeam\HTML\DOM\Node::locateNamespace + * @covers \MensBeam\HTML\DOM\Node::lookupNamespaceURI + * @covers \MensBeam\HTML\DOM\Node::postInsertionBugFixes * @covers \MensBeam\HTML\DOM\Node::postParsingTemplatesFix + * @covers \MensBeam\HTML\DOM\Node::preInsertionBugFixes + * @covers \MensBeam\HTML\DOM\Node::preInsertionValidity * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathRegisterPhpFunctions + * @covers \MensBeam\HTML\DOM\XPathEvaluatorBase::createNSResolver * @covers \MensBeam\HTML\DOM\XPathEvaluatorBase::evaluate + * @covers \MensBeam\HTML\DOM\XPathNSResolver::__construct * @covers \MensBeam\HTML\DOM\XPathResult::__construct * @covers \MensBeam\HTML\DOM\XPathResult::__get_booleanValue * @covers \MensBeam\HTML\DOM\XPathResult::__get_numberValue @@ -80,6 +102,10 @@ class TestXPathEvaluate extends \PHPUnit\Framework\TestCase { $this->assertEquals(3, count($result)); $result = $d->evaluate('.//span', $d->body, null, XPathResult::FIRST_ORDERED_NODE_TYPE); $this->assertSame($d->body->firstChild, $result->singleNodeValue); + + $d->documentElement->setAttributeNS(Node::XMLNS_NAMESPACE, 'xmlns:poop', 'https://poop.poop'); + $poop = $d->body->appendChild($d->createElementNS('https://poop.poop', 'poop:poop')); + $this->assertSame($poop, $d->evaluate('//poop:poop', $d->body, $d->createNSResolver($d->body), XPathResult::FIRST_ORDERED_NODE_TYPE)->singleNodeValue); } @@ -132,7 +158,7 @@ class TestXPathEvaluate extends \PHPUnit\Framework\TestCase { $d->evaluate('//svg:svg', $d, null); }, XPathException::class, - XPathException::UNDEFINED_NAMESPACE_PREFIX ], + XPathException::UNRESOLVABLE_NAMESPACE_PREFIX ], [ function() { $d = new Document(); @@ -165,33 +191,4 @@ class TestXPathEvaluate extends \PHPUnit\Framework\TestCase { $this->expectExceptionCode($errorCode); $closure(); } - - - /** - * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathRegisterNamespace - * - * @covers \MensBeam\HTML\DOM\Document::__construct - * @covers \MensBeam\HTML\DOM\Document::load - * @covers \MensBeam\HTML\DOM\Document::registerXPathNamespace - * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct - * @covers \MensBeam\HTML\DOM\Node::__construct - * @covers \MensBeam\HTML\DOM\Node::getInnerDocument - * @covers \MensBeam\HTML\DOM\Node::hasChildNodes - * @covers \MensBeam\HTML\DOM\Node::postParsingTemplatesFix - * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathEvaluate - * @covers \MensBeam\HTML\DOM\XPathEvaluatorBase::evaluate - * @covers \MensBeam\HTML\DOM\XPathResult::__construct - * @covers \MensBeam\HTML\DOM\Inner\Document::__construct - * @covers \MensBeam\HTML\DOM\Inner\Document::__get_xpath - * @covers \MensBeam\HTML\DOM\Inner\NodeCache::has - * @covers \MensBeam\HTML\DOM\Inner\NodeCache::key - * @covers \MensBeam\HTML\DOM\Inner\NodeCache::set - * @covers \MensBeam\HTML\DOM\Inner\Reflection::createFromProtectedConstructor - * @covers \MensBeam\HTML\DOM\Inner\Reflection::getProtectedProperty - */ - function testMethod_xpathRegisterNamespace(): void { - $d = new Document(''); - $d->registerXPathNamespace('svg', Node::SVG_NAMESPACE); - $this->assertEquals(1, count($d->evaluate('//svg:svg', $d))); - } } \ No newline at end of file diff --git a/tests/cases/TestXPathEvaluator.php b/tests/cases/TestXPathEvaluator.php index 3734a90..96938e5 100644 --- a/tests/cases/TestXPathEvaluator.php +++ b/tests/cases/TestXPathEvaluator.php @@ -10,7 +10,6 @@ namespace MensBeam\HTML\DOM\TestCase; use MensBeam\HTML\DOM\{ Document, - Node, XPathEvaluator }; @@ -22,12 +21,4 @@ class TestXPathEvaluator extends \PHPUnit\Framework\TestCase { $e = new XPathEvaluator(); $this->assertNull($e->registerXPathFunctions($d)); } - - - function testMethod_xpathRegisterNamespace(): void { - $d = new Document(''); - $e = new XPathEvaluator(); - $e->registerXPathNamespace($d, 'svg', Node::SVG_NAMESPACE); - $this->assertEquals(1, count($e->evaluate('//svg:svg', $d))); - } } \ No newline at end of file diff --git a/tests/cases/TestXPathExpression.php b/tests/cases/TestXPathExpression.php new file mode 100644 index 0000000..08fb26d --- /dev/null +++ b/tests/cases/TestXPathExpression.php @@ -0,0 +1,159 @@ +'); + $d->documentElement->setAttributeNS(Node::XMLNS_NAMESPACE, 'xmlns:poop', 'https://poop.poop'); + $poop = $d->body->appendChild($d->createElementNS('https://poop.poop', 'poop:poop')); + + $this->assertSame(XPathExpression::class, $d->createExpression('//poop:poop', $d->createNSResolver($d))::class); + $this->assertSame(XPathExpression::class, $d->createExpression('//span')::class); + } + + + function provideMethod_constructor__errors(): iterable { + return [ + [ function() { + $d = new Document(); + $d->createExpression('//fail:fail'); + }, + XPathException::UNRESOLVABLE_NAMESPACE_PREFIX ], + + [ function() { + $d = new Document(); + $d->createExpression('//fail:fail', $d->createNSResolver($d)); + }, + XPathException::UNRESOLVABLE_NAMESPACE_PREFIX ], + + [ function() { + $d = new Document(); + $d->createExpression('fail?'); + }, + XPathException::INVALID_EXPRESSION ], + + [ function() { + $d = new Document(); + $d->createExpression('fail?', $d->createNSResolver($d)); + }, + XPathException::INVALID_EXPRESSION ], + ]; + } + + + /** + * @dataProvider provideMethod_constructor__errors + * @covers \MensBeam\HTML\DOM\XPathExpression::__construct + * + * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct + * @covers \MensBeam\HTML\DOM\Node::__construct + * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathErrorHandler + * @covers \MensBeam\HTML\DOM\XPathEvaluatorBase::createExpression + * @covers \MensBeam\HTML\DOM\XPathException::__construct + * @covers \MensBeam\HTML\DOM\Inner\Document::__construct + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::has + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::key + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::set + * @covers \MensBeam\HTML\DOM\Inner\Reflection::createFromProtectedConstructor + */ + function testMethod_constructor__errors(\Closure $closure, int $errorCode): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode($errorCode); + $closure(); + } + + + /** + * @covers \MensBeam\HTML\DOM\XPathExpression::evaluate + * + * @covers \MensBeam\HTML\DOM\Document::__construct + * @covers \MensBeam\HTML\DOM\Document::load + * @covers \MensBeam\HTML\DOM\DOMImplementation::__construct + * @covers \MensBeam\HTML\DOM\Node::__construct + * @covers \MensBeam\HTML\DOM\Node::getInnerDocument + * @covers \MensBeam\HTML\DOM\Node::hasChildNodes + * @covers \MensBeam\HTML\DOM\Node::postParsingTemplatesFix + * @covers \MensBeam\HTML\DOM\XPathEvaluate::xpathEvaluate + * @covers \MensBeam\HTML\DOM\XPathEvaluatorBase::createExpression + * @covers \MensBeam\HTML\DOM\XPathExpression::__construct + * @covers \MensBeam\HTML\DOM\XPathResult::__construct + * @covers \MensBeam\HTML\DOM\XPathResult::__get_booleanValue + * @covers \MensBeam\HTML\DOM\Inner\Document::__construct + * @covers \MensBeam\HTML\DOM\Inner\Document::__get_xpath + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::has + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::key + * @covers \MensBeam\HTML\DOM\Inner\NodeCache::set + * @covers \MensBeam\HTML\DOM\Inner\Reflection::createFromProtectedConstructor + * @covers \MensBeam\HTML\DOM\Inner\Reflection::getProtectedProperty + */ + function testMethod_evaluate(): void { + $d = new Document('Ook'); + $e = $d->createExpression('//span'); + $this->assertTrue($e->evaluate($d, XPathResult::BOOLEAN_TYPE)->booleanValue); + } +} \ No newline at end of file diff --git a/tests/cases/TestXPathResult.php b/tests/cases/TestXPathResult.php new file mode 100644 index 0000000..eacf147 --- /dev/null +++ b/tests/cases/TestXPathResult.php @@ -0,0 +1,149 @@ +Ook'); + $result = $d->evaluate('.//span', $d); + foreach ($result as $k => $r) { + $this->assertSame(HTMLElement::class, $r::class); + $this->assertSame(HTMLElement::class, $result[$k]::class); + } + } + + + function testMethod_iterateNext(): void { + $d = new Document(''); + $result = $d->evaluate('.//span', $d); + $this->assertSame($d->getElementsByTagName('span')[0], $result->iterateNext()); + $this->assertNull($result->iterateNext()); + } + + + function testMethod_iterateNext__errors(): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode(XPathException::TYPE_ERROR); + $d = new Document(); + $d->evaluate('.//fail', $d, null, XPathResult::ORDERED_NODE_SNAPSHOT_TYPE)->iterateNext(); + } + + + function testMethod_snapshotItem(): void { + $d = new Document(''); + $this->assertSame($d->getElementsByTagName('span')[0], $d->evaluate('.//span', $d, null, XPathResult::ORDERED_NODE_SNAPSHOT_TYPE)->snapshotItem(0)); + $this->assertNull($d->evaluate('.//span', $d, null, XPathResult::ORDERED_NODE_SNAPSHOT_TYPE)->snapshotItem(42)); + } + + + function testMethod_snapshotItem__errors(): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode(XPathException::TYPE_ERROR); + $d = new Document(); + $d->evaluate('.//fail', $d)->snapshotItem(0); + } + + function testMethod_validateStorage__errors(): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode(XPathException::TYPE_ERROR); + $d = new Document(); + $result = $d->evaluate('count(.//fail)', $d, null, XPathResult::NUMBER_TYPE); + $result[0]; + } + + + function provideProperty__errors(): iterable { + return [ + [ function() { + $d = new Document(); + $result = $d->evaluate('name(//fail)', $d, null, XPathResult::NUMBER_TYPE); + $result->booleanValue; + } ], + + [ function() { + $d = new Document(); + $result = $d->evaluate('//fail', $d, null, XPathResult::BOOLEAN_TYPE); + $result->numberValue; + } ], + + [ function() { + $d = new Document(); + $result = $d->evaluate('//fail', $d, null, XPathResult::BOOLEAN_TYPE); + $result->singleNodeValue; + } ], + + [ function() { + $d = new Document(); + $result = $d->evaluate('//fail', $d, null, XPathResult::BOOLEAN_TYPE); + $result->stringValue; + } ] + ]; + } + + /** @dataProvider provideProperty__errors */ + function testProperty__errors(\Closure $closure): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode(XPathException::TYPE_ERROR); + $closure(); + } + + function testProperty_invalidIteratorState(): void { + $d = new Document(); + $this->assertFalse($d->evaluate('//span', $d)->invalidIteratorState); + } + + + function testProperty_offsetSet_offsetUnset(): void { + $d = new Document('Ook'); + $result = $d->evaluate('.//span', $d); + $result[1] = 'ook'; + $this->assertNotSame('ook', $result[1]); + unset($result[1]); + $this->assertSame(HTMLElement::class, $result[1]::class); + } + + + function testProperty_resultType(): void { + $d = new Document('Ook'); + $this->assertEquals(XPathResult::ORDERED_NODE_ITERATOR_TYPE, $d->evaluate('.//span', $d->body, null, XPathResult::ORDERED_NODE_ITERATOR_TYPE)->resultType); + $this->assertEquals(XPathResult::ORDERED_NODE_ITERATOR_TYPE, $d->evaluate('.//span', $d->body)->resultType); + $this->assertEquals(XPathResult::NUMBER_TYPE, $d->evaluate('count(.//span)', $d->body, null, XPathResult::NUMBER_TYPE)->resultType); + $this->assertEquals(XPathResult::NUMBER_TYPE, $d->evaluate('count(.//span)', $d->body)->resultType); + $this->assertEquals(XPathResult::STRING_TYPE, $d->evaluate('name(.//span)', $d->body, null, XPathResult::STRING_TYPE)->resultType); + $this->assertEquals(XPathResult::STRING_TYPE, $d->evaluate('name(.//span)', $d->body)->resultType); + $this->assertEquals(XPathResult::BOOLEAN_TYPE, $d->evaluate('.//span', $d->body, null, XPathResult::BOOLEAN_TYPE)->resultType); + $this->assertEquals(XPathResult::BOOLEAN_TYPE, $d->evaluate('not(.//span)', $d->body, null, XPathResult::BOOLEAN_TYPE)->resultType); + $this->assertEquals(XPathResult::BOOLEAN_TYPE, $d->evaluate('not(.//span)', $d->body)->resultType); + $this->assertEquals(XPathResult::UNORDERED_NODE_SNAPSHOT_TYPE, $d->evaluate('.//span', $d->body, null, XPathResult::UNORDERED_NODE_SNAPSHOT_TYPE)->resultType); + $this->assertEquals(XPathResult::FIRST_ORDERED_NODE_TYPE, $d->evaluate('.//span', $d->body, null, XPathResult::FIRST_ORDERED_NODE_TYPE)->resultType); + } + + function testProperty_snapshotLength(): void { + $d = new Document('Ook'); + $this->assertEquals(3, $d->evaluate('.//span', $d, null, XPathResult::ORDERED_NODE_SNAPSHOT_TYPE)->snapshotLength); + } + + + function testProperty_snapshotLength__errors(): void { + $this->expectException(XPathException::class); + $this->expectExceptionCode(XPathException::TYPE_ERROR); + $d = new Document(); + $d->evaluate('.//fail', $d)->snapshotLength; + } +} \ No newline at end of file diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml index b4270be..48901c6 100644 --- a/tests/phpunit.dist.xml +++ b/tests/phpunit.dist.xml @@ -36,6 +36,8 @@ cases/TestXMLDocument.php cases/TestXPathEvaluate.php cases/TestXPathEvaluator.php + cases/TestXPathExpression.php + cases/TestXPathResult.php cases/TestSerializer.php