diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php index 0413b8dea426a..4a9fc03f582f8 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -95,16 +95,18 @@ final protected static function parse_complex_selector( string $input, int &$off } $updated_offset = $offset; - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { + $self_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $self_selector ) { return null; } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + /** @var array{WP_CSS_Compound_Selector, string}[] */ + $selectors = array(); $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { + $combinator = null; + $next_selector = null; + if ( WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || @@ -114,42 +116,40 @@ final protected static function parse_complex_selector( string $input, int &$off ++$updated_offset; self::parse_whitespace( $input, $updated_offset ); - // Failure to find a selector here is a parse error - $selector = self::parse_compound_selector( $input, $updated_offset ); + // A combinator has been found, failure to find a selector here is a parse error. + $next_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $next_selector ) { + return null; + } } elseif ( $found_whitespace ) { /* * Whitespace is ambiguous, it could be a descendant combinator or * insignificant whitespace. */ - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { - break; + $next_selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null !== $next_selector ) { + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; } - $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; - } else { - break; } - if ( null === $selector ) { - return null; + if ( null === $next_selector ) { + break; } - /* - * Subclass selectors in non-final position is not supported: - * - `div > .className` is valid - * - `.className > div` is not - */ - if ( $has_preceding_subclass_selector ) { + // $self_selector will pass to a relative selector where only the type selector is allowed. + if ( null !== $self_selector->subclass_selectors || null === $self_selector->type_selector ) { return null; } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - $selectors[] = $combinator; - $selectors[] = $selector; + /** @var array{WP_CSS_Compound_Selector, string} */ + $selector_pair = array( $self_selector->type_selector, $combinator ); + $selectors[] = $selector_pair; + $self_selector = $next_selector; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; - return new WP_CSS_Complex_Selector( $selectors ); + + return new WP_CSS_Complex_Selector( $self_selector, array_reverse( $selectors ) ); } } diff --git a/src/wp-includes/html-api/class-wp-css-complex-selector.php b/src/wp-includes/html-api/class-wp-css-complex-selector.php index a532e87ecc15d..9db2912d3ac16 100644 --- a/src/wp-includes/html-api/class-wp-css-complex-selector.php +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -6,26 +6,87 @@ * > = [ ? ] * */ final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * This is the selector in the final position of the complex selector. This corresponds to the + * selected element. + * + * @example + * + * $self_selector + * ┏━━━━┻━━━━┓ + * .heading h1 > el.selected + * + * @readonly + * @var WP_CSS_Compound_Selector + */ + public $self_selector; + + /** + * This is the selector in the final position of the complex selector. This corresponds to the + * selected element. + * + * @example + * + * $relative_selectors + * ┏━━━━━━┻━━━━┓ + * .heading h1 > el.selected + * + * The example would have the following relative selectors (note that the order is reversed): + * + * @example + * + * array ( + * array( + * WP_CSS_Type_Selector( 'ident' => 'h1' ), + * '>', // WP_CSS_Complex_Selector::COMBINATOR_CHILD + * ), + * array( + * new WP_CSS_Type_Selector( 'header' ), + * ' ', // WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT + * ), + * ) + * + * @readonly + * @var array{WP_CSS_Type_Selector, string}[] + */ + public $relative_selectors; + + /** + * @param WP_CSS_Compound_Selector $self_selector + * @param array{WP_CSS_Type_Selector, string}[] $selectors + */ + public function __construct( + WP_CSS_Compound_Selector $self_selector, + ?array $relative_selectors + ) { + $this->self_selector = $self_selector; + $this->relative_selectors = $relative_selectors; + } + public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. - if ( ! $this->selectors[0]->matches( $processor ) ) { + if ( ! $this->self_selector->matches( $processor ) ) { return false; } - if ( count( $this->selectors ) === 1 ) { + if ( null === $this->relative_selectors || array() === $this->relative_selectors ) { return true; } /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); - $selectors = array_slice( $this->selectors, 1 ); - return $this->explore_matches( $selectors, $breadcrumbs ); + return $this->explore_matches( $this->relative_selectors, $breadcrumbs ); } /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array{WP_CSS_Type_Selector, string}[] $selectors * @param string[] $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { @@ -36,24 +97,22 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } - /** @var self::COMBINATOR_* */ - $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector */ - $selector = $selectors[1]; + $selector = $selectors[0][0]; + $combinator = $selectors[0][1]; switch ( $combinator ) { case self::COMBINATOR_CHILD: - if ( $selector->type_selector->matches_tag( $breadcrumbs[0] ) ) { - return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + if ( $selector->matches_tag( $breadcrumbs[0] ) ) { + return $this->explore_matches( array_slice( $selectors, 1 ), array_slice( $breadcrumbs, 1 ) ); } return false; case self::COMBINATOR_DESCENDANT: // Find _all_ the breadcrumbs that match and recurse from each of them. for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { - if ( $selector->type_selector->matches_tag( $breadcrumbs[ $i ] ) ) { - $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); - if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + if ( $selector->matches_tag( $breadcrumbs[ $i ] ) ) { + $next_breadcrumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 1 ), $next_breadcrumbs ) ) { return true; } } @@ -61,28 +120,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; default: - throw new Exception( "Combinator '{$combinator}' is not supported yet." ); + throw new Exception( "Unsupported combinator '{$combinator}' found." ); } } - - const COMBINATOR_CHILD = '>'; - const COMBINATOR_DESCENDANT = ' '; - const COMBINATOR_NEXT_SIBLING = '+'; - const COMBINATOR_SUBSEQUENT_SIBLING = '~'; - - /** - * even indexes are WP_CSS_Compound_Selector, odd indexes are string combinators. - * In reverse order to match the current element and then work up the tree. - * Any non-final selector is a type selector. - * - * @var array - */ - public $selectors = array(); - - /** - * @param array $selectors - */ - public function __construct( array $selectors ) { - $this->selectors = array_reverse( $selectors ); - } } diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector.php index e64695abe9ab3..2ef2051880936 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -25,7 +25,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { /** @var WP_CSS_Type_Selector|null */ public $type_selector; - /** @var array|null */ + /** @var (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null */ public $subclass_selectors; /** diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 0b17e57847662..795e230033cdb 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -20,7 +20,7 @@ public function __construct() { parent::__construct( array() ); } - public static function test_parse_complex_selector( string $input, int &$offset ) { + public static function test_parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { return self::parse_complex_selector( $input, $offset ); } }; @@ -30,21 +30,24 @@ public static function test_parse_complex_selector( string $input, int &$offset * @ticket 62653 */ public function test_parse_complex_selector() { - $input = 'el1 > .child#bar[baz=quux] , rest'; + $input = 'el1 el2 > .child#bar[baz=quux] , rest'; $offset = 0; - $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertSame( 3, count( $sel->selectors ) ); + /** @var WP_CSS_Complex_Selector|null */ + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); - $this->assertNull( $sel->selectors[2]->subclass_selectors ); + $this->assertSame( 2, count( $sel->relative_selectors ) ); - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + // Relative selectors should be reverse ordered. + $this->assertSame( 'el2', $sel->relative_selectors[0][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->relative_selectors[0][1] ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertNull( $sel->selectors[0]->type_selector ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); - $this->assertSame( 'child', $sel->selectors[0]->subclass_selectors[0]->ident ); + $this->assertSame( 'el1', $sel->relative_selectors[1][0]->ident ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT, $sel->relative_selectors[1][1] ); + + $this->assertSame( 3, count( $sel->self_selector->subclass_selectors ) ); + $this->assertNull( $sel->self_selector->type_selector ); + $this->assertSame( 'child', $sel->self_selector->subclass_selectors[0]->ident ); $this->assertSame( ', rest', substr( $input, $offset ) ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index d94190ff91077..21828faf42e80 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -47,14 +47,15 @@ public function test_select_all( string $html, string $selector, int $match_coun */ public static function data_selectors(): array { return array( - 'any' => array( '

', '*', 5 ), - 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), - 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), - 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), - 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), - 'any descendant' => array( '

', 'section *', 4 ), - 'any child 1' => array( '

', 'section > *', 2 ), - 'any child 2' => array( '

', 'div > *', 1 ), + 'any' => array( '

', '*', 5 ), + 'quirks mode ID' => array( '

In quirks mode, ID matching is case-insensitive.', '#id', 2 ), + 'quirks mode class' => array( '

In quirks mode, class matching is case-insensitive.', '.c', 2 ), + 'no-quirks mode ID' => array( '

In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ), + 'no-quirks mode class' => array( '

In no-quirks mode, class matching is case-sensitive.', '.c', 1 ), + 'any descendant' => array( '

', 'section *', 4 ), + 'any child matches all children' => array( '

', 'section > *', 2 ), + + 'multiple complex selectors' => array( '

', 'section > div p > i', 1 ), ); }