'action', 'do_action_ref_array' => 'action', 'do_action_deprecated' => 'action', 'apply_filters' => 'filter', 'apply_filters_ref_array' => 'filter', 'apply_filters_deprecated' => 'filter', ); const SKIP_DIRS = array( 'vendor', 'node_modules', 'tests', 'test', '.git', 'bower_components' ); /** * Installed plugins that live in their own folder, for the picker. * * @return array Folder => plugin name. */ public static function plugin_choices() { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $choices = array(); foreach ( get_plugins() as $file => $data ) { if ( false === strpos( $file, '/' ) ) { continue; } $choices[ strtok( $file, '/' ) ] = $data['Name']; } asort( $choices ); return $choices; } /** * Scan one installed plugin by folder name. * * @param string $folder Plugin folder name. * @return array|WP_Error { hooks: array, files: int, truncated: bool } */ public function scan_plugin( $folder ) { $folder = sanitize_file_name( (string) $folder ); if ( '' === $folder || ! array_key_exists( $folder, self::plugin_choices() ) ) { return new WP_Error( 'hooktrail_unknown_plugin', __( 'That plugin is not installed.', 'hooktrail' ) ); } return $this->scan_dir( trailingslashit( WP_PLUGIN_DIR ) . $folder ); } /** * @param string $dir Absolute directory. * @return array { hooks, files, truncated } */ public function scan_dir( $dir ) { $dir = wp_normalize_path( $dir ); $hooks = array(); $files = 0; $cut = false; $iterator = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), function ( $current ) { if ( $current->isDir() ) { return ! in_array( $current->getFilename(), self::SKIP_DIRS, true ) && ! $current->isLink(); } return 'php' === strtolower( $current->getExtension() ); } ) ); $fs = self::filesystem(); foreach ( $iterator as $file ) { if ( $files >= self::MAX_FILES ) { $cut = true; break; } if ( $file->getSize() > self::MAX_FILE_SIZE ) { continue; } $path = wp_normalize_path( $file->getPathname() ); $source = $fs->get_contents( $path ); ++$files; if ( ! is_string( $source ) || '' === $source ) { continue; } foreach ( $this->scan_source( $source ) as $found ) { $found['file'] = ltrim( substr( $path, strlen( $dir ) ), '/' ); $hooks[] = $found; } } usort( $hooks, function ( $a, $b ) { $by_hook = strcmp( $a['hook'], $b['hook'] ); if ( 0 !== $by_hook ) { return $by_hook; } $by_file = strcmp( $a['file'], $b['file'] ); return 0 !== $by_file ? $by_file : $a['line'] - $b['line']; } ); return array( 'hooks' => $hooks, 'files' => $files, 'truncated' => $cut, ); } /** * Find hook calls in one file's source. * * @param string $source PHP source. * @return array */ public function scan_source( $source ) { $tokens = token_get_all( $source ); $count = count( $tokens ); $found = array(); $last_doc = ''; $last_doc_line = -100; for ( $i = 0; $i < $count; $i++ ) { $token = $tokens[ $i ]; if ( ! is_array( $token ) ) { continue; } if ( T_DOC_COMMENT === $token[0] ) { $last_doc = $token[1]; $last_doc_line = $token[2] + substr_count( $token[1], "\n" ); continue; } $name = self::function_name( $token ); if ( '' === $name || ! isset( self::FUNCTIONS[ $name ] ) ) { continue; } // Skip method calls ($obj->do_action), static calls and // function definitions named like a hook function. $prev = $this->previous_significant( $tokens, $i ); if ( null !== $prev && ( ( is_array( $prev ) && in_array( $prev[0], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON, T_FUNCTION, T_NEW ), true ) ) || ( defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) && is_array( $prev ) && T_NULLSAFE_OBJECT_OPERATOR === $prev[0] ) ) ) { continue; } $open = $this->next_significant_index( $tokens, $i ); if ( null === $open || '(' !== $tokens[ $open ] ) { continue; } list( $arg_tokens ) = $this->first_argument( $tokens, $open, $count ); $hook = $this->hook_name( $arg_tokens ); $line = $token[2]; $doc = ( $line - $last_doc_line ) <= 3 ? self::doc_summary( $last_doc ) : ''; $found[] = array( 'hook' => $hook['name'], 'dynamic' => $hook['dynamic'], 'type' => self::FUNCTIONS[ $name ], 'deprecated' => false !== strpos( $name, 'deprecated' ), 'function' => $name, 'line' => (int) $line, 'doc' => $doc, ); } return $found; } /** * Plain function name for T_STRING and namespaced name tokens. * * @param array $token Token. * @return string */ private static function function_name( $token ) { if ( T_STRING === $token[0] ) { return strtolower( $token[1] ); } if ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $token[0] ) { return strtolower( ltrim( $token[1], '\\' ) ); } return ''; } private function previous_significant( $tokens, $i ) { for ( $j = $i - 1; $j >= 0; $j-- ) { if ( is_array( $tokens[ $j ] ) && in_array( $tokens[ $j ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { continue; } return $tokens[ $j ]; } return null; } private function next_significant_index( $tokens, $i ) { $count = count( $tokens ); for ( $j = $i + 1; $j < $count; $j++ ) { if ( is_array( $tokens[ $j ] ) && in_array( $tokens[ $j ][0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { continue; } return $j; } return null; } /** * Tokens of the first argument after an opening parenthesis. * * @return array { tokens } */ private function first_argument( $tokens, $open, $count ) { $depth = 0; $arg = array(); for ( $j = $open + 1; $j < $count; $j++ ) { $t = $tokens[ $j ]; if ( ! is_array( $t ) ) { if ( in_array( $t, array( '(', '[', '{' ), true ) ) { ++$depth; } elseif ( in_array( $t, array( ')', ']', '}' ), true ) ) { if ( 0 === $depth ) { break; } --$depth; } elseif ( ',' === $t && 0 === $depth ) { break; } } elseif ( in_array( $t[0], array( T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ), true ) ) { ++$depth; } $arg[] = $t; } return array( $arg ); } /** * @param array $arg_tokens Tokens of the first argument. * @return array { name, dynamic } */ private function hook_name( $arg_tokens ) { $significant = array_values( array_filter( $arg_tokens, function ( $t ) { return ! ( is_array( $t ) && in_array( $t[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ); } ) ); if ( 1 === count( $significant ) && is_array( $significant[0] ) && T_CONSTANT_ENCAPSED_STRING === $significant[0][0] ) { $raw = $significant[0][1]; $quote = $raw[0]; $inner = substr( $raw, 1, -1 ); $name = "'" === $quote ? str_replace( array( "\\'", '\\\\' ), array( "'", '\\' ), $inner ) : stripcslashes( $inner ); return array( 'name' => $name, 'dynamic' => false, ); } $text = ''; foreach ( $arg_tokens as $t ) { $text .= is_array( $t ) ? $t[1] : $t; } return array( 'name' => trim( preg_replace( '/\s+/', ' ', $text ) ), 'dynamic' => true, ); } /** * First sentence of a docblock's description. * * @param string $doc Docblock. * @return string */ private static function doc_summary( $doc ) { if ( '' === $doc ) { return ''; } $lines = array(); foreach ( preg_split( '/\R/', $doc ) as $line ) { $line = trim( preg_replace( '#^\s*(/\*\*|\*/|\*)#', '', $line ) ); if ( '' === $line ) { if ( $lines ) { break; } continue; } if ( 0 === strpos( $line, '@' ) ) { break; } $lines[] = $line; } return substr( implode( ' ', $lines ), 0, 300 ); } /** * The scan result as Markdown, for the download. * * @param string $plugin_name Plugin display name. * @param array $result From scan_dir(). * @return string */ public static function to_markdown( $plugin_name, $result ) { /* translators: %s: plugin name */ $out = '# ' . sprintf( __( 'Hooks provided by %s', 'hooktrail' ), $plugin_name ) . "\n\n"; $out .= '| ' . __( 'Hook', 'hooktrail' ) . ' | ' . __( 'Type', 'hooktrail' ) . ' | ' . __( 'File', 'hooktrail' ) . ' | ' . __( 'Description', 'hooktrail' ) . " |\n"; $out .= "| --- | --- | --- | --- |\n"; foreach ( $result['hooks'] as $hook ) { $name = $hook['dynamic'] ? $hook['hook'] . ' ' . __( '(dynamic)', 'hooktrail' ) : $hook['hook']; $type = $hook['type'] . ( $hook['deprecated'] ? ' ' . __( '(deprecated)', 'hooktrail' ) : '' ); $cells = array( '`' . str_replace( '`', "'", $name ) . '`', $type, $hook['file'] . ':' . $hook['line'], $hook['doc'] ); $out .= '| ' . implode( ' | ', array_map( array( __CLASS__, 'md_cell' ), $cells ) ) . " |\n"; } return $out; } private static function md_cell( $text ) { return str_replace( array( '|', "\n", "\r" ), array( '\\|', ' ', '' ), (string) $text ); } /** * Direct filesystem access for reading plugin files. * * @return WP_Filesystem_Direct */ private static function filesystem() { require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php'; return new WP_Filesystem_Direct( null ); } }