From 0c4cbb804fcd7c82796958246da00b927046a30b Mon Sep 17 00:00:00 2001 From: phcorp Date: Fri, 6 Nov 2026 14:01:39 +0100 Subject: [PATCH] Fix combinators after a compound-leading selector-taking pseudo-class When :is(), :where(), :not(), :has() or :nth-child(An+B of S) started a compound selector, a nested-selector rejection made the descendant and subsequent-sibling combinators give up after the nearest candidate, so ":is(ul) li" behaved like ":is(ul) > li" and ":is(y) ~ *" like ":is(y) + *". Port lexbor/lexbor@f6c1d06 into the adapted selectors matcher: keep walking the combinator before backtracking to another entry. See lexbor/lexbor#436. --- NEWS | 4 ++ .../lexbor/selectors-adapted/selectors.c | 27 ++++++++++++ .../tests/modern/css_selectors/lexbor436.phpt | 43 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 ext/dom/tests/modern/css_selectors/lexbor436.phpt diff --git a/NEWS b/NEWS index facc4a1d0031..c7bcb9a040fd 100644 --- a/NEWS +++ b/NEWS @@ -25,6 +25,10 @@ PHP NEWS - DOM: . Fixed bug GH-25268 (NULL php_libxml_ref_obj dereference in dom_objects_store_clone_obj when cloning Dom\Notation). (David Carlier) + . Fixed CSS selectors checking only the nearest element for a descendant or + subsequent-sibling combinator after a compound selector that starts with + :is(), :where(), :not(), :has() or :nth-child(An+B of S), by merging + lexbor/lexbor@f6c1d06. (phcorp) - MBString: . Fixed stack overflow in mb_check_encoding() and mb_convert_encoding() with diff --git a/ext/dom/lexbor/lexbor/selectors-adapted/selectors.c b/ext/dom/lexbor/lexbor/selectors-adapted/selectors.c index 3f16ccfa5f5a..19e1c1523c0b 100644 --- a/ext/dom/lexbor/lexbor/selectors-adapted/selectors.c +++ b/ext/dom/lexbor/lexbor/selectors-adapted/selectors.c @@ -189,6 +189,10 @@ static lxb_selectors_entry_t * lxb_selectors_state_find(lxb_selectors_t *selectors, lxb_selectors_entry_t *entry); +static lxb_selectors_entry_t * +lxb_selectors_state_find_forward(lxb_selectors_t *selectors, + lxb_selectors_entry_t *entry); + static lxb_selectors_entry_t * lxb_selectors_state_found_check(lxb_selectors_t *selectors, lxb_selectors_entry_t *entry); @@ -673,6 +677,29 @@ lxb_inline void lxb_selectors_switch_to_not_found(lxb_selectors_t *selectors, lxb_selectors_nested_t *current) { + lxb_css_selector_combinator_t combinator; + + combinator = current->entry->combinator; + + /* + * A nested selector rejected this candidate. Continue the combinator + * before backtracking to another entry. + */ + if (combinator == LXB_CSS_SELECTOR_COMBINATOR_DESCENDANT + || combinator == LXB_CSS_SELECTOR_COMBINATOR_FOLLOWING + || (current->forward + && combinator == LXB_CSS_SELECTOR_COMBINATOR_CHILD)) + { + if (current->forward) { + selectors->state = lxb_selectors_state_find_forward; + } + else { + selectors->state = lxb_selectors_state_find; + } + + return; + } + if (current->forward) { selectors->state = lxb_selectors_state_not_found_forward; } diff --git a/ext/dom/tests/modern/css_selectors/lexbor436.phpt b/ext/dom/tests/modern/css_selectors/lexbor436.phpt new file mode 100644 index 000000000000..95ab6e765f1d --- /dev/null +++ b/ext/dom/tests/modern/css_selectors/lexbor436.phpt @@ -0,0 +1,43 @@ +--TEST-- +Lexbor #436 (combinator after a compound-leading selector-taking pseudo-class checks only one element) +--EXTENSIONS-- +dom +--FILE-- +
' +); +$nested = Dom\HTMLDocument::createFromString( + '' +); + +$cases = [ + [$siblings, 'div > :is(y) ~ *'], + [$siblings, 'div > :where(y) ~ *'], + [$nested, ':is(ul) li'], + [$nested, ':where(ul) li'], + [$nested, ':not(ol) li'], + [$nested, ':has(> li > ol) li'], + [$nested, ':nth-child(1 of ul) li'], + [$nested, 'body :is(li) li'], +]; + +foreach ($cases as [$dom, $selector]) { + $result = []; + foreach ($dom->querySelectorAll($selector) as $node) { + $result[] = $node->localName . '(' . trim($node->firstChild?->textContent ?? '') . ')'; + } + echo $selector, ': ', implode(' ', $result), "\n"; +} + +?> +--EXPECT-- +div > :is(y) ~ *: z() w() +div > :where(y) ~ *: z() w() +:is(ul) li: li(a) li(b) +:where(ul) li: li(a) li(b) +:not(ol) li: li(a) li(b) +:has(> li > ol) li: li(a) li(b) +:nth-child(1 of ul) li: li(a) li(b) +body :is(li) li: li(b)