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 );