diff --git a/.gitignore b/.gitignore index bdaf98ed0..e9f4607b9 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ temp/ .wp-env.override.json .wp-env.tests.override.json +wp-tests-config.php diff --git a/docs/CLI.md b/docs/CLI.md index 9b7eab8aa..240e59c1b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -56,6 +56,14 @@ By default, `.git`, `vendor`, `vendor_prefixed`, `vendor-prefixed` and `node_mod [--exclude-files=] : Additional files to exclude from checks. +[--include-files=] +: Specific files to include in checks (comma-separated). Mutually exclusive with --exclude-files. +When specified, only the listed files will be checked. + +[--include-directories=] +: Specific directories to include in checks (comma-separated, recursive). Mutually exclusive with --exclude-directories. +When specified, only files within the listed directories will be checked. + [--severity=] : Severity level. @@ -90,6 +98,9 @@ wp plugin check akismet --checks=late_escaping wp plugin check akismet --format=json wp plugin check akismet --format=ctrf wp plugin check akismet --mode=update +wp plugin check akismet --include-files=akismet.php,class.akismet.php +wp plugin check akismet --include-directories=includes,views +wp plugin check akismet --exclude-directories=tests,vendor ``` # wp plugin list-checks diff --git a/includes/Admin/Admin_AJAX.php b/includes/Admin/Admin_AJAX.php index eef695078..668ab1c6d 100644 --- a/includes/Admin/Admin_AJAX.php +++ b/includes/Admin/Admin_AJAX.php @@ -125,6 +125,14 @@ private function configure_runner( $runner ) { $runner->set_check_slugs( $checks ); $runner->set_plugin( $plugin ); + // Load configuration filters (e.g. .distignore, .plugin-check.json). + if ( ! empty( $plugin ) ) { + $plugin_path = WP_PLUGIN_DIR . '/' . basename( $plugin ); + if ( is_dir( $plugin_path ) ) { + Plugin_Request_Utility::load_filters_from_config( $plugin_path ); + } + } + return array( 'checks' => $checks, 'plugin' => $plugin, diff --git a/includes/CLI/Plugin_Check_Command.php b/includes/CLI/Plugin_Check_Command.php index 860ccde18..6e55ae756 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -116,6 +116,14 @@ public function __construct( Plugin_Context $plugin_context ) { * [--exclude-files=] * : Additional files to exclude from checks. * + * [--include-files=] + * : Specific files to include in checks (comma-separated). Mutually exclusive with --exclude-files. + * When specified, only the listed files will be checked. + * + * [--include-directories=] + * : Specific directories to include in checks (comma-separated, recursive). Mutually exclusive with --exclude-directories. + * When specified, only files within the listed directories will be checked. + * * [--severity=] * : Severity level. * @@ -157,6 +165,9 @@ public function __construct( Plugin_Context $plugin_context ) { * wp plugin check akismet --mode=update * wp plugin check akismet --ai * wp plugin check akismet --ai --ai-model=openai::gpt-4o + * wp plugin check akismet --include-files=akismet.php,class.akismet.php + * wp plugin check akismet --include-directories=includes,views + * wp plugin check akismet --exclude-directories=tests,vendor * * @subcommand check * @@ -172,9 +183,27 @@ public function __construct( Plugin_Context $plugin_context ) { * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ public function check( $args, $assoc_args ) { - // Get options based on the CLI arguments. - $options = $this->get_options( - $assoc_args, + $plugin = isset( $args[0] ) ? $args[0] : ''; + $config = array(); + + if ( ! empty( $plugin ) ) { + $plugin_path = ''; + if ( is_dir( $plugin ) ) { + $plugin_path = $plugin; + } elseif ( is_file( $plugin ) ) { + $plugin_path = dirname( $plugin ); + } elseif ( ! filter_var( $plugin, FILTER_VALIDATE_URL ) ) { + // Assume slug for installed plugin. + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin; + } + + if ( ! empty( $plugin_path ) && is_dir( $plugin_path ) ) { + $config = Plugin_Request_Utility::get_plugin_configuration( $plugin_path ); + Plugin_Request_Utility::load_filters_from_config( $plugin_path ); + } + } + + $defaults = array_merge( array( 'checks' => '', 'format' => 'table', @@ -191,11 +220,15 @@ public function check( $args, $assoc_args ) { 'mode' => 'new', 'ai' => false, 'ai-model' => '', - ) + ), + $config ); + // Get options based on the CLI arguments. + $options = $this->get_options( $assoc_args, $defaults ); + // Create the plugin and checks array from CLI arguments. - $plugin = isset( $args[0] ) ? $args[0] : ''; + // $plugin is already set above. $checks = wp_parse_list( $options['checks'] ); // Ignore codes. @@ -205,6 +238,14 @@ public function check( $args, $assoc_args ) { $categories = isset( $options['categories'] ) ? wp_parse_list( $options['categories'] ) : array(); $excluded_directories = isset( $options['exclude-directories'] ) ? wp_parse_list( $options['exclude-directories'] ) : array(); + $included_directories = isset( $options['include-directories'] ) ? wp_parse_list( $options['include-directories'] ) : array(); + + // Validate mutual exclusivity for directories. + if ( ! empty( $excluded_directories ) && ! empty( $included_directories ) ) { + WP_CLI::error( + __( 'The --include-directories and --exclude-directories options are mutually exclusive. Please use only one.', 'plugin-check' ) + ); + } add_filter( 'wp_plugin_check_ignore_directories', @@ -213,7 +254,22 @@ static function ( $dirs ) use ( $excluded_directories ) { } ); + add_filter( + 'wp_plugin_check_include_directories', + static function ( $dirs ) use ( $included_directories ) { + return array_unique( array_merge( $dirs, $included_directories ) ); + } + ); + $excluded_files = isset( $options['exclude-files'] ) ? wp_parse_list( $options['exclude-files'] ) : array(); + $included_files = isset( $options['include-files'] ) ? wp_parse_list( $options['include-files'] ) : array(); + + // Validate mutual exclusivity for files. + if ( ! empty( $excluded_files ) && ! empty( $included_files ) ) { + WP_CLI::error( + __( 'The --include-files and --exclude-files options are mutually exclusive. Please use only one.', 'plugin-check' ) + ); + } add_filter( 'wp_plugin_check_ignore_files', @@ -222,6 +278,13 @@ static function ( $dirs ) use ( $excluded_files ) { } ); + add_filter( + 'wp_plugin_check_include_files', + static function ( $dirs ) use ( $included_files ) { + return array_unique( array_merge( $dirs, $included_files ) ); + } + ); + // Get the CLI Runner. $runner = Plugin_Request_Utility::get_runner(); diff --git a/includes/Checker/Checks/Abstract_File_Check.php b/includes/Checker/Checks/Abstract_File_Check.php index 70ebc1f88..fbc9859f8 100644 --- a/includes/Checker/Checks/Abstract_File_Check.php +++ b/includes/Checker/Checks/Abstract_File_Check.php @@ -265,6 +265,9 @@ private static function file_get_contents( $file ) { * * @param Check_Context $plugin Context for the plugin to check. * @return array List of absolute file paths. + * + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ private static function get_files( Check_Context $plugin ) { $location = wp_normalize_path( $plugin->location() ); @@ -284,7 +287,10 @@ private static function get_files( Check_Context $plugin ) { $directories_to_ignore = Plugin_Request_Utility::get_directories_to_ignore(); - $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + $files_to_ignore = Plugin_Request_Utility::get_files_to_ignore(); + $directories_to_include = Plugin_Request_Utility::get_directories_to_include(); + $files_to_include = Plugin_Request_Utility::get_files_to_include(); + $ignore_patterns = Plugin_Request_Utility::get_files_to_ignore_patterns(); foreach ( $iterator as $file ) { if ( ! $file->isFile() ) { @@ -296,6 +302,30 @@ private static function get_files( Check_Context $plugin ) { // Flag to check if the file should be included or not. $include_file = true; + if ( ! empty( $directories_to_include ) || ! empty( $files_to_include ) ) { + $include_file = false; + + foreach ( $directories_to_include as $directory ) { + if ( false !== strpos( $file_path, '/' . $directory . '/' ) ) { + $include_file = true; + break; + } + } + + if ( ! $include_file ) { + foreach ( $files_to_include as $inc_file ) { + if ( str_ends_with( $file_path, '/' . $inc_file ) ) { + $include_file = true; + break; + } + } + } + + if ( ! $include_file ) { + continue; + } + } + foreach ( $directories_to_ignore as $directory ) { // Check if the current file belongs to the directory you want to ignore. if ( false !== strpos( $file_path, '/' . $directory . '/' ) ) { @@ -311,6 +341,16 @@ private static function get_files( Check_Context $plugin ) { } } + if ( $include_file && ! empty( $ignore_patterns ) ) { + $relative_path = substr( $file_path, strlen( $location ) + 1 ); + foreach ( $ignore_patterns as $pattern ) { + if ( preg_match( $pattern, $relative_path ) ) { + $include_file = false; + break; + } + } + } + if ( $include_file ) { self::$file_list_cache[ $location ][] = $file_path; } diff --git a/includes/Utilities/Plugin_Request_Utility.php b/includes/Utilities/Plugin_Request_Utility.php index 3451f8052..6285f0fe2 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -16,6 +16,8 @@ * Class providing utility methods to return plugin information based on the request. * * @since 1.0.0 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class Plugin_Request_Utility { @@ -204,6 +206,46 @@ public static function get_files_to_ignore() { return $files_to_ignore; } + /** + * Gets the directories to include using the filter. + * + * @since 1.9.0 + */ + public static function get_directories_to_include() { + $default_include_directories = array(); + + /** + * Filters the directories to include. + * + * @since 1.9.0 + * + * @param array $default_include_directories An array of directories to include. + */ + $directories_to_include = (array) apply_filters( 'wp_plugin_check_include_directories', $default_include_directories ); + + return $directories_to_include; + } + + /** + * Gets the files to include using the filter. + * + * @since 1.9.0 + */ + public static function get_files_to_include() { + $default_include_files = array(); + + /** + * Filters the files to include. + * + * @since 1.9.0 + * + * @param array $default_include_files An array of files to include. + */ + $files_to_include = (array) apply_filters( 'wp_plugin_check_include_files', $default_include_files ); + + return $files_to_include; + } + /** * Returns the plugin basename after downloading and installing the plugin. * @@ -345,4 +387,225 @@ public static function is_directory_valid_plugin( $directory ) { return $is_valid; } + + /** + * Gets the configuration from .plugin-check.json in the plugin root. + * + * @since 1.9.0 + * + * @param string $plugin_root_path The plugin root path. + * @return array The configuration array. + */ + public static function get_plugin_configuration( $plugin_root_path ) { + $config_file = trailingslashit( $plugin_root_path ) . '.plugin-check.json'; + + if ( ! file_exists( $config_file ) ) { + return array(); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $config_file ); + + if ( empty( $content ) ) { + return array(); + } + + $config = json_decode( $content, true ); + + if ( JSON_ERROR_NONE !== json_last_error() ) { + return array(); + } + + return (array) $config; + } + + /** + * Gets the entries from .distignore in the plugin root. + * + * @since 1.9.0 + * + * @param string $plugin_root_path The plugin root path. + * @return array The list of ignored entries. + */ + public static function get_distignore_entries( $plugin_root_path ) { + $distignore_file = trailingslashit( $plugin_root_path ) . '.distignore'; + + if ( ! file_exists( $distignore_file ) ) { + return array(); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $distignore_file ); + + if ( empty( $content ) ) { + return array(); + } + + $lines = explode( "\n", $content ); + $entries = array(); + + foreach ( $lines as $line ) { + $line = trim( $line ); + if ( empty( $line ) || str_starts_with( $line, '#' ) ) { + continue; + } + $entries[] = $line; + } + + return $entries; + } + + /** + * Converts a gitignore pattern to a PCRE regex. + * + * @since 1.9.0 + * + * @param string $pattern Gitignore pattern. + * @return string PCRE regex. + */ + public static function convert_gitignore_pattern_to_regex( $pattern ) { + $pattern = trim( $pattern ); + if ( empty( $pattern ) ) { + return ''; + } + + $start_anchor = '(?:^|/)'; + $end_anchor = '(?:/|$)'; + + if ( str_starts_with( $pattern, '/' ) ) { + $start_anchor = '^'; + $pattern = substr( $pattern, 1 ); + } + + if ( str_ends_with( $pattern, '/' ) ) { + $end_anchor = '/'; + $pattern = substr( $pattern, 0, -1 ); + } + + $pattern = preg_quote( $pattern, '#' ); + + // Convert double-star glob to match-all regex. + $pattern = str_replace( '\*\*', '.*', $pattern ); + + // Convert single-star glob to non-slash wildcard. + $pattern = str_replace( '\*', '[^/]*', $pattern ); + + // Convert question-mark glob to single non-slash char. + $pattern = str_replace( '\?', '[^/]', $pattern ); + + return '#' . $start_anchor . $pattern . $end_anchor . '#'; + } + + /** + * Gets the patterns to ignore using the filter. + * + * @since 1.9.0 + * + * @return array Array of regex patterns. + */ + public static function get_files_to_ignore_patterns() { + $default_ignore_patterns = array(); + + /** + * Filters the regex patterns to ignore. + * + * @since 1.9.0 + * + * @param array $default_ignore_patterns An array of regex patterns to ignore. + */ + $ignore_patterns = (array) apply_filters( 'wp_plugin_check_ignore_patterns', $default_ignore_patterns ); + + return $ignore_patterns; + } + + /** + * Loads configuration filters from the plugin config files. + * + * @since 1.9.0 + * + * @param string $plugin_path The plugin root path. + */ + public static function load_filters_from_config( $plugin_path ) { + $plugin_path = untrailingslashit( $plugin_path ); + self::load_distignore_filters( $plugin_path ); + self::load_config_filters( $plugin_path ); + } + + /** + * Loads .distignore filters. + * + * @since 1.9.0 + * + * @param string $plugin_path The plugin root path. + */ + public static function load_distignore_filters( $plugin_path ) { + // Load .distignore patterns. + $distignore_entries = self::get_distignore_entries( $plugin_path ); + if ( ! empty( $distignore_entries ) ) { + add_filter( + 'wp_plugin_check_ignore_patterns', + static function ( $patterns ) use ( $distignore_entries ) { + foreach ( $distignore_entries as $entry ) { + $regex = self::convert_gitignore_pattern_to_regex( $entry ); + if ( ! empty( $regex ) ) { + $patterns[] = $regex; + } + } + return $patterns; + } + ); + } + } + + /** + * Loads .plugin-check.json filters. + * + * @since 1.9.0 + * + * @param string $plugin_path The plugin root path. + */ + public static function load_config_filters( $plugin_path ) { + // Load .plugin-check.json config. + $config = self::get_plugin_configuration( $plugin_path ); + + if ( ! empty( $config['exclude-directories'] ) ) { + $dirs = wp_parse_list( $config['exclude-directories'] ); + add_filter( + 'wp_plugin_check_ignore_directories', + static function ( $ignore_dirs ) use ( $dirs ) { + return array_unique( array_merge( $ignore_dirs, $dirs ) ); + } + ); + } + + if ( ! empty( $config['exclude-files'] ) ) { + $files = wp_parse_list( $config['exclude-files'] ); + add_filter( + 'wp_plugin_check_ignore_files', + static function ( $ignore_files ) use ( $files ) { + return array_unique( array_merge( $ignore_files, $files ) ); + } + ); + } + + if ( ! empty( $config['include-directories'] ) ) { + $dirs = wp_parse_list( $config['include-directories'] ); + add_filter( + 'wp_plugin_check_include_directories', + static function ( $include_dirs ) use ( $dirs ) { + return array_unique( array_merge( $include_dirs, $dirs ) ); + } + ); + } + + if ( ! empty( $config['include-files'] ) ) { + $files = wp_parse_list( $config['include-files'] ); + add_filter( + 'wp_plugin_check_include_files', + static function ( $include_files ) use ( $files ) { + return array_unique( array_merge( $include_files, $files ) ); + } + ); + } + } } diff --git a/tests/phpunit/testdata/Checks/Include_Test_File_Check.php b/tests/phpunit/testdata/Checks/Include_Test_File_Check.php new file mode 100644 index 000000000..35b6c8e98 --- /dev/null +++ b/tests/phpunit/testdata/Checks/Include_Test_File_Check.php @@ -0,0 +1,45 @@ +files_checked = $files; + } + + public function get_description(): string { + return 'Test check for include/exclude file filtering.'; + } + + public function get_documentation_url(): string { + return ''; + } +} diff --git a/tests/phpunit/tests/Checker/Checks/Abstract_File_Check_Include_Tests.php b/tests/phpunit/tests/Checker/Checks/Abstract_File_Check_Include_Tests.php new file mode 100644 index 000000000..e8b217a2e --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Abstract_File_Check_Include_Tests.php @@ -0,0 +1,194 @@ +plugin_root = sys_get_temp_dir() . '/pcp_test_include_' . uniqid(); + mkdir( $this->plugin_root, 0777, true ); + + // Create file structure. + $files = array( + 'plugin.php' => ' ' ' ' ' '# Readme', + ); + + foreach ( $files as $path => $content ) { + $full_path = $this->plugin_root . '/' . $path; + $dir = dirname( $full_path ); + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0777, true ); + } + file_put_contents( $full_path, $content ); + } + + $this->clear_cache(); + $this->clear_filters(); + } + + public function tear_down() { + $this->recursive_rmdir( $this->plugin_root ); + $this->clear_cache(); + $this->clear_filters(); + parent::tear_down(); + } + + protected function recursive_rmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $files as $fileinfo ) { + $todo = ( $fileinfo->isDir() ? 'rmdir' : 'unlink' ); + $todo( $fileinfo->getRealPath() ); + } + rmdir( $dir ); + } + + protected function clear_cache() { + $ref = new ReflectionClass( Abstract_File_Check::class ); + $prop = $ref->getProperty( 'file_list_cache' ); + $prop->setAccessible( true ); + $prop->setValue( array() ); + } + + protected function clear_filters() { + remove_all_filters( 'wp_plugin_check_include_files' ); + remove_all_filters( 'wp_plugin_check_include_directories' ); + remove_all_filters( 'wp_plugin_check_ignore_files' ); + remove_all_filters( 'wp_plugin_check_ignore_directories' ); + remove_all_filters( 'wp_plugin_check_ignore_patterns' ); + } + + protected function get_basenames( array $files ) { + return array_map( 'basename', $files ); + } + + protected function run_check() { + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Include_Test_File_Check(); + $check->run( $result ); + + return $check->files_checked; + } + + public function test_includes_only_specified_files() { + add_filter( + 'wp_plugin_check_include_files', + function () { + return array( 'includes/main.php' ); + } + ); + + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertCount( 1, $files, 'Should include exactly one file.' ); + $this->assertContains( 'main.php', $basenames ); + } + + public function test_includes_specified_directories_recursively() { + add_filter( + 'wp_plugin_check_include_directories', + function () { + return array( 'includes/admin' ); + } + ); + + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertNotEmpty( $files ); + $this->assertContains( 'admin.php', $basenames ); + $this->assertNotContains( 'view.php', $basenames ); + } + + public function test_combines_include_files_and_directories() { + add_filter( + 'wp_plugin_check_include_files', + function () { + return array( 'includes/main.php' ); + } + ); + add_filter( + 'wp_plugin_check_include_directories', + function () { + return array( 'includes/views' ); + } + ); + + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertContains( 'main.php', $basenames ); + $this->assertContains( 'view.php', $basenames ); + $this->assertNotContains( 'admin.php', $basenames ); + } + + public function test_respects_exclusions_within_included_directories() { + add_filter( + 'wp_plugin_check_include_directories', + function () { + return array( 'includes' ); + } + ); + add_filter( + 'wp_plugin_check_ignore_directories', + function ( $dirs ) { + $dirs[] = 'includes/admin'; + return $dirs; + } + ); + + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertContains( 'main.php', $basenames ); + $this->assertContains( 'view.php', $basenames ); + $this->assertNotContains( 'admin.php', $basenames ); + } + + public function test_respects_distignore_patterns() { + file_put_contents( $this->plugin_root . '/.distignore', "vendor\n*.md" ); + + Plugin_Request_Utility::load_distignore_filters( $this->plugin_root ); + + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertContains( 'main.php', $basenames, 'main.php should be checked.' ); + $this->assertNotContains( 'autoload.php', $basenames, 'vendor/autoload.php should be ignored.' ); + $this->assertNotContains( 'README.md', $basenames, '*.md files should be ignored.' ); + } + + public function test_default_exclusions_are_respected() { + $files = $this->run_check(); + $basenames = $this->get_basenames( $files ); + + $this->assertContains( 'main.php', $basenames ); + // vendor is in default exclude list. + $this->assertNotContains( 'autoload.php', $basenames, 'Default vendor directory should be excluded.' ); + } +} diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Tests.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Tests.php new file mode 100644 index 000000000..baec290ff --- /dev/null +++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Tests.php @@ -0,0 +1,193 @@ +plugin_dir = sys_get_temp_dir() . '/pcp_test_config_' . uniqid(); + mkdir( $this->plugin_dir, 0777, true ); + } + + public function tear_down() { + $this->recursive_rmdir( $this->plugin_dir ); + parent::tear_down(); + } + + protected function recursive_rmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $files as $fileinfo ) { + $todo = ( $fileinfo->isDir() ? 'rmdir' : 'unlink' ); + $todo( $fileinfo->getRealPath() ); + } + rmdir( $dir ); + } + + public function test_get_plugin_configuration_loads_valid_json() { + $config = array( + 'exclude-directories' => array( 'vendor' ), + 'exclude-files' => array( 'readme.md' ), + ); + file_put_contents( + $this->plugin_dir . '/.plugin-check.json', + wp_json_encode( $config ) + ); + + $result = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + + $this->assertSame( $config, $result ); + } + + public function test_get_plugin_configuration_returns_empty_for_missing_file() { + $result = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertSame( array(), $result ); + } + + public function test_get_plugin_configuration_returns_empty_for_invalid_json() { + file_put_contents( $this->plugin_dir . '/.plugin-check.json', '{invalid' ); + + $result = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertSame( array(), $result ); + } + + public function test_get_plugin_configuration_returns_empty_for_empty_file() { + file_put_contents( $this->plugin_dir . '/.plugin-check.json', '' ); + + $result = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertSame( array(), $result ); + } + + public function test_get_distignore_entries_parses_lines() { + file_put_contents( + $this->plugin_dir . '/.distignore', + "tests\n*.md\n# comment\n\nvendor\n" + ); + + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + + $this->assertSame( array( 'tests', '*.md', 'vendor' ), $entries ); + } + + public function test_get_distignore_entries_returns_empty_for_missing_file() { + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + $this->assertSame( array(), $entries ); + } + + public function test_get_distignore_entries_returns_empty_for_empty_file() { + file_put_contents( $this->plugin_dir . '/.distignore', '' ); + + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + $this->assertSame( array(), $entries ); + } + + public function test_convert_gitignore_pattern_to_regex_matches_simple_filename() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( 'readme.md' ); + + $this->assertMatchesRegularExpression( $regex, 'readme.md' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'other.md' ); + } + + public function test_convert_gitignore_pattern_to_regex_matches_wildcard_extension() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( '*.md' ); + + $this->assertMatchesRegularExpression( $regex, 'README.md' ); + $this->assertMatchesRegularExpression( $regex, 'docs/readme.md' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'readme.txt' ); + } + + public function test_convert_gitignore_pattern_to_regex_matches_directory() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( 'vendor/' ); + + $this->assertMatchesRegularExpression( $regex, 'vendor/' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'vendorlib' ); + } + + public function test_convert_gitignore_pattern_to_regex_matches_double_star() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( '**/test' ); + + $this->assertMatchesRegularExpression( $regex, 'deep/nested/test' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'testing' ); + } + + public function test_convert_gitignore_pattern_to_regex_matches_question_mark() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( 'file?.txt' ); + + $this->assertMatchesRegularExpression( $regex, 'file1.txt' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'file12.txt' ); + } + + public function test_convert_gitignore_pattern_to_regex_root_anchored() { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( '/build' ); + + $this->assertMatchesRegularExpression( $regex, 'build' ); + $this->assertDoesNotMatchRegularExpression( $regex, 'src/build' ); + } + + public function data_gitignore_patterns() { + return array( + 'simple file' => array( 'readme.md', '#(?:^|/)readme\.md(?:/|$)#' ), + 'wildcard ext' => array( '*.md', '#(?:^|/)[^/]*\.md(?:/|$)#' ), + 'directory' => array( 'vendor/', '#(?:^|/)vendor/#' ), + 'double star' => array( '**/test', '#(?:^|/).*/test(?:/|$)#' ), + 'question mark' => array( 'file?.txt', '#(?:^|/)file[^/]\.txt(?:/|$)#' ), + 'root anchored' => array( '/build', '#^build(?:/|$)#' ), + ); + } + + /** + * @dataProvider data_gitignore_patterns + */ + public function test_convert_gitignore_pattern_to_regex_structure( $pattern, $expected ) { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( $pattern ); + $this->assertSame( $expected, $regex ); + } + + public function test_load_filters_from_config_registers_distignore_filter() { + file_put_contents( $this->plugin_dir . '/.distignore', "*.md\nvendor\n" ); + + Plugin_Request_Utility::load_filters_from_config( $this->plugin_dir ); + + $this->assertTrue( has_filter( 'wp_plugin_check_ignore_patterns' ) !== false ); + } + + public function test_load_filters_from_config_registers_config_filters() { + $config = array( + 'exclude-directories' => array( 'build' ), + 'exclude-files' => array( 'changelog.txt' ), + 'include-directories' => array( 'src' ), + 'include-files' => array( 'index.php' ), + ); + file_put_contents( + $this->plugin_dir . '/.plugin-check.json', + wp_json_encode( $config ) + ); + + Plugin_Request_Utility::load_filters_from_config( $this->plugin_dir ); + + $this->assertTrue( has_filter( 'wp_plugin_check_ignore_directories' ) !== false ); + $this->assertTrue( has_filter( 'wp_plugin_check_ignore_files' ) !== false ); + $this->assertTrue( has_filter( 'wp_plugin_check_include_directories' ) !== false ); + $this->assertTrue( has_filter( 'wp_plugin_check_include_files' ) !== false ); + } + + public function test_load_filters_from_config_with_no_config_files() { + Plugin_Request_Utility::load_filters_from_config( $this->plugin_dir ); + + $this->assertFalse( has_filter( 'wp_plugin_check_ignore_patterns' ) ); + $this->assertFalse( has_filter( 'wp_plugin_check_ignore_directories' ) ); + } +}