diff --git a/docs/commands.md b/docs/commands.md index ec94b9b..17cb181 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -11,6 +11,7 @@ * [`pup i18n`](/docs/commands.md#pup-i18n) * [`pup info`](/docs/commands.md#pup-info) * [`pup package`](/docs/commands.md#pup-package) +* [`pup replace-tbd`](/docs/commands.md#pup-replace-tbd) * [`pup workflow`](/docs/commands.md#pup-workflow) * [`pup zip`](/docs/commands.md#pup-zip) * [`pup zip-name`](/docs/commands.md#pup-zip-name) @@ -75,8 +76,9 @@ composer -- pup check ### `pup check:tbd` Scans your files for `tbd` (case-insensitive) and tells you where to find them. -The `tbd` check will scan your files in relevant locations (`@since`, `@todo`, `@version`, etc) and display the files -and line numbers where they appear. +The `tbd` check will scan your files in relevant locations (`@since`, `@todo`, `@version`, etc, as well as quoted +`'tbd'`/`"tbd"` strings such as `_deprecated_function( __METHOD__, 'TBD' )`) and display the files and line numbers +where they appear. #### Usage ```bash @@ -273,6 +275,30 @@ composer -- pup package | `--root` | **Optional.** Run the command from a different directory from the current. | +## `pup replace-tbd` +Replaces `TBD` version placeholders in your codebase with the version you provide. + +This is the companion to the [`tbd` check](/docs/commands.md#pup-checktbd): it scans the same directories (using the `tbd` check's `dirs`, `skip_files`, and `skip_directories` configuration) and resolves the `TBD` version placeholders it flags — docblock tag values such as `@since TBD`, `@deprecated TBD`, and `@version TBD`, and quoted `'tbd'`/`"tbd"` strings (including those passed to `_deprecated_*()` calls, e.g. `_deprecated_function( __METHOD__, 'TBD' )`). Only the placeholder itself is replaced; an unrelated `tbd` elsewhere on a line (e.g. a word in prose) is left untouched. + +It's typically run during release prep, once you know the version the pending changes will ship in. + +Unlike `pup package`, this command writes the changes directly to your working files and does **not** restore them afterward. Use `--dry-run` to preview the changes first, or your version control system (e.g. `git checkout`) to undo them. + +### Usage +```bash +pup replace-tbd [--dry-run] +# or +composer -- pup replace-tbd [--dry-run] +``` + +### Arguments +| Argument | Description | +|-------------|----------------------------------------------------------------------------------------------------------| +| `version` | **Required.** The version to replace `TBD` placeholders with. | +| `--dry-run` | **Optional.** Print the files and number of replacements that would be made, without modifying anything. | +| `--root` | **Optional.** Run the command from a different directory from the current. | + + ## `pup workflow` Run a command workflow. diff --git a/src/App.php b/src/App.php index 24f0ab2..e920d13 100644 --- a/src/App.php +++ b/src/App.php @@ -62,6 +62,7 @@ public function __construct( string $version ) { $this->add( new Commands\I18n() ); $this->add( new Commands\Info() ); $this->add( new Commands\Package() ); + $this->add( new Commands\ReplaceTbd() ); $this->add( new Commands\Workflow() ); $this->add( new Commands\Zip() ); $this->add( new Commands\ZipName() ); diff --git a/src/Check/TbdScanner.php b/src/Check/TbdScanner.php new file mode 100644 index 0000000..11d73ae --- /dev/null +++ b/src/Check/TbdScanner.php @@ -0,0 +1,169 @@ +files_to_skip = str_replace( '.', '\.', $files_to_skip ); + $this->directories_to_skip = str_replace( '.', '\.', $directories_to_skip ); + } + + /** + * Builds a scanner from a check config array (e.g. the `tbd` check config). + * + * @param array $config + * + * @return self + */ + public static function fromConfig( array $config ): self { + $files_to_skip = ! empty( $config['skip_files'] ) ? (string) $config['skip_files'] : self::DEFAULT_SKIP_FILES; + $directories_to_skip = ! empty( $config['skip_directories'] ) ? (string) $config['skip_directories'] : self::DEFAULT_SKIP_DIRECTORIES; + + return new self( $files_to_skip, $directories_to_skip ); + } + + /** + * Returns the eligible (non-skipped) file short-paths within a scan dir. + * + * @param string $root The root directory to scan from. + * @param string $current_dir The current working directory, stripped from paths. + * @param string $scan_dir The directory (relative to root) to scan. + * + * @return string[] + */ + public function getFiles( string $root, string $current_dir, string $scan_dir ): array { + $files = []; + + $dir = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $root . '/' . $scan_dir ) ); + foreach ( $dir as $file ) { + // Skip directories like "." and ".." to avoid file_get_contents errors. + if ( $file->isDir() ) { + continue; + } + + $file_path = $file->getPathname(); + $short_path = (string) str_replace( $current_dir . '/', '', $file_path ); + + if ( preg_match( '!(' . $this->files_to_skip . ')$!', $short_path ) ) { + continue; + } + + if ( preg_match( '!(\.pup-)|(\.puprc)!', $short_path ) ) { + continue; + } + + $directory_separator = DIRECTORY_SEPARATOR; + if ( $directory_separator === '\\' ) { + $directory_separator = '\\\\'; + } + + if ( preg_match( '!(' . $this->directories_to_skip . ')' . $directory_separator . '!', $short_path ) ) { + continue; + } + + $files[] = $short_path; + } + + return $files; + } + + /** + * Whether a line contains a TBD version placeholder. + * + * Detection is derived from {@see self::REPLACEMENTS} so that `check:tbd` + * flags exactly the placeholders `replace-tbd` is able to resolve — the two + * cannot drift apart. + * + * @param string $line + * + * @return bool + */ + public function lineMatches( string $line ): bool { + foreach ( array_keys( self::REPLACEMENTS ) as $pattern ) { + if ( preg_match( $pattern, $line ) ) { + return true; + } + } + + return false; + } + + /** + * Replacement patterns that target the TBD *placeholder* specifically, rather + * than any `tbd` token on a flagged line. The captured context is preserved and + * `%version%` marks where the version is written. + * + * @var array Map of pattern => replacement template. + */ + const REPLACEMENTS = [ + // Docblock tag value: @since / @deprecated / @version TBD + '/(@(?:since|deprecated|version)\s+)tbd\b/i' => '${1}%version%', + // Quoted placeholder: 'tbd' or "tbd" (e.g. _deprecated_function( ..., 'TBD' )) + '/([\'"])tbd\1/i' => '${1}%version%${1}', + ]; + + /** + * Replaces TBD version placeholders on a line with the given version. + * + * Only genuine placeholders are touched — docblock tag values (`@since TBD`) + * and quoted strings (`'tbd'`). A non-placeholder `tbd` elsewhere on the line + * (e.g. prose in a comment) is left intact, so this never corrupts unrelated + * text. + * + * @param string $line The line to process. + * @param string $version The version to replace TBD with. + * @param int $count Set to the number of replacements made on the line. + * + * @return string The (possibly) modified line. + */ + public function replaceInLine( string $line, string $version, int &$count = 0 ): string { + $count = 0; + + // Escape characters that are significant in a preg replacement string so a + // version like "1.0-$1" is written literally rather than as a backreference. + $safe_version = str_replace( [ '\\', '$' ], [ '\\\\', '\\$' ], $version ); + + $patterns = array_keys( self::REPLACEMENTS ); + $replacements = array_map( + static function ( string $template ) use ( $safe_version ): string { + return str_replace( '%version%', $safe_version, $template ); + }, + array_values( self::REPLACEMENTS ) + ); + + $replaced = preg_replace( $patterns, $replacements, $line, -1, $count ); + + return $replaced === null ? $line : $replaced; + } +} diff --git a/src/Commands/Checks/Tbd.php b/src/Commands/Checks/Tbd.php index 613de91..084cb0d 100644 --- a/src/Commands/Checks/Tbd.php +++ b/src/Commands/Checks/Tbd.php @@ -3,6 +3,7 @@ namespace StellarWP\Pup\Commands\Checks; use StellarWP\Pup\App; +use StellarWP\Pup\Check\TbdScanner; use StellarWP\Pup\Command\Io; use Symfony\Component\Console\Input\InputInterface; @@ -38,34 +39,20 @@ protected function checkExecute( InputInterface $input, Io $output ): int { $found_tbds = false; - $files_to_skip = '.min.css|.min.js|.map.js|.css|.png|.jpg|.jpeg|.svg|.gif|.ico'; - $directories_to_skip = 'bin|build|vendor|node_modules|.git|.github|tests'; - - $check_config = $this->check_config->getConfig(); - - if ( ! empty( $this->check_config->getConfig()['skip_files'] ) ) { - $files_to_skip = $this->check_config->getConfig()['skip_files']; - } - - if ( ! empty( $this->check_config->getConfig()['skip_directories'] ) ) { - $directories_to_skip = $this->check_config->getConfig()['skip_directories']; - } - - $files_to_skip = str_replace( '.', '\.', $files_to_skip ); - $directories_to_skip = str_replace( '.', '\.', $directories_to_skip ); + $config = $this->check_config->getConfig(); + $scanner = TbdScanner::fromConfig( $config ); $matched_lines = []; $current_dir = getcwd(); - $dirs = isset( $this->check_config->getConfig()['dirs'] ) ? $this->check_config->getConfig()['dirs'] : []; + $dirs = isset( $config['dirs'] ) ? $config['dirs'] : []; foreach ( $dirs as $dir ) { $results = $this->scanDir( + $scanner, $root ?: '.', $current_dir ?: '.', - $dir, - $files_to_skip, - $directories_to_skip, + $dir ); $matched_lines = array_merge( $matched_lines, $results ); @@ -96,45 +83,18 @@ protected function checkExecute( InputInterface $input, Io $output ): int { } /** - * @param string $root - * @param string $current_dir - * @param string $scan_dir - * @param string $files_to_skip - * @param string $directories_to_skip + * @param TbdScanner $scanner + * @param string $root + * @param string $current_dir + * @param string $scan_dir * * @return array>> */ - protected function scanDir( string $root, string $current_dir, string $scan_dir, string $files_to_skip, string $directories_to_skip ): array { + protected function scanDir( TbdScanner $scanner, string $root, string $current_dir, string $scan_dir ): array { $matched_lines = []; - $dir = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $root . '/' . $scan_dir ) ); - foreach ( $dir as $file ) { - // Skip directories like "." and ".." to avoid file_get_contents errors. - if ( $file->isDir() ) { - continue; - } - - $file_path = $file->getPathname(); - $short_path = (string) str_replace( $current_dir . '/', '', $file_path ); - - if ( preg_match( '!(' . $files_to_skip . ')$!', $short_path ) ) { - continue; - } - - if ( preg_match( '!(\.pup-)|(\.puprc)!', $short_path ) ) { - continue; - } - - $directory_separator = DIRECTORY_SEPARATOR; - if ( $directory_separator === '\\' ) { - $directory_separator = '\\\\'; - } - - if ( preg_match( '!(' . $directories_to_skip . ')' . $directory_separator . '!', $short_path ) ) { - continue; - } - - $content = file_get_contents( $short_path ); + foreach ( $scanner->getFiles( $root, $current_dir, $scan_dir ) as $short_path ) { + $content = file_get_contents( $short_path ); if ( ! $content ) { continue; @@ -148,12 +108,7 @@ protected function scanDir( string $root, string $current_dir, string $scan_dir, $lines[ $line ] = trim( $lines[ $line ] ); // does the line match? - if ( - preg_match( '/\*\s*\@(since|deprecated|version)\s.*tbd/i', $lines[ $line ] ) - || preg_match( '/_deprecated_\w\(.*[\'"]tbd[\'"]/i', $lines[ $line ] ) - || preg_match( '/[\'"]tbd[\'"]/i', $lines[ $line ] ) - ) { - + if ( $scanner->lineMatches( $lines[ $line ] ) ) { // if the file isn't being tracked already, add it to the array if ( ! isset( $matched_lines[ $short_path ] ) ) { $matched_lines[ $short_path ] = [ diff --git a/src/Commands/ReplaceTbd.php b/src/Commands/ReplaceTbd.php new file mode 100644 index 0000000..7ff0709 --- /dev/null +++ b/src/Commands/ReplaceTbd.php @@ -0,0 +1,125 @@ +setName( 'replace-tbd' ) + ->addArgument( 'version', InputArgument::REQUIRED, 'The version to replace TBD placeholders with.' ) + ->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Preview the changes without writing to files.' ) + ->addOption( 'root', null, InputOption::VALUE_REQUIRED, 'Set the root directory for running commands.' ) + ->setDescription('Replaces "TBD" version placeholders (e.g. @since TBD, _deprecated_function( __METHOD__, "TBD" ), "tbd") with the provided version.' ) + ->setHelp( 'Scans the directories configured for the tbd check and replaces TBD version placeholders with the provided version. This resolves exactly what `pup check:tbd` reports.' ); + } + + /** + * @inheritDoc + */ + protected function execute( InputInterface $input, OutputInterface $output ) { + parent::execute( $input, $output ); + + $config = App::getConfig(); + $version = $input->getArgument( 'version' ); + $dry_run = (bool) $input->getOption( 'dry-run' ); + $root = $input->getOption( 'root' ); + + $checks = $config->getChecks(); + $tbd_config = isset( $checks['tbd'] ) ? $checks['tbd']->getConfig() : []; + $scanner = TbdScanner::fromConfig( $tbd_config ); + + // Mirror the tbd check's directory resolution exactly: when the tbd check is + // absent/unconfigured it scans nothing, so replace-tbd must not edit files + // the check would never examine. + $dirs = isset( $tbd_config['dirs'] ) ? (array) $tbd_config['dirs'] : []; + + if ( $root ) { + chdir( $root ); + } + + $current_dir = getcwd() ?: '.'; + $total_files = 0; + $total_count = 0; + + if ( $dry_run ) { + $output->writeln( '[dry-run] No files will be modified.' ); + } + + foreach ( $dirs as $dir ) { + foreach ( $scanner->getFiles( '.', $current_dir, $dir ) as $short_path ) { + $content = file_get_contents( $short_path ); + + if ( ! $content ) { + continue; + } + + $lines = explode( "\n", $content ); + $file_count = 0; + + foreach ( $lines as $i => $line ) { + $count = 0; + $lines[ $i ] = $scanner->replaceInLine( $line, $version, $count ); + $file_count += $count; + } + + if ( $file_count === 0 ) { + continue; + } + + $total_files++; + $total_count += $file_count; + + $output->writeln( "{$short_path} ({$file_count} replaced)" ); + + if ( ! $dry_run ) { + $results = file_put_contents( $short_path, implode( "\n", $lines ) ); + + if ( false === $results ) { + $this->restoreCwd( $root ); + throw new BaseException( 'Could not write to file: ' . $short_path ); + } + } + } + } + + $this->restoreCwd( $root ); + + $output->writeln( '' ); + + if ( $total_count === 0 ) { + $output->writeln( 'No TBDs found to replace.' ); + } elseif ( $dry_run ) { + $output->writeln( "[dry-run] Would replace {$total_count} TBD occurrence(s) across {$total_files} file(s)." ); + } else { + $output->writeln( "Replaced {$total_count} TBD occurrence(s) across {$total_files} file(s) with {$version}." ); + } + + return 0; + } + + /** + * Restores the working directory if it was changed via --root. + * + * @param string|null $root + * + * @return void + */ + protected function restoreCwd( $root ): void { + if ( $root ) { + chdir( App::getConfig()->getWorkingDir() ); + } + } +} diff --git a/tests/cli/Commands/ReplaceTbdCest.php b/tests/cli/Commands/ReplaceTbdCest.php new file mode 100644 index 0000000..53e97af --- /dev/null +++ b/tests/cli/Commands/ReplaceTbdCest.php @@ -0,0 +1,133 @@ +restore_fixtures(); + parent::_after( $I ); + } + + /** + * Restores the git-tracked fixture files modified during a test. + * + * @return void + */ + protected function restore_fixtures(): void { + foreach ( $this->fixture_files as $file ) { + $path = $this->tests_root . '/_data/fake-project-with-tbds/' . $file; + system( 'git checkout -- ' . escapeshellarg( $path ) ); + } + } + + /** + * @test + */ + public function it_should_replace_tbds_with_the_version( CliTester $I ) { + $this->write_default_puprc( 'fake-project-with-tbds' ); + + $project = $this->tests_root . '/_data/fake-project-with-tbds'; + chdir( $project ); + + $I->runShellCommand( "php {$this->pup} replace-tbd 1.4.0" ); + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'Replaced 8 TBD occurrence(s) across 2 file(s) with 1.4.0.' ); + + $plugin = (string) file_get_contents( $project . '/src/Plugin.php' ); + $I->assertStringContainsString( '@since 1.4.0', $plugin ); + $I->assertStringNotContainsString( 'TBD', $plugin ); + + $another = (string) file_get_contents( $project . '/src/Thing/AnotherFile.php' ); + $I->assertStringContainsString( "_deprecated_file( __FILE__, '1.4.0' );", $another ); + $I->assertStringContainsString( "_deprecated_function( __METHOD__, '1.4.0' );", $another ); + $I->assertStringContainsString( '@deprecated 1.4.0', $another ); + // Lower-case tbd placeholders are resolved too. + $I->assertStringNotContainsStringIgnoringCase( 'tbd', $another ); + } + + /** + * @test + */ + public function it_should_only_replace_placeholders_not_surrounding_prose( CliTester $I ) { + $this->write_default_puprc( 'fake-project-with-tbds' ); + + $project = $this->tests_root . '/_data/fake-project-with-tbds'; + $tmp = $project . '/src/ProseTbd.php'; + + // A docblock line with a real version plus the word "tbd" in prose, and a + // quoted placeholder followed by an unrelated "tbd" in a trailing comment. + $contents = "runShellCommand( "php {$this->pup} replace-tbd 1.4.0" ); + $I->seeResultCodeIs( 0 ); + + $result = (string) file_get_contents( $tmp ); + + // The docblock prose (and its real version) is untouched. + $I->assertStringContainsString( '@since 5.0.0 reworked the tbd handler', $result ); + // The quoted placeholder is resolved... + $I->assertStringContainsString( "\$status = '1.4.0';", $result ); + // ...but the unrelated "tbd" in the trailing comment is preserved. + $I->assertStringContainsString( '// resolve tbd later', $result ); + } finally { + @unlink( $tmp ); + } + } + + /** + * @test + */ + public function it_should_not_modify_files_on_dry_run( CliTester $I ) { + $this->write_default_puprc( 'fake-project-with-tbds' ); + + $project = $this->tests_root . '/_data/fake-project-with-tbds'; + chdir( $project ); + + $I->runShellCommand( "php {$this->pup} replace-tbd 1.4.0 --dry-run" ); + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'dry-run' ); + $I->seeInShellOutput( 'Would replace 8 TBD occurrence(s) across 2 file(s).' ); + + // The file should still contain the original TBD placeholders. + $plugin = (string) file_get_contents( $project . '/src/Plugin.php' ); + $I->assertStringContainsString( '@since TBD', $plugin ); + } + + /** + * @test + */ + public function it_should_report_when_no_tbds_are_found( CliTester $I ) { + $this->write_default_puprc(); + + chdir( $this->tests_root . '/_data/fake-project' ); + + $I->runShellCommand( "php {$this->pup} replace-tbd 1.4.0" ); + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'No TBDs found to replace.' ); + } +}