diff --git a/src/php/Plugin.php b/src/php/Plugin.php index 79056cc2..b8fd5ac5 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -21,6 +21,14 @@ use Code_Snippets\UnifiedSnippets\Scanner_Registry; use Code_Snippets\UnifiedSnippets\Scan_Results_Store; use Code_Snippets\UnifiedSnippets\REST\Scan_REST_Controller; +use Code_Snippets\UnifiedSnippets\Scanners\Additional_CSS_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Functions_Php_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Header_Footer_Code_Manager_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Htaccess_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Insert_Headers_And_Footers_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Insert_PHP_Code_Snippet_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Mu_Plugins_Scanner; +use Code_Snippets\UnifiedSnippets\Scanners\Wp_Config_Scanner; /** * The main plugin class @@ -169,6 +177,15 @@ private function init_unified_snippets(): void { $this->unified_snippets = new Scanner_Registry(); $this->unified_snippets_store = new Scan_Results_Store(); + $this->unified_snippets->register( new Functions_Php_Scanner() ); + $this->unified_snippets->register( new Additional_CSS_Scanner() ); + $this->unified_snippets->register( new Htaccess_Scanner() ); + $this->unified_snippets->register( new Wp_Config_Scanner() ); + $this->unified_snippets->register( new Mu_Plugins_Scanner() ); + $this->unified_snippets->register( new Insert_Headers_And_Footers_Scanner() ); + $this->unified_snippets->register( new Header_Footer_Code_Manager_Scanner() ); + $this->unified_snippets->register( new Insert_PHP_Code_Snippet_Scanner() ); + new Scan_REST_Controller( $this->unified_snippets, $this->unified_snippets_store ); } diff --git a/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php new file mode 100644 index 00000000..c429c30e --- /dev/null +++ b/src/php/UnifiedSnippets/Adapters/DB_Scanner_Adapter.php @@ -0,0 +1,109 @@ +importer = $importer ?? $this->create_importer(); + } + + abstract protected function create_importer(): Plugin_Importer; + + abstract protected function get_table_name(): string; + + /** + * Map a single raw row from the importer into Discovered_Snippet field overrides. + * + * Return null to skip the row (unsupported type, missing code, etc.). + * + * @param array $row Raw row cast to associative array. + * + * @return array|null + */ + abstract protected function map_row( array $row ): ?array; + + public function is_available(): bool { + return (bool) call_user_func( [ get_class( $this->importer ), 'is_active' ] ); + } + + public function scan(): array { + if ( ! $this->is_available() ) { + return []; + } + + $snippets = []; + + foreach ( $this->importer->get_data() as $row ) { + $row_array = is_array( $row ) ? $row : (array) $row; + $fields = $this->map_row( $row_array ); + + if ( null === $fields ) { + continue; + } + + $snippets[] = $this->build_snippet( $this->with_defaults( $fields ) ); + } + + return $snippets; + } + + private function with_defaults( array $fields ): array { + return array_merge( + [ + 'source_type' => 'plugin', + 'source_name' => $this->get_label(), + 'line_start' => 0, + 'line_end' => 0, + ], + $fields + ); + } + + /** + * Build a synthetic URI identifying a row in the source plugin's table. + * + * @param int|string $id Row identifier. + * + * @return string e.g. 'db://hfcm_scripts/42'. + */ + protected function build_source_path( $id ): string { + return 'db://' . $this->get_table_name() . '/' . $id; + } + + /** + * Derive a {@see Discovered_Snippet} `type` from the source plugin's code-type value. + * + * @param string $code_type Source-plugin code type, e.g. 'html', 'universal'. + * + * @return string + */ + protected function derive_type( string $code_type ): string { + switch ( strtolower( $code_type ) ) { + case 'css': + return 'css'; + case 'js': + case 'javascript': + return 'js'; + case 'html': + case 'universal': + return 'html'; + case 'php': + case '': + return 'php'; + default: + return 'mixed'; + } + } +} diff --git a/src/php/UnifiedSnippets/Filesystem_Reader.php b/src/php/UnifiedSnippets/Filesystem_Reader.php new file mode 100644 index 00000000..0f10abad --- /dev/null +++ b/src/php/UnifiedSnippets/Filesystem_Reader.php @@ -0,0 +1,110 @@ +exists( $path ) ) { + $contents = $fs->get_contents( $path ); + return false === $contents ? null : $contents; + } + + if ( ! is_readable( $path ) ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $path ); + return false === $contents ? null : $contents; + } + + /** + * Determine whether a path is readable through the filesystem API. + * + * @param string $path Absolute path. + * + * @return bool + */ + public static function is_readable( string $path ): bool { + $fs = self::get_fs(); + + if ( $fs ) { + return $fs->is_readable( $path ); + } + + return is_readable( $path ); + } + + /** + * Lazily initialise and retrieve the shared WP_Filesystem instance. + * + * @return WP_Filesystem_Base|null + */ + private static function get_fs(): ?WP_Filesystem_Base { + if ( self::$initialised ) { + return self::$fs; + } + + self::$initialised = true; + + if ( ! defined( 'ABSPATH' ) ) { + return null; + } + + if ( ! function_exists( 'WP_Filesystem' ) ) { + $includes = ABSPATH . 'wp-admin/includes/file.php'; + if ( ! is_readable( $includes ) ) { + return null; + } + require_once $includes; + } + + if ( ! WP_Filesystem() ) { + return null; + } + + global $wp_filesystem; + + if ( $wp_filesystem instanceof WP_Filesystem_Base ) { + self::$fs = $wp_filesystem; + } + + return self::$fs; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php new file mode 100644 index 00000000..5e75caed --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Additional_CSS_Scanner.php @@ -0,0 +1,78 @@ +post_content ) ) { + return []; + } + + $stylesheet = get_stylesheet(); + $theme_name = function_exists( 'wp_get_theme' ) ? wp_get_theme()->get( 'Name' ) : $stylesheet; + + return [ + $this->build_snippet( + [ + 'name' => sprintf( + /* translators: %s: theme name */ + __( 'Additional CSS (%s)', 'code-snippets' ), + $theme_name + ), + 'code' => $post->post_content, + 'type' => 'css', + 'source_type' => 'customizer', + 'source_name' => $theme_name, + 'source_path' => 'customizer://custom_css/' . $stylesheet, + 'line_start' => 0, + 'line_end' => 0, + 'is_active' => true, + ] + ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php new file mode 100644 index 00000000..7b515c5a --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Functions_Php_Scanner.php @@ -0,0 +1,342 @@ + + */ + private array $path_overrides; + + private const DEFINITION_TOKENS = [ T_FUNCTION, T_CLASS, T_TRAIT, T_INTERFACE ]; + + /** + * Class constructor. + * + * @param array $path_overrides Optional path overrides for testing. + */ + public function __construct( array $path_overrides = [] ) { + $this->path_overrides = $path_overrides; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'functions-php'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'Theme functions.php', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + foreach ( $this->resolve_targets() as $target ) { + if ( Filesystem_Reader::is_readable( $target['path'] ) ) { + return true; + } + } + + return false; + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'medium'; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $snippets = []; + + foreach ( $this->resolve_targets() as $source_type => $target ) { + if ( ! Filesystem_Reader::is_readable( $target['path'] ) ) { + continue; + } + + $snippets = array_merge( + $snippets, + $this->scan_file( $target['path'], $source_type, $target['name'] ) + ); + } + + return $snippets; + } + + /** + * Resolve parent and child theme functions.php targets. + * + * @return array + */ + private function resolve_targets(): array { + if ( $this->path_overrides ) { + return $this->path_overrides; + } + + $targets = []; + + if ( function_exists( 'get_template_directory' ) ) { + $parent_path = wp_normalize_path( get_template_directory() . '/functions.php' ); + $parent_name = function_exists( 'wp_get_theme' ) ? wp_get_theme( get_template() )->get( 'Name' ) : 'Parent theme'; + + $targets['theme'] = [ + 'path' => $parent_path, + 'name' => $parent_name, + ]; + } + + if ( function_exists( 'get_stylesheet_directory' ) && get_stylesheet() !== get_template() ) { + $child_path = wp_normalize_path( get_stylesheet_directory() . '/functions.php' ); + $child_name = wp_get_theme()->get( 'Name' ); + + $targets['child-theme'] = [ + 'path' => $child_path, + 'name' => $child_name, + ]; + } + + return $targets; + } + + /** + * Scan a single functions.php file, extracting top-level symbols. + * + * @param string $path Absolute file path. + * @param string $source_type 'theme' or 'child-theme'. + * @param string $source_name Human-readable theme name. + * + * @return Discovered_Snippet[] + */ + private function scan_file( string $path, string $source_type, string $source_name ): array { + $code = Filesystem_Reader::get_contents( $path ); + + if ( null === $code || '' === $code ) { + return []; + } + + $tokens = $this->tokenize( $code ); + + if ( null === $tokens ) { + return []; + } + + $lines = explode( "\n", $code ); + $snippets = []; + + foreach ( $this->find_top_level_symbols( $tokens ) as $symbol ) { + $snippets[] = $this->build_snippet( + [ + 'name' => $symbol['name'], + 'code' => $this->slice_source( $lines, $symbol['line_start'], $symbol['line_end'] ), + 'type' => 'php', + 'source_type' => $source_type, + 'source_name' => $source_name, + 'source_path' => $path, + 'line_start' => $symbol['line_start'], + 'line_end' => $symbol['line_end'], + 'is_active' => true, + ] + ); + } + + return $snippets; + } + + /** + * Tokenize PHP source, returning null if the file cannot be parsed. + * + * TOKEN_PARSE (PHP 8.0+) makes token_get_all() throw ParseError on invalid source + * instead of silently returning a partial stream. On 7.4 we accept partial results. + * + * @param string $code Source code. + * + * @return array|null + */ + private function tokenize( string $code ): ?array { + try { + return PHP_VERSION_ID >= 80000 + ? token_get_all( $code, TOKEN_PARSE ) + : token_get_all( $code ); + } catch ( ParseError $e ) { + return null; + } + } + + /** + * Yield each top-level function/class/trait/interface definition as {name, line_start, line_end}. + * + * @param array $tokens Token stream. + * + * @return \Generator + */ + private function find_top_level_symbols( array $tokens ): \Generator { + $depth = 0; + $count = count( $tokens ); + + for ( $i = 0; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + + if ( '{' === $token ) { + ++$depth; + continue; + } + + if ( '}' === $token ) { + $depth = max( 0, $depth - 1 ); + continue; + } + + if ( 0 !== $depth || ! is_array( $token ) ) { + continue; + } + + if ( ! in_array( $token[0], self::DEFINITION_TOKENS, true ) ) { + continue; + } + + $name = $this->read_symbol_name( $tokens, $i ); + + if ( '' === $name ) { + continue; + } + + $end_index = $this->find_symbol_end( $tokens, $i, $token[0] ); + + yield [ + 'name' => $name, + 'line_start' => $token[2], + 'line_end' => $this->token_line( $tokens, $end_index ), + ]; + + $i = $end_index; + } + } + + /** + * Read the T_STRING name that follows a definition token (function foo, class Bar, etc.). + * + * @param array $tokens Token stream. + * @param int $start Index of the definition keyword. + * + * @return string Empty string if no name is present (e.g. an anonymous class). + */ + private function read_symbol_name( array $tokens, int $start ): string { + for ( $i = $start + 1, $n = count( $tokens ); $i < $n; $i++ ) { + $token = $tokens[ $i ]; + + if ( is_array( $token ) && T_STRING === $token[0] ) { + return $token[1]; + } + + // Reached the parameter list or body without seeing a name: it's anonymous. + if ( '(' === $token || '{' === $token || ';' === $token ) { + return ''; + } + } + + return ''; + } + + /** + * Find the index of the token that closes a symbol's body (the matching `}` or, for + * interface/abstract declarations, the terminating `;`). + * + * @param array $tokens Token stream. + * @param int $start Index of the definition keyword. + * @param int $type_id The definition token id (T_FUNCTION etc). + * + * @return int Index of the closing token. Falls back to the last token if unmatched. + */ + private function find_symbol_end( array $tokens, int $start, int $type_id ): int { + $depth = 0; + $seen_brace = false; + $count = count( $tokens ); + + for ( $i = $start + 1; $i < $count; $i++ ) { + $token = $tokens[ $i ]; + + if ( '{' === $token ) { + ++$depth; + $seen_brace = true; + continue; + } + + if ( '}' === $token ) { + --$depth; + if ( $seen_brace && 0 === $depth ) { + return $i; + } + continue; + } + + // A `;` before any body closes declarations that have no body (e.g. abstract method). + // Functions always have a body in a functions.php top-level context, so ignore `;` for them. + if ( ';' === $token && ! $seen_brace && T_FUNCTION !== $type_id ) { + return $i; + } + } + + return $count - 1; + } + + /** + * Join a 1-indexed inclusive line range from an array of source lines. + * + * @param string[] $lines Source lines. + * @param int $line_start First line (1-indexed). + * @param int $line_end Last line (1-indexed, inclusive). + * + * @return string + */ + private function slice_source( array $lines, int $line_start, int $line_end ): string { + return implode( "\n", array_slice( $lines, $line_start - 1, ( $line_end - $line_start ) + 1 ) ); + } + + /** + * Get the source line for a token index. For string tokens (which carry no line + * metadata), walk backwards to the nearest array token and add any newlines in + * its text so the result reflects the string token's actual source line. + * + * @param array $tokens Full token stream. + * @param int $index Token index. + * + * @return int + */ + private function token_line( array $tokens, int $index ): int { + if ( is_array( $tokens[ $index ] ) ) { + return $tokens[ $index ][2]; + } + + for ( $k = $index - 1; $k >= 0; $k-- ) { + if ( is_array( $tokens[ $k ] ) ) { + return $tokens[ $k ][2] + substr_count( $tokens[ $k ][1], "\n" ); + } + } + + return 1; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php new file mode 100644 index 00000000..d42b9998 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Header_Footer_Code_Manager_Scanner.php @@ -0,0 +1,54 @@ + '' !== $title ? $title : sprintf( 'HFCM #%d', $id ), + 'code' => $code, + 'type' => $this->derive_type( (string) ( $row['snippet_type'] ?? '' ) ), + 'source_path' => $this->build_source_path( $id ), + 'is_active' => 'active' === ( $row['status'] ?? '' ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php new file mode 100644 index 00000000..78cfc16d --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Htaccess_Scanner.php @@ -0,0 +1,412 @@ +path = $path ?? ( defined( 'ABSPATH' ) ? ABSPATH . '.htaccess' : '.htaccess' ); + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'htaccess'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( '.htaccess', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return Filesystem_Reader::is_readable( $this->path ); + } + + /** + * {@inheritDoc} + */ + public function supports_import(): bool { + return false; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $contents = Filesystem_Reader::get_contents( $this->path ); + + if ( null === $contents || '' === $contents ) { + return []; + } + + $lines = preg_split( "/\r\n|\n|\r/", $contents ); + $sections = $this->split_into_sections( $lines ); + + $snippets = []; + + foreach ( $sections as $section ) { + $snippets[] = $this->build_section_snippet( $section ); + } + + return $snippets; + } + + /** + * Split the file lines into labeled BEGIN/END sections plus contiguous custom groups. + * + * Walks the file once. Each iteration is either: + * - a BEGIN marker → consume through matching END (or orphan cutoff) and emit a labeled section + * - anything else → buffer as part of a pending custom section + * + * A custom section is only emitted if it contains at least one non-empty line. + * + * @param string[] $lines The file lines (without trailing newlines). + * + * @return array + */ + private function split_into_sections( array $lines ): array { + $sections = []; + $custom = $this->new_custom_buffer(); + $total = count( $lines ); + + for ( $i = 0; $i < $total; ) { + if ( preg_match( self::BEGIN_PATTERN, $lines[ $i ], $matches ) ) { + $this->flush_custom( $sections, $custom ); + + $labeled = $this->consume_labeled_section( $lines, $i, trim( $matches[1] ) ); + $sections[] = $labeled['section']; + $i = $labeled['next_index']; + continue; + } + + $this->append_to_custom( $custom, $lines[ $i ], $i ); + ++$i; + } + + $this->flush_custom( $sections, $custom ); + + return $sections; + } + + /** + * Consume a labeled BEGIN/END section starting at $start_idx. + * + * @param string[] $lines All file lines. + * @param int $start_idx Index of the BEGIN line. + * @param string $marker Marker name from the BEGIN line. + * + * @return array{ + * section: array{marker: string, unmatched: bool, line_start: int, line_end: int, code: string}, + * next_index: int + * } + */ + private function consume_labeled_section( array $lines, int $start_idx, string $marker ): array { + [ $end_idx, $matched ] = $this->find_section_end( $lines, $start_idx, $marker ); + + $section = [ + 'marker' => $marker, + 'unmatched' => ! $matched, + 'line_start' => $start_idx + 1, + 'line_end' => $end_idx + 1, + 'code' => implode( "\n", array_slice( $lines, $start_idx, ( $end_idx - $start_idx ) + 1 ) ), + ]; + + return [ + 'section' => $section, + 'next_index' => $end_idx + 1, + ]; + } + + /** + * Locate the closing line for a BEGIN marker. + * + * A section ends at its matching `# END $marker`. If a different `# BEGIN` appears + * first, the section is treated as unmatched and ends at the line before that BEGIN + * so the orphan does not swallow the next block. If neither is found, it runs to EOF. + * + * @param string[] $lines All file lines. + * @param int $start_idx Index of the BEGIN line. + * @param string $marker Marker name to match. + * + * @return array{0: int, 1: bool} [end index, matched?] + */ + private function find_section_end( array $lines, int $start_idx, string $marker ): array { + $total = count( $lines ); + $end_idx = $start_idx; + + for ( $j = $start_idx + 1; $j < $total; $j++ ) { + if ( preg_match( self::END_PATTERN, $lines[ $j ], $em ) && trim( $em[1] ) === $marker ) { + return [ $j, true ]; + } + + if ( preg_match( self::BEGIN_CUTOFF_PATTERN, $lines[ $j ] ) ) { + return [ $j - 1, false ]; + } + + $end_idx = $j; + } + + return [ $end_idx, false ]; + } + + /** + * Initial state for a pending custom (unlabeled) section. + * + * @return array{start: int|null, has_body: bool, buffer: string[]} + */ + private function new_custom_buffer(): array { + return [ + 'start' => null, + 'has_body' => false, + 'buffer' => [], + ]; + } + + /** + * Append a line to the in-progress custom buffer. + * + * @param array{start: int|null, has_body: bool, buffer: string[]} $custom Buffer state (by reference). + * @param string $line Raw line content. + * @param int $idx 0-indexed line number. + */ + private function append_to_custom( array &$custom, string $line, int $idx ): void { + if ( null === $custom['start'] ) { + $custom['start'] = $idx + 1; + } + + $custom['buffer'][] = $line; + + if ( '' !== trim( $line ) ) { + $custom['has_body'] = true; + } + } + + /** + * Emit any pending custom section and reset the buffer. + * + * Custom sections with no non-empty lines are discarded. + * + * @param array $sections Accumulated sections (by reference). + * @param array{start: int|null, has_body: bool, buffer: string[]} $custom Buffer state (by reference). + */ + private function flush_custom( array &$sections, array &$custom ): void { + if ( null !== $custom['start'] && $custom['has_body'] ) { + $sections[] = [ + 'marker' => '', + 'unmatched' => false, + 'line_start' => $custom['start'], + 'line_end' => $custom['start'] + count( $custom['buffer'] ) - 1, + 'code' => implode( "\n", $custom['buffer'] ), + ]; + } + + $custom = $this->new_custom_buffer(); + } + + /** + * Build a Discovered_Snippet for a single parsed section. + * + * @param array{marker: string, unmatched: bool, line_start: int, line_end: int, code: string} $section Section data. + * + * @return Discovered_Snippet + */ + private function build_section_snippet( array $section ): Discovered_Snippet { + $marker = $section['marker']; + $classification = $this->classify_section( $marker, $section['code'], $section['unmatched'] ); + $default_name = '' === $marker ? __( 'Custom', 'code-snippets' ) : $marker; + + return $this->build_snippet( + [ + 'name' => $default_name, + 'code' => $section['code'], + 'type' => 'config', + 'source_type' => 'server', + 'source_name' => $default_name, + 'source_path' => $this->path, + 'line_start' => $section['line_start'], + 'line_end' => $section['line_end'], + 'is_active' => true, + 'is_importable' => $classification['importable'], + 'risk_level' => $classification['risk'], + 'import_notes' => '[' . $classification['category'] . '] ' . $classification['note'], + ] + ); + } + + /** + * Classify a section. First match wins, in this order: + * 1. unmatched BEGIN marker + * 2. WordPress core rewrite block + * 3. server-only directives (high risk, then medium risk) + * 4. convertible directives + * 5. fallthrough: unrecognized custom directive + * + * @param string $marker Section marker name (empty for custom/unattributed). + * @param string $body Section body text. + * @param bool $unmatched Whether the BEGIN marker was not closed by a matching END. + * + * @return array{category: string, risk: string, importable: bool, note: string} + */ + private function classify_section( string $marker, string $body, bool $unmatched ): array { + if ( $unmatched ) { + return $this->classification( + 'server-only', + 'high', + false, + sprintf( + /* translators: %s: section marker name */ + __( 'Unclosed BEGIN marker for "%s"; review .htaccess manually.', 'code-snippets' ), + $marker + ) + ); + } + + if ( 'WordPress' === $marker ) { + return $this->classification( + 'core', + 'high', + false, + __( 'WordPress core rewrite block. Never edit manually.', 'code-snippets' ) + ); + } + + if ( $this->body_contains_any( $body, self::SERVER_ONLY_HIGH ) ) { + return $this->classification( + 'server-only', + 'high', + false, + __( 'Server-level directive with no PHP equivalent.', 'code-snippets' ) + ); + } + + if ( $this->body_contains_any( $body, self::SERVER_ONLY_MEDIUM ) ) { + return $this->classification( + 'server-only', + 'medium', + false, + __( 'Server-level performance directive with no PHP equivalent.', 'code-snippets' ) + ); + } + + if ( $this->body_contains_any( $body, self::CONVERTIBLE_DIRECTIVES ) ) { + return $this->classification( + 'convertible', + 'low', + true, + __( 'Can be converted to a PHP snippet via WordPress hooks.', 'code-snippets' ) + ); + } + + return $this->classification( + 'convertible', + 'medium', + false, + __( 'Unrecognized directive; review manually before importing.', 'code-snippets' ) + ); + } + + /** + * Whether any of the given needles appear (case-insensitively) in the section body. + * + * @param string $body Section body text. + * @param string[] $needles Needles to search for. + * + * @return bool + */ + private function body_contains_any( string $body, array $needles ): bool { + foreach ( $needles as $needle ) { + if ( false !== stripos( $body, $needle ) ) { + return true; + } + } + + return false; + } + + /** + * Build a classification result. + * + * @param string $category Category key. + * @param string $risk Risk level. + * @param bool $importable Whether the section can be imported. + * @param string $note Human-readable note. + * + * @return array{category: string, risk: string, importable: bool, note: string} + */ + private function classification( string $category, string $risk, bool $importable, string $note ): array { + return [ + 'category' => $category, + 'risk' => $risk, + 'importable' => $importable, + 'note' => $note, + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php new file mode 100644 index 00000000..652abf4e --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Insert_Headers_And_Footers_Scanner.php @@ -0,0 +1,62 @@ + '' !== $title ? $title : sprintf( 'WPCode #%d', (int) $id ), + 'code' => $code, + 'type' => $this->derive_type( $code_type ), + 'source_path' => $this->build_source_path( (int) $id ), + 'is_active' => true, + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php new file mode 100644 index 00000000..f47a2d90 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Insert_PHP_Code_Snippet_Scanner.php @@ -0,0 +1,58 @@ + '' !== $title ? $title : sprintf( 'Insert PHP #%d', $id ), + 'code' => $code, + 'type' => 'php', + 'source_path' => $this->build_source_path( $id ), + 'is_active' => 1 === (int) ( $row['status'] ?? 0 ), + ]; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php b/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php new file mode 100644 index 00000000..8a65c6c8 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Mu_Plugins_Scanner.php @@ -0,0 +1,140 @@ +directory = $directory ?? $default; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'mu-plugins'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'Must-Use Plugins', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return '' !== $this->directory && is_dir( $this->directory ); + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'medium'; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + if ( ! $this->is_available() ) { + return []; + } + + $mu_plugins = $this->collect_mu_plugins(); + $snippets = []; + + foreach ( $mu_plugins as $file => $data ) { + $path = trailingslashit( $this->directory ) . $file; + + if ( ! Filesystem_Reader::is_readable( $path ) ) { + continue; + } + + $code = Filesystem_Reader::get_contents( $path ); + + if ( null === $code ) { + continue; + } + + $line_end = max( 1, substr_count( $code, "\n" ) + 1 ); + $name = ! empty( $data['Name'] ) ? $data['Name'] : $file; + + $snippets[] = $this->build_snippet( + [ + 'name' => $name, + 'code' => $code, + 'type' => 'php', + 'source_type' => 'mu-plugin', + 'source_name' => $name, + 'source_path' => $path, + 'line_start' => 1, + 'line_end' => $line_end, + 'is_active' => true, + ] + ); + } + + return $snippets; + } + + /** + * Collect mu-plugin metadata from the configured directory. + * + * @return array> + */ + private function collect_mu_plugins(): array { + if ( function_exists( 'get_mu_plugins' ) && defined( 'WPMU_PLUGIN_DIR' ) && WPMU_PLUGIN_DIR === $this->directory ) { + return get_mu_plugins(); + } + + if ( defined( 'ABSPATH' ) && ! function_exists( 'get_plugin_data' ) ) { + $plugin_includes = ABSPATH . 'wp-admin/includes/plugin.php'; + if ( is_readable( $plugin_includes ) ) { + require_once $plugin_includes; + } + } + + $results = []; + + foreach ( (array) glob( trailingslashit( $this->directory ) . '*.php' ) as $path ) { + if ( ! is_file( $path ) ) { + continue; + } + + $file = basename( $path ); + $data = function_exists( 'get_plugin_data' ) + ? get_plugin_data( $path, false, false ) + : [ 'Name' => $file ]; + + $results[ $file ] = $data; + } + + return $results; + } +} diff --git a/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php new file mode 100644 index 00000000..95f50622 --- /dev/null +++ b/src/php/UnifiedSnippets/Scanners/Wp_Config_Scanner.php @@ -0,0 +1,277 @@ +config_path = $config_path ?? $abspath . 'wp-config.php'; + $this->sample_path = $sample_path ?? $abspath . 'wp-config-sample.php'; + } + + /** + * {@inheritDoc} + */ + public function get_id(): string { + return 'wp-config'; + } + + /** + * {@inheritDoc} + */ + public function get_label(): string { + return __( 'wp-config.php', 'code-snippets' ); + } + + /** + * {@inheritDoc} + */ + public function is_available(): bool { + return Filesystem_Reader::is_readable( $this->config_path ) + && Filesystem_Reader::is_readable( $this->sample_path ); + } + + /** + * {@inheritDoc} + */ + public function get_risk_level(): string { + return 'high'; + } + + /** + * {@inheritDoc} + */ + public function supports_import(): bool { + return false; + } + + /** + * {@inheritDoc} + */ + public function scan(): array { + $config = Filesystem_Reader::get_contents( $this->config_path ); + $sample = Filesystem_Reader::get_contents( $this->sample_path ); + + if ( null === $config || null === $sample ) { + return []; + } + + $sample_lines = $this->build_sample_set( $sample ); + $config_lines = preg_split( "/\r\n|\n|\r/", $config ); + $blocks = $this->find_addition_blocks( $config_lines, $sample_lines ); + + $snippets = []; + + foreach ( $blocks as $index => $block ) { + $snippets[] = $this->build_addition_snippet( $block, $index + 1 ); + } + + return $snippets; + } + + /** + * Group contiguous user-added lines into blocks. + * + * @param string[] $config_lines Lines of wp-config.php. + * @param array $sample_lines Lookup of lines present in wp-config-sample.php. + * + * @return array + */ + private function find_addition_blocks( array $config_lines, array $sample_lines ): array { + $blocks = []; + $buffer = []; + $start = 0; + + foreach ( $config_lines as $idx => $line ) { + if ( $this->is_user_addition( $line, $sample_lines ) ) { + if ( empty( $buffer ) ) { + $start = $idx + 1; + } + $buffer[] = $line; + continue; + } + + if ( ! empty( $buffer ) ) { + $blocks[] = $this->make_block( $buffer, $start ); + $buffer = []; + } + } + + if ( ! empty( $buffer ) ) { + $blocks[] = $this->make_block( $buffer, $start ); + } + + return $blocks; + } + + /** + * Build a block record from a buffer of contiguous addition lines. + * + * @param string[] $buffer Lines making up the block. + * @param int $start 1-indexed line number of the first buffered line. + * + * @return array{line_start: int, line_end: int, code: string} + */ + private function make_block( array $buffer, int $start ): array { + return [ + 'line_start' => $start, + 'line_end' => $start + count( $buffer ) - 1, + 'code' => implode( "\n", $buffer ), + ]; + } + + /** + * Build a Discovered_Snippet for a single addition block. + * + * @param array{line_start: int, line_end: int, code: string} $block Block data. + * @param int $display_index 1-indexed block number used in the snippet name. + * + * @return \Code_Snippets\UnifiedSnippets\Model\Discovered_Snippet + */ + private function build_addition_snippet( array $block, int $display_index ) { + return $this->build_snippet( + [ + 'name' => sprintf( + /* translators: %d: block index */ + __( 'wp-config.php addition #%d', 'code-snippets' ), + $display_index + ), + 'code' => $block['code'], + 'type' => 'config', + 'source_type' => 'core', + 'source_name' => 'wp-config.php', + 'source_path' => $this->config_path, + 'line_start' => $block['line_start'], + 'line_end' => $block['line_end'], + 'is_active' => true, + 'is_importable' => false, + 'risk_level' => 'high', + 'import_notes' => __( 'wp-config constants run before WordPress loads and cannot be replaced by a snippet.', 'code-snippets' ), + ] + ); + } + + /** + * Build a set of trimmed sample lines for quick membership checks. + * + * @param string $sample Contents of wp-config-sample.php. + * + * @return array Lookup keyed by trimmed line. + */ + private function build_sample_set( string $sample ): array { + $set = []; + + foreach ( preg_split( "/\r\n|\n|\r/", $sample ) as $line ) { + $trimmed = trim( $line ); + if ( '' !== $trimmed ) { + $set[ $trimmed ] = true; + } + } + + return $set; + } + + /** + * Determine whether a wp-config line represents a user addition worth reporting. + * + * @param string $line Raw line from wp-config.php. + * @param array $sample_lines Lookup of sample lines. + * + * @return bool + */ + private function is_user_addition( string $line, array $sample_lines ): bool { + $trimmed = trim( $line ); + + if ( '' === $trimmed || $this->is_envelope_line( $trimmed ) ) { + return false; + } + + if ( isset( $sample_lines[ $trimmed ] ) ) { + return false; + } + + return ! $this->is_noise_line( $trimmed ); + } + + /** + * Whether a trimmed line is part of the PHP envelope (opening/closing tag or comment). + * + * @param string $trimmed Trimmed line. + * + * @return bool + */ + private function is_envelope_line( string $trimmed ): bool { + if ( '' === $trimmed ) { + return true; + } + + return 1 === preg_match( self::COMMENT_PREFIX_PATTERN, $trimmed ); + } + + /** + * Whether a trimmed line matches a known noise pattern (DB creds, salts, bootstrap require). + * + * @param string $trimmed Trimmed line. + * + * @return bool + */ + private function is_noise_line( string $trimmed ): bool { + foreach ( self::NOISE_PATTERNS as $pattern ) { + if ( preg_match( $pattern, $trimmed ) ) { + return true; + } + } + + return false; + } +} diff --git a/tests/phpunit/fakes/Fake_Hfcm_Importer.php b/tests/phpunit/fakes/Fake_Hfcm_Importer.php new file mode 100644 index 00000000..ce71c374 --- /dev/null +++ b/tests/phpunit/fakes/Fake_Hfcm_Importer.php @@ -0,0 +1,16 @@ +rows; + } +} diff --git a/tests/phpunit/fakes/Fake_Ihaf_Importer.php b/tests/phpunit/fakes/Fake_Ihaf_Importer.php new file mode 100644 index 00000000..f17e709e --- /dev/null +++ b/tests/phpunit/fakes/Fake_Ihaf_Importer.php @@ -0,0 +1,18 @@ +rows; + } +} diff --git a/tests/phpunit/fakes/Fake_Ipcs_Importer.php b/tests/phpunit/fakes/Fake_Ipcs_Importer.php new file mode 100644 index 00000000..7d999c22 --- /dev/null +++ b/tests/phpunit/fakes/Fake_Ipcs_Importer.php @@ -0,0 +1,18 @@ +rows; + } +} diff --git a/tests/phpunit/test-db-scanners.php b/tests/phpunit/test-db-scanners.php new file mode 100644 index 00000000..8bee1c43 --- /dev/null +++ b/tests/phpunit/test-db-scanners.php @@ -0,0 +1,128 @@ +rows = [ + [ + 'code' => "console.log('hi');", + 'code_type' => 'js', + 'table_data' => [ + 'id' => 1, + 'title' => 'JS', + ], + ], + [ + 'code' => '// php', + 'code_type' => 'php', + 'table_data' => [ + 'id' => 2, + 'title' => 'PHP', + ], + ], + [ + 'code' => '', + 'code_type' => 'text', + 'table_data' => [ + 'id' => 3, + 'title' => 'Unsupported', + ], + ], + [ + 'code' => ' ', + 'code_type' => 'php', + 'table_data' => [ + 'id' => 4, + 'title' => 'Blank', + ], + ], + ]; + + $results = ( new Insert_Headers_And_Footers_Scanner( $importer ) )->scan(); + + $this->assertCount( 2, $results ); + $this->assertSame( 'js', $results[0]->type ); + $this->assertSame( 'php', $results[1]->type ); + } + + public function test_hfcm_active_flag_and_empty_skip() { + $importer = new Fake_Hfcm_Importer(); + $importer->rows = [ + [ + 'script_id' => 1, + 'name' => 'On', + 'snippet' => '1', + 'snippet_type' => 'html', + 'status' => 'active', + ], + [ + 'script_id' => 2, + 'name' => 'Off', + 'snippet' => '2', + 'snippet_type' => 'html', + 'status' => 'inactive', + ], + [ + 'script_id' => 3, + 'name' => 'Blank', + 'snippet' => '', + 'snippet_type' => 'html', + 'status' => 'active', + ], + ]; + + $results = ( new Header_Footer_Code_Manager_Scanner( $importer ) )->scan(); + + $this->assertCount( 2, $results ); + $this->assertTrue( $results[0]->is_active ); + $this->assertFalse( $results[1]->is_active ); + } + + public function test_ipcs_active_flag_name_fallback_and_risk() { + $importer = new Fake_Ipcs_Importer(); + $importer->rows = [ + (object) [ + 'id' => 5, + 'title' => 'Custom Hook', + 'content' => ' 1, + ], + (object) [ + 'id' => 6, + 'title' => '', + 'content' => ' 0, + ], + ]; + + $scanner = new Insert_PHP_Code_Snippet_Scanner( $importer ); + $results = $scanner->scan(); + + $this->assertSame( 'high', $scanner->get_risk_level() ); + $this->assertTrue( $results[0]->is_active ); + $this->assertFalse( $results[1]->is_active ); + $this->assertSame( 'Insert PHP #6', $results[1]->name ); + } + + public function test_adapter_returns_empty_when_unavailable() { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $hfcm = new Header_Footer_Code_Manager_Scanner(); + + $this->assertFalse( $hfcm->is_available() ); + $this->assertSame( [], $hfcm->scan() ); + } +} diff --git a/tests/phpunit/test-unified-scanners.php b/tests/phpunit/test-unified-scanners.php new file mode 100644 index 00000000..ce820ebb --- /dev/null +++ b/tests/phpunit/test-unified-scanners.php @@ -0,0 +1,360 @@ +tmp_dir = sys_get_temp_dir() . '/cs-scanner-' . wp_generate_uuid4(); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + mkdir( $this->tmp_dir, 0777, true ); + } + + /** + * Remove the fixture directory after each test. + */ + public function tear_down() { + $this->rrmdir( $this->tmp_dir ); + parent::tear_down(); + } + + /** + * Recursively delete a directory. + * + * @param string $dir Directory path. + */ + private function rrmdir( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + // scandir() returns dotfiles (e.g. .htaccess fixtures); glob('*') would skip them + // and leave the directory non-empty so rmdir() fails. + foreach ( (array) scandir( $dir ) as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $path = $dir . '/' . $entry; + + if ( is_dir( $path ) ) { + $this->rrmdir( $path ); + } else { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $path ); + } + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir + rmdir( $dir ); + } + + /** + * Write a fixture file through the filesystem directly. + * + * @param string $path Absolute file path. + * @param string $contents File contents. + */ + private function write_fixture( string $path, string $contents ): void { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + file_put_contents( $path, $contents ); + } + + /** + * The functions.php scanner extracts top-level functions and classes. + */ + public function test_functions_php_scanner_extracts_top_level_symbols() { + $code = <<<'PHP' +tmp_dir . '/functions.php'; + $this->write_fixture( $path, $code ); + + $scanner = new Functions_Php_Scanner( + [ + 'theme' => [ + 'path' => $path, + 'name' => 'Fixture Theme', + ], + ] + ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 2, $results ); + + $by_name = []; + foreach ( $results as $snippet ) { + $by_name[ $snippet->name ] = $snippet; + } + + $this->assertArrayHasKey( 'my_custom_function', $by_name ); + $this->assertArrayHasKey( 'My_Plugin_Helper', $by_name ); + + // Fixture layout: + // L1: assertSame( 3, $by_name['my_custom_function']->line_start ); + $this->assertSame( 5, $by_name['my_custom_function']->line_end ); + $this->assertSame( 7, $by_name['My_Plugin_Helper']->line_start ); + $this->assertSame( 11, $by_name['My_Plugin_Helper']->line_end ); + + foreach ( $results as $snippet ) { + $this->assertSame( 'php', $snippet->type ); + $this->assertSame( 'theme', $snippet->source_type ); + $this->assertSame( 'Fixture Theme', $snippet->source_name ); + $this->assertTrue( $snippet->is_active ); + } + } + + /** + * The Additional CSS scanner reads the active theme's custom CSS post. + */ + public function test_additional_css_scanner_returns_customizer_css() { + wp_update_custom_css_post( 'body { color: red; }' ); + + $scanner = new Additional_CSS_Scanner(); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'css', $snippet->type ); + $this->assertSame( 'customizer', $snippet->source_type ); + $this->assertSame( 'body { color: red; }', $snippet->code ); + $this->assertStringStartsWith( 'customizer://custom_css/', $snippet->source_path ); + + wp_update_custom_css_post( '' ); + } + + /** + * The Additional CSS scanner returns nothing when there is no custom CSS. + */ + public function test_additional_css_scanner_returns_empty_when_no_css() { + wp_update_custom_css_post( '' ); + + $scanner = new Additional_CSS_Scanner(); + $results = $scanner->scan(); + + $this->assertSame( [], $results ); + } + + /** + * The .htaccess scanner splits sections and classifies them correctly. + */ + public function test_htaccess_scanner_classifies_sections() { + $htaccess = <<<'TXT' +# BEGIN WordPress +RewriteEngine On +RewriteBase / +RewriteRule ^index\.php$ - [L] +# END WordPress + +# BEGIN Redirects +Redirect 301 /old /new +# END Redirects + + + Require all denied + +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $scanner = new Htaccess_Scanner( $path ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 3, $results ); + + $by_name = []; + foreach ( $results as $snippet ) { + $by_name[ $snippet->name ] = $snippet; + } + + $this->assertArrayHasKey( 'WordPress', $by_name ); + $this->assertSame( 'high', $by_name['WordPress']->risk_level ); + $this->assertFalse( $by_name['WordPress']->is_importable ); + $this->assertStringStartsWith( '[core]', $by_name['WordPress']->import_notes ); + + $this->assertArrayHasKey( 'Redirects', $by_name ); + $this->assertTrue( $by_name['Redirects']->is_importable ); + $this->assertStringStartsWith( '[convertible]', $by_name['Redirects']->import_notes ); + + $this->assertArrayHasKey( 'Custom', $by_name ); + $this->assertFalse( $by_name['Custom']->is_importable ); + $this->assertStringStartsWith( '[server-only]', $by_name['Custom']->import_notes ); + } + + /** + * The wp-config scanner reports only user-added lines grouped by contiguous blocks. + */ + public function test_wp_config_scanner_detects_user_additions() { + $sample = <<<'PHP' +tmp_dir . '/wp-config-sample.php'; + $config_path = $this->tmp_dir . '/wp-config.php'; + $this->write_fixture( $sample_path, $sample ); + $this->write_fixture( $config_path, $config ); + + $scanner = new Wp_Config_Scanner( $config_path, $sample_path ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'config', $snippet->type ); + $this->assertSame( 'core', $snippet->source_type ); + $this->assertFalse( $snippet->is_importable ); + $this->assertSame( 'high', $snippet->risk_level ); + $this->assertStringContainsString( "define( 'WP_DEBUG', true );", $snippet->code ); + $this->assertStringContainsString( "define( 'DISALLOW_FILE_EDIT', true );", $snippet->code ); + $this->assertSame( 4, $snippet->line_start ); + $this->assertSame( 5, $snippet->line_end ); + } + + /** + * The mu-plugins scanner emits one snippet per file in the configured directory. + */ + public function test_mu_plugins_scanner_reads_files() { + $file = $this->tmp_dir . '/site-helper.php'; + $this->write_fixture( + $file, + "tmp_dir ); + + $this->assertTrue( $scanner->is_available() ); + + $results = $scanner->scan(); + + $this->assertCount( 1, $results ); + $snippet = $results[0]; + + $this->assertSame( 'php', $snippet->type ); + $this->assertSame( 'mu-plugin', $snippet->source_type ); + $this->assertSame( $file, $snippet->source_path ); + $this->assertSame( 'Site Helper', $snippet->name ); + $this->assertStringContainsString( 'return true;', $snippet->code ); + $this->assertSame( 1, $snippet->line_start ); + $this->assertGreaterThan( 1, $snippet->line_end ); + } + + /** + * The .htaccess classifier uses first-match-wins: a section containing both a + * high-risk server-only directive and a convertible one is marked server-only. + */ + public function test_htaccess_scanner_first_match_wins() { + $htaccess = <<<'TXT' +# BEGIN Mixed +Redirect 301 /old /new +php_value upload_max_filesize 64M +# END Mixed +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $results = ( new Htaccess_Scanner( $path ) )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Mixed', $results[0]->name ); + $this->assertSame( 'high', $results[0]->risk_level ); + $this->assertFalse( $results[0]->is_importable ); + $this->assertStringStartsWith( '[server-only]', $results[0]->import_notes ); + } + + /** + * An unmatched BEGIN marker is reported as a high-risk server-only section + * instead of silently swallowing the rest of the file. + */ + public function test_htaccess_scanner_reports_unmatched_begin() { + $htaccess = <<<'TXT' +# BEGIN Orphan +RewriteEngine On +RewriteRule ^foo$ /bar [L] +TXT; + + $path = $this->tmp_dir . '/.htaccess'; + $this->write_fixture( $path, $htaccess ); + + $results = ( new Htaccess_Scanner( $path ) )->scan(); + + $this->assertCount( 1, $results ); + $this->assertSame( 'Orphan', $results[0]->name ); + $this->assertSame( 'high', $results[0]->risk_level ); + $this->assertFalse( $results[0]->is_importable ); + $this->assertStringContainsString( 'Unclosed BEGIN marker', $results[0]->import_notes ); + } +}