From eddf5eb985de749b0d360c7843cd8764ac3edbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?PHP=20Compiler=20Ext=20=E2=80=94=20DOM=20=26=20XML?= Date: Tue, 18 Aug 2026 04:26:44 +0000 Subject: [PATCH] Stdlib: DOMXPath or/and predicates and evaluate() (#32050) XPath 1.0 or/and were swallowed as invalid tag names, so [@id or @class] and evaluate('true() or false()') diverged from Zend/libxml. Evaluate boolean ops in VmDomXPath and host-fold the same shapes for user-script AOT. Co-authored-by: Cursor --- ext/dom/JitDomXPathEvaluate.php | 52 ++++ ext/dom/JitDomXPathEvaluateUserScript.php | 124 +++++++++ ext/dom/JitDomXPathQueryUserScript.php | 11 +- ext/dom/VmDomXPath.php | 260 ++++++++++++++++-- phpunit.xml.dist | 3 + test/aot/DomXpathOrAndAotTest.php | 27 ++ test/compliance/DomXpathOrAndJITTest.php | 26 ++ test/compliance/DomXpathOrAndVMTest.php | 26 ++ .../cases/dom/dom_xpath_or_and.phpt | 31 +++ test/fixtures/aot/cases/dom_xpath_or_and.phpt | 21 ++ .../repro/maintainer_gap_dom_xpath_or_and.php | 15 + 11 files changed, 564 insertions(+), 32 deletions(-) create mode 100644 test/aot/DomXpathOrAndAotTest.php create mode 100644 test/compliance/DomXpathOrAndJITTest.php create mode 100644 test/compliance/DomXpathOrAndVMTest.php create mode 100644 test/compliance/cases/dom/dom_xpath_or_and.phpt create mode 100644 test/fixtures/aot/cases/dom_xpath_or_and.phpt create mode 100644 test/repro/maintainer_gap_dom_xpath_or_and.php diff --git a/ext/dom/JitDomXPathEvaluate.php b/ext/dom/JitDomXPathEvaluate.php index ea60422a54c..580775435b5 100644 --- a/ext/dom/JitDomXPathEvaluate.php +++ b/ext/dom/JitDomXPathEvaluate.php @@ -87,6 +87,10 @@ private static function isBoolEvaluateExpr(string $expr): bool if (preg_match('~^(true|false|boolean\(|not\()~i', $expr)) { return true; } + // XPath 1.0 `or` / `and` (#32050) — not NCName fragments (`ancestor-or-self`). + if (self::hasTopLevelOrAnd($expr)) { + return true; + } // Top-level comparison only — skip [=<>] inside () / [] (#21148). $depth = 0; $quote = null; @@ -119,6 +123,54 @@ private static function isBoolEvaluateExpr(string $expr): bool return false; } + private static function hasTopLevelOrAnd(string $expr): bool + { + foreach (['or', 'and'] as $word) { + $depth = 0; + $quote = null; + $len = \strlen($expr); + $wordLen = \strlen($word); + for ($i = 0; $i < $len; ++$i) { + $ch = $expr[$i]; + if (null !== $quote) { + if ($ch === $quote) { + $quote = null; + } + continue; + } + if ('"' === $ch || "'" === $ch) { + $quote = $ch; + continue; + } + if ('(' === $ch || '[' === $ch) { + ++$depth; + continue; + } + if (')' === $ch || ']' === $ch) { + --$depth; + continue; + } + if (0 !== $depth) { + continue; + } + if ($i + $wordLen > $len || 0 !== substr_compare($expr, $word, $i, $wordLen)) { + continue; + } + $beforeOk = 0 === $i || !preg_match('/[\w.-]/', $expr[$i - 1]); + $afterOk = $i + $wordLen >= $len || !preg_match('/[\w.-]/', $expr[$i + $wordLen]); + if ($beforeOk && $afterOk) { + $left = trim(substr($expr, 0, $i)); + $right = trim(substr($expr, $i + $wordLen)); + if ('' !== $left && '' !== $right) { + return true; + } + } + } + } + + return false; + } + private static function isDoubleEvaluateExpr(string $expr): bool { if (preg_match('~^(number|count|sum)\(~i', $expr)) { diff --git a/ext/dom/JitDomXPathEvaluateUserScript.php b/ext/dom/JitDomXPathEvaluateUserScript.php index f90b2eac0af..b4b60dac9c1 100644 --- a/ext/dom/JitDomXPathEvaluateUserScript.php +++ b/ext/dom/JitDomXPathEvaluateUserScript.php @@ -48,6 +48,13 @@ public static function tryInvoke(Context $context, JITVariable ...$args): ?Value if (null !== $invalid) { return $invalid; } + // XPath 1.0 `or` / `and` evaluate() — host-fold when Zend DOM is available (#32050). + if (self::hasTopLevelOrAnd($expression)) { + $host = self::tryHostEvaluateScalar($xml, $expression); + if (null !== $host) { + return self::boxHostScalar($context, $host); + } + } if (preg_match('~^boolean\((.+)\)$~i', $expression, $boolWrap)) { $inner = trim($boolWrap[1]); if (preg_match('~^count\((.+)\)$~i', $inner, $countWrap)) { @@ -355,6 +362,117 @@ private static function sumForXPath(string $xml, string $inner): ?float return $sum; } + /** + * Host Zend DOMXPath::evaluate() for compile-time scalar folds (#32050). + * + * @return bool|int|float|string|null + */ + private static function tryHostEvaluateScalar(string $xml, string $expression): mixed + { + if (!\extension_loaded('dom') || !\class_exists(\DOMDocument::class, false)) { + return null; + } + set_error_handler(static function (): bool { + return true; + }); + try { + $doc = new \DOMDocument(); + if (!@$doc->loadXML($xml)) { + restore_error_handler(); + + return null; + } + $xpath = new \DOMXPath($doc); + foreach (JitDomXPathRegisterUserScript::namespaces() as $prefix => $uri) { + if ('' === $prefix) { + continue; + } + @$xpath->registerNamespace($prefix, $uri); + } + $result = $xpath->evaluate($expression); + } catch (\Throwable) { + restore_error_handler(); + + return null; + } + restore_error_handler(); + if (\is_bool($result) || \is_int($result) || \is_float($result) || \is_string($result)) { + return $result; + } + + return null; + } + + private static function hasTopLevelOrAnd(string $expression): bool + { + foreach (['or', 'and'] as $word) { + $depth = 0; + $quote = null; + $len = \strlen($expression); + $wordLen = \strlen($word); + for ($i = 0; $i < $len; ++$i) { + $ch = $expression[$i]; + if (null !== $quote) { + if ($ch === $quote) { + $quote = null; + } + continue; + } + if ('"' === $ch || "'" === $ch) { + $quote = $ch; + continue; + } + if ('(' === $ch || '[' === $ch) { + ++$depth; + continue; + } + if (')' === $ch || ']' === $ch) { + --$depth; + continue; + } + if (0 !== $depth) { + continue; + } + if ($i + $wordLen > $len || 0 !== substr_compare($expression, $word, $i, $wordLen)) { + continue; + } + $beforeOk = 0 === $i || !preg_match('/[\w.-]/', $expression[$i - 1]); + $afterOk = $i + $wordLen >= $len || !preg_match('/[\w.-]/', $expression[$i + $wordLen]); + if ($beforeOk && $afterOk) { + $left = trim(substr($expression, 0, $i)); + $right = trim(substr($expression, $i + $wordLen)); + if ('' !== $left && '' !== $right) { + return true; + } + } + } + } + + return false; + } + + private static function boxHostScalar(Context $context, bool|int|float|string $value): Value + { + if (\is_bool($value)) { + $slot = JitValueBox::alloc($context); + JitValueBox::writeBool( + $context, + $slot, + $context->getTypeFromString('int1')->constInt($value ? 1 : 0, false) + ); + + return JitValueBox::normalizeValuePtr($context, $slot); + } + if (\is_int($value)) { + return self::boxLong($context, $value); + } + if (\is_float($value)) { + return self::boxDouble($context, $value); + } + + return self::boxString($context, $value); + } + private static function countForXPath(string $xml, string $inner): ?int { // //tag[n] — at most one node (#19456). @@ -363,6 +481,12 @@ private static function countForXPath(string $xml, string $inner): ?int return null === $text ? 0 : 1; } + if (preg_match('~\s+(?:or|and)\s+|\[not\(~i', $inner)) { + $host = self::tryHostEvaluateScalar($xml, 'count('.$inner.')'); + if (\is_int($host) || \is_float($host)) { + return (int) $host; + } + } if (!preg_match( '~^//([*\w][\w:-]*)(?:\[@([^\]=]+)=(?:["\']([^"\']*)["\']|([+-]?(?:\d+\.?\d*|\.\d+)))\])?$~', $inner, diff --git a/ext/dom/JitDomXPathQueryUserScript.php b/ext/dom/JitDomXPathQueryUserScript.php index dc311e151a8..c0615308343 100644 --- a/ext/dom/JitDomXPathQueryUserScript.php +++ b/ext/dom/JitDomXPathQueryUserScript.php @@ -259,11 +259,16 @@ private static function tryHostTreeAxisCompileTime(Context $context, string $xml { $trimmed = trim($exprLit); // Host-fold named axes / `..` (#31773), `//*[last()]` / `[position()…]` (#31923), - // and abbreviated attribute axis `@*` / `@name` (#32003, #32032). Flattened - // descendant `[last()]` is wrong; user-script AOT ABI aborts on these paths. + // abbreviated attribute axis `@*` / `@name` (#32003, #32032), and `or`/`and`/`not(` + // boolean predicates (#32050). Flattened descendant `[last()]` is wrong; user-script + // AOT ABI aborts on these paths. $positional = (bool) preg_match('~\[(?:last\(\)|position\(\))~i', $trimmed); $attrAxis = self::isHostFoldAttributeAxis($trimmed); - if (!str_contains($trimmed, '::') && !str_contains($trimmed, '..') && !$positional && !$attrAxis) { + // Boolean `or`/`and`/`not(` — user-script regex only handles [@attr=v] (#32050). + $boolExpr = str_contains($trimmed, ' or ') + || str_contains($trimmed, ' and ') + || str_contains($trimmed, '[not('); + if (!str_contains($trimmed, '::') && !str_contains($trimmed, '..') && !$positional && !$attrAxis && !$boolExpr) { return null; } if (!\extension_loaded('dom') || !\class_exists(\DOMDocument::class, false)) { diff --git a/ext/dom/VmDomXPath.php b/ext/dom/VmDomXPath.php index 7113c31e478..1e01946d808 100644 --- a/ext/dom/VmDomXPath.php +++ b/ext/dom/VmDomXPath.php @@ -449,7 +449,7 @@ private static function evaluateNodeSetBody( return DomRegistry::has($context) ? [$context->id] : []; } if ('..' === $expression) { - return self::collectMatchingAlongAxis($context, '..', $state->xpathNamespaces); + return self::collectMatchingAlongAxis($context, '..', $state->xpathNamespaces, $ctx, $xpath); } if (str_starts_with($expression, './/')) { return self::evaluateRelativeDescendantPath( @@ -510,23 +510,25 @@ private static function evaluateNodeSetBody( return self::evaluateDescendantPath( $document, substr($expression, 2), - $state->xpathNamespaces + $state->xpathNamespaces, + $ctx, + $xpath ); } // Absolute /… from the document root (#19709). if (str_starts_with($expression, '/')) { - return self::evaluateAbsolutePath($context, substr($expression, 1), $state->xpathNamespaces); + return self::evaluateAbsolutePath($context, substr($expression, 1), $state->xpathNamespaces, $ctx, $xpath); } // Relative multi-segment child path: wrap/a, a/text() (#20456). if (str_contains($expression, '/')) { - return self::evaluateChildAxisPath($context, $expression, $state->xpathNamespaces); + return self::evaluateChildAxisPath($context, $expression, $state->xpathNamespaces, $ctx, $xpath); } // Child / named-axis step: tag / * / text() / child::* / following-sibling::* (#20456, #31773). if (self::looksLikePathSegment($expression)) { - return self::collectMatchingAlongAxis($context, $expression, $state->xpathNamespaces); + return self::collectMatchingAlongAxis($context, $expression, $state->xpathNamespaces, $ctx, $xpath); } throw new \DOMException('Invalid expression'); @@ -745,9 +747,7 @@ private static function evaluateRelativeDescendantPath( if (null !== $attrIds) { return $attrIds; } - unset($xpath); - - return self::evaluateDescendantPath($context, $inner, $namespaces); + return self::evaluateDescendantPath($context, $inner, $namespaces, $ctx, $xpath); } /** @@ -1185,7 +1185,9 @@ private static function collectChildElements( private static function evaluateAbsolutePath( ObjectEntry $context, string $path, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $document = VmDom::isDocument($context) ? $context @@ -1198,7 +1200,7 @@ private static function evaluateAbsolutePath( return []; } - return self::evaluateChildAxisPath($document, $path, $namespaces); + return self::evaluateChildAxisPath($document, $path, $namespaces, $ctx, $xpath); } /** @@ -1211,7 +1213,9 @@ private static function evaluateAbsolutePath( private static function evaluateDescendantPath( ObjectEntry $context, string $path, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $segments = self::splitLocationPath($path); if ([] === $segments) { @@ -1229,13 +1233,15 @@ private static function evaluateDescendantPath( self::walkAxisSegments( self::descendantOrSelfNodeIds($context), $segments, - $namespaces + $namespaces, + $ctx, + $xpath ) ); } - $currentIds = self::collectMatchingDescendants($context, $segments[0], $namespaces); + $currentIds = self::collectMatchingDescendants($context, $segments[0], $namespaces, $ctx, $xpath); - return self::walkAxisSegments($currentIds, array_slice($segments, 1), $namespaces); + return self::walkAxisSegments($currentIds, array_slice($segments, 1), $namespaces, $ctx, $xpath); } /** @@ -1248,14 +1254,16 @@ private static function evaluateDescendantPath( private static function evaluateChildAxisPath( ObjectEntry $start, string $path, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $segments = self::splitLocationPath($path); if ([] === $segments) { return []; } - return self::walkAxisSegments([$start->id], $segments, $namespaces); + return self::walkAxisSegments([$start->id], $segments, $namespaces, $ctx, $xpath); } /** @@ -1297,7 +1305,9 @@ static function (int $a, int $b) use ($rank): int { private static function walkAxisSegments( array $currentIds, array $segments, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { foreach ($segments as $segment) { $nextIds = []; @@ -1307,7 +1317,7 @@ private static function walkAxisSegments( if (null === $node) { continue; } - foreach (self::collectMatchingAlongAxis($node, $segment, $namespaces) as $nextId) { + foreach (self::collectMatchingAlongAxis($node, $segment, $namespaces, $ctx, $xpath) as $nextId) { if (isset($seen[$nextId])) { continue; } @@ -1345,7 +1355,11 @@ private static function looksLikePathSegment(string $expression): bool return true; } $candidate = self::splitAxisAndTest($expression)['testSegment']; - if (self::isNodeTypeTestName(self::parsePathSegment($candidate)['test'])) { + $parsed = self::parsePathSegment($candidate); + if (self::isNodeTypeTestName($parsed['test'])) { + return true; + } + if (null !== ($parsed['generalPred'] ?? null)) { return true; } @@ -1523,10 +1537,29 @@ private static function parsePathSegment(string $segment): array if (isset($matches[4]) && '' !== $matches[4]) { $positionPred = ['op' => '=', 'rhs' => (int) $matches[4]]; } + $attr = isset($matches[2]) && '' !== $matches[2] ? $matches[2] : null; + $test = $matches[1]; + // Catch-all is optional-predicate, so `a[@id or @class]` is eaten as a tag name. + // Split a trailing `[pred]` into a general boolean predicate (#32050). + if (null === $attr && null === $positionPred && str_contains($test, '[')) { + $split = self::splitTrailingPredicate($test); + if (null !== $split) { + return [ + 'test' => $split['test'], + 'attr' => null, + 'attrValue' => '', + 'attrNumeric' => false, + 'positionPred' => null, + 'fnPred' => null, + 'fnPredValue' => '', + 'generalPred' => $split['pred'], + ]; + } + } return [ - 'test' => $matches[1], - 'attr' => isset($matches[2]) && '' !== $matches[2] ? $matches[2] : null, + 'test' => $test, + 'attr' => $attr, 'attrValue' => $matches[3] ?? '', 'attrNumeric' => false, 'positionPred' => $positionPred, @@ -1535,6 +1568,20 @@ private static function parsePathSegment(string $segment): array ]; } + $split = self::splitTrailingPredicate($segment); + if (null !== $split) { + return [ + 'test' => $split['test'], + 'attr' => null, + 'attrValue' => '', + 'attrNumeric' => false, + 'positionPred' => null, + 'fnPred' => null, + 'fnPredValue' => '', + 'generalPred' => $split['pred'], + ]; + } + return [ 'test' => $segment, 'attr' => null, @@ -1546,6 +1593,54 @@ private static function parsePathSegment(string $segment): array ]; } + /** + * Split `test[pred]` from the last balanced `[…]` (#32050). + * + * @return array{test: string, pred: string}|null + */ + private static function splitTrailingPredicate(string $segment): ?array + { + $len = \strlen($segment); + if ($len < 3 || ']' !== $segment[$len - 1]) { + return null; + } + $depth = 0; + $quote = null; + for ($i = $len - 1; $i >= 0; --$i) { + $ch = $segment[$i]; + if (null !== $quote) { + if ($ch === $quote) { + $quote = null; + } + continue; + } + if ('"' === $ch || "'" === $ch) { + $quote = $ch; + continue; + } + if (']' === $ch) { + ++$depth; + continue; + } + if ('[' !== $ch) { + continue; + } + --$depth; + if (0 !== $depth) { + continue; + } + $test = substr($segment, 0, $i); + $pred = trim(substr($segment, $i + 1, $len - $i - 2)); + if ('' === $test || '' === $pred) { + return null; + } + + return ['test' => $test, 'pred' => $pred]; + } + + return null; + } + private static function isNodeTypeTestName(string $test): bool { return 'node()' === $test @@ -1633,8 +1728,26 @@ private static function applyPathSegmentPredicates( string $fnPredValue = '', bool $attrNumeric = false, bool $attrExists = false, - ?string $attrOp = null + ?string $attrOp = null, + ?string $generalPred = null, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { + if (null !== $generalPred && null !== $ctx && null !== $xpath) { + $nodeIds = array_values(array_filter( + $nodeIds, + static function (int $id) use ($ctx, $xpath, $generalPred): bool { + $node = DomRegistry::entry($id); + if (null === $node) { + return false; + } + + return self::booleanize( + self::evaluateToMixed($ctx, $xpath, $generalPred, $node, false) + ); + } + )); + } if (null !== $attr) { if ($attrExists) { $nodeIds = array_values(array_filter( @@ -1843,7 +1956,9 @@ private static function splitAxisAndTest(string $segment): array private static function collectMatchingAlongAxis( ObjectEntry $context, string $segment, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $split = self::splitAxisAndTest($segment); $axis = $split['axis']; @@ -1869,7 +1984,10 @@ private static function collectMatchingAlongAxis( $parsed['fnPredValue'], $parsed['attrNumeric'], $parsed['attrExists'] ?? false, - $parsed['attrOp'] ?? null + $parsed['attrOp'] ?? null, + $parsed['generalPred'] ?? null, + $ctx, + $xpath ); } @@ -2158,7 +2276,9 @@ private static function attributeAxisNodes(ObjectEntry $context): array private static function collectMatchingChildren( ObjectEntry $parent, string $segment, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $parsed = self::parsePathSegment($segment); $ids = []; @@ -2185,7 +2305,10 @@ private static function collectMatchingChildren( $parsed['fnPredValue'], $parsed['attrNumeric'], $parsed['attrExists'] ?? false, - $parsed['attrOp'] ?? null + $parsed['attrOp'] ?? null, + $parsed['generalPred'] ?? null, + $ctx, + $xpath ); } @@ -2200,7 +2323,9 @@ private static function collectMatchingChildren( private static function collectMatchingDescendants( ObjectEntry $context, string $segment, - array $namespaces + array $namespaces, + ?Context $ctx = null, + ?ObjectEntry $xpath = null ): array { $parsed = self::parsePathSegment($segment); $test = $parsed['test']; @@ -2218,7 +2343,10 @@ private static function collectMatchingDescendants( $parsed['fnPredValue'], $parsed['attrNumeric'], $parsed['attrExists'] ?? false, - $parsed['attrOp'] ?? null + $parsed['attrOp'] ?? null, + $parsed['generalPred'] ?? null, + $ctx, + $xpath ); } $ids = []; @@ -2234,7 +2362,10 @@ private static function collectMatchingDescendants( $parsed['fnPredValue'], $parsed['attrNumeric'], $parsed['attrExists'] ?? false, - $parsed['attrOp'] ?? null + $parsed['attrOp'] ?? null, + $parsed['generalPred'] ?? null, + $ctx, + $xpath ); } @@ -2466,6 +2597,12 @@ private static function isBooleanExpression(string $expression): bool if (preg_match('~^(true|false|boolean\(|not\(|starts-with\(|contains\(|lang\()~i', $expression)) { return true; } + // XPath 1.0 `or` / `and` — boolean result, not a node-set union (`|`) (#32050). + if (null !== self::findTopLevelWordOperator($expression, 'or') + || null !== self::findTopLevelWordOperator($expression, 'and') + ) { + return true; + } // XPath 1.0 comparisons (= != < <= > >=) at top level (#20280). return null !== self::findTopLevelComparison($expression); @@ -2519,6 +2656,18 @@ private static function evaluateBoolean( if (0 === strcasecmp($expression, 'false()')) { return false; } + // `or` then `and` (XPath 1.0 §3.4 precedence; #32050). Word tokens only — + // `ancestor-or-self` / `standard` must not split. + $or = self::findTopLevelWordOperator($expression, 'or'); + if (null !== $or) { + return self::booleanize(self::evaluateToMixed($ctx, $xpath, $or['left'], $contextNode, $registerNodeNS)) + || self::booleanize(self::evaluateToMixed($ctx, $xpath, $or['right'], $contextNode, $registerNodeNS)); + } + $and = self::findTopLevelWordOperator($expression, 'and'); + if (null !== $and) { + return self::booleanize(self::evaluateToMixed($ctx, $xpath, $and['left'], $contextNode, $registerNodeNS)) + && self::booleanize(self::evaluateToMixed($ctx, $xpath, $and['right'], $contextNode, $registerNodeNS)); + } if (preg_match('~^not\(~i', $expression)) { $inner = self::wrappedFunctionInner($expression, 'not'); if (null === $inner) { @@ -3499,6 +3648,59 @@ private static function findTopLevelMultiplicative(string $expression): ?array return null; } + /** + * Leftmost top-level XPath 1.0 word operator (`or` / `and`; also reused shape of `div`/`mod`). + * Skips quotes, parens, predicates, and NCName fragments (`ancestor-or-self`) (#32050). + * + * @return array{op: string, left: string, right: string}|null + */ + private static function findTopLevelWordOperator(string $expression, string $word): ?array + { + $depth = 0; + $quote = null; + $len = \strlen($expression); + $wordLen = \strlen($word); + for ($i = 0; $i < $len; ++$i) { + $ch = $expression[$i]; + if (null !== $quote) { + if ($ch === $quote) { + $quote = null; + } + continue; + } + if ('"' === $ch || "'" === $ch) { + $quote = $ch; + continue; + } + if ('(' === $ch || '[' === $ch) { + ++$depth; + continue; + } + if (')' === $ch || ']' === $ch) { + --$depth; + continue; + } + if (0 !== $depth) { + continue; + } + if ($i + $wordLen > $len || 0 !== substr_compare($expression, $word, $i, $wordLen)) { + continue; + } + $beforeOk = 0 === $i || !preg_match('/[\w.-]/', $expression[$i - 1]); + $afterOk = $i + $wordLen >= $len || !preg_match('/[\w.-]/', $expression[$i + $wordLen]); + if (!$beforeOk || !$afterOk) { + continue; + } + $left = trim(substr($expression, 0, $i)); + $right = trim(substr($expression, $i + $wordLen)); + if ('' !== $left && '' !== $right) { + return ['op' => $word, 'left' => $left, 'right' => $right]; + } + } + + return null; + } + /** Inner text of func(...) when the call spans the whole expression. */ private static function wrappedFunctionInner(string $expression, string $funcName): ?string { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1f8e68f8cbc..1e70a6a90e5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -561,6 +561,8 @@ ./test/compliance/DomXpathTreeAxesJITTest.php ./test/compliance/DomXpathAttrStarVMTest.php ./test/compliance/DomXpathAttrStarJITTest.php + ./test/compliance/DomXpathOrAndVMTest.php + ./test/compliance/DomXpathOrAndJITTest.php ./test/compliance/DomGetElementsByTagNameNullStrictVMTest.php ./test/compliance/DomGetElementsByTagNameNullStrictJITTest.php ./test/compliance/DomCreateNullStrictVMTest.php @@ -1049,6 +1051,7 @@ ./test/aot/MbChrOrdAotTest.php ./test/aot/DomXpathTreeAxesAotTest.php ./test/aot/DomXpathAttrStarAotTest.php + ./test/aot/DomXpathOrAndAotTest.php ./test/aot/PasswordHashVerifyAlgosReflectionAotTest.php ./test/aot/MbEncodingRegistryAotTest.php ./test/aot/DateIsodateSetAotTest.php diff --git a/test/aot/DomXpathOrAndAotTest.php b/test/aot/DomXpathOrAndAotTest.php new file mode 100644 index 00000000000..59de5bb1de8 --- /dev/null +++ b/test/aot/DomXpathOrAndAotTest.php @@ -0,0 +1,27 @@ + self::parsePHPT($path, $basename); + } +} diff --git a/test/compliance/DomXpathOrAndJITTest.php b/test/compliance/DomXpathOrAndJITTest.php new file mode 100644 index 00000000000..a2574d4a641 --- /dev/null +++ b/test/compliance/DomXpathOrAndJITTest.php @@ -0,0 +1,26 @@ + self::parsePHPT( + __DIR__.'/cases/dom/dom_xpath_or_and.phpt', + 'dom_xpath_or_and.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/jit.php'); + } +} diff --git a/test/compliance/DomXpathOrAndVMTest.php b/test/compliance/DomXpathOrAndVMTest.php new file mode 100644 index 00000000000..f687bcd3cd7 --- /dev/null +++ b/test/compliance/DomXpathOrAndVMTest.php @@ -0,0 +1,26 @@ + self::parsePHPT( + __DIR__.'/cases/dom/dom_xpath_or_and.phpt', + 'dom_xpath_or_and.phpt' + ); + } + + public function setUp(): void + { + $this->BIN = realpath(__DIR__.'/../../bin/vm.php'); + } +} diff --git a/test/compliance/cases/dom/dom_xpath_or_and.phpt b/test/compliance/cases/dom/dom_xpath_or_and.phpt new file mode 100644 index 00000000000..ce9388d2c05 --- /dev/null +++ b/test/compliance/cases/dom/dom_xpath_or_and.phpt @@ -0,0 +1,31 @@ +--TEST-- +DOMXPath or/and predicates and evaluate() (#32050, ext/dom/xpath.c) +--FILE-- +loadXML('onetwothree'); +$xp = new DOMXPath($d); +echo 'or=', $xp->query('//a[@id or @class]')->length, "\n"; +echo 'and=', $xp->query('//*[@id and @class]')->length, "\n"; +echo 'eq_or=', $xp->query('//a[@id=1 or @id=2]')->length, "\n"; +echo 'not_id=', $xp->query('//*[not(@id)]')->length, "\n"; +echo 'eval_or=', var_export($xp->evaluate('true() or false()'), true), "\n"; +echo 'eval_and=', var_export($xp->evaluate('true() and false()'), true), "\n"; +echo 'num_or=', var_export($xp->evaluate('1 or 0'), true), "\n"; +echo 'count=', var_export($xp->evaluate('count(//a[@id or @class])'), true), "\n"; +echo 'starts=', $xp->query('//*[starts-with(@id,"1") or starts-with(@class,"x")]')->length, "\n"; +echo 'path_or=', var_export($xp->evaluate('//a or //b'), true), "\n"; +echo 'query_path_or=', $xp->query('//a or //b')->length, "\n"; +?> +--EXPECT-- +or=2 +and=1 +eq_or=2 +not_id=2 +eval_or=true +eval_and=false +num_or=true +count=2.0 +starts=2 +path_or=true +query_path_or=0 diff --git a/test/fixtures/aot/cases/dom_xpath_or_and.phpt b/test/fixtures/aot/cases/dom_xpath_or_and.phpt new file mode 100644 index 00000000000..7ba92abffab --- /dev/null +++ b/test/fixtures/aot/cases/dom_xpath_or_and.phpt @@ -0,0 +1,21 @@ +--TEST-- +AOT: DOMXPath or/and predicates — query lengths (#32050) +--FILE-- +loadXML('onetwothree'); +$xp = new DOMXPath($d); +echo 'or=', $xp->query('//a[@id or @class]')->length, "\n"; +echo 'and=', $xp->query('//*[@id and @class]')->length, "\n"; +echo 'eq_or=', $xp->query('//a[@id=1 or @id=2]')->length, "\n"; +echo 'not_id=', $xp->query('//*[not(@id)]')->length, "\n"; +echo 'starts=', $xp->query('//*[starts-with(@id,"1") or starts-with(@class,"x")]')->length, "\n"; +echo 'query_path_or=', $xp->query('//a or //b')->length, "\n"; +?> +--EXPECT-- +or=2 +and=1 +eq_or=2 +not_id=2 +starts=2 +query_path_or=0 diff --git a/test/repro/maintainer_gap_dom_xpath_or_and.php b/test/repro/maintainer_gap_dom_xpath_or_and.php new file mode 100644 index 00000000000..a0908795865 --- /dev/null +++ b/test/repro/maintainer_gap_dom_xpath_or_and.php @@ -0,0 +1,15 @@ +loadXML('onetwothree'); +$xp = new DOMXPath($d); +echo 'or=', $xp->query('//a[@id or @class]')->length, "\n"; +echo 'and=', $xp->query('//*[@id and @class]')->length, "\n"; +echo 'eq_or=', $xp->query('//a[@id=1 or @id=2]')->length, "\n"; +echo 'not_id=', $xp->query('//*[not(@id)]')->length, "\n"; +echo 'eval_or=', var_export($xp->evaluate('true() or false()'), true), "\n"; +echo 'eval_and=', var_export($xp->evaluate('true() and false()'), true), "\n"; +echo 'num_or=', var_export($xp->evaluate('1 or 0'), true), "\n"; +echo 'count=', var_export($xp->evaluate('count(//a[@id or @class])'), true), "\n"; +echo 'starts=', $xp->query('//*[starts-with(@id,"1") or starts-with(@class,"x")]')->length, "\n"; +echo 'path_or=', var_export($xp->evaluate('//a or //b'), true), "\n";