From 8795f233b670b0f2e0b9f082cdfc1ce450de70db Mon Sep 17 00:00:00 2001 From: Brian Henry Date: Wed, 1 May 2024 20:53:06 -0700 Subject: [PATCH 1/6] Add failing test --- features/dist-archive.feature | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/features/dist-archive.feature b/features/dist-archive.feature index ff4d809..a284d87 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 foo/.distignore file: + """ + """ + And a lifterlms/lifterlms.php file: + """ + Date: Wed, 1 May 2024 21:07:48 -0700 Subject: [PATCH 2/6] Extract `Version_Tool` class --- src/Dist_Archive_Command.php | 82 ++-------------------- src/Version_Tool.php | 129 +++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 77 deletions(-) create mode 100644 src/Version_Tool.php 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..6859253 --- /dev/null +++ b/src/Version_Tool.php @@ -0,0 +1,129 @@ +).*/', '', $match[1] ) ); + } + } + + if ( empty( $version ) ) { + foreach ( glob( $path . '/*.php' ) as $php_file ) { + $contents = file_get_contents( $php_file, false, null, 0, 5000 ); + $version = $this->get_version_in_code( $contents ); + if ( ! empty( $version ) ) { + $version = trim( $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; + } + + /** + * 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. + */ + public 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; + } +} From d75f9a9afb86192a303083d8a9f362098f33eb81 Mon Sep 17 00:00:00 2001 From: Brian Henry Date: Wed, 1 May 2024 21:08:12 -0700 Subject: [PATCH 3/6] Change PHP requirement to 7.4 It was already 7.4 elsewhere --- phpcs.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 88e4f5d..0dd2806 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -38,7 +38,7 @@ - + From f91c2dd97a1136041b634d7db965659e1f3279fb Mon Sep 17 00:00:00 2001 From: Brian Henry Date: Wed, 1 May 2024 21:35:26 -0700 Subject: [PATCH 4/6] Fix test to include `.distignore` --- features/dist-archive.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/dist-archive.feature b/features/dist-archive.feature index a284d87..ee8f500 100644 --- a/features/dist-archive.feature +++ b/features/dist-archive.feature @@ -497,7 +497,7 @@ Feature: Generate a distribution archive of a project Scenario: Uses version from plugin header when multiple versions are present Given an empty directory - And a foo/.distignore file: + And a lifterlms/.distignore file: """ """ And a lifterlms/lifterlms.php file: From fb7dce83ac199c33f00605730410f3fd6e8896ba Mon Sep 17 00:00:00 2001 From: Brian Henry Date: Wed, 1 May 2024 21:35:54 -0700 Subject: [PATCH 5/6] Only consider "Version" from php files with "Plugin Name" --- src/Version_Tool.php | 126 ++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 68 deletions(-) diff --git a/src/Version_Tool.php b/src/Version_Tool.php index 6859253..8ef8e1d 100644 --- a/src/Version_Tool.php +++ b/src/Version_Tool.php @@ -24,10 +24,18 @@ public function get_version( string $path ): ?string { if ( empty( $version ) ) { foreach ( glob( $path . '/*.php' ) as $php_file ) { - $contents = file_get_contents( $php_file, false, null, 0, 5000 ); - $version = $this->get_version_in_code( $contents ); - if ( ! empty( $version ) ) { - $version = trim( $version ); + $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; } } @@ -52,78 +60,60 @@ public function get_version( string $path ): ?string { } /** - * Gets the content of a version tag in any doc block in the given source code string. + * Retrieves metadata from a file. * - * The version tag might be specified as "@version x.y.z" or "Version: x.y.z" and it can - * be preceded by an asterisk (*). + * 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 * - * @param string $code_str The source code string to look into. - * @return null|string The detected version string. - */ - public 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. + * 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. * - * @param string $docblock Docblock to parse. - * @return null|string The content of the version tag. + * @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_version_in_docblock( $docblock ) { - $docblocktags = $this->parse_doc_block( $docblock ); - if ( isset( $docblocktags['version'] ) ) { - return $docblocktags['version']; + 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 = ''; } - 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; - } - } + // Make sure we catch CR-only line endings. + $file_data = str_replace( "\r", "\n", $file_data ); - $tag_name = strtolower( $matches[1] ); - $metadata = trim( isset( $matches[2] ) ? $matches[2] : '' ); + /** + * 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 ) ); + }; - $tags[ $tag_name ] = $metadata; + 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 $tags; + + return $all_headers; } } From 1c1e5bbe01900f9890f2e16968b131db9a907d50 Mon Sep 17 00:00:00 2001 From: Brian Henry Date: Wed, 8 May 2024 06:55:57 -0700 Subject: [PATCH 6/6] Do not crash when a broken symlink is encountered (#91) "Broken symlink at {$relative_filepath}. Target missing at {$item->getLinkTarget()}."