diff --git a/features/dist-archive.feature b/features/dist-archive.feature index ff4d809..ee8f500 100644 --- a/features/dist-archive.feature +++ b/features/dist-archive.feature @@ -494,3 +494,41 @@ Feature: Generate a distribution archive of a project """ And the {RUN_DIR}/subdir/hello-world-dist.zip file should exist And the return code should be 0 + + Scenario: Uses version from plugin header when multiple versions are present + Given an empty directory + And a lifterlms/.distignore file: + """ + """ + And a lifterlms/lifterlms.php file: + """ + - + diff --git a/src/Dist_Archive_Command.php b/src/Dist_Archive_Command.php index f4c66dc..fafcf58 100644 --- a/src/Dist_Archive_Command.php +++ b/src/Dist_Archive_Command.php @@ -12,6 +12,8 @@ class Dist_Archive_Command { */ private $checker; + private Version_Tool $version_tool; + /** * Create a distribution archive based on a project's .distignore file. * @@ -69,6 +71,8 @@ class Dist_Archive_Command { */ public function __invoke( $args, $assoc_args ) { + $this->version_tool = new Version_Tool(); + list( $source_dir_path, $destination_dir_path, $archive_file_name, $archive_output_dir_name ) = $this->get_file_paths_and_names( $args, $assoc_args ); $this->checker = new GitIgnoreChecker( $source_dir_path, '.distignore' ); @@ -241,7 +245,7 @@ private function get_file_paths_and_names( $args, $assoc_args ) { : basename( $source_dir_path ); if ( is_null( $archive_file_name ) ) { - $version = $this->get_version( $source_dir_path ); + $version = $this->version_tool->get_version( $source_dir_path ); // If the version number has been found, substitute it into the filename-format template, or just use the name. $archive_file_stem = ! empty( $version ) @@ -326,82 +330,6 @@ private function maybe_create_directory( $destination_dir_path ) { } } - /** - * Gets the content of a version tag in any doc block in the given source code string. - * - * The version tag might be specified as "@version x.y.z" or "Version: x.y.z" and it can - * be preceded by an asterisk (*). - * - * @param string $code_str The source code string to look into. - * @return null|string The detected version string. - */ - private function get_version_in_code( $code_str ) { - $tokens = array_values( - array_filter( - token_get_all( $code_str ), - function ( $token ) { - return ! is_array( $token ) || T_WHITESPACE !== $token[0]; - } - ) - ); - foreach ( $tokens as $token ) { - if ( T_DOC_COMMENT === $token[0] ) { - $version = $this->get_version_in_docblock( $token[1] ); - if ( null !== $version ) { - return $version; - } - } - } - return null; - } - - /** - * Gets the content of a version tag in a docblock. - * - * @param string $docblock Docblock to parse. - * @return null|string The content of the version tag. - */ - private function get_version_in_docblock( $docblock ) { - $docblocktags = $this->parse_doc_block( $docblock ); - if ( isset( $docblocktags['version'] ) ) { - return $docblocktags['version']; - } - return null; - } - - /** - * Parses a docblock and gets an array of tags with their values. - * - * The tags might be specified as "@version x.y.z" or "Version: x.y.z" and they can - * be preceded by an asterisk (*). - * - * This code is based on the 'phpactor' package. - * @see https://github.com/phpactor/docblock/blob/master/lib/Parser.php - * - * @param string $docblock Docblock to parse. - * @return array Associative array of parsed data. - */ - private function parse_doc_block( $docblock ) { - $tag_documentor = '{@([a-zA-Z0-9-_\\\]+)\s*?(.*)?}'; - $tag_property = '{\s*\*?\s*(.*?):(.*)}'; - $lines = explode( PHP_EOL, $docblock ); - $tags = []; - - foreach ( $lines as $line ) { - if ( 0 === preg_match( $tag_documentor, $line, $matches ) ) { - if ( 0 === preg_match( $tag_property, $line, $matches ) ) { - continue; - } - } - - $tag_name = strtolower( $matches[1] ); - $metadata = trim( isset( $matches[2] ) ? $matches[2] : '' ); - - $tags[ $tag_name ] = $metadata; - } - return $tags; - } - /** * Run PHP's escapeshellcmd() then undo escaping known intentional characters. * diff --git a/src/Version_Tool.php b/src/Version_Tool.php new file mode 100644 index 0000000..8ef8e1d --- /dev/null +++ b/src/Version_Tool.php @@ -0,0 +1,119 @@ +).*/', '', $match[1] ) ); + } + } + + if ( empty( $version ) ) { + foreach ( glob( $path . '/*.php' ) as $php_file ) { + $headers = $this->get_file_data( + $php_file, + array( + 'name' => 'Plugin Name', + 'version' => 'Version', + ) + ); + if ( empty( $headers['name'] ) ) { + continue; + } + if ( ! empty( $headers['version'] ) ) { + $version = $headers['version']; + break; + } + } + } + + if ( empty( $version ) && file_exists( $path . '/composer.json' ) ) { + $composer_obj = json_decode( file_get_contents( $path . '/composer.json' ) ); + if ( ! empty( $composer_obj->version ) ) { + $version = trim( $composer_obj->version ); + } + } + + if ( ! empty( $version ) && false !== stripos( $version, '-alpha' ) && is_dir( $path . '/.git' ) ) { + $response = WP_CLI::launch( "cd {$path}; git log --pretty=format:'%h' -n 1", false, true ); + $maybe_hash = trim( $response->stdout ); + if ( $maybe_hash && 7 === strlen( $maybe_hash ) ) { + $version .= '-' . $maybe_hash; + } + } + + return $version; + } + + /** + * Retrieves metadata from a file. + * + * Modified slightly from WordPress 6.5.2 wp-includes/functions.php:6830 + * @see get_file_data() + * @see https://github.com/WordPress/WordPress/blob/ddc3f387b5df4687f5b829119d0c0f797be674bf/wp-includes/functions.php#L6830-L6888 + * + * Searches for metadata in the first 8 KB of a file, such as a plugin or theme. + * Each piece of metadata must be on its own line. Fields can not span multiple + * lines, the value will get cut at the end of the first line. + * + * @link https://codex.wordpress.org/File_Header + * + * @param string $file Absolute path to the file. + * @param array $all_headers List of headers, in the format `array( 'HeaderKey' => 'Header Name' )`. + * @return string[] Array of file header values keyed by header name. + */ + private function get_file_data( string $file, array $all_headers ): array { + + /** + * @see wp_initial_constants() + * `define( 'KB_IN_BYTES', 1024 );` + */ + $kb_in_bytes = 1024; + + // Pull only the first 8 KB of the file in. + $file_data = file_get_contents( $file, false, null, 0, 8 * $kb_in_bytes ); + + if ( false === $file_data ) { + $file_data = ''; + } + + // Make sure we catch CR-only line endings. + $file_data = str_replace( "\r", "\n", $file_data ); + + /** + * Strips close comment and close php tags from file headers used by WP. + * + * functions.php:6763 + * + * @param string $str Header comment to clean up. + * @return string + */ + $_cleanup_header_comment = function ( $str ) { + return trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $str ) ); + }; + + foreach ( $all_headers as $field => $regex ) { + if ( preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) { + $all_headers[ $field ] = $_cleanup_header_comment( $match[1] ); + } else { + $all_headers[ $field ] = ''; + } + } + + return $all_headers; + } +}