diff --git a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php index fda72fdee..4e7d51372 100644 --- a/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/WP_Functions_Compatibility_Check.php @@ -252,8 +252,9 @@ private function find_wp_function_calls( string $file ): array { return array(); } - $tokens = token_get_all( $source ); - $calls = array(); + $tokens = token_get_all( $source ); + $guarded_functions = $this->get_function_exists_guarded_names( $tokens ); + $calls = array(); foreach ( $tokens as $index => $token ) { if ( ! is_array( $token ) || T_STRING !== $token[0] ) { @@ -269,8 +270,18 @@ private function find_wp_function_calls( string $file ): array { continue; } + $function_name = strtolower( $token[1] ); + + // Skip calls that are guarded by a function_exists() check for the same + // function anywhere in the file. This is a documented backward + // compatibility pattern, so the call is never reached on WordPress + // versions that lack the function. + if ( isset( $guarded_functions[ $function_name ] ) ) { + continue; + } + $calls[] = array( - 'name' => strtolower( $token[1] ), + 'name' => $function_name, 'line' => (int) $token[2], ); } @@ -278,6 +289,69 @@ private function find_wp_function_calls( string $file ): array { return $calls; } + /** + * Collects function names guarded by a function_exists() call within the file. + * + * Only string literal arguments are considered, since a dynamic argument cannot + * be resolved to a specific function name through tokenization. + * + * @since 2.0.0 + * + * @param array $tokens Token stream. + * @return array Map of guarded function names, keyed by lowercase name. + */ + private function get_function_exists_guarded_names( array $tokens ): array { + $guarded = array(); + + foreach ( $tokens as $index => $token ) { + if ( ! is_array( $token ) || T_STRING !== $token[0] || 'function_exists' !== strtolower( $token[1] ) ) { + continue; + } + + $guarded_name = $this->resolve_function_exists_argument( $tokens, $index ); + if ( null !== $guarded_name ) { + $guarded[ $guarded_name ] = true; + } + } + + return $guarded; + } + + /** + * Resolves the string literal argument of a function_exists() call to a function name. + * + * @since 2.0.0 + * + * @param array $tokens Token stream. + * @param int $index Index of the function_exists T_STRING token. + * @return string|null Lowercase function name, or null when the call is not a + * global function_exists() with a single string literal argument. + */ + private function resolve_function_exists_argument( array $tokens, int $index ): ?string { + $open_index = $this->get_next_significant_token_index( $tokens, $index ); + if ( null === $open_index || '(' !== $tokens[ $open_index ] ) { + return null; + } + + if ( ! $this->is_global_function_call( $tokens, $index ) ) { + return null; + } + + $argument_index = $this->get_next_significant_token_index( $tokens, $open_index ); + if ( null === $argument_index ) { + return null; + } + + $argument_token = $tokens[ $argument_index ]; + if ( ! is_array( $argument_token ) || T_CONSTANT_ENCAPSED_STRING !== $argument_token[0] ) { + return null; + } + + $guarded_name = strtolower( ltrim( trim( $argument_token[1], '\'"' ), '\\' ) ); + + return '' !== $guarded_name ? $guarded_name : null; + } + /** * Checks whether a tokenized T_STRING is a global function call. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php b/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php new file mode 100644 index 000000000..89d0813c5 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php @@ -0,0 +1,19 @@ +assertEmpty( wp_list_filter( $errors['uses-compatible-function.php'][8][0] ?? array(), array( 'code' => 'wp_function_not_compatible_with_requires_wp' ) ) ); } + public function test_run_with_function_exists_guard_without_errors() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-function-exists-guard/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new WP_Functions_Compatibility_Check(); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $this->assertArrayNotHasKey( 'uses-guarded-function.php', $errors ); + } + public function test_run_with_php_serialize_without_errors() { $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-wp-functions-compatibility-with-php-serialize/load.php' ); $check_result = new Check_Result( $check_context );