diff --git a/docs/CLI.md b/docs/CLI.md index 4d76d8503..f77f52352 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -54,6 +54,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. @@ -87,6 +95,9 @@ wp plugin check akismet wp plugin check akismet --checks=late_escaping wp plugin check akismet --format=json 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 435401289..9df216ef8 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 766f9a8cd..12316f63d 100644 --- a/includes/CLI/Plugin_Check_Command.php +++ b/includes/CLI/Plugin_Check_Command.php @@ -112,6 +112,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. * @@ -145,6 +153,9 @@ public function __construct( Plugin_Context $plugin_context ) { * wp plugin check akismet --checks=late_escaping * wp plugin check akismet --format=json * 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 * * @subcommand check * @@ -160,9 +171,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_distignore_filters( $plugin_path ); + } + } + + $defaults = array_merge( array( 'checks' => '', 'format' => 'table', @@ -177,11 +206,15 @@ public function check( $args, $assoc_args ) { 'slug' => '', 'ignore-codes' => '', 'mode' => 'new', - ) + ), + $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. @@ -191,6 +224,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', @@ -199,7 +240,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', @@ -208,6 +264,15 @@ 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..4c28d6de4 100644 --- a/includes/Checker/Checks/Abstract_File_Check.php +++ b/includes/Checker/Checks/Abstract_File_Check.php @@ -285,6 +285,9 @@ 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(); + $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 +299,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 +338,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..775c64fd2 100644 --- a/includes/Utilities/Plugin_Request_Utility.php +++ b/includes/Utilities/Plugin_Request_Utility.php @@ -204,6 +204,46 @@ public static function get_files_to_ignore() { return $files_to_ignore; } + /** + * Gets the directories to include using the filter. + * + * @since 1.1.0 + */ + public static function get_directories_to_include() { + $default_include_directories = array(); + + /** + * Filters the directories to include. + * + * @since 1.1.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.1.0 + */ + public static function get_files_to_include() { + $default_include_files = array(); + + /** + * Filters the files to include. + * + * @since 1.1.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 +385,229 @@ 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(); + } + + $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(); + } + + $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 ** to .* + $pattern = str_replace( '\*\*', '.*', $pattern ); + + // Convert * to [^/]* + $pattern = str_replace( '\*', '[^/]*', $pattern ); + + // Convert ? to [^/] + $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. + */ + /** + * 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/includes/isolated-bootstrap.php b/tests/phpunit/includes/isolated-bootstrap.php new file mode 100644 index 000000000..fe05f5fd8 --- /dev/null +++ b/tests/phpunit/includes/isolated-bootstrap.php @@ -0,0 +1,76 @@ +files_checked = $files; + } + + public function get_stability() { + return self::STABILITY_STABLE; + } + + public function get_categories() { + return array( 'general' ); + } + + public function get_description(): string { + return 'Test check description'; + } + + public function get_documentation_url(): string { + return 'http://example.com/doc'; + } +} + +/** + * Tests for Abstract_File_Check include options. + * + * @group checks + * @group include-options + */ +class Abstract_File_Check_Ignore_Test extends TestCase { + + protected $plugin_root; + protected $plugin_slug = 'test-plugin'; + + public function setUp(): void { + parent::setUp(); + + // Setup temp directory structure inside the mocked WP_PLUGIN_DIR + $this->plugin_root = WP_PLUGIN_DIR . '/' . $this->plugin_slug; + + if ( ! file_exists( WP_PLUGIN_DIR ) ) { + mkdir( WP_PLUGIN_DIR, 0777, true ); + } + if ( file_exists( $this->plugin_root ) ) { + $this->recursive_rmdir( $this->plugin_root ); + } + mkdir( $this->plugin_root, 0777, true ); + + // Create file structure + $files = [ + 'plugin.php' => ' ' ' ' ' $content ) { + $full_path = $this->plugin_root . '/' . $path; + $dir = dirname( $full_path ); + if ( ! file_exists( $dir ) ) { + mkdir( $dir, 0777, true ); + } + file_put_contents( $full_path, $content ); + } + + $this->clear_cache(); + $this->clear_filters(); + } + + public function tearDown(): void { + $this->recursive_rmdir( $this->plugin_root ); + $this->clear_cache(); + $this->clear_filters(); + parent::tearDown(); + } + + 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() { + global $wp_filters; + $wp_filters = []; + } + + public function test_it_includes_only_specified_files() { + add_filter( 'wp_plugin_check_include_files', function() { + return array( 'src/Plugin.php' ); + } ); + + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files = $check->files_checked; + $basenames = array_map( 'basename', $files ); + + $this->assertCount( 1, $files, 'Should include exactly one file.' ); + $this->assertContains( 'Plugin.php', $basenames ); + } + + public function test_it_includes_specified_directories_recursively() { + add_filter( 'wp_plugin_check_include_directories', function() { + return array( 'src/Admin' ); + } ); + + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files = $check->files_checked; + $basenames = array_map( 'basename', $files ); + + $this->assertNotEmpty( $files ); + $this->assertContains( 'Admin.php', $basenames ); + $this->assertNotContains( 'Frontend.php', $basenames ); + } + + public function test_it_combines_include_files_and_directories() { + add_filter( 'wp_plugin_check_include_files', function() { + return array( 'src/Plugin.php' ); + } ); + add_filter( 'wp_plugin_check_include_directories', function() { + return array( 'src/Frontend' ); + } ); + + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files = $check->files_checked; + $basenames = array_map( 'basename', $files ); + + $this->assertContains( 'Plugin.php', $basenames ); + $this->assertContains( 'Frontend.php', $basenames ); + $this->assertNotContains( 'Admin.php', $basenames ); + } + + public function test_it_respects_exclusions_within_included_directories() { + add_filter( 'wp_plugin_check_include_directories', function() { + return array( 'src' ); + } ); + // Exclude Admin directory which is inside src + add_filter( 'wp_plugin_check_ignore_directories', function( $dirs ) { + $dirs[] = 'src/Admin'; + return $dirs; + } ); + + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files = $check->files_checked; + $basenames = array_map( 'basename', $files ); + + // src/Plugin.php, src/Frontend/Frontend.php included. + // src/Admin/Admin.php excluded. + + $this->assertContains( 'Frontend.php', $basenames ); + $this->assertNotContains( 'Admin.php', $basenames ); + } + + public function test_it_respects_distignore_patterns() { + // Create structure + mkdir( $this->plugin_root . '/src/node_modules', 0777, true ); // Deep node_modules + + file_put_contents( $this->plugin_root . '/src/main.php', 'plugin_root . '/src/README.md', '# Readme' ); + file_put_contents( $this->plugin_root . '/vendor/autoload.php', 'plugin_root . '/src/node_modules/pkg.json', '{}' ); + + // Create .distignore + file_put_contents( $this->plugin_root . '/.distignore', "vendor\n*.md" ); + + // Load filters using our utility + Plugin_Request_Utility::load_distignore_filters( $this->plugin_root ); + + // Run check + $context = new Check_Context( $this->plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files_checked = $check->files_checked; + $basenames = array_map( 'basename', $files_checked ); + + // Verify main.php is checked + $this->assertContains( 'main.php', $basenames, 'main.php should be checked' ); + // Verify vendor excluded + $this->assertNotContains( 'autoload.php', $basenames, 'vendor/autoload.php should be ignored' ); + // Verify *.md excluded + $this->assertNotContains( 'README.md', $basenames, '*.md should be ignored' ); + } + + public function test_default_exclusions_are_respected() { + // By default, vendor and node_modules are executed. + // Note: In isolated test environment, we rely on Plugin_Request_Utility default lists. + // We need to ensure no other filters are interfering (tearDown/setUp does this). + + // Create default ignored structure + // vendor already created in setUp + file_put_contents( $this->plugin_root . '/vendor/lib.php', 'plugin_root . '/node_modules' ) ) { + mkdir( $this->plugin_root . '/node_modules', 0777, true ); + } + file_put_contents( $this->plugin_root . '/node_modules/tool.js', '{}' ); + + file_put_contents( $this->plugin_root . '/main.php', 'plugin_root . '/plugin.php' ); + $result = new Check_Result( $context ); + $check = new Isolated_Test_File_Check(); + $check->run( $result ); + + $files_checked = $check->files_checked; + $basenames = array_map( 'basename', $files_checked ); + + $this->assertContains( 'main.php', $basenames ); + + // vendor and node_modules are in the default exclude list in Plugin_Request_Utility. + $this->assertNotContains( 'lib.php', $basenames, 'Default vendor directory should be excluded' ); + $this->assertNotContains( 'tool.js', $basenames, 'Default node_modules should be excluded' ); + } +} diff --git a/tests/phpunit/tests/Utilities/Distignore_Parser_Tests.php b/tests/phpunit/tests/Utilities/Distignore_Parser_Tests.php new file mode 100644 index 000000000..413b87ca8 --- /dev/null +++ b/tests/phpunit/tests/Utilities/Distignore_Parser_Tests.php @@ -0,0 +1,169 @@ +plugin_dir = sys_get_temp_dir() . '/pcp_test_distignore_' . uniqid(); + mkdir( $this->plugin_dir ); + } + + public function tearDown(): void { + $this->recursive_rmdir( $this->plugin_dir ); + parent::tearDown(); + } + + private function recursive_rmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $files as $file ) { + ( is_dir( "$dir/$file" ) ) ? $this->recursive_rmdir( "$dir/$file" ) : unlink( "$dir/$file" ); + } + rmdir( $dir ); + } + + public function test_get_distignore_entries_returns_empty_if_no_file() { + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + $this->assertIsArray( $entries ); + $this->assertEmpty( $entries ); + } + + public function test_get_distignore_entries_parses_basic_entries() { + $content = "tests\nvendor\n*.md"; + file_put_contents( $this->plugin_dir . '/.distignore', $content ); + + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + + // Note: The parser relies on returning raw entries first? + // Or does it assume directories vs files? + // The requirement is to filter both files and directories. + // get_distignore_entries might return a structure or just list? + // Existing ignore lists are separated: get_directories_to_ignore vs get_files_to_ignore. + // So get_distignore_entries should probably return separated arrays? + // config file had 'exclude-files' and 'exclude-directories'. + // .distignore mixes them. + + // For this test, let's assume it returns a raw list for now, or processed? + // If we use it in 'ignore_directories' filter, we expect directories. + // If we use it in 'ignore_files' filter, we expect files. + + // If get_distignore_entries returns ALL entries, we need to know which are dirs and which are files. + // But in gitignore/distignore, "vendor" could be file or dir. usually dir. + // "tests" usually dir. + // "*.md" is file pattern. + + // Let's assume get_distignore_entries returns an array with 'files' and 'directories' keys? + // Or just a raw list and we let the checker logic decide? + // Checker logic: Abstract_File_Check uses str_contains/ends_with. + + // Wait, `Abstract_File_Check` logic for exclusion: + /* + foreach ( $directories_to_ignore as $directory ) { + if ( str_contains( $file_path, '/' . $directory . '/' ) ) { $exclude = true; } + } + foreach ( $files_to_ignore as $ignore_file ) { + if ( str_ends_with( $file_path, $ignore_file ) ) { $exclude = true; } + } + */ + + // If I pass "*.md" as a `file_to_ignore`, `str_ends_with(path, "*.md")` will FAIL. + // Existing logic assumes LITERAL filenames or simple substrings? + // Let's check `Abstract_File_Check.php`. + + // I need to know how strict existing exclusion is. + + $this->assertContains( 'tests', $entries ); + $this->assertContains( 'vendor', $entries ); + $this->assertContains( '*.md', $entries ); + } + + public function test_get_distignore_entries_ignores_comments_and_empty_lines() { + $content = "src\n\n# This is a comment\nvendor"; + file_put_contents( $this->plugin_dir . '/.distignore', $content ); + + $entries = Plugin_Request_Utility::get_distignore_entries( $this->plugin_dir ); + + $this->assertContains( 'src', $entries ); + $this->assertContains( 'vendor', $entries ); + $this->assertNotContains( '# This is a comment', $entries ); + $this->assertCount( 2, $entries ); + } + + /** + * @dataProvider data_gitignore_patterns + */ + public function test_convert_gitignore_pattern_to_regex( $pattern, $expected_regex_part ) { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( $pattern ); + // We assert that the regex matches what we expect it to match. + // Instead of asserting the regex string (which is implementation detail), let's assert matching behavior? + // But here I'm testing the utility method which returns a string. + + // Simplified check: does it look like a regex? + $this->assertStringStartsWith( '#', $regex ); + $this->assertStringEndsWith( '#', $regex ); + } + + public function data_gitignore_patterns() { + return array( + array( 'vendor', 'vendor' ), + array( '*.md', '\.md' ), + array( '/tests', '^tests' ), + ); + } + + /** + * @dataProvider data_gitignore_matches + */ + public function test_regex_matching_logic( $pattern, $path, $should_match ) { + $regex = Plugin_Request_Utility::convert_gitignore_pattern_to_regex( $pattern ); + $match = preg_match( $regex, $path ); + + if ( $should_match ) { + $this->assertEquals( 1, $match, "Pattern '$pattern' should match '$path' with regex '$regex'" ); + } else { + $this->assertEquals( 0, $match, "Pattern '$pattern' should NOT match '$path' with regex '$regex'" ); + } + } + + public function data_gitignore_matches() { + return array( + // Simple file/dir in root or subdir + array( 'vendor', 'vendor', true ), + array( 'vendor', 'vendor/autoload.php', true ), // vendor matches directory prefix + array( 'vendor', 'src/vendor/autoload.php', true ), // matches in subdir + + // Rooted + array( '/vendor', 'vendor', true ), + array( '/vendor', 'src/vendor', false ), + + // Wildcard + array( '*.md', 'readme.md', true ), + array( '*.md', 'src/docs/index.md', true ), + array( '*.md', 'style.css', false ), + + // Directory specific + array( 'tests/', 'tests/foo.php', true ), + array( 'tests/', 'src/tests/foo.php', true ), + + // Deep wildcard + array( 'src/**/tests', 'src/foo/tests', true ), + array( 'src/**/tests', 'src/foo/bar/tests', true ), + ); + } +} diff --git a/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Test.php b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Test.php new file mode 100644 index 000000000..8bacc017d --- /dev/null +++ b/tests/phpunit/tests/Utilities/Plugin_Request_Utility_Config_Test.php @@ -0,0 +1,75 @@ +plugin_dir = sys_get_temp_dir() . '/pcp_test_config_' . uniqid(); + mkdir( $this->plugin_dir ); + } + + public function tearDown(): void { + $this->recursive_rmdir( $this->plugin_dir ); + parent::tearDown(); + } + + private function recursive_rmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $files = array_diff( scandir( $dir ), array( '.', '..' ) ); + foreach ( $files as $file ) { + ( is_dir( "$dir/$file" ) ) ? $this->recursive_rmdir( "$dir/$file" ) : unlink( "$dir/$file" ); + } + rmdir( $dir ); + } + + public function test_get_plugin_configuration_returns_empty_if_no_file() { + $config = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertIsArray( $config ); + $this->assertEmpty( $config ); + } + + public function test_get_plugin_configuration_loads_valid_json() { + $data = array( + 'exclude-files' => array( 'foo.php' ), + 'categories' => array( 'security' ), + ); + file_put_contents( $this->plugin_dir . '/.plugin-check.json', json_encode( $data ) ); + + $config = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertEquals( $data, $config ); + } + + public function test_get_plugin_configuration_ignores_invalid_json() { + file_put_contents( $this->plugin_dir . '/.plugin-check.json', '{invalid json' ); + + $config = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertIsArray( $config ); + $this->assertEmpty( $config ); + } + + public function test_get_plugin_configuration_normalizes_keys() { + // Future proofing: ensure we handle keys consistently if needed? + // For now just raw data. + $data = array( 'foo' => 'bar' ); + file_put_contents( $this->plugin_dir . '/.plugin-check.json', json_encode( $data ) ); + + $config = Plugin_Request_Utility::get_plugin_configuration( $this->plugin_dir ); + $this->assertEquals( 'bar', $config['foo'] ); + } +}