From 6383964a542f52093262d94262e4bd4cdfb8cb9d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Dec 2024 17:06:29 +0000 Subject: [PATCH 001/115] I18N: Load admin translations for auto update emails. As a follow-up to [59460], make sure that admin strings are loaded when switching locales for auto update notification emails, as those strings are in a separate translation file. Props benniledl, swissspidy. Fixes #62496. git-svn-id: https://develop.svn.wordpress.org/trunk@59478 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/l10n.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index f9239f84b9e03..1ed9a6a1a88c9 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -963,7 +963,7 @@ function load_default_textdomain( $locale = null ) { return $return; } - if ( is_admin() || wp_installing() || ( defined( 'WP_REPAIRING' ) && WP_REPAIRING ) ) { + if ( is_admin() || wp_installing() || ( defined( 'WP_REPAIRING' ) && WP_REPAIRING ) || doing_action( 'wp_maybe_auto_update' ) ) { load_textdomain( 'default', WP_LANG_DIR . "/admin-$locale.mo", $locale ); } From daad8631f707f730530711ed874efe94c7fec968 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Dec 2024 17:08:19 +0000 Subject: [PATCH 002/115] Plugins: Make more plugin-related functions available early on. This is a follow-up to [59461], which moved `get_plugin_data()` from `wp-admin/includes/plugin.php` to `wp-includes/functions.php` so it's available during the plugin loading process. Related functions like `is_plugin_active()` are often used together and should therefore be moved as well, to improve backward compatibility for plugins which load `wp-admin/includes/plugin.php` only conditionally. Props johnbillion, dd32, swissspidy. See #62244. git-svn-id: https://develop.svn.wordpress.org/trunk@59479 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/plugin.php | 91 -------------------------------- src/wp-includes/functions.php | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 91 deletions(-) diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index 0969f956577ed..977801d92b010 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -304,97 +304,6 @@ function _get_dropins() { return $dropins; } -/** - * Determines whether a plugin is active. - * - * Only plugins installed in the plugins/ folder can be active. - * - * Plugins in the mu-plugins/ folder can't be "activated," so this function will - * return false for those plugins. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 2.5.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True, if in the active plugins list. False, not in the list. - */ -function is_plugin_active( $plugin ) { - return in_array( $plugin, (array) get_option( 'active_plugins', array() ), true ) || is_plugin_active_for_network( $plugin ); -} - -/** - * Determines whether the plugin is inactive. - * - * Reverse of is_plugin_active(). Used as a callback. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 3.1.0 - * - * @see is_plugin_active() - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if inactive. False if active. - */ -function is_plugin_inactive( $plugin ) { - return ! is_plugin_active( $plugin ); -} - -/** - * Determines whether the plugin is active for the entire network. - * - * Only plugins installed in the plugins/ folder can be active. - * - * Plugins in the mu-plugins/ folder can't be "activated," so this function will - * return false for those plugins. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 3.0.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if active for the network, otherwise false. - */ -function is_plugin_active_for_network( $plugin ) { - if ( ! is_multisite() ) { - return false; - } - - $plugins = get_site_option( 'active_sitewide_plugins' ); - if ( isset( $plugins[ $plugin ] ) ) { - return true; - } - - return false; -} - -/** - * Checks for "Network: true" in the plugin header to see if this should - * be activated only as a network wide plugin. The plugin would also work - * when Multisite is not enabled. - * - * Checks for "Site Wide Only: true" for backward compatibility. - * - * @since 3.0.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if plugin is network only, false otherwise. - */ -function is_network_only_plugin( $plugin ) { - $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); - if ( $plugin_data ) { - return $plugin_data['Network']; - } - return false; -} - /** * Attempts activation of plugin in a "sandbox" and redirects on success. * diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index dd0066cf3fb63..44f908a301f75 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -7145,6 +7145,97 @@ function _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup return $plugin_data; } +/** + * Determines whether a plugin is active. + * + * Only plugins installed in the plugins/ folder can be active. + * + * Plugins in the mu-plugins/ folder can't be "activated," so this function will + * return false for those plugins. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 2.5.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True, if in the active plugins list. False, not in the list. + */ +function is_plugin_active( $plugin ) { + return in_array( $plugin, (array) get_option( 'active_plugins', array() ), true ) || is_plugin_active_for_network( $plugin ); +} + +/** + * Determines whether the plugin is inactive. + * + * Reverse of is_plugin_active(). Used as a callback. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 3.1.0 + * + * @see is_plugin_active() + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if inactive. False if active. + */ +function is_plugin_inactive( $plugin ) { + return ! is_plugin_active( $plugin ); +} + +/** + * Determines whether the plugin is active for the entire network. + * + * Only plugins installed in the plugins/ folder can be active. + * + * Plugins in the mu-plugins/ folder can't be "activated," so this function will + * return false for those plugins. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 3.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if active for the network, otherwise false. + */ +function is_plugin_active_for_network( $plugin ) { + if ( ! is_multisite() ) { + return false; + } + + $plugins = get_site_option( 'active_sitewide_plugins' ); + if ( isset( $plugins[ $plugin ] ) ) { + return true; + } + + return false; +} + +/** + * Checks for "Network: true" in the plugin header to see if this should + * be activated only as a network wide plugin. The plugin would also work + * when Multisite is not enabled. + * + * Checks for "Site Wide Only: true" for backward compatibility. + * + * @since 3.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if plugin is network only, false otherwise. + */ +function is_network_only_plugin( $plugin ) { + $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); + if ( $plugin_data ) { + return $plugin_data['Network']; + } + return false; +} + /** * Returns true. * From d93b275c3a5bbbbaf74690c1894d54da64fb3467 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Mon, 2 Dec 2024 23:34:02 +0000 Subject: [PATCH 003/115] Customize: Begin HTML markup before Customizer script hooks. This prevents printing styles and scripts before the ``. The `_wp_admin_html_begin()` function should precede Customizer script hooks, in case a plugin prints markup inside a hook such as `admin_enqueue_scripts`. Follow-up to [19995], [27907]. Props sabernhardt. Fixes #62629. git-svn-id: https://develop.svn.wordpress.org/trunk@59480 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/customize.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 40857031a7ef8..b27292d9eb949 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -100,6 +100,12 @@ $wp_customize->set_autofocus( $autofocus ); } +// Let's roll. +header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); + +wp_user_settings(); +_wp_admin_html_begin(); + $registered = $wp_scripts->registered; $wp_scripts = new WP_Scripts(); $wp_scripts->registered = $registered; @@ -126,12 +132,6 @@ */ do_action( 'customize_controls_enqueue_scripts' ); -// Let's roll. -header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) ); - -wp_user_settings(); -_wp_admin_html_begin(); - $body_class = 'wp-core-ui wp-customizer js'; if ( wp_is_mobile() ) : From 8eb8d634095e9b6716e977abd1085bab8d703e6f Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Tue, 3 Dec 2024 15:20:13 +0000 Subject: [PATCH 004/115] External Libraries: Upgrade PHPMailer to version 6.9.3. This is a maintenance release, adding support for the release version of PHP 8.4, and experimental support for PHP 8.5. References: * [https://github.com/PHPMailer/PHPMailer/releases/tag/v6.9.3 PHPMailer 6.9.3 release notes] * [https://github.com/PHPMailer/PHPMailer/compare/v6.9.2...v6.9.3 Full list of changes in PHPMailer 6.9.3] Follow-up to [50628], [50799], [51169], [51634], [51635], [52252], [52749], [52811], [53500], [53535], [53917], [54427], [54937], [55557], [56484], [57137], [59246]. Props desrosj, yogeshbhutkar, ayeshrajans. Fixes #62632. git-svn-id: https://develop.svn.wordpress.org/trunk@59481 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/PHPMailer/PHPMailer.php | 20 ++++++++++---------- src/wp-includes/PHPMailer/SMTP.php | 12 ++++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/PHPMailer/PHPMailer.php b/src/wp-includes/PHPMailer/PHPMailer.php index 0bc29b7832e6e..31731594f12ec 100644 --- a/src/wp-includes/PHPMailer/PHPMailer.php +++ b/src/wp-includes/PHPMailer/PHPMailer.php @@ -253,7 +253,7 @@ class PHPMailer * You can set your own, but it must be in the format "", * as defined in RFC5322 section 3.6.4 or it will be ignored. * - * @see https://tools.ietf.org/html/rfc5322#section-3.6.4 + * @see https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 * * @var string */ @@ -387,7 +387,7 @@ class PHPMailer * 'DELAY' will notify you if there is an unusual delay in delivery, but the actual * delivery's outcome (success or failure) is not yet decided. * - * @see https://tools.ietf.org/html/rfc3461 See section 4.1 for more information about NOTIFY + * @see https://www.rfc-editor.org/rfc/rfc3461.html#section-4.1 for more information about NOTIFY */ public $dsn = ''; @@ -756,7 +756,7 @@ class PHPMailer * * @var string */ - const VERSION = '6.9.2'; + const VERSION = '6.9.3'; /** * Error severity: message only, continue processing. @@ -1873,7 +1873,7 @@ protected static function isShellSafe($string) */ protected static function isPermittedPath($path) { - //Matches scheme definition from https://tools.ietf.org/html/rfc3986#section-3.1 + //Matches scheme definition from https://www.rfc-editor.org/rfc/rfc3986#section-3.1 return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); } @@ -2707,7 +2707,7 @@ public function createHeader() } //Only allow a custom message ID if it conforms to RFC 5322 section 3.6.4 - //https://tools.ietf.org/html/rfc5322#section-3.6.4 + //https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4 if ( '' !== $this->MessageID && preg_match( @@ -4912,7 +4912,7 @@ public function DKIM_Sign($signHeader) * Uses the 'relaxed' algorithm from RFC6376 section 3.4.2. * Canonicalized headers should *always* use CRLF, regardless of mailer setting. * - * @see https://tools.ietf.org/html/rfc6376#section-3.4.2 + * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.2 * * @param string $signHeader Header * @@ -4924,7 +4924,7 @@ public function DKIM_HeaderC($signHeader) $signHeader = static::normalizeBreaks($signHeader, self::CRLF); //Unfold header lines //Note PCRE \s is too broad a definition of whitespace; RFC5322 defines it as `[ \t]` - //@see https://tools.ietf.org/html/rfc5322#section-2.2 + //@see https://www.rfc-editor.org/rfc/rfc5322#section-2.2 //That means this may break if you do something daft like put vertical tabs in your headers. $signHeader = preg_replace('/\r\n[ \t]+/', ' ', $signHeader); //Break headers out into an array @@ -4956,7 +4956,7 @@ public function DKIM_HeaderC($signHeader) * Uses the 'simple' algorithm from RFC6376 section 3.4.3. * Canonicalized bodies should *always* use CRLF, regardless of mailer setting. * - * @see https://tools.ietf.org/html/rfc6376#section-3.4.3 + * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4.3 * * @param string $body Message Body * @@ -4992,7 +4992,7 @@ public function DKIM_Add($headers_line, $subject, $body) $DKIMquery = 'dns/txt'; //Query method $DKIMtime = time(); //Always sign these headers without being asked - //Recommended list from https://tools.ietf.org/html/rfc6376#section-5.4.1 + //Recommended list from https://www.rfc-editor.org/rfc/rfc6376#section-5.4.1 $autoSignHeaders = [ 'from', 'to', @@ -5098,7 +5098,7 @@ public function DKIM_Add($headers_line, $subject, $body) } //The DKIM-Signature header is included in the signature *except for* the value of the `b` tag //which is appended after calculating the signature - //https://tools.ietf.org/html/rfc6376#section-3.5 + //https://www.rfc-editor.org/rfc/rfc6376#section-3.5 $dkimSignatureHeader = 'DKIM-Signature: v=1;' . ' d=' . $this->DKIM_domain . ';' . ' s=' . $this->DKIM_selector . ';' . static::$LE . diff --git a/src/wp-includes/PHPMailer/SMTP.php b/src/wp-includes/PHPMailer/SMTP.php index 5b238b5279758..b4eff40424ffc 100644 --- a/src/wp-includes/PHPMailer/SMTP.php +++ b/src/wp-includes/PHPMailer/SMTP.php @@ -35,7 +35,7 @@ class SMTP * * @var string */ - const VERSION = '6.9.2'; + const VERSION = '6.9.3'; /** * SMTP line break constant. @@ -62,7 +62,7 @@ class SMTP * The maximum line length allowed by RFC 5321 section 4.5.3.1.6, * *excluding* a trailing CRLF break. * - * @see https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6 + * @see https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.1.6 * * @var int */ @@ -72,7 +72,7 @@ class SMTP * The maximum line length allowed for replies in RFC 5321 section 4.5.3.1.5, * *including* a trailing CRLF line break. * - * @see https://tools.ietf.org/html/rfc5321#section-4.5.3.1.5 + * @see https://www.rfc-editor.org/rfc/rfc5321#section-4.5.3.1.5 * * @var int */ @@ -373,7 +373,7 @@ public function connect($host, $port = null, $timeout = 30, $options = []) } //Anything other than a 220 response means something went wrong //RFC 5321 says the server will wait for us to send a QUIT in response to a 554 error - //https://tools.ietf.org/html/rfc5321#section-3.1 + //https://www.rfc-editor.org/rfc/rfc5321#section-3.1 if ($responseCode === 554) { $this->quit(); } @@ -582,7 +582,7 @@ public function authenticate( } //Send encoded username and password if ( - //Format from https://tools.ietf.org/html/rfc4616#section-2 + //Format from https://www.rfc-editor.org/rfc/rfc4616#section-2 //We skip the first field (it's forgery), so the string starts with a null byte !$this->sendCommand( 'User & Password', @@ -795,7 +795,7 @@ public function data($msg_data) //Send the lines to the server foreach ($lines_out as $line_out) { //Dot-stuffing as per RFC5321 section 4.5.2 - //https://tools.ietf.org/html/rfc5321#section-4.5.2 + //https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2 if (!empty($line_out) && $line_out[0] === '.') { $line_out = '.' . $line_out; } From 1d49212bb97622e0773652cf1972b86fd08fca22 Mon Sep 17 00:00:00 2001 From: bernhard-reiter Date: Wed, 4 Dec 2024 12:05:33 +0000 Subject: [PATCH 005/115] Block Hooks: Fix context in `update_ignored_hooked_blocks_postmeta`. Ensure that the `$context` arg passed from `update_ignored_hooked_blocks_postmeta` to `apply_block_hooks_to_content` (and from there, to filters such as `hooked_block_types` and `hooked_block`) has the correct type (`WP_Post`). Filters hooked to `hooked_block_types` etc can typically include checks that conditionally insert a hooked block depending on `$context`. Prior to this changeset, a check like `if ( $context instanceof WP_Post )` would incorrectly fail, as `$context` would be a `stdClass` instance rather than a `WP_Post`. As a consequence, a hooked block inside of a Navigation post object that was modified by the user would not be marked as ignored by `update_ignored_hooked_blocks_postmeta`, and thus be erroneosly re-inserted by the Block Hooks algorithm. Props bernhard-reiter. Fixes #62639. git-svn-id: https://develop.svn.wordpress.org/trunk@59482 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/blocks.php | 1 + .../updateIgnoredHookedBlocksPostMeta.php | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/wp-includes/blocks.php b/src/wp-includes/blocks.php index 9d8f4bfaf9845..94c4dfa43afb6 100644 --- a/src/wp-includes/blocks.php +++ b/src/wp-includes/blocks.php @@ -1217,6 +1217,7 @@ function update_ignored_hooked_blocks_postmeta( $post ) { $existing_post = get_post( $post->ID ); // Merge the existing post object with the updated post object to pass to the block hooks algorithm for context. $context = (object) array_merge( (array) $existing_post, (array) $post ); + $context = new WP_Post( $context ); // Convert to WP_Post object. $serialized_block = apply_block_hooks_to_content( $markup, $context, 'set_ignored_hooked_blocks_metadata' ); $root_block = parse_blocks( $serialized_block )[0]; diff --git a/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php b/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php index 7b0a830dd52dd..18b5eff08ba79 100644 --- a/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php +++ b/tests/phpunit/tests/blocks/updateIgnoredHookedBlocksPostMeta.php @@ -193,4 +193,31 @@ public function test_update_ignored_hooked_blocks_postmeta_dont_modify_if_no_pos 'Post content did not match the original markup.' ); } + + /** + * @ticket 62639 + */ + public function test_update_ignored_hooked_blocks_postmeta_sets_correct_context_type() { + $action = new MockAction(); + add_filter( 'hooked_block_types', array( $action, 'filter' ), 10, 4 ); + + $original_markup = ''; + $post = new stdClass(); + $post->ID = self::$navigation_post->ID; + $post->post_content = $original_markup; + $post->post_type = 'wp_navigation'; + + $post = update_ignored_hooked_blocks_postmeta( $post ); + + $args = $action->get_args(); + $contexts = array_column( $args, 3 ); + + foreach ( $contexts as $context ) { + $this->assertInstanceOf( + WP_Post::class, + $context, + 'The context passed to the hooked_block_types filter is not a WP_Post instance.' + ); + } + } } From d576e249754ee902dfbb2d07364e694307396be0 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 4 Dec 2024 14:41:26 +0000 Subject: [PATCH 006/115] Build/Test Tools: Add `repository` input to support JSON reading workflow. `actions/checkout` will always checkout the current repository unless the `repository` input is specified. This updates the `reusable-support-json-reader-v1.yml` workflow to always default to reading the JSON files from `wordpress-develop`. A `repository` has also been added to the workflow to allow a different set of JSON files to be read if desired. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59483 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/reusable-support-json-reader-v1.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/reusable-support-json-reader-v1.yml b/.github/workflows/reusable-support-json-reader-v1.yml index 2adc15cebdee1..f1843cd5aa79b 100644 --- a/.github/workflows/reusable-support-json-reader-v1.yml +++ b/.github/workflows/reusable-support-json-reader-v1.yml @@ -11,6 +11,10 @@ on: description: 'The WordPress version to test . Accepts major and minor versions, "latest", or "nightly". Major releases must not end with ".0".' type: string default: 'nightly' + repository: + description: 'The repository to read support JSON files from.' + type: string + default: 'WordPress/wordpress-develop' outputs: major-wp-version: description: "The major WordPress version based on the version provided in wp-version" @@ -42,6 +46,7 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} - name: Determine the major WordPress version @@ -74,6 +79,7 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} # Look up the major version's specific PHP support policy when a version is provided. @@ -106,6 +112,7 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: + repository: ${{ inputs.repository }} show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} # Look up the major version's specific MySQL support policy when a version is provided. From ea80ac8082ab983b91324836f466a8b585fe8590 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 4 Dec 2024 15:16:02 +0000 Subject: [PATCH 007/115] Build/Test Tools: Support older MariaDB versions in local Docker environment. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older versions of MariaDB did not contain the `mariadb-admin` command. This command is configured as the `healthcheck` used by the local Docker environment to confirm that the database container has successfully started and is reporting as “healthy”. The current result is a failure when starting the environment while using one of the affected older versions. For MariaDB versions 10.3 and earlier, the `mysqladmin` command was used instead. Since WordPress still technically supports back to MariaDB 5.5, the local environment should support running these versions. This updates the environment configuration to take this into account when performing a `healthcheck` test. The README file is also updated to reflect that the same workaround added in [57568] for MySQL <= 5.7 is required when using MariaDB 5.5 on an Apple silicon machine. Props johnbillion. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59484 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/reusable-phpunit-tests-v3.yml | 2 +- README.md | 9 ++++++--- docker-compose.yml | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/reusable-phpunit-tests-v3.yml b/.github/workflows/reusable-phpunit-tests-v3.yml index 56e40e762cd27..fd21b3a9b48f5 100644 --- a/.github/workflows/reusable-phpunit-tests-v3.yml +++ b/.github/workflows/reusable-phpunit-tests-v3.yml @@ -171,7 +171,7 @@ jobs: - name: WordPress Docker container debug information run: | - docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE }} --version + docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE == 'mariadb' && contains( fromJSON('["5.5", "10.0", "10.1", "10.2", "10.3"]'), env.LOCAL_DB_VERSION ) && 'mysql' || env.LOCAL_DB_TYPE }} --version docker compose run --rm php php --version docker compose run --rm php php -m docker compose run --rm php php -i diff --git a/README.md b/README.md index adb4fc946d9a8..fc8c00f6821af 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,14 @@ The development environment can be reset. This will destroy the database and att npm run env:reset ``` -### Apple Silicon machines and old MySQL versions +### Apple Silicon machines and old MySQL/MariaDB versions -The MySQL Docker images do not support Apple Silicon processors (M1, M2, etc.) for MySQL versions 5.7 and earlier. +Older MySQL and MariaDB Docker images do not support Apple Silicon processors (M1, M2, etc.). This is true for: -When using MySQL <= 5.7 on an Apple Silicon machine, you must create a `docker-compose.override.yml` file with the following contents: +- MySQL versions 5.7 and earlier +- MariaDB 5.5 + +When using these versions on an Apple Silicon machine, you must create a `docker-compose.override.yml` file with the following contents: ``` services: diff --git a/docker-compose.yml b/docker-compose.yml index 8b90b678a00a2..a95735fdd35a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,7 @@ services: command: ${LOCAL_DB_AUTH_OPTION-} healthcheck: - test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then mariadb-admin ping -h localhost; else mysqladmin ping -h localhost; fi" ] + test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then case \"$LOCAL_DB_VERSION\" in 5.5|10.0|10.1|10.2|10.3) mysqladmin ping -h localhost || exit $?;; *) mariadb-admin ping -h localhost || exit $?;; esac; else mysqladmin ping -h localhost || exit $?; fi" ] timeout: 5s interval: 5s retries: 10 From 9558c1f48a5d118791e09a0184d22b4872714b7b Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Wed, 4 Dec 2024 15:25:18 +0000 Subject: [PATCH 008/115] Build/Test Tools: Run install tests when JSON reading workflow is changed. Because the installation testing workflow relies on the reusable workflow that reads the JSON support files, it should be run when that file is changed to confirm there are no issues. This is currently only configured for `pull_request` events, but should also be true for `push`. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59485 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/install-testing.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/install-testing.yml b/.github/workflows/install-testing.yml index c9de4a73abf25..ca03ebd518a00 100644 --- a/.github/workflows/install-testing.yml +++ b/.github/workflows/install-testing.yml @@ -11,6 +11,7 @@ on: paths: - '.github/workflows/install-testing.yml' - '.version-support-*.json' + - '.github/workflows/reusable-support-json-reader-v1.yml' pull_request: # Always test the workflow when changes are suggested. paths: From 7d3ce7a591eb8fe85e626a1e288d5c1528a54321 Mon Sep 17 00:00:00 2001 From: Sergey Biryukov Date: Wed, 4 Dec 2024 23:36:04 +0000 Subject: [PATCH 009/115] Docs: Remove blank line at the end of `wp_prepare_attachment_for_js()` DocBlock. Follow-up to [21680], [49281]. Props nareshbheda. Fixes #62642. git-svn-id: https://develop.svn.wordpress.org/trunk@59486 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/media.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 00e23ff53d7ef..2f280a58790c9 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4455,7 +4455,6 @@ function wp_plupload_default_settings() { * @type string $url Direct URL to the attachment file (from wp-content). * @type int $width If the attachment is an image, represents the width of the image in pixels. * } - * */ function wp_prepare_attachment_for_js( $attachment ) { $attachment = get_post( $attachment ); From b67c76ebc616f77f1a3349362dbcc262397da99c Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 5 Dec 2024 12:11:27 +0000 Subject: [PATCH 010/115] Plugins: Load `wp-admin/includes/plugin.php` earlier. Partially reverts [59479] and [59461], which previously tried to move some functions from `wp-admin/includes/plugin.php` to `wp-includes/functions.php` so they are available early, so that `get_plugin_data()` can be used. However, other functions from that file are often used by plugins without necessarily checking whether they are available, easily causing fatal errors. Requiring this file directly is a safer approach to avoid such errors. Props peterwilsoncc, dd32, swissspidy, johnbillion. Fixes #62244. git-svn-id: https://develop.svn.wordpress.org/trunk@59488 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/includes/plugin.php | 306 +++++++++++++++++++++++++++++++ src/wp-includes/functions.php | 306 ------------------------------- src/wp-settings.php | 3 + 3 files changed, 309 insertions(+), 306 deletions(-) diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php index 977801d92b010..de1468352b3d9 100644 --- a/src/wp-admin/includes/plugin.php +++ b/src/wp-admin/includes/plugin.php @@ -6,6 +6,221 @@ * @subpackage Administration */ +/** + * Parses the plugin contents to retrieve plugin's metadata. + * + * All plugin headers must be on their own line. Plugin description must not have + * any newlines, otherwise only parts of the description will be displayed. + * The below is formatted for printing. + * + * /* + * Plugin Name: Name of the plugin. + * Plugin URI: The home page of the plugin. + * Description: Plugin description. + * Author: Plugin author's name. + * Author URI: Link to the author's website. + * Version: Plugin version. + * Text Domain: Optional. Unique identifier, should be same as the one used in + * load_plugin_textdomain(). + * Domain Path: Optional. Only useful if the translations are located in a + * folder above the plugin's base path. For example, if .mo files are + * located in the locale folder then Domain Path will be "/locale/" and + * must have the first slash. Defaults to the base folder the plugin is + * located in. + * Network: Optional. Specify "Network: true" to require that a plugin is activated + * across all sites in an installation. This will prevent a plugin from being + * activated on a single site when Multisite is enabled. + * Requires at least: Optional. Specify the minimum required WordPress version. + * Requires PHP: Optional. Specify the minimum required PHP version. + * * / # Remove the space to close comment. + * + * The first 8 KB of the file will be pulled in and if the plugin data is not + * within that first 8 KB, then the plugin author should correct their plugin + * and move the plugin data headers to the top. + * + * The plugin file is assumed to have permissions to allow for scripts to read + * the file. This is not checked however and the file is only opened for + * reading. + * + * @since 1.5.0 + * @since 5.3.0 Added support for `Requires at least` and `Requires PHP` headers. + * @since 5.8.0 Added support for `Update URI` header. + * @since 6.5.0 Added support for `Requires Plugins` header. + * + * @param string $plugin_file Absolute path to the main plugin file. + * @param bool $markup Optional. If the returned data should have HTML markup applied. + * Default true. + * @param bool $translate Optional. If the returned data should be translated. Default true. + * @return array { + * Plugin data. Values will be empty if not supplied by the plugin. + * + * @type string $Name Name of the plugin. Should be unique. + * @type string $PluginURI Plugin URI. + * @type string $Version Plugin version. + * @type string $Description Plugin description. + * @type string $Author Plugin author's name. + * @type string $AuthorURI Plugin author's website address (if set). + * @type string $TextDomain Plugin textdomain. + * @type string $DomainPath Plugin's relative directory path to .mo files. + * @type bool $Network Whether the plugin can only be activated network-wide. + * @type string $RequiresWP Minimum required version of WordPress. + * @type string $RequiresPHP Minimum required version of PHP. + * @type string $UpdateURI ID of the plugin for update purposes, should be a URI. + * @type string $RequiresPlugins Comma separated list of dot org plugin slugs. + * @type string $Title Title of the plugin and link to the plugin's site (if set). + * @type string $AuthorName Plugin author's name. + * } + */ +function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { + + $default_headers = array( + 'Name' => 'Plugin Name', + 'PluginURI' => 'Plugin URI', + 'Version' => 'Version', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + 'Network' => 'Network', + 'RequiresWP' => 'Requires at least', + 'RequiresPHP' => 'Requires PHP', + 'UpdateURI' => 'Update URI', + 'RequiresPlugins' => 'Requires Plugins', + // Site Wide Only is deprecated in favor of Network. + '_sitewide' => 'Site Wide Only', + ); + + $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); + + // Site Wide Only is the old header for Network. + if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) { + /* translators: 1: Site Wide Only: true, 2: Network: true */ + _deprecated_argument( __FUNCTION__, '3.0.0', sprintf( __( 'The %1$s plugin header is deprecated. Use %2$s instead.' ), 'Site Wide Only: true', 'Network: true' ) ); + $plugin_data['Network'] = $plugin_data['_sitewide']; + } + $plugin_data['Network'] = ( 'true' === strtolower( $plugin_data['Network'] ) ); + unset( $plugin_data['_sitewide'] ); + + // If no text domain is defined fall back to the plugin slug. + if ( ! $plugin_data['TextDomain'] ) { + $plugin_slug = dirname( plugin_basename( $plugin_file ) ); + if ( '.' !== $plugin_slug && ! str_contains( $plugin_slug, '/' ) ) { + $plugin_data['TextDomain'] = $plugin_slug; + } + } + + if ( $markup || $translate ) { + $plugin_data = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup, $translate ); + } else { + $plugin_data['Title'] = $plugin_data['Name']; + $plugin_data['AuthorName'] = $plugin_data['Author']; + } + + return $plugin_data; +} + +/** + * Sanitizes plugin data, optionally adds markup, optionally translates. + * + * @since 2.7.0 + * + * @see get_plugin_data() + * + * @access private + * + * @param string $plugin_file Path to the main plugin file. + * @param array $plugin_data An array of plugin data. See get_plugin_data(). + * @param bool $markup Optional. If the returned data should have HTML markup applied. + * Default true. + * @param bool $translate Optional. If the returned data should be translated. Default true. + * @return array Plugin data. Values will be empty if not supplied by the plugin. + * See get_plugin_data() for the list of possible values. + */ +function _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup = true, $translate = true ) { + + // Sanitize the plugin filename to a WP_PLUGIN_DIR relative path. + $plugin_file = plugin_basename( $plugin_file ); + + // Translate fields. + if ( $translate ) { + $textdomain = $plugin_data['TextDomain']; + if ( $textdomain ) { + if ( ! is_textdomain_loaded( $textdomain ) ) { + if ( $plugin_data['DomainPath'] ) { + load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) . $plugin_data['DomainPath'] ); + } else { + load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) ); + } + } + } elseif ( 'hello.php' === basename( $plugin_file ) ) { + $textdomain = 'default'; + } + if ( $textdomain ) { + foreach ( array( 'Name', 'PluginURI', 'Description', 'Author', 'AuthorURI', 'Version' ) as $field ) { + if ( ! empty( $plugin_data[ $field ] ) ) { + // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain + $plugin_data[ $field ] = translate( $plugin_data[ $field ], $textdomain ); + } + } + } + } + + // Sanitize fields. + $allowed_tags_in_links = array( + 'abbr' => array( 'title' => true ), + 'acronym' => array( 'title' => true ), + 'code' => true, + 'em' => true, + 'strong' => true, + ); + + $allowed_tags = $allowed_tags_in_links; + $allowed_tags['a'] = array( + 'href' => true, + 'title' => true, + ); + + /* + * Name is marked up inside tags. Don't allow these. + * Author is too, but some plugins have used here (omitting Author URI). + */ + $plugin_data['Name'] = wp_kses( $plugin_data['Name'], $allowed_tags_in_links ); + $plugin_data['Author'] = wp_kses( $plugin_data['Author'], $allowed_tags ); + + $plugin_data['Description'] = wp_kses( $plugin_data['Description'], $allowed_tags ); + $plugin_data['Version'] = wp_kses( $plugin_data['Version'], $allowed_tags ); + + $plugin_data['PluginURI'] = esc_url( $plugin_data['PluginURI'] ); + $plugin_data['AuthorURI'] = esc_url( $plugin_data['AuthorURI'] ); + + $plugin_data['Title'] = $plugin_data['Name']; + $plugin_data['AuthorName'] = $plugin_data['Author']; + + // Apply markup. + if ( $markup ) { + if ( $plugin_data['PluginURI'] && $plugin_data['Name'] ) { + $plugin_data['Title'] = '' . $plugin_data['Name'] . ''; + } + + if ( $plugin_data['AuthorURI'] && $plugin_data['Author'] ) { + $plugin_data['Author'] = '' . $plugin_data['Author'] . ''; + } + + $plugin_data['Description'] = wptexturize( $plugin_data['Description'] ); + + if ( $plugin_data['Author'] ) { + $plugin_data['Description'] .= sprintf( + /* translators: %s: Plugin author. */ + ' ' . __( 'By %s.' ) . '', + $plugin_data['Author'] + ); + } + } + + return $plugin_data; +} + /** * Gets a list of a plugin's files. * @@ -304,6 +519,97 @@ function _get_dropins() { return $dropins; } +/** + * Determines whether a plugin is active. + * + * Only plugins installed in the plugins/ folder can be active. + * + * Plugins in the mu-plugins/ folder can't be "activated," so this function will + * return false for those plugins. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 2.5.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True, if in the active plugins list. False, not in the list. + */ +function is_plugin_active( $plugin ) { + return in_array( $plugin, (array) get_option( 'active_plugins', array() ), true ) || is_plugin_active_for_network( $plugin ); +} + +/** + * Determines whether the plugin is inactive. + * + * Reverse of is_plugin_active(). Used as a callback. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 3.1.0 + * + * @see is_plugin_active() + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if inactive. False if active. + */ +function is_plugin_inactive( $plugin ) { + return ! is_plugin_active( $plugin ); +} + +/** + * Determines whether the plugin is active for the entire network. + * + * Only plugins installed in the plugins/ folder can be active. + * + * Plugins in the mu-plugins/ folder can't be "activated," so this function will + * return false for those plugins. + * + * For more information on this and similar theme functions, check out + * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ + * Conditional Tags} article in the Theme Developer Handbook. + * + * @since 3.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if active for the network, otherwise false. + */ +function is_plugin_active_for_network( $plugin ) { + if ( ! is_multisite() ) { + return false; + } + + $plugins = get_site_option( 'active_sitewide_plugins' ); + if ( isset( $plugins[ $plugin ] ) ) { + return true; + } + + return false; +} + +/** + * Checks for "Network: true" in the plugin header to see if this should + * be activated only as a network wide plugin. The plugin would also work + * when Multisite is not enabled. + * + * Checks for "Site Wide Only: true" for backward compatibility. + * + * @since 3.0.0 + * + * @param string $plugin Path to the plugin file relative to the plugins directory. + * @return bool True if plugin is network only, false otherwise. + */ +function is_network_only_plugin( $plugin ) { + $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); + if ( $plugin_data ) { + return $plugin_data['Network']; + } + return false; +} + /** * Attempts activation of plugin in a "sandbox" and redirects on success. * diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 44f908a301f75..7e46dd53cab2d 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -6930,312 +6930,6 @@ function get_file_data( $file, $default_headers, $context = '' ) { return $all_headers; } -/** - * Parses the plugin contents to retrieve plugin's metadata. - * - * All plugin headers must be on their own line. Plugin description must not have - * any newlines, otherwise only parts of the description will be displayed. - * The below is formatted for printing. - * - * /* - * Plugin Name: Name of the plugin. - * Plugin URI: The home page of the plugin. - * Description: Plugin description. - * Author: Plugin author's name. - * Author URI: Link to the author's website. - * Version: Plugin version. - * Text Domain: Optional. Unique identifier, should be same as the one used in - * load_plugin_textdomain(). - * Domain Path: Optional. Only useful if the translations are located in a - * folder above the plugin's base path. For example, if .mo files are - * located in the locale folder then Domain Path will be "/locale/" and - * must have the first slash. Defaults to the base folder the plugin is - * located in. - * Network: Optional. Specify "Network: true" to require that a plugin is activated - * across all sites in an installation. This will prevent a plugin from being - * activated on a single site when Multisite is enabled. - * Requires at least: Optional. Specify the minimum required WordPress version. - * Requires PHP: Optional. Specify the minimum required PHP version. - * * / # Remove the space to close comment. - * - * The first 8 KB of the file will be pulled in and if the plugin data is not - * within that first 8 KB, then the plugin author should correct their plugin - * and move the plugin data headers to the top. - * - * The plugin file is assumed to have permissions to allow for scripts to read - * the file. This is not checked however and the file is only opened for - * reading. - * - * @since 1.5.0 - * @since 5.3.0 Added support for `Requires at least` and `Requires PHP` headers. - * @since 5.8.0 Added support for `Update URI` header. - * @since 6.5.0 Added support for `Requires Plugins` header. - * - * @param string $plugin_file Absolute path to the main plugin file. - * @param bool $markup Optional. If the returned data should have HTML markup applied. - * Default true. - * @param bool $translate Optional. If the returned data should be translated. Default true. - * @return array { - * Plugin data. Values will be empty if not supplied by the plugin. - * - * @type string $Name Name of the plugin. Should be unique. - * @type string $PluginURI Plugin URI. - * @type string $Version Plugin version. - * @type string $Description Plugin description. - * @type string $Author Plugin author's name. - * @type string $AuthorURI Plugin author's website address (if set). - * @type string $TextDomain Plugin textdomain. - * @type string $DomainPath Plugin's relative directory path to .mo files. - * @type bool $Network Whether the plugin can only be activated network-wide. - * @type string $RequiresWP Minimum required version of WordPress. - * @type string $RequiresPHP Minimum required version of PHP. - * @type string $UpdateURI ID of the plugin for update purposes, should be a URI. - * @type string $RequiresPlugins Comma separated list of dot org plugin slugs. - * @type string $Title Title of the plugin and link to the plugin's site (if set). - * @type string $AuthorName Plugin author's name. - * } - */ -function get_plugin_data( $plugin_file, $markup = true, $translate = true ) { - - $default_headers = array( - 'Name' => 'Plugin Name', - 'PluginURI' => 'Plugin URI', - 'Version' => 'Version', - 'Description' => 'Description', - 'Author' => 'Author', - 'AuthorURI' => 'Author URI', - 'TextDomain' => 'Text Domain', - 'DomainPath' => 'Domain Path', - 'Network' => 'Network', - 'RequiresWP' => 'Requires at least', - 'RequiresPHP' => 'Requires PHP', - 'UpdateURI' => 'Update URI', - 'RequiresPlugins' => 'Requires Plugins', - // Site Wide Only is deprecated in favor of Network. - '_sitewide' => 'Site Wide Only', - ); - - $plugin_data = get_file_data( $plugin_file, $default_headers, 'plugin' ); - - // Site Wide Only is the old header for Network. - if ( ! $plugin_data['Network'] && $plugin_data['_sitewide'] ) { - /* translators: 1: Site Wide Only: true, 2: Network: true */ - _deprecated_argument( __FUNCTION__, '3.0.0', sprintf( __( 'The %1$s plugin header is deprecated. Use %2$s instead.' ), 'Site Wide Only: true', 'Network: true' ) ); - $plugin_data['Network'] = $plugin_data['_sitewide']; - } - $plugin_data['Network'] = ( 'true' === strtolower( $plugin_data['Network'] ) ); - unset( $plugin_data['_sitewide'] ); - - // If no text domain is defined fall back to the plugin slug. - if ( ! $plugin_data['TextDomain'] ) { - $plugin_slug = dirname( plugin_basename( $plugin_file ) ); - if ( '.' !== $plugin_slug && ! str_contains( $plugin_slug, '/' ) ) { - $plugin_data['TextDomain'] = $plugin_slug; - } - } - - if ( $markup || $translate ) { - $plugin_data = _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup, $translate ); - } else { - $plugin_data['Title'] = $plugin_data['Name']; - $plugin_data['AuthorName'] = $plugin_data['Author']; - } - - return $plugin_data; -} - -/** - * Sanitizes plugin data, optionally adds markup, optionally translates. - * - * @since 2.7.0 - * - * @see get_plugin_data() - * - * @access private - * - * @param string $plugin_file Path to the main plugin file. - * @param array $plugin_data An array of plugin data. See get_plugin_data(). - * @param bool $markup Optional. If the returned data should have HTML markup applied. - * Default true. - * @param bool $translate Optional. If the returned data should be translated. Default true. - * @return array Plugin data. Values will be empty if not supplied by the plugin. - * See get_plugin_data() for the list of possible values. - */ -function _get_plugin_data_markup_translate( $plugin_file, $plugin_data, $markup = true, $translate = true ) { - - // Sanitize the plugin filename to a WP_PLUGIN_DIR relative path. - $plugin_file = plugin_basename( $plugin_file ); - - // Translate fields. - if ( $translate ) { - $textdomain = $plugin_data['TextDomain']; - if ( $textdomain ) { - if ( ! is_textdomain_loaded( $textdomain ) ) { - if ( $plugin_data['DomainPath'] ) { - load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) . $plugin_data['DomainPath'] ); - } else { - load_plugin_textdomain( $textdomain, false, dirname( $plugin_file ) ); - } - } - } elseif ( 'hello.php' === basename( $plugin_file ) ) { - $textdomain = 'default'; - } - if ( $textdomain ) { - foreach ( array( 'Name', 'PluginURI', 'Description', 'Author', 'AuthorURI', 'Version' ) as $field ) { - if ( ! empty( $plugin_data[ $field ] ) ) { - // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain - $plugin_data[ $field ] = translate( $plugin_data[ $field ], $textdomain ); - } - } - } - } - - // Sanitize fields. - $allowed_tags_in_links = array( - 'abbr' => array( 'title' => true ), - 'acronym' => array( 'title' => true ), - 'code' => true, - 'em' => true, - 'strong' => true, - ); - - $allowed_tags = $allowed_tags_in_links; - $allowed_tags['a'] = array( - 'href' => true, - 'title' => true, - ); - - /* - * Name is marked up inside tags. Don't allow these. - * Author is too, but some plugins have used here (omitting Author URI). - */ - $plugin_data['Name'] = wp_kses( $plugin_data['Name'], $allowed_tags_in_links ); - $plugin_data['Author'] = wp_kses( $plugin_data['Author'], $allowed_tags ); - - $plugin_data['Description'] = wp_kses( $plugin_data['Description'], $allowed_tags ); - $plugin_data['Version'] = wp_kses( $plugin_data['Version'], $allowed_tags ); - - $plugin_data['PluginURI'] = esc_url( $plugin_data['PluginURI'] ); - $plugin_data['AuthorURI'] = esc_url( $plugin_data['AuthorURI'] ); - - $plugin_data['Title'] = $plugin_data['Name']; - $plugin_data['AuthorName'] = $plugin_data['Author']; - - // Apply markup. - if ( $markup ) { - if ( $plugin_data['PluginURI'] && $plugin_data['Name'] ) { - $plugin_data['Title'] = '' . $plugin_data['Name'] . ''; - } - - if ( $plugin_data['AuthorURI'] && $plugin_data['Author'] ) { - $plugin_data['Author'] = '' . $plugin_data['Author'] . ''; - } - - $plugin_data['Description'] = wptexturize( $plugin_data['Description'] ); - - if ( $plugin_data['Author'] ) { - $plugin_data['Description'] .= sprintf( - /* translators: %s: Plugin author. */ - ' ' . __( 'By %s.' ) . '', - $plugin_data['Author'] - ); - } - } - - return $plugin_data; -} - -/** - * Determines whether a plugin is active. - * - * Only plugins installed in the plugins/ folder can be active. - * - * Plugins in the mu-plugins/ folder can't be "activated," so this function will - * return false for those plugins. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 2.5.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True, if in the active plugins list. False, not in the list. - */ -function is_plugin_active( $plugin ) { - return in_array( $plugin, (array) get_option( 'active_plugins', array() ), true ) || is_plugin_active_for_network( $plugin ); -} - -/** - * Determines whether the plugin is inactive. - * - * Reverse of is_plugin_active(). Used as a callback. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 3.1.0 - * - * @see is_plugin_active() - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if inactive. False if active. - */ -function is_plugin_inactive( $plugin ) { - return ! is_plugin_active( $plugin ); -} - -/** - * Determines whether the plugin is active for the entire network. - * - * Only plugins installed in the plugins/ folder can be active. - * - * Plugins in the mu-plugins/ folder can't be "activated," so this function will - * return false for those plugins. - * - * For more information on this and similar theme functions, check out - * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/ - * Conditional Tags} article in the Theme Developer Handbook. - * - * @since 3.0.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if active for the network, otherwise false. - */ -function is_plugin_active_for_network( $plugin ) { - if ( ! is_multisite() ) { - return false; - } - - $plugins = get_site_option( 'active_sitewide_plugins' ); - if ( isset( $plugins[ $plugin ] ) ) { - return true; - } - - return false; -} - -/** - * Checks for "Network: true" in the plugin header to see if this should - * be activated only as a network wide plugin. The plugin would also work - * when Multisite is not enabled. - * - * Checks for "Site Wide Only: true" for backward compatibility. - * - * @since 3.0.0 - * - * @param string $plugin Path to the plugin file relative to the plugins directory. - * @return bool True if plugin is network only, false otherwise. - */ -function is_network_only_plugin( $plugin ) { - $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); - if ( $plugin_data ) { - return $plugin_data['Network']; - } - return false; -} - /** * Returns true. * diff --git a/src/wp-settings.php b/src/wp-settings.php index 44c04a3a7bead..635f6de248dd5 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -518,6 +518,9 @@ wp_recovery_mode()->initialize(); } +// To make get_plugin_data() available in a way that's compatible with plugins also loading this file, see #62244. +require_once ABSPATH . 'wp-admin/includes/plugin.php'; + // Load active plugins. foreach ( wp_get_active_and_valid_plugins() as $plugin ) { wp_register_plugin_realpath( $plugin ); From e585c954677cbec31f152a70c302464a5769080e Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Thu, 5 Dec 2024 15:35:17 +0000 Subject: [PATCH 011/115] Build/Test Tools: Properly escape `$` characters in Docker compose file. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes an invalid interpolation format error that can be encountered in the `mysql` container’s healthcheck test command. Follow up to [59484]. Props afercia. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59489 602fd350-edb4-49c9-b593-d223f7449a82 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index a95735fdd35a0..ec462c8a24c5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,7 +82,7 @@ services: command: ${LOCAL_DB_AUTH_OPTION-} healthcheck: - test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then case \"$LOCAL_DB_VERSION\" in 5.5|10.0|10.1|10.2|10.3) mysqladmin ping -h localhost || exit $?;; *) mariadb-admin ping -h localhost || exit $?;; esac; else mysqladmin ping -h localhost || exit $?; fi" ] + test: [ "CMD-SHELL", "if [ \"$LOCAL_DB_TYPE\" = \"mariadb\" ]; then case \"$LOCAL_DB_VERSION\" in 5.5|10.0|10.1|10.2|10.3) mysqladmin ping -h localhost || exit $$?;; *) mariadb-admin ping -h localhost || exit $$?;; esac; else mysqladmin ping -h localhost || exit $$?; fi" ] timeout: 5s interval: 5s retries: 10 From 5adbb3e517e16c2396601dec968e991a63802978 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Thu, 5 Dec 2024 15:54:14 +0000 Subject: [PATCH 012/115] Build/Test Tools: Use newer versions for `include` jobs. The `include` part of the strategy for the PHPUnit testing workflow defines a few testing configurations outside of the matrix. The versions of PHP and MySQL used in these have not been updated for some time. This was mostly due to various incompatibilities that have since been resolved. Props peterwilsoncc, johnbillion. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59490 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/phpunit-tests.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/phpunit-tests.yml b/.github/workflows/phpunit-tests.yml index 949e08d5339ca..62a7aa50f7718 100644 --- a/.github/workflows/phpunit-tests.yml +++ b/.github/workflows/phpunit-tests.yml @@ -53,40 +53,40 @@ jobs: memcached: [ false ] include: - # Include jobs for PHP 7.4 with memcached. + # Include jobs that test with memcached. - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org' multisite: false memcached: true - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org' multisite: true memcached: true # Include jobs with a port on the test domain for both single and multisite. - os: ubuntu-latest - php: '7.4' + php: '8.4' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org:8889' multisite: false memcached: false - os: ubuntu-latest - php: '7.4' + php: '8.4' db-type: 'mysql' - db-version: '5.7' + db-version: '8.4' tests-domain: 'example.org:8889' multisite: true memcached: false # Report test results to the Host Test Results. - os: ubuntu-latest db-type: 'mysql' - db-version: '8.0' + db-version: '8.4' tests-domain: 'example.org' multisite: false memcached: false @@ -131,15 +131,15 @@ jobs: memcached: [ false ] include: - # Include jobs for PHP 7.4 with memcached. + # Include jobs that test with memcached. - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mariadb' db-version: '11.2' multisite: false memcached: true - os: ubuntu-latest - php: '7.4' + php: '8.3' db-type: 'mariadb' db-version: '11.2' multisite: true From 2d8d21f4f040b7c4d6cebcacafca98195746b2a8 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Thu, 5 Dec 2024 18:21:22 +0000 Subject: [PATCH 013/115] Build/Test Tools: Support `trunk` as a version. `trunk` is used interchangeably with `nightly`, so should be an accepted value when determining which version of WordPress is being tested. Follow up to [59452], [59483]. Props johnbillion. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59491 602fd350-edb4-49c9-b593-d223f7449a82 --- .github/workflows/reusable-support-json-reader-v1.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-support-json-reader-v1.yml b/.github/workflows/reusable-support-json-reader-v1.yml index f1843cd5aa79b..ce574e4b2c3b7 100644 --- a/.github/workflows/reusable-support-json-reader-v1.yml +++ b/.github/workflows/reusable-support-json-reader-v1.yml @@ -52,9 +52,9 @@ jobs: - name: Determine the major WordPress version id: major-wp-version run: | - if [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "nightly" ] && [ "${{ inputs.wp-version }}" != "latest" ]; then + if [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "nightly" ] && [ "${{ inputs.wp-version }}" != "latest" ] && [ "${{ inputs.wp-version }}" != "trunk" ]; then echo "version=$(echo "${{ inputs.wp-version }}" | tr '.' '-' | cut -d '-' -f1-2)" >> $GITHUB_OUTPUT - elif [ "${{ inputs.wp-version }}" ]; then + elif [ "${{ inputs.wp-version }}" ] && [ "${{ inputs.wp-version }}" != "trunk" ]; then echo "version=$(echo "${{ inputs.wp-version }}")" >> $GITHUB_OUTPUT else echo "version=nightly" >> $GITHUB_OUTPUT From 29ec3128755be07a468a709f5d42c6dc783798f7 Mon Sep 17 00:00:00 2001 From: Jonathan Desrosiers Date: Thu, 5 Dec 2024 18:32:31 +0000 Subject: [PATCH 014/115] Build/Test Tools: Introduce workflow for testing the local Docker environment. While the PHPUnit workflow currently relies on the local Docker environment and provides some safety checks that the environment works as expected, this may not always be true and does not test all of the available commands related to the environment. This introduces a basic workflow for testing the related scripts for the various supported combinations of PHP and database software with the environment to confirm everything is working as expected. Ideally this would also be run on Windows and MacOS to catch platform specific bugs. Unfortunately, Docker is not supported within the GitHub Action runner images, so not all bugs will be caught by this workflow. Props johnbillion, Clorith. See #62221. git-svn-id: https://develop.svn.wordpress.org/trunk@59492 602fd350-edb4-49c9-b593-d223f7449a82 --- .../workflows/local-docker-environment.yml | 154 +++++++++++++++++ ...sable-test-local-docker-environment-v1.yml | 160 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 .github/workflows/local-docker-environment.yml create mode 100644 .github/workflows/reusable-test-local-docker-environment-v1.yml diff --git a/.github/workflows/local-docker-environment.yml b/.github/workflows/local-docker-environment.yml new file mode 100644 index 0000000000000..1e25c82ef1287 --- /dev/null +++ b/.github/workflows/local-docker-environment.yml @@ -0,0 +1,154 @@ +name: Local Docker Environment + +on: + push: + branches: + - trunk + - '6.[8-9]' + - '[7-9].[0-9]' + paths: + # Any changes to Docker related files. + - '.env.example' + - 'docker-compose.yml' + # Any changes to local environment related files + - 'tools/local-env/**' + # These files manage packages used by the local environment. + - 'package*.json' + # These files configure Composer. Changes could affect the local environment. + - 'composer.*' + # These files define the versions to test. + - '.version-support-*.json' + # Changes to this and related workflow files should always be verified. + - '.github/workflows/local-docker-environment.yml' + - '.github/workflows/reusable-support-json-reader-v1.yml' + - '.github/workflows/reusable-test-docker-environment-v1.yml' + pull_request: + branches: + - trunk + - '6.[8-9]' + - '[7-9].[0-9]' + paths: + # Any changes to Docker related files. + - '.env.example' + - 'docker-compose.yml' + # Any changes to local environment related files + - 'tools/local-env/**' + # These files manage packages used by the local environment. + - 'package*.json' + # These files configure Composer. Changes could affect the local environment. + - 'composer.*' + # These files define the versions to test. + - '.version-support-*.json' + # Changes to this and related workflow files should always be verified. + - '.github/workflows/local-docker-environment.yml' + - '.github/workflows/reusable-support-json-reader-v1.yml' + - '.github/workflows/reusable-test-docker-environment-v1.yml' + workflow_dispatch: + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + # The concurrency group contains the workflow name and the branch name for pull requests + # or the commit hash for any other events. + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + # + # Determines the appropriate supported values for PHP and database versions based on the WordPress + # version being tested. + # + build-test-matrix: + name: Build Test Matrix + uses: WordPress/wordpress-develop/.github/workflows/reusable-support-json-reader-v1.yml@trunk + permissions: + contents: read + secrets: inherit + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + with: + wp-version: ${{ github.event_name == 'pull_request' && github.base_ref || github.ref_name }} + + # Tests the local Docker environment. + environment-tests-mysql: + name: PHP ${{ matrix.php }} + uses: WordPress/wordpress-develop/.github/workflows/reusable-test-local-docker-environment-v1.yml@trunk + permissions: + contents: read + if: ${{ github.repository == 'WordPress/wordpress-develop' || github.event_name == 'pull_request' }} + needs: [ build-test-matrix ] + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + memcached: [ false, true ] + php: ${{ fromJSON( needs.build-test-matrix.outputs.php-versions ) }} + db-version: ${{ fromJSON( needs.build-test-matrix.outputs.mysql-versions ) }} + + exclude: + # The MySQL 5.5 containers will not start. + - db-version: '5.5' + # MySQL 9.0+ will not work on PHP 7.2 & 7.3 + - php: '7.2' + db-version: '9.0' + - php: '7.3' + db-version: '9.0' + + with: + os: ${{ matrix.os }} + php: ${{ matrix.php }} + db-type: 'mysql' + db-version: ${{ matrix.db-version }} + memcached: ${{ matrix.memcached }} + tests-domain: ${{ matrix.tests-domain }} + + slack-notifications: + name: Slack Notifications + uses: WordPress/wordpress-develop/.github/workflows/slack-notifications.yml@trunk + permissions: + actions: read + contents: read + needs: [ build-test-matrix, environment-tests-mysql ] + if: ${{ github.repository == 'WordPress/wordpress-develop' && github.event_name != 'pull_request' && always() }} + with: + calling_status: ${{ contains( needs.*.result, 'cancelled' ) && 'cancelled' || contains( needs.*.result, 'failure' ) && 'failure' || 'success' }} + secrets: + SLACK_GHA_SUCCESS_WEBHOOK: ${{ secrets.SLACK_GHA_SUCCESS_WEBHOOK }} + SLACK_GHA_CANCELLED_WEBHOOK: ${{ secrets.SLACK_GHA_CANCELLED_WEBHOOK }} + SLACK_GHA_FIXED_WEBHOOK: ${{ secrets.SLACK_GHA_FIXED_WEBHOOK }} + SLACK_GHA_FAILURE_WEBHOOK: ${{ secrets.SLACK_GHA_FAILURE_WEBHOOK }} + + failed-workflow: + name: Failed workflow tasks + runs-on: ubuntu-latest + permissions: + actions: write + needs: [ build-test-matrix, environment-tests-mysql, slack-notifications ] + if: | + always() && + github.repository == 'WordPress/wordpress-develop' && + github.event_name != 'pull_request' && + github.run_attempt < 2 && + ( + contains( needs.*.result, 'cancelled' ) || + contains( needs.*.result, 'failure' ) + ) + + steps: + - name: Dispatch workflow run + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + retries: 2 + retry-exempt-status-codes: 418 + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'failed-workflow.yml', + ref: 'trunk', + inputs: { + run_id: '${{ github.run_id }}' + } + }); diff --git a/.github/workflows/reusable-test-local-docker-environment-v1.yml b/.github/workflows/reusable-test-local-docker-environment-v1.yml new file mode 100644 index 0000000000000..4dccd7ef3dccd --- /dev/null +++ b/.github/workflows/reusable-test-local-docker-environment-v1.yml @@ -0,0 +1,160 @@ +## +# A reusable workflow that ensures the local Docker environment is working properly. +# +# This workflow is used by `trunk` and branches >= 6.8. +## +name: Test local Docker environment + +on: + workflow_call: + inputs: + os: + description: 'Operating system to test' + required: false + type: 'string' + default: 'ubuntu-latest' + php: + description: 'The version of PHP to use, in the format of X.Y' + required: false + type: 'string' + default: 'latest' + db-type: + description: 'Database type. Valid types are mysql and mariadb' + required: false + type: 'string' + default: 'mysql' + db-version: + description: 'Database version' + required: false + type: 'string' + default: '8.0' + memcached: + description: 'Whether to enable memcached' + required: false + type: 'boolean' + default: false + tests-domain: + description: 'The domain to use for the tests' + required: false + type: 'string' + default: 'example.org' + +env: + LOCAL_PHP: ${{ inputs.php == 'latest' && 'latest' || format( '{0}-fpm', inputs.php ) }} + LOCAL_DB_TYPE: ${{ inputs.db-type }} + LOCAL_DB_VERSION: ${{ inputs.db-version }} + LOCAL_PHP_MEMCACHED: ${{ inputs.memcached }} + LOCAL_WP_TESTS_DOMAIN: ${{ inputs.tests-domain }} + PUPPETEER_SKIP_DOWNLOAD: ${{ true }} + +jobs: + # Tests the local Docker environment. + # + # Performs the following steps: + # - Sets environment variables. + # - Checks out the repository. + # - Sets up Node.js. + # - Sets up PHP. + # - Installs Composer dependencies. + # - Installs npm dependencies + # - Logs general debug information about the runner. + # - Logs Docker debug information (about the Docker installation within the runner). + # - Starts the WordPress Docker container. + # - Logs the running Docker containers. + # - Logs debug information about what's installed within the WordPress Docker containers. + # - Install WordPress within the Docker container. + # - Restarts the Docker environment. + # - Runs a WP CLI command. + # - Tests the logs command. + # - Tests the reset command. + # - Ensures version-controlled files are not modified or deleted. + local-docker-environment-tests: + name: PHP ${{ inputs.php }} / ${{ 'mariadb' == inputs.db-type && 'MariaDB' || 'MySQL' }} ${{ inputs.db-version }}${{ inputs.memcached && ' with memcached' || '' }}${{ 'example.org' != inputs.tests-domain && format( ' {0}', inputs.tests-domain ) || '' }} + runs-on: ${{ inputs.os }} + timeout-minutes: 20 + + steps: + - name: Configure environment variables + run: | + echo "PHP_FPM_UID=$(id -u)" >> $GITHUB_ENV + echo "PHP_FPM_GID=$(id -g)" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + + - name: Set up Node.js + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version-file: '.nvmrc' + cache: npm + + ## + # This allows Composer dependencies to be installed using a single step. + # + # Since tests are currently run within the Docker containers where the PHP version varies, + # the same PHP version needs to be configured for the action runner machine so that the correct + # dependency versions are installed and cached. + ## + - name: Set up PHP + uses: shivammathur/setup-php@c541c155eee45413f5b09a52248675b1a2575231 # v2.31.1 + with: + php-version: '${{ inputs.php }}' + coverage: none + + # Since Composer dependencies are installed using `composer update` and no lock file is in version control, + # passing a custom cache suffix ensures that the cache is flushed at least once per week. + - name: Install Composer dependencies + uses: ramsey/composer-install@57532f8be5bda426838819c5ee9afb8af389d51a # v3.0.0 + with: + custom-cache-suffix: $(/bin/date -u --date='last Mon' "+%F") + + - name: Install npm dependencies + run: npm ci + + - name: General debug information + run: | + npm --version + node --version + curl --version + git --version + composer --version + locale -a + + - name: Docker debug information + run: | + docker -v + + - name: Start Docker environment + run: | + npm run env:start + + - name: Log running Docker containers + run: docker ps -a + + - name: WordPress Docker container debug information + run: | + docker compose run --rm mysql ${{ env.LOCAL_DB_TYPE }} --version + docker compose run --rm php php --version + docker compose run --rm php php -m + docker compose run --rm php php -i + docker compose run --rm php locale -a + + - name: Install WordPress + run: npm run env:install + + - name: Restart Docker environment + run: npm run env:restart + + - name: Test a CLI command + run: npm run env:cli wp option get siteurl + + - name: Test logs command + run: npm run env:logs + + - name: Reset the Docker environment + run: npm run env:reset + + - name: Ensure version-controlled files are not modified or deleted + run: git diff --exit-code From 9c7d0081584d777c6cc480601b9a777cc872caa8 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 5 Dec 2024 21:19:37 +0000 Subject: [PATCH 015/115] Embeds: ensure correct thumbnail height. Use height 0 instead of 9999 to avoid unnecessarily using the full size version. Props colinleroy, swissspidy. Fixes #62094. git-svn-id: https://develop.svn.wordpress.org/trunk@59493 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/embed.php | 2 +- .../phpunit/tests/oembed/getResponseData.php | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/embed.php b/src/wp-includes/embed.php index c7dfd38761cb4..f7fe10e48239d 100644 --- a/src/wp-includes/embed.php +++ b/src/wp-includes/embed.php @@ -721,7 +721,7 @@ function get_oembed_response_data_rich( $data, $post, $width, $height ) { } if ( $thumbnail_id ) { - list( $thumbnail_url, $thumbnail_width, $thumbnail_height ) = wp_get_attachment_image_src( $thumbnail_id, array( $width, 99999 ) ); + list( $thumbnail_url, $thumbnail_width, $thumbnail_height ) = wp_get_attachment_image_src( $thumbnail_id, array( $width, 0 ) ); $data['thumbnail_url'] = $thumbnail_url; $data['thumbnail_width'] = $thumbnail_width; $data['thumbnail_height'] = $thumbnail_height; diff --git a/tests/phpunit/tests/oembed/getResponseData.php b/tests/phpunit/tests/oembed/getResponseData.php index 7cd5b1cc66ddf..09a0f3142b319 100644 --- a/tests/phpunit/tests/oembed/getResponseData.php +++ b/tests/phpunit/tests/oembed/getResponseData.php @@ -251,6 +251,26 @@ public function test_get_oembed_response_data_with_thumbnail() { $this->assertLessThanOrEqual( 400, $data['thumbnail_width'] ); } + /** + * @ticket 62094 + */ + public function test_get_oembed_response_data_has_correct_thumbnail_size() { + $post = self::factory()->post->create_and_get(); + + /* Use a large image as post thumbnail */ + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/33772.jpg' ); + set_post_thumbnail( $post, $attachment_id ); + + /* Get the image, sized for 400x??? pixels display */ + $image = wp_get_attachment_image_src( $attachment_id, array( 400, 0 ) ); + + /* Get the oembed data array for a 400 pixels wide embed */ + $data = get_oembed_response_data( $post, 400 ); + + /* Make sure the embed references the small image, not the full-size one. */ + $this->assertSame( $image[0], $data['thumbnail_url'] ); + } + public function test_get_oembed_response_data_for_attachment() { $parent = self::factory()->post->create(); $file = DIR_TESTDATA . '/images/canola.jpg'; From 59a6b5e65eb145fa3d4095315d51e890d3208c4e Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 5 Dec 2024 21:35:40 +0000 Subject: [PATCH 016/115] I18N: Add new `WP_Locale::get_month_genitive()` method. Complements existing helper methods such as `WP_Locale::get_month_abbrev()`. Props ankitkumarshah, Tkama, SergeyBiryukov. Fixes #58658. git-svn-id: https://develop.svn.wordpress.org/trunk@59494 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/class-wp-locale.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/wp-includes/class-wp-locale.php b/src/wp-includes/class-wp-locale.php index 87af36a2a42e5..a78617b5e3332 100644 --- a/src/wp-includes/class-wp-locale.php +++ b/src/wp-includes/class-wp-locale.php @@ -336,6 +336,26 @@ public function get_month_abbrev( $month_name ) { return $this->month_abbrev[ $month_name ]; } + /** + * Retrieves translated version of month genitive string. + * + * The $month_number parameter has to be a string + * because it must have the '0' in front of any number + * that is less than 10. Starts from '01' and ends at + * '12'. + * + * You can use an integer instead and it will add the + * '0' before the numbers less than 10 for you. + * + * @since 6.8.0 + * + * @param string|int $month_number '01' through '12'. + * @return string Translated genitive month name. + */ + public function get_month_genitive( $month_number ) { + return $this->month_genitive[ zeroise( $month_number, 2 ) ]; + } + /** * Retrieves translated version of meridiem string. * From 0e8c4fb381ef4d41ceb948fc408f4b52ab5f545d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 19 Nov 2024 16:30:04 +0100 Subject: [PATCH 017/115] WIP class skeleton --- .../html-api/class-wp-css-selector.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/wp-includes/html-api/class-wp-css-selector.php diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php new file mode 100644 index 0000000000000..7ec6b5a69ced2 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -0,0 +1,31 @@ + Date: Wed, 20 Nov 2024 16:57:19 +0100 Subject: [PATCH 018/115] Document class --- .../html-api/class-wp-css-selector.php | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 7ec6b5a69ced2..1684aefef2024 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -12,20 +12,47 @@ * * This class is designed for internal use by the HTML processor. * + * This class is instantiated via the `WP_CSS_Selector::from_selector( string $selector )` method. + * It accepts a CSS selector string and returns an instance of itself or `null` if the selector + * is invalid or unsupported. + * + * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax + * specification, which is available at https://www.w3.org/TR/css-syntax-3/. + * + * Supported selector syntax: + * - Type selectors (tag names, e.g. `div`) + * - Class selectors (e.g. `.class-name`) + * - ID selectors (e.g. `#unique-id`) + * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) + * - The following combinators: + * - descendant (e.g. `.parent .descendant`) + * - child (`.parent > .child`) + * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) + * + * Unsupported selector syntax: + * - The following combinators: + * - Next sibling (`.sibling + .sibling`) + * - Subsequent sibling (`.sibling ~ .sibling`) + * - Pseudo-element selectors (e.g. `::before`) + * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * * @since TBD * * @access private * + * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token * @see https://www.w3.org/tr/selectors/#parse-selector + * */ class WP_CSS_Selector { private function __construct() {} /** - * @return static + * @return static|null */ public static function from_selector( string $selector ) { $res = new static(); return $res; } + } From 40222d30200afdf998586cb127c35c880bfe7df8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 21 Nov 2024 11:57:28 +0100 Subject: [PATCH 019/115] Do not support namespaced selectors --- src/wp-includes/html-api/class-wp-css-selector.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 1684aefef2024..fb8934bec06f4 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -35,6 +35,7 @@ * - Subsequent sibling (`.sibling ~ .sibling`) * - Pseudo-element selectors (e.g. `::before`) * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * - Namespace prefixes that need to be resolved (e.g. `svg|title` or `[xlink|href]`) * * @since TBD * From 60926421295e58229637891c853ab50f0920ae23 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 16:04:42 +0100 Subject: [PATCH 020/115] Flesh out stuff --- .../html-api/class-wp-css-selector.php | 59 ----- .../html-api/class-wp-css-selectors.php | 248 ++++++++++++++++++ 2 files changed, 248 insertions(+), 59 deletions(-) delete mode 100644 src/wp-includes/html-api/class-wp-css-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-selectors.php diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php deleted file mode 100644 index fb8934bec06f4..0000000000000 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ /dev/null @@ -1,59 +0,0 @@ - .child`) - * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) - * - * Unsupported selector syntax: - * - The following combinators: - * - Next sibling (`.sibling + .sibling`) - * - Subsequent sibling (`.sibling ~ .sibling`) - * - Pseudo-element selectors (e.g. `::before`) - * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) - * - Namespace prefixes that need to be resolved (e.g. `svg|title` or `[xlink|href]`) - * - * @since TBD - * - * @access private - * - * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token - * @see https://www.w3.org/tr/selectors/#parse-selector - * - */ -class WP_CSS_Selector { - private function __construct() {} - - /** - * @return static|null - */ - public static function from_selector( string $selector ) { - $res = new static(); - return $res; - } - -} diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php new file mode 100644 index 0000000000000..acc5db02752c3 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -0,0 +1,248 @@ + .child`) + * + * Unsupported selector syntax: + * - Pseudo-element selectors (e.g. `::before`) + * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) + * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) + * - The following combinators: + * - Next sibling (`.sibling + .sibling`) + * - Subsequent sibling (`.sibling ~ .sibling`) + * + * @since TBD + * + * @access private + * + * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token + * @see https://www.w3.org/tr/selectors/#parse-selector + * @see https://www.w3.org/TR/selectors-api2/ + * @see https://www.w3.org/TR/selectors-4/ + * + */ +class WP_CSS_Selectors { + + /** + * Takes a CSS selectors string and returns an instance of itself or `null` if the selector + * is invalid or unsupported. + * + * @since TBD + * + * @param string $selectors CSS selectors string. + * @return static|null + */ + public static function from_selectors( string $selectors ) { + $res = new static(); + return $res; + } + + /** + * Returns a list of selectors. + * + * @since TBD + * + * @return WP_CSS_Selector[] + */ + private static function parse( string $input ) { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. + $input = trim( $input, " \t\r\n\r" ); + + if ( '' === $input ) { + null; + } + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + $at = 0; + $length = strlen( $input ); + $selectors = array(); + + $at = strspn( $input, "\n\t ", $at ); + while ( $at < $length ) { + } + } +} + +interface IWP_CSS_Selector_Parser { + public static function parse( string $input, string $offset, ?int $consumed_bytes = null ): ?self; +} + +abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { + public static function parse_whitespace( string $input, string &$offset ): bool { + $length = strspn( $input, " \t\r\n\f", $offset ); + $advanced = $length > 0; + $offset += $length; + return $advanced; + } + + /* + * Utiltities + * ========== + * + * The following functions do not consume any input. + */ + + /** + * > 4.3.8. Check if two code points are a valid escape + * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. + * > + * > Note: This algorithm will not consume any additional code point. + * > + * > If the first code point is not U+005C REVERSE SOLIDUS (\), return false. + * > + * > Otherwise, if the second code point is a newline, return false. + * > + * > Otherwise, return true. + * + * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape + * + * @todo this does not check whether the second codepoint is valid. + */ + public static function next_two_are_valid_escape( string $input, string $offset ): bool { + if ( $offset + 1 >= strlen( $input ) ) { + return false; + } + return '\\' === $input[ $offset ] && "\n" !== $input[ $offset + 1 ]; + } + + /** + * > ident-start code point + * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). + * > uppercase letter + * > A code point between U+0041 LATIN CAPITAL LETTER A (A) and U+005A LATIN CAPITAL LETTER Z (Z) inclusive. + * > lowercase letter + * > A code point between U+0061 LATIN SMALL LETTER A (a) and U+007A LATIN SMALL LETTER Z (z) inclusive. + * > letter + * > An uppercase letter or a lowercase letter. + * > non-ASCII code point + * > A code point with a value equal to or greater than U+0080 . + */ + public static function is_ident_start_codepoint( string $input, string $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + return ( + '_' === $input[ $offset ] || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || + $input[ $offset ] <= '\x7F' + ); + } + + /** + * > ident code point + * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). + * > digit + * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + */ + public static function is_ident_codepoint( string $input, string $offset ): bool { + return '-' === $input[ $offset ] || + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + self::is_ident_start_codepoint( $input, $offset ); + } + + /** + * > 4.3.9. Check if three code points would start an ident sequence + * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. + * > + * > Note: This algorithm will not consume any additional code points. + * > + * > Look at the first code point: + * > + * > U+002D HYPHEN-MINUS + * > If the second code point is an ident-start code point or a U+002D HYPHEN-MINUS, or the second and third code points are a valid escape, return true. Otherwise, return false. + * > ident-start code point + * > Return true. + * > U+005C REVERSE SOLIDUS (\) + * > If the first and second code points are a valid escape, return true. Otherwise, return false. + * > anything else + * > Return false. + * + * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + */ + public static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > U+005C REVERSE SOLIDUS (\) + if ( '\\' === $input[ $offset ] ) { + return self::next_two_are_valid_escape( $input, $offset ); + } + + // > U+002D HYPHEN-MINUS + if ( '-' === $input[ $offset ] ) { + $after_initial_hyphen_minus_offset = $offset + 1; + if ( $offset >= strlen( $input ) ) { + return false; + } + + // > If the second code point is… U+002D HYPHEN-MINUS… return true + if ( '-' === $input[ $after_initial_hyphen_minus_offset ] ) { + return true; + } + + // > If the second and third code points are a valid escape, return true. + if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > If the second code point is an ident-start code point… return true. + if ( self::is_ident_start_codepoint( $input, $after_initial_hyphen_minus_offset ) ) { + return true; + } + + // > Otherwise, return false. + return false; + } + + // > ident-start code point + // > Return true. + // > anything else + // > Return false. + return self::is_ident_start_codepoint( $input, $offset ); + } +} From 3e3b2b200696d9e5f51c29f86f8ec48a20df1bf4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:06:20 +0100 Subject: [PATCH 021/115] Starting to actually parse --- .../html-api/class-wp-css-selectors.php | 213 ++++++++++++++++-- 1 file changed, 199 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index acc5db02752c3..53417a0f1967c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -52,6 +52,11 @@ * */ class WP_CSS_Selectors { + private $selectors; + + private function __construct( array $selectors ) { + $this->selectors = $selectors; + } /** * Takes a CSS selectors string and returns an instance of itself or `null` if the selector @@ -60,11 +65,10 @@ class WP_CSS_Selectors { * @since TBD * * @param string $selectors CSS selectors string. - * @return static|null + * @return self|null */ - public static function from_selectors( string $selectors ) { - $res = new static(); - return $res; + public static function from_selectors( string $selectors ): ?self { + return self::parse( $selectors ); } /** @@ -72,7 +76,7 @@ public static function from_selectors( string $selectors ) { * * @since TBD * - * @return WP_CSS_Selector[] + * @return WP_CSS_Selectors|null */ private static function parse( string $input ) { // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. @@ -95,28 +99,209 @@ private static function parse( string $input ) { $input = str_replace( array( "\r", "\f" ), "\n", $input ); $input = str_replace( "\0", "\u{FFFD}", $input ); - $at = 0; $length = strlen( $input ); $selectors = array(); - $at = strspn( $input, "\n\t ", $at ); - while ( $at < $length ) { + $offset = 0; + + while ( $offset < $length ) { + $sel = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( $sel ) { + $selectors[] = $sel; + } + } + if ( count( $selectors ) ) { + return new WP_CSS_Selectors( $selectors ); + } + return null; + } +} + +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, string &$offset ): ?self { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; } + return new self( $ident ); } } interface IWP_CSS_Selector_Parser { - public static function parse( string $input, string $offset, ?int $consumed_bytes = null ): ?self; + /** + * @return static|null + */ + public static function parse( string $input, string &$offset ); } abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { - public static function parse_whitespace( string $input, string &$offset ): bool { + const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; + + protected static function parse_whitespace( string $input, string &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; return $advanced; } + /** + * Tokenization of hash tokens + * + * > U+0023 NUMBER SIGN (#) + * > If the next input code point is an ident code point or the next two input code points are a valid escape, then: + * > 1. Create a . + * > 2. If the next 3 input code points would start an ident sequence, set the + * > ’s type flag to "id". + * > 3. Consume an ident sequence, and set the ’s value to the + * > returned string. + * > 4. Return the . + * > Otherwise, return a with its value set to the current input code point. + * + * This implementation is not interested in the , a '#' delim token is not relevant for selectors. + */ + protected static function parse_hash_token( string $input, string &$offset ): ?string { + if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { + return null; + } + + $offset_after_hash = $offset + 1; + if ( self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset_after_hash ) ) { + $offset = $offset_after_hash; + return self::parse_ident( $input, $offset ); + } + return null; + } + + /** + * Parse an ident token + * + * CAUTION: This method is _not_ for parsing and ID selector! + * + * > 4.3.11. Consume an ident sequence + * > This section describes how to consume an ident sequence from a stream of code points. It returns a string containing the largest name that can be formed from adjacent code points in the stream, starting from the first. + * > + * > Note: This algorithm does not do the verification of the first few code points that are necessary to ensure the returned code points would constitute an . If that is the intended use, ensure that the stream starts with an ident sequence before calling this algorithm. + * > + * > Let result initially be an empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ident code point + * > Append the code point to result. + * > the stream starts with a valid escape + * > Consume an escaped code point. Append the returned code point to result. + * > anything else + * > Reconsume the current input code point. Return result. + * + * https://www.w3.org/TR/css-syntax-3/#consume-name + */ + protected static function parse_ident( string $input, string &$offset ): ?string { + if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { + return null; + } + + $ident = ''; + + while ( $offset < strlen( $input ) ) { + if ( self::next_two_are_valid_escape( $input, $offset ) ) { + $ident .= self::consume_escaped_codepoint( $input, $offset ); + continue; + } elseif ( self::is_ident_codepoint( $input, $offset ) ) { + // @todo this should append and advance the correct number of bytes. + $ident .= $input[ $offset ]; + $offset += 1; + continue; + } + break; + } + + return $ident; + } + + /** + * Consume an escaped code point. + * + * > 4.3.7. Consume an escaped code point + * > This section describes how to consume an escaped code point. It assumes that the U+005C + * > REVERSE SOLIDUS (\) has already been consumed and that the next input code point has + * > already been verified to be part of a valid escape. It will return a code point. + * > + * > Consume the next input code point. + * > + * > hex digit + * > Consume as many hex digits as possible, but no more than 5. Note that this means 1-6 + * > hex digits have been consumed in total. If the next input code point is whitespace, + * > consume it as well. Interpret the hex digits as a hexadecimal number. If this number is + * > zero, or is for a surrogate, or is greater than the maximum allowed code point, return + * > U+FFFD REPLACEMENT CHARACTER (�). Otherwise, return the code point with that value. + * > EOF + * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). + * > anything else + * > Return the current input code point. + */ + protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + $char = $input[ $offset ]; + if ( + ( '0' <= $char && $char <= '9' ) || + ( 'a' <= $char && $char <= 'f' ) || + ( 'A' <= $char && $char <= 'F' ) + ) { + $hex_end_offset = $offset + 1; + while ( + strlen( $input ) > $hex_end_offset && + $hex_end_offset - $offset < 6 && + ( + ( '0' <= $char && $char <= '9' ) || + ( 'a' <= $char && $char <= 'f' ) || + ( 'A' <= $char && $char <= 'F' ) + ) + ) { + $hex_end_offset += 1; + } + + $codepoint_value = hexdec( substr( $input, $offset, $hex_end_offset - $offset ) ); + + // > A surrogate is a leading surrogate or a trailing surrogate. + // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + // > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + // The surrogate ranges are adjacent, so the complete range is 0xD800..=0xDFFF, + // inclusive. + $codepoint_char = ( + 0 === $codepoint_value || + $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || + ( 0xD800 <= $codepoint_value || $codepoint_value <= 0xDFFF ) + ) ? + "\u{FFFD}" : + mb_chr( $codepoint_value, 'UTF-8' ); + + $offset = $hex_end_offset; + + // If the next input code point is whitespace, consume it as well. + if ( + strlen( $input ) > $offset && + ( + "\n" === $input[ $offset ] || + "\t" === $input[ $offset ] || + ' ' === $input[ $offset ] + ) + ) { + ++$offset; + } + return $codepoint_char; + } + + $codepoint_char = mb_substr( $input, $offset, 1, 'UTF-8' ); + $offset += strlen( $codepoint_char ); + return $codepoint_char; + } + /* * Utiltities * ========== @@ -140,7 +325,7 @@ public static function parse_whitespace( string $input, string &$offset ): bool * * @todo this does not check whether the second codepoint is valid. */ - public static function next_two_are_valid_escape( string $input, string $offset ): bool { + protected static function next_two_are_valid_escape( string $input, string $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -159,7 +344,7 @@ public static function next_two_are_valid_escape( string $input, string $offset * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . */ - public static function is_ident_start_codepoint( string $input, string $offset ): bool { + protected static function is_ident_start_codepoint( string $input, string $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -178,7 +363,7 @@ public static function is_ident_start_codepoint( string $input, string $offset ) * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. */ - public static function is_ident_codepoint( string $input, string $offset ): bool { + protected static function is_ident_codepoint( string $input, string $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); @@ -203,7 +388,7 @@ public static function is_ident_codepoint( string $input, string $offset ): bool * * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier */ - public static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } From 967557fb01f0e016d63fa2b391d351aec90090bc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:41:16 +0100 Subject: [PATCH 022/115] Add ident tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/phpunit/tests/html-api/wpCssSelectors.php diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php new file mode 100644 index 0000000000000..2857603360e79 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -0,0 +1,50 @@ +assertSame( $ident, $result ); + $this->assertSame( substr( $input, $offset ), $rest ); + } +} From 2ec1db32af13f1248935ee5e8bb2d634430afc31 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:41:42 +0100 Subject: [PATCH 023/115] Fix ident non-ascii bug --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 53417a0f1967c..547a51293bb11 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -353,7 +353,7 @@ protected static function is_ident_start_codepoint( string $input, string $offse '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || - $input[ $offset ] <= '\x7F' + $input[ $offset ] > '\x7F' ); } From ee2c7cefa987ef4cb208447aad489a700ab7f91f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 17:42:12 +0100 Subject: [PATCH 024/115] Use class after defined --- .../html-api/class-wp-css-selectors.php | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 547a51293bb11..55396c8851294 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -117,23 +117,6 @@ private static function parse( string $input ) { } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { - /** @var string */ - public $ident; - - private function __construct( string $ident ) { - $this->ident = $ident; - } - - public static function parse( string $input, string &$offset ): ?self { - $ident = self::parse_hash_token( $input, $offset ); - if ( null === $ident ) { - return null; - } - return new self( $ident ); - } -} - interface IWP_CSS_Selector_Parser { /** * @return static|null @@ -431,3 +414,20 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen return self::is_ident_start_codepoint( $input, $offset ); } } + +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, string &$offset ): ?self { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new self( $ident ); + } +} From 0f708ba4892a50249d0c2267640acf2a256beb21 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 18:01:07 +0100 Subject: [PATCH 025/115] Fix some char stuff --- .../html-api/class-wp-css-selectors.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 55396c8851294..408d25395febb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -194,6 +194,8 @@ protected static function parse_ident( string $input, string &$offset ): ?string while ( $offset < strlen( $input ) ) { if ( self::next_two_are_valid_escape( $input, $offset ) ) { + // Move past the `\` character. + ++$offset; $ident .= self::consume_escaped_codepoint( $input, $offset ); continue; } elseif ( self::is_ident_codepoint( $input, $offset ) ) { @@ -230,20 +232,19 @@ protected static function parse_ident( string $input, string &$offset ): ?string * > Return the current input code point. */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { - $char = $input[ $offset ]; if ( - ( '0' <= $char && $char <= '9' ) || - ( 'a' <= $char && $char <= 'f' ) || - ( 'A' <= $char && $char <= 'F' ) + ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || + ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'f' ) || + ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'F' ) ) { $hex_end_offset = $offset + 1; while ( strlen( $input ) > $hex_end_offset && $hex_end_offset - $offset < 6 && ( - ( '0' <= $char && $char <= '9' ) || - ( 'a' <= $char && $char <= 'f' ) || - ( 'A' <= $char && $char <= 'F' ) + ( '0' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= '9' ) || + ( 'a' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'f' ) || + ( 'A' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'F' ) ) ) { $hex_end_offset += 1; @@ -259,7 +260,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string $codepoint_char = ( 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || - ( 0xD800 <= $codepoint_value || $codepoint_value <= 0xDFFF ) + ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) ) ? "\u{FFFD}" : mb_chr( $codepoint_value, 'UTF-8' ); From 3cb455d41f7923d4b4be9fec3b7cf3f72686dfdc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 18:01:17 +0100 Subject: [PATCH 026/115] Improve tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 2857603360e79..a55463ec7122e 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -15,19 +15,20 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { public static function data_valid_idents() { return array( - array( '_-foo123#xyz', '_-foo123', '#xyz' ), - array( '😍foo123.xyz', '😍foo123', '.xyz' ), - array( '\\xyz', 'xyz', '' ), - array( '\\ x', ' x', '' ), - array( '\\😍', '😍', '' ), - array( '\\abcd', 'ꯍ', '' ), + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), - array( "\\31\t23", '123', '' ), - array( "\\31\n23", '123', '' ), - array( "\\31 23", '123', '' ), - array( '\\9', "\t", '' ), - array( '\\61 bc', 'abc', '' ), - array( '\\000061bc', 'abc', '' ), + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), ); } @@ -44,7 +45,7 @@ public static function test( string $input, &$offset ) { $offset = 0; $ident = $c::test( $input, $offset ); - $this->assertSame( $ident, $result ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $ident, $result, 'Ident did not match.' ); + $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); } } From 5609e509ef589afbe23654fe629ce85fc06ad7ec Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 19:53:10 +0100 Subject: [PATCH 027/115] Housekeeping --- src/wp-includes/html-api/class-wp-css-selectors.php | 4 +--- tests/phpunit/tests/html-api/wpCssSelectors.php | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 408d25395febb..f9c85f9b48a3c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1,14 +1,12 @@ array( '_-foo123#xyz', '_-foo123', '#xyz' ), @@ -33,6 +36,8 @@ public static function data_valid_idents() { } /** + * @ticket TBD + * * @dataProvider data_valid_idents */ public function test_valid_idents( string $input, string $result, string $rest ) { From 4f25bc21f907369c899ea2c8c07e7461bdb731e3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 19:56:30 +0100 Subject: [PATCH 028/115] Require new file in WP --- src/wp-settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-settings.php b/src/wp-settings.php index 635f6de248dd5..6c799d5c95140 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -265,6 +265,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-selectors.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.php'; From 943293f2f840988546c84d17d59dfe4d37e05448 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:14:21 +0100 Subject: [PATCH 029/115] Fix offset type --- .../html-api/class-wp-css-selectors.php | 18 +++++++++--------- .../phpunit/tests/html-api/wpCssSelectors.php | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index f9c85f9b48a3c..897cf4b59d752 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -119,13 +119,13 @@ interface IWP_CSS_Selector_Parser { /** * @return static|null */ - public static function parse( string $input, string &$offset ); + public static function parse( string $input, int &$offset ); } abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; - protected static function parse_whitespace( string $input, string &$offset ): bool { + protected static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; @@ -147,7 +147,7 @@ protected static function parse_whitespace( string $input, string &$offset ): bo * * This implementation is not interested in the , a '#' delim token is not relevant for selectors. */ - protected static function parse_hash_token( string $input, string &$offset ): ?string { + protected static function parse_hash_token( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { return null; } @@ -183,7 +183,7 @@ protected static function parse_hash_token( string $input, string &$offset ): ?s * * https://www.w3.org/TR/css-syntax-3/#consume-name */ - protected static function parse_ident( string $input, string &$offset ): ?string { + protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { return null; } @@ -307,7 +307,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string * * @todo this does not check whether the second codepoint is valid. */ - protected static function next_two_are_valid_escape( string $input, string $offset ): bool { + protected static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -326,7 +326,7 @@ protected static function next_two_are_valid_escape( string $input, string $offs * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . */ - protected static function is_ident_start_codepoint( string $input, string $offset ): bool { + protected static function is_ident_start_codepoint( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -345,7 +345,7 @@ protected static function is_ident_start_codepoint( string $input, string $offse * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. */ - protected static function is_ident_codepoint( string $input, string $offset ): bool { + protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); @@ -370,7 +370,7 @@ protected static function is_ident_codepoint( string $input, string $offset ): b * * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier */ - protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, string $offset ): bool { + protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -422,7 +422,7 @@ private function __construct( string $ident ) { $this->ident = $ident; } - public static function parse( string $input, string &$offset ): ?self { + public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); if ( null === $ident ) { return null; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 39d68efcd8f4a..e0dd09c929d09 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -42,7 +42,7 @@ public static function data_valid_idents() { */ public function test_valid_idents( string $input, string $result, string $rest ) { $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, string &$offset ) {} + public static function parse( string $input, int &$offset ) {} public static function test( string $input, &$offset ) { return self::parse_ident( $input, $offset ); } From 24c9744657023179a33f786a6a7b4d0242534783 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:14:48 +0100 Subject: [PATCH 030/115] Add more tests and invalid tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index e0dd09c929d09..d12fcc42c8e60 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -18,20 +18,41 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { */ public static function data_valid_idents() { return array( - 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), - 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), - 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), - 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), - 'escaped space' => array( '\\ x', ' x', '' ), - 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), + 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), + 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), + 'trailing " "' => array( '😍foo123 more', '😍foo123', ' more' ), + 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), + 'escaped space' => array( '\\ x', ' x', '' ), + 'escaped emoji' => array( '\\😍', '😍', '' ), + 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), - 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), - 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), - 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), - 'hex tab' => array( '\\9', "\t", '' ), - 'hex a' => array( '\\61 bc', 'abc', '' ), - 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), + 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), + 'hex space-suffixed 1' => array( "\\31 23", '123', '' ), + 'hex tab' => array( '\\9', "\t", '' ), + 'hex a' => array( '\\61 bc', 'abc', '' ), + 'hex a max escape length' => array( '\\000061bc', 'abc', '' ), + + 'out of range replacement min' => array( '\\110000 ', "\u{fffd}", '' ), + 'out of range replacement max' => array( '\\ffffff ', "\u{fffd}", '' ), + 'leading surrogate min replacement' => array( '\\d800 ', "\u{fffd}", '' ), + 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), + 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), + 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + ); + } + + /** + * Data provider. + */ + public static function data_invalid_idents() { + return array( + 'bad start >' => array( '>' ), + 'bad start [' => array( '[' ), + 'bad start #' => array( '#' ), + 'bad start " "' => array( ' ' ), + 'bad start -' => array( '-' ), + 'bad start 1' => array( '-' ), ); } @@ -53,4 +74,23 @@ public static function test( string $input, &$offset ) { $this->assertSame( $ident, $result, 'Ident did not match.' ); $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); } + + /** + * @ticket TBD + * + * @dataProvider data_invalid_idents + */ + public function test_invalid_idents( string $input ) { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + public static function test( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + }; + + $offset = 0; + $result = $c::test( $input, $offset ); + $this->assertNull( $result, 'Ident did not match.' ); + $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); + } } From a7c10b9e12aeed9263a69b46eeb011e59092ed07 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:15:03 +0100 Subject: [PATCH 031/115] Fix wrong offset var usage --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 897cf4b59d752..8afb3928e07de 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -383,7 +383,7 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen // > U+002D HYPHEN-MINUS if ( '-' === $input[ $offset ] ) { $after_initial_hyphen_minus_offset = $offset + 1; - if ( $offset >= strlen( $input ) ) { + if ( $after_initial_hyphen_minus_offset >= strlen( $input ) ) { return false; } From dd718b7093dfa3510d6b7476b39510013f759797 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:17:15 +0100 Subject: [PATCH 032/115] comment tweak --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8afb3928e07de..64020bcc0c607 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -392,7 +392,7 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen return true; } - // > If the second and third code points are a valid escape, return true. + // > If the second and third code points are a valid escape… return true. if ( self::next_two_are_valid_escape( $input, $after_initial_hyphen_minus_offset ) ) { return true; } From 5884aca6e807002d6474c37e291b3dde5c59778d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:53:50 +0100 Subject: [PATCH 033/115] Implement codepoint escape with strspn --- .../html-api/class-wp-css-selectors.php | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 64020bcc0c607..56c31911d95b8 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -230,25 +230,9 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * > Return the current input code point. */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { - if ( - ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || - ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'f' ) || - ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'F' ) - ) { - $hex_end_offset = $offset + 1; - while ( - strlen( $input ) > $hex_end_offset && - $hex_end_offset - $offset < 6 && - ( - ( '0' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= '9' ) || - ( 'a' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'f' ) || - ( 'A' <= $input[ $hex_end_offset ] && $input[ $hex_end_offset ] <= 'F' ) - ) - ) { - $hex_end_offset += 1; - } - - $codepoint_value = hexdec( substr( $input, $offset, $hex_end_offset - $offset ) ); + $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); + if ( $hex_length > 0 ) { + $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); // > A surrogate is a leading surrogate or a trailing surrogate. // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. @@ -263,7 +247,7 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string "\u{FFFD}" : mb_chr( $codepoint_value, 'UTF-8' ); - $offset = $hex_end_offset; + $offset += $hex_length; // If the next input code point is whitespace, consume it as well. if ( From a9a077f463c9c981adc811b7be6b27d89c05d9dc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 22 Nov 2024 20:54:11 +0100 Subject: [PATCH 034/115] Test with UPPER HEX --- tests/phpunit/tests/html-api/wpCssSelectors.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index d12fcc42c8e60..270def39b53d3 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -24,7 +24,8 @@ public static function data_valid_idents() { 'escaped ASCII character' => array( '\\xyz', 'xyz', '' ), 'escaped space' => array( '\\ x', ' x', '' ), 'escaped emoji' => array( '\\😍', '😍', '' ), - 'hex unicode codepoint' => array( '\\abcd', 'ꯍ', '' ), + 'hex unicode codepoint' => array( '\\1f0a1', '🂡', '' ), + 'HEX UNICODE CODEPOINT' => array( '\\1D4B2', '𝒲', '' ), 'hex tab-suffixed 1' => array( "\\31\t23", '123', '' ), 'hex newline-suffixed 1' => array( "\\31\n23", '123', '' ), From 5f53e0a50b472a0aff078f233d6d7ffae189de33 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:34:25 +0100 Subject: [PATCH 035/115] Add ID tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 270def39b53d3..149bcd1f9572d 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -94,4 +94,33 @@ public static function test( string $input, int &$offset ) { $this->assertNull( $result, 'Ident did not match.' ); $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); } + + /** + * @ticket TBD + * + * @dataProvider data_ids + */ + public function test_parse_id( string $input, ?string $expected_id = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( null === $expected_id ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected_id ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + public static function data_ids(): array { + return array( + 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), + 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), + 'escaped #\31 23' => array( '#\\31 23', '123', '' ), + 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), + + 'not ID foo' => array( 'foo' ), + 'not valid #1foo' => array( '#1foo' ), + 'not id .bar' => array( '.bar' ), + ); + } } From effbbbece335486d269ecccf480fab99fc497d17 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:46:07 +0100 Subject: [PATCH 036/115] Improve tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 72 ++++++++----------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 149bcd1f9572d..53495f0b09004 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -15,8 +15,10 @@ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { /** * Data provider. + * + * @return array */ - public static function data_valid_idents() { + public static function data_idents(): array { return array( 'trailing #' => array( '_-foo123#xyz', '_-foo123', '#xyz' ), 'trailing .' => array( '😍foo123.xyz', '😍foo123', '.xyz' ), @@ -40,29 +42,23 @@ public static function data_valid_idents() { 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), - ); - } - /** - * Data provider. - */ - public static function data_invalid_idents() { - return array( - 'bad start >' => array( '>' ), - 'bad start [' => array( '[' ), - 'bad start #' => array( '#' ), - 'bad start " "' => array( ' ' ), - 'bad start -' => array( '-' ), - 'bad start 1' => array( '-' ), + // Invalid + 'bad start >' => array( '>' ), + 'bad start [' => array( '[' ), + 'bad start #' => array( '#' ), + 'bad start " "' => array( ' ' ), + 'bad start -' => array( '-' ), + 'bad start 1' => array( '-' ), ); } /** * @ticket TBD * - * @dataProvider data_valid_idents + * @dataProvider data_idents */ - public function test_valid_idents( string $input, string $result, string $rest ) { + public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ) {} public static function test( string $input, &$offset ) { @@ -70,48 +66,38 @@ public static function test( string $input, &$offset ) { } }; - $offset = 0; - $ident = $c::test( $input, $offset ); - $this->assertSame( $ident, $result, 'Ident did not match.' ); - $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); - } - - /** - * @ticket TBD - * - * @dataProvider data_invalid_idents - */ - public function test_invalid_idents( string $input ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, int &$offset ) { - return self::parse_ident( $input, $offset ); - } - }; - $offset = 0; $result = $c::test( $input, $offset ); - $this->assertNull( $result, 'Ident did not match.' ); - $this->assertSame( 0, $offset, 'Offset was incorrectly adjusted.' ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'Ident did not match.' ); + $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); + } } /** * @ticket TBD * - * @dataProvider data_ids + * @dataProvider data_id_selectors */ - public function test_parse_id( string $input, ?string $expected_id = null, ?string $rest = null ) { + public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; $result = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( null === $expected_id ) { + if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected_id ); + $this->assertSame( $result->ident, $expected ); $this->assertSame( substr( $input, $offset ), $rest ); } } - public static function data_ids(): array { + /** + * Data provider. + * + * @return array + */ + public static function data_id_selectors(): array { return array( 'valid #_-foo123' => array( '#_-foo123', '_-foo123', '' ), 'valid #foo#bar' => array( '#foo#bar', 'foo', '#bar' ), @@ -119,8 +105,8 @@ public static function data_ids(): array { 'with descendant #\31 23 div' => array( '#\\31 23 div', '123', ' div' ), 'not ID foo' => array( 'foo' ), + 'not ID .bar' => array( '.bar' ), 'not valid #1foo' => array( '#1foo' ), - 'not id .bar' => array( '.bar' ), ); } } From 62ec5bb804872afe38073e86a0e23ee1d5cd16a7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:46:23 +0100 Subject: [PATCH 037/115] Add class selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 53495f0b09004..aac3339e4d27d 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -109,4 +109,38 @@ public static function data_id_selectors(): array { 'not valid #1foo' => array( '#1foo' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_class_selectors + */ + public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_Class_Selector::parse( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_class_selectors(): array { + return array( + 'valid ._-foo123' => array( '._-foo123', '_-foo123', '' ), + 'valid .foo.bar' => array( '.foo.bar', 'foo', '.bar' ), + 'escaped .\31 23' => array( '.\\31 23', '123', '' ), + 'with descendant .\31 23 div' => array( '.\\31 23 div', '123', ' div' ), + + 'not class foo' => array( 'foo' ), + 'not class #bar' => array( '#bar' ), + 'not valid .1foo' => array( '.1foo' ), + ); + } } From 153f00978429f98cd7c5cc3d65a8b8affdcf1e45 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:47:00 +0100 Subject: [PATCH 038/115] Add class selector --- .../html-api/class-wp-css-selectors.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 56c31911d95b8..7b72fa0fe9616 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -414,3 +414,29 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $ident ); } } + +final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { + /** @var string */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + public static function parse( string $input, int &$offset ): ?self { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + $offset = $updated_offset; + } + + $offset = $updated_offset; + return new self( $result ); + } +} From fcc6401475554cd955891ae1dd82e067064067e8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 17:47:21 +0100 Subject: [PATCH 039/115] Simplify id selector parse --- .../html-api/class-wp-css-selectors.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 7b72fa0fe9616..fbccb55a5a0eb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -152,12 +152,16 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri return null; } - $offset_after_hash = $offset + 1; - if ( self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset_after_hash ) ) { - $offset = $offset_after_hash; - return self::parse_ident( $input, $offset ); + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + $offset = $updated_offset; } - return null; + + $offset = $updated_offset; + return $result; } /** From 21c67e52745b532489f6a494892b71c83f1b03ac Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:02:03 +0100 Subject: [PATCH 040/115] Improve ident tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index aac3339e4d27d..b3099146e226c 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -42,14 +42,20 @@ public static function data_idents(): array { 'leading surrogate max replacement' => array( '\\dbff ', "\u{fffd}", '' ), 'trailing surrogate min replacement' => array( '\\dc00 ', "\u{fffd}", '' ), 'trailing surrogate max replacement' => array( '\\dfff ', "\u{fffd}", '' ), + 'can start with -ident' => array( '-ident', '-ident', '' ), + 'can start with --anything' => array( '--anything', '--anything', '' ), + 'can start with ---anything' => array( '--_anything', '--_anything', '' ), + 'can start with --1anything' => array( '--1anything', '--1anything', '' ), + 'can start with -\31 23' => array( '-\31 23', '-123', '' ), + 'can start with --\31 23' => array( '--\31 23', '--123', '' ), // Invalid - 'bad start >' => array( '>' ), - 'bad start [' => array( '[' ), - 'bad start #' => array( '#' ), - 'bad start " "' => array( ' ' ), - 'bad start -' => array( '-' ), - 'bad start 1' => array( '-' ), + 'bad start >' => array( '>ident' ), + 'bad start [' => array( '[ident' ), + 'bad start #' => array( '#ident' ), + 'bad start " "' => array( ' ident' ), + 'bad start 1' => array( '1ident' ), + 'bad start -1' => array( '-1ident' ), ); } From 728d798d663d27f5b385d82fe54f3b88544983de Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:31:24 +0100 Subject: [PATCH 041/115] Add type selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index b3099146e226c..694c405c09e0b 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -149,4 +149,39 @@ public static function data_class_selectors(): array { 'not valid .1foo' => array( '.1foo' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_type_selectors + */ + public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { + $offset = 0; + $result = WP_CSS_Type_Selector::parse( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->ident, $expected ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_type_selectors(): array { + return array( + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + + // invalid + '#id' => array( '#id' ), + '.class' => array( '.class' ), + '[attr]' => array( '[attr]' ), + ); + } } From e1e8e098cfa4d0854104760e7e225e265f022064 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:31:54 +0100 Subject: [PATCH 042/115] Add docs and remove unreachable line --- .../html-api/class-wp-css-selectors.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index fbccb55a5a0eb..4ea438b95d8ce 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -410,6 +410,13 @@ private function __construct( string $ident ) { $this->ident = $ident; } + /** + * Parse an ID selector + * + * > = + * + * https://www.w3.org/TR/selectors/#grammar + */ public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); if ( null === $ident ) { @@ -427,6 +434,13 @@ private function __construct( string $ident ) { $this->ident = $ident; } + /** + * Parse a class selector + * + * > = '.' + * + * https://www.w3.org/TR/selectors/#grammar + */ public static function parse( string $input, int &$offset ): ?self { if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { return null; @@ -437,7 +451,6 @@ public static function parse( string $input, int &$offset ): ?self { if ( null === $result ) { return null; - $offset = $updated_offset; } $offset = $updated_offset; From 13ac3c11204d31e30455870bff92f0b81ecd3386 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 18:32:17 +0100 Subject: [PATCH 043/115] Add type selector class --- .../html-api/class-wp-css-selectors.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 4ea438b95d8ce..4a6b65048b62b 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -457,3 +457,46 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $result ); } } + +final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { + /** + * @var string + * + * The type identifier string or '*'. + */ + public $ident; + + private function __construct( string $ident ) { + $this->ident = $ident; + } + + /** + * Parse a type selector + * + * > = | ? '*' + * > = [ | '*' ]? '|' + * > = ? + * + * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, + * so this selector effectively matches * or ident. + * + * https://www.w3.org/TR/selectors/#grammar + */ + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return false; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new self( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new self( $result ); + } +} From a3c25e892f059f02d42070d593d03c5199a15e8d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:13:39 +0100 Subject: [PATCH 044/115] Add attribute selector tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 694c405c09e0b..5d0af28006039 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -184,4 +184,69 @@ public static function data_type_selectors(): array { '[attr]' => array( '[attr]' ), ); } + + /** + * @ticket TBD + * + * @dataProvider data_attribute_selectors + */ + public function test_parse_attribute( + string $input, + ?string $expected_name = null, + ?string $expected_matcher = null, + ?string $expected_value = null, + ?string $expected_modifier = null, + ?string $rest = null + ) { + $offset = 0; + $result = WP_CSS_Attribute_Selector::parse( $input, $offset ); + if ( null === $expected_name ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $result->name, $expected_name ); + $this->assertSame( $result->matcher, $expected_matcher ); + $this->assertSame( $result->value, $expected_value ); + $this->assertSame( $result->modifier, $expected_modifier ); + $this->assertSame( substr( $input, $offset ), $rest ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_attribute_selectors(): array { + return array( + array( '[href]', 'href', null, null, null, '' ), + array( '[href] type', 'href', null, null, null, ' type' ), + array( '[href]#id', 'href', null, null, null, '#id' ), + array( '[href].class', 'href', null, null, null, '.class' ), + array( '[href][href2]', 'href', null, null, null, '[href2]' ), + array( "[\n href\t\r]", 'href', null, null, null, '' ), + array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), + array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + // Invalid + array( 'foo' ), + array( '[foo' ), + array( '[#foo]' ), + array( '[*|*]' ), + array( '[ns|*]' ), + array( '[* |att]' ), + array( '[*| att]' ), + array( '[att * =]' ), + array( '[att * =]' ), + array( '[att i]' ), + array( '[att s]' ), + array( '[att="val" I]' ), + array( '[att="val" S]' ), + ); + } } From ad5c600d99ffeb98e92e6678b1476c0a7e02a808 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:49:57 +0100 Subject: [PATCH 045/115] improve attr tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 5d0af28006039..43c710a6f750c 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -218,35 +218,35 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - array( '[href]', 'href', null, null, null, '' ), - array( '[href] type', 'href', null, null, null, ' type' ), - array( '[href]#id', 'href', null, null, null, '#id' ), - array( '[href].class', 'href', null, null, null, '.class' ), - array( '[href][href2]', 'href', null, null, null, '[href2]' ), - array( "[\n href\t\r]", 'href', null, null, null, '' ), - array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), - array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match="quoted[][]"]' => array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + "[match='quoted!{}']" => array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), // Invalid - array( 'foo' ), - array( '[foo' ), - array( '[#foo]' ), - array( '[*|*]' ), - array( '[ns|*]' ), - array( '[* |att]' ), - array( '[*| att]' ), - array( '[att * =]' ), - array( '[att * =]' ), - array( '[att i]' ), - array( '[att s]' ), - array( '[att="val" I]' ), - array( '[att="val" S]' ), + 'foo' => array( 'foo' ), + '[foo' => array( '[foo' ), + '[#foo]' => array( '[#foo]' ), + '[*|*]' => array( '[*|*]' ), + '[ns|*]' => array( '[ns|*]' ), + '[* |att]' => array( '[* |att]' ), + '[*| att]' => array( '[*| att]' ), + '[att * =]' => array( '[att * =]' ), + '[att * =]' => array( '[att * =]' ), + '[att i]' => array( '[att i]' ), + '[att s]' => array( '[att s]' ), + '[att="val" I]' => array( '[att="val" I]' ), + '[att="val" S]' => array( '[att="val" S]' ), ); } } From 675870497312b388d4992090c7681886b06c919a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 19:53:06 +0100 Subject: [PATCH 046/115] Fix expectation argument order --- .../phpunit/tests/html-api/wpCssSelectors.php | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 43c710a6f750c..7bea7c3b34180 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -78,7 +78,7 @@ public static function test( string $input, &$offset ) { $this->assertNull( $result ); } else { $this->assertSame( $expected, $result, 'Ident did not match.' ); - $this->assertSame( substr( $input, $offset ), $rest, 'Offset was not updated correctly.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); } } @@ -93,8 +93,8 @@ public function test_parse_id( string $input, ?string $expected = null, ?string if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -127,8 +127,8 @@ public function test_parse_class( string $input, ?string $expected = null, ?stri if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -161,8 +161,8 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin if ( null === $expected ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->ident, $expected ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected, $result->ident ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } @@ -203,11 +203,11 @@ public function test_parse_attribute( if ( null === $expected_name ) { $this->assertNull( $result ); } else { - $this->assertSame( $result->name, $expected_name ); - $this->assertSame( $result->matcher, $expected_matcher ); - $this->assertSame( $result->value, $expected_value ); - $this->assertSame( $result->modifier, $expected_modifier ); - $this->assertSame( substr( $input, $offset ), $rest ); + $this->assertSame( $expected_name, $result->name ); + $this->assertSame( $expected_matcher, $result->matcher ); + $this->assertSame( $expected_value, $result->value ); + $this->assertSame( $expected_modifier, $result->modifier ); + $this->assertSame( $rest, substr( $input, $offset ) ); } } From e97842cf6665fef97059b71acef61e70ebbdf03e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 25 Nov 2024 21:31:31 +0100 Subject: [PATCH 047/115] Add test and fix is_ident --- .../html-api/class-wp-css-selectors.php | 2 +- .../phpunit/tests/html-api/wpCssSelectors.php | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 4a6b65048b62b..49b51e51fe81e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -323,7 +323,7 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || ( 'A' <= $input[ $offset ] && $input[ $offset ] <= 'Z' ) || - $input[ $offset ] > '\x7F' + ord( $input[ $offset ] ) > 0x7F ); } diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7bea7c3b34180..55cd1eafb29c9 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -48,6 +48,7 @@ public static function data_idents(): array { 'can start with --1anything' => array( '--1anything', '--1anything', '' ), 'can start with -\31 23' => array( '-\31 23', '-123', '' ), 'can start with --\31 23' => array( '--\31 23', '--123', '' ), + 'ident ends before ]' => array( 'ident]', 'ident', ']' ), // Invalid 'bad start >' => array( '>ident' ), @@ -59,6 +60,28 @@ public static function data_idents(): array { ); } + /** + * @ticket TBD + */ + public function test_is_ident_and_is_ident_start() { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + + public static function test_is_ident( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + + $this->assertFalse( $c::test_is_ident( '[', 0 ) ); + $this->assertFalse( $c::test_is_ident( ']', 0 ) ); + $this->assertFalse( $c::test_is_ident_start( '[', 0 ) ); + $this->assertFalse( $c::test_is_ident_start( ']', 0 ) ); + } + /** * @ticket TBD * From ef0085631424083dfc217308684c1baac3eea7f8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:36:40 +0100 Subject: [PATCH 048/115] Add parse_string stub --- src/wp-includes/html-api/class-wp-css-selectors.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 49b51e51fe81e..96c4465c2dbd6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -212,6 +212,11 @@ protected static function parse_ident( string $input, int &$offset ): ?string { return $ident; } + // @todo stub + protected static function parse_string( string $input, int &$offset ): ?string { + return null; + } + /** * Consume an escaped code point. * From 463e799a75d713829f84a988d58595d2ba0923f0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:37:31 +0100 Subject: [PATCH 049/115] Add attribute selector parsing --- .../html-api/class-wp-css-selectors.php | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 96c4465c2dbd6..5067d1c2b87e6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -505,3 +505,216 @@ public static function parse( string $input, int &$offset ): ?self { return new self( $result ); } } + +final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { + /** + * [attr=value] + * Represents elements with an attribute name of attr whose value is exactly value. + */ + const MATCH_EXACT = 'MATCH_EXACT'; + + /** + * [attr~=value] + * Represents elements with an attribute name of attr whose value is a + * whitespace-separated list of words, one of which is exactly value. + */ + const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; + + /** + * [attr|=value] + * Represents elements with an attribute name of attr whose value can be exactly value or + * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for + * language subcode matches. + */ + const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; + + /** + * [attr^=value] + * Represents elements with an attribute name of attr whose value is prefixed (preceded) + * by value. + */ + const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; + + /** + * [attr$=value] + * Represents elements with an attribute name of attr whose value is suffixed (followed) + * by value. + */ + const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; + + /** + * [attr*=value] + * Represents elements with an attribute name of attr whose value contains at least one + * occurrence of value within the string. + */ + const MATCH_CONTAINS = 'MATCH_CONTAINS'; + + /** + * Modifier for case sensitive matching + * [attr=value s] + */ + const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; + + /** + * Modifier for case insensitive matching + * [attr=value i] + */ + const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; + + + /** + * The attribute name. + * + * @var string + */ + public $name; + + /** + * The attribute matcher. + * + * @var string|null + */ + public $matcher; + + /** + * The attribute value. + * + * @var string|null + */ + public $value; + + /** + * The attribute modifier. + * + * @var string|null + */ + public $modifier; + + private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + $this->name = $name; + $this->matcher = $matcher; + $this->value = $value; + $this->modifier = $modifier; + } + + /** + * Parse a attribute selector + * + * > = '[' ']' | + * > '[' [ | ] ? ']' + * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * > = i | s + * > = ? + * + * Namespaces are not supported, so attribute names are effectively identifiers. + * + * https://www.w3.org/TR/selectors/#grammar + */ + public static function parse( string $input, int &$offset ): ?self { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return false; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new self( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } +} From 0f5b28cc5ed226f23ea38a3025ae5403b9b24bff Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 12:45:17 +0100 Subject: [PATCH 050/115] Fix test expectations --- tests/phpunit/tests/html-api/wpCssSelectors.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 55cd1eafb29c9..ae3c3e80c4f90 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -248,13 +248,13 @@ public static function data_attribute_selectors(): array { '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'bar', null, '' ), - '[match $= insensitive i]' => array( '[match $= insensitive i]', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[match="quoted[][]"]' => array( '[match="quoted[][]"]', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - "[match='quoted!{}']" => array( "[match='quoted!{}']", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match="quoted[][]"]' => array( '[match="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), + "[match='quoted!{}']" => array( "[match='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), // Invalid 'foo' => array( 'foo' ), From f4a491ae52aaaf4807e9eb9c9b6c671bae105abf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:11:25 +0100 Subject: [PATCH 051/115] More and improved attribute tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index ae3c3e80c4f90..4557ee1a5b3c4 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -252,24 +252,28 @@ public static function data_attribute_selectors(): array { '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[match="quoted[][]"]' => array( '[match="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted[][]', null, '' ), - "[match='quoted!{}']" => array( "[match='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid - 'foo' => array( 'foo' ), - '[foo' => array( '[foo' ), - '[#foo]' => array( '[#foo]' ), - '[*|*]' => array( '[*|*]' ), - '[ns|*]' => array( '[ns|*]' ), - '[* |att]' => array( '[* |att]' ), - '[*| att]' => array( '[*| att]' ), - '[att * =]' => array( '[att * =]' ), - '[att * =]' => array( '[att * =]' ), - '[att i]' => array( '[att i]' ), - '[att s]' => array( '[att s]' ), - '[att="val" I]' => array( '[att="val" I]' ), - '[att="val" S]' => array( '[att="val" S]' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + 'Invalid: [att="val" I]' => array( '[att="val" I]' ), + 'Invalid: [att="val" S]' => array( '[att="val" S]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), ); } } From b680b1b8e5f69bf17490934761899452fc935826 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:11:52 +0100 Subject: [PATCH 052/115] Implement parse_string --- .../html-api/class-wp-css-selectors.php | 84 ++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 5067d1c2b87e6..c1c3e35fc9ae1 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -212,9 +212,89 @@ protected static function parse_ident( string $input, int &$offset ): ?string { return $ident; } - // @todo stub + /** + * Parse a string token + * + * > 4.3.5. Consume a string token + * > This section describes how to consume a string token from a stream of code points. It returns either a or . + * > + * > This algorithm may be called with an ending code point, which denotes the code point that ends the string. If an ending code point is not specified, the current input code point is used. + * > + * > Initially create a with its value set to the empty string. + * > + * > Repeatedly consume the next input code point from the stream: + * > + * > ending code point + * > Return the . + * > EOF + * > This is a parse error. Return the . + * > newline + * > This is a parse error. Reconsume the current input code point, create a , and return it. + * > U+005C REVERSE SOLIDUS (\) + * > If the next input code point is EOF, do nothing. + * > Otherwise, if the next input code point is a newline, consume it. + * > Otherwise, (the stream starts with a valid escape) consume an escaped code point and append the returned code point to the ’s value. + * > + * > anything else + * > Append the current input code point to the ’s value. + * + * https://www.w3.org/TR/css-syntax-3/#consume-string-token + * + * This implementation will never return a because + * the is not a part of the selector grammar. That + * case is treated as failure to parse and null is returned. + */ protected static function parse_string( string $input, int &$offset ): ?string { - return null; + if ( $offset + 1 >= strlen( $input ) ) { + return null; + } + + $ending_code_point = $input[ $offset ]; + if ( '"' !== $ending_code_point && "'" !== $ending_code_point ) { + return null; + } + + $string_token = ''; + + $stop_characters = "\\\n{$ending_code_point}"; + + $updated_offset = $offset + 1; + while ( $updated_offset < strlen( $input ) ) { + switch ( $input[ $updated_offset ] ) { + case '\\': + if ( $updated_offset + 1 >= strlen( $input ) ) { + break; + } + ++$updated_offset; + if ( "\n" === $input[ $updated_offset ] ) { + ++$updated_offset; + break; + } else { + $string_token .= self::consume_escaped_codepoint( $input, $updated_offset ); + } + break; + + /* + * This case would return a . + * The is not a part of the selector grammar + * so we do not return it and instead treat this as a + * failure to parse a string token. + */ + case "\n": + return null; + + case $ending_code_point: + ++$updated_offset; + break 2; + + default: + $string_token .= $input[ $updated_offset ]; + ++$updated_offset; + } + } + + $offset = $updated_offset; + return $string_token; } /** From e7da05f238008dd987f176672565acfeacbd86b4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:25:20 +0100 Subject: [PATCH 053/115] Add string parse tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 4557ee1a5b3c4..96f2fa96dcb7f 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -9,8 +9,6 @@ * @since TBD * * @group html-api - * - * @coversDefaultClass WP_CSS_Selectors */ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { /** @@ -62,6 +60,9 @@ public static function data_idents(): array { /** * @ticket TBD + * + * @covers WP_CSS_Selector_Parser::is_ident_codepoint + * @covers WP_CSS_Selector_Parser::is_ident_start_codepoint */ public function test_is_ident_and_is_ident_start() { $c = new class() extends WP_CSS_Selector_Parser { @@ -86,6 +87,8 @@ public static function test_is_ident_start( string $input, int $offset ) { * @ticket TBD * * @dataProvider data_idents + * + * @covers WP_CSS_Selector_Parser::parse_ident */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -105,10 +108,69 @@ public static function test( string $input, &$offset ) { } } + /** + * @ticket TBD + * + * @dataProvider data_strings + * + * @covers WP_CSS_Selector_Parser::parse_string + */ + public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { + $c = new class() extends WP_CSS_Selector_Parser { + public static function parse( string $input, int &$offset ) {} + public static function test( string $input, &$offset ) { + return self::parse_string( $input, $offset ); + } + }; + + $offset = 0; + $result = $c::test( $input, $offset ); + if ( null === $expected ) { + $this->assertNull( $result ); + } else { + $this->assertSame( $expected, $result, 'String did not match.' ); + $this->assertSame( $rest, substr( $input, $offset ), 'Offset was not updated correctly.' ); + } + } + + /** + * Data provider. + * + * @return array + */ + public static function data_strings(): array { + return array( + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + // Invalid + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: \\"' => array( '\\"' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), + ); + } + /** * @ticket TBD * * @dataProvider data_id_selectors + * + * @covers WP_CSS_ID_Selector::parse */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -143,6 +205,8 @@ public static function data_id_selectors(): array { * @ticket TBD * * @dataProvider data_class_selectors + * + * @covers WP_CSS_Class_Selector::parse */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -177,6 +241,8 @@ public static function data_class_selectors(): array { * @ticket TBD * * @dataProvider data_type_selectors + * + * @covers WP_CSS_Type_Selector::parse */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -212,6 +278,8 @@ public static function data_type_selectors(): array { * @ticket TBD * * @dataProvider data_attribute_selectors + * + * @covers WP_CSS_Attribute_Selector::parse */ public function test_parse_attribute( string $input, From d5e7e6087aab9f58905aa3c5993a5357efe812e1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:26:01 +0100 Subject: [PATCH 054/115] Remove covers annotations --- tests/phpunit/tests/html-api/wpCssSelectors.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 96f2fa96dcb7f..7c5cdca447bbe 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -60,9 +60,6 @@ public static function data_idents(): array { /** * @ticket TBD - * - * @covers WP_CSS_Selector_Parser::is_ident_codepoint - * @covers WP_CSS_Selector_Parser::is_ident_start_codepoint */ public function test_is_ident_and_is_ident_start() { $c = new class() extends WP_CSS_Selector_Parser { @@ -87,8 +84,6 @@ public static function test_is_ident_start( string $input, int $offset ) { * @ticket TBD * * @dataProvider data_idents - * - * @covers WP_CSS_Selector_Parser::parse_ident */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -112,8 +107,6 @@ public static function test( string $input, &$offset ) { * @ticket TBD * * @dataProvider data_strings - * - * @covers WP_CSS_Selector_Parser::parse_string */ public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { $c = new class() extends WP_CSS_Selector_Parser { @@ -169,8 +162,6 @@ public static function data_strings(): array { * @ticket TBD * * @dataProvider data_id_selectors - * - * @covers WP_CSS_ID_Selector::parse */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -205,8 +196,6 @@ public static function data_id_selectors(): array { * @ticket TBD * * @dataProvider data_class_selectors - * - * @covers WP_CSS_Class_Selector::parse */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -241,8 +230,6 @@ public static function data_class_selectors(): array { * @ticket TBD * * @dataProvider data_type_selectors - * - * @covers WP_CSS_Type_Selector::parse */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; @@ -278,8 +265,6 @@ public static function data_type_selectors(): array { * @ticket TBD * * @dataProvider data_attribute_selectors - * - * @covers WP_CSS_Attribute_Selector::parse */ public function test_parse_attribute( string $input, From 08187c6858d95503d0e11eed6832045a68579f8a Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 18:32:55 +0100 Subject: [PATCH 055/115] Remove unused line --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index c1c3e35fc9ae1..3a4c0a7577679 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -256,8 +256,6 @@ protected static function parse_string( string $input, int &$offset ): ?string { $string_token = ''; - $stop_characters = "\\\n{$ending_code_point}"; - $updated_offset = $offset + 1; while ( $updated_offset < strlen( $input ) ) { switch ( $input[ $updated_offset ] ) { From 5a5066ce52335b330a57441b765ed9cc33184467 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:32:21 +0100 Subject: [PATCH 056/115] Improve tests for 100% coverage on parse methods --- .../phpunit/tests/html-api/wpCssSelectors.php | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7c5cdca447bbe..7b6e5ce79a365 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -49,12 +49,14 @@ public static function data_idents(): array { 'ident ends before ]' => array( 'ident]', 'ident', ']' ), // Invalid - 'bad start >' => array( '>ident' ), - 'bad start [' => array( '[ident' ), - 'bad start #' => array( '#ident' ), - 'bad start " "' => array( ' ident' ), - 'bad start 1' => array( '1ident' ), - 'bad start -1' => array( '-1ident' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: bad start >' => array( '>ident' ), + 'Invalid: bad start [' => array( '[ident' ), + 'Invalid: bad start #' => array( '#ident' ), + 'Invalid: bad start " "' => array( ' ident' ), + 'Invalid: bad start 1' => array( '1ident' ), + 'Invalid: bad start -1' => array( '-1ident' ), + 'Invalid: bad start -' => array( '-' ), ); } @@ -133,28 +135,31 @@ public static function test( string $input, &$offset ) { */ public static function data_strings(): array { return array( - '"foo"' => array( '"foo"', 'foo', '' ), - '"foo"after' => array( '"foo"after', 'foo', 'after' ), - '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), - '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), + '"foo"' => array( '"foo"', 'foo', '' ), + '"foo"after' => array( '"foo"after', 'foo', 'after' ), + '"foo""two"' => array( '"foo""two"', 'foo', '"two"' ), + '"foo"\'two\'' => array( '"foo"\'two\'', 'foo', "'two'" ), - "'foo'" => array( "'foo'", 'foo', '' ), - "'foo'after" => array( "'foo'after", 'foo', 'after' ), - "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), - "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), + "'foo'" => array( "'foo'", 'foo', '' ), + "'foo'after" => array( "'foo'after", 'foo', 'after' ), + "'foo'\"two\"" => array( "'foo'\"two\"", 'foo', '"two"' ), + "'foo''two'" => array( "'foo''two'", 'foo', "'two'" ), - "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), - "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), - "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), - "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), - "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + "'foo\\nbar'" => array( "'foo\\\nbar'", 'foobar', '' ), + "'foo\\31 23'" => array( "'foo\\31 23'", 'foo123', '' ), + "'foo\\31\\n23'" => array( "'foo\\31\n23'", 'foo123', '' ), + "'foo\\31\\t23'" => array( "'foo\\31\t23'", 'foo123', '' ), + "'foo\\00003123'" => array( "'foo\\00003123'", 'foo123', '' ), + + "'foo\\" => array( "'foo\\", 'foo', '' ), // Invalid - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: \\"' => array( '\\"' ), - 'Invalid: .foo' => array( '.foo' ), - 'Invalid: #foo' => array( '#foo' ), + 'Invalid: (empty string)' => array( '' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: \\"' => array( '\\"' ), + 'Invalid: .foo' => array( '.foo' ), + 'Invalid: #foo' => array( '#foo' ), ); } @@ -249,15 +254,16 @@ public function test_parse_type( string $input, ?string $expected = null, ?strin */ public static function data_type_selectors(): array { return array( - 'any *' => array( '* .class', '*', ' .class' ), - 'a' => array( 'a', 'a', '' ), - 'div.class' => array( 'div.class', 'div', '.class' ), - 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), + 'any *' => array( '* .class', '*', ' .class' ), + 'a' => array( 'a', 'a', '' ), + 'div.class' => array( 'div.class', 'div', '.class' ), + 'custom-type#id' => array( 'custom-type#id', 'custom-type', '#id' ), - // invalid - '#id' => array( '#id' ), - '.class' => array( '.class' ), - '[attr]' => array( '[attr]' ), + // Invalid + 'Invalid: (empty string)' => array( '' ), + 'Invalid: #id' => array( '#id' ), + 'Invalid: .class' => array( '.class' ), + 'Invalid: [attr]' => array( '[attr]' ), ); } @@ -313,6 +319,7 @@ public static function data_attribute_selectors(): array { '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid + 'Invalid: (empty string)' => array( '' ), 'Invalid: foo' => array( 'foo' ), 'Invalid: [foo' => array( '[foo' ), 'Invalid: [#foo]' => array( '[#foo]' ), @@ -321,12 +328,14 @@ public static function data_attribute_selectors(): array { 'Invalid: [* |att]' => array( '[* |att]' ), 'Invalid: [*| att]' => array( '[*| att]' ), 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), 'Invalid: [att="val" I]' => array( '[att="val" I]' ), 'Invalid: [att="val" S]' => array( '[att="val" S]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), ); } } From 2f8bd19efec5fb4f5f6cabd51d7173642d79af34 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:33:01 +0100 Subject: [PATCH 057/115] Improve documentation --- .../html-api/class-wp-css-selectors.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 3a4c0a7577679..669c74c1b676d 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -260,10 +260,10 @@ protected static function parse_string( string $input, int &$offset ): ?string { while ( $updated_offset < strlen( $input ) ) { switch ( $input[ $updated_offset ] ) { case '\\': - if ( $updated_offset + 1 >= strlen( $input ) ) { + ++$updated_offset; + if ( $updated_offset >= strlen( $input ) ) { break; } - ++$updated_offset; if ( "\n" === $input[ $updated_offset ] ) { ++$updated_offset; break; @@ -386,6 +386,11 @@ protected static function next_two_are_valid_escape( string $input, int $offset } /** + * Check if the next code point is an "ident start code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * * > ident-start code point * > A letter, a non-ASCII code point, or U+005F LOW LINE (_). * > uppercase letter @@ -396,12 +401,10 @@ protected static function next_two_are_valid_escape( string $input, int $offset * > An uppercase letter or a lowercase letter. * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . + * + * https://www.w3.org/TR/css-syntax-3/#ident-start-code-point */ protected static function is_ident_start_codepoint( string $input, int $offset ): bool { - if ( $offset >= strlen( $input ) ) { - return false; - } - return ( '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || @@ -411,10 +414,17 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) } /** + * Check if the next code point is an "ident code point". + * + * Caution! This method does not do any bounds checking, it should not be passed + * a string with an offset that is out of bounds. + * * > ident code point * > An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-). * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. + * + * https://www.w3.org/TR/css-syntax-3/#ident-code-point */ protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || From 8b0ac551e7694d3de921d84e60afe372583558b8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 26 Nov 2024 19:37:26 +0100 Subject: [PATCH 058/115] Fix parse return type and return annotations --- .../html-api/class-wp-css-selectors.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 669c74c1b676d..6a80ca2e42b7c 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -186,6 +186,8 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri * > Reconsume the current input code point. Return result. * * https://www.w3.org/TR/css-syntax-3/#consume-name + * + * @return string|null */ protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { @@ -243,6 +245,8 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * This implementation will never return a because * the is not a part of the selector grammar. That * case is treated as failure to parse and null is returned. + * + * @return string|null */ protected static function parse_string( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) ) { @@ -509,6 +513,8 @@ private function __construct( string $ident ) { * > = * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { $ident = self::parse_hash_token( $input, $offset ); @@ -533,6 +539,8 @@ private function __construct( string $ident ) { * > = '.' * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { @@ -574,10 +582,12 @@ private function __construct( string $ident ) { * so this selector effectively matches * or ident. * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { if ( $offset >= strlen( $input ) ) { - return false; + return null; } if ( '*' === $input[ $offset ] ) { @@ -697,11 +707,13 @@ private function __construct( string $name, ?string $matcher = null, ?string $va * Namespaces are not supported, so attribute names are effectively identifiers. * * https://www.w3.org/TR/selectors/#grammar + * + * @return self|null */ public static function parse( string $input, int &$offset ): ?self { // Need at least 3 bytes [x] if ( $offset + 2 >= strlen( $input ) ) { - return false; + return null; } $updated_offset = $offset; From dffcac6ed016f727aaacfb192f151f5c3cb3c67f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 17:07:48 +0100 Subject: [PATCH 059/115] Update documentation links and grammar --- .../html-api/class-wp-css-selectors.php | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 6a80ca2e42b7c..264f684692f17 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -19,7 +19,29 @@ * is invalid or unsupported. * * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax - * specification, which is available at https://www.w3.org/TR/css-syntax-3/. + * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. + * + * @todo Review this grammar, especially the complex selector for accurate support information. + * The supported grammar is: + * + * = + * = # + * = # + * = # + * = [ ? ]* + * = [ ? * ]! + * = | + * = '>' | '+' | '~' | [ '|' '|' ] + * = | '*' + * = | | + * = + * = '.' + * = '[' ']' | + * '[' [ | ] ? ']' + * = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * = i | s + * + * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) @@ -43,10 +65,10 @@ * * @access private * - * @see https://www.w3.org/TR/css-syntax-3/#consume-a-token - * @see https://www.w3.org/tr/selectors/#parse-selector - * @see https://www.w3.org/TR/selectors-api2/ - * @see https://www.w3.org/TR/selectors-4/ + * @see {@link https://www.w3.org/TR/css-syntax-3/} + * @see {@link https://www.w3.org/tr/selectors/} + * @see {@link https://www.w3.org/TR/selectors-api2/} + * @see {@link https://www.w3.org/TR/selectors-4/} * */ class WP_CSS_Selectors { From 9f81744aa7bc68fda9269d48251fa13fb2223519 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 20:29:56 +0100 Subject: [PATCH 060/115] Update documentation and class name --- src/wp-includes/html-api/class-wp-css-selectors.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 264f684692f17..d9bbc4b9235c8 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -14,7 +14,7 @@ * * This class is designed for internal use by the HTML processor. * - * This class is instantiated via the `WP_CSS_Selector::from_selector( string $selector )` method. + * This class is instantiated via the `WP_CSS_Selector_List::from_selector( string $selector )` method. * It accepts a CSS selector string and returns an instance of itself or `null` if the selector * is invalid or unsupported. * @@ -27,10 +27,8 @@ * = * = # * = # - * = # * = [ ? ]* * = [ ? * ]! - * = | * = '>' | '+' | '~' | [ '|' '|' ] * = | '*' * = | | @@ -71,7 +69,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selectors { +class WP_CSS_Selector_List { private $selectors; private function __construct( array $selectors ) { @@ -131,7 +129,7 @@ private static function parse( string $input ) { } } if ( count( $selectors ) ) { - return new WP_CSS_Selectors( $selectors ); + return new WP_CSS_Selector_List( $selectors ); } return null; } From d4c6f382dc246e151dc688a70daf88ad8a9f7916 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 20:30:12 +0100 Subject: [PATCH 061/115] Add selector class --- .../html-api/class-wp-css-selectors.php | 64 +++++++++++++++++++ .../phpunit/tests/html-api/wpCssSelectors.php | 18 ++++++ 2 files changed, 82 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index d9bbc4b9235c8..8d8ec35de98b6 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -838,3 +838,67 @@ public static function parse( string $input, int &$offset ): ?self { return null; } } + +/** + * This corresponds to in the grammar. + */ +final class WP_CSS_Selector extends WP_CSS_Selector_Parser { + + /** @var WP_CSS_Type_Selector|null */ + public $type_selector; + + /** @var array|null */ + public $subclass_selectors; + + private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } + + /** + * Parses a selector string into a `WP_CSS_Selector` object. + * + * > = [ ? * ]! + * + * @param string $input The selector string to parse. + * @return WP_CSS_Selector|null The parsed selector, or `null` if the selector is invalid or unsupported. + */ + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = WP_CSS_Type_Selector::parse( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new self( $type_selector, $subclass_selectors ); + } + } + + /** + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char ? + WP_CSS_Class_Selector::parse( $input, $offset ) : ( + '#' === $next_char ? + WP_CSS_ID_Selector::parse( $input, $offset ) : ( + '[' === $next_char ? + WP_CSS_Attribute_Selector::parse( $input, $offset ) : + null ) ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 7b6e5ce79a365..180bee4f53c05 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -338,4 +338,22 @@ public static function data_attribute_selectors(): array { 'Invalid: [att=val i ' => array( '[att=val i ' ), ); } + + /** + * @ticket TBD + */ + public function test_parse_selector() { + $input = 'el.foo#bar[baz=quux] > .child'; + $offset = 0; + $sel = WP_CSS_Selector::parse( $input, $offset ); + + $this->assertSame( $sel->type_selector->ident, 'el' ); + $this->assertSame( count( $sel->subclass_selectors ), 3 ); + $this->assertSame( $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( $sel->subclass_selectors[2]->matcher, WP_CSS_Attribute_Selector::MATCH_EXACT ); + $this->assertSame( $sel->subclass_selectors[2]->value, 'quux' ); + $this->assertSame( ' > .child', substr( $input, $offset ) ); + } } From 6432056bd38a8aebb94c51b6bfe6ac87353181c7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 21:01:43 +0100 Subject: [PATCH 062/115] Implement complex selector --- .../html-api/class-wp-css-selectors.php | 87 +++++++++++++++++-- .../phpunit/tests/html-api/wpCssSelectors.php | 34 ++++++-- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8d8ec35de98b6..8ccec5de029cc 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -123,9 +123,9 @@ private static function parse( string $input ) { $offset = 0; while ( $offset < $length ) { - $sel = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( $sel ) { - $selectors[] = $sel; + $selector = WP_CSS_ID_Selector::parse( $input, $offset ); + if ( null !== $selector ) { + $selectors[] = $selector; } } if ( count( $selectors ) ) { @@ -841,6 +841,8 @@ public static function parse( string $input, int &$offset ): ?self { /** * This corresponds to in the grammar. + * + * > = [ ? * ]! */ final class WP_CSS_Selector extends WP_CSS_Selector_Parser { @@ -856,12 +858,7 @@ private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subcl } /** - * Parses a selector string into a `WP_CSS_Selector` object. - * * > = [ ? * ]! - * - * @param string $input The selector string to parse. - * @return WP_CSS_Selector|null The parsed selector, or `null` if the selector is invalid or unsupported. */ public static function parse( string $input, int &$offset ): ?self { if ( $offset >= strlen( $input ) ) { @@ -882,6 +879,7 @@ public static function parse( string $input, int &$offset ): ?self { $offset = $updated_offset; return new self( $type_selector, $subclass_selectors ); } + return null; } /** @@ -902,3 +900,76 @@ private static function parse_subclass_selector( string $input, int &$offset ) { null ) ); } } + + +/** + * This corresponds to in the grammar. + * + * > = [ ? ]* + */ +final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { + const COMBINATOR_CHILD = '>'; + const COMBINATOR_DESCENDANT = ' '; + const COMBINATOR_NEXT_SIBLING = '+'; + const COMBINATOR_SUBSEQUENT_SIBLING = '~'; + + /** + * even indexes are WP_CSS_Selector, odd indexes are string combinators. + * @var array + */ + public $selectors = array(); + + private function __construct( array $selectors ) { + $this->selectors = $selectors; + } + + public static function parse( string $input, int &$offset ): ?self { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + switch ( $input[ $updated_offset ] ) { + case self::COMBINATOR_CHILD: + case self::COMBINATOR_NEXT_SIBLING: + case self::COMBINATOR_SUBSEQUENT_SIBLING: + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + break; + + default: + /* + * Whitespace is a descendant combinator. + * Either whitespace was found and we're on a selector, + * or we've failed to find any combinator and parsing is complete. + */ + if ( ! $found_whitespace ) { + break 2; + } + $combinator = self::COMBINATOR_DESCENDANT; + break; + } + // Here we've found a combinator and need another selector. + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + // Failure to find a selector is a parse error. + if ( null === $selector ) { + return null; + } + $selectors[] = $combinator; + $selectors[] = $selector; + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new self( $selectors ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 180bee4f53c05..4189ec586011a 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -347,13 +347,33 @@ public function test_parse_selector() { $offset = 0; $sel = WP_CSS_Selector::parse( $input, $offset ); - $this->assertSame( $sel->type_selector->ident, 'el' ); - $this->assertSame( count( $sel->subclass_selectors ), 3 ); - $this->assertSame( $sel->subclass_selectors[0]->ident, 'foo' ); - $this->assertSame( $sel->subclass_selectors[1]->ident, 'bar' ); - $this->assertSame( $sel->subclass_selectors[2]->name, 'baz' ); - $this->assertSame( $sel->subclass_selectors[2]->matcher, WP_CSS_Attribute_Selector::MATCH_EXACT ); - $this->assertSame( $sel->subclass_selectors[2]->value, 'quux' ); + $this->assertSame( 'el', $sel->type_selector->ident ); + $this->assertSame( 3, count( $sel->subclass_selectors ) ); + $this->assertSame( 'foo', $sel->subclass_selectors[0]->ident, 'foo' ); + $this->assertSame( 'bar', $sel->subclass_selectors[1]->ident, 'bar' ); + $this->assertSame( 'baz', $sel->subclass_selectors[2]->name, 'baz' ); + $this->assertSame( WP_CSS_Attribute_Selector::MATCH_EXACT, $sel->subclass_selectors[2]->matcher ); + $this->assertSame( 'quux', $sel->subclass_selectors[2]->value ); $this->assertSame( ' > .child', substr( $input, $offset ) ); } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector() { + $input = 'el.foo#bar[baz=quux] > .child, rest'; + $offset = 0; + $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); + + var_dump( $sel ); + $this->assertSame( 3, count( $sel->selectors ) ); + $this->assertNotNull( $sel->selectors[0]->type_selector ); + $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); + $this->assertNull( $sel->selectors[2]->type_selector ); + $this->assertSame( 1, count( $sel->selectors[2]->subclass_selectors ) ); + $this->assertSame( 'child', $sel->selectors[2]->subclass_selectors[0]->ident ); + + $this->assertSame( ', rest', substr( $input, $offset ) ); + } } From 5c746cd58b3e1178e9579e11b71974a5be652ac2 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 27 Nov 2024 22:39:22 +0100 Subject: [PATCH 063/115] Working and tested --- .../html-api/class-wp-css-selectors.php | 83 +++++++++++-------- .../phpunit/tests/html-api/wpCssSelectors.php | 67 ++++++++++++++- 2 files changed, 113 insertions(+), 37 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8ccec5de029cc..734c3e38d094b 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -117,21 +117,31 @@ private static function parse( string $input ) { $input = str_replace( array( "\r", "\f" ), "\n", $input ); $input = str_replace( "\0", "\u{FFFD}", $input ); - $length = strlen( $input ); - $selectors = array(); - $offset = 0; - while ( $offset < $length ) { - $selector = WP_CSS_ID_Selector::parse( $input, $offset ); - if ( null !== $selector ) { - $selectors[] = $selector; - } + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + if ( null === $selector ) { + return null; } - if ( count( $selectors ) ) { - return new WP_CSS_Selector_List( $selectors ); + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); } - return null; + + return new WP_CSS_Selector_List( $selectors ); } } @@ -145,7 +155,7 @@ public static function parse( string $input, int &$offset ); abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; - protected static function parse_whitespace( string $input, int &$offset ): bool { + public static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, " \t\r\n\f", $offset ); $advanced = $length > 0; $offset += $length; @@ -938,35 +948,38 @@ public static function parse( string $input, int &$offset ): ?self { $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { - switch ( $input[ $updated_offset ] ) { - case self::COMBINATOR_CHILD: - case self::COMBINATOR_NEXT_SIBLING: - case self::COMBINATOR_SUBSEQUENT_SIBLING: + if ( + self::COMBINATOR_CHILD === $input[ $updated_offset ] || + self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { $combinator = $input[ $updated_offset ]; ++$updated_offset; self::parse_whitespace( $input, $updated_offset ); - break; - default: - /* - * Whitespace is a descendant combinator. - * Either whitespace was found and we're on a selector, - * or we've failed to find any combinator and parsing is complete. - */ - if ( ! $found_whitespace ) { - break 2; - } - $combinator = self::COMBINATOR_DESCENDANT; + // Failure to find a selector here is a parse error + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + // Failure to find a selector is a parse error. + if ( null === $selector ) { + return null; + } + $selectors[] = $combinator; + $selectors[] = $selector; + } elseif ( ! $found_whitespace ) { + break; + } else { + + /* + * Whitespace is ambiguous, it could be a descendant combinator or + * insignificant whitespace. + */ + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + if ( null === $selector ) { break; + } + $selectors[] = self::COMBINATOR_DESCENDANT; + $selectors[] = $selector; } - // Here we've found a combinator and need another selector. - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - // Failure to find a selector is a parse error. - if ( null === $selector ) { - return null; - } - $selectors[] = $combinator; - $selectors[] = $selector; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 4189ec586011a..33ada4ccbe3f9 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -357,15 +357,24 @@ public function test_parse_selector() { $this->assertSame( ' > .child', substr( $input, $offset ) ); } + /** + * @ticket TBD + */ + public function test_parse_empty_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + /** * @ticket TBD */ public function test_parse_complex_selector() { - $input = 'el.foo#bar[baz=quux] > .child, rest'; + $input = 'el.foo#bar[baz=quux] > .child , rest'; $offset = 0; $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); - var_dump( $sel ); $this->assertSame( 3, count( $sel->selectors ) ); $this->assertNotNull( $sel->selectors[0]->type_selector ); $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); @@ -376,4 +385,58 @@ public function test_parse_complex_selector() { $this->assertSame( ', rest', substr( $input, $offset ) ); } + + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $this->assertNull( $result ); + } + + + /** + * @ticket TBD + */ + public function test_parse_selector_list() { + $input = 'el.foo#bar[baz=quux] .descendent , rest'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } } From 501102a87bb3f38bc2781c22b6de9a59d640bf62 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 18:30:47 +0100 Subject: [PATCH 064/115] Selector parsing should allow cap I,S modifier --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 734c3e38d094b..6e382f8f8b744 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -823,11 +823,13 @@ public static function parse( string $input, int &$offset ): ?self { $attr_modifier = null; switch ( $input[ $updated_offset ] ) { case 'i': + case 'I': $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; ++$updated_offset; break; case 's': + case 'S': $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; ++$updated_offset; break; From f98fbb39c71333b22e3c7f97c380c7ce81c56097 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:08:17 +0100 Subject: [PATCH 065/115] CSS Add matches to selector classes --- .../html-api/class-wp-css-selectors.php | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 6e382f8f8b744..d9c507bb5f557 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -69,7 +69,20 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List { +class WP_CSS_Selector_List implements IWP_CSS_Selector_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $processor->get_token_type() !== '#tag' ) { + return false; + } + + foreach ( $this->selectors as $selector ) { + if ( ! $selector->matches( $processor ) ) { + return false; + } + } + return true; + } + private $selectors; private function __construct( array $selectors ) { @@ -145,6 +158,13 @@ private static function parse( string $input ) { } } +interface IWP_CSS_Selector_Matcher { + /** + * @return bool + */ + public function matches( WP_HTML_Processor $processor ): bool; +} + interface IWP_CSS_Selector_Parser { /** * @return static|null @@ -152,7 +172,7 @@ interface IWP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ); } -abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser { +abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; public static function parse_whitespace( string $input, int &$offset ): bool { @@ -553,9 +573,18 @@ public static function parse( string $input, int &$offset ): ?self { } return new self( $ident ); } + + public function matches( WP_HTML_Processor $processor ): bool { + // @todo check case sensitivity. + return $processor->get_attribute( 'id' ) === $this->ident; + } } final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + return $processor->has_class( $this->ident ); + } + /** @var string */ public $ident; @@ -590,6 +619,13 @@ public static function parse( string $input, int &$offset ): ?self { } final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + if ( '*' === $this->ident ) { + return true; + } + return 0 === strcasecmp( $processor->get_tag(), $this->ident ); + } + /** * @var string * @@ -635,9 +671,64 @@ public static function parse( string $input, int &$offset ): ?self { } final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + $att_value = $processor->get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive ? + 0 === strcasecmp( $att_value, $this->value ) : + $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + // @todo + throw new Exception( 'One of attribute matching is not supported yet.' ); + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive ? + 0 === strcasecmp( $att_value, $this->value ) : + $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive ? + stripos( $att_value, $this->value ) : + strpos( $att_value, $this->value ) + ); + } + } + /** - * [attr=value] - * Represents elements with an attribute name of attr whose value is exactly value. + * [att=val] + * Represents an element with the att attribute whose value is exactly "val". */ const MATCH_EXACT = 'MATCH_EXACT'; @@ -857,6 +948,19 @@ public static function parse( string $input, int &$offset ): ?self { * > = [ ? * ]! */ final class WP_CSS_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $this->type_selector ) { + if ( ! $this->type_selector->matches( $processor ) ) { + return false; + } + } + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } + } + return true; + } /** @var WP_CSS_Type_Selector|null */ public $type_selector; @@ -920,6 +1024,14 @@ private static function parse_subclass_selector( string $input, int &$offset ) { * > = [ ? ]* */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { + public function matches( WP_HTML_Processor $processor ): bool { + // @todo this can throw on parse. + if ( count( $this->selectors ) > 1 ) { + throw new Exception( 'Combined complex selectors are not supported yet.' ); + } + return $this->selectors[0]->matches( $processor ); + } + const COMBINATOR_CHILD = '>'; const COMBINATOR_DESCENDANT = ' '; const COMBINATOR_NEXT_SIBLING = '+'; From c8f16e19f30ec4b4ad0cfbaac849b33e811229e3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:40:55 +0100 Subject: [PATCH 066/115] Match is successful on _any_ match in selector list --- src/wp-includes/html-api/class-wp-css-selectors.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index d9c507bb5f557..1a50defba8ea3 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -76,11 +76,11 @@ public function matches( WP_HTML_Processor $processor ): bool { } foreach ( $this->selectors as $selector ) { - if ( ! $selector->matches( $processor ) ) { - return false; + if ( $selector->matches( $processor ) ) { + return true; } } - return true; + return false; } private $selectors; From c689c9c50fb6827dd330df1707844410479b4234 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:41:55 +0100 Subject: [PATCH 067/115] PICKME: Add is_quirks_mode method to processor --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 39390621e86a6..7dadbc1bebdb2 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -537,6 +537,10 @@ class WP_HTML_Tag_Processor { */ protected $compat_mode = self::NO_QUIRKS_MODE; + public function is_quirks_mode() { + return self::QUIRKS_MODE === $this->compat_mode; + } + /** * Indicates whether the parser is inside foreign content, * e.g. inside an SVG or MathML element. From 1221efae34bf033af893180aa32a13e58b5312d8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:41:27 +0100 Subject: [PATCH 068/115] ID matches depend on quirks mode --- src/wp-includes/html-api/class-wp-css-selectors.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 1a50defba8ea3..01e3253893d57 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -575,8 +575,10 @@ public static function parse( string $input, int &$offset ): ?self { } public function matches( WP_HTML_Processor $processor ): bool { - // @todo check case sensitivity. - return $processor->get_attribute( 'id' ) === $this->ident; + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + return $case_insensitive ? + 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) : + $processor->get_attribute( 'id' ) === $this->ident; } } From e5e94b11b5d9e3c113364c2a595ebb8cfdb715f7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 19:42:12 +0100 Subject: [PATCH 069/115] has_class may return null, coerce to bool --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 01e3253893d57..3e35a383b4446 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -584,7 +584,7 @@ public function matches( WP_HTML_Processor $processor ): bool { final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { public function matches( WP_HTML_Processor $processor ): bool { - return $processor->has_class( $this->ident ); + return (bool) $processor->has_class( $this->ident ); } /** @var string */ From 1e888babcc7e4448a02ace55a509e655bdea1e5d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 21:29:13 +0100 Subject: [PATCH 070/115] Update docs to only allow subclass selectors in final complex selector position --- .../html-api/class-wp-css-selectors.php | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 3e35a383b4446..b0d5afbb5bba7 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -27,9 +27,9 @@ * = * = # * = # - * = [ ? ]* + * = [ ? ]* * = [ ? * ]! - * = '>' | '+' | '~' | [ '|' '|' ] + * = '>' | [ '|' '|' ] * = | '*' * = | | * = @@ -47,17 +47,23 @@ * - ID selectors (e.g. `#unique-id`) * - Attribute selectors (e.g. `[attribute-name]` or `[attribute-name="value"]`) * - Comma-separated selector lists (e.g. `.selector-1, .selector-2`) - * - The following combinators: - * - descendant (e.g. `.parent .descendant`) - * - child (`.parent > .child`) + * - The following combinators. Only type (element) selectors are allowed in non-final position: + * - descendant (e.g. `el .descendant`) + * - child (`el > .child`) * * Unsupported selector syntax: * - Pseudo-element selectors (e.g. `::before`) * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) * - The following combinators: - * - Next sibling (`.sibling + .sibling`) - * - Subsequent sibling (`.sibling ~ .sibling`) + * - Next sibling (`el + el`) + * - Subsequent sibling (`el ~ el`) + * + * Future ideas + * - Namespace type selectors could be implemented with select namespaces in order to + * select elements from a namespace, for example: + * - `svg|*` to select all SVG elements + * - `html|title` to select only HTML TITLE elements. * * @since TBD * From dd4fcb01184f9e07ec51067e1d7c1a8d4021d168 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:10:05 +0100 Subject: [PATCH 071/115] Restrict complex selectors to only allow subclass selectors in final position --- .../html-api/class-wp-css-selectors.php | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index b0d5afbb5bba7..45a2f78d94fd5 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1066,7 +1066,8 @@ public static function parse( string $input, int &$offset ): ?self { return null; } - $selectors = array( $selector ); + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; $found_whitespace = self::parse_whitespace( $input, $updated_offset ); while ( $updated_offset < strlen( $input ) ) { @@ -1075,22 +1076,13 @@ public static function parse( string $input, int &$offset ): ?self { self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // Failure to find a selector here is a parse error - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - // Failure to find a selector is a parse error. - if ( null === $selector ) { - return null; - } - $selectors[] = $combinator; - $selectors[] = $selector; - } elseif ( ! $found_whitespace ) { - break; - } else { + $combinator = $input[ $updated_offset ]; + ++$updated_offset; + self::parse_whitespace( $input, $updated_offset ); + // Failure to find a selector here is a parse error + $selector = WP_CSS_Selector::parse( $input, $updated_offset ); + } elseif ( $found_whitespace ) { /* * Whitespace is ambiguous, it could be a descendant combinator or * insignificant whitespace. @@ -1099,9 +1091,24 @@ public static function parse( string $input, int &$offset ): ?self { if ( null === $selector ) { break; } - $selectors[] = self::COMBINATOR_DESCENDANT; - $selectors[] = $selector; + $combinator = self::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); } $offset = $updated_offset; From 256c55a16d8e5adf3ebdc64a360e3373eeecaa28 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:10:21 +0100 Subject: [PATCH 072/115] Work on complex selector handling --- .../html-api/class-wp-css-selectors.php | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 45a2f78d94fd5..bc28cfaa4f20e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1033,11 +1033,47 @@ private static function parse_subclass_selector( string $input, int &$offset ) { */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { public function matches( WP_HTML_Processor $processor ): bool { - // @todo this can throw on parse. - if ( count( $this->selectors ) > 1 ) { - throw new Exception( 'Combined complex selectors are not supported yet.' ); + if ( count( $this->selectors ) === 1 ) { + return $this->selectors[0]->matches( $processor ); + } + + // First selector must match this location. + if ( ! $this->selectors[0]->matches( $processor ) ) { + return false; + } + + $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); + $selectors = array_slice( $this->selectors, 1 ); + return $this->explore_matches( $selectors, $breadcrumbs ); + } + + /** + * This only looks at breadcrumbs and can therefore only support type selectors. + * + * @param array $selectors + */ + private function explore_matches( array $selectors, array $breadcrumbs ): bool { + if ( array() === $selectors ) { + return true; + } + if ( array() === $breadcrumbs ) { + return false; + } + + $combinator = $selectors[0]; + $selector = $selectors[1]; + + switch ( $combinator ) { + case self::COMBINATOR_CHILD: + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + } + return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + + case self::COMBINATOR_DESCENDANT: + default: + throw new Exception( "Combinator '{$combinator}' is not supported yet." ); } - return $this->selectors[0]->matches( $processor ); } const COMBINATOR_CHILD = '>'; @@ -1047,12 +1083,15 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * even indexes are WP_CSS_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(); private function __construct( array $selectors ) { - $this->selectors = $selectors; + $this->selectors = array_reverse( $selectors ); } public static function parse( string $input, int &$offset ): ?self { From 465cc3673cb15e2b229767223801224d8fd36335 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 22:26:43 +0100 Subject: [PATCH 073/115] Implement descendent selector matching --- src/wp-includes/html-api/class-wp-css-selectors.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index bc28cfaa4f20e..974c56e6581ff 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1071,6 +1071,19 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); case self::COMBINATOR_DESCENDANT: + $ident = $selector->type_selector->ident; + + // Find _all_ the breadcrumbs that match and recurse from each of them. + for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + return true; + } + } + } + return false; + default: throw new Exception( "Combinator '{$combinator}' is not supported yet." ); } From 467d45dc3133dfefb7081e8e7e7821254dd073a0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 15:48:21 +0100 Subject: [PATCH 074/115] Add null check for subclass selectors --- src/wp-includes/html-api/class-wp-css-selectors.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 974c56e6581ff..21039c0c7940e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -962,9 +962,11 @@ public function matches( WP_HTML_Processor $processor ): bool { return false; } } - foreach ( $this->subclass_selectors as $subclass_selector ) { - if ( ! $subclass_selector->matches( $processor ) ) { - return false; + if ( null !== $this->subclass_selectors ) { + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } } } return true; From 44bfc64b4fe9711f1800e854c059156bcf2b45fb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:20:22 +0100 Subject: [PATCH 075/115] CSS selector reformat ternaries --- .../html-api/class-wp-css-selectors.php | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 21039c0c7940e..65e384639abcb 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -390,9 +390,9 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || ( 0xD800 <= $codepoint_value && $codepoint_value <= 0xDFFF ) - ) ? - "\u{FFFD}" : - mb_chr( $codepoint_value, 'UTF-8' ); + ) + ? "\u{FFFD}" + : mb_chr( $codepoint_value, 'UTF-8' ); $offset += $hex_length; @@ -582,9 +582,9 @@ public static function parse( string $input, int &$offset ): ?self { public function matches( WP_HTML_Processor $processor ): bool { $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); - return $case_insensitive ? - 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) : - $processor->get_attribute( 'id' ) === $this->ident; + return $case_insensitive + ? 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) + : $processor->get_attribute( 'id' ) === $this->ident; } } @@ -693,9 +693,9 @@ public function matches( WP_HTML_Processor $processor ): bool { switch ( $this->matcher ) { case self::MATCH_EXACT: - return $case_insensitive ? - 0 === strcasecmp( $att_value, $this->value ) : - $att_value === $this->value; + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; case self::MATCH_ONE_OF_EXACT: // @todo @@ -704,9 +704,9 @@ public function matches( WP_HTML_Processor $processor ): bool { case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: // Attempt the full match first if ( - $case_insensitive ? - 0 === strcasecmp( $att_value, $this->value ) : - $att_value === $this->value + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value ) { return true; } @@ -1017,13 +1017,16 @@ private static function parse_subclass_selector( string $input, int &$offset ) { } $next_char = $input[ $offset ]; - return '.' === $next_char ? - WP_CSS_Class_Selector::parse( $input, $offset ) : ( - '#' === $next_char ? - WP_CSS_ID_Selector::parse( $input, $offset ) : ( - '[' === $next_char ? - WP_CSS_Attribute_Selector::parse( $input, $offset ) : - null ) ); + return '.' === $next_char + ? WP_CSS_Class_Selector::parse( $input, $offset ) + : ( + '#' === $next_char + ? WP_CSS_ID_Selector::parse( $input, $offset ) + : ( '[' === $next_char + ? WP_CSS_Attribute_Selector::parse( $input, $offset ) + : null + ) + ); } } From ca4531c0a190b89f6072799b2b1f90dbd1deb2c1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:20:54 +0100 Subject: [PATCH 076/115] Implement ~= attribute matching --- .../html-api/class-wp-css-selectors.php | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 65e384639abcb..49c3daf66c3b2 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -180,9 +180,10 @@ public static function parse( string $input, int &$offset ); abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; + const WHITESPACE_CHARACTERS = " \t\r\n\f"; public static function parse_whitespace( string $input, int &$offset ): bool { - $length = strspn( $input, " \t\r\n\f", $offset ); + $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $advanced = $length > 0; $offset += $length; return $advanced; @@ -698,8 +699,16 @@ public function matches( WP_HTML_Processor $processor ): bool { : $att_value === $this->value; case self::MATCH_ONE_OF_EXACT: - // @todo - throw new Exception( 'One of attribute matching is not supported yet.' ); + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: // Attempt the full match first @@ -727,13 +736,35 @@ public function matches( WP_HTML_Processor $processor ): bool { case self::MATCH_CONTAINS: return false !== ( - $case_insensitive ? - stripos( $att_value, $this->value ) : - strpos( $att_value, $this->value ) + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) ); } } + /** + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); + if ( 0 === $length ) { + return; + } + + $value = substr( $input, $offset, $length ); + $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); + + yield $value; + } + } + /** * [att=val] * Represents an element with the att attribute whose value is exactly "val". From 489db93a917625bc7d42d6e3d9f5ad924d3a96ed Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:48:15 +0100 Subject: [PATCH 077/115] CSS fix return type --- src/wp-includes/html-api/class-wp-css-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 49c3daf66c3b2..1431dc58afb52 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -113,7 +113,7 @@ public static function from_selectors( string $selectors ): ?self { * * @since TBD * - * @return WP_CSS_Selectors|null + * @return self|null */ private static function parse( string $input ) { // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. From e57a2114aafdd6cb1d0e3cf1b7d2e3064c3e8d0b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:05:40 +0100 Subject: [PATCH 078/115] Fix static analysis problems --- .../html-api/class-wp-css-selectors.php | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 1431dc58afb52..2205146bdf2be 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -1,4 +1,8 @@ -value ) ); } + + throw new Exception( 'Unreachable' ); } /** @@ -830,7 +834,7 @@ private function whitespace_delimited_list( string $input ): Generator { /** * The attribute matcher. * - * @var string|null + * @var null|self::MATCH_* */ public $matcher; @@ -844,7 +848,7 @@ private function whitespace_delimited_list( string $input ): Generator { /** * The attribute modifier. * - * @var string|null + * @var null|self::MODIFIER_* */ public $modifier; @@ -1086,7 +1090,7 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array $selectors */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -1096,8 +1100,10 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } + /** @var self::COMBINATOR_* $combinator */ $combinator = $selectors[0]; - $selector = $selectors[1]; + /** @var WP_CSS_Selector $selector */ + $selector = $selectors[1]; switch ( $combinator ) { case self::COMBINATOR_CHILD: @@ -1107,8 +1113,6 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); case self::COMBINATOR_DESCENDANT: - $ident = $selector->type_selector->ident; - // Find _all_ the breadcrumbs that match and recurse from each of them. for ( $i = 0; $i < count( $breadcrumbs ); $i++ ) { if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { From 509e648685af757a6b38830c8ccd58e2ac36fe07 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:40:39 +0100 Subject: [PATCH 079/115] Fix and annotate things (static analysis) --- .../html-api/class-wp-css-selectors.php | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 2205146bdf2be..28e51aa9a9735 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -77,7 +77,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List implements IWP_CSS_Selector_Matcher { +class WP_CSS_Selector_List extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; @@ -91,8 +91,14 @@ public function matches( WP_HTML_Processor $processor ): bool { return false; } + /** + * @var array + */ private $selectors; + /** + * @param array $selectors + */ private function __construct( array $selectors ) { $this->selectors = $selectors; } @@ -122,7 +128,7 @@ private static function parse( string $input ) { $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { - null; + return null; } /* @@ -144,7 +150,7 @@ private static function parse( string $input ) { if ( null === $selector ) { return null; } - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); $selectors = array( $selector ); while ( $offset < strlen( $input ) ) { @@ -153,16 +159,16 @@ private static function parse( string $input ) { return null; } ++$offset; - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); if ( null === $selector ) { return null; } $selectors[] = $selector; - WP_CSS_Selector_Parser::parse_whitespace( $input, $offset ); + self::parse_whitespace( $input, $offset ); } - return new WP_CSS_Selector_List( $selectors ); + return new self( $selectors ); } } @@ -180,7 +186,7 @@ interface IWP_CSS_Selector_Parser { public static function parse( string $input, int &$offset ); } -abstract class WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +abstract class WP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; @@ -216,7 +222,6 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri if ( null === $result ) { return null; - $offset = $updated_offset; } $offset = $updated_offset; @@ -263,8 +268,8 @@ protected static function parse_ident( string $input, int &$offset ): ?string { continue; } elseif ( self::is_ident_codepoint( $input, $offset ) ) { // @todo this should append and advance the correct number of bytes. - $ident .= $input[ $offset ]; - $offset += 1; + $ident .= $input[ $offset ]; + ++$offset; continue; } break; @@ -378,6 +383,10 @@ protected static function parse_string( string $input, int &$offset ): ?string { * > This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�). * > anything else * > Return the current input code point. + * + * @param string $input + * @param int $offset + * @return string|null */ protected static function consume_escaped_codepoint( $input, &$offset ): ?string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); @@ -558,7 +567,8 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { + /** @var string */ public $ident; @@ -591,7 +601,7 @@ public function matches( WP_HTML_Processor $processor ): bool { } } -final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { return (bool) $processor->has_class( $this->ident ); } @@ -629,7 +639,7 @@ public static function parse( string $input, int &$offset ): ?self { } } -final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( '*' === $this->ident ) { return true; @@ -681,7 +691,7 @@ public static function parse( string $input, int &$offset ): ?self { } } -final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { $att_value = $processor->get_attribute( $this->name ); if ( null === $att_value ) { @@ -990,7 +1000,7 @@ public static function parse( string $input, int &$offset ): ?self { * * > = [ ? * ]! */ -final class WP_CSS_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { @@ -1013,6 +1023,10 @@ public function matches( WP_HTML_Processor $processor ): bool { /** @var array|null */ public $subclass_selectors; + /** + * @param WP_CSS_Type_Selector|null $type_selector + * @param array $subclass_selectors + */ private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { $this->type_selector = $type_selector; $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; @@ -1071,7 +1085,7 @@ private static function parse_subclass_selector( string $input, int &$offset ) { * * > = [ ? ]* */ -final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser { +final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( count( $this->selectors ) === 1 ) { return $this->selectors[0]->matches( $processor ); @@ -1091,6 +1105,7 @@ public function matches( WP_HTML_Processor $processor ): bool { * This only looks at breadcrumbs and can therefore only support type selectors. * * @param array $selectors + * @param array $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -1139,10 +1154,13 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { * In reverse order to match the current element and then work up the tree. * Any non-final selector is a type selector. * - * @var array + * @var array */ public $selectors = array(); + /** + * @param array $selectors + */ private function __construct( array $selectors ) { $this->selectors = array_reverse( $selectors ); } From 58c1698b16a55ac3d9bc92c35b4c2346e43b67c7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:40:46 +0100 Subject: [PATCH 080/115] update tests --- .../phpunit/tests/html-api/wpCssSelectors.php | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 33ada4ccbe3f9..5983f91c5d9ba 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -309,8 +309,12 @@ public static function data_attribute_selectors(): array { '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), @@ -332,8 +336,6 @@ public static function data_attribute_selectors(): array { 'Invalid: [att=val ' => array( '[att=val ' ), 'Invalid: [att i]' => array( '[att i]' ), 'Invalid: [att s]' => array( '[att s]' ), - 'Invalid: [att="val" I]' => array( '[att="val" I]' ), - 'Invalid: [att="val" S]' => array( '[att="val" S]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), 'Invalid: [att=val i ' => array( '[att=val i ' ), ); @@ -371,17 +373,21 @@ public function test_parse_empty_selector() { * @ticket TBD */ public function test_parse_complex_selector() { - $input = 'el.foo#bar[baz=quux] > .child , rest'; + $input = 'el1 > .child#bar[baz=quux] , rest'; $offset = 0; $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); $this->assertSame( 3, count( $sel->selectors ) ); - $this->assertNotNull( $sel->selectors[0]->type_selector ); - $this->assertSame( 3, count( $sel->selectors[0]->subclass_selectors ) ); + + $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); + $this->assertNull( $sel->selectors[2]->subclass_selectors ); + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[1] ); - $this->assertNull( $sel->selectors[2]->type_selector ); - $this->assertSame( 1, count( $sel->selectors[2]->subclass_selectors ) ); - $this->assertSame( 'child', $sel->selectors[2]->subclass_selectors[0]->ident ); + + $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( ', rest', substr( $input, $offset ) ); } @@ -408,7 +414,7 @@ public function test_parse_empty_complex_selector() { * @ticket TBD */ public function test_parse_selector_list() { - $input = 'el.foo#bar[baz=quux] .descendent , rest'; + $input = 'el1 el2 el.foo#bar[baz=quux], rest'; $result = WP_CSS_Selector_List::from_selectors( $input ); $this->assertNotNull( $result ); } From c9b914517674004d8b7c38099325183cf3a592a8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:44:21 +0100 Subject: [PATCH 081/115] Id attribute must be a string to match id selector --- src/wp-includes/html-api/class-wp-css-selectors.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 28e51aa9a9735..8af33c2194723 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -594,9 +594,14 @@ public static function parse( string $input, int &$offset ): ?self { } public function matches( WP_HTML_Processor $processor ): bool { + $id = $processor->get_attribute( 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); return $case_insensitive - ? 0 === strcasecmp( $processor->get_attribute( 'id' ), $this->ident ) + ? 0 === strcasecmp( $id, $this->ident ) : $processor->get_attribute( 'id' ) === $this->ident; } } From e5cac63369f3c7b1a6cdf3c02c097bdae4e3d669 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:47:31 +0100 Subject: [PATCH 082/115] Coerce boolean attributes to "" --- src/wp-includes/html-api/class-wp-css-selectors.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8af33c2194723..8b92150cbef8f 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -707,6 +707,10 @@ public function matches( WP_HTML_Processor $processor ): bool { return true; } + if ( true === $att_value ) { + $att_value = ''; + } + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; switch ( $this->matcher ) { From 2bafae995a64897ec393167e8a7416b74ff8b485 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:56:57 +0100 Subject: [PATCH 083/115] Fix a few more static analysis things --- .../html-api/class-wp-css-selectors.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 8b92150cbef8f..87e32727a434e 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -871,6 +871,12 @@ private function whitespace_delimited_list( string $input ): Generator { */ public $modifier; + /** + * @param string $name + * @param null|self::MATCH_* $matcher + * @param null|string $value + * @param null|self::MODIFIER_* $modifier + */ private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; $this->matcher = $matcher; @@ -1092,19 +1098,20 @@ private static function parse_subclass_selector( string $input, int &$offset ) { /** * This corresponds to in the grammar. * - * > = [ ? ]* + * > = [ ? ] * */ final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { - if ( count( $this->selectors ) === 1 ) { - return $this->selectors[0]->matches( $processor ); - } - // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { return false; } + if ( count( $this->selectors ) === 1 ) { + return true; + } + + /** @var array $breadcrumbs */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); $selectors = array_slice( $this->selectors, 1 ); return $this->explore_matches( $selectors, $breadcrumbs ); From 8fe57e393d947c2b8db0ee326cfa7989ade8c801 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 28 Nov 2024 18:04:13 +0100 Subject: [PATCH 084/115] Add select method --- .../html-api/class-wp-html-processor.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index e88757ec7b4c2..438dee4c47f4e 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -635,6 +635,44 @@ public function get_unsupported_exception() { return $this->unsupported_exception; } + /** + * Use a selector to advance. + * + * @param string $selectors + * @return Generator|null + */ + public function select_all( string $selectors ): ?Generator { + $select = WP_CSS_Selector_List::from_selectors( $selectors ); + if ( null === $select ) { + return null; + } + + while ( $this->next_tag() ) { + if ( $select->matches( $this ) ) { + yield; + } + } + } + + /** + * Select the next matching element. + * + * If iterating through matching elements, use `select_all` instead. + * + * @param string $selectors + * @return bool|null + */ + public function select( string $selectors ) { + $selection = $this->select_all( $selectors ); + if ( null === $selection ) { + return null; + } + foreach ( $selection as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * From ab2fe0d78e2f2f54b29dae6ddb36a664f703d476 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 18:20:01 +0100 Subject: [PATCH 085/115] Unify parsing under single class --- .../html-api/class-wp-css-selectors.php | 820 +++++++++--------- .../html-api/class-wp-html-processor.php | 2 +- .../phpunit/tests/html-api/wpCssSelectors.php | 121 ++- 3 files changed, 510 insertions(+), 433 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selectors.php index 87e32727a434e..7588eb72294bd 100644 --- a/src/wp-includes/html-api/class-wp-css-selectors.php +++ b/src/wp-includes/html-api/class-wp-css-selectors.php @@ -16,8 +16,8 @@ * * This class is designed for internal use by the HTML processor. * - * This class is instantiated via the `WP_CSS_Selector_List::from_selector( string $selector )` method. - * It accepts a CSS selector string and returns an instance of itself or `null` if the selector + * This class is instantiated via the `WP_CSS_Selector::from_selectors( string $input )` method. + * It takes a CSS selector string and returns an instance of itself or `null` if the selector * is invalid or unsupported. * * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax @@ -39,7 +39,7 @@ * = '[' ']' | * '[' [ | ] ? ']' * = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * = i | s + * = i | I | s | S * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * @@ -77,7 +77,7 @@ * @see {@link https://www.w3.org/TR/selectors-4/} * */ -class WP_CSS_Selector_List extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Matcher { +class WP_CSS_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; @@ -97,34 +97,25 @@ public function matches( WP_HTML_Processor $processor ): bool { private $selectors; /** + * Constructor. + * * @param array $selectors */ - private function __construct( array $selectors ) { + protected function __construct( array $selectors ) { $this->selectors = $selectors; } /** - * Takes a CSS selectors string and returns an instance of itself or `null` if the selector - * is invalid or unsupported. - * - * @since TBD - * - * @param string $selectors CSS selectors string. - * @return self|null - */ - public static function from_selectors( string $selectors ): ?self { - return self::parse( $selectors ); - } - - /** - * Returns a list of selectors. + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. * * @since TBD * + * @param string $input CSS selectors. * @return self|null */ - private static function parse( string $input ) { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace and matches the dom_selectors_group production. + public static function from_selectors( string $input ): ?self { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { @@ -146,7 +137,7 @@ private static function parse( string $input ) { $offset = 0; - $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -160,7 +151,7 @@ private static function parse( string $input ) { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = WP_CSS_Complex_Selector::parse( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -170,23 +161,343 @@ private static function parse( string $input ) { return new self( $selectors ); } -} -interface IWP_CSS_Selector_Matcher { + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + /** - * @return bool + * Parse an ID selector + * + * > = + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_ID_Selector|null */ - public function matches( WP_HTML_Processor $processor ): bool; -} + final protected static function parse_id_selector( string $input, int &$offset ): ?WP_CSS_ID_Selector { + $ident = self::parse_hash_token( $input, $offset ); + if ( null === $ident ) { + return null; + } + return new WP_CSS_ID_Selector( $ident ); + } -interface IWP_CSS_Selector_Parser { /** - * @return static|null + * Parse a class selector + * + * > = '.' + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Class_Selector|null + */ + final protected static function parse_class_selector( string $input, int &$offset ): ?WP_CSS_Class_Selector { + if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { + return null; + } + + $updated_offset = $offset + 1; + $result = self::parse_ident( $input, $updated_offset ); + + if ( null === $result ) { + return null; + } + + $offset = $updated_offset; + return new WP_CSS_Class_Selector( $result ); + } + + /** + * Parse a type selector + * + * > = | ? '*' + * > = [ | '*' ]? '|' + * > = ? + * + * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, + * so this selector effectively matches * or ident. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Type_Selector|null + */ + final protected static function parse_type_selector( string $input, int &$offset ): ?WP_CSS_Type_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + if ( '*' === $input[ $offset ] ) { + ++$offset; + return new WP_CSS_Type_Selector( '*' ); + } + + $result = self::parse_ident( $input, $offset ); + if ( null === $result ) { + return null; + } + + return new WP_CSS_Type_Selector( $result ); + } + + /** + * Parse an attribute selector + * + * > = '[' ']' | + * > '[' [ | ] ? ']' + * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' + * > = i | s + * > = ? + * + * Namespaces are not supported, so attribute names are effectively identifiers. + * + * https://www.w3.org/TR/selectors/#grammar + * + * @return WP_CSS_Attribute_Selector|null + */ + final protected static function parse_attribute_selector( string $input, int &$offset ): ?WP_CSS_Attribute_Selector { + // Need at least 3 bytes [x] + if ( $offset + 2 >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + + if ( '[' !== $input[ $updated_offset ] ) { + return null; + } + ++$updated_offset; + + self::parse_whitespace( $input, $updated_offset ); + $attr_name = self::parse_ident( $input, $updated_offset ); + if ( null === $attr_name ) { + return null; + } + self::parse_whitespace( $input, $updated_offset ); + + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name ); + } + + // need to match at least `=x]` at this point + if ( $updated_offset + 3 >= strlen( $input ) ) { + return null; + } + + if ( '=' === $input[ $updated_offset ] ) { + ++$updated_offset; + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; + } elseif ( '=' === $input[ $updated_offset + 1 ] ) { + switch ( $input[ $updated_offset ] ) { + case '~': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; + $updated_offset += 2; + break; + case '|': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; + $updated_offset += 2; + break; + case '^': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; + $updated_offset += 2; + break; + case '$': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; + $updated_offset += 2; + break; + case '*': + $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; + $updated_offset += 2; + break; + default: + return null; + } + } else { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + $attr_val = + self::parse_string( $input, $updated_offset ) ?? + self::parse_ident( $input, $updated_offset ); + + if ( null === $attr_val ) { + return null; + } + + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + + $attr_modifier = null; + switch ( $input[ $updated_offset ] ) { + case 'i': + case 'I': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; + ++$updated_offset; + break; + + case 's': + case 'S': + $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; + ++$updated_offset; + break; + } + + if ( null !== $attr_modifier ) { + self::parse_whitespace( $input, $updated_offset ); + if ( $updated_offset >= strlen( $input ) ) { + return null; + } + } + + if ( ']' === $input[ $updated_offset ] ) { + $offset = $updated_offset + 1; + return new WP_CSS_Attribute_Selector( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); + } + + return null; + } + + /** + * Parses a compound selector. + * + * > = [ ? * ]! + * + * @return WP_CSS_Compound_Selector|null + */ + final protected static function parse_compound_selector( string $input, int &$offset ): ?WP_CSS_Compound_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $type_selector = self::parse_type_selector( $input, $updated_offset ); + + $subclass_selectors = array(); + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + while ( null !== $last_parsed_subclass_selector ) { + $subclass_selectors[] = $last_parsed_subclass_selector; + $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); + } + + if ( null !== $type_selector || array() !== $subclass_selectors ) { + $offset = $updated_offset; + return new WP_CSS_Compound_Selector( $type_selector, $subclass_selectors ); + } + return null; + } + + /** + * Parses a complex selector. + * + * > = [ ? ]* + * + * @return WP_CSS_Complex_Selector|null + */ + final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$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 ); + } 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; + } + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; + } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new WP_CSS_Complex_Selector( $selectors ); + } + + /** + * Parses a subclass selector. + * + * > = | | + * + * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null + */ + private static function parse_subclass_selector( string $input, int &$offset ) { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $next_char = $input[ $offset ]; + return '.' === $next_char + ? self::parse_class_selector( $input, $offset ) + : ( + '#' === $next_char + ? self::parse_id_selector( $input, $offset ) + : ( '[' === $next_char + ? self::parse_attribute_selector( $input, $offset ) + : null + ) + ); + } + + + /* + * ------------------------ + * Selector partial parsing + * ------------------------ + * + * These functions consume parts of a selector string input when successful + * and return meaningful values to be used by selectors. */ - public static function parse( string $input, int &$offset ); -} -abstract class WP_CSS_Selector_Parser { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; @@ -212,7 +523,7 @@ public static function parse_whitespace( string $input, int &$offset ): bool { * * This implementation is not interested in the , a '#' delim token is not relevant for selectors. */ - protected static function parse_hash_token( string $input, int &$offset ): ?string { + final protected static function parse_hash_token( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) || '#' !== $input[ $offset ] ) { return null; } @@ -253,7 +564,7 @@ protected static function parse_hash_token( string $input, int &$offset ): ?stri * * @return string|null */ - protected static function parse_ident( string $input, int &$offset ): ?string { + final protected static function parse_ident( string $input, int &$offset ): ?string { if ( ! self::check_if_three_code_points_would_start_an_ident_sequence( $input, $offset ) ) { return null; } @@ -312,7 +623,7 @@ protected static function parse_ident( string $input, int &$offset ): ?string { * * @return string|null */ - protected static function parse_string( string $input, int &$offset ): ?string { + final protected static function parse_string( string $input, int &$offset ): ?string { if ( $offset + 1 >= strlen( $input ) ) { return null; } @@ -388,16 +699,24 @@ protected static function parse_string( string $input, int &$offset ): ?string { * @param int $offset * @return string|null */ - protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + final protected static function consume_escaped_codepoint( $input, &$offset ): ?string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); if ( $hex_length > 0 ) { + /** + * The 6-character hex string has a maximum value of 0xFFFFFF. + * It is likely to fit in an int value and not be a float. + * + * @var int + */ $codepoint_value = hexdec( substr( $input, $offset, $hex_length ) ); - // > A surrogate is a leading surrogate or a trailing surrogate. - // > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. - // > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. - // The surrogate ranges are adjacent, so the complete range is 0xD800..=0xDFFF, - // inclusive. + /* + * > A surrogate is a leading surrogate or a trailing surrogate. + * > A leading surrogate is a code point that is in the range U+D800 to U+DBFF, inclusive. + * > A trailing surrogate is a code point that is in the range U+DC00 to U+DFFF, inclusive. + * + * The surrogate ranges are adjacent, so the complete range is 0xD800 to 0xDFFF, inclusive. + */ $codepoint_char = ( 0 === $codepoint_value || $codepoint_value > self::UTF8_MAX_CODEPOINT_VALUE || @@ -428,13 +747,16 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string } /* - * Utiltities - * ========== + * --------------------------- + * Selector parsing utiltities + * --------------------------- * - * The following functions do not consume any input. + * The following functions are used for parsing but do not consume any input. */ /** + * Checks for two valid escape codepoints. + * * > 4.3.8. Check if two code points are a valid escape * > This section describes how to check if two code points are a valid escape. The algorithm described here can be called explicitly with two code points, or can be called with the input stream itself. In the latter case, the two code points in question are the current input code point and the next input code point, in that order. * > @@ -449,8 +771,12 @@ protected static function consume_escaped_codepoint( $input, &$offset ): ?string * https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape * * @todo this does not check whether the second codepoint is valid. + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next two codepoints are a valid escape, otherwise false. */ - protected static function next_two_are_valid_escape( string $input, int $offset ): bool { + private static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -458,7 +784,7 @@ protected static function next_two_are_valid_escape( string $input, int $offset } /** - * Check if the next code point is an "ident start code point". + * Checks if the next code point is an "ident start code point". * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -474,9 +800,13 @@ protected static function next_two_are_valid_escape( string $input, int $offset * > non-ASCII code point * > A code point with a value equal to or greater than U+0080 . * - * https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * @link https://www.w3.org/TR/css-syntax-3/#ident-start-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident start code point, otherwise false. */ - protected static function is_ident_start_codepoint( string $input, int $offset ): bool { + final protected static function is_ident_start_codepoint( string $input, int $offset ): bool { return ( '_' === $input[ $offset ] || ( 'a' <= $input[ $offset ] && $input[ $offset ] <= 'z' ) || @@ -486,7 +816,7 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) } /** - * Check if the next code point is an "ident code point". + * Checks if the next code point is an "ident code point". * * Caution! This method does not do any bounds checking, it should not be passed * a string with an offset that is out of bounds. @@ -496,15 +826,21 @@ protected static function is_ident_start_codepoint( string $input, int $offset ) * > digit * > A code point between U+0030 DIGIT ZERO (0) and U+0039 DIGIT NINE (9) inclusive. * - * https://www.w3.org/TR/css-syntax-3/#ident-code-point + * @link https://www.w3.org/TR/css-syntax-3/#ident-code-point + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next codepoint is an ident code point, otherwise false. */ - protected static function is_ident_codepoint( string $input, int $offset ): bool { + final protected static function is_ident_codepoint( string $input, int $offset ): bool { return '-' === $input[ $offset ] || ( '0' <= $input[ $offset ] && $input[ $offset ] <= '9' ) || self::is_ident_start_codepoint( $input, $offset ); } /** + * Checks if three code points would start an ident sequence. + * * > 4.3.9. Check if three code points would start an ident sequence * > This section describes how to check if three code points would start an ident sequence. The algorithm described here can be called explicitly with three code points, or can be called with the input stream itself. In the latter case, the three code points in question are the current input code point and the next two input code points, in that order. * > @@ -521,9 +857,13 @@ protected static function is_ident_codepoint( string $input, int $offset ): bool * > anything else * > Return false. * - * https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * @link https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier + * + * @param string $input The input string. + * @param int $offset The byte offset in the string. + * @return bool True if the next three codepoints would start an ident sequence, otherwise false. */ - protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + private static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } @@ -567,32 +907,21 @@ protected static function check_if_three_code_points_would_start_an_ident_sequen } } -final class WP_CSS_ID_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +interface IWP_CSS_Selector_Matcher { + /** + * @return bool + */ + public function matches( WP_HTML_Processor $processor ): bool; +} +final class WP_CSS_ID_Selector implements IWP_CSS_Selector_Matcher { /** @var string */ public $ident; - private function __construct( string $ident ) { + public function __construct( string $ident ) { $this->ident = $ident; } - /** - * Parse an ID selector - * - * > = - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - $ident = self::parse_hash_token( $input, $offset ); - if ( null === $ident ) { - return null; - } - return new self( $ident ); - } - public function matches( WP_HTML_Processor $processor ): bool { $id = $processor->get_attribute( 'id' ); if ( ! is_string( $id ) ) { @@ -606,50 +935,29 @@ public function matches( WP_HTML_Processor $processor ): bool { } } -final class WP_CSS_Class_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - return (bool) $processor->has_class( $this->ident ); - } - - /** @var string */ - public $ident; - - private function __construct( string $ident ) { - $this->ident = $ident; - } - - /** - * Parse a class selector - * - * > = '.' - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset + 1 >= strlen( $input ) || '.' !== $input[ $offset ] ) { - return null; - } - - $updated_offset = $offset + 1; - $result = self::parse_ident( $input, $updated_offset ); - - if ( null === $result ) { - return null; - } +final class WP_CSS_Class_Selector implements IWP_CSS_Selector_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + return (bool) $processor->has_class( $this->ident ); + } - $offset = $updated_offset; - return new self( $result ); + /** @var string */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; } } -final class WP_CSS_Type_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Type_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { + $tag_name = $processor->get_tag(); + if ( null === $tag_name ) { + return false; + } if ( '*' === $this->ident ) { return true; } - return 0 === strcasecmp( $processor->get_tag(), $this->ident ); + return 0 === strcasecmp( $tag_name, $this->ident ); } /** @@ -659,44 +967,12 @@ public function matches( WP_HTML_Processor $processor ): bool { */ public $ident; - private function __construct( string $ident ) { + public function __construct( string $ident ) { $this->ident = $ident; } - - /** - * Parse a type selector - * - * > = | ? '*' - * > = [ | '*' ]? '|' - * > = ? - * - * Namespaces (e.g. |div, *|div, or namespace|div) are not supported, - * so this selector effectively matches * or ident. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - if ( '*' === $input[ $offset ] ) { - ++$offset; - return new self( '*' ); - } - - $result = self::parse_ident( $input, $offset ); - if ( null === $result ) { - return null; - } - - return new self( $result ); - } } -final class WP_CSS_Attribute_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Attribute_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { $att_value = $processor->get_attribute( $this->name ); if ( null === $att_value ) { @@ -772,17 +1048,17 @@ public function matches( WP_HTML_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); + $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. - $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); + $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); if ( 0 === $length ) { return; } $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); + $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; } @@ -877,137 +1153,12 @@ private function whitespace_delimited_list( string $input ): Generator { * @param null|string $value * @param null|self::MODIFIER_* $modifier */ - private function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { $this->name = $name; $this->matcher = $matcher; $this->value = $value; $this->modifier = $modifier; } - - /** - * Parse a attribute selector - * - * > = '[' ']' | - * > '[' [ | ] ? ']' - * > = [ '~' | '|' | '^' | '$' | '*' ]? '=' - * > = i | s - * > = ? - * - * Namespaces are not supported, so attribute names are effectively identifiers. - * - * https://www.w3.org/TR/selectors/#grammar - * - * @return self|null - */ - public static function parse( string $input, int &$offset ): ?self { - // Need at least 3 bytes [x] - if ( $offset + 2 >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - - if ( '[' !== $input[ $updated_offset ] ) { - return null; - } - ++$updated_offset; - - self::parse_whitespace( $input, $updated_offset ); - $attr_name = self::parse_ident( $input, $updated_offset ); - if ( null === $attr_name ) { - return null; - } - self::parse_whitespace( $input, $updated_offset ); - - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new self( $attr_name ); - } - - // need to match at least `=x]` at this point - if ( $updated_offset + 3 >= strlen( $input ) ) { - return null; - } - - if ( '=' === $input[ $updated_offset ] ) { - ++$updated_offset; - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT; - } elseif ( '=' === $input[ $updated_offset + 1 ] ) { - switch ( $input[ $updated_offset ] ) { - case '~': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT; - $updated_offset += 2; - break; - case '|': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN; - $updated_offset += 2; - break; - case '^': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY; - $updated_offset += 2; - break; - case '$': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY; - $updated_offset += 2; - break; - case '*': - $attr_matcher = WP_CSS_Attribute_Selector::MATCH_CONTAINS; - $updated_offset += 2; - break; - default: - return null; - } - } else { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - $attr_val = - self::parse_string( $input, $updated_offset ) ?? - self::parse_ident( $input, $updated_offset ); - - if ( null === $attr_val ) { - return null; - } - - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - - $attr_modifier = null; - switch ( $input[ $updated_offset ] ) { - case 'i': - case 'I': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE; - ++$updated_offset; - break; - - case 's': - case 'S': - $attr_modifier = WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE; - ++$updated_offset; - break; - } - - if ( null !== $attr_modifier ) { - self::parse_whitespace( $input, $updated_offset ); - if ( $updated_offset >= strlen( $input ) ) { - return null; - } - } - - if ( ']' === $input[ $updated_offset ] ) { - $offset = $updated_offset + 1; - return new self( $attr_name, $attr_matcher, $attr_val, $attr_modifier ); - } - - return null; - } } /** @@ -1015,7 +1166,7 @@ public static function parse( string $input, int &$offset ): ?self { * * > = [ ? * ]! */ -final class WP_CSS_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Compound_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { @@ -1042,65 +1193,18 @@ public function matches( WP_HTML_Processor $processor ): bool { * @param WP_CSS_Type_Selector|null $type_selector * @param array $subclass_selectors */ - private function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { $this->type_selector = $type_selector; $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; } - - /** - * > = [ ? * ]! - */ - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $type_selector = WP_CSS_Type_Selector::parse( $input, $updated_offset ); - - $subclass_selectors = array(); - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - while ( null !== $last_parsed_subclass_selector ) { - $subclass_selectors[] = $last_parsed_subclass_selector; - $last_parsed_subclass_selector = self::parse_subclass_selector( $input, $updated_offset ); - } - - if ( null !== $type_selector || array() !== $subclass_selectors ) { - $offset = $updated_offset; - return new self( $type_selector, $subclass_selectors ); - } - return null; - } - - /** - * @return WP_CSS_ID_Selector|WP_CSS_Class_Selector|WP_CSS_Attribute_Selector|null - */ - private static function parse_subclass_selector( string $input, int &$offset ) { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $next_char = $input[ $offset ]; - return '.' === $next_char - ? WP_CSS_Class_Selector::parse( $input, $offset ) - : ( - '#' === $next_char - ? WP_CSS_ID_Selector::parse( $input, $offset ) - : ( '[' === $next_char - ? WP_CSS_Attribute_Selector::parse( $input, $offset ) - : null - ) - ); - } } - /** * This corresponds to in the grammar. * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector extends WP_CSS_Selector_Parser implements IWP_CSS_Selector_Parser, IWP_CSS_Selector_Matcher { +final class WP_CSS_Complex_Selector implements IWP_CSS_Selector_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { @@ -1120,7 +1224,7 @@ public function matches( WP_HTML_Processor $processor ): bool { /** * This only looks at breadcrumbs and can therefore only support type selectors. * - * @param array $selectors + * @param array $selectors * @param array $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { @@ -1133,7 +1237,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { /** @var self::COMBINATOR_* $combinator */ $combinator = $selectors[0]; - /** @var WP_CSS_Selector $selector */ + /** @var WP_CSS_Compound_Selector $selector */ $selector = $selectors[1]; switch ( $combinator ) { @@ -1166,78 +1270,18 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { const COMBINATOR_SUBSEQUENT_SIBLING = '~'; /** - * even indexes are WP_CSS_Selector, odd indexes are string combinators. + * 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 + * @var array */ public $selectors = array(); /** - * @param array $selectors + * @param array $selectors */ - private function __construct( array $selectors ) { + public function __construct( array $selectors ) { $this->selectors = array_reverse( $selectors ); } - - public static function parse( string $input, int &$offset ): ?self { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - if ( null === $selector ) { - return null; - } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - while ( $updated_offset < strlen( $input ) ) { - if ( - self::COMBINATOR_CHILD === $input[ $updated_offset ] || - self::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || - self::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] - ) { - $combinator = $input[ $updated_offset ]; - ++$updated_offset; - self::parse_whitespace( $input, $updated_offset ); - - // Failure to find a selector here is a parse error - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - } elseif ( $found_whitespace ) { - /* - * Whitespace is ambiguous, it could be a descendant combinator or - * insignificant whitespace. - */ - $selector = WP_CSS_Selector::parse( $input, $updated_offset ); - if ( null === $selector ) { - break; - } - $combinator = self::COMBINATOR_DESCENDANT; - } else { - break; - } - - if ( null === $selector ) { - return null; - } - - // `div > .className` is valid, but `.className > div` is not. - if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); - } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $selectors[] = $combinator; - $selectors[] = $selector; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - } - $offset = $updated_offset; - return new self( $selectors ); - } } diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 438dee4c47f4e..bee0f63824abd 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -642,7 +642,7 @@ public function get_unsupported_exception() { * @return Generator|null */ public function select_all( string $selectors ): ?Generator { - $select = WP_CSS_Selector_List::from_selectors( $selectors ); + $select = WP_CSS_Selector::from_selectors( $selectors ); if ( null === $select ) { return null; } diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelectors.php index 5983f91c5d9ba..19c1595253d84 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelectors.php @@ -11,6 +11,63 @@ * @group html-api */ class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { + private $test_class; + + public function set_up(): void { + parent::set_up(); + $this->test_class = new class() extends WP_CSS_Selector { + public function __construct() { + parent::__construct( array() ); + } + + /* + * Parsing + */ + public static function test_parse_ident( string $input, int &$offset ) { + return self::parse_ident( $input, $offset ); + } + + public static function test_parse_string( string $input, int &$offset ) { + return self::parse_string( $input, $offset ); + } + + public static function test_parse_type_selector( string $input, int &$offset ) { + return self::parse_type_selector( $input, $offset ); + } + + public static function test_parse_id_selector( string $input, int &$offset ) { + return self::parse_id_selector( $input, $offset ); + } + + public static function test_parse_class_selector( string $input, int &$offset ) { + return self::parse_class_selector( $input, $offset ); + } + + public static function test_parse_attribute_selector( string $input, int &$offset ) { + return self::parse_attribute_selector( $input, $offset ); + } + + public static function test_parse_compound_selector( string $input, int &$offset ) { + return self::parse_compound_selector( $input, $offset ); + } + + public static function test_parse_complex_selector( string $input, int &$offset ) { + return self::parse_complex_selector( $input, $offset ); + } + + /* + * Utilities + */ + public static function test_is_ident_codepoint( string $input, int $offset ) { + return self::is_ident_codepoint( $input, $offset ); + } + + public static function test_is_ident_start_codepoint( string $input, int $offset ) { + return self::is_ident_start_codepoint( $input, $offset ); + } + }; + } + /** * Data provider. * @@ -64,22 +121,10 @@ public static function data_idents(): array { * @ticket TBD */ public function test_is_ident_and_is_ident_start() { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - - public static function test_is_ident( string $input, int $offset ) { - return self::is_ident_codepoint( $input, $offset ); - } - - public static function test_is_ident_start( string $input, int $offset ) { - return self::is_ident_start_codepoint( $input, $offset ); - } - }; - - $this->assertFalse( $c::test_is_ident( '[', 0 ) ); - $this->assertFalse( $c::test_is_ident( ']', 0 ) ); - $this->assertFalse( $c::test_is_ident_start( '[', 0 ) ); - $this->assertFalse( $c::test_is_ident_start( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_codepoint( ']', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( '[', 0 ) ); + $this->assertFalse( $this->test_class::test_is_ident_start_codepoint( ']', 0 ) ); } /** @@ -88,15 +133,9 @@ public static function test_is_ident_start( string $input, int $offset ) { * @dataProvider data_idents */ public function test_parse_ident( string $input, ?string $expected = null, ?string $rest = null ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, &$offset ) { - return self::parse_ident( $input, $offset ); - } - }; $offset = 0; - $result = $c::test( $input, $offset ); + $result = $this->test_class::test_parse_ident( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -111,15 +150,8 @@ public static function test( string $input, &$offset ) { * @dataProvider data_strings */ public function test_parse_string( string $input, ?string $expected = null, ?string $rest = null ) { - $c = new class() extends WP_CSS_Selector_Parser { - public static function parse( string $input, int &$offset ) {} - public static function test( string $input, &$offset ) { - return self::parse_string( $input, $offset ); - } - }; - $offset = 0; - $result = $c::test( $input, $offset ); + $result = $this->test_class::test_parse_string( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -170,7 +202,7 @@ public static function data_strings(): array { */ public function test_parse_id( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_ID_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_id_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -204,7 +236,7 @@ public static function data_id_selectors(): array { */ public function test_parse_class( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Class_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_class_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -238,7 +270,7 @@ public static function data_class_selectors(): array { */ public function test_parse_type( string $input, ?string $expected = null, ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Type_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_type_selector( $input, $offset ); if ( null === $expected ) { $this->assertNull( $result ); } else { @@ -281,7 +313,7 @@ public function test_parse_attribute( ?string $rest = null ) { $offset = 0; - $result = WP_CSS_Attribute_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_attribute_selector( $input, $offset ); if ( null === $expected_name ) { $this->assertNull( $result ); } else { @@ -347,7 +379,7 @@ public static function data_attribute_selectors(): array { public function test_parse_selector() { $input = 'el.foo#bar[baz=quux] > .child'; $offset = 0; - $sel = WP_CSS_Selector::parse( $input, $offset ); + $sel = $this->test_class::test_parse_compound_selector( $input, $offset ); $this->assertSame( 'el', $sel->type_selector->ident ); $this->assertSame( 3, count( $sel->subclass_selectors ) ); @@ -365,8 +397,9 @@ public function test_parse_selector() { public function test_parse_empty_selector() { $input = ''; $offset = 0; - $result = WP_CSS_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_compound_selector( $input, $offset ); $this->assertNull( $result ); + $this->assertSame( 0, $offset ); } /** @@ -375,7 +408,7 @@ public function test_parse_empty_selector() { public function test_parse_complex_selector() { $input = 'el1 > .child#bar[baz=quux] , rest'; $offset = 0; - $sel = WP_CSS_Complex_Selector::parse( $input, $offset ); + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertSame( 3, count( $sel->selectors ) ); @@ -398,14 +431,14 @@ public function test_parse_complex_selector() { public function test_parse_invalid_complex_selector() { $input = 'el.foo#bar[baz=quux] > , rest'; $offset = 0; - $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertNull( $result ); } public function test_parse_empty_complex_selector() { $input = ''; $offset = 0; - $result = WP_CSS_Complex_Selector::parse( $input, $offset ); + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); $this->assertNull( $result ); } @@ -415,7 +448,7 @@ public function test_parse_empty_complex_selector() { */ public function test_parse_selector_list() { $input = 'el1 el2 el.foo#bar[baz=quux], rest'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNotNull( $result ); } @@ -424,7 +457,7 @@ public function test_parse_selector_list() { */ public function test_parse_invalid_selector_list() { $input = 'el,,'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } @@ -433,7 +466,7 @@ public function test_parse_invalid_selector_list() { */ public function test_parse_invalid_selector_list2() { $input = 'el!'; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } @@ -442,7 +475,7 @@ public function test_parse_invalid_selector_list2() { */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; - $result = WP_CSS_Selector_List::from_selectors( $input ); + $result = WP_CSS_Selector::from_selectors( $input ); $this->assertNull( $result ); } } From 6a6969f435d659f9fc26c208faf4495c18c60278 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 18:22:35 +0100 Subject: [PATCH 086/115] Rename files to align with class name --- .../{class-wp-css-selectors.php => class-wp-css-selector.php} | 0 src/wp-settings.php | 2 +- .../html-api/{wpCssSelectors.php => wpCssSelector-parsing.php} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/wp-includes/html-api/{class-wp-css-selectors.php => class-wp-css-selector.php} (100%) rename tests/phpunit/tests/html-api/{wpCssSelectors.php => wpCssSelector-parsing.php} (99%) diff --git a/src/wp-includes/html-api/class-wp-css-selectors.php b/src/wp-includes/html-api/class-wp-css-selector.php similarity index 100% rename from src/wp-includes/html-api/class-wp-css-selectors.php rename to src/wp-includes/html-api/class-wp-css-selector.php diff --git a/src/wp-settings.php b/src/wp-settings.php index 6c799d5c95140..cfdd9234b7003 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -265,7 +265,7 @@ require ABSPATH . WPINC . '/html-api/class-wp-html-stack-event.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor-state.php'; require ABSPATH . WPINC . '/html-api/class-wp-html-processor.php'; -require ABSPATH . WPINC . '/html-api/class-wp-css-selectors.php'; +require ABSPATH . WPINC . '/html-api/class-wp-css-selector.php'; require ABSPATH . WPINC . '/class-wp-http.php'; require ABSPATH . WPINC . '/class-wp-http-streams.php'; require ABSPATH . WPINC . '/class-wp-http-curl.php'; diff --git a/tests/phpunit/tests/html-api/wpCssSelectors.php b/tests/phpunit/tests/html-api/wpCssSelector-parsing.php similarity index 99% rename from tests/phpunit/tests/html-api/wpCssSelectors.php rename to tests/phpunit/tests/html-api/wpCssSelector-parsing.php index 19c1595253d84..4caa186158149 100644 --- a/tests/phpunit/tests/html-api/wpCssSelectors.php +++ b/tests/phpunit/tests/html-api/wpCssSelector-parsing.php @@ -10,7 +10,7 @@ * * @group html-api */ -class Tests_HtmlApi_WpCssSelectors extends WP_UnitTestCase { +class Tests_HtmlApi_WpCssSelector_Parsing extends WP_UnitTestCase { private $test_class; public function set_up(): void { From 27ca891846d35f6d18f0b0031147ece99bd11d9e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 21:00:08 +0100 Subject: [PATCH 087/115] Add html processor select test suite --- .../tests/html-api/wpHtmlProcessor-select.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/phpunit/tests/html-api/wpHtmlProcessor-select.php diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php new file mode 100644 index 0000000000000..e70dedcfcd3c4 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -0,0 +1,68 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket TBD + * + * @dataProvider data_selectors + */ + public function test_select( string $html, string $selector ) { + $processor = WP_HTML_Processor::create_full_parser( $html ); + $this->assertTrue( $processor->select( $selector ) ); + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'simple type' => array( '
', 'div' ), + 'any type' => array( '', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'simple attribute' => array( '
', '[att]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + 'complex any descendant' => array( '
', 'section *' ), + 'complex any child' => array( '
', 'section > *' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att~="bar"]' ), + ); + } + + /** + * @ticket TBD + */ + public function test_select_all() { + $processor = WP_HTML_Processor::create_full_parser( '

' ); + $count = 0; + foreach ( $processor->select_all( 'div, .x, svg>rect, #y' ) as $_ ) { + ++$count; + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + $this->assertSame( 4, $count ); + } +} From 9ff276965a60f3a7ccd89facc67cc9d4b267d90e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 3 Dec 2024 21:00:30 +0100 Subject: [PATCH 088/115] Fix select types --- src/wp-includes/html-api/class-wp-html-processor.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index bee0f63824abd..23ca6edc4ff7e 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -638,13 +638,15 @@ public function get_unsupported_exception() { /** * Use a selector to advance. * + * @todo _doing_it_wrong on null selector? + * * @param string $selectors - * @return Generator|null + * @return Generator */ public function select_all( string $selectors ): ?Generator { $select = WP_CSS_Selector::from_selectors( $selectors ); if ( null === $select ) { - return null; + return; } while ( $this->next_tag() ) { @@ -660,13 +662,10 @@ public function select_all( string $selectors ): ?Generator { * If iterating through matching elements, use `select_all` instead. * * @param string $selectors - * @return bool|null + * @return bool */ public function select( string $selectors ) { $selection = $this->select_all( $selectors ); - if ( null === $selection ) { - return null; - } foreach ( $selection as $_ ) { return true; } From d1a276b848ef8b9b5f954641ed762ad3d591b2cb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 13:55:57 +0100 Subject: [PATCH 089/115] Update class doc --- src/wp-includes/html-api/class-wp-css-selector.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index 7588eb72294bd..c27c81593059d 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -23,8 +23,7 @@ * A subset of the CSS selector grammar is supported. The grammar is defined in the CSS Syntax * specification, which is available at {@link https://www.w3.org/TR/selectors/#grammar}. * - * @todo Review this grammar, especially the complex selector for accurate support information. - * The supported grammar is: + * This class is rougly analogous to the in the grammar. The supported grammar is: * * = * = # @@ -43,6 +42,7 @@ * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * + * Note that this grammar has been adapted and does not support the full CSS selector grammar. * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) * - Class selectors (e.g. `.class-name`) @@ -61,11 +61,11 @@ * - Next sibling (`el + el`) * - Subsequent sibling (`el ~ el`) * - * Future ideas - * - Namespace type selectors could be implemented with select namespaces in order to - * select elements from a namespace, for example: - * - `svg|*` to select all SVG elements - * - `html|title` to select only HTML TITLE elements. + * Future ideas: + * - Namespace type selectors could be implemented with select namespaces in order to + * select elements from a namespace, for example: + * - `svg|*` to select all SVG elements + * - `html|title` to select only HTML TITLE elements. * * @since TBD * From 4909b569c067ab556e81b0cbcce087d3d1867676 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 16:00:36 +0100 Subject: [PATCH 090/115] Improve select_ method arguments, docs, implementation --- .../html-api/class-wp-html-processor.php | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 23ca6edc4ff7e..398c5c4fd096c 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -636,37 +636,64 @@ public function get_unsupported_exception() { } /** - * Use a selector to advance. + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } * - * @todo _doing_it_wrong on null selector? + * @since TBD * - * @param string $selectors - * @return Generator + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selectors ): ?Generator { - $select = WP_CSS_Selector::from_selectors( $selectors ); - if ( null === $select ) { + public function select_all( string $selector_string ): ?Generator { + $selector = WP_CSS_Selector::from_selectors( $selector_string ); + if ( null === $selector ) { return; } while ( $this->next_tag() ) { - if ( $select->matches( $this ) ) { + if ( $selector->matches( $this ) ) { yield; } } } /** - * Select the next matching element. + * Move to the next tag matching the provided CSS selector string. * - * If iterating through matching elements, use `select_all` instead. + * This method will stop at the next match. To progress through all matches, use + * the `select_all` method. * - * @param string $selectors - * @return bool + * @example + * + * $processor = WP_HTML_Processor::create_fragment( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. */ - public function select( string $selectors ) { - $selection = $this->select_all( $selectors ); - foreach ( $selection as $_ ) { + public function select( string $selector_string ) { + foreach ( $this->select_all( $selector_string ) as $_ ) { return true; } return false; From 1d45225e46b85b2e8e9f8091cf9aefac3c46c2eb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:08:58 +0100 Subject: [PATCH 091/115] Split classes into their own files Satisfy the 1-class-per-file requirement --- .../class-wp-css-attribute-selector.php | 190 +++++++++ .../html-api/class-wp-css-class-selector.php | 14 + .../class-wp-css-complex-selector.php | 88 ++++ .../class-wp-css-compound-selector.php | 39 ++ .../html-api/class-wp-css-id-selector.php | 22 + .../html-api/class-wp-css-selector.php | 389 +----------------- .../html-api/class-wp-css-type-selector.php | 25 ++ ...nterface-wp-css-html-processor-matcher.php | 8 + src/wp-settings.php | 7 + 9 files changed, 396 insertions(+), 386 deletions(-) create mode 100644 src/wp-includes/html-api/class-wp-css-attribute-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-class-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-complex-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-compound-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-id-selector.php create mode 100644 src/wp-includes/html-api/class-wp-css-type-selector.php create mode 100644 src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php new file mode 100644 index 0000000000000..be7332c85b72d --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -0,0 +1,190 @@ +get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + if ( true === $att_value ) { + $att_value = ''; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) + ); + } + + throw new Exception( 'Unreachable' ); + } + + /** + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); + if ( 0 === $length ) { + return; + } + + $value = substr( $input, $offset, $length ); + $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); + + yield $value; + } + } + + /** + * [att=val] + * Represents an element with the att attribute whose value is exactly "val". + */ + const MATCH_EXACT = 'MATCH_EXACT'; + + /** + * [attr~=value] + * Represents elements with an attribute name of attr whose value is a + * whitespace-separated list of words, one of which is exactly value. + */ + const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; + + /** + * [attr|=value] + * Represents elements with an attribute name of attr whose value can be exactly value or + * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for + * language subcode matches. + */ + const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; + + /** + * [attr^=value] + * Represents elements with an attribute name of attr whose value is prefixed (preceded) + * by value. + */ + const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; + + /** + * [attr$=value] + * Represents elements with an attribute name of attr whose value is suffixed (followed) + * by value. + */ + const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; + + /** + * [attr*=value] + * Represents elements with an attribute name of attr whose value contains at least one + * occurrence of value within the string. + */ + const MATCH_CONTAINS = 'MATCH_CONTAINS'; + + /** + * Modifier for case sensitive matching + * [attr=value s] + */ + const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; + + /** + * Modifier for case insensitive matching + * [attr=value i] + */ + const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; + + + /** + * The attribute name. + * + * @var string + */ + public $name; + + /** + * The attribute matcher. + * + * @var null|self::MATCH_* + */ + public $matcher; + + /** + * The attribute value. + * + * @var string|null + */ + public $value; + + /** + * The attribute modifier. + * + * @var null|self::MODIFIER_* + */ + public $modifier; + + /** + * @param string $name + * @param null|self::MATCH_* $matcher + * @param null|string $value + * @param null|self::MODIFIER_* $modifier + */ + public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { + $this->name = $name; + $this->matcher = $matcher; + $this->value = $value; + $this->modifier = $modifier; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php new file mode 100644 index 0000000000000..c4f858d4a05d9 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -0,0 +1,14 @@ +has_class( $this->ident ); + } + + /** @var string */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} 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 new file mode 100644 index 0000000000000..520f3bf3d8fde --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector.php @@ -0,0 +1,88 @@ + in the grammar. + * + * > = [ ? ] * + */ +final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + // First selector must match this location. + if ( ! $this->selectors[0]->matches( $processor ) ) { + return false; + } + + if ( count( $this->selectors ) === 1 ) { + return true; + } + + /** @var array $breadcrumbs */ + $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); + $selectors = array_slice( $this->selectors, 1 ); + return $this->explore_matches( $selectors, $breadcrumbs ); + } + + /** + * This only looks at breadcrumbs and can therefore only support type selectors. + * + * @param array $selectors + * @param array $breadcrumbs + */ + private function explore_matches( array $selectors, array $breadcrumbs ): bool { + if ( array() === $selectors ) { + return true; + } + if ( array() === $breadcrumbs ) { + return false; + } + + /** @var self::COMBINATOR_* $combinator */ + $combinator = $selectors[0]; + /** @var WP_CSS_Compound_Selector $selector */ + $selector = $selectors[1]; + + switch ( $combinator ) { + case self::COMBINATOR_CHILD: + if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { + return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); + } + return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + + 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->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { + $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); + if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { + return true; + } + } + } + return false; + + default: + throw new Exception( "Combinator '{$combinator}' is not supported yet." ); + } + } + + 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 new file mode 100644 index 0000000000000..1162aaef78c1e --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-compound-selector.php @@ -0,0 +1,39 @@ + in the grammar. + * + * > = [ ? * ]! + */ +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Processor_Matcher { + public function matches( WP_HTML_Processor $processor ): bool { + if ( $this->type_selector ) { + if ( ! $this->type_selector->matches( $processor ) ) { + return false; + } + } + if ( null !== $this->subclass_selectors ) { + foreach ( $this->subclass_selectors as $subclass_selector ) { + if ( ! $subclass_selector->matches( $processor ) ) { + return false; + } + } + } + return true; + } + + /** @var WP_CSS_Type_Selector|null */ + public $type_selector; + + /** @var array|null */ + public $subclass_selectors; + + /** + * @param WP_CSS_Type_Selector|null $type_selector + * @param array $subclass_selectors + */ + public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { + $this->type_selector = $type_selector; + $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php new file mode 100644 index 0000000000000..cc0589327c829 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -0,0 +1,22 @@ +ident = $ident; + } + + public function matches( WP_HTML_Processor $processor ): bool { + $id = $processor->get_attribute( 'id' ); + if ( ! is_string( $id ) ) { + return false; + } + + $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + return $case_insensitive + ? 0 === strcasecmp( $id, $this->ident ) + : $processor->get_attribute( 'id' ) === $this->ident; + } +} diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index c27c81593059d..b776bad66146b 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -1,10 +1,6 @@ -get_token_type() !== '#tag' ) { return false; @@ -906,382 +902,3 @@ private static function check_if_three_code_points_would_start_an_ident_sequence return self::is_ident_start_codepoint( $input, $offset ); } } - -interface IWP_CSS_Selector_Matcher { - /** - * @return bool - */ - public function matches( WP_HTML_Processor $processor ): bool; -} - -final class WP_CSS_ID_Selector implements IWP_CSS_Selector_Matcher { - /** @var string */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } - - public function matches( WP_HTML_Processor $processor ): bool { - $id = $processor->get_attribute( 'id' ); - if ( ! is_string( $id ) ) { - return false; - } - - $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); - return $case_insensitive - ? 0 === strcasecmp( $id, $this->ident ) - : $processor->get_attribute( 'id' ) === $this->ident; - } -} - -final class WP_CSS_Class_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - return (bool) $processor->has_class( $this->ident ); - } - - /** @var string */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } -} - -final class WP_CSS_Type_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - $tag_name = $processor->get_tag(); - if ( null === $tag_name ) { - return false; - } - if ( '*' === $this->ident ) { - return true; - } - return 0 === strcasecmp( $tag_name, $this->ident ); - } - - /** - * @var string - * - * The type identifier string or '*'. - */ - public $ident; - - public function __construct( string $ident ) { - $this->ident = $ident; - } -} - -final class WP_CSS_Attribute_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - $att_value = $processor->get_attribute( $this->name ); - if ( null === $att_value ) { - return false; - } - - if ( null === $this->value ) { - return true; - } - - if ( true === $att_value ) { - $att_value = ''; - } - - $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; - - switch ( $this->matcher ) { - case self::MATCH_EXACT: - return $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value; - - case self::MATCH_ONE_OF_EXACT: - foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { - if ( - $case_insensitive - ? 0 === strcasecmp( $val, $this->value ) - : $val === $this->value - ) { - return true; - } - } - return false; - - case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: - // Attempt the full match first - if ( - $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value - ) { - return true; - } - - // Partial match - if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { - return false; - } - - $starts_with = "{$this->value}-"; - return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); - - case self::MATCH_PREFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); - - case self::MATCH_SUFFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); - - case self::MATCH_CONTAINS: - return false !== ( - $case_insensitive - ? stripos( $att_value, $this->value ) - : strpos( $att_value, $this->value ) - ); - } - - throw new Exception( 'Unreachable' ); - } - - /** - * @param string $input - * - * @return Generator - */ - private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); - - while ( $offset < strlen( $input ) ) { - // Find the byte length until the next boundary. - $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); - if ( 0 === $length ) { - return; - } - - $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); - - yield $value; - } - } - - /** - * [att=val] - * Represents an element with the att attribute whose value is exactly "val". - */ - const MATCH_EXACT = 'MATCH_EXACT'; - - /** - * [attr~=value] - * Represents elements with an attribute name of attr whose value is a - * whitespace-separated list of words, one of which is exactly value. - */ - const MATCH_ONE_OF_EXACT = 'MATCH_ONE_OF_EXACT'; - - /** - * [attr|=value] - * Represents elements with an attribute name of attr whose value can be exactly value or - * can begin with value immediately followed by a hyphen, - (U+002D). It is often used for - * language subcode matches. - */ - const MATCH_EXACT_OR_EXACT_WITH_HYPHEN = 'MATCH_EXACT_OR_EXACT_WITH_HYPHEN'; - - /** - * [attr^=value] - * Represents elements with an attribute name of attr whose value is prefixed (preceded) - * by value. - */ - const MATCH_PREFIXED_BY = 'MATCH_PREFIXED_BY'; - - /** - * [attr$=value] - * Represents elements with an attribute name of attr whose value is suffixed (followed) - * by value. - */ - const MATCH_SUFFIXED_BY = 'MATCH_SUFFIXED_BY'; - - /** - * [attr*=value] - * Represents elements with an attribute name of attr whose value contains at least one - * occurrence of value within the string. - */ - const MATCH_CONTAINS = 'MATCH_CONTAINS'; - - /** - * Modifier for case sensitive matching - * [attr=value s] - */ - const MODIFIER_CASE_SENSITIVE = 'case-sensitive'; - - /** - * Modifier for case insensitive matching - * [attr=value i] - */ - const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; - - - /** - * The attribute name. - * - * @var string - */ - public $name; - - /** - * The attribute matcher. - * - * @var null|self::MATCH_* - */ - public $matcher; - - /** - * The attribute value. - * - * @var string|null - */ - public $value; - - /** - * The attribute modifier. - * - * @var null|self::MODIFIER_* - */ - public $modifier; - - /** - * @param string $name - * @param null|self::MATCH_* $matcher - * @param null|string $value - * @param null|self::MODIFIER_* $modifier - */ - public function __construct( string $name, ?string $matcher = null, ?string $value = null, ?string $modifier = null ) { - $this->name = $name; - $this->matcher = $matcher; - $this->value = $value; - $this->modifier = $modifier; - } -} - -/** - * This corresponds to in the grammar. - * - * > = [ ? * ]! - */ -final class WP_CSS_Compound_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - if ( $this->type_selector ) { - if ( ! $this->type_selector->matches( $processor ) ) { - return false; - } - } - if ( null !== $this->subclass_selectors ) { - foreach ( $this->subclass_selectors as $subclass_selector ) { - if ( ! $subclass_selector->matches( $processor ) ) { - return false; - } - } - } - return true; - } - - /** @var WP_CSS_Type_Selector|null */ - public $type_selector; - - /** @var array|null */ - public $subclass_selectors; - - /** - * @param WP_CSS_Type_Selector|null $type_selector - * @param array $subclass_selectors - */ - public function __construct( ?WP_CSS_Type_Selector $type_selector, array $subclass_selectors ) { - $this->type_selector = $type_selector; - $this->subclass_selectors = array() === $subclass_selectors ? null : $subclass_selectors; - } -} - -/** - * This corresponds to in the grammar. - * - * > = [ ? ] * - */ -final class WP_CSS_Complex_Selector implements IWP_CSS_Selector_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { - // First selector must match this location. - if ( ! $this->selectors[0]->matches( $processor ) ) { - return false; - } - - if ( count( $this->selectors ) === 1 ) { - return true; - } - - /** @var array $breadcrumbs */ - $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); - $selectors = array_slice( $this->selectors, 1 ); - return $this->explore_matches( $selectors, $breadcrumbs ); - } - - /** - * This only looks at breadcrumbs and can therefore only support type selectors. - * - * @param array $selectors - * @param array $breadcrumbs - */ - private function explore_matches( array $selectors, array $breadcrumbs ): bool { - if ( array() === $selectors ) { - return true; - } - if ( array() === $breadcrumbs ) { - return false; - } - - /** @var self::COMBINATOR_* $combinator */ - $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector $selector */ - $selector = $selectors[1]; - - switch ( $combinator ) { - case self::COMBINATOR_CHILD: - if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { - return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); - } - return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); - - 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->ident || strcasecmp( $breadcrumbs[ $i ], $selector->type_selector->ident ) === 0 ) { - $next_crumbs = array_slice( $breadcrumbs, $i + 1 ); - if ( $this->explore_matches( array_slice( $selectors, 2 ), $next_crumbs ) ) { - return true; - } - } - } - return false; - - default: - throw new Exception( "Combinator '{$combinator}' is not supported yet." ); - } - } - - 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-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php new file mode 100644 index 0000000000000..a2dcd16521cb5 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -0,0 +1,25 @@ +get_tag(); + if ( null === $tag_name ) { + return false; + } + if ( '*' === $this->ident ) { + return true; + } + return 0 === strcasecmp( $tag_name, $this->ident ); + } + + /** + * @var string + * + * The type identifier string or '*'. + */ + public $ident; + + public function __construct( string $ident ) { + $this->ident = $ident; + } +} diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php new file mode 100644 index 0000000000000..2ae29413b35d2 --- /dev/null +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -0,0 +1,8 @@ + Date: Wed, 4 Dec 2024 18:09:17 +0100 Subject: [PATCH 092/115] Remove redundant see phpdoc annotations --- src/wp-includes/html-api/class-wp-css-selector.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-selector.php index b776bad66146b..487c100ab47e4 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-selector.php @@ -67,11 +67,10 @@ * * @access private * - * @see {@link https://www.w3.org/TR/css-syntax-3/} - * @see {@link https://www.w3.org/tr/selectors/} - * @see {@link https://www.w3.org/TR/selectors-api2/} - * @see {@link https://www.w3.org/TR/selectors-4/} - * + * @link https://www.w3.org/TR/css-syntax-3/ + * @link https://www.w3.org/tr/selectors/ + * @link https://www.w3.org/TR/selectors-api2/ + * @link https://www.w3.org/TR/selectors-4/ */ class WP_CSS_Selector implements WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { From 0c53c422de2f40206b9322f8f0ae3beaf85b5e4b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:28:54 +0100 Subject: [PATCH 093/115] Fix docs and return type on select_all --- src/wp-includes/html-api/class-wp-html-processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 398c5c4fd096c..9f7a43acaebbd 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -657,7 +657,7 @@ public function get_unsupported_exception() { * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selector_string ): ?Generator { + public function select_all( string $selector_string ): Generator { $selector = WP_CSS_Selector::from_selectors( $selector_string ); if ( null === $selector ) { return; @@ -674,7 +674,7 @@ public function select_all( string $selector_string ): ?Generator { * Move to the next tag matching the provided CSS selector string. * * This method will stop at the next match. To progress through all matches, use - * the `select_all` method. + * the {@see WP_HTML_Processor::select_all()} method. * * @example * From d966e9ad7fdc9270fded62abb9e32923ced79d61 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 18:31:05 +0100 Subject: [PATCH 094/115] Improve html select test docs --- tests/phpunit/tests/html-api/wpHtmlProcessor-select.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index e70dedcfcd3c4..c3a1e4121ecab 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -1,6 +1,9 @@ Date: Wed, 4 Dec 2024 19:40:23 +0100 Subject: [PATCH 095/115] Add select support to tag processor Split up main CSS selector class and support more restricted selectors in the tag processor. --- .../class-wp-css-attribute-selector.php | 12 +- .../html-api/class-wp-css-class-selector.php | 4 +- .../class-wp-css-complex-selector-list.php | 165 ++++++++++++++++++ ...> class-wp-css-compound-selector-list.php} | 126 ++++--------- .../class-wp-css-compound-selector.php | 4 +- .../html-api/class-wp-css-id-selector.php | 7 +- .../html-api/class-wp-css-type-selector.php | 4 +- .../html-api/class-wp-html-processor.php | 11 +- .../html-api/class-wp-html-tag-processor.php | 69 ++++++++ ...face-wp-css-html-tag-processor-matcher.php | 8 + src/wp-settings.php | 4 +- .../html-api/wpCssComplexSelectorList.php | 107 ++++++++++++ ...sing.php => wpCssCompoundSelectorList.php} | 59 +------ .../tests/html-api/wpHtmlProcessor-select.php | 10 ++ .../html-api/wpHtmlTagProcessor-select.php | 92 ++++++++++ 15 files changed, 520 insertions(+), 162 deletions(-) create mode 100644 src/wp-includes/html-api/class-wp-css-complex-selector-list.php rename src/wp-includes/html-api/{class-wp-css-selector.php => class-wp-css-compound-selector-list.php} (87%) create mode 100644 src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php create mode 100644 tests/phpunit/tests/html-api/wpCssComplexSelectorList.php rename tests/phpunit/tests/html-api/{wpCssSelector-parsing.php => wpCssCompoundSelectorList.php} (89%) create mode 100644 tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index be7332c85b72d..76ccdf3804b36 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,7 +1,9 @@ get_attribute( $this->name ); if ( null === $att_value ) { return false; @@ -76,17 +78,17 @@ public function matches( WP_HTML_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { - $offset = strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS ); + $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. - $length = strcspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset ); + $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); if ( 0 === $length ) { return; } $value = substr( $input, $offset, $length ); - $offset += $length + strspn( $input, WP_CSS_Selector::WHITESPACE_CHARACTERS, $offset + $length ); + $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; } diff --git a/src/wp-includes/html-api/class-wp-css-class-selector.php b/src/wp-includes/html-api/class-wp-css-class-selector.php index c4f858d4a05d9..c3e7ced008a6e 100644 --- a/src/wp-includes/html-api/class-wp-css-class-selector.php +++ b/src/wp-includes/html-api/class-wp-css-class-selector.php @@ -1,7 +1,7 @@ has_class( $this->ident ); } 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 new file mode 100644 index 0000000000000..f3769a035f6e5 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-css-complex-selector-list.php @@ -0,0 +1,165 @@ + in the grammar. See {@see WP_CSS_Compound_Selector_List} for more details on the grammar. + * + * This class supports the same selector syntax as {@see WP_CSS_Compound_Selector_List} as well as: + * - The following combinators: + * - Next sibling (`el + el`) + * - Subsequent sibling (`el ~ el`) + * + * @since TBD + * + * @access private + */ +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { + /** + * Takes a CSS selector string and returns an instance of itself or `null` if the selector + * string is invalid or unsupported. + * + * @since TBD + * + * @param string $input CSS selectors. + * @return static|null + */ + public static function from_selectors( string $input ) { + // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + $input = trim( $input, " \t\r\n\r" ); + + if ( '' === $input ) { + return null; + } + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + $offset = 0; + + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + self::parse_whitespace( $input, $offset ); + + $selectors = array( $selector ); + while ( $offset < strlen( $input ) ) { + // Each loop should stop on a `,` selector list delimiter. + if ( ',' !== $input[ $offset ] ) { + return null; + } + ++$offset; + self::parse_whitespace( $input, $offset ); + $selector = self::parse_complex_selector( $input, $offset ); + if ( null === $selector ) { + return null; + } + $selectors[] = $selector; + self::parse_whitespace( $input, $offset ); + } + + return new self( $selectors ); + } + + /* + * ------------------------------ + * Selector parsing functionality + * ------------------------------ + */ + + /** + * Parses a complex selector. + * + * > = [ ? ]* + * + * @return WP_CSS_Complex_Selector|null + */ + final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { + if ( $offset >= strlen( $input ) ) { + return null; + } + + $updated_offset = $offset; + $selector = self::parse_compound_selector( $input, $updated_offset ); + if ( null === $selector ) { + return null; + } + + $selectors = array( $selector ); + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + while ( $updated_offset < strlen( $input ) ) { + if ( + WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || + WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] + ) { + $combinator = $input[ $updated_offset ]; + ++$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 ); + } 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; + } + $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; + } else { + break; + } + + if ( null === $selector ) { + return null; + } + + // `div > .className` is valid, but `.className > div` is not. + if ( $has_preceding_subclass_selector ) { + throw new Exception( 'Unsupported non-final subclass selector.' ); + } + $has_preceding_subclass_selector = null !== $selector->subclass_selectors; + + $selectors[] = $combinator; + $selectors[] = $selector; + + $found_whitespace = self::parse_whitespace( $input, $updated_offset ); + } + $offset = $updated_offset; + return new WP_CSS_Complex_Selector( $selectors ); + } +} diff --git a/src/wp-includes/html-api/class-wp-css-selector.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php similarity index 87% rename from src/wp-includes/html-api/class-wp-css-selector.php rename to src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 487c100ab47e4..2aae51d671f6b 100644 --- a/src/wp-includes/html-api/class-wp-css-selector.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -1,6 +1,6 @@ in the grammar. The supported grammar is: + * This class is analogous to in the grammar. The supported grammar is: * * = * = # @@ -38,6 +40,10 @@ * * @link https://www.w3.org/TR/selectors/#grammar Refer to the grammar for more details. * + * This class of selectors does not support "complex" selectors. That is any selector with a + * combinator such as descendent (`.ancestor .descendant`) or child (`.parent > .child`). + * See {@see WP_CSS_Complex_Selector_List} for support of some combinators. + * * Note that this grammar has been adapted and does not support the full CSS selector grammar. * Supported selector syntax: * - Type selectors (tag names, e.g. `div`) @@ -50,12 +56,10 @@ * - child (`el > .child`) * * Unsupported selector syntax: - * - Pseudo-element selectors (e.g. `::before`) - * - Pseudo-class selectors (e.g. `:hover` or `:nth-child(2)`) - * - Namespace prefixes (e.g. `svg|title` or `[xlink|href]`) - * - The following combinators: - * - Next sibling (`el + el`) - * - Subsequent sibling (`el ~ el`) + * - Pseudo-element selectors (`::before`) + * - Pseudo-class selectors (`:hover` or `:nth-child(2)`) + * - Namespace prefixes (`svg|title` or `[xlink|href]`) + * - No combinators are supported (descendant, child, next sibling, subsequent sibling) * * Future ideas: * - Namespace type selectors could be implemented with select namespaces in order to @@ -72,8 +76,12 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Selector implements WP_CSS_HTML_Processor_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { +class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { + /** + * @param WP_HTML_Tag_Processor $processor + * @return bool + */ + public function matches( $processor ): bool { if ( $processor->get_token_type() !== '#tag' ) { return false; } @@ -87,14 +95,16 @@ public function matches( WP_HTML_Processor $processor ): bool { } /** - * @var array + * Array of selectors. + * + * @var array */ private $selectors; /** * Constructor. * - * @param array $selectors + * @param array $selectors Array of selectors. */ protected function __construct( array $selectors ) { $this->selectors = $selectors; @@ -107,10 +117,9 @@ protected function __construct( array $selectors ) { * @since TBD * * @param string $input CSS selectors. - * @return self|null + * @return static|null */ - public static function from_selectors( string $input ): ?self { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + public static function from_selectors( string $input ) { $input = trim( $input, " \t\r\n\r" ); if ( '' === $input ) { @@ -132,7 +141,7 @@ public static function from_selectors( string $input ): ?self { $offset = 0; - $selector = self::parse_complex_selector( $input, $offset ); + $selector = self::parse_compound_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -146,7 +155,7 @@ public static function from_selectors( string $input ): ?self { } ++$offset; self::parse_whitespace( $input, $offset ); - $selector = self::parse_complex_selector( $input, $offset ); + $selector = self::parse_compound_selector( $input, $offset ); if ( null === $selector ) { return null; } @@ -391,73 +400,6 @@ final protected static function parse_compound_selector( string $input, int &$of return null; } - /** - * Parses a complex selector. - * - * > = [ ? ]* - * - * @return WP_CSS_Complex_Selector|null - */ - final protected static function parse_complex_selector( string $input, int &$offset ): ?WP_CSS_Complex_Selector { - if ( $offset >= strlen( $input ) ) { - return null; - } - - $updated_offset = $offset; - $selector = self::parse_compound_selector( $input, $updated_offset ); - if ( null === $selector ) { - return null; - } - - $selectors = array( $selector ); - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - while ( $updated_offset < strlen( $input ) ) { - if ( - WP_CSS_Complex_Selector::COMBINATOR_CHILD === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_NEXT_SIBLING === $input[ $updated_offset ] || - WP_CSS_Complex_Selector::COMBINATOR_SUBSEQUENT_SIBLING === $input[ $updated_offset ] - ) { - $combinator = $input[ $updated_offset ]; - ++$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 ); - } 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; - } - $combinator = WP_CSS_Complex_Selector::COMBINATOR_DESCENDANT; - } else { - break; - } - - if ( null === $selector ) { - return null; - } - - // `div > .className` is valid, but `.className > div` is not. - if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); - } - $has_preceding_subclass_selector = null !== $selector->subclass_selectors; - - $selectors[] = $combinator; - $selectors[] = $selector; - - $found_whitespace = self::parse_whitespace( $input, $updated_offset ); - } - $offset = $updated_offset; - return new WP_CSS_Complex_Selector( $selectors ); - } - /** * Parses a subclass selector. * @@ -496,7 +438,7 @@ private static function parse_subclass_selector( string $input, int &$offset ) { const UTF8_MAX_CODEPOINT_VALUE = 0x10FFFF; const WHITESPACE_CHARACTERS = " \t\r\n\f"; - public static function parse_whitespace( string $input, int &$offset ): bool { + final public static function parse_whitespace( string $input, int &$offset ): bool { $length = strspn( $input, self::WHITESPACE_CHARACTERS, $offset ); $advanced = $length > 0; $offset += $length; @@ -692,9 +634,9 @@ final protected static function parse_string( string $input, int &$offset ): ?st * * @param string $input * @param int $offset - * @return string|null + * @return string */ - final protected static function consume_escaped_codepoint( $input, &$offset ): ?string { + final protected static function consume_escaped_codepoint( $input, &$offset ): string { $hex_length = strspn( $input, '0123456789abcdefABCDEF', $offset, 6 ); if ( $hex_length > 0 ) { /** @@ -771,7 +713,7 @@ final protected static function consume_escaped_codepoint( $input, &$offset ): ? * @param int $offset The byte offset in the string. * @return bool True if the next two codepoints are a valid escape, otherwise false. */ - private static function next_two_are_valid_escape( string $input, int $offset ): bool { + final protected static function next_two_are_valid_escape( string $input, int $offset ): bool { if ( $offset + 1 >= strlen( $input ) ) { return false; } @@ -858,7 +800,7 @@ final protected static function is_ident_codepoint( string $input, int $offset ) * @param int $offset The byte offset in the string. * @return bool True if the next three codepoints would start an ident sequence, otherwise false. */ - private static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { + final protected static function check_if_three_code_points_would_start_an_ident_sequence( string $input, int $offset ): bool { if ( $offset >= strlen( $input ) ) { return false; } 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 1162aaef78c1e..e64695abe9ab3 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 @@ -5,8 +5,8 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Processor_Matcher { - public function matches( WP_HTML_Processor $processor ): bool { +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { + public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { return false; diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index cc0589327c829..83339ff839317 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ ident = $ident; } - public function matches( WP_HTML_Processor $processor ): bool { + public function matches( WP_HTML_Tag_Processor $processor ): bool { $id = $processor->get_attribute( 'id' ); if ( ! is_string( $id ) ) { return false; } - $case_insensitive = method_exists( $processor, 'is_quirks_mode' ) && $processor->is_quirks_mode(); + $case_insensitive = $processor->is_quirks_mode(); + return $case_insensitive ? 0 === strcasecmp( $id, $this->ident ) : $processor->get_attribute( 'id' ) === $this->ident; diff --git a/src/wp-includes/html-api/class-wp-css-type-selector.php b/src/wp-includes/html-api/class-wp-css-type-selector.php index a2dcd16521cb5..c65adce14047d 100644 --- a/src/wp-includes/html-api/class-wp-css-type-selector.php +++ b/src/wp-includes/html-api/class-wp-css-type-selector.php @@ -1,7 +1,7 @@ get_tag(); if ( null === $tag_name ) { return false; diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index 9f7a43acaebbd..bbca730279876 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -657,9 +657,14 @@ public function get_unsupported_exception() { * @param string $selector_string Selector string. * @return Generator A generator pausing on each tag matching the selector. */ - public function select_all( string $selector_string ): Generator { - $selector = WP_CSS_Selector::from_selectors( $selector_string ); + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Complex_Selector_List::from_selectors( $selector_string ); if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); return; } @@ -692,7 +697,7 @@ public function select_all( string $selector_string ): Generator { * @param string $selector_string * @return bool True if a matching tag was found, otherwise false. */ - public function select( string $selector_string ) { + public function select( string $selector_string ): bool { foreach ( $this->select_all( $selector_string ) as $_ ) { return true; } diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 7dadbc1bebdb2..a7633291b6bb2 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -860,6 +860,75 @@ public function change_parsing_namespace( string $new_namespace ): bool { return true; } + /** + * Progress through a document pausing on tags matching the provided CSS selector string. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * foreach ( $processor->select_all( 'meta[property^="og:" i]' ) as $_ ) { + * // Loop is entered twice. + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'property' ), // string(7) "og:type" / string(14) "og:description" + * $processor->get_attribute( 'content' ), // string(7) "website" / string(11) "An example." + * ); + * } + * + * @since TBD + * + * @param string $selector_string Selector string. + * @return Generator A generator pausing on each tag matching the selector. + */ + public function select_all( $selector_string ): Generator { + $selector = WP_CSS_Compound_Selector_List::from_selectors( $selector_string ); + if ( null === $selector ) { + _doing_it_wrong( + __METHOD__, + sprintf( 'Received unsupported or invalid selector "%s".', $selector_string ), + '6.8' + ); + return; + } + + while ( $this->next_tag() ) { + if ( $selector->matches( $this ) ) { + yield; + } + } + } + + /** + * Move to the next tag matching the provided CSS selector string. + * + * This method will stop at the next match. To progress through all matches, use + * the {@see WP_HTML_Tag_Processor::select_all()} method. + * + * @example + * + * $processor = new WP_HTML_Tag_Processor( + * 'Example' + * ); + * $processor->select( 'meta[charset]' ); + * var_dump( + * $processor->get_tag(), // string(4) "META" + * $processor->get_attribute( 'charset' ), // string(5) "utf-8" + * ); + * + * @since TBD + * + * @param string $selector_string + * @return bool True if a matching tag was found, otherwise false. + */ + public function select( string $selector_string ): bool { + foreach ( $this->select_all( $selector_string ) as $_ ) { + return true; + } + return false; + } + /** * Finds the next tag matching the $query. * diff --git a/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php new file mode 100644 index 0000000000000..73d108150bb95 --- /dev/null +++ b/src/wp-includes/html-api/interface-wp-css-html-tag-processor-matcher.php @@ -0,0 +1,8 @@ +test_class = new class() extends WP_CSS_Complex_Selector_List { + public function __construct() { + parent::__construct( array() ); + } + + public static function test_parse_complex_selector( string $input, int &$offset ) { + return self::parse_complex_selector( $input, $offset ); + } + }; + } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector() { + $input = 'el1 > .child#bar[baz=quux] , rest'; + $offset = 0; + $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); + + $this->assertSame( 3, count( $sel->selectors ) ); + + $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); + $this->assertNull( $sel->selectors[2]->subclass_selectors ); + + $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[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( ', rest', substr( $input, $offset ) ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector() { + $input = 'el.foo#bar[baz=quux] > , rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_complex_selector() { + $input = ''; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_complex_selector_list() { + $input = 'el1 el2 el.foo#bar[baz=quux], second > selector'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNotNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list() { + $input = 'el,,'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_invalid_selector_list2() { + $input = 'el!'; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } + + /** + * @ticket TBD + */ + public function test_parse_empty_selector_list() { + $input = " \t \t\n\r\f"; + $result = WP_CSS_Complex_Selector_List::from_selectors( $input ); + $this->assertNull( $result ); + } +} diff --git a/tests/phpunit/tests/html-api/wpCssSelector-parsing.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php similarity index 89% rename from tests/phpunit/tests/html-api/wpCssSelector-parsing.php rename to tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 4caa186158149..d94b61d49c14e 100644 --- a/tests/phpunit/tests/html-api/wpCssSelector-parsing.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -10,12 +10,12 @@ * * @group html-api */ -class Tests_HtmlApi_WpCssSelector_Parsing extends WP_UnitTestCase { +class Tests_HtmlApi_WpCssCompoundSelectorList extends WP_UnitTestCase { private $test_class; public function set_up(): void { parent::set_up(); - $this->test_class = new class() extends WP_CSS_Selector { + $this->test_class = new class() extends WP_CSS_Compound_Selector_List { public function __construct() { parent::__construct( array() ); } @@ -51,10 +51,6 @@ public static function test_parse_compound_selector( string $input, int &$offset return self::parse_compound_selector( $input, $offset ); } - public static function test_parse_complex_selector( string $input, int &$offset ) { - return self::parse_complex_selector( $input, $offset ); - } - /* * Utilities */ @@ -402,53 +398,12 @@ public function test_parse_empty_selector() { $this->assertSame( 0, $offset ); } - /** - * @ticket TBD - */ - public function test_parse_complex_selector() { - $input = 'el1 > .child#bar[baz=quux] , rest'; - $offset = 0; - $sel = $this->test_class::test_parse_complex_selector( $input, $offset ); - - $this->assertSame( 3, count( $sel->selectors ) ); - - $this->assertSame( 'el1', $sel->selectors[2]->type_selector->ident ); - $this->assertNull( $sel->selectors[2]->subclass_selectors ); - - $this->assertSame( WP_CSS_Complex_Selector::COMBINATOR_CHILD, $sel->selectors[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( ', rest', substr( $input, $offset ) ); - } - - /** - * @ticket TBD - */ - public function test_parse_invalid_complex_selector() { - $input = 'el.foo#bar[baz=quux] > , rest'; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - public function test_parse_empty_complex_selector() { - $input = ''; - $offset = 0; - $result = $this->test_class::test_parse_complex_selector( $input, $offset ); - $this->assertNull( $result ); - } - - /** * @ticket TBD */ public function test_parse_selector_list() { - $input = 'el1 el2 el.foo#bar[baz=quux], rest'; - $result = WP_CSS_Selector::from_selectors( $input ); + $input = 'el1, el2, el.foo#bar[baz=quux]'; + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNotNull( $result ); } @@ -457,7 +412,7 @@ public function test_parse_selector_list() { */ public function test_parse_invalid_selector_list() { $input = 'el,,'; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } @@ -466,7 +421,7 @@ public function test_parse_invalid_selector_list() { */ public function test_parse_invalid_selector_list2() { $input = 'el!'; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } @@ -475,7 +430,7 @@ public function test_parse_invalid_selector_list2() { */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; - $result = WP_CSS_Selector::from_selectors( $input ); + $result = WP_CSS_Compound_Selector_List::from_selectors( $input ); $this->assertNull( $result ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index c3a1e4121ecab..733a7135f1b17 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -66,4 +66,14 @@ public function test_select_all() { } $this->assertSame( 4, $count ); } + + /** + * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Processor::select_all + */ + public function test_invalid_selector() { + $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); + $this->assertFalse( $processor->select( '[invalid!selector]' ) ); + } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php new file mode 100644 index 0000000000000..c42c69ff0a095 --- /dev/null +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -0,0 +1,92 @@ +' ); + $this->assertFalse( $processor->select( 'div' ) ); + } + + /** + * @ticket TBD + * + * @dataProvider data_selectors + */ + public function test_select( string $html, string $selector ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->select( $selector ) ); + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_selectors(): array { + return array( + 'simple type' => array( '

', 'div' ), + 'any type' => array( '', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'simple attribute' => array( '
', '[att]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att~="bar"]' ), + ); + } + + /** + * @ticket TBD + */ + public function test_select_all() { + $processor = new WP_HTML_Tag_Processor( '

' ); + $count = 0; + foreach ( $processor->select_all( 'div, .x, rect, #y' ) as $_ ) { + ++$count; + $this->assertTrue( $processor->get_attribute( 'match' ) ); + } + $this->assertSame( 4, $count ); + } + + /** + * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all + * + * @dataProvider data_invalid_selectors + */ + public function test_invalid_selector( string $selector ) { + $processor = new WP_HTML_Tag_Processor( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_invalid_selectors(): array { + return array( + 'complex descendant' => array( 'div *' ), + 'complex child' => array( 'div > *' ), + 'invalid selector' => array( '[invalid!selector]' ), + ); + } +} From 2036a83f77a419fd1f3df89c7c7a316d4a42d5bb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:36:19 +0100 Subject: [PATCH 096/115] Simplify whitspace splitting function --- .../html-api/class-wp-css-attribute-selector.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 76ccdf3804b36..1a7a9ffb37716 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -78,16 +78,15 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { * @return Generator */ private function whitespace_delimited_list( string $input ): Generator { + // Start by skipping whitespace. $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); while ( $offset < strlen( $input ) ) { // Find the byte length until the next boundary. $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); - if ( 0 === $length ) { - return; - } + $value = substr( $input, $offset, $length ); - $value = substr( $input, $offset, $length ); + // Move past trailing whitespace. $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); yield $value; From 3421a4e0d634686fd820db906eb6077503985fe8 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:41:15 +0100 Subject: [PATCH 097/115] Remove unreachable code --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 1a7a9ffb37716..17787dd70815b 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -68,8 +68,6 @@ public function matches( WP_HTML_Tag_Processor $processor ): bool { : strpos( $att_value, $this->value ) ); } - - throw new Exception( 'Unreachable' ); } /** From 784b2d913cbf469a3847b93a46c9c202f19091b7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:41:25 +0100 Subject: [PATCH 098/115] Add a lot of selector integration tests --- .../html-api/wpHtmlTagProcessor-select.php | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index c42c69ff0a095..66f32f905c04f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -39,16 +39,42 @@ public function test_select( string $html, string $selector ) { */ public static function data_selectors(): array { return array( - 'simple type' => array( '

', 'div' ), - 'any type' => array( '', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'simple attribute' => array( '
', '[att]' ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), + 'simple type' => array( '

', 'div' ), + 'any type' => array( '
', '*' ), + 'simple class' => array( '
', '.x' ), + 'simple id' => array( '
', '#x' ), + 'boolean attribute' => array( '
', '[att]' ), + 'boolean attribute with string match' => array( '
', '[att=""]' ), - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att~="bar"]' ), + 'attribute value' => array( '
', '[att=val]' ), + 'attribute quoted value' => array( '
', '[att="::"]' ), + 'attribute case insensitive' => array( '
', '[att="VAL"i]' ), + 'attribute case sensitive mod' => array( '
', '[att="val"s]' ), + + 'attribute one of' => array( '
', '[att~="b"]' ), + 'attribute one of insensitive' => array( '
', '[att~="b"i]' ), + 'attribute one of mod sensitive' => array( '
', '[att~="b"s]' ), + 'attribute one of whitespace cases' => array( "
", '[att~="b"]' ), + + 'attribute with-hyphen (no hyphen)' => array( '

', '[att|="special"]' ), + 'attribute with-hyphen (hyphen prefix)' => array( '

', '[att|="special"]' ), + 'attribute with-hyphen insensitive' => array( '

', '[att|="special"i]' ), + 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]' ), + + 'attribute prefixed' => array( '

', '[att^="p"]' ), + 'attribute prefixed insensitive' => array( '

', '[att^="p"i]' ), + 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]' ), + + 'attribute suffixed' => array( '

', '[att$="x"]' ), + 'attribute suffixed insensitive' => array( '

', '[att$="x"i]' ), + 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]' ), + + 'attribute contains' => array( '

', '[att*="x"]' ), + 'attribute contains insensitive' => array( '

', '[att*="x"i]' ), + 'attribute contains sensitive mod' => array( '

', '[att*="x"s]' ), + + 'list' => array( '

', 'a, p' ), + 'compound' => array( '

', 'section[att="bar"]' ), ); } From 4d4c5fe2db713a4a85a8c4073e3e39f44731d140 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:48:39 +0100 Subject: [PATCH 099/115] Extract normalize input method --- .../class-wp-css-complex-selector-list.php | 16 +------ .../class-wp-css-compound-selector-list.php | 43 +++++++++++++------ 2 files changed, 30 insertions(+), 29 deletions(-) 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 f3769a035f6e5..59b08532868a8 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 @@ -43,26 +43,12 @@ class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List impleme * @return static|null */ public static function from_selectors( string $input ) { - // > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… - $input = trim( $input, " \t\r\n\r" ); + $input = self::normalize_selector_input( $input ); if ( '' === $input ) { return null; } - /* - * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. - * > - * > To filter code points from a stream of (unfiltered) code points input: - * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. - * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). - * - * https://www.w3.org/TR/css-syntax-3/#input-preprocessing - */ - $input = str_replace( array( "\r\n" ), "\n", $input ); - $input = str_replace( array( "\r", "\f" ), "\n", $input ); - $input = str_replace( "\0", "\u{FFFD}", $input ); - $offset = 0; $selector = self::parse_complex_selector( $input, $offset ); diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 2aae51d671f6b..a41b0ac9cd530 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -120,25 +120,12 @@ protected function __construct( array $selectors ) { * @return static|null */ public static function from_selectors( string $input ) { - $input = trim( $input, " \t\r\n\r" ); + $input = self::normalize_selector_input( $input ); if ( '' === $input ) { return null; } - /* - * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. - * > - * > To filter code points from a stream of (unfiltered) code points input: - * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. - * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). - * - * https://www.w3.org/TR/css-syntax-3/#input-preprocessing - */ - $input = str_replace( array( "\r\n" ), "\n", $input ); - $input = str_replace( array( "\r", "\f" ), "\n", $input ); - $input = str_replace( "\0", "\u{FFFD}", $input ); - $offset = 0; $selector = self::parse_compound_selector( $input, $offset ); @@ -842,4 +829,32 @@ final protected static function check_if_three_code_points_would_start_an_ident_ // > Return false. return self::is_ident_start_codepoint( $input, $offset ); } + + /** + * @todo doc… + */ + final protected static function normalize_selector_input( string $input ): string { + /* + * > A selector string is a list of one or more complex selectors ([SELECTORS4], section 3.1) that may be surrounded by whitespace… + * + * This list includes \f. + * A later step would normalize it to a known whitespace character, but it can be trimmed here as well. + */ + $input = trim( $input, " \t\r\n\r\f" ); + + /* + * > The input stream consists of the filtered code points pushed into it as the input byte stream is decoded. + * > + * > To filter code points from a stream of (unfiltered) code points input: + * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF) code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE FEED (LF) in input by a single U+000A LINE FEED (LF) code point. + * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT CHARACTER (�). + * + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + $input = str_replace( array( "\r\n" ), "\n", $input ); + $input = str_replace( array( "\r", "\f" ), "\n", $input ); + $input = str_replace( "\0", "\u{FFFD}", $input ); + + return $input; + } } From dbc37fc2d819057c9678364021d1d14ee8f91292 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:52:54 +0100 Subject: [PATCH 100/115] tests --- tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index d94b61d49c14e..2a20e317338bd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -366,6 +366,7 @@ public static function data_attribute_selectors(): array { 'Invalid: [att s]' => array( '[att s]' ), "Invalid: [att='val\\n']" => array( "[att='val\n']" ), 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From d241f31643a14f70ed3469121d6f45ce0db143d0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 21:57:08 +0100 Subject: [PATCH 101/115] Add nonfinal subclass selector test --- .../html-api/class-wp-css-complex-selector-list.php | 8 ++++++-- .../tests/html-api/wpCssComplexSelectorList.php | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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 59b08532868a8..0413b8dea426a 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 @@ -134,9 +134,13 @@ final protected static function parse_complex_selector( string $input, int &$off return null; } - // `div > .className` is valid, but `.className > div` is not. + /* + * Subclass selectors in non-final position is not supported: + * - `div > .className` is valid + * - `.className > div` is not + */ if ( $has_preceding_subclass_selector ) { - throw new Exception( 'Unsupported non-final subclass selector.' ); + return null; } $has_preceding_subclass_selector = null !== $selector->subclass_selectors; diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 5b485a5029db5..5cceddbdddd30 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -59,6 +59,16 @@ public function test_parse_invalid_complex_selector() { $this->assertNull( $result ); } + /** + * @ticket TBD + */ + public function test_parse_invalid_complex_selector_nonfinal_subclass() { + $input = 'el.foo#bar[baz=quux] > final, rest'; + $offset = 0; + $result = $this->test_class::test_parse_complex_selector( $input, $offset ); + $this->assertNull( $result ); + } + /** * @ticket TBD */ From 663070b34b7b9b04413a6d8b7cf0f20645d7eadb Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 12:38:54 +0100 Subject: [PATCH 102/115] Fix logic bug in child selector exploration --- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 520f3bf3d8fde..ed4d2e7a6e662 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 @@ -46,7 +46,7 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( '*' === $selector->type_selector->ident || strcasecmp( $breadcrumbs[0], $selector->type_selector->ident ) === 0 ) { return $this->explore_matches( array_slice( $selectors, 2 ), array_slice( $breadcrumbs, 1 ) ); } - return $this->explore_matches( $selectors, array_slice( $breadcrumbs, 1 ) ); + return false; case self::COMBINATOR_DESCENDANT: // Find _all_ the breadcrumbs that match and recurse from each of them. From 5478af99a8ecbbff54503f3230f247bc06f56fdf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 12:54:58 +0100 Subject: [PATCH 103/115] Improve selector integration tests --- .../tests/html-api/wpHtmlProcessor-select.php | 62 +++++++------- .../html-api/wpHtmlTagProcessor-select.php | 83 +++++++++---------- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 733a7135f1b17..8515be63d83f8 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -26,54 +26,60 @@ public function test_select_miss() { * * @dataProvider data_selectors */ - public function test_select( string $html, string $selector ) { + public function test_select_all( string $html, string $selector, int $match_count ) { $processor = WP_HTML_Processor::create_full_parser( $html ); - $this->assertTrue( $processor->select( $selector ) ); - $this->assertTrue( $processor->get_attribute( 'match' ) ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $breadcrumb_string = implode( ', ', $processor->get_breadcrumbs() ); + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()} @ {$breadcrumb_string}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); } /** * Data provider. * + * Most selectors are covered by the tag processor selector tests. + * This suite should focus on complex selectors. + * * @return array */ public static function data_selectors(): array { return array( - 'simple type' => array( '
', 'div' ), - 'any type' => array( '', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'simple attribute' => array( '
', '[att]' ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), - 'complex any descendant' => array( '
', 'section *' ), - 'complex any child' => array( '
', 'section > *' ), - - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att~="bar"]' ), + 'any descendant' => array( '

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

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

', 'div > *', 1 ), ); } /** * @ticket TBD + * + * @expectedIncorrectUsage WP_HTML_Processor::select_all + * + * @dataProvider data_invalid_selectors */ - public function test_select_all() { - $processor = WP_HTML_Processor::create_full_parser( '

' ); - $count = 0; - foreach ( $processor->select_all( 'div, .x, svg>rect, #y' ) as $_ ) { - ++$count; - $this->assertTrue( $processor->get_attribute( 'match' ) ); - } - $this->assertSame( 4, $count ); + public function test_invalid_selector( string $selector ) { + $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); + $this->assertFalse( $processor->select( $selector ) ); } /** - * @ticket TBD + * Data provider. * - * @expectedIncorrectUsage WP_HTML_Processor::select_all + * @return array */ - public function test_invalid_selector() { - $processor = WP_HTML_Processor::create_fragment( 'irrelevant' ); - $this->assertFalse( $processor->select( '[invalid!selector]' ) ); + public static function data_invalid_selectors(): array { + return array( + 'invalid selector' => array( '[invalid!selector]' ), + + // The class selectors below are not allowed in non-final position. + 'unsupported child selector' => array( '.parent > .child' ), + 'unsupported descendant selector' => array( '.ancestor .descendant' ), + ); } } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 66f32f905c04f..6bc6ba1e6edbc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -26,10 +26,17 @@ public function test_select_miss() { * * @dataProvider data_selectors */ - public function test_select( string $html, string $selector ) { + public function test_select( string $html, string $selector, int $match_count ) { $processor = new WP_HTML_Tag_Processor( $html ); - $this->assertTrue( $processor->select( $selector ) ); - $this->assertTrue( $processor->get_attribute( 'match' ) ); + $count = 0; + foreach ( $processor->select_all( $selector ) as $_ ) { + $this->assertTrue( + $processor->get_attribute( 'match' ), + "Matched unexpected tag {$processor->get_tag()}" + ); + ++$count; + } + $this->assertSame( $match_count, $count, 'Did not match expected number of tags.' ); } /** @@ -39,58 +46,44 @@ public function test_select( string $html, string $selector ) { */ public static function data_selectors(): array { return array( - 'simple type' => array( '

', 'div' ), - 'any type' => array( '
', '*' ), - 'simple class' => array( '
', '.x' ), - 'simple id' => array( '
', '#x' ), - 'boolean attribute' => array( '
', '[att]' ), - 'boolean attribute with string match' => array( '
', '[att=""]' ), + 'simple type' => array( '
', 'div', 2 ), + 'any type' => array( '
', '*', 2 ), + 'simple class' => array( '
', '.x', 2 ), + 'simple id' => array( '
', '#x', 2 ), - 'attribute value' => array( '
', '[att=val]' ), - 'attribute quoted value' => array( '
', '[att="::"]' ), - 'attribute case insensitive' => array( '
', '[att="VAL"i]' ), - 'attribute case sensitive mod' => array( '
', '[att="val"s]' ), + 'attribute presence' => array( '
', '[att]', 2 ), + 'attribute empty string match' => array( '
', '[att=""]', 2 ), + 'attribute value' => array( '

', '[att=val]', 2 ), + 'attribute quoted value' => array( '

', '[att="::"]', 2 ), + 'attribute case insensitive' => array( '

', '[att="VAL"i]', 2 ), + 'attribute case sensitive mod' => array( '

', '[att="val"s]', 2 ), - 'attribute one of' => array( '

', '[att~="b"]' ), - 'attribute one of insensitive' => array( '
', '[att~="b"i]' ), - 'attribute one of mod sensitive' => array( '
', '[att~="b"s]' ), - 'attribute one of whitespace cases' => array( "
", '[att~="b"]' ), + 'attribute one of' => array( '

', '[att~="b"]', 3 ), + 'attribute one of insensitive' => array( '

', '[att~="b"i]', 1 ), + 'attribute one of mod sensitive' => array( '
', '[att~="b"s]', 1 ), + 'attribute one of whitespace cases' => array( "
", '[att~="b"]', 1 ), - 'attribute with-hyphen (no hyphen)' => array( '

', '[att|="special"]' ), - 'attribute with-hyphen (hyphen prefix)' => array( '

', '[att|="special"]' ), - 'attribute with-hyphen insensitive' => array( '

', '[att|="special"i]' ), - 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]' ), + 'attribute with-hyphen' => array( '

', '[att|="special"]', 2 ), + 'attribute with-hyphen insensitive' => array( '

', '[att|="special" i]', 2 ), + 'attribute with-hyphen sensitive mod' => array( '

', '[att|="special"s]', 1 ), - 'attribute prefixed' => array( '

', '[att^="p"]' ), - 'attribute prefixed insensitive' => array( '

', '[att^="p"i]' ), - 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]' ), + 'attribute prefixed' => array( '

', '[att^="p"]', 2 ), + 'attribute prefixed insensitive' => array( '

', '[att^="p"i]', 1 ), + 'attribute prefixed sensitive mod' => array( '

', '[att^="p"s]', 1 ), - 'attribute suffixed' => array( '

', '[att$="x"]' ), - 'attribute suffixed insensitive' => array( '

', '[att$="x"i]' ), - 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]' ), + 'attribute suffixed' => array( '

', '[att$="x"]', 2 ), + 'attribute suffixed insensitive' => array( '

', '[att$="x"i]', 1 ), + 'attribute suffixed sensitive mod' => array( '

', '[att$="x"s]', 1 ), - 'attribute contains' => array( '

', '[att*="x"]' ), - 'attribute contains insensitive' => array( '

', '[att*="x"i]' ), - 'attribute contains sensitive mod' => array( '

', '[att*="x"s]' ), + 'attribute contains' => array( '

', '[att*="x"]', 2 ), + 'attribute contains insensitive' => array( '

', '[att*="x"i]', 1 ), + 'attribute contains sensitive mod' => array( '

', '[att*="x"s]', 1 ), - 'list' => array( '

', 'a, p' ), - 'compound' => array( '

', 'section[att="bar"]' ), + 'list' => array( '

', 'a, p, .class, #id, [att]', 2 ), + 'compound' => array( '

', 'custom-el[att="bar"][ fruit ~= "banana" i]', 1 ), ); } - /** - * @ticket TBD - */ - public function test_select_all() { - $processor = new WP_HTML_Tag_Processor( '

' ); - $count = 0; - foreach ( $processor->select_all( 'div, .x, rect, #y' ) as $_ ) { - ++$count; - $this->assertTrue( $processor->get_attribute( 'match' ) ); - } - $this->assertSame( 4, $count ); - } - /** * @ticket TBD * From 4f6bf948404cae07425b676048109be3a52d8853 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 13:13:03 +0100 Subject: [PATCH 104/115] Try abstract class instead of interface --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- .../html-api/interface-wp-css-html-processor-matcher.php | 4 ++-- .../html-api/interface-wp-css-html-tag-processor-matcher.php | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 17787dd70815b..4cf554c10eca9 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,6 +1,6 @@ has_class( $this->ident ); } 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..669139097fa75 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 @@ -32,7 +32,7 @@ * * @access private */ -class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { /** * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. 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 ed4d2e7a6e662..4f83476898ec0 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 @@ -5,7 +5,7 @@ * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { +final class WP_CSS_Complex_Selector extends WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a41b0ac9cd530..0095b22977b0a 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -76,7 +76,7 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { +class WP_CSS_Compound_Selector_List extends WP_CSS_HTML_Tag_Processor_Matcher { /** * @param WP_HTML_Tag_Processor $processor * @return bool 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..3340515569bdd 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 @@ -5,7 +5,7 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Compound_Selector extends WP_CSS_HTML_Tag_Processor_Matcher { public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 83339ff839317..15cb2745ede9e 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ get_tag(); if ( null === $tag_name ) { diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php index 2ae29413b35d2..aa280ddefa696 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -1,8 +1,8 @@ Date: Thu, 5 Dec 2024 13:13:06 +0100 Subject: [PATCH 105/115] Revert "Try abstract class instead of interface" This reverts commit 74881651faf991eabceb090707ce8b43c2a25316. --- src/wp-includes/html-api/class-wp-css-attribute-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-class-selector.php | 2 +- .../html-api/class-wp-css-complex-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-complex-selector.php | 2 +- .../html-api/class-wp-css-compound-selector-list.php | 2 +- src/wp-includes/html-api/class-wp-css-compound-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-id-selector.php | 2 +- src/wp-includes/html-api/class-wp-css-type-selector.php | 2 +- .../html-api/interface-wp-css-html-processor-matcher.php | 4 ++-- .../html-api/interface-wp-css-html-tag-processor-matcher.php | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 4cf554c10eca9..17787dd70815b 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,6 +1,6 @@ has_class( $this->ident ); } 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 669139097fa75..0413b8dea426a 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 @@ -32,7 +32,7 @@ * * @access private */ -class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List { +class WP_CSS_Complex_Selector_List extends WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Processor_Matcher { /** * Takes a CSS selector string and returns an instance of itself or `null` if the selector * string is invalid or unsupported. 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 4f83476898ec0..ed4d2e7a6e662 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 @@ -5,7 +5,7 @@ * * > = [ ? ] * */ -final class WP_CSS_Complex_Selector extends WP_CSS_HTML_Processor_Matcher { +final class WP_CSS_Complex_Selector implements WP_CSS_HTML_Processor_Matcher { public function matches( WP_HTML_Processor $processor ): bool { // First selector must match this location. if ( ! $this->selectors[0]->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index 0095b22977b0a..a41b0ac9cd530 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -76,7 +76,7 @@ * @link https://www.w3.org/TR/selectors-api2/ * @link https://www.w3.org/TR/selectors-4/ */ -class WP_CSS_Compound_Selector_List extends WP_CSS_HTML_Tag_Processor_Matcher { +class WP_CSS_Compound_Selector_List implements WP_CSS_HTML_Tag_Processor_Matcher { /** * @param WP_HTML_Tag_Processor $processor * @return bool 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 3340515569bdd..e64695abe9ab3 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 @@ -5,7 +5,7 @@ * * > = [ ? * ]! */ -final class WP_CSS_Compound_Selector extends WP_CSS_HTML_Tag_Processor_Matcher { +final class WP_CSS_Compound_Selector implements WP_CSS_HTML_Tag_Processor_Matcher { public function matches( WP_HTML_Tag_Processor $processor ): bool { if ( $this->type_selector ) { if ( ! $this->type_selector->matches( $processor ) ) { diff --git a/src/wp-includes/html-api/class-wp-css-id-selector.php b/src/wp-includes/html-api/class-wp-css-id-selector.php index 15cb2745ede9e..83339ff839317 100644 --- a/src/wp-includes/html-api/class-wp-css-id-selector.php +++ b/src/wp-includes/html-api/class-wp-css-id-selector.php @@ -1,6 +1,6 @@ get_tag(); if ( null === $tag_name ) { diff --git a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php index aa280ddefa696..2ae29413b35d2 100644 --- a/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php +++ b/src/wp-includes/html-api/interface-wp-css-html-processor-matcher.php @@ -1,8 +1,8 @@ Date: Thu, 5 Dec 2024 14:51:39 +0100 Subject: [PATCH 106/115] Clean up and document attribute selector --- .../class-wp-css-attribute-selector.php | 214 ++++++++++-------- 1 file changed, 122 insertions(+), 92 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-attribute-selector.php b/src/wp-includes/html-api/class-wp-css-attribute-selector.php index 17787dd70815b..7036dd3775cc1 100644 --- a/src/wp-includes/html-api/class-wp-css-attribute-selector.php +++ b/src/wp-includes/html-api/class-wp-css-attribute-selector.php @@ -1,96 +1,23 @@ get_attribute( $this->name ); - if ( null === $att_value ) { - return false; - } - - if ( null === $this->value ) { - return true; - } - - if ( true === $att_value ) { - $att_value = ''; - } - - $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; - - switch ( $this->matcher ) { - case self::MATCH_EXACT: - return $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value; - - case self::MATCH_ONE_OF_EXACT: - foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { - if ( - $case_insensitive - ? 0 === strcasecmp( $val, $this->value ) - : $val === $this->value - ) { - return true; - } - } - return false; - - case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: - // Attempt the full match first - if ( - $case_insensitive - ? 0 === strcasecmp( $att_value, $this->value ) - : $att_value === $this->value - ) { - return true; - } - - // Partial match - if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { - return false; - } - - $starts_with = "{$this->value}-"; - return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); - - case self::MATCH_PREFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); - - case self::MATCH_SUFFIXED_BY: - return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); - - case self::MATCH_CONTAINS: - return false !== ( - $case_insensitive - ? stripos( $att_value, $this->value ) - : strpos( $att_value, $this->value ) - ); - } - } - - /** - * @param string $input - * - * @return Generator - */ - private function whitespace_delimited_list( string $input ): Generator { - // Start by skipping whitespace. - $offset = strspn( $input, self::WHITESPACE_CHARACTERS ); - - while ( $offset < strlen( $input ) ) { - // Find the byte length until the next boundary. - $length = strcspn( $input, self::WHITESPACE_CHARACTERS, $offset ); - $value = substr( $input, $offset, $length ); - - // Move past trailing whitespace. - $offset += $length + strspn( $input, self::WHITESPACE_CHARACTERS, $offset + $length ); - - yield $value; - } - } - /** * [att=val] * Represents an element with the att attribute whose value is exactly "val". @@ -145,11 +72,11 @@ private function whitespace_delimited_list( string $input ): Generator { */ const MODIFIER_CASE_INSENSITIVE = 'case-insensitive'; - /** * The attribute name. * * @var string + * @readonly */ public $name; @@ -157,6 +84,7 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute matcher. * * @var null|self::MATCH_* + * @readonly */ public $matcher; @@ -164,6 +92,7 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute value. * * @var string|null + * @readonly */ public $value; @@ -171,10 +100,13 @@ private function whitespace_delimited_list( string $input ): Generator { * The attribute modifier. * * @var null|self::MODIFIER_* + * @readonly */ public $modifier; /** + * Constructor. + * * @param string $name * @param null|self::MATCH_* $matcher * @param null|string $value @@ -186,4 +118,102 @@ public function __construct( string $name, ?string $matcher = null, ?string $val $this->value = $value; $this->modifier = $modifier; } + + /** + * Determines if the processor's current position matches the selector. + * + * @param WP_HTML_Tag_Processor $processor + * @return bool True if the processor's current position matches the selector. + */ + public function matches( WP_HTML_Tag_Processor $processor ): bool { + $att_value = $processor->get_attribute( $this->name ); + if ( null === $att_value ) { + return false; + } + + if ( null === $this->value ) { + return true; + } + + if ( true === $att_value ) { + $att_value = ''; + } + + $case_insensitive = self::MODIFIER_CASE_INSENSITIVE === $this->modifier; + + switch ( $this->matcher ) { + case self::MATCH_EXACT: + return $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value; + + case self::MATCH_ONE_OF_EXACT: + foreach ( $this->whitespace_delimited_list( $att_value ) as $val ) { + if ( + $case_insensitive + ? 0 === strcasecmp( $val, $this->value ) + : $val === $this->value + ) { + return true; + } + } + return false; + + case self::MATCH_EXACT_OR_EXACT_WITH_HYPHEN: + // Attempt the full match first + if ( + $case_insensitive + ? 0 === strcasecmp( $att_value, $this->value ) + : $att_value === $this->value + ) { + return true; + } + + // Partial match + if ( strlen( $att_value ) < strlen( $this->value ) + 1 ) { + return false; + } + + $starts_with = "{$this->value}-"; + return 0 === substr_compare( $att_value, $starts_with, 0, strlen( $starts_with ), $case_insensitive ); + + case self::MATCH_PREFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, 0, strlen( $this->value ), $case_insensitive ); + + case self::MATCH_SUFFIXED_BY: + return 0 === substr_compare( $att_value, $this->value, -strlen( $this->value ), null, $case_insensitive ); + + case self::MATCH_CONTAINS: + return false !== ( + $case_insensitive + ? stripos( $att_value, $this->value ) + : strpos( $att_value, $this->value ) + ); + } + } + + /** + * Splits a string into a list of whitespace delimited values. + * + * This is useful for the {@see WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT} matcher. + * + * @param string $input + * + * @return Generator + */ + private function whitespace_delimited_list( string $input ): Generator { + // Start by skipping whitespace. + $offset = strspn( $input, " \t\r\n\f" ); + + while ( $offset < strlen( $input ) ) { + // Find the byte length until the next boundary. + $length = strcspn( $input, " \t\r\n\f", $offset ); + $value = substr( $input, $offset, $length ); + + // Move past trailing whitespace. + $offset += $length + strspn( $input, " \t\r\n\f", $offset + $length ); + + yield $value; + } + } } From 32ee2a71197572ea713b6a9a3ee1a9e6b53c0d09 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 19:43:08 +0100 Subject: [PATCH 107/115] Update ticket number in tests --- .../html-api/wpCssComplexSelectorList.php | 16 ++++++------ .../html-api/wpCssCompoundSelectorList.php | 26 +++++++++---------- .../tests/html-api/wpHtmlProcessor-select.php | 6 ++--- .../html-api/wpHtmlTagProcessor-select.php | 6 ++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php index 5cceddbdddd30..0b17e57847662 100644 --- a/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssComplexSelectorList.php @@ -27,7 +27,7 @@ public static function test_parse_complex_selector( string $input, int &$offset } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_complex_selector() { $input = 'el1 > .child#bar[baz=quux] , rest'; @@ -50,7 +50,7 @@ public function test_parse_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_complex_selector() { $input = 'el.foo#bar[baz=quux] > , rest'; @@ -60,7 +60,7 @@ public function test_parse_invalid_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_complex_selector_nonfinal_subclass() { $input = 'el.foo#bar[baz=quux] > final, rest'; @@ -70,7 +70,7 @@ public function test_parse_invalid_complex_selector_nonfinal_subclass() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_complex_selector() { $input = ''; @@ -80,7 +80,7 @@ public function test_parse_empty_complex_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_complex_selector_list() { $input = 'el1 el2 el.foo#bar[baz=quux], second > selector'; @@ -89,7 +89,7 @@ public function test_parse_complex_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list() { $input = 'el,,'; @@ -98,7 +98,7 @@ public function test_parse_invalid_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list2() { $input = 'el!'; @@ -107,7 +107,7 @@ public function test_parse_invalid_selector_list2() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 2a20e317338bd..b5a2d9956679d 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -114,7 +114,7 @@ public static function data_idents(): array { } /** - * @ticket TBD + * @ticket 62653 */ public function test_is_ident_and_is_ident_start() { $this->assertFalse( $this->test_class::test_is_ident_codepoint( '[', 0 ) ); @@ -124,7 +124,7 @@ public function test_is_ident_and_is_ident_start() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_idents */ @@ -141,7 +141,7 @@ public function test_parse_ident( string $input, ?string $expected = null, ?stri } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_strings */ @@ -192,7 +192,7 @@ public static function data_strings(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_id_selectors */ @@ -226,7 +226,7 @@ public static function data_id_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_class_selectors */ @@ -260,7 +260,7 @@ public static function data_class_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_type_selectors */ @@ -296,7 +296,7 @@ public static function data_type_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_attribute_selectors */ @@ -371,7 +371,7 @@ public static function data_attribute_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_selector() { $input = 'el.foo#bar[baz=quux] > .child'; @@ -389,7 +389,7 @@ public function test_parse_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector() { $input = ''; @@ -400,7 +400,7 @@ public function test_parse_empty_selector() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_selector_list() { $input = 'el1, el2, el.foo#bar[baz=quux]'; @@ -409,7 +409,7 @@ public function test_parse_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list() { $input = 'el,,'; @@ -418,7 +418,7 @@ public function test_parse_invalid_selector_list() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_invalid_selector_list2() { $input = 'el!'; @@ -427,7 +427,7 @@ public function test_parse_invalid_selector_list2() { } /** - * @ticket TBD + * @ticket 62653 */ public function test_parse_empty_selector_list() { $input = " \t \t\n\r\f"; diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 8515be63d83f8..40e1d96978afe 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -14,7 +14,7 @@ */ class Tests_HtmlApi_WpHtmlProcessor_Select extends WP_UnitTestCase { /** - * @ticket TBD + * @ticket 62653 */ public function test_select_miss() { $processor = WP_HTML_Processor::create_full_parser( '' ); @@ -22,7 +22,7 @@ public function test_select_miss() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_selectors */ @@ -57,7 +57,7 @@ public static function data_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @expectedIncorrectUsage WP_HTML_Processor::select_all * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php index 6bc6ba1e6edbc..586e38b4bafb2 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-select.php @@ -14,7 +14,7 @@ */ class Tests_HtmlApi_WpHtmlTagProcessor_Select extends WP_UnitTestCase { /** - * @ticket TBD + * @ticket 62653 */ public function test_select_miss() { $processor = new WP_HTML_Tag_Processor( '' ); @@ -22,7 +22,7 @@ public function test_select_miss() { } /** - * @ticket TBD + * @ticket 62653 * * @dataProvider data_selectors */ @@ -85,7 +85,7 @@ public static function data_selectors(): array { } /** - * @ticket TBD + * @ticket 62653 * * @expectedIncorrectUsage WP_HTML_Tag_Processor::select_all * From 5922494030b000bf4d229975a5fd1968c14b20fc Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 21:28:24 +0100 Subject: [PATCH 108/115] Improve some types --- .../html-api/class-wp-css-complex-selector.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ed4d2e7a6e662..a4cfd46622560 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 @@ -16,7 +16,7 @@ public function matches( WP_HTML_Processor $processor ): bool { return true; } - /** @var array $breadcrumbs */ + /** @var string[] */ $breadcrumbs = array_slice( array_reverse( $processor->get_breadcrumbs() ), 1 ); $selectors = array_slice( $this->selectors, 1 ); return $this->explore_matches( $selectors, $breadcrumbs ); @@ -26,7 +26,7 @@ public function matches( WP_HTML_Processor $processor ): bool { * This only looks at breadcrumbs and can therefore only support type selectors. * * @param array $selectors - * @param array $breadcrumbs + * @param string[] $breadcrumbs */ private function explore_matches( array $selectors, array $breadcrumbs ): bool { if ( array() === $selectors ) { @@ -36,9 +36,9 @@ private function explore_matches( array $selectors, array $breadcrumbs ): bool { return false; } - /** @var self::COMBINATOR_* $combinator */ + /** @var self::COMBINATOR_* */ $combinator = $selectors[0]; - /** @var WP_CSS_Compound_Selector $selector */ + /** @var WP_CSS_Compound_Selector */ $selector = $selectors[1]; switch ( $combinator ) { From e492aa60e2db167ec87a048f64fab13378ec4694 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:16:57 +0100 Subject: [PATCH 109/115] Fix and improve string token parsing --- .../class-wp-css-compound-selector-list.php | 19 +++++++++++++------ .../html-api/wpCssCompoundSelectorList.php | 9 ++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php index a41b0ac9cd530..8cca2e27c9ec3 100644 --- a/src/wp-includes/html-api/class-wp-css-compound-selector-list.php +++ b/src/wp-includes/html-api/class-wp-css-compound-selector-list.php @@ -548,7 +548,7 @@ final protected static function parse_ident( string $input, int &$offset ): ?str * @return string|null */ final protected static function parse_string( string $input, int &$offset ): ?string { - if ( $offset + 1 >= strlen( $input ) ) { + if ( $offset >= strlen( $input ) ) { return null; } @@ -559,8 +559,19 @@ final protected static function parse_string( string $input, int &$offset ): ?st $string_token = ''; - $updated_offset = $offset + 1; + $updated_offset = $offset + 1; + $anything_else_mask = "\\\n{$ending_code_point}"; while ( $updated_offset < strlen( $input ) ) { + $anything_else_length = strcspn( $input, $anything_else_mask, $updated_offset ); + if ( $anything_else_length > 0 ) { + $string_token .= substr( $input, $updated_offset, $anything_else_length ); + $updated_offset += $anything_else_length; + + if ( $updated_offset >= strlen( $input ) ) { + break; + } + } + switch ( $input[ $updated_offset ] ) { case '\\': ++$updated_offset; @@ -587,10 +598,6 @@ final protected static function parse_string( string $input, int &$offset ): ?st case $ending_code_point: ++$updated_offset; break 2; - - default: - $string_token .= $input[ $updated_offset ]; - ++$updated_offset; } } diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index b5a2d9956679d..715e0e26bc9cd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -181,13 +181,16 @@ public static function data_strings(): array { "'foo\\" => array( "'foo\\", 'foo', '' ), + '"' => array( '"', '', '' ), + '"\\"' => array( '"\\"', '"', '' ), + '"missing close' => array( '"missing close', 'missing close', '' ), + // Invalid 'Invalid: (empty string)' => array( '' ), - "Invalid: 'newline\\n'" => array( "'newline\n'" ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: \\"' => array( '\\"' ), 'Invalid: .foo' => array( '.foo' ), 'Invalid: #foo' => array( '#foo' ), + "Invalid: 'newline\\n'" => array( "'newline\n'" ), + 'Invalid: foo' => array( 'foo' ), ); } From 81c67582deef44766e188482586538cdbe84272d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:17:11 +0100 Subject: [PATCH 110/115] Update attribute selector tests --- .../html-api/wpCssCompoundSelectorList.php | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 715e0e26bc9cd..6d1b142c17ea9 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -331,45 +331,53 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - '[href]' => array( '[href]', 'href', null, null, null, '' ), - '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), - '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), - '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), - '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), - '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), - '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), - - '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), - "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), - '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), + + 'Unterminated: [att' => array( '[att', 'att', null, null, null, '' ), + 'Unterminated: [att="' => array( '[att="', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '', null, '' ), + 'Unterminated: [att="\\"' => array( '[att="\\"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '"', null, '' ), + 'Unterminated: [att="x"' => array( '[att="x"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', null, '' ), + 'Unterminated: [att="x\\"i]' => array( '[att="x\\"i]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x"i]', null, '' ), + 'Unterminated: [att="x" i' => array( '[att="x" i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + 'Unterminated: [att = x i' => array( '[att = x i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), - 'Invalid: [#foo]' => array( '[#foo]' ), - 'Invalid: [*|*]' => array( '[*|*]' ), - 'Invalid: [ns|*]' => array( '[ns|*]' ), - 'Invalid: [* |att]' => array( '[* |att]' ), - 'Invalid: [*| att]' => array( '[*| att]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att+=val]' => array( '[att+=val]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), - 'Invalid: [att i]' => array( '[att i]' ), - 'Invalid: [att s]' => array( '[att s]' ), - "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), - 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From 7bccf3eada582c8b66ec24781dc151a1afbfe9b6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:36:26 +0100 Subject: [PATCH 111/115] Revert "Update attribute selector tests" This reverts commit 7df9ed91a1360d80c1dcb87980af941010b926ba. --- .../html-api/wpCssCompoundSelectorList.php | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php index 6d1b142c17ea9..715e0e26bc9cd 100644 --- a/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php +++ b/tests/phpunit/tests/html-api/wpCssCompoundSelectorList.php @@ -331,53 +331,45 @@ public function test_parse_attribute( */ public static function data_attribute_selectors(): array { return array( - '[href]' => array( '[href]', 'href', null, null, null, '' ), - '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), - '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), - '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), - '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), - '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), - '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), - '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), - '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), - - '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), - "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), - "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), - - '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), - '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), - - 'Unterminated: [att' => array( '[att', 'att', null, null, null, '' ), - 'Unterminated: [att="' => array( '[att="', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '', null, '' ), - 'Unterminated: [att="\\"' => array( '[att="\\"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, '"', null, '' ), - 'Unterminated: [att="x"' => array( '[att="x"', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', null, '' ), - 'Unterminated: [att="x\\"i]' => array( '[att="x\\"i]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x"i]', null, '' ), - 'Unterminated: [att="x" i' => array( '[att="x" i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), - 'Unterminated: [att = x i' => array( '[att = x i', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'x', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[href]' => array( '[href]', 'href', null, null, null, '' ), + '[href] type' => array( '[href] type', 'href', null, null, null, ' type' ), + '[href]#id' => array( '[href]#id', 'href', null, null, null, '#id' ), + '[href].class' => array( '[href].class', 'href', null, null, null, '.class' ), + '[href][href2]' => array( '[href][href2]', 'href', null, null, null, '[href2]' ), + '[\n href\t\r]' => array( "[\n href\t\r]", 'href', null, null, null, '' ), + '[href=foo]' => array( '[href=foo]', 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foo', null, '' ), + '[href \n = bar ]' => array( "[href \n = bar ]", 'href', WP_CSS_Attribute_Selector::MATCH_EXACT, 'bar', null, '' ), + '[href \n ^= baz ]' => array( "[href \n ^= baz ]", 'href', WP_CSS_Attribute_Selector::MATCH_PREFIXED_BY, 'baz', null, '' ), + + '[match $= insensitive i]' => array( '[match $= insensitive i]', 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'insensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[match|=sensitive s]' => array( '[match|=sensitive s]', 'match', WP_CSS_Attribute_Selector::MATCH_EXACT_OR_EXACT_WITH_HYPHEN, 'sensitive', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + '[att=val I]' => array( '[att=val I]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_INSENSITIVE, '' ), + '[att=val S]' => array( '[att=val S]', 'att', WP_CSS_Attribute_Selector::MATCH_EXACT, 'val', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[match~="quoted[][]"]' => array( '[match~="quoted[][]"]', 'match', WP_CSS_Attribute_Selector::MATCH_ONE_OF_EXACT, 'quoted[][]', null, '' ), + "[match$='quoted!{}']" => array( "[match$='quoted!{}']", 'match', WP_CSS_Attribute_Selector::MATCH_SUFFIXED_BY, 'quoted!{}', null, '' ), + "[match*='quoted's]" => array( "[match*='quoted's]", 'match', WP_CSS_Attribute_Selector::MATCH_CONTAINS, 'quoted', WP_CSS_Attribute_Selector::MODIFIER_CASE_SENSITIVE, '' ), + + '[escape-nl="foo\\nbar"]' => array( "[escape-nl='foo\\\nbar']", 'escape-nl', WP_CSS_Attribute_Selector::MATCH_EXACT, 'foobar', null, '' ), + '[escape-seq="\\31 23"]' => array( "[escape-seq='\\31 23']", 'escape-seq', WP_CSS_Attribute_Selector::MATCH_EXACT, '123', null, '' ), // Invalid - 'Invalid: (empty string)' => array( '' ), - 'Invalid: foo' => array( 'foo' ), - 'Invalid: [foo' => array( '[foo' ), - 'Invalid: [#foo]' => array( '[#foo]' ), - 'Invalid: [*|*]' => array( '[*|*]' ), - 'Invalid: [ns|*]' => array( '[ns|*]' ), - 'Invalid: [* |att]' => array( '[* |att]' ), - 'Invalid: [*| att]' => array( '[*| att]' ), - 'Invalid: [att * =]' => array( '[att * =]' ), - 'Invalid: [att+=val]' => array( '[att+=val]' ), - 'Invalid: [att=val ' => array( '[att=val ' ), - 'Invalid: [att i]' => array( '[att i]' ), - 'Invalid: [att s]' => array( '[att s]' ), - "Invalid: [att='val\\n']" => array( "[att='val\n']" ), - 'Invalid: [att=val i ' => array( '[att=val i ' ), - 'Invalid: [att="val"ix' => array( '[att="val"ix' ), + 'Invalid: (empty string)' => array( '' ), + 'Invalid: foo' => array( 'foo' ), + 'Invalid: [foo' => array( '[foo' ), + 'Invalid: [#foo]' => array( '[#foo]' ), + 'Invalid: [*|*]' => array( '[*|*]' ), + 'Invalid: [ns|*]' => array( '[ns|*]' ), + 'Invalid: [* |att]' => array( '[* |att]' ), + 'Invalid: [*| att]' => array( '[*| att]' ), + 'Invalid: [att * =]' => array( '[att * =]' ), + 'Invalid: [att+=val]' => array( '[att+=val]' ), + 'Invalid: [att=val ' => array( '[att=val ' ), + 'Invalid: [att i]' => array( '[att i]' ), + 'Invalid: [att s]' => array( '[att s]' ), + "Invalid: [att='val\\n']" => array( "[att='val\n']" ), + 'Invalid: [att=val i ' => array( '[att=val i ' ), + 'Invalid: [att="val"ix' => array( '[att="val"ix' ), ); } From 3949cc53b4bebdc8324a07a8ce49bd6ede291e53 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 5 Dec 2024 22:51:04 +0100 Subject: [PATCH 112/115] Improve some complex selector match tests --- .../tests/html-api/wpHtmlProcessor-select.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php index 40e1d96978afe..d94190ff91077 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor-select.php @@ -43,16 +43,18 @@ public function test_select_all( string $html, string $selector, int $match_coun /** * Data provider. * - * Most selectors are covered by the tag processor selector tests. - * This suite should focus on complex selectors. - * * @return array */ public static function data_selectors(): array { return array( - '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 1' => array( '

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

', 'div > *', 1 ), ); } From cf6ffef1ad092b7733b2975cc779fd7571a4b130 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 16:47:59 +0100 Subject: [PATCH 113/115] DROPME: phpstan setup --- composer.json | 3 ++- phpstan.neon | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 phpstan.neon diff --git a/composer.json b/composer.json index eb78d144e590c..16e0097ba01fd 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "squizlabs/php_codesniffer": "3.10.3", "wp-coding-standards/wpcs": "~3.1.0", "phpcompatibility/phpcompatibility-wp": "~2.1.3", - "yoast/phpunit-polyfills": "^1.1.0" + "yoast/phpunit-polyfills": "^1.1.0", + "phpstan/phpstan": "^2.0" }, "config": { "allow-plugins": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000000000..47247c2a8b0bb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 2 + phpVersion: 70224 + paths: + - src/wp-includes/html-api/class-wp-css-selectors.php + scanDirectories: + - src/wp-includes/html-api From 9782e106c36a0ce5ccaedcfada3947eb23f03226 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Fri, 29 Nov 2024 17:57:34 +0100 Subject: [PATCH 114/115] Max phpstan level --- phpstan.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon b/phpstan.neon index 47247c2a8b0bb..dcfe612ddb003 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 2 + level: max phpVersion: 70224 paths: - src/wp-includes/html-api/class-wp-css-selectors.php From f38704e478c7606659df3099d77db60b69d9bba0 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 4 Dec 2024 20:40:36 +0100 Subject: [PATCH 115/115] DROPME: ignore code coverage HTML API --- .../html-api/class-wp-html-active-formatting-elements.php | 1 + src/wp-includes/html-api/class-wp-html-attribute-token.php | 1 + src/wp-includes/html-api/class-wp-html-decoder.php | 1 + src/wp-includes/html-api/class-wp-html-doctype-info.php | 1 + src/wp-includes/html-api/class-wp-html-open-elements.php | 1 + src/wp-includes/html-api/class-wp-html-processor-state.php | 1 + src/wp-includes/html-api/class-wp-html-processor.php | 1 + src/wp-includes/html-api/class-wp-html-span.php | 2 ++ src/wp-includes/html-api/class-wp-html-stack-event.php | 1 + src/wp-includes/html-api/class-wp-html-tag-processor.php | 1 + src/wp-includes/html-api/class-wp-html-text-replacement.php | 1 + src/wp-includes/html-api/class-wp-html-token.php | 2 ++ .../html-api/class-wp-html-unsupported-exception.php | 1 + src/wp-includes/html-api/html5-named-character-references.php | 1 + 14 files changed, 16 insertions(+) diff --git a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php index 2f51482eee052..b545e5a861e72 100644 --- a/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php +++ b/src/wp-includes/html-api/class-wp-html-active-formatting-elements.php @@ -29,6 +29,7 @@ * @since 6.4.0 * * @access private + * @codeCoverageIgnore * * @see https://html.spec.whatwg.org/#list-of-active-formatting-elements * @see WP_HTML_Processor diff --git a/src/wp-includes/html-api/class-wp-html-attribute-token.php b/src/wp-includes/html-api/class-wp-html-attribute-token.php index 74d41320b1c79..a93115c8fb77d 100644 --- a/src/wp-includes/html-api/class-wp-html-attribute-token.php +++ b/src/wp-includes/html-api/class-wp-html-attribute-token.php @@ -16,6 +16,7 @@ * @access private * @since 6.2.0 * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * @codeCoverageIgnore * * @see WP_HTML_Tag_Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-decoder.php b/src/wp-includes/html-api/class-wp-html-decoder.php index 6c1404beddcf1..ff0c9438f6bec 100644 --- a/src/wp-includes/html-api/class-wp-html-decoder.php +++ b/src/wp-includes/html-api/class-wp-html-decoder.php @@ -8,6 +8,7 @@ * @package WordPress * @subpackage HTML-API * @since 6.6.0 + * @codeCoverageIgnore */ class WP_HTML_Decoder { /** diff --git a/src/wp-includes/html-api/class-wp-html-doctype-info.php b/src/wp-includes/html-api/class-wp-html-doctype-info.php index e0396f7d7d603..2087f689c0b26 100644 --- a/src/wp-includes/html-api/class-wp-html-doctype-info.php +++ b/src/wp-includes/html-api/class-wp-html-doctype-info.php @@ -49,6 +49,7 @@ * @see https://www.iso.org/standard/16387.html * * @since 6.7.0 + * @codeCoverageIgnore * * @see WP_HTML_Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-open-elements.php b/src/wp-includes/html-api/class-wp-html-open-elements.php index 210492ab9af08..eff82fecead25 100644 --- a/src/wp-includes/html-api/class-wp-html-open-elements.php +++ b/src/wp-includes/html-api/class-wp-html-open-elements.php @@ -21,6 +21,7 @@ * > for misnested tags). * * @since 6.4.0 + * @codeCoverageIgnore * * @access private * diff --git a/src/wp-includes/html-api/class-wp-html-processor-state.php b/src/wp-includes/html-api/class-wp-html-processor-state.php index b257aa809da75..75a4a996251f1 100644 --- a/src/wp-includes/html-api/class-wp-html-processor-state.php +++ b/src/wp-includes/html-api/class-wp-html-processor-state.php @@ -16,6 +16,7 @@ * @since 6.4.0 * * @access private + * @codeCoverageIgnore * * @see WP_HTML_Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-processor.php b/src/wp-includes/html-api/class-wp-html-processor.php index bbca730279876..0ad41d932add0 100644 --- a/src/wp-includes/html-api/class-wp-html-processor.php +++ b/src/wp-includes/html-api/class-wp-html-processor.php @@ -136,6 +136,7 @@ * these situations and will bail. * * @since 6.4.0 + * @codeCoverageIgnore * * @see WP_HTML_Tag_Processor * @see https://html.spec.whatwg.org/ diff --git a/src/wp-includes/html-api/class-wp-html-span.php b/src/wp-includes/html-api/class-wp-html-span.php index 04a1d5258c904..b76d3708a8884 100644 --- a/src/wp-includes/html-api/class-wp-html-span.php +++ b/src/wp-includes/html-api/class-wp-html-span.php @@ -20,6 +20,8 @@ * @since 6.2.0 * @since 6.5.0 Replaced `end` with `length` to more closely align with `substr()`. * + * @codeCoverageIgnore + * * @see WP_HTML_Tag_Processor */ class WP_HTML_Span { diff --git a/src/wp-includes/html-api/class-wp-html-stack-event.php b/src/wp-includes/html-api/class-wp-html-stack-event.php index dcb3c79ef1003..69a23f747fdef 100644 --- a/src/wp-includes/html-api/class-wp-html-stack-event.php +++ b/src/wp-includes/html-api/class-wp-html-stack-event.php @@ -14,6 +14,7 @@ * * @access private * @since 6.6.0 + * @codeCoverageIgnore * * @see WP_HTML_Processor */ diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index a7633291b6bb2..9b541ebc946ff 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -407,6 +407,7 @@ * @since 6.5.0 Pauses processor when input ends in an incomplete syntax token. * Introduces "special" elements which act like void elements, e.g. TITLE, STYLE. * Allows scanning through all tokens and processing modifiable text, where applicable. + * @codeCoverageIgnore */ class WP_HTML_Tag_Processor { /** diff --git a/src/wp-includes/html-api/class-wp-html-text-replacement.php b/src/wp-includes/html-api/class-wp-html-text-replacement.php index 65e17d48fdb4a..797c101aead2d 100644 --- a/src/wp-includes/html-api/class-wp-html-text-replacement.php +++ b/src/wp-includes/html-api/class-wp-html-text-replacement.php @@ -18,6 +18,7 @@ * @since 6.5.0 Replace `end` with `length` to more closely match `substr()`. * * @see WP_HTML_Tag_Processor + * @codeCoverageIgnore */ class WP_HTML_Text_Replacement { /** diff --git a/src/wp-includes/html-api/class-wp-html-token.php b/src/wp-includes/html-api/class-wp-html-token.php index d5e51ac29007f..55645644c7ba3 100644 --- a/src/wp-includes/html-api/class-wp-html-token.php +++ b/src/wp-includes/html-api/class-wp-html-token.php @@ -17,6 +17,8 @@ * * @access private * + * @codeCoverageIgnore + * * @see WP_HTML_Processor */ class WP_HTML_Token { diff --git a/src/wp-includes/html-api/class-wp-html-unsupported-exception.php b/src/wp-includes/html-api/class-wp-html-unsupported-exception.php index 7b244a5e8a8dd..ce254225ed967 100644 --- a/src/wp-includes/html-api/class-wp-html-unsupported-exception.php +++ b/src/wp-includes/html-api/class-wp-html-unsupported-exception.php @@ -25,6 +25,7 @@ * * @access private * + * @codeCoverageIgnore * @see WP_HTML_Processor */ class WP_HTML_Unsupported_Exception extends Exception { diff --git a/src/wp-includes/html-api/html5-named-character-references.php b/src/wp-includes/html-api/html5-named-character-references.php index 9466f0a06b8cb..1094a52ead1d3 100644 --- a/src/wp-includes/html-api/html5-named-character-references.php +++ b/src/wp-includes/html-api/html5-named-character-references.php @@ -14,6 +14,7 @@ * * @package WordPress * @since 6.6.0 + * @codeCoverageIgnore */ // phpcs:disable