Skip to content

Commit

Permalink
Improve complex selector structure
Browse files Browse the repository at this point in the history
Separate the self selector from relative selectors
  • Loading branch information
sirreal committed Dec 9, 2024
1 parent c696889 commit c193551
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 81 deletions.
50 changes: 25 additions & 25 deletions src/wp-includes/html-api/class-wp-css-complex-selector-list.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ] ||
Expand All @@ -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 ) );
}
}
110 changes: 74 additions & 36 deletions src/wp-includes/html-api/class-wp-css-complex-selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,87 @@
* > <complex-selector> = <compound-selector> [ <combinator>? <compound-selector> ] *
*/
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<WP_CSS_Compound_Selector|self::COMBINATOR_*> $selectors
* @param array{WP_CSS_Type_Selector, string}[] $selectors
* @param string[] $breadcrumbs
*/
private function explore_matches( array $selectors, array $breadcrumbs ): bool {
Expand All @@ -36,53 +97,30 @@ 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;
}
}
}
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<WP_CSS_Compound_Selector|self::COMBINATOR_*>
*/
public $selectors = array();

/**
* @param array<WP_CSS_Compound_Selector|self::COMBINATOR_*> $selectors
*/
public function __construct( array $selectors ) {
$this->selectors = array_reverse( $selectors );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool {
/** @var WP_CSS_Type_Selector|null */
public $type_selector;

/** @var array<WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector>|null */
/** @var (WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector)[]|null */
public $subclass_selectors;

/**
Expand Down
25 changes: 14 additions & 11 deletions tests/phpunit/tests/html-api/wpCssComplexSelectorList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
};
Expand All @@ -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 ) );
}
Expand Down
17 changes: 9 additions & 8 deletions tests/phpunit/tests/html-api/wpHtmlProcessor-select.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '<html match><head match><meta match><body match><p match>', '*', 5 ),
'quirks mode ID' => array( '<p id="id" match><p id="ID" match>In quirks mode, ID matching is case-insensitive.', '#id', 2 ),
'quirks mode class' => array( '<p class="c" match><p class="C" match>In quirks mode, class matching is case-insensitive.', '.c', 2 ),
'no-quirks mode ID' => array( '<!DOCTYPE html><p id="id" match><p id="ID" match>In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ),
'no-quirks mode class' => array( '<!DOCTYPE html><p class="c" match><p class="C">In no-quirks mode, class matching is case-sensitive.', '.c', 1 ),
'any descendant' => array( '<section><p match><i match><em match><p match>', 'section *', 4 ),
'any child 1' => array( '<section><p match><i><em><p match>', 'section > *', 2 ),
'any child 2' => array( '<div><section match><div>', 'div > *', 1 ),
'any' => array( '<html match><head match><meta match><body match><p match>', '*', 5 ),
'quirks mode ID' => array( '<p id="id" match><p id="ID" match>In quirks mode, ID matching is case-insensitive.', '#id', 2 ),
'quirks mode class' => array( '<p class="c" match><p class="C" match>In quirks mode, class matching is case-insensitive.', '.c', 2 ),
'no-quirks mode ID' => array( '<!DOCTYPE html><p id="id" match><p id="ID" match>In no-quirks mode, ID matching is case-sensitive.', '#id', 1 ),
'no-quirks mode class' => array( '<!DOCTYPE html><p class="c" match><p class="C">In no-quirks mode, class matching is case-sensitive.', '.c', 1 ),
'any descendant' => array( '<section><p match><i match><em match><p match>', 'section *', 4 ),
'any child matches all children' => array( '<section><p match><i><em><p match>', 'section > *', 2 ),

'multiple complex selectors' => array( '<section><div><p><span><i></i><p><i match>', 'section > div p > i', 1 ),
);
}

Expand Down

0 comments on commit c193551

Please sign in to comment.