Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -273,6 +275,30 @@ composer -- pup package <version>
| `--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 <version> [--dry-run]
# or
composer -- pup replace-tbd <version> [--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.

Expand Down
1 change: 1 addition & 0 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
Expand Down
169 changes: 169 additions & 0 deletions src/Check/TbdScanner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

namespace StellarWP\Pup\Check;

/**
* Shared file-walk and TBD detection used by both the `tbd` check and the
* `replace-tbd` command, so the two stay in sync: replace-tbd resolves exactly
* what check:tbd flags.
*/
class TbdScanner {
/**
* Default pipe-delimited list of file patterns to skip.
*/
const DEFAULT_SKIP_FILES = '.min.css|.min.js|.map.js|.css|.png|.jpg|.jpeg|.svg|.gif|.ico';

/**
* Default pipe-delimited list of directories to skip.
*/
const DEFAULT_SKIP_DIRECTORIES = 'bin|build|vendor|node_modules|.git|.github|tests';

/**
* Regex-ready list of file patterns to skip.
* @var string
*/
protected $files_to_skip;

/**
* Regex-ready list of directories to skip.
* @var string
*/
protected $directories_to_skip;

/**
* @param string $files_to_skip Pipe-delimited file patterns to skip.
* @param string $directories_to_skip Pipe-delimited directories to skip.
*/
public function __construct( string $files_to_skip = self::DEFAULT_SKIP_FILES, string $directories_to_skip = self::DEFAULT_SKIP_DIRECTORIES ) {
$this->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<string, mixed> $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<string, string> Map of pattern => replacement template.
*/
const REPLACEMENTS = [
// Docblock tag value: @since / @deprecated / @version <space> 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}',
];
Comment thread
d4mation marked this conversation as resolved.

/**
* 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;
}
}
73 changes: 14 additions & 59 deletions src/Commands/Checks/Tbd.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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<string, array<string, array<int, string>>>
*/
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 {
Comment thread
d4mation marked this conversation as resolved.
$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;
Expand All @@ -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 ] = [
Expand Down
Loading
Loading