diff --git a/ext/dom/JitDomXPathEvaluate.php b/ext/dom/JitDomXPathEvaluate.php
index ea60422a54..580775435b 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 f90b2eac0a..b4b60dac9c 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 dc311e151a..c061530834 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 7113c31e47..1e01946d80 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 1f8e68f8cb..1e70a6a90e 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 0000000000..59de5bb1de
--- /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 0000000000..a2574d4a64
--- /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 0000000000..f687bcd3cd
--- /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 0000000000..ce9388d2c0
--- /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 0000000000..7ba92abffa
--- /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 0000000000..a090879586
--- /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";