diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index e86f0f1d..17c4b6e7 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -78,6 +78,8 @@ To make use of the WP-CLI testing framework, you need to complete the following ``` All other [PHPCS configuration options](https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki/Annotated-Ruleset) are, of course, available. + The PHP snippets embedded in your feature files are checked along with the rest of the package. See [Checking the code style of the PHP blocks in feature files](#checking-the-code-style-of-the-php-blocks-in-feature-files) below. + 6. Optionally add a `phpstan-feature-files.neon.dist` file to the package root to also run PHPStan over the PHP snippets embedded in your feature files. See [Analysing the PHP blocks in feature files](#analysing-the-php-blocks-in-feature-files) below. 7. Update your composer dependencies and regenerate your autoloader and binary folders: @@ -165,6 +167,56 @@ Two kinds of blocks are left out of the analysis, and are listed at the end of t Blocks that declare the same class or function as another block are analysed separately from each other, so that PHPStan does not resolve a name to the wrong block's declaration. +### Checking the code style of the PHP blocks in feature files + +`composer phpcs` also checks the PHP snippets that feature files embed in docstrings, and +`composer phpcbf` fixes them in place. No configuration is needed, and like the analysis above the +blocks are padded so that findings are reported against the feature file itself: + +```text +FILE: features/command.feature +---------------------------------------------------------------------- +FOUND 1 ERROR AFFECTING 1 LINE +---------------------------------------------------------------------- + 438 | ERROR | [x] Expected 1 space after IF keyword; 0 found +---------------------------------------------------------------------- +``` + +Only a docstring belonging to a step that creates a `.php` file is checked: + +```gherkin +Given a wp-content/mu-plugins/test-harness.php file: + """ + + + + + + + + + +``` + +The blocks are left alone when a run is narrowed down to a path, as in `composer phpcs -- src/`, +since such an argument is about the files of the package itself. + ### Controlling what to test To send one or more arguments to one of the test tools, prepend the argument(s) with a double dash. As an example, here's how to run the functional tests for a specific feature file only: diff --git a/bin/run-phpcbf-cleanup b/bin/run-phpcbf-cleanup index f7a4d8fb..677a13ec 100755 --- a/bin/run-phpcbf-cleanup +++ b/bin/run-phpcbf-cleanup @@ -1,7 +1,82 @@ #!/bin/sh -# Run the code style check only if a configuration file exists. +EXIT_CODE=0 + +# 1. Run standard PHPCBF if configuration file exists. if [ -f ".phpcs.xml" ] || [ -f "phpcs.xml" ] || [ -f ".phpcs.xml.dist" ] || [ -f "phpcs.xml.dist" ] then - vendor/bin/phpcbf "$@" + vendor/bin/phpcbf "$@" || EXIT_CODE=$? fi + +# 2. Run PHPCBF over the PHP blocks in .feature files and sync back fixes. +# Composer installs this script as a symlink in the vendor binary directory, so +# it has to be resolved before the root of this package can be derived from it. +SOURCE="$0" +while [ -h "$SOURCE" ] +do + SOURCE_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + # A relative symlink is resolved against the directory holding the symlink. + case "$SOURCE" in + /*) ;; + *) SOURCE="$SOURCE_DIR/$SOURCE" ;; + esac +done +DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" + +# A ruleset of the same purpose in the package root replaces the defaults +# wholesale. Both scripts read the defaults from the same file, so that the +# check and the fixer cannot disagree over which sniff applies to a block. +FEATURE_STANDARD="" +for CANDIDATE in "phpcs-feature-files.xml" "phpcs-feature-files.xml.dist" +do + if [ -f "$CANDIDATE" ] + then + FEATURE_STANDARD="$(pwd)/$CANDIDATE" + break + fi +done + +FEATURE_ARGS="" +if [ -z "$FEATURE_STANDARD" ] && [ -f "$DIR/phpcs/feature-files.sh" ] +then + . "$DIR/phpcs/feature-files.sh" + FEATURE_STANDARD="$WP_CLI_TESTS_FEATURE_STANDARD" + # Holds no path, so leaving it unquoted below splits it into arguments. + FEATURE_ARGS="$WP_CLI_TESTS_FEATURE_ARGS" +fi + +# An argument naming what to fix applies to the files of the package itself, so +# the blocks are left alone once a run has been narrowed down to a path. +FIX_BLOCKS=1 +for ARG in "$@" +do + case "$ARG" in + -*) ;; + *) FIX_BLOCKS=0 ;; + esac +done + +if [ "$FIX_BLOCKS" -eq 1 ] && [ -d "features" ] && [ -n "$FEATURE_STANDARD" ] \ + && [ -f "$DIR/utils/extract-feature-php.php" ] +then + TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'feature_phpcbf') + trap 'rm -rf "$TEMP_DIR"' EXIT HUP INT TERM + + # Fixes are only synced back when the extraction they are based on succeeded. + if php "$DIR/utils/extract-feature-php.php" extract features "$TEMP_DIR" + then + if [ -n "$(ls -A "$TEMP_DIR" 2>/dev/null)" ] + then + # shellcheck disable=SC2086 # Intentional word splitting. + vendor/bin/phpcbf --standard="$FEATURE_STANDARD" $FEATURE_ARGS \ + "$TEMP_DIR" >/dev/null || EXIT_CODE=$? + + php "$DIR/utils/extract-feature-php.php" update features "$TEMP_DIR" >/dev/null || EXIT_CODE=$? + fi + else + EXIT_CODE=1 + fi +fi + +exit $EXIT_CODE diff --git a/bin/run-phpcs-tests b/bin/run-phpcs-tests index 82d96b4e..e973e85f 100755 --- a/bin/run-phpcs-tests +++ b/bin/run-phpcs-tests @@ -1,7 +1,92 @@ #!/bin/sh -# Run the code style check only if a configuration file exists. +EXIT_CODE=0 + +# 1. Run standard PHP code style check if a configuration file exists. if [ -f ".phpcs.xml" ] || [ -f "phpcs.xml" ] || [ -f ".phpcs.xml.dist" ] || [ -f "phpcs.xml.dist" ] then - vendor/bin/phpcs "$@" + vendor/bin/phpcs "$@" || EXIT_CODE=$? fi + +# 2. Run PHPCS over the PHP blocks in .feature files. +# Composer installs this script as a symlink in the vendor binary directory, so +# it has to be resolved before the root of this package can be derived from it. +SOURCE="$0" +while [ -h "$SOURCE" ] +do + SOURCE_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + # A relative symlink is resolved against the directory holding the symlink. + case "$SOURCE" in + /*) ;; + *) SOURCE="$SOURCE_DIR/$SOURCE" ;; + esac +done +DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)" + +# A ruleset of the same purpose in the package root replaces the defaults +# wholesale. Both scripts read the defaults from the same file, so that the +# check and the fixer cannot disagree over which sniff applies to a block. +FEATURE_STANDARD="" +for CANDIDATE in "phpcs-feature-files.xml" "phpcs-feature-files.xml.dist" +do + if [ -f "$CANDIDATE" ] + then + FEATURE_STANDARD="$(pwd)/$CANDIDATE" + break + fi +done + +FEATURE_ARGS="" +if [ -z "$FEATURE_STANDARD" ] && [ -f "$DIR/phpcs/feature-files.sh" ] +then + . "$DIR/phpcs/feature-files.sh" + FEATURE_STANDARD="$WP_CLI_TESTS_FEATURE_STANDARD" + # Holds no path, so leaving it unquoted below splits it into arguments. + FEATURE_ARGS="$WP_CLI_TESTS_FEATURE_ARGS" +fi + +# An argument naming what to check applies to the files of the package itself, +# so the blocks are left alone once a run has been narrowed down to a path. +CHECK_BLOCKS=1 +for ARG in "$@" +do + case "$ARG" in + -*) ;; + *) CHECK_BLOCKS=0 ;; + esac +done + +if [ "$CHECK_BLOCKS" -eq 1 ] && [ -d "features" ] && [ -n "$FEATURE_STANDARD" ] \ + && [ -f "$DIR/utils/extract-feature-php.php" ] +then + TEMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'feature_phpcs') + PHPCS_OUTPUT=$(mktemp 2>/dev/null || mktemp -t 'feature_phpcs_output') + trap 'rm -rf "$TEMP_DIR" "$PHPCS_OUTPUT"' EXIT HUP INT TERM + + # Results are only reported when the extraction they are based on succeeded. + if php "$DIR/utils/extract-feature-php.php" extract features "$TEMP_DIR" + then + if [ -n "$(ls -A "$TEMP_DIR" 2>/dev/null)" ] + then + # The report is written to a file so that the status of PHPCS itself + # is preserved instead of the status of the command rewriting it. + # `--basepath` reduces the reported paths to the part that is worth + # showing, which also keeps PHPCS from truncating them from the left + # once they grow past the width of the report. + # shellcheck disable=SC2086 # Intentional word splitting. + vendor/bin/phpcs --standard="$FEATURE_STANDARD" $FEATURE_ARGS \ + --basepath="$TEMP_DIR" "$TEMP_DIR" >"$PHPCS_OUTPUT" 2>&1 || EXIT_CODE=$? + + # Findings are reported against the feature files they came from. + sed -E \ + -e 's|^FILE: |FILE: features/|' \ + -e 's/\.feature_L[0-9]+_E[0-9]+_(HASPHP|NOPHP)\.php/.feature/g' \ + "$PHPCS_OUTPUT" + fi + else + EXIT_CODE=1 + fi +fi + +exit $EXIT_CODE diff --git a/features/behat-steps.feature b/features/behat-steps.feature index beaeaf5c..399bf094 100644 --- a/features/behat-steps.feature +++ b/features/behat-steps.feature @@ -587,7 +587,7 @@ Feature: Test that WP-CLI Behat steps work as expected And a send-email.php file: """ get_script_name(), '.php' ) . '-'; + + $this->temp_dir = Utils\get_temp_dir() . uniqid( $prefix, true ); + $this->features_dir = $this->temp_dir . '/features'; + $this->target_dir = $this->temp_dir . '/extracted'; + + mkdir( $this->temp_dir ); + mkdir( $this->features_dir ); + } + + protected function tear_down(): void { + if ( is_dir( $this->temp_dir ) ) { + $this->remove_dir( $this->temp_dir ); + } + + parent::tear_down(); + } + + /** + * Recursively removes a directory and its contents. + * + * @param string $dir The directory to remove. + */ + private function remove_dir( $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $iterator as $file ) { + if ( $file->isDir() ) { + rmdir( $file->getPathname() ); + } else { + unlink( $file->getPathname() ); + } + } + + rmdir( $dir ); + } + + /** + * Runs the script under test from within the temporary directory. + * + * @param string[] $args Arguments to pass to the script. + * @return array{output: string, exit_code: int} Combined output and exit code of the script. + */ + protected function run_script( array $args ): array { + $script = dirname( dirname( __DIR__ ) ) . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . $this->get_script_name(); + + $command = escapeshellarg( PHP_BINARY ); + + foreach ( $this->get_php_flags() as $flag ) { + $command .= ' ' . $flag; + } + + $command .= ' ' . escapeshellarg( $script ); + + foreach ( $args as $arg ) { + $command .= ' ' . escapeshellarg( $arg ); + } + + $cd_command = Utils\is_windows() ? 'cd /d ' : 'cd '; + $command = $cd_command . escapeshellarg( $this->temp_dir ) . ' && ' . $command . ' 2>&1'; + + $output = array(); + $exit_code = 0; + + exec( $command, $output, $exit_code ); + + return array( + 'output' => implode( "\n", $output ), + 'exit_code' => $exit_code, + ); + } + + /** + * Creates a feature file in the features directory. + * + * @param string $relative_path Path relative to the features directory. + * @param string $contents Contents of the feature file. + * @return string Full path to the created file. + */ + protected function create_feature_file( $relative_path, $contents ): string { + $path = $this->features_dir . '/' . $relative_path; + + $directory = dirname( $path ); + if ( ! is_dir( $directory ) ) { + mkdir( $directory, 0777, true ); + } + + file_put_contents( $path, $contents ); + + return $path; + } + + /** + * Returns the paths of all extracted files, relative to the target directory. + * + * Extraction only ever writes `.php` files, so anything else in the target + * directory was put there by something other than the script under test. + * + * @return string[] Sorted list of relative file paths. + */ + protected function get_extracted_files(): array { + if ( ! is_dir( $this->target_dir ) ) { + return array(); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $this->target_dir, \FilesystemIterator::SKIP_DOTS ) + ); + + $files = array(); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && 'php' === $file->getExtension() ) { + $files[] = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $this->target_dir ) + 1 ) ); + } + } + + sort( $files ); + + return $files; + } + + /** + * Returns the contents of an extracted file. + * + * @param string $relative_path Path relative to the target directory. + * @return string Contents of the file. + */ + protected function get_extracted_contents( $relative_path ): string { + $contents = file_get_contents( $this->target_dir . '/' . $relative_path ); + + return false === $contents ? '' : $contents; + } +} diff --git a/tests/tests/TestExtractFeaturePhp.php b/tests/tests/TestExtractFeaturePhp.php new file mode 100644 index 00000000..908d01ed --- /dev/null +++ b/tests/tests/TestExtractFeaturePhp.php @@ -0,0 +1,571 @@ +create_feature_file( + 'example.feature', + "Feature: Example\n" + . "\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'example.feature_L5_E8_HASPHP.php' ), $this->get_extracted_files() ); + + // The block is padded with one empty line per preceding line of the + // feature file, so that reported line numbers keep matching. + $this->assertSame( + "\n\n\n\n\nget_extracted_contents( 'example.feature_L5_E8_HASPHP.php' ) + ); + } + + public function test_extracts_block_without_opening_tag(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . "\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " \$foo = 'bar';\n" + . " \"\"\"\n" + ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'example.feature_L5_E7_NOPHP.php' ), $this->get_extracted_files() ); + + // The added opening tag takes the place of the docstring delimiter, so + // that it is not overwritten by the first line of code. + $this->assertSame( + "\n\n\n\nget_extracted_contents( 'example.feature_L5_E7_NOPHP.php' ) + ); + } + + public function test_extracts_multiple_blocks_from_one_feature_file(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . "\n" + . " Scenario: Two PHP blocks\n" + . " Given a first.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + array( + 'example.feature_L10_E13_HASPHP.php', + 'example.feature_L5_E8_HASPHP.php', + ), + $this->get_extracted_files() + ); + $this->assertSame( + "\n\n\n\n\nget_extracted_contents( 'example.feature_L5_E8_HASPHP.php' ) + ); + $this->assertSame( + "\n\n\n\n\n\n\n\n\n\nget_extracted_contents( 'example.feature_L10_E13_HASPHP.php' ) + ); + } + + public function test_extracts_from_nested_directories(): void { + $this->create_feature_file( + 'sub/nested.feature', + "Feature: Nested\n" + . "\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'sub/nested.feature_L5_E8_HASPHP.php' ), $this->get_extracted_files() ); + } + + public function test_extraction_preserves_relative_indentation_and_empty_lines(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "\n\n\n\nget_extracted_contents( 'example.feature_L4_E10_HASPHP.php' ) + ); + } + + public function test_extraction_skips_docstrings_that_are_not_php_files(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: An expectation about a file\n" + . " Then the wp-config.php file should contain:\n" + . " \"\"\"\n" + . " if ( defined( 'X' ) === false ) { define( 'X', true ); }\n" + . " \"\"\"\n" + ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array(), $this->get_extracted_files() ); + } + + public function test_extraction_keeps_empty_lines_before_the_opening_tag(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . "\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "\n\n\n\n\nget_extracted_contents( 'example.feature_L4_E8_HASPHP.php' ) + ); + + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_keeps_unrelated_files_in_target_directory(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " target_dir ); + file_put_contents( $this->target_dir . '/keep-me.txt', 'important' ); + file_put_contents( $this->target_dir . '/stale.feature_L1_E2_HASPHP.php', 'run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertFileExists( $this->target_dir . '/keep-me.txt' ); + $this->assertSame( 'important', file_get_contents( $this->target_dir . '/keep-me.txt' ) ); + $this->assertFileDoesNotExist( $this->target_dir . '/stale.feature_L1_E2_HASPHP.php' ); + } + + public function test_extraction_refuses_to_use_the_source_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', 'features' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertFileExists( $feature_file ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_refuses_to_use_the_current_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', '.' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertDirectoryExists( $this->features_dir ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + + $extracted_files = array(); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $this->temp_dir, \FilesystemIterator::SKIP_DOTS ) + ); + foreach ( $iterator as $file ) { + if ( $file->isFile() && 'php' === $file->getExtension() ) { + $extracted_files[] = $file->getPathname(); + } + } + $this->assertSame( array(), $extracted_files ); + } + + public function test_extraction_reports_unterminated_docstring(): void { + $this->create_feature_file( + 'unterminated.feature', + "Feature: Unterminated\n" + . " Scenario: Unterminated docstring\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Unterminated docstring', $result['output'] ); + $this->assertSame( array(), $this->get_extracted_files() ); + } + + public function test_directories_are_not_mistaken_for_an_action(): void { + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array( 'example.feature_L4_E7_HASPHP.php' ), $this->get_extracted_files() ); + } + + public function test_missing_arguments_are_reported(): void { + $result = $this->run_script( array( 'extract' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Usage:', $result['output'] ); + } + + public function test_update_syncs_fixes_back_into_the_feature_file(): void { + $feature_file = $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $extracted = $this->target_dir . '/example.feature_L4_E7_HASPHP.php'; + file_put_contents( $extracted, "\n\n\n\nrun_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " \$foo='bar';\n" + . " \"\"\"\n" + ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $extracted = $this->target_dir . '/example.feature_L4_E6_NOPHP.php'; + file_put_contents( $extracted, "\n\n\nrun_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " \$foo = 'bar';\n" + . " \"\"\"\n", + file_get_contents( $feature_file ) + ); + } + + public function test_update_without_changes_leaves_the_feature_file_untouched(): void { + // Includes a block starting and ending with an empty line, nested + // directories, and code that is indented relative to the block. + $contents = "Feature: Example\n" + . "\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . "\n" + . " create_feature_file( 'sub/example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_update_reports_unexpected_content_without_changing_the_feature_file(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + // Shift the whole block, so the padding no longer lines up. + $extracted = $this->target_dir . '/example.feature_L4_E7_HASPHP.php'; + file_put_contents( $extracted, "\$shifted = true;\n" . (string) file_get_contents( $extracted ) ); + + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_update_reports_missing_feature_file(): void { + $feature_file = $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + unlink( $feature_file ); + + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'does not exist', $result['output'] ); + } + + public function test_update_skips_block_when_source_coordinates_mismatch(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + // Modify the feature file so the step preceding the docstring no longer creates a PHP file. + $modified_contents = str_replace( 'Given a test.php file:', 'Given a non-php step:', $contents ); + file_put_contents( $feature_file, $modified_contents ); + + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'is no longer the one that was checked', $result['output'] ); + $this->assertSame( $modified_contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_skips_php_docstrings_that_do_not_belong_to_a_php_file_step(): void { + // The block opens with `create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( array(), $this->get_extracted_files() ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_update_preserves_a_block_indented_below_its_opening_tag(): void { + // The first line of the block is not the one carrying the least + // indentation, so the indentation to restore cannot be read off it. + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_update_preserves_mixed_tab_and_space_indentation(): void { + // Extraction takes a shared prefix off the block rather than a number of + // characters, so a tab never comes back as a space or the other way round. + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $this->run_script( array( 'extract', 'features', 'extracted' ) ); + $result = $this->run_script( array( 'update', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_refuses_to_use_a_root_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', '/' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Refusing to use', $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_refuses_a_target_that_only_resolves_to_a_root(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', str_repeat( '../', 64 ) ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Refusing to use', $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } +} diff --git a/tests/tests/TestPhpStanFeatureFiles.php b/tests/tests/TestPhpStanFeatureFiles.php index 275957a3..b49255ee 100644 --- a/tests/tests/TestPhpStanFeatureFiles.php +++ b/tests/tests/TestPhpStanFeatureFiles.php @@ -2,158 +2,22 @@ namespace WP_CLI\Tests\Tests; -use WP_CLI\Tests\TestCase; -use WP_CLI\Utils; - -class TestPhpStanFeatureFiles extends TestCase { - - /** - * @var string - */ - public $temp_dir; - - /** - * @var string - */ - public $features_dir; - - /** - * @var string - */ - public $target_dir; - - protected function set_up(): void { - parent::set_up(); - - $this->temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-test-phpstan-feature-files-', true ); - $this->features_dir = $this->temp_dir . '/features'; - $this->target_dir = $this->temp_dir . '/extracted'; - - mkdir( $this->temp_dir ); - mkdir( $this->features_dir ); - } - - protected function tear_down(): void { - if ( is_dir( $this->temp_dir ) ) { - $this->remove_dir( $this->temp_dir ); - } - - parent::tear_down(); - } - - /** - * Recursively removes a directory and its contents. - * - * @param string $dir The directory to remove. - */ - private function remove_dir( $dir ): void { - if ( ! is_dir( $dir ) ) { - return; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), - \RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ( $iterator as $file ) { - if ( $file->isDir() ) { - rmdir( $file->getPathname() ); - } else { - unlink( $file->getPathname() ); - } - } - - rmdir( $dir ); - } +class TestPhpStanFeatureFiles extends FeatureFilesTestCase { /** - * Runs the phpstan-feature-files.php script from within the temporary directory. - * - * @param string[] $args Arguments to pass to the script. - * @return array{output: string, exit_code: int} Combined output and exit code of the script. + * @return string Name of the script. */ - private function run_script( array $args ): array { - $script = dirname( dirname( __DIR__ ) ) . DIRECTORY_SEPARATOR . 'utils' . DIRECTORY_SEPARATOR . 'phpstan-feature-files.php'; - - // `php.ini` is loaded as usual here, as the script needs ext-tokenizer. - $command = escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( $script ); - - foreach ( $args as $arg ) { - $command .= ' ' . escapeshellarg( $arg ); - } - - $cd_command = Utils\is_windows() ? 'cd /d ' : 'cd '; - $command = $cd_command . escapeshellarg( $this->temp_dir ) . ' && ' . $command . ' 2>&1'; - - $output = array(); - $exit_code = 0; - - exec( $command, $output, $exit_code ); - - return array( - 'output' => implode( "\n", $output ), - 'exit_code' => $exit_code, - ); + protected function get_script_name(): string { + return 'phpstan-feature-files.php'; } /** - * Creates a feature file in the features directory. + * `php.ini` is loaded as usual here, as the script needs ext-tokenizer. * - * @param string $relative_path Path relative to the features directory. - * @param string $contents Contents of the feature file. - * @return string Full path to the created file. + * @return string[] Flags to pass to the PHP binary. */ - private function create_feature_file( $relative_path, $contents ): string { - $path = $this->features_dir . '/' . $relative_path; - - $directory = dirname( $path ); - if ( ! is_dir( $directory ) ) { - mkdir( $directory, 0777, true ); - } - - file_put_contents( $path, $contents ); - - return $path; - } - - /** - * Returns the paths of all extracted files, relative to the target directory. - * - * @return string[] Sorted list of relative file paths. - */ - private function get_extracted_files(): array { - if ( ! is_dir( $this->target_dir ) ) { - return array(); - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator( $this->target_dir, \FilesystemIterator::SKIP_DOTS ) - ); - - $files = array(); - - foreach ( $iterator as $file ) { - if ( $file->isFile() && 'php' === $file->getExtension() ) { - $files[] = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $this->target_dir ) + 1 ) ); - } - } - - sort( $files ); - - return $files; - } - - /** - * Returns the contents of an extracted file. - * - * @param string $relative_path Path relative to the target directory. - * @return string Contents of the file. - */ - private function get_extracted_contents( $relative_path ): string { - $contents = file_get_contents( $this->target_dir . '/' . $relative_path ); - - return false === $contents ? '' : $contents; + protected function get_php_flags(): array { + return array(); } /** @@ -546,6 +410,66 @@ public function test_extraction_refuses_to_use_the_source_directory_as_target(): $this->assertSame( $contents, file_get_contents( $feature_file ) ); } + public function test_extraction_preserves_indentation_that_mixes_tabs_and_spaces(): void { + // The shared indentation is taken off a block as a prefix rather than as + // a number of characters, so a line indented with a tab where the rest of + // the block uses spaces keeps its tab instead of trading it for a space. + $this->create_feature_file( + 'example.feature', + "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " run_script( array( 'extract', 'features', 'extracted' ) ); + + $this->assertSame( 0, $result['exit_code'], $result['output'] ); + $this->assertSame( + "get_extracted_contents( 'batch0/example.feature_L4_E7.php' ) + ); + } + + public function test_extraction_refuses_to_use_a_root_directory_as_target(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', '/' ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Refusing to use', $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + + public function test_extraction_refuses_a_target_that_only_resolves_to_a_root(): void { + $contents = "Feature: Example\n" + . " Scenario: A PHP block\n" + . " Given a test.php file:\n" + . " \"\"\"\n" + . " create_feature_file( 'example.feature', $contents ); + + $result = $this->run_script( array( 'extract', 'features', str_repeat( '../', 64 ) ) ); + + $this->assertSame( 1, $result['exit_code'] ); + $this->assertStringContainsString( 'Refusing to use', $result['output'] ); + $this->assertSame( $contents, file_get_contents( $feature_file ) ); + } + public function test_extraction_refuses_to_use_the_current_directory_as_target(): void { $contents = "Feature: Example\n" . " Scenario: A PHP block\n" diff --git a/utils/extract-feature-php.php b/utils/extract-feature-php.php new file mode 100644 index 00000000..c52fe224 --- /dev/null +++ b/utils/extract-feature-php.php @@ -0,0 +1,373 @@ +, has_php_tag: bool} $block Block to render. + * @return string Source of the standalone PHP file. + */ +function render_fixable_block( array $block ) { + $indent_length = strlen( (string) get_common_indent( $block['lines'] ) ); + + $out_lines = []; + for ( $i = 0; $i < $block['start'] + 1; $i++ ) { + $out_lines[ $i ] = "\n"; + } + + if ( ! $block['has_php_tag'] ) { + $out_lines[ $block['start'] ] = " $code_line ) { + if ( '' === trim( $code_line ) ) { + $out_lines[ $line_idx ] = "\n"; + } else { + $out_lines[ $line_idx ] = substr( $code_line, $indent_length ); + } + } + + return implode( '', $out_lines ); +} + +/** + * Extract PHP blocks from a source directory of feature files to a target directory. + * + * @param string $source_dir Source directory containing .feature files. + * @param string $target_dir Target directory to output extracted .php files. + * @return bool Whether extraction completed successfully. + */ +function extract_feature_php( $source_dir, $target_dir ) { + $source_dir = rtrim( str_replace( '\\', '/', $source_dir ), '/' ); + $target_dir = rtrim( str_replace( '\\', '/', $target_dir ), '/' ); + + if ( ! is_dir( $source_dir ) ) { + fwrite( STDERR, sprintf( 'Source directory "%s" does not exist.', $source_dir ) . PHP_EOL ); + return false; + } + + if ( ! is_valid_target_dir( $target_dir, $source_dir ) ) { + fwrite( STDERR, sprintf( 'Refusing to use "%s" as target directory.', $target_dir ) . PHP_EOL ); + return false; + } + + remove_extracted_files( $target_dir, EXTRACTED_FILE_PATTERN ); + + $success = true; + + foreach ( find_feature_files( $source_dir ) as $filepath ) { + $relative = substr( $filepath, strlen( $source_dir ) + 1 ); + $lines = file( $filepath ); + + if ( false === $lines ) { + fwrite( STDERR, sprintf( 'Could not read "%s".', $filepath ) . PHP_EOL ); + $success = false; + continue; + } + + $blocks = collect_blocks( $lines ); + + if ( null === $blocks ) { + fwrite( STDERR, sprintf( 'Unterminated docstring in "%s".', $filepath ) . PHP_EOL ); + $success = false; + continue; + } + + foreach ( $blocks as $block ) { + // A docstring that merely opens with ` $line ) { + $trimmed = trim( $line ); + + if ( '' === $trimmed ) { + continue; + } + + // The opening tag added during extraction sits right before the code. + if ( ! $had_php_tag && $index === $code_start - 1 && 0 === strpos( $trimmed, 'isFile() && 'php' === $file->getExtension() ) { + $temp_filepath = str_replace( '\\', '/', $file->getPathname() ); + $temp_filename = $file->getFilename(); + + if ( ! preg_match( EXTRACTED_FILE_PATTERN, $temp_filename, $matches ) ) { + continue; + } + + $sub_path = substr( dirname( $temp_filepath ), strlen( $target_dir ) ); + $feature_rel_path = ( '' !== $sub_path ? $sub_path . '/' : '' ) . $matches[1]; + $feature_path = $source_dir . '/' . ltrim( $feature_rel_path, '/' ); + + $files_by_feature[ $feature_path ][] = [ + 'temp_filepath' => $temp_filepath, + 'docstring_start' => (int) $matches[2] - 1, + 'docstring_end' => (int) $matches[3] - 1, + 'had_php_tag' => 'HASPHP' === $matches[4], + ]; + } + } + + $success = true; + + foreach ( $files_by_feature as $feature_path => $blocks ) { + if ( ! file_exists( $feature_path ) ) { + fwrite( STDERR, sprintf( 'Feature file "%s" does not exist.', $feature_path ) . PHP_EOL ); + $success = false; + continue; + } + + usort( + $blocks, + function ( $a, $b ) { + return $b['docstring_start'] <=> $a['docstring_start']; + } + ); + + $feature_lines = file( $feature_path ); + + if ( false === $feature_lines ) { + fwrite( STDERR, sprintf( 'Could not read "%s".', $feature_path ) . PHP_EOL ); + $success = false; + continue; + } + + foreach ( $blocks as $block ) { + $docstring_start = $block['docstring_start']; + $docstring_end = $block['docstring_end']; + $code_start = $docstring_start + 1; + $code_end = $docstring_end - 1; + $temp_lines = file( $block['temp_filepath'] ); + + if ( false === $temp_lines ) { + fwrite( STDERR, sprintf( 'Could not read "%s".', $block['temp_filepath'] ) . PHP_EOL ); + $success = false; + continue; + } + + if ( + $code_start > $code_end + || ! isset( $feature_lines[ $docstring_start ] ) + || ! isset( $feature_lines[ $docstring_end ] ) + || ( 0 !== strpos( trim( $feature_lines[ $docstring_start ] ), '"""' ) && 0 !== strpos( trim( $feature_lines[ $docstring_start ] ), "'''" ) ) + || ( 0 !== strpos( trim( $feature_lines[ $docstring_end ] ), '"""' ) && 0 !== strpos( trim( $feature_lines[ $docstring_end ] ), "'''" ) ) + || 0 === $docstring_start + || ! isset( $feature_lines[ $docstring_start - 1 ] ) + || ! is_php_file_step( $feature_lines[ $docstring_start - 1 ] ) + ) { + fwrite( + STDERR, + sprintf( + 'The block at "%s" line %d is no longer the one that was checked, dropping its fixes.', + $feature_path, + $docstring_start + 1 + ) . PHP_EOL + ); + $success = false; + continue; + } + + $code_lines = strip_extraction_padding( $temp_lines, $code_start, $block['had_php_tag'] ); + + if ( null === $code_lines ) { + fwrite( + STDERR, + sprintf( + 'The checked copy of the block at "%s" line %d is padded unexpectedly, dropping its fixes.', + $feature_path, + $docstring_start + 1 + ) . PHP_EOL + ); + $success = false; + continue; + } + + $indent = get_block_indent( $feature_lines, $code_start, $code_end, $block['docstring_start'] ); + + $fixed_lines = []; + foreach ( $code_lines as $line_content ) { + if ( '' === trim( $line_content ) ) { + $fixed_lines[] = "\n"; + continue; + } + + $fixed_line = $indent . $line_content; + if ( "\n" !== substr( $fixed_line, -1 ) ) { + $fixed_line .= "\n"; + } + + $fixed_lines[] = $fixed_line; + } + + $num_code_lines = ( $code_end - $code_start + 1 ); + array_splice( $feature_lines, $code_start, $num_code_lines, $fixed_lines ); + } + + if ( false === file_put_contents( $feature_path, implode( '', $feature_lines ) ) ) { + fwrite( STDERR, sprintf( 'Could not write "%s".', $feature_path ) . PHP_EOL ); + $success = false; + } + } + + return $success; +} + +$wp_cli_tests_args = array_slice( $argv, 1 ); +$wp_cli_tests_action = 'extract'; + +// Only treat the first argument as an action if it actually is one, so that +// a source directory does not accidentally end up being used as target. +if ( isset( $wp_cli_tests_args[0] ) && in_array( $wp_cli_tests_args[0], [ 'extract', 'update' ], true ) ) { + $wp_cli_tests_action = array_shift( $wp_cli_tests_args ); +} + +$wp_cli_tests_source = $wp_cli_tests_args[0] ?? ''; +$wp_cli_tests_target = $wp_cli_tests_args[1] ?? ''; + +if ( '' === $wp_cli_tests_source || '' === $wp_cli_tests_target ) { + fwrite( STDERR, 'Usage: extract-feature-php.php [extract|update] ' . PHP_EOL ); + exit( 1 ); +} + +if ( 'update' === $wp_cli_tests_action ) { + exit( update_feature_php( $wp_cli_tests_source, $wp_cli_tests_target ) ? 0 : 1 ); +} + +exit( extract_feature_php( $wp_cli_tests_source, $wp_cli_tests_target ) ? 0 : 1 ); diff --git a/utils/feature-php-blocks.php b/utils/feature-php-blocks.php new file mode 100644 index 00000000..219598ab --- /dev/null +++ b/utils/feature-php-blocks.php @@ -0,0 +1,305 @@ +isFile() && 'feature' === $file->getExtension() ) { + $feature_files[] = str_replace( '\\', '/', $file->getPathname() ); + } + } + + // A stable order keeps the results of a run comparable to those of the next. + sort( $feature_files ); + + return $feature_files; +} + +/** + * Determine the indentation that all lines holding code share. + * + * This is what extraction takes off a block and what syncing puts back, so it + * is determined as an actual prefix rather than as a number of characters: a + * block mixing tabs and spaces would otherwise come back with one swapped for + * the other. Blank lines carry no indentation of their own and are left out. + * + * @param string[] $lines Lines to compare. + * @return string|null Shared indentation, or null if no line holds code. + */ +function get_common_indent( array $lines ) { + $common = null; + + foreach ( $lines as $line ) { + if ( '' === trim( $line ) ) { + continue; + } + + preg_match( '/^[ \t]*/', $line, $matches ); + + if ( null === $common ) { + $common = $matches[0]; + continue; + } + + $length = min( strlen( $common ), strlen( $matches[0] ) ); + while ( $length > 0 && substr( $common, 0, $length ) !== substr( $matches[0], 0, $length ) ) { + --$length; + } + + $common = substr( $common, 0, $length ); + + if ( '' === $common ) { + break; + } + } + + return $common; +} + +/** + * Collect the PHP blocks contained in a single feature file. + * + * A docstring holds a PHP block when the step it belongs to creates a `.php` + * file, and also when it opens with `, from_step: bool, has_php_tag: bool}>|null Blocks, or null on an unterminated docstring. + */ +function collect_blocks( array $lines ) { + $blocks = []; + $in_docstring = false; + $from_step = false; + $has_php_tag = false; + $has_content = false; + $start_line = 0; + $docstring_lines = []; + + foreach ( $lines as $index => $line ) { + $trimmed = trim( $line ); + + if ( 0 === strpos( $trimmed, '"""' ) || 0 === strpos( $trimmed, "'''" ) ) { + if ( ! $in_docstring ) { + $in_docstring = true; + $from_step = $index > 0 && is_php_file_step( $lines[ $index - 1 ] ); + $has_php_tag = false; + $has_content = false; + $docstring_lines = []; + $start_line = $index; + } else { + $in_docstring = false; + + if ( ( $from_step || $has_php_tag ) && ! empty( $docstring_lines ) ) { + $blocks[] = [ + 'start' => $start_line, + 'end' => $index, + 'lines' => $docstring_lines, + 'from_step' => $from_step, + 'has_php_tag' => $has_php_tag, + ]; + } + } + continue; + } + + if ( $in_docstring ) { + if ( ! $has_content && 0 === strpos( $trimmed, 'getPathname(); + + if ( $fileinfo->isDir() ) { + $contents = new FilesystemIterator( $pathname ); + if ( ! $contents->valid() ) { + rmdir( $pathname ); + } + } elseif ( preg_match( $pattern, $fileinfo->getFilename() ) ) { + unlink( $pathname ); + } + } +} diff --git a/utils/phpstan-feature-files.php b/utils/phpstan-feature-files.php index b4339ef1..9228ac43 100644 --- a/utils/phpstan-feature-files.php +++ b/utils/phpstan-feature-files.php @@ -12,10 +12,9 @@ namespace WP_CLI\Tests; -use FilesystemIterator; use ParseError; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; + +require_once __DIR__ . '/feature-php-blocks.php'; /** * Pattern matching the file names created during extraction. @@ -27,180 +26,6 @@ */ const MANIFEST_FILE = 'manifest.json'; -/** - * Bring a path into the form used to compare it against another path. - * - * @param string $path Path to normalize. - * @return string Normalized path. - */ -function normalize_path( $path ) { - $path = rtrim( str_replace( '\\', '/', $path ), '/' ); - - // Windows paths are not case sensitive. - return DIRECTORY_SEPARATOR === '\\' ? strtolower( $path ) : $path; -} - -/** - * Determine whether a directory can be used as extraction target. - * - * Extraction removes previously extracted files from the target directory, - * so guard against pointing it at a directory holding actual project files. - * - * @param string $target_dir Target directory to output extracted .php files. - * @param string $source_dir Source directory containing .feature files. - * @return bool Whether the target directory can be used. - */ -function is_valid_target_dir( $target_dir, $source_dir ) { - if ( '' === $target_dir || '.' === $target_dir || '..' === $target_dir ) { - return false; - } - - // A Windows drive root, such as `C:`, `C:\`, or `C:/`. - if ( preg_match( '/^[a-z]:[\\\\\/]?$/i', $target_dir ) ) { - return false; - } - - $target_real = realpath( $target_dir ); - - // A directory that does not exist yet gets created during extraction. - if ( false === $target_real ) { - return true; - } - - $cwd = getcwd(); - if ( false !== $cwd && realpath( $cwd ) === $target_real ) { - return false; - } - - $source_real = realpath( $source_dir ); - if ( false === $source_real ) { - return true; - } - - if ( $source_real === $target_real ) { - return false; - } - - // The target directory contains the feature files themselves. - if ( 0 === strpos( $source_real . DIRECTORY_SEPARATOR, $target_real . DIRECTORY_SEPARATOR ) ) { - return false; - } - - return true; -} - -/** - * Remove files of a previous extraction from the target directory. - * - * Only files created by this script and the directories that held them are - * removed, so that an unrelated file in the target directory is never lost. - * - * @param string $target_dir Target directory containing extracted .php files. - * @return void - */ -function remove_extracted_files( $target_dir ) { - if ( ! is_dir( $target_dir ) ) { - return; - } - - $manifest = $target_dir . '/' . MANIFEST_FILE; - if ( is_file( $manifest ) ) { - unlink( $manifest ); - } - - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $target_dir, RecursiveDirectoryIterator::SKIP_DOTS ), - RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ( $files as $fileinfo ) { - $pathname = $fileinfo->getPathname(); - - if ( $fileinfo->isDir() ) { - $contents = new FilesystemIterator( $pathname ); - if ( ! $contents->valid() ) { - rmdir( $pathname ); - } - } elseif ( preg_match( EXTRACTED_FILE_PATTERN, $fileinfo->getFilename() ) ) { - unlink( $pathname ); - } - } -} - -/** - * Determine whether a step creates a PHP file. - * - * The docstring following such a step holds the contents of a PHP file, while - * docstrings following other steps -- an expectation about the contents of a - * file, for example -- are not necessarily PHP code. A docstring that opens - * with `}>|null Blocks, or null on an unterminated docstring. - */ -function collect_blocks( array $lines ) { - $blocks = []; - $in_docstring = false; - $is_php_block = false; - $has_content = false; - $start_line = 0; - $docstring_lines = []; - - foreach ( $lines as $index => $line ) { - $trimmed = trim( $line ); - - if ( 0 === strpos( $trimmed, '"""' ) || 0 === strpos( $trimmed, "'''" ) ) { - if ( ! $in_docstring ) { - $in_docstring = true; - $is_php_block = $index > 0 && is_php_file_step( $lines[ $index - 1 ] ); - $has_content = false; - $docstring_lines = []; - $start_line = $index; - } else { - $in_docstring = false; - - if ( $is_php_block && ! empty( $docstring_lines ) ) { - $blocks[] = [ - 'start' => $start_line, - 'end' => $index, - 'lines' => $docstring_lines, - ]; - } - } - continue; - } - - if ( $in_docstring ) { - // A block opening with `isFile() && 'feature' === $file->getExtension() ) { - $feature_files[] = str_replace( '\\', '/', $file->getPathname() ); - } - } - - // The order determines the batch a block ends up in, so keep it stable. - sort( $feature_files ); - - foreach ( $feature_files as $filepath ) { + // The order determines the batch a block ends up in, so it has to be stable. + foreach ( find_feature_files( $source_dir ) as $filepath ) { $relative = substr( $filepath, strlen( $source_dir ) + 1 ); $lines = file( $filepath );